diff --git a/README.md b/README.md index da42e85..6e143ee 100755 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Package to retrieve PV information from the growatt server. Special thanks to [Sjoerd Langkemper](https://github.com/Sjord) who has provided a strong base to start off from https://github.com/Sjord/growatt_api_client That project has since ben archived. -This library now supports both the legacy password-based authentication and the V1 API with token-based authentication for MIN systems (TLX are identified as MIN system in the public API). Certain endpoints are not supported anymore by openapi.growatt.com. For example `api.min_write_parameter()` should be used instead of old `api.update_tlx_inverter_setting()`. +This library supports both the classic password-based API and the token-based V1 API, officially supported by Growatt. Currently, the V1 API implementation supports MIN and SPH devices, where MIN broadly corresponds to classic TLX and SPH to classic MIX. If your inverter supports the V1 API, it is encouraged to use this over the classic API, as it offers better security, more features, and more relaxed rate limiting. ## Usage diff --git a/docs/openapiv1.md b/docs/openapiv1.md index 61f49d1..c2c52b9 100644 --- a/docs/openapiv1.md +++ b/docs/openapiv1.md @@ -21,7 +21,9 @@ print(plants) ### Methods -Any methods that may be useful. +#### Generic Methods + +Methods that work across all device types. | Method | Arguments | Description | |:---|:---|:---| @@ -30,6 +32,13 @@ Any methods that may be useful. | `api.plant_energy_overview(plant_id)` | plant_id: String | Get energy overview data for a plant. | | `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. | + +#### MIN Methods + +Methods for MIN devices (type 7). + +| Method | Arguments | Description | +|:---|:---|:---| | `api.min_energy(device_sn)` | device_sn: String | Get current energy data for a min inverter, including power and energy values. | | `api.min_detail(device_sn)` | device_sn: String | Get detailed data for a min inverter. | | `api.min_energy_history(device_sn, start_date=None, end_date=None, timezone=None, page=None, limit=None)` | device_sn: String, start_date: Date, end_date: Date, timezone: String, page: Int, limit: Int | Get energy history data for a min inverter (7-day max range). | @@ -39,6 +48,23 @@ Any methods that may be useful. | `api.min_write_time_segment(device_sn, segment_id, batt_mode, start_time, end_time, enabled=True)` | device_sn: String, segment_id: Int, batt_mode: Int <0=load priority, 1=battery priority, 2=grid priority>, start_time: Time, end_time: Time, enabled: Bool | Update a specific time segment for a min inverter. see: [details](./openapiv1/min_tlx_settings.md) | | `api.min_read_time_segments(device_sn, settings_data=None)` | device_sn: String, settings_data: Dict | Read all time segments from a MIN inverter. Optionally pass settings_data to avoid redundant API calls. see: [details](./openapiv1/min_tlx_settings.md) | +#### SPH Methods + +Methods for SPH devices (type 5). + +| Method | Arguments | Description | +|:---|:---|:---| +| `api.sph_detail(device_sn)` | device_sn: String | Get detailed data for an SPH hybrid inverter. | +| `api.sph_energy(device_sn)` | device_sn: String | Get current energy data for an SPH inverter, including power and energy values. | +| `api.sph_energy_history(device_sn, start_date=None, end_date=None, timezone=None, page=None, limit=None)` | device_sn: String, start_date: Date, end_date: Date, timezone: String, page: Int, limit: Int | Get energy history data for an SPH inverter (7-day max range). | +| `api.sph_settings(device_sn)` | device_sn: String | Get all settings for an SPH inverter. | +| `api.sph_read_parameter(device_sn, parameter_id, start_address=None, end_address=None)` | device_sn: String, parameter_id: String, start_address: Int, end_address: Int | Read a specific setting for an SPH inverter. see: [details](./openapiv1/sph_settings.md) | +| `api.sph_write_parameter(device_sn, parameter_id, parameter_values)` | device_sn: String, parameter_id: String, parameter_values: Dict/Array | Set parameters on an SPH inverter. Parameter values can be a single value, a list, or a dictionary. see: [details](./openapiv1/sph_settings.md) | +| `api.sph_write_ac_charge_time(device_sn, period_id, charge_power, charge_stop_soc, start_time, end_time, mains_enabled=True, enabled=True)` | device_sn: String, period_id: Int (1-3), charge_power: Int (0-100), charge_stop_soc: Int (0-100), start_time: Time, end_time: Time, mains_enabled: Bool, enabled: Bool | Configure an AC charge time period for an SPH inverter. see: [details](./openapiv1/sph_settings.md) | +| `api.sph_write_ac_discharge_time(device_sn, period_id, discharge_power, discharge_stop_soc, start_time, end_time, enabled=True)` | device_sn: String, period_id: Int (1-3), discharge_power: Int (0-100), discharge_stop_soc: Int (0-100), start_time: Time, end_time: Time, enabled: Bool | Configure an AC discharge time period for an SPH inverter. see: [details](./openapiv1/sph_settings.md) | +| `api.sph_read_ac_charge_times(device_sn, settings_data=None)` | device_sn: String, settings_data: Dict | Read all AC charge time periods from an SPH inverter. Optionally pass settings_data to avoid redundant API calls. see: [details](./openapiv1/sph_settings.md) | +| `api.sph_read_ac_discharge_times(device_sn, settings_data=None)` | device_sn: String, settings_data: Dict | Read all AC discharge time periods from an SPH inverter. Optionally pass settings_data to avoid redundant API calls. see: [details](./openapiv1/sph_settings.md) | + Methods from [here](./shinephone.md#methods) should be available, but it's safer to rely on the functions described in this file where possible. There is no guarantee those methods will work, or remain stable through updates. ### Variables diff --git a/docs/openapiv1/sph_settings.md b/docs/openapiv1/sph_settings.md new file mode 100644 index 0000000..71af73a --- /dev/null +++ b/docs/openapiv1/sph_settings.md @@ -0,0 +1,54 @@ +# SPH Inverter Settings + +This is part of the [OpenAPI V1 doc](../openapiv1.md). + +For SPH (hybrid inverter) systems, the public V1 API provides methods to read and write inverter settings. SPH inverters have different time period configurations compared to MIN inverters: + +* **Read Parameter** + * function: `api.sph_read_parameter` + * parameters: + * `device_sn`: The device serial number + * `parameter_id`: Parameter ID to read (e.g., "discharge_power") + * `start_address`, `end_address`: Optional, for reading registers by address + +* **Write Parameter** + * function: `api.sph_write_parameter` + * parameters: + * `device_sn`: The device serial number + * `parameter_id`: Parameter ID to write (e.g., "ac_charge") + * `parameter_values`: Value to set (single value, list, or dictionary) + +* **AC Charge Time Periods** + * function: `api.sph_write_ac_charge_time` + * parameters: + * `device_sn`: The device serial number + * `period_id`: Period number (1-3) + * `charge_power`: Charging power percentage (0-100) + * `charge_stop_soc`: Stop charging at this SOC percentage (0-100) + * `start_time`: Datetime.time object for period start + * `end_time`: Datetime.time object for period end + * `mains_enabled`: Boolean to enable/disable grid charging (default: True) + * `enabled`: Boolean to enable/disable period (default: True) + +* **AC Discharge Time Periods** + * function: `api.sph_write_ac_discharge_time` + * parameters: + * `device_sn`: The device serial number + * `period_id`: Period number (1-3) + * `discharge_power`: Discharge power percentage (0-100) + * `discharge_stop_soc`: Stop discharging at this SOC percentage (0-100) + * `start_time`: Datetime.time object for period start + * `end_time`: Datetime.time object for period end + * `enabled`: Boolean to enable/disable period (default: True) + +* **Read AC Charge Time Periods** + * function: `api.sph_read_ac_charge_times` + * parameters: + * `device_sn`: The device serial number + * `settings_data`: Optional settings data to avoid redundant API calls + +* **Read AC Discharge Time Periods** + * function: `api.sph_read_ac_discharge_times` + * parameters: + * `device_sn`: The device serial number + * `settings_data`: Optional settings data to avoid redundant API calls diff --git a/examples/sph_example.py b/examples/sph_example.py new file mode 100644 index 0000000..bea3ee7 --- /dev/null +++ b/examples/sph_example.py @@ -0,0 +1,135 @@ +""" +Example script for SPH devices using the OpenAPI V1. + +This script demonstrates controlling SPH interface devices (device type 5) +such as hybrid inverter systems. +You can obtain an API token from the Growatt API documentation or developer portal. +""" + +import datetime +import json +from pathlib import Path + +import requests + +from . import growattServer + +# Get the API token from user input or environment variable +# api_token = os.environ.get("GROWATT_API_TOKEN") or input("Enter your Growatt API token: ") + +# test token from official API docs https://www.showdoc.com.cn/262556420217021/1494053950115877 +api_token = "6eb6f069523055a339d71e5b1f6c88cc" # noqa: S105 + +try: + # Initialize the API with token instead of using login + api = growattServer.OpenApiV1(token=api_token) + + # Plant info + plants = api.plant_list() + print(f"Plants: Found {plants['count']} plants") # noqa: T201 + plant_id = plants["plants"][0]["plant_id"] + + # Devices + devices = api.device_list(plant_id) + + for device in devices["devices"]: + print(device) # noqa: T201 + if device["type"] == growattServer.DeviceType.SPH.value: + inverter_sn = device["device_sn"] + print(f"Processing SPH device: {inverter_sn}") # noqa: T201 + + # Get device details + inverter_data = api.sph_detail( + device_sn=inverter_sn, + ) + print("Saving inverter data to inverter_data.json") # noqa: T201 + with Path("inverter_data.json").open("w") as f: + json.dump(inverter_data, f, indent=4, sort_keys=True) + + # Get energy data + energy_data = api.sph_energy( + device_sn=inverter_sn, + ) + print("Saving energy data to energy_data.json") # noqa: T201 + with Path("energy_data.json").open("w") as f: + json.dump(energy_data, f, indent=4, sort_keys=True) + + # Get energy history + energy_history_data = api.sph_energy_history( + device_sn=inverter_sn, + ) + print("Saving energy history data to energy_history.json") # noqa: T201 + with Path("energy_history.json").open("w") as f: + json.dump( + energy_history_data.get("datas", []), + f, + indent=4, + sort_keys=True, + ) + + # Get settings + settings_data = api.sph_settings( + device_sn=inverter_sn, + ) + print("Saving settings data to settings_data.json") # noqa: T201 + with Path("settings_data.json").open("w") as f: + json.dump(settings_data, f, indent=4, sort_keys=True) + + # Read AC charge time periods + charge_times = api.sph_read_ac_charge_times( + device_sn=inverter_sn, + settings_data=settings_data, + ) + print("AC Charge Time Periods:") # noqa: T201 + print(json.dumps(charge_times, indent=4)) # noqa: T201 + + # Read AC discharge time periods + discharge_times = api.sph_read_ac_discharge_times( + device_sn=inverter_sn, + settings_data=settings_data, + ) + print("AC Discharge Time Periods:") # noqa: T201 + print(json.dumps(discharge_times, indent=4)) # noqa: T201 + + # Read discharge power + discharge_power = api.sph_read_parameter( + device_sn=inverter_sn, + parameter_id="discharge_power", + ) + print(f"Current discharge power: {discharge_power}%") # noqa: T201 + + # Write examples - Uncomment to test + + # Set AC charge time period 1: charge at 50% power to 95% SOC between 00:00-06:00 + # api.sph_write_ac_charge_time( + # device_sn=inverter_sn, + # period_id=1, + # charge_power=50, # 50% charging power + # charge_stop_soc=95, # Stop at 95% SOC + # start_time=datetime.time(0, 0), + # end_time=datetime.time(6, 0), + # mains_enabled=True, # Enable grid charging + # enabled=True + # ) + # print("AC charge period 1 updated successfully") + + # Set AC discharge time period 1: discharge at 100% power to 20% SOC between 17:00-22:00 + # api.sph_write_ac_discharge_time( + # device_sn=inverter_sn, + # period_id=1, + # discharge_power=100, # 100% discharge power + # discharge_stop_soc=20, # Stop at 20% SOC + # start_time=datetime.time(17, 0), + # end_time=datetime.time(22, 0), + # enabled=True + # ) + # print("AC discharge period 1 updated successfully") + +except growattServer.GrowattV1ApiError as e: + print(f"API Error: {e} (Code: {e.error_code}, Message: {e.error_msg})") # noqa: T201 +except growattServer.GrowattParameterError as e: + print(f"Parameter Error: {e}") # noqa: T201 +except requests.exceptions.RequestException as e: + print(f"Network Error: {e}") # noqa: T201 +except Exception as e: # noqa: BLE001 + print(f"Unexpected error: {e}") # noqa: T201 diff --git a/growattServer/__init__.py b/growattServer/__init__.py index 927f010..75698ac 100755 --- a/growattServer/__init__.py +++ b/growattServer/__init__.py @@ -1,7 +1,7 @@ # Import everything from base_api to ensure backward compatibility from .base_api import * -# Import the V1 API class -from .open_api_v1 import OpenApiV1 +# Import the V1 API class and DeviceType enum +from .open_api_v1 import OpenApiV1, DeviceType # Import exceptions from .exceptions import GrowattError, GrowattParameterError, GrowattV1ApiError diff --git a/growattServer/open_api_v1.py b/growattServer/open_api_v1.py index 02ae3cc..3446fa6 100644 --- a/growattServer/open_api_v1.py +++ b/growattServer/open_api_v1.py @@ -1,14 +1,30 @@ import warnings from datetime import date, timedelta +from enum import Enum from . import GrowattApi import platform from .exceptions import GrowattParameterError, GrowattV1ApiError +class DeviceType(Enum): + """Enumeration of Growatt device types.""" + + INVERTER = 1 + STORAGE = 2 + OTHER = 3 + MAX = 4 + SPH = 5 # (MIX) + SPA = 6 + MIN = 7 + PCS = 8 + HPS = 9 + PBD = 10 + + class OpenApiV1(GrowattApi): """ Extended Growatt API client with V1 API support. - This class extends the base GrowattApi class with methods for MIN inverters using + 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 """ @@ -136,45 +152,6 @@ def plant_energy_overview(self, 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: - """ - 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 - - { - 'count': int, # Total number of records - 'powers': list[dict], # List of power data entries - # 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'), - params={ - 'plant_id': plant_id, - 'date': day, - } - ) - - 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): """ Retrieve plant energy data for multiple days/months/years. @@ -681,3 +658,538 @@ def min_read_time_segments(self, device_sn, settings_data=None): segments.append(segment) return segments + + # SPH Device Methods (Device Type 5) + + def sph_detail(self, device_sn): + """ + 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. + """ + + 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") + + def sph_energy(self, device_sn): + """ + 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. + """ + + 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") + + def sph_energy_history(self, device_sn, 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 = date.today() + end_date = date.today() + 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/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") + + def sph_settings(self, device_sn): + """ + Get settings for an SPH inverter. + + Args: + device_sn (str): The serial number of the SPH inverter. + + Returns: + dict: A dictionary containing the SPH 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.session.get( + self._get_url('device/mix/mix_data_info'), + params={ + 'device_sn': device_sn + } + ) + + return self._process_response(response.json(), "getting SPH inverter settings") + + def sph_read_parameter(self, device_sn, parameter_id, start_address=None, end_address=None): + """ + Read setting from SPH inverter. + + Args: + device_sn (str): The ID of the SPH 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. + """ + + 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: + raise GrowattParameterError( + "specify either parameter_id or start_address/end_address - not both." + ) + elif parameter_id is not None: + # named parameter + start_address = 0 + end_address = 0 + else: + # address range + parameter_id = "set_any_reg" + + response = self.session.post( + self._get_url('readMixParam'), + data={ + "mix_sn": device_sn, + "type": parameter_id, + "param1": start_address, + "param2": end_address + } + ) + + return self._process_response(response.json(), f"reading parameter {parameter_id}") + + def sph_write_parameter(self, device_sn, 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 + parameters = {i: "" for i in range(1, 20)} + + # 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 <= 19: # Only use up to 19 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 + parameters[pos] = str(value) + + # IMPORTANT: Create a data dictionary with ALL parameters explicitly included + request_data = { + "mix_sn": device_sn, + "type": parameter_id + } + + # Add all 19 parameters to the request + for i in range(1, 20): + request_data[f"param{i}"] = str(parameters[i]) + + # Send the request + response = self.session.post( + self._get_url('mixSet'), + data=request_data + ) + + return self._process_response(response.json(), f"writing parameter {parameter_id}") + + def sph_write_ac_charge_time(self, device_sn, period_id, charge_power, charge_stop_soc, + start_time, end_time, mains_enabled=True, enabled=True): + """ + Set an AC charge time period for an SPH inverter. + + Args: + device_sn (str): The serial number of the inverter. + period_id (int): Period ID (1-3). + charge_power (int): Charging power percentage (0-100). + charge_stop_soc (int): Stop charging at this SOC percentage (0-100). + start_time (datetime.time): Start time for the period. + end_time (datetime.time): End time for the period. + mains_enabled (bool): Enable grid charging. Default True. + enabled (bool): Whether this period is enabled. Default True. + + 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. + """ + + if not 1 <= period_id <= 3: + raise GrowattParameterError("period_id must be between 1 and 3") + + if not 0 <= charge_power <= 100: + raise GrowattParameterError("charge_power must be between 0 and 100") + + if not 0 <= charge_stop_soc <= 100: + raise GrowattParameterError("charge_stop_soc must be between 0 and 100") + + # Initialize ALL 19 parameters as empty strings + all_params = { + "mix_sn": device_sn, + "type": "mix_ac_charge_time_period" + } + + # Period-specific parameter offsets + base_param = (period_id - 1) * 5 + + # Set parameters according to SPH AC charge format + all_params["param1"] = str(charge_power) + all_params["param2"] = str(charge_stop_soc) + all_params["param3"] = "1" if mains_enabled else "0" + all_params[f"param{base_param + 4}"] = str(start_time.hour) + all_params[f"param{base_param + 5}"] = str(start_time.minute) + all_params[f"param{base_param + 6}"] = str(end_time.hour) + all_params[f"param{base_param + 7}"] = str(end_time.minute) + all_params[f"param{base_param + 8}"] = "1" if enabled else "0" + + # Add empty strings for all other parameters + for i in range(1, 20): + if f"param{i}" not in all_params: + all_params[f"param{i}"] = "" + + # Send the request + response = self.session.post( + self._get_url('mixSet'), + data=all_params + ) + + return self._process_response(response.json(), f"writing AC charge period {period_id}") + + def sph_write_ac_discharge_time(self, device_sn, period_id, discharge_power, discharge_stop_soc, + start_time, end_time, enabled=True): + """ + Set an AC discharge time period for an SPH inverter. + + Args: + device_sn (str): The serial number of the inverter. + period_id (int): Period ID (1-3). + discharge_power (int): Discharging power percentage (0-100). + discharge_stop_soc (int): Stop discharging at this SOC percentage (0-100). + 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. Default True. + + 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. + """ + + if not 1 <= period_id <= 3: + raise GrowattParameterError("period_id must be between 1 and 3") + + if not 0 <= discharge_power <= 100: + raise GrowattParameterError("discharge_power must be between 0 and 100") + + if not 0 <= discharge_stop_soc <= 100: + raise GrowattParameterError("discharge_stop_soc must be between 0 and 100") + + # Initialize ALL 19 parameters as empty strings + all_params = { + "mix_sn": device_sn, + "type": "mix_ac_discharge_time_period" + } + + # Period-specific parameter offsets + base_param = (period_id - 1) * 5 + + # Set parameters according to SPH AC discharge format + all_params["param1"] = str(discharge_power) + all_params["param2"] = str(discharge_stop_soc) + all_params[f"param{base_param + 3}"] = str(start_time.hour) + all_params[f"param{base_param + 4}"] = str(start_time.minute) + all_params[f"param{base_param + 5}"] = str(end_time.hour) + all_params[f"param{base_param + 6}"] = str(end_time.minute) + all_params[f"param{base_param + 7}"] = "1" if enabled else "0" + + # Add empty strings for all other parameters + for i in range(1, 20): + if f"param{i}" not in all_params: + all_params[f"param{i}"] = "" + + # Send the request + response = self.session.post( + self._get_url('mixSet'), + data=all_params + ) + + return self._process_response(response.json(), f"writing AC discharge period {period_id}") + + def sph_read_ac_charge_times(self, device_sn, settings_data=None): + """ + Read AC charge time periods from an SPH inverter. + + Retrieves all 3 AC charge time periods from an SPH inverter and + parses them into a structured format. + + Note that this function uses sph_settings() internally to get the settings data. + To avoid endpoint rate limit, you can pass the settings_data parameter + with the data returned from sph_settings(). + + Args: + device_sn (str): The device serial number of the inverter + settings_data (dict, optional): Settings data from sph_settings call to avoid repeated API calls. + + 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 + + Example: + # Option 1: Make a single call + charge_times = api.sph_read_ac_charge_times("DEVICE_SERIAL_NUMBER") + + # Option 2: Reuse existing settings data + settings_response = api.sph_settings("DEVICE_SERIAL_NUMBER") + charge_times = api.sph_read_ac_charge_times("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: + settings_data = self.sph_settings(device_sn=device_sn) + + 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'forcedChargeTimeStart{i}', "0:0") + end_time_raw = settings_data.get(f'forcedChargeTimeStop{i}', "0:0") + enabled_raw = settings_data.get(f'forcedChargeStopSwitch{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_discharge_times(self, device_sn, settings_data=None): + """ + Read AC discharge time periods from an SPH inverter. + + Retrieves all 3 AC discharge time periods from an SPH inverter and + parses them into a structured format. + + Note that this function uses sph_settings() internally to get the settings data. + To avoid endpoint rate limit, you can pass the settings_data parameter + with the data returned from sph_settings(). + + Args: + device_sn (str): The device serial number of the inverter + settings_data (dict, optional): Settings data from sph_settings call to avoid repeated API calls. + + 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 + + Example: + # Option 1: Make a single call + discharge_times = api.sph_read_ac_discharge_times("DEVICE_SERIAL_NUMBER") + + # Option 2: Reuse existing settings data + settings_response = api.sph_settings("DEVICE_SERIAL_NUMBER") + discharge_times = api.sph_read_ac_discharge_times("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: + settings_data = self.sph_settings(device_sn=device_sn) + + 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'forcedDischargeTimeStart{i}', "0:0") + end_time_raw = settings_data.get(f'forcedDischargeTimeStop{i}', "0:0") + enabled_raw = settings_data.get(f'forcedDischargeStopSwitch{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