diff --git a/README.md b/README.md index dc01c8b..f6e1e3c 100755 --- a/README.md +++ b/README.md @@ -28,8 +28,12 @@ Any methods that may be useful. `api.plant_info(plant_id)` Get info for specified plant. +`api.plant_settings(plant_id)` Get the current settings for the specified plant + `api.plant_detail(plant_id, timespan<1=day, 2=month>, date)` Get details of a specific plant. +`api.plant_energy_data(plant_id)` Get energy data for the specified plant. + `api.inverter_list(plant_id)` Get a list of inverters in specified plant. (May be deprecated in the future, since it gets all devices. Use `device_list` instead). `api.device_list(plant_id)` Get a list of devices in specified plant. @@ -38,10 +42,26 @@ Any methods that may be useful. `api.inverter_detail(inverter_id)` Get detailed data on inverter. +`api.tlx_system_status(plant_id, tlx_id)` Get system status. + +`api.tlx_energy_overview(plant_id, tlx_id)` Get energy overview of the system. + +`api.tlx_energy_prod_cons(plant_id, tlx_id)` Get energy production and consumption for the system. + `api.tlx_data(tlx_id, date)` Get some basic data of a specific date for the tlx type inverter. `api.tlx_detail(tlx_id)` Get detailed data on a tlx type inverter. +`api.tlx_params(tlx_id)` Get parameters for the tlx type inverter. + +`api.tlx_get_all_settings(tlx_id)` Get all possible settings for the tlx type inverter. + +`api.tlx_get_enabled_settings(tlx_id)` Get all enabled settings for the tlx type inverter. + +`api.tlx_battery_info(serial_num)` Get battery info for tlx systems. + +`api.tlx_battery_info_detailed(serial_num)` Get detailed battery info. + `api.mix_info(mix_id, plant_id=None)` Get high level information about the Mix system including daily and overall totals. NOTE: `plant_id` is an optional parameter, it does not appear to be used by the remote API, but is used by the mobile app these calls were reverse-engineered from. `api.mix_totals(mix_id, plant_id)` Get daily and overall total information for the Mix system (duplicates some of the information from `mix_info`). @@ -58,8 +78,6 @@ Any methods that may be useful. `api.storage_energy_overview(plant_id, storage_id)` Get the information you see in the "Generation overview". -`api.get_plant_settings(plant_id)` Get the current settings for the specified plant - `api.is_plant_noah_system(plant_id)` Get the Information if noah devices are configured for the specified plant `api.noah_system_status(serial_number)` Get the current status for the specified noah device e.g. workMode, soc, chargePower, disChargePower, current import/export etc. @@ -68,6 +86,10 @@ Any methods that may be useful. `api.update_plant_settings(plant_id, changed_settings, current_settings)` Update the settings for a plant to the values specified in the dictionary, if the `current_settings` are not provided it will look them up automatically using the `get_plant_settings` function - See 'Plant settings' below for more information +`api.update_tlx_inverter_setting(serial_number, setting_type, parameter)` Applies the provided parameter for the specified setting on the specified tlx inverter; see 'Inverter settings' below for more information. + +`api.update_tlx_inverter_time_segment(serial_number, segment_id, batt_mode, start_time, end_time, enabled)` Updates one of the 9 time segments with the specified battery mode (load, battery, grid first); see 'Inverter settings' below for more information. + `api.update_mix_inverter_setting(serial_number, setting_type, parameters)` Applies the provided parameters (dictionary or array) for the specified setting on the specified mix inverter; see 'Inverter settings' below for more information `api.update_ac_inverter_setting(serial_number, setting_type, parameters)` Applies the provided parameters (dictionary or array) for the specified setting on the specified AC-coupled inverter; see 'Inverter settings' below for more information @@ -137,7 +159,7 @@ The plant settings function(s) allow you to re-configure the settings for a spec The function `update_plant_settings` allows you to provide a python dictionary of any/all of the above settings and change their value. ## Inverter Settings -NOTE: The inverter settings function appears to only work with 'mix' systems based on the API call that it makes being specific to 'mix' inverters +NOTE: The inverter settings function appears to only work with 'mix' and 'tlx' systems based on the API call that it makes being specific to those inverter types The inverter settings function(s) allow you to change individual values on your inverter e.g. time, charging period etc. From what has been reverse engineered from the api, each setting has a `setting_type` and a set of `parameters` that are relevant to it. @@ -191,8 +213,32 @@ Known working settings & parameters are as follows (all parameter values are str * `param15`: Schedule 3 - End time - Hour e.g. "02" (2am) * `param16`: Schedule 3 - End time - Minute e.g. "00" (0 minutes) * `param17`: Schedule 3 - Enabled/Disabled (0 = Disabled, 1 = Enabled) - -The three functions `update_mix_inverter_setting`, `update_ac_inverter_setting`, and `update_inverter_setting` take either a dictionary or an array. If an array is passed it will automatically generate the `paramN` key based on array index since all params for settings seem to used the same numbering scheme. +* **TLX inverter settings** + * function: `api.update_tlx_inverter_setting` + * type: `charge_power` + * param1: Charging power % (value between 0 and 100) + * type: `charge_stop_soc` + * param1: Charge Stop SOC + * type: `discharge_power` + * param1: Discharging power % (value between 0 and 100) + * type: `on_grid_discharge_stop_soc` + * param1: On-grid discharge Stop SOC + * type: `discharge_stop_soc` + * param1: Off-grid discharge Stop SOC + * type: `ac_charge` + * param1: Allow AC (grid) charging (0 = Disabled, 1 = Enabled) + * type: `pf_sys_year` + * param1: datetime in format: `YYYY-MM-DD HH:MM:SS` + * function: `api.update_tlx_inverter_time_segment` + * segment_id: The segment to update (1-9) + * batt_mode: Battery Mode for the segment: 0=Load First(Self-Consumption), 1=Battery First, 2=Grid First + * start_time: timedate object with start time of segment with format HH:MM + * end_time: timedate object with end time of segment with format HH:MM + * enabled: time segment enabled, boolean: True (Enabled), False (Disabled) + +The four functions `update_tlx_inverter_setting`, `update_mix_inverter_setting`, `update_ac_inverter_setting`, and `update_inverter_setting` take either a dictionary or an array. If an array is passed it will automatically generate the `paramN` key based on array index since all params for settings seem to used the same numbering scheme. + +Only the settings described above have been tested with `update_tlx_inverter_setting` and they all take only one single parameter. It is very likely that the function works with all settings returned by `tlx_get_enabled_settings`, but this has not been tested. A helper function `update_tlx_inverter_time_segment` is provided for the settings that require more than one parameter. ## Noah Settings The noah settings function allow you to change individual values on your noah system e.g. system default output power, battery management, operation mode and currency diff --git a/examples/tlx_example.py b/examples/tlx_example.py new file mode 100644 index 0000000..6488cee --- /dev/null +++ b/examples/tlx_example.py @@ -0,0 +1,110 @@ +import growattServer +import datetime +import getpass +import json + +""" +# 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 +# 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 +# tlx_get_enabled_settings() can be set using update_tlx_inverter_setting(), but has not been tested. +# +""" + +# Prompt user for username +username=input("Enter username:") + +# 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)' +api = growattServer.GrowattApi(agent_identifier=user_agent) + +login_response = api.login(username, user_pass) +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_info = api.plant_info(plant_id) +print("Plant info:", json.dumps(plant_info, indent=4, sort_keys=True)) + +# Energy data (used in the 'Plant' Tab) +energy_data = api.plant_energy_data(plant_id) +print("Plant Energy data", json.dumps(energy_data, indent=4, sort_keys=True)) + +# Devices +devices = api.device_list(plant_id) +print("Devices:", json.dumps(devices, indent=4, sort_keys=True)) + +for device in devices: + if device['deviceType'] == 'tlx': + # Inverter info (used in inverter view) + inverter_sn = device['deviceSn'] + inverter_info = api.tlx_params(inverter_sn) + print("Inverter info:", json.dumps(inverter_info, indent=4, sort_keys=True)) + + # PV production data + data = api.tlx_data(inverter_sn, datetime.datetime.now()) + print("PV production data:", json.dumps(data, indent=4, sort_keys=True)) + + # System settings + all_settings = api.tlx_all_settings(inverter_sn) + 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() + 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)) + + # System status + data = api.tlx_system_status(plant_id, inverter_sn) + print("System status:", json.dumps(data, indent=4, sort_keys=True)) + + # 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': + # Battery info + 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']) + print("Battery info: detailed", json.dumps(batt_info_detailed, indent=4, sort_keys=True)) + + +# Examples of updating settings, uncomment to use + +# Set charging power to 95% +#res = api.update_tlx_inverter_setting(inverter_sn, 'charge_power', 95) +#print(res) + +# Turn on AC charging +#res = api.update_tlx_inverter_setting(inverter_sn, 'ac_charge', 1) +#print(res) + +# Enable Load First between 00:01 and 11:59 using time segment 1 +#res = api.update_tlx_inverter_time_segment(serial_number = inverter_sn, +# segment_id = 1, +# batt_mode = growattServer.BATT_MODE_LOAD_FIRST, +# start_time = datetime.time(00, 1), +# end_time = datetime.time(11, 59), +# enabled=True) +#print(res) \ No newline at end of file diff --git a/examples/tlx_example_dashboard.py b/examples/tlx_example_dashboard.py new file mode 100644 index 0000000..1c93528 --- /dev/null +++ b/examples/tlx_example_dashboard.py @@ -0,0 +1,127 @@ + +import growattServer +import getpass + +# 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 +# tlx_system_status(), tlx_energy_overview() and tlx_battery_info_detailed() instead + + +# Prompt user for username +username=input("Enter username:") + +# Prompt user to input password +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)' +api = growattServer.GrowattApi(agent_identifier=user_agent) +login_response = api.login(username, user_pass) +if not login_response['success']: + print(f"Failed to log in, msg: {login_response['msg']}, error: {login_response['error']}") + exit() + +# Get plant(s) +plant_list = api.plant_list_two() +plant_id = plant_list[0]['id'] + +# Get devices in plant +devices = api.device_list(plant_id) + +# 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'] + + # Inverter detail, contains the bulk of energy and power values + 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) + + # 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'): + # Disconnected batteries are listed with 'old' power/energy/SOC data + # 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') + + 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'] + } + 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}' +solar_production_pv2 = f'{float(inverter_detail["epv2Today"]):.1f}/{float(inverter_detail["epv2Total"]):.1f}' +energy_output = f'{float(inverter_detail["eacToday"]):.1f}/{float(inverter_detail["eacTotal"]):.1f}' +system_production = f'{float(inverter_detail["esystemToday"]):.1f}/{float(inverter_detail["esystemTotal"]):.1f}' +battery_charged = f'{float(inverter_detail["echargeToday"]):.1f}/{float(inverter_detail["echargeTotal"]):.1f}' +battery_grid_charge = f'{float(inverter_detail["eacChargeToday"]):.1f}/{float(inverter_detail["eacChargeTotal"]):.1f}' +battery_discharged = f'{float(inverter_detail["edischargeToday"]):.1f}/{float(inverter_detail["edischargeTotal"]):.1f}' +exported_to_grid = f'{float(inverter_detail["etoGridToday"]):.1f}/{float(inverter_detail["etoGridTotal"]):.1f}' +imported_from_grid = f'{float(inverter_detail["etoUserToday"]):.1f}/{float(inverter_detail["etoUserTotal"]):.1f}' +load_consumption = f'{float(inverter_detail["elocalLoadToday"]):.1f}/{float(inverter_detail["elocalLoadTotal"]):.1f}' +self_consumption = f'{float(inverter_detail["eselfToday"]):.1f}/{float(inverter_detail["eselfTotal"]):.1f}' +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("\nPower overview (Watts)") +print(f'AC Power {float(inverter_detail["pac"]):>22.1f}') +print(f'Self power {float(inverter_detail["pself"]):>22.1f}') +print(f'Export power {float(inverter_detail["pacToGridTotal"]):>22.1f}') +print(f'Import power {float(inverter_detail["pacToUserTotal"]):>22.1f}') +print(f'Local load power {float(inverter_detail["pacToLocalLoad"]):>22.1f}') +print(f'PV power {float(inverter_detail["psystem"]):>22.1f}') +print(f'PV #1 power {float(inverter_detail["ppv1"]):>22.1f}') +print(f'PV #2 power {float(inverter_detail["ppv2"]):>22.1f}') +print(f'Battery charge power {float(system_status["chargePower"])*1000:>22.1f}') +if len(batteries_info) > 0: + print(f'Batt #1 charge power {float(batteries_info[0]["bdcChargePower"]):>22.1f}') +if len(batteries_info) > 1: + print(f'Batt #2 charge power {float(batteries_info[1]["bdcChargePower"]):>22.1f}') +print(f'Battery discharge power {float(system_status["pdisCharge"])*1000:>18.1f}') +if len(batteries_info) > 0: + print(f'Batt #1 discharge power {float(batteries_info[0]["bdcDischargePower"]):>22.1f}') +if len(batteries_info) > 1: + print(f'Batt #2 discharge power {float(batteries_info[1]["bdcDischargePower"]):>22.1f}') +if len(batteries_info) > 0: + print(f'Batt #1 SOC {int(batteries_info[0]["soc"]):>21}%') +if len(batteries_info) > 1: + print(f'Batt #2 SOC {int(batteries_info[1]["soc"]):>21}%') diff --git a/growattServer/__init__.py b/growattServer/__init__.py index bb8a685..cc53527 100755 --- a/growattServer/__init__.py +++ b/growattServer/__init__.py @@ -1,12 +1,15 @@ -name = "growattServer" - import datetime from enum import IntEnum -import hashlib -import json import requests -import warnings from random import randint +import warnings +import hashlib + +name = "growattServer" + +BATT_MODE_LOAD_FIRST = 0 +BATT_MODE_BATTERY_FIRST = 1 +BATT_MODE_GRID_FIRST = 2 def hash_password(password): """ @@ -46,10 +49,10 @@ def __init__(self, add_random_user_id=False, agent_identifier=None): def __get_date_string(self, timespan=None, date=None): if timespan is not None: - assert timespan in Timespan + assert timespan in Timespan if date is None: - date = datetime.datetime.now() + date = datetime.datetime.now() date_str="" if timespan == Timespan.month: @@ -61,7 +64,7 @@ def __get_date_string(self, timespan=None, date=None): 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 @@ -132,7 +135,8 @@ def login(self, username, password, is_password_hashed=False): 'userName': username, 'password': password }) - data = json.loads(response.content.decode('utf-8'))['back'] + + data = response.json()['back'] if data['success']: data.update({ 'userId': data['user']['id'], @@ -143,17 +147,38 @@ def login(self, username, password, is_password_hashed=False): def plant_list(self, user_id): """ Get a list of plants connected to this account. + + Args: + user_id (str): The ID of the user. + + Returns: + list: A list of plants connected to the account. + + Raises: + Exception: If the request to the server fails. """ - response = self.session.get(self.get_url('PlantListAPI.do'), - params={'userId': user_id}, - allow_redirects=False) + response = self.session.get( + self.get_url('PlantListAPI.do'), + params={'userId': user_id}, + allow_redirects=False + ) - data = json.loads(response.content.decode('utf-8')) - return data['back'] + return response.json().get('back', []) def plant_detail(self, plant_id, timespan, date=None): """ Get plant details for specified timespan. + + Args: + plant_id (str): The ID of the plant. + timespan (Timespan): The ENUM value conforming to the time window you want e.g. hours from today, days, or months. + date (datetime, optional): The date you are interested in. Defaults to datetime.datetime.now(). + + Returns: + dict: A dictionary containing the plant details. + + Raises: + Exception: If the request to the server fails. """ date_str = self.__get_date_string(timespan, date) @@ -162,12 +187,45 @@ def plant_detail(self, plant_id, timespan, date=None): 'type': timespan.value, 'date': date_str }) - data = json.loads(response.content.decode('utf-8')) - return data['back'] + + return response.json().get('back', {}) + + def plant_list_two(self): + """ + Get a list of all plants with detailed information. + + Returns: + list: A list of plants with detailed information. + """ + response = self.session.post( + self.get_url('newTwoPlantAPI.do'), + params={'op': 'getAllPlantListTwo'}, + data={ + 'language': '1', + 'nominalPower': '', + 'order': '1', + 'pageSize': '15', + 'plantName': '', + 'plantStatus': '', + 'toPageNum': '1' + } + ) + + return response.json().get('PlantList', []) def inverter_data(self, inverter_id, date=None): """ Get inverter data for specified date or today. + + Args: + inverter_id (str): The ID of the inverter. + date (datetime, optional): The date you are interested in. Defaults to datetime.datetime.now(). + + Returns: + dict: A dictionary containing the inverter data. + + 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={ @@ -176,36 +234,138 @@ def inverter_data(self, inverter_id, date=None): 'type': 1, 'date': date_str }) - data = json.loads(response.content.decode('utf-8')) - return data + + return response.json() def inverter_detail(self, inverter_id): """ - Get "All parameters" from PV inverter. + Get detailed data from PV inverter. + + Args: + inverter_id (str): The ID of the inverter. + + Returns: + dict: A dictionary containing the inverter details. + + Raises: + Exception: If the request to the server fails. """ response = self.session.get(self.get_url('newInverterAPI.do'), params={ 'op': 'getInverterDetailData', 'inverterId': inverter_id }) - data = json.loads(response.content.decode('utf-8')) - return data + return response.json() def inverter_detail_two(self, inverter_id): """ - Get "All parameters" from PV inverter. + Get detailed data from PV inverter (alternative endpoint). + + Args: + inverter_id (str): The ID of the inverter. + + Returns: + dict: A dictionary containing the inverter details. + + 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 }) - data = json.loads(response.content.decode('utf-8')) - return data + return response.json() + + def tlx_system_status(self, plant_id, tlx_id): + """ + Get status of the system + + Args: + plant_id (str): The ID of the plant. + tlx_id (str): The ID of the TLX inverter. + + Returns: + dict: A dictionary containing system status. + + Raises: + Exception: If the request to the server fails. + """ + response = self.session.post( + self.get_url("newTlxApi.do"), + params={"op": "getSystemStatus_KW"}, + data={"plantId": plant_id, + "id": tlx_id} + ) + + return response.json().get('obj', {}) + + def tlx_energy_overview(self, plant_id, tlx_id): + """ + Get energy overview + + Args: + plant_id (str): The ID of the plant. + tlx_id (str): The ID of the TLX inverter. + + Returns: + dict: A dictionary containing energy data. + + Raises: + Exception: If the request to the server fails. + """ + response = self.session.post( + self.get_url("newTlxApi.do"), + params={"op": "getEnergyOverview"}, + data={"plantId": plant_id, + "id": tlx_id} + ) + + 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) + + 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. + + Returns: + dict: A dictionary containing energy data. + + 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, + "plantId": plant_id, + "language": "1", + "id": tlx_id, + "type": timespan.value} + ) + + return response.json().get('obj', {}) def tlx_data(self, tlx_id, date=None): """ - Get inverter data for specified date or today. + Get TLX inverter data for specified date or today. + + Args: + tlx_id (str): The ID of the TLX inverter. + date (datetime, optional): The date you are interested in. Defaults to datetime.datetime.now(). + + Returns: + dict: A dictionary containing the TLX inverter data. + + 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={ @@ -214,20 +374,134 @@ def tlx_data(self, tlx_id, date=None): 'type': 1, 'date': date_str }) - data = json.loads(response.content.decode('utf-8')) - return data + + return response.json() def tlx_detail(self, tlx_id): """ - Get "All parameters" from PV inverter. + Get detailed data from TLX inverter. + + Args: + tlx_id (str): The ID of the TLX inverter. + + Returns: + dict: A dictionary containing the detailed TLX inverter data. + + Raises: + Exception: If the request to the server fails. """ response = self.session.get(self.get_url('newTlxApi.do'), params={ 'op': 'getTlxDetailData', 'id': tlx_id }) - data = json.loads(response.content.decode('utf-8')) - return data + return response.json() + + def tlx_params(self, tlx_id): + """ + Get parameters for TLX inverter. + + Args: + tlx_id (str): The ID of the TLX inverter. + + Returns: + dict: A dictionary containing the TLX inverter parameters. + + Raises: + Exception: If the request to the server fails. + """ + response = self.session.get(self.get_url('newTlxApi.do'), params={ + 'op': 'getTlxParams', + 'id': tlx_id + }) + + return response.json() + + def tlx_all_settings(self, tlx_id): + """ + Get all possible settings from TLX inverter. + + Args: + tlx_id (str): The ID of the TLX inverter. + + Returns: + dict: A dictionary containing all possible settings for the TLX inverter. + + Raises: + Exception: If the request to the server fails. + """ + response = self.session.post(self.get_url('newTlxApi.do'), params={ + 'op': 'getTlxSetData' + }, data={ + 'serialNum': tlx_id + }) + + return response.json().get('obj', {}).get('tlxSetBean') + + def tlx_enabled_settings(self, tlx_id): + """ + Get "Enabled settings" from TLX inverter. + + Args: + tlx_id (str): The ID of the TLX inverter. + + Returns: + dict: A dictionary containing the enabled settings. + + Raises: + Exception: If the request to the server fails. + """ + 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'} + ) + + return response.json().get('obj', {}) + + def tlx_battery_info(self, serial_num): + """ + Get battery information. + + Args: + serial_num (str): The serial number of the battery. + + Returns: + dict: A dictionary containing the battery information. + + 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} + ) + + return response.json().get('obj', {}) + + def tlx_battery_info_detailed(self, plant_id, serial_num): + """ + Get detailed battery information. + + Args: + plant_id (str): The ID of the plant. + serial_num (str): The serial number of the battery. + + Returns: + dict: A dictionary containing the detailed battery information. + + 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} + ) + + return response.json() def mix_info(self, mix_id, plant_id = None): """ @@ -270,8 +544,7 @@ def mix_info(self, mix_id, plant_id = None): response = self.session.get(self.get_url('newMixApi.do'), params=request_params) - data = json.loads(response.content.decode('utf-8')) - return data['obj'] + return response.json().get('obj', {}) def mix_totals(self, mix_id, plant_id): """ @@ -302,8 +575,7 @@ def mix_totals(self, mix_id, plant_id): 'plantId': plant_id }) - data = json.loads(response.content.decode('utf-8')) - return data['obj'] + return response.json().get('obj', {}) def mix_system_status(self, mix_id, plant_id): """ @@ -345,8 +617,7 @@ def mix_system_status(self, mix_id, plant_id): 'plantId': plant_id }) - data = json.loads(response.content.decode('utf-8')) - return data['obj'] + return response.json().get('obj', {}) def mix_detail(self, mix_id, plant_id, timespan=Timespan.hour, date=None): """ @@ -406,9 +677,8 @@ def mix_detail(self, mix_id, plant_id, timespan=Timespan.hour, date=None): 'type': timespan.value, 'date': date_str }) - data = json.loads(response.content.decode('utf-8')) - return data['obj'] + return response.json().get('obj', {}) def dashboard_data(self, plant_id, timespan=Timespan.hour, date=None): """ @@ -455,6 +725,8 @@ def dashboard_data(self, plant_id, timespan=Timespan.hour, date=None): '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) @@ -465,8 +737,24 @@ def dashboard_data(self, plant_id, timespan=Timespan.hour, date=None): 'plantId': plant_id }) - data = json.loads(response.content.decode('utf-8')) - return data + 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 + }) + + return response.json() def storage_detail(self, storage_id): """ @@ -477,8 +765,7 @@ def storage_detail(self, storage_id): 'storageId': storage_id }) - data = json.loads(response.content.decode('utf-8')) - return data + return response.json() def storage_params(self, storage_id): """ @@ -489,8 +776,7 @@ def storage_params(self, storage_id): 'storageId': storage_id }) - data = json.loads(response.content.decode('utf-8')) - return data + return response.json() def storage_energy_overview(self, plant_id, storage_id): """ @@ -501,8 +787,7 @@ def storage_energy_overview(self, plant_id, storage_id): 'storageSn': storage_id }) - data = json.loads(response.content.decode('utf-8')) - return data['obj'] + return response.json().get('obj', {}) def inverter_list(self, plant_id): """ @@ -511,11 +796,29 @@ def inverter_list(self, plant_id): warnings.warn("This function may be deprecated in the future because naming is not correct, use device_list instead", DeprecationWarning) 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}) + + return response.json().get('deviceList', {}) + def device_list(self, plant_id): """ Get a list of all devices connected to plant. """ - return self.plant_info(plant_id)['deviceList'] + + 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 + device_list = self.__get_all_devices(plant_id) + + return device_list def plant_info(self, plant_id): """ @@ -528,25 +831,18 @@ def plant_info(self, plant_id): 'pageSize': 1 }) - data = json.loads(response.content.decode('utf-8')) - return data + return response.json() - def get_plant_settings(self, plant_id): + def plant_energy_data(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 + Get the energy data used in the 'Plant' tab in the phone """ - response = self.session.get(self.get_url('newPlantAPI.do'), params={ - 'op': 'getPlant', - 'plantId': plant_id - }) - data = json.loads(response.content.decode('utf-8')) - return data + response = self.session.post(self.get_url('newTwoPlantAPI.do'), + params={'op': 'getUserCenterEnertyDataByPlantid'}, + data={ 'language': 1, + 'plantId': plant_id}) + + return response.json() def is_plant_noah_system(self, plant_id): """ @@ -568,8 +864,8 @@ def is_plant_noah_system(self, plant_id): response = self.session.post(self.get_url('noahDeviceApi/noah/isPlantNoahSystem'), data={ 'plantId': plant_id }) - data = json.loads(response.content.decode('utf-8')) - return data + return response.json() + def noah_system_status(self, serial_number): """ @@ -602,8 +898,8 @@ def noah_system_status(self, serial_number): response = self.session.post(self.get_url('noahDeviceApi/noah/getSystemStatus'), data={ 'deviceSn': serial_number }) - data = json.loads(response.content.decode('utf-8')) - return data + return response.json() + def noah_info(self, serial_number): """ @@ -648,8 +944,8 @@ def noah_info(self, serial_number): response = self.session.post(self.get_url('noahDeviceApi/noah/getNoahInfoBySn'), data={ 'deviceSn': serial_number }) - data = json.loads(response.content.decode('utf-8')) - return data + return response.json() + def update_plant_settings(self, plant_id, changed_settings, current_settings = None): """ @@ -659,14 +955,14 @@ def update_plant_settings(self, plant_id, changed_settings, current_settings = N 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 get_plant_settings), if None - fetched for you + 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: - current_settings = self.get_plant_settings(plant_id) + 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 = { @@ -695,8 +991,8 @@ def update_plant_settings(self, plant_id, changed_settings, current_settings = N form_settings[setting] = (None, str(value)) response = self.session.post(self.get_url('newTwoPlantAPI.do?op=updatePlant'), files = form_settings) - data = json.loads(response.content.decode('utf-8')) - return data + + return response.json() def update_inverter_setting(self, serial_number, setting_type, default_parameters, parameters): @@ -726,8 +1022,8 @@ def update_inverter_setting(self, serial_number, setting_type, response = self.session.post(self.get_url('newTcpsetAPI.do'), params=settings_parameters) - data = json.loads(response.content.decode('utf-8')) - return data + + return response.json() def update_mix_inverter_setting(self, serial_number, setting_type, parameters): """ @@ -773,6 +1069,73 @@ def update_ac_inverter_setting(self, serial_number, setting_type, parameters): return self.update_inverter_setting(serial_number, setting_type, default_parameters, 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. + + 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) + + Returns: + JSON response from the server whether the configuration was successful + """ + params = { + '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' + } + + response = self.session.post(self.get_url('newTcpsetAPI.do'), params=params, data=data) + result = response.json() + + if not result.get('success', False): + raise Exception(f"Failed to update TLX inverter time segment: {result.get('msg', 'Unknown error')}") + + 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 + + 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) + + Returns: + JSON response from the server whether the configuration was successful + """ + default_parameters = { + '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} + elif isinstance(parameter, list): + parameter = {f'param{index+1}': param for index, param in enumerate(parameter)} + + return self.update_inverter_setting(serial_number, setting_type, + default_parameters, parameter) + + def update_noah_settings(self, serial_number, setting_type, parameters): """ Applies settings for specified noah device based on serial number @@ -803,5 +1166,8 @@ def update_noah_settings(self, serial_number, setting_type, parameters): response = self.session.post(self.get_url('noahDeviceApi/noah/set'), data=settings_parameters) - data = json.loads(response.content.decode('utf-8')) - return data \ No newline at end of file + + return response.json() + + + diff --git a/setup.py b/setup.py index f58534a..1452ae7 100755 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ setuptools.setup( name="growattServer", - version="1.5.0", + version="1.6.0", author="IndyKoning", author_email="indykoningnl@gmail.com", description="A package to talk to growatt server",