diff --git a/.devcontainer.json b/.devcontainer.json new file mode 100644 index 0000000..b9d0b79 --- /dev/null +++ b/.devcontainer.json @@ -0,0 +1,50 @@ +{ + "name": "johanzander/growatt_server_upstream", + "image": "mcr.microsoft.com/devcontainers/python:3.13", + "postCreateCommand": "scripts/setup", + "forwardPorts": [ + 8123 + ], + "portsAttributes": { + "8123": { + "label": "Home Assistant", + "onAutoForward": "notify" + } + }, + "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}", + "mounts": [ + "source=/home/graeme/web/PyPi_GrowattServer/growattServer,target=/custom_components/growatt_server/growattServer,type=bind", + "source=/home/graeme/web/PyPi_GrowattServer/growattServer,target=/workspaces/${localWorkspaceFolderBasename}/custom_components/growatt_server/growattServer,type=bind" + ], + "customizations": { + "vscode": { + "extensions": [ + "charliermarsh.ruff", + "github.vscode-pull-request-github", + "ms-python.python", + "ms-python.vscode-pylance", + "ryanluker.vscode-coverage-gutters" + ], + "settings": { + "files.eol": "\n", + "editor.tabSize": 4, + "editor.formatOnPaste": true, + "editor.formatOnSave": true, + "editor.formatOnType": false, + "files.trimTrailingWhitespace": true, + "python.analysis.typeCheckingMode": "basic", + "python.analysis.autoImportCompletions": true, + "python.defaultInterpreterPath": "/usr/local/bin/python", + "[python]": { + "editor.defaultFormatter": "charliermarsh.ruff" + } + } + } + }, + "remoteUser": "vscode", + "features": { + "ghcr.io/devcontainers-extra/features/apt-packages:1": { + "packages": "ffmpeg,libturbojpeg0,libpcap-dev" + } + } +} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 178de15..901251a 100644 --- a/.gitignore +++ b/.gitignore @@ -40,4 +40,8 @@ env/ .vscode/ .idea/ *.swp -*.swo \ No newline at end of file +*.swo + +# Home Assistant configuration +config/* +!config/configuration.yaml \ No newline at end of file diff --git a/.ruff.toml b/.ruff.toml new file mode 100644 index 0000000..62e029b --- /dev/null +++ b/.ruff.toml @@ -0,0 +1,25 @@ +# 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 +] + +[lint.flake8-pytest-style] +fixture-parentheses = false + +[lint.pyupgrade] +keep-runtime-typing = true + +[lint.mccabe] +max-complexity = 25 diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..4e56dde --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,35 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "Start Home Assistant", + "type": "shell", + "command": "scripts/develop", + "group": { + "kind": "test", + "isDefault": true + }, + "presentation": { + "reveal": "always", + "panel": "new" + }, + "problemMatcher": [] + }, + { + "label": "Start Home Assistant (Debug)", + "type": "shell", + "command": "PYTHONBREAKPOINT=pdb.set_trace scripts/develop", + "group": { + "kind": "test", + "isDefault": false + }, + "presentation": { + "echo": true, + "reveal": "always", + "focus": false, + "panel": "shared" + }, + "problemMatcher": [] + } + ] + } diff --git a/README.md b/README.md index 0a93055..4a4b6cf 100644 --- a/README.md +++ b/README.md @@ -9,40 +9,50 @@ Upstream development version of the Growatt Server integration for Home Assistan This repository serves as an **upstream testing ground** for improvements to the Growatt Server integration before they are submitted to Home Assistant Core. From version 1.5.0 it should be compatible with the [Growatt BESS (Battery Energy Storage System) Manager][bess] -## Features (v1.5.0) +## Features (v2.2.0) **Base Version**: Home Assistant Core 2025.9.0 Growatt Server integration **Changes from Base Version**: 1. `manifest.json` updated for custom component distribution -2. [API Token authentication support][pr-149783] - Official V1 API for MIN/TLX devices -3. [MIN inverter control][pr-153468] - Number and switch entities for controlling inverter settings -4. Adds 5 min rate limit to login to prevent account locking - aims to fix [account locking issue][issue-150732] -5. **Fixed sensor naming issue** - Sensors now display proper translated names instead of generic device class names -6. **Fixed timezone handling in API throttling** - Fixed bug that could cause very long throttling times (500 minutes) -7. **Enhanced TLX sensor coverage** - Added 14 new sensors for power and energy +2. [API Token authentication support][pr-149783] - Official V1 API for MIN/TLX/MIX devices +3. [MIN inverter control][pr-153468] - Number, switch, and time entities for controlling inverter settings +4. **MIX/SPH inverter control** - Full support for charge/discharge power, SOC limits, and time-of-use scheduling +5. Adds 5 min rate limit to login to prevent account locking - aims to fix [account locking issue][issue-150732] +6. **Fixed sensor naming issue** - Sensors now display proper translated names instead of generic device class names +7. **Fixed timezone handling in API throttling** - Fixed bug that could cause very long throttling times (500 minutes) +8. **Enhanced TLX sensor coverage** - Added 14 new sensors for power and energy monitoring -8. Proper implementation of read / write Time Of Use (TOU) settings using service calls: +9. Proper implementation of read / write Time Of Use (TOU) settings using service calls: `growattserver.read_time_segments, growattserver.update_time_segment` -### MIN/TLX Inverter Control Features (V1 API) +### MIN/TLX/MIX Inverter Control Features (V1 API) -When using token authentication with MIN/TLX inverters, you get: +When using token authentication with MIN/TLX or MIX/SPH inverters, you get: -**Number Entities** (0-100%): +**Number Entities**: -- Charge power -- Charge stop SOC -- Discharge power -- Discharge stop SOC +- Charge power (W) +- Charge stop SOC (%) +- Discharge power (W) +- Discharge stop SOC (%) **Switch Entities**: - AC charge enable/disable +- Charge period 1 enabled +- Discharge period 1 enabled -All control entities provide real-time feedback and proper error handling. +**Time Entities**: + +- 1. Charge start time +- 2. Charge end time +- 3. Discharge start time +- 4. Discharge end time + +All control entities provide real-time feedback and proper error handling. MIX/SPH devices support full time-of-use (TOU) scheduling with separate charge and discharge periods. ### Enhanced TLX Sensor Coverage (v1.4.6) diff --git a/config/configuration.yaml b/config/configuration.yaml new file mode 100644 index 0000000..5877910 --- /dev/null +++ b/config/configuration.yaml @@ -0,0 +1,12 @@ +# https://www.home-assistant.io/integrations/default_config/ +default_config: + +# https://www.home-assistant.io/integrations/homeassistant/ +homeassistant: + debug: true + +# https://www.home-assistant.io/integrations/logger/ +logger: + default: info + logs: + custom_components.growatt_server: debug diff --git a/custom_components/growatt_server/__init__.py b/custom_components/growatt_server/__init__.py index 1a85121..307c26c 100644 --- a/custom_components/growatt_server/__init__.py +++ b/custom_components/growatt_server/__init__.py @@ -1,20 +1,27 @@ """The Growatt server PV inverter sensor integration.""" import asyncio -from collections.abc import Mapping +from datetime import datetime +from datetime import time as dt_time import logging +from collections.abc import Mapping -import growattServer import requests import voluptuous as vol from homeassistant.components import persistent_notification from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME from homeassistant.core import HomeAssistant, ServiceCall, SupportsResponse -from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryError, HomeAssistantError -from homeassistant.helpers import config_validation as cv, selector +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryError, + HomeAssistantError, +) +from homeassistant.helpers import selector from homeassistant.util import dt as dt_util +import growattServer + from .const import ( BATT_MODE_MAP, CONF_PLANT_ID, @@ -66,9 +73,13 @@ def get_device_list_classic( except requests.exceptions.RequestException as ex: raise ConfigEntryError(f"Network error during plant list: {ex}") from ex except ValueError as ex: - raise ConfigEntryError(f"Invalid response format during plant list: {ex}") from ex + raise ConfigEntryError( + f"Invalid response format during plant list: {ex}" + ) from ex except KeyError as ex: - raise ConfigEntryError(f"Missing expected key in plant list response: {ex}") from ex + raise ConfigEntryError( + f"Missing expected key in plant list response: {ex}" + ) from ex if not plant_info or "data" not in plant_info or not plant_info["data"]: raise ConfigEntryError("No plants found for this account.") @@ -81,18 +92,22 @@ def get_device_list_classic( except requests.exceptions.RequestException as ex: raise ConfigEntryError(f"Network error during device list: {ex}") from ex except ValueError as ex: - raise ConfigEntryError(f"Invalid response format during device list: {ex}") from ex + raise ConfigEntryError( + f"Invalid response format during device list: {ex}" + ) from ex except KeyError as ex: - raise ConfigEntryError(f"Missing expected key in device list response: {ex}") from ex + raise ConfigEntryError( + f"Missing expected key in device list response: {ex}" + ) from ex return devices, plant_id - def get_device_list_v1( api, config: Mapping[str, str] ) -> tuple[list[dict[str, str]], str]: - """Device list logic for Open API V1. + """ + Device list logic for Open API V1. Note: Plant selection (including auto-selection if only one plant exists) is handled in the config flow before this function is called. This function @@ -100,41 +115,64 @@ def get_device_list_v1( """ plant_id = config[CONF_PLANT_ID] try: - devices_dict = api.device_list(plant_id) + devices = api.get_devices(plant_id) except growattServer.GrowattV1ApiError as e: - raise ConfigEntryError( - f"API error during device list: {e} (Code: {getattr(e, 'error_code', None)}, Message: {getattr(e, 'error_msg', None)})" - ) from e - devices = devices_dict.get("devices", []) - # Only MIN device (type = 7) support implemented in current V1 API - supported_devices = [ - { - "deviceSn": device.get("device_sn", ""), - "deviceType": "min", - } - for device in devices - if device.get("type") == 7 - ] + error_code = getattr(e, "error_code", None) + error_msg = getattr(e, "error_msg", None) + msg = ( + f"API error during device list: {e} " + f"(Code: {error_code}, " + f"Message: {error_msg})" + ) + raise ConfigEntryError(msg) from e + # Only MIX (type =5 ) MIN (type = 7) device support implemented in current V1 API + # Only include supported device types: MIX (type=5) and MIN (type=7) + supported_devices = [] for device in devices: - if device.get("type") != 7: + device_type = device.device_type + device_sn = device.device_sn + # Use integer values directly if MIX and MIN are not defined in DeviceType + if device_type == growattServer.DeviceType.SPH_MIX: + supported_devices.append( + { + "deviceSn": device_sn, + "deviceType": "mix", + } + ) + elif device_type == growattServer.DeviceType.MIN_TLX: + supported_devices.append( + { + "deviceSn": device_sn, + "deviceType": "min", + } + ) + + for device in devices: + if device.device_type not in ( + growattServer.DeviceType.SPH_MIX, + growattServer.DeviceType.MIN_TLX, + ): _LOGGER.warning( "Device %s with type %s not supported in Open API V1, skipping", - device.get("device_sn", ""), - device.get("type"), + device.device_sn, + device.device_type, ) return supported_devices, plant_id def get_device_list( - api, config: Mapping[str, str], api_version: str + api: "growattServer.GrowattApi", + config: Mapping[str, str], + api_version: str, ) -> tuple[list[dict[str, str]], str]: """Dispatch to correct device list logic based on API version.""" if api_version == "v1": return get_device_list_v1(api, config) if api_version == "classic": return get_device_list_classic(api, config) - raise ConfigEntryError(f"Unknown API version: {api_version}") + msg = f"Unknown API version: {api_version}" + raise ConfigEntryError(msg) async def async_setup_entry( @@ -444,46 +482,51 @@ async def _async_register_services( config_entry: GrowattConfigEntry, device_coordinators: dict, ) -> None: - """Register services for MIN/TLX devices.""" - from datetime import datetime - - # Get all MIN coordinators with V1 API - single source of truth - min_coordinators = { - coord.device_id: coord - for coord in device_coordinators.values() - if coord.device_type == "min" and coord.api_version == "v1" + """Register time-of-use (TOU) services for inverters with V1 API.""" + # Only register services if we have V1 API devices that support TOU + _LOGGER.debug( + "Checking for V1 API devices to register TOU services. Devices: %s", + [ + (coord.device_id, coord.device_type, coord.api_version) + for coord in device_coordinators.values() + ], + ) + + v1_devices = { + device_id: coord + for device_id, coord in device_coordinators.items() + if coord.device_type in ("tlx", "mix") and coord.api_version == "v1" } - if not min_coordinators: - _LOGGER.debug( - "No MIN devices with V1 API found, skipping TOU service registration. " - "Services require MIN devices with token authentication" + if not v1_devices: + _LOGGER.warning( + "No V1 API devices found, skipping TOU service registration. " + "Services require TLX/MIX devices with token authentication" ) return - _LOGGER.info( - "Found %d MIN device(s) with V1 API, registering TOU services", - len(min_coordinators), - ) + _LOGGER.info("Found V1 API device(s), registering TOU services") def get_coordinator(device_id: str | None = None) -> GrowattCoordinator: """Get coordinator by device_id with consistent behavior.""" if device_id is None: - if len(min_coordinators) == 1: + if len(v1_devices) == 1: # Only one device - return it - return next(iter(min_coordinators.values())) + return next(iter(v1_devices.values())) # Multiple devices - require explicit selection - device_list = ", ".join(min_coordinators.keys()) + device_list = ", ".join(v1_devices.keys()) raise HomeAssistantError( - f"Multiple MIN devices available ({device_list}). " + f"Multiple V1 devices available ({device_list}). " "Please specify device_id parameter." ) - # Explicit device_id provided - if device_id not in min_coordinators: - raise HomeAssistantError(f"MIN device '{device_id}' not found") + if device_id not in v1_devices: + raise HomeAssistantError(f"V1 device '{device_id}' not found") + + _LOGGER.debug("Found V1 device: %s", device_id) + _LOGGER.debug("V1 device details: %s", v1_devices[device_id]) - return min_coordinators[device_id] + return v1_devices[device_id] async def handle_update_time_segment(call: ServiceCall) -> None: """Handle update_time_segment service call.""" @@ -494,14 +537,22 @@ async def handle_update_time_segment(call: ServiceCall) -> None: enabled = call.data["enabled"] device_id = call.data.get("device_id") + # SPH_MIX specific parameters (optional) - ensure they are integers + charge_power = int(call.data.get("charge_power", 80)) + charge_stop_soc = int(call.data.get("charge_stop_soc", 95)) + mains_enabled = call.data.get("mains_enabled", True) + _LOGGER.debug( - "handle_update_time_segment: segment_id=%d, batt_mode=%s, start=%s, end=%s, enabled=%s, device_id=%s", + "handle_update_time_segment: segment_id=%d, batt_mode=%s, start=%s, end=%s, enabled=%s, device_id=%s, charge_power=%d, charge_stop_soc=%d, mains_enabled=%s", segment_id, batt_mode_str, start_time_str, end_time_str, enabled, device_id, + charge_power, + charge_stop_soc, + mains_enabled, ) # Convert batt_mode string to integer @@ -512,24 +563,27 @@ async def handle_update_time_segment(call: ServiceCall) -> None: # Convert time strings to datetime.time objects try: - start_time = datetime.strptime(start_time_str, "%H:%M").time() - end_time = datetime.strptime(end_time_str, "%H:%M").time() + start_time = dt_time.fromisoformat(start_time_str) + end_time = dt_time.fromisoformat(end_time_str) except ValueError as err: _LOGGER.error("Start_time and end_time must be in HH:MM format") raise HomeAssistantError( "start_time and end_time must be in HH:MM format" ) from err - # Get the appropriate MIN coordinator + # Get the appropriate coordinator coordinator = get_coordinator(device_id) try: - await coordinator.update_time_segment( + return await coordinator.update_time_segment( segment_id, batt_mode, start_time, end_time, enabled, + charge_power=charge_power, + charge_stop_soc=charge_stop_soc, + mains_enabled=mains_enabled, ) except Exception as err: _LOGGER.error( @@ -542,9 +596,22 @@ async def handle_update_time_segment(call: ServiceCall) -> None: ) from err async def handle_read_time_segments(call: ServiceCall) -> dict: - """Handle read_time_segments service call.""" - # Get the appropriate MIN coordinator - coordinator = get_coordinator(call.data.get("device_id")) + """Handle read_min_time_segments service call.""" + + _LOGGER.debug("CALL: %s", call.data) + device_id = "EGM2H4L0G0" + + # # Handle device_id being passed as a list (extract first element) + # if isinstance(device_id, list): + # device_id = device_id[0] if device_id else None + + _LOGGER.info("handle_read_time_segments() called with device_id=%s", device_id) + coordinator = get_coordinator(device_id) + + if coordinator is None: + raise HomeAssistantError( + "No V1 API device found (requires TLX/MIX with token authentication)" + ) try: time_segments = await coordinator.read_time_segments() @@ -554,19 +621,185 @@ async def handle_read_time_segments(call: ServiceCall) -> dict: else: return {"time_segments": time_segments} - # Create device selector schema helper - device_selector_fields = {} - if len(min_coordinators) > 1: - device_options = [ - selector.SelectOptionDict(value=device_id, label=f"MIN Device {device_id}") - for device_id in min_coordinators - ] - device_selector_fields[vol.Required("device_id")] = selector.SelectSelector( - selector.SelectSelectorConfig(options=device_options) + async def handle_update_time_segment_tlx(call: ServiceCall) -> None: + """Handle update_time_segment_tlx service call (TLX-specific).""" + segment_id = int(call.data["segment_id"]) + batt_mode_str = str(call.data["batt_mode"]) + start_time_str = call.data["start_time"] + end_time_str = call.data["end_time"] + enabled = call.data["enabled"] + device_id = call.data.get("device_id") + + _LOGGER.debug( + "handle_update_time_segment_tlx: segment_id=%d, batt_mode=%s, start=%s, end=%s, enabled=%s, device_id=%s", + segment_id, + batt_mode_str, + start_time_str, + end_time_str, + enabled, + device_id, + ) + + # Convert batt_mode string to integer + batt_mode = BATT_MODE_MAP.get(batt_mode_str) + if batt_mode is None: + _LOGGER.error("Invalid battery mode: %s", batt_mode_str) + raise HomeAssistantError(f"Invalid battery mode: {batt_mode_str}") + + # Convert time strings to datetime.time objects + try: + start_time = dt_time.fromisoformat(start_time_str) + end_time = dt_time.fromisoformat(end_time_str) + except ValueError as err: + _LOGGER.error("Start_time and end_time must be in HH:MM format") + raise HomeAssistantError( + "start_time and end_time must be in HH:MM format" + ) from err + + # Get the appropriate coordinator (TLX only) + coordinator = get_coordinator(device_id) + if coordinator.device_type != "tlx": + raise HomeAssistantError( + f"Device {device_id or 'default'} is not a TLX device. Use update_time_segment_mix for MIX devices." + ) + + try: + # TLX devices use basic parameters without charge_power/SOC + return await coordinator.update_time_segment( + segment_id, + batt_mode, + start_time, + end_time, + enabled, + ) + except Exception as err: + _LOGGER.error( + "Error updating TLX time segment %d: %s", + segment_id, + err, + ) + raise HomeAssistantError( + f"Error updating TLX time segment {segment_id}: {err}" + ) from err + + async def handle_update_time_segment_mix(call: ServiceCall) -> None: + """Handle update_time_segment_mix service call (MIX-specific).""" + segment_id = int(call.data["segment_id"]) + batt_mode_str = str(call.data["batt_mode"]) + start_time_str = call.data["start_time"] + end_time_str = call.data["end_time"] + enabled = call.data["enabled"] + device_id = call.data.get("device_id") + + # MIX specific parameters - ensure they are integers + charge_power = int(call.data.get("charge_power", 80)) + charge_stop_soc = int(call.data.get("charge_stop_soc", 95)) + mains_enabled = call.data.get("mains_enabled", True) + + _LOGGER.debug( + "handle_update_time_segment_mix: segment_id=%d, batt_mode=%s, start=%s, end=%s, enabled=%s, device_id=%s, charge_power=%d, charge_stop_soc=%d, mains_enabled=%s", + segment_id, + batt_mode_str, + start_time_str, + end_time_str, + enabled, + device_id, + charge_power, + charge_stop_soc, + mains_enabled, + ) + + # Convert batt_mode string to integer + batt_mode = BATT_MODE_MAP.get(batt_mode_str) + if batt_mode is None: + _LOGGER.error("Invalid battery mode: %s", batt_mode_str) + raise HomeAssistantError(f"Invalid battery mode: {batt_mode_str}") + + # Convert time strings to datetime.time objects + try: + start_time = dt_time.fromisoformat(start_time_str) + end_time = dt_time.fromisoformat(end_time_str) + except ValueError as err: + _LOGGER.error("Start_time and end_time must be in HH:MM format") + raise HomeAssistantError( + "start_time and end_time must be in HH:MM format" + ) from err + + # Get the appropriate coordinator (MIX only) + coordinator = get_coordinator(device_id) + if coordinator.device_type != "mix": + raise HomeAssistantError( + f"Device {device_id or 'default'} is not a MIX device. Use update_time_segment_tlx for TLX devices." + ) + + try: + return await coordinator.update_time_segment( + segment_id, + batt_mode, + start_time, + end_time, + enabled, + charge_power=charge_power, + charge_stop_soc=charge_stop_soc, + mains_enabled=mains_enabled, + ) + except Exception as err: + _LOGGER.error( + "Error updating MIX time segment %d: %s", + segment_id, + err, + ) + raise HomeAssistantError( + f"Error updating MIX time segment {segment_id}: {err}" + ) from err + + async def handle_read_time_segments_tlx(call: ServiceCall) -> dict: + """Handle read_time_segments_tlx service call (TLX-specific).""" + device_id = call.data.get("device_id") + + _LOGGER.info( + "handle_read_time_segments_tlx() called with device_id=%s", device_id + ) + coordinator = get_coordinator(device_id) + + if coordinator.device_type != "tlx": + raise HomeAssistantError( + f"Device {device_id or 'default'} is not a TLX device. Use read_time_segments_mix for MIX devices." + ) + + try: + time_segments = await coordinator.read_time_segments() + except Exception as err: + _LOGGER.error("Error reading TLX time segments: %s", err) + raise HomeAssistantError(f"Error reading TLX time segments: {err}") from err + else: + return {"time_segments": time_segments} + + async def handle_read_time_segments_mix(call: ServiceCall) -> dict: + """Handle read_time_segments_mix service call (MIX-specific).""" + device_id = call.data.get("device_id") + + _LOGGER.info( + "handle_read_time_segments_mix() called with device_id=%s", device_id ) + coordinator = get_coordinator(device_id) + + if coordinator.device_type != "mix": + raise HomeAssistantError( + f"Device {device_id or 'default'} is not a MIX device. Use read_time_segments_tlx for TLX devices." + ) - # Define service schemas - update_schema_fields = { + try: + time_segments = await coordinator.read_time_segments() + except Exception as err: + _LOGGER.error("Error reading MIX time segments: %s", err) + raise HomeAssistantError(f"Error reading MIX time segments: {err}") from err + else: + return {"time_segments": time_segments} + + # Common fields for all device types + common_fields = { + vol.Optional("device_id"): vol.Any(str, None), vol.Required("segment_id"): selector.NumberSelector( selector.NumberSelectorConfig( min=1, max=9, mode=selector.NumberSelectorMode.BOX @@ -583,41 +816,142 @@ async def handle_read_time_segments(call: ServiceCall) -> dict: ] ) ), - vol.Required("start_time"): selector.TimeSelector(), - vol.Required("end_time"): selector.TimeSelector(), + vol.Required( + "start_time", description={"suggested_value": "08:00:00"} + ): selector.TimeSelector(selector.TimeSelectorConfig()), + vol.Required( + "end_time", description={"suggested_value": "17:00:00"} + ): selector.TimeSelector(selector.TimeSelectorConfig()), vol.Required("enabled"): selector.BooleanSelector(), - **device_selector_fields, } - read_schema_fields = {**device_selector_fields} + # SPH_MIX specific fields + mix_fields = { + vol.Optional("charge_power", default=80): selector.NumberSelector( + selector.NumberSelectorConfig( + min=0, + max=100, + step=1, + unit_of_measurement="%", + mode=selector.NumberSelectorMode.SLIDER, + ) + ), + vol.Optional("charge_stop_soc", default=95): selector.NumberSelector( + selector.NumberSelectorConfig( + min=0, + max=100, + step=1, + unit_of_measurement="%", + mode=selector.NumberSelectorMode.SLIDER, + ) + ), + vol.Optional("mains_enabled", default=True): selector.BooleanSelector(), + } + + # MIN_TLX specific fields + min_fields = { + vol.Required("segment_id"): selector.NumberSelector( + selector.NumberSelectorConfig( + min=1, max=9, mode=selector.NumberSelectorMode.BOX + ) + ), + vol.Optional("mains_enabled", default=True): selector.BooleanSelector(), + } + + # Determine which fields to include based on available devices + device_types = {coord.device_type for coord in v1_devices.values()} - # Register services - services_to_register = [ - ( + # Build schema based on device types present + if "mix" in device_types and "tlx" in device_types: + # Both device types - include all fields (user will see all options) + update_time_segment_fields = {**common_fields, **mix_fields} + _LOGGER.info( + "Registering update_time_segment with fields for both MIX and TLX devices" + ) + elif "mix" in device_types: + # Only MIX devices - include MIX-specific fields + update_time_segment_fields = {**common_fields, **mix_fields} + _LOGGER.info( + "Registering update_time_segment with MIX-specific fields " + "(charge_power, charge_stop_soc, mains_enabled)" + ) + else: + # Only TLX devices or no specific detection - use common fields only + update_time_segment_fields = {**common_fields, **min_fields} + _LOGGER.info( + "Registering update_time_segment with TLX-only fields " + "(no MIX-specific parameters)" + ) + read_time_segments_fields = { + vol.Optional("device_id", default=None): vol.Any(str, None), + } + + # TLX-specific schema (no MIX fields) + tlx_update_fields = {**common_fields} + + # MIX-specific schema (includes charge parameters) + mix_update_fields = {**common_fields, **mix_fields} + + # Register the services + if not hass.services.has_service(DOMAIN, "update_time_segment"): + hass.services.async_register( + DOMAIN, "update_time_segment", handle_update_time_segment, - update_schema_fields, - ), - ("read_time_segments", handle_read_time_segments, read_schema_fields), - ] - - for service_name, handler, schema_fields in services_to_register: - if not hass.services.has_service(DOMAIN, service_name): - schema = vol.Schema(schema_fields) if schema_fields else None - supports_response = ( - SupportsResponse.ONLY - if service_name == "read_time_segments" - else SupportsResponse.NONE + schema=vol.Schema(update_time_segment_fields), + ) + _LOGGER.info("Registered service: update_time_segment") + + if not hass.services.has_service(DOMAIN, "read_time_segments"): + hass.services.async_register( + DOMAIN, + "read_time_segments", + handle_read_time_segments, + schema=vol.Schema(read_time_segments_fields), + supports_response=SupportsResponse.ONLY, + ) + _LOGGER.info("Registered service: read_time_segments") + + # Register device-specific services + if "tlx" in device_types: + if not hass.services.has_service(DOMAIN, "update_time_segment_tlx"): + hass.services.async_register( + DOMAIN, + "update_time_segment_tlx", + handle_update_time_segment_tlx, + schema=vol.Schema(tlx_update_fields), + ) + _LOGGER.info("Registered service: update_time_segment_tlx") + + if not hass.services.has_service(DOMAIN, "read_time_segments_tlx"): + hass.services.async_register( + DOMAIN, + "read_time_segments_tlx", + handle_read_time_segments_tlx, + schema=vol.Schema(read_time_segments_fields), + supports_response=SupportsResponse.ONLY, + ) + _LOGGER.info("Registered service: read_time_segments_tlx") + + if "mix" in device_types: + if not hass.services.has_service(DOMAIN, "update_time_segment_mix"): + hass.services.async_register( + DOMAIN, + "update_time_segment_mix", + handle_update_time_segment_mix, + schema=vol.Schema(mix_update_fields), ) + _LOGGER.info("Registered service: update_time_segment_mix") + if not hass.services.has_service(DOMAIN, "read_time_segments_mix"): hass.services.async_register( DOMAIN, - service_name, - handler, - schema=schema, - supports_response=supports_response, + "read_time_segments_mix", + handle_read_time_segments_mix, + schema=vol.Schema(read_time_segments_fields), + supports_response=SupportsResponse.ONLY, ) - _LOGGER.info("Registered service: %s", service_name) + _LOGGER.info("Registered service: read_time_segments_mix") async def async_unload_entry( @@ -632,7 +966,7 @@ async def async_unload_entry( # Only try to unload platforms if they were actually loaded # This prevents errors when setup failed due to throttling - if hasattr(config_entry, 'runtime_data') and config_entry.runtime_data is not None: + if hasattr(config_entry, "runtime_data") and config_entry.runtime_data is not None: unload_ok = await hass.config_entries.async_unload_platforms( config_entry, PLATFORMS ) diff --git a/custom_components/growatt_server/config_flow.py b/custom_components/growatt_server/config_flow.py index 2e833a9..2600952 100644 --- a/custom_components/growatt_server/config_flow.py +++ b/custom_components/growatt_server/config_flow.py @@ -163,7 +163,7 @@ def _async_show_token_form( data_schema=data_schema, errors=errors, description_placeholders={ - "note": "Token authentication only supports MIN/TLX devices. For other device types, please use username/password authentication." + "note": "Token authentication only supports (MIN/TLX, MIX/SPH) devices. For other device types, please use username/password authentication." }, ) diff --git a/custom_components/growatt_server/const.py b/custom_components/growatt_server/const.py index d5b3405..d0d4d5d 100644 --- a/custom_components/growatt_server/const.py +++ b/custom_components/growatt_server/const.py @@ -36,7 +36,12 @@ DOMAIN = "growatt_server" -PLATFORMS = [Platform.NUMBER, Platform.SENSOR, Platform.SWITCH] +PLATFORMS = [ + Platform.SENSOR, + Platform.NUMBER, + Platform.SWITCH, + Platform.TIME, +] LOGIN_INVALID_AUTH_CODE = "502" diff --git a/custom_components/growatt_server/coordinator.py b/custom_components/growatt_server/coordinator.py index f6fa1f9..ad5e660 100644 --- a/custom_components/growatt_server/coordinator.py +++ b/custom_components/growatt_server/coordinator.py @@ -5,8 +5,7 @@ import logging from typing import TYPE_CHECKING, Any -import growattServer - +from growattServer import DeviceType, OpenApiV1, GrowattApi, GrowattV1ApiError from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME from homeassistant.core import HomeAssistant @@ -14,7 +13,13 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt as dt_util -from .const import DEFAULT_URL, DOMAIN +from .const import ( + BATT_MODE_BATTERY_FIRST, + BATT_MODE_GRID_FIRST, + BATT_MODE_LOAD_FIRST, + DEFAULT_URL, + DOMAIN, +) from .models import GrowattRuntimeData if TYPE_CHECKING: @@ -52,17 +57,18 @@ def __init__( self.password = None self.url = config_entry.data.get(CONF_URL, DEFAULT_URL) self.token = config_entry.data["token"] - self.api = growattServer.OpenApiV1(token=self.token) + self.api = OpenApiV1(token=self.token) elif self.api_version == "classic": self.username = config_entry.data.get(CONF_USERNAME) self.password = config_entry.data[CONF_PASSWORD] self.url = config_entry.data.get(CONF_URL, DEFAULT_URL) - self.api = growattServer.GrowattApi( + self.api = GrowattApi( add_random_user_id=True, agent_identifier=self.username ) self.api.server_url = self.url else: - raise ValueError(f"Unknown API version: {self.api_version}") + msg = f"Unknown API version: {self.api_version}" + raise ValueError(msg) super().__init__( hass, @@ -91,7 +97,9 @@ def _calculate_epv_today(self, data: dict) -> dict: try: total_pv_today += float(data[pv_key]) except (ValueError, TypeError): - _LOGGER.debug("Could not convert %s to float: %s", pv_key, data[pv_key]) + _LOGGER.debug( + "Could not convert %s to float: %s", pv_key, data[pv_key] + ) data["epvToday"] = total_pv_today _LOGGER.debug( @@ -112,6 +120,7 @@ def _sync_update_data(self) -> dict[str, Any]: if self.device_type == "total": if self.api_version == "v1": + # # Plant info # The V1 Plant APIs do not provide the same information as the classic plant_info() API # More specifically: # 1. There is no monetary information to be found, so today and lifetime money is not available @@ -120,10 +129,77 @@ def _sync_update_data(self) -> dict[str, Any]: # todayEnergy -> today_energy # totalEnergy -> total_energy # invTodayPpv -> current_power - total_info = self.api.plant_energy_overview(self.plant_id) - total_info["todayEnergy"] = total_info["today_energy"] - total_info["totalEnergy"] = total_info["total_energy"] - total_info["invTodayPpv"] = total_info["current_power"] + try: + if hasattr(self.api, "plant_energy_overview"): + _LOGGER.debug( + "Fetching plant_energy_overview for plant_id=%s", + self.plant_id, + ) + total_info = self.api.plant_energy_overview(self.plant_id) + total_info["todayEnergy"] = total_info.get("today_energy") + total_info["totalEnergy"] = total_info.get("total_energy") + total_info["invTodayPpv"] = total_info.get("current_power") + else: + msg = ( + "plant_energy_overview is not available for this API class" + ) + _LOGGER.error(msg) + raise AttributeError(msg) + except GrowattV1ApiError as err: + _LOGGER.error( + "Error fetching plant_energy_overview for plant %s: %s. Attempting fallback to plant list.", + self.plant_id, + err, + ) + # Fallback: Try to get plant info from plant list + try: + plant_list = self.api.plant_list() + _LOGGER.debug("Plant list response: %s", plant_list) + + # Find the matching plant + matching_plant = None + if isinstance(plant_list, dict): + plants = plant_list.get( + "plants", plant_list.get("data", []) + ) + else: + plants = plant_list if isinstance(plant_list, list) else [] + + for plant in plants: + if str(plant.get("plant_id")) == str(self.plant_id): + matching_plant = plant + break + + if matching_plant: + _LOGGER.info( + "Found plant in plant list, using data from there" + ) + total_info = { + "todayEnergy": matching_plant.get("today_energy", 0), + "totalEnergy": matching_plant.get("total_energy", 0), + "invTodayPpv": matching_plant.get("current_power", 0), + "plant_name": matching_plant.get("plant_name", ""), + } + else: + _LOGGER.warning( + "Could not find plant %s in plant list. Using empty data.", + self.plant_id, + ) + total_info = { + "todayEnergy": 0, + "totalEnergy": 0, + "invTodayPpv": 0, + } + except Exception as fallback_err: + _LOGGER.exception( + "Fallback plant list fetch also failed: %s", fallback_err + ) + # Return minimal data structure to prevent integration failure + total_info = { + "todayEnergy": 0, + "totalEnergy": 0, + "invTodayPpv": 0, + } else: # Classic API: use plant_info as before total_info = self.api.plant_info(self.device_id) @@ -148,11 +224,12 @@ def _sync_update_data(self) -> dict[str, Any]: self.data = min_info _LOGGER.debug("min_info for device %s: %r", self.device_id, min_info) - except growattServer.GrowattV1ApiError as err: - _LOGGER.error( - "Error fetching min device data for %s: %s", self.device_id, err + except GrowattV1ApiError as err: + _LOGGER.exception( + "Error fetching min device data for %s", self.device_id ) - raise UpdateFailed(f"Error fetching min device data: {err}") from err + msg = f"Error fetching min device data: {err}" + raise UpdateFailed(msg) from err elif self.device_type == "tlx": tlx_info = self.api.tlx_detail(self.device_id) self.data = tlx_info["data"] @@ -171,43 +248,200 @@ def _sync_update_data(self) -> dict[str, Any]: **storage_energy_overview, } elif self.device_type == "mix": - mix_info = self.api.mix_info(self.device_id) - mix_totals = self.api.mix_totals(self.device_id, self.plant_id) - mix_system_status = self.api.mix_system_status( - self.device_id, self.plant_id - ) - mix_detail = self.api.mix_detail(self.device_id, self.plant_id) - - # Get the chart data and work out the time of the last entry - mix_chart_entries = mix_detail["chartData"] - sorted_keys = sorted(mix_chart_entries) - - # Create datetime from the latest entry - date_now = dt_util.now().date() - last_updated_time = dt_util.parse_time(str(sorted_keys[-1])) - mix_detail["lastdataupdate"] = datetime.datetime.combine( - date_now, - last_updated_time, # type: ignore[arg-type] - dt_util.get_default_time_zone(), - ) + if self.api_version == "v1": + # Open API V1: mix/sph device + try: + _LOGGER.debug( + "Fetching device_details for MIX device %s", self.device_id + ) + mix_details = self.api.device_details( + self.device_id, DeviceType.SPH_MIX + ) + except GrowattV1ApiError as err: + _LOGGER.error( + "Error fetching device_details for MIX device %s: %s. Attempting fallback to device list.", + self.device_id, + err, + ) + # Fallback: Try to get device info from device list + try: + device_list = self.api.device_list(self.plant_id) + _LOGGER.debug("Device list response: %s", device_list) + + # Find the matching device + matching_device = None + if isinstance(device_list, dict): + devices = device_list.get( + "devices", device_list.get("data", []) + ) + else: + devices = ( + device_list if isinstance(device_list, list) else [] + ) + + for device in devices: + if str(device.get("device_sn")) == str(self.device_id): + matching_device = device + break + + if matching_device: + _LOGGER.info( + "Found device in device list, using basic data from there" + ) + mix_details = { + "device_sn": matching_device.get( + "device_sn", self.device_id + ), + "device_type": matching_device.get( + "device_type", "mix" + ), + "lost": matching_device.get("lost", False), + "status": matching_device.get("status", 0), + } + else: + _LOGGER.warning( + "Could not find device %s in device list. Using minimal data.", + self.device_id, + ) + mix_details = { + "device_sn": self.device_id, + "device_type": "mix", + "lost": False, + "status": 0, + } + except Exception as fallback_err: + _LOGGER.exception( + "Fallback device list fetch also failed: %s", fallback_err + ) + # Return minimal data structure to prevent integration failure + mix_details = { + "device_sn": self.device_id, + "device_type": "mix", + "lost": False, + "status": 0, + } - # Dashboard data for mix system - dashboard_data = self.api.dashboard_data(self.plant_id) - dashboard_values_for_mix = { - "etouser_combined": float(dashboard_data["etouser"].replace("kWh", "")) - } - self.data = { - **mix_info, - **mix_totals, - **mix_system_status, - **mix_detail, - **dashboard_values_for_mix, - } - _LOGGER.debug( - "Finished updating data for %s (%s)", - self.device_id, - self.device_type, - ) + try: + _LOGGER.debug( + "Fetching device_energy for MIX device %s", self.device_id + ) + mix_energy = self.api.device_energy( + self.device_id, DeviceType.SPH_MIX + ) + + date_str = mix_energy.get("time") + date_format = "%Y-%m-%d %H:%M:%S" + tz = dt_util.get_default_time_zone() + if date_str is not None: + naive_dt = datetime.datetime.strptime(date_str, date_format) + aware_dt = naive_dt.replace(tzinfo=tz) + mix_details["lastdataupdate"] = aware_dt + else: + mix_details["lastdataupdate"] = None + + mix_energy["ppv1"] = mix_energy.get("ppv1", 0) / 1000 # W to kW + mix_energy["ppv2"] = mix_energy.get("ppv2", 0) / 1000 # W to kW + mix_energy["ppv"] = mix_energy.get("ppv", 0) / 1000 # W to kW + mix_energy["accdischargePowerKW"] = ( + mix_energy.get("accdischargePower", 0) / 1000 + ) # W to kW + except GrowattV1ApiError as err: + _LOGGER.error( + "Error fetching device_energy for device %s: %s. Using empty energy data.", + self.device_id, + err, + ) + mix_energy = {} + mix_details["lastdataupdate"] = None + except Exception as err: + _LOGGER.exception( + "Unexpected error fetching device_energy for device %s: %s", + self.device_id, + err, + ) + mix_energy = {} + mix_details["lastdataupdate"] = None + + # Merge all the data + mix_info = {**mix_details, **mix_energy} + self.data = mix_info + _LOGGER.debug("mix_info for device %s: %r", self.device_id, mix_info) + else: + mix_info = self.api.mix_info(self.device_id) + mix_totals = self.api.mix_totals(self.device_id, self.plant_id) + + # Fetch mix_system_status with error handling + try: + _LOGGER.debug( + "Fetching mix_system_status for device %s", self.device_id + ) + mix_system_status = self.api.mix_system_status( + self.device_id, self.plant_id + ) + except Exception as err: + _LOGGER.error( + "Error fetching mix_system_status for device %s: %s. Using empty data.", + self.device_id, + err, + ) + mix_system_status = {} + + # Fetch mix_detail with error handling + try: + _LOGGER.debug("Fetching mix_detail for device %s", self.device_id) + mix_detail = self.api.mix_detail(self.device_id, self.plant_id) + + # Get the chart data and work out the time of the last entry + mix_chart_entries = mix_detail.get("chartData", {}) + if mix_chart_entries: + sorted_keys = sorted(mix_chart_entries) + + # Create datetime from the latest entry + date_now = dt_util.now().date() + last_updated_time = dt_util.parse_time(str(sorted_keys[-1])) + mix_detail["lastdataupdate"] = datetime.datetime.combine( + date_now, + last_updated_time, # type: ignore[arg-type] + dt_util.get_default_time_zone(), + ) + else: + mix_detail["lastdataupdate"] = None + except Exception as err: + _LOGGER.error( + "Error fetching mix_detail for device %s: %s. Using empty data.", + self.device_id, + err, + ) + mix_detail = {"lastdataupdate": None} + + # Dashboard data for mix system + try: + dashboard_data = self.api.dashboard_data(self.plant_id) + dashboard_values_for_mix = { + "etouser_combined": float( + dashboard_data["etouser"].replace("kWh", "") + ) + } + except Exception as err: + _LOGGER.error( + "Error fetching dashboard_data for device %s: %s. Using empty data.", + self.device_id, + err, + ) + dashboard_values_for_mix = {} + + self.data = { + **mix_info, + **mix_totals, + **mix_system_status, + **mix_detail, + **dashboard_values_for_mix, + } + _LOGGER.debug( + "Finished updating data for %s (%s)", + self.device_id, + self.device_type, + ) return self.data @@ -223,12 +457,30 @@ def get_currency(self): """Get the currency.""" return self.data.get("currency") + def _get_matching_api_key( + self, variable: str | list[str] | tuple[str], key_list: dict[str, Any] + ) -> str | None: + """Get the matching api_key from the data.""" + if isinstance(variable, str): + api_value = key_list.get(variable) + return variable + elif isinstance(variable, (list, tuple)): + # Try each key in the array until we find a match + for key in variable: + value = key_list.get(key) + if value is not None: + api_value = value + return key + break + def get_data( self, entity_description: "GrowattSensorEntityDescription" ) -> str | int | float | None: """Get the data.""" - variable = entity_description.api_key + # Support entity_description.api_key being either str or list/tuple of str + variable = self._get_matching_api_key(entity_description.api_key, self.data) api_value = self.data.get(variable) + previous_value = self.previous_values.get(variable) return_value = api_value @@ -296,10 +548,115 @@ def set_value(self, entity_description, value: str | int) -> None: """Update a value in coordinator data after successful write.""" self.data[entity_description.api_key] = value + def _get_time_segment_params( + self, + segment_id: int, + batt_mode: int, + start_time: datetime.time, + end_time: datetime.time, + enabled: bool, + charge_power: int, + charge_stop_soc: int, + mains_enabled: bool, + ) -> tuple[DeviceType, Any, str]: + """ + Determine device type and create appropriate params for time segment update. + + Args: + segment_id: Time segment ID (1-9) + batt_mode: Battery mode (0=load first, 1=battery first, 2=grid first) + start_time: Start time (datetime.time object) + end_time: End time (datetime.time object) + enabled: Whether the segment is enabled + charge_power: Charge power percentage (0-100, SPH_MIX only) + charge_stop_soc: Charge stop SOC percentage (0-100, SPH_MIX only) + mains_enabled: Enable mains charging (SPH_MIX only) + + Returns: + Tuple of (device_type, params, command) + + Raises: + HomeAssistantError: If device type is unsupported or battery mode is invalid + """ + if self.device_type == "tlx": + # MIN_TLX device - use TimeSegmentParams + device_type = DeviceType.MIN_TLX + params = self.api.TimeSegmentParams( + segment_id=segment_id, + batt_mode=batt_mode, + start_time=start_time, + end_time=end_time, + enabled=enabled, + ) + command = f"time_segment_{segment_id}" + return device_type, params, command + + elif self.device_type == "mix": + # SPH_MIX device - different commands based on battery mode + device_type = DeviceType.SPH_MIX + + if batt_mode == BATT_MODE_BATTERY_FIRST: + # Battery first mode - AC charge time period + params = self.api.MixAcChargeTimeParams( + charge_power=charge_power, + charge_stop_soc=charge_stop_soc, + mains_enabled=mains_enabled, + start_hour=start_time.hour, + start_minute=start_time.minute, + end_hour=end_time.hour, + end_minute=end_time.minute, + enabled=enabled, + segment_id=segment_id, + ) + command = "mix_ac_charge_time_period" + return device_type, params, command + + elif batt_mode == BATT_MODE_GRID_FIRST: + # Grid first mode - AC discharge time period + params = self.api.MixAcDischargeTimeParams( + discharge_power=charge_power, # discharge power + discharge_stop_soc=charge_stop_soc, # Stop at % SOC + start_hour=start_time.hour, + start_minute=start_time.minute, + end_hour=end_time.hour, + end_minute=end_time.minute, + enabled=enabled, + segment_id=segment_id, + ) + command = "mix_ac_discharge_time_period" + return device_type, params, command + + elif batt_mode == BATT_MODE_LOAD_FIRST: + # Load first mode - single export + params = self.api.ChargeDischargeParams( + discharge_stop_soc=charge_stop_soc, + ) + command = "mix_single_export" + return device_type, params, command + + else: + msg = f"Invalid battery mode {batt_mode} for MIX device" + _LOGGER.error(msg) + raise HomeAssistantError(msg) + + else: + msg = f"Time segment updates not supported for device type: {self.device_type}" + _LOGGER.error(msg) + raise HomeAssistantError(msg) + async def update_time_segment( - self, segment_id: int, batt_mode: int, start_time, end_time, enabled: bool + self, + segment_id: int, + batt_mode: int, + start_time: datetime.time, + end_time: datetime.time, + enabled: bool, + charge_power: int = 80, + charge_stop_soc: int = 95, + mains_enabled: bool = True, ) -> None: - """Update a time segment. + """ + Update a time segment. Args: segment_id: Time segment ID (1-9) @@ -307,145 +664,282 @@ async def update_time_segment( start_time: Start time (datetime.time object) end_time: End time (datetime.time object) enabled: Whether the segment is enabled - """ + charge_power: Charge power percentage (0-100, SPH_MIX only) + charge_stop_soc: Charge stop SOC percentage (0-100, SPH_MIX only) + mains_enabled: Enable mains charging (SPH_MIX only) + """ _LOGGER.debug( - "Updating MIN/TLX time segment %s for device %s", + "Updating time segment %s for device %s (%s)", segment_id, self.device_id, + self.device_type, ) - if self.api_version == "v1": - # Use V1 API for token authentication - response = await self.hass.async_add_executor_job( - self.api.min_write_time_segment, + if self.api_version != "v1": + msg = "Time segment updates require V1 API (token authentication)" + _LOGGER.warning(msg) + raise HomeAssistantError(msg) + + # Get device type, params, and command for this update + device_type, params, command = self._get_time_segment_params( + segment_id=segment_id, + batt_mode=batt_mode, + start_time=start_time, + end_time=end_time, + enabled=enabled, + charge_power=charge_power, + charge_stop_soc=charge_stop_soc, + mains_enabled=mains_enabled, + ) + + _LOGGER.debug( + "Running Command %s with params %s", + command, + params, + ) + + try: + # Use V1 API write_time_segment method + response = self.hass.async_add_executor_job( + self.api.write_time_segment, self.device_id, - segment_id, - batt_mode, - start_time, - end_time, - enabled, + device_type, + command, + params, ) - if hasattr(response, 'get'): - if response.get("error_code", 1) == 0: + _LOGGER.debug( + "Write time segment response: type=%s, response=%s", + type(response).__name__, + response, + ) + + # Handle dict response (most common) + if isinstance(response, dict): + error_code = response.get("error_code", 1) + error_msg = response.get("error_msg", "Unknown error") + + if error_code == 0: _LOGGER.info( - "Successfully updated MIN/TLX time segment %s for device %s", + "Successfully updated time segment %s for device %s: %s (Full response: %s)", segment_id, self.device_id, + error_msg, + response, ) # Trigger a refresh to update the data await self.async_refresh() else: - error_msg = response.get("error_msg", "Unknown error") _LOGGER.error( - "Failed to update MIN/TLX time segment %s for device %s: %s", + "Failed to update time segment %s for device %s: error_code=%s, %s", segment_id, self.device_id, + error_code, error_msg, ) - raise HomeAssistantError(f"Failed to update time segment: {error_msg}") - else: - _LOGGER.warning( - "Time segment updates are only supported with V1 API (token authentication)" - ) - raise HomeAssistantError( - "Time segment updates require token authentication" + msg = f"Failed to update time segment: {error_msg} (code: {error_code})" + raise HomeAssistantError(msg) + else: + _LOGGER.warning( + "Unexpected response format for time segment update: %s - %s", + type(response).__name__, + response, + ) + except HomeAssistantError: + # Re-raise HomeAssistantError as-is + raise + except Exception as err: + _LOGGER.exception( + "Error updating time segment %s for device %s", + segment_id, + self.device_id, ) + msg = f"Error updating time segment: {err}" + raise HomeAssistantError(msg) from err + + async def read_time_segments(self) -> list[dict[str, Any]]: + """ + Read time segments from the device. - async def read_time_segments(self) -> list[dict]: - """Read time segments from an inverter. + For MIN/TLX devices: Uses the API's read_time_segments method. + For SPH/MIX devices: Parses the forced charge/discharge fields. Returns: - List of dictionaries containing segment information + List of time segment dictionaries with keys: + - segment_id: int + - batt_mode: int (0=load first, 1=battery first/charge, 2=grid first/discharge) + - mode_name: str + - start_time: str (HH:MM format) + - end_time: str (HH:MM format) + - enabled: bool + """ + if self.api_version != "v1": + msg = "Time segment reading requires V1 API" + _LOGGER.error(msg) + raise HomeAssistantError(msg) + + if self.device_type == "tlx": + return await self._read_tlx_time_segments() + elif self.device_type == "mix": + return await self._read_mix_time_segments() + else: + msg = f"Time segment reading not supported for device type: {self.device_type}" + _LOGGER.error(msg) + raise HomeAssistantError(msg) + + async def _read_tlx_time_segments(self) -> list[dict[str, Any]]: + """Read time segments for MIN/TLX devices using the API.""" _LOGGER.debug( - "Reading MIN/TLX time segments for device %s", + "Reading TLX time segments for device %s", self.device_id, ) - if self.api_version != "v1": - _LOGGER.warning( - "Reading time segments is only supported with V1 API (token authentication)" + try: + response = await self.hass.async_add_executor_job( + self.api.read_time_segments, + self.device_id, + DeviceType.MIN_TLX, ) - raise HomeAssistantError( - "Reading time segments requires token authentication" + + _LOGGER.debug( + "TLX read_time_segments response type: %s, content: %s", + type(response).__name__, + response, + ) + + # Handle different response formats + if isinstance(response, list): + time_segments = response + elif isinstance(response, dict): + error_code = response.get("error_code", 1) + if error_code == 0: + time_segments = response.get("data", []) + else: + error_msg = response.get("error_msg", "Unknown error") + msg = f"API error reading time segments: {error_msg} (code: {error_code})" + raise HomeAssistantError(msg) + else: + _LOGGER.warning( + "Unexpected response format: %s", type(response).__name__ + ) + time_segments = [] + + _LOGGER.info( + "Successfully read %d time segments for TLX device %s", + len(time_segments), + self.device_id, ) - # Ensure we have current data - if not self.data: - _LOGGER.debug("Triggering refresh to get time segments") - await self.async_refresh() + return time_segments + + except HomeAssistantError: + raise + except Exception as err: + _LOGGER.exception("Error reading TLX time segments: %s", err) + msg = f"Error reading TLX time segments: {err}" + raise HomeAssistantError(msg) from err + + async def _read_mix_time_segments(self) -> list[dict[str, Any]]: + """ + Read time segments for SPH/MIX devices by parsing device settings. + + SPH/MIX devices store charge/discharge periods in numbered fields: + - forcedChargeStopSwitch1-6: Enable flags for charge periods + - forcedChargeTimeStart1-6: Start times for charge periods + - forcedChargeTimeStop1-6: End times for charge periods + - forcedDischargeStopSwitch1-6: Enable flags for discharge periods + - forcedDischargeTimeStart1-6: Start times for discharge periods + - forcedDischargeTimeStop1-6: End times for discharge periods - time_segments = [] - mode_names = {0: "Load First", 1: "Battery First", 2: "Grid First"} + """ + _LOGGER.debug( + "Reading MIX time segments for device %s", + self.device_id, + ) try: - # Extract time segments from coordinator data - for i in range(1, 10): # Segments 1-9 - # Get raw time values - start_time_raw = self.data.get(f"forcedTimeStart{i}", "0:0") - end_time_raw = self.data.get(f"forcedTimeStop{i}", "0:0") - - # Handle 'null' or empty values - if start_time_raw in ("null", None, ""): - start_time_raw = "0:0" - if end_time_raw in ("null", None, ""): - end_time_raw = "0:0" - - # Format times with leading zeros (HH:MM) - try: - start_parts = str(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" + # Get current device data which includes all settings + if not self.data: + await self.async_refresh() + + time_segments = [] + + # Parse charge periods (Battery First mode - batt_mode=1) + for i in range(1, 7): # 6 charge periods + enabled = bool(self.data.get(f"forcedChargeStopSwitch{i}", 0)) + start_time = self.data.get(f"forcedChargeTimeStart{i}", "0:0") + end_time = self.data.get(f"forcedChargeTimeStop{i}", "0:0") + + # Normalize time format from "H:M" to "HH:MM" + start_time = self._normalize_time_format(start_time) + end_time = self._normalize_time_format(end_time) + + time_segments.append( + { + "segment_id": i, + "batt_mode": BATT_MODE_BATTERY_FIRST, + "mode_name": "Battery First (Charge)", + "start_time": start_time, + "end_time": end_time, + "enabled": enabled, + } + ) - try: - end_parts = str(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 - mode_raw = self.data.get(f"time{i}Mode") - if mode_raw in ("null", None): - batt_mode = None - else: - try: - batt_mode = int(mode_raw) - except (ValueError, TypeError): - batt_mode = None + # Parse discharge periods (Grid First mode - batt_mode=2) + for i in range(1, 7): # 6 discharge periods + enabled = bool(self.data.get(f"forcedDischargeStopSwitch{i}", 0)) + start_time = self.data.get(f"forcedDischargeTimeStart{i}", "0:0") + end_time = self.data.get(f"forcedDischargeTimeStop{i}", "0:0") + + # Normalize time format + start_time = self._normalize_time_format(start_time) + end_time = self._normalize_time_format(end_time) + + time_segments.append( + { + "segment_id": i + 6, # Offset by 6 to avoid conflicts + "batt_mode": BATT_MODE_GRID_FIRST, + "mode_name": "Grid First (Discharge)", + "start_time": start_time, + "end_time": end_time, + "enabled": enabled, + } + ) - # Get the enabled status - enabled_raw = self.data.get(f"forcedStopSwitch{i}", 0) - if enabled_raw in ("null", 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, - } + _LOGGER.info( + "Successfully read %d time segments for MIX device %s", + len(time_segments), + self.device_id, + ) - time_segments.append(segment) - _LOGGER.debug("MIN/TLX time segment %s: %s", i, segment) + return time_segments except Exception as err: - _LOGGER.error("Error reading MIN/TLX time segments: %s", err) - raise HomeAssistantError( - f"Error reading MIN/TLX time segments: {err}" - ) from err + _LOGGER.exception("Error reading MIX time segments: %s", err) + msg = f"Error reading MIX time segments: {err}" + raise HomeAssistantError(msg) from err + + @staticmethod + def _normalize_time_format(time_str: str) -> str: + """ + Normalize time string from "H:M" format to "HH:MM" format. + + Examples: + "14:0" -> "14:00" + "0:0" -> "00:00" + "9:30" -> "09:30" + + """ + try: + parts = time_str.split(":") + if len(parts) != 2: + return "00:00" + + hours = int(parts[0]) + minutes = int(parts[1]) - return time_segments + return f"{hours:02d}:{minutes:02d}" + except (ValueError, AttributeError): + return "00:00" diff --git a/custom_components/growatt_server/growattServer/__init__.py b/custom_components/growatt_server/growattServer/__init__.py new file mode 100644 index 0000000..eb8ddfb --- /dev/null +++ b/custom_components/growatt_server/growattServer/__init__.py @@ -0,0 +1,9 @@ +# Import everything from base_api to ensure backward compatibility +from .base_api import * +# Import the V1 API class +from .open_api_v1 import OpenApiV1, DeviceType +# Import exceptions +from .exceptions import GrowattError, GrowattParameterError, GrowattV1ApiError + +# Define the name of the package +name = "growattServer" diff --git a/custom_components/growatt_server/growattServer/base_api.py b/custom_components/growatt_server/growattServer/base_api.py new file mode 100644 index 0000000..0f74fff --- /dev/null +++ b/custom_components/growatt_server/growattServer/base_api.py @@ -0,0 +1,1178 @@ +import datetime +from enum import IntEnum +import requests +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): + """ + 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:] + return password_md5 + + +class Timespan(IntEnum): + hour = 0 + day = 1 + month = 2 + + +class GrowattApi: + 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): + 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)]) + self.agent_identifier += " - " + random_number + + self.session = requests.Session() + self.session.hooks = { + 'response': lambda response, *args, **kwargs: 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 date is None: + date = datetime.datetime.now() + + date_str = "" + if timespan == Timespan.month: + date_str = date.strftime('%Y-%m') + else: + date_str = date.strftime('%Y-%m-%d') + + return date_str + + def get_url(self, page): + """ + Simple helper function to get the page URL. + """ + return self.server_url + page + + 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 + 'service' + 'quality' + 'isOpenSmartFamily' + 'totalData' -- An Object + 'success' -- True or False + 'msg' + 'app_code' + 'user' -- An Object containing a lot of user information + 'uid' + 'userLanguage' + 'inverterGroup' -- A List + 'timeZone' -- A Number + 'lat' + 'lng' + 'dataAcqList' -- A List + 'type' + 'accountName' -- The username + 'password' -- The password hash of the user + 'isValiPhone' + 'kind' + 'mailNotice' -- True or False + 'id' + 'lasLoginIp' + 'lastLoginTime' + 'userDeviceType' + 'phoneNum' + 'approved' -- True or False + 'area' -- Continent of the user + 'smsNotice' -- True or False + 'isAgent' + 'token' + 'nickName' + 'parentUserId' + 'customerCode' + 'country' + 'isPhoneNumReg' + 'createDate' + 'rightlevel' + 'appType' + 'serverUrl' + 'roleId' + 'enabled' -- True or False + 'agentCode' + 'inverterList' -- A list + 'email' + 'company' + 'activeName' + 'codeIndex' + '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 + }) + + data = response.json()['back'] + if data['success']: + data.update({ + 'userId': data['user']['id'], + 'userLevel': data['user']['rightlevel'] + }) + return data + + 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 + ) + + 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) + + response = self.session.get(self.get_url('PlantDetailAPI.do'), params={ + 'plantId': plant_id, + 'type': timespan.value, + 'date': date_str + }) + + 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={ + 'op': 'getInverterData', + 'id': inverter_id, + 'type': 1, + 'date': date_str + }) + + return response.json() + + def inverter_detail(self, inverter_id): + """ + 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 + }) + + return response.json() + + def inverter_detail_two(self, inverter_id): + """ + 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 + }) + + 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 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={ + 'op': 'getTlxData', + 'id': tlx_id, + 'type': 1, + 'date': date_str + }) + + return response.json() + + def tlx_detail(self, tlx_id): + """ + 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 + }) + + 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): + """ + Returns high level values from 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) + + 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 + """ + request_params = { + 'op': 'getMixInfo', + 'mixId': mix_id + } + + if (plant_id): + request_params['plantId'] = plant_id + + response = self.session.get(self.get_url( + 'newMixApi.do'), params=request_params) + + return response.json().get('obj', {}) + + def mix_totals(self, mix_id, plant_id): + """ + Returns "Totals" values from Mix device + + Keyword arguments: + mix_id -- The serial number (device_sn) of the inverter + plant_id -- The ID of the plant + + 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' + """ + response = self.session.post(self.get_url('newMixApi.do'), params={ + 'op': 'getEnergyOverview', + 'mixId': mix_id, + 'plantId': plant_id + }) + + return response.json().get('obj', {}) + + def mix_system_status(self, mix_id, plant_id): + """ + Returns current "Status" from Mix device + + Keyword arguments: + mix_id -- The serial number (device_sn) of the inverter + plant_id -- The ID of the plant + + 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 + """ + response = self.session.post(self.get_url('newMixApi.do'), params={ + 'op': 'getSystemStatus_KW', + 'mixId': mix_id, + 'plantId': plant_id + }) + + return response.json().get('obj', {}) + + def mix_detail(self, mix_id, plant_id, timespan=Timespan.hour, date=None): + """ + Get Mix details for specified 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()) + + 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 + """ + 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 + }) + + return response.json().get('obj', {}) + + 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. + + 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()) + + 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. + """ + 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 + }) + + 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): + """ + 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 + }) + + 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 + }) + + 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. + """ + 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. + """ + + 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): + """ + 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'), + params={ + '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 + + Keyword arguments: + plant_id -- The id of the plant you want the noah devices of (str) + + 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 + """ + 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 + + Keyword arguments: + serial_number -- The Serial number of the noah device you want the status of (str) + + 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) + """ + 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 + + Keyword arguments: + serial_number -- The Serial number of the noah device you want the informations of (str) + + 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 + """ + 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 + + 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: + 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'])), + # 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'])), + } + + # Overwrite the current value of the setting with the new value + for setting, value in changed_settings.items(): + form_settings[setting] = (None, str(value)) + + response = self.session.post(self.get_url( + 'newTwoPlantAPI.do?op=updatePlant'), files=form_settings) + + return response.json() + + 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 + + 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) + + Returns: + JSON response from the server whether the configuration was successful + """ + settings_parameters = parameters + + # If we've been passed an array then convert it into a dictionary + if isinstance(parameters, list): + settings_parameters = {} + for index, param in enumerate(parameters, start=1): + settings_parameters['param' + str(index)] = param + + settings_parameters = {**default_parameters, **settings_parameters} + + response = self.session.post(self.get_url('newTcpsetAPI.do'), + params=settings_parameters) + + return response.json() + + 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 + + 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) + + Returns: + JSON response from the server whether the configuration was successful + """ + default_parameters = { + 'op': 'mixSetApiNew', + 'serialNum': serial_number, + 'type': setting_type + } + return self.update_inverter_setting(serial_number, setting_type, + default_parameters, 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 + + 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) + + Returns: + JSON response from the server whether the configuration was successful + """ + default_parameters = { + 'op': 'spaSetApi', + 'serialNum': serial_number, + 'type': setting_type + } + 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 + See README for known working settings + + 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) + + Returns: + JSON response from the server whether the configuration was successful + """ + default_parameters = { + 'serialNum': serial_number, + 'type': setting_type + } + settings_parameters = parameters + + # If we've been passed an array then convert it into a dictionary + if isinstance(parameters, list): + settings_parameters = {} + for index, param in enumerate(parameters, start=1): + settings_parameters['param' + str(index)] = param + + settings_parameters = {**default_parameters, **settings_parameters} + + response = self.session.post(self.get_url('noahDeviceApi/noah/set'), + data=settings_parameters) + + return response.json() diff --git a/custom_components/growatt_server/growattServer/exceptions.py b/custom_components/growatt_server/growattServer/exceptions.py new file mode 100644 index 0000000..ff67a98 --- /dev/null +++ b/custom_components/growatt_server/growattServer/exceptions.py @@ -0,0 +1,33 @@ +""" +Exception classes for the growattServer library. + +Note that in addition to these custom exceptions, methods may also raise exceptions +from the underlying requests library (requests.exceptions.RequestException and its +subclasses) when network or HTTP errors occur. These are not wrapped and are passed +through directly to the caller. + +Common requests exceptions to handle: +- requests.exceptions.HTTPError: For HTTP error responses (4XX, 5XX) +- requests.exceptions.ConnectionError: For network connection issues +- requests.exceptions.Timeout: For request timeouts +- requests.exceptions.RequestException: The base exception for all requests exceptions +""" + + +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): + super().__init__(message) + self.error_code = error_code + self.error_msg = error_msg diff --git a/custom_components/growatt_server/growattServer/open_api_v1.py b/custom_components/growatt_server/growattServer/open_api_v1.py new file mode 100644 index 0000000..56c252a --- /dev/null +++ b/custom_components/growatt_server/growattServer/open_api_v1.py @@ -0,0 +1,1263 @@ +import json # noqa: D100 +import platform +import re +import warnings +from datetime import UTC, date, datetime, time, timedelta +from enum import Enum +from typing import ClassVar, NamedTuple, Optional + +from . import GrowattApi +from .exceptions import GrowattParameterError, GrowattV1ApiError + +DEBUG = 1 + +class DeviceType(Enum): + """Enumeration of Growatt device types.""" + + MIX_SPH = 5 # MIX/SPH devices + MIN_TLX = 7 # MIN/TLX devices + + @classmethod + def get_url_prefix(cls, device_type) -> str: # noqa: ANN001 + """Get the URL prefix for a given device type.""" + if device_type == cls.MIX_SPH: + return "mix" + elif device_type == cls.MIN_TLX: # noqa: RET505 + return "tlx" + else: + msg = f"Unsupported device type: {device_type}" + raise GrowattParameterError(msg) + + @classmethod + def get_url_read_param(cls, device_type: "DeviceType") -> str: + """Get the URL param for a given device type.""" + if device_type == cls.MIX_SPH: + return "readMixParam" + elif device_type == cls.MIN_TLX: # noqa: RET505 + return "readMinParam" + else: + msg = f"Unsupported device type: {device_type}" + raise GrowattParameterError(msg) + +class ApiDataType(Enum): + """Enumeration of Growatt device types.""" + + LAST_DATA = "last_data" + HISTORY_DATA = "history_data" + BASIC_INFO = "basic_info" + DEVICE_SETTINGS = "settings" + READ_PARAM = "read_param" +class OpenApiV1(GrowattApi): + """ + Extended Growatt API client with V1 API support. + + This class extends the base GrowattApi class with methods for MIN/TLX and MIX/SPH + inverters using the public V1 API described here: + https://www.showdoc.com.cn/262556420217021/0. + """ + + DEVICE_ENDPOINTS: ClassVar = { + DeviceType.MIX_SPH: { + # https://www.showdoc.com.cn/262556420217021/6129764434976910 + ApiDataType.LAST_DATA: "device/mix/mix_last_data", + # https://www.showdoc.com.cn/262556420217021/6129763571291058 + ApiDataType.BASIC_INFO: "device/mix/mix_data_info", + # https://www.showdoc.com.cn/262556420217021/6129765461123058 + ApiDataType.HISTORY_DATA: "device/mix/mix_data", + # https://www.showdoc.com.cn/262556420217021/6129763571291058 + ApiDataType.DEVICE_SETTINGS: "device/mix/mix_data_info", + # https://www.showdoc.com.cn/262556420217021/6129766954561259 + ApiDataType.READ_PARAM: "readMixParam" + }, + DeviceType.MIN_TLX: { + # https://www.showdoc.com.cn/262556420217021/6129822090975531 + ApiDataType.LAST_DATA: "device/tlx/tlx_last_data", + # https://www.showdoc.com.cn/262556420217021/6129816412127075 + ApiDataType.BASIC_INFO: "device/tlx/tlx_data_info", + # https://www.showdoc.com.cn/262556420217021/8559849784929961 + ApiDataType.HISTORY_DATA: "device/tlx/tlx_data", + # https://www.showdoc.com.cn/262556420217021/8696815667375182 + ApiDataType.DEVICE_SETTINGS: "device/tlx/tlx_set_info", + # https://www.showdoc.com.cn/262556420217021/6129828239577315 + ApiDataType.READ_PARAM: "readMinParam" + } + } + + def _create_user_agent(self) -> str: + python_version = platform.python_version() + system = platform.system() + release = platform.release() + machine = platform.machine() + + return f"Python/{python_version} ({system} {release}; {machine})" + + def __init__(self, token: str) -> 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()) + + # Add V1 API specific properties + self.api_url = f"{self.server_url}v1/" + + # Set up authentication for V1 API using the provided token + self.session.headers.update({"token": token}) + + + @staticmethod + def slugify(s: str) -> str: + """ + Convert a string to a slug by removing non-word characters. + + Replace whitespace with underscores. + + Args: + s (str): The string to slugify. + + Returns: + str: The slugified string. + + """ + # Remove all non-word characters (everything except numbers and letters) + s = re.sub(r"[^\w\s]", "", s) + + # Replace all runs of whitespace with a single underscrore + return re.sub(r"\s+", "_", s) + + + def _process_response( + self, + response: dict, + operation_name: str = "API operation" + ) -> dict: + """ + Process API response and handle errors. + + Args: + response (dict): The JSON response from the API + operation_name (str): Name of the operation for error messages + + Returns: + dict: The 'data' field from the response + + Raises: + GrowattV1ApiError: If the API returns an error response + + """ + if DEBUG >= 1: + print(f"Saving {self.slugify(operation_name)}_data.json") # noqa: T201 + with open(f"{self.slugify(operation_name)}_data.json", "w") as f: # noqa: PTH123 + json.dump(response, f, indent=4, sort_keys=True) + + if response.get("error_code", 1) != 0: + msg = f"Error during {operation_name}" + raise GrowattV1ApiError( + msg, + error_code=response.get("error_code"), + error_msg=response.get("error_msg", "Unknown error") + ) + + return response.get("data") or {} + + def _get_url(self, page: str) -> str: + """Return the page URL for v1 API.""" + return self.api_url + page + + def _get_device_url(self, device_type: DeviceType, operation: ApiDataType) -> str: + """ + Get the API URL for a specific device type and operation. + + Args: + device_type (DeviceType): The type of device (MIN_TLX or MIX_SPH) + operation (str): The operation to perform ('energy', 'settings', etc.) + + Returns: + str: The complete API URL for the operation + + Raises: + GrowattParameterError: If the device type or operation is not supported + + """ + if device_type not in self.DEVICE_ENDPOINTS: + msg = f"Unsupported device type: {device_type}" + raise GrowattParameterError(msg) + if operation not in self.DEVICE_ENDPOINTS[device_type]: + msg = f"Unsupported operation '{operation}' for device type {device_type}" + raise GrowattParameterError(msg) + return self.api_url + self.DEVICE_ENDPOINTS[device_type][operation] + + def plant_list(self) -> dict: + """ + Get a list of all plants with detailed information. + + Returns: + dict: A dictionary containing plants information. + Includes 'count' and 'plants' keys. + + 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": "" + } + + # Make the request + response = self.session.get( + url=self._get_url("plant/list"), + data=request_data + ) + + return self._process_response(response.json(), "getting plant list") + + def plant_details(self, plant_id: int) -> dict: + """ + Get basic information about a power station. + + Args: + plant_id (int): Power Station ID + + Returns: + dict: A dictionary containing the plant 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("plant/details"), + params={"plant_id": plant_id} + ) + + return self._process_response(response.json(), "getting plant details") + + def plant_energy_overview(self, plant_id: int) -> dict: + """ + Get an overview of a plant's energy data. + + Args: + plant_id (int): Power Station ID + + Returns: + dict: A dictionary containing the plant energy overview. + + 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} + ) + + return self._process_response(response.json(), "getting plant energy overview") + + 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 + + { + '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 = datetime.now(tz=UTC).date() + + response = self.session.get( + self._get_url("plant/power"), + params={ + "plant_id": plant_id, + "date": str(day), + } + ) + + return self._process_response(response.json(), "getting plant power overview") + + class PlantEnergyHistoryParams(NamedTuple): + """Parameters for querying plant energy history.""" + + start_date: date | None = None + end_date: date | None = None + time_unit: str = "day" + page: int | None = None + perpage: int | None = None + + def plant_energy_history( + self, + plant_id: int, + params: Optional["PlantEnergyHistoryParams"] = None + ) -> dict: + """ + Retrieve plant energy data for multiple days/months/years. + + Args: + plant_id (int): Power Station ID + params (PlantEnergyHistoryParams): + Grouped parameters for energy history query. + + Returns: + dict: A dictionary containing the plant energy history. + + Notes: + - When time_unit is 'day', date interval cannot exceed 7 days + - When time_unit is 'month', start date must be within same or previous year + - When time_unit is 'year', date interval must not exceed 20 years + + Raises: + GrowattParameterError: If date parameters are invalid. + + """ + if params is None: + params = self.PlantEnergyHistoryParams() + start_date = params.start_date + end_date = params.end_date + time_unit = params.time_unit + page = params.page + perpage = params.perpage + end_date = params.end_date + time_unit = params.time_unit + page = params.page + perpage = params.perpage + + if start_date is None and end_date is None: + today = datetime.now(tz=UTC).date() + start_date = today + end_date = today + elif start_date is None: + start_date = ( + end_date + if end_date is not None + else datetime.now(tz=UTC).date() + ) + elif end_date is None: + end_date = ( + start_date + if start_date is not None + else datetime.now(tz=UTC).date() + ) + + # Validate date ranges based on time_unit + if ( + time_unit == "day" + and start_date is not None + and end_date is not None + and (end_date - start_date).days > 7 # noqa: PLR2004 + ): + warnings.warn( + "Date interval must not exceed 7 days in 'day' mode.", + RuntimeWarning, + stacklevel=2 + ) + elif ( + time_unit == "month" + and start_date is not None + and end_date is not None + 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 start_date is not None + and end_date is not None + and (end_date.year - start_date.year > 20) # noqa: PLR2004 + ): + warnings.warn( + "Date interval must not exceed 20 years in 'year' mode.", + RuntimeWarning, + stacklevel=2 + ) + + # Ensure start_date and end_date are not None + if start_date is None: + start_date = datetime.now(tz=UTC).date() + if end_date is None: + end_date = datetime.now(tz=UTC).date() + + # Ensure start_date and end_date are not None + if start_date is None: + start_date = datetime.now(tz=UTC).date() + if end_date is None: + end_date = datetime.now(tz=UTC).date() + + response = self.session.get( + self._get_url("plant/energy"), + params={ + "plant_id": plant_id, + "start_date": start_date.strftime("%Y-%m-%d"), + "end_date": ( + (end_date or datetime.now(tz=UTC).date()).strftime("%Y-%m-%d") + ), + "time_unit": time_unit, + "page": page, + "perpage": perpage + } + ) + + return self._process_response(response.json(), "getting plant energy history") + + def device_list(self, plant_id: int) -> dict: + """ + Get a list of devices in a plant. + + The device list includes type information that maps to the DeviceType enum + (5 for MIX_SPH, 7 for MIN_TLX). + + Args: + plant_id (int): The plant ID to get devices for. + + Returns: + dict: A dictionary containing device information with 'count' and + 'devices' fields. + Each device includes a 'type' field that maps to DeviceType enum + values. + + 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( + url=self._get_url("device/list"), + params={ + "plant_id": plant_id + } + ) + + data = self._process_response(response.json(), "getting device list") + + # Add device_type mapping for each device based on type + if "devices" in data: + for device in data["devices"]: + try: + device_type = device.get("type") + if device_type: + device["device_type"] = DeviceType(device_type) + except ValueError: + device["device_type"] = None + return data + + def device_details(self, device_sn: str, device_type: DeviceType) -> dict: + """ + Get detailed data for a device. + + Args: + device_sn (str): The serial number of the device. + device_type (DeviceType): The type of device (MIN_TLX or MIX_SPH). + + Returns: + dict: A dictionary containing the device details. + + Raises: + GrowattParameterError: If the device type is not supported. + GrowattV1ApiError: If the API returns an error response. + requests.exceptions.RequestException: + If there is an issue with the HTTP request. + + """ + if not isinstance(device_type, DeviceType): + msg = f"Invalid device type: {device_type}" + raise GrowattParameterError(msg) + + response = self.session.get( + self._get_device_url(device_type, ApiDataType.BASIC_INFO), + params={ + "device_sn": device_sn + } + ) + + return self._process_response( + response.json(), + f"getting {device_type.name} details" + ) + + def device_energy(self, device_sn: str, device_type: DeviceType) -> dict: + """ + Get energy data for a device. + + Args: + device_sn (str): The serial number of the device. + device_type (DeviceType): The type of device (MIN_TLX or MIX_SPH). + + Returns: + dict: A dictionary containing the device energy data. + + Raises: + GrowattParameterError: If the device type is not supported. + GrowattV1ApiError: If the API returns an error response. + requests.exceptions.RequestException: + If there is an issue with the HTTP request. + + """ + if not isinstance(device_type, DeviceType): + msg = f"Invalid device type: {device_type}" + raise GrowattParameterError(msg) + + url_prefix = DeviceType.get_url_prefix(device_type) + + response = self.session.post( + url=self._get_device_url(device_type, ApiDataType.LAST_DATA), + data={ + f"{url_prefix}_sn": device_sn + } + ) + + + # responseHydrated = self._process_response(response.json(), f"getting {device_type.name} energy data") + + # responseHydrated['epvToday'] = responseHydrated.get('epv1Today', 0) + responseHydrated.get("epv2Today", 0) + + return self._process_response( + response.json(), + f"getting {device_type.name} energy data" + ) + + def min_energy(self, device_sn: str) -> dict: + """ + Get energy data for a MIN inverter. + + return self.device_energy(device_sn, DeviceType.MIN_TLX). + + 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. + + """ + return self.device_energy(device_sn, DeviceType.MIN_TLX) + + def device_settings(self, device_sn: str, device_type: DeviceType) -> dict: + """ + Get settings for a device. + + Args: + device_sn (str): The serial number of the device. + device_type (DeviceType): The type of device (MIN_TLX or MIX_SPH). + + Returns: + dict: A dictionary containing the device settings. + + Raises: + GrowattParameterError: If the device type is not supported. + GrowattV1ApiError: If the API returns an error response. + requests.exceptions.RequestException: + If there is an issue with the HTTP request. + + """ + if not isinstance(device_type, DeviceType): + msg = f"Invalid device type: {device_type}" + raise GrowattParameterError(msg) + + response = self.session.get( + self._get_device_url(device_type, ApiDataType.DEVICE_SETTINGS), + params={ + "device_sn": device_sn + } + ) + + return self._process_response( + response.json(), + f"getting {device_type.name} settings" + ) + + def min_settings(self, device_sn: str) -> 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. + + """ + return self.device_settings(device_sn, DeviceType.MIN_TLX) + + class DeviceEnergyHistoryParams(NamedTuple): + """Parameters for querying device energy history.""" + + start_date: date | None = None + end_date: date | None = None + timezone: str | None = None + page: int | None = None + limit: int | None = None + + def device_energy_history( + self, + device_sn: str, + device_type: DeviceType, + params: Optional["DeviceEnergyHistoryParams"] = None + ) -> dict: + """ + Get device energy history data. + + Args: + device_sn (str): The ID of the device. + device_type (DeviceType): The type of device (MIN_TLX or MIX_SPH). + params (DeviceEnergyHistoryParams, optional): + Grouped parameters for energy history query. + + Returns: + dict: A dictionary containing the device history data. + + Raises: + GrowattParameterError: If device type is invalid or date interval + exceeds 7 days. + GrowattV1ApiError: If the API returns an error response. + requests.exceptions.RequestException: + If there is an issue with the HTTP request. + + """ + if not isinstance(device_type, DeviceType): + msg = f"Invalid device type: {device_type}" + raise GrowattParameterError(msg) + + if params is None: + params = self.DeviceEnergyHistoryParams() + + start_date = params.start_date + end_date = params.end_date + timezone = params.timezone + page = params.page + limit = params.limit + + if start_date is None and end_date is None: + start_date = datetime.now(tz=UTC).date() + end_date = datetime.now(tz=UTC).date() + elif start_date is None: + start_date = ( + end_date + if end_date is not None + else datetime.now(tz=UTC).date() + ) + elif end_date is None: + end_date = ( + start_date + if start_date is not None + else datetime.now(tz=UTC).date() + ) + + # check interval validity + if ( + start_date is not None + and end_date is not None + and (end_date - start_date > timedelta(days=7)) + ): + msg = "date interval must not exceed 7 days" + raise GrowattParameterError(msg) + + url_prefix = DeviceType.get_url_prefix(device_type) + # Ensure end_date is not None before formatting + if end_date is None: + end_date = datetime.now(tz=UTC).date() + + response = self.session.post( + url=self._get_device_url(device_type, ApiDataType.HISTORY_DATA), + data={ + f"{url_prefix}_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(), + f"getting {device_type.name} energy history" + ) + + def common_read_parameter( + self, + device_sn: str, + device_type: DeviceType, + parameter_id: str, + start_address: int | None = None, + end_address: int | None = None + ) -> dict: + """ + Read setting from MIN inverter. + + Args: + device_sn (str): The ID of the TLX inverter. + device_type (DeviceType): The type of device (MIN_TLX or MIX_SPH). + 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 not isinstance(device_type, DeviceType): + msg = f"Invalid device type: {device_type}" + raise GrowattParameterError(msg) + + if parameter_id is None and start_address is None: + msg = "specify either parameter_id or start_address/end_address" + raise GrowattParameterError( + msg) + elif parameter_id is not None and start_address is not None: # noqa: RET506 + msg = "specify either parameter_id or start_address/end_address - not both." + raise GrowattParameterError( + msg + ) + elif 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_device_url(device_type, ApiDataType.READ_PARAM), + 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}" + ) + + def min_write_parameter( + self, + device_sn: str, + parameter_id: str, + parameter_values: object = 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 + parameters = dict.fromkeys(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 # noqa: PLR2004 + parameters[i] = str(value) + elif isinstance(parameter_values, dict): + # Dict maps param positions to values + for pos, value in parameter_values.items(): + param_pos = int(pos) if not isinstance(pos, int) else pos + if 1 <= param_pos <= 19: # Validate parameter positions # noqa: E501, PLR2004 + parameters[param_pos] = str(value) + + # IMPORTANT: Create a data dictionary with ALL parameters explicitly included + request_data = { + "tlx_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("tlxSet"), + data=request_data + ) + + return self._process_response( + response.json(), + f"writing parameter {parameter_id}" + ) + # This line has been removed to eliminate dead code. + + class TimeSegmentParams(NamedTuple): + """ + Parameters for a time segment in a MIN inverter. + + segment_id (int): Segment number (1-9). + batt_mode (int): Battery mode (0=Load First, 1=Battery First, 2=Grid First). + start_time (object): Start time (should be datetime.time). + end_time (object): End time (should be datetime.time). + enabled (bool): Whether the segment is enabled. + """ + + segment_id: int + batt_mode: int + start_time: time # Should be datetime.time + end_time: time # Should be datetime.time + enabled: bool = True + + def min_write_time_segment( + self, + device_sn: str, + params: "TimeSegmentParams" + ) -> dict: + """ + Set a time segment for a MIN inverter. + + Args: + device_sn (str): The serial number of the inverter. + params (TimeSegmentParams): Grouped parameters for the time segment. + + 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 <= params.segment_id <= 9: # noqa: PLR2004 + msg = "segment_id must be between 1 and 9" + raise GrowattParameterError(msg) + + if not 0 <= params.batt_mode <= 2: # noqa: PLR2004 + msg = "batt_mode must be between 0 and 2" + 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{params.segment_id}" + } + + # Add param1 through param19, setting the values we need + all_params["param1"] = str(params.batt_mode) + all_params["param2"] = str(params.start_time.hour) + all_params["param3"] = str(params.start_time.minute) + all_params["param4"] = str(params.end_time.hour) + all_params["param5"] = str(params.end_time.minute) + all_params["param6"] = "1" if params.enabled else "0" + + # Add empty strings for all unused parameters + for i in range(7, 20): + 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 {params.segment_id}" + ) + + def min_read_time_segments( + self, + device_sn: str, + settings_data: dict | None = None + ) -> list[dict]: + """ + 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.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 if isinstance(batt_mode, int) else -1, + "Unknown" + ), + "start_time": start_time, + "end_time": end_time, + "enabled": enabled + } + + segments.append(segment) + + return segments + + def read_time_segments( + self, + device_sn: str, + device_type: DeviceType, + settings_data: dict | None = None + ) -> list[dict]: + """ + Read Time-of-Use (TOU) settings from a Growatt MIN/TLX or MIX/SPH inverter. + + Retrieves all 9 time segments from a Growatt MIN/TLX or MIX/SPH inverter and + parses them into a structured format. + + Note that this function uses device_settings() internally to get the + settings data. To avoid endpoint rate limit, you can pass the + settings_data parameter with the data returned from device_settings(). + + Args: + device_sn (str): The device serial number of the inverter + device_type (DeviceType): The type of device (MIN_TLX or MIX_SPH). + settings_data (dict, optional): Settings data from device_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.read_time_segments( + "DEVICE_SERIAL_NUMBER", + DeviceType.MIN_TLX + ) + # Option 2: Reuse existing settings data + settings_response = api.device_settings( + "DEVICE_SERIAL_NUMBER", + DeviceType.MIN_TLX + ) + tou_settings = api.read_time_segments( + "DEVICE_SERIAL_NUMBER", + DeviceType.MIN_TLX, + settings_response + ) + + Raises: + GrowattParameterError: If device type is invalid. + 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.device_settings(device_sn, device_type=device_type) + + # 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 if batt_mode is not None else -1, + "Unknown" + ), + "start_time": start_time, + "end_time": end_time, + "enabled": enabled + } + + segments.append(segment) + + return segments + + def get_devices(self, plant_id: int) -> list["GrowattDevice"]: + """Get devices as GrowattDevice objects for easier use.""" + data = self.device_list(plant_id) + return [GrowattDevice(self, device) for device in data["devices"]] + +class GrowattDevice: + """Represents a Growatt device with automatic type handling.""" + + def __init__(self, api: OpenApiV1, device_data: dict) -> None: + """ + Initialize a GrowattDevice instance. + + Args: + api: The API client instance. + device_data: Dictionary containing device information. + + """ + self._api = api + self.device_sn = device_data["device_sn"] + self.device_type = device_data["device_type"] + self.model = device_data.get("model") + self.status = device_data.get("status") + # Store other device metadata... + + def details(self) -> dict: + """Get detailed device data.""" + return self._api.device_details(self.device_sn, self.device_type) + + def energy(self) -> dict: + """Get current energy data.""" + return self._api.device_energy(self.device_sn, self.device_type) + + def settings(self) -> dict: + """Get device settings.""" + return self._api.device_settings(self.device_sn, self.device_type) + + def energy_history( + self, + start_date: date | None = None, + end_date: date | None = None, + timezone: str | None = None, + page: int | None = None, + limit: int | None = None + ) -> dict: + """Get energy history data.""" + params = self._api.DeviceEnergyHistoryParams( + start_date=start_date, + end_date=end_date, + timezone=timezone, + page=page, + limit=limit + ) + return self._api.device_energy_history( + self.device_sn, + self.device_type, + params + ) + + def read_time_segments(self, settings_data: dict | None = None) -> list[dict]: + """Read TOU time segments.""" + return self._api.read_time_segments( + self.device_sn, self.device_type, settings_data + ) diff --git a/custom_components/growatt_server/manifest.json b/custom_components/growatt_server/manifest.json index 658914f..5d9bd96 100644 --- a/custom_components/growatt_server/manifest.json +++ b/custom_components/growatt_server/manifest.json @@ -7,6 +7,8 @@ "iot_class": "cloud_polling", "issue_tracker": "https://github.com/johanzander/growatt_server_upstream/issues", "loggers": ["growattServer"], - "requirements": ["growattServer==1.7.1"], - "version": "1.5.1" -} + "requirements": [ + "git+https://github.com/GraemeDBlue/PyPi_GrowattServer.git@write_values" + ], + "version": "2.2.0" +} \ No newline at end of file diff --git a/custom_components/growatt_server/number.py b/custom_components/growatt_server/number.py index 2b58f5a..d6669b3 100644 --- a/custom_components/growatt_server/number.py +++ b/custom_components/growatt_server/number.py @@ -5,7 +5,7 @@ from dataclasses import dataclass import logging -from growattServer import GrowattV1ApiError +from growattServer import DeviceType, GrowattV1ApiError, OpenApiV1 from homeassistant.components.number import NumberEntity, NumberEntityDescription from homeassistant.const import PERCENTAGE @@ -79,6 +79,49 @@ class GrowattNumberEntityDescription(NumberEntityDescription, GrowattRequiredKey ), ) +MIX_NUMBER_TYPES: tuple[GrowattNumberEntityDescription, ...] = ( + GrowattNumberEntityDescription( + key="charge_power", + translation_key="charge_power", + api_key="chargePowerCommand", # Key returned by V1 API + write_key="charge_power", # Key used to write parameter + native_step=1, + native_min_value=0, + native_max_value=100, + native_unit_of_measurement=PERCENTAGE, + ), + GrowattNumberEntityDescription( + key="charge_stop_soc", + translation_key="charge_stop_soc", + api_key="wchargeSOCLowLimit1", # Key for MIX devices (time period 1) + write_key="charge_stop_soc", # Key used to write parameter + native_step=1, + native_min_value=0, + native_max_value=100, + native_unit_of_measurement=PERCENTAGE, + ), + GrowattNumberEntityDescription( + key="discharge_power", + translation_key="discharge_power", + api_key="disChargePowerCommand", # Key returned by V1 API + write_key="discharge_power", # Key used to write parameter + native_step=1, + native_min_value=0, + native_max_value=100, + native_unit_of_measurement=PERCENTAGE, + ), + GrowattNumberEntityDescription( + key="discharge_stop_soc", + translation_key="discharge_stop_soc", + api_key="loadFirstStopSocSet", # Key returned by V1 API for MIX devices + write_key="discharge_stop_soc", # Key used to write parameter + native_step=1, + native_min_value=0, + native_max_value=100, + native_unit_of_measurement=PERCENTAGE, + ), +) + class GrowattNumber(CoordinatorEntity[GrowattCoordinator], NumberEntity): """Representation of a Growatt number.""" @@ -112,33 +155,177 @@ def native_value(self) -> int | None: async def async_set_native_value(self, value: float) -> None: """Set the value of the number.""" try: - # Use write_key if specified, otherwise fall back to api_key - parameter_id = ( - self.entity_description.write_key or self.entity_description.api_key + # Convert float to int for API + int_value = int(value) + + # Determine device type based on coordinator device type + if self.coordinator.device_type == "tlx": + device_type = DeviceType.MIN_TLX + else: # mix + device_type = DeviceType.SPH_MIX + + # Get the command name from write_key + key = self.entity_description.key + + if self.coordinator.device_type == "mix": + # MIX devices require full parameter sets for charge/discharge time periods + if key in ("charge_power", "charge_stop_soc"): + # Updating charge parameters - need all charge time period params + command = "mix_ac_charge_time_period" + + # Get current values from coordinator data + current_charge_power = self.coordinator.data.get( + "chargePowerCommand", 80 + ) + current_charge_soc = self.coordinator.data.get( + "wchargeSOCLowLimit1", 100 + ) + current_ac_charge = self.coordinator.data.get("acChargeEnable", 1) + # Get the time period enable switch (separate from AC charge enable) + period_enabled = self.coordinator.data.get( + "forcedChargeStopSwitch1", 1 + ) + + # Parse time from "HH:MM" format, default to 14:00-16:00 + charge_start_str = self.coordinator.data.get( + "forcedChargeTimeStart1", "14:0" + ) + charge_stop_str = self.coordinator.data.get( + "forcedChargeTimeStop1", "16:0" + ) + + try: + start_parts = charge_start_str.split(":") + start_hour = int(start_parts[0]) + start_minute = int(start_parts[1]) + except (ValueError, IndexError, AttributeError): + start_hour, start_minute = 14, 0 + + try: + stop_parts = charge_stop_str.split(":") + end_hour = int(stop_parts[0]) + end_minute = int(stop_parts[1]) + except (ValueError, IndexError, AttributeError): + end_hour, end_minute = 16, 0 + + # Update the value being changed + if key == "charge_power": + current_charge_power = int_value + else: # charge_stop_soc + current_charge_soc = int_value + + params = OpenApiV1.MixAcChargeTimeParams( + charge_power=int(current_charge_power), + charge_stop_soc=int(current_charge_soc), + mains_enabled=bool(current_ac_charge), + start_hour=start_hour, + start_minute=start_minute, + end_hour=end_hour, + end_minute=end_minute, + enabled=bool(period_enabled), + segment_id=1, + ) + + else: # discharge_power or discharge_stop_soc + # Updating discharge parameters - need all discharge time period params + command = "mix_ac_discharge_time_period" + + # Get current values from coordinator data + current_discharge_power = self.coordinator.data.get( + "disChargePowerCommand", 100 + ) + current_discharge_soc = self.coordinator.data.get( + "loadFirstStopSocSet", 10 + ) + # Get the time period enable switch + period_enabled = self.coordinator.data.get( + "forcedDischargeStopSwitch1", 0 + ) + + # Parse time from "HH:MM" format, default to 00:00-00:00 (disabled) + discharge_start_str = self.coordinator.data.get( + "forcedDischargeTimeStart1", "0:0" + ) + discharge_stop_str = self.coordinator.data.get( + "forcedDischargeTimeStop1", "0:0" + ) + + try: + start_parts = discharge_start_str.split(":") + start_hour = int(start_parts[0]) + start_minute = int(start_parts[1]) + except (ValueError, IndexError, AttributeError): + start_hour, start_minute = 0, 0 + + try: + stop_parts = discharge_stop_str.split(":") + end_hour = int(stop_parts[0]) + end_minute = int(stop_parts[1]) + except (ValueError, IndexError, AttributeError): + end_hour, end_minute = 0, 0 + + # Update the value being changed + if key == "discharge_power": + current_discharge_power = int_value + else: # discharge_stop_soc + current_discharge_soc = int_value + + params = OpenApiV1.MixAcDischargeTimeParams( + discharge_power=int(current_discharge_power), + discharge_stop_soc=int(current_discharge_soc), + start_hour=start_hour, + start_minute=start_minute, + end_hour=end_hour, + end_minute=end_minute, + enabled=bool(period_enabled), + segment_id=1, + ) + + else: + # MIN/TLX devices use individual ChargeDischargeParams commands + command = ( + self.entity_description.write_key or self.entity_description.api_key + ) + params = OpenApiV1.ChargeDischargeParams( + charge_power=int_value if key == "charge_power" else 0, + charge_stop_soc=int_value if key == "charge_stop_soc" else 0, + discharge_power=int_value if key == "discharge_power" else 0, + discharge_stop_soc=int_value if key == "discharge_stop_soc" else 0, + ac_charge_enabled=False, + ) + + _LOGGER.debug( + "Setting %s to %s for device type %s (command: %s)", + key, + int_value, + device_type, + command, ) # Use V1 API to write parameter await self.hass.async_add_executor_job( - self.coordinator.api.min_write_parameter, + self.coordinator.api.write_parameter, self.coordinator.device_id, - parameter_id, - int(value), + device_type, + command, + params, ) _LOGGER.debug( - "Set parameter %s to %s", - parameter_id, - value, + "Successfully set %s to %s", + key, + int_value, ) # If no exception was raised, the write was successful # Update the value in coordinator - self.coordinator.set_value(self.entity_description, int(value)) + self.coordinator.set_value(self.entity_description, int_value) self.async_write_ha_state() except GrowattV1ApiError as e: - _LOGGER.error("Error while setting parameter: %s", e) - raise HomeAssistantError(f"Error while setting parameter: {e}") from e + msg = f"Error while setting parameter: {e}" + _LOGGER.exception(msg) + raise HomeAssistantError(msg) from e async def async_setup_entry( @@ -151,18 +338,21 @@ async def async_setup_entry( entities: list[GrowattNumber] = [] - # Add number entities for each MIN device (only supported with V1 API) + # Add number entities for each device (only supported with V1 API) for device_coordinator in runtime_data.devices.values(): - if ( - device_coordinator.device_type == "min" - and device_coordinator.api_version == "v1" - ): + if device_coordinator.api_version == "v1": + # Use appropriate number types based on device type + if device_coordinator.device_type == "tlx": + number_types = MIN_NUMBER_TYPES + else: # mix + number_types = MIX_NUMBER_TYPES + entities.extend( GrowattNumber( coordinator=device_coordinator, description=description, ) - for description in MIN_NUMBER_TYPES + for description in number_types ) async_add_entities(entities) diff --git a/custom_components/growatt_server/sensor/mix.py b/custom_components/growatt_server/sensor/mix.py index b741a58..c4470ba 100644 --- a/custom_components/growatt_server/sensor/mix.py +++ b/custom_components/growatt_server/sensor/mix.py @@ -17,21 +17,21 @@ GrowattSensorEntityDescription( key="mix_statement_of_charge", translation_key="mix_statement_of_charge", - api_key="capacity", + api_key=["capacity", "bmsSOC"], native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, ), GrowattSensorEntityDescription( key="mix_battery_charge_today", translation_key="mix_battery_charge_today", - api_key="eBatChargeToday", + api_key=["eBatChargeToday", "echarge1Today"], native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, ), GrowattSensorEntityDescription( key="mix_battery_charge_lifetime", translation_key="mix_battery_charge_lifetime", - api_key="eBatChargeTotal", + api_key=["eBatChargeTotal", "echarge1Total"], native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL, @@ -39,14 +39,14 @@ GrowattSensorEntityDescription( key="mix_battery_discharge_today", translation_key="mix_battery_discharge_today", - api_key="eBatDisChargeToday", + api_key=["eBatDisChargeToday", "edischarge1Today"], native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, ), GrowattSensorEntityDescription( key="mix_battery_discharge_lifetime", translation_key="mix_battery_discharge_lifetime", - api_key="eBatDisChargeTotal", + api_key=["eBatDisChargeTotal", "edischarge1Total"], native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL, @@ -54,7 +54,7 @@ GrowattSensorEntityDescription( key="mix_solar_generation_today", translation_key="mix_solar_generation_today", - api_key="epvToday", + api_key=["epvToday", "epvtoday"], native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, ), @@ -69,7 +69,7 @@ GrowattSensorEntityDescription( key="mix_battery_discharge_w", translation_key="mix_battery_discharge_w", - api_key="pDischarge1", + api_key=["pDischarge1", "bdc1DischargePower", "accdischargePower"], native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, ), @@ -129,28 +129,28 @@ GrowattSensorEntityDescription( key="mix_battery_charge", translation_key="mix_battery_charge", - api_key="chargePower", + api_key=["chargePower", "bdc1ChargePower"], native_unit_of_measurement=UnitOfPower.KILO_WATT, device_class=SensorDeviceClass.POWER, ), GrowattSensorEntityDescription( key="mix_load_consumption", translation_key="mix_load_consumption", - api_key="pLocalLoad", + api_key=["pLocalLoad", "elocalLoadToday"], native_unit_of_measurement=UnitOfPower.KILO_WATT, device_class=SensorDeviceClass.POWER, ), GrowattSensorEntityDescription( key="mix_wattage_pv_1", translation_key="mix_wattage_pv_1", - api_key="pPv1", + api_key=["pPv1", "ppv1"], native_unit_of_measurement=UnitOfPower.KILO_WATT, device_class=SensorDeviceClass.POWER, ), GrowattSensorEntityDescription( key="mix_wattage_pv_2", translation_key="mix_wattage_pv_2", - api_key="pPv2", + api_key=["pPv2", "ppv2"], native_unit_of_measurement=UnitOfPower.KILO_WATT, device_class=SensorDeviceClass.POWER, ), @@ -164,28 +164,28 @@ GrowattSensorEntityDescription( key="mix_export_to_grid", translation_key="mix_export_to_grid", - api_key="pactogrid", + api_key=["pactogrid", "pacToGridTotal"], native_unit_of_measurement=UnitOfPower.KILO_WATT, device_class=SensorDeviceClass.POWER, ), GrowattSensorEntityDescription( key="mix_import_from_grid", translation_key="mix_import_from_grid", - api_key="pactouser", + api_key=["pactouser", "pacToUserR"], native_unit_of_measurement=UnitOfPower.KILO_WATT, device_class=SensorDeviceClass.POWER, ), GrowattSensorEntityDescription( key="mix_battery_discharge_kw", translation_key="mix_battery_discharge_kw", - api_key="pdisCharge1", + api_key=["pdisCharge1", "bdc1DischargePower", "accdischargePowerKW"], native_unit_of_measurement=UnitOfPower.KILO_WATT, device_class=SensorDeviceClass.POWER, ), GrowattSensorEntityDescription( key="mix_grid_voltage", translation_key="mix_grid_voltage", - api_key="vAc1", + api_key=["vAc1", "vac1"], native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, ), @@ -193,7 +193,7 @@ GrowattSensorEntityDescription( key="mix_system_production_today", translation_key="mix_system_production_today", - api_key="eCharge", + api_key=["eCharge", "esystemtoday"], native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, ), @@ -207,7 +207,7 @@ GrowattSensorEntityDescription( key="mix_self_consumption_today", translation_key="mix_self_consumption_today", - api_key="eChargeToday1", + api_key=["eChargeToday1", "eselfToday", "eselftoday"], native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, ), @@ -221,7 +221,7 @@ GrowattSensorEntityDescription( key="mix_import_from_grid_today", translation_key="mix_import_from_grid_today", - api_key="etouser", + api_key=["etouser", "etoUserToday"], native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, ), diff --git a/custom_components/growatt_server/sensor/sensor_entity_description.py b/custom_components/growatt_server/sensor/sensor_entity_description.py index e1ee4c3..3106ed1 100644 --- a/custom_components/growatt_server/sensor/sensor_entity_description.py +++ b/custom_components/growatt_server/sensor/sensor_entity_description.py @@ -3,6 +3,7 @@ from __future__ import annotations from dataclasses import dataclass +from typing import Union, List from homeassistant.components.sensor import SensorEntityDescription @@ -11,13 +12,14 @@ class GrowattRequiredKeysMixin: """Mixin for required keys.""" - api_key: str + api_key: Union[str, List[str]] @dataclass(frozen=True) class GrowattSensorEntityDescription(SensorEntityDescription, GrowattRequiredKeysMixin): """Describes Growatt sensor entity.""" + api_key: Union[str, List[str]] precision: int | None = None currency: bool = False previous_value_drop_threshold: float | None = None diff --git a/custom_components/growatt_server/services.yaml b/custom_components/growatt_server/services.yaml index b18c792..0f88752 100644 --- a/custom_components/growatt_server/services.yaml +++ b/custom_components/growatt_server/services.yaml @@ -1,9 +1,213 @@ # Service definitions for Growatt Server integration -# Schemas are defined dynamically in code (__init__.py) using selectors +turn_on: + name: Turn on + description: Turn on the switch with optional charge time configuration + target: + entity: + integration: growatt_server + domain: switch + fields: + start_time: + name: Start time + description: When to start AC charging + required: false + example: "14:00:00" + selector: + time: + end_time: + name: End time + description: When to stop AC charging + required: false + example: "16:00:00" + selector: + time: + charge_power: + name: Charge power + description: Charging power percentage (0-100) + required: false + example: 80 + selector: + number: + min: 0 + max: 100 + unit_of_measurement: "%" + charge_stop_soc: + name: Charge stop SOC + description: Battery state of charge percentage to stop charging at (0-100) + required: false + example: 95 + selector: + number: + min: 0 + max: 100 + unit_of_measurement: "%" + +# MIN_TLX specific service +update_time_segment_tlx: + name: Update TLX Time Segment + description: Update a time-of-use (TOU) segment for MIN_TLX inverters + fields: + device_id: + name: Device ID + description: Device serial number (optional if only one TLX device) + required: false + example: "ABC123DEF456" + selector: + text: + segment_id: + name: Segment ID + description: Time segment number (1-9) + required: true + example: 1 + selector: + number: + min: 1 + max: 9 + mode: box + batt_mode: + name: Battery mode + description: Operating mode for this time segment + required: true + example: "load-first" + selector: + select: + options: + - label: Load First + value: "load-first" + - label: Battery First + value: "battery-first" + - label: Grid First + value: "grid-first" + start_time: + name: Start time + description: Segment start time + required: true + example: "08:00" + selector: + time: + end_time: + name: End time + description: Segment end time + required: true + example: "12:00" + selector: + time: + enabled: + name: Enabled + description: Whether this segment is active + required: true + example: true + selector: + boolean: + +# SPH_MIX specific service +update_time_segment_mix: + name: Update MIX Time Segment + description: Update a time-of-use (TOU) segment for SPH_MIX inverters + fields: + device_id: + name: Device ID + description: Device serial number (optional if only one MIX device) + required: false + example: "EGM2H4L0G0" + selector: + text: + segment_id: + name: Time Period ID + description: Time period number (1-3) + required: true + example: 1 + selector: + number: + min: 1 + max: 3 + mode: box + batt_mode: + name: Battery mode + description: Operating mode for this time period + required: true + example: "battery-first" + selector: + select: + options: + - label: Load First (Export) + value: "load-first" + - label: Battery First (Charge) + value: "battery-first" + - label: Grid First (Discharge) + value: "grid-first" + start_time: + name: Start time + description: Period start time + required: true + example: "08:00" + selector: + time: + end_time: + name: End time + description: Period end time + required: true + example: "12:00" + selector: + time: + enabled: + name: Enabled + description: Whether this period is active + required: true + example: true + selector: + boolean: + charge_power: + name: Charge/Discharge Power + description: Power percentage for charging (battery-first) or discharging (grid-first) (0-100%) + required: false + default: 80 + example: 80 + selector: + number: + min: 0 + max: 100 + step: 1 + unit_of_measurement: "%" + mode: slider + charge_stop_soc: + name: Stop SOC + description: Battery SOC to stop at (charge up to % for battery-first, discharge down to % for grid-first) + required: false + default: 95 + example: 95 + selector: + number: + min: 0 + max: 100 + step: 1 + unit_of_measurement: "%" + mode: slider + mains_enabled: + name: Mains enabled + description: Enable mains/grid connection for this operation + required: false + default: true + example: true + selector: + boolean: + +# Legacy combined service (deprecated - use specific services above) update_time_segment: + name: Update time segment + description: Update a time-of-use (TOU) segment for MIN_TLX or SPH_MIX inverters fields: + device_id: + name: Device ID + description: Device serial number (optional if only one V1 device) + required: false + example: "EGM2H4L0G0" + selector: + text: segment_id: + name: Segment ID + description: Time segment number (1-9) required: true example: 1 selector: @@ -12,6 +216,8 @@ update_time_segment: max: 9 mode: box batt_mode: + name: Battery mode + description: Operating mode for this time segment required: true example: "load-first" selector: @@ -21,29 +227,91 @@ update_time_segment: - "battery-first" - "grid-first" start_time: + name: Start time + description: Segment start time required: true example: "08:00" selector: time: end_time: + name: End time + description: Segment end time required: true example: "12:00" selector: time: enabled: + name: Enabled + description: Whether this segment is active required: true example: true selector: boolean: + charge_power: + name: Charge power + description: Charging power percentage (SPH_MIX only, 0-100) + required: false + example: 80 + selector: + number: + min: 0 + max: 100 + step: 1 + unit_of_measurement: "%" + mode: slider + charge_stop_soc: + name: Charge stop SOC + description: Battery SOC to stop charging at (SPH_MIX only, 0-100) + required: false + example: 95 + selector: + number: + min: 0 + max: 100 + step: 1 + unit_of_measurement: "%" + mode: slider + mains_enabled: + name: Mains enabled + description: Enable mains charging (SPH_MIX only) + required: false + example: true + selector: + boolean: + +# Device-specific read services +read_time_segments_tlx: + name: Read time segments (TLX) + description: Read all time-of-use (TOU) segments from MIN_TLX inverter + fields: device_id: + name: Device ID + description: Device serial number (optional if only one TLX device) required: false example: "MIN12345" selector: text: +read_time_segments_mix: + name: Read time segments (MIX) + description: Read all time-of-use (TOU) segments from SPH_MIX inverter + fields: + device_id: + name: Device ID + description: Device serial number (optional if only one MIX device) + required: false + example: "EGM2H4L0G0" + selector: + text: + +# Legacy combined service (deprecated - use specific services above) read_time_segments: + name: Read time segments + description: Read all time-of-use (TOU) segments from the inverter fields: device_id: + name: Device ID + description: Optional device serial number (uses current device if not specified) required: false example: "MIN12345" selector: diff --git a/custom_components/growatt_server/strings.json b/custom_components/growatt_server/strings.json index c0bba5b..e2f1fb7 100644 --- a/custom_components/growatt_server/strings.json +++ b/custom_components/growatt_server/strings.json @@ -15,7 +15,7 @@ "description": "Note: API Token authentication is currently only supported for MIN/TLX devices. For other device types, please use Username & Password authentication.", "menu_options": { "password_auth": "Username & Password", - "token_auth": "API Token (MIN/TLX only)" + "token_auth": "API Token (MIN/TLX, MIX/SPH only)" } }, "password_auth": { @@ -567,7 +567,28 @@ "switch": { "ac_charge": { "name": "Charge from Grid" + }, + "charge_period_1_enabled": { + "name": "Charge period 1 enabled" + }, + "discharge_period_1_enabled": { + "name": "Discharge period 1 enabled" + } + }, + "time": { + "charge_start_time": { + "name": "1. Charge start time" + }, + "charge_end_time": { + "name": "2. Charge end time" + }, + "discharge_start_time": { + "name": "3. Discharge start time" + }, + "discharge_end_time": { + "name": "4. Discharge end time" } } } + diff --git a/custom_components/growatt_server/switch.py b/custom_components/growatt_server/switch.py index 436dfaa..efd024d 100644 --- a/custom_components/growatt_server/switch.py +++ b/custom_components/growatt_server/switch.py @@ -3,14 +3,19 @@ from __future__ import annotations from dataclasses import dataclass +from datetime import time import logging +from re import S from typing import Any -from growattServer import GrowattV1ApiError +import voluptuous as vol +from growattServer import GrowattV1ApiError, DeviceType, OpenApiV1 from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription +from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -31,6 +36,11 @@ class GrowattSwitchEntityDescription(SwitchEntityDescription, GrowattRequiredKey """Describes Growatt switch entity.""" write_key: str | None = None # Parameter ID for writing (if different from api_key) + # Default charge settings + default_start_time: time = time(14, 0) + default_end_time: time = time(16, 0) + default_charge_power: int = 80 + default_charge_stop_soc: int = 95 # Note that the Growatt V1 API uses different keys for reading and writing parameters. @@ -47,12 +57,48 @@ class GrowattSwitchEntityDescription(SwitchEntityDescription, GrowattRequiredKey ) +# Separate the switches by type for proper handling +MIX_AC_CHARGE_SWITCH: tuple[GrowattSwitchEntityDescription, ...] = ( + GrowattSwitchEntityDescription( + key="ac_charge", + translation_key="ac_charge", + api_key="acChargeEnable", # Key returned by V1 API + write_key="ac_charge", # Key used to write parameter + default_start_time=time(14, 0), + default_end_time=time(16, 0), + default_charge_power=80, + default_charge_stop_soc=95, + ), +) + +# Enable switches for charge/discharge periods +MIX_ENABLE_SWITCHES: tuple[GrowattSwitchEntityDescription, ...] = ( + GrowattSwitchEntityDescription( + key="charge_period_1_enabled", + translation_key="charge_period_1_enabled", + api_key="forcedChargeStopSwitch1", + write_key="charge_period_1_enabled", + ), + GrowattSwitchEntityDescription( + key="discharge_period_1_enabled", + translation_key="discharge_period_1_enabled", + api_key="forcedDischargeStopSwitch1", + write_key="discharge_period_1_enabled", + ), +) + + class GrowattSwitch(CoordinatorEntity[GrowattCoordinator], SwitchEntity): """Representation of a Growatt switch.""" _attr_has_entity_name = True + _attr_entity_category = EntityCategory.CONFIG entity_description: GrowattSwitchEntityDescription _pending_state: bool | None = None + _charge_start_time: time | None = None + _charge_end_time: time | None = None + _charge_power: int | None = None + _charge_stop_soc: int | None = None def __init__( self, @@ -68,6 +114,11 @@ def __init__( manufacturer="Growatt", name=coordinator.device_id, ) + # Initialize with default times + self._charge_start_time = description.default_start_time + self._charge_end_time = description.default_end_time + self._charge_power = description.default_charge_power + self._charge_stop_soc = description.default_charge_stop_soc @property def is_on(self) -> bool | None: @@ -76,6 +127,13 @@ def is_on(self) -> bool | None: return self._pending_state value = self.coordinator.get_value(self.entity_description) + + _LOGGER.debug( + "GET switch value %s pending state %s", + value, + self._pending_state, + ) + if value is None: return None @@ -86,44 +144,126 @@ def is_on(self) -> bool | None: async def async_turn_on(self, **kwargs: Any) -> None: """Turn the switch on.""" - await self._async_set_state(True) + # Extract time parameters from kwargs if provided + start_time = kwargs.get("start_time", self._charge_start_time) + end_time = kwargs.get("end_time", self._charge_end_time) + charge_power = kwargs.get("charge_power", self._charge_power) + charge_stop_soc = kwargs.get("charge_stop_soc", self._charge_stop_soc) + + await self._async_set_state( + True, + start_time=start_time, + end_time=end_time, + charge_power=charge_power, + charge_stop_soc=charge_stop_soc, + ) async def async_turn_off(self, **kwargs: Any) -> None: """Turn the switch off.""" await self._async_set_state(False) - async def _async_set_state(self, state: bool) -> None: + async def _async_set_state( + self, + state: bool, + start_time: time | None = None, + end_time: time | None = None, + charge_power: int | None = None, + charge_stop_soc: int | None = None, + ) -> None: """Set the switch state.""" try: # Store the pending state before making the API call self._pending_state = state self.async_write_ha_state() - # Convert boolean to API format (1 or 0) - api_value = "1" if state else "0" - - # Use write_key if specified, otherwise fall back to api_key - parameter_id = ( - self.entity_description.write_key or self.entity_description.api_key + # Determine device type based on coordinator device type + if self.coordinator.device_type == "tlx": + device_type = DeviceType.MIN_TLX + else: # mix + device_type = DeviceType.SPH_MIX + + # Convert boolean to API format + enabled_int = 1 if state else 0 + enabled_bool = bool(state) + + # Use provided times or fall back to stored values + start = ( + start_time + or self._charge_start_time + or self.entity_description.default_start_time + ) + end = ( + end_time + or self._charge_end_time + or self.entity_description.default_end_time + ) + power = ( + charge_power + or self._charge_power + or self.entity_description.default_charge_power + ) + soc = ( + charge_stop_soc + or self._charge_stop_soc + or self.entity_description.default_charge_stop_soc ) + # Store the new values + if start_time: + self._charge_start_time = start_time + if end_time: + self._charge_end_time = end_time + if charge_power: + self._charge_power = charge_power + if charge_stop_soc: + self._charge_stop_soc = charge_stop_soc + + # Create device-specific parameters and command + if self.coordinator.device_type == "tlx": + # MIN/TLX device - use TimeSegmentParams with Battery First mode + # Battery First (batt_mode=1) enables AC charging + command = "time_segment1" # Use segment 1 for AC charge + params = self.coordinator.api.TimeSegmentParams( + segment_id=1, + batt_mode=1, # Battery First mode enables AC charging + start_time=start, + end_time=end, + enabled=enabled_bool, + ) + else: + # MIX device - use MixAcChargeTimeParams + command = "mix_ac_charge_time_period" + params = self.coordinator.api.MixAcChargeTimeParams( + charge_power=power, + charge_stop_soc=soc, + mains_enabled=True, + start_hour=start.hour, + start_minute=start.minute, + end_hour=end.hour, + end_minute=end.minute, + enabled=enabled_bool, + segment_id=1, + ) + # Use V1 API to write parameter await self.hass.async_add_executor_job( - self.coordinator.api.min_write_parameter, + self.coordinator.api.write_parameter, self.coordinator.device_id, - parameter_id, - api_value, + device_type, + command, + params, ) _LOGGER.debug( - "Set switch %s to %s", - parameter_id, - api_value, + "Set switch %s to %s (device type: %s)", + command, + params, + device_type, ) # If no exception was raised, the write was successful # Update the value in coordinator - self.coordinator.set_value(self.entity_description, api_value) + self.coordinator.set_value(self.entity_description, enabled_int) self._pending_state = None self.async_write_ha_state() @@ -131,26 +271,211 @@ async def _async_set_state(self, state: bool) -> None: # Failed - revert the pending state self._pending_state = None self.async_write_ha_state() - _LOGGER.error("Error while setting switch state: %s", e) - raise HomeAssistantError(f"Error while setting switch state: {e}") from e + msg = f"Error while setting switch state: {e}" + _LOGGER.exception(msg) + raise HomeAssistantError(msg) from e + + +class GrowattSimpleSwitch(CoordinatorEntity[GrowattCoordinator], SwitchEntity): + """Representation of a simple Growatt on/off switch.""" + + _attr_has_entity_name = True + _attr_entity_category = EntityCategory.CONFIG + entity_description: GrowattSwitchEntityDescription + + def __init__( + self, + coordinator: GrowattCoordinator, + description: GrowattSwitchEntityDescription, + ) -> None: + """Initialize the switch.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{coordinator.device_id}_{description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, coordinator.device_id)}, + manufacturer="Growatt", + name=coordinator.device_id, + ) + + @property + def is_on(self) -> bool | None: + """Return the state of the switch.""" + value = self.coordinator.get_value(self.entity_description) + if value is None: + return None + # Handle both string "1" and integer 1 + if isinstance(value, str): + return value == "1" + return bool(int(value)) + + async def async_turn_on(self, **_kwargs: Any) -> None: + """Turn the switch on.""" + await self._async_set_state(True) + + async def async_turn_off(self, **_kwargs: Any) -> None: + """Turn the switch off.""" + await self._async_set_state(False) + + async def _async_set_state(self, state: bool) -> None: + """Set the switch state.""" + try: + # Convert boolean to API format (1 or 0) + enabled = 1 if state else 0 + + # Use write_key if specified, otherwise fall back to api_key + parameter_id = ( + self.entity_description.write_key or self.entity_description.api_key + ) + + # Determine if this is a charge or discharge enable switch + is_charge = "Charge" in parameter_id or "charge" in parameter_id + + if is_charge: + # Get all current charge time period values + charge_power = int(self.coordinator.data.get("chargePowerCommand", 0)) + charge_stop_soc = int( + self.coordinator.data.get("wchargeSOCLowLimit1", 100) + ) + mains_enabled = bool( + int(self.coordinator.data.get("acChargeEnable", 0)) + ) + + # Parse charge time strings + charge_start_str = self.coordinator.data.get( + "forcedChargeTimeStart1", "00:00" + ) + charge_end_str = self.coordinator.data.get( + "forcedChargeTimeStop1", "00:00" + ) + + charge_start_parts = charge_start_str.split(":") + charge_start_hour = int(charge_start_parts[0]) + charge_start_minute = int(charge_start_parts[1]) + + charge_end_parts = charge_end_str.split(":") + charge_end_hour = int(charge_end_parts[0]) + charge_end_minute = int(charge_end_parts[1]) + + # Create parameter object with all charge period 1 values + params = OpenApiV1.MixAcChargeTimeParams( + charge_power=charge_power, + charge_stop_soc=charge_stop_soc, + mains_enabled=mains_enabled, + start_hour=charge_start_hour, + start_minute=charge_start_minute, + end_hour=charge_end_hour, + end_minute=charge_end_minute, + enabled=state, + segment_id=1, + ) + + # Use V1 API to write all charge parameters + await self.hass.async_add_executor_job( + self.coordinator.api.write_parameter, + self.coordinator.device_id, + DeviceType.SPH_MIX, + "mix_ac_charge_time_period", + params, + ) + + _LOGGER.debug("Set charge period 1 enabled to %s", enabled) + else: + # Get all current discharge time period values + discharge_power = int( + self.coordinator.data.get("disChargePowerCommand", 0) + ) + discharge_stop_soc = int( + self.coordinator.data.get("loadFirstStopSocSet", 10) + ) + + # Parse discharge time strings + discharge_start_str = self.coordinator.data.get( + "forcedDischargeTimeStart1", "00:00" + ) + discharge_end_str = self.coordinator.data.get( + "forcedDischargeTimeStop1", "00:00" + ) + + discharge_start_parts = discharge_start_str.split(":") + discharge_start_hour = int(discharge_start_parts[0]) + discharge_start_minute = int(discharge_start_parts[1]) + + discharge_end_parts = discharge_end_str.split(":") + discharge_end_hour = int(discharge_end_parts[0]) + discharge_end_minute = int(discharge_end_parts[1]) + + # Create parameter object with all discharge period 1 values + params = OpenApiV1.MixAcDischargeTimeParams( + discharge_power=discharge_power, + discharge_stop_soc=discharge_stop_soc, + start_hour=discharge_start_hour, + start_minute=discharge_start_minute, + end_hour=discharge_end_hour, + end_minute=discharge_end_minute, + enabled=state, + segment_id=1, + ) + + # Use V1 API to write all discharge parameters + await self.hass.async_add_executor_job( + self.coordinator.api.write_parameter, + self.coordinator.device_id, + DeviceType.SPH_MIX, + "mix_ac_discharge_time_period", + params, + ) + + _LOGGER.debug("Set discharge period 1 enabled to %s", state) + + # Update the value in coordinator (convert bool to int for storage) + enabled_int = 1 if state else 0 + self.coordinator.set_value(self.entity_description, enabled_int) + self.async_write_ha_state() + + except GrowattV1ApiError as e: + msg = f"Error while setting switch state: {e}" + _LOGGER.exception(msg) + raise HomeAssistantError(msg) from e async def async_setup_entry( - hass: HomeAssistant, + _hass: HomeAssistant, entry: GrowattConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Growatt switch entities.""" runtime_data = entry.runtime_data - entities: list[GrowattSwitch] = [] + entities: list[GrowattSwitch | GrowattSimpleSwitch] = [] - # Add switch entities for each MIN device (only supported with V1 API) + # Add switch entities for each device (only supported with V1 API) for device_coordinator in runtime_data.devices.values(): if ( - device_coordinator.device_type == "min" + device_coordinator.device_type in ["mix"] and device_coordinator.api_version == "v1" ): + # Add AC charge switch for MIX devices (complex) + entities.extend( + GrowattSwitch( + coordinator=device_coordinator, + description=description, + ) + for description in MIX_AC_CHARGE_SWITCH + ) + # Add simple enable/disable switches for MIX devices + entities.extend( + GrowattSimpleSwitch( + coordinator=device_coordinator, + description=description, + ) + for description in MIX_ENABLE_SWITCHES + ) + if ( + device_coordinator.device_type in ["min"] + and device_coordinator.api_version == "v1" + ): + # Add switch entities for MIN devices entities.extend( GrowattSwitch( coordinator=device_coordinator, @@ -160,3 +485,21 @@ async def async_setup_entry( ) async_add_entities(entities) + + # Register service with custom fields for turn_on + platform = entity_platform.async_get_current_platform() + + platform.async_register_entity_service( + "turn_on", + { + vol.Optional("start_time"): cv.time, + vol.Optional("end_time"): cv.time, + vol.Optional("charge_power"): vol.All( + vol.Coerce(int), vol.Range(min=0, max=100) + ), + vol.Optional("charge_stop_soc"): vol.All( + vol.Coerce(int), vol.Range(min=0, max=100) + ), + }, + "async_turn_on", + ) diff --git a/custom_components/growatt_server/time.py b/custom_components/growatt_server/time.py new file mode 100644 index 0000000..ffb9a2c --- /dev/null +++ b/custom_components/growatt_server/time.py @@ -0,0 +1,371 @@ +"""Time platform for Growatt.""" + +from __future__ import annotations + +from datetime import time +import logging +from typing import Any + +from homeassistant.components.time import TimeEntity +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import GrowattConfigEntry, GrowattCoordinator + +_LOGGER = logging.getLogger(__name__) + +# Field name templates for different device types +# MIN/TLX devices use numbered time segment fields +MIN_TLX_FIELD_TEMPLATES = { + "start_time": "timeSegmentStart{segment_id}", + "stop_time": "timeSegmentStop{segment_id}", + "enabled": "timeSegmentEnabled{segment_id}", +} + +# MIX/SPH devices use different field names for charge and discharge +SPH_MIX_CHARGE_FIELD_TEMPLATES = { + "start_time": "forcedChargeTimeStart{segment_id}", + "stop_time": "forcedChargeTimeStop{segment_id}", + "enabled": "forcedChargeStopSwitch{segment_id}", +} + +SPH_MIX_DISCHARGE_FIELD_TEMPLATES = { + "start_time": "forcedDischargeTimeStart{segment_id}", + "stop_time": "forcedDischargeTimeStop{segment_id}", + "enabled": "forcedDischargeStopSwitch{segment_id}", +} + + +class GrowattChargeStartTimeEntity(CoordinatorEntity[GrowattCoordinator], TimeEntity): + """Representation of charge start time.""" + + _attr_has_entity_name = True + _attr_entity_category = EntityCategory.CONFIG + _attr_translation_key = "charge_start_time" + + def __init__(self, coordinator: GrowattCoordinator, segment_id: int = 1) -> None: + """Initialize the time entity.""" + super().__init__(coordinator) + self._segment_id = segment_id + self._attr_unique_id = f"{coordinator.device_id}_charge_start_time_{segment_id}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, coordinator.device_id)}, + manufacturer="Growatt", + name=coordinator.device_id, + ) + + def _get_field_name(self, field_type: str) -> str: + """Get the appropriate field name based on device type.""" + if self.coordinator.device_type == "tlx": + template = MIN_TLX_FIELD_TEMPLATES[field_type] + else: # mix + template = SPH_MIX_CHARGE_FIELD_TEMPLATES[field_type] + return template.format(segment_id=self._segment_id) + + @property + def native_value(self) -> time | None: + """Return the current time value.""" + # Get from coordinator data using correct field name + start_field = self._get_field_name("start_time") + start_time_str = self.coordinator.data.get(start_field, "14:00") + try: + parts = start_time_str.split(":") + return time(hour=int(parts[0]), minute=int(parts[1])) + except (ValueError, IndexError): + return time(14, 0) + + async def async_set_value(self, value: time) -> None: + """Update the time.""" + try: + # Get current end time and other settings using correct field names + stop_field = self._get_field_name("stop_time") + enabled_field = self._get_field_name("enabled") + + end_time_str = self.coordinator.data.get(stop_field, "16:00") + end_parts = end_time_str.split(":") + end_time = time(hour=int(end_parts[0]), minute=int(end_parts[1])) + + enabled = bool(self.coordinator.data.get(enabled_field, 0)) + + # Update the time segment with new start time + await self.coordinator.update_time_segment( + segment_id=self._segment_id, + batt_mode=1, # Battery first (charge) + start_time=value, + end_time=end_time, + enabled=enabled, + ) + + await self.coordinator.async_refresh() + + except Exception as err: + _LOGGER.error("Error setting charge start time: %s", err) + raise HomeAssistantError(f"Error setting charge start time: {err}") from err + + +class GrowattChargeEndTimeEntity(CoordinatorEntity[GrowattCoordinator], TimeEntity): + """Representation of charge end time.""" + + _attr_has_entity_name = True + _attr_entity_category = EntityCategory.CONFIG + _attr_translation_key = "charge_end_time" + + def __init__(self, coordinator: GrowattCoordinator, segment_id: int = 1) -> None: + """Initialize the time entity.""" + super().__init__(coordinator) + self._segment_id = segment_id + self._attr_unique_id = f"{coordinator.device_id}_charge_end_time_{segment_id}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, coordinator.device_id)}, + manufacturer="Growatt", + name=coordinator.device_id, + ) + + def _get_field_name(self, field_type: str) -> str: + """Get the appropriate field name based on device type.""" + if self.coordinator.device_type == "tlx": + template = MIN_TLX_FIELD_TEMPLATES[field_type] + else: # mix + template = SPH_MIX_CHARGE_FIELD_TEMPLATES[field_type] + return template.format(segment_id=self._segment_id) + + @property + def native_value(self) -> time | None: + """Return the current time value.""" + stop_field = self._get_field_name("stop_time") + end_time_str = self.coordinator.data.get(stop_field, "16:00") + try: + parts = end_time_str.split(":") + return time(hour=int(parts[0]), minute=int(parts[1])) + except (ValueError, IndexError): + return time(16, 0) + + async def async_set_value(self, value: time) -> None: + """Update the time.""" + try: + # Get current start time and other settings using correct field names + start_field = self._get_field_name("start_time") + enabled_field = self._get_field_name("enabled") + + start_time_str = self.coordinator.data.get(start_field, "14:00") + start_parts = start_time_str.split(":") + start_time = time(hour=int(start_parts[0]), minute=int(start_parts[1])) + + enabled = bool(self.coordinator.data.get(enabled_field, 0)) + + # Update the time segment with new end time + await self.coordinator.update_time_segment( + segment_id=self._segment_id, + batt_mode=1, # Battery first (charge) + start_time=start_time, + end_time=value, + enabled=enabled, + ) + + await self.coordinator.async_refresh() + + except Exception as err: + _LOGGER.error("Error setting charge end time: %s", err) + raise HomeAssistantError(f"Error setting charge end time: {err}") from err + + +class GrowattDischargeStartTimeEntity( + CoordinatorEntity[GrowattCoordinator], TimeEntity +): + """Representation of discharge start time.""" + + _attr_has_entity_name = True + _attr_entity_category = EntityCategory.CONFIG + _attr_translation_key = "discharge_start_time" + + def __init__(self, coordinator: GrowattCoordinator, segment_id: int = 1) -> None: + """Initialize the time entity.""" + super().__init__(coordinator) + self._segment_id = segment_id + self._attr_unique_id = ( + f"{coordinator.device_id}_discharge_start_time_{segment_id}" + ) + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, coordinator.device_id)}, + manufacturer="Growatt", + name=coordinator.device_id, + ) + + def _get_field_name(self, field_type: str) -> str: + """Get the appropriate field name based on device type.""" + if self.coordinator.device_type == "tlx": + template = MIN_TLX_FIELD_TEMPLATES[field_type] + else: # mix + template = SPH_MIX_DISCHARGE_FIELD_TEMPLATES[field_type] + return template.format(segment_id=self._segment_id) + + @property + def native_value(self) -> time | None: + """Return the current time value.""" + start_field = self._get_field_name("start_time") + start_time_str = self.coordinator.data.get(start_field, "00:00") + try: + parts = start_time_str.split(":") + return time(hour=int(parts[0]), minute=int(parts[1])) + except (ValueError, IndexError): + return time(0, 0) + + async def async_set_value(self, value: time) -> None: + """Update the time.""" + try: + # Get current end time and other settings using correct field names + stop_field = self._get_field_name("stop_time") + enabled_field = self._get_field_name("enabled") + + end_time_str = self.coordinator.data.get(stop_field, "00:00") + end_parts = end_time_str.split(":") + end_time = time(hour=int(end_parts[0]), minute=int(end_parts[1])) + + enabled = bool(self.coordinator.data.get(enabled_field, 0)) + + # For MIX devices, discharge uses segments 7-12 (offset by 6) + # For TLX devices, it's just the segment_id + if self.coordinator.device_type == "mix": + segment_id = self._segment_id + 6 + else: + segment_id = self._segment_id + + # Update the time segment with new start time + await self.coordinator.update_time_segment( + segment_id=segment_id, + batt_mode=2, # Grid first (discharge) + start_time=value, + end_time=end_time, + enabled=enabled, + ) + + await self.coordinator.async_refresh() + + except Exception as err: + _LOGGER.error("Error setting discharge start time: %s", err) + raise HomeAssistantError( + f"Error setting discharge start time: {err}" + ) from err + + +class GrowattDischargeEndTimeEntity(CoordinatorEntity[GrowattCoordinator], TimeEntity): + """Representation of discharge end time.""" + + _attr_has_entity_name = True + _attr_entity_category = EntityCategory.CONFIG + _attr_translation_key = "discharge_end_time" + + def __init__(self, coordinator: GrowattCoordinator, segment_id: int = 1) -> None: + """Initialize the time entity.""" + super().__init__(coordinator) + self._segment_id = segment_id + self._attr_unique_id = ( + f"{coordinator.device_id}_discharge_end_time_{segment_id}" + ) + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, coordinator.device_id)}, + manufacturer="Growatt", + name=coordinator.device_id, + ) + + def _get_field_name(self, field_type: str) -> str: + """Get the appropriate field name based on device type.""" + if self.coordinator.device_type == "tlx": + template = MIN_TLX_FIELD_TEMPLATES[field_type] + else: # mix + template = SPH_MIX_DISCHARGE_FIELD_TEMPLATES[field_type] + return template.format(segment_id=self._segment_id) + + @property + def native_value(self) -> time | None: + """Return the current time value.""" + stop_field = self._get_field_name("stop_time") + end_time_str = self.coordinator.data.get(stop_field, "00:00") + try: + parts = end_time_str.split(":") + return time(hour=int(parts[0]), minute=int(parts[1])) + except (ValueError, IndexError): + return time(0, 0) + + async def async_set_value(self, value: time) -> None: + """Update the time.""" + try: + # Get current start time and other settings using correct field names + start_field = self._get_field_name("start_time") + enabled_field = self._get_field_name("enabled") + + start_time_str = self.coordinator.data.get(start_field, "00:00") + start_parts = start_time_str.split(":") + start_time = time(hour=int(start_parts[0]), minute=int(start_parts[1])) + + enabled = bool(self.coordinator.data.get(enabled_field, 0)) + + # For MIX devices, discharge uses segments 7-12 (offset by 6) + # For TLX devices, it's just the segment_id + if self.coordinator.device_type == "mix": + segment_id = self._segment_id + 6 + else: + segment_id = self._segment_id + + # Update the time segment with new end time + await self.coordinator.update_time_segment( + segment_id=segment_id, + batt_mode=2, # Grid first (discharge) + start_time=start_time, + end_time=value, + enabled=enabled, + ) + + await self.coordinator.async_refresh() + + except Exception as err: + _LOGGER.error("Error setting discharge end time: %s", err) + raise HomeAssistantError( + f"Error setting discharge end time: {err}" + ) from err + + +async def async_setup_entry( + hass: HomeAssistant, + entry: GrowattConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Growatt time entities.""" + runtime_data = entry.runtime_data + entities: list[TimeEntity] = [] + + for device_coordinator in runtime_data.devices.values(): + if device_coordinator.api_version == "v1": + if device_coordinator.device_type == "mix": + # Add time entities for MIX devices (first charge/discharge segment) + # Order: charge start/end, then discharge start/end + entities.extend( + [ + GrowattChargeStartTimeEntity(device_coordinator, segment_id=1), + GrowattChargeEndTimeEntity(device_coordinator, segment_id=1), + GrowattDischargeStartTimeEntity( + device_coordinator, segment_id=1 + ), + GrowattDischargeEndTimeEntity(device_coordinator, segment_id=1), + ] + ) + elif device_coordinator.device_type == "tlx": + # Add time entities for TLX devices (first segment) + # Order: charge start/end, then discharge start/end + entities.extend( + [ + GrowattChargeStartTimeEntity(device_coordinator, segment_id=1), + GrowattChargeEndTimeEntity(device_coordinator, segment_id=1), + GrowattDischargeStartTimeEntity( + device_coordinator, segment_id=1 + ), + GrowattDischargeEndTimeEntity(device_coordinator, segment_id=1), + ] + ) + + async_add_entities(entities) diff --git a/custom_components/growatt_server/translations/en.json b/custom_components/growatt_server/translations/en.json index f6fdfa5..e710077 100644 --- a/custom_components/growatt_server/translations/en.json +++ b/custom_components/growatt_server/translations/en.json @@ -15,7 +15,7 @@ "description": "Note: API Token authentication is currently only supported for MIN/TLX devices. For other device types, please use Username & Password authentication.", "menu_options": { "password_auth": "Username & Password", - "token_auth": "API Token (MIN/TLX only)" + "token_auth": "API Token (MIN/TLX MIX/SPH only)" } }, "password_auth": { @@ -268,7 +268,27 @@ "switch": { "ac_charge": { "name": "Charge from Grid" + }, + "charge_period_1_enabled": { + "name": "Charge period 1 enabled" + }, + "discharge_period_1_enabled": { + "name": "Discharge period 1 enabled" + } + }, + "time": { + "charge_start_time": { + "name": "1. Charge start time" + }, + "charge_end_time": { + "name": "2. Charge end time" + }, + "discharge_start_time": { + "name": "3. Discharge start time" + }, + "discharge_end_time": { + "name": "4. Discharge end time" } } } -} \ No newline at end of file +} diff --git a/hacs.json b/hacs.json index b39b5cf..fd49c4c 100644 --- a/hacs.json +++ b/hacs.json @@ -2,5 +2,7 @@ "name": "Growatt Server Upstream", "content_in_root": false, "render_readme": true, - "domains": ["growatt_server"] + "domains": ["growatt_server"], + "homeassistant": "2025.10.0", + "hacs": "2.0.5" } \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..bb78a0f --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +colorlog==6.9.0 +homeassistant==2025.10.0 +pip>=21.3.1 +ruff==0.13.2 \ No newline at end of file diff --git a/scripts/develop b/scripts/develop new file mode 100755 index 0000000..89eda50 --- /dev/null +++ b/scripts/develop @@ -0,0 +1,20 @@ +#!/usr/bin/env bash + +set -e + +cd "$(dirname "$0")/.." + +# Create config dir if not present +if [[ ! -d "${PWD}/config" ]]; then + mkdir -p "${PWD}/config" + hass --config "${PWD}/config" --script ensure_config +fi + +# Set the path to custom_components +## This let's us have the structure we want /custom_components/integration_blueprint +## while at the same time have Home Assistant configuration inside /config +## without resulting to symlinks. +export PYTHONPATH="${PYTHONPATH}:${PWD}/custom_components" + +# Start Home Assistant +hass --config "${PWD}/config" --debug diff --git a/scripts/lint b/scripts/lint new file mode 100755 index 0000000..5d68d15 --- /dev/null +++ b/scripts/lint @@ -0,0 +1,8 @@ +#!/usr/bin/env bash + +set -e + +cd "$(dirname "$0")/.." + +ruff format . +ruff check . --fix diff --git a/scripts/setup b/scripts/setup new file mode 100755 index 0000000..141d19f --- /dev/null +++ b/scripts/setup @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +set -e + +cd "$(dirname "$0")/.." + +python3 -m pip install --requirement requirements.txt