From 10c07e6186f4d1da621eab6bb8ec9aacc495eec4 Mon Sep 17 00:00:00 2001 From: indy koning Date: Sun, 1 Feb 2026 15:47:02 +0100 Subject: [PATCH 01/13] Added ruff formatter --- .github/workflows/ruff.yml | 11 + .ruff.toml | 30 ++ examples/min_example.py | 27 +- examples/min_example_dashboard.py | 38 +- examples/mix_example.py | 202 +++++----- examples/noah_example.py | 76 ++-- examples/settings_example.py | 31 +- examples/settings_example_AC.py | 66 ++-- examples/settings_example_classic.py | 19 +- examples/sph_example.py | 49 ++- examples/tlx_example.py | 45 +-- examples/tlx_example_dashboard.py | 86 +++-- examples/user_agent_options.py | 27 +- growattServer/__init__.py | 6 +- growattServer/base_api.py | 549 ++++++++++++++------------- growattServer/exceptions.py | 6 +- growattServer/open_api_v1.py | 257 +++++++------ setup.py | 4 +- 18 files changed, 803 insertions(+), 726 deletions(-) create mode 100644 .github/workflows/ruff.yml create mode 100644 .ruff.toml diff --git a/.github/workflows/ruff.yml b/.github/workflows/ruff.yml new file mode 100644 index 0000000..ddc0a15 --- /dev/null +++ b/.github/workflows/ruff.yml @@ -0,0 +1,11 @@ +name: Ruff +on: + - push + - pull_request + +jobs: + ruff: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/ruff-action@v3 \ No newline at end of file diff --git a/.ruff.toml b/.ruff.toml new file mode 100644 index 0000000..ce9fa51 --- /dev/null +++ b/.ruff.toml @@ -0,0 +1,30 @@ +# The contents of this file is based on https://github.com/home-assistant/core/blob/dev/pyproject.toml + +target-version = "py313" + +[lint] +select = [ + "ALL", +] + +ignore = [ + "ANN401", # Dynamically typed expressions (typing.Any) are disallowed + "D203", # no-blank-line-before-class (incompatible with formatter) + "D212", # multi-line-summary-first-line (incompatible with formatter) + "COM812", # incompatible with formatter + "ISC001", # incompatible with formatter + "E501", # Line length is not important + "EM101", + "TRY003", # Allow exception messages to be used directly + "ERA001", # Commented out code is allowed for examples + "T201", # Prints are allowed in examples +] + +[lint.flake8-pytest-style] +fixture-parentheses = false + +[lint.pyupgrade] +keep-runtime-typing = true + +[lint.mccabe] +max-complexity = 25 \ No newline at end of file diff --git a/examples/min_example.py b/examples/min_example.py index 3594fd7..b1fee17 100644 --- a/examples/min_example.py +++ b/examples/min_example.py @@ -1,10 +1,11 @@ -import growattServer -import datetime import json + import requests +import growattServer + """ -# Example script controlling a MID/TLX Growatt (MID-30KTL3-XH + APX battery) system using the public growatt API +# Example script controlling a MID/TLX Growatt (MID-30KTL3-XH + APX battery) system using the public growatt API # You can obtain an API token from the Growatt API documentation or developer portal. """ @@ -21,39 +22,39 @@ # Plant info plants = api.plant_list() print(f"Plants: Found {plants['count']} plants") - plant_id = plants['plants'][0]['plant_id'] + plant_id = plants["plants"][0]["plant_id"] # Devices devices = api.device_list(plant_id) - for device in devices['devices']: - if device['type'] == 7: # (MIN/TLX) - inverter_sn = device['device_sn'] + for device in devices["devices"]: + if device["type"] == 7: # (MIN/TLX) + inverter_sn = device["device_sn"] print(f"Processing inverter: {inverter_sn}") # Get device details inverter_data = api.min_detail(inverter_sn) print("Saving inverter data to inverter_data.json") - with open('inverter_data.json', 'w') as f: + with open("inverter_data.json", "w") as f: json.dump(inverter_data, f, indent=4, sort_keys=True) # Get energy data energy_data = api.min_energy(device_sn=inverter_sn) print("Saving energy data to energy_data.json") - with open('energy_data.json', 'w') as f: + with open("energy_data.json", "w") as f: json.dump(energy_data, f, indent=4, sort_keys=True) # Get energy history energy_history_data = api.min_energy_history(inverter_sn) print("Saving energy history data to energy_history.json") - with open('energy_history.json', 'w') as f: - json.dump(energy_history_data['datas'], + with open("energy_history.json", "w") as f: + json.dump(energy_history_data["datas"], f, indent=4, sort_keys=True) # Get settings settings_data = api.min_settings(device_sn=inverter_sn) print("Saving settings data to settings_data.json") - with open('settings_data.json', 'w') as f: + with open("settings_data.json", "w") as f: json.dump(settings_data, f, indent=4, sort_keys=True) # Read time segments @@ -62,7 +63,7 @@ # Read discharge power discharge_power = api.min_read_parameter( - inverter_sn, 'discharge_power') + inverter_sn, "discharge_power") print("Current discharge power:", discharge_power, "%") # Settings parameters - Uncomment to test diff --git a/examples/min_example_dashboard.py b/examples/min_example_dashboard.py index fac0082..8f479ff 100644 --- a/examples/min_example_dashboard.py +++ b/examples/min_example_dashboard.py @@ -1,7 +1,9 @@ -import growattServer import json + import requests +import growattServer + """ Example script fetching key power and today+total energy metrics from a Growatt MID-30KTL3-XH (TLX) + APX battery hybrid system using the V1 API with token-based authentication. @@ -19,20 +21,20 @@ # Get plant list using V1 API plants = api.plant_list() - plant_id = plants['plants'][0]['plant_id'] + plant_id = plants["plants"][0]["plant_id"] # Get devices in plant devices = api.device_list(plant_id) # Iterate over all devices energy_data = None - for device in devices['devices']: - if device['type'] == 7: # (MIN/TLX) - inverter_sn = device['device_sn'] + for device in devices["devices"]: + if device["type"] == 7: # (MIN/TLX) + inverter_sn = device["device_sn"] # Get energy data energy_data = api.min_energy(device_sn=inverter_sn) - with open('energy_data.json', 'w') as f: + with open("energy_data.json", "w") as f: json.dump(energy_data, f, indent=4, sort_keys=True) # energy data does not contain epvToday for some reason, so we need to calculate it @@ -54,18 +56,18 @@ # Output the dashboard print("\nGeneration overview Today/Total(kWh)") - print(f'Solar production {solar_production:>22}') - print(f' Solar production, PV1 {solar_production_pv1:>22}') - print(f' Solar production, PV2 {solar_production_pv2:>22}') - print(f'Energy Output {energy_output:>22}') - print(f'System production {system_production:>22}') - print(f'Self consumption {self_consumption:>22}') - print(f'Load consumption {load_consumption:>22}') - print(f'Battery Charged {battery_charged:>22}') - print(f' Charged from grid {battery_grid_charge:>22}') - print(f'Battery Discharged {battery_discharged:>22}') - print(f'Import from grid {imported_from_grid:>22}') - print(f'Export to grid {exported_to_grid:>22}') + print(f"Solar production {solar_production:>22}") + print(f" Solar production, PV1 {solar_production_pv1:>22}") + print(f" Solar production, PV2 {solar_production_pv2:>22}") + print(f"Energy Output {energy_output:>22}") + print(f"System production {system_production:>22}") + print(f"Self consumption {self_consumption:>22}") + print(f"Load consumption {load_consumption:>22}") + print(f"Battery Charged {battery_charged:>22}") + print(f" Charged from grid {battery_grid_charge:>22}") + print(f"Battery Discharged {battery_discharged:>22}") + print(f"Import from grid {imported_from_grid:>22}") + print(f"Export to grid {exported_to_grid:>22}") print("\nPower overview (Watts)") print(f'AC Power {float(energy_data["pac"]):>22.1f}') diff --git a/examples/mix_example.py b/examples/mix_example.py index b28b3e6..e16e961 100755 --- a/examples/mix_example.py +++ b/examples/mix_example.py @@ -1,8 +1,8 @@ -import growattServer -import datetime import getpass import pprint +import growattServer + """ This is a very trivial script that logs into a user's account and prints out useful data for a "Mix" system (Hybrid). The first half of the logic is applicable to all types of system. There is a clear point (marked in the script) where we specifically @@ -21,9 +21,9 @@ """ A really hacky function to allow me to print out things with an indent in-front """ -def indent_print(to_output, indent): +def indent_print(to_output, indent) -> None: indent_string = "" - for x in range(indent): + for _x in range(indent): indent_string += " " print(indent_string + to_output) @@ -36,41 +36,41 @@ def indent_print(to_output, indent): api = growattServer.GrowattApi() login_response = api.login(username, user_pass) -plant_list = api.plant_list(login_response['user']['id']) +plant_list = api.plant_list(login_response["user"]["id"]) #pp.pprint(plant_list) print("***Totals for all plants***") -pp.pprint(plant_list['totalData']) -print("") +pp.pprint(plant_list["totalData"]) +print() print("***List of plants***") -for plant in plant_list['data']: - indent_print("ID: %s, Name: %s"%(plant['plantId'], plant['plantName']), 2) -print("") +for plant in plant_list["data"]: + indent_print("ID: {}, Name: {}".format(plant["plantId"], plant["plantName"]), 2) +print() -for plant in plant_list['data']: - plant_id = plant['plantId'] - plant_name = plant['plantName'] +for plant in plant_list["data"]: + plant_id = plant["plantId"] + plant_name = plant["plantName"] plant_info=api.plant_info(plant_id) #pp.pprint(plant_info) - print("***Info for Plant %s - %s***"%(plant_id, plant_name)) + print(f"***Info for Plant {plant_id} - {plant_name}***") #There are more values in plant_info, but these are some of the useful/interesting ones - indent_print("CO2 Reducion: %s"%(plant_info['Co2Reduction']),2) - indent_print("Nominal Power (w): %s"%(plant_info['nominal_Power']),2) - indent_print("Solar Energy Today (kw): %s"%(plant_info['todayEnergy']),2) - indent_print("Solar Energy Total (kw): %s"%(plant_info['totalEnergy']),2) - print("") + indent_print("CO2 Reducion: {}".format(plant_info["Co2Reduction"]),2) + indent_print("Nominal Power (w): {}".format(plant_info["nominal_Power"]),2) + indent_print("Solar Energy Today (kw): {}".format(plant_info["todayEnergy"]),2) + indent_print("Solar Energy Total (kw): {}".format(plant_info["totalEnergy"]),2) + print() indent_print("Devices in plant:",2) - for device in plant_info['deviceList']: - device_sn = device['deviceSn'] - device_type = device['deviceType'] - indent_print("- Device - SN: %s, Type: %s"%(device_sn, device_type),4) - - print("") - for device in plant_info['deviceList']: - device_sn = device['deviceSn'] - device_type = device['deviceType'] - indent_print("**Device - SN: %s, Type: %s**"%(device_sn, device_type),2) + for device in plant_info["deviceList"]: + device_sn = device["deviceSn"] + device_type = device["deviceType"] + indent_print(f"- Device - SN: {device_sn}, Type: {device_type}",4) + + print() + for device in plant_info["deviceList"]: + device_sn = device["deviceSn"] + device_type = device["deviceType"] + indent_print(f"**Device - SN: {device_sn}, Type: {device_type}**",2) #NOTE - This is the bit where we specifically only handle information on Mix devices - this won't work for non-mix devices #These two API calls return lots of duplicated information, but each also holds unique information as well @@ -80,18 +80,18 @@ def indent_print(to_output, indent): #pp.pprint(mix_totals) indent_print("*TOTAL VALUES*", 4) indent_print("==Today Totals==", 4) - indent_print("Battery Charge (kwh): %s"%(mix_info['eBatChargeToday']),6) - indent_print("Battery Discharge (kwh): %s"%(mix_info['eBatDisChargeToday']),6) - indent_print("Solar Generation (kwh): %s"%(mix_info['epvToday']),6) - indent_print("Local Load (kwh): %s"%(mix_totals['elocalLoadToday']),6) - indent_print("Export to Grid (kwh): %s"%(mix_totals['etoGridToday']),6) + indent_print("Battery Charge (kwh): {}".format(mix_info["eBatChargeToday"]),6) + indent_print("Battery Discharge (kwh): {}".format(mix_info["eBatDisChargeToday"]),6) + indent_print("Solar Generation (kwh): {}".format(mix_info["epvToday"]),6) + indent_print("Local Load (kwh): {}".format(mix_totals["elocalLoadToday"]),6) + indent_print("Export to Grid (kwh): {}".format(mix_totals["etoGridToday"]),6) indent_print("==Overall Totals==",4) - indent_print("Battery Charge: %s"%(mix_info['eBatChargeTotal']),6) - indent_print("Battery Discharge (kwh): %s"%(mix_info['eBatDisChargeTotal']),6) - indent_print("Solar Generation (kwh): %s"%(mix_info['epvTotal']),6) - indent_print("Local Load (kwh): %s"%(mix_totals['elocalLoadTotal']),6) - indent_print("Export to Grid (kwh): %s"%(mix_totals['etogridTotal']),6) - print("") + indent_print("Battery Charge: {}".format(mix_info["eBatChargeTotal"]),6) + indent_print("Battery Discharge (kwh): {}".format(mix_info["eBatDisChargeTotal"]),6) + indent_print("Solar Generation (kwh): {}".format(mix_info["epvTotal"]),6) + indent_print("Local Load (kwh): {}".format(mix_totals["elocalLoadTotal"]),6) + indent_print("Export to Grid (kwh): {}".format(mix_totals["etogridTotal"]),6) + print() mix_detail = api.mix_detail(device_sn, plant_id) #pp.pprint(mix_detail) @@ -106,23 +106,23 @@ def indent_print(to_output, indent): ppvToday = 0.0 sysOutToday = 0.0 - chartData = mix_detail['chartData'] - for time_entry, data_points in chartData.items(): + chartData = mix_detail["chartData"] + for data_points in chartData.values(): #For each time entry convert it's wattage into kWh, this assumes that the wattage value is #the same for the whole 5 minute window (it's the only assumption we can make) #We Multiply the wattage by 5/60 (the number of minutes of the time window divided by the number of minutes in an hour) #to give us the equivalent kWh reading for that 5 minute window - pacToGridToday += float(data_points['pacToGrid']) * (5/60) - pacToUserToday += float(data_points['pacToUser']) * (5/60) - pdischargeToday += float(data_points['pdischarge']) * (5/60) - ppvToday += float(data_points['ppv']) * (5/60) - sysOutToday += float(data_points['sysOut']) * (5/60) - - mix_detail['calculatedPacToGridTodayKwh'] = round(pacToGridToday,2) - mix_detail['calculatedPacToUserTodayKwh'] = round(pacToUserToday,2) - mix_detail['calculatedPdischargeTodayKwh'] = round(pdischargeToday,2) - mix_detail['calculatedPpvTodayKwh'] = round(ppvToday,2) - mix_detail['calculatedSysOutTodayKwh'] = round(sysOutToday,2) + pacToGridToday += float(data_points["pacToGrid"]) * (5/60) + pacToUserToday += float(data_points["pacToUser"]) * (5/60) + pdischargeToday += float(data_points["pdischarge"]) * (5/60) + ppvToday += float(data_points["ppv"]) * (5/60) + sysOutToday += float(data_points["sysOut"]) * (5/60) + + mix_detail["calculatedPacToGridTodayKwh"] = round(pacToGridToday,2) + mix_detail["calculatedPacToUserTodayKwh"] = round(pacToUserToday,2) + mix_detail["calculatedPdischargeTodayKwh"] = round(pdischargeToday,2) + mix_detail["calculatedPpvTodayKwh"] = round(ppvToday,2) + mix_detail["calculatedSysOutTodayKwh"] = round(sysOutToday,2) #Option to print mix_detail again now we've made the additions #pp.pprint(mix_detail) @@ -131,55 +131,55 @@ def indent_print(to_output, indent): #pp.pprint(dashboard_data) indent_print("*TODAY TOTALS BREAKDOWN*", 4) - indent_print("Self generation total (batteries & solar - from API) (kwh): %s"%(mix_detail['eCharge']),6) - indent_print("Load consumed from solar (kwh): %s"%(mix_detail['eChargeToday']),6) - indent_print("Load consumed from batteries (kwh): %s"%(mix_detail['echarge1']),6) - indent_print("Self consumption total (batteries & solar - from API) (kwh): %s"%(mix_detail['eChargeToday1']),6) - indent_print("Load consumed from grid (kwh): %s"%(mix_detail['etouser']),6) - indent_print("Total imported from grid (Load + AC charging) (kwh): %s"%(dashboard_data['etouser'].replace('kWh','')),6) - calculated_consumption = float(mix_detail['eChargeToday']) + float(mix_detail['echarge1']) + float(mix_detail['etouser']) - indent_print("Load consumption (calculated) (kwh): %s"%(round(calculated_consumption,2)),6) - indent_print("Load consumption (API) (kwh): %s"%(mix_detail['elocalLoad']),6) - - indent_print("Exported (kwh): %s"%(mix_detail['eAcCharge']), 6) - - solar_to_battery = round(float(mix_info['epvToday']) - float(mix_detail['eAcCharge']) - float(mix_detail['eChargeToday']),2) - indent_print("Solar battery charge (calculated) (kwh): %s"%(solar_to_battery), 6) - ac_to_battery = round(float(mix_info['eBatChargeToday']) - solar_to_battery,2) - indent_print("AC battery charge (calculated) (kwh): %s"%(ac_to_battery), 6) - print("") + indent_print("Self generation total (batteries & solar - from API) (kwh): {}".format(mix_detail["eCharge"]),6) + indent_print("Load consumed from solar (kwh): {}".format(mix_detail["eChargeToday"]),6) + indent_print("Load consumed from batteries (kwh): {}".format(mix_detail["echarge1"]),6) + indent_print("Self consumption total (batteries & solar - from API) (kwh): {}".format(mix_detail["eChargeToday1"]),6) + indent_print("Load consumed from grid (kwh): {}".format(mix_detail["etouser"]),6) + indent_print("Total imported from grid (Load + AC charging) (kwh): {}".format(dashboard_data["etouser"].replace("kWh","")),6) + calculated_consumption = float(mix_detail["eChargeToday"]) + float(mix_detail["echarge1"]) + float(mix_detail["etouser"]) + indent_print(f"Load consumption (calculated) (kwh): {round(calculated_consumption,2)}",6) + indent_print("Load consumption (API) (kwh): {}".format(mix_detail["elocalLoad"]),6) + + indent_print("Exported (kwh): {}".format(mix_detail["eAcCharge"]), 6) + + solar_to_battery = round(float(mix_info["epvToday"]) - float(mix_detail["eAcCharge"]) - float(mix_detail["eChargeToday"]),2) + indent_print(f"Solar battery charge (calculated) (kwh): {solar_to_battery}", 6) + ac_to_battery = round(float(mix_info["eBatChargeToday"]) - solar_to_battery,2) + indent_print(f"AC battery charge (calculated) (kwh): {ac_to_battery}", 6) + print() indent_print("*TODAY TOTALS COMPARISONS*", 4) indent_print("Export to Grid (kwh) - TRUSTED:", 6) - indent_print("mix_totals['etoGridToday']: %s"%(mix_totals['etoGridToday']), 8) - indent_print("mix_detail['eAcCharge']: %s"%(mix_detail['eAcCharge']), 8) - indent_print("mix_detail['calculatedPacToGridTodayKwh']: %s"%(mix_detail['calculatedPacToGridTodayKwh']), 8) - print("") + indent_print("mix_totals['etoGridToday']: {}".format(mix_totals["etoGridToday"]), 8) + indent_print("mix_detail['eAcCharge']: {}".format(mix_detail["eAcCharge"]), 8) + indent_print("mix_detail['calculatedPacToGridTodayKwh']: {}".format(mix_detail["calculatedPacToGridTodayKwh"]), 8) + print() indent_print("Imported from Grid (kwh) - TRUSTED:", 6) - indent_print("dashboard_data['etouser']: %s"%(dashboard_data['etouser'].replace('kWh','')), 8) - indent_print("mix_detail['calculatedPacToUserTodayKwh']: %s"%(mix_detail['calculatedPacToUserTodayKwh']), 8) - print("") + indent_print("dashboard_data['etouser']: {}".format(dashboard_data["etouser"].replace("kWh","")), 8) + indent_print("mix_detail['calculatedPacToUserTodayKwh']: {}".format(mix_detail["calculatedPacToUserTodayKwh"]), 8) + print() indent_print("Battery discharge (kwh) - TRUSTED:", 6) - indent_print("mix_info['eBatDisChargeToday']: %s"%(mix_info['eBatDisChargeToday']), 8) - indent_print("mix_totals['edischarge1Today']: %s"%(mix_totals['edischarge1Today']), 8) - indent_print("mix_detail['echarge1']: %s"%(mix_detail['echarge1']), 8) - indent_print("mix_detail['calculatedPdischargeTodayKwh']: %s"%(mix_detail['calculatedPdischargeTodayKwh']), 8) - print("") + indent_print("mix_info['eBatDisChargeToday']: {}".format(mix_info["eBatDisChargeToday"]), 8) + indent_print("mix_totals['edischarge1Today']: {}".format(mix_totals["edischarge1Today"]), 8) + indent_print("mix_detail['echarge1']: {}".format(mix_detail["echarge1"]), 8) + indent_print("mix_detail['calculatedPdischargeTodayKwh']: {}".format(mix_detail["calculatedPdischargeTodayKwh"]), 8) + print() indent_print("Solar generation (kwh) - TRUSTED:", 6) - indent_print("mix_info['epvToday']: %s"%(mix_info['epvToday']), 8) - indent_print("mix_totals['epvToday']: %s"%(mix_totals['epvToday']), 8) - indent_print("mix_detail['calculatedPpvTodayKwh']: %s"%(mix_detail['calculatedPpvTodayKwh']), 8) - print("") + indent_print("mix_info['epvToday']: {}".format(mix_info["epvToday"]), 8) + indent_print("mix_totals['epvToday']: {}".format(mix_totals["epvToday"]), 8) + indent_print("mix_detail['calculatedPpvTodayKwh']: {}".format(mix_detail["calculatedPpvTodayKwh"]), 8) + print() indent_print("Load Consumption (kwh) - TRUSTED:", 6) - indent_print("mix_totals['elocalLoadToday']: %s"%(mix_totals['elocalLoadToday'],), 8) - indent_print("mix_detail['elocalLoad']: %s"%(mix_detail['elocalLoad']), 8) - indent_print("mix_detail['calculatedSysOutTodayKwh']: %s"%(mix_detail['calculatedSysOutTodayKwh']), 8) - print("") + indent_print("mix_totals['elocalLoadToday']: {}".format(mix_totals["elocalLoadToday"]), 8) + indent_print("mix_detail['elocalLoad']: {}".format(mix_detail["elocalLoad"]), 8) + indent_print("mix_detail['calculatedSysOutTodayKwh']: {}".format(mix_detail["calculatedSysOutTodayKwh"]), 8) + print() #This call gets all of the instantaneous values from the system e.g. current load, generation etc. @@ -188,21 +188,21 @@ def indent_print(to_output, indent): #NOTE - There are some other values available in mix_status, however these are the most useful ones indent_print("*CURRENT VALUES*",4) indent_print("==Batteries==",4) - indent_print("Charging Batteries at (kw): %s"%(mix_status['chargePower']),6) - indent_print("Discharging Batteries at (kw): %s"%(mix_status['pdisCharge1']),6) - indent_print("Batteries %%: %s"%(mix_status['SOC']),6) + indent_print("Charging Batteries at (kw): {}".format(mix_status["chargePower"]),6) + indent_print("Discharging Batteries at (kw): {}".format(mix_status["pdisCharge1"]),6) + indent_print("Batteries %: {}".format(mix_status["SOC"]),6) indent_print("==PVs==",4) - indent_print("PV1 wattage: %s"%(mix_status['pPv1']),6) - indent_print("PV2 wattage: %s"%(mix_status['pPv2']),6) - calc_pv_total = (float(mix_status['pPv1']) + float(mix_status['pPv2']))/1000 - indent_print("PV total wattage (calculated) - KW: %s"%(round(calc_pv_total,2)),6) - indent_print("PV total wattage (API) - KW: %s"%(mix_status['ppv']),6) + indent_print("PV1 wattage: {}".format(mix_status["pPv1"]),6) + indent_print("PV2 wattage: {}".format(mix_status["pPv2"]),6) + calc_pv_total = (float(mix_status["pPv1"]) + float(mix_status["pPv2"]))/1000 + indent_print(f"PV total wattage (calculated) - KW: {round(calc_pv_total,2)}",6) + indent_print("PV total wattage (API) - KW: {}".format(mix_status["ppv"]),6) indent_print("==Consumption==",4) - indent_print("Local load/consumption - KW: %s"%(mix_status['pLocalLoad']),6) + indent_print("Local load/consumption - KW: {}".format(mix_status["pLocalLoad"]),6) indent_print("==Import/Export==",4) - indent_print("Importing from Grid - KW: %s"%(mix_status['pactouser']),6) - indent_print("Exporting to Grid - KW: %s"%(mix_status['pactogrid']),6) + indent_print("Importing from Grid - KW: {}".format(mix_status["pactouser"]),6) + indent_print("Exporting to Grid - KW: {}".format(mix_status["pactogrid"]),6) diff --git a/examples/noah_example.py b/examples/noah_example.py index cc899f4..df84b67 100644 --- a/examples/noah_example.py +++ b/examples/noah_example.py @@ -1,8 +1,8 @@ -import growattServer -import datetime import getpass import pprint +import growattServer + """ This is a very trivial script that logs into a user's account and prints out useful data for a "NOAH" system. This has been tested against my personal system (NOAH2000) which is a 2kW Balcony Storage system. @@ -16,9 +16,9 @@ """ A really hacky function to allow me to print out things with an indent in-front """ -def indent_print(to_output, indent): +def indent_print(to_output, indent) -> None: indent_string = "" - for x in range(indent): + for _x in range(indent): indent_string += " " print(indent_string + to_output) @@ -31,50 +31,50 @@ def indent_print(to_output, indent): api = growattServer.GrowattApi() login_response = api.login(username, user_pass) -plant_list = api.plant_list(login_response['user']['id']) +plant_list = api.plant_list(login_response["user"]["id"]) #pp.pprint(plant_list) print("***Totals for all plants***") -pp.pprint(plant_list['totalData']) -print("") +pp.pprint(plant_list["totalData"]) +print() print("***List of plants***") -for plant in plant_list['data']: - indent_print("ID: %s, Name: %s"%(plant['plantId'], plant['plantName']), 2) -print("") +for plant in plant_list["data"]: + indent_print("ID: {}, Name: {}".format(plant["plantId"], plant["plantName"]), 2) +print() -for plant in plant_list['data']: - plant_id = plant['plantId'] - plant_name = plant['plantName'] +for plant in plant_list["data"]: + plant_id = plant["plantId"] + plant_name = plant["plantName"] plant_info=api.plant_info(plant_id) #pp.pprint(plant_info) - print("***Info for Plant %s - %s***"%(plant_id, plant_name)) + print(f"***Info for Plant {plant_id} - {plant_name}***") #There are more values in plant_info, but these are some of the useful/interesting ones - indent_print("CO2 Reducion: %s"%(plant_info['Co2Reduction']),2) - indent_print("Nominal Power (w): %s"%(plant_info['nominal_Power']),2) - indent_print("Solar Energy Today (kw): %s"%(plant_info['todayEnergy']),2) - indent_print("Solar Energy Total (kw): %s"%(plant_info['totalEnergy']),2) - print("") + indent_print("CO2 Reducion: {}".format(plant_info["Co2Reduction"]),2) + indent_print("Nominal Power (w): {}".format(plant_info["nominal_Power"]),2) + indent_print("Solar Energy Today (kw): {}".format(plant_info["todayEnergy"]),2) + indent_print("Solar Energy Total (kw): {}".format(plant_info["totalEnergy"]),2) + print() indent_print("Devices in plant:",2) - for device in plant_info['deviceList']: - device_sn = device['deviceSn'] - device_type = device['deviceType'] - indent_print("- Device - SN: %s, Type: %s"%(device_sn, device_type),4) + for device in plant_info["deviceList"]: + device_sn = device["deviceSn"] + device_type = device["deviceType"] + indent_print(f"- Device - SN: {device_sn}, Type: {device_type}",4) - is_noah = api.is_plant_noah_system(plant['plantId']) - if is_noah['result'] == 1 and (is_noah['obj']['isPlantNoahSystem'] or is_noah['obj']['isPlantHaveNoah']): - device_sn = is_noah['obj']['deviceSn'] - indent_print("**NOAH - SN: %s**"%(device_sn),2) + is_noah = api.is_plant_noah_system(plant["plantId"]) + if is_noah["result"] == 1 and (is_noah["obj"]["isPlantNoahSystem"] or is_noah["obj"]["isPlantHaveNoah"]): + device_sn = is_noah["obj"]["deviceSn"] + indent_print(f"**NOAH - SN: {device_sn}**",2) - noah_system = api.noah_system_status(is_noah['obj']['deviceSn']) - pp.pprint(noah_system['obj']) - print("") + noah_system = api.noah_system_status(is_noah["obj"]["deviceSn"]) + pp.pprint(noah_system["obj"]) + print() - noah_infos = api.noah_info(is_noah['obj']['deviceSn']) - pp.pprint(noah_infos['obj']['noah']) - print("") - indent_print("Remaining battery (" + "%" + "): %s"%(noah_system['obj']['soc']),2) - indent_print("Solar Power (w): %s"%(noah_system['obj']['ppv']),2) - indent_print("Charge Power (w): %s"%(noah_system['obj']['chargePower']),2) - indent_print("Discharge Power (w): %s"%(noah_system['obj']['disChargePower']),2) - indent_print("Output Power (w): %s"%(noah_system['obj']['pac']),2) \ No newline at end of file + noah_infos = api.noah_info(is_noah["obj"]["deviceSn"]) + pp.pprint(noah_infos["obj"]["noah"]) + print() + indent_print("Remaining battery (" + "%" + "): {}".format(noah_system["obj"]["soc"]),2) + indent_print("Solar Power (w): {}".format(noah_system["obj"]["ppv"]),2) + indent_print("Charge Power (w): {}".format(noah_system["obj"]["chargePower"]),2) + indent_print("Discharge Power (w): {}".format(noah_system["obj"]["disChargePower"]),2) + indent_print("Output Power (w): {}".format(noah_system["obj"]["pac"]),2) diff --git a/examples/settings_example.py b/examples/settings_example.py index b359242..ba7baf4 100755 --- a/examples/settings_example.py +++ b/examples/settings_example.py @@ -1,8 +1,9 @@ -import growattServer import datetime import getpass import pprint +import growattServer + """ This is a very trivial script to show how to interface with the configuration settings of a plant and it's inverters This has been tested against my personal system (muppet3000) which is a hybrid (aka 'mix') inverter system. @@ -21,19 +22,19 @@ api = growattServer.GrowattApi() login_response = api.login(username, user_pass) -plant_list = api.plant_list(login_response['user']['id']) +plant_list = api.plant_list(login_response["user"]["id"]) #Simple logic to just get the first inverter from the first plant #Expand this using a for-loop to perform for more systems (see mix_example for more detail) -plant = plant_list['data'][0] #This is an array - we just take the first - would need a for-loop for more systems -plant_id = plant['plantId'] -plant_name = plant['plantName'] +plant = plant_list["data"][0] #This is an array - we just take the first - would need a for-loop for more systems +plant_id = plant["plantId"] +plant_name = plant["plantName"] plant_info=api.plant_info(plant_id) -device = plant_info['deviceList'][0] #This is an array - we just take the first - would need a for-loop for more systems -device_sn = device['deviceSn'] -device_type = device['deviceType'] +device = plant_info["deviceList"][0] #This is an array - we just take the first - would need a for-loop for more systems +device_sn = device["deviceSn"] +device_type = device["deviceType"] #Get plant settings - This is performed for us inside 'update_plant_settings' but you can get ALL of the settings using this @@ -46,13 +47,13 @@ #Change the timezone of the plant plant_settings_changes = { - 'plantTimezone': '0' + "plantTimezone": "0" } print("Changing the following plant setting(s):") pp.pprint(plant_settings_changes) response = api.update_plant_settings(plant_id, plant_settings_changes) print(response) -print("") +print() @@ -61,12 +62,12 @@ now = datetime.datetime.now() dt_string = now.strftime("%Y-%m-%d %H:%M:%S") time_settings={ - 'param1': dt_string + "param1": dt_string } -print("Setting inverter time to: %s" %(dt_string)) -response = api.update_mix_inverter_setting(device_sn, 'pf_sys_year', time_settings) +print(f"Setting inverter time to: {dt_string}") +response = api.update_mix_inverter_setting(device_sn, "pf_sys_year", time_settings) print(response) -print("") +print() @@ -85,5 +86,5 @@ "0"] #Schedule 3 - Enabled/Disabled (0 = Disabled) print("Setting the inverter charging schedule to:") pp.pprint(schedule_settings) -response = api.update_mix_inverter_setting(device_sn, 'mix_ac_charge_time_period', schedule_settings) +response = api.update_mix_inverter_setting(device_sn, "mix_ac_charge_time_period", schedule_settings) print(response) diff --git a/examples/settings_example_AC.py b/examples/settings_example_AC.py index d7a1dc3..bd10700 100644 --- a/examples/settings_example_AC.py +++ b/examples/settings_example_AC.py @@ -1,60 +1,62 @@ -import growattServer -import sys import json -''' +import sys + +import growattServer + +""" Sample script to set AC battery charging -Takes commandline arguments for terminal SOC, start time, end time, +Takes commandline arguments for terminal SOC, start time, end time, and whether to run, with default arguments if none are given Tested on an SPA3000 -''' +""" -# check for SOC percent and whether to run +# check for SOC percent and whether to run if len(sys.argv) != 7: - SOC = '40' - startH = '0' - startM = '40' - endH = '04' - endM = '30' - run = '1' + SOC = "40" + startH = "0" + startM = "40" + endH = "04" + endM = "30" + run = "1" else: SOC = str(sys.argv[1]) - startH = '{:02.0f}'.format(int(sys.argv[2])) - startM = '{:02.0f}'.format(int(sys.argv[3])) - endH = '{:02.0f}'.format(int(sys.argv[4])) - endM = '{:02.0f}'.format(int(sys.argv[5])) + startH = f"{int(sys.argv[2]):02.0f}" + startM = f"{int(sys.argv[3]):02.0f}" + endH = f"{int(sys.argv[4]):02.0f}" + endM = f"{int(sys.argv[5]):02.0f}" run = str(sys.argv[6]) api = growattServer.GrowattApi() # This part needs to be adapted by the user -login_response = api.login('USERNAME_AS_STRING', - 'PASSWORD_AS_STRING') +login_response = api.login("USERNAME_AS_STRING", + "PASSWORD_AS_STRING") -if login_response['success']: +if login_response["success"]: # Get a list of growatt plants. - plant_list = api.plant_list(login_response['user']['id']) - plant = plant_list['data'][0] - plant_id = plant['plantId'] + plant_list = api.plant_list(login_response["user"]["id"]) + plant = plant_list["data"][0] + plant_id = plant["plantId"] plant_info = api.plant_info(plant_id) - device = plant_info['deviceList'][0] - device_sn = device['deviceSn'] + device = plant_info["deviceList"][0] + device_sn = device["deviceSn"] # All parameters need to be given, including zeros # All parameters must be strings - schedule_settings = ['100', # Charging power % + schedule_settings = ["100", # Charging power % SOC, # Stop charging at SoC % startH, startM, # Schedule 1 - Start time endH, endM, # Schedule 1 - End time run, # Schedule 1 - Enabled/Disabled (1 = Enabled) - '00','00', # Schedule 2 - Start time - '00','00', # Schedule 2 - End time - '0', # Schedule 2 - Enabled/Disabled (1 = Enabled) - '00','00', # Schedule 3 - Start time - '00','00', # Schedule 3 - End time - '0'] # Schedule 3 - Enabled/Disabled (1 = Enabled) + "00","00", # Schedule 2 - Start time + "00","00", # Schedule 2 - End time + "0", # Schedule 2 - Enabled/Disabled (1 = Enabled) + "00","00", # Schedule 3 - Start time + "00","00", # Schedule 3 - End time + "0"] # Schedule 3 - Enabled/Disabled (1 = Enabled) response = api.update_ac_inverter_setting(device_sn, - 'spa_ac_charge_time_period', + "spa_ac_charge_time_period", schedule_settings) else: response = login_response diff --git a/examples/settings_example_classic.py b/examples/settings_example_classic.py index ef79c54..464481a 100644 --- a/examples/settings_example_classic.py +++ b/examples/settings_example_classic.py @@ -1,7 +1,8 @@ -import growattServer import getpass import pprint +import growattServer + """ This script demonstrates how to interface with the configuration settings of a plant and its classic inverters. It uses the `update_classic_inverter_setting` function to apply settings to a classic inverter. @@ -17,22 +18,22 @@ api = growattServer.GrowattApi(True, username) login_response = api.login(username, user_pass) -plant_list = api.plant_list(login_response['user']['id']) +plant_list = api.plant_list(login_response["user"]["id"]) # Simple logic to just get the first inverter from the first plant # Expand this using a for-loop to perform for more systems -plant = plant_list['data'][0] # This is an array - we just take the first - would need a for-loop for more systems -plant_id = plant['plantId'] -plant_name = plant['plantName'] +plant = plant_list["data"][0] # This is an array - we just take the first - would need a for-loop for more systems +plant_id = plant["plantId"] +plant_name = plant["plantName"] plant_info = api.plant_info(plant_id) devices = api.device_list(plant_id) device = devices[0] # This is an array - we just take the first - would need a for-loop for more systems -device_sn = device['deviceSn'] -device_type = device['deviceType'] +device_sn = device["deviceSn"] +device_type = device["deviceType"] # Turn inverter on -print("Turning on inverter: %s" % (device_sn)) +print(f"Turning on inverter: {device_sn}") # Set up the default parameters default_parameters = { @@ -46,4 +47,4 @@ "command_2": "", # Empty string for command_2 as not used } response = api.update_classic_inverter_setting(default_parameters, parameters) -print(response) \ No newline at end of file +print(response) diff --git a/examples/sph_example.py b/examples/sph_example.py index b8ee926..03fe716 100644 --- a/examples/sph_example.py +++ b/examples/sph_example.py @@ -6,7 +6,6 @@ You can obtain an API token from the Growatt API documentation or developer portal. """ -import datetime import json import os @@ -26,23 +25,23 @@ # Plant info plants = api.plant_list() - print(f"Plants: Found {plants['count']} plants") # noqa: T201 + print(f"Plants: Found {plants['count']} plants") plant_id = plants["plants"][0]["plant_id"] # Devices devices = api.device_list(plant_id) for device in devices["devices"]: - print(device) # noqa: T201 + print(device) if device["type"] == growattServer.DeviceType.SPH.value: inverter_sn = device["device_sn"] - print(f"Processing SPH device: {inverter_sn}") # noqa: T201 + print(f"Processing SPH device: {inverter_sn}") # Get energy data energy_data = api.sph_energy( device_sn=inverter_sn, ) - print("Saving energy data to energy_data.json") # noqa: T201 + print("Saving energy data to energy_data.json") with open("energy_data.json", "w") as f: json.dump(energy_data, f, indent=4, sort_keys=True) @@ -50,7 +49,7 @@ energy_history_data = api.sph_energy_history( device_sn=inverter_sn, ) - print("Saving energy history data to energy_history.json") # noqa: T201 + print("Saving energy history data to energy_history.json") with open("energy_history.json", "w") as f: json.dump( energy_history_data.get("datas", []), @@ -63,36 +62,36 @@ inverter_data = api.sph_detail( device_sn=inverter_sn, ) - print("Saving inverter data to inverter_data.json") # noqa: T201 + print("Saving inverter data to inverter_data.json") with open("inverter_data.json", "w") as f: json.dump(inverter_data, f, indent=4, sort_keys=True) # Read some settings directly from inverter_data (from sph_detail) # See docs/openapiv1/sph_settings.md for all available fields - print("Device Settings:") # noqa: T201 - print(f" Device status: {inverter_data.get('status', 'N/A')}") # noqa: T201 - print(f" Battery type: {inverter_data.get('batteryType', 'N/A')}") # noqa: T201 - print(f" EPS enabled: {inverter_data.get('epsFunEn', 'N/A')}") # noqa: T201 - print(f" Export limit: {inverter_data.get('exportLimitPowerRate', 'N/A')}%") # noqa: T201 + print("Device Settings:") + print(f" Device status: {inverter_data.get('status', 'N/A')}") + print(f" Battery type: {inverter_data.get('batteryType', 'N/A')}") + print(f" EPS enabled: {inverter_data.get('epsFunEn', 'N/A')}") + print(f" Export limit: {inverter_data.get('exportLimitPowerRate', 'N/A')}%") # Read AC charge time periods using helper function and inverter_data to avoid rate limiting charge_config = api.sph_read_ac_charge_times( settings_data=inverter_data, ) - print("AC Charge Configuration:") # noqa: T201 - print(f" Charge Power: {charge_config['charge_power']}%") # noqa: T201 - print(f" Stop SOC: {charge_config['charge_stop_soc']}%") # noqa: T201 - print(f" Mains Enabled: {charge_config['mains_enabled']}") # noqa: T201 - print(f" Periods: {json.dumps(charge_config['periods'], indent=4)}") # noqa: T201 + print("AC Charge Configuration:") + print(f" Charge Power: {charge_config['charge_power']}%") + print(f" Stop SOC: {charge_config['charge_stop_soc']}%") + print(f" Mains Enabled: {charge_config['mains_enabled']}") + print(f" Periods: {json.dumps(charge_config['periods'], indent=4)}") # Read AC discharge time periods using helper function and inverter_data to avoid rate limiting discharge_config = api.sph_read_ac_discharge_times( settings_data=inverter_data, ) - print("AC Discharge Configuration:") # noqa: T201 - print(f" Discharge Power: {discharge_config['discharge_power']}%") # noqa: T201 - print(f" Stop SOC: {discharge_config['discharge_stop_soc']}%") # noqa: T201 - print(f" Periods: {json.dumps(discharge_config['periods'], indent=4)}") # noqa: T201 + print("AC Discharge Configuration:") + print(f" Discharge Power: {discharge_config['discharge_power']}%") + print(f" Stop SOC: {discharge_config['discharge_stop_soc']}%") + print(f" Periods: {json.dumps(discharge_config['periods'], indent=4)}") # Write examples - Uncomment to test @@ -142,10 +141,10 @@ # api.sph_write_parameter(inverter_sn, "backflow_setting", ["1", "50"]) # On, 50% except growattServer.GrowattV1ApiError as e: - print(f"API Error: {e} (Code: {e.error_code}, Message: {e.error_msg})") # noqa: T201 + print(f"API Error: {e} (Code: {e.error_code}, Message: {e.error_msg})") except growattServer.GrowattParameterError as e: - print(f"Parameter Error: {e}") # noqa: T201 + print(f"Parameter Error: {e}") except requests.exceptions.RequestException as e: - print(f"Network Error: {e}") # noqa: T201 + print(f"Network Error: {e}") except Exception as e: # noqa: BLE001 - print(f"Unexpected error: {e}") # noqa: T201 + print(f"Unexpected error: {e}") diff --git a/examples/tlx_example.py b/examples/tlx_example.py index 6488cee..c0d216e 100644 --- a/examples/tlx_example.py +++ b/examples/tlx_example.py @@ -1,25 +1,26 @@ -import growattServer import datetime import getpass import json +import growattServer + """ -# Example script controlling a Growatt MID-30KTL3-XH + APX battery hybrid system by emulating the ShinePhone iOS app. -# The same API calls are used by the ShinePhone Android app as well. Traffic intercepted using HTTP Toolkit. -# +# Example script controlling a Growatt MID-30KTL3-XH + APX battery hybrid system by emulating the ShinePhone iOS app. +# The same API calls are used by the ShinePhone Android app as well. Traffic intercepted using HTTP Toolkit. +# # The plant / energy / device APIs seem to be generic for all Growatt systems, while the inverter and battery APIs use the TLX APIs. # # The available settings under the 'Control' tab in ShinePhone are created by combining the results from two function calls: -# tlx_get_all_settings() seem to returns the sum of all settings for all systems while tlx_get_enabled_settings() tells +# tlx_get_all_settings() seem to returns the sum of all settings for all systems while tlx_get_enabled_settings() tells # which of these settings are valid for the TLX system. -# +# # Settings that takes a single parameter can be set using update_tlx_inverter_setting(). A helper function, update_tlx_inverter_time_segment() # is provided for updating time segments which take several parameters. The inverter is picky and time intervals can't be overlapping, -# even if they are disabled. -# -# The set functions are commented out in the example, uncomment to test, and use at your own risk. Most likely all settings returned in +# even if they are disabled. +# +# The set functions are commented out in the example, uncomment to test, and use at your own risk. Most likely all settings returned in # tlx_get_enabled_settings() can be set using update_tlx_inverter_setting(), but has not been tested. -# +# """ # Prompt user for username @@ -28,16 +29,16 @@ # Prompt user to input password user_pass=getpass.getpass("Enter password:") -user_agent = 'ShinePhone/8.1.17 (iPhone; iOS 15.6.1; Scale/2.00)' +user_agent = "ShinePhone/8.1.17 (iPhone; iOS 15.6.1; Scale/2.00)" api = growattServer.GrowattApi(agent_identifier=user_agent) login_response = api.login(username, user_pass) -user_id = login_response['user']['id'] +user_id = login_response["user"]["id"] print("Login successful, user_id:", user_id) # Plant info plant_list = api.plant_list_two() -plant_id = plant_list[0]['id'] +plant_id = plant_list[0]["id"] plant_info = api.plant_info(plant_id) print("Plant info:", json.dumps(plant_info, indent=4, sort_keys=True)) @@ -50,9 +51,9 @@ print("Devices:", json.dumps(devices, indent=4, sort_keys=True)) for device in devices: - if device['deviceType'] == 'tlx': + if device["deviceType"] == "tlx": # Inverter info (used in inverter view) - inverter_sn = device['deviceSn'] + inverter_sn = device["deviceSn"] inverter_info = api.tlx_params(inverter_sn) print("Inverter info:", json.dumps(inverter_info, indent=4, sort_keys=True)) @@ -65,8 +66,8 @@ enabled_settings = api.tlx_enabled_settings(inverter_sn) # 'on_grid_discharge_stop_soc' is present in web UI, but for some reason not # returned in enabled settings so we enable it manually here instead - enabled_settings['enable']['on_grid_discharge_stop_soc'] = '1' - enabled_keys = enabled_settings['enable'].keys() + enabled_settings["enable"]["on_grid_discharge_stop_soc"] = "1" + enabled_keys = enabled_settings["enable"].keys() available_settings = {k: v for k, v in all_settings.items() if k in enabled_keys} print("System settings:", json.dumps(available_settings, indent=4, sort_keys=True)) @@ -77,16 +78,16 @@ # Energy overview data = api.tlx_energy_overview(plant_id, inverter_sn) print("Energy overview:", json.dumps(data, indent=4, sort_keys=True)) - + # Energy production & consumption data = api.tlx_energy_prod_cons(plant_id, inverter_sn) print("Energy production & consumption:", json.dumps(data, indent=4, sort_keys=True)) - elif device['deviceType'] == 'bat': + elif device["deviceType"] == "bat": # Battery info - batt_info = api.tlx_battery_info(device['deviceSn']) + batt_info = api.tlx_battery_info(device["deviceSn"]) print("Battery info:", json.dumps(batt_info, indent=4, sort_keys=True)) - batt_info_detailed = api.tlx_battery_info_detailed(plant_id, device['deviceSn']) + batt_info_detailed = api.tlx_battery_info_detailed(plant_id, device["deviceSn"]) print("Battery info: detailed", json.dumps(batt_info_detailed, indent=4, sort_keys=True)) @@ -107,4 +108,4 @@ # start_time = datetime.time(00, 1), # end_time = datetime.time(11, 59), # enabled=True) -#print(res) \ No newline at end of file +#print(res) diff --git a/examples/tlx_example_dashboard.py b/examples/tlx_example_dashboard.py index 14a7962..676adb7 100644 --- a/examples/tlx_example_dashboard.py +++ b/examples/tlx_example_dashboard.py @@ -1,11 +1,13 @@ -import growattServer import getpass +import sys + +import growattServer -# Example script fetching key power and today+total energy metrics from a Growatt MID-30KTL3-XH (TLX) + APX battery hybrid system +# Example script fetching key power and today+total energy metrics from a Growatt MID-30KTL3-XH (TLX) + APX battery hybrid system # -# There is a lot of overlap in what the various Growatt APIs returns. -# tlx_detail() contains the bulk of the needed data, but some info is missing and is fetched from +# There is a lot of overlap in what the various Growatt APIs returns. +# tlx_detail() contains the bulk of the needed data, but some info is missing and is fetched from # tlx_system_status(), tlx_energy_overview() and tlx_battery_info_detailed() instead @@ -16,16 +18,16 @@ user_pass=getpass.getpass("Enter password:") # Login, emulating the Growatt app -user_agent = 'ShinePhone/8.1.17 (iPhone; iOS 15.6.1; Scale/2.00)' +user_agent = "ShinePhone/8.1.17 (iPhone; iOS 15.6.1; Scale/2.00)" api = growattServer.GrowattApi(agent_identifier=user_agent) login_response = api.login(username, user_pass) -if not login_response['success']: +if not login_response["success"]: print(f"Failed to log in, msg: {login_response['msg']}, error: {login_response['error']}") - exit() - + sys.exit() + # Get plant(s) plant_list = api.plant_list_two() -plant_id = plant_list[0]['id'] +plant_id = plant_list[0]["id"] # Get devices in plant devices = api.device_list(plant_id) @@ -33,11 +35,11 @@ # Iterate over all devices. Here we are interested in data from 'tlx' inverters and 'bat' devices batteries_info = [] for device in devices: - if device['deviceType'] == 'tlx': - inverter_sn = device['deviceSn'] - + if device["deviceType"] == "tlx": + inverter_sn = device["deviceSn"] + # Inverter detail, contains the bulk of energy and power values - inverter_detail = api.tlx_detail(inverter_sn).get('data') + inverter_detail = api.tlx_detail(inverter_sn).get("data") # Energy overview is used to retrieve "epvToday" which is not present in tlx_detail() for some reason energy_overview = api.tlx_energy_overview(plant_id, inverter_sn) @@ -45,34 +47,34 @@ # System status, contains power values, not available in inverter_detail() system_status = api.tlx_system_status(plant_id, inverter_sn) - if device['deviceType'] == 'bat': - batt_info = api.tlx_battery_info(device['deviceSn']) - if batt_info.get('lost'): + if device["deviceType"] == "bat": + batt_info = api.tlx_battery_info(device["deviceSn"]) + if batt_info.get("lost"): # Disconnected batteries are listed with 'old' power/energy/SOC data - # Therefore we check it it's 'lost' and skip it in that case. + # Therefore we check it it's 'lost' and skip it in that case. print("'Lost' battery found, skipping") continue # Battery info - batt_info = api.tlx_battery_info_detailed(plant_id, device['deviceSn']).get('data') + batt_info = api.tlx_battery_info_detailed(plant_id, device["deviceSn"]).get("data") - if float(batt_info['chargeOrDisPower']) > 0: - bdcChargePower = float(batt_info['chargeOrDisPower']) + if float(batt_info["chargeOrDisPower"]) > 0: + bdcChargePower = float(batt_info["chargeOrDisPower"]) bdcDischargePower = 0 else: bdcChargePower = 0 - bdcDischargePower = float(batt_info['chargeOrDisPower']) - bdcDischargePower = -bdcDischargePower - - battery_data = { - 'serialNum': device['deviceSn'], - 'bdcChargePower': bdcChargePower, - 'bdcDischargePower': bdcDischargePower, - 'dischargeTotal': batt_info['dischargeTotal'], - 'soc': batt_info['soc'] + bdcDischargePower = float(batt_info["chargeOrDisPower"]) + bdcDischargePower = -bdcDischargePower + + battery_data = { + "serialNum": device["deviceSn"], + "bdcChargePower": bdcChargePower, + "bdcDischargePower": bdcDischargePower, + "dischargeTotal": batt_info["dischargeTotal"], + "soc": batt_info["soc"] } batteries_info.append(battery_data) - + solar_production = f'{float(energy_overview["epvToday"]):.1f}/{float(energy_overview["epvTotal"]):.1f}' solar_production_pv1 = f'{float(inverter_detail["epv1Today"]):.1f}/{float(inverter_detail["epv1Total"]):.1f}' @@ -89,18 +91,18 @@ battery_charged = f'{float(inverter_detail["echargeToday"]):.1f}/{float(inverter_detail["echargeTotal"]):.1f}' print("\nGeneration overview Today/Total(kWh)") -print(f'Solar production {solar_production:>22}') -print(f' Solar production, PV1 {solar_production_pv1:>22}') -print(f' Solar production, PV2 {solar_production_pv2:>22}') -print(f'Energy Output {energy_output:>22}') -print(f'System production {system_production:>22}') -print(f'Self consumption {self_consumption:>22}') -print(f'Load consumption {load_consumption:>22}') -print(f'Battery Charged {battery_charged:>22}') -print(f' Charged from grid {battery_grid_charge:>22}') -print(f'Battery Discharged {battery_discharged:>22}') -print(f'Import from grid {imported_from_grid:>22}') -print(f'Export to grid {exported_to_grid:>22}') +print(f"Solar production {solar_production:>22}") +print(f" Solar production, PV1 {solar_production_pv1:>22}") +print(f" Solar production, PV2 {solar_production_pv2:>22}") +print(f"Energy Output {energy_output:>22}") +print(f"System production {system_production:>22}") +print(f"Self consumption {self_consumption:>22}") +print(f"Load consumption {load_consumption:>22}") +print(f"Battery Charged {battery_charged:>22}") +print(f" Charged from grid {battery_grid_charge:>22}") +print(f"Battery Discharged {battery_discharged:>22}") +print(f"Import from grid {imported_from_grid:>22}") +print(f"Export to grid {exported_to_grid:>22}") print("\nPower overview (Watts)") print(f'AC Power {float(inverter_detail["pac"]):>22.1f}') diff --git a/examples/user_agent_options.py b/examples/user_agent_options.py index a9b6fac..728beb7 100755 --- a/examples/user_agent_options.py +++ b/examples/user_agent_options.py @@ -1,6 +1,7 @@ -import growattServer import getpass +import growattServer + """ This is a simple script that demonstrates the various ways to initialise the library to set a User Agent """ @@ -16,36 +17,36 @@ api = growattServer.GrowattApi() login_response = api.login(username, user_pass) print("Default initialisation") -print("User-Agent: %s\nLogged in User id: %s" % (api.agent_identifier, login_response['userId'])) -print("") +print("User-Agent: {}\nLogged in User id: {}".format(api.agent_identifier, login_response["userId"])) +print() api = growattServer.GrowattApi(True) login_response = api.login(username, user_pass) print("Add random ID to default User-Agent") -print("User-Agent: %s\nLogged in User id: %s" % (api.agent_identifier, login_response['userId'])) -print("") +print("User-Agent: {}\nLogged in User id: {}".format(api.agent_identifier, login_response["userId"])) +print() api = growattServer.GrowattApi(False, "my-user-id") login_response = api.login(username, user_pass) print("Override default User-Agent") -print("User-Agent: %s\nLogged in User id: %s" % (api.agent_identifier, login_response['userId'])) -print("") +print("User-Agent: {}\nLogged in User id: {}".format(api.agent_identifier, login_response["userId"])) +print() api = growattServer.GrowattApi(True, "my-user-id") login_response = api.login(username, user_pass) print("Override default User-Agent and add random ID") -print("User-Agent: %s\nLogged in User id: %s" % (api.agent_identifier, login_response['userId'])) -print("") +print("User-Agent: {}\nLogged in User id: {}".format(api.agent_identifier, login_response["userId"])) +print() api = growattServer.GrowattApi(False, growattServer.GrowattApi.agent_identifier + " - my-user-id") login_response = api.login(username, user_pass) print("Extend default User-Agent") -print("User-Agent: %s\nLogged in User id: %s" % (api.agent_identifier, login_response['userId'])) -print("") +print("User-Agent: {}\nLogged in User id: {}".format(api.agent_identifier, login_response["userId"])) +print() api = growattServer.GrowattApi(True, growattServer.GrowattApi.agent_identifier + " - my-user-id") login_response = api.login(username, user_pass) print("Extend default User-Agent and add random ID") -print("User-Agent: %s\nLogged in User id: %s" % (api.agent_identifier, login_response['userId'])) -print("") +print("User-Agent: {}\nLogged in User id: {}".format(api.agent_identifier, login_response["userId"])) +print() diff --git a/growattServer/__init__.py b/growattServer/__init__.py index 75698ac..6148dc7 100755 --- a/growattServer/__init__.py +++ b/growattServer/__init__.py @@ -1,9 +1,11 @@ # Import everything from base_api to ensure backward compatibility from .base_api import * -# Import the V1 API class and DeviceType enum -from .open_api_v1 import OpenApiV1, DeviceType + # Import exceptions from .exceptions import GrowattError, GrowattParameterError, GrowattV1ApiError +# Import the V1 API class and DeviceType enum +from .open_api_v1 import DeviceType, OpenApiV1 + # Define the name of the package name = "growattServer" diff --git a/growattServer/base_api.py b/growattServer/base_api.py index 9a43af3..13c7c83 100644 --- a/growattServer/base_api.py +++ b/growattServer/base_api.py @@ -1,9 +1,10 @@ import datetime +import hashlib +import warnings from enum import IntEnum -import requests from random import randint -import warnings -import hashlib + +import requests name = "growattServer" @@ -13,13 +14,11 @@ def hash_password(password): - """ - Normal MD5, except add c if a byte of the digest is less than 10. - """ - password_md5 = hashlib.md5(password.encode('utf-8')).hexdigest() + """Normal MD5, except add c if a byte of the digest is less than 10.""" + password_md5 = hashlib.md5(password.encode("utf-8")).hexdigest() for i in range(0, len(password_md5), 2): - if password_md5[i] == '0': - password_md5 = password_md5[0:i] + 'c' + password_md5[i + 1:] + if password_md5[i] == "0": + password_md5 = password_md5[0:i] + "c" + password_md5[i + 1:] return password_md5 @@ -30,25 +29,25 @@ class Timespan(IntEnum): class GrowattApi: - server_url = 'https://openapi.growatt.com/' + server_url = "https://openapi.growatt.com/" agent_identifier = "Dalvik/2.1.0 (Linux; U; Android 12; https://github.com/indykoning/PyPi_GrowattServer)" - def __init__(self, add_random_user_id=False, agent_identifier=None): - if (agent_identifier != None): + def __init__(self, add_random_user_id=False, agent_identifier=None) -> None: + if (agent_identifier is not None): self.agent_identifier = agent_identifier # If a random user id is required, generate a 5 digit number and add it to the user agent if (add_random_user_id): - random_number = ''.join(["{}".format(randint(0, 9)) - for num in range(0, 5)]) + random_number = "".join([f"{randint(0, 9)}" + for num in range(5)]) self.agent_identifier += " - " + random_number self.session = requests.Session() self.session.hooks = { - 'response': lambda response, *args, **kwargs: response.raise_for_status() + "response": lambda response, *args, **kwargs: response.raise_for_status() } - headers = {'User-Agent': self.agent_identifier} + headers = {"User-Agent": self.agent_identifier} self.session.headers.update(headers) def __get_date_string(self, timespan=None, date=None): @@ -60,16 +59,14 @@ def __get_date_string(self, timespan=None, date=None): date_str = "" if timespan == Timespan.month: - date_str = date.strftime('%Y-%m') + date_str = date.strftime("%Y-%m") else: - date_str = date.strftime('%Y-%m-%d') + date_str = date.strftime("%Y-%m-%d") return date_str def get_url(self, page): - """ - Simple helper function to get the page URL. - """ + """Simple helper function to get the page URL.""" return self.server_url + page def login(self, username, password, is_password_hashed=False): @@ -77,6 +74,7 @@ def login(self, username, password, is_password_hashed=False): Log the user in. Returns + ------- 'data' -- A List containing Objects containing the folowing 'plantName' -- Friendly name of the plant 'plantId' -- The ID of the plant @@ -131,20 +129,21 @@ def login(self, username, password, is_password_hashed=False): 'appAlias' 'isBigCustomer' 'noticeType' + """ if not is_password_hashed: password = hash_password(password) - response = self.session.post(self.get_url('newTwoLoginAPI.do'), data={ - 'userName': username, - 'password': password + response = self.session.post(self.get_url("newTwoLoginAPI.do"), data={ + "userName": username, + "password": password }) - data = response.json()['back'] - if data['success']: + data = response.json()["back"] + if data["success"]: data.update({ - 'userId': data['user']['id'], - 'userLevel': data['user']['rightlevel'] + "userId": data["user"]["id"], + "userLevel": data["user"]["rightlevel"] }) return data @@ -160,14 +159,15 @@ def plant_list(self, user_id): Raises: Exception: If the request to the server fails. + """ response = self.session.get( - self.get_url('PlantListAPI.do'), - params={'userId': user_id}, + self.get_url("PlantListAPI.do"), + params={"userId": user_id}, allow_redirects=False ) - return response.json().get('back', []) + return response.json().get("back", []) def plant_detail(self, plant_id, timespan, date=None): """ @@ -183,16 +183,17 @@ def plant_detail(self, plant_id, timespan, date=None): Raises: Exception: If the request to the server fails. + """ date_str = self.__get_date_string(timespan, date) - response = self.session.get(self.get_url('PlantDetailAPI.do'), params={ - 'plantId': plant_id, - 'type': timespan.value, - 'date': date_str + response = self.session.get(self.get_url("PlantDetailAPI.do"), params={ + "plantId": plant_id, + "type": timespan.value, + "date": date_str }) - return response.json().get('back', {}) + return response.json().get("back", {}) def plant_list_two(self): """ @@ -200,22 +201,23 @@ def plant_list_two(self): Returns: list: A list of plants with detailed information. + """ response = self.session.post( - self.get_url('newTwoPlantAPI.do'), - params={'op': 'getAllPlantListTwo'}, + self.get_url("newTwoPlantAPI.do"), + params={"op": "getAllPlantListTwo"}, data={ - 'language': '1', - 'nominalPower': '', - 'order': '1', - 'pageSize': '15', - 'plantName': '', - 'plantStatus': '', - 'toPageNum': '1' + "language": "1", + "nominalPower": "", + "order": "1", + "pageSize": "15", + "plantName": "", + "plantStatus": "", + "toPageNum": "1" } ) - return response.json().get('PlantList', []) + return response.json().get("PlantList", []) def inverter_data(self, inverter_id, date=None): """ @@ -230,13 +232,14 @@ def inverter_data(self, inverter_id, date=None): Raises: Exception: If the request to the server fails. + """ date_str = self.__get_date_string(date=date) - response = self.session.get(self.get_url('newInverterAPI.do'), params={ - 'op': 'getInverterData', - 'id': inverter_id, - 'type': 1, - 'date': date_str + response = self.session.get(self.get_url("newInverterAPI.do"), params={ + "op": "getInverterData", + "id": inverter_id, + "type": 1, + "date": date_str }) return response.json() @@ -253,10 +256,11 @@ def inverter_detail(self, inverter_id): Raises: Exception: If the request to the server fails. + """ - response = self.session.get(self.get_url('newInverterAPI.do'), params={ - 'op': 'getInverterDetailData', - 'inverterId': inverter_id + response = self.session.get(self.get_url("newInverterAPI.do"), params={ + "op": "getInverterDetailData", + "inverterId": inverter_id }) return response.json() @@ -273,17 +277,18 @@ def inverter_detail_two(self, inverter_id): Raises: Exception: If the request to the server fails. + """ - response = self.session.get(self.get_url('newInverterAPI.do'), params={ - 'op': 'getInverterDetailData_two', - 'inverterId': inverter_id + response = self.session.get(self.get_url("newInverterAPI.do"), params={ + "op": "getInverterDetailData_two", + "inverterId": inverter_id }) return response.json() def tlx_system_status(self, plant_id, tlx_id): """ - Get status of the system + Get status of the system. Args: plant_id (str): The ID of the plant. @@ -294,6 +299,7 @@ def tlx_system_status(self, plant_id, tlx_id): Raises: Exception: If the request to the server fails. + """ response = self.session.post( self.get_url("newTlxApi.do"), @@ -302,11 +308,11 @@ def tlx_system_status(self, plant_id, tlx_id): "id": tlx_id} ) - return response.json().get('obj', {}) + return response.json().get("obj", {}) def tlx_energy_overview(self, plant_id, tlx_id): """ - Get energy overview + Get energy overview. Args: plant_id (str): The ID of the plant. @@ -317,6 +323,7 @@ def tlx_energy_overview(self, plant_id, tlx_id): Raises: Exception: If the request to the server fails. + """ response = self.session.post( self.get_url("newTlxApi.do"), @@ -325,11 +332,11 @@ def tlx_energy_overview(self, plant_id, tlx_id): "id": tlx_id} ) - return response.json().get('obj', {}) + return response.json().get("obj", {}) def tlx_energy_prod_cons(self, plant_id, tlx_id, timespan=Timespan.hour, date=None): """ - Get energy production and consumption (KW) + Get energy production and consumption (KW). Args: tlx_id (str): The ID of the TLX inverter. @@ -341,21 +348,21 @@ def tlx_energy_prod_cons(self, plant_id, tlx_id, timespan=Timespan.hour, date=No Raises: Exception: If the request to the server fails. - """ + """ date_str = self.__get_date_string(timespan, date) response = self.session.post( self.get_url("newTlxApi.do"), params={"op": "getEnergyProdAndCons_KW"}, - data={'date': date_str, + data={"date": date_str, "plantId": plant_id, "language": "1", "id": tlx_id, "type": timespan.value} ) - return response.json().get('obj', {}) + return response.json().get("obj", {}) def tlx_data(self, tlx_id, date=None): """ @@ -370,13 +377,14 @@ def tlx_data(self, tlx_id, date=None): Raises: Exception: If the request to the server fails. + """ date_str = self.__get_date_string(date=date) - response = self.session.get(self.get_url('newTlxApi.do'), params={ - 'op': 'getTlxData', - 'id': tlx_id, - 'type': 1, - 'date': date_str + response = self.session.get(self.get_url("newTlxApi.do"), params={ + "op": "getTlxData", + "id": tlx_id, + "type": 1, + "date": date_str }) return response.json() @@ -393,10 +401,11 @@ def tlx_detail(self, tlx_id): Raises: Exception: If the request to the server fails. + """ - response = self.session.get(self.get_url('newTlxApi.do'), params={ - 'op': 'getTlxDetailData', - 'id': tlx_id + response = self.session.get(self.get_url("newTlxApi.do"), params={ + "op": "getTlxDetailData", + "id": tlx_id }) return response.json() @@ -413,10 +422,11 @@ def tlx_params(self, tlx_id): Raises: Exception: If the request to the server fails. + """ - response = self.session.get(self.get_url('newTlxApi.do'), params={ - 'op': 'getTlxParams', - 'id': tlx_id + response = self.session.get(self.get_url("newTlxApi.do"), params={ + "op": "getTlxParams", + "id": tlx_id }) return response.json() @@ -433,14 +443,15 @@ def tlx_all_settings(self, tlx_id): Raises: Exception: If the request to the server fails. + """ - response = self.session.post(self.get_url('newTlxApi.do'), params={ - 'op': 'getTlxSetData' + response = self.session.post(self.get_url("newTlxApi.do"), params={ + "op": "getTlxSetData" }, data={ - 'serialNum': tlx_id + "serialNum": tlx_id }) - return response.json().get('obj', {}).get('tlxSetBean') + return response.json().get("obj", {}).get("tlxSetBean") def tlx_enabled_settings(self, tlx_id): """ @@ -454,15 +465,16 @@ def tlx_enabled_settings(self, tlx_id): Raises: Exception: If the request to the server fails. + """ - string_time = datetime.datetime.now().strftime('%Y-%m-%d') + string_time = datetime.datetime.now().strftime("%Y-%m-%d") response = self.session.post( - self.get_url('newLoginAPI.do'), - params={'op': 'getSetPass'}, - data={'deviceSn': tlx_id, 'stringTime': string_time, 'type': '5'} + self.get_url("newLoginAPI.do"), + params={"op": "getSetPass"}, + data={"deviceSn": tlx_id, "stringTime": string_time, "type": "5"} ) - return response.json().get('obj', {}) + return response.json().get("obj", {}) def tlx_battery_info(self, serial_num): """ @@ -476,14 +488,15 @@ def tlx_battery_info(self, serial_num): Raises: Exception: If the request to the server fails. + """ response = self.session.post( - self.get_url('newTlxApi.do'), - params={'op': 'getBatInfo'}, - data={'lan': 1, 'serialNum': serial_num} + self.get_url("newTlxApi.do"), + params={"op": "getBatInfo"}, + data={"lan": 1, "serialNum": serial_num} ) - return response.json().get('obj', {}) + return response.json().get("obj", {}) def tlx_battery_info_detailed(self, plant_id, serial_num): """ @@ -498,20 +511,21 @@ def tlx_battery_info_detailed(self, plant_id, serial_num): Raises: Exception: If the request to the server fails. + """ response = self.session.post( - self.get_url('newTlxApi.do'), - params={'op': 'getBatDetailData'}, - data={'lan': 1, 'plantId': plant_id, 'id': serial_num} + self.get_url("newTlxApi.do"), + params={"op": "getBatDetailData"}, + data={"lan": 1, "plantId": plant_id, "id": serial_num} ) return response.json() def mix_info(self, mix_id, plant_id=None): """ - Returns high level values from Mix device + Returns high level values from Mix device. - Keyword arguments: + Keyword Arguments: mix_id -- The serial number (device_sn) of the inverter plant_id -- The ID of the plant (the mobile app uses this but it does not appear to be necessary) (default None) @@ -537,25 +551,26 @@ def mix_info(self, mix_id, plant_id=None): 'vbatdsp' -- ??? 51.8 'vpv1' -- Voltage PV1 'vpv2' -- Voltage PV2 + """ request_params = { - 'op': 'getMixInfo', - 'mixId': mix_id + "op": "getMixInfo", + "mixId": mix_id } if (plant_id): - request_params['plantId'] = plant_id + request_params["plantId"] = plant_id response = self.session.get(self.get_url( - 'newMixApi.do'), params=request_params) + "newMixApi.do"), params=request_params) - return response.json().get('obj', {}) + return response.json().get("obj", {}) def mix_totals(self, mix_id, plant_id): """ - Returns "Totals" values from Mix device + Returns "Totals" values from Mix device. - Keyword arguments: + Keyword Arguments: mix_id -- The serial number (device_sn) of the inverter plant_id -- The ID of the plant @@ -573,20 +588,21 @@ def mix_totals(self, mix_id, plant_id): 'photovoltaicRevenueToday' -- Revenue earned from PV today in 'unit' currency 'photovoltaicRevenueTotal' -- Revenue earned from PV total (all time) in 'unit' currency 'unit' -- Unit of currency for 'Revenue' + """ - response = self.session.post(self.get_url('newMixApi.do'), params={ - 'op': 'getEnergyOverview', - 'mixId': mix_id, - 'plantId': plant_id + response = self.session.post(self.get_url("newMixApi.do"), params={ + "op": "getEnergyOverview", + "mixId": mix_id, + "plantId": plant_id }) - return response.json().get('obj', {}) + return response.json().get("obj", {}) def mix_system_status(self, mix_id, plant_id): """ - Returns current "Status" from Mix device + Returns current "Status" from Mix device. - Keyword arguments: + Keyword Arguments: mix_id -- The serial number (device_sn) of the inverter plant_id -- The ID of the plant @@ -615,20 +631,21 @@ def mix_system_status(self, mix_id, plant_id): 'vPv2' -- PV2 voltage in V 'vac1' -- Grid voltage in V (same as vAc1) 'wBatteryType' -- ??? 1 + """ - response = self.session.post(self.get_url('newMixApi.do'), params={ - 'op': 'getSystemStatus_KW', - 'mixId': mix_id, - 'plantId': plant_id + response = self.session.post(self.get_url("newMixApi.do"), params={ + "op": "getSystemStatus_KW", + "mixId": mix_id, + "plantId": plant_id }) - return response.json().get('obj', {}) + return response.json().get("obj", {}) def mix_detail(self, mix_id, plant_id, timespan=Timespan.hour, date=None): """ - Get Mix details for specified timespan + Get Mix details for specified timespan. - Keyword arguments: + Keyword Arguments: mix_id -- The serial number (device_sn) of the inverter plant_id -- The ID of the plant timespan -- The ENUM value conforming to the time window you want e.g. hours from today, days, or months (Default Timespan.hour) @@ -672,18 +689,19 @@ def mix_detail(self, mix_id, plant_id, timespan=Timespan.hour, date=None): NOTE - It is possible to calculate the PV generation that went into charging the batteries by performing the following calculation: Solar to Battery = Solar Generation - Export to Grid - Load consumption from solar epvToday (from mix_info) - eAcCharge - eChargeToday + """ date_str = self.__get_date_string(timespan, date) - response = self.session.post(self.get_url('newMixApi.do'), params={ - 'op': 'getEnergyProdAndCons_KW', - 'plantId': plant_id, - 'mixId': mix_id, - 'type': timespan.value, - 'date': date_str + response = self.session.post(self.get_url("newMixApi.do"), params={ + "op": "getEnergyProdAndCons_KW", + "plantId": plant_id, + "mixId": mix_id, + "type": timespan.value, + "date": date_str }) - return response.json().get('obj', {}) + return response.json().get("obj", {}) def get_mix_inverter_settings(self, serial_number): """ @@ -691,17 +709,15 @@ def get_mix_inverter_settings(self, serial_number): Keyword arguments: serial_number -- The serial number (device_sn) of the inverter Returns: - A dictionary of settings + A dictionary of settings. """ - default_params = { - 'op': 'getMixSetParams', - 'serialNum': serial_number, - 'kind': 0 + "op": "getMixSetParams", + "serialNum": serial_number, + "kind": 0 } - response = self.session.get(self.get_url('newMixApi.do'), params=default_params) - data = json.loads(response.content.decode('utf-8')) - return data + response = self.session.get(self.get_url("newMixApi.do"), params=default_params) + return json.loads(response.content.decode("utf-8")) def dashboard_data(self, plant_id, timespan=Timespan.hour, date=None): """ @@ -711,7 +727,7 @@ def dashboard_data(self, plant_id, timespan=Timespan.hour, date=None): However, the statistics that are correct are not available on any other interface, plus these values may be accurate for non-mix types of system. Where the values have been proven to be inaccurate they are commented below. - Keyword arguments: + Keyword Arguments: plant_id -- The ID of the plant timespan -- The ENUM value conforming to the time window you want e.g. hours from today, days, or months (Default Timespan.hour) date -- The date you are interested in (Default datetime.datetime.now()) @@ -750,93 +766,82 @@ def dashboard_data(self, plant_id, timespan=Timespan.hour, date=None): 'ratio6' -- % of Self consumption that is directly from Solar e.g. '7.9%' (not accurate for Mix systems) NOTE: Does not return any data for a tlx system. Use plant_energy_data() instead. + """ date_str = self.__get_date_string(timespan, date) - response = self.session.post(self.get_url('newPlantAPI.do'), params={ - 'action': "getEnergyStorageData", - 'date': date_str, - 'type': timespan.value, - 'plantId': plant_id + response = self.session.post(self.get_url("newPlantAPI.do"), params={ + "action": "getEnergyStorageData", + "date": date_str, + "type": timespan.value, + "plantId": plant_id }) return response.json() def plant_settings(self, plant_id): """ - Returns a dictionary containing the settings for the specified plant + Returns a dictionary containing the settings for the specified plant. - Keyword arguments: + Keyword Arguments: plant_id -- The id of the plant you want the settings of Returns: A python dictionary containing the settings for the specified plant + """ - response = self.session.get(self.get_url('newPlantAPI.do'), params={ - 'op': 'getPlant', - 'plantId': plant_id + response = self.session.get(self.get_url("newPlantAPI.do"), params={ + "op": "getPlant", + "plantId": plant_id }) return response.json() def storage_detail(self, storage_id): - """ - Get "All parameters" from battery storage. - """ - response = self.session.get(self.get_url('newStorageAPI.do'), params={ - 'op': 'getStorageInfo_sacolar', - 'storageId': storage_id + """Get "All parameters" from battery storage.""" + response = self.session.get(self.get_url("newStorageAPI.do"), params={ + "op": "getStorageInfo_sacolar", + "storageId": storage_id }) return response.json() def storage_params(self, storage_id): - """ - Get much more detail from battery storage. - """ - response = self.session.get(self.get_url('newStorageAPI.do'), params={ - 'op': 'getStorageParams_sacolar', - 'storageId': storage_id + """Get much more detail from battery storage.""" + response = self.session.get(self.get_url("newStorageAPI.do"), params={ + "op": "getStorageParams_sacolar", + "storageId": storage_id }) return response.json() def storage_energy_overview(self, plant_id, storage_id): - """ - Get some energy/generation overview data. - """ - response = self.session.post(self.get_url('newStorageAPI.do?op=getEnergyOverviewData_sacolar'), params={ - 'plantId': plant_id, - 'storageSn': storage_id + """Get some energy/generation overview data.""" + response = self.session.post(self.get_url("newStorageAPI.do?op=getEnergyOverviewData_sacolar"), params={ + "plantId": plant_id, + "storageSn": storage_id }) - return response.json().get('obj', {}) + return response.json().get("obj", {}) def inverter_list(self, plant_id): - """ - Use device_list, it's more descriptive since the list contains more than inverters. - """ + """Use device_list, it's more descriptive since the list contains more than inverters.""" warnings.warn( - "This function may be deprecated in the future because naming is not correct, use device_list instead", DeprecationWarning) + "This function may be deprecated in the future because naming is not correct, use device_list instead", DeprecationWarning, stacklevel=2) return self.device_list(plant_id) def __get_all_devices(self, plant_id): - """ - Get basic plant information with device list. - """ - response = self.session.get(self.get_url('newTwoPlantAPI.do'), - params={'op': 'getAllDeviceList', - 'plantId': plant_id, - 'language': 1}) + """Get basic plant information with device list.""" + response = self.session.get(self.get_url("newTwoPlantAPI.do"), + params={"op": "getAllDeviceList", + "plantId": plant_id, + "language": 1}) - return response.json().get('deviceList', {}) + return response.json().get("deviceList", {}) def device_list(self, plant_id): - """ - Get a list of all devices connected to plant. - """ - - device_list = self.plant_info(plant_id).get('deviceList', []) + """Get a list of all devices connected to plant.""" + device_list = self.plant_info(plant_id).get("deviceList", []) if not device_list: # for tlx systems, the device_list in plant is empty, so use __get_all_devices() instead @@ -845,38 +850,34 @@ def device_list(self, plant_id): return device_list def plant_info(self, plant_id): - """ - Get basic plant information with device list. - """ - response = self.session.get(self.get_url('newTwoPlantAPI.do'), params={ - 'op': 'getAllDeviceListTwo', - 'plantId': plant_id, - 'pageNum': 1, - 'pageSize': 1 + """Get basic plant information with device list.""" + response = self.session.get(self.get_url("newTwoPlantAPI.do"), params={ + "op": "getAllDeviceListTwo", + "plantId": plant_id, + "pageNum": 1, + "pageSize": 1 }) return response.json() def plant_energy_data(self, plant_id): - """ - Get the energy data used in the 'Plant' tab in the phone - """ - response = self.session.post(self.get_url('newTwoPlantAPI.do'), + """Get the energy data used in the 'Plant' tab in the phone.""" + response = self.session.post(self.get_url("newTwoPlantAPI.do"), params={ - 'op': 'getUserCenterEnertyDataByPlantid'}, - data={'language': 1, - 'plantId': plant_id}) + "op": "getUserCenterEnertyDataByPlantid"}, + data={"language": 1, + "plantId": plant_id}) return response.json() def is_plant_noah_system(self, plant_id): """ - Returns a dictionary containing if noah devices are configured for the specified plant + Returns a dictionary containing if noah devices are configured for the specified plant. - Keyword arguments: + Keyword Arguments: plant_id -- The id of the plant you want the noah devices of (str) - Returns + Returns: 'msg' 'result' -- True or False 'obj' -- An Object containing if noah devices are configured @@ -885,20 +886,21 @@ def is_plant_noah_system(self, plant_id): 'isPlantHaveNoah' -- Are noah devices configured in the specified plant (True or False) 'deviceSn' -- Serial number of the configured noah device 'plantName' -- Friendly name of the plant + """ - response = self.session.post(self.get_url('noahDeviceApi/noah/isPlantNoahSystem'), data={ - 'plantId': plant_id + response = self.session.post(self.get_url("noahDeviceApi/noah/isPlantNoahSystem"), data={ + "plantId": plant_id }) return response.json() def noah_system_status(self, serial_number): """ - Returns a dictionary containing the status for the specified Noah Device + Returns a dictionary containing the status for the specified Noah Device. - Keyword arguments: + Keyword Arguments: serial_number -- The Serial number of the noah device you want the status of (str) - Returns + Returns: 'msg' 'result' -- True or False 'obj' -- An Object containing the noah device status @@ -918,20 +920,21 @@ def noah_system_status(self, serial_number): 'profitTotal' -- Total generated profit through noah device 'moneyUnit' -- Unit of currency e.g. '€' 'status' -- Is the noah device online (True or False) + """ - response = self.session.post(self.get_url('noahDeviceApi/noah/getSystemStatus'), data={ - 'deviceSn': serial_number + response = self.session.post(self.get_url("noahDeviceApi/noah/getSystemStatus"), data={ + "deviceSn": serial_number }) return response.json() def noah_info(self, serial_number): """ - Returns a dictionary containing the informations for the specified Noah Device + Returns a dictionary containing the informations for the specified Noah Device. - Keyword arguments: + Keyword Arguments: serial_number -- The Serial number of the noah device you want the informations of (str) - Returns + Returns: 'msg' 'result' -- True or False 'obj' -- An Object containing the noah device informations @@ -945,7 +948,7 @@ def noah_info(self, serial_number): 'time_segment2': "1_8:0_18:0_0", ([Battery First]_[08:00]_[18:00]_[0 watt]) .... } - 'batSns' -- A List containing all battery Serial Numbers + 'batSns' -- A List containing all battery Serial Numbers 'associatedInvSn' -- ??? 'plantId' -- The ID of the plant 'chargingSocHighLimit' -- Configured "Battery Management" charging upper limit @@ -963,50 +966,52 @@ def noah_info(self, serial_number): 'plantId' -- The ID of the plant 'plantImgName' -- Friendly name of the plant Image 'plantName' -- Friendly name of the plant + """ - response = self.session.post(self.get_url('noahDeviceApi/noah/getNoahInfoBySn'), data={ - 'deviceSn': serial_number + response = self.session.post(self.get_url("noahDeviceApi/noah/getNoahInfoBySn"), data={ + "deviceSn": serial_number }) return response.json() def update_plant_settings(self, plant_id, changed_settings, current_settings=None): """ Applies settings to the plant e.g. ID, Location, Timezone - See README for all possible settings options + See README for all possible settings options. - Keyword arguments: + Keyword Arguments: plant_id -- The id of the plant you wish to update the settings for changed_settings -- A python dictionary containing the settings to be changed and their value current_settings -- A python dictionary containing the current settings of the plant (use the response from plant_settings), if None - fetched for you Returns: A response from the server stating whether the configuration was successful or not + """ # If no existing settings have been provided then get them from the growatt server - if current_settings == None: + if current_settings is None: current_settings = self.plant_settings(plant_id) # These are the parameters that the form requires, without these an error is thrown. Pre-populate their values with the current values form_settings = { - 'plantCoal': (None, str(current_settings['formulaCoal'])), - 'plantSo2': (None, str(current_settings['formulaSo2'])), - 'accountName': (None, str(current_settings['userAccount'])), - 'plantID': (None, str(current_settings['id'])), + "plantCoal": (None, str(current_settings["formulaCoal"])), + "plantSo2": (None, str(current_settings["formulaSo2"])), + "accountName": (None, str(current_settings["userAccount"])), + "plantID": (None, str(current_settings["id"])), # Hardcoded to 0 as I can't work out what value it should have - 'plantFirm': (None, '0'), - 'plantCountry': (None, str(current_settings['country'])), - 'plantType': (None, str(current_settings['plantType'])), - 'plantIncome': (None, str(current_settings['formulaMoneyStr'])), - 'plantAddress': (None, str(current_settings['plantAddress'])), - 'plantTimezone': (None, str(current_settings['timezone'])), - 'plantLng': (None, str(current_settings['plant_lng'])), - 'plantCity': (None, str(current_settings['city'])), - 'plantCo2': (None, str(current_settings['formulaCo2'])), - 'plantMoney': (None, str(current_settings['formulaMoneyUnitId'])), - 'plantPower': (None, str(current_settings['nominalPower'])), - 'plantLat': (None, str(current_settings['plant_lat'])), - 'plantDate': (None, str(current_settings['createDateText'])), - 'plantName': (None, str(current_settings['plantName'])), + "plantFirm": (None, "0"), + "plantCountry": (None, str(current_settings["country"])), + "plantType": (None, str(current_settings["plantType"])), + "plantIncome": (None, str(current_settings["formulaMoneyStr"])), + "plantAddress": (None, str(current_settings["plantAddress"])), + "plantTimezone": (None, str(current_settings["timezone"])), + "plantLng": (None, str(current_settings["plant_lng"])), + "plantCity": (None, str(current_settings["city"])), + "plantCo2": (None, str(current_settings["formulaCo2"])), + "plantMoney": (None, str(current_settings["formulaMoneyUnitId"])), + "plantPower": (None, str(current_settings["nominalPower"])), + "plantLat": (None, str(current_settings["plant_lat"])), + "plantDate": (None, str(current_settings["createDateText"])), + "plantName": (None, str(current_settings["plantName"])), } # Overwrite the current value of the setting with the new value @@ -1014,7 +1019,7 @@ def update_plant_settings(self, plant_id, changed_settings, current_settings=Non form_settings[setting] = (None, str(value)) response = self.session.post(self.get_url( - 'newTwoPlantAPI.do?op=updatePlant'), files=form_settings) + "newTwoPlantAPI.do?op=updatePlant"), files=form_settings) return response.json() @@ -1022,7 +1027,7 @@ def update_inverter_setting(self, serial_number, setting_type, default_parameters, parameters): """ Applies settings for specified system based on serial number - See README for known working settings + See README for known working settings. Arguments: serial_number -- Serial number (device_sn) of the inverter (str) @@ -1033,6 +1038,7 @@ def update_inverter_setting(self, serial_number, setting_type, Returns: JSON response from the server whether the configuration was successful + """ settings_parameters = parameters @@ -1040,11 +1046,11 @@ def update_inverter_setting(self, serial_number, setting_type, if isinstance(parameters, list): settings_parameters = {} for index, param in enumerate(parameters, start=1): - settings_parameters['param' + str(index)] = param + settings_parameters["param" + str(index)] = param settings_parameters = {**default_parameters, **settings_parameters} - response = self.session.post(self.get_url('newTcpsetAPI.do'), + response = self.session.post(self.get_url("newTcpsetAPI.do"), params=settings_parameters) return response.json() @@ -1052,7 +1058,7 @@ def update_inverter_setting(self, serial_number, setting_type, def update_mix_inverter_setting(self, serial_number, setting_type, parameters): """ Alias for setting inverter parameters on a mix inverter - See README for known working settings + See README for known working settings. Arguments: serial_number -- Serial number (device_sn) of the inverter (str) @@ -1062,11 +1068,12 @@ def update_mix_inverter_setting(self, serial_number, setting_type, parameters): Returns: JSON response from the server whether the configuration was successful + """ default_parameters = { - 'op': 'mixSetApiNew', - 'serialNum': serial_number, - 'type': setting_type + "op": "mixSetApiNew", + "serialNum": serial_number, + "type": setting_type } return self.update_inverter_setting(serial_number, setting_type, default_parameters, parameters) @@ -1074,7 +1081,7 @@ def update_mix_inverter_setting(self, serial_number, setting_type, parameters): def update_ac_inverter_setting(self, serial_number, setting_type, parameters): """ Alias for setting inverter parameters on an AC-coupled inverter - See README for known working settings + See README for known working settings. Arguments: serial_number -- Serial number (device_sn) of the inverter (str) @@ -1084,11 +1091,12 @@ def update_ac_inverter_setting(self, serial_number, setting_type, parameters): Returns: JSON response from the server whether the configuration was successful + """ default_parameters = { - 'op': 'spaSetApi', - 'serialNum': serial_number, - 'type': setting_type + "op": "spaSetApi", + "serialNum": serial_number, + "type": setting_type } return self.update_inverter_setting(serial_number, setting_type, default_parameters, parameters) @@ -1107,35 +1115,37 @@ def update_tlx_inverter_time_segment(self, serial_number, segment_id, batt_mode, Returns: JSON response from the server whether the configuration was successful + """ params = { - 'op': 'tlxSet' + "op": "tlxSet" } data = { - 'serialNum': serial_number, - 'type': f'time_segment{segment_id}', - 'param1': batt_mode, - 'param2': start_time.strftime('%H'), - 'param3': start_time.strftime('%M'), - 'param4': end_time.strftime('%H'), - 'param5': end_time.strftime('%M'), - 'param6': '1' if enabled else '0' + "serialNum": serial_number, + "type": f"time_segment{segment_id}", + "param1": batt_mode, + "param2": start_time.strftime("%H"), + "param3": start_time.strftime("%M"), + "param4": end_time.strftime("%H"), + "param5": end_time.strftime("%M"), + "param6": "1" if enabled else "0" } response = self.session.post(self.get_url( - 'newTcpsetAPI.do'), params=params, data=data) + "newTcpsetAPI.do"), params=params, data=data) result = response.json() - if not result.get('success', False): + if not result.get("success", False): + msg = f"Failed to update TLX inverter time segment: {result.get('msg', 'Unknown error')}" raise Exception( - f"Failed to update TLX inverter time segment: {result.get('msg', 'Unknown error')}") + msg) return result def update_tlx_inverter_setting(self, serial_number, setting_type, parameter): """ Alias for setting parameters on a tlx hybrid inverter - See README for known working settings + See README for known working settings. Arguments: serial_number -- Serial number (device_sn) of the inverter (str) @@ -1145,18 +1155,19 @@ def update_tlx_inverter_setting(self, serial_number, setting_type, parameter): Returns: JSON response from the server whether the configuration was successful + """ default_parameters = { - 'op': 'tlxSet', - 'serialNum': serial_number, - 'type': setting_type + "op": "tlxSet", + "serialNum": serial_number, + "type": setting_type } # If parameter is a single value, convert it to a dictionary if not isinstance(parameter, (dict, list)): - parameter = {'param1': parameter} + parameter = {"param1": parameter} elif isinstance(parameter, list): - parameter = {f'param{index+1}': param for index, + parameter = {f"param{index+1}": param for index, param in enumerate(parameter)} return self.update_inverter_setting(serial_number, setting_type, @@ -1165,7 +1176,7 @@ def update_tlx_inverter_setting(self, serial_number, setting_type, parameter): def update_noah_settings(self, serial_number, setting_type, parameters): """ Applies settings for specified noah device based on serial number - See README for known working settings + See README for known working settings. Arguments: serial_number -- Serial number (device_sn) of the noah (str) @@ -1175,10 +1186,11 @@ def update_noah_settings(self, serial_number, setting_type, parameters): Returns: JSON response from the server whether the configuration was successful + """ default_parameters = { - 'serialNum': serial_number, - 'type': setting_type + "serialNum": serial_number, + "type": setting_type } settings_parameters = parameters @@ -1186,11 +1198,11 @@ def update_noah_settings(self, serial_number, setting_type, parameters): if isinstance(parameters, list): settings_parameters = {} for index, param in enumerate(parameters, start=1): - settings_parameters['param' + str(index)] = param + settings_parameters["param" + str(index)] = param settings_parameters = {**default_parameters, **settings_parameters} - response = self.session.post(self.get_url('noahDeviceApi/noah/set'), + response = self.session.post(self.get_url("noahDeviceApi/noah/set"), data=settings_parameters) return response.json() @@ -1198,7 +1210,7 @@ def update_noah_settings(self, serial_number, setting_type, parameters): def update_classic_inverter_setting(self, default_parameters, parameters): """ Applies settings for specified system based on serial number - See README for known working settings + See README for known working settings. Arguments: default_params -- Default set of parameters for the setting call (dict) @@ -1207,6 +1219,7 @@ def update_classic_inverter_setting(self, default_parameters, parameters): Returns: JSON response from the server whether the configuration was successful + """ settings_parameters = parameters @@ -1214,11 +1227,11 @@ def update_classic_inverter_setting(self, default_parameters, parameters): if isinstance(parameters, list): settings_parameters = {} for index, param in enumerate(parameters, start=1): - settings_parameters['param' + str(index)] = param + settings_parameters["param" + str(index)] = param settings_parameters = {**default_parameters, **settings_parameters} - response = self.session.post(self.get_url('tcpSet.do'), + response = self.session.post(self.get_url("tcpSet.do"), params=settings_parameters) - return response.json() \ No newline at end of file + return response.json() diff --git a/growattServer/exceptions.py b/growattServer/exceptions.py index ff67a98..8538526 100644 --- a/growattServer/exceptions.py +++ b/growattServer/exceptions.py @@ -16,18 +16,18 @@ class GrowattError(Exception): """Base exception class for all Growatt API related errors.""" - pass + class GrowattParameterError(GrowattError): """Raised when invalid parameters are provided to API methods.""" - pass + class GrowattV1ApiError(GrowattError): """Raised when a Growatt V1 API request fails or returns an error.""" - def __init__(self, message, error_code=None, error_msg=None): + def __init__(self, message, error_code=None, error_msg=None) -> None: super().__init__(message) self.error_code = error_code self.error_msg = error_msg diff --git a/growattServer/open_api_v1.py b/growattServer/open_api_v1.py index 3cf8d2f..a3bfda9 100644 --- a/growattServer/open_api_v1.py +++ b/growattServer/open_api_v1.py @@ -1,8 +1,9 @@ +import platform import warnings from datetime import date, timedelta from enum import Enum + from . import GrowattApi -import platform from .exceptions import GrowattParameterError, GrowattV1ApiError @@ -25,24 +26,24 @@ class OpenApiV1(GrowattApi): """ Extended Growatt API client with V1 API support. This class extends the base GrowattApi class with methods for MIN and SPH devices using - the public V1 API described here: https://www.showdoc.com.cn/262556420217021/0 + the public V1 API described here: https://www.showdoc.com.cn/262556420217021/0. """ - def _create_user_agent(self): + def _create_user_agent(self) -> str: python_version = platform.python_version() system = platform.system() release = platform.release() machine = platform.machine() - user_agent = f"Python/{python_version} ({system} {release}; {machine})" - return user_agent + return f"Python/{python_version} ({system} {release}; {machine})" - def __init__(self, token): + def __init__(self, token) -> None: """ Initialize the Growatt API client with V1 API support. Args: token (str): API token for authentication (required for V1 API access). + """ # Initialize the base class super().__init__(agent_identifier=self._create_user_agent()) @@ -66,19 +67,19 @@ def _process_response(self, response, operation_name="API operation"): Raises: GrowattV1ApiError: If the API returns an error response + """ - if response.get('error_code', 1) != 0: + if response.get("error_code", 1) != 0: + msg = f"Error during {operation_name}" raise GrowattV1ApiError( - f"Error during {operation_name}", - error_code=response.get('error_code'), - error_msg=response.get('error_msg', 'Unknown error') + msg, + error_code=response.get("error_code"), + error_msg=response.get("error_msg", "Unknown error") ) - return response.get('data') + return response.get("data") def _get_url(self, page): - """ - Simple helper function to get the page URL for v1 API. - """ + """Simple helper function to get the page URL for v1 API.""" return self.api_url + page def plant_list(self): @@ -91,18 +92,19 @@ def plant_list(self): Raises: GrowattV1ApiError: If the API returns an error response. requests.exceptions.RequestException: If there is an issue with the HTTP request. + """ # Prepare request data request_data = { - 'page': '', - 'perpage': '', - 'search_type': '', - 'search_keyword': '' + "page": "", + "perpage": "", + "search_type": "", + "search_keyword": "" } # Make the request response = self.session.get( - url=self._get_url('plant/list'), + url=self._get_url("plant/list"), data=request_data ) @@ -121,11 +123,11 @@ def plant_details(self, plant_id): Raises: GrowattV1ApiError: If the API returns an error response. requests.exceptions.RequestException: If there is an issue with the HTTP request. - """ + """ response = self.session.get( - self._get_url('plant/details'), - params={'plant_id': plant_id} + self._get_url("plant/details"), + params={"plant_id": plant_id} ) return self._process_response(response.json(), "getting plant details") @@ -143,16 +145,16 @@ def plant_energy_overview(self, plant_id): Raises: GrowattV1ApiError: If the API returns an error response. requests.exceptions.RequestException: If there is an issue with the HTTP request. - """ + """ response = self.session.get( - self._get_url('plant/data'), - params={'plant_id': plant_id} + self._get_url("plant/data"), + params={"plant_id": plant_id} ) return self._process_response(response.json(), "getting plant energy overview") - def plant_power_overview(self, plant_id: int, day: str | date = None) -> dict: + def plant_power_overview(self, plant_id: int, day: str | date | None = None) -> dict: """ Obtain power data of a certain power station. Get the frequency once every 5 minutes @@ -168,21 +170,23 @@ def plant_power_overview(self, plant_id: int, day: str | date = None) -> dict: # Each entry in 'powers' is a dictionary with: # 'time': str, # Time of the power reading # 'power': float | None # Power value in Watts (can be None) - } + }. + Raises: GrowattV1ApiError: If the API returns an error response. requests.exceptions.RequestException: If there is an issue with the HTTP request. API-Doc: https://www.showdoc.com.cn/262556420217021/1494062656174173 + """ if day is None: day = date.today() response = self.session.get( - self._get_url('plant/power'), + self._get_url("plant/power"), params={ - 'plant_id': plant_id, - 'date': day, + "plant_id": plant_id, + "date": day, } ) @@ -212,8 +216,8 @@ def plant_energy_history(self, plant_id, start_date=None, end_date=None, time_un GrowattParameterError: If date parameters are invalid. GrowattV1ApiError: If the API returns an error response. requests.exceptions.RequestException: If there is an issue with the HTTP request. - """ + """ if start_date is None and end_date is None: start_date = date.today() end_date = date.today() @@ -225,23 +229,23 @@ def plant_energy_history(self, plant_id, start_date=None, end_date=None, time_un # Validate date ranges based on time_unit if time_unit == "day" and (end_date - start_date).days > 7: warnings.warn( - "Date interval must not exceed 7 days in 'day' mode.", RuntimeWarning) + "Date interval must not exceed 7 days in 'day' mode.", RuntimeWarning, stacklevel=2) elif time_unit == "month" and (end_date.year - start_date.year > 1): warnings.warn( - "Start date must be within same or previous year in 'month' mode.", RuntimeWarning) + "Start date must be within same or previous year in 'month' mode.", RuntimeWarning, stacklevel=2) elif time_unit == "year" and (end_date.year - start_date.year > 20): warnings.warn( - "Date interval must not exceed 20 years in 'year' mode.", RuntimeWarning) + "Date interval must not exceed 20 years in 'year' mode.", RuntimeWarning, stacklevel=2) response = self.session.get( - self._get_url('plant/energy'), + self._get_url("plant/energy"), params={ - 'plant_id': plant_id, - 'start_date': start_date.strftime("%Y-%m-%d"), - 'end_date': end_date.strftime("%Y-%m-%d"), - 'time_unit': time_unit, - 'page': page, - 'perpage': perpage + "plant_id": plant_id, + "start_date": start_date.strftime("%Y-%m-%d"), + "end_date": end_date.strftime("%Y-%m-%d"), + "time_unit": time_unit, + "page": page, + "perpage": perpage } ) @@ -290,6 +294,7 @@ def device_list(self, plant_id): "error_code": 0, "error_msg": "" } + """ response = self.session.get( url=self._get_url("device/list"), @@ -314,12 +319,12 @@ def min_detail(self, device_sn): Raises: GrowattV1ApiError: If the API returns an error response. requests.exceptions.RequestException: If there is an issue with the HTTP request. - """ + """ response = self.session.get( - self._get_url('device/tlx/tlx_data_info'), + self._get_url("device/tlx/tlx_data_info"), params={ - 'device_sn': device_sn + "device_sn": device_sn } ) @@ -338,8 +343,8 @@ def min_energy(self, device_sn): Raises: GrowattV1ApiError: If the API returns an error response. requests.exceptions.RequestException: If there is an issue with the HTTP request. - """ + """ response = self.session.post( url=self._get_url("device/tlx/tlx_last_data"), data={ @@ -368,8 +373,8 @@ def min_energy_history(self, device_sn, start_date=None, end_date=None, timezone GrowattParameterError: If date interval is invalid (exceeds 7 days). GrowattV1ApiError: If the API returns an error response. requests.exceptions.RequestException: If there is an issue with the HTTP request. - """ + """ if start_date is None and end_date is None: start_date = date.today() end_date = date.today() @@ -383,7 +388,7 @@ def min_energy_history(self, device_sn, start_date=None, end_date=None, timezone raise GrowattParameterError("date interval must not exceed 7 days") response = self.session.post( - url=self._get_url('device/tlx/tlx_data'), + url=self._get_url("device/tlx/tlx_data"), data={ "tlx_sn": device_sn, "start_date": start_date.strftime("%Y-%m-%d"), @@ -409,12 +414,12 @@ def min_settings(self, device_sn): Raises: GrowattV1ApiError: If the API returns an error response. requests.exceptions.RequestException: If there is an issue with the HTTP request. - """ + """ response = self.session.get( - self._get_url('device/tlx/tlx_set_info'), + self._get_url("device/tlx/tlx_set_info"), params={ - 'device_sn': device_sn + "device_sn": device_sn } ) @@ -437,16 +442,16 @@ def min_read_parameter(self, device_sn, parameter_id, start_address=None, end_ad GrowattParameterError: If parameters are invalid. GrowattV1ApiError: If the API returns an error response. requests.exceptions.RequestException: If there is an issue with the HTTP request. - """ + """ if parameter_id is None and start_address is None: raise GrowattParameterError( "specify either parameter_id or start_address/end_address") - elif parameter_id is not None and start_address is not None: + if parameter_id is not None and start_address is not None: raise GrowattParameterError( "specify either parameter_id or start_address/end_address - not both." ) - elif parameter_id is not None: + if parameter_id is not None: # named parameter start_address = 0 end_address = 0 @@ -459,7 +464,7 @@ def min_read_parameter(self, device_sn, parameter_id, start_address=None, end_ad end_address = start_address response = self.session.post( - self._get_url('readMinParam'), + self._get_url("readMinParam"), data={ "device_sn": device_sn, "paramId": parameter_id, @@ -488,10 +493,10 @@ def min_write_parameter(self, device_sn, parameter_id, parameter_values=None): Raises: GrowattV1ApiError: If the API returns an error response. requests.exceptions.RequestException: If there is an issue with the HTTP request. - """ + """ # Initialize all parameters as empty strings - parameters = {i: "" for i in range(1, 20)} + parameters = dict.fromkeys(range(1, 20), "") # Process parameter values based on type if parameter_values is not None: @@ -522,7 +527,7 @@ def min_write_parameter(self, device_sn, parameter_id, parameter_values=None): # Send the request response = self.session.post( - self._get_url('tlxSet'), + self._get_url("tlxSet"), data=request_data ) @@ -547,8 +552,8 @@ def min_write_time_segment(self, device_sn, segment_id, batt_mode, start_time, e GrowattParameterError: If parameters are invalid. GrowattV1ApiError: If the API returns an error response. requests.exceptions.RequestException: If there is an issue with the HTTP request. - """ + """ if not 1 <= segment_id <= 9: raise GrowattParameterError("segment_id must be between 1 and 9") @@ -575,7 +580,7 @@ def min_write_time_segment(self, device_sn, segment_id, batt_mode, start_time, e # Send the request response = self.session.post( - self._get_url('tlxSet'), + self._get_url("tlxSet"), data=all_params ) @@ -617,8 +622,8 @@ def min_read_time_segments(self, device_sn, settings_data=None): Raises: GrowattV1ApiError: If the API request fails requests.exceptions.RequestException: If there is an issue with the HTTP request. - """ + """ # Process the settings data if settings_data is None: # Fetch settings if not provided @@ -636,13 +641,13 @@ def min_read_time_segments(self, device_sn, settings_data=None): # Process each time segment for i in range(1, 10): # Segments 1-9 # Get raw time values - start_time_raw = settings_data.get(f'forcedTimeStart{i}', "0:0") - end_time_raw = settings_data.get(f'forcedTimeStop{i}', "0:0") + start_time_raw = settings_data.get(f"forcedTimeStart{i}", "0:0") + end_time_raw = settings_data.get(f"forcedTimeStop{i}", "0:0") # Handle 'null' string values - if start_time_raw == 'null' or not start_time_raw: + if start_time_raw == "null" or not start_time_raw: start_time_raw = "0:0" - if end_time_raw == 'null' or not end_time_raw: + if end_time_raw == "null" or not end_time_raw: end_time_raw = "0:0" # Format times with leading zeros (HH:MM) @@ -663,8 +668,8 @@ def min_read_time_segments(self, device_sn, settings_data=None): end_time = "00:00" # Get the mode value safely - mode_raw = settings_data.get(f'time{i}Mode') - if mode_raw == 'null' or mode_raw is None: + mode_raw = settings_data.get(f"time{i}Mode") + if mode_raw == "null" or mode_raw is None: batt_mode = None else: try: @@ -673,8 +678,8 @@ def min_read_time_segments(self, device_sn, settings_data=None): batt_mode = None # Get the enabled status safely - enabled_raw = settings_data.get(f'forcedStopSwitch{i}', 0) - if enabled_raw == 'null' or enabled_raw is None: + enabled_raw = settings_data.get(f"forcedStopSwitch{i}", 0) + if enabled_raw == "null" or enabled_raw is None: enabled = False else: try: @@ -683,12 +688,12 @@ def min_read_time_segments(self, device_sn, settings_data=None): enabled = False segment = { - 'segment_id': i, - 'batt_mode': batt_mode, - 'mode_name': mode_names.get(batt_mode, "Unknown"), - 'start_time': start_time, - 'end_time': end_time, - 'enabled': enabled + "segment_id": i, + "batt_mode": batt_mode, + "mode_name": mode_names.get(batt_mode, "Unknown"), + "start_time": start_time, + "end_time": end_time, + "enabled": enabled } segments.append(segment) @@ -710,13 +715,13 @@ def sph_detail(self, device_sn): Raises: GrowattV1ApiError: If the API returns an error response. requests.exceptions.RequestException: If there is an issue with the HTTP request. - """ + """ # API: https://www.showdoc.com.cn/262556420217021/6129763571291058 response = self.session.get( - self._get_url('device/mix/mix_data_info'), + self._get_url("device/mix/mix_data_info"), params={ - 'device_sn': device_sn + "device_sn": device_sn } ) @@ -735,8 +740,8 @@ def sph_energy(self, device_sn): Raises: GrowattV1ApiError: If the API returns an error response. requests.exceptions.RequestException: If there is an issue with the HTTP request. - """ + """ # API: https://www.showdoc.com.cn/262556420217021/6129764475556048 response = self.session.post( url=self._get_url("device/mix/mix_last_data"), @@ -766,8 +771,8 @@ def sph_energy_history(self, device_sn, start_date=None, end_date=None, timezone GrowattParameterError: If date interval is invalid (exceeds 7 days). GrowattV1ApiError: If the API returns an error response. requests.exceptions.RequestException: If there is an issue with the HTTP request. - """ + """ if start_date is None and end_date is None: start_date = date.today() end_date = date.today() @@ -782,7 +787,7 @@ def sph_energy_history(self, device_sn, start_date=None, end_date=None, timezone # API: https://www.showdoc.com.cn/262556420217021/6129765461123058 response = self.session.post( - url=self._get_url('device/mix/mix_data'), + url=self._get_url("device/mix/mix_data"), data={ "mix_sn": device_sn, "start_date": start_date.strftime("%Y-%m-%d"), @@ -812,16 +817,16 @@ def sph_read_parameter(self, device_sn, parameter_id=None, start_address=None, e GrowattParameterError: If parameters are invalid. GrowattV1ApiError: If the API returns an error response. requests.exceptions.RequestException: If there is an issue with the HTTP request. - """ + """ if parameter_id is None and start_address is None: raise GrowattParameterError( "specify either parameter_id or start_address/end_address") - elif parameter_id is not None and start_address is not None: + if parameter_id is not None and start_address is not None: raise GrowattParameterError( "specify either parameter_id or start_address/end_address - not both." ) - elif parameter_id is not None: + if parameter_id is not None: # named parameter start_address = 0 end_address = 0 @@ -831,7 +836,7 @@ def sph_read_parameter(self, device_sn, parameter_id=None, start_address=None, e # API: https://www.showdoc.com.cn/262556420217021/6129766954561259 response = self.session.post( - self._get_url('readMixParam'), + self._get_url("readMixParam"), data={ "device_sn": device_sn, "paramId": parameter_id, @@ -860,10 +865,11 @@ def sph_write_parameter(self, device_sn, parameter_id, parameter_values=None): Raises: GrowattV1ApiError: If the API returns an error response. requests.exceptions.RequestException: If there is an issue with the HTTP request. - """ + """ # Initialize all parameters as empty strings (API uses param1-param18) - parameters = {i: "" for i in range(1, 19)} + maxParams = 18 + parameters = dict.fromkeys(range(1, maxParams + 1), "") # Process parameter values based on type if parameter_values is not None: @@ -873,7 +879,7 @@ def sph_write_parameter(self, device_sn, parameter_id, parameter_values=None): elif isinstance(parameter_values, list): # List of values go to sequential params for i, value in enumerate(parameter_values, 1): - if i <= 18: # Only use up to 18 parameters + if i <= maxParams: # Only use up to 18 parameters parameters[i] = str(value) elif isinstance(parameter_values, dict): # Dict maps param positions to values @@ -894,7 +900,7 @@ def sph_write_parameter(self, device_sn, parameter_id, parameter_values=None): # API: https://www.showdoc.com.cn/262556420217021/6129761750718760 response = self.session.post( - self._get_url('mixSet'), + self._get_url("mixSet"), data=request_data ) @@ -936,14 +942,15 @@ def sph_write_ac_charge_times(self, device_sn, charge_power, charge_stop_soc, ma GrowattParameterError: If parameters are invalid. GrowattV1ApiError: If the API returns an error response. requests.exceptions.RequestException: If there is an issue with the HTTP request. + """ - if not 0 <= charge_power <= 100: + if not 0 <= charge_power <= 100: # noqa: PLR2004 raise GrowattParameterError("charge_power must be between 0 and 100") - if not 0 <= charge_stop_soc <= 100: + if not 0 <= charge_stop_soc <= 100: # noqa: PLR2004 raise GrowattParameterError("charge_stop_soc must be between 0 and 100") - if len(periods) != 3: + if len(periods) != 3: # noqa: PLR2004 raise GrowattParameterError("periods must contain exactly 3 period definitions") # Build request data @@ -966,7 +973,7 @@ def sph_write_ac_charge_times(self, device_sn, charge_power, charge_stop_soc, ma # API: https://www.showdoc.com.cn/262556420217021/6129761750718760 response = self.session.post( - self._get_url('mixSet'), + self._get_url("mixSet"), data=request_data ) @@ -1006,14 +1013,15 @@ def sph_write_ac_discharge_times(self, device_sn, discharge_power, discharge_sto GrowattParameterError: If parameters are invalid. GrowattV1ApiError: If the API returns an error response. requests.exceptions.RequestException: If there is an issue with the HTTP request. + """ - if not 0 <= discharge_power <= 100: + if not 0 <= discharge_power <= 100: # noqa: PLR2004 raise GrowattParameterError("discharge_power must be between 0 and 100") - if not 0 <= discharge_stop_soc <= 100: + if not 0 <= discharge_stop_soc <= 100: # noqa: PLR2004 raise GrowattParameterError("discharge_stop_soc must be between 0 and 100") - if len(periods) != 3: + if len(periods) != 3: # noqa: PLR2004 raise GrowattParameterError("periods must contain exactly 3 period definitions") # Build request data @@ -1035,7 +1043,7 @@ def sph_write_ac_discharge_times(self, device_sn, discharge_power, discharge_sto # API: https://www.showdoc.com.cn/262556420217021/6129761750718760 response = self.session.post( - self._get_url('mixSet'), + self._get_url("mixSet"), data=request_data ) @@ -1057,20 +1065,21 @@ def _parse_time_periods(self, settings_data, time_type): - start_time (str): Start time in format "HH:MM" - end_time (str): End time in format "HH:MM" - enabled (bool): Whether the period is enabled + """ periods = [] # Process each time period (1-3 for SPH) for i in range(1, 4): # Get raw time values - start_time_raw = settings_data.get(f'forced{time_type}TimeStart{i}', "0:0") - end_time_raw = settings_data.get(f'forced{time_type}TimeStop{i}', "0:0") - enabled_raw = settings_data.get(f'forced{time_type}StopSwitch{i}', 0) + start_time_raw = settings_data.get(f"forced{time_type}TimeStart{i}", "0:0") + end_time_raw = settings_data.get(f"forced{time_type}TimeStop{i}", "0:0") + enabled_raw = settings_data.get(f"forced{time_type}StopSwitch{i}", 0) # Handle 'null' string values - if start_time_raw == 'null' or not start_time_raw: + if start_time_raw == "null" or not start_time_raw: start_time_raw = "0:0" - if end_time_raw == 'null' or not end_time_raw: + if end_time_raw == "null" or not end_time_raw: end_time_raw = "0:0" # Format times with leading zeros (HH:MM) @@ -1091,7 +1100,7 @@ def _parse_time_periods(self, settings_data, time_type): end_time = "00:00" # Get the enabled status - if enabled_raw == 'null' or enabled_raw is None: + if enabled_raw == "null" or enabled_raw is None: enabled = False else: try: @@ -1100,10 +1109,10 @@ def _parse_time_periods(self, settings_data, time_type): enabled = False period = { - 'period_id': i, - 'start_time': start_time, - 'end_time': end_time, - 'enabled': enabled + "period_id": i, + "start_time": start_time, + "end_time": end_time, + "enabled": enabled } periods.append(period) @@ -1152,6 +1161,7 @@ def sph_read_ac_charge_times(self, device_sn=None, settings_data=None): GrowattParameterError: If neither device_sn nor settings_data is provided. GrowattV1ApiError: If the API request fails. requests.exceptions.RequestException: If there is an issue with the HTTP request. + """ if settings_data is None: if device_sn is None: @@ -1159,25 +1169,25 @@ def sph_read_ac_charge_times(self, device_sn=None, settings_data=None): settings_data = self.sph_detail(device_sn=device_sn) # Extract global charge settings - charge_power = settings_data.get('chargePowerCommand', 0) - charge_stop_soc = settings_data.get('wchargeSOCLowLimit', 100) - mains_enabled_raw = settings_data.get('acChargeEnable', 0) + charge_power = settings_data.get("chargePowerCommand", 0) + charge_stop_soc = settings_data.get("wchargeSOCLowLimit", 100) + mains_enabled_raw = settings_data.get("acChargeEnable", 0) # Handle null/empty values - if charge_power == 'null' or charge_power is None or charge_power == '': + if charge_power == "null" or charge_power is None or charge_power == "": charge_power = 0 - if charge_stop_soc == 'null' or charge_stop_soc is None or charge_stop_soc == '': + if charge_stop_soc == "null" or charge_stop_soc is None or charge_stop_soc == "": charge_stop_soc = 100 - if mains_enabled_raw == 'null' or mains_enabled_raw is None or mains_enabled_raw == '': + if mains_enabled_raw == "null" or mains_enabled_raw is None or mains_enabled_raw == "": mains_enabled = False else: mains_enabled = int(mains_enabled_raw) == 1 return { - 'charge_power': int(charge_power), - 'charge_stop_soc': int(charge_stop_soc), - 'mains_enabled': mains_enabled, - 'periods': self._parse_time_periods(settings_data, "Charge") + "charge_power": int(charge_power), + "charge_stop_soc": int(charge_stop_soc), + "mains_enabled": mains_enabled, + "periods": self._parse_time_periods(settings_data, "Charge") } def sph_read_ac_discharge_times(self, device_sn=None, settings_data=None): @@ -1221,6 +1231,7 @@ def sph_read_ac_discharge_times(self, device_sn=None, settings_data=None): GrowattParameterError: If neither device_sn nor settings_data is provided. GrowattV1ApiError: If the API request fails. requests.exceptions.RequestException: If there is an issue with the HTTP request. + """ if settings_data is None: if device_sn is None: @@ -1228,17 +1239,17 @@ def sph_read_ac_discharge_times(self, device_sn=None, settings_data=None): settings_data = self.sph_detail(device_sn=device_sn) # Extract global discharge settings - discharge_power = settings_data.get('disChargePowerCommand', 0) - discharge_stop_soc = settings_data.get('wdisChargeSOCLowLimit', 10) + discharge_power = settings_data.get("disChargePowerCommand", 0) + discharge_stop_soc = settings_data.get("wdisChargeSOCLowLimit", 10) # Handle null/empty values - if discharge_power == 'null' or discharge_power is None or discharge_power == '': + if discharge_power == "null" or discharge_power is None or discharge_power == "": discharge_power = 0 - if discharge_stop_soc == 'null' or discharge_stop_soc is None or discharge_stop_soc == '': + if discharge_stop_soc == "null" or discharge_stop_soc is None or discharge_stop_soc == "": discharge_stop_soc = 10 return { - 'discharge_power': int(discharge_power), - 'discharge_stop_soc': int(discharge_stop_soc), - 'periods': self._parse_time_periods(settings_data, "Discharge") + "discharge_power": int(discharge_power), + "discharge_stop_soc": int(discharge_stop_soc), + "periods": self._parse_time_periods(settings_data, "Discharge") } diff --git a/setup.py b/setup.py index d61bc46..d6cf32e 100755 --- a/setup.py +++ b/setup.py @@ -1,7 +1,7 @@ + import setuptools -import os -with open("README.md", "r") as fh: +with open("README.md") as fh: long_description = fh.read() setuptools.setup( From f67aff059bbdcb71aa33a6cc4767642a890add71 Mon Sep 17 00:00:00 2001 From: indy koning Date: Sun, 1 Feb 2026 16:32:19 +0100 Subject: [PATCH 02/13] Fix leftover warnings --- .github/workflows/ruff.yml | 4 +- .ruff.toml | 2 + growattServer/__init__.py | 22 +- growattServer/base_api.py | 688 +++++++++++++++++------------------ growattServer/exceptions.py | 11 +- growattServer/open_api_v1.py | 78 ++-- setup.py | 7 +- 7 files changed, 421 insertions(+), 391 deletions(-) diff --git a/.github/workflows/ruff.yml b/.github/workflows/ruff.yml index ddc0a15..d0ddc75 100644 --- a/.github/workflows/ruff.yml +++ b/.github/workflows/ruff.yml @@ -8,4 +8,6 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: astral-sh/ruff-action@v3 \ No newline at end of file + - uses: astral-sh/ruff-action@v3 + with: + src: "./growattServer" \ No newline at end of file diff --git a/.ruff.toml b/.ruff.toml index ce9fa51..37d5f57 100644 --- a/.ruff.toml +++ b/.ruff.toml @@ -18,6 +18,8 @@ ignore = [ "TRY003", # Allow exception messages to be used directly "ERA001", # Commented out code is allowed for examples "T201", # Prints are allowed in examples + "PLR0913", + "FBT002", ] [lint.flake8-pytest-style] diff --git a/growattServer/__init__.py b/growattServer/__init__.py index 6148dc7..b057305 100755 --- a/growattServer/__init__.py +++ b/growattServer/__init__.py @@ -1,11 +1,21 @@ -# Import everything from base_api to ensure backward compatibility -from .base_api import * +#!/usr/bin/env python3 +"""growattServer package exports.""" -# Import exceptions +# ruff: noqa: N999 +from .base_api import GrowattApi, Timespan, hash_password from .exceptions import GrowattError, GrowattParameterError, GrowattV1ApiError - -# Import the V1 API class and DeviceType enum from .open_api_v1 import DeviceType, OpenApiV1 -# Define the name of the package +# Package name name = "growattServer" + +__all__ = [ + "DeviceType", + "GrowattApi", + "GrowattError", + "GrowattParameterError", + "GrowattV1ApiError", + "OpenApiV1", + "Timespan", + "hash_password", +] diff --git a/growattServer/base_api.py b/growattServer/base_api.py index 13c7c83..50c1be7 100644 --- a/growattServer/base_api.py +++ b/growattServer/base_api.py @@ -1,11 +1,17 @@ +"""Core Growatt API client and helpers.""" + +# ruff: noqa: S324 + import datetime import hashlib +import secrets import warnings from enum import IntEnum -from random import randint import requests +from .exceptions import GrowattV1ApiError + name = "growattServer" BATT_MODE_LOAD_FIRST = 0 @@ -14,8 +20,13 @@ def hash_password(password): - """Normal MD5, except add c if a byte of the digest is less than 10.""" - password_md5 = hashlib.md5(password.encode("utf-8")).hexdigest() + """ + Return a modified MD5-like hex digest with 'c' substitutions. + + The algorithm computes an MD5 hex digest and replaces bytes with a '0' + nibble by the character 'c' at every other position. + """ + password_md5 = hashlib.md5(password.encode("utf-8")).hexdigest() # nosec for i in range(0, len(password_md5), 2): if password_md5[i] == "0": password_md5 = password_md5[0:i] + "c" + password_md5[i + 1:] @@ -23,39 +34,54 @@ def hash_password(password): class Timespan(IntEnum): + """Enumeration of supported timespans.""" + hour = 0 day = 1 month = 2 class GrowattApi: + """Base client for Growatt API endpoints.""" + server_url = "https://openapi.growatt.com/" agent_identifier = "Dalvik/2.1.0 (Linux; U; Android 12; https://github.com/indykoning/PyPi_GrowattServer)" def __init__(self, add_random_user_id=False, agent_identifier=None) -> None: - if (agent_identifier is not None): + """ + Initialize the Growatt API client. + + Args: + add_random_user_id: Append a short random suffix to the user-agent. + agent_identifier: Optional override for the user-agent string. + + """ + if agent_identifier is not None: self.agent_identifier = agent_identifier # If a random user id is required, generate a 5 digit number and add it to the user agent - if (add_random_user_id): - random_number = "".join([f"{randint(0, 9)}" - for num in range(5)]) + if add_random_user_id: + random_number = "".join(str(secrets.randbelow(10)) for _ in range(5)) self.agent_identifier += " - " + random_number self.session = requests.Session() - self.session.hooks = { - "response": lambda response, *args, **kwargs: response.raise_for_status() - } + + def _raise_for_status(response, *args: object, **kwargs: object) -> None: + _ = args + _ = kwargs + response.raise_for_status() + + self.session.hooks = {"response": _raise_for_status} headers = {"User-Agent": self.agent_identifier} self.session.headers.update(headers) def __get_date_string(self, timespan=None, date=None): - if timespan is not None: - assert timespan in Timespan + if timespan is not None and not isinstance(timespan, Timespan): + raise ValueError("timespan must be a Timespan enum value") if date is None: - date = datetime.datetime.now() + date = datetime.datetime.now(datetime.UTC) date_str = "" if timespan == Timespan.month: @@ -66,7 +92,7 @@ def __get_date_string(self, timespan=None, date=None): return date_str def get_url(self, page): - """Simple helper function to get the page URL.""" + """Return the page URL.""" return self.server_url + page def login(self, username, password, is_password_hashed=False): @@ -336,12 +362,13 @@ def tlx_energy_overview(self, plant_id, tlx_id): def tlx_energy_prod_cons(self, plant_id, tlx_id, timespan=Timespan.hour, date=None): """ - Get energy production and consumption (KW). + Get energy production and consumption (kW). Args: - tlx_id (str): The ID of the TLX inverter. - timespan (Timespan): The ENUM value conforming to the time window you want e.g. hours from today, days, or months. - date (datetime): The date you are interested in. + plant_id: The plant identifier. + tlx_id: The ID of the TLX inverter. + timespan: Timespan enum for the requested range. + date: Date of interest. Returns: dict: A dictionary containing energy data. @@ -467,7 +494,7 @@ def tlx_enabled_settings(self, tlx_id): Exception: If the request to the server fails. """ - string_time = datetime.datetime.now().strftime("%Y-%m-%d") + string_time = datetime.datetime.now(datetime.UTC).strftime("%Y-%m-%d") response = self.session.post( self.get_url("newLoginAPI.do"), params={"op": "getSetPass"}, @@ -523,34 +550,35 @@ def tlx_battery_info_detailed(self, plant_id, serial_num): def mix_info(self, mix_id, plant_id=None): """ - Returns high level values from Mix device. + Get high-level values from a Mix device. - Keyword Arguments: - mix_id -- The serial number (device_sn) of the inverter - plant_id -- The ID of the plant (the mobile app uses this but it does not appear to be necessary) (default None) + Args: + mix_id: The device serial number. + plant_id: Optional plant identifier. Returns: - 'acChargeEnergyToday' -- ??? 2.7 - 'acChargeEnergyTotal' -- ??? 25.3 - 'acChargePower' -- ??? 0 - 'capacity': '45' -- The current remaining capacity of the batteries (same as soc but without the % sign) - 'eBatChargeToday' -- Battery charged today in kWh - 'eBatChargeTotal' -- Battery charged total (all time) in kWh - 'eBatDisChargeToday' -- Battery discharged today in kWh - 'eBatDisChargeTotal' -- Battery discharged total (all time) in kWh - 'epvToday' -- Energy generated from PVs today in kWh - 'epvTotal' -- Energy generated from PVs total (all time) in kWh - 'isCharge'-- ??? 0 - Possible a 0/1 based on whether or not the battery is charging - 'pCharge1' -- ??? 0 - 'pDischarge1' -- Battery discharging rate in W - 'soc' -- Statement of charge including % symbol - 'upsPac1' -- ??? 0 - 'upsPac2' -- ??? 0 - 'upsPac3' -- ??? 0 - 'vbat' -- Battery Voltage - 'vbatdsp' -- ??? 51.8 - 'vpv1' -- Voltage PV1 - 'vpv2' -- Voltage PV2 + dict: + 'acChargeEnergyToday' -- ??? 2.7 + 'acChargeEnergyTotal' -- ??? 25.3 + 'acChargePower' -- ??? 0 + 'capacity': '45' -- The current remaining capacity of the batteries (same as soc but without the % sign) + 'eBatChargeToday' -- Battery charged today in kWh + 'eBatChargeTotal' -- Battery charged total (all time) in kWh + 'eBatDisChargeToday' -- Battery discharged today in kWh + 'eBatDisChargeTotal' -- Battery discharged total (all time) in kWh + 'epvToday' -- Energy generated from PVs today in kWh + 'epvTotal' -- Energy generated from PVs total (all time) in kWh + 'isCharge'-- ??? 0 - Possible a 0/1 based on whether or not the battery is charging + 'pCharge1' -- ??? 0 + 'pDischarge1' -- Battery discharging rate in W + 'soc' -- Statement of charge including % symbol + 'upsPac1' -- ??? 0 + 'upsPac2' -- ??? 0 + 'upsPac3' -- ??? 0 + 'vbat' -- Battery Voltage + 'vbatdsp' -- ??? 51.8 + 'vpv1' -- Voltage PV1 + 'vpv2' -- Voltage PV2 """ request_params = { @@ -568,26 +596,27 @@ def mix_info(self, mix_id, plant_id=None): def mix_totals(self, mix_id, plant_id): """ - Returns "Totals" values from Mix device. + Get totals values from a Mix device. - Keyword Arguments: - mix_id -- The serial number (device_sn) of the inverter - plant_id -- The ID of the plant + Args: + mix_id: The device serial number. + plant_id: Plant identifier. Returns: - 'echargetoday' -- Battery charged today in kWh (same as eBatChargeToday from mix_info) - 'echargetotal' -- Battery charged total (all time) in kWh (same as eBatChargeTotal from mix_info) - 'edischarge1Today' -- Battery discharged today in kWh (same as eBatDisChargeToday from mix_info) - 'edischarge1Total' -- Battery discharged total (all time) in kWh (same as eBatDisChargeTotal from mix_info) - 'elocalLoadToday' -- Load consumption today in kWh - 'elocalLoadTotal' -- Load consumption total (all time) in kWh - 'epvToday' -- Energy generated from PVs today in kWh (same as epvToday from mix_info) - 'epvTotal' -- Energy generated from PVs total (all time) in kWh (same as epvTotal from mix_info) - 'etoGridToday' -- Energy exported to the grid today in kWh - 'etogridTotal' -- Energy exported to the grid total (all time) in kWh - 'photovoltaicRevenueToday' -- Revenue earned from PV today in 'unit' currency - 'photovoltaicRevenueTotal' -- Revenue earned from PV total (all time) in 'unit' currency - 'unit' -- Unit of currency for 'Revenue' + dict: Totals response object. + 'echargetoday' -- Battery charged today in kWh (same as eBatChargeToday from mix_info) + 'echargetotal' -- Battery charged total (all time) in kWh (same as eBatChargeTotal from mix_info) + 'edischarge1Today' -- Battery discharged today in kWh (same as eBatDisChargeToday from mix_info) + 'edischarge1Total' -- Battery discharged total (all time) in kWh (same as eBatDisChargeTotal from mix_info) + 'elocalLoadToday' -- Load consumption today in kWh + 'elocalLoadTotal' -- Load consumption total (all time) in kWh + 'epvToday' -- Energy generated from PVs today in kWh (same as epvToday from mix_info) + 'epvTotal' -- Energy generated from PVs total (all time) in kWh (same as epvTotal from mix_info) + 'etoGridToday' -- Energy exported to the grid today in kWh + 'etogridTotal' -- Energy exported to the grid total (all time) in kWh + 'photovoltaicRevenueToday' -- Revenue earned from PV today in 'unit' currency + 'photovoltaicRevenueTotal' -- Revenue earned from PV total (all time) in 'unit' currency + 'unit' -- Unit of currency for 'Revenue' """ response = self.session.post(self.get_url("newMixApi.do"), params={ @@ -600,37 +629,38 @@ def mix_totals(self, mix_id, plant_id): def mix_system_status(self, mix_id, plant_id): """ - Returns current "Status" from Mix device. + Get current status from a Mix device. - Keyword Arguments: - mix_id -- The serial number (device_sn) of the inverter - plant_id -- The ID of the plant + Args: + mix_id: The device serial number. + plant_id: Plant identifier. Returns: - 'SOC' -- Statement of charge (remaining battery %) - 'chargePower' -- Battery charging rate in kw - 'fAc' -- Frequency (Hz) - 'lost' -- System status e.g. 'mix.status.normal' - 'pLocalLoad' -- Load conumption in kW - 'pPv1' -- PV1 Wattage in W - 'pPv2' -- PV2 Wattage in W - 'pactogrid' -- Export to grid rate in kW - 'pactouser' -- Import from grid rate in kW - 'pdisCharge1' -- Discharging batteries rate in kW - 'pmax' -- ??? 6 ??? PV Maximum kW ?? - 'ppv' -- PV combined Wattage in kW - 'priorityChoose' -- Priority setting - 0=Local load - 'status' -- System statue - ENUM - Unknown values - 'unit' -- Unit of measurement e.g. 'kW' - 'upsFac' -- ??? 0 - 'upsVac1' -- ??? 0 - 'uwSysWorkMode' -- ??? 6 - 'vAc1' -- Grid voltage in V - 'vBat' -- Battery voltage in V - 'vPv1' -- PV1 voltage in V - 'vPv2' -- PV2 voltage in V - 'vac1' -- Grid voltage in V (same as vAc1) - 'wBatteryType' -- ??? 1 + dict: Status response object. + 'SOC' -- Statement of charge (remaining battery %) + 'chargePower' -- Battery charging rate in kw + 'fAc' -- Frequency (Hz) + 'lost' -- System status e.g. 'mix.status.normal' + 'pLocalLoad' -- Load conumption in kW + 'pPv1' -- PV1 Wattage in W + 'pPv2' -- PV2 Wattage in W + 'pactogrid' -- Export to grid rate in kW + 'pactouser' -- Import from grid rate in kW + 'pdisCharge1' -- Discharging batteries rate in kW + 'pmax' -- ??? 6 ??? PV Maximum kW ?? + 'ppv' -- PV combined Wattage in kW + 'priorityChoose' -- Priority setting - 0=Local load + 'status' -- System statue - ENUM - Unknown values + 'unit' -- Unit of measurement e.g. 'kW' + 'upsFac' -- ??? 0 + 'upsVac1' -- ??? 0 + 'uwSysWorkMode' -- ??? 6 + 'vAc1' -- Grid voltage in V + 'vBat' -- Battery voltage in V + 'vPv1' -- PV1 voltage in V + 'vPv2' -- PV2 voltage in V + 'vac1' -- Grid voltage in V (same as vAc1) + 'wBatteryType' -- ??? 1 """ response = self.session.post(self.get_url("newMixApi.do"), params={ @@ -643,129 +673,114 @@ def mix_system_status(self, mix_id, plant_id): def mix_detail(self, mix_id, plant_id, timespan=Timespan.hour, date=None): """ - Get Mix details for specified timespan. + Get Mix details for the given timespan. - Keyword Arguments: - mix_id -- The serial number (device_sn) of the inverter - plant_id -- The ID of the plant - timespan -- The ENUM value conforming to the time window you want e.g. hours from today, days, or months (Default Timespan.hour) - date -- The date you are interested in (Default datetime.datetime.now()) + Args: + mix_id: Serial number (device_sn) of the inverter. + plant_id: Plant identifier. + timespan: Timespan enum for the requested range. + date: Date of interest (defaults to now). Returns: - A chartData object where each entry is for a specific 5 minute window e.g. 00:05 and 00:10 respectively (below) - 'chartData': { '00:05': { 'pacToGrid' -- Export rate to grid in kW - 'pacToUser' -- Import rate from grid in kW - 'pdischarge' -- Battery discharge in kW - 'ppv' -- Solar generation in kW - 'sysOut' -- Load consumption in kW - }, - '00:10': { 'pacToGrid': '0', - 'pacToUser': '0.93', - 'pdischarge': '0', - 'ppv': '0', - 'sysOut': '0.93'}, - ...... - } - 'eAcCharge' -- Exported to grid in kWh - 'eCharge' -- System production in kWh = Self-consumption + Exported to Grid - 'eChargeToday' -- Load consumption from solar in kWh - 'eChargeToday1' -- Self-consumption in kWh - 'eChargeToday2' -- Self-consumption in kWh (eChargeToday + echarge1) - 'echarge1' -- Load consumption from battery in kWh - 'echargeToat' -- Total battery discharged (all time) in kWh - 'elocalLoad' -- Load consumption in kW (battery + solar + imported) - 'etouser' -- Load consumption imported from grid in kWh - 'photovoltaic' -- Load consumption from solar in kWh (same as eChargeToday) - 'ratio1' -- % of system production that is self-consumed - 'ratio2' -- % of system production that is exported - 'ratio3' -- % of Load consumption that is "self consumption" - 'ratio4' -- % of Load consumption that is "imported from grid" - 'ratio5' -- % of Self consumption that is directly from Solar - 'ratio6' -- % of Self consumption that is from batteries - 'unit' -- Unit of measurement e.g kWh - 'unit2' -- Unit of measurement e.g kW - - - NOTE - It is possible to calculate the PV generation that went into charging the batteries by performing the following calculation: - Solar to Battery = Solar Generation - Export to Grid - Load consumption from solar - epvToday (from mix_info) - eAcCharge - eChargeToday + dict: The response object containing mix details. + A chartData object where each entry is for a specific 5 minute window e.g. 00:05 and 00:10 respectively (below) + 'chartData': { '00:05': { 'pacToGrid' -- Export rate to grid in kW + 'pacToUser' -- Import rate from grid in kW + 'pdischarge' -- Battery discharge in kW + 'ppv' -- Solar generation in kW + 'sysOut' -- Load consumption in kW + }, + '00:10': { 'pacToGrid': '0', + 'pacToUser': '0.93', + 'pdischarge': '0', + 'ppv': '0', + 'sysOut': '0.93'}, + ...... + } + 'eAcCharge' -- Exported to grid in kWh + 'eCharge' -- System production in kWh = Self-consumption + Exported to Grid + 'eChargeToday' -- Load consumption from solar in kWh + 'eChargeToday1' -- Self-consumption in kWh + 'eChargeToday2' -- Self-consumption in kWh (eChargeToday + echarge1) + 'echarge1' -- Load consumption from battery in kWh + 'echargeToat' -- Total battery discharged (all time) in kWh + 'elocalLoad' -- Load consumption in kW (battery + solar + imported) + 'etouser' -- Load consumption imported from grid in kWh + 'photovoltaic' -- Load consumption from solar in kWh (same as eChargeToday) + 'ratio1' -- % of system production that is self-consumed + 'ratio2' -- % of system production that is exported + 'ratio3' -- % of Load consumption that is "self consumption" + 'ratio4' -- % of Load consumption that is "imported from grid" + 'ratio5' -- % of Self consumption that is directly from Solar + 'ratio6' -- % of Self consumption that is from batteries + 'unit' -- Unit of measurement e.g kWh + 'unit2' -- Unit of measurement e.g kW + + + NOTE - It is possible to calculate the PV generation that went into charging the batteries by performing the following calculation: + Solar to Battery = Solar Generation - Export to Grid - Load consumption from solar + epvToday (from mix_info) - eAcCharge - eChargeToday """ date_str = self.__get_date_string(timespan, date) - response = self.session.post(self.get_url("newMixApi.do"), params={ - "op": "getEnergyProdAndCons_KW", - "plantId": plant_id, - "mixId": mix_id, - "type": timespan.value, - "date": date_str - }) + response = self.session.post( + self.get_url("newMixApi.do"), + params={ + "op": "getEnergyProdAndCons_KW", + "plantId": plant_id, + "mixId": mix_id, + "type": timespan.value, + "date": date_str, + }, + ) return response.json().get("obj", {}) - def get_mix_inverter_settings(self, serial_number): - """ - Gets the inverter settings related to battery modes - Keyword arguments: - serial_number -- The serial number (device_sn) of the inverter - Returns: - A dictionary of settings. - """ - default_params = { - "op": "getMixSetParams", - "serialNum": serial_number, - "kind": 0 - } - response = self.session.get(self.get_url("newMixApi.do"), params=default_params) - return json.loads(response.content.decode("utf-8")) - def dashboard_data(self, plant_id, timespan=Timespan.hour, date=None): """ - Get 'dashboard' data for specified timespan - NOTE - All numerical values returned by this api call include units e.g. kWh or % - - Many of the 'total' values that are returned for a Mix system are inaccurate on the system this was tested against. - However, the statistics that are correct are not available on any other interface, plus these values may be accurate for - non-mix types of system. Where the values have been proven to be inaccurate they are commented below. + Get dashboard data for a plant over a timespan. - Keyword Arguments: - plant_id -- The ID of the plant - timespan -- The ENUM value conforming to the time window you want e.g. hours from today, days, or months (Default Timespan.hour) - date -- The date you are interested in (Default datetime.datetime.now()) + Args: + plant_id: Plant identifier. + timespan: Timespan enum for the requested range. + date: Date of interest (defaults to now). Returns: - A chartData object where each entry is for a specific 5 minute window e.g. 00:05 and 00:10 respectively (below) - NOTE: The keys are interpreted differently, the examples below describe what they are used for in a 'Mix' system - 'chartData': { '00:05': { 'pacToUser' -- Power from battery in kW - 'ppv' -- Solar generation in kW - 'sysOut' -- Load consumption in kW - 'userLoad' -- Export in kW - }, - '00:10': { 'pacToUser': '0', - 'ppv': '0', - 'sysOut': '0.7', - 'userLoad': '0'}, - ...... - } - 'chartDataUnit' -- Unit of measurement e.g. 'kW', - 'eAcCharge' -- Energy exported to the grid in kWh e.g. '20.5kWh' (not accurate for Mix systems) - 'eCharge' -- System production in kWh = Self-consumption + Exported to Grid e.g '23.1kWh' (not accurate for Mix systems - actually showing the total 'load consumption' - 'eChargeToday1' -- Self-consumption of PPV (possibly including excess diverted to batteries) in kWh e.g. '2.6kWh' (not accurate for Mix systems) - 'eChargeToday2' -- Total self-consumption (PPV consumption(eChargeToday2Echarge1) + Battery Consumption(echarge1)) e.g. '10.1kWh' (not accurate for Mix systems) - 'eChargeToday2Echarge1' -- Self-consumption of PPV only e.g. '0.8kWh' (not accurate for Mix systems) - 'echarge1' -- Self-consumption from Battery only e.g. '9.3kWh' - 'echargeToat' -- Not used on Dashboard view, likely to be total battery discharged e.g. '152.1kWh' - 'elocalLoad' -- Total load consumption (etouser + eChargeToday2) e.g. '20.3kWh', (not accurate for Mix systems) - 'etouser'-- Energy imported from grid today (includes both directly used by load and AC battery charging e.g. '10.2kWh' - 'keyNames' -- Keys to be used for the graph data e.g. ['Solar', 'Load Consumption', 'Export To Grid', 'From Battery'] - 'photovoltaic' -- Same as eChargeToday2Echarge1 e.g. '0.8kWh' - 'ratio1' -- % of 'Solar production' that is self-consumed e.g. '11.3%' (not accurate for Mix systems) - 'ratio2' -- % of 'Solar production' that is exported e.g. '88.7%' (not accurate for Mix systems) - 'ratio3' -- % of 'Load consumption' that is self consumption e.g. '49.8%' (not accurate for Mix systems) - 'ratio4' -- % of 'Load consumption' that is imported from the grid e.g '50.2%' (not accurate for Mix systems) - 'ratio5' -- % of Self consumption that is from batteries e.g. '92.1%' (not accurate for Mix systems) - 'ratio6' -- % of Self consumption that is directly from Solar e.g. '7.9%' (not accurate for Mix systems) - - NOTE: Does not return any data for a tlx system. Use plant_energy_data() instead. + dict: Dashboard chart and summary data. + A chartData object where each entry is for a specific 5 minute window e.g. 00:05 and 00:10 respectively (below) + NOTE: The keys are interpreted differently, the examples below describe what they are used for in a 'Mix' system + 'chartData': { '00:05': { 'pacToUser' -- Power from battery in kW + 'ppv' -- Solar generation in kW + 'sysOut' -- Load consumption in kW + 'userLoad' -- Export in kW + }, + '00:10': { 'pacToUser': '0', + 'ppv': '0', + 'sysOut': '0.7', + 'userLoad': '0'}, + ...... + } + 'chartDataUnit' -- Unit of measurement e.g. 'kW', + 'eAcCharge' -- Energy exported to the grid in kWh e.g. '20.5kWh' (not accurate for Mix systems) + 'eCharge' -- System production in kWh = Self-consumption + Exported to Grid e.g '23.1kWh' (not accurate for Mix systems - actually showing the total 'load consumption' + 'eChargeToday1' -- Self-consumption of PPV (possibly including excess diverted to batteries) in kWh e.g. '2.6kWh' (not accurate for Mix systems) + 'eChargeToday2' -- Total self-consumption (PPV consumption(eChargeToday2Echarge1) + Battery Consumption(echarge1)) e.g. '10.1kWh' (not accurate for Mix systems) + 'eChargeToday2Echarge1' -- Self-consumption of PPV only e.g. '0.8kWh' (not accurate for Mix systems) + 'echarge1' -- Self-consumption from Battery only e.g. '9.3kWh' + 'echargeToat' -- Not used on Dashboard view, likely to be total battery discharged e.g. '152.1kWh' + 'elocalLoad' -- Total load consumption (etouser + eChargeToday2) e.g. '20.3kWh', (not accurate for Mix systems) + 'etouser'-- Energy imported from grid today (includes both directly used by load and AC battery charging e.g. '10.2kWh' + 'keyNames' -- Keys to be used for the graph data e.g. ['Solar', 'Load Consumption', 'Export To Grid', 'From Battery'] + 'photovoltaic' -- Same as eChargeToday2Echarge1 e.g. '0.8kWh' + 'ratio1' -- % of 'Solar production' that is self-consumed e.g. '11.3%' (not accurate for Mix systems) + 'ratio2' -- % of 'Solar production' that is exported e.g. '88.7%' (not accurate for Mix systems) + 'ratio3' -- % of 'Load consumption' that is self consumption e.g. '49.8%' (not accurate for Mix systems) + 'ratio4' -- % of 'Load consumption' that is imported from the grid e.g '50.2%' (not accurate for Mix systems) + 'ratio5' -- % of Self consumption that is from batteries e.g. '92.1%' (not accurate for Mix systems) + 'ratio6' -- % of Self consumption that is directly from Solar e.g. '7.9%' (not accurate for Mix systems) + + NOTE: Does not return any data for a tlx system. Use plant_energy_data() instead. """ date_str = self.__get_date_string(timespan, date) @@ -774,27 +789,8 @@ def dashboard_data(self, plant_id, timespan=Timespan.hour, date=None): "action": "getEnergyStorageData", "date": date_str, "type": timespan.value, - "plantId": plant_id - }) - - return response.json() - - def plant_settings(self, plant_id): - """ - Returns a dictionary containing the settings for the specified plant. - - Keyword Arguments: - plant_id -- The id of the plant you want the settings of - - Returns: - A python dictionary containing the settings for the specified plant - - """ - response = self.session.get(self.get_url("newPlantAPI.do"), params={ - "op": "getPlant", - "plantId": plant_id + "plantId": plant_id, }) - return response.json() def storage_detail(self, storage_id): @@ -872,20 +868,21 @@ def plant_energy_data(self, plant_id): def is_plant_noah_system(self, plant_id): """ - Returns a dictionary containing if noah devices are configured for the specified plant. + Check whether a plant is a Noah system. - Keyword Arguments: - plant_id -- The id of the plant you want the noah devices of (str) + Args: + plant_id: The id of the plant. Returns: - 'msg' - 'result' -- True or False - 'obj' -- An Object containing if noah devices are configured - 'isPlantNoahSystem' -- Is the specified plant a noah system (True or False) - 'plantId' -- The ID of the plant - 'isPlantHaveNoah' -- Are noah devices configured in the specified plant (True or False) - 'deviceSn' -- Serial number of the configured noah device - 'plantName' -- Friendly name of the plant + dict: API response indicating Noah configuration. + 'msg' + 'result' -- True or False + 'obj' -- An Object containing if noah devices are configured + 'isPlantNoahSystem' -- Is the specified plant a noah system (True or False) + 'plantId' -- The ID of the plant + 'isPlantHaveNoah' -- Are noah devices configured in the specified plant (True or False) + 'deviceSn' -- Serial number of the configured noah device + 'plantName' -- Friendly name of the plant """ response = self.session.post(self.get_url("noahDeviceApi/noah/isPlantNoahSystem"), data={ @@ -895,31 +892,32 @@ def is_plant_noah_system(self, plant_id): def noah_system_status(self, serial_number): """ - Returns a dictionary containing the status for the specified Noah Device. + Get the Noah device status. - Keyword Arguments: - serial_number -- The Serial number of the noah device you want the status of (str) + Args: + serial_number: Noah device serial number. Returns: - 'msg' - 'result' -- True or False - 'obj' -- An Object containing the noah device status - 'chargePower' -- Battery charging rate in watt e.g. '200Watt' - 'workMode' -- Workingmode of the battery (0 = Load First, 1 = Battery First) - 'soc' -- Statement of charge (remaining battery %) - 'associatedInvSn' -- ??? - 'batteryNum' -- Numbers of batterys - 'profitToday' -- Today generated profit through noah device - 'plantId' -- The ID of the plant - 'disChargePower' -- Battery discharging rate in watt e.g. '200Watt' - 'eacTotal' -- Total energy exported to the grid in kWh e.g. '20.5kWh' - 'eacToday' -- Today energy exported to the grid in kWh e.g. '20.5kWh' - 'pac' -- Export to grid rate in watt e.g. '200Watt' - 'ppv' -- Solar generation in watt e.g. '200Watt' - 'alias' -- Friendly name of the noah device - 'profitTotal' -- Total generated profit through noah device - 'moneyUnit' -- Unit of currency e.g. '€' - 'status' -- Is the noah device online (True or False) + dict: Status response object. + 'msg' + 'result' -- True or False + 'obj' -- An Object containing the noah device status + 'chargePower' -- Battery charging rate in watt e.g. '200Watt' + 'workMode' -- Workingmode of the battery (0 = Load First, 1 = Battery First) + 'soc' -- Statement of charge (remaining battery %) + 'associatedInvSn' -- ??? + 'batteryNum' -- Numbers of batterys + 'profitToday' -- Today generated profit through noah device + 'plantId' -- The ID of the plant + 'disChargePower' -- Battery discharging rate in watt e.g. '200Watt' + 'eacTotal' -- Total energy exported to the grid in kWh e.g. '20.5kWh' + 'eacToday' -- Today energy exported to the grid in kWh e.g. '20.5kWh' + 'pac' -- Export to grid rate in watt e.g. '200Watt' + 'ppv' -- Solar generation in watt e.g. '200Watt' + 'alias' -- Friendly name of the noah device + 'profitTotal' -- Total generated profit through noah device + 'moneyUnit' -- Unit of currency e.g. '€' + 'status' -- Is the noah device online (True or False) """ response = self.session.post(self.get_url("noahDeviceApi/noah/getSystemStatus"), data={ @@ -929,43 +927,44 @@ def noah_system_status(self, serial_number): def noah_info(self, serial_number): """ - Returns a dictionary containing the informations for the specified Noah Device. + Get detailed Noah device information. - Keyword Arguments: - serial_number -- The Serial number of the noah device you want the informations of (str) + Args: + serial_number: Noah device serial number. Returns: - 'msg' - 'result' -- True or False - 'obj' -- An Object containing the noah device informations - 'neoList' -- A List containing Objects - 'unitList' -- A Object containing currency units e.g. "Euro": "euro", "DOLLAR": "dollar" - 'noah' -- A Object containing the folowing - 'time_segment' -- A List containing Objects with configured "Operation Mode" - NOTE: The keys are generated numerical, the values are generated with folowing syntax "[workingmode (0 = Load First, 1 = Battery First)]_[starttime]_[endtime]_[output power]" - 'time_segment': { - 'time_segment1': "0_0:0_8:0_150", ([Load First]_[00:00]_[08:00]_[150 watt]) - 'time_segment2': "1_8:0_18:0_0", ([Battery First]_[08:00]_[18:00]_[0 watt]) - .... - } - 'batSns' -- A List containing all battery Serial Numbers - 'associatedInvSn' -- ??? - 'plantId' -- The ID of the plant - 'chargingSocHighLimit' -- Configured "Battery Management" charging upper limit - 'chargingSocLowLimit' -- Configured "Battery Management" charging lower limit - 'defaultPower' -- Configured "System Default Output Power" - 'version' -- The Firmware Version of the noah device - 'deviceSn' -- The Serial number of the noah device - 'formulaMoney' -- Configured "Select Currency" energy cost per kWh e.g. '0.22' - 'alias' -- Friendly name of the noah device - 'model' -- Model Name of the noah device - 'plantName' -- Friendly name of the plant - 'tempType' -- ??? - 'moneyUnitText' -- Configured "Select Currency" (Value from the unitList) e.G. "euro" - 'plantList' -- A List containing Objects containing the folowing - 'plantId' -- The ID of the plant - 'plantImgName' -- Friendly name of the plant Image - 'plantName' -- Friendly name of the plant + dict: Detailed Noah device info. + 'msg' + 'result' -- True or False + 'obj' -- An Object containing the noah device informations + 'neoList' -- A List containing Objects + 'unitList' -- A Object containing currency units e.g. "Euro": "euro", "DOLLAR": "dollar" + 'noah' -- A Object containing the folowing + 'time_segment' -- A List containing Objects with configured "Operation Mode" + NOTE: The keys are generated numerical, the values are generated with folowing syntax "[workingmode (0 = Load First, 1 = Battery First)]_[starttime]_[endtime]_[output power]" + 'time_segment': { + 'time_segment1': "0_0:0_8:0_150", ([Load First]_[00:00]_[08:00]_[150 watt]) + 'time_segment2': "1_8:0_18:0_0", ([Battery First]_[08:00]_[18:00]_[0 watt]) + .... + } + 'batSns' -- A List containing all battery Serial Numbers + 'associatedInvSn' -- ??? + 'plantId' -- The ID of the plant + 'chargingSocHighLimit' -- Configured "Battery Management" charging upper limit + 'chargingSocLowLimit' -- Configured "Battery Management" charging lower limit + 'defaultPower' -- Configured "System Default Output Power" + 'version' -- The Firmware Version of the noah device + 'deviceSn' -- The Serial number of the noah device + 'formulaMoney' -- Configured "Select Currency" energy cost per kWh e.g. '0.22' + 'alias' -- Friendly name of the noah device + 'model' -- Model Name of the noah device + 'plantName' -- Friendly name of the plant + 'tempType' -- ??? + 'moneyUnitText' -- Configured "Select Currency" (Value from the unitList) e.G. "euro" + 'plantList' -- A List containing Objects containing the folowing + 'plantId' -- The ID of the plant + 'plantImgName' -- Friendly name of the plant Image + 'plantName' -- Friendly name of the plant """ response = self.session.post(self.get_url("noahDeviceApi/noah/getNoahInfoBySn"), data={ @@ -975,16 +974,15 @@ def noah_info(self, serial_number): def update_plant_settings(self, plant_id, changed_settings, current_settings=None): """ - Applies settings to the plant e.g. ID, Location, Timezone - See README for all possible settings options. + Update plant settings. - Keyword Arguments: - plant_id -- The id of the plant you wish to update the settings for - changed_settings -- A python dictionary containing the settings to be changed and their value - current_settings -- A python dictionary containing the current settings of the plant (use the response from plant_settings), if None - fetched for you + Args: + plant_id: Plant identifier. + changed_settings: Dict of settings to change. + current_settings: Current settings dict or None. Returns: - A response from the server stating whether the configuration was successful or not + dict: Server response indicating success or failure. """ # If no existing settings have been provided then get them from the growatt server @@ -1026,20 +1024,21 @@ def update_plant_settings(self, plant_id, changed_settings, current_settings=Non def update_inverter_setting(self, serial_number, setting_type, default_parameters, parameters): """ - Applies settings for specified system based on serial number - See README for known working settings. + Apply inverter settings. - Arguments: - serial_number -- Serial number (device_sn) of the inverter (str) - setting_type -- Setting to be configured (str) - default_params -- Default set of parameters for the setting call (dict) - parameters -- Parameters to be sent to the system (dict or list of str) - (array which will be converted to a dictionary) + Args: + serial_number: Serial number of the inverter. + setting_type: Type of setting to configure. + default_parameters: Default parameter mapping for the request. + parameters: Parameters to send (dict or list). Returns: - JSON response from the server whether the configuration was successful + dict: Server response JSON. """ + # Ensure declared but unused args are referenced to satisfy linters + _ = serial_number + _ = setting_type settings_parameters = parameters # If we've been passed an array then convert it into a dictionary @@ -1057,17 +1056,15 @@ def update_inverter_setting(self, serial_number, setting_type, def update_mix_inverter_setting(self, serial_number, setting_type, parameters): """ - Alias for setting inverter parameters on a mix inverter - See README for known working settings. + Set inverter parameters for a Mix inverter. - Arguments: - serial_number -- Serial number (device_sn) of the inverter (str) - setting_type -- Setting to be configured (str) - parameters -- Parameters to be sent to the system (dict or list of str) - (array which will be converted to a dictionary) + Args: + serial_number: Inverter serial number. + setting_type: Setting type. + parameters: Parameters to send. Returns: - JSON response from the server whether the configuration was successful + dict: Server response JSON. """ default_parameters = { @@ -1080,17 +1077,15 @@ def update_mix_inverter_setting(self, serial_number, setting_type, parameters): def update_ac_inverter_setting(self, serial_number, setting_type, parameters): """ - Alias for setting inverter parameters on an AC-coupled inverter - See README for known working settings. + Set inverter parameters for an AC-coupled inverter. - Arguments: - serial_number -- Serial number (device_sn) of the inverter (str) - setting_type -- Setting to be configured (str) - parameters -- Parameters to be sent to the system (dict or list of str) - (array which will be converted to a dictionary) + Args: + serial_number: Inverter serial number. + setting_type: Setting type. + parameters: Parameters to send. Returns: - JSON response from the server whether the configuration was successful + dict: Server response JSON. """ default_parameters = { @@ -1103,18 +1098,18 @@ def update_ac_inverter_setting(self, serial_number, setting_type, parameters): def update_tlx_inverter_time_segment(self, serial_number, segment_id, batt_mode, start_time, end_time, enabled): """ - Updates the time segment settings for a TLX hybrid inverter. + Update a TLX inverter time segment. - Arguments: - serial_number -- Serial number (device_sn) of the inverter (str) - segment_id -- ID of the time segment to be updated (int) - batt_mode -- Battery mode (int) - start_time -- Start time of the segment (datetime.time) - end_time -- End time of the segment (datetime.time) - enabled -- Whether the segment is enabled (bool) + Args: + serial_number: Inverter serial number. + segment_id: ID of the time segment. + batt_mode: Battery mode. + start_time: Segment start time (datetime.time). + end_time: Segment end time (datetime.time). + enabled: Whether the segment is enabled. Returns: - JSON response from the server whether the configuration was successful + dict: Server JSON response. """ params = { @@ -1137,24 +1132,21 @@ def update_tlx_inverter_time_segment(self, serial_number, segment_id, batt_mode, if not result.get("success", False): msg = f"Failed to update TLX inverter time segment: {result.get('msg', 'Unknown error')}" - raise Exception( - msg) + raise GrowattV1ApiError(msg) return result def update_tlx_inverter_setting(self, serial_number, setting_type, parameter): """ - Alias for setting parameters on a tlx hybrid inverter - See README for known working settings. + Set parameters on a TLX inverter. - Arguments: - serial_number -- Serial number (device_sn) of the inverter (str) - setting_type -- Setting to be configured (str) - parameter -- Parameter(s) to be sent to the system (str, dict, list of str) - (array which will be converted to a dictionary) + Args: + serial_number: Inverter serial number. + setting_type: Setting type to configure. + parameter: Parameter(s) to send (dict, list or single value). Returns: - JSON response from the server whether the configuration was successful + dict: Server JSON response. """ default_parameters = { @@ -1175,17 +1167,15 @@ def update_tlx_inverter_setting(self, serial_number, setting_type, parameter): def update_noah_settings(self, serial_number, setting_type, parameters): """ - Applies settings for specified noah device based on serial number - See README for known working settings. + Apply settings for a Noah device. - Arguments: - serial_number -- Serial number (device_sn) of the noah (str) - setting_type -- Setting to be configured (str) - parameters -- Parameters to be sent to the system (dict or list of str) - (array which will be converted to a dictionary) + Args: + serial_number: Noah device serial number. + setting_type: Setting to be configured. + parameters: Parameters to send (dict or list). Returns: - JSON response from the server whether the configuration was successful + dict: Server JSON response. """ default_parameters = { @@ -1209,16 +1199,14 @@ def update_noah_settings(self, serial_number, setting_type, parameters): def update_classic_inverter_setting(self, default_parameters, parameters): """ - Applies settings for specified system based on serial number - See README for known working settings. + Apply classic inverter settings. - Arguments: - default_params -- Default set of parameters for the setting call (dict) - parameters -- Parameters to be sent to the system (dict or list of str) - (array which will be converted to a dictionary) + Args: + default_parameters: Default parameters dict. + parameters: Parameters to send (dict or list). Returns: - JSON response from the server whether the configuration was successful + dict: Server JSON response. """ settings_parameters = parameters diff --git a/growattServer/exceptions.py b/growattServer/exceptions.py index 8538526..776afa2 100644 --- a/growattServer/exceptions.py +++ b/growattServer/exceptions.py @@ -27,7 +27,16 @@ class GrowattParameterError(GrowattError): class GrowattV1ApiError(GrowattError): """Raised when a Growatt V1 API request fails or returns an error.""" - def __init__(self, message, error_code=None, error_msg=None) -> None: + def __init__(self, message: str, error_code: int | None = None, error_msg: str | None = None) -> None: + """ + Initialize the GrowattV1ApiError. + + Args: + message: Human readable error message. + error_code: Optional numeric error code returned by the API. + error_msg: Optional detailed error message from the API. + + """ super().__init__(message) self.error_code = error_code self.error_msg = error_msg diff --git a/growattServer/open_api_v1.py b/growattServer/open_api_v1.py index a3bfda9..19a49bc 100644 --- a/growattServer/open_api_v1.py +++ b/growattServer/open_api_v1.py @@ -1,6 +1,8 @@ +"""OpenApi V1 extensions for Growatt API client.""" + import platform import warnings -from datetime import date, timedelta +from datetime import UTC, date, datetime, timedelta from enum import Enum from . import GrowattApi @@ -25,6 +27,7 @@ class DeviceType(Enum): class OpenApiV1(GrowattApi): """ Extended Growatt API client with V1 API support. + This class extends the base GrowattApi class with methods for MIN and SPH devices using the public V1 API described here: https://www.showdoc.com.cn/262556420217021/0. """ @@ -79,7 +82,7 @@ def _process_response(self, response, operation_name="API operation"): return response.get("data") def _get_url(self, page): - """Simple helper function to get the page URL for v1 API.""" + """Return the page URL for the v1 API.""" return self.api_url + page def plant_list(self): @@ -157,10 +160,13 @@ def plant_energy_overview(self, plant_id): def plant_power_overview(self, plant_id: int, day: str | date | None = None) -> dict: """ Obtain power data of a certain power station. + Get the frequency once every 5 minutes + Args: plant_id (int): Power Station ID day (date): Date - defaults to today + Returns: dict: A dictionary containing the plants power data. .. code-block:: python @@ -180,7 +186,7 @@ def plant_power_overview(self, plant_id: int, day: str | date | None = None) -> """ if day is None: - day = date.today() + day = datetime.now(UTC).date() response = self.session.get( self._get_url("plant/power"), @@ -218,22 +224,25 @@ def plant_energy_history(self, plant_id, start_date=None, end_date=None, time_un requests.exceptions.RequestException: If there is an issue with the HTTP request. """ + max_day_interval = 7 + max_year_interval = 20 + if start_date is None and end_date is None: - start_date = date.today() - end_date = date.today() + start_date = datetime.now(UTC).date() + end_date = datetime.now(UTC).date() elif start_date is None: start_date = end_date elif end_date is None: end_date = start_date # Validate date ranges based on time_unit - if time_unit == "day" and (end_date - start_date).days > 7: + if time_unit == "day" and (end_date - start_date).days > max_day_interval: warnings.warn( "Date interval must not exceed 7 days in 'day' mode.", RuntimeWarning, stacklevel=2) elif time_unit == "month" and (end_date.year - start_date.year > 1): warnings.warn( "Start date must be within same or previous year in 'month' mode.", RuntimeWarning, stacklevel=2) - elif time_unit == "year" and (end_date.year - start_date.year > 20): + elif time_unit == "year" and (end_date.year - start_date.year > max_year_interval): warnings.warn( "Date interval must not exceed 20 years in 'year' mode.", RuntimeWarning, stacklevel=2) @@ -376,8 +385,8 @@ def min_energy_history(self, device_sn, start_date=None, end_date=None, timezone """ if start_date is None and end_date is None: - start_date = date.today() - end_date = date.today() + start_date = datetime.now(timezone.utc).date() + end_date = datetime.now(timezone.utc).date() elif start_date is None: start_date = end_date elif end_date is None: @@ -496,7 +505,8 @@ def min_write_parameter(self, device_sn, parameter_id, parameter_values=None): """ # Initialize all parameters as empty strings - parameters = dict.fromkeys(range(1, 20), "") + max_min_params = 19 + parameters = dict.fromkeys(range(1, max_min_params + 1), "") # Process parameter values based on type if parameter_values is not None: @@ -506,13 +516,13 @@ def min_write_parameter(self, device_sn, parameter_id, parameter_values=None): elif isinstance(parameter_values, list): # List of values go to sequential params for i, value in enumerate(parameter_values, 1): - if i <= 19: # Only use up to 19 parameters + if i <= max_min_params: # Only use up to max_min_params parameters parameters[i] = str(value) elif isinstance(parameter_values, dict): # Dict maps param positions to values - for pos, value in parameter_values.items(): - pos = int(pos) if not isinstance(pos, int) else pos - if 1 <= pos <= 19: # Validate parameter positions + for pos_raw, value in parameter_values.items(): + pos = int(pos_raw) if not isinstance(pos_raw, int) else pos_raw + if 1 <= pos <= max_min_params: # Validate parameter positions parameters[pos] = str(value) # IMPORTANT: Create a data dictionary with ALL parameters explicitly included @@ -521,8 +531,8 @@ def min_write_parameter(self, device_sn, parameter_id, parameter_values=None): "type": parameter_id } - # Add all 19 parameters to the request - for i in range(1, 20): + # Add all MIN parameters to the request + for i in range(1, max_min_params + 1): request_data[f"param{i}"] = str(parameters[i]) # Send the request @@ -554,11 +564,17 @@ def min_write_time_segment(self, device_sn, segment_id, batt_mode, start_time, e requests.exceptions.RequestException: If there is an issue with the HTTP request. """ - if not 1 <= segment_id <= 9: - raise GrowattParameterError("segment_id must be between 1 and 9") + max_min_params = 19 + max_min_segments = 9 + max_batt_mode = 2 + + if not 1 <= segment_id <= max_min_segments: + msg = f"segment_id must be between 1 and {max_min_segments}" + raise GrowattParameterError(msg) - if not 0 <= batt_mode <= 2: - raise GrowattParameterError("batt_mode must be between 0 and 2") + if not 0 <= batt_mode <= max_batt_mode: + msg = f"batt_mode must be between 0 and {max_batt_mode}" + raise GrowattParameterError(msg) # Initialize ALL 19 parameters as empty strings, not just the ones we need all_params = { @@ -575,7 +591,7 @@ def min_write_time_segment(self, device_sn, segment_id, batt_mode, start_time, e all_params["param6"] = "1" if enabled else "0" # Add empty strings for all unused parameters - for i in range(7, 20): + for i in range(7, max_min_params + 1): all_params[f"param{i}"] = "" # Send the request @@ -774,8 +790,8 @@ def sph_energy_history(self, device_sn, start_date=None, end_date=None, timezone """ if start_date is None and end_date is None: - start_date = date.today() - end_date = date.today() + start_date = datetime.now(timezone.utc).date() + end_date = datetime.now(timezone.utc).date() elif start_date is None: start_date = end_date elif end_date is None: @@ -868,8 +884,8 @@ def sph_write_parameter(self, device_sn, parameter_id, parameter_values=None): """ # Initialize all parameters as empty strings (API uses param1-param18) - maxParams = 18 - parameters = dict.fromkeys(range(1, maxParams + 1), "") + max_sph_params = 18 + parameters = dict.fromkeys(range(1, max_sph_params + 1), "") # Process parameter values based on type if parameter_values is not None: @@ -879,13 +895,13 @@ def sph_write_parameter(self, device_sn, parameter_id, parameter_values=None): elif isinstance(parameter_values, list): # List of values go to sequential params for i, value in enumerate(parameter_values, 1): - if i <= maxParams: # Only use up to 18 parameters + if i <= max_sph_params: # Only use up to max_sph_params parameters parameters[i] = str(value) elif isinstance(parameter_values, dict): # Dict maps param positions to values - for pos, value in parameter_values.items(): - pos = int(pos) if not isinstance(pos, int) else pos - if 1 <= pos <= 18: # Validate parameter positions + for pos_raw, value in parameter_values.items(): + pos = int(pos_raw) if not isinstance(pos_raw, int) else pos_raw + if 1 <= pos <= max_sph_params: # Validate parameter positions parameters[pos] = str(value) # Create a data dictionary with ALL parameters explicitly included @@ -894,8 +910,8 @@ def sph_write_parameter(self, device_sn, parameter_id, parameter_values=None): "type": parameter_id } - # Add all 18 parameters to the request - for i in range(1, 19): + # Add all SPH parameters to the request + for i in range(1, max_sph_params + 1): request_data[f"param{i}"] = str(parameters[i]) # API: https://www.showdoc.com.cn/262556420217021/6129761750718760 diff --git a/setup.py b/setup.py index d6cf32e..ca665d3 100755 --- a/setup.py +++ b/setup.py @@ -1,8 +1,11 @@ +"""Setup metadata for the growattServer package.""" + +from pathlib import Path + import setuptools -with open("README.md") as fh: - long_description = fh.read() +long_description = Path("README.md").read_text(encoding="utf8") setuptools.setup( name="growattServer", From 2bd42a42eb8ceac2c8d5d85289e30ee7dd78c2a8 Mon Sep 17 00:00:00 2001 From: indy koning Date: Sun, 1 Feb 2026 16:58:15 +0100 Subject: [PATCH 03/13] Re-added removed functions --- .ruff.toml | 3 +++ growattServer/base_api.py | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/.ruff.toml b/.ruff.toml index 37d5f57..0163d6c 100644 --- a/.ruff.toml +++ b/.ruff.toml @@ -9,6 +9,9 @@ select = [ ignore = [ "ANN401", # Dynamically typed expressions (typing.Any) are disallowed + "ANN001", + "ANN201", + "ANN202", "D203", # no-blank-line-before-class (incompatible with formatter) "D212", # multi-line-summary-first-line (incompatible with formatter) "COM812", # incompatible with formatter diff --git a/growattServer/base_api.py b/growattServer/base_api.py index 50c1be7..85f7005 100644 --- a/growattServer/base_api.py +++ b/growattServer/base_api.py @@ -737,6 +737,25 @@ def mix_detail(self, mix_id, plant_id, timespan=Timespan.hour, date=None): return response.json().get("obj", {}) + def get_mix_inverter_settings(self, serial_number): + """ + Get the inverter settings related to battery modes. + + Args: + serial_number: -- The serial number (device_sn) of the inverter. + + Returns: + dict: A dictionary of settings. + + """ + default_params = { + "op": "getMixSetParams", + "serialNum": serial_number, + "kind": 0 + } + response = self.session.get(self.get_url("newMixApi.do"), params=default_params) + return response.json() + def dashboard_data(self, plant_id, timespan=Timespan.hour, date=None): """ Get dashboard data for a plant over a timespan. @@ -793,6 +812,24 @@ def dashboard_data(self, plant_id, timespan=Timespan.hour, date=None): }) return response.json() + def plant_settings(self, plant_id): + """ + Get a dictionary containing the settings for the specified plant. + + Args: + plant_id: The id of the plant you want the settings of + + Returns: + dict: A python dictionary containing the settings for the specified plant. + + """ + response = self.session.get(self.get_url("newPlantAPI.do"), params={ + "op": "getPlant", + "plantId": plant_id + }) + + return response.json() + def storage_detail(self, storage_id): """Get "All parameters" from battery storage.""" response = self.session.get(self.get_url("newStorageAPI.do"), params={ From bceea9b677c55ec7d467baf4d978b79340d22340 Mon Sep 17 00:00:00 2001 From: indy koning Date: Mon, 2 Feb 2026 20:50:48 +0100 Subject: [PATCH 04/13] wip --- .../__init__.py} | 297 ++----------- growattServer/open_api_v1/devices/__init__.py | 2 + .../open_api_v1/devices/abstract_device.py | 49 +++ growattServer/open_api_v1/devices/min.py | 407 ++++++++++++++++++ 4 files changed, 497 insertions(+), 258 deletions(-) rename growattServer/{open_api_v1.py => open_api_v1/__init__.py} (78%) create mode 100644 growattServer/open_api_v1/devices/__init__.py create mode 100644 growattServer/open_api_v1/devices/abstract_device.py create mode 100644 growattServer/open_api_v1/devices/min.py diff --git a/growattServer/open_api_v1.py b/growattServer/open_api_v1/__init__.py similarity index 78% rename from growattServer/open_api_v1.py rename to growattServer/open_api_v1/__init__.py index 19a49bc..4ca9462 100644 --- a/growattServer/open_api_v1.py +++ b/growattServer/open_api_v1/__init__.py @@ -5,8 +5,10 @@ from datetime import UTC, date, datetime, timedelta from enum import Enum +from growattServer.exceptions import GrowattParameterError, GrowattV1ApiError + from . import GrowattApi -from .exceptions import GrowattParameterError, GrowattV1ApiError +from .devices import Min class DeviceType(Enum): @@ -57,7 +59,7 @@ def __init__(self, token) -> None: # Set up authentication for V1 API using the provided token self.session.headers.update({"token": token}) - def _process_response(self, response, operation_name="API operation"): + def process_response(self, response, operation_name="API operation"): """ Process API response and handle errors. @@ -81,7 +83,7 @@ def _process_response(self, response, operation_name="API operation"): ) return response.get("data") - def _get_url(self, page): + def get_url(self, page): """Return the page URL for the v1 API.""" return self.api_url + page @@ -107,11 +109,11 @@ def plant_list(self): # Make the request response = self.session.get( - url=self._get_url("plant/list"), + url=self.get_url("plant/list"), data=request_data ) - return self._process_response(response.json(), "getting plant list") + return self.process_response(response.json(), "getting plant list") def plant_details(self, plant_id): """ @@ -129,11 +131,11 @@ def plant_details(self, plant_id): """ response = self.session.get( - self._get_url("plant/details"), + self.get_url("plant/details"), params={"plant_id": plant_id} ) - return self._process_response(response.json(), "getting plant details") + return self.process_response(response.json(), "getting plant details") def plant_energy_overview(self, plant_id): """ @@ -151,11 +153,11 @@ def plant_energy_overview(self, plant_id): """ response = self.session.get( - self._get_url("plant/data"), + self.get_url("plant/data"), params={"plant_id": plant_id} ) - return self._process_response(response.json(), "getting plant energy overview") + return self.process_response(response.json(), "getting plant energy overview") def plant_power_overview(self, plant_id: int, day: str | date | None = None) -> dict: """ @@ -189,14 +191,14 @@ def plant_power_overview(self, plant_id: int, day: str | date | None = None) -> day = datetime.now(UTC).date() response = self.session.get( - self._get_url("plant/power"), + self.get_url("plant/power"), params={ "plant_id": plant_id, "date": day, } ) - return self._process_response(response.json(), "getting plant power overview") + return self.process_response(response.json(), "getting plant power overview") def plant_energy_history(self, plant_id, start_date=None, end_date=None, time_unit="day", page=None, perpage=None): """ @@ -247,7 +249,7 @@ def plant_energy_history(self, plant_id, start_date=None, end_date=None, time_un "Date interval must not exceed 20 years in 'year' mode.", RuntimeWarning, stacklevel=2) response = self.session.get( - self._get_url("plant/energy"), + self.get_url("plant/energy"), params={ "plant_id": plant_id, "start_date": start_date.strftime("%Y-%m-%d"), @@ -258,7 +260,7 @@ def plant_energy_history(self, plant_id, start_date=None, end_date=None, time_un } ) - return self._process_response(response.json(), "getting plant energy history") + return self.process_response(response.json(), "getting plant energy history") def device_list(self, plant_id): """ @@ -306,14 +308,14 @@ def device_list(self, plant_id): """ response = self.session.get( - url=self._get_url("device/list"), + url=self.get_url("device/list"), params={ "plant_id": plant_id, "page": "", "perpage": "", }, ) - return self._process_response(response.json(), "getting device list") + return self.process_response(response.json(), "getting device list") def min_detail(self, device_sn): """ @@ -330,14 +332,7 @@ def min_detail(self, device_sn): requests.exceptions.RequestException: If there is an issue with the HTTP request. """ - response = self.session.get( - self._get_url("device/tlx/tlx_data_info"), - params={ - "device_sn": device_sn - } - ) - - return self._process_response(response.json(), "getting MIN inverter details") + return Min(self, device_sn).detail() def min_energy(self, device_sn): """ @@ -354,14 +349,7 @@ def min_energy(self, device_sn): requests.exceptions.RequestException: If there is an issue with the HTTP request. """ - response = self.session.post( - url=self._get_url("device/tlx/tlx_last_data"), - data={ - "tlx_sn": device_sn, - }, - ) - - return self._process_response(response.json(), "getting MIN inverter energy data") + return Min(self, device_sn).energy() def min_energy_history(self, device_sn, start_date=None, end_date=None, timezone=None, page=None, limit=None): """ @@ -384,31 +372,7 @@ def min_energy_history(self, device_sn, start_date=None, end_date=None, timezone requests.exceptions.RequestException: If there is an issue with the HTTP request. """ - if start_date is None and end_date is None: - start_date = datetime.now(timezone.utc).date() - end_date = datetime.now(timezone.utc).date() - elif start_date is None: - start_date = end_date - elif end_date is None: - end_date = start_date - - # check interval validity - if end_date - start_date > timedelta(days=7): - raise GrowattParameterError("date interval must not exceed 7 days") - - response = self.session.post( - url=self._get_url("device/tlx/tlx_data"), - data={ - "tlx_sn": device_sn, - "start_date": start_date.strftime("%Y-%m-%d"), - "end_date": end_date.strftime("%Y-%m-%d"), - "timezone_id": timezone, - "page": page, - "perpage": limit, - } - ) - - return self._process_response(response.json(), "getting MIN inverter energy history") + return Min(self, device_sn).energy_history(start_date, end_date, timezone, page, limit) def min_settings(self, device_sn): """ @@ -425,14 +389,7 @@ def min_settings(self, device_sn): requests.exceptions.RequestException: If there is an issue with the HTTP request. """ - response = self.session.get( - self._get_url("device/tlx/tlx_set_info"), - params={ - "device_sn": device_sn - } - ) - - return self._process_response(response.json(), "getting MIN inverter settings") + return Min(self, device_sn).settings(device_sn) def min_read_parameter(self, device_sn, parameter_id, start_address=None, end_address=None): """ @@ -453,36 +410,7 @@ def min_read_parameter(self, device_sn, parameter_id, start_address=None, end_ad requests.exceptions.RequestException: If there is an issue with the HTTP request. """ - if parameter_id is None and start_address is None: - raise GrowattParameterError( - "specify either parameter_id or start_address/end_address") - if parameter_id is not None and start_address is not None: - raise GrowattParameterError( - "specify either parameter_id or start_address/end_address - not both." - ) - if parameter_id is not None: - # named parameter - start_address = 0 - end_address = 0 - else: - # using register-number mode - parameter_id = "set_any_reg" - if start_address is None: - start_address = end_address - if end_address is None: - end_address = start_address - - response = self.session.post( - self._get_url("readMinParam"), - data={ - "device_sn": device_sn, - "paramId": parameter_id, - "startAddr": start_address, - "endAddr": end_address, - } - ) - - return self._process_response(response.json(), f"reading parameter {parameter_id}") + return Min(self, device_sn).read_parameter(parameter_id, start_address, end_address) def min_write_parameter(self, device_sn, parameter_id, parameter_values=None): """ @@ -504,44 +432,7 @@ def min_write_parameter(self, device_sn, parameter_id, parameter_values=None): requests.exceptions.RequestException: If there is an issue with the HTTP request. """ - # Initialize all parameters as empty strings - max_min_params = 19 - parameters = dict.fromkeys(range(1, max_min_params + 1), "") - - # Process parameter values based on type - if parameter_values is not None: - if isinstance(parameter_values, (str, int, float, bool)): - # Single value goes to param1 - parameters[1] = str(parameter_values) - elif isinstance(parameter_values, list): - # List of values go to sequential params - for i, value in enumerate(parameter_values, 1): - if i <= max_min_params: # Only use up to max_min_params parameters - parameters[i] = str(value) - elif isinstance(parameter_values, dict): - # Dict maps param positions to values - for pos_raw, value in parameter_values.items(): - pos = int(pos_raw) if not isinstance(pos_raw, int) else pos_raw - if 1 <= pos <= max_min_params: # Validate parameter positions - parameters[pos] = str(value) - - # IMPORTANT: Create a data dictionary with ALL parameters explicitly included - request_data = { - "tlx_sn": device_sn, - "type": parameter_id - } - - # Add all MIN parameters to the request - for i in range(1, max_min_params + 1): - request_data[f"param{i}"] = str(parameters[i]) - - # Send the request - response = self.session.post( - self._get_url("tlxSet"), - data=request_data - ) - - return self._process_response(response.json(), f"writing parameter {parameter_id}") + return Min(self, device_sn).write_parameter(parameter_id, parameter_values) def min_write_time_segment(self, device_sn, segment_id, batt_mode, start_time, end_time, enabled=True): """ @@ -564,43 +455,7 @@ def min_write_time_segment(self, device_sn, segment_id, batt_mode, start_time, e requests.exceptions.RequestException: If there is an issue with the HTTP request. """ - max_min_params = 19 - max_min_segments = 9 - max_batt_mode = 2 - - if not 1 <= segment_id <= max_min_segments: - msg = f"segment_id must be between 1 and {max_min_segments}" - raise GrowattParameterError(msg) - - if not 0 <= batt_mode <= max_batt_mode: - msg = f"batt_mode must be between 0 and {max_batt_mode}" - raise GrowattParameterError(msg) - - # Initialize ALL 19 parameters as empty strings, not just the ones we need - all_params = { - "tlx_sn": device_sn, - "type": f"time_segment{segment_id}" - } - - # Add param1 through param19, setting the values we need - all_params["param1"] = str(batt_mode) - all_params["param2"] = str(start_time.hour) - all_params["param3"] = str(start_time.minute) - all_params["param4"] = str(end_time.hour) - all_params["param5"] = str(end_time.minute) - all_params["param6"] = "1" if enabled else "0" - - # Add empty strings for all unused parameters - for i in range(7, max_min_params + 1): - all_params[f"param{i}"] = "" - - # Send the request - response = self.session.post( - self._get_url("tlxSet"), - data=all_params - ) - - return self._process_response(response.json(), f"writing time segment {segment_id}") + return Min(self, device_sn).write_time_segment(segment_id, batt_mode, start_time, end_time, enabled) def min_read_time_segments(self, device_sn, settings_data=None): """ @@ -640,81 +495,7 @@ def min_read_time_segments(self, device_sn, settings_data=None): requests.exceptions.RequestException: If there is an issue with the HTTP request. """ - # Process the settings data - if settings_data is None: - # Fetch settings if not provided - settings_data = self.min_settings(device_sn=device_sn) - - # Define mode names - mode_names = { - 0: "Load First", - 1: "Battery First", - 2: "Grid First" - } - - segments = [] - - # Process each time segment - for i in range(1, 10): # Segments 1-9 - # Get raw time values - start_time_raw = settings_data.get(f"forcedTimeStart{i}", "0:0") - end_time_raw = settings_data.get(f"forcedTimeStop{i}", "0:0") - - # Handle 'null' string values - if start_time_raw == "null" or not start_time_raw: - start_time_raw = "0:0" - if end_time_raw == "null" or not end_time_raw: - end_time_raw = "0:0" - - # Format times with leading zeros (HH:MM) - try: - start_parts = start_time_raw.split(":") - start_hour = int(start_parts[0]) - start_min = int(start_parts[1]) - start_time = f"{start_hour:02d}:{start_min:02d}" - except (ValueError, IndexError): - start_time = "00:00" - - try: - end_parts = end_time_raw.split(":") - end_hour = int(end_parts[0]) - end_min = int(end_parts[1]) - end_time = f"{end_hour:02d}:{end_min:02d}" - except (ValueError, IndexError): - end_time = "00:00" - - # Get the mode value safely - mode_raw = settings_data.get(f"time{i}Mode") - if mode_raw == "null" or mode_raw is None: - batt_mode = None - else: - try: - batt_mode = int(mode_raw) - except (ValueError, TypeError): - batt_mode = None - - # Get the enabled status safely - enabled_raw = settings_data.get(f"forcedStopSwitch{i}", 0) - if enabled_raw == "null" or enabled_raw is None: - enabled = False - else: - try: - enabled = int(enabled_raw) == 1 - except (ValueError, TypeError): - enabled = False - - segment = { - "segment_id": i, - "batt_mode": batt_mode, - "mode_name": mode_names.get(batt_mode, "Unknown"), - "start_time": start_time, - "end_time": end_time, - "enabled": enabled - } - - segments.append(segment) - - return segments + return Min(self, device_sn).read_time_segments(settings_data) # SPH Device Methods (Device Type 5) @@ -735,13 +516,13 @@ def sph_detail(self, device_sn): """ # API: https://www.showdoc.com.cn/262556420217021/6129763571291058 response = self.session.get( - self._get_url("device/mix/mix_data_info"), + self.get_url("device/mix/mix_data_info"), params={ "device_sn": device_sn } ) - return self._process_response(response.json(), "getting SPH inverter details") + return self.process_response(response.json(), "getting SPH inverter details") def sph_energy(self, device_sn): """ @@ -760,13 +541,13 @@ def sph_energy(self, device_sn): """ # API: https://www.showdoc.com.cn/262556420217021/6129764475556048 response = self.session.post( - url=self._get_url("device/mix/mix_last_data"), + url=self.get_url("device/mix/mix_last_data"), data={ "mix_sn": device_sn, }, ) - return self._process_response(response.json(), "getting SPH inverter energy data") + return self.process_response(response.json(), "getting SPH inverter energy data") def sph_energy_history(self, device_sn, start_date=None, end_date=None, timezone=None, page=None, limit=None): """ @@ -803,7 +584,7 @@ def sph_energy_history(self, device_sn, start_date=None, end_date=None, timezone # API: https://www.showdoc.com.cn/262556420217021/6129765461123058 response = self.session.post( - url=self._get_url("device/mix/mix_data"), + url=self.get_url("device/mix/mix_data"), data={ "mix_sn": device_sn, "start_date": start_date.strftime("%Y-%m-%d"), @@ -814,7 +595,7 @@ def sph_energy_history(self, device_sn, start_date=None, end_date=None, timezone } ) - return self._process_response(response.json(), "getting SPH inverter energy history") + return self.process_response(response.json(), "getting SPH inverter energy history") def sph_read_parameter(self, device_sn, parameter_id=None, start_address=None, end_address=None): """ @@ -852,7 +633,7 @@ def sph_read_parameter(self, device_sn, parameter_id=None, start_address=None, e # API: https://www.showdoc.com.cn/262556420217021/6129766954561259 response = self.session.post( - self._get_url("readMixParam"), + self.get_url("readMixParam"), data={ "device_sn": device_sn, "paramId": parameter_id, @@ -861,7 +642,7 @@ def sph_read_parameter(self, device_sn, parameter_id=None, start_address=None, e } ) - return self._process_response(response.json(), f"reading parameter {parameter_id}") + return self.process_response(response.json(), f"reading parameter {parameter_id}") def sph_write_parameter(self, device_sn, parameter_id, parameter_values=None): """ @@ -916,11 +697,11 @@ def sph_write_parameter(self, device_sn, parameter_id, parameter_values=None): # API: https://www.showdoc.com.cn/262556420217021/6129761750718760 response = self.session.post( - self._get_url("mixSet"), + self.get_url("mixSet"), data=request_data ) - return self._process_response(response.json(), f"writing parameter {parameter_id}") + return self.process_response(response.json(), f"writing parameter {parameter_id}") def sph_write_ac_charge_times(self, device_sn, charge_power, charge_stop_soc, mains_enabled, periods): """ @@ -989,11 +770,11 @@ def sph_write_ac_charge_times(self, device_sn, charge_power, charge_stop_soc, ma # API: https://www.showdoc.com.cn/262556420217021/6129761750718760 response = self.session.post( - self._get_url("mixSet"), + self.get_url("mixSet"), data=request_data ) - return self._process_response(response.json(), "writing AC charge time periods") + return self.process_response(response.json(), "writing AC charge time periods") def sph_write_ac_discharge_times(self, device_sn, discharge_power, discharge_stop_soc, periods): """ @@ -1059,11 +840,11 @@ def sph_write_ac_discharge_times(self, device_sn, discharge_power, discharge_sto # API: https://www.showdoc.com.cn/262556420217021/6129761750718760 response = self.session.post( - self._get_url("mixSet"), + self.get_url("mixSet"), data=request_data ) - return self._process_response(response.json(), "writing AC discharge time periods") + return self.process_response(response.json(), "writing AC discharge time periods") def _parse_time_periods(self, settings_data, time_type): """ diff --git a/growattServer/open_api_v1/devices/__init__.py b/growattServer/open_api_v1/devices/__init__.py new file mode 100644 index 0000000..cae3538 --- /dev/null +++ b/growattServer/open_api_v1/devices/__init__.py @@ -0,0 +1,2 @@ +# noqa: D104 +from .min import Min # noqa: F401 diff --git a/growattServer/open_api_v1/devices/abstract_device.py b/growattServer/open_api_v1/devices/abstract_device.py new file mode 100644 index 0000000..f848f2e --- /dev/null +++ b/growattServer/open_api_v1/devices/abstract_device.py @@ -0,0 +1,49 @@ +"""Abstract device file for centralising shared device logic.""" +from typing import TypedDict + +from growattServer.exceptions import GrowattParameterError +from growattServer.open_api_v1 import OpenApiV1 + + +class ReadParamResponse(TypedDict): + """Response type for ReadParam endpoints.""" + + data: str + error_code: str + error_msg: str + +class AbstactDevice: + """Abstract device type. Must not be used directly.""" + + def __init__(self, api: OpenApiV1, device_sn: str) -> None: + """ + Initialize the device with the bare minimum being the device_sn. + + Args: + api (OpenApiV1): API used for all API calls. + device_sn (str): Device serial number used for all API calls. + + """ + self.api = api + self.device_sn = device_sn + + def validate_read_parameter_input(self, parameter_id: str | None, start_address: int | None, end_address: int | None): # noqa: ARG002 + """ + Validate read parameter input and throws an error if it is invalid. + + Args: + parameter_id (str): Parameter ID to read. Don't use start_address and end_address if this is set. + start_address (int, optional): Register start address (for set_any_reg). Don't use parameter_id if this is set. + end_address (int, optional): Register end address (for set_any_reg). Don't use parameter_id if this is set. + + Raises: + GrowattParameterError: If parameters are invalid. + + """ + if parameter_id is None and start_address is None: + raise GrowattParameterError( + "specify either parameter_id or start_address/end_address") + if parameter_id is not None and start_address is not None: + raise GrowattParameterError( + "specify either parameter_id or start_address/end_address - not both." + ) diff --git a/growattServer/open_api_v1/devices/min.py b/growattServer/open_api_v1/devices/min.py new file mode 100644 index 0000000..572995b --- /dev/null +++ b/growattServer/open_api_v1/devices/min.py @@ -0,0 +1,407 @@ +"""Min device file.""" +from datetime import datetime, timedelta + +from growattServer.exceptions import GrowattParameterError +from typing import Any +from .abstract_device import AbstactDevice + + +class Min(AbstactDevice): + """Min device type.""" + + def detail(self) -> dict: + """ + Get detailed data for a MIN inverter. + https://www.showdoc.com.cn/262556420217021/6129816412127075. + + Args: + device_sn (str): The serial number of the MIN inverter. + + Returns: + dict: A dictionary containing the MIN inverter details. + + Raises: + GrowattV1ApiError: If the API returns an error response. + requests.exceptions.RequestException: If there is an issue with the HTTP request. + + """ + response = self.api.session.get( + self.api.get_url("device/tlx/tlx_data_info"), + params={ + "device_sn": self.device_sn + } + ) + + return self.api.process_response(response.json(), "getting MIN inverter details") + + def energy(self) -> dict: + """ + Get energy data for a MIN inverter. + + Args: + device_sn (str): The serial number of the MIN inverter. + + Returns: + dict: A dictionary containing the MIN inverter energy data. + + Raises: + GrowattV1ApiError: If the API returns an error response. + requests.exceptions.RequestException: If there is an issue with the HTTP request. + + """ + response = self.api.session.post( + url=self.api.get_url("device/tlx/tlx_last_data"), + data={ + "tlx_sn": self.device_sn, + }, + ) + + return self.api.process_response(response.json(), "getting MIN inverter energy data") + + def energy_history(self, start_date=None, end_date=None, timezone=None, page=None, limit=None) -> dict: + """ + Get MIN inverter data history. + + Args: + device_sn (str): The ID of the MIN inverter. + start_date (date, optional): Start date. Defaults to today. + end_date (date, optional): End date. Defaults to today. + timezone (str, optional): Timezone ID. + page (int, optional): Page number. + limit (int, optional): Results per page. + + Returns: + dict: A dictionary containing the MIN inverter history data. + + Raises: + GrowattParameterError: If date interval is invalid (exceeds 7 days). + GrowattV1ApiError: If the API returns an error response. + requests.exceptions.RequestException: If there is an issue with the HTTP request. + + """ + if start_date is None and end_date is None: + start_date = datetime.now(timezone.utc).date() + end_date = datetime.now(timezone.utc).date() + elif start_date is None: + start_date = end_date + elif end_date is None: + end_date = start_date + + # check interval validity + if end_date - start_date > timedelta(days=7): + raise GrowattParameterError("date interval must not exceed 7 days") + + response = self.api.session.post( + url=self.api.get_url("device/tlx/tlx_data"), + data={ + "tlx_sn": self.device_sn, + "start_date": start_date.strftime("%Y-%m-%d"), + "end_date": end_date.strftime("%Y-%m-%d"), + "timezone_id": timezone, + "page": page, + "perpage": limit, + } + ) + + return self.api.process_response(response.json(), "getting MIN inverter energy history") + + def settings(self) -> dict: + """ + Get settings for a MIN inverter. + + Args: + device_sn (str): The serial number of the MIN inverter. + + Returns: + dict: A dictionary containing the MIN inverter settings. + + Raises: + GrowattV1ApiError: If the API returns an error response. + requests.exceptions.RequestException: If there is an issue with the HTTP request. + + """ + response = self.api.session.get( + self.api.get_url("device/tlx/tlx_set_info"), + params={ + "device_sn": self.device_sn + } + ) + + return self.api.process_response(response.json(), "getting MIN inverter settings") + + def read_parameter(self, parameter_id, start_address=None, end_address=None) -> dict: + """ + Read setting from MIN inverter. + + Args: + device_sn (str): The ID of the TLX inverter. + parameter_id (str): Parameter ID to read. Don't use start_address and end_address if this is set. + start_address (int, optional): Register start address (for set_any_reg). Don't use parameter_id if this is set. + end_address (int, optional): Register end address (for set_any_reg). Don't use parameter_id if this is set. + + Returns: + dict: A dictionary containing the setting value. + + Raises: + GrowattParameterError: If parameters are invalid. + GrowattV1ApiError: If the API returns an error response. + requests.exceptions.RequestException: If there is an issue with the HTTP request. + + """ + self.validate_read_parameter_input(parameter_id, start_address, end_address) + + if parameter_id is not None: + # named parameter + start_address = 0 + end_address = 0 + else: + # using register-number mode + parameter_id = "set_any_reg" + if start_address is None: + start_address = end_address + if end_address is None: + end_address = start_address + + response = self.api.session.post( + self.api.get_url("readMinParam"), + data={ + "device_sn": self.device_sn, + "paramId": parameter_id, + "startAddr": start_address, + "endAddr": end_address, + } + ) + + return self.api.process_response(response.json(), f"reading parameter {parameter_id}") + + def write_parameter(self, parameter_id, parameter_values=None) -> dict: + """ + Set parameters on a MIN inverter. + + Args: + device_sn (str): Serial number of the inverter + parameter_id (str): Setting type to be configured + parameter_values: Parameter values to be sent to the system. + Can be a single string (for param1 only), + a list of strings (for sequential params), + or a dictionary mapping param positions to values + + Returns: + dict: JSON response from the server + + Raises: + GrowattV1ApiError: If the API returns an error response. + requests.exceptions.RequestException: If there is an issue with the HTTP request. + + """ + # Initialize all parameters as empty strings + max_min_params = 19 + parameters = dict.fromkeys(range(1, max_min_params + 1), "") + + # Process parameter values based on type + if parameter_values is not None: + if isinstance(parameter_values, (str, int, float, bool)): + # Single value goes to param1 + parameters[1] = str(parameter_values) + elif isinstance(parameter_values, list): + # List of values go to sequential params + for i, value in enumerate(parameter_values, 1): + if i <= max_min_params: # Only use up to max_min_params parameters + parameters[i] = str(value) + elif isinstance(parameter_values, dict): + # Dict maps param positions to values + for pos_raw, value in parameter_values.items(): + pos = int(pos_raw) if not isinstance(pos_raw, int) else pos_raw + if 1 <= pos <= max_min_params: # Validate parameter positions + parameters[pos] = str(value) + + # IMPORTANT: Create a data dictionary with ALL parameters explicitly included + request_data = { + "tlx_sn": self.device_sn, + "type": parameter_id + } + + # Add all MIN parameters to the request + for i in range(1, max_min_params + 1): + request_data[f"param{i}"] = str(parameters[i]) + + # Send the request + response = self.api.session.post( + self.api.get_url("tlxSet"), + data=request_data + ) + + return self.api.process_response(response.json(), f"writing parameter {parameter_id}") + + def write_time_segment(self, segment_id, batt_mode, start_time, end_time, enabled=True) -> dict: + """ + Set a time segment for a MIN inverter. + + Args: + device_sn (str): The serial number of the inverter. + segment_id (int): Time segment ID (1-9). + batt_mode (int): 0=load priority, 1=battery priority, 2=grid priority. + start_time (datetime.time): Start time for the segment. + end_time (datetime.time): End time for the segment. + enabled (bool): Whether this segment is enabled. + + Returns: + dict: The server response. + + Raises: + GrowattParameterError: If parameters are invalid. + GrowattV1ApiError: If the API returns an error response. + requests.exceptions.RequestException: If there is an issue with the HTTP request. + + """ + max_min_params = 19 + max_min_segments = 9 + max_batt_mode = 2 + + if not 1 <= segment_id <= max_min_segments: + msg = f"segment_id must be between 1 and {max_min_segments}" + raise GrowattParameterError(msg) + + if not 0 <= batt_mode <= max_batt_mode: + msg = f"batt_mode must be between 0 and {max_batt_mode}" + raise GrowattParameterError(msg) + + # Initialize ALL 19 parameters as empty strings, not just the ones we need + all_params = { + "tlx_sn": self.device_sn, + "type": f"time_segment{segment_id}" + } + + # Add param1 through param19, setting the values we need + all_params["param1"] = str(batt_mode) + all_params["param2"] = str(start_time.hour) + all_params["param3"] = str(start_time.minute) + all_params["param4"] = str(end_time.hour) + all_params["param5"] = str(end_time.minute) + all_params["param6"] = "1" if enabled else "0" + + # Add empty strings for all unused parameters + for i in range(7, max_min_params + 1): + all_params[f"param{i}"] = "" + + # Send the request + response = self.api.session.post( + self.api.get_url("tlxSet"), + data=all_params + ) + + return self.api.process_response(response.json(), f"writing time segment {segment_id}") + + def read_time_segments(self, settings_data=None) -> list[dict[str, Any]]: + """ + Read Time-of-Use (TOU) settings from a Growatt MIN/TLX inverter. + + Retrieves all 9 time segments from a Growatt MIN/TLX inverter and + parses them into a structured format. + + Note that this function uses min_settings() internally to get the settings data, + To avoid endpoint rate limit, you can pass the settings_data parameter + with the data returned from min_settings(). + + Args: + device_sn (str): The device serial number of the inverter + settings_data (dict, optional): Settings data from min_settings call to avoid repeated API calls. + Can be either the complete response or just the data portion. + + Returns: + list: A list of dictionaries, each containing details for one time segment: + - segment_id (int): The segment number (1-9) + - batt_mode (int): 0=Load First, 1=Battery First, 2=Grid First + - mode_name (str): String representation of the mode + - start_time (str): Start time in format "HH:MM" + - end_time (str): End time in format "HH:MM" + - enabled (bool): Whether the segment is enabled + + Example: + # Option 1: Make a single call + tou_settings = api.min_read_time_segments("DEVICE_SERIAL_NUMBER") + + # Option 2: Reuse existing settings data + settings_response = api.min_settings("DEVICE_SERIAL_NUMBER") + tou_settings = api.min_read_time_segments("DEVICE_SERIAL_NUMBER", settings_response) + + Raises: + GrowattV1ApiError: If the API request fails + requests.exceptions.RequestException: If there is an issue with the HTTP request. + + """ + # Process the settings data + if settings_data is None: + # Fetch settings if not provided + settings_data = self.settings() + + # Define mode names + mode_names = { + 0: "Load First", + 1: "Battery First", + 2: "Grid First" + } + + segments = [] + + # Process each time segment + for i in range(1, 10): # Segments 1-9 + # Get raw time values + start_time_raw = settings_data.get(f"forcedTimeStart{i}", "0:0") + end_time_raw = settings_data.get(f"forcedTimeStop{i}", "0:0") + + # Handle 'null' string values + if start_time_raw == "null" or not start_time_raw: + start_time_raw = "0:0" + if end_time_raw == "null" or not end_time_raw: + end_time_raw = "0:0" + + # Format times with leading zeros (HH:MM) + try: + start_parts = start_time_raw.split(":") + start_hour = int(start_parts[0]) + start_min = int(start_parts[1]) + start_time = f"{start_hour:02d}:{start_min:02d}" + except (ValueError, IndexError): + start_time = "00:00" + + try: + end_parts = end_time_raw.split(":") + end_hour = int(end_parts[0]) + end_min = int(end_parts[1]) + end_time = f"{end_hour:02d}:{end_min:02d}" + except (ValueError, IndexError): + end_time = "00:00" + + # Get the mode value safely + mode_raw = settings_data.get(f"time{i}Mode") + if mode_raw == "null" or mode_raw is None: + batt_mode = None + else: + try: + batt_mode = int(mode_raw) + except (ValueError, TypeError): + batt_mode = None + + # Get the enabled status safely + enabled_raw = settings_data.get(f"forcedStopSwitch{i}", 0) + if enabled_raw == "null" or enabled_raw is None: + enabled = False + else: + try: + enabled = int(enabled_raw) == 1 + except (ValueError, TypeError): + enabled = False + + segment = { + "segment_id": i, + "batt_mode": batt_mode, + "mode_name": mode_names.get(batt_mode, "Unknown"), + "start_time": start_time, + "end_time": end_time, + "enabled": enabled + } + + segments.append(segment) + + return segments From c5b6342e5e198eb2450ac66b895b93b6ae0c1cae Mon Sep 17 00:00:00 2001 From: indy koning Date: Mon, 2 Feb 2026 21:46:11 +0100 Subject: [PATCH 05/13] Migrate SPH type --- .ruff.toml | 1 + growattServer/__init__.py | 1 - growattServer/open_api_v1/__init__.py | 341 +---------- growattServer/open_api_v1/devices/__init__.py | 2 + .../open_api_v1/devices/abstract_device.py | 2 +- growattServer/open_api_v1/devices/min.py | 16 +- growattServer/open_api_v1/devices/sph.py | 556 ++++++++++++++++++ 7 files changed, 602 insertions(+), 317 deletions(-) create mode 100644 growattServer/open_api_v1/devices/sph.py diff --git a/.ruff.toml b/.ruff.toml index 0163d6c..159e67e 100644 --- a/.ruff.toml +++ b/.ruff.toml @@ -23,6 +23,7 @@ ignore = [ "T201", # Prints are allowed in examples "PLR0913", "FBT002", + "N999", ] [lint.flake8-pytest-style] diff --git a/growattServer/__init__.py b/growattServer/__init__.py index b057305..41d839a 100755 --- a/growattServer/__init__.py +++ b/growattServer/__init__.py @@ -1,7 +1,6 @@ #!/usr/bin/env python3 """growattServer package exports.""" -# ruff: noqa: N999 from .base_api import GrowattApi, Timespan, hash_password from .exceptions import GrowattError, GrowattParameterError, GrowattV1ApiError from .open_api_v1 import DeviceType, OpenApiV1 diff --git a/growattServer/open_api_v1/__init__.py b/growattServer/open_api_v1/__init__.py index 4ca9462..2503031 100644 --- a/growattServer/open_api_v1/__init__.py +++ b/growattServer/open_api_v1/__init__.py @@ -1,14 +1,13 @@ """OpenApi V1 extensions for Growatt API client.""" - import platform import warnings -from datetime import UTC, date, datetime, timedelta +from datetime import UTC, date, datetime from enum import Enum -from growattServer.exceptions import GrowattParameterError, GrowattV1ApiError +from growattServer.exceptions import GrowattV1ApiError from . import GrowattApi -from .devices import Min +from .devices import AbstractDevice, Min, Sph class DeviceType(Enum): @@ -18,9 +17,9 @@ class DeviceType(Enum): STORAGE = 2 OTHER = 3 MAX = 4 - SPH = 5 # (MIX) + SPH = Sph.DEVICE_TYPE_ID # (MIX) SPA = 6 - MIN = 7 + MIN = Min.DEVICE_TYPE_ID PCS = 8 HPS = 9 PBD = 10 @@ -317,6 +316,17 @@ def device_list(self, plant_id): ) return self.process_response(response.json(), "getting device list") + def get_device(self, device_sn: str, device_type: int) -> AbstractDevice|None: + """Get the device class by serial number and device_type id.""" + match device_type: + case Sph.DEVICE_TYPE_ID: + return Sph(device_sn) + case Min.DEVICE_TYPE_ID: + return Min(device_sn) + case _: + warnings.warn(f"Device for type id: {device_type} has not been implemented yet.", stacklevel=2) + return None + def min_detail(self, device_sn): """ Get detailed data for a MIN inverter. @@ -514,15 +524,7 @@ def sph_detail(self, device_sn): requests.exceptions.RequestException: If there is an issue with the HTTP request. """ - # API: https://www.showdoc.com.cn/262556420217021/6129763571291058 - response = self.session.get( - self.get_url("device/mix/mix_data_info"), - params={ - "device_sn": device_sn - } - ) - - return self.process_response(response.json(), "getting SPH inverter details") + return Sph(device_sn).detail() def sph_energy(self, device_sn): """ @@ -539,15 +541,7 @@ def sph_energy(self, device_sn): requests.exceptions.RequestException: If there is an issue with the HTTP request. """ - # API: https://www.showdoc.com.cn/262556420217021/6129764475556048 - response = self.session.post( - url=self.get_url("device/mix/mix_last_data"), - data={ - "mix_sn": device_sn, - }, - ) - - return self.process_response(response.json(), "getting SPH inverter energy data") + return Sph(device_sn).energy() def sph_energy_history(self, device_sn, start_date=None, end_date=None, timezone=None, page=None, limit=None): """ @@ -570,32 +564,7 @@ def sph_energy_history(self, device_sn, start_date=None, end_date=None, timezone requests.exceptions.RequestException: If there is an issue with the HTTP request. """ - if start_date is None and end_date is None: - start_date = datetime.now(timezone.utc).date() - end_date = datetime.now(timezone.utc).date() - elif start_date is None: - start_date = end_date - elif end_date is None: - end_date = start_date - - # check interval validity - if end_date - start_date > timedelta(days=7): - raise GrowattParameterError("date interval must not exceed 7 days") - - # API: https://www.showdoc.com.cn/262556420217021/6129765461123058 - response = self.session.post( - url=self.get_url("device/mix/mix_data"), - data={ - "mix_sn": device_sn, - "start_date": start_date.strftime("%Y-%m-%d"), - "end_date": end_date.strftime("%Y-%m-%d"), - "timezone_id": timezone, - "page": page, - "perpage": limit, - } - ) - - return self.process_response(response.json(), "getting SPH inverter energy history") + return Sph(device_sn).energy_history(start_date, end_date, timezone, page, limit) def sph_read_parameter(self, device_sn, parameter_id=None, start_address=None, end_address=None): """ @@ -616,33 +585,7 @@ def sph_read_parameter(self, device_sn, parameter_id=None, start_address=None, e requests.exceptions.RequestException: If there is an issue with the HTTP request. """ - if parameter_id is None and start_address is None: - raise GrowattParameterError( - "specify either parameter_id or start_address/end_address") - if parameter_id is not None and start_address is not None: - raise GrowattParameterError( - "specify either parameter_id or start_address/end_address - not both." - ) - if parameter_id is not None: - # named parameter - start_address = 0 - end_address = 0 - else: - # address range - parameter_id = "set_any_reg" - - # API: https://www.showdoc.com.cn/262556420217021/6129766954561259 - response = self.session.post( - self.get_url("readMixParam"), - data={ - "device_sn": device_sn, - "paramId": parameter_id, - "startAddr": start_address, - "endAddr": end_address - } - ) - - return self.process_response(response.json(), f"reading parameter {parameter_id}") + return Sph(device_sn).read_parameter(parameter_id, start_address, end_address) def sph_write_parameter(self, device_sn, parameter_id, parameter_values=None): """ @@ -664,44 +607,7 @@ def sph_write_parameter(self, device_sn, parameter_id, parameter_values=None): requests.exceptions.RequestException: If there is an issue with the HTTP request. """ - # Initialize all parameters as empty strings (API uses param1-param18) - max_sph_params = 18 - parameters = dict.fromkeys(range(1, max_sph_params + 1), "") - - # Process parameter values based on type - if parameter_values is not None: - if isinstance(parameter_values, (str, int, float, bool)): - # Single value goes to param1 - parameters[1] = str(parameter_values) - elif isinstance(parameter_values, list): - # List of values go to sequential params - for i, value in enumerate(parameter_values, 1): - if i <= max_sph_params: # Only use up to max_sph_params parameters - parameters[i] = str(value) - elif isinstance(parameter_values, dict): - # Dict maps param positions to values - for pos_raw, value in parameter_values.items(): - pos = int(pos_raw) if not isinstance(pos_raw, int) else pos_raw - if 1 <= pos <= max_sph_params: # Validate parameter positions - parameters[pos] = str(value) - - # Create a data dictionary with ALL parameters explicitly included - request_data = { - "mix_sn": device_sn, - "type": parameter_id - } - - # Add all SPH parameters to the request - for i in range(1, max_sph_params + 1): - request_data[f"param{i}"] = str(parameters[i]) - - # API: https://www.showdoc.com.cn/262556420217021/6129761750718760 - response = self.session.post( - self.get_url("mixSet"), - data=request_data - ) - - return self.process_response(response.json(), f"writing parameter {parameter_id}") + return Sph(device_sn).write_parameter(parameter_id, parameter_values) def sph_write_ac_charge_times(self, device_sn, charge_power, charge_stop_soc, mains_enabled, periods): """ @@ -741,40 +647,7 @@ def sph_write_ac_charge_times(self, device_sn, charge_power, charge_stop_soc, ma requests.exceptions.RequestException: If there is an issue with the HTTP request. """ - if not 0 <= charge_power <= 100: # noqa: PLR2004 - raise GrowattParameterError("charge_power must be between 0 and 100") - - if not 0 <= charge_stop_soc <= 100: # noqa: PLR2004 - raise GrowattParameterError("charge_stop_soc must be between 0 and 100") - - if len(periods) != 3: # noqa: PLR2004 - raise GrowattParameterError("periods must contain exactly 3 period definitions") - - # Build request data - request_data = { - "mix_sn": device_sn, - "type": "mix_ac_charge_time_period", - "param1": str(charge_power), - "param2": str(charge_stop_soc), - "param3": "1" if mains_enabled else "0", - } - - # Add period parameters (param4-18) - for i, period in enumerate(periods): - base = i * 5 + 4 - request_data[f"param{base}"] = str(period["start_time"].hour) - request_data[f"param{base + 1}"] = str(period["start_time"].minute) - request_data[f"param{base + 2}"] = str(period["end_time"].hour) - request_data[f"param{base + 3}"] = str(period["end_time"].minute) - request_data[f"param{base + 4}"] = "1" if period["enabled"] else "0" - - # API: https://www.showdoc.com.cn/262556420217021/6129761750718760 - response = self.session.post( - self.get_url("mixSet"), - data=request_data - ) - - return self.process_response(response.json(), "writing AC charge time periods") + return Sph(device_sn).write_ac_charge_times(charge_power, charge_stop_soc, mains_enabled, periods) def sph_write_ac_discharge_times(self, device_sn, discharge_power, discharge_stop_soc, periods): """ @@ -812,111 +685,9 @@ def sph_write_ac_discharge_times(self, device_sn, discharge_power, discharge_sto requests.exceptions.RequestException: If there is an issue with the HTTP request. """ - if not 0 <= discharge_power <= 100: # noqa: PLR2004 - raise GrowattParameterError("discharge_power must be between 0 and 100") - - if not 0 <= discharge_stop_soc <= 100: # noqa: PLR2004 - raise GrowattParameterError("discharge_stop_soc must be between 0 and 100") - - if len(periods) != 3: # noqa: PLR2004 - raise GrowattParameterError("periods must contain exactly 3 period definitions") + return Sph(device_sn).write_ac_discharge_times(discharge_power, discharge_stop_soc, periods) - # Build request data - request_data = { - "mix_sn": device_sn, - "type": "mix_ac_discharge_time_period", - "param1": str(discharge_power), - "param2": str(discharge_stop_soc), - } - - # Add period parameters (param3-17) - for i, period in enumerate(periods): - base = i * 5 + 3 - request_data[f"param{base}"] = str(period["start_time"].hour) - request_data[f"param{base + 1}"] = str(period["start_time"].minute) - request_data[f"param{base + 2}"] = str(period["end_time"].hour) - request_data[f"param{base + 3}"] = str(period["end_time"].minute) - request_data[f"param{base + 4}"] = "1" if period["enabled"] else "0" - - # API: https://www.showdoc.com.cn/262556420217021/6129761750718760 - response = self.session.post( - self.get_url("mixSet"), - data=request_data - ) - - return self.process_response(response.json(), "writing AC discharge time periods") - - def _parse_time_periods(self, settings_data, time_type): - """ - Parse time periods from settings data. - - Internal helper method to extract and format time period data from SPH settings. - - Args: - settings_data (dict): Settings data from sph_detail call. - time_type (str): Either "Charge" or "Discharge" to specify which periods to parse. - - Returns: - list: A list of dictionaries, each containing details for one time period: - - period_id (int): The period number (1-3) - - start_time (str): Start time in format "HH:MM" - - end_time (str): End time in format "HH:MM" - - enabled (bool): Whether the period is enabled - - """ - periods = [] - - # Process each time period (1-3 for SPH) - for i in range(1, 4): - # Get raw time values - start_time_raw = settings_data.get(f"forced{time_type}TimeStart{i}", "0:0") - end_time_raw = settings_data.get(f"forced{time_type}TimeStop{i}", "0:0") - enabled_raw = settings_data.get(f"forced{time_type}StopSwitch{i}", 0) - - # Handle 'null' string values - if start_time_raw == "null" or not start_time_raw: - start_time_raw = "0:0" - if end_time_raw == "null" or not end_time_raw: - end_time_raw = "0:0" - - # Format times with leading zeros (HH:MM) - try: - start_parts = start_time_raw.split(":") - start_hour = int(start_parts[0]) - start_min = int(start_parts[1]) - start_time = f"{start_hour:02d}:{start_min:02d}" - except (ValueError, IndexError): - start_time = "00:00" - - try: - end_parts = end_time_raw.split(":") - end_hour = int(end_parts[0]) - end_min = int(end_parts[1]) - end_time = f"{end_hour:02d}:{end_min:02d}" - except (ValueError, IndexError): - end_time = "00:00" - - # Get the enabled status - if enabled_raw == "null" or enabled_raw is None: - enabled = False - else: - try: - enabled = int(enabled_raw) == 1 - except (ValueError, TypeError): - enabled = False - - period = { - "period_id": i, - "start_time": start_time, - "end_time": end_time, - "enabled": enabled - } - - periods.append(period) - - return periods - - def sph_read_ac_charge_times(self, device_sn=None, settings_data=None): + def sph_read_ac_charge_times(self, device_sn, settings_data=None): """ Read AC charge time periods and settings from an SPH inverter. @@ -928,10 +699,8 @@ def sph_read_ac_charge_times(self, device_sn=None, settings_data=None): with the data returned from sph_detail(). Args: - device_sn (str, optional): The device serial number of the inverter. - Required if settings_data is not provided. + device_sn (str): The device serial number of the inverter. settings_data (dict, optional): Settings data from sph_detail call to avoid repeated API calls. - If provided, device_sn is not required. Returns: dict: A dictionary containing: @@ -950,9 +719,9 @@ def sph_read_ac_charge_times(self, device_sn=None, settings_data=None): print(f"Charge power: {charge_config['charge_power']}%") print(f"Periods: {charge_config['periods']}") - # Option 2: Reuse existing settings data (no device_sn needed) + # Option 2: Reuse existing settings data settings_response = api.sph_detail("DEVICE_SERIAL_NUMBER") - charge_config = api.sph_read_ac_charge_times(settings_data=settings_response) + charge_config = api.sph_read_ac_charge_times(device_sn="DEVICE_SERIAL_NUMBER", settings_data=settings_response) Raises: GrowattParameterError: If neither device_sn nor settings_data is provided. @@ -960,34 +729,9 @@ def sph_read_ac_charge_times(self, device_sn=None, settings_data=None): requests.exceptions.RequestException: If there is an issue with the HTTP request. """ - if settings_data is None: - if device_sn is None: - raise GrowattParameterError("Either device_sn or settings_data must be provided") - settings_data = self.sph_detail(device_sn=device_sn) - - # Extract global charge settings - charge_power = settings_data.get("chargePowerCommand", 0) - charge_stop_soc = settings_data.get("wchargeSOCLowLimit", 100) - mains_enabled_raw = settings_data.get("acChargeEnable", 0) - - # Handle null/empty values - if charge_power == "null" or charge_power is None or charge_power == "": - charge_power = 0 - if charge_stop_soc == "null" or charge_stop_soc is None or charge_stop_soc == "": - charge_stop_soc = 100 - if mains_enabled_raw == "null" or mains_enabled_raw is None or mains_enabled_raw == "": - mains_enabled = False - else: - mains_enabled = int(mains_enabled_raw) == 1 - - return { - "charge_power": int(charge_power), - "charge_stop_soc": int(charge_stop_soc), - "mains_enabled": mains_enabled, - "periods": self._parse_time_periods(settings_data, "Charge") - } + return Sph(device_sn).read_ac_charge_times(settings_data) - def sph_read_ac_discharge_times(self, device_sn=None, settings_data=None): + def sph_read_ac_discharge_times(self, device_sn, settings_data=None): """ Read AC discharge time periods and settings from an SPH inverter. @@ -1000,9 +744,7 @@ def sph_read_ac_discharge_times(self, device_sn=None, settings_data=None): Args: device_sn (str, optional): The device serial number of the inverter. - Required if settings_data is not provided. settings_data (dict, optional): Settings data from sph_detail call to avoid repeated API calls. - If provided, device_sn is not required. Returns: dict: A dictionary containing: @@ -1020,9 +762,9 @@ def sph_read_ac_discharge_times(self, device_sn=None, settings_data=None): print(f"Discharge power: {discharge_config['discharge_power']}%") print(f"Stop SOC: {discharge_config['discharge_stop_soc']}%") - # Option 2: Reuse existing settings data (no device_sn needed) + # Option 2: Reuse existing settings data settings_response = api.sph_detail("DEVICE_SERIAL_NUMBER") - discharge_config = api.sph_read_ac_discharge_times(settings_data=settings_response) + discharge_config = api.sph_read_ac_discharge_times(device_sn="DEVICE_SERIAL_NUMBER", settings_data=settings_response) Raises: GrowattParameterError: If neither device_sn nor settings_data is provided. @@ -1030,23 +772,4 @@ def sph_read_ac_discharge_times(self, device_sn=None, settings_data=None): requests.exceptions.RequestException: If there is an issue with the HTTP request. """ - if settings_data is None: - if device_sn is None: - raise GrowattParameterError("Either device_sn or settings_data must be provided") - settings_data = self.sph_detail(device_sn=device_sn) - - # Extract global discharge settings - discharge_power = settings_data.get("disChargePowerCommand", 0) - discharge_stop_soc = settings_data.get("wdisChargeSOCLowLimit", 10) - - # Handle null/empty values - if discharge_power == "null" or discharge_power is None or discharge_power == "": - discharge_power = 0 - if discharge_stop_soc == "null" or discharge_stop_soc is None or discharge_stop_soc == "": - discharge_stop_soc = 10 - - return { - "discharge_power": int(discharge_power), - "discharge_stop_soc": int(discharge_stop_soc), - "periods": self._parse_time_periods(settings_data, "Discharge") - } + return Sph(device_sn).read_ac_discharge_times(device_sn, settings_data) diff --git a/growattServer/open_api_v1/devices/__init__.py b/growattServer/open_api_v1/devices/__init__.py index cae3538..d2c0fe0 100644 --- a/growattServer/open_api_v1/devices/__init__.py +++ b/growattServer/open_api_v1/devices/__init__.py @@ -1,2 +1,4 @@ # noqa: D104 +from .abstract_device import AbstractDevice # noqa: F401 from .min import Min # noqa: F401 +from .sph import Sph # noqa: F401 diff --git a/growattServer/open_api_v1/devices/abstract_device.py b/growattServer/open_api_v1/devices/abstract_device.py index f848f2e..6e63834 100644 --- a/growattServer/open_api_v1/devices/abstract_device.py +++ b/growattServer/open_api_v1/devices/abstract_device.py @@ -12,7 +12,7 @@ class ReadParamResponse(TypedDict): error_code: str error_msg: str -class AbstactDevice: +class AbstractDevice: """Abstract device type. Must not be used directly.""" def __init__(self, api: OpenApiV1, device_sn: str) -> None: diff --git a/growattServer/open_api_v1/devices/min.py b/growattServer/open_api_v1/devices/min.py index 572995b..0f0fedf 100644 --- a/growattServer/open_api_v1/devices/min.py +++ b/growattServer/open_api_v1/devices/min.py @@ -1,18 +1,22 @@ -"""Min device file.""" +"""Min/TLX device file.""" from datetime import datetime, timedelta +from typing import Any from growattServer.exceptions import GrowattParameterError -from typing import Any -from .abstract_device import AbstactDevice + +from .abstract_device import AbstractDevice -class Min(AbstactDevice): - """Min device type.""" +class Min(AbstractDevice): + """Min/TLX device type.""" + + DEVICE_TYPE_ID = 7 def detail(self) -> dict: """ Get detailed data for a MIN inverter. - https://www.showdoc.com.cn/262556420217021/6129816412127075. + + See the API doc: https://www.showdoc.com.cn/262556420217021/6129816412127075. Args: device_sn (str): The serial number of the MIN inverter. diff --git a/growattServer/open_api_v1/devices/sph.py b/growattServer/open_api_v1/devices/sph.py new file mode 100644 index 0000000..d366aeb --- /dev/null +++ b/growattServer/open_api_v1/devices/sph.py @@ -0,0 +1,556 @@ +"""SPH/MIX device file.""" +from datetime import datetime, timedelta + +from growattServer.exceptions import GrowattParameterError + +from .abstract_device import AbstractDevice + + +class Sph(AbstractDevice): + """SPH/MIX device type.""" + + DEVICE_TYPE_ID = 5 + + def detail(self): + """ + Get detailed data for an SPH inverter. + + Args: + device_sn (str): The serial number of the SPH inverter. + + Returns: + dict: A dictionary containing the SPH inverter details. + + Raises: + GrowattV1ApiError: If the API returns an error response. + requests.exceptions.RequestException: If there is an issue with the HTTP request. + + """ + # API: https://www.showdoc.com.cn/262556420217021/6129763571291058 + response = self.session.get( + self.get_url("device/mix/mix_data_info"), + params={ + "device_sn": self.device_sn + } + ) + + return self.process_response(response.json(), "getting SPH inverter details") + + def energy(self): + """ + Get energy data for an SPH inverter. + + Args: + device_sn (str): The serial number of the SPH inverter. + + Returns: + dict: A dictionary containing the SPH inverter energy data. + + Raises: + GrowattV1ApiError: If the API returns an error response. + requests.exceptions.RequestException: If there is an issue with the HTTP request. + + """ + # API: https://www.showdoc.com.cn/262556420217021/6129764475556048 + response = self.session.post( + url=self.get_url("device/mix/mix_last_data"), + data={ + "mix_sn": self.device_sn, + }, + ) + + return self.process_response(response.json(), "getting SPH inverter energy data") + + def energy_history(self, start_date=None, end_date=None, timezone=None, page=None, limit=None): + """ + Get SPH inverter data history. + + Args: + device_sn (str): The ID of the SPH inverter. + start_date (date, optional): Start date. Defaults to today. + end_date (date, optional): End date. Defaults to today. + timezone (str, optional): Timezone ID. + page (int, optional): Page number. + limit (int, optional): Results per page. + + Returns: + dict: A dictionary containing the SPH inverter history data. + + Raises: + GrowattParameterError: If date interval is invalid (exceeds 7 days). + GrowattV1ApiError: If the API returns an error response. + requests.exceptions.RequestException: If there is an issue with the HTTP request. + + """ + if start_date is None and end_date is None: + start_date = datetime.now(timezone.utc).date() + end_date = datetime.now(timezone.utc).date() + elif start_date is None: + start_date = end_date + elif end_date is None: + end_date = start_date + + # check interval validity + if end_date - start_date > timedelta(days=7): + raise GrowattParameterError("date interval must not exceed 7 days") + + # API: https://www.showdoc.com.cn/262556420217021/6129765461123058 + response = self.session.post( + url=self.get_url("device/mix/mix_data"), + data={ + "mix_sn": self.device_sn, + "start_date": start_date.strftime("%Y-%m-%d"), + "end_date": end_date.strftime("%Y-%m-%d"), + "timezone_id": timezone, + "page": page, + "perpage": limit, + } + ) + + return self.process_response(response.json(), "getting SPH inverter energy history") + + def read_parameter(self, parameter_id=None, start_address=None, end_address=None): + """ + Read setting from SPH inverter. + + Args: + device_sn (str): The ID of the SPH inverter. + parameter_id (str, optional): Parameter ID to read. Don't use start_address and end_address if this is set. + start_address (int, optional): Register start address (for set_any_reg). Don't use parameter_id if this is set. + end_address (int, optional): Register end address (for set_any_reg). Don't use parameter_id if this is set. + + Returns: + dict: A dictionary containing the setting value. + + Raises: + GrowattParameterError: If parameters are invalid. + GrowattV1ApiError: If the API returns an error response. + requests.exceptions.RequestException: If there is an issue with the HTTP request. + + """ + if parameter_id is None and start_address is None: + raise GrowattParameterError( + "specify either parameter_id or start_address/end_address") + if parameter_id is not None and start_address is not None: + raise GrowattParameterError( + "specify either parameter_id or start_address/end_address - not both." + ) + if parameter_id is not None: + # named parameter + start_address = 0 + end_address = 0 + else: + # address range + parameter_id = "set_any_reg" + + # API: https://www.showdoc.com.cn/262556420217021/6129766954561259 + response = self.session.post( + self.get_url("readMixParam"), + data={ + "device_sn": self.device_sn, + "paramId": parameter_id, + "startAddr": start_address, + "endAddr": end_address + } + ) + + return self.process_response(response.json(), f"reading parameter {parameter_id}") + + def write_parameter(self, parameter_id, parameter_values=None): + """ + Set parameters on an SPH inverter. + + Args: + device_sn (str): Serial number of the inverter + parameter_id (str): Setting type to be configured + parameter_values: Parameter values to be sent to the system. + Can be a single string (for param1 only), + a list of strings (for sequential params), + or a dictionary mapping param positions to values + + Returns: + dict: JSON response from the server + + Raises: + GrowattV1ApiError: If the API returns an error response. + requests.exceptions.RequestException: If there is an issue with the HTTP request. + + """ + # Initialize all parameters as empty strings (API uses param1-param18) + max_sph_params = 18 + parameters = dict.fromkeys(range(1, max_sph_params + 1), "") + + # Process parameter values based on type + if parameter_values is not None: + if isinstance(parameter_values, (str, int, float, bool)): + # Single value goes to param1 + parameters[1] = str(parameter_values) + elif isinstance(parameter_values, list): + # List of values go to sequential params + for i, value in enumerate(parameter_values, 1): + if i <= max_sph_params: # Only use up to max_sph_params parameters + parameters[i] = str(value) + elif isinstance(parameter_values, dict): + # Dict maps param positions to values + for pos_raw, value in parameter_values.items(): + pos = int(pos_raw) if not isinstance(pos_raw, int) else pos_raw + if 1 <= pos <= max_sph_params: # Validate parameter positions + parameters[pos] = str(value) + + # Create a data dictionary with ALL parameters explicitly included + request_data = { + "mix_sn": self.device_sn, + "type": parameter_id + } + + # Add all SPH parameters to the request + for i in range(1, max_sph_params + 1): + request_data[f"param{i}"] = str(parameters[i]) + + # API: https://www.showdoc.com.cn/262556420217021/6129761750718760 + response = self.session.post( + self.get_url("mixSet"), + data=request_data + ) + + return self.process_response(response.json(), f"writing parameter {parameter_id}") + + def write_ac_charge_times(self, charge_power, charge_stop_soc, mains_enabled, periods): + """ + Set AC charge time periods for an SPH inverter. + + Args: + device_sn (str): The serial number of the inverter. + charge_power (int): Charging power percentage (0-100). + charge_stop_soc (int): Stop charging at this SOC percentage (0-100). + mains_enabled (bool): Enable grid charging. + periods (list): List of 3 period dicts, each with keys: + - start_time (datetime.time): Start time for the period + - end_time (datetime.time): End time for the period + - enabled (bool): Whether this period is enabled + + Returns: + dict: The server response. + + Example: + from datetime import time + + api.sph_write_ac_charge_times( + device_sn="ABC123", + charge_power=100, + charge_stop_soc=100, + mains_enabled=True, + periods=[ + {"start_time": time(1, 0), "end_time": time(5, 0), "enabled": True}, + {"start_time": time(0, 0), "end_time": time(0, 0), "enabled": False}, + {"start_time": time(0, 0), "end_time": time(0, 0), "enabled": False}, + ] + ) + + Raises: + GrowattParameterError: If parameters are invalid. + GrowattV1ApiError: If the API returns an error response. + requests.exceptions.RequestException: If there is an issue with the HTTP request. + + """ + if not 0 <= charge_power <= 100: # noqa: PLR2004 + raise GrowattParameterError("charge_power must be between 0 and 100") + + if not 0 <= charge_stop_soc <= 100: # noqa: PLR2004 + raise GrowattParameterError("charge_stop_soc must be between 0 and 100") + + if len(periods) != 3: # noqa: PLR2004 + raise GrowattParameterError("periods must contain exactly 3 period definitions") + + # Build request data + request_data = { + "mix_sn": self.device_sn, + "type": "mix_ac_charge_time_period", + "param1": str(charge_power), + "param2": str(charge_stop_soc), + "param3": "1" if mains_enabled else "0", + } + + # Add period parameters (param4-18) + for i, period in enumerate(periods): + base = i * 5 + 4 + request_data[f"param{base}"] = str(period["start_time"].hour) + request_data[f"param{base + 1}"] = str(period["start_time"].minute) + request_data[f"param{base + 2}"] = str(period["end_time"].hour) + request_data[f"param{base + 3}"] = str(period["end_time"].minute) + request_data[f"param{base + 4}"] = "1" if period["enabled"] else "0" + + # API: https://www.showdoc.com.cn/262556420217021/6129761750718760 + response = self.session.post( + self.get_url("mixSet"), + data=request_data + ) + + return self.process_response(response.json(), "writing AC charge time periods") + + def write_ac_discharge_times(self, discharge_power, discharge_stop_soc, periods): + """ + Set AC discharge time periods for an SPH inverter. + + Args: + device_sn (str): The serial number of the inverter. + discharge_power (int): Discharging power percentage (0-100). + discharge_stop_soc (int): Stop discharging at this SOC percentage (0-100). + periods (list): List of 3 period dicts, each with keys: + - start_time (datetime.time): Start time for the period + - end_time (datetime.time): End time for the period + - enabled (bool): Whether this period is enabled + + Returns: + dict: The server response. + + Example: + from datetime import time + + api.sph_write_ac_discharge_times( + device_sn="ABC123", + discharge_power=100, + discharge_stop_soc=10, + periods=[ + {"start_time": time(17, 0), "end_time": time(21, 0), "enabled": True}, + {"start_time": time(0, 0), "end_time": time(0, 0), "enabled": False}, + {"start_time": time(0, 0), "end_time": time(0, 0), "enabled": False}, + ] + ) + + Raises: + GrowattParameterError: If parameters are invalid. + GrowattV1ApiError: If the API returns an error response. + requests.exceptions.RequestException: If there is an issue with the HTTP request. + + """ + if not 0 <= discharge_power <= 100: # noqa: PLR2004 + raise GrowattParameterError("discharge_power must be between 0 and 100") + + if not 0 <= discharge_stop_soc <= 100: # noqa: PLR2004 + raise GrowattParameterError("discharge_stop_soc must be between 0 and 100") + + if len(periods) != 3: # noqa: PLR2004 + raise GrowattParameterError("periods must contain exactly 3 period definitions") + + # Build request data + request_data = { + "mix_sn": self.device_sn, + "type": "mix_ac_discharge_time_period", + "param1": str(discharge_power), + "param2": str(discharge_stop_soc), + } + + # Add period parameters (param3-17) + for i, period in enumerate(periods): + base = i * 5 + 3 + request_data[f"param{base}"] = str(period["start_time"].hour) + request_data[f"param{base + 1}"] = str(period["start_time"].minute) + request_data[f"param{base + 2}"] = str(period["end_time"].hour) + request_data[f"param{base + 3}"] = str(period["end_time"].minute) + request_data[f"param{base + 4}"] = "1" if period["enabled"] else "0" + + # API: https://www.showdoc.com.cn/262556420217021/6129761750718760 + response = self.session.post( + self.get_url("mixSet"), + data=request_data + ) + + return self.process_response(response.json(), "writing AC discharge time periods") + + def _parse_time_periods(self, settings_data, time_type): + """ + Parse time periods from settings data. + + Internal helper method to extract and format time period data from SPH settings. + + Args: + settings_data (dict): Settings data from sph_detail call. + time_type (str): Either "Charge" or "Discharge" to specify which periods to parse. + + Returns: + list: A list of dictionaries, each containing details for one time period: + - period_id (int): The period number (1-3) + - start_time (str): Start time in format "HH:MM" + - end_time (str): End time in format "HH:MM" + - enabled (bool): Whether the period is enabled + + """ + periods = [] + + # Process each time period (1-3 for SPH) + for i in range(1, 4): + # Get raw time values + start_time_raw = settings_data.get(f"forced{time_type}TimeStart{i}", "0:0") + end_time_raw = settings_data.get(f"forced{time_type}TimeStop{i}", "0:0") + enabled_raw = settings_data.get(f"forced{time_type}StopSwitch{i}", 0) + + # Handle 'null' string values + if start_time_raw == "null" or not start_time_raw: + start_time_raw = "0:0" + if end_time_raw == "null" or not end_time_raw: + end_time_raw = "0:0" + + # Format times with leading zeros (HH:MM) + try: + start_parts = start_time_raw.split(":") + start_hour = int(start_parts[0]) + start_min = int(start_parts[1]) + start_time = f"{start_hour:02d}:{start_min:02d}" + except (ValueError, IndexError): + start_time = "00:00" + + try: + end_parts = end_time_raw.split(":") + end_hour = int(end_parts[0]) + end_min = int(end_parts[1]) + end_time = f"{end_hour:02d}:{end_min:02d}" + except (ValueError, IndexError): + end_time = "00:00" + + # Get the enabled status + if enabled_raw == "null" or enabled_raw is None: + enabled = False + else: + try: + enabled = int(enabled_raw) == 1 + except (ValueError, TypeError): + enabled = False + + period = { + "period_id": i, + "start_time": start_time, + "end_time": end_time, + "enabled": enabled + } + + periods.append(period) + + return periods + + def read_ac_charge_times(self, settings_data=None): + """ + Read AC charge time periods and settings from an SPH inverter. + + Retrieves all 3 AC charge time periods plus global charge settings + (power, stop SOC, mains enabled) from an SPH inverter. + + Note that this function uses sph_detail() internally to get the settings data. + To avoid endpoint rate limit, you can pass the settings_data parameter + with the data returned from sph_detail(). + + Args: + device_sn (str): The device serial number of the inverter. + settings_data (dict, optional): Settings data from sph_detail call to avoid repeated API calls. + + Returns: + dict: A dictionary containing: + - charge_power (int): Charging power percentage (0-100) + - charge_stop_soc (int): Stop charging at this SOC percentage (0-100) + - mains_enabled (bool): Whether grid/mains charging is enabled + - periods (list): List of 3 period dicts, each with: + - period_id (int): The period number (1-3) + - start_time (str): Start time in format "HH:MM" + - end_time (str): End time in format "HH:MM" + - enabled (bool): Whether the period is enabled + + Example: + # Option 1: Fetch settings automatically + charge_config = api.sph_read_ac_charge_times(device_sn="DEVICE_SERIAL_NUMBER") + print(f"Charge power: {charge_config['charge_power']}%") + print(f"Periods: {charge_config['periods']}") + + # Option 2: Reuse existing settings data + settings_response = api.sph_detail("DEVICE_SERIAL_NUMBER") + charge_config = api.sph_read_ac_charge_times(device_sn="DEVICE_SERIAL_NUMBER", settings_data=settings_response) + + Raises: + GrowattParameterError: If neither device_sn nor settings_data is provided. + GrowattV1ApiError: If the API request fails. + requests.exceptions.RequestException: If there is an issue with the HTTP request. + + """ + if settings_data is None: + settings_data = self.detail() + + # Extract global charge settings + charge_power = settings_data.get("chargePowerCommand", 0) + charge_stop_soc = settings_data.get("wchargeSOCLowLimit", 100) + mains_enabled_raw = settings_data.get("acChargeEnable", 0) + + # Handle null/empty values + if charge_power == "null" or charge_power is None or charge_power == "": + charge_power = 0 + if charge_stop_soc == "null" or charge_stop_soc is None or charge_stop_soc == "": + charge_stop_soc = 100 + if mains_enabled_raw == "null" or mains_enabled_raw is None or mains_enabled_raw == "": + mains_enabled = False + else: + mains_enabled = int(mains_enabled_raw) == 1 + + return { + "charge_power": int(charge_power), + "charge_stop_soc": int(charge_stop_soc), + "mains_enabled": mains_enabled, + "periods": self._parse_time_periods(settings_data, "Charge") + } + + def read_ac_discharge_times(self, settings_data=None): + """ + Read AC discharge time periods and settings from an SPH inverter. + + Retrieves all 3 AC discharge time periods plus global discharge settings + (power, stop SOC) from an SPH inverter. + + Note that this function uses sph_detail() internally to get the settings data. + To avoid endpoint rate limit, you can pass the settings_data parameter + with the data returned from sph_detail(). + + Args: + device_sn (str, optional): The device serial number of the inverter. + settings_data (dict, optional): Settings data from sph_detail call to avoid repeated API calls. + + Returns: + dict: A dictionary containing: + - discharge_power (int): Discharging power percentage (0-100) + - discharge_stop_soc (int): Stop discharging at this SOC percentage (0-100) + - periods (list): List of 3 period dicts, each with: + - period_id (int): The period number (1-3) + - start_time (str): Start time in format "HH:MM" + - end_time (str): End time in format "HH:MM" + - enabled (bool): Whether the period is enabled + + Example: + # Option 1: Fetch settings automatically + discharge_config = api.sph_read_ac_discharge_times(device_sn="DEVICE_SERIAL_NUMBER") + print(f"Discharge power: {discharge_config['discharge_power']}%") + print(f"Stop SOC: {discharge_config['discharge_stop_soc']}%") + + # Option 2: Reuse existing settings data + settings_response = api.sph_detail("DEVICE_SERIAL_NUMBER") + discharge_config = api.sph_read_ac_discharge_times(device_sn="DEVICE_SERIAL_NUMBER", settings_data=settings_response) + + Raises: + GrowattParameterError: If neither device_sn nor settings_data is provided. + GrowattV1ApiError: If the API request fails. + requests.exceptions.RequestException: If there is an issue with the HTTP request. + + """ + if settings_data is None: + settings_data = self.detail() + + # Extract global discharge settings + discharge_power = settings_data.get("disChargePowerCommand", 0) + discharge_stop_soc = settings_data.get("wdisChargeSOCLowLimit", 10) + + # Handle null/empty values + if discharge_power == "null" or discharge_power is None or discharge_power == "": + discharge_power = 0 + if discharge_stop_soc == "null" or discharge_stop_soc is None or discharge_stop_soc == "": + discharge_stop_soc = 10 + + return { + "discharge_power": int(discharge_power), + "discharge_stop_soc": int(discharge_stop_soc), + "periods": self._parse_time_periods(settings_data, "Discharge") + } From 170c6c9f9a651ac04fe6b91c5cc92a055135a5cb Mon Sep 17 00:00:00 2001 From: indykoning <15870933+indykoning@users.noreply.github.com> Date: Tue, 3 Feb 2026 14:26:59 +0100 Subject: [PATCH 06/13] Fixed import path --- growattServer/open_api_v1/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/growattServer/open_api_v1/__init__.py b/growattServer/open_api_v1/__init__.py index 2503031..d826d2e 100644 --- a/growattServer/open_api_v1/__init__.py +++ b/growattServer/open_api_v1/__init__.py @@ -6,7 +6,7 @@ from growattServer.exceptions import GrowattV1ApiError -from . import GrowattApi +from growattServer import GrowattApi from .devices import AbstractDevice, Min, Sph From 22a87e0c4559d39bce747772be2964585fe43a5f Mon Sep 17 00:00:00 2001 From: indykoning <15870933+indykoning@users.noreply.github.com> Date: Sat, 7 Feb 2026 19:32:35 +0100 Subject: [PATCH 07/13] Only import OpenApiV1 when type checking Co-authored-by: steinmn <46349253+steinmn@users.noreply.github.com> --- growattServer/open_api_v1/devices/abstract_device.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/growattServer/open_api_v1/devices/abstract_device.py b/growattServer/open_api_v1/devices/abstract_device.py index 6e63834..9e02c9a 100644 --- a/growattServer/open_api_v1/devices/abstract_device.py +++ b/growattServer/open_api_v1/devices/abstract_device.py @@ -1,8 +1,10 @@ """Abstract device file for centralising shared device logic.""" -from typing import TypedDict +from typing import TYPE_CHECKING, TypedDict from growattServer.exceptions import GrowattParameterError -from growattServer.open_api_v1 import OpenApiV1 + +if TYPE_CHECKING: + from growattServer.open_api_v1 import OpenApiV1 class ReadParamResponse(TypedDict): @@ -15,7 +17,7 @@ class ReadParamResponse(TypedDict): class AbstractDevice: """Abstract device type. Must not be used directly.""" - def __init__(self, api: OpenApiV1, device_sn: str) -> None: + def __init__(self, api: "OpenApiV1", device_sn: str) -> None: """ Initialize the device with the bare minimum being the device_sn. From 6442b96c5bd5ca2503930c84b6ad5dff5727c0da Mon Sep 17 00:00:00 2001 From: indy koning Date: Sat, 7 Feb 2026 19:34:45 +0100 Subject: [PATCH 08/13] Reorder imports --- growattServer/open_api_v1/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/growattServer/open_api_v1/__init__.py b/growattServer/open_api_v1/__init__.py index d826d2e..5e16889 100644 --- a/growattServer/open_api_v1/__init__.py +++ b/growattServer/open_api_v1/__init__.py @@ -4,9 +4,9 @@ from datetime import UTC, date, datetime from enum import Enum +from growattServer import GrowattApi from growattServer.exceptions import GrowattV1ApiError -from growattServer import GrowattApi from .devices import AbstractDevice, Min, Sph From a04ff937d78e3d10e65fc4e7ca47992161554cb6 Mon Sep 17 00:00:00 2001 From: indy koning Date: Sat, 7 Feb 2026 19:43:22 +0100 Subject: [PATCH 09/13] Fixed UTC references --- growattServer/open_api_v1/devices/min.py | 4 ++-- growattServer/open_api_v1/devices/sph.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/growattServer/open_api_v1/devices/min.py b/growattServer/open_api_v1/devices/min.py index 0f0fedf..4eb754f 100644 --- a/growattServer/open_api_v1/devices/min.py +++ b/growattServer/open_api_v1/devices/min.py @@ -84,8 +84,8 @@ def energy_history(self, start_date=None, end_date=None, timezone=None, page=Non """ if start_date is None and end_date is None: - start_date = datetime.now(timezone.utc).date() - end_date = datetime.now(timezone.utc).date() + start_date = datetime.now(datetime.UTC).date() + end_date = datetime.now(datetime.UTC).date() elif start_date is None: start_date = end_date elif end_date is None: diff --git a/growattServer/open_api_v1/devices/sph.py b/growattServer/open_api_v1/devices/sph.py index d366aeb..6ba83f9 100644 --- a/growattServer/open_api_v1/devices/sph.py +++ b/growattServer/open_api_v1/devices/sph.py @@ -83,8 +83,8 @@ def energy_history(self, start_date=None, end_date=None, timezone=None, page=Non """ if start_date is None and end_date is None: - start_date = datetime.now(timezone.utc).date() - end_date = datetime.now(timezone.utc).date() + start_date = datetime.now(datetime.UTC).date() + end_date = datetime.now(datetime.UTC).date() elif start_date is None: start_date = end_date elif end_date is None: From 63b376ce3868a501746668fca6ff3089df7dee4d Mon Sep 17 00:00:00 2001 From: indy koning Date: Sat, 7 Feb 2026 20:57:45 +0100 Subject: [PATCH 10/13] Added docs --- docs/openapiv1.md | 34 ++++++++++++++++++++++++-- docs/openapiv1/devices.md | 46 +++++++++++++++++++++++++++++++++++ docs/openapiv1/devices/min.md | 14 +++++++++++ docs/openapiv1/devices/sph.md | 22 +++++++++++++++++ 4 files changed, 114 insertions(+), 2 deletions(-) create mode 100644 docs/openapiv1/devices.md create mode 100644 docs/openapiv1/devices/min.md create mode 100644 docs/openapiv1/devices/sph.md diff --git a/docs/openapiv1.md b/docs/openapiv1.md index da21723..767613a 100644 --- a/docs/openapiv1.md +++ b/docs/openapiv1.md @@ -2,7 +2,7 @@ This version of the API follows the newer [OpenAPI V1 API](https://www.showdoc.com.cn/262556420217021/0) Growatt has made available. -It extends our ["Legacy" ShinePhone](./shinephone.md) so methods from [there](./shinephone.md#methods) should be available, but it's safer to rely on the functions described in this file where possible. +It extends our ["Legacy" ShinePhone](./shinephone.md) so methods from [there](./shinephone.md#methods) should be available, but it's safer to rely on the methods described in this file where possible. ## Usage @@ -33,6 +33,36 @@ Methods that work across all device types. | `api.plant_energy_history(plant_id, start_date, end_date, time_unit, page, perpage)` | plant_id: String, start_date: Date, end_date: Date, time_unit: String, page: Int, perpage: Int | Get historical energy data for a plant for multiple days/months/years. | | `api.device_list(plant_id)` | plant_id: String | Get a list of devices in specified plant. | +#### Devices + +Devices offer a generic way to interact with your device using the V1 API without needing to provide your S/N every time. And can be used instead of the more specific device methods in the API class. + +```python +import growattServer +from growattServer.open_api_v1.devices import Sph, Min + +api = growattServer.OpenApiV1(token="YOUR_API_TOKEN") + +my_inverter = Sph(api, 'YOUR_DEVICE_SERIAL_NUMBER') # or Min(api, 'YOUR_DEVICE_SERIAL_NUMBER') +my_inverter.detail() +my_inverter.energy() +my_inverter.energy_history() +my_inverter.read_parameter() +my_inverter.write_parameter() +``` + +| Method | Arguments | Description | +|:---|:---|:---| +| `device.energy()` | None | Get current energy data for any inverter, including power and energy values. | +| `device.detail()` | None | Get detailed data for any inverter. | +| `device.energy_history(start_date=None, end_date=None, timezone=None, page=None, limit=None)` | start_date: Date, end_date: Date, timezone: String, page: Int, limit: Int | Get energy history data for any inverter (7-day max range). | +| `device.read_parameter(parameter_id, start_address=None, end_address=None)` | parameter_id: String, start_address: Int, end_address: Int | Read a specific setting for any inverter. | +| `device.write_parameter(parameter_id, parameter_values)` | parameter_id: String, parameter_values: Dict/Array | Set parameters on any inverter. Parameter values can be a single value, a list, or a dictionary. | + +For more details see: [OpenApiV1 Devices](./openapiv1/devices.md) + +The remaining methods below all actually use these device methods. + #### MIN Methods Methods for MIN devices (type 7). @@ -73,7 +103,7 @@ Convenience methods that wrap the core SPH methods above for common use cases. #### Classic methods -Methods from [classic API](./shinephone.md#methods) should be available, but it's safer to rely on the functions described in this section where possible. There is no guarantee that the classic API methods will work, or remain stable through updates. +Methods from [classic API](./shinephone.md#methods) should be available, but it's safer to rely on the methods described in this section where possible. There is no guarantee that the classic API methods will work, or remain stable through updates. ### Variables diff --git a/docs/openapiv1/devices.md b/docs/openapiv1/devices.md new file mode 100644 index 0000000..89d1231 --- /dev/null +++ b/docs/openapiv1/devices.md @@ -0,0 +1,46 @@ +# OpenAPI V1 - Devices + +Devices offer a generic way to interact with your device using the V1 API without needing to provide your S/N every time. And can be used instead of the more specific device functions in the API class. + +```python +import growattServer +from growattServer.open_api_v1.devices import Sph, Min + +api = growattServer.OpenApiV1(token="YOUR_API_TOKEN") + +device = Sph(api, 'YOUR_DEVICE_SERIAL_NUMBER') # or Min(api, 'YOUR_DEVICE_SERIAL_NUMBER') +device.detail() +device.energy() +device.energy_history() +device.read_parameter() +device.write_parameter() +``` + +If you do not know your devices type, but do have their type id this method will provide you with the correct device class to use + +``` +import growattServer + +api = growattServer.OpenApiV1(token="YOUR_API_TOKEN") +device = api.get_device(device_sn, device_type) +if device is not None: + device.detail() + device.energy() + device.energy_history() + device.read_parameter() + device.write_parameter() +``` + +The basic methods are described here + +| Method | Arguments | Description | +|:---|:---|:---| +| `device.energy()` | None | Get current energy data for any inverter, including power and energy values. | +| `device.detail()` | None | Get detailed data for any inverter. | +| `device.energy_history(start_date=None, end_date=None, timezone=None, page=None, limit=None)` | start_date: Date, end_date: Date, timezone: String, page: Int, limit: Int | Get energy history data for any inverter (7-day max range). | +| `device.read_parameter(parameter_id, start_address=None, end_address=None)` | parameter_id: String, start_address: Int, end_address: Int | Read a specific setting for any inverter. | +| `device.write_parameter(parameter_id, parameter_values)` | parameter_id: String, parameter_values: Dict/Array | Set parameters on any inverter. Parameter values can be a single value, a list, or a dictionary. | + +However some device classes harbor more methods, which will be described in their respective readmes: +- [SPH/MIX](./devices/sph.md) +- [Min/TLX](./devices/min.md) \ No newline at end of file diff --git a/docs/openapiv1/devices/min.md b/docs/openapiv1/devices/min.md new file mode 100644 index 0000000..0152150 --- /dev/null +++ b/docs/openapiv1/devices/min.md @@ -0,0 +1,14 @@ +# OpenAPI V1 - Min/TLX Device (device type 7) + +The Min device offers the following methods + +| Method | Arguments | Description | +|:---|:---|:---| +| `device.energy()` | None | Get current energy data for a min inverter, including power and energy values. | +| `device.detail()` | None | Get detailed data for a min inverter. | +| `device.energy_history(start_date=None, end_date=None, timezone=None, page=None, limit=None)` | start_date: Date, end_date: Date, timezone: String, page: Int, limit: Int | Get energy history data for a min inverter (7-day max range). | +| `device.settings()` | None | Get all settings for a min inverter. | +| `device.read_parameter(parameter_id, start_address=None, end_address=None)` | parameter_id: String, start_address: Int, end_address: Int | Read a specific setting for a min inverter. see: [details](../min_tlx_settings.md) | +| `device.write_parameter(parameter_id, parameter_values)` | parameter_id: String, parameter_values: Dict/Array | Set parameters on a min inverter. Parameter values can be a single value, a list, or a dictionary. see: [details](../min_tlx_settings.md) | +| `device.write_time_segment(segment_id, batt_mode, start_time, end_time, enabled=True)` | segment_id: Int, batt_mode: Int <0=load priority, 1=battery priority, 2=grid priority>, start_time: datetime.time, end_time: datetime.time, enabled: Bool | Update a specific time segment for a min inverter. see: [details](../min_tlx_settings.md) | +| `device.read_time_segments(settings_data=None)` | settings_data: Dict | Read all time segments from a MIN inverter. Optionally pass settings_data to avoid redundant API calls. see: [details](../min_tlx_settings.md) | \ No newline at end of file diff --git a/docs/openapiv1/devices/sph.md b/docs/openapiv1/devices/sph.md new file mode 100644 index 0000000..670d45f --- /dev/null +++ b/docs/openapiv1/devices/sph.md @@ -0,0 +1,22 @@ +# OpenAPI V1 - SPH/MIX Device (device type 5) + +The SPH device offers the following methods + +| Method | Arguments | Description | +|:---|:---|:---| +| `device.detail()` | None | Get detailed data and settings for an SPH hybrid inverter. see: [details](../sph_settings.md) | +| `device.energy()` | None | Get current energy data for an SPH inverter, including power and energy values. | +| `device.energy_history(start_date=None, end_date=None, timezone=None, page=None, limit=None)` | start_date: Date, end_date: Date, timezone: String, page: Int, limit: Int | Get energy history data for an SPH inverter (7-day max range). | +| `device.read_parameter(parameter_id=None, start_address=None, end_address=None)` | parameter_id: String (optional), start_address: Int (optional), end_address: Int (optional) | Read a specific parameter (only pv_on_off supported). see: [details](../sph_settings.md) | +| `device.write_parameter(parameter_id, parameter_values)` | parameter_id: String, parameter_values: Dict/Array | Set parameters on an SPH inverter. see: [details](../sph_settings.md) | + +#### SPH Helper Methods + +Convenience methods that wrap the core SPH methods above for common use cases. + +| Method | Arguments | Description | +|:---|:---|:---| +| `device.write_ac_charge_times(...)` | device_sn, charge_power, charge_stop_soc, mains_enabled, periods | Helper: wraps `sph_write_parameter()` with type `mix_ac_charge_time_period`. see: [details](../sph_settings.md) | +| `device.write_ac_discharge_times(...)` | device_sn, discharge_power, discharge_stop_soc, periods | Helper: wraps `sph_write_parameter()` with type `mix_ac_discharge_time_period`. see: [details](../sph_settings.md) | +| `device.read_ac_charge_times(...)` | device_sn (optional), settings_data (optional) | Helper: parses charge config from `sph_detail()` response. see: [details](../sph_settings.md) | +| `device.read_ac_discharge_times(...)` | device_sn (optional), settings_data (optional) | Helper: parses discharge config from `sph_detail()` response. see: [details](../sph_settings.md) | \ No newline at end of file From 0fd2b41bab87019de02703326bdb578f3cf63c91 Mon Sep 17 00:00:00 2001 From: indy koning Date: Thu, 12 Feb 2026 17:03:52 +0100 Subject: [PATCH 11/13] Fixed SPH creation --- docs/openapiv1.md | 4 ++-- docs/openapiv1/sph_settings.md | 2 +- examples/sph_example.py | 2 ++ growattServer/open_api_v1/__init__.py | 22 +++++++++++----------- growattServer/open_api_v1/devices/min.py | 6 +++--- growattServer/open_api_v1/devices/sph.py | 20 ++++++++++---------- 6 files changed, 29 insertions(+), 27 deletions(-) diff --git a/docs/openapiv1.md b/docs/openapiv1.md index 767613a..866302d 100644 --- a/docs/openapiv1.md +++ b/docs/openapiv1.md @@ -98,8 +98,8 @@ Convenience methods that wrap the core SPH methods above for common use cases. |:---|:---|:---| | `api.sph_write_ac_charge_times(...)` | device_sn, charge_power, charge_stop_soc, mains_enabled, periods | Helper: wraps `sph_write_parameter()` with type `mix_ac_charge_time_period`. see: [details](./openapiv1/sph_settings.md) | | `api.sph_write_ac_discharge_times(...)` | device_sn, discharge_power, discharge_stop_soc, periods | Helper: wraps `sph_write_parameter()` with type `mix_ac_discharge_time_period`. see: [details](./openapiv1/sph_settings.md) | -| `api.sph_read_ac_charge_times(...)` | device_sn (optional), settings_data (optional) | Helper: parses charge config from `sph_detail()` response. see: [details](./openapiv1/sph_settings.md) | -| `api.sph_read_ac_discharge_times(...)` | device_sn (optional), settings_data (optional) | Helper: parses discharge config from `sph_detail()` response. see: [details](./openapiv1/sph_settings.md) | +| `api.sph_read_ac_charge_times(...)` | device_sn, settings_data (optional) | Helper: parses charge config from `sph_detail()` response. see: [details](./openapiv1/sph_settings.md) | +| `api.sph_read_ac_discharge_times(...)` | device_sn, settings_data (optional) | Helper: parses discharge config from `sph_detail()` response. see: [details](./openapiv1/sph_settings.md) | #### Classic methods diff --git a/docs/openapiv1/sph_settings.md b/docs/openapiv1/sph_settings.md index 6d46df1..4ec37b2 100644 --- a/docs/openapiv1/sph_settings.md +++ b/docs/openapiv1/sph_settings.md @@ -184,7 +184,7 @@ For SPH (hybrid inverter) systems, the public V1 API provides methods to read an ### Read: `api.sph_read_ac_charge_times` * parameters: - * `device_sn`: The device serial number (not used if settings_data is provided) + * `device_sn`: The device serial number * `settings_data`: Settings data from sph_detail() (not used if device_sn is provided) * note: Either `device_sn` or `settings_data` must be provided * returns: Dict with `charge_power`, `charge_stop_soc`, `mains_enabled`, and `periods` list diff --git a/examples/sph_example.py b/examples/sph_example.py index 03fe716..9f110a0 100644 --- a/examples/sph_example.py +++ b/examples/sph_example.py @@ -76,6 +76,7 @@ # Read AC charge time periods using helper function and inverter_data to avoid rate limiting charge_config = api.sph_read_ac_charge_times( + device_sn=inverter_sn, settings_data=inverter_data, ) print("AC Charge Configuration:") @@ -86,6 +87,7 @@ # Read AC discharge time periods using helper function and inverter_data to avoid rate limiting discharge_config = api.sph_read_ac_discharge_times( + device_sn=inverter_sn, settings_data=inverter_data, ) print("AC Discharge Configuration:") diff --git a/growattServer/open_api_v1/__init__.py b/growattServer/open_api_v1/__init__.py index 5e16889..51a524e 100644 --- a/growattServer/open_api_v1/__init__.py +++ b/growattServer/open_api_v1/__init__.py @@ -320,9 +320,9 @@ def get_device(self, device_sn: str, device_type: int) -> AbstractDevice|None: """Get the device class by serial number and device_type id.""" match device_type: case Sph.DEVICE_TYPE_ID: - return Sph(device_sn) + return Sph(self, device_sn) case Min.DEVICE_TYPE_ID: - return Min(device_sn) + return Min(self, device_sn) case _: warnings.warn(f"Device for type id: {device_type} has not been implemented yet.", stacklevel=2) return None @@ -524,7 +524,7 @@ def sph_detail(self, device_sn): requests.exceptions.RequestException: If there is an issue with the HTTP request. """ - return Sph(device_sn).detail() + return Sph(self, device_sn).detail() def sph_energy(self, device_sn): """ @@ -541,7 +541,7 @@ def sph_energy(self, device_sn): requests.exceptions.RequestException: If there is an issue with the HTTP request. """ - return Sph(device_sn).energy() + return Sph(self, device_sn).energy() def sph_energy_history(self, device_sn, start_date=None, end_date=None, timezone=None, page=None, limit=None): """ @@ -564,7 +564,7 @@ def sph_energy_history(self, device_sn, start_date=None, end_date=None, timezone requests.exceptions.RequestException: If there is an issue with the HTTP request. """ - return Sph(device_sn).energy_history(start_date, end_date, timezone, page, limit) + return Sph(self, device_sn).energy_history(start_date, end_date, timezone, page, limit) def sph_read_parameter(self, device_sn, parameter_id=None, start_address=None, end_address=None): """ @@ -585,7 +585,7 @@ def sph_read_parameter(self, device_sn, parameter_id=None, start_address=None, e requests.exceptions.RequestException: If there is an issue with the HTTP request. """ - return Sph(device_sn).read_parameter(parameter_id, start_address, end_address) + return Sph(self, device_sn).read_parameter(parameter_id, start_address, end_address) def sph_write_parameter(self, device_sn, parameter_id, parameter_values=None): """ @@ -607,7 +607,7 @@ def sph_write_parameter(self, device_sn, parameter_id, parameter_values=None): requests.exceptions.RequestException: If there is an issue with the HTTP request. """ - return Sph(device_sn).write_parameter(parameter_id, parameter_values) + return Sph(self, device_sn).write_parameter(parameter_id, parameter_values) def sph_write_ac_charge_times(self, device_sn, charge_power, charge_stop_soc, mains_enabled, periods): """ @@ -647,7 +647,7 @@ def sph_write_ac_charge_times(self, device_sn, charge_power, charge_stop_soc, ma requests.exceptions.RequestException: If there is an issue with the HTTP request. """ - return Sph(device_sn).write_ac_charge_times(charge_power, charge_stop_soc, mains_enabled, periods) + return Sph(self, device_sn).write_ac_charge_times(charge_power, charge_stop_soc, mains_enabled, periods) def sph_write_ac_discharge_times(self, device_sn, discharge_power, discharge_stop_soc, periods): """ @@ -685,7 +685,7 @@ def sph_write_ac_discharge_times(self, device_sn, discharge_power, discharge_sto requests.exceptions.RequestException: If there is an issue with the HTTP request. """ - return Sph(device_sn).write_ac_discharge_times(discharge_power, discharge_stop_soc, periods) + return Sph(self, device_sn).write_ac_discharge_times(discharge_power, discharge_stop_soc, periods) def sph_read_ac_charge_times(self, device_sn, settings_data=None): """ @@ -729,7 +729,7 @@ def sph_read_ac_charge_times(self, device_sn, settings_data=None): requests.exceptions.RequestException: If there is an issue with the HTTP request. """ - return Sph(device_sn).read_ac_charge_times(settings_data) + return Sph(self, device_sn).read_ac_charge_times(settings_data) def sph_read_ac_discharge_times(self, device_sn, settings_data=None): """ @@ -772,4 +772,4 @@ def sph_read_ac_discharge_times(self, device_sn, settings_data=None): requests.exceptions.RequestException: If there is an issue with the HTTP request. """ - return Sph(device_sn).read_ac_discharge_times(device_sn, settings_data) + return Sph(self, device_sn).read_ac_discharge_times(device_sn, settings_data) diff --git a/growattServer/open_api_v1/devices/min.py b/growattServer/open_api_v1/devices/min.py index 4eb754f..ca1737b 100644 --- a/growattServer/open_api_v1/devices/min.py +++ b/growattServer/open_api_v1/devices/min.py @@ -1,5 +1,5 @@ """Min/TLX device file.""" -from datetime import datetime, timedelta +from datetime import UTC, datetime, timedelta from typing import Any from growattServer.exceptions import GrowattParameterError @@ -84,8 +84,8 @@ def energy_history(self, start_date=None, end_date=None, timezone=None, page=Non """ if start_date is None and end_date is None: - start_date = datetime.now(datetime.UTC).date() - end_date = datetime.now(datetime.UTC).date() + start_date = datetime.now(UTC).date() + end_date = datetime.now(UTC).date() elif start_date is None: start_date = end_date elif end_date is None: diff --git a/growattServer/open_api_v1/devices/sph.py b/growattServer/open_api_v1/devices/sph.py index 6ba83f9..c74a0fa 100644 --- a/growattServer/open_api_v1/devices/sph.py +++ b/growattServer/open_api_v1/devices/sph.py @@ -1,5 +1,5 @@ """SPH/MIX device file.""" -from datetime import datetime, timedelta +from datetime import UTC, datetime, timedelta from growattServer.exceptions import GrowattParameterError @@ -27,7 +27,7 @@ def detail(self): """ # API: https://www.showdoc.com.cn/262556420217021/6129763571291058 - response = self.session.get( + response = self.api.session.get( self.get_url("device/mix/mix_data_info"), params={ "device_sn": self.device_sn @@ -52,7 +52,7 @@ def energy(self): """ # API: https://www.showdoc.com.cn/262556420217021/6129764475556048 - response = self.session.post( + response = self.api.session.post( url=self.get_url("device/mix/mix_last_data"), data={ "mix_sn": self.device_sn, @@ -83,8 +83,8 @@ def energy_history(self, start_date=None, end_date=None, timezone=None, page=Non """ if start_date is None and end_date is None: - start_date = datetime.now(datetime.UTC).date() - end_date = datetime.now(datetime.UTC).date() + start_date = datetime.now(UTC).date() + end_date = datetime.now(UTC).date() elif start_date is None: start_date = end_date elif end_date is None: @@ -95,7 +95,7 @@ def energy_history(self, start_date=None, end_date=None, timezone=None, page=Non raise GrowattParameterError("date interval must not exceed 7 days") # API: https://www.showdoc.com.cn/262556420217021/6129765461123058 - response = self.session.post( + response = self.api.session.post( url=self.get_url("device/mix/mix_data"), data={ "mix_sn": self.device_sn, @@ -144,7 +144,7 @@ def read_parameter(self, parameter_id=None, start_address=None, end_address=None parameter_id = "set_any_reg" # API: https://www.showdoc.com.cn/262556420217021/6129766954561259 - response = self.session.post( + response = self.api.session.post( self.get_url("readMixParam"), data={ "device_sn": self.device_sn, @@ -208,7 +208,7 @@ def write_parameter(self, parameter_id, parameter_values=None): request_data[f"param{i}"] = str(parameters[i]) # API: https://www.showdoc.com.cn/262556420217021/6129761750718760 - response = self.session.post( + response = self.api.session.post( self.get_url("mixSet"), data=request_data ) @@ -281,7 +281,7 @@ def write_ac_charge_times(self, charge_power, charge_stop_soc, mains_enabled, pe request_data[f"param{base + 4}"] = "1" if period["enabled"] else "0" # API: https://www.showdoc.com.cn/262556420217021/6129761750718760 - response = self.session.post( + response = self.api.session.post( self.get_url("mixSet"), data=request_data ) @@ -351,7 +351,7 @@ def write_ac_discharge_times(self, discharge_power, discharge_stop_soc, periods) request_data[f"param{base + 4}"] = "1" if period["enabled"] else "0" # API: https://www.showdoc.com.cn/262556420217021/6129761750718760 - response = self.session.post( + response = self.api.session.post( self.get_url("mixSet"), data=request_data ) From 7d696da406c2eca2500ba079c06624dca93c9f61 Mon Sep 17 00:00:00 2001 From: indy koning Date: Thu, 12 Feb 2026 22:55:09 +0100 Subject: [PATCH 12/13] Fixed get url and process response --- growattServer/open_api_v1/devices/sph.py | 28 ++++++++++++------------ 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/growattServer/open_api_v1/devices/sph.py b/growattServer/open_api_v1/devices/sph.py index c74a0fa..5bdf24e 100644 --- a/growattServer/open_api_v1/devices/sph.py +++ b/growattServer/open_api_v1/devices/sph.py @@ -28,13 +28,13 @@ def detail(self): """ # API: https://www.showdoc.com.cn/262556420217021/6129763571291058 response = self.api.session.get( - self.get_url("device/mix/mix_data_info"), + self.api.get_url("device/mix/mix_data_info"), params={ "device_sn": self.device_sn } ) - return self.process_response(response.json(), "getting SPH inverter details") + return self.api.process_response(response.json(), "getting SPH inverter details") def energy(self): """ @@ -53,13 +53,13 @@ def energy(self): """ # API: https://www.showdoc.com.cn/262556420217021/6129764475556048 response = self.api.session.post( - url=self.get_url("device/mix/mix_last_data"), + url=self.api.get_url("device/mix/mix_last_data"), data={ "mix_sn": self.device_sn, }, ) - return self.process_response(response.json(), "getting SPH inverter energy data") + return self.api.process_response(response.json(), "getting SPH inverter energy data") def energy_history(self, start_date=None, end_date=None, timezone=None, page=None, limit=None): """ @@ -96,7 +96,7 @@ def energy_history(self, start_date=None, end_date=None, timezone=None, page=Non # API: https://www.showdoc.com.cn/262556420217021/6129765461123058 response = self.api.session.post( - url=self.get_url("device/mix/mix_data"), + url=self.api.get_url("device/mix/mix_data"), data={ "mix_sn": self.device_sn, "start_date": start_date.strftime("%Y-%m-%d"), @@ -107,7 +107,7 @@ def energy_history(self, start_date=None, end_date=None, timezone=None, page=Non } ) - return self.process_response(response.json(), "getting SPH inverter energy history") + return self.api.process_response(response.json(), "getting SPH inverter energy history") def read_parameter(self, parameter_id=None, start_address=None, end_address=None): """ @@ -145,7 +145,7 @@ def read_parameter(self, parameter_id=None, start_address=None, end_address=None # API: https://www.showdoc.com.cn/262556420217021/6129766954561259 response = self.api.session.post( - self.get_url("readMixParam"), + self.api.get_url("readMixParam"), data={ "device_sn": self.device_sn, "paramId": parameter_id, @@ -154,7 +154,7 @@ def read_parameter(self, parameter_id=None, start_address=None, end_address=None } ) - return self.process_response(response.json(), f"reading parameter {parameter_id}") + return self.api.process_response(response.json(), f"reading parameter {parameter_id}") def write_parameter(self, parameter_id, parameter_values=None): """ @@ -209,11 +209,11 @@ def write_parameter(self, parameter_id, parameter_values=None): # API: https://www.showdoc.com.cn/262556420217021/6129761750718760 response = self.api.session.post( - self.get_url("mixSet"), + self.api.get_url("mixSet"), data=request_data ) - return self.process_response(response.json(), f"writing parameter {parameter_id}") + return self.api.process_response(response.json(), f"writing parameter {parameter_id}") def write_ac_charge_times(self, charge_power, charge_stop_soc, mains_enabled, periods): """ @@ -282,11 +282,11 @@ def write_ac_charge_times(self, charge_power, charge_stop_soc, mains_enabled, pe # API: https://www.showdoc.com.cn/262556420217021/6129761750718760 response = self.api.session.post( - self.get_url("mixSet"), + self.api.get_url("mixSet"), data=request_data ) - return self.process_response(response.json(), "writing AC charge time periods") + return self.api.process_response(response.json(), "writing AC charge time periods") def write_ac_discharge_times(self, discharge_power, discharge_stop_soc, periods): """ @@ -352,11 +352,11 @@ def write_ac_discharge_times(self, discharge_power, discharge_stop_soc, periods) # API: https://www.showdoc.com.cn/262556420217021/6129761750718760 response = self.api.session.post( - self.get_url("mixSet"), + self.api.get_url("mixSet"), data=request_data ) - return self.process_response(response.json(), "writing AC discharge time periods") + return self.api.process_response(response.json(), "writing AC discharge time periods") def _parse_time_periods(self, settings_data, time_type): """ From ff53c92a090089c745c88c123edc36a390290a83 Mon Sep 17 00:00:00 2001 From: indy koning Date: Fri, 13 Feb 2026 10:33:39 +0100 Subject: [PATCH 13/13] Removed sn from function call on sph device --- growattServer/open_api_v1/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/growattServer/open_api_v1/__init__.py b/growattServer/open_api_v1/__init__.py index 51a524e..6ec2bd4 100644 --- a/growattServer/open_api_v1/__init__.py +++ b/growattServer/open_api_v1/__init__.py @@ -772,4 +772,4 @@ def sph_read_ac_discharge_times(self, device_sn, settings_data=None): requests.exceptions.RequestException: If there is an issue with the HTTP request. """ - return Sph(self, device_sn).read_ac_discharge_times(device_sn, settings_data) + return Sph(self, device_sn).read_ac_discharge_times(settings_data)