From 7f9d49d7ae37ae32a4498db56aefddbdc601c81f Mon Sep 17 00:00:00 2001 From: Trefor Southwell Date: Sun, 10 May 2026 18:37:45 +0100 Subject: [PATCH 01/18] Draft --- apps/predbat/components.py | 16 + apps/predbat/sigenergy.py | 1474 ++++++++++++++++++++++++++ apps/predbat/tests/test_sigenergy.py | 852 +++++++++++++++ apps/predbat/unit_test.py | 2 + 4 files changed, 2344 insertions(+) create mode 100644 apps/predbat/sigenergy.py create mode 100644 apps/predbat/tests/test_sigenergy.py diff --git a/apps/predbat/components.py b/apps/predbat/components.py index 6880453ce..bc2b82404 100644 --- a/apps/predbat/components.py +++ b/apps/predbat/components.py @@ -26,6 +26,7 @@ from temperature import TemperatureAPI from axle import AxleAPI from solax import SolaxAPI +from sigenergy import SigenergyAPI from solis import SolisAPI from alertfeed import AlertFeed from web import WebInterface @@ -345,6 +346,21 @@ "required_or": ["api_key", "managed_mode"], "phase": 1, }, + "sigenergy": { + "class": SigenergyAPI, + "name": "Sigenergy Cloud API", + "event_filter": "predbat_sigenergy_", + "args": { + "app_key": {"required": True, "config": "sigenergy_app_key"}, + "app_secret": {"required": True, "config": "sigenergy_app_secret"}, + "base_url": {"required": False, "config": "sigenergy_base_url", "default": "https://openapi-eu.sigencloud.com"}, + "system_id": {"required": False, "config": "sigenergy_system_id"}, + "automatic": {"required": False, "config": "sigenergy_automatic", "default": False}, + "enable_controls": {"required": False, "config": "sigenergy_enable_controls", "default": True}, + }, + "phase": 1, + "can_restart": True, + }, "solax": { "class": SolaxAPI, "name": "SolaX Cloud API", diff --git a/apps/predbat/sigenergy.py b/apps/predbat/sigenergy.py new file mode 100644 index 000000000..a03b99eb3 --- /dev/null +++ b/apps/predbat/sigenergy.py @@ -0,0 +1,1474 @@ +# ----------------------------------------------------------------------------- +# Predbat Home Battery System +# Copyright Trefor Southwell 2026 - All Rights Reserved +# This application maybe used for personal use only and not for commercial use +# ----------------------------------------------------------------------------- +# fmt: off +# pylint: disable=consider-using-f-string +# pylint: disable=line-too-long +# pylint: disable=attribute-defined-outside-init + +"""Sigenergy Cloud API integration component. + +REST polling client for Sigenergy inverters (Sigenstor) with OAuth2 Client +Credentials authentication. Publishes realtime energy-flow and battery state +to Home Assistant entities and issues charge/discharge/eco commands via the +Sigenergy MQTT broker. + +Authentication is via the Key-based endpoint: + POST /openapi/auth/login/key {"key": base64(AppKey:AppSecret)} + +Data endpoints used: + GET /openapi/system — system list + GET /openapi/system/{systemId}/devices — device inventory + GET /openapi/systems/{systemId}/energyFlow — realtime power & SOC + GET /openapi/systems/{systemId}/summary — daily/lifetime yield + GET /openapi/instruction/{systemId}/settings — current operating mode + +Control endpoints: + PUT /openapi/instruction/settings — switch operating mode (MSC/FFG) + MQTT openapi/instruction/command — charge / discharge / idle (via MQTT broker) + +The MQTT broker hostname is derived from the REST base URL (same host, port 8883, TLS). +Authentication to the MQTT broker uses app_key as username and the current +access_token as password. + +The component maps onto the existing 'SIG' inverter type already defined in +config.py so no changes are needed there. + +Registered in components.py under key 'sigenergy'. +""" + +import argparse +import asyncio +import base64 +import json +import ssl +import time +import traceback + +try: + import aiohttp + HAS_AIOHTTP = True +except ImportError: + aiohttp = None + HAS_AIOHTTP = False + +try: + import aiomqtt + HAS_AIOMQTT = True +except ImportError: + aiomqtt = None + HAS_AIOMQTT = False + +from datetime import datetime, timedelta +from component_base import ComponentBase + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +SIGENERGY_DEFAULT_BASE_URL = "https://openapi.sigencloud.com" +SIGENERGY_TIMEOUT = 20 # seconds per HTTP request +SIGENERGY_MAX_RETRIES = 3 +SIGENERGY_TOKEN_EXPIRY_BUFFER = 600 # refresh token 10 min before expiry +SIGENERGY_MIN_REQUEST_INTERVAL = 6.0 # enforce ≥10 req/min API limit +SIGENERGY_POLL_INTERVAL = 300 # realtime data poll every 5 minutes +SIGENERGY_DEVICE_POLL_INTERVAL = 1800 # device list refresh every 30 minutes +SIGENERGY_COMMAND_RETRY_DELAY = 2.0 +SIGENERGY_MQTT_PORT = 8883 # TLS MQTT port on the Sigenergy broker + +# Operating mode enums (REST mode switch endpoint — MSC and FFG only; NBI is not used) +SIGENERGY_MODE_MSC = 0 # Maximum Self-Consumption (eco) +SIGENERGY_MODE_FFG = 5 # Fully Feed-in to Grid +SIGENERGY_MODE_NBI = 8 # NorthBound (defined for completeness; not switched to by this component) + +# Battery command activeMode strings +SIGENERGY_ACTIVE_MODE_CHARGE = "charge" +SIGENERGY_ACTIVE_MODE_DISCHARGE = "discharge" +SIGENERGY_ACTIVE_MODE_IDLE = "idle" +SIGENERGY_ACTIVE_MODE_SELF = "selfConsumption" +SIGENERGY_ACTIVE_MODE_SELF_GRID = "selfConsumption-grid" + +# Device type strings returned by the device-list endpoint +SIGENERGY_DEVICE_INVERTER = "Inverter" +SIGENERGY_DEVICE_BATTERY = "Battery" +SIGENERGY_DEVICE_GATEWAY = "Gateway" +SIGENERGY_DEVICE_METER = "Meter" + +# Time options for schedule selects (HH:MM, one per minute) +_BASE_TIME = datetime.strptime("00:00", "%H:%M") +SIGENERGY_OPTIONS_TIME = [(_BASE_TIME + timedelta(seconds=m * 60)).strftime("%H:%M") for m in range(0, 24 * 60)] + + +def _safe_float(value, default=0.0): + """Convert value to float with a fallback default.""" + try: + return float(value) + except (TypeError, ValueError): + return default + + +def _safe_int(value, default=0): + """Convert value to int with a fallback default.""" + try: + return int(value) + except (TypeError, ValueError): + return default + + +class SigenergyAPI(ComponentBase): + """Sigenergy Cloud API component for Predbat. + + Polls the Sigenergy OpenAPI for realtime energy-flow data and battery + state, publishes Home Assistant entities, and applies charge/discharge + control commands on behalf of Predbat's planner. + """ + + def initialize(self, app_key, app_secret, base_url=None, system_id=None, automatic=False, enable_controls=True, **kwargs): + """Initialise the Sigenergy API component. + + Args: + app_key: Sigenergy Application Key (from Control Center → Settings). + app_secret: Sigenergy Application Secret. + base_url: Override the API base URL (default: SIGENERGY_DEFAULT_BASE_URL). + system_id: Optional system ID filter. When None all authorised + systems are used. When a string or list, only matching + systems are used. + automatic: When True, call set_arg() to wire Predbat config to the + published entity IDs on first run. + enable_controls: When True, apply charge/discharge commands. + """ + if not HAS_AIOHTTP: + raise ImportError("SigenergyAPI requires the 'aiohttp' package: pip install aiohttp") + if not HAS_AIOMQTT: + raise ImportError("SigenergyAPI requires the 'aiomqtt' package: pip install aiomqtt") + + self.app_key = app_key + self.app_secret = app_secret + self.base_url = (base_url or SIGENERGY_DEFAULT_BASE_URL).rstrip("/") + # Derive MQTT hostname from REST base URL (strip scheme, no port suffix needed) + self.mqtt_host = self.base_url.replace("https://", "").replace("http://", "").rstrip("/") + self.mqtt_port = SIGENERGY_MQTT_PORT + self.automatic = automatic + self.enable_controls = enable_controls + + # Normalise system_id filter to a set (empty = all systems) + if system_id is None: + self.system_id_filter = set() + elif isinstance(system_id, (list, tuple)): + self.system_id_filter = set(system_id) + else: + self.system_id_filter = {str(system_id)} + + # Token state + self.access_token = None + self.token_expires_at = 0.0 # UNIX timestamp + + # Data stores keyed by systemId + self.systems = {} # systemId → system info dict + self.devices = {} # systemId → list of device dicts + self.energy_flow = {} # systemId → latest energyFlow dict + self.daily_summary = {} # systemId → latest summary dict + self.current_mode = {} # systemId → energyStorageOperationMode int + + # Control state keyed by systemId + self.controls = {} # systemId → {charge: {…}, export: {…}, reserve: …} + + # Mode-change deduplication + self.current_mode_hash = {} # systemId → hash of last applied command + self.current_mode_hash_timestamp = {} # systemId → datetime of last applied command + + # Rate-limit tracking + self._last_request_time = 0.0 + + # Delay between mode-switch and battery command (seconds); set to 0 in tests + self._command_delay = 1.0 + + self.log("SigenergyAPI: Initialised, base_url={}".format(self.base_url)) + + # ----------------------------------------------------------------------- + # Authentication + # ----------------------------------------------------------------------- + + async def get_access_token(self): + """Obtain or refresh the access token. + + Uses the Sigenergy key-based authentication endpoint: + POST /openapi/auth/login/key {"key": base64(AppKey:AppSecret)} + + The token is cached until within SIGENERGY_TOKEN_EXPIRY_BUFFER seconds + of expiry (default 12 h lifetime). + + Returns: + Access token string on success, None on failure. + """ + now = time.monotonic() + if self.access_token and now < self.token_expires_at - SIGENERGY_TOKEN_EXPIRY_BUFFER: + return self.access_token + + raw_key = "{}:{}".format(self.app_key, self.app_secret) + encoded_key = base64.b64encode(raw_key.encode()).decode() + url = "{}/openapi/auth/login/key".format(self.base_url) + payload = {"key": encoded_key} + + self.log("SigenergyAPI: Requesting new access token") + try: + timeout = aiohttp.ClientTimeout(total=SIGENERGY_TIMEOUT) + async with aiohttp.ClientSession(timeout=timeout) as session: + async with session.post(url, json=payload) as response: + if response.status != 200: + self.log("Warn: SigenergyAPI: Auth request returned HTTP {}".format(response.status)) + return None + try: + data = await response.json(content_type=None) + except (Exception) as e: + self.log("Warn: SigenergyAPI: Failed to decode auth response: {}".format(e)) + return None + + code = data.get("code", -1) + if code != 0: + self.log("Warn: SigenergyAPI: Auth failed, code={}, msg={}".format(code, data.get("msg", "unknown"))) + self.access_token = None + return None + + token_data = data.get("data", {}) + self.access_token = token_data.get("accessToken") + expires_in = _safe_int(token_data.get("expiresIn", 43200), 43200) + self.token_expires_at = now + expires_in + self.log("SigenergyAPI: Token obtained, expires in {} s".format(expires_in)) + return self.access_token + + except (asyncio.TimeoutError, Exception) as e: + self.log("Warn: SigenergyAPI: Exception during authentication: {}".format(e)) + self.access_token = None + return None + + # ----------------------------------------------------------------------- + # HTTP helpers + # ----------------------------------------------------------------------- + + async def _enforce_rate_limit(self): + """Sleep if necessary to respect the 10 req/min API limit.""" + now = time.monotonic() + elapsed = now - self._last_request_time + if elapsed < SIGENERGY_MIN_REQUEST_INTERVAL: + await asyncio.sleep(SIGENERGY_MIN_REQUEST_INTERVAL - elapsed) + self._last_request_time = time.monotonic() + + async def _request(self, method, path, params=None, json_data=None, retries=SIGENERGY_MAX_RETRIES): + """Perform an authenticated HTTP request with retry logic. + + Args: + method: HTTP method string ('GET', 'POST', 'PUT'). + path: API path, e.g. '/openapi/system'. + params: URL query parameters dict. + json_data: Request body dict (serialised to JSON). + retries: Number of retry attempts. + + Returns: + Parsed 'data' field from the response JSON, or None on failure. + """ + token = await self.get_access_token() + if not token: + return None + + url = "{}{}".format(self.base_url, path) + headers = { + "Authorization": "Bearer {}".format(token), + "Content-Type": "application/json", + } + + for attempt in range(retries): + await self._enforce_rate_limit() + try: + timeout = aiohttp.ClientTimeout(total=SIGENERGY_TIMEOUT) + async with aiohttp.ClientSession(timeout=timeout) as session: + if method == "GET": + ctx = session.get(url, headers=headers, params=params) + elif method == "POST": + ctx = session.post(url, headers=headers, json=json_data) + elif method == "PUT": + ctx = session.put(url, headers=headers, json=json_data) + else: + self.log("Warn: SigenergyAPI: Unknown HTTP method {}".format(method)) + return None + + async with ctx as response: + if response.status == 401: + self.log("Warn: SigenergyAPI: 401 Unauthorised — refreshing token") + self.access_token = None + token = await self.get_access_token() + if not token: + return None + headers["Authorization"] = "Bearer {}".format(token) + continue + + if response.status not in (200, 201): + self.log("Warn: SigenergyAPI: HTTP {} for {} {}".format(response.status, method, path)) + if attempt < retries - 1: + await asyncio.sleep(SIGENERGY_COMMAND_RETRY_DELAY) + continue + return None + + try: + body = await response.json(content_type=None) + except Exception as e: + self.log("Warn: SigenergyAPI: Failed to decode response from {}: {}".format(path, e)) + return None + + code = body.get("code", -1) + if code != 0: + self.log("Warn: SigenergyAPI: API error code={} msg={} for {}".format(code, body.get("msg", ""), path)) + return None + + return body.get("data") + + except asyncio.TimeoutError: + self.log("Warn: SigenergyAPI: Timeout on {} {} (attempt {}/{})".format(method, path, attempt + 1, retries)) + if attempt < retries - 1: + await asyncio.sleep(SIGENERGY_COMMAND_RETRY_DELAY) + except Exception as e: + self.log("Warn: SigenergyAPI: Exception on {} {}: {}\n{}".format(method, path, e, traceback.format_exc())) + if attempt < retries - 1: + await asyncio.sleep(SIGENERGY_COMMAND_RETRY_DELAY) + + return None + + # ----------------------------------------------------------------------- + # Data fetching + # ----------------------------------------------------------------------- + + async def fetch_system_list(self): + """Fetch the list of authorised power stations. + + Returns: + True on success, False on failure. + """ + data = await self._request("GET", "/openapi/system") + if data is None: + self.log("Warn: SigenergyAPI: Failed to fetch system list") + return False + + # data may be a list directly or wrapped in a dict + systems = data if isinstance(data, list) else data.get("list", data.get("records", [])) + if not isinstance(systems, list): + self.log("Warn: SigenergyAPI: Unexpected system list format: {}".format(type(systems))) + return False + + for system in systems: + sid = system.get("systemId") + if not sid: + continue + if self.system_id_filter and sid not in self.system_id_filter: + continue + self.systems[sid] = system + self.log("SigenergyAPI: Found system {} ({})".format(sid, system.get("systemName", "unnamed"))) + + if not self.systems: + self.log("Warn: SigenergyAPI: No matching systems found (filter={})".format(self.system_id_filter or "all")) + return False + + return True + + async def fetch_device_list(self, system_id): + """Fetch the device inventory for a power station. + + Args: + system_id: Sigenergy system unique identifier. + + Returns: + True on success, False on failure. + """ + data = await self._request("GET", "/openapi/system/{}/devices".format(system_id)) + if data is None: + self.log("Warn: SigenergyAPI: Failed to fetch device list for {}".format(system_id)) + return False + + devices = data if isinstance(data, list) else data.get("list", data.get("records", [])) + if not isinstance(devices, list): + self.log("Warn: SigenergyAPI: Unexpected device list format for {}: {}".format(system_id, type(devices))) + return False + + self.devices[system_id] = devices + self.log("SigenergyAPI: System {} has {} device(s)".format(system_id, len(devices))) + return True + + async def fetch_energy_flow(self, system_id): + """Fetch realtime energy-flow data for a system. + + The API returns power values in kW with these sign conventions: + pvPower — always positive + gridPower — positive = export to grid, negative = import from grid + batteryPower — positive = charging, negative = discharging + loadPower — always positive + + Args: + system_id: Sigenergy system unique identifier. + + Returns: + True on success, False on failure. + """ + data = await self._request("GET", "/openapi/systems/{}/energyFlow".format(system_id)) + if data is None: + self.log("Warn: SigenergyAPI: Failed to fetch energy flow for {}".format(system_id)) + return False + + self.energy_flow[system_id] = data + soc = _safe_float(data.get("batterySoc", 0)) + battery_kw = _safe_float(data.get("batteryPower", 0)) + pv_kw = _safe_float(data.get("pvPower", 0)) + grid_kw = _safe_float(data.get("gridPower", 0)) + self.log("SigenergyAPI: System {} — SOC {:.0f}% battery {:.2f}kW pv {:.2f}kW grid {:.2f}kW".format(system_id, soc, battery_kw, pv_kw, grid_kw)) + return True + + async def fetch_daily_summary(self, system_id): + """Fetch daily/lifetime generation summary for a system. + + Args: + system_id: Sigenergy system unique identifier. + + Returns: + True on success, False on failure. + """ + data = await self._request("GET", "/openapi/systems/{}/summary".format(system_id)) + if data is None: + self.log("Warn: SigenergyAPI: Failed to fetch summary for {}".format(system_id)) + return False + + self.daily_summary[system_id] = data + return True + + async def fetch_current_mode(self, system_id): + """Fetch the current energy storage operating mode. + + Args: + system_id: Sigenergy system unique identifier. + + Returns: + True on success, False on failure. + """ + data = await self._request("GET", "/openapi/instruction/{}/settings".format(system_id)) + if data is None: + return False + + mode_int = _safe_int(data.get("energyStorageOperationMode", SIGENERGY_MODE_MSC)) + self.current_mode[system_id] = mode_int + return True + + # ----------------------------------------------------------------------- + # Control commands + # ----------------------------------------------------------------------- + + async def set_operating_mode(self, system_id, mode_int): + """Set the energy storage operating mode via REST. + + Args: + system_id: Sigenergy system unique identifier. + mode_int: Operating mode integer (SIGENERGY_MODE_MSC/FFG/NBI). + + Returns: + True on success, False on failure. + """ + payload = { + "systemId": system_id, + "energyStorageOperationMode": mode_int, + } + result = await self._request("PUT", "/openapi/instruction/settings", json_data=payload) + if result is None: + # Some implementations return an empty data field on success — treat None as success + # if the HTTP call didn't raise (the _request wrapper returns None for both API errors + # and non-zero code responses, but we can't distinguish here without more context). + self.log("SigenergyAPI: set_operating_mode({}) returned None — assuming success".format(mode_int)) + return True + self.log("SigenergyAPI: Operating mode set to {} for system {}".format(mode_int, system_id)) + return True + + async def _publish_mqtt(self, topic, payload_dict): + """Publish a JSON payload to the Sigenergy MQTT broker. + + Connects to the broker (same hostname as the REST base URL) using TLS + on port 8883. Authenticates with app_key as username and the current + access_token as password. A fresh connection is made for each publish + (Sigenergy commands are infrequent so persistent connection overhead is + unnecessary). + + Args: + topic: MQTT topic string. + payload_dict: Dict that will be serialised to JSON and published. + + Returns: + True on success, False on failure. + """ + try: + tls_context = ssl.create_default_context() + async with aiomqtt.Client( + hostname=self.mqtt_host, + port=self.mqtt_port, + username=self.app_key, + password=self.access_token, + tls_context=tls_context, + keepalive=30, + ) as client: + await client.publish(topic, payload=json.dumps(payload_dict), qos=1) + self.log("SigenergyAPI: MQTT published to {}".format(topic)) + return True + except Exception as e: + self.log("Warn: SigenergyAPI: MQTT publish to {} failed: {}".format(topic, e)) + return False + + async def send_battery_command(self, system_id, active_mode, duration_minutes, charging_power_kw=None): + """Send a battery command via MQTT to the Sigenergy broker. + + Publishes to the MQTT topic ``openapi/instruction/command``. A fresh + access token is obtained if needed before building the payload. + + Args: + system_id: Sigenergy system unique identifier. + active_mode: One of the SIGENERGY_ACTIVE_MODE_* string constants. + duration_minutes: Command duration in minutes (max ~720). + charging_power_kw: Charging/discharging power in kW. Required for + charge and discharge modes; optional otherwise. + + Returns: + True on success, False on failure. + """ + token = await self.get_access_token() + if not token: + self.log("Warn: SigenergyAPI: No access token for MQTT battery command") + return False + + payload = { + "accessToken": token, + "systemId": system_id, + "activeMode": active_mode, + "startTime": int(time.time()), + "duration": int(duration_minutes), + } + if charging_power_kw is not None: + payload["chargingPower"] = round(charging_power_kw, 2) + + self.log("SigenergyAPI: Sending MQTT battery command {} ({} min, {:.2f}kW) to system {}".format( + active_mode, duration_minutes, charging_power_kw or 0.0, system_id)) + + return await self._publish_mqtt("openapi/instruction/command", payload) + + # ----------------------------------------------------------------------- + # HA entity publishing + # ----------------------------------------------------------------------- + + def _system_slug(self, system_id): + """Return a short, safe slug for use in entity IDs. + + Uses the last 12 characters of the system ID (or the full string if + shorter) to keep entity names manageable. + """ + return str(system_id)[-12:].lower().replace("-", "_") + + def _get_battery_capacity_kwh(self, system_id): + """Return the rated battery capacity in kWh for a system. + + Prefers the batteryCapacity field from the system-list response. + Falls back to summing ratedEnergy from individual Battery devices. + """ + system_info = self.systems.get(system_id, {}) + capacity = _safe_float(system_info.get("batteryCapacity", 0)) + if capacity > 0: + return capacity + + # Fallback: sum device-level ratedEnergy + for device in self.devices.get(system_id, []): + if device.get("deviceType") == SIGENERGY_DEVICE_BATTERY: + attr = device.get("attrMap", {}) + capacity += _safe_float(attr.get("ratedEnergy", 0)) + return capacity + + def _get_battery_max_power_kw(self, system_id): + """Return the combined rated charge/discharge power in kW for a system.""" + # Prefer device-level ratedChargePower + power = 0.0 + for device in self.devices.get(system_id, []): + if device.get("deviceType") == SIGENERGY_DEVICE_BATTERY: + attr = device.get("attrMap", {}) + power += _safe_float(attr.get("ratedChargePower", 0)) + if power > 0: + return power + + # Fallback: inverter rated power + for device in self.devices.get(system_id, []): + if device.get("deviceType") == SIGENERGY_DEVICE_INVERTER: + attr = device.get("attrMap", {}) + power += _safe_float(attr.get("ratedActivePower", 0)) + return power + + def _get_inverter_max_power_kw(self, system_id): + """Return the combined inverter rated active power in kW.""" + power = 0.0 + for device in self.devices.get(system_id, []): + if device.get("deviceType") == SIGENERGY_DEVICE_INVERTER: + attr = device.get("attrMap", {}) + power += _safe_float(attr.get("ratedActivePower", 0)) + return power + + async def publish_system_entities(self, system_id): + """Publish Home Assistant entities for a system. + + Publishes realtime energy-flow data (battery SOC/power, PV power, + grid power, load power) and daily generation summary. + + Args: + system_id: Sigenergy system unique identifier. + """ + slug = self._system_slug(system_id) + system_info = self.systems.get(system_id, {}) + system_name = system_info.get("systemName", system_id) + flow = self.energy_flow.get(system_id, {}) + summary = self.daily_summary.get(system_id, {}) + + battery_soc_pct = _safe_float(flow.get("batterySoc", 0)) + # battery_power: API positive=charging, negative=discharging (same as Predbat convention, in kW) + battery_power_kw = _safe_float(flow.get("batteryPower", 0)) + pv_power_kw = _safe_float(flow.get("pvPower", 0)) + # gridPower: API positive=export, negative=import → invert for Predbat (positive=import) + grid_power_kw = -_safe_float(flow.get("gridPower", 0)) + load_power_kw = _safe_float(flow.get("loadPower", 0)) + ev_power_kw = _safe_float(flow.get("evPower", 0)) + + daily_yield_kwh = _safe_float(summary.get("dailyPowerGeneration", 0)) + monthly_yield_kwh = _safe_float(summary.get("monthlyPowerGeneration", 0)) + annual_yield_kwh = _safe_float(summary.get("annualPowerGeneration", 0)) + lifetime_yield_kwh = _safe_float(summary.get("lifetimePowerGeneration", 0)) + + capacity_kwh = self._get_battery_capacity_kwh(system_id) + battery_soc_kwh = round(battery_soc_pct * capacity_kwh / 100.0, 3) + battery_max_kw = self._get_battery_max_power_kw(system_id) + inverter_max_kw = self._get_inverter_max_power_kw(system_id) + + # --- Battery SOC (kWh) --- + self.dashboard_item( + "sensor.{}_sigenergy_{}_battery_soc".format(self.prefix, slug), + state=battery_soc_kwh, + attributes={ + "friendly_name": "Sigenergy {} Battery SOC".format(system_name), + "unit_of_measurement": "kWh", + "device_class": "energy", + "state_class": "measurement", + "soc_percent": battery_soc_pct, + "soc_max": capacity_kwh, + }, + app="sigenergy", + ) + + # --- Battery SOC percentage --- + self.dashboard_item( + "sensor.{}_sigenergy_{}_battery_soc_percent".format(self.prefix, slug), + state=battery_soc_pct, + attributes={ + "friendly_name": "Sigenergy {} Battery SOC %".format(system_name), + "unit_of_measurement": "%", + "device_class": "battery", + "state_class": "measurement", + }, + app="sigenergy", + ) + + # --- Battery power (W, positive=charging) --- + self.dashboard_item( + "sensor.{}_sigenergy_{}_battery_power".format(self.prefix, slug), + state=round(battery_power_kw * 1000), + attributes={ + "friendly_name": "Sigenergy {} Battery Power".format(system_name), + "unit_of_measurement": "W", + "device_class": "power", + "state_class": "measurement", + }, + app="sigenergy", + ) + + # --- PV power (W) --- + self.dashboard_item( + "sensor.{}_sigenergy_{}_pv_power".format(self.prefix, slug), + state=round(pv_power_kw * 1000), + attributes={ + "friendly_name": "Sigenergy {} PV Power".format(system_name), + "unit_of_measurement": "W", + "device_class": "power", + "state_class": "measurement", + }, + app="sigenergy", + ) + + # --- Grid power (W, positive=import from grid) --- + self.dashboard_item( + "sensor.{}_sigenergy_{}_grid_power".format(self.prefix, slug), + state=round(grid_power_kw * 1000), + attributes={ + "friendly_name": "Sigenergy {} Grid Power".format(system_name), + "unit_of_measurement": "W", + "device_class": "power", + "state_class": "measurement", + }, + app="sigenergy", + ) + + # --- Load power (W) --- + self.dashboard_item( + "sensor.{}_sigenergy_{}_load_power".format(self.prefix, slug), + state=round(load_power_kw * 1000), + attributes={ + "friendly_name": "Sigenergy {} Load Power".format(system_name), + "unit_of_measurement": "W", + "device_class": "power", + "state_class": "measurement", + }, + app="sigenergy", + ) + + # --- EV charger power (W) --- + self.dashboard_item( + "sensor.{}_sigenergy_{}_ev_power".format(self.prefix, slug), + state=round(ev_power_kw * 1000), + attributes={ + "friendly_name": "Sigenergy {} EV Power".format(system_name), + "unit_of_measurement": "W", + "device_class": "power", + "state_class": "measurement", + }, + app="sigenergy", + ) + + # --- Daily PV yield (kWh) --- + self.dashboard_item( + "sensor.{}_sigenergy_{}_pv_today".format(self.prefix, slug), + state=round(daily_yield_kwh, 3), + attributes={ + "friendly_name": "Sigenergy {} PV Today".format(system_name), + "unit_of_measurement": "kWh", + "device_class": "energy", + "state_class": "total_increasing", + }, + app="sigenergy", + ) + + # --- Battery capacity (kWh) --- + self.dashboard_item( + "sensor.{}_sigenergy_{}_battery_capacity".format(self.prefix, slug), + state=round(capacity_kwh, 3), + attributes={ + "friendly_name": "Sigenergy {} Battery Capacity".format(system_name), + "unit_of_measurement": "kWh", + "device_class": "energy", + }, + app="sigenergy", + ) + + # --- Battery max charge/discharge power (W) --- + self.dashboard_item( + "sensor.{}_sigenergy_{}_battery_rate_max".format(self.prefix, slug), + state=round(battery_max_kw * 1000), + attributes={ + "friendly_name": "Sigenergy {} Battery Max Power".format(system_name), + "unit_of_measurement": "W", + "device_class": "power", + }, + app="sigenergy", + ) + + # --- Inverter limit (W) --- + self.dashboard_item( + "sensor.{}_sigenergy_{}_inverter_limit".format(self.prefix, slug), + state=round(inverter_max_kw * 1000), + attributes={ + "friendly_name": "Sigenergy {} Inverter Limit".format(system_name), + "unit_of_measurement": "W", + "device_class": "power", + }, + app="sigenergy", + ) + + # --- System status --- + system_status = system_info.get("status", "Unknown") + self.dashboard_item( + "sensor.{}_sigenergy_{}_status".format(self.prefix, slug), + state=system_status, + attributes={ + "friendly_name": "Sigenergy {} Status".format(system_name), + "system_id": system_id, + "system_name": system_name, + "pv_capacity": system_info.get("pvCapacity"), + "battery_capacity_kwh": capacity_kwh, + "daily_yield_kwh": daily_yield_kwh, + "monthly_yield_kwh": monthly_yield_kwh, + "annual_yield_kwh": annual_yield_kwh, + "lifetime_yield_kwh": lifetime_yield_kwh, + }, + app="sigenergy", + ) + + # ----------------------------------------------------------------------- + # Automatic configuration + # ----------------------------------------------------------------------- + + async def automatic_config(self): + """Wire Predbat config args to the published Sigenergy entity IDs. + + Called once on the first run when self.automatic is True. Sets + inverter_type, soc_kw, battery_power, pv_power, grid_power, etc. so + that the core prediction engine can read the data it needs. + """ + system_ids = list(self.systems.keys()) + num = len(system_ids) + if not num: + self.log("Warn: SigenergyAPI: automatic_config called with no systems") + return + + self.log("SigenergyAPI: automatic_config — configuring {} system(s)".format(num)) + slugs = [self._system_slug(sid) for sid in system_ids] + + self.set_arg("num_inverters", num) + self.set_arg("inverter_type", ["SIG" for _ in range(num)]) + + self.set_arg("soc_kw", ["sensor.{}_sigenergy_{}_battery_soc".format(self.prefix, s) for s in slugs]) + self.set_arg("soc_max", ["sensor.{}_sigenergy_{}_battery_capacity".format(self.prefix, s) for s in slugs]) + self.set_arg("battery_power", ["sensor.{}_sigenergy_{}_battery_power".format(self.prefix, s) for s in slugs]) + self.set_arg("battery_rate_max", ["sensor.{}_sigenergy_{}_battery_rate_max".format(self.prefix, s) for s in slugs]) + self.set_arg("inverter_limit", ["sensor.{}_sigenergy_{}_inverter_limit".format(self.prefix, s) for s in slugs]) + self.set_arg("pv_power", ["sensor.{}_sigenergy_{}_pv_power".format(self.prefix, s) for s in slugs]) + self.set_arg("grid_power", ["sensor.{}_sigenergy_{}_grid_power".format(self.prefix, s) for s in slugs]) + self.set_arg("load_power", ["sensor.{}_sigenergy_{}_load_power".format(self.prefix, s) for s in slugs]) + self.set_arg("pv_today", ["sensor.{}_sigenergy_{}_pv_today".format(self.prefix, s) for s in slugs]) + + # Control entities + self.set_arg("charge_start_time", ["select.{}_sigenergy_{}_charge_start_time".format(self.prefix, s) for s in slugs]) + self.set_arg("charge_end_time", ["select.{}_sigenergy_{}_charge_end_time".format(self.prefix, s) for s in slugs]) + self.set_arg("charge_limit", ["number.{}_sigenergy_{}_charge_target_soc".format(self.prefix, s) for s in slugs]) + self.set_arg("scheduled_charge_enable", ["switch.{}_sigenergy_{}_charge_enable".format(self.prefix, s) for s in slugs]) + self.set_arg("charge_rate", ["number.{}_sigenergy_{}_charge_rate".format(self.prefix, s) for s in slugs]) + self.set_arg("discharge_start_time", ["select.{}_sigenergy_{}_export_start_time".format(self.prefix, s) for s in slugs]) + self.set_arg("discharge_end_time", ["select.{}_sigenergy_{}_export_end_time".format(self.prefix, s) for s in slugs]) + self.set_arg("discharge_target_soc", ["number.{}_sigenergy_{}_export_target_soc".format(self.prefix, s) for s in slugs]) + self.set_arg("scheduled_discharge_enable", ["switch.{}_sigenergy_{}_export_enable".format(self.prefix, s) for s in slugs]) + self.set_arg("discharge_rate", ["number.{}_sigenergy_{}_export_rate".format(self.prefix, s) for s in slugs]) + self.set_arg("reserve", ["number.{}_sigenergy_{}_reserve".format(self.prefix, s) for s in slugs]) + + self.log("SigenergyAPI: automatic_config complete") + + # ----------------------------------------------------------------------- + # Controls + # ----------------------------------------------------------------------- + + def _control_info(self, system_id, direction, field): + """Return metadata for a single control entity. + + Args: + system_id: System ID string. + direction: 'charge', 'export', or None (for global fields like reserve). + field: Field name string. + + Returns: + Tuple (item_name, ha_name, friendly_name, field_type, field_units, + default, min_value, max_value). + """ + slug = self._system_slug(system_id) + system_name = self.systems.get(system_id, {}).get("systemName", system_id) + field_type = "select" + field_units = None + default = None + min_value = None + max_value = None + + if direction is None: + item_name = "sigenergy_{}_{}".format(slug, field) + friendly_name = "Sigenergy {} {}".format(system_name, field.replace("_", " ").capitalize()) + else: + item_name = "sigenergy_{}_{}_{}".format(slug, direction, field) + friendly_name = "Sigenergy {} {} {}".format(system_name, direction.capitalize(), field.replace("_", " ").capitalize()) + + if "_time" in field: + default = "00:00" + field_type = "select" + field_units = "time" + elif field == "enable": + default = False + field_type = "switch" + elif field == "target_soc": + field_type = "number" + field_units = "%" + min_value = 0 + max_value = 100 + default = 100 if direction == "charge" else 0 + elif field == "rate": + battery_max_w = round(self._get_battery_max_power_kw(system_id) * 1000) + min_value = 0 + max_value = battery_max_w if battery_max_w > 0 else 10000 + default = max_value + field_type = "number" + field_units = "W" + elif field == "reserve": + min_value = 0 + max_value = 100 + default = 10 + field_type = "number" + field_units = "%" + + ha_name = "{}.{}_{}_{}".format(field_type, self.prefix, "sigenergy", item_name.replace("sigenergy_{}_".format(slug), slug + "_", 1)) + ha_name = "{}.{}_{}".format(field_type, self.prefix, item_name) + return item_name, ha_name, friendly_name, field_type, field_units, default, min_value, max_value + + async def fetch_controls(self, system_id): + """Read current control state from Home Assistant entities. + + Args: + system_id: System ID string. + """ + if system_id not in self.controls: + self.controls[system_id] = {} + + for direction in ("charge", "export"): + if direction not in self.controls[system_id]: + self.controls[system_id][direction] = {} + for field in ("start_time", "end_time", "enable", "target_soc", "rate"): + item_name, ha_name, friendly_name, field_type, field_units, default, min_value, max_value = self._control_info(system_id, direction, field) + state = self.get_state_wrapper(ha_name, default=default) + if field_type == "number": + state = _safe_int(state, default=default if default is not None else 0) + if min_value is not None: + state = max(min_value, state) + if max_value is not None: + state = min(max_value, state) + elif field_type == "switch": + if isinstance(state, str): + state = state.lower() == "on" + self.controls[system_id][direction][field] = state + + # Global fields + for field in ("reserve",): + item_name, ha_name, friendly_name, field_type, field_units, default, min_value, max_value = self._control_info(system_id, None, field) + state = self.get_state_wrapper(ha_name, default=default) + if field_type == "number": + state = _safe_int(state, default=default if default is not None else 0) + if min_value is not None: + state = max(min_value, state) + if max_value is not None: + state = min(max_value, state) + self.controls[system_id][field] = state + + async def publish_controls(self, system_id=None): + """Publish control entity states to the HA dashboard. + + Args: + system_id: Specific system ID to publish, or None for all systems. + """ + target_systems = [system_id] if system_id else list(self.controls.keys()) + + for sid in target_systems: + if sid not in self.controls: + continue + + for direction in ("charge", "export"): + for field in ("start_time", "end_time", "enable", "target_soc", "rate"): + item_name, ha_name, friendly_name, field_type, field_units, default, min_value, max_value = self._control_info(sid, direction, field) + value = self.controls[sid].get(direction, {}).get(field, default) + attributes = {"friendly_name": friendly_name} + if field_units: + attributes["unit_of_measurement"] = field_units + if min_value is not None: + attributes["min"] = min_value + if max_value is not None: + attributes["max"] = max_value + attributes["step"] = 1 + if "_time" in field: + attributes["options"] = SIGENERGY_OPTIONS_TIME + if field_type == "switch": + value = "on" if value else "off" + self.dashboard_item(ha_name, state=value, attributes=attributes, app="sigenergy") + + for field in ("reserve",): + item_name, ha_name, friendly_name, field_type, field_units, default, min_value, max_value = self._control_info(sid, None, field) + value = self.controls[sid].get(field, default) + self.dashboard_item( + ha_name, + state=value, + attributes={ + "friendly_name": friendly_name, + "unit_of_measurement": field_units, + "min": min_value, + "max": max_value, + "step": 1, + }, + app="sigenergy", + ) + + def _apply_service_to_toggle(self, current, service): + """Map a switch service call to a boolean.""" + if service == "turn_on": + return True + if service == "turn_off": + return False + if service == "toggle": + return not current + return current + + async def _update_control(self, entity_id, value, direction, field, system_id): + """Apply a single control update and re-publish.""" + if system_id not in self.controls: + self.log("Warn: SigenergyAPI: No controls for system {}".format(system_id)) + return + + item_name, ha_name, friendly_name, field_type, field_units, default, min_value, max_value = self._control_info(system_id, direction, field) + + if field == "enable": + current = self.controls[system_id].get(direction, {}).get(field, False) + value = self._apply_service_to_toggle(current, value) + elif "_time" in field: + if value not in SIGENERGY_OPTIONS_TIME: + self.log("Warn: SigenergyAPI: Invalid time value {} for {}".format(value, entity_id)) + return + elif field in ("target_soc", "rate"): + value = _safe_int(value, default=default if default is not None else 0) + if min_value is not None: + value = max(min_value, value) + if max_value is not None: + value = min(max_value, value) + + if direction: + if direction not in self.controls[system_id]: + self.controls[system_id][direction] = {} + self.controls[system_id][direction][field] = value + else: + self.controls[system_id][field] = value + + self.log("SigenergyAPI: Control update system={} direction={} field={} value={}".format(system_id, direction, field, value)) + await self.publish_controls(system_id) + + def _parse_entity_system(self, entity_id): + """Extract (system_id, direction, field) from a control entity ID. + + Entity ID format: + {domain}.{prefix}_sigenergy_{slug}_{direction}_{field} + {domain}.{prefix}_sigenergy_{slug}_{field} (for global controls) + """ + # Remove domain prefix + name = entity_id.split(".", 1)[-1] + # Remove predbat prefix + if name.startswith(self.prefix + "_"): + name = name[len(self.prefix) + 1:] + # Must start with 'sigenergy_' + if not name.startswith("sigenergy_"): + return None, None, None + name = name[len("sigenergy_"):] + + # Match slug to known systems + for sid in self.controls: + slug = self._system_slug(sid) + if name.startswith(slug + "_"): + rest = name[len(slug) + 1:] + # Try direction-field split + for direction in ("charge", "export"): + if rest.startswith(direction + "_"): + field = rest[len(direction) + 1:] + return sid, direction, field + # Global field + return sid, None, rest + + return None, None, None + + async def select_event(self, entity_id, value): + """Handle a HA select change event.""" + system_id, direction, field = self._parse_entity_system(entity_id) + if system_id: + await self._update_control(entity_id, value, direction, field, system_id) + + async def number_event(self, entity_id, value): + """Handle a HA number change event.""" + system_id, direction, field = self._parse_entity_system(entity_id) + if system_id: + await self._update_control(entity_id, value, direction, field, system_id) + + async def switch_event(self, entity_id, service): + """Handle a HA switch service call event.""" + system_id, direction, field = self._parse_entity_system(entity_id) + if system_id: + await self._update_control(entity_id, service, direction, field, system_id) + + # ----------------------------------------------------------------------- + # Control application + # ----------------------------------------------------------------------- + + async def apply_controls(self, system_id): + """Compute and apply the charge/discharge/eco command for a system. + + Inspects the current control state (charge/export window, target SOC, + power rate) and the latest battery SOC to decide which command to send. + Uses a hash to skip redundant API calls within 15 minutes. + + Args: + system_id: System ID string. + + Returns: + True on success, False on failure. + """ + if system_id not in self.controls: + self.log("Warn: SigenergyAPI: No controls for system {}".format(system_id)) + return False + + flow = self.energy_flow.get(system_id, {}) + battery_soc_pct = _safe_float(flow.get("batterySoc", 50)) + battery_max_kw = self._get_battery_max_power_kw(system_id) + + now = datetime.now(self.local_tz) + + charge_enable = self.controls[system_id].get("charge", {}).get("enable", False) + charge_start_str = self.controls[system_id].get("charge", {}).get("start_time", "00:00") + charge_end_str = self.controls[system_id].get("charge", {}).get("end_time", "00:00") + charge_target_soc = _safe_int(self.controls[system_id].get("charge", {}).get("target_soc", 100), 100) + charge_rate_w = _safe_int(self.controls[system_id].get("charge", {}).get("rate", round(battery_max_kw * 1000)), round(battery_max_kw * 1000)) + export_enable = self.controls[system_id].get("export", {}).get("enable", False) + export_start_str = self.controls[system_id].get("export", {}).get("start_time", "00:00") + export_end_str = self.controls[system_id].get("export", {}).get("end_time", "00:00") + export_target_soc = _safe_int(self.controls[system_id].get("export", {}).get("target_soc", 0), 0) + export_rate_w = _safe_int(self.controls[system_id].get("export", {}).get("rate", round(battery_max_kw * 1000)), round(battery_max_kw * 1000)) + reserve_soc = _safe_int(self.controls[system_id].get("reserve", 10), 10) + + def parse_window(start_str, end_str): + """Return (start_dt, end_dt) adjusted for midnight-spanning windows.""" + start_dt = now.replace(hour=int(start_str.split(":")[0]), minute=int(start_str.split(":")[1]), second=0, microsecond=0) + end_dt = now.replace(hour=int(end_str.split(":")[0]), minute=int(end_str.split(":")[1]), second=0, microsecond=0) + if end_dt <= start_dt: + if now <= end_dt: + start_dt -= timedelta(days=1) + else: + end_dt += timedelta(days=1) + return start_dt, end_dt + + charge_window = False + export_window = False + charge_start_dt = charge_end_dt = None + export_start_dt = export_end_dt = None + + if charge_enable: + charge_start_dt, charge_end_dt = parse_window(charge_start_str, charge_end_str) + if charge_start_dt <= now <= charge_end_dt: + charge_window = True + + if export_enable: + export_start_dt, export_end_dt = parse_window(export_start_str, export_end_str) + if export_start_dt <= now <= export_end_dt: + export_window = True + + # Determine desired mode (export takes priority) + if export_window and export_start_dt and export_end_dt: + duration_min = max(1, int((export_end_dt - now).total_seconds() / 60)) + effective_target = max(export_target_soc, reserve_soc) + if effective_target >= battery_soc_pct: + # Already at or below target — freeze (idle) + new_mode = "freeze_export" + active_mode = SIGENERGY_ACTIVE_MODE_IDLE + power_kw = 0.0 + else: + new_mode = "export" + active_mode = SIGENERGY_ACTIVE_MODE_DISCHARGE + power_kw = export_rate_w / 1000.0 + elif charge_window and charge_start_dt and charge_end_dt: + duration_min = max(1, int((charge_end_dt - now).total_seconds() / 60)) + effective_target = max(charge_target_soc, reserve_soc) + if effective_target <= reserve_soc or abs(effective_target - battery_soc_pct) < 1: + # Freeze charge — stay at current SOC + new_mode = "freeze_charge" + active_mode = SIGENERGY_ACTIVE_MODE_SELF + power_kw = 0.0 + elif effective_target < battery_soc_pct: + # Target below current — go to eco + new_mode = "eco" + active_mode = SIGENERGY_ACTIVE_MODE_SELF + power_kw = 0.0 + else: + new_mode = "charge" + active_mode = SIGENERGY_ACTIVE_MODE_CHARGE + power_kw = charge_rate_w / 1000.0 + else: + duration_min = 60 + new_mode = "eco" + active_mode = SIGENERGY_ACTIVE_MODE_SELF + power_kw = 0.0 + + duration_min = min(duration_min, 720) + + # Deduplication — skip if mode unchanged in last 15 minutes + new_hash = hash((new_mode, round(power_kw, 2), duration_min)) + old_hash = self.current_mode_hash.get(system_id) + old_ts = self.current_mode_hash_timestamp.get(system_id) + if old_hash is not None and old_hash == new_hash and old_ts is not None: + age = (now - old_ts).total_seconds() + if age < 15 * 60: + self.log("SigenergyAPI: Mode unchanged for system {} ({} — {:.1f} min ago), skipping".format(system_id, new_mode, age / 60)) + return True + + self.log("SigenergyAPI: Applying mode={} power={:.2f}kW duration={}min to system {}".format(new_mode, power_kw, duration_min, system_id)) + + # Send battery command via MQTT — no mode pre-switch required + ok = await self.send_battery_command(system_id, active_mode, duration_min, charging_power_kw=power_kw if power_kw > 0 else None) + success = ok + + if success: + self.current_mode_hash[system_id] = new_hash + self.current_mode_hash_timestamp[system_id] = now + + return success + + # ----------------------------------------------------------------------- + # Main run loop + # ----------------------------------------------------------------------- + + async def run(self, seconds, first): + """Main component loop called every 60 seconds by ComponentBase. + + First call: discover systems and devices, publish controls, run + automatic_config if enabled. + Every call: refresh realtime data, publish entities, apply controls. + + Args: + seconds: Elapsed seconds since component start. + first: True on the first call. + + Returns: + True on success, False on failure (triggers retry/backoff). + """ + if first: + self.log("SigenergyAPI: First run — discovering systems") + ok = await self.fetch_system_list() + if not ok: + self.log("Warn: SigenergyAPI: Failed to discover systems, will retry") + return False + + # Refresh device inventory periodically + if first or seconds % SIGENERGY_DEVICE_POLL_INTERVAL == 0: + for sid in list(self.systems.keys()): + await self.fetch_device_list(sid) + + # Fetch controls from HA on first run only + if first: + for sid in list(self.systems.keys()): + await self.fetch_controls(sid) + await self.publish_controls() + + # Automatic configuration + if first and self.automatic: + await self.automatic_config() + + # Realtime data refresh + if first or seconds % SIGENERGY_POLL_INTERVAL == 0: + for sid in list(self.systems.keys()): + await self.fetch_energy_flow(sid) + await self.fetch_daily_summary(sid) + + # Publish entities + if first or seconds % SIGENERGY_POLL_INTERVAL == 0: + for sid in list(self.systems.keys()): + await self.publish_system_entities(sid) + + # Apply controls + is_readonly = self.get_state_wrapper("switch.{}_set_read_only".format(self.prefix), default="off") == "on" + if self.enable_controls and not is_readonly: + if first or seconds % 60 == 0: + for sid in list(self.systems.keys()): + await self.apply_controls(sid) + else: + if first: + self.log("SigenergyAPI: Controls disabled or read-only mode active") + + self.update_success_timestamp() + return True + + +class MockBase: # pragma: no cover + """Mock base class for standalone testing.""" + + def __init__(self): + """Initialise mock base.""" + self.prefix = "predbat" + self.local_tz = datetime.now().astimezone().tzinfo + self.args = {} + self.entities = {} + + def get_state_wrapper(self, entity_id, default=None, attribute=None, refresh=False, required_unit=None, raw=None): + """Return entity state or default.""" + if raw: + return self.entities.get(entity_id, {}) + return self.entities.get(entity_id, {}).get("state", default) + + def set_state_wrapper(self, entity_id, state, attributes=None, app=None): + """Store entity state.""" + self.entities[entity_id] = {"state": state, "attributes": attributes or {}} + + def log(self, message): + """Print log message with timestamp.""" + print("[{}] {}".format(datetime.now().strftime("%H:%M:%S"), message)) + + def dashboard_item(self, entity_id, state=None, attributes=None, app=None): + """Print and store a dashboard entity.""" + import json + print("ENTITY: {} = {}".format(entity_id, state)) + if attributes: + display = {k: ("..." if k == "options" else v) for k, v in attributes.items()} + print(" Attributes: {}".format(json.dumps(display, indent=2, default=str))) + self.set_state_wrapper(entity_id, state, attributes) + + def get_arg(self, key, default=None): + """Return arg default (mock always returns default).""" + return default + + def set_arg(self, key, value): + """Print auto-config arg assignment.""" + if isinstance(value, list): + state = "[list of {} items]".format(len(value)) + else: + state = str(value) + print("Set arg {} = {}".format(key, state)) + + def update_success_timestamp(self): + """No-op success timestamp update.""" + + +async def test_sigenergy_api(app_key, app_secret, base_url, system_id, test_mode): # pragma: no cover + """Run one cycle of the Sigenergy API and optionally test a control mode. + + Args: + app_key: Sigenergy Application Key. + app_secret: Sigenergy Application Secret. + base_url: API base URL. + system_id: Optional system ID filter string. + test_mode: One of 'eco', 'charge', 'freeze_charge', 'export', 'freeze_export', or None. + """ + print("\n{}".format("=" * 60)) + print("Testing Sigenergy Cloud API") + print("Base URL: {}".format(base_url)) + print("App Key: {}...".format(app_key[:10] if len(app_key) >= 10 else app_key)) + if system_id: + print("System ID filter: {}".format(system_id)) + if test_mode: + print("Test mode: {}".format(test_mode)) + print("{}\n".format("=" * 60)) + + mock_base = MockBase() + + sig = SigenergyAPI( + mock_base, + app_key=app_key, + app_secret=app_secret, + base_url=base_url, + system_id=system_id, + automatic=True, + enable_controls=(test_mode is not None), + ) + + result = await sig.run(first=True, seconds=0) + if not result: + print("x Initialisation failed") + return 1 + print("+ Initialisation successful") + + if test_mode and sig.systems: + sid = list(sig.systems.keys())[0] + flow = sig.energy_flow.get(sid, {}) + battery_soc_pct = _safe_float(flow.get("batterySoc", 50)) + now = datetime.now(sig.local_tz) + + print("\n{}".format("=" * 60)) + print("Testing control mode: {}".format(test_mode)) + print("System ID: {} SOC: {:.0f}%".format(sid, battery_soc_pct)) + print("{}\n".format("=" * 60)) + + def _window(offset_start_min, offset_end_min): + """Return HH:MM strings offset from now.""" + s = (now + timedelta(minutes=offset_start_min)).strftime("%H:%M") + e = (now + timedelta(minutes=offset_end_min)).strftime("%H:%M") + return s, e + + battery_max_w = round(sig._get_battery_max_power_kw(sid) * 1000) or 5000 + + if test_mode == "eco": + sig.controls[sid] = { + "reserve": 10, + "charge": {"start_time": "23:00", "end_time": "23:30", "enable": False, "target_soc": 100, "rate": battery_max_w}, + "export": {"start_time": "23:30", "end_time": "23:59", "enable": False, "target_soc": 10, "rate": battery_max_w}, + } + print("+ Configured for ECO mode (no active windows)") + + elif test_mode == "charge": + cs, ce = _window(-30, 120) + sig.controls[sid] = { + "reserve": 10, + "charge": {"start_time": cs, "end_time": ce, "enable": True, "target_soc": 95, "rate": battery_max_w}, + "export": {"start_time": "23:30", "end_time": "23:59", "enable": False, "target_soc": 10, "rate": battery_max_w}, + } + print("+ Configured for CHARGE mode ({} - {}, target 95%)".format(cs, ce)) + + elif test_mode == "freeze_charge": + cs, ce = _window(-30, 120) + target = round(battery_soc_pct) # same as current = freeze + sig.controls[sid] = { + "reserve": 10, + "charge": {"start_time": cs, "end_time": ce, "enable": True, "target_soc": target, "rate": battery_max_w}, + "export": {"start_time": "23:30", "end_time": "23:59", "enable": False, "target_soc": 10, "rate": battery_max_w}, + } + print("+ Configured for FREEZE CHARGE mode ({} - {}, target=current {:.0f}%)".format(cs, ce, battery_soc_pct)) + + elif test_mode == "export": + es, ee = _window(-30, 120) + sig.controls[sid] = { + "reserve": 10, + "charge": {"start_time": "23:00", "end_time": "23:30", "enable": False, "target_soc": 100, "rate": battery_max_w}, + "export": {"start_time": es, "end_time": ee, "enable": True, "target_soc": 15, "rate": battery_max_w}, + } + print("+ Configured for EXPORT mode ({} - {}, target 15%)".format(es, ee)) + + elif test_mode == "freeze_export": + es, ee = _window(-30, 120) + target = min(100, round(battery_soc_pct) + 10) # above current = freeze + sig.controls[sid] = { + "reserve": 10, + "charge": {"start_time": "23:00", "end_time": "23:30", "enable": False, "target_soc": 100, "rate": battery_max_w}, + "export": {"start_time": es, "end_time": ee, "enable": True, "target_soc": target, "rate": battery_max_w}, + } + print("+ Configured for FREEZE EXPORT mode ({} - {}, current {:.0f}% target {}%)".format(es, ee, battery_soc_pct, target)) + + else: + print("x Unknown test mode: {}".format(test_mode)) + return 1 + + print("\nApplying controls...") + ok = await sig.apply_controls(sid) + if ok: + print("+ Controls applied successfully") + else: + print("x Controls application failed") + return 1 + + return 0 + + +def main(): # pragma: no cover + """Main entry point for standalone testing.""" + parser = argparse.ArgumentParser( + description="Test Sigenergy Cloud API and control modes", + epilog="Example: python sigenergy.py --app-key YOUR_KEY --app-secret YOUR_SECRET --test-mode charge", + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument("--app-key", required=True, help="Sigenergy Application Key") + parser.add_argument("--app-secret", required=True, help="Sigenergy Application Secret") + parser.add_argument("--base-url", default=SIGENERGY_DEFAULT_BASE_URL, help="API base URL (default: {})".format(SIGENERGY_DEFAULT_BASE_URL)) + parser.add_argument("--system-id", help="Optional system ID filter") + parser.add_argument( + "--test-mode", + choices=["eco", "charge", "freeze_charge", "export", "freeze_export"], + help="Control mode to test", + ) + + args = parser.parse_args() + result = asyncio.run(test_sigenergy_api(args.app_key, args.app_secret, args.base_url, args.system_id, args.test_mode)) + raise SystemExit(result or 0) + + +if __name__ == "__main__": # pragma: no cover + main() diff --git a/apps/predbat/tests/test_sigenergy.py b/apps/predbat/tests/test_sigenergy.py new file mode 100644 index 000000000..ec44efacd --- /dev/null +++ b/apps/predbat/tests/test_sigenergy.py @@ -0,0 +1,852 @@ +# ----------------------------------------------------------------------------- +# Predbat Home Battery System +# Copyright Trefor Southwell 2026 - All Rights Reserved +# This application maybe used for personal use only and not for commercial use +# ----------------------------------------------------------------------------- +# fmt: off +# pylint: disable=consider-using-f-string +# pylint: disable=line-too-long +# pylint: disable=attribute-defined-outside-init + +"""Unit tests for the Sigenergy Cloud API integration component.""" + +import asyncio +from datetime import datetime, timezone, timedelta +from unittest.mock import MagicMock, patch + +from sigenergy import ( + SigenergyAPI, + SIGENERGY_ACTIVE_MODE_CHARGE, + SIGENERGY_ACTIVE_MODE_DISCHARGE, + SIGENERGY_ACTIVE_MODE_SELF, + _safe_float, + _safe_int, +) +from tests.test_infra import run_async + + +def _make_mock_response(status=200, json_data=None): + """Create a mock aiohttp response that accepts json(content_type=...) kwargs.""" + mock_resp = MagicMock() + mock_resp.status = status + + async def return_json(*args, **kwargs): + return json_data or {} + + mock_resp.json = return_json + + async def aenter(*args, **kwargs): + return mock_resp + + async def aexit(*args, **kwargs): + pass + + mock_resp.__aenter__ = aenter + mock_resp.__aexit__ = aexit + return mock_resp + + +def _make_mock_session(mock_response): + """Create a mock aiohttp ClientSession for sigenergy tests (supports get/post/put).""" + mock_ctx = MagicMock() + + async def ctx_aenter(*args, **kwargs): + return mock_response + + async def ctx_aexit(*args, **kwargs): + pass + + mock_ctx.__aenter__ = ctx_aenter + mock_ctx.__aexit__ = ctx_aexit + + mock_session = MagicMock() + mock_session.get = MagicMock(return_value=mock_ctx) + mock_session.post = MagicMock(return_value=mock_ctx) + mock_session.put = MagicMock(return_value=mock_ctx) + + async def session_aenter(*args): + return mock_session + + async def session_aexit(*args): + pass + + mock_session.__aenter__ = session_aenter + mock_session.__aexit__ = session_aexit + return mock_session + + +# --------------------------------------------------------------------------- +# Mock class +# --------------------------------------------------------------------------- + + +class MockSigenergyAPI(SigenergyAPI): + """Minimal SigenergyAPI subclass that bypasses ComponentBase initialisation.""" + + def __init__(self, prefix="predbat"): + # Manually initialise attributes that ComponentBase would provide + self.prefix = prefix + self.local_tz = timezone.utc + self.log_messages = [] + self.dashboard_items = {} + self.set_args = {} + self.args = {} + + # Now call the SigenergyAPI initialize directly + self.initialize( + app_key="test_app_key", + app_secret="test_app_secret", + system_id=None, + automatic=False, + enable_controls=True, + ) + # Skip mode-switch → command delay in unit tests + self._command_delay = 0 + + def log(self, message): + """Capture log messages for assertion.""" + self.log_messages.append(message) + + def dashboard_item(self, entity_id, state=None, attributes=None, app=None): + """Capture dashboard item publishes.""" + self.dashboard_items[entity_id] = {"state": state, "attributes": attributes or {}} + + def get_state_wrapper(self, entity_id, default=None, attribute=None, refresh=False, required_unit=None, raw=None): + """Return dashboard state or default.""" + if entity_id in self.dashboard_items: + return self.dashboard_items[entity_id]["state"] + return default + + def set_state_wrapper(self, entity_id, state, attributes=None, app=None): + """Store state.""" + self.dashboard_items[entity_id] = {"state": state, "attributes": attributes or {}} + + def get_arg(self, key, default=None): + """Return stored arg or default.""" + return self.args.get(key, default) + + def set_arg(self, key, value): + """Capture set_arg calls.""" + self.set_args[key] = value + self.args[key] = value + + def update_success_timestamp(self): + """No-op for tests.""" + pass + + async def _publish_mqtt(self, topic, payload_dict): + """Mock MQTT publish — records calls and returns success.""" + if not hasattr(self, "mqtt_publishes"): + self.mqtt_publishes = [] + self.mqtt_publishes.append((topic, payload_dict)) + return True + + +# --------------------------------------------------------------------------- +# Helper +# --------------------------------------------------------------------------- + + +def run_async(coro): + """Run a coroutine synchronously for test purposes.""" + try: + loop = asyncio.get_event_loop() + except RuntimeError: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + return loop.run_until_complete(coro) + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +def test_sigenergy_helper_functions(my_predbat): + """Test _safe_float and _safe_int helper functions.""" + failed = False + + # _safe_float + assert _safe_float(3.14) == 3.14, "_safe_float: float passthrough" + assert _safe_float("2.5") == 2.5, "_safe_float: string to float" + assert _safe_float(None) == 0.0, "_safe_float: None → 0.0" + assert _safe_float("abc") == 0.0, "_safe_float: invalid string → 0.0" + assert _safe_float(None, default=99.0) == 99.0, "_safe_float: None with custom default" + + # _safe_int + assert _safe_int(42) == 42, "_safe_int: int passthrough" + assert _safe_int("7") == 7, "_safe_int: string to int" + assert _safe_int(None) == 0, "_safe_int: None → 0" + assert _safe_int("bad") == 0, "_safe_int: invalid → 0" + assert _safe_int(None, default=5) == 5, "_safe_int: None with custom default" + + return failed + + +def test_sigenergy_initialize(my_predbat): + """Test SigenergyAPI initialisation state.""" + failed = False + api = MockSigenergyAPI() + + assert api.app_key == "test_app_key", "app_key stored" + assert api.app_secret == "test_app_secret", "app_secret stored" + assert api.access_token is None, "No token initially" + assert api.token_expires_at == 0.0, "Token not yet obtained" + assert api.systems == {}, "No systems initially" + assert api.devices == {}, "No devices initially" + assert api.controls == {}, "No controls initially" + assert api.system_id_filter == set(), "No filter when system_id=None" + + # System ID filter — string + api2 = MockSigenergyAPI() + api2.initialize(app_key="k", app_secret="s", system_id="sys-1") + assert api2.system_id_filter == {"sys-1"}, "Single system ID filter" + + # System ID filter — list + api3 = MockSigenergyAPI() + api3.initialize(app_key="k", app_secret="s", system_id=["sys-1", "sys-2"]) + assert api3.system_id_filter == {"sys-1", "sys-2"}, "Multi system ID filter" + + return failed + + +def test_sigenergy_system_slug(my_predbat): + """Test _system_slug generates safe, short identifiers.""" + failed = False + api = MockSigenergyAPI() + + # Long ID → last 12 chars + slug = api._system_slug("ABCDEFGHIJKLMNOPQRSTUVWXYZ") + assert len(slug) <= 12, "Slug max 12 chars: {}".format(slug) + + # Hyphens replaced + api.systems["my-system-id"] = {"systemName": "Test"} + slug = api._system_slug("my-system-id") + assert "-" not in slug, "Hyphens removed: {}".format(slug) + + return failed + + +def test_sigenergy_battery_capacity(my_predbat): + """Test _get_battery_capacity_kwh falls back to device data.""" + failed = False + api = MockSigenergyAPI() + + # From system info + api.systems["sys1"] = {"batteryCapacity": 12.5} + assert api._get_battery_capacity_kwh("sys1") == 12.5, "Capacity from system info" + + # Fallback to device attrMap + api.systems["sys2"] = {} + api.devices["sys2"] = [ + {"deviceType": "Battery", "attrMap": {"ratedEnergy": 6.5}}, + {"deviceType": "Battery", "attrMap": {"ratedEnergy": 6.5}}, + {"deviceType": "Inverter", "attrMap": {"ratedActivePower": 5.0}}, + ] + assert api._get_battery_capacity_kwh("sys2") == 13.0, "Capacity summed from Battery devices" + + return failed + + +def test_sigenergy_publish_system_entities(my_predbat): + """Test publish_system_entities creates expected HA entities.""" + failed = False + api = MockSigenergyAPI() + + system_id = "SIG12345" + slug = api._system_slug(system_id) + api.systems[system_id] = {"systemName": "My Site", "batteryCapacity": 10.0, "status": "online"} + api.devices[system_id] = [{"deviceType": "Inverter", "attrMap": {"ratedActivePower": 5.0}}] + api.energy_flow[system_id] = { + "batterySoc": 60.0, + "batteryPower": 2.0, # kW charging + "pvPower": 3.5, # kW + "gridPower": 1.0, # kW export (positive=export, will be inverted to negative) + "loadPower": 4.5, + "evPower": 0.0, + } + api.daily_summary[system_id] = {"dailyPowerGeneration": 12.3} + + run_async(api.publish_system_entities(system_id)) + + soc_key = "sensor.predbat_sigenergy_{}_battery_soc".format(slug) + battery_key = "sensor.predbat_sigenergy_{}_battery_power".format(slug) + grid_key = "sensor.predbat_sigenergy_{}_grid_power".format(slug) + pv_key = "sensor.predbat_sigenergy_{}_pv_power".format(slug) + today_key = "sensor.predbat_sigenergy_{}_pv_today".format(slug) + + assert soc_key in api.dashboard_items, "Battery SOC entity published" + soc_kwh = api.dashboard_items[soc_key]["state"] + assert abs(soc_kwh - 6.0) < 0.01, "SOC kWh = 60% × 10kWh = 6.0, got {}".format(soc_kwh) + + assert battery_key in api.dashboard_items, "Battery power entity published" + assert api.dashboard_items[battery_key]["state"] == 2000, "Battery 2kW = 2000W" + + assert grid_key in api.dashboard_items, "Grid power entity published" + # API gridPower +1.0 (export) → Predbat −1000 W (import-negative) + assert api.dashboard_items[grid_key]["state"] == -1000, "Grid power inverted: export 1kW → -1000W" + + assert pv_key in api.dashboard_items, "PV power entity published" + assert api.dashboard_items[pv_key]["state"] == 3500, "PV 3.5kW = 3500W" + + assert today_key in api.dashboard_items, "PV today entity published" + assert abs(api.dashboard_items[today_key]["state"] - 12.3) < 0.01, "PV today correct" + + return failed + + +def test_sigenergy_automatic_config(my_predbat): + """Test automatic_config wires the expected Predbat args.""" + failed = False + api = MockSigenergyAPI() + api.automatic = True + + api.systems = {"SIG001": {"systemName": "Home"}, "SIG002": {"systemName": "Office"}} + + run_async(api.automatic_config()) + + assert "num_inverters" in api.set_args, "num_inverters set" + assert api.set_args["num_inverters"] == 2, "num_inverters == 2" + assert api.set_args.get("inverter_type") == ["SIG", "SIG"], "inverter_type wired" + assert "soc_kw" in api.set_args, "soc_kw wired" + assert "battery_power" in api.set_args, "battery_power wired" + assert "pv_power" in api.set_args, "pv_power wired" + assert "grid_power" in api.set_args, "grid_power wired" + + return failed + + +def test_sigenergy_fetch_controls(my_predbat): + """Test fetch_controls reads default values when entities have no state.""" + failed = False + api = MockSigenergyAPI() + system_id = "SIG001" + api.systems[system_id] = {"systemName": "Home", "batteryCapacity": 10.0} + api.devices[system_id] = [] + + run_async(api.fetch_controls(system_id)) + + assert system_id in api.controls, "Controls entry created" + assert "charge" in api.controls[system_id], "charge key present" + assert "export" in api.controls[system_id], "export key present" + assert api.controls[system_id]["charge"].get("enable") is False, "charge enable defaults off" + assert api.controls[system_id]["export"].get("enable") is False, "export enable defaults off" + + return failed + + +def test_sigenergy_publish_controls(my_predbat): + """Test publish_controls creates HA switch/select/number entities.""" + failed = False + api = MockSigenergyAPI() + system_id = "SIG001" + api.systems[system_id] = {"systemName": "Home", "batteryCapacity": 10.0} + api.devices[system_id] = [] + api.controls[system_id] = { + "charge": {"start_time": "01:00", "end_time": "05:00", "enable": False, "target_soc": 100, "rate": 2000}, + "export": {"start_time": "17:00", "end_time": "19:00", "enable": False, "target_soc": 20, "rate": 2000}, + "reserve": 10, + } + + run_async(api.publish_controls(system_id)) + + slug = api._system_slug(system_id) + charge_enable_key = "switch.predbat_sigenergy_{}_charge_enable".format(slug) + export_start_key = "select.predbat_sigenergy_{}_export_start_time".format(slug) + reserve_key = "number.predbat_sigenergy_{}_reserve".format(slug) + + assert charge_enable_key in api.dashboard_items, "Charge enable switch published: {}".format(charge_enable_key) + assert export_start_key in api.dashboard_items, "Export start time select published" + assert reserve_key in api.dashboard_items, "Reserve number published" + + return failed + + +def test_sigenergy_parse_entity_system(my_predbat): + """Test _parse_entity_system correctly decodes entity IDs.""" + failed = False + api = MockSigenergyAPI() + system_id = "SIG12345" + api.systems[system_id] = {} + api.controls[system_id] = {} + + slug = api._system_slug(system_id) + + entity_id = "switch.predbat_sigenergy_{}_charge_enable".format(slug) + sid, direction, field = api._parse_entity_system(entity_id) + assert sid == system_id, "System ID parsed: got {}".format(sid) + assert direction == "charge", "Direction parsed" + assert field == "enable", "Field parsed" + + entity_id2 = "number.predbat_sigenergy_{}_reserve".format(slug) + sid2, direction2, field2 = api._parse_entity_system(entity_id2) + assert sid2 == system_id, "System ID parsed for global field" + assert direction2 is None, "No direction for global field" + assert field2 == "reserve", "Global field name parsed" + + return failed + + +def test_sigenergy_apply_service_to_toggle(my_predbat): + """Test _apply_service_to_toggle correctly maps service strings.""" + failed = False + api = MockSigenergyAPI() + + assert api._apply_service_to_toggle(False, "turn_on") is True, "turn_on → True" + assert api._apply_service_to_toggle(True, "turn_off") is False, "turn_off → False" + assert api._apply_service_to_toggle(False, "toggle") is True, "toggle False → True" + assert api._apply_service_to_toggle(True, "toggle") is False, "toggle True → False" + assert api._apply_service_to_toggle(True, "unknown") is True, "unknown keeps current" + + return failed + + +def test_sigenergy_get_access_token_success(my_predbat): + """Test get_access_token caches the token on success.""" + failed = False + api = MockSigenergyAPI() + + fake_response = { + "code": 0, + "data": { + "accessToken": "test_token_abc", + "expiresIn": 43200, + "tokenType": "Bearer", + }, + } + + mock_response = _make_mock_response(status=200, json_data=fake_response) + mock_session = _make_mock_session(mock_response) + + with patch("sigenergy.aiohttp.ClientSession", return_value=mock_session): + token = run_async(api.get_access_token()) + + assert token == "test_token_abc", "Token returned: {}".format(token) + assert api.access_token == "test_token_abc", "Token cached" + assert api.token_expires_at > 0, "Expiry set" + + # Second call should use cache without hitting the network + token2 = run_async(api.get_access_token()) + assert token2 == "test_token_abc", "Cached token returned on second call" + + return failed + + +def test_sigenergy_get_access_token_failure(my_predbat): + """Test get_access_token returns None on API error.""" + failed = False + api = MockSigenergyAPI() + + fake_response = {"code": 10001, "msg": "Invalid key"} + + mock_response = _make_mock_response(status=200, json_data=fake_response) + mock_session = _make_mock_session(mock_response) + + with patch("sigenergy.aiohttp.ClientSession", return_value=mock_session): + token = run_async(api.get_access_token()) + + assert token is None, "None returned on API error" + assert api.access_token is None, "Token not cached on failure" + + return failed + + +def test_sigenergy_fetch_system_list(my_predbat): + """Test fetch_system_list populates self.systems.""" + failed = False + api = MockSigenergyAPI() + api.access_token = "fake_token" + api.token_expires_at = 9_999_999_999 + api._last_request_time = 0 # ensure no rate-limit delay + + fake_response = { + "code": 0, + "data": [ + {"systemId": "SIG001", "systemName": "Home", "batteryCapacity": 10.0, "pvCapacity": 6.0, "status": "online"}, + {"systemId": "SIG002", "systemName": "Office", "batteryCapacity": 20.0, "pvCapacity": 12.0, "status": "offline"}, + ], + } + + mock_response = _make_mock_response(status=200, json_data=fake_response) + mock_session = _make_mock_session(mock_response) + + with patch("sigenergy.aiohttp.ClientSession", return_value=mock_session): + ok = run_async(api.fetch_system_list()) + + assert ok is True, "fetch_system_list should return True, got {}".format(ok) + assert "SIG001" in api.systems, "SIG001 stored" + assert "SIG002" in api.systems, "SIG002 stored" + assert api.systems["SIG001"]["systemName"] == "Home", "System name correct" + + return failed + + +def test_sigenergy_fetch_system_list_with_filter(my_predbat): + """Test fetch_system_list respects system_id_filter.""" + failed = False + api = MockSigenergyAPI() + api.system_id_filter = {"SIG001"} + api.access_token = "fake_token" + api.token_expires_at = 9_999_999_999 + api._last_request_time = 0 + + fake_response = { + "code": 0, + "data": [ + {"systemId": "SIG001", "systemName": "Home", "batteryCapacity": 10.0}, + {"systemId": "SIG002", "systemName": "Office", "batteryCapacity": 20.0}, + ], + } + + mock_response = _make_mock_response(status=200, json_data=fake_response) + mock_session = _make_mock_session(mock_response) + + with patch("sigenergy.aiohttp.ClientSession", return_value=mock_session): + ok = run_async(api.fetch_system_list()) + + assert ok is True, "fetch_system_list should return True with filter, got {}".format(ok) + assert "SIG001" in api.systems, "Filtered system included" + assert "SIG002" not in api.systems, "Non-matching system excluded" + + return failed + + +def test_sigenergy_apply_controls_charge_mode(my_predbat): + """Test apply_controls selects charge command during active charge window.""" + failed = False + api = MockSigenergyAPI() + system_id = "SIG001" + api.systems[system_id] = {"systemName": "Home", "batteryCapacity": 10.0} + api.devices[system_id] = [{"deviceType": "Battery", "attrMap": {"ratedChargePower": 3.0}}] + + # SOC at 50%, charge window active now, target 90% + api.energy_flow[system_id] = {"batterySoc": 50.0} + now = datetime.now(timezone.utc) + start_str = (now - timedelta(hours=1)).strftime("%H:%M") + end_str = (now + timedelta(hours=2)).strftime("%H:%M") + api.controls[system_id] = { + "charge": {"enable": True, "start_time": start_str, "end_time": end_str, "target_soc": 90, "rate": 3000}, + "export": {"enable": False, "start_time": "00:00", "end_time": "00:00", "target_soc": 20, "rate": 3000}, + "reserve": 10, + } + + commands_sent = [] + + async def mock_set_operating_mode(sid, mode): + commands_sent.append(("set_mode", sid, mode)) + return True + + async def mock_send_battery_command(sid, active_mode, duration_min, charging_power_kw=None): + commands_sent.append(("battery_cmd", sid, active_mode, duration_min, charging_power_kw)) + return True + + api.set_operating_mode = mock_set_operating_mode + api.send_battery_command = mock_send_battery_command + + ok = run_async(api.apply_controls(system_id)) + assert ok is True, "apply_controls returned True" + + bat_cmds = [c for c in commands_sent if c[0] == "battery_cmd"] + assert len(bat_cmds) >= 1, "send_battery_command called" + assert bat_cmds[0][2] == SIGENERGY_ACTIVE_MODE_CHARGE, "Charge active mode sent" + + return failed + + +def test_sigenergy_apply_controls_eco_mode(my_predbat): + """Test apply_controls sends eco command when no window is active.""" + failed = False + api = MockSigenergyAPI() + system_id = "SIG001" + api.systems[system_id] = {"systemName": "Home", "batteryCapacity": 10.0} + api.devices[system_id] = [] + api.energy_flow[system_id] = {"batterySoc": 70.0} + api.controls[system_id] = { + "charge": {"enable": False, "start_time": "00:00", "end_time": "00:00", "target_soc": 100, "rate": 3000}, + "export": {"enable": False, "start_time": "00:00", "end_time": "00:00", "target_soc": 0, "rate": 3000}, + "reserve": 10, + } + + commands_sent = [] + + async def mock_set_operating_mode(sid, mode): + commands_sent.append(("set_mode", sid, mode)) + return True + + async def mock_send_battery_command(sid, active_mode, duration_min, charging_power_kw=None): + commands_sent.append(("battery_cmd", sid, active_mode, duration_min, charging_power_kw)) + return True + + api.set_operating_mode = mock_set_operating_mode + api.send_battery_command = mock_send_battery_command + + ok = run_async(api.apply_controls(system_id)) + assert ok is True, "apply_controls eco returned True" + + bat_cmds = [c for c in commands_sent if c[0] == "battery_cmd"] + assert len(bat_cmds) >= 1, "send_battery_command called for eco" + assert bat_cmds[0][2] == SIGENERGY_ACTIVE_MODE_SELF, "selfConsumption sent for eco" + + return failed + + +def test_sigenergy_apply_controls_deduplication(my_predbat): + """Test that apply_controls skips redundant commands within 15 minutes.""" + failed = False + api = MockSigenergyAPI() + system_id = "SIG001" + api.systems[system_id] = {"systemName": "Home", "batteryCapacity": 10.0} + api.devices[system_id] = [] + api.energy_flow[system_id] = {"batterySoc": 70.0} + api.controls[system_id] = { + "charge": {"enable": False, "start_time": "00:00", "end_time": "00:00", "target_soc": 100, "rate": 3000}, + "export": {"enable": False, "start_time": "00:00", "end_time": "00:00", "target_soc": 0, "rate": 3000}, + "reserve": 10, + } + + call_count = {"count": 0} + + async def mock_set_operating_mode(sid, mode): + call_count["count"] += 1 + return True + + async def mock_send_battery_command(sid, active_mode, duration_min, charging_power_kw=None): + call_count["count"] += 1 + return True + + api.set_operating_mode = mock_set_operating_mode + api.send_battery_command = mock_send_battery_command + + # First call + run_async(api.apply_controls(system_id)) + first_count = call_count["count"] + assert first_count >= 1, "Commands sent on first call" + + # Second call immediately after — mode unchanged, should skip + run_async(api.apply_controls(system_id)) + second_count = call_count["count"] + assert second_count == first_count, "No additional commands sent within 15-min dedup window (first={}, second={})".format(first_count, second_count) + + return failed + + +def test_sigenergy_apply_controls_export_mode(my_predbat): + """Test apply_controls sends discharge command during export window.""" + failed = False + api = MockSigenergyAPI() + system_id = "SIG001" + api.systems[system_id] = {"systemName": "Home", "batteryCapacity": 10.0} + api.devices[system_id] = [{"deviceType": "Battery", "attrMap": {"ratedChargePower": 3.0}}] + api.energy_flow[system_id] = {"batterySoc": 80.0} + + now = datetime.now(timezone.utc) + start_str = (now - timedelta(hours=1)).strftime("%H:%M") + end_str = (now + timedelta(hours=1)).strftime("%H:%M") + api.controls[system_id] = { + "charge": {"enable": False, "start_time": "00:00", "end_time": "00:00", "target_soc": 100, "rate": 3000}, + "export": {"enable": True, "start_time": start_str, "end_time": end_str, "target_soc": 10, "rate": 3000}, + "reserve": 10, + } + + commands_sent = [] + + async def mock_set_operating_mode(sid, mode): + commands_sent.append(("set_mode", sid, mode)) + return True + + async def mock_send_battery_command(sid, active_mode, duration_min, charging_power_kw=None): + commands_sent.append(("battery_cmd", sid, active_mode, duration_min, charging_power_kw)) + return True + + api.set_operating_mode = mock_set_operating_mode + api.send_battery_command = mock_send_battery_command + + ok = run_async(api.apply_controls(system_id)) + assert ok is True, "apply_controls export returned True" + + bat_cmds = [c for c in commands_sent if c[0] == "battery_cmd"] + assert len(bat_cmds) >= 1, "send_battery_command called for export" + assert bat_cmds[0][2] == SIGENERGY_ACTIVE_MODE_DISCHARGE, "discharge mode sent for export" + + return failed + + +# --------------------------------------------------------------------------- +# MQTT tests +# --------------------------------------------------------------------------- + + +def _make_mock_aiomqtt_client(): + """Create a mock aiomqtt.Client context manager that records publishes.""" + publishes = [] + + mock_client = MagicMock() + mock_client.publishes = publishes + + async def mock_publish(topic, payload=None, qos=0, **kwargs): + publishes.append((topic, payload)) + + mock_client.publish = mock_publish + + async def client_aenter(*args, **kwargs): + return mock_client + + async def client_aexit(*args, **kwargs): + pass + + mock_client.__aenter__ = client_aenter + mock_client.__aexit__ = client_aexit + return mock_client + + +def test_sigenergy_publish_mqtt_success(my_predbat): + """Test _publish_mqtt connects to the broker and publishes JSON payload.""" + failed = False + api = MockSigenergyAPI() + # Use the real _publish_mqtt (not the mock override) by calling via super/direct + api.access_token = "tok123" + api.mqtt_host = "openapi-eu.sigencloud.com" # cspell:disable-line + api.mqtt_port = 8883 + + mock_client = _make_mock_aiomqtt_client() + + with patch("sigenergy.ssl.create_default_context", return_value=MagicMock()): + with patch("sigenergy.aiomqtt.Client", return_value=mock_client): + ok = run_async(SigenergyAPI._publish_mqtt(api, "openapi/instruction/command", {"activeMode": "charge", "systemId": "SIG1"})) + + assert ok is True, "_publish_mqtt should return True on success" + assert len(mock_client.publishes) == 1, "Exactly one publish call expected" + topic, payload = mock_client.publishes[0] + assert topic == "openapi/instruction/command", "Topic correct" + import json + decoded = json.loads(payload) + assert decoded["activeMode"] == "charge", "Payload content correct" + assert decoded["systemId"] == "SIG1", "systemId in payload" + + return failed + + +def test_sigenergy_publish_mqtt_failure(my_predbat): + """Test _publish_mqtt returns False when the broker connection raises.""" + failed = False + api = MockSigenergyAPI() + api.access_token = "tok123" + api.mqtt_host = "openapi-eu.sigencloud.com" # cspell:disable-line + api.mqtt_port = 8883 + + def raise_error(*args, **kwargs): + raise ConnectionRefusedError("broker unavailable") + + with patch("sigenergy.ssl.create_default_context", return_value=MagicMock()): + with patch("sigenergy.aiomqtt.Client", side_effect=raise_error): + ok = run_async(SigenergyAPI._publish_mqtt(api, "openapi/instruction/command", {})) + + assert ok is False, "_publish_mqtt should return False on connection error" + assert any("MQTT publish" in m and "failed" in m for m in api.log_messages), "Error logged on failure" + + return failed + + +def test_sigenergy_send_battery_command_mqtt(my_predbat): + """Test send_battery_command publishes the correct MQTT payload.""" + failed = False + api = MockSigenergyAPI() + api.access_token = "reused_token" + api.token_expires_at = 9_999_999_999 # token still valid + + published = [] + + async def mock_publish_mqtt(topic, payload_dict): + published.append((topic, payload_dict)) + return True + + api._publish_mqtt = mock_publish_mqtt + + ok = run_async(api.send_battery_command("SIG001", "charge", 60, charging_power_kw=3.5)) + + assert ok is True, "send_battery_command should return True" + assert len(published) == 1, "One MQTT publish expected" + topic, payload = published[0] + assert topic == "openapi/instruction/command", "Correct MQTT topic" + assert payload["accessToken"] == "reused_token", "Token in payload" + assert payload["systemId"] == "SIG001", "systemId in payload" + assert payload["activeMode"] == "charge", "activeMode in payload" + assert payload["duration"] == 60, "duration in payload" + assert abs(payload["chargingPower"] - 3.5) < 0.01, "chargingPower in payload" + + return failed + + +def test_sigenergy_send_battery_command_no_token(my_predbat): + """Test send_battery_command returns False when token cannot be obtained.""" + failed = False + api = MockSigenergyAPI() + # Force get_access_token to fail by returning None + api.access_token = None + api.token_expires_at = 0.0 + + # Patch get_access_token to always return None + async def mock_get_access_token(): + return None + + api.get_access_token = mock_get_access_token + + ok = run_async(api.send_battery_command("SIG001", "charge", 60, charging_power_kw=3.5)) + assert ok is False, "send_battery_command should return False when no token" + assert any("No access token" in m for m in api.log_messages), "No-token error logged" + + return failed + + +# --------------------------------------------------------------------------- +# Test registration entry point +# --------------------------------------------------------------------------- + + +def run_sigenergy_tests(my_predbat): + """Run all Sigenergy API unit tests. + + Returns: + False on success (all tests passed), True if any test failed. + """ + failed = False + tests = [ + ("helper_functions", test_sigenergy_helper_functions), + ("initialize", test_sigenergy_initialize), + ("system_slug", test_sigenergy_system_slug), + ("battery_capacity", test_sigenergy_battery_capacity), + ("publish_system_entities", test_sigenergy_publish_system_entities), + ("automatic_config", test_sigenergy_automatic_config), + ("fetch_controls", test_sigenergy_fetch_controls), + ("publish_controls", test_sigenergy_publish_controls), + ("parse_entity_system", test_sigenergy_parse_entity_system), + ("apply_service_to_toggle", test_sigenergy_apply_service_to_toggle), + ("get_access_token_success", test_sigenergy_get_access_token_success), + ("get_access_token_failure", test_sigenergy_get_access_token_failure), + ("fetch_system_list", test_sigenergy_fetch_system_list), + ("fetch_system_list_with_filter", test_sigenergy_fetch_system_list_with_filter), + ("apply_controls_charge_mode", test_sigenergy_apply_controls_charge_mode), + ("apply_controls_eco_mode", test_sigenergy_apply_controls_eco_mode), + ("apply_controls_deduplication", test_sigenergy_apply_controls_deduplication), + ("apply_controls_export_mode", test_sigenergy_apply_controls_export_mode), + ("publish_mqtt_success", test_sigenergy_publish_mqtt_success), + ("publish_mqtt_failure", test_sigenergy_publish_mqtt_failure), + ("send_battery_command_mqtt", test_sigenergy_send_battery_command_mqtt), + ("send_battery_command_no_token", test_sigenergy_send_battery_command_no_token), + ] + + for name, fn in tests: + try: + result = fn(my_predbat) + if result: + print("FAIL: test_sigenergy_{}".format(name)) + failed = True + else: + print("PASS: test_sigenergy_{}".format(name)) + except (AssertionError, Exception) as e: + print("FAIL: test_sigenergy_{} — {}".format(name, e)) + import traceback + traceback.print_exc() + failed = True + + return failed diff --git a/apps/predbat/unit_test.py b/apps/predbat/unit_test.py index b7e1a77f9..2d94b0328 100644 --- a/apps/predbat/unit_test.py +++ b/apps/predbat/unit_test.py @@ -40,6 +40,7 @@ from tests.test_iboost import run_iboost_smart_tests from tests.test_alert_feed import test_alert_feed from tests.test_solax import run_solax_tests +from tests.test_sigenergy import run_sigenergy_tests from tests.test_single_debug import run_single_debug from tests.test_saving_session import test_saving_session, test_saving_session_null_octopoints, test_saving_session_notify_config, test_saving_session_default_rate from tests.test_secrets import run_secrets_tests @@ -245,6 +246,7 @@ def main(): ("solcast", run_solcast_tests, "Solcast API tests", False), ("open_meteo", run_open_meteo_tests, "Open-Meteo solar forecast provider tests", False), ("solax", run_solax_tests, "SolaX API tests", False), + ("sigenergy", run_sigenergy_tests, "Sigenergy Cloud API tests", False), ("iboost_smart", run_iboost_smart_tests, "iBoost smart tests", False), ("car_charging_smart", run_car_charging_smart_tests, "Car charging smart tests", False), ("intersect_window", run_intersect_window_tests, "Intersect window tests", False), From 602ef2a0ef46e4b2e8bda27f39a231c57ffa38bb Mon Sep 17 00:00:00 2001 From: Trefor Southwell Date: Sun, 10 May 2026 20:05:15 +0100 Subject: [PATCH 02/18] Sig fixes --- apps/predbat/sigenergy.py | 83 ++++++++++++++++++---------- apps/predbat/tests/test_sigenergy.py | 73 ++++++++++++++++++++++++ 2 files changed, 127 insertions(+), 29 deletions(-) diff --git a/apps/predbat/sigenergy.py b/apps/predbat/sigenergy.py index a03b99eb3..c31167569 100644 --- a/apps/predbat/sigenergy.py +++ b/apps/predbat/sigenergy.py @@ -68,7 +68,7 @@ # Constants # --------------------------------------------------------------------------- -SIGENERGY_DEFAULT_BASE_URL = "https://openapi.sigencloud.com" +SIGENERGY_DEFAULT_BASE_URL = "https://openapi-eu.sigencloud.com" # cspell:disable-line SIGENERGY_TIMEOUT = 20 # seconds per HTTP request SIGENERGY_MAX_RETRIES = 3 SIGENERGY_TOKEN_EXPIRY_BUFFER = 600 # refresh token 10 min before expiry @@ -139,6 +139,7 @@ def initialize(self, app_key, app_secret, base_url=None, system_id=None, automat published entity IDs on first run. enable_controls: When True, apply charge/discharge commands. """ + if not HAS_AIOHTTP: raise ImportError("SigenergyAPI requires the 'aiohttp' package: pip install aiohttp") if not HAS_AIOMQTT: @@ -200,6 +201,10 @@ async def get_access_token(self): The token is cached until within SIGENERGY_TOKEN_EXPIRY_BUFFER seconds of expiry (default 12 h lifetime). + Transient network errors (timeout, connection error, bad HTTP status) + are retried up to SIGENERGY_MAX_RETRIES times. API-level rejections + (wrong credentials, code != 0) are not retried as they are permanent. + Returns: Access token string on success, None on failure. """ @@ -213,36 +218,52 @@ async def get_access_token(self): payload = {"key": encoded_key} self.log("SigenergyAPI: Requesting new access token") - try: - timeout = aiohttp.ClientTimeout(total=SIGENERGY_TIMEOUT) - async with aiohttp.ClientSession(timeout=timeout) as session: - async with session.post(url, json=payload) as response: - if response.status != 200: - self.log("Warn: SigenergyAPI: Auth request returned HTTP {}".format(response.status)) - return None - try: - data = await response.json(content_type=None) - except (Exception) as e: - self.log("Warn: SigenergyAPI: Failed to decode auth response: {}".format(e)) - return None - code = data.get("code", -1) - if code != 0: - self.log("Warn: SigenergyAPI: Auth failed, code={}, msg={}".format(code, data.get("msg", "unknown"))) - self.access_token = None - return None - - token_data = data.get("data", {}) - self.access_token = token_data.get("accessToken") - expires_in = _safe_int(token_data.get("expiresIn", 43200), 43200) - self.token_expires_at = now + expires_in - self.log("SigenergyAPI: Token obtained, expires in {} s".format(expires_in)) - return self.access_token + for attempt in range(SIGENERGY_MAX_RETRIES): + try: + timeout = aiohttp.ClientTimeout(total=SIGENERGY_TIMEOUT) + async with aiohttp.ClientSession(timeout=timeout) as session: + async with session.post(url, json=payload) as response: + if response.status != 200: + self.log("Warn: SigenergyAPI: Auth request returned HTTP {} (attempt {}/{})".format(response.status, attempt + 1, SIGENERGY_MAX_RETRIES)) + if attempt < SIGENERGY_MAX_RETRIES - 1: + await asyncio.sleep(SIGENERGY_COMMAND_RETRY_DELAY) + continue + return None + try: + data = await response.json(content_type=None) + except Exception as e: + self.log("Warn: SigenergyAPI: Failed to decode auth response: {}".format(e)) + if attempt < SIGENERGY_MAX_RETRIES - 1: + await asyncio.sleep(SIGENERGY_COMMAND_RETRY_DELAY) + continue + return None - except (asyncio.TimeoutError, Exception) as e: - self.log("Warn: SigenergyAPI: Exception during authentication: {}".format(e)) - self.access_token = None - return None + code = data.get("code", -1) + if code != 0: + # Permanent API-level failure (e.g. wrong credentials) — do not retry + self.log("Warn: SigenergyAPI: Auth failed, code={}, msg={}".format(code, data.get("msg", "unknown"))) + self.access_token = None + return None + + token_data = data.get("data", {}) + self.access_token = token_data.get("accessToken") + expires_in = _safe_int(token_data.get("expiresIn", 43200), 43200) + self.token_expires_at = now + expires_in + self.log("SigenergyAPI: Token obtained, expires in {} s".format(expires_in)) + return self.access_token + + except asyncio.TimeoutError: + self.log("Warn: SigenergyAPI: Auth request timed out (attempt {}/{})".format(attempt + 1, SIGENERGY_MAX_RETRIES)) + if attempt < SIGENERGY_MAX_RETRIES - 1: + await asyncio.sleep(SIGENERGY_COMMAND_RETRY_DELAY) + except Exception as e: + self.log("Warn: SigenergyAPI: Exception during authentication: {}".format(e)) + if attempt < SIGENERGY_MAX_RETRIES - 1: + await asyncio.sleep(SIGENERGY_COMMAND_RETRY_DELAY) + + self.access_token = None + return None # ----------------------------------------------------------------------- # HTTP helpers @@ -1236,6 +1257,10 @@ async def run(self, seconds, first): """ if first: self.log("SigenergyAPI: First run — discovering systems") + token = await self.get_access_token() + if not token: + self.log("Warn: SigenergyAPI: Authentication failed — cannot proceed") + return False ok = await self.fetch_system_list() if not ok: self.log("Warn: SigenergyAPI: Failed to discover systems, will retry") diff --git a/apps/predbat/tests/test_sigenergy.py b/apps/predbat/tests/test_sigenergy.py index ec44efacd..913fd99a4 100644 --- a/apps/predbat/tests/test_sigenergy.py +++ b/apps/predbat/tests/test_sigenergy.py @@ -451,6 +451,77 @@ def test_sigenergy_get_access_token_failure(my_predbat): return failed +def test_sigenergy_get_access_token_retry(my_predbat): + """Test get_access_token retries on transient errors then succeeds.""" + failed = False + api = MockSigenergyAPI() + + attempt_count = {"n": 0} + + # First two calls raise a timeout; third succeeds + success_response = _make_mock_response(status=200, json_data={"code": 0, "data": {"accessToken": "retried_token", "expiresIn": 43200}}) + success_session = _make_mock_session(success_response) + + call_log = [] + + original_class = __import__("sigenergy").aiohttp.ClientSession + + class SequencedSession: + """Return failure sessions then success session.""" + def __init__(self, *args, **kwargs): + attempt_count["n"] += 1 + self._n = attempt_count["n"] + + async def __aenter__(self): + call_log.append(self._n) + if self._n < 3: + raise asyncio.TimeoutError() + return await success_session.__aenter__() + + async def __aexit__(self, *args): + if self._n >= 3: + await success_session.__aexit__(*args) + + with patch("sigenergy.aiohttp.ClientSession", SequencedSession): + token = run_async(api.get_access_token()) + + assert token == "retried_token", "Token returned after retry: {}".format(token) + assert attempt_count["n"] == 3, "Exactly 3 attempts made, got {}".format(attempt_count["n"]) + assert any("timed out" in m for m in api.log_messages), "Timeout warning logged" + + return failed + + +def test_sigenergy_get_access_token_no_retry_on_api_error(my_predbat): + """Test get_access_token does not retry after a permanent API rejection.""" + failed = False + api = MockSigenergyAPI() + + attempt_count = {"n": 0} + fake_response = {"code": 11003, "msg": "authentication failed"} + + mock_response = _make_mock_response(status=200, json_data=fake_response) + + class CountingSession: + """Count how many times a session is created.""" + def __init__(self, *args, **kwargs): + attempt_count["n"] += 1 + + async def __aenter__(self): + return await _make_mock_session(mock_response).__aenter__() + + async def __aexit__(self, *args): + pass + + with patch("sigenergy.aiohttp.ClientSession", CountingSession): + token = run_async(api.get_access_token()) + + assert token is None, "None returned on permanent API error" + assert attempt_count["n"] == 1, "Only one attempt made for API rejection, got {}".format(attempt_count["n"]) + + return failed + + def test_sigenergy_fetch_system_list(my_predbat): """Test fetch_system_list populates self.systems.""" failed = False @@ -823,6 +894,8 @@ def run_sigenergy_tests(my_predbat): ("apply_service_to_toggle", test_sigenergy_apply_service_to_toggle), ("get_access_token_success", test_sigenergy_get_access_token_success), ("get_access_token_failure", test_sigenergy_get_access_token_failure), + ("get_access_token_retry", test_sigenergy_get_access_token_retry), + ("get_access_token_no_retry_on_api_error", test_sigenergy_get_access_token_no_retry_on_api_error), ("fetch_system_list", test_sigenergy_fetch_system_list), ("fetch_system_list_with_filter", test_sigenergy_fetch_system_list_with_filter), ("apply_controls_charge_mode", test_sigenergy_apply_controls_charge_mode), From 6ae3330b269c0ca596270437e5659a842e243657 Mon Sep 17 00:00:00 2001 From: Trefor Southwell Date: Sat, 16 May 2026 12:50:26 +0100 Subject: [PATCH 03/18] Dev --- apps/predbat/sigenergy.py | 187 +++++++++++++++++++++++++-- apps/predbat/tests/test_sigenergy.py | 11 +- 2 files changed, 184 insertions(+), 14 deletions(-) diff --git a/apps/predbat/sigenergy.py b/apps/predbat/sigenergy.py index c31167569..623d5658b 100644 --- a/apps/predbat/sigenergy.py +++ b/apps/predbat/sigenergy.py @@ -70,12 +70,46 @@ SIGENERGY_DEFAULT_BASE_URL = "https://openapi-eu.sigencloud.com" # cspell:disable-line SIGENERGY_TIMEOUT = 20 # seconds per HTTP request -SIGENERGY_MAX_RETRIES = 3 +SIGENERGY_MAX_RETRIES = 5 # must be >= len(SIGENERGY_RATE_LIMIT_BACKOFF) for full backoff coverage +SIGENERGY_COMMAND_RETRY_DELAY = 2.0 + +# Sigenergy API response codes +SIGENERGY_CODE_SUCCESS = 0 +SIGENERGY_CODE_PARAM_ILLEGAL = 1000 +SIGENERGY_CODE_WRONG_SERIAL = 1101 +SIGENERGY_CODE_REGISTRATION_INCOMPLETE = 1102 +SIGENERGY_CODE_IN_OTHER_VPP = 1103 +SIGENERGY_CODE_DEVICE_OFFLINE = 1104 +SIGENERGY_CODE_SOFTWARE_NO_VPP = 1105 +SIGENERGY_CODE_STATION_NOT_FOUND = 1106 +SIGENERGY_CODE_AIO_INVERTER_ONLY = 1107 +SIGENERGY_CODE_STATION_INFO_NOT_FOUND = 1108 +SIGENERGY_CODE_RPC_FAIL = 1109 +SIGENERGY_CODE_INTERFACE_CURRENT_LIMITED = 1110 +SIGENERGY_CODE_STATION_NOT_PERMITTED = 1111 +SIGENERGY_CODE_IN_OTHER_VPP_EVERGEN = 1112 +SIGENERGY_CODE_ACCESS_RESTRICTION = 1201 +SIGENERGY_CODE_CLIENT_NOT_FOUND = 1301 +SIGENERGY_CODE_STATION_STATUS_ANOMALY = 1302 +SIGENERGY_CODE_CLIENT_EXISTS = 1303 +SIGENERGY_CODE_FIRMWARE_MISMATCH = 1304 +SIGENERGY_CODE_NO_PERMISSION_STATION = 1401 +SIGENERGY_CODE_NO_PERMISSION = 1402 +SIGENERGY_CODE_COMMAND_FAILED = 1501 +SIGENERGY_CODE_INTERNAL_ERROR = 1502 +SIGENERGY_CODE_ANTI_BACKFLOW_ENABLED = 1503 +SIGENERGY_CODE_PEAK_SHAVING_ENABLED = 1504 +SIGENERGY_CODE_INVITATION_INVALID = 1600 +SIGENERGY_CODE_ACCOUNT_SYSTEM_ERROR = 1601 +SIGENERGY_CODE_ACCOUNT_ALREADY_REGISTERED = 1602 +SIGENERGY_CODE_ACCOUNT_UNREVIEWED = 1603 +SIGENERGY_CODE_DEVELOPER_NOT_APPROVED = 1604 SIGENERGY_TOKEN_EXPIRY_BUFFER = 600 # refresh token 10 min before expiry SIGENERGY_MIN_REQUEST_INTERVAL = 6.0 # enforce ≥10 req/min API limit SIGENERGY_POLL_INTERVAL = 300 # realtime data poll every 5 minutes SIGENERGY_DEVICE_POLL_INTERVAL = 1800 # device list refresh every 30 minutes -SIGENERGY_COMMAND_RETRY_DELAY = 2.0 +SIGENERGY_RATE_LIMIT_BACKOFF = [15, 30, 60, 120, 480] # seconds to wait after code 1201 +SIGENERGY_BATTERY_NOMINAL_VOLTAGE_V = 28.8 # 8S LiFePO4 pack: 8 × 3.6V; used to convert ratedEnergy (Ah) → kWh SIGENERGY_MQTT_PORT = 8883 # TLS MQTT port on the Sigenergy broker # Operating mode enums (REST mode switch endpoint — MSC and FFG only; NBI is not used) @@ -247,6 +281,12 @@ async def get_access_token(self): return None token_data = data.get("data", {}) + if isinstance(token_data, str): + try: + token_data = json.loads(token_data) + except Exception as e: + self.log("Warn: SigenergyAPI: Failed to parse token data JSON: {}".format(e)) + return None self.access_token = token_data.get("accessToken") expires_in = _safe_int(token_data.get("expiresIn", 43200), 43200) self.token_expires_at = now + expires_in @@ -338,12 +378,39 @@ async def _request(self, method, path, params=None, json_data=None, retries=SIGE self.log("Warn: SigenergyAPI: Failed to decode response from {}: {}".format(path, e)) return None + self.log("SigenergyAPI: Response from {} {}: {}".format(method, path, body)) + code = body.get("code", -1) if code != 0: self.log("Warn: SigenergyAPI: API error code={} msg={} for {}".format(code, body.get("msg", ""), path)) + if code == SIGENERGY_CODE_ACCESS_RESTRICTION: + # Rate-limited — exponential backoff then retry + wait = SIGENERGY_RATE_LIMIT_BACKOFF[min(attempt, len(SIGENERGY_RATE_LIMIT_BACKOFF) - 1)] + self.log("Warn: SigenergyAPI: Rate limited (1201) — waiting {}s before retry (attempt {}/{})" .format(wait, attempt + 1, retries)) + if attempt < retries - 1: + await asyncio.sleep(wait) + continue return None - return body.get("data") + data = body.get("data") + # Some Sigenergy endpoints double-encode the data field as a JSON string + if isinstance(data, str): + try: + data = json.loads(data) + except Exception: + pass # Leave as string if it's not valid JSON + # Some endpoints return a list whose items are also JSON strings + if isinstance(data, list): + decoded = [] + for item in data: + if isinstance(item, str): + try: + item = json.loads(item) + except Exception: + pass + decoded.append(item) + data = decoded + return data except asyncio.TimeoutError: self.log("Warn: SigenergyAPI: Timeout on {} {} (attempt {}/{})".format(method, path, attempt + 1, retries)) @@ -371,6 +438,16 @@ async def fetch_system_list(self): self.log("Warn: SigenergyAPI: Failed to fetch system list") return False + self.log("SigenergyAPI: System list raw data type={} value={}".format(type(data).__name__, repr(data)[:200])) + + # data may be a JSON string (some Sigenergy endpoints double-encode), a list, or a dict + if isinstance(data, str): + try: + data = json.loads(data) + except Exception as e: + self.log("Warn: SigenergyAPI: Failed to parse system list JSON string: {}".format(e)) + return False + # data may be a list directly or wrapped in a dict systems = data if isinstance(data, list) else data.get("list", data.get("records", [])) if not isinstance(systems, list): @@ -412,6 +489,12 @@ async def fetch_device_list(self, system_id): return False self.devices[system_id] = devices + for device in devices: + if isinstance(device, dict) and isinstance(device.get("attrMap"), str): + try: + device["attrMap"] = json.loads(device["attrMap"]) + except Exception: + pass self.log("SigenergyAPI: System {} has {} device(s)".format(system_id, len(devices))) return True @@ -505,6 +588,50 @@ async def set_operating_mode(self, system_id, mode_int): self.log("SigenergyAPI: Operating mode set to {} for system {}".format(mode_int, system_id)) return True + async def onboard_systems(self, system_ids): + """Onboard one or more systems into the Sigenergy platform. + + Calls POST /openapi/board/onboard with a batch of system IDs. + + Args: + system_ids: A single system ID string or a list of system ID strings. + + Returns: + List of per-system result dicts on success (may be empty), or None on failure. + """ + if isinstance(system_ids, str): + system_ids = [system_ids] + #payload = {"systemIds": system_ids} + self.log("SigenergyAPI: Onboarding systems: {}".format(system_ids)) + result = await self._request("POST", "/openapi/board/onboard", json_data=system_ids) + if result is None: + self.log("Warn: SigenergyAPI: Onboard request failed for systems {}".format(system_ids)) + return None + self.log("SigenergyAPI: Onboard completed for {}: {}".format(system_ids, result)) + return result + + async def offboard_systems(self, system_ids): + """Offboard (remove) one or more systems from the Sigenergy platform. + + Calls POST /openapi/board/offboard with a batch of system IDs. + + Args: + system_ids: A single system ID string or a list of system ID strings. + + Returns: + List of per-system result dicts on success (may be empty), or None on failure. + """ + if isinstance(system_ids, str): + system_ids = [system_ids] + payload = {"systemIds": system_ids} + self.log("SigenergyAPI: Offboarding systems: {}".format(system_ids)) + result = await self._request("POST", "/openapi/board/offboard", json_data=payload) + if result is None: + self.log("Warn: SigenergyAPI: Offboard request failed for systems {}".format(system_ids)) + return None + self.log("SigenergyAPI: Offboard completed for {}: {}".format(system_ids, result)) + return result + async def _publish_mqtt(self, topic, payload_dict): """Publish a JSON payload to the Sigenergy MQTT broker. @@ -589,19 +716,22 @@ def _system_slug(self, system_id): def _get_battery_capacity_kwh(self, system_id): """Return the rated battery capacity in kWh for a system. - Prefers the batteryCapacity field from the system-list response. + Prefers the batteryCapacity field from the system-list response (already in kWh). Falls back to summing ratedEnergy from individual Battery devices. + The device-level ratedEnergy field is in Ah; multiply by the nominal pack voltage + (SIGENERGY_BATTERY_NOMINAL_VOLTAGE_V = 28.8 V for an 8S LiFePO4 pack) to convert to kWh. """ system_info = self.systems.get(system_id, {}) capacity = _safe_float(system_info.get("batteryCapacity", 0)) if capacity > 0: return capacity - # Fallback: sum device-level ratedEnergy + # Fallback: sum device-level ratedEnergy (Ah) converted to kWh for device in self.devices.get(system_id, []): if device.get("deviceType") == SIGENERGY_DEVICE_BATTERY: attr = device.get("attrMap", {}) - capacity += _safe_float(attr.get("ratedEnergy", 0)) + rated_ah = _safe_float(attr.get("ratedEnergy", 0)) + capacity += rated_ah * SIGENERGY_BATTERY_NOMINAL_VOLTAGE_V / 1000.0 return capacity def _get_battery_max_power_kw(self, system_id): @@ -1355,15 +1485,16 @@ def update_success_timestamp(self): """No-op success timestamp update.""" -async def test_sigenergy_api(app_key, app_secret, base_url, system_id, test_mode): # pragma: no cover - """Run one cycle of the Sigenergy API and optionally test a control mode. +async def test_sigenergy_api(app_key, app_secret, base_url, system_id, test_mode, action=None): # pragma: no cover + """Run one cycle of the Sigenergy API and optionally test a control mode or boarding action. Args: app_key: Sigenergy Application Key. app_secret: Sigenergy Application Secret. base_url: API base URL. - system_id: Optional system ID filter string. + system_id: Optional system ID filter string (required for onboard/offboard). test_mode: One of 'eco', 'charge', 'freeze_charge', 'export', 'freeze_export', or None. + action: One of 'onboard', 'offboard', or None. """ print("\n{}".format("=" * 60)) print("Testing Sigenergy Cloud API") @@ -1373,6 +1504,8 @@ async def test_sigenergy_api(app_key, app_secret, base_url, system_id, test_mode print("System ID filter: {}".format(system_id)) if test_mode: print("Test mode: {}".format(test_mode)) + if action: + print("Action: {}".format(action)) print("{}\n".format("=" * 60)) mock_base = MockBase() @@ -1387,6 +1520,27 @@ async def test_sigenergy_api(app_key, app_secret, base_url, system_id, test_mode enable_controls=(test_mode is not None), ) + # For boarding actions we only need the token, not a full system scan + if action in ("onboard", "offboard"): + if not system_id: + print("x --system-id is required for --{}\n".format(action)) + return 1 + token = await sig.get_access_token() + if not token: + print("x Authentication failed") + return 1 + print("+ Authentication successful") + if action == "onboard": + board_result = await sig.onboard_systems(system_id) + else: + board_result = await sig.offboard_systems(system_id) + if board_result is None: + print("x {} failed for system {}".format(action.capitalize(), system_id)) + return 1 + print("+ {} successful for system {}".format(action.capitalize(), system_id)) + print(" Results: {}".format(board_result)) + return 0 + result = await sig.run(first=True, seconds=0) if not result: print("x Initialisation failed") @@ -1490,8 +1644,21 @@ def main(): # pragma: no cover help="Control mode to test", ) + action_group = parser.add_mutually_exclusive_group() + action_group.add_argument( + "--onboard", + action="store_true", + help="Onboard the system specified by --system-id", + ) + action_group.add_argument( + "--offboard", + action="store_true", + help="Offboard (remove) the system specified by --system-id", + ) + args = parser.parse_args() - result = asyncio.run(test_sigenergy_api(args.app_key, args.app_secret, args.base_url, args.system_id, args.test_mode)) + action = "onboard" if args.onboard else ("offboard" if args.offboard else None) + result = asyncio.run(test_sigenergy_api(args.app_key, args.app_secret, args.base_url, args.system_id, args.test_mode, action)) raise SystemExit(result or 0) diff --git a/apps/predbat/tests/test_sigenergy.py b/apps/predbat/tests/test_sigenergy.py index 913fd99a4..d0fa1ed45 100644 --- a/apps/predbat/tests/test_sigenergy.py +++ b/apps/predbat/tests/test_sigenergy.py @@ -236,14 +236,17 @@ def test_sigenergy_battery_capacity(my_predbat): api.systems["sys1"] = {"batteryCapacity": 12.5} assert api._get_battery_capacity_kwh("sys1") == 12.5, "Capacity from system info" - # Fallback to device attrMap + # Fallback to device attrMap — ratedEnergy is in Ah, converted via nominal voltage 28.8V + # e.g. 314 Ah × 28.8V / 1000 = 9.0432 kWh per battery api.systems["sys2"] = {} api.devices["sys2"] = [ - {"deviceType": "Battery", "attrMap": {"ratedEnergy": 6.5}}, - {"deviceType": "Battery", "attrMap": {"ratedEnergy": 6.5}}, + {"deviceType": "Battery", "attrMap": {"ratedEnergy": 314}}, + {"deviceType": "Battery", "attrMap": {"ratedEnergy": 314}}, {"deviceType": "Inverter", "attrMap": {"ratedActivePower": 5.0}}, ] - assert api._get_battery_capacity_kwh("sys2") == 13.0, "Capacity summed from Battery devices" + expected_kwh = 2 * 314 * 28.8 / 1000 # = 18.0864 + actual_kwh = api._get_battery_capacity_kwh("sys2") + assert abs(actual_kwh - expected_kwh) < 0.001, "Capacity summed from Battery devices, expected {:.4f} got {:.4f}".format(expected_kwh, actual_kwh) return failed From 040313c8d51375e317105bc0ec2bb49518553332 Mon Sep 17 00:00:00 2001 From: Trefor Southwell Date: Sat, 16 May 2026 14:59:20 +0100 Subject: [PATCH 04/18] Dev --- apps/predbat/sigenergy.py | 229 +++++++++++++++++++++++++-- apps/predbat/tests/test_sigenergy.py | 105 ++++++++++++ 2 files changed, 323 insertions(+), 11 deletions(-) diff --git a/apps/predbat/sigenergy.py b/apps/predbat/sigenergy.py index 623d5658b..ad5302765 100644 --- a/apps/predbat/sigenergy.py +++ b/apps/predbat/sigenergy.py @@ -76,6 +76,7 @@ # Sigenergy API response codes SIGENERGY_CODE_SUCCESS = 0 SIGENERGY_CODE_PARAM_ILLEGAL = 1000 +SIGENERGY_CODE_RPC_FAIL = 1001 SIGENERGY_CODE_WRONG_SERIAL = 1101 SIGENERGY_CODE_REGISTRATION_INCOMPLETE = 1102 SIGENERGY_CODE_IN_OTHER_VPP = 1103 @@ -88,6 +89,7 @@ SIGENERGY_CODE_INTERFACE_CURRENT_LIMITED = 1110 SIGENERGY_CODE_STATION_NOT_PERMITTED = 1111 SIGENERGY_CODE_IN_OTHER_VPP_EVERGEN = 1112 +SIGENERGY_CODE_SYSTEM_PENDING_REVIEW = 1116 # Guess, seems to give this until user approves onboarding SIGENERGY_CODE_ACCESS_RESTRICTION = 1201 SIGENERGY_CODE_CLIENT_NOT_FOUND = 1301 SIGENERGY_CODE_STATION_STATUS_ANOMALY = 1302 @@ -126,6 +128,7 @@ # Device type strings returned by the device-list endpoint SIGENERGY_DEVICE_INVERTER = "Inverter" +SIGENERGY_DEVICE_AIO = "AIO" SIGENERGY_DEVICE_BATTERY = "Battery" SIGENERGY_DEVICE_GATEWAY = "Gateway" SIGENERGY_DEVICE_METER = "Meter" @@ -340,6 +343,8 @@ async def _request(self, method, path, params=None, json_data=None, retries=SIGE "Content-Type": "application/json", } + self.log("Requesting {} {} with params={} json={}".format(method, path, params, json_data)) + for attempt in range(retries): await self._enforce_rate_limit() try: @@ -498,6 +503,95 @@ async def fetch_device_list(self, system_id): self.log("SigenergyAPI: System {} has {} device(s)".format(system_id, len(devices))) return True + def _get_inverter_serial(self, system_id): + """Return the serial number of the first Inverter (or AIO) device for a system. + + Args: + system_id: Sigenergy system unique identifier. + + Returns: + Serial number string, or None if not found. + """ + for device in self.devices.get(system_id, []): + dt = device.get("deviceType", "") + if dt in (SIGENERGY_DEVICE_INVERTER, SIGENERGY_DEVICE_AIO): + return device.get("serialNumber") + return None + + async def fetch_inverter_realtime(self, system_id): + """Fetch realtime data from the inverter realtimeInfo endpoint. + + Endpoint: GET /openapi/systems/{systemId}/devices/{serialNumber}/realtimeInfo + + Maps device-level fields to the same dict format as fetch_energy_flow so + that all downstream code (publish_system_entities, apply_controls) works + unchanged. Also updates daily_summary with pvEnergyDaily if present. + + Field sign conventions (realtimeInfo vs energyFlow): + batPower: realtimeInfo positive=discharging → energyFlow positive=charging (negated) + activePower: positive=generation/export → gridPower positive=export (same) + loadPower is derived as: pv + battery_discharge - grid_export + + Note: The API enforces a 5-minute access restriction per device, so this + should only be called at SIGENERGY_POLL_INTERVAL intervals (default 5 min). + + Args: + system_id: Sigenergy system unique identifier. + + Returns: + True on success, False on failure. + """ + serial = self._get_inverter_serial(system_id) + if not serial: + self.log("Warn: SigenergyAPI: No inverter device found for system {}".format(system_id)) + return False + + data = await self._request("GET", "/openapi/systems/{}/devices/{}/realtimeInfo".format(system_id, serial)) + if data is None: + self.log("Warn: SigenergyAPI: Failed to fetch inverter realtime info for {}".format(system_id)) + return False + + # data has systemId, serialNumber, deviceType, realTimeInfo + rt = data.get("realTimeInfo", data) + if isinstance(rt, str): + try: + rt = json.loads(rt) + except Exception: + pass + + bat_soc = _safe_float(rt.get("batSoc", 0)) + # batPower: appears to be negative for discharge, positive for charge — invert to match energyFlow convention + bat_power_kw = _safe_float(rt.get("batPower", 0)) + pv_power_kw = _safe_float(rt.get("pvPower", 0)) + # activePower: Active power of the inverter itself, positive is generation, negative is consumption. Generation includes discharging the battery. + active_power_kw = _safe_float(rt.get("activePower", 0)) + grid_power_kw = 0 # Unknown... + # Derive load: pv + battery_discharge - grid_export + # battery_discharge = -bat_power_kw when bat_power_kw < 0 + battery_discharge_kw = max(0.0, -bat_power_kw) + load_power_kw = max(0.0, pv_power_kw + battery_discharge_kw - grid_power_kw) + + flow = { + "batterySoc": bat_soc, + "batteryPower": bat_power_kw, + "pvPower": pv_power_kw, + "gridPower": grid_power_kw, + "loadPower": load_power_kw, + "evPower": 0.0, + } + self.energy_flow[system_id] = flow + + # Update daily summary from pvEnergyDaily if present + pv_daily = rt.get("pvEnergyDaily") + if pv_daily is not None: + if system_id not in self.daily_summary: + self.daily_summary[system_id] = {} + self.daily_summary[system_id]["dailyPowerGeneration"] = _safe_float(pv_daily) + + self.log("SigenergyAPI: System {} realtimeInfo — SOC {:.0f}% battery {:.2f}kW pv {:.2f}kW grid {:.2f}kW load {:.2f}kW".format( + system_id, bat_soc, bat_power_kw, pv_power_kw, grid_power_kw, load_power_kw)) + return True + async def fetch_energy_flow(self, system_id): """Fetch realtime energy-flow data for a system. @@ -1407,14 +1501,11 @@ async def run(self, seconds, first): await self.fetch_controls(sid) await self.publish_controls() - # Automatic configuration - if first and self.automatic: - await self.automatic_config() - # Realtime data refresh if first or seconds % SIGENERGY_POLL_INTERVAL == 0: for sid in list(self.systems.keys()): - await self.fetch_energy_flow(sid) + if not await self.fetch_inverter_realtime(sid): + await self.fetch_energy_flow(sid) await self.fetch_daily_summary(sid) # Publish entities @@ -1422,6 +1513,10 @@ async def run(self, seconds, first): for sid in list(self.systems.keys()): await self.publish_system_entities(sid) + # Automatic configuration + if first and self.automatic: + await self.automatic_config() + # Apply controls is_readonly = self.get_state_wrapper("switch.{}_set_read_only".format(self.prefix), default="off") == "on" if self.enable_controls and not is_readonly: @@ -1475,10 +1570,7 @@ def get_arg(self, key, default=None): def set_arg(self, key, value): """Print auto-config arg assignment.""" - if isinstance(value, list): - state = "[list of {} items]".format(len(value)) - else: - state = str(value) + state = str(value) print("Set arg {} = {}".format(key, state)) def update_success_timestamp(self): @@ -1627,6 +1719,112 @@ def _window(offset_start_min, offset_end_min): return 0 +async def test_mqtt_connection(app_key, app_secret, base_url, system_id=None, topic_filter="#"): # pragma: no cover + """Connect to the Sigenergy MQTT broker, request push data, and print all incoming messages. + + Authenticates using app_key/app_secret, then: + 1. Subscribes at MQTT protocol level to *topic_filter* to receive all broker messages. + 2. Publishes to ``openapi/subscription/period`` with the Sigenergy subscription + request payload (accessToken + systemIdList) so the broker starts pushing + periodic telemetry data. + 3. Prints every received message to stdout until the user presses Ctrl+C. + + The subscription payload follows the spec at + https://developer.sigencloud.com/user/api/document/45: + { "accessToken": "", "systemIdList": ["", ...] } + + Args: + app_key: Sigenergy Application Key. + app_secret: Sigenergy Application Secret. + base_url: REST API base URL (MQTT host is derived from this). + system_id: Optional system ID (or comma-separated list) to subscribe to. + When None, a full system scan is performed first to discover + all authorised system IDs. + topic_filter: MQTT protocol-level topic filter (default '#' = all topics). + + Returns: + 0 on clean exit, 1 on error. + """ + if not HAS_AIOMQTT: + print("x aiomqtt is not installed. Run: pip install aiomqtt") + return 1 + + print("\n{}".format("=" * 60)) + print("Sigenergy MQTT test mode") + print("Base URL : {}".format(base_url)) + print("App Key : {}...".format(app_key[:10] if len(app_key) >= 10 else app_key)) + print("Topic : {}".format(topic_filter)) + print("{}\n".format("=" * 60)) + + mock_base = MockBase() + sig = SigenergyAPI( + mock_base, + app_key=app_key, + app_secret=app_secret, + base_url=base_url, + system_id=system_id, + ) + + token = await sig.get_access_token() + if not token: + print("x Authentication failed") + return 1 + print("+ Authentication successful") + + # Resolve system ID list for the subscription request + if system_id: + system_id_list = [s.strip() for s in system_id.split(",") if s.strip()] + else: + print("+ No --system-id provided; scanning for authorised systems ...") + await sig.fetch_system_list() + system_id_list = list(sig.systems.keys()) + if not system_id_list: + print("x No systems found; cannot build subscription request") + return 1 + print("+ Found systems: {}".format(system_id_list)) + + mqtt_host = sig.mqtt_host + print("+ Connecting to MQTT broker {}:{} (TLS) ...".format(mqtt_host, SIGENERGY_MQTT_PORT)) + + tls_context = ssl.create_default_context() + try: + async with aiomqtt.Client( + hostname=mqtt_host, + port=SIGENERGY_MQTT_PORT, + username=app_key, + password=token, + tls_context=tls_context, + keepalive=60, + ) as client: + # MQTT protocol-level subscribe so we receive everything the broker sends us + await client.subscribe(topic_filter) + print("+ MQTT subscribe to '{}' OK".format(topic_filter)) + + # Sigenergy application-level subscription: publish to openapi/subscription/period + # so the broker starts pushing periodic telemetry (spec doc 45) + sub_payload = {"accessToken": token, "systemIdList": system_id_list} + await client.publish("openapi/subscription/period", payload=json.dumps(sub_payload), qos=1) + print("+ Published subscription request for systems: {}".format(system_id_list)) + print(" Waiting for messages (Ctrl+C to stop) ...\n") + + async for message in client.messages: + payload_bytes = message.payload if isinstance(message.payload, (bytes, bytearray)) else str(message.payload).encode() + payload_str = payload_bytes.decode("utf-8", errors="replace") + try: + payload_display = json.dumps(json.loads(payload_str), indent=2) + except (json.JSONDecodeError, ValueError): + payload_display = payload_str + print("[{}] Topic: {}".format(datetime.now().strftime("%H:%M:%S.%f")[:-3], message.topic)) + print(" QoS: {} Retain: {}".format(message.qos, message.retain)) + print(" Payload: {}".format(payload_display)) + print() + except KeyboardInterrupt: + pass + + print("\n+ MQTT test session ended") + return 0 + + def main(): # pragma: no cover """Main entry point for standalone testing.""" parser = argparse.ArgumentParser( @@ -1643,6 +1841,7 @@ def main(): # pragma: no cover choices=["eco", "charge", "freeze_charge", "export", "freeze_export"], help="Control mode to test", ) + parser.add_argument("--mqtt-topic", default="#", help="MQTT topic filter used with --mqtt-test (default: '#' = all topics)") action_group = parser.add_mutually_exclusive_group() action_group.add_argument( @@ -1655,10 +1854,18 @@ def main(): # pragma: no cover action="store_true", help="Offboard (remove) the system specified by --system-id", ) + action_group.add_argument( + "--mqtt-test", + action="store_true", + help="Connect to the MQTT broker and print all received messages until Ctrl+C", + ) args = parser.parse_args() - action = "onboard" if args.onboard else ("offboard" if args.offboard else None) - result = asyncio.run(test_sigenergy_api(args.app_key, args.app_secret, args.base_url, args.system_id, args.test_mode, action)) + if args.mqtt_test: + result = asyncio.run(test_mqtt_connection(args.app_key, args.app_secret, args.base_url, args.system_id, args.mqtt_topic)) + else: + action = "onboard" if args.onboard else ("offboard" if args.offboard else None) + result = asyncio.run(test_sigenergy_api(args.app_key, args.app_secret, args.base_url, args.system_id, args.test_mode, action)) raise SystemExit(result or 0) diff --git a/apps/predbat/tests/test_sigenergy.py b/apps/predbat/tests/test_sigenergy.py index d0fa1ed45..f3e93b53e 100644 --- a/apps/predbat/tests/test_sigenergy.py +++ b/apps/predbat/tests/test_sigenergy.py @@ -872,6 +872,108 @@ async def mock_get_access_token(): return failed +def test_sigenergy_fetch_inverter_realtime(my_predbat): + """Test fetch_inverter_realtime maps realtimeInfo fields to energy_flow correctly.""" + failed = False + api = MockSigenergyAPI() + api.access_token = "fake_token" + api.token_expires_at = 9_999_999_999 + api._last_request_time = 0 + + # Populate a minimal device list with one Inverter + api.devices["SYS1"] = [ + {"deviceType": "Inverter", "serialNumber": "INV001"}, + {"deviceType": "Battery", "serialNumber": "BAT001"}, + ] + + # realtimeInfo response: batPower positive=discharging (3.0 kW discharging) + # activePower positive=export (1.5 kW export) + # pvPower 5.0 kW + # batSoc 72.0 % + # pvEnergyDaily 12.5 kWh + fake_response = { + "code": 0, + "data": { + "systemId": "SYS1", + "serialNumber": "INV001", + "deviceType": "Inverter", + "realTimeInfo": { + "batSoc": 72.0, + "batPower": 3.0, # discharging → batteryPower should be -3.0 + "pvPower": 5.0, + "activePower": 1.5, # export → gridPower = 1.5 + "pvEnergyDaily": 12.5, + }, + }, + } + + mock_response = _make_mock_response(status=200, json_data=fake_response) + mock_session = _make_mock_session(mock_response) + + with patch("sigenergy.aiohttp.ClientSession", return_value=mock_session): + ok = run_async(api.fetch_inverter_realtime("SYS1")) + + assert ok is True, "fetch_inverter_realtime should return True" + flow = api.energy_flow.get("SYS1", {}) + + assert flow.get("batterySoc") == 72.0, "batterySoc = 72.0" + # batPower was 3.0 (discharging) → batteryPower should be -3.0 (discharging in energyFlow convention) + assert flow.get("batteryPower") == -3.0, "batteryPower = -3.0 (discharging, sign negated)" + assert flow.get("pvPower") == 5.0, "pvPower = 5.0" + assert flow.get("gridPower") == 1.5, "gridPower = 1.5 (export)" + # loadPower = pv + battery_discharge - grid_export = 5.0 + 3.0 - 1.5 = 6.5 + assert flow.get("loadPower") == 6.5, "loadPower = 6.5 (derived)" + assert flow.get("evPower") == 0.0, "evPower = 0.0 (not available)" + + # pvEnergyDaily should update daily_summary + daily = api.daily_summary.get("SYS1", {}) + assert daily.get("dailyPowerGeneration") == 12.5, "daily PV yield updated from pvEnergyDaily" + + return failed + + +def test_sigenergy_fetch_inverter_realtime_no_inverter(my_predbat): + """Test fetch_inverter_realtime returns False when no inverter device is found.""" + failed = False + api = MockSigenergyAPI() + api.devices["SYS1"] = [ + {"deviceType": "Battery", "serialNumber": "BAT001"}, + ] + + ok = run_async(api.fetch_inverter_realtime("SYS1")) + assert ok is False, "Should return False when no inverter in device list" + assert any("No inverter" in m for m in api.log_messages), "Warning logged about missing inverter" + + return failed + + +def test_sigenergy_get_inverter_serial(my_predbat): + """Test _get_inverter_serial finds Inverter and AIO device types.""" + failed = False + api = MockSigenergyAPI() + + # No devices → None + api.devices["SYS1"] = [] + assert api._get_inverter_serial("SYS1") is None, "Empty device list returns None" + + # Only battery → None + api.devices["SYS1"] = [{"deviceType": "Battery", "serialNumber": "BAT001"}] + assert api._get_inverter_serial("SYS1") is None, "Battery-only list returns None" + + # Inverter type → found + api.devices["SYS1"] = [ + {"deviceType": "Battery", "serialNumber": "BAT001"}, + {"deviceType": "Inverter", "serialNumber": "INV001"}, + ] + assert api._get_inverter_serial("SYS1") == "INV001", "Inverter serial returned" + + # AIO type → found + api.devices["SYS2"] = [{"deviceType": "AIO", "serialNumber": "AIO001"}] + assert api._get_inverter_serial("SYS2") == "AIO001", "AIO serial returned" + + return failed + + # --------------------------------------------------------------------------- # Test registration entry point # --------------------------------------------------------------------------- @@ -909,6 +1011,9 @@ def run_sigenergy_tests(my_predbat): ("publish_mqtt_failure", test_sigenergy_publish_mqtt_failure), ("send_battery_command_mqtt", test_sigenergy_send_battery_command_mqtt), ("send_battery_command_no_token", test_sigenergy_send_battery_command_no_token), + ("fetch_inverter_realtime", test_sigenergy_fetch_inverter_realtime), + ("fetch_inverter_realtime_no_inverter", test_sigenergy_fetch_inverter_realtime_no_inverter), + ("get_inverter_serial", test_sigenergy_get_inverter_serial), ] for name, fn in tests: From 1e5bf7fc0ec2c82832f7bfc9643d2e63e5d7a6fd Mon Sep 17 00:00:00 2001 From: Trefor Southwell Date: Sun, 10 May 2026 18:37:45 +0100 Subject: [PATCH 05/18] Draft --- apps/predbat/components.py | 16 + apps/predbat/sigenergy.py | 1474 ++++++++++++++++++++++++++ apps/predbat/tests/test_sigenergy.py | 852 +++++++++++++++ apps/predbat/unit_test.py | 2 + 4 files changed, 2344 insertions(+) create mode 100644 apps/predbat/sigenergy.py create mode 100644 apps/predbat/tests/test_sigenergy.py diff --git a/apps/predbat/components.py b/apps/predbat/components.py index 6880453ce..bc2b82404 100644 --- a/apps/predbat/components.py +++ b/apps/predbat/components.py @@ -26,6 +26,7 @@ from temperature import TemperatureAPI from axle import AxleAPI from solax import SolaxAPI +from sigenergy import SigenergyAPI from solis import SolisAPI from alertfeed import AlertFeed from web import WebInterface @@ -345,6 +346,21 @@ "required_or": ["api_key", "managed_mode"], "phase": 1, }, + "sigenergy": { + "class": SigenergyAPI, + "name": "Sigenergy Cloud API", + "event_filter": "predbat_sigenergy_", + "args": { + "app_key": {"required": True, "config": "sigenergy_app_key"}, + "app_secret": {"required": True, "config": "sigenergy_app_secret"}, + "base_url": {"required": False, "config": "sigenergy_base_url", "default": "https://openapi-eu.sigencloud.com"}, + "system_id": {"required": False, "config": "sigenergy_system_id"}, + "automatic": {"required": False, "config": "sigenergy_automatic", "default": False}, + "enable_controls": {"required": False, "config": "sigenergy_enable_controls", "default": True}, + }, + "phase": 1, + "can_restart": True, + }, "solax": { "class": SolaxAPI, "name": "SolaX Cloud API", diff --git a/apps/predbat/sigenergy.py b/apps/predbat/sigenergy.py new file mode 100644 index 000000000..a03b99eb3 --- /dev/null +++ b/apps/predbat/sigenergy.py @@ -0,0 +1,1474 @@ +# ----------------------------------------------------------------------------- +# Predbat Home Battery System +# Copyright Trefor Southwell 2026 - All Rights Reserved +# This application maybe used for personal use only and not for commercial use +# ----------------------------------------------------------------------------- +# fmt: off +# pylint: disable=consider-using-f-string +# pylint: disable=line-too-long +# pylint: disable=attribute-defined-outside-init + +"""Sigenergy Cloud API integration component. + +REST polling client for Sigenergy inverters (Sigenstor) with OAuth2 Client +Credentials authentication. Publishes realtime energy-flow and battery state +to Home Assistant entities and issues charge/discharge/eco commands via the +Sigenergy MQTT broker. + +Authentication is via the Key-based endpoint: + POST /openapi/auth/login/key {"key": base64(AppKey:AppSecret)} + +Data endpoints used: + GET /openapi/system — system list + GET /openapi/system/{systemId}/devices — device inventory + GET /openapi/systems/{systemId}/energyFlow — realtime power & SOC + GET /openapi/systems/{systemId}/summary — daily/lifetime yield + GET /openapi/instruction/{systemId}/settings — current operating mode + +Control endpoints: + PUT /openapi/instruction/settings — switch operating mode (MSC/FFG) + MQTT openapi/instruction/command — charge / discharge / idle (via MQTT broker) + +The MQTT broker hostname is derived from the REST base URL (same host, port 8883, TLS). +Authentication to the MQTT broker uses app_key as username and the current +access_token as password. + +The component maps onto the existing 'SIG' inverter type already defined in +config.py so no changes are needed there. + +Registered in components.py under key 'sigenergy'. +""" + +import argparse +import asyncio +import base64 +import json +import ssl +import time +import traceback + +try: + import aiohttp + HAS_AIOHTTP = True +except ImportError: + aiohttp = None + HAS_AIOHTTP = False + +try: + import aiomqtt + HAS_AIOMQTT = True +except ImportError: + aiomqtt = None + HAS_AIOMQTT = False + +from datetime import datetime, timedelta +from component_base import ComponentBase + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +SIGENERGY_DEFAULT_BASE_URL = "https://openapi.sigencloud.com" +SIGENERGY_TIMEOUT = 20 # seconds per HTTP request +SIGENERGY_MAX_RETRIES = 3 +SIGENERGY_TOKEN_EXPIRY_BUFFER = 600 # refresh token 10 min before expiry +SIGENERGY_MIN_REQUEST_INTERVAL = 6.0 # enforce ≥10 req/min API limit +SIGENERGY_POLL_INTERVAL = 300 # realtime data poll every 5 minutes +SIGENERGY_DEVICE_POLL_INTERVAL = 1800 # device list refresh every 30 minutes +SIGENERGY_COMMAND_RETRY_DELAY = 2.0 +SIGENERGY_MQTT_PORT = 8883 # TLS MQTT port on the Sigenergy broker + +# Operating mode enums (REST mode switch endpoint — MSC and FFG only; NBI is not used) +SIGENERGY_MODE_MSC = 0 # Maximum Self-Consumption (eco) +SIGENERGY_MODE_FFG = 5 # Fully Feed-in to Grid +SIGENERGY_MODE_NBI = 8 # NorthBound (defined for completeness; not switched to by this component) + +# Battery command activeMode strings +SIGENERGY_ACTIVE_MODE_CHARGE = "charge" +SIGENERGY_ACTIVE_MODE_DISCHARGE = "discharge" +SIGENERGY_ACTIVE_MODE_IDLE = "idle" +SIGENERGY_ACTIVE_MODE_SELF = "selfConsumption" +SIGENERGY_ACTIVE_MODE_SELF_GRID = "selfConsumption-grid" + +# Device type strings returned by the device-list endpoint +SIGENERGY_DEVICE_INVERTER = "Inverter" +SIGENERGY_DEVICE_BATTERY = "Battery" +SIGENERGY_DEVICE_GATEWAY = "Gateway" +SIGENERGY_DEVICE_METER = "Meter" + +# Time options for schedule selects (HH:MM, one per minute) +_BASE_TIME = datetime.strptime("00:00", "%H:%M") +SIGENERGY_OPTIONS_TIME = [(_BASE_TIME + timedelta(seconds=m * 60)).strftime("%H:%M") for m in range(0, 24 * 60)] + + +def _safe_float(value, default=0.0): + """Convert value to float with a fallback default.""" + try: + return float(value) + except (TypeError, ValueError): + return default + + +def _safe_int(value, default=0): + """Convert value to int with a fallback default.""" + try: + return int(value) + except (TypeError, ValueError): + return default + + +class SigenergyAPI(ComponentBase): + """Sigenergy Cloud API component for Predbat. + + Polls the Sigenergy OpenAPI for realtime energy-flow data and battery + state, publishes Home Assistant entities, and applies charge/discharge + control commands on behalf of Predbat's planner. + """ + + def initialize(self, app_key, app_secret, base_url=None, system_id=None, automatic=False, enable_controls=True, **kwargs): + """Initialise the Sigenergy API component. + + Args: + app_key: Sigenergy Application Key (from Control Center → Settings). + app_secret: Sigenergy Application Secret. + base_url: Override the API base URL (default: SIGENERGY_DEFAULT_BASE_URL). + system_id: Optional system ID filter. When None all authorised + systems are used. When a string or list, only matching + systems are used. + automatic: When True, call set_arg() to wire Predbat config to the + published entity IDs on first run. + enable_controls: When True, apply charge/discharge commands. + """ + if not HAS_AIOHTTP: + raise ImportError("SigenergyAPI requires the 'aiohttp' package: pip install aiohttp") + if not HAS_AIOMQTT: + raise ImportError("SigenergyAPI requires the 'aiomqtt' package: pip install aiomqtt") + + self.app_key = app_key + self.app_secret = app_secret + self.base_url = (base_url or SIGENERGY_DEFAULT_BASE_URL).rstrip("/") + # Derive MQTT hostname from REST base URL (strip scheme, no port suffix needed) + self.mqtt_host = self.base_url.replace("https://", "").replace("http://", "").rstrip("/") + self.mqtt_port = SIGENERGY_MQTT_PORT + self.automatic = automatic + self.enable_controls = enable_controls + + # Normalise system_id filter to a set (empty = all systems) + if system_id is None: + self.system_id_filter = set() + elif isinstance(system_id, (list, tuple)): + self.system_id_filter = set(system_id) + else: + self.system_id_filter = {str(system_id)} + + # Token state + self.access_token = None + self.token_expires_at = 0.0 # UNIX timestamp + + # Data stores keyed by systemId + self.systems = {} # systemId → system info dict + self.devices = {} # systemId → list of device dicts + self.energy_flow = {} # systemId → latest energyFlow dict + self.daily_summary = {} # systemId → latest summary dict + self.current_mode = {} # systemId → energyStorageOperationMode int + + # Control state keyed by systemId + self.controls = {} # systemId → {charge: {…}, export: {…}, reserve: …} + + # Mode-change deduplication + self.current_mode_hash = {} # systemId → hash of last applied command + self.current_mode_hash_timestamp = {} # systemId → datetime of last applied command + + # Rate-limit tracking + self._last_request_time = 0.0 + + # Delay between mode-switch and battery command (seconds); set to 0 in tests + self._command_delay = 1.0 + + self.log("SigenergyAPI: Initialised, base_url={}".format(self.base_url)) + + # ----------------------------------------------------------------------- + # Authentication + # ----------------------------------------------------------------------- + + async def get_access_token(self): + """Obtain or refresh the access token. + + Uses the Sigenergy key-based authentication endpoint: + POST /openapi/auth/login/key {"key": base64(AppKey:AppSecret)} + + The token is cached until within SIGENERGY_TOKEN_EXPIRY_BUFFER seconds + of expiry (default 12 h lifetime). + + Returns: + Access token string on success, None on failure. + """ + now = time.monotonic() + if self.access_token and now < self.token_expires_at - SIGENERGY_TOKEN_EXPIRY_BUFFER: + return self.access_token + + raw_key = "{}:{}".format(self.app_key, self.app_secret) + encoded_key = base64.b64encode(raw_key.encode()).decode() + url = "{}/openapi/auth/login/key".format(self.base_url) + payload = {"key": encoded_key} + + self.log("SigenergyAPI: Requesting new access token") + try: + timeout = aiohttp.ClientTimeout(total=SIGENERGY_TIMEOUT) + async with aiohttp.ClientSession(timeout=timeout) as session: + async with session.post(url, json=payload) as response: + if response.status != 200: + self.log("Warn: SigenergyAPI: Auth request returned HTTP {}".format(response.status)) + return None + try: + data = await response.json(content_type=None) + except (Exception) as e: + self.log("Warn: SigenergyAPI: Failed to decode auth response: {}".format(e)) + return None + + code = data.get("code", -1) + if code != 0: + self.log("Warn: SigenergyAPI: Auth failed, code={}, msg={}".format(code, data.get("msg", "unknown"))) + self.access_token = None + return None + + token_data = data.get("data", {}) + self.access_token = token_data.get("accessToken") + expires_in = _safe_int(token_data.get("expiresIn", 43200), 43200) + self.token_expires_at = now + expires_in + self.log("SigenergyAPI: Token obtained, expires in {} s".format(expires_in)) + return self.access_token + + except (asyncio.TimeoutError, Exception) as e: + self.log("Warn: SigenergyAPI: Exception during authentication: {}".format(e)) + self.access_token = None + return None + + # ----------------------------------------------------------------------- + # HTTP helpers + # ----------------------------------------------------------------------- + + async def _enforce_rate_limit(self): + """Sleep if necessary to respect the 10 req/min API limit.""" + now = time.monotonic() + elapsed = now - self._last_request_time + if elapsed < SIGENERGY_MIN_REQUEST_INTERVAL: + await asyncio.sleep(SIGENERGY_MIN_REQUEST_INTERVAL - elapsed) + self._last_request_time = time.monotonic() + + async def _request(self, method, path, params=None, json_data=None, retries=SIGENERGY_MAX_RETRIES): + """Perform an authenticated HTTP request with retry logic. + + Args: + method: HTTP method string ('GET', 'POST', 'PUT'). + path: API path, e.g. '/openapi/system'. + params: URL query parameters dict. + json_data: Request body dict (serialised to JSON). + retries: Number of retry attempts. + + Returns: + Parsed 'data' field from the response JSON, or None on failure. + """ + token = await self.get_access_token() + if not token: + return None + + url = "{}{}".format(self.base_url, path) + headers = { + "Authorization": "Bearer {}".format(token), + "Content-Type": "application/json", + } + + for attempt in range(retries): + await self._enforce_rate_limit() + try: + timeout = aiohttp.ClientTimeout(total=SIGENERGY_TIMEOUT) + async with aiohttp.ClientSession(timeout=timeout) as session: + if method == "GET": + ctx = session.get(url, headers=headers, params=params) + elif method == "POST": + ctx = session.post(url, headers=headers, json=json_data) + elif method == "PUT": + ctx = session.put(url, headers=headers, json=json_data) + else: + self.log("Warn: SigenergyAPI: Unknown HTTP method {}".format(method)) + return None + + async with ctx as response: + if response.status == 401: + self.log("Warn: SigenergyAPI: 401 Unauthorised — refreshing token") + self.access_token = None + token = await self.get_access_token() + if not token: + return None + headers["Authorization"] = "Bearer {}".format(token) + continue + + if response.status not in (200, 201): + self.log("Warn: SigenergyAPI: HTTP {} for {} {}".format(response.status, method, path)) + if attempt < retries - 1: + await asyncio.sleep(SIGENERGY_COMMAND_RETRY_DELAY) + continue + return None + + try: + body = await response.json(content_type=None) + except Exception as e: + self.log("Warn: SigenergyAPI: Failed to decode response from {}: {}".format(path, e)) + return None + + code = body.get("code", -1) + if code != 0: + self.log("Warn: SigenergyAPI: API error code={} msg={} for {}".format(code, body.get("msg", ""), path)) + return None + + return body.get("data") + + except asyncio.TimeoutError: + self.log("Warn: SigenergyAPI: Timeout on {} {} (attempt {}/{})".format(method, path, attempt + 1, retries)) + if attempt < retries - 1: + await asyncio.sleep(SIGENERGY_COMMAND_RETRY_DELAY) + except Exception as e: + self.log("Warn: SigenergyAPI: Exception on {} {}: {}\n{}".format(method, path, e, traceback.format_exc())) + if attempt < retries - 1: + await asyncio.sleep(SIGENERGY_COMMAND_RETRY_DELAY) + + return None + + # ----------------------------------------------------------------------- + # Data fetching + # ----------------------------------------------------------------------- + + async def fetch_system_list(self): + """Fetch the list of authorised power stations. + + Returns: + True on success, False on failure. + """ + data = await self._request("GET", "/openapi/system") + if data is None: + self.log("Warn: SigenergyAPI: Failed to fetch system list") + return False + + # data may be a list directly or wrapped in a dict + systems = data if isinstance(data, list) else data.get("list", data.get("records", [])) + if not isinstance(systems, list): + self.log("Warn: SigenergyAPI: Unexpected system list format: {}".format(type(systems))) + return False + + for system in systems: + sid = system.get("systemId") + if not sid: + continue + if self.system_id_filter and sid not in self.system_id_filter: + continue + self.systems[sid] = system + self.log("SigenergyAPI: Found system {} ({})".format(sid, system.get("systemName", "unnamed"))) + + if not self.systems: + self.log("Warn: SigenergyAPI: No matching systems found (filter={})".format(self.system_id_filter or "all")) + return False + + return True + + async def fetch_device_list(self, system_id): + """Fetch the device inventory for a power station. + + Args: + system_id: Sigenergy system unique identifier. + + Returns: + True on success, False on failure. + """ + data = await self._request("GET", "/openapi/system/{}/devices".format(system_id)) + if data is None: + self.log("Warn: SigenergyAPI: Failed to fetch device list for {}".format(system_id)) + return False + + devices = data if isinstance(data, list) else data.get("list", data.get("records", [])) + if not isinstance(devices, list): + self.log("Warn: SigenergyAPI: Unexpected device list format for {}: {}".format(system_id, type(devices))) + return False + + self.devices[system_id] = devices + self.log("SigenergyAPI: System {} has {} device(s)".format(system_id, len(devices))) + return True + + async def fetch_energy_flow(self, system_id): + """Fetch realtime energy-flow data for a system. + + The API returns power values in kW with these sign conventions: + pvPower — always positive + gridPower — positive = export to grid, negative = import from grid + batteryPower — positive = charging, negative = discharging + loadPower — always positive + + Args: + system_id: Sigenergy system unique identifier. + + Returns: + True on success, False on failure. + """ + data = await self._request("GET", "/openapi/systems/{}/energyFlow".format(system_id)) + if data is None: + self.log("Warn: SigenergyAPI: Failed to fetch energy flow for {}".format(system_id)) + return False + + self.energy_flow[system_id] = data + soc = _safe_float(data.get("batterySoc", 0)) + battery_kw = _safe_float(data.get("batteryPower", 0)) + pv_kw = _safe_float(data.get("pvPower", 0)) + grid_kw = _safe_float(data.get("gridPower", 0)) + self.log("SigenergyAPI: System {} — SOC {:.0f}% battery {:.2f}kW pv {:.2f}kW grid {:.2f}kW".format(system_id, soc, battery_kw, pv_kw, grid_kw)) + return True + + async def fetch_daily_summary(self, system_id): + """Fetch daily/lifetime generation summary for a system. + + Args: + system_id: Sigenergy system unique identifier. + + Returns: + True on success, False on failure. + """ + data = await self._request("GET", "/openapi/systems/{}/summary".format(system_id)) + if data is None: + self.log("Warn: SigenergyAPI: Failed to fetch summary for {}".format(system_id)) + return False + + self.daily_summary[system_id] = data + return True + + async def fetch_current_mode(self, system_id): + """Fetch the current energy storage operating mode. + + Args: + system_id: Sigenergy system unique identifier. + + Returns: + True on success, False on failure. + """ + data = await self._request("GET", "/openapi/instruction/{}/settings".format(system_id)) + if data is None: + return False + + mode_int = _safe_int(data.get("energyStorageOperationMode", SIGENERGY_MODE_MSC)) + self.current_mode[system_id] = mode_int + return True + + # ----------------------------------------------------------------------- + # Control commands + # ----------------------------------------------------------------------- + + async def set_operating_mode(self, system_id, mode_int): + """Set the energy storage operating mode via REST. + + Args: + system_id: Sigenergy system unique identifier. + mode_int: Operating mode integer (SIGENERGY_MODE_MSC/FFG/NBI). + + Returns: + True on success, False on failure. + """ + payload = { + "systemId": system_id, + "energyStorageOperationMode": mode_int, + } + result = await self._request("PUT", "/openapi/instruction/settings", json_data=payload) + if result is None: + # Some implementations return an empty data field on success — treat None as success + # if the HTTP call didn't raise (the _request wrapper returns None for both API errors + # and non-zero code responses, but we can't distinguish here without more context). + self.log("SigenergyAPI: set_operating_mode({}) returned None — assuming success".format(mode_int)) + return True + self.log("SigenergyAPI: Operating mode set to {} for system {}".format(mode_int, system_id)) + return True + + async def _publish_mqtt(self, topic, payload_dict): + """Publish a JSON payload to the Sigenergy MQTT broker. + + Connects to the broker (same hostname as the REST base URL) using TLS + on port 8883. Authenticates with app_key as username and the current + access_token as password. A fresh connection is made for each publish + (Sigenergy commands are infrequent so persistent connection overhead is + unnecessary). + + Args: + topic: MQTT topic string. + payload_dict: Dict that will be serialised to JSON and published. + + Returns: + True on success, False on failure. + """ + try: + tls_context = ssl.create_default_context() + async with aiomqtt.Client( + hostname=self.mqtt_host, + port=self.mqtt_port, + username=self.app_key, + password=self.access_token, + tls_context=tls_context, + keepalive=30, + ) as client: + await client.publish(topic, payload=json.dumps(payload_dict), qos=1) + self.log("SigenergyAPI: MQTT published to {}".format(topic)) + return True + except Exception as e: + self.log("Warn: SigenergyAPI: MQTT publish to {} failed: {}".format(topic, e)) + return False + + async def send_battery_command(self, system_id, active_mode, duration_minutes, charging_power_kw=None): + """Send a battery command via MQTT to the Sigenergy broker. + + Publishes to the MQTT topic ``openapi/instruction/command``. A fresh + access token is obtained if needed before building the payload. + + Args: + system_id: Sigenergy system unique identifier. + active_mode: One of the SIGENERGY_ACTIVE_MODE_* string constants. + duration_minutes: Command duration in minutes (max ~720). + charging_power_kw: Charging/discharging power in kW. Required for + charge and discharge modes; optional otherwise. + + Returns: + True on success, False on failure. + """ + token = await self.get_access_token() + if not token: + self.log("Warn: SigenergyAPI: No access token for MQTT battery command") + return False + + payload = { + "accessToken": token, + "systemId": system_id, + "activeMode": active_mode, + "startTime": int(time.time()), + "duration": int(duration_minutes), + } + if charging_power_kw is not None: + payload["chargingPower"] = round(charging_power_kw, 2) + + self.log("SigenergyAPI: Sending MQTT battery command {} ({} min, {:.2f}kW) to system {}".format( + active_mode, duration_minutes, charging_power_kw or 0.0, system_id)) + + return await self._publish_mqtt("openapi/instruction/command", payload) + + # ----------------------------------------------------------------------- + # HA entity publishing + # ----------------------------------------------------------------------- + + def _system_slug(self, system_id): + """Return a short, safe slug for use in entity IDs. + + Uses the last 12 characters of the system ID (or the full string if + shorter) to keep entity names manageable. + """ + return str(system_id)[-12:].lower().replace("-", "_") + + def _get_battery_capacity_kwh(self, system_id): + """Return the rated battery capacity in kWh for a system. + + Prefers the batteryCapacity field from the system-list response. + Falls back to summing ratedEnergy from individual Battery devices. + """ + system_info = self.systems.get(system_id, {}) + capacity = _safe_float(system_info.get("batteryCapacity", 0)) + if capacity > 0: + return capacity + + # Fallback: sum device-level ratedEnergy + for device in self.devices.get(system_id, []): + if device.get("deviceType") == SIGENERGY_DEVICE_BATTERY: + attr = device.get("attrMap", {}) + capacity += _safe_float(attr.get("ratedEnergy", 0)) + return capacity + + def _get_battery_max_power_kw(self, system_id): + """Return the combined rated charge/discharge power in kW for a system.""" + # Prefer device-level ratedChargePower + power = 0.0 + for device in self.devices.get(system_id, []): + if device.get("deviceType") == SIGENERGY_DEVICE_BATTERY: + attr = device.get("attrMap", {}) + power += _safe_float(attr.get("ratedChargePower", 0)) + if power > 0: + return power + + # Fallback: inverter rated power + for device in self.devices.get(system_id, []): + if device.get("deviceType") == SIGENERGY_DEVICE_INVERTER: + attr = device.get("attrMap", {}) + power += _safe_float(attr.get("ratedActivePower", 0)) + return power + + def _get_inverter_max_power_kw(self, system_id): + """Return the combined inverter rated active power in kW.""" + power = 0.0 + for device in self.devices.get(system_id, []): + if device.get("deviceType") == SIGENERGY_DEVICE_INVERTER: + attr = device.get("attrMap", {}) + power += _safe_float(attr.get("ratedActivePower", 0)) + return power + + async def publish_system_entities(self, system_id): + """Publish Home Assistant entities for a system. + + Publishes realtime energy-flow data (battery SOC/power, PV power, + grid power, load power) and daily generation summary. + + Args: + system_id: Sigenergy system unique identifier. + """ + slug = self._system_slug(system_id) + system_info = self.systems.get(system_id, {}) + system_name = system_info.get("systemName", system_id) + flow = self.energy_flow.get(system_id, {}) + summary = self.daily_summary.get(system_id, {}) + + battery_soc_pct = _safe_float(flow.get("batterySoc", 0)) + # battery_power: API positive=charging, negative=discharging (same as Predbat convention, in kW) + battery_power_kw = _safe_float(flow.get("batteryPower", 0)) + pv_power_kw = _safe_float(flow.get("pvPower", 0)) + # gridPower: API positive=export, negative=import → invert for Predbat (positive=import) + grid_power_kw = -_safe_float(flow.get("gridPower", 0)) + load_power_kw = _safe_float(flow.get("loadPower", 0)) + ev_power_kw = _safe_float(flow.get("evPower", 0)) + + daily_yield_kwh = _safe_float(summary.get("dailyPowerGeneration", 0)) + monthly_yield_kwh = _safe_float(summary.get("monthlyPowerGeneration", 0)) + annual_yield_kwh = _safe_float(summary.get("annualPowerGeneration", 0)) + lifetime_yield_kwh = _safe_float(summary.get("lifetimePowerGeneration", 0)) + + capacity_kwh = self._get_battery_capacity_kwh(system_id) + battery_soc_kwh = round(battery_soc_pct * capacity_kwh / 100.0, 3) + battery_max_kw = self._get_battery_max_power_kw(system_id) + inverter_max_kw = self._get_inverter_max_power_kw(system_id) + + # --- Battery SOC (kWh) --- + self.dashboard_item( + "sensor.{}_sigenergy_{}_battery_soc".format(self.prefix, slug), + state=battery_soc_kwh, + attributes={ + "friendly_name": "Sigenergy {} Battery SOC".format(system_name), + "unit_of_measurement": "kWh", + "device_class": "energy", + "state_class": "measurement", + "soc_percent": battery_soc_pct, + "soc_max": capacity_kwh, + }, + app="sigenergy", + ) + + # --- Battery SOC percentage --- + self.dashboard_item( + "sensor.{}_sigenergy_{}_battery_soc_percent".format(self.prefix, slug), + state=battery_soc_pct, + attributes={ + "friendly_name": "Sigenergy {} Battery SOC %".format(system_name), + "unit_of_measurement": "%", + "device_class": "battery", + "state_class": "measurement", + }, + app="sigenergy", + ) + + # --- Battery power (W, positive=charging) --- + self.dashboard_item( + "sensor.{}_sigenergy_{}_battery_power".format(self.prefix, slug), + state=round(battery_power_kw * 1000), + attributes={ + "friendly_name": "Sigenergy {} Battery Power".format(system_name), + "unit_of_measurement": "W", + "device_class": "power", + "state_class": "measurement", + }, + app="sigenergy", + ) + + # --- PV power (W) --- + self.dashboard_item( + "sensor.{}_sigenergy_{}_pv_power".format(self.prefix, slug), + state=round(pv_power_kw * 1000), + attributes={ + "friendly_name": "Sigenergy {} PV Power".format(system_name), + "unit_of_measurement": "W", + "device_class": "power", + "state_class": "measurement", + }, + app="sigenergy", + ) + + # --- Grid power (W, positive=import from grid) --- + self.dashboard_item( + "sensor.{}_sigenergy_{}_grid_power".format(self.prefix, slug), + state=round(grid_power_kw * 1000), + attributes={ + "friendly_name": "Sigenergy {} Grid Power".format(system_name), + "unit_of_measurement": "W", + "device_class": "power", + "state_class": "measurement", + }, + app="sigenergy", + ) + + # --- Load power (W) --- + self.dashboard_item( + "sensor.{}_sigenergy_{}_load_power".format(self.prefix, slug), + state=round(load_power_kw * 1000), + attributes={ + "friendly_name": "Sigenergy {} Load Power".format(system_name), + "unit_of_measurement": "W", + "device_class": "power", + "state_class": "measurement", + }, + app="sigenergy", + ) + + # --- EV charger power (W) --- + self.dashboard_item( + "sensor.{}_sigenergy_{}_ev_power".format(self.prefix, slug), + state=round(ev_power_kw * 1000), + attributes={ + "friendly_name": "Sigenergy {} EV Power".format(system_name), + "unit_of_measurement": "W", + "device_class": "power", + "state_class": "measurement", + }, + app="sigenergy", + ) + + # --- Daily PV yield (kWh) --- + self.dashboard_item( + "sensor.{}_sigenergy_{}_pv_today".format(self.prefix, slug), + state=round(daily_yield_kwh, 3), + attributes={ + "friendly_name": "Sigenergy {} PV Today".format(system_name), + "unit_of_measurement": "kWh", + "device_class": "energy", + "state_class": "total_increasing", + }, + app="sigenergy", + ) + + # --- Battery capacity (kWh) --- + self.dashboard_item( + "sensor.{}_sigenergy_{}_battery_capacity".format(self.prefix, slug), + state=round(capacity_kwh, 3), + attributes={ + "friendly_name": "Sigenergy {} Battery Capacity".format(system_name), + "unit_of_measurement": "kWh", + "device_class": "energy", + }, + app="sigenergy", + ) + + # --- Battery max charge/discharge power (W) --- + self.dashboard_item( + "sensor.{}_sigenergy_{}_battery_rate_max".format(self.prefix, slug), + state=round(battery_max_kw * 1000), + attributes={ + "friendly_name": "Sigenergy {} Battery Max Power".format(system_name), + "unit_of_measurement": "W", + "device_class": "power", + }, + app="sigenergy", + ) + + # --- Inverter limit (W) --- + self.dashboard_item( + "sensor.{}_sigenergy_{}_inverter_limit".format(self.prefix, slug), + state=round(inverter_max_kw * 1000), + attributes={ + "friendly_name": "Sigenergy {} Inverter Limit".format(system_name), + "unit_of_measurement": "W", + "device_class": "power", + }, + app="sigenergy", + ) + + # --- System status --- + system_status = system_info.get("status", "Unknown") + self.dashboard_item( + "sensor.{}_sigenergy_{}_status".format(self.prefix, slug), + state=system_status, + attributes={ + "friendly_name": "Sigenergy {} Status".format(system_name), + "system_id": system_id, + "system_name": system_name, + "pv_capacity": system_info.get("pvCapacity"), + "battery_capacity_kwh": capacity_kwh, + "daily_yield_kwh": daily_yield_kwh, + "monthly_yield_kwh": monthly_yield_kwh, + "annual_yield_kwh": annual_yield_kwh, + "lifetime_yield_kwh": lifetime_yield_kwh, + }, + app="sigenergy", + ) + + # ----------------------------------------------------------------------- + # Automatic configuration + # ----------------------------------------------------------------------- + + async def automatic_config(self): + """Wire Predbat config args to the published Sigenergy entity IDs. + + Called once on the first run when self.automatic is True. Sets + inverter_type, soc_kw, battery_power, pv_power, grid_power, etc. so + that the core prediction engine can read the data it needs. + """ + system_ids = list(self.systems.keys()) + num = len(system_ids) + if not num: + self.log("Warn: SigenergyAPI: automatic_config called with no systems") + return + + self.log("SigenergyAPI: automatic_config — configuring {} system(s)".format(num)) + slugs = [self._system_slug(sid) for sid in system_ids] + + self.set_arg("num_inverters", num) + self.set_arg("inverter_type", ["SIG" for _ in range(num)]) + + self.set_arg("soc_kw", ["sensor.{}_sigenergy_{}_battery_soc".format(self.prefix, s) for s in slugs]) + self.set_arg("soc_max", ["sensor.{}_sigenergy_{}_battery_capacity".format(self.prefix, s) for s in slugs]) + self.set_arg("battery_power", ["sensor.{}_sigenergy_{}_battery_power".format(self.prefix, s) for s in slugs]) + self.set_arg("battery_rate_max", ["sensor.{}_sigenergy_{}_battery_rate_max".format(self.prefix, s) for s in slugs]) + self.set_arg("inverter_limit", ["sensor.{}_sigenergy_{}_inverter_limit".format(self.prefix, s) for s in slugs]) + self.set_arg("pv_power", ["sensor.{}_sigenergy_{}_pv_power".format(self.prefix, s) for s in slugs]) + self.set_arg("grid_power", ["sensor.{}_sigenergy_{}_grid_power".format(self.prefix, s) for s in slugs]) + self.set_arg("load_power", ["sensor.{}_sigenergy_{}_load_power".format(self.prefix, s) for s in slugs]) + self.set_arg("pv_today", ["sensor.{}_sigenergy_{}_pv_today".format(self.prefix, s) for s in slugs]) + + # Control entities + self.set_arg("charge_start_time", ["select.{}_sigenergy_{}_charge_start_time".format(self.prefix, s) for s in slugs]) + self.set_arg("charge_end_time", ["select.{}_sigenergy_{}_charge_end_time".format(self.prefix, s) for s in slugs]) + self.set_arg("charge_limit", ["number.{}_sigenergy_{}_charge_target_soc".format(self.prefix, s) for s in slugs]) + self.set_arg("scheduled_charge_enable", ["switch.{}_sigenergy_{}_charge_enable".format(self.prefix, s) for s in slugs]) + self.set_arg("charge_rate", ["number.{}_sigenergy_{}_charge_rate".format(self.prefix, s) for s in slugs]) + self.set_arg("discharge_start_time", ["select.{}_sigenergy_{}_export_start_time".format(self.prefix, s) for s in slugs]) + self.set_arg("discharge_end_time", ["select.{}_sigenergy_{}_export_end_time".format(self.prefix, s) for s in slugs]) + self.set_arg("discharge_target_soc", ["number.{}_sigenergy_{}_export_target_soc".format(self.prefix, s) for s in slugs]) + self.set_arg("scheduled_discharge_enable", ["switch.{}_sigenergy_{}_export_enable".format(self.prefix, s) for s in slugs]) + self.set_arg("discharge_rate", ["number.{}_sigenergy_{}_export_rate".format(self.prefix, s) for s in slugs]) + self.set_arg("reserve", ["number.{}_sigenergy_{}_reserve".format(self.prefix, s) for s in slugs]) + + self.log("SigenergyAPI: automatic_config complete") + + # ----------------------------------------------------------------------- + # Controls + # ----------------------------------------------------------------------- + + def _control_info(self, system_id, direction, field): + """Return metadata for a single control entity. + + Args: + system_id: System ID string. + direction: 'charge', 'export', or None (for global fields like reserve). + field: Field name string. + + Returns: + Tuple (item_name, ha_name, friendly_name, field_type, field_units, + default, min_value, max_value). + """ + slug = self._system_slug(system_id) + system_name = self.systems.get(system_id, {}).get("systemName", system_id) + field_type = "select" + field_units = None + default = None + min_value = None + max_value = None + + if direction is None: + item_name = "sigenergy_{}_{}".format(slug, field) + friendly_name = "Sigenergy {} {}".format(system_name, field.replace("_", " ").capitalize()) + else: + item_name = "sigenergy_{}_{}_{}".format(slug, direction, field) + friendly_name = "Sigenergy {} {} {}".format(system_name, direction.capitalize(), field.replace("_", " ").capitalize()) + + if "_time" in field: + default = "00:00" + field_type = "select" + field_units = "time" + elif field == "enable": + default = False + field_type = "switch" + elif field == "target_soc": + field_type = "number" + field_units = "%" + min_value = 0 + max_value = 100 + default = 100 if direction == "charge" else 0 + elif field == "rate": + battery_max_w = round(self._get_battery_max_power_kw(system_id) * 1000) + min_value = 0 + max_value = battery_max_w if battery_max_w > 0 else 10000 + default = max_value + field_type = "number" + field_units = "W" + elif field == "reserve": + min_value = 0 + max_value = 100 + default = 10 + field_type = "number" + field_units = "%" + + ha_name = "{}.{}_{}_{}".format(field_type, self.prefix, "sigenergy", item_name.replace("sigenergy_{}_".format(slug), slug + "_", 1)) + ha_name = "{}.{}_{}".format(field_type, self.prefix, item_name) + return item_name, ha_name, friendly_name, field_type, field_units, default, min_value, max_value + + async def fetch_controls(self, system_id): + """Read current control state from Home Assistant entities. + + Args: + system_id: System ID string. + """ + if system_id not in self.controls: + self.controls[system_id] = {} + + for direction in ("charge", "export"): + if direction not in self.controls[system_id]: + self.controls[system_id][direction] = {} + for field in ("start_time", "end_time", "enable", "target_soc", "rate"): + item_name, ha_name, friendly_name, field_type, field_units, default, min_value, max_value = self._control_info(system_id, direction, field) + state = self.get_state_wrapper(ha_name, default=default) + if field_type == "number": + state = _safe_int(state, default=default if default is not None else 0) + if min_value is not None: + state = max(min_value, state) + if max_value is not None: + state = min(max_value, state) + elif field_type == "switch": + if isinstance(state, str): + state = state.lower() == "on" + self.controls[system_id][direction][field] = state + + # Global fields + for field in ("reserve",): + item_name, ha_name, friendly_name, field_type, field_units, default, min_value, max_value = self._control_info(system_id, None, field) + state = self.get_state_wrapper(ha_name, default=default) + if field_type == "number": + state = _safe_int(state, default=default if default is not None else 0) + if min_value is not None: + state = max(min_value, state) + if max_value is not None: + state = min(max_value, state) + self.controls[system_id][field] = state + + async def publish_controls(self, system_id=None): + """Publish control entity states to the HA dashboard. + + Args: + system_id: Specific system ID to publish, or None for all systems. + """ + target_systems = [system_id] if system_id else list(self.controls.keys()) + + for sid in target_systems: + if sid not in self.controls: + continue + + for direction in ("charge", "export"): + for field in ("start_time", "end_time", "enable", "target_soc", "rate"): + item_name, ha_name, friendly_name, field_type, field_units, default, min_value, max_value = self._control_info(sid, direction, field) + value = self.controls[sid].get(direction, {}).get(field, default) + attributes = {"friendly_name": friendly_name} + if field_units: + attributes["unit_of_measurement"] = field_units + if min_value is not None: + attributes["min"] = min_value + if max_value is not None: + attributes["max"] = max_value + attributes["step"] = 1 + if "_time" in field: + attributes["options"] = SIGENERGY_OPTIONS_TIME + if field_type == "switch": + value = "on" if value else "off" + self.dashboard_item(ha_name, state=value, attributes=attributes, app="sigenergy") + + for field in ("reserve",): + item_name, ha_name, friendly_name, field_type, field_units, default, min_value, max_value = self._control_info(sid, None, field) + value = self.controls[sid].get(field, default) + self.dashboard_item( + ha_name, + state=value, + attributes={ + "friendly_name": friendly_name, + "unit_of_measurement": field_units, + "min": min_value, + "max": max_value, + "step": 1, + }, + app="sigenergy", + ) + + def _apply_service_to_toggle(self, current, service): + """Map a switch service call to a boolean.""" + if service == "turn_on": + return True + if service == "turn_off": + return False + if service == "toggle": + return not current + return current + + async def _update_control(self, entity_id, value, direction, field, system_id): + """Apply a single control update and re-publish.""" + if system_id not in self.controls: + self.log("Warn: SigenergyAPI: No controls for system {}".format(system_id)) + return + + item_name, ha_name, friendly_name, field_type, field_units, default, min_value, max_value = self._control_info(system_id, direction, field) + + if field == "enable": + current = self.controls[system_id].get(direction, {}).get(field, False) + value = self._apply_service_to_toggle(current, value) + elif "_time" in field: + if value not in SIGENERGY_OPTIONS_TIME: + self.log("Warn: SigenergyAPI: Invalid time value {} for {}".format(value, entity_id)) + return + elif field in ("target_soc", "rate"): + value = _safe_int(value, default=default if default is not None else 0) + if min_value is not None: + value = max(min_value, value) + if max_value is not None: + value = min(max_value, value) + + if direction: + if direction not in self.controls[system_id]: + self.controls[system_id][direction] = {} + self.controls[system_id][direction][field] = value + else: + self.controls[system_id][field] = value + + self.log("SigenergyAPI: Control update system={} direction={} field={} value={}".format(system_id, direction, field, value)) + await self.publish_controls(system_id) + + def _parse_entity_system(self, entity_id): + """Extract (system_id, direction, field) from a control entity ID. + + Entity ID format: + {domain}.{prefix}_sigenergy_{slug}_{direction}_{field} + {domain}.{prefix}_sigenergy_{slug}_{field} (for global controls) + """ + # Remove domain prefix + name = entity_id.split(".", 1)[-1] + # Remove predbat prefix + if name.startswith(self.prefix + "_"): + name = name[len(self.prefix) + 1:] + # Must start with 'sigenergy_' + if not name.startswith("sigenergy_"): + return None, None, None + name = name[len("sigenergy_"):] + + # Match slug to known systems + for sid in self.controls: + slug = self._system_slug(sid) + if name.startswith(slug + "_"): + rest = name[len(slug) + 1:] + # Try direction-field split + for direction in ("charge", "export"): + if rest.startswith(direction + "_"): + field = rest[len(direction) + 1:] + return sid, direction, field + # Global field + return sid, None, rest + + return None, None, None + + async def select_event(self, entity_id, value): + """Handle a HA select change event.""" + system_id, direction, field = self._parse_entity_system(entity_id) + if system_id: + await self._update_control(entity_id, value, direction, field, system_id) + + async def number_event(self, entity_id, value): + """Handle a HA number change event.""" + system_id, direction, field = self._parse_entity_system(entity_id) + if system_id: + await self._update_control(entity_id, value, direction, field, system_id) + + async def switch_event(self, entity_id, service): + """Handle a HA switch service call event.""" + system_id, direction, field = self._parse_entity_system(entity_id) + if system_id: + await self._update_control(entity_id, service, direction, field, system_id) + + # ----------------------------------------------------------------------- + # Control application + # ----------------------------------------------------------------------- + + async def apply_controls(self, system_id): + """Compute and apply the charge/discharge/eco command for a system. + + Inspects the current control state (charge/export window, target SOC, + power rate) and the latest battery SOC to decide which command to send. + Uses a hash to skip redundant API calls within 15 minutes. + + Args: + system_id: System ID string. + + Returns: + True on success, False on failure. + """ + if system_id not in self.controls: + self.log("Warn: SigenergyAPI: No controls for system {}".format(system_id)) + return False + + flow = self.energy_flow.get(system_id, {}) + battery_soc_pct = _safe_float(flow.get("batterySoc", 50)) + battery_max_kw = self._get_battery_max_power_kw(system_id) + + now = datetime.now(self.local_tz) + + charge_enable = self.controls[system_id].get("charge", {}).get("enable", False) + charge_start_str = self.controls[system_id].get("charge", {}).get("start_time", "00:00") + charge_end_str = self.controls[system_id].get("charge", {}).get("end_time", "00:00") + charge_target_soc = _safe_int(self.controls[system_id].get("charge", {}).get("target_soc", 100), 100) + charge_rate_w = _safe_int(self.controls[system_id].get("charge", {}).get("rate", round(battery_max_kw * 1000)), round(battery_max_kw * 1000)) + export_enable = self.controls[system_id].get("export", {}).get("enable", False) + export_start_str = self.controls[system_id].get("export", {}).get("start_time", "00:00") + export_end_str = self.controls[system_id].get("export", {}).get("end_time", "00:00") + export_target_soc = _safe_int(self.controls[system_id].get("export", {}).get("target_soc", 0), 0) + export_rate_w = _safe_int(self.controls[system_id].get("export", {}).get("rate", round(battery_max_kw * 1000)), round(battery_max_kw * 1000)) + reserve_soc = _safe_int(self.controls[system_id].get("reserve", 10), 10) + + def parse_window(start_str, end_str): + """Return (start_dt, end_dt) adjusted for midnight-spanning windows.""" + start_dt = now.replace(hour=int(start_str.split(":")[0]), minute=int(start_str.split(":")[1]), second=0, microsecond=0) + end_dt = now.replace(hour=int(end_str.split(":")[0]), minute=int(end_str.split(":")[1]), second=0, microsecond=0) + if end_dt <= start_dt: + if now <= end_dt: + start_dt -= timedelta(days=1) + else: + end_dt += timedelta(days=1) + return start_dt, end_dt + + charge_window = False + export_window = False + charge_start_dt = charge_end_dt = None + export_start_dt = export_end_dt = None + + if charge_enable: + charge_start_dt, charge_end_dt = parse_window(charge_start_str, charge_end_str) + if charge_start_dt <= now <= charge_end_dt: + charge_window = True + + if export_enable: + export_start_dt, export_end_dt = parse_window(export_start_str, export_end_str) + if export_start_dt <= now <= export_end_dt: + export_window = True + + # Determine desired mode (export takes priority) + if export_window and export_start_dt and export_end_dt: + duration_min = max(1, int((export_end_dt - now).total_seconds() / 60)) + effective_target = max(export_target_soc, reserve_soc) + if effective_target >= battery_soc_pct: + # Already at or below target — freeze (idle) + new_mode = "freeze_export" + active_mode = SIGENERGY_ACTIVE_MODE_IDLE + power_kw = 0.0 + else: + new_mode = "export" + active_mode = SIGENERGY_ACTIVE_MODE_DISCHARGE + power_kw = export_rate_w / 1000.0 + elif charge_window and charge_start_dt and charge_end_dt: + duration_min = max(1, int((charge_end_dt - now).total_seconds() / 60)) + effective_target = max(charge_target_soc, reserve_soc) + if effective_target <= reserve_soc or abs(effective_target - battery_soc_pct) < 1: + # Freeze charge — stay at current SOC + new_mode = "freeze_charge" + active_mode = SIGENERGY_ACTIVE_MODE_SELF + power_kw = 0.0 + elif effective_target < battery_soc_pct: + # Target below current — go to eco + new_mode = "eco" + active_mode = SIGENERGY_ACTIVE_MODE_SELF + power_kw = 0.0 + else: + new_mode = "charge" + active_mode = SIGENERGY_ACTIVE_MODE_CHARGE + power_kw = charge_rate_w / 1000.0 + else: + duration_min = 60 + new_mode = "eco" + active_mode = SIGENERGY_ACTIVE_MODE_SELF + power_kw = 0.0 + + duration_min = min(duration_min, 720) + + # Deduplication — skip if mode unchanged in last 15 minutes + new_hash = hash((new_mode, round(power_kw, 2), duration_min)) + old_hash = self.current_mode_hash.get(system_id) + old_ts = self.current_mode_hash_timestamp.get(system_id) + if old_hash is not None and old_hash == new_hash and old_ts is not None: + age = (now - old_ts).total_seconds() + if age < 15 * 60: + self.log("SigenergyAPI: Mode unchanged for system {} ({} — {:.1f} min ago), skipping".format(system_id, new_mode, age / 60)) + return True + + self.log("SigenergyAPI: Applying mode={} power={:.2f}kW duration={}min to system {}".format(new_mode, power_kw, duration_min, system_id)) + + # Send battery command via MQTT — no mode pre-switch required + ok = await self.send_battery_command(system_id, active_mode, duration_min, charging_power_kw=power_kw if power_kw > 0 else None) + success = ok + + if success: + self.current_mode_hash[system_id] = new_hash + self.current_mode_hash_timestamp[system_id] = now + + return success + + # ----------------------------------------------------------------------- + # Main run loop + # ----------------------------------------------------------------------- + + async def run(self, seconds, first): + """Main component loop called every 60 seconds by ComponentBase. + + First call: discover systems and devices, publish controls, run + automatic_config if enabled. + Every call: refresh realtime data, publish entities, apply controls. + + Args: + seconds: Elapsed seconds since component start. + first: True on the first call. + + Returns: + True on success, False on failure (triggers retry/backoff). + """ + if first: + self.log("SigenergyAPI: First run — discovering systems") + ok = await self.fetch_system_list() + if not ok: + self.log("Warn: SigenergyAPI: Failed to discover systems, will retry") + return False + + # Refresh device inventory periodically + if first or seconds % SIGENERGY_DEVICE_POLL_INTERVAL == 0: + for sid in list(self.systems.keys()): + await self.fetch_device_list(sid) + + # Fetch controls from HA on first run only + if first: + for sid in list(self.systems.keys()): + await self.fetch_controls(sid) + await self.publish_controls() + + # Automatic configuration + if first and self.automatic: + await self.automatic_config() + + # Realtime data refresh + if first or seconds % SIGENERGY_POLL_INTERVAL == 0: + for sid in list(self.systems.keys()): + await self.fetch_energy_flow(sid) + await self.fetch_daily_summary(sid) + + # Publish entities + if first or seconds % SIGENERGY_POLL_INTERVAL == 0: + for sid in list(self.systems.keys()): + await self.publish_system_entities(sid) + + # Apply controls + is_readonly = self.get_state_wrapper("switch.{}_set_read_only".format(self.prefix), default="off") == "on" + if self.enable_controls and not is_readonly: + if first or seconds % 60 == 0: + for sid in list(self.systems.keys()): + await self.apply_controls(sid) + else: + if first: + self.log("SigenergyAPI: Controls disabled or read-only mode active") + + self.update_success_timestamp() + return True + + +class MockBase: # pragma: no cover + """Mock base class for standalone testing.""" + + def __init__(self): + """Initialise mock base.""" + self.prefix = "predbat" + self.local_tz = datetime.now().astimezone().tzinfo + self.args = {} + self.entities = {} + + def get_state_wrapper(self, entity_id, default=None, attribute=None, refresh=False, required_unit=None, raw=None): + """Return entity state or default.""" + if raw: + return self.entities.get(entity_id, {}) + return self.entities.get(entity_id, {}).get("state", default) + + def set_state_wrapper(self, entity_id, state, attributes=None, app=None): + """Store entity state.""" + self.entities[entity_id] = {"state": state, "attributes": attributes or {}} + + def log(self, message): + """Print log message with timestamp.""" + print("[{}] {}".format(datetime.now().strftime("%H:%M:%S"), message)) + + def dashboard_item(self, entity_id, state=None, attributes=None, app=None): + """Print and store a dashboard entity.""" + import json + print("ENTITY: {} = {}".format(entity_id, state)) + if attributes: + display = {k: ("..." if k == "options" else v) for k, v in attributes.items()} + print(" Attributes: {}".format(json.dumps(display, indent=2, default=str))) + self.set_state_wrapper(entity_id, state, attributes) + + def get_arg(self, key, default=None): + """Return arg default (mock always returns default).""" + return default + + def set_arg(self, key, value): + """Print auto-config arg assignment.""" + if isinstance(value, list): + state = "[list of {} items]".format(len(value)) + else: + state = str(value) + print("Set arg {} = {}".format(key, state)) + + def update_success_timestamp(self): + """No-op success timestamp update.""" + + +async def test_sigenergy_api(app_key, app_secret, base_url, system_id, test_mode): # pragma: no cover + """Run one cycle of the Sigenergy API and optionally test a control mode. + + Args: + app_key: Sigenergy Application Key. + app_secret: Sigenergy Application Secret. + base_url: API base URL. + system_id: Optional system ID filter string. + test_mode: One of 'eco', 'charge', 'freeze_charge', 'export', 'freeze_export', or None. + """ + print("\n{}".format("=" * 60)) + print("Testing Sigenergy Cloud API") + print("Base URL: {}".format(base_url)) + print("App Key: {}...".format(app_key[:10] if len(app_key) >= 10 else app_key)) + if system_id: + print("System ID filter: {}".format(system_id)) + if test_mode: + print("Test mode: {}".format(test_mode)) + print("{}\n".format("=" * 60)) + + mock_base = MockBase() + + sig = SigenergyAPI( + mock_base, + app_key=app_key, + app_secret=app_secret, + base_url=base_url, + system_id=system_id, + automatic=True, + enable_controls=(test_mode is not None), + ) + + result = await sig.run(first=True, seconds=0) + if not result: + print("x Initialisation failed") + return 1 + print("+ Initialisation successful") + + if test_mode and sig.systems: + sid = list(sig.systems.keys())[0] + flow = sig.energy_flow.get(sid, {}) + battery_soc_pct = _safe_float(flow.get("batterySoc", 50)) + now = datetime.now(sig.local_tz) + + print("\n{}".format("=" * 60)) + print("Testing control mode: {}".format(test_mode)) + print("System ID: {} SOC: {:.0f}%".format(sid, battery_soc_pct)) + print("{}\n".format("=" * 60)) + + def _window(offset_start_min, offset_end_min): + """Return HH:MM strings offset from now.""" + s = (now + timedelta(minutes=offset_start_min)).strftime("%H:%M") + e = (now + timedelta(minutes=offset_end_min)).strftime("%H:%M") + return s, e + + battery_max_w = round(sig._get_battery_max_power_kw(sid) * 1000) or 5000 + + if test_mode == "eco": + sig.controls[sid] = { + "reserve": 10, + "charge": {"start_time": "23:00", "end_time": "23:30", "enable": False, "target_soc": 100, "rate": battery_max_w}, + "export": {"start_time": "23:30", "end_time": "23:59", "enable": False, "target_soc": 10, "rate": battery_max_w}, + } + print("+ Configured for ECO mode (no active windows)") + + elif test_mode == "charge": + cs, ce = _window(-30, 120) + sig.controls[sid] = { + "reserve": 10, + "charge": {"start_time": cs, "end_time": ce, "enable": True, "target_soc": 95, "rate": battery_max_w}, + "export": {"start_time": "23:30", "end_time": "23:59", "enable": False, "target_soc": 10, "rate": battery_max_w}, + } + print("+ Configured for CHARGE mode ({} - {}, target 95%)".format(cs, ce)) + + elif test_mode == "freeze_charge": + cs, ce = _window(-30, 120) + target = round(battery_soc_pct) # same as current = freeze + sig.controls[sid] = { + "reserve": 10, + "charge": {"start_time": cs, "end_time": ce, "enable": True, "target_soc": target, "rate": battery_max_w}, + "export": {"start_time": "23:30", "end_time": "23:59", "enable": False, "target_soc": 10, "rate": battery_max_w}, + } + print("+ Configured for FREEZE CHARGE mode ({} - {}, target=current {:.0f}%)".format(cs, ce, battery_soc_pct)) + + elif test_mode == "export": + es, ee = _window(-30, 120) + sig.controls[sid] = { + "reserve": 10, + "charge": {"start_time": "23:00", "end_time": "23:30", "enable": False, "target_soc": 100, "rate": battery_max_w}, + "export": {"start_time": es, "end_time": ee, "enable": True, "target_soc": 15, "rate": battery_max_w}, + } + print("+ Configured for EXPORT mode ({} - {}, target 15%)".format(es, ee)) + + elif test_mode == "freeze_export": + es, ee = _window(-30, 120) + target = min(100, round(battery_soc_pct) + 10) # above current = freeze + sig.controls[sid] = { + "reserve": 10, + "charge": {"start_time": "23:00", "end_time": "23:30", "enable": False, "target_soc": 100, "rate": battery_max_w}, + "export": {"start_time": es, "end_time": ee, "enable": True, "target_soc": target, "rate": battery_max_w}, + } + print("+ Configured for FREEZE EXPORT mode ({} - {}, current {:.0f}% target {}%)".format(es, ee, battery_soc_pct, target)) + + else: + print("x Unknown test mode: {}".format(test_mode)) + return 1 + + print("\nApplying controls...") + ok = await sig.apply_controls(sid) + if ok: + print("+ Controls applied successfully") + else: + print("x Controls application failed") + return 1 + + return 0 + + +def main(): # pragma: no cover + """Main entry point for standalone testing.""" + parser = argparse.ArgumentParser( + description="Test Sigenergy Cloud API and control modes", + epilog="Example: python sigenergy.py --app-key YOUR_KEY --app-secret YOUR_SECRET --test-mode charge", + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument("--app-key", required=True, help="Sigenergy Application Key") + parser.add_argument("--app-secret", required=True, help="Sigenergy Application Secret") + parser.add_argument("--base-url", default=SIGENERGY_DEFAULT_BASE_URL, help="API base URL (default: {})".format(SIGENERGY_DEFAULT_BASE_URL)) + parser.add_argument("--system-id", help="Optional system ID filter") + parser.add_argument( + "--test-mode", + choices=["eco", "charge", "freeze_charge", "export", "freeze_export"], + help="Control mode to test", + ) + + args = parser.parse_args() + result = asyncio.run(test_sigenergy_api(args.app_key, args.app_secret, args.base_url, args.system_id, args.test_mode)) + raise SystemExit(result or 0) + + +if __name__ == "__main__": # pragma: no cover + main() diff --git a/apps/predbat/tests/test_sigenergy.py b/apps/predbat/tests/test_sigenergy.py new file mode 100644 index 000000000..ec44efacd --- /dev/null +++ b/apps/predbat/tests/test_sigenergy.py @@ -0,0 +1,852 @@ +# ----------------------------------------------------------------------------- +# Predbat Home Battery System +# Copyright Trefor Southwell 2026 - All Rights Reserved +# This application maybe used for personal use only and not for commercial use +# ----------------------------------------------------------------------------- +# fmt: off +# pylint: disable=consider-using-f-string +# pylint: disable=line-too-long +# pylint: disable=attribute-defined-outside-init + +"""Unit tests for the Sigenergy Cloud API integration component.""" + +import asyncio +from datetime import datetime, timezone, timedelta +from unittest.mock import MagicMock, patch + +from sigenergy import ( + SigenergyAPI, + SIGENERGY_ACTIVE_MODE_CHARGE, + SIGENERGY_ACTIVE_MODE_DISCHARGE, + SIGENERGY_ACTIVE_MODE_SELF, + _safe_float, + _safe_int, +) +from tests.test_infra import run_async + + +def _make_mock_response(status=200, json_data=None): + """Create a mock aiohttp response that accepts json(content_type=...) kwargs.""" + mock_resp = MagicMock() + mock_resp.status = status + + async def return_json(*args, **kwargs): + return json_data or {} + + mock_resp.json = return_json + + async def aenter(*args, **kwargs): + return mock_resp + + async def aexit(*args, **kwargs): + pass + + mock_resp.__aenter__ = aenter + mock_resp.__aexit__ = aexit + return mock_resp + + +def _make_mock_session(mock_response): + """Create a mock aiohttp ClientSession for sigenergy tests (supports get/post/put).""" + mock_ctx = MagicMock() + + async def ctx_aenter(*args, **kwargs): + return mock_response + + async def ctx_aexit(*args, **kwargs): + pass + + mock_ctx.__aenter__ = ctx_aenter + mock_ctx.__aexit__ = ctx_aexit + + mock_session = MagicMock() + mock_session.get = MagicMock(return_value=mock_ctx) + mock_session.post = MagicMock(return_value=mock_ctx) + mock_session.put = MagicMock(return_value=mock_ctx) + + async def session_aenter(*args): + return mock_session + + async def session_aexit(*args): + pass + + mock_session.__aenter__ = session_aenter + mock_session.__aexit__ = session_aexit + return mock_session + + +# --------------------------------------------------------------------------- +# Mock class +# --------------------------------------------------------------------------- + + +class MockSigenergyAPI(SigenergyAPI): + """Minimal SigenergyAPI subclass that bypasses ComponentBase initialisation.""" + + def __init__(self, prefix="predbat"): + # Manually initialise attributes that ComponentBase would provide + self.prefix = prefix + self.local_tz = timezone.utc + self.log_messages = [] + self.dashboard_items = {} + self.set_args = {} + self.args = {} + + # Now call the SigenergyAPI initialize directly + self.initialize( + app_key="test_app_key", + app_secret="test_app_secret", + system_id=None, + automatic=False, + enable_controls=True, + ) + # Skip mode-switch → command delay in unit tests + self._command_delay = 0 + + def log(self, message): + """Capture log messages for assertion.""" + self.log_messages.append(message) + + def dashboard_item(self, entity_id, state=None, attributes=None, app=None): + """Capture dashboard item publishes.""" + self.dashboard_items[entity_id] = {"state": state, "attributes": attributes or {}} + + def get_state_wrapper(self, entity_id, default=None, attribute=None, refresh=False, required_unit=None, raw=None): + """Return dashboard state or default.""" + if entity_id in self.dashboard_items: + return self.dashboard_items[entity_id]["state"] + return default + + def set_state_wrapper(self, entity_id, state, attributes=None, app=None): + """Store state.""" + self.dashboard_items[entity_id] = {"state": state, "attributes": attributes or {}} + + def get_arg(self, key, default=None): + """Return stored arg or default.""" + return self.args.get(key, default) + + def set_arg(self, key, value): + """Capture set_arg calls.""" + self.set_args[key] = value + self.args[key] = value + + def update_success_timestamp(self): + """No-op for tests.""" + pass + + async def _publish_mqtt(self, topic, payload_dict): + """Mock MQTT publish — records calls and returns success.""" + if not hasattr(self, "mqtt_publishes"): + self.mqtt_publishes = [] + self.mqtt_publishes.append((topic, payload_dict)) + return True + + +# --------------------------------------------------------------------------- +# Helper +# --------------------------------------------------------------------------- + + +def run_async(coro): + """Run a coroutine synchronously for test purposes.""" + try: + loop = asyncio.get_event_loop() + except RuntimeError: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + return loop.run_until_complete(coro) + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +def test_sigenergy_helper_functions(my_predbat): + """Test _safe_float and _safe_int helper functions.""" + failed = False + + # _safe_float + assert _safe_float(3.14) == 3.14, "_safe_float: float passthrough" + assert _safe_float("2.5") == 2.5, "_safe_float: string to float" + assert _safe_float(None) == 0.0, "_safe_float: None → 0.0" + assert _safe_float("abc") == 0.0, "_safe_float: invalid string → 0.0" + assert _safe_float(None, default=99.0) == 99.0, "_safe_float: None with custom default" + + # _safe_int + assert _safe_int(42) == 42, "_safe_int: int passthrough" + assert _safe_int("7") == 7, "_safe_int: string to int" + assert _safe_int(None) == 0, "_safe_int: None → 0" + assert _safe_int("bad") == 0, "_safe_int: invalid → 0" + assert _safe_int(None, default=5) == 5, "_safe_int: None with custom default" + + return failed + + +def test_sigenergy_initialize(my_predbat): + """Test SigenergyAPI initialisation state.""" + failed = False + api = MockSigenergyAPI() + + assert api.app_key == "test_app_key", "app_key stored" + assert api.app_secret == "test_app_secret", "app_secret stored" + assert api.access_token is None, "No token initially" + assert api.token_expires_at == 0.0, "Token not yet obtained" + assert api.systems == {}, "No systems initially" + assert api.devices == {}, "No devices initially" + assert api.controls == {}, "No controls initially" + assert api.system_id_filter == set(), "No filter when system_id=None" + + # System ID filter — string + api2 = MockSigenergyAPI() + api2.initialize(app_key="k", app_secret="s", system_id="sys-1") + assert api2.system_id_filter == {"sys-1"}, "Single system ID filter" + + # System ID filter — list + api3 = MockSigenergyAPI() + api3.initialize(app_key="k", app_secret="s", system_id=["sys-1", "sys-2"]) + assert api3.system_id_filter == {"sys-1", "sys-2"}, "Multi system ID filter" + + return failed + + +def test_sigenergy_system_slug(my_predbat): + """Test _system_slug generates safe, short identifiers.""" + failed = False + api = MockSigenergyAPI() + + # Long ID → last 12 chars + slug = api._system_slug("ABCDEFGHIJKLMNOPQRSTUVWXYZ") + assert len(slug) <= 12, "Slug max 12 chars: {}".format(slug) + + # Hyphens replaced + api.systems["my-system-id"] = {"systemName": "Test"} + slug = api._system_slug("my-system-id") + assert "-" not in slug, "Hyphens removed: {}".format(slug) + + return failed + + +def test_sigenergy_battery_capacity(my_predbat): + """Test _get_battery_capacity_kwh falls back to device data.""" + failed = False + api = MockSigenergyAPI() + + # From system info + api.systems["sys1"] = {"batteryCapacity": 12.5} + assert api._get_battery_capacity_kwh("sys1") == 12.5, "Capacity from system info" + + # Fallback to device attrMap + api.systems["sys2"] = {} + api.devices["sys2"] = [ + {"deviceType": "Battery", "attrMap": {"ratedEnergy": 6.5}}, + {"deviceType": "Battery", "attrMap": {"ratedEnergy": 6.5}}, + {"deviceType": "Inverter", "attrMap": {"ratedActivePower": 5.0}}, + ] + assert api._get_battery_capacity_kwh("sys2") == 13.0, "Capacity summed from Battery devices" + + return failed + + +def test_sigenergy_publish_system_entities(my_predbat): + """Test publish_system_entities creates expected HA entities.""" + failed = False + api = MockSigenergyAPI() + + system_id = "SIG12345" + slug = api._system_slug(system_id) + api.systems[system_id] = {"systemName": "My Site", "batteryCapacity": 10.0, "status": "online"} + api.devices[system_id] = [{"deviceType": "Inverter", "attrMap": {"ratedActivePower": 5.0}}] + api.energy_flow[system_id] = { + "batterySoc": 60.0, + "batteryPower": 2.0, # kW charging + "pvPower": 3.5, # kW + "gridPower": 1.0, # kW export (positive=export, will be inverted to negative) + "loadPower": 4.5, + "evPower": 0.0, + } + api.daily_summary[system_id] = {"dailyPowerGeneration": 12.3} + + run_async(api.publish_system_entities(system_id)) + + soc_key = "sensor.predbat_sigenergy_{}_battery_soc".format(slug) + battery_key = "sensor.predbat_sigenergy_{}_battery_power".format(slug) + grid_key = "sensor.predbat_sigenergy_{}_grid_power".format(slug) + pv_key = "sensor.predbat_sigenergy_{}_pv_power".format(slug) + today_key = "sensor.predbat_sigenergy_{}_pv_today".format(slug) + + assert soc_key in api.dashboard_items, "Battery SOC entity published" + soc_kwh = api.dashboard_items[soc_key]["state"] + assert abs(soc_kwh - 6.0) < 0.01, "SOC kWh = 60% × 10kWh = 6.0, got {}".format(soc_kwh) + + assert battery_key in api.dashboard_items, "Battery power entity published" + assert api.dashboard_items[battery_key]["state"] == 2000, "Battery 2kW = 2000W" + + assert grid_key in api.dashboard_items, "Grid power entity published" + # API gridPower +1.0 (export) → Predbat −1000 W (import-negative) + assert api.dashboard_items[grid_key]["state"] == -1000, "Grid power inverted: export 1kW → -1000W" + + assert pv_key in api.dashboard_items, "PV power entity published" + assert api.dashboard_items[pv_key]["state"] == 3500, "PV 3.5kW = 3500W" + + assert today_key in api.dashboard_items, "PV today entity published" + assert abs(api.dashboard_items[today_key]["state"] - 12.3) < 0.01, "PV today correct" + + return failed + + +def test_sigenergy_automatic_config(my_predbat): + """Test automatic_config wires the expected Predbat args.""" + failed = False + api = MockSigenergyAPI() + api.automatic = True + + api.systems = {"SIG001": {"systemName": "Home"}, "SIG002": {"systemName": "Office"}} + + run_async(api.automatic_config()) + + assert "num_inverters" in api.set_args, "num_inverters set" + assert api.set_args["num_inverters"] == 2, "num_inverters == 2" + assert api.set_args.get("inverter_type") == ["SIG", "SIG"], "inverter_type wired" + assert "soc_kw" in api.set_args, "soc_kw wired" + assert "battery_power" in api.set_args, "battery_power wired" + assert "pv_power" in api.set_args, "pv_power wired" + assert "grid_power" in api.set_args, "grid_power wired" + + return failed + + +def test_sigenergy_fetch_controls(my_predbat): + """Test fetch_controls reads default values when entities have no state.""" + failed = False + api = MockSigenergyAPI() + system_id = "SIG001" + api.systems[system_id] = {"systemName": "Home", "batteryCapacity": 10.0} + api.devices[system_id] = [] + + run_async(api.fetch_controls(system_id)) + + assert system_id in api.controls, "Controls entry created" + assert "charge" in api.controls[system_id], "charge key present" + assert "export" in api.controls[system_id], "export key present" + assert api.controls[system_id]["charge"].get("enable") is False, "charge enable defaults off" + assert api.controls[system_id]["export"].get("enable") is False, "export enable defaults off" + + return failed + + +def test_sigenergy_publish_controls(my_predbat): + """Test publish_controls creates HA switch/select/number entities.""" + failed = False + api = MockSigenergyAPI() + system_id = "SIG001" + api.systems[system_id] = {"systemName": "Home", "batteryCapacity": 10.0} + api.devices[system_id] = [] + api.controls[system_id] = { + "charge": {"start_time": "01:00", "end_time": "05:00", "enable": False, "target_soc": 100, "rate": 2000}, + "export": {"start_time": "17:00", "end_time": "19:00", "enable": False, "target_soc": 20, "rate": 2000}, + "reserve": 10, + } + + run_async(api.publish_controls(system_id)) + + slug = api._system_slug(system_id) + charge_enable_key = "switch.predbat_sigenergy_{}_charge_enable".format(slug) + export_start_key = "select.predbat_sigenergy_{}_export_start_time".format(slug) + reserve_key = "number.predbat_sigenergy_{}_reserve".format(slug) + + assert charge_enable_key in api.dashboard_items, "Charge enable switch published: {}".format(charge_enable_key) + assert export_start_key in api.dashboard_items, "Export start time select published" + assert reserve_key in api.dashboard_items, "Reserve number published" + + return failed + + +def test_sigenergy_parse_entity_system(my_predbat): + """Test _parse_entity_system correctly decodes entity IDs.""" + failed = False + api = MockSigenergyAPI() + system_id = "SIG12345" + api.systems[system_id] = {} + api.controls[system_id] = {} + + slug = api._system_slug(system_id) + + entity_id = "switch.predbat_sigenergy_{}_charge_enable".format(slug) + sid, direction, field = api._parse_entity_system(entity_id) + assert sid == system_id, "System ID parsed: got {}".format(sid) + assert direction == "charge", "Direction parsed" + assert field == "enable", "Field parsed" + + entity_id2 = "number.predbat_sigenergy_{}_reserve".format(slug) + sid2, direction2, field2 = api._parse_entity_system(entity_id2) + assert sid2 == system_id, "System ID parsed for global field" + assert direction2 is None, "No direction for global field" + assert field2 == "reserve", "Global field name parsed" + + return failed + + +def test_sigenergy_apply_service_to_toggle(my_predbat): + """Test _apply_service_to_toggle correctly maps service strings.""" + failed = False + api = MockSigenergyAPI() + + assert api._apply_service_to_toggle(False, "turn_on") is True, "turn_on → True" + assert api._apply_service_to_toggle(True, "turn_off") is False, "turn_off → False" + assert api._apply_service_to_toggle(False, "toggle") is True, "toggle False → True" + assert api._apply_service_to_toggle(True, "toggle") is False, "toggle True → False" + assert api._apply_service_to_toggle(True, "unknown") is True, "unknown keeps current" + + return failed + + +def test_sigenergy_get_access_token_success(my_predbat): + """Test get_access_token caches the token on success.""" + failed = False + api = MockSigenergyAPI() + + fake_response = { + "code": 0, + "data": { + "accessToken": "test_token_abc", + "expiresIn": 43200, + "tokenType": "Bearer", + }, + } + + mock_response = _make_mock_response(status=200, json_data=fake_response) + mock_session = _make_mock_session(mock_response) + + with patch("sigenergy.aiohttp.ClientSession", return_value=mock_session): + token = run_async(api.get_access_token()) + + assert token == "test_token_abc", "Token returned: {}".format(token) + assert api.access_token == "test_token_abc", "Token cached" + assert api.token_expires_at > 0, "Expiry set" + + # Second call should use cache without hitting the network + token2 = run_async(api.get_access_token()) + assert token2 == "test_token_abc", "Cached token returned on second call" + + return failed + + +def test_sigenergy_get_access_token_failure(my_predbat): + """Test get_access_token returns None on API error.""" + failed = False + api = MockSigenergyAPI() + + fake_response = {"code": 10001, "msg": "Invalid key"} + + mock_response = _make_mock_response(status=200, json_data=fake_response) + mock_session = _make_mock_session(mock_response) + + with patch("sigenergy.aiohttp.ClientSession", return_value=mock_session): + token = run_async(api.get_access_token()) + + assert token is None, "None returned on API error" + assert api.access_token is None, "Token not cached on failure" + + return failed + + +def test_sigenergy_fetch_system_list(my_predbat): + """Test fetch_system_list populates self.systems.""" + failed = False + api = MockSigenergyAPI() + api.access_token = "fake_token" + api.token_expires_at = 9_999_999_999 + api._last_request_time = 0 # ensure no rate-limit delay + + fake_response = { + "code": 0, + "data": [ + {"systemId": "SIG001", "systemName": "Home", "batteryCapacity": 10.0, "pvCapacity": 6.0, "status": "online"}, + {"systemId": "SIG002", "systemName": "Office", "batteryCapacity": 20.0, "pvCapacity": 12.0, "status": "offline"}, + ], + } + + mock_response = _make_mock_response(status=200, json_data=fake_response) + mock_session = _make_mock_session(mock_response) + + with patch("sigenergy.aiohttp.ClientSession", return_value=mock_session): + ok = run_async(api.fetch_system_list()) + + assert ok is True, "fetch_system_list should return True, got {}".format(ok) + assert "SIG001" in api.systems, "SIG001 stored" + assert "SIG002" in api.systems, "SIG002 stored" + assert api.systems["SIG001"]["systemName"] == "Home", "System name correct" + + return failed + + +def test_sigenergy_fetch_system_list_with_filter(my_predbat): + """Test fetch_system_list respects system_id_filter.""" + failed = False + api = MockSigenergyAPI() + api.system_id_filter = {"SIG001"} + api.access_token = "fake_token" + api.token_expires_at = 9_999_999_999 + api._last_request_time = 0 + + fake_response = { + "code": 0, + "data": [ + {"systemId": "SIG001", "systemName": "Home", "batteryCapacity": 10.0}, + {"systemId": "SIG002", "systemName": "Office", "batteryCapacity": 20.0}, + ], + } + + mock_response = _make_mock_response(status=200, json_data=fake_response) + mock_session = _make_mock_session(mock_response) + + with patch("sigenergy.aiohttp.ClientSession", return_value=mock_session): + ok = run_async(api.fetch_system_list()) + + assert ok is True, "fetch_system_list should return True with filter, got {}".format(ok) + assert "SIG001" in api.systems, "Filtered system included" + assert "SIG002" not in api.systems, "Non-matching system excluded" + + return failed + + +def test_sigenergy_apply_controls_charge_mode(my_predbat): + """Test apply_controls selects charge command during active charge window.""" + failed = False + api = MockSigenergyAPI() + system_id = "SIG001" + api.systems[system_id] = {"systemName": "Home", "batteryCapacity": 10.0} + api.devices[system_id] = [{"deviceType": "Battery", "attrMap": {"ratedChargePower": 3.0}}] + + # SOC at 50%, charge window active now, target 90% + api.energy_flow[system_id] = {"batterySoc": 50.0} + now = datetime.now(timezone.utc) + start_str = (now - timedelta(hours=1)).strftime("%H:%M") + end_str = (now + timedelta(hours=2)).strftime("%H:%M") + api.controls[system_id] = { + "charge": {"enable": True, "start_time": start_str, "end_time": end_str, "target_soc": 90, "rate": 3000}, + "export": {"enable": False, "start_time": "00:00", "end_time": "00:00", "target_soc": 20, "rate": 3000}, + "reserve": 10, + } + + commands_sent = [] + + async def mock_set_operating_mode(sid, mode): + commands_sent.append(("set_mode", sid, mode)) + return True + + async def mock_send_battery_command(sid, active_mode, duration_min, charging_power_kw=None): + commands_sent.append(("battery_cmd", sid, active_mode, duration_min, charging_power_kw)) + return True + + api.set_operating_mode = mock_set_operating_mode + api.send_battery_command = mock_send_battery_command + + ok = run_async(api.apply_controls(system_id)) + assert ok is True, "apply_controls returned True" + + bat_cmds = [c for c in commands_sent if c[0] == "battery_cmd"] + assert len(bat_cmds) >= 1, "send_battery_command called" + assert bat_cmds[0][2] == SIGENERGY_ACTIVE_MODE_CHARGE, "Charge active mode sent" + + return failed + + +def test_sigenergy_apply_controls_eco_mode(my_predbat): + """Test apply_controls sends eco command when no window is active.""" + failed = False + api = MockSigenergyAPI() + system_id = "SIG001" + api.systems[system_id] = {"systemName": "Home", "batteryCapacity": 10.0} + api.devices[system_id] = [] + api.energy_flow[system_id] = {"batterySoc": 70.0} + api.controls[system_id] = { + "charge": {"enable": False, "start_time": "00:00", "end_time": "00:00", "target_soc": 100, "rate": 3000}, + "export": {"enable": False, "start_time": "00:00", "end_time": "00:00", "target_soc": 0, "rate": 3000}, + "reserve": 10, + } + + commands_sent = [] + + async def mock_set_operating_mode(sid, mode): + commands_sent.append(("set_mode", sid, mode)) + return True + + async def mock_send_battery_command(sid, active_mode, duration_min, charging_power_kw=None): + commands_sent.append(("battery_cmd", sid, active_mode, duration_min, charging_power_kw)) + return True + + api.set_operating_mode = mock_set_operating_mode + api.send_battery_command = mock_send_battery_command + + ok = run_async(api.apply_controls(system_id)) + assert ok is True, "apply_controls eco returned True" + + bat_cmds = [c for c in commands_sent if c[0] == "battery_cmd"] + assert len(bat_cmds) >= 1, "send_battery_command called for eco" + assert bat_cmds[0][2] == SIGENERGY_ACTIVE_MODE_SELF, "selfConsumption sent for eco" + + return failed + + +def test_sigenergy_apply_controls_deduplication(my_predbat): + """Test that apply_controls skips redundant commands within 15 minutes.""" + failed = False + api = MockSigenergyAPI() + system_id = "SIG001" + api.systems[system_id] = {"systemName": "Home", "batteryCapacity": 10.0} + api.devices[system_id] = [] + api.energy_flow[system_id] = {"batterySoc": 70.0} + api.controls[system_id] = { + "charge": {"enable": False, "start_time": "00:00", "end_time": "00:00", "target_soc": 100, "rate": 3000}, + "export": {"enable": False, "start_time": "00:00", "end_time": "00:00", "target_soc": 0, "rate": 3000}, + "reserve": 10, + } + + call_count = {"count": 0} + + async def mock_set_operating_mode(sid, mode): + call_count["count"] += 1 + return True + + async def mock_send_battery_command(sid, active_mode, duration_min, charging_power_kw=None): + call_count["count"] += 1 + return True + + api.set_operating_mode = mock_set_operating_mode + api.send_battery_command = mock_send_battery_command + + # First call + run_async(api.apply_controls(system_id)) + first_count = call_count["count"] + assert first_count >= 1, "Commands sent on first call" + + # Second call immediately after — mode unchanged, should skip + run_async(api.apply_controls(system_id)) + second_count = call_count["count"] + assert second_count == first_count, "No additional commands sent within 15-min dedup window (first={}, second={})".format(first_count, second_count) + + return failed + + +def test_sigenergy_apply_controls_export_mode(my_predbat): + """Test apply_controls sends discharge command during export window.""" + failed = False + api = MockSigenergyAPI() + system_id = "SIG001" + api.systems[system_id] = {"systemName": "Home", "batteryCapacity": 10.0} + api.devices[system_id] = [{"deviceType": "Battery", "attrMap": {"ratedChargePower": 3.0}}] + api.energy_flow[system_id] = {"batterySoc": 80.0} + + now = datetime.now(timezone.utc) + start_str = (now - timedelta(hours=1)).strftime("%H:%M") + end_str = (now + timedelta(hours=1)).strftime("%H:%M") + api.controls[system_id] = { + "charge": {"enable": False, "start_time": "00:00", "end_time": "00:00", "target_soc": 100, "rate": 3000}, + "export": {"enable": True, "start_time": start_str, "end_time": end_str, "target_soc": 10, "rate": 3000}, + "reserve": 10, + } + + commands_sent = [] + + async def mock_set_operating_mode(sid, mode): + commands_sent.append(("set_mode", sid, mode)) + return True + + async def mock_send_battery_command(sid, active_mode, duration_min, charging_power_kw=None): + commands_sent.append(("battery_cmd", sid, active_mode, duration_min, charging_power_kw)) + return True + + api.set_operating_mode = mock_set_operating_mode + api.send_battery_command = mock_send_battery_command + + ok = run_async(api.apply_controls(system_id)) + assert ok is True, "apply_controls export returned True" + + bat_cmds = [c for c in commands_sent if c[0] == "battery_cmd"] + assert len(bat_cmds) >= 1, "send_battery_command called for export" + assert bat_cmds[0][2] == SIGENERGY_ACTIVE_MODE_DISCHARGE, "discharge mode sent for export" + + return failed + + +# --------------------------------------------------------------------------- +# MQTT tests +# --------------------------------------------------------------------------- + + +def _make_mock_aiomqtt_client(): + """Create a mock aiomqtt.Client context manager that records publishes.""" + publishes = [] + + mock_client = MagicMock() + mock_client.publishes = publishes + + async def mock_publish(topic, payload=None, qos=0, **kwargs): + publishes.append((topic, payload)) + + mock_client.publish = mock_publish + + async def client_aenter(*args, **kwargs): + return mock_client + + async def client_aexit(*args, **kwargs): + pass + + mock_client.__aenter__ = client_aenter + mock_client.__aexit__ = client_aexit + return mock_client + + +def test_sigenergy_publish_mqtt_success(my_predbat): + """Test _publish_mqtt connects to the broker and publishes JSON payload.""" + failed = False + api = MockSigenergyAPI() + # Use the real _publish_mqtt (not the mock override) by calling via super/direct + api.access_token = "tok123" + api.mqtt_host = "openapi-eu.sigencloud.com" # cspell:disable-line + api.mqtt_port = 8883 + + mock_client = _make_mock_aiomqtt_client() + + with patch("sigenergy.ssl.create_default_context", return_value=MagicMock()): + with patch("sigenergy.aiomqtt.Client", return_value=mock_client): + ok = run_async(SigenergyAPI._publish_mqtt(api, "openapi/instruction/command", {"activeMode": "charge", "systemId": "SIG1"})) + + assert ok is True, "_publish_mqtt should return True on success" + assert len(mock_client.publishes) == 1, "Exactly one publish call expected" + topic, payload = mock_client.publishes[0] + assert topic == "openapi/instruction/command", "Topic correct" + import json + decoded = json.loads(payload) + assert decoded["activeMode"] == "charge", "Payload content correct" + assert decoded["systemId"] == "SIG1", "systemId in payload" + + return failed + + +def test_sigenergy_publish_mqtt_failure(my_predbat): + """Test _publish_mqtt returns False when the broker connection raises.""" + failed = False + api = MockSigenergyAPI() + api.access_token = "tok123" + api.mqtt_host = "openapi-eu.sigencloud.com" # cspell:disable-line + api.mqtt_port = 8883 + + def raise_error(*args, **kwargs): + raise ConnectionRefusedError("broker unavailable") + + with patch("sigenergy.ssl.create_default_context", return_value=MagicMock()): + with patch("sigenergy.aiomqtt.Client", side_effect=raise_error): + ok = run_async(SigenergyAPI._publish_mqtt(api, "openapi/instruction/command", {})) + + assert ok is False, "_publish_mqtt should return False on connection error" + assert any("MQTT publish" in m and "failed" in m for m in api.log_messages), "Error logged on failure" + + return failed + + +def test_sigenergy_send_battery_command_mqtt(my_predbat): + """Test send_battery_command publishes the correct MQTT payload.""" + failed = False + api = MockSigenergyAPI() + api.access_token = "reused_token" + api.token_expires_at = 9_999_999_999 # token still valid + + published = [] + + async def mock_publish_mqtt(topic, payload_dict): + published.append((topic, payload_dict)) + return True + + api._publish_mqtt = mock_publish_mqtt + + ok = run_async(api.send_battery_command("SIG001", "charge", 60, charging_power_kw=3.5)) + + assert ok is True, "send_battery_command should return True" + assert len(published) == 1, "One MQTT publish expected" + topic, payload = published[0] + assert topic == "openapi/instruction/command", "Correct MQTT topic" + assert payload["accessToken"] == "reused_token", "Token in payload" + assert payload["systemId"] == "SIG001", "systemId in payload" + assert payload["activeMode"] == "charge", "activeMode in payload" + assert payload["duration"] == 60, "duration in payload" + assert abs(payload["chargingPower"] - 3.5) < 0.01, "chargingPower in payload" + + return failed + + +def test_sigenergy_send_battery_command_no_token(my_predbat): + """Test send_battery_command returns False when token cannot be obtained.""" + failed = False + api = MockSigenergyAPI() + # Force get_access_token to fail by returning None + api.access_token = None + api.token_expires_at = 0.0 + + # Patch get_access_token to always return None + async def mock_get_access_token(): + return None + + api.get_access_token = mock_get_access_token + + ok = run_async(api.send_battery_command("SIG001", "charge", 60, charging_power_kw=3.5)) + assert ok is False, "send_battery_command should return False when no token" + assert any("No access token" in m for m in api.log_messages), "No-token error logged" + + return failed + + +# --------------------------------------------------------------------------- +# Test registration entry point +# --------------------------------------------------------------------------- + + +def run_sigenergy_tests(my_predbat): + """Run all Sigenergy API unit tests. + + Returns: + False on success (all tests passed), True if any test failed. + """ + failed = False + tests = [ + ("helper_functions", test_sigenergy_helper_functions), + ("initialize", test_sigenergy_initialize), + ("system_slug", test_sigenergy_system_slug), + ("battery_capacity", test_sigenergy_battery_capacity), + ("publish_system_entities", test_sigenergy_publish_system_entities), + ("automatic_config", test_sigenergy_automatic_config), + ("fetch_controls", test_sigenergy_fetch_controls), + ("publish_controls", test_sigenergy_publish_controls), + ("parse_entity_system", test_sigenergy_parse_entity_system), + ("apply_service_to_toggle", test_sigenergy_apply_service_to_toggle), + ("get_access_token_success", test_sigenergy_get_access_token_success), + ("get_access_token_failure", test_sigenergy_get_access_token_failure), + ("fetch_system_list", test_sigenergy_fetch_system_list), + ("fetch_system_list_with_filter", test_sigenergy_fetch_system_list_with_filter), + ("apply_controls_charge_mode", test_sigenergy_apply_controls_charge_mode), + ("apply_controls_eco_mode", test_sigenergy_apply_controls_eco_mode), + ("apply_controls_deduplication", test_sigenergy_apply_controls_deduplication), + ("apply_controls_export_mode", test_sigenergy_apply_controls_export_mode), + ("publish_mqtt_success", test_sigenergy_publish_mqtt_success), + ("publish_mqtt_failure", test_sigenergy_publish_mqtt_failure), + ("send_battery_command_mqtt", test_sigenergy_send_battery_command_mqtt), + ("send_battery_command_no_token", test_sigenergy_send_battery_command_no_token), + ] + + for name, fn in tests: + try: + result = fn(my_predbat) + if result: + print("FAIL: test_sigenergy_{}".format(name)) + failed = True + else: + print("PASS: test_sigenergy_{}".format(name)) + except (AssertionError, Exception) as e: + print("FAIL: test_sigenergy_{} — {}".format(name, e)) + import traceback + traceback.print_exc() + failed = True + + return failed diff --git a/apps/predbat/unit_test.py b/apps/predbat/unit_test.py index d64fd8b18..06ca2ab84 100644 --- a/apps/predbat/unit_test.py +++ b/apps/predbat/unit_test.py @@ -40,6 +40,7 @@ from tests.test_iboost import run_iboost_smart_tests from tests.test_alert_feed import test_alert_feed from tests.test_solax import run_solax_tests +from tests.test_sigenergy import run_sigenergy_tests from tests.test_single_debug import run_single_debug from tests.test_saving_session import test_saving_session, test_saving_session_null_octopoints, test_saving_session_notify_config, test_saving_session_default_rate from tests.test_secrets import run_secrets_tests @@ -246,6 +247,7 @@ def main(): ("solcast", run_solcast_tests, "Solcast API tests", False), ("open_meteo", run_open_meteo_tests, "Open-Meteo solar forecast provider tests", False), ("solax", run_solax_tests, "SolaX API tests", False), + ("sigenergy", run_sigenergy_tests, "Sigenergy Cloud API tests", False), ("iboost_smart", run_iboost_smart_tests, "iBoost smart tests", False), ("car_charging_smart", run_car_charging_smart_tests, "Car charging smart tests", False), ("intersect_window", run_intersect_window_tests, "Intersect window tests", False), From b83ecb8afebd3a4947cff534dd9892ff021aef84 Mon Sep 17 00:00:00 2001 From: Trefor Southwell Date: Sun, 10 May 2026 20:05:15 +0100 Subject: [PATCH 06/18] Sig fixes --- apps/predbat/sigenergy.py | 83 ++++++++++++++++++---------- apps/predbat/tests/test_sigenergy.py | 73 ++++++++++++++++++++++++ 2 files changed, 127 insertions(+), 29 deletions(-) diff --git a/apps/predbat/sigenergy.py b/apps/predbat/sigenergy.py index a03b99eb3..c31167569 100644 --- a/apps/predbat/sigenergy.py +++ b/apps/predbat/sigenergy.py @@ -68,7 +68,7 @@ # Constants # --------------------------------------------------------------------------- -SIGENERGY_DEFAULT_BASE_URL = "https://openapi.sigencloud.com" +SIGENERGY_DEFAULT_BASE_URL = "https://openapi-eu.sigencloud.com" # cspell:disable-line SIGENERGY_TIMEOUT = 20 # seconds per HTTP request SIGENERGY_MAX_RETRIES = 3 SIGENERGY_TOKEN_EXPIRY_BUFFER = 600 # refresh token 10 min before expiry @@ -139,6 +139,7 @@ def initialize(self, app_key, app_secret, base_url=None, system_id=None, automat published entity IDs on first run. enable_controls: When True, apply charge/discharge commands. """ + if not HAS_AIOHTTP: raise ImportError("SigenergyAPI requires the 'aiohttp' package: pip install aiohttp") if not HAS_AIOMQTT: @@ -200,6 +201,10 @@ async def get_access_token(self): The token is cached until within SIGENERGY_TOKEN_EXPIRY_BUFFER seconds of expiry (default 12 h lifetime). + Transient network errors (timeout, connection error, bad HTTP status) + are retried up to SIGENERGY_MAX_RETRIES times. API-level rejections + (wrong credentials, code != 0) are not retried as they are permanent. + Returns: Access token string on success, None on failure. """ @@ -213,36 +218,52 @@ async def get_access_token(self): payload = {"key": encoded_key} self.log("SigenergyAPI: Requesting new access token") - try: - timeout = aiohttp.ClientTimeout(total=SIGENERGY_TIMEOUT) - async with aiohttp.ClientSession(timeout=timeout) as session: - async with session.post(url, json=payload) as response: - if response.status != 200: - self.log("Warn: SigenergyAPI: Auth request returned HTTP {}".format(response.status)) - return None - try: - data = await response.json(content_type=None) - except (Exception) as e: - self.log("Warn: SigenergyAPI: Failed to decode auth response: {}".format(e)) - return None - code = data.get("code", -1) - if code != 0: - self.log("Warn: SigenergyAPI: Auth failed, code={}, msg={}".format(code, data.get("msg", "unknown"))) - self.access_token = None - return None - - token_data = data.get("data", {}) - self.access_token = token_data.get("accessToken") - expires_in = _safe_int(token_data.get("expiresIn", 43200), 43200) - self.token_expires_at = now + expires_in - self.log("SigenergyAPI: Token obtained, expires in {} s".format(expires_in)) - return self.access_token + for attempt in range(SIGENERGY_MAX_RETRIES): + try: + timeout = aiohttp.ClientTimeout(total=SIGENERGY_TIMEOUT) + async with aiohttp.ClientSession(timeout=timeout) as session: + async with session.post(url, json=payload) as response: + if response.status != 200: + self.log("Warn: SigenergyAPI: Auth request returned HTTP {} (attempt {}/{})".format(response.status, attempt + 1, SIGENERGY_MAX_RETRIES)) + if attempt < SIGENERGY_MAX_RETRIES - 1: + await asyncio.sleep(SIGENERGY_COMMAND_RETRY_DELAY) + continue + return None + try: + data = await response.json(content_type=None) + except Exception as e: + self.log("Warn: SigenergyAPI: Failed to decode auth response: {}".format(e)) + if attempt < SIGENERGY_MAX_RETRIES - 1: + await asyncio.sleep(SIGENERGY_COMMAND_RETRY_DELAY) + continue + return None - except (asyncio.TimeoutError, Exception) as e: - self.log("Warn: SigenergyAPI: Exception during authentication: {}".format(e)) - self.access_token = None - return None + code = data.get("code", -1) + if code != 0: + # Permanent API-level failure (e.g. wrong credentials) — do not retry + self.log("Warn: SigenergyAPI: Auth failed, code={}, msg={}".format(code, data.get("msg", "unknown"))) + self.access_token = None + return None + + token_data = data.get("data", {}) + self.access_token = token_data.get("accessToken") + expires_in = _safe_int(token_data.get("expiresIn", 43200), 43200) + self.token_expires_at = now + expires_in + self.log("SigenergyAPI: Token obtained, expires in {} s".format(expires_in)) + return self.access_token + + except asyncio.TimeoutError: + self.log("Warn: SigenergyAPI: Auth request timed out (attempt {}/{})".format(attempt + 1, SIGENERGY_MAX_RETRIES)) + if attempt < SIGENERGY_MAX_RETRIES - 1: + await asyncio.sleep(SIGENERGY_COMMAND_RETRY_DELAY) + except Exception as e: + self.log("Warn: SigenergyAPI: Exception during authentication: {}".format(e)) + if attempt < SIGENERGY_MAX_RETRIES - 1: + await asyncio.sleep(SIGENERGY_COMMAND_RETRY_DELAY) + + self.access_token = None + return None # ----------------------------------------------------------------------- # HTTP helpers @@ -1236,6 +1257,10 @@ async def run(self, seconds, first): """ if first: self.log("SigenergyAPI: First run — discovering systems") + token = await self.get_access_token() + if not token: + self.log("Warn: SigenergyAPI: Authentication failed — cannot proceed") + return False ok = await self.fetch_system_list() if not ok: self.log("Warn: SigenergyAPI: Failed to discover systems, will retry") diff --git a/apps/predbat/tests/test_sigenergy.py b/apps/predbat/tests/test_sigenergy.py index ec44efacd..913fd99a4 100644 --- a/apps/predbat/tests/test_sigenergy.py +++ b/apps/predbat/tests/test_sigenergy.py @@ -451,6 +451,77 @@ def test_sigenergy_get_access_token_failure(my_predbat): return failed +def test_sigenergy_get_access_token_retry(my_predbat): + """Test get_access_token retries on transient errors then succeeds.""" + failed = False + api = MockSigenergyAPI() + + attempt_count = {"n": 0} + + # First two calls raise a timeout; third succeeds + success_response = _make_mock_response(status=200, json_data={"code": 0, "data": {"accessToken": "retried_token", "expiresIn": 43200}}) + success_session = _make_mock_session(success_response) + + call_log = [] + + original_class = __import__("sigenergy").aiohttp.ClientSession + + class SequencedSession: + """Return failure sessions then success session.""" + def __init__(self, *args, **kwargs): + attempt_count["n"] += 1 + self._n = attempt_count["n"] + + async def __aenter__(self): + call_log.append(self._n) + if self._n < 3: + raise asyncio.TimeoutError() + return await success_session.__aenter__() + + async def __aexit__(self, *args): + if self._n >= 3: + await success_session.__aexit__(*args) + + with patch("sigenergy.aiohttp.ClientSession", SequencedSession): + token = run_async(api.get_access_token()) + + assert token == "retried_token", "Token returned after retry: {}".format(token) + assert attempt_count["n"] == 3, "Exactly 3 attempts made, got {}".format(attempt_count["n"]) + assert any("timed out" in m for m in api.log_messages), "Timeout warning logged" + + return failed + + +def test_sigenergy_get_access_token_no_retry_on_api_error(my_predbat): + """Test get_access_token does not retry after a permanent API rejection.""" + failed = False + api = MockSigenergyAPI() + + attempt_count = {"n": 0} + fake_response = {"code": 11003, "msg": "authentication failed"} + + mock_response = _make_mock_response(status=200, json_data=fake_response) + + class CountingSession: + """Count how many times a session is created.""" + def __init__(self, *args, **kwargs): + attempt_count["n"] += 1 + + async def __aenter__(self): + return await _make_mock_session(mock_response).__aenter__() + + async def __aexit__(self, *args): + pass + + with patch("sigenergy.aiohttp.ClientSession", CountingSession): + token = run_async(api.get_access_token()) + + assert token is None, "None returned on permanent API error" + assert attempt_count["n"] == 1, "Only one attempt made for API rejection, got {}".format(attempt_count["n"]) + + return failed + + def test_sigenergy_fetch_system_list(my_predbat): """Test fetch_system_list populates self.systems.""" failed = False @@ -823,6 +894,8 @@ def run_sigenergy_tests(my_predbat): ("apply_service_to_toggle", test_sigenergy_apply_service_to_toggle), ("get_access_token_success", test_sigenergy_get_access_token_success), ("get_access_token_failure", test_sigenergy_get_access_token_failure), + ("get_access_token_retry", test_sigenergy_get_access_token_retry), + ("get_access_token_no_retry_on_api_error", test_sigenergy_get_access_token_no_retry_on_api_error), ("fetch_system_list", test_sigenergy_fetch_system_list), ("fetch_system_list_with_filter", test_sigenergy_fetch_system_list_with_filter), ("apply_controls_charge_mode", test_sigenergy_apply_controls_charge_mode), From c7edf1be36bb1d360d878b4929e88c7ff38bc489 Mon Sep 17 00:00:00 2001 From: Trefor Southwell Date: Sat, 16 May 2026 12:50:26 +0100 Subject: [PATCH 07/18] Dev --- apps/predbat/sigenergy.py | 187 +++++++++++++++++++++++++-- apps/predbat/tests/test_sigenergy.py | 11 +- 2 files changed, 184 insertions(+), 14 deletions(-) diff --git a/apps/predbat/sigenergy.py b/apps/predbat/sigenergy.py index c31167569..623d5658b 100644 --- a/apps/predbat/sigenergy.py +++ b/apps/predbat/sigenergy.py @@ -70,12 +70,46 @@ SIGENERGY_DEFAULT_BASE_URL = "https://openapi-eu.sigencloud.com" # cspell:disable-line SIGENERGY_TIMEOUT = 20 # seconds per HTTP request -SIGENERGY_MAX_RETRIES = 3 +SIGENERGY_MAX_RETRIES = 5 # must be >= len(SIGENERGY_RATE_LIMIT_BACKOFF) for full backoff coverage +SIGENERGY_COMMAND_RETRY_DELAY = 2.0 + +# Sigenergy API response codes +SIGENERGY_CODE_SUCCESS = 0 +SIGENERGY_CODE_PARAM_ILLEGAL = 1000 +SIGENERGY_CODE_WRONG_SERIAL = 1101 +SIGENERGY_CODE_REGISTRATION_INCOMPLETE = 1102 +SIGENERGY_CODE_IN_OTHER_VPP = 1103 +SIGENERGY_CODE_DEVICE_OFFLINE = 1104 +SIGENERGY_CODE_SOFTWARE_NO_VPP = 1105 +SIGENERGY_CODE_STATION_NOT_FOUND = 1106 +SIGENERGY_CODE_AIO_INVERTER_ONLY = 1107 +SIGENERGY_CODE_STATION_INFO_NOT_FOUND = 1108 +SIGENERGY_CODE_RPC_FAIL = 1109 +SIGENERGY_CODE_INTERFACE_CURRENT_LIMITED = 1110 +SIGENERGY_CODE_STATION_NOT_PERMITTED = 1111 +SIGENERGY_CODE_IN_OTHER_VPP_EVERGEN = 1112 +SIGENERGY_CODE_ACCESS_RESTRICTION = 1201 +SIGENERGY_CODE_CLIENT_NOT_FOUND = 1301 +SIGENERGY_CODE_STATION_STATUS_ANOMALY = 1302 +SIGENERGY_CODE_CLIENT_EXISTS = 1303 +SIGENERGY_CODE_FIRMWARE_MISMATCH = 1304 +SIGENERGY_CODE_NO_PERMISSION_STATION = 1401 +SIGENERGY_CODE_NO_PERMISSION = 1402 +SIGENERGY_CODE_COMMAND_FAILED = 1501 +SIGENERGY_CODE_INTERNAL_ERROR = 1502 +SIGENERGY_CODE_ANTI_BACKFLOW_ENABLED = 1503 +SIGENERGY_CODE_PEAK_SHAVING_ENABLED = 1504 +SIGENERGY_CODE_INVITATION_INVALID = 1600 +SIGENERGY_CODE_ACCOUNT_SYSTEM_ERROR = 1601 +SIGENERGY_CODE_ACCOUNT_ALREADY_REGISTERED = 1602 +SIGENERGY_CODE_ACCOUNT_UNREVIEWED = 1603 +SIGENERGY_CODE_DEVELOPER_NOT_APPROVED = 1604 SIGENERGY_TOKEN_EXPIRY_BUFFER = 600 # refresh token 10 min before expiry SIGENERGY_MIN_REQUEST_INTERVAL = 6.0 # enforce ≥10 req/min API limit SIGENERGY_POLL_INTERVAL = 300 # realtime data poll every 5 minutes SIGENERGY_DEVICE_POLL_INTERVAL = 1800 # device list refresh every 30 minutes -SIGENERGY_COMMAND_RETRY_DELAY = 2.0 +SIGENERGY_RATE_LIMIT_BACKOFF = [15, 30, 60, 120, 480] # seconds to wait after code 1201 +SIGENERGY_BATTERY_NOMINAL_VOLTAGE_V = 28.8 # 8S LiFePO4 pack: 8 × 3.6V; used to convert ratedEnergy (Ah) → kWh SIGENERGY_MQTT_PORT = 8883 # TLS MQTT port on the Sigenergy broker # Operating mode enums (REST mode switch endpoint — MSC and FFG only; NBI is not used) @@ -247,6 +281,12 @@ async def get_access_token(self): return None token_data = data.get("data", {}) + if isinstance(token_data, str): + try: + token_data = json.loads(token_data) + except Exception as e: + self.log("Warn: SigenergyAPI: Failed to parse token data JSON: {}".format(e)) + return None self.access_token = token_data.get("accessToken") expires_in = _safe_int(token_data.get("expiresIn", 43200), 43200) self.token_expires_at = now + expires_in @@ -338,12 +378,39 @@ async def _request(self, method, path, params=None, json_data=None, retries=SIGE self.log("Warn: SigenergyAPI: Failed to decode response from {}: {}".format(path, e)) return None + self.log("SigenergyAPI: Response from {} {}: {}".format(method, path, body)) + code = body.get("code", -1) if code != 0: self.log("Warn: SigenergyAPI: API error code={} msg={} for {}".format(code, body.get("msg", ""), path)) + if code == SIGENERGY_CODE_ACCESS_RESTRICTION: + # Rate-limited — exponential backoff then retry + wait = SIGENERGY_RATE_LIMIT_BACKOFF[min(attempt, len(SIGENERGY_RATE_LIMIT_BACKOFF) - 1)] + self.log("Warn: SigenergyAPI: Rate limited (1201) — waiting {}s before retry (attempt {}/{})" .format(wait, attempt + 1, retries)) + if attempt < retries - 1: + await asyncio.sleep(wait) + continue return None - return body.get("data") + data = body.get("data") + # Some Sigenergy endpoints double-encode the data field as a JSON string + if isinstance(data, str): + try: + data = json.loads(data) + except Exception: + pass # Leave as string if it's not valid JSON + # Some endpoints return a list whose items are also JSON strings + if isinstance(data, list): + decoded = [] + for item in data: + if isinstance(item, str): + try: + item = json.loads(item) + except Exception: + pass + decoded.append(item) + data = decoded + return data except asyncio.TimeoutError: self.log("Warn: SigenergyAPI: Timeout on {} {} (attempt {}/{})".format(method, path, attempt + 1, retries)) @@ -371,6 +438,16 @@ async def fetch_system_list(self): self.log("Warn: SigenergyAPI: Failed to fetch system list") return False + self.log("SigenergyAPI: System list raw data type={} value={}".format(type(data).__name__, repr(data)[:200])) + + # data may be a JSON string (some Sigenergy endpoints double-encode), a list, or a dict + if isinstance(data, str): + try: + data = json.loads(data) + except Exception as e: + self.log("Warn: SigenergyAPI: Failed to parse system list JSON string: {}".format(e)) + return False + # data may be a list directly or wrapped in a dict systems = data if isinstance(data, list) else data.get("list", data.get("records", [])) if not isinstance(systems, list): @@ -412,6 +489,12 @@ async def fetch_device_list(self, system_id): return False self.devices[system_id] = devices + for device in devices: + if isinstance(device, dict) and isinstance(device.get("attrMap"), str): + try: + device["attrMap"] = json.loads(device["attrMap"]) + except Exception: + pass self.log("SigenergyAPI: System {} has {} device(s)".format(system_id, len(devices))) return True @@ -505,6 +588,50 @@ async def set_operating_mode(self, system_id, mode_int): self.log("SigenergyAPI: Operating mode set to {} for system {}".format(mode_int, system_id)) return True + async def onboard_systems(self, system_ids): + """Onboard one or more systems into the Sigenergy platform. + + Calls POST /openapi/board/onboard with a batch of system IDs. + + Args: + system_ids: A single system ID string or a list of system ID strings. + + Returns: + List of per-system result dicts on success (may be empty), or None on failure. + """ + if isinstance(system_ids, str): + system_ids = [system_ids] + #payload = {"systemIds": system_ids} + self.log("SigenergyAPI: Onboarding systems: {}".format(system_ids)) + result = await self._request("POST", "/openapi/board/onboard", json_data=system_ids) + if result is None: + self.log("Warn: SigenergyAPI: Onboard request failed for systems {}".format(system_ids)) + return None + self.log("SigenergyAPI: Onboard completed for {}: {}".format(system_ids, result)) + return result + + async def offboard_systems(self, system_ids): + """Offboard (remove) one or more systems from the Sigenergy platform. + + Calls POST /openapi/board/offboard with a batch of system IDs. + + Args: + system_ids: A single system ID string or a list of system ID strings. + + Returns: + List of per-system result dicts on success (may be empty), or None on failure. + """ + if isinstance(system_ids, str): + system_ids = [system_ids] + payload = {"systemIds": system_ids} + self.log("SigenergyAPI: Offboarding systems: {}".format(system_ids)) + result = await self._request("POST", "/openapi/board/offboard", json_data=payload) + if result is None: + self.log("Warn: SigenergyAPI: Offboard request failed for systems {}".format(system_ids)) + return None + self.log("SigenergyAPI: Offboard completed for {}: {}".format(system_ids, result)) + return result + async def _publish_mqtt(self, topic, payload_dict): """Publish a JSON payload to the Sigenergy MQTT broker. @@ -589,19 +716,22 @@ def _system_slug(self, system_id): def _get_battery_capacity_kwh(self, system_id): """Return the rated battery capacity in kWh for a system. - Prefers the batteryCapacity field from the system-list response. + Prefers the batteryCapacity field from the system-list response (already in kWh). Falls back to summing ratedEnergy from individual Battery devices. + The device-level ratedEnergy field is in Ah; multiply by the nominal pack voltage + (SIGENERGY_BATTERY_NOMINAL_VOLTAGE_V = 28.8 V for an 8S LiFePO4 pack) to convert to kWh. """ system_info = self.systems.get(system_id, {}) capacity = _safe_float(system_info.get("batteryCapacity", 0)) if capacity > 0: return capacity - # Fallback: sum device-level ratedEnergy + # Fallback: sum device-level ratedEnergy (Ah) converted to kWh for device in self.devices.get(system_id, []): if device.get("deviceType") == SIGENERGY_DEVICE_BATTERY: attr = device.get("attrMap", {}) - capacity += _safe_float(attr.get("ratedEnergy", 0)) + rated_ah = _safe_float(attr.get("ratedEnergy", 0)) + capacity += rated_ah * SIGENERGY_BATTERY_NOMINAL_VOLTAGE_V / 1000.0 return capacity def _get_battery_max_power_kw(self, system_id): @@ -1355,15 +1485,16 @@ def update_success_timestamp(self): """No-op success timestamp update.""" -async def test_sigenergy_api(app_key, app_secret, base_url, system_id, test_mode): # pragma: no cover - """Run one cycle of the Sigenergy API and optionally test a control mode. +async def test_sigenergy_api(app_key, app_secret, base_url, system_id, test_mode, action=None): # pragma: no cover + """Run one cycle of the Sigenergy API and optionally test a control mode or boarding action. Args: app_key: Sigenergy Application Key. app_secret: Sigenergy Application Secret. base_url: API base URL. - system_id: Optional system ID filter string. + system_id: Optional system ID filter string (required for onboard/offboard). test_mode: One of 'eco', 'charge', 'freeze_charge', 'export', 'freeze_export', or None. + action: One of 'onboard', 'offboard', or None. """ print("\n{}".format("=" * 60)) print("Testing Sigenergy Cloud API") @@ -1373,6 +1504,8 @@ async def test_sigenergy_api(app_key, app_secret, base_url, system_id, test_mode print("System ID filter: {}".format(system_id)) if test_mode: print("Test mode: {}".format(test_mode)) + if action: + print("Action: {}".format(action)) print("{}\n".format("=" * 60)) mock_base = MockBase() @@ -1387,6 +1520,27 @@ async def test_sigenergy_api(app_key, app_secret, base_url, system_id, test_mode enable_controls=(test_mode is not None), ) + # For boarding actions we only need the token, not a full system scan + if action in ("onboard", "offboard"): + if not system_id: + print("x --system-id is required for --{}\n".format(action)) + return 1 + token = await sig.get_access_token() + if not token: + print("x Authentication failed") + return 1 + print("+ Authentication successful") + if action == "onboard": + board_result = await sig.onboard_systems(system_id) + else: + board_result = await sig.offboard_systems(system_id) + if board_result is None: + print("x {} failed for system {}".format(action.capitalize(), system_id)) + return 1 + print("+ {} successful for system {}".format(action.capitalize(), system_id)) + print(" Results: {}".format(board_result)) + return 0 + result = await sig.run(first=True, seconds=0) if not result: print("x Initialisation failed") @@ -1490,8 +1644,21 @@ def main(): # pragma: no cover help="Control mode to test", ) + action_group = parser.add_mutually_exclusive_group() + action_group.add_argument( + "--onboard", + action="store_true", + help="Onboard the system specified by --system-id", + ) + action_group.add_argument( + "--offboard", + action="store_true", + help="Offboard (remove) the system specified by --system-id", + ) + args = parser.parse_args() - result = asyncio.run(test_sigenergy_api(args.app_key, args.app_secret, args.base_url, args.system_id, args.test_mode)) + action = "onboard" if args.onboard else ("offboard" if args.offboard else None) + result = asyncio.run(test_sigenergy_api(args.app_key, args.app_secret, args.base_url, args.system_id, args.test_mode, action)) raise SystemExit(result or 0) diff --git a/apps/predbat/tests/test_sigenergy.py b/apps/predbat/tests/test_sigenergy.py index 913fd99a4..d0fa1ed45 100644 --- a/apps/predbat/tests/test_sigenergy.py +++ b/apps/predbat/tests/test_sigenergy.py @@ -236,14 +236,17 @@ def test_sigenergy_battery_capacity(my_predbat): api.systems["sys1"] = {"batteryCapacity": 12.5} assert api._get_battery_capacity_kwh("sys1") == 12.5, "Capacity from system info" - # Fallback to device attrMap + # Fallback to device attrMap — ratedEnergy is in Ah, converted via nominal voltage 28.8V + # e.g. 314 Ah × 28.8V / 1000 = 9.0432 kWh per battery api.systems["sys2"] = {} api.devices["sys2"] = [ - {"deviceType": "Battery", "attrMap": {"ratedEnergy": 6.5}}, - {"deviceType": "Battery", "attrMap": {"ratedEnergy": 6.5}}, + {"deviceType": "Battery", "attrMap": {"ratedEnergy": 314}}, + {"deviceType": "Battery", "attrMap": {"ratedEnergy": 314}}, {"deviceType": "Inverter", "attrMap": {"ratedActivePower": 5.0}}, ] - assert api._get_battery_capacity_kwh("sys2") == 13.0, "Capacity summed from Battery devices" + expected_kwh = 2 * 314 * 28.8 / 1000 # = 18.0864 + actual_kwh = api._get_battery_capacity_kwh("sys2") + assert abs(actual_kwh - expected_kwh) < 0.001, "Capacity summed from Battery devices, expected {:.4f} got {:.4f}".format(expected_kwh, actual_kwh) return failed From d6756b46bfd0630486c96a0013c0a33cc416721e Mon Sep 17 00:00:00 2001 From: Trefor Southwell Date: Sat, 16 May 2026 14:59:20 +0100 Subject: [PATCH 08/18] Dev --- apps/predbat/sigenergy.py | 229 +++++++++++++++++++++++++-- apps/predbat/tests/test_sigenergy.py | 105 ++++++++++++ 2 files changed, 323 insertions(+), 11 deletions(-) diff --git a/apps/predbat/sigenergy.py b/apps/predbat/sigenergy.py index 623d5658b..ad5302765 100644 --- a/apps/predbat/sigenergy.py +++ b/apps/predbat/sigenergy.py @@ -76,6 +76,7 @@ # Sigenergy API response codes SIGENERGY_CODE_SUCCESS = 0 SIGENERGY_CODE_PARAM_ILLEGAL = 1000 +SIGENERGY_CODE_RPC_FAIL = 1001 SIGENERGY_CODE_WRONG_SERIAL = 1101 SIGENERGY_CODE_REGISTRATION_INCOMPLETE = 1102 SIGENERGY_CODE_IN_OTHER_VPP = 1103 @@ -88,6 +89,7 @@ SIGENERGY_CODE_INTERFACE_CURRENT_LIMITED = 1110 SIGENERGY_CODE_STATION_NOT_PERMITTED = 1111 SIGENERGY_CODE_IN_OTHER_VPP_EVERGEN = 1112 +SIGENERGY_CODE_SYSTEM_PENDING_REVIEW = 1116 # Guess, seems to give this until user approves onboarding SIGENERGY_CODE_ACCESS_RESTRICTION = 1201 SIGENERGY_CODE_CLIENT_NOT_FOUND = 1301 SIGENERGY_CODE_STATION_STATUS_ANOMALY = 1302 @@ -126,6 +128,7 @@ # Device type strings returned by the device-list endpoint SIGENERGY_DEVICE_INVERTER = "Inverter" +SIGENERGY_DEVICE_AIO = "AIO" SIGENERGY_DEVICE_BATTERY = "Battery" SIGENERGY_DEVICE_GATEWAY = "Gateway" SIGENERGY_DEVICE_METER = "Meter" @@ -340,6 +343,8 @@ async def _request(self, method, path, params=None, json_data=None, retries=SIGE "Content-Type": "application/json", } + self.log("Requesting {} {} with params={} json={}".format(method, path, params, json_data)) + for attempt in range(retries): await self._enforce_rate_limit() try: @@ -498,6 +503,95 @@ async def fetch_device_list(self, system_id): self.log("SigenergyAPI: System {} has {} device(s)".format(system_id, len(devices))) return True + def _get_inverter_serial(self, system_id): + """Return the serial number of the first Inverter (or AIO) device for a system. + + Args: + system_id: Sigenergy system unique identifier. + + Returns: + Serial number string, or None if not found. + """ + for device in self.devices.get(system_id, []): + dt = device.get("deviceType", "") + if dt in (SIGENERGY_DEVICE_INVERTER, SIGENERGY_DEVICE_AIO): + return device.get("serialNumber") + return None + + async def fetch_inverter_realtime(self, system_id): + """Fetch realtime data from the inverter realtimeInfo endpoint. + + Endpoint: GET /openapi/systems/{systemId}/devices/{serialNumber}/realtimeInfo + + Maps device-level fields to the same dict format as fetch_energy_flow so + that all downstream code (publish_system_entities, apply_controls) works + unchanged. Also updates daily_summary with pvEnergyDaily if present. + + Field sign conventions (realtimeInfo vs energyFlow): + batPower: realtimeInfo positive=discharging → energyFlow positive=charging (negated) + activePower: positive=generation/export → gridPower positive=export (same) + loadPower is derived as: pv + battery_discharge - grid_export + + Note: The API enforces a 5-minute access restriction per device, so this + should only be called at SIGENERGY_POLL_INTERVAL intervals (default 5 min). + + Args: + system_id: Sigenergy system unique identifier. + + Returns: + True on success, False on failure. + """ + serial = self._get_inverter_serial(system_id) + if not serial: + self.log("Warn: SigenergyAPI: No inverter device found for system {}".format(system_id)) + return False + + data = await self._request("GET", "/openapi/systems/{}/devices/{}/realtimeInfo".format(system_id, serial)) + if data is None: + self.log("Warn: SigenergyAPI: Failed to fetch inverter realtime info for {}".format(system_id)) + return False + + # data has systemId, serialNumber, deviceType, realTimeInfo + rt = data.get("realTimeInfo", data) + if isinstance(rt, str): + try: + rt = json.loads(rt) + except Exception: + pass + + bat_soc = _safe_float(rt.get("batSoc", 0)) + # batPower: appears to be negative for discharge, positive for charge — invert to match energyFlow convention + bat_power_kw = _safe_float(rt.get("batPower", 0)) + pv_power_kw = _safe_float(rt.get("pvPower", 0)) + # activePower: Active power of the inverter itself, positive is generation, negative is consumption. Generation includes discharging the battery. + active_power_kw = _safe_float(rt.get("activePower", 0)) + grid_power_kw = 0 # Unknown... + # Derive load: pv + battery_discharge - grid_export + # battery_discharge = -bat_power_kw when bat_power_kw < 0 + battery_discharge_kw = max(0.0, -bat_power_kw) + load_power_kw = max(0.0, pv_power_kw + battery_discharge_kw - grid_power_kw) + + flow = { + "batterySoc": bat_soc, + "batteryPower": bat_power_kw, + "pvPower": pv_power_kw, + "gridPower": grid_power_kw, + "loadPower": load_power_kw, + "evPower": 0.0, + } + self.energy_flow[system_id] = flow + + # Update daily summary from pvEnergyDaily if present + pv_daily = rt.get("pvEnergyDaily") + if pv_daily is not None: + if system_id not in self.daily_summary: + self.daily_summary[system_id] = {} + self.daily_summary[system_id]["dailyPowerGeneration"] = _safe_float(pv_daily) + + self.log("SigenergyAPI: System {} realtimeInfo — SOC {:.0f}% battery {:.2f}kW pv {:.2f}kW grid {:.2f}kW load {:.2f}kW".format( + system_id, bat_soc, bat_power_kw, pv_power_kw, grid_power_kw, load_power_kw)) + return True + async def fetch_energy_flow(self, system_id): """Fetch realtime energy-flow data for a system. @@ -1407,14 +1501,11 @@ async def run(self, seconds, first): await self.fetch_controls(sid) await self.publish_controls() - # Automatic configuration - if first and self.automatic: - await self.automatic_config() - # Realtime data refresh if first or seconds % SIGENERGY_POLL_INTERVAL == 0: for sid in list(self.systems.keys()): - await self.fetch_energy_flow(sid) + if not await self.fetch_inverter_realtime(sid): + await self.fetch_energy_flow(sid) await self.fetch_daily_summary(sid) # Publish entities @@ -1422,6 +1513,10 @@ async def run(self, seconds, first): for sid in list(self.systems.keys()): await self.publish_system_entities(sid) + # Automatic configuration + if first and self.automatic: + await self.automatic_config() + # Apply controls is_readonly = self.get_state_wrapper("switch.{}_set_read_only".format(self.prefix), default="off") == "on" if self.enable_controls and not is_readonly: @@ -1475,10 +1570,7 @@ def get_arg(self, key, default=None): def set_arg(self, key, value): """Print auto-config arg assignment.""" - if isinstance(value, list): - state = "[list of {} items]".format(len(value)) - else: - state = str(value) + state = str(value) print("Set arg {} = {}".format(key, state)) def update_success_timestamp(self): @@ -1627,6 +1719,112 @@ def _window(offset_start_min, offset_end_min): return 0 +async def test_mqtt_connection(app_key, app_secret, base_url, system_id=None, topic_filter="#"): # pragma: no cover + """Connect to the Sigenergy MQTT broker, request push data, and print all incoming messages. + + Authenticates using app_key/app_secret, then: + 1. Subscribes at MQTT protocol level to *topic_filter* to receive all broker messages. + 2. Publishes to ``openapi/subscription/period`` with the Sigenergy subscription + request payload (accessToken + systemIdList) so the broker starts pushing + periodic telemetry data. + 3. Prints every received message to stdout until the user presses Ctrl+C. + + The subscription payload follows the spec at + https://developer.sigencloud.com/user/api/document/45: + { "accessToken": "", "systemIdList": ["", ...] } + + Args: + app_key: Sigenergy Application Key. + app_secret: Sigenergy Application Secret. + base_url: REST API base URL (MQTT host is derived from this). + system_id: Optional system ID (or comma-separated list) to subscribe to. + When None, a full system scan is performed first to discover + all authorised system IDs. + topic_filter: MQTT protocol-level topic filter (default '#' = all topics). + + Returns: + 0 on clean exit, 1 on error. + """ + if not HAS_AIOMQTT: + print("x aiomqtt is not installed. Run: pip install aiomqtt") + return 1 + + print("\n{}".format("=" * 60)) + print("Sigenergy MQTT test mode") + print("Base URL : {}".format(base_url)) + print("App Key : {}...".format(app_key[:10] if len(app_key) >= 10 else app_key)) + print("Topic : {}".format(topic_filter)) + print("{}\n".format("=" * 60)) + + mock_base = MockBase() + sig = SigenergyAPI( + mock_base, + app_key=app_key, + app_secret=app_secret, + base_url=base_url, + system_id=system_id, + ) + + token = await sig.get_access_token() + if not token: + print("x Authentication failed") + return 1 + print("+ Authentication successful") + + # Resolve system ID list for the subscription request + if system_id: + system_id_list = [s.strip() for s in system_id.split(",") if s.strip()] + else: + print("+ No --system-id provided; scanning for authorised systems ...") + await sig.fetch_system_list() + system_id_list = list(sig.systems.keys()) + if not system_id_list: + print("x No systems found; cannot build subscription request") + return 1 + print("+ Found systems: {}".format(system_id_list)) + + mqtt_host = sig.mqtt_host + print("+ Connecting to MQTT broker {}:{} (TLS) ...".format(mqtt_host, SIGENERGY_MQTT_PORT)) + + tls_context = ssl.create_default_context() + try: + async with aiomqtt.Client( + hostname=mqtt_host, + port=SIGENERGY_MQTT_PORT, + username=app_key, + password=token, + tls_context=tls_context, + keepalive=60, + ) as client: + # MQTT protocol-level subscribe so we receive everything the broker sends us + await client.subscribe(topic_filter) + print("+ MQTT subscribe to '{}' OK".format(topic_filter)) + + # Sigenergy application-level subscription: publish to openapi/subscription/period + # so the broker starts pushing periodic telemetry (spec doc 45) + sub_payload = {"accessToken": token, "systemIdList": system_id_list} + await client.publish("openapi/subscription/period", payload=json.dumps(sub_payload), qos=1) + print("+ Published subscription request for systems: {}".format(system_id_list)) + print(" Waiting for messages (Ctrl+C to stop) ...\n") + + async for message in client.messages: + payload_bytes = message.payload if isinstance(message.payload, (bytes, bytearray)) else str(message.payload).encode() + payload_str = payload_bytes.decode("utf-8", errors="replace") + try: + payload_display = json.dumps(json.loads(payload_str), indent=2) + except (json.JSONDecodeError, ValueError): + payload_display = payload_str + print("[{}] Topic: {}".format(datetime.now().strftime("%H:%M:%S.%f")[:-3], message.topic)) + print(" QoS: {} Retain: {}".format(message.qos, message.retain)) + print(" Payload: {}".format(payload_display)) + print() + except KeyboardInterrupt: + pass + + print("\n+ MQTT test session ended") + return 0 + + def main(): # pragma: no cover """Main entry point for standalone testing.""" parser = argparse.ArgumentParser( @@ -1643,6 +1841,7 @@ def main(): # pragma: no cover choices=["eco", "charge", "freeze_charge", "export", "freeze_export"], help="Control mode to test", ) + parser.add_argument("--mqtt-topic", default="#", help="MQTT topic filter used with --mqtt-test (default: '#' = all topics)") action_group = parser.add_mutually_exclusive_group() action_group.add_argument( @@ -1655,10 +1854,18 @@ def main(): # pragma: no cover action="store_true", help="Offboard (remove) the system specified by --system-id", ) + action_group.add_argument( + "--mqtt-test", + action="store_true", + help="Connect to the MQTT broker and print all received messages until Ctrl+C", + ) args = parser.parse_args() - action = "onboard" if args.onboard else ("offboard" if args.offboard else None) - result = asyncio.run(test_sigenergy_api(args.app_key, args.app_secret, args.base_url, args.system_id, args.test_mode, action)) + if args.mqtt_test: + result = asyncio.run(test_mqtt_connection(args.app_key, args.app_secret, args.base_url, args.system_id, args.mqtt_topic)) + else: + action = "onboard" if args.onboard else ("offboard" if args.offboard else None) + result = asyncio.run(test_sigenergy_api(args.app_key, args.app_secret, args.base_url, args.system_id, args.test_mode, action)) raise SystemExit(result or 0) diff --git a/apps/predbat/tests/test_sigenergy.py b/apps/predbat/tests/test_sigenergy.py index d0fa1ed45..f3e93b53e 100644 --- a/apps/predbat/tests/test_sigenergy.py +++ b/apps/predbat/tests/test_sigenergy.py @@ -872,6 +872,108 @@ async def mock_get_access_token(): return failed +def test_sigenergy_fetch_inverter_realtime(my_predbat): + """Test fetch_inverter_realtime maps realtimeInfo fields to energy_flow correctly.""" + failed = False + api = MockSigenergyAPI() + api.access_token = "fake_token" + api.token_expires_at = 9_999_999_999 + api._last_request_time = 0 + + # Populate a minimal device list with one Inverter + api.devices["SYS1"] = [ + {"deviceType": "Inverter", "serialNumber": "INV001"}, + {"deviceType": "Battery", "serialNumber": "BAT001"}, + ] + + # realtimeInfo response: batPower positive=discharging (3.0 kW discharging) + # activePower positive=export (1.5 kW export) + # pvPower 5.0 kW + # batSoc 72.0 % + # pvEnergyDaily 12.5 kWh + fake_response = { + "code": 0, + "data": { + "systemId": "SYS1", + "serialNumber": "INV001", + "deviceType": "Inverter", + "realTimeInfo": { + "batSoc": 72.0, + "batPower": 3.0, # discharging → batteryPower should be -3.0 + "pvPower": 5.0, + "activePower": 1.5, # export → gridPower = 1.5 + "pvEnergyDaily": 12.5, + }, + }, + } + + mock_response = _make_mock_response(status=200, json_data=fake_response) + mock_session = _make_mock_session(mock_response) + + with patch("sigenergy.aiohttp.ClientSession", return_value=mock_session): + ok = run_async(api.fetch_inverter_realtime("SYS1")) + + assert ok is True, "fetch_inverter_realtime should return True" + flow = api.energy_flow.get("SYS1", {}) + + assert flow.get("batterySoc") == 72.0, "batterySoc = 72.0" + # batPower was 3.0 (discharging) → batteryPower should be -3.0 (discharging in energyFlow convention) + assert flow.get("batteryPower") == -3.0, "batteryPower = -3.0 (discharging, sign negated)" + assert flow.get("pvPower") == 5.0, "pvPower = 5.0" + assert flow.get("gridPower") == 1.5, "gridPower = 1.5 (export)" + # loadPower = pv + battery_discharge - grid_export = 5.0 + 3.0 - 1.5 = 6.5 + assert flow.get("loadPower") == 6.5, "loadPower = 6.5 (derived)" + assert flow.get("evPower") == 0.0, "evPower = 0.0 (not available)" + + # pvEnergyDaily should update daily_summary + daily = api.daily_summary.get("SYS1", {}) + assert daily.get("dailyPowerGeneration") == 12.5, "daily PV yield updated from pvEnergyDaily" + + return failed + + +def test_sigenergy_fetch_inverter_realtime_no_inverter(my_predbat): + """Test fetch_inverter_realtime returns False when no inverter device is found.""" + failed = False + api = MockSigenergyAPI() + api.devices["SYS1"] = [ + {"deviceType": "Battery", "serialNumber": "BAT001"}, + ] + + ok = run_async(api.fetch_inverter_realtime("SYS1")) + assert ok is False, "Should return False when no inverter in device list" + assert any("No inverter" in m for m in api.log_messages), "Warning logged about missing inverter" + + return failed + + +def test_sigenergy_get_inverter_serial(my_predbat): + """Test _get_inverter_serial finds Inverter and AIO device types.""" + failed = False + api = MockSigenergyAPI() + + # No devices → None + api.devices["SYS1"] = [] + assert api._get_inverter_serial("SYS1") is None, "Empty device list returns None" + + # Only battery → None + api.devices["SYS1"] = [{"deviceType": "Battery", "serialNumber": "BAT001"}] + assert api._get_inverter_serial("SYS1") is None, "Battery-only list returns None" + + # Inverter type → found + api.devices["SYS1"] = [ + {"deviceType": "Battery", "serialNumber": "BAT001"}, + {"deviceType": "Inverter", "serialNumber": "INV001"}, + ] + assert api._get_inverter_serial("SYS1") == "INV001", "Inverter serial returned" + + # AIO type → found + api.devices["SYS2"] = [{"deviceType": "AIO", "serialNumber": "AIO001"}] + assert api._get_inverter_serial("SYS2") == "AIO001", "AIO serial returned" + + return failed + + # --------------------------------------------------------------------------- # Test registration entry point # --------------------------------------------------------------------------- @@ -909,6 +1011,9 @@ def run_sigenergy_tests(my_predbat): ("publish_mqtt_failure", test_sigenergy_publish_mqtt_failure), ("send_battery_command_mqtt", test_sigenergy_send_battery_command_mqtt), ("send_battery_command_no_token", test_sigenergy_send_battery_command_no_token), + ("fetch_inverter_realtime", test_sigenergy_fetch_inverter_realtime), + ("fetch_inverter_realtime_no_inverter", test_sigenergy_fetch_inverter_realtime_no_inverter), + ("get_inverter_serial", test_sigenergy_get_inverter_serial), ] for name, fn in tests: From 965068c359cdb2b21a7f72df9fcfe17459b25732 Mon Sep 17 00:00:00 2001 From: Trefor Southwell Date: Mon, 25 May 2026 13:54:03 +0100 Subject: [PATCH 09/18] Mqtt sig --- apps/predbat/components.py | 4 + apps/predbat/config.py | 10 ++ apps/predbat/sigenergy.py | 204 ++++++++++++++++++++++++++++--------- 3 files changed, 172 insertions(+), 46 deletions(-) diff --git a/apps/predbat/components.py b/apps/predbat/components.py index bc2b82404..f7b4f0b8c 100644 --- a/apps/predbat/components.py +++ b/apps/predbat/components.py @@ -354,6 +354,10 @@ "app_key": {"required": True, "config": "sigenergy_app_key"}, "app_secret": {"required": True, "config": "sigenergy_app_secret"}, "base_url": {"required": False, "config": "sigenergy_base_url", "default": "https://openapi-eu.sigencloud.com"}, + "mqtt_host": {"required": False, "config": "sigenergy_mqtt_host"}, + "ca_cert": {"required": False, "config": "sigenergy_ca_pem"}, + "client_cert": {"required": False, "config": "sigenergy_client_pem"}, + "client_key": {"required": False, "config": "sigenergy_client_key"}, "system_id": {"required": False, "config": "sigenergy_system_id"}, "automatic": {"required": False, "config": "sigenergy_automatic", "default": False}, "enable_controls": {"required": False, "config": "sigenergy_enable_controls", "default": True}, diff --git a/apps/predbat/config.py b/apps/predbat/config.py index e2eda8005..4a79baca6 100644 --- a/apps/predbat/config.py +++ b/apps/predbat/config.py @@ -2116,6 +2116,16 @@ "axle_pence_per_kwh": {"type": "float"}, "axle_automatic": {"type": "boolean"}, "axle_control": {"type": "boolean"}, + "sigenergy_app_key": {"type": "string", "empty": False}, + "sigenergy_app_secret": {"type": "string", "empty": False}, + "sigenergy_base_url": {"type": "string", "empty": False}, + "sigenergy_mqtt_host": {"type": "string", "empty": False}, + "sigenergy_system_id": {"type": "string", "empty": False}, + "sigenergy_automatic": {"type": "boolean"}, + "sigenergy_enable_controls": {"type": "boolean"}, + "sigenergy_ca_pem": {"type": "string", "empty": False}, + "sigenergy_client_pem": {"type": "string", "empty": False}, + "sigenergy_client_key": {"type": "string", "empty": False}, "solis_api_key": {"type": "string", "empty": False}, "solis_api_secret": {"type": "string", "empty": False}, "solis_inverter_sn": {"type": "string|string_list", "empty": False}, diff --git a/apps/predbat/sigenergy.py b/apps/predbat/sigenergy.py index ad5302765..82826d2d2 100644 --- a/apps/predbat/sigenergy.py +++ b/apps/predbat/sigenergy.py @@ -69,6 +69,7 @@ # --------------------------------------------------------------------------- SIGENERGY_DEFAULT_BASE_URL = "https://openapi-eu.sigencloud.com" # cspell:disable-line +SIGENERGY_DEFAULT_MQTT_HOST = "mqtt-eu.sigencloud.com" # cspell:disable-line SIGENERGY_TIMEOUT = 20 # seconds per HTTP request SIGENERGY_MAX_RETRIES = 5 # must be >= len(SIGENERGY_RATE_LIMIT_BACKOFF) for full backoff coverage SIGENERGY_COMMAND_RETRY_DELAY = 2.0 @@ -114,6 +115,12 @@ SIGENERGY_BATTERY_NOMINAL_VOLTAGE_V = 28.8 # 8S LiFePO4 pack: 8 × 3.6V; used to convert ratedEnergy (Ah) → kWh SIGENERGY_MQTT_PORT = 8883 # TLS MQTT port on the Sigenergy broker +# MQTT topic patterns — format with app_key and system_id +SIGENERGY_MQTT_TOPIC_CHANGE = "openapi/change/{app_key}/{system_id}" # system data (device state) +SIGENERGY_MQTT_TOPIC_PERIOD = "openapi/period/{app_key}/{system_id}" # telemetry data +SIGENERGY_MQTT_TOPIC_ALARM = "openapi/alarm/{app_key}/{system_id}" # alarm data +SIGENERGY_MQTT_TOPIC_COMMAND = "openapi/instruction/command" # battery command publish + # Operating mode enums (REST mode switch endpoint — MSC and FFG only; NBI is not used) SIGENERGY_MODE_MSC = 0 # Maximum Self-Consumption (eco) SIGENERGY_MODE_FFG = 5 # Fully Feed-in to Grid @@ -162,13 +169,18 @@ class SigenergyAPI(ComponentBase): control commands on behalf of Predbat's planner. """ - def initialize(self, app_key, app_secret, base_url=None, system_id=None, automatic=False, enable_controls=True, **kwargs): + def initialize(self, app_key, app_secret, base_url=None, mqtt_host=None, ca_cert=None, client_cert=None, client_key=None, system_id=None, automatic=False, enable_controls=True, **kwargs): """Initialise the Sigenergy API component. Args: app_key: Sigenergy Application Key (from Control Center → Settings). app_secret: Sigenergy Application Secret. base_url: Override the API base URL (default: SIGENERGY_DEFAULT_BASE_URL). + mqtt_host: Override the MQTT broker hostname (default: derived from SIGENERGY_DEFAULT_MQTT_HOST + by replacing the regional prefix to match *base_url*). + ca_cert: Path to CA certificate PEM file for verifying the broker's TLS certificate. + client_cert: Path to client certificate PEM file for mutual TLS authentication. + client_key: Path to client private key file for mutual TLS authentication. system_id: Optional system ID filter. When None all authorised systems are used. When a string or list, only matching systems are used. @@ -185,9 +197,18 @@ def initialize(self, app_key, app_secret, base_url=None, system_id=None, automat self.app_key = app_key self.app_secret = app_secret self.base_url = (base_url or SIGENERGY_DEFAULT_BASE_URL).rstrip("/") - # Derive MQTT hostname from REST base URL (strip scheme, no port suffix needed) - self.mqtt_host = self.base_url.replace("https://", "").replace("http://", "").rstrip("/") + # MQTT broker is on a dedicated host separate from the REST endpoint. + # Derive it from the REST hostname by replacing the 'openapi-' prefix with 'mqtt-' + # (e.g. openapi-eu.sigencloud.com → mqtt-eu.sigencloud.com) unless overridden. + if mqtt_host: + self.mqtt_host = mqtt_host + else: + rest_host = self.base_url.replace("https://", "").replace("http://", "").rstrip("/") + self.mqtt_host = rest_host.replace("openapi-", "mqtt-", 1) if rest_host.startswith("openapi-") else SIGENERGY_DEFAULT_MQTT_HOST self.mqtt_port = SIGENERGY_MQTT_PORT + self.ca_cert = ca_cert or self.get_arg("sigenergy_ca_pem", None) + self.client_cert = client_cert or self.get_arg("sigenergy_client_pem", None) + self.client_key = client_key or self.get_arg("sigenergy_client_key", None) self.automatic = automatic self.enable_controls = enable_controls @@ -717,7 +738,7 @@ async def offboard_systems(self, system_ids): """ if isinstance(system_ids, str): system_ids = [system_ids] - payload = {"systemIds": system_ids} + payload = system_ids self.log("SigenergyAPI: Offboarding systems: {}".format(system_ids)) result = await self._request("POST", "/openapi/board/offboard", json_data=payload) if result is None: @@ -726,6 +747,28 @@ async def offboard_systems(self, system_ids): self.log("SigenergyAPI: Offboard completed for {}: {}".format(system_ids, result)) return result + def _build_tls_context(self): + """Build an SSL context for the MQTT connection. + + Uses ca_cert if provided (for custom/self-signed CA), otherwise the + system default trust store. Loads client_cert + client_key when both + are provided (mutual TLS). + + Returns: + ssl.SSLContext ready for use with aiomqtt. + """ + if self.ca_cert: + tls_context = ssl.create_default_context(cafile=self.ca_cert) + # Relax strict RFC 5280 key-usage enforcement; Sigenergy's CA cert + # may not include the keyUsage/basicConstraints extensions that + # Python 3.14+ enforces by default. + tls_context.verify_flags = ssl.VERIFY_DEFAULT + else: + tls_context = ssl.create_default_context() + if self.client_cert and self.client_key: + tls_context.load_cert_chain(certfile=self.client_cert, keyfile=self.client_key) + return tls_context + async def _publish_mqtt(self, topic, payload_dict): """Publish a JSON payload to the Sigenergy MQTT broker. @@ -743,7 +786,7 @@ async def _publish_mqtt(self, topic, payload_dict): True on success, False on failure. """ try: - tls_context = ssl.create_default_context() + tls_context = self._build_tls_context() async with aiomqtt.Client( hostname=self.mqtt_host, port=self.mqtt_port, @@ -793,7 +836,7 @@ async def send_battery_command(self, system_id, active_mode, duration_minutes, c self.log("SigenergyAPI: Sending MQTT battery command {} ({} min, {:.2f}kW) to system {}".format( active_mode, duration_minutes, charging_power_kw or 0.0, system_id)) - return await self._publish_mqtt("openapi/instruction/command", payload) + return await self._publish_mqtt(SIGENERGY_MQTT_TOPIC_COMMAND, payload) # ----------------------------------------------------------------------- # HA entity publishing @@ -1564,7 +1607,7 @@ def dashboard_item(self, entity_id, state=None, attributes=None, app=None): print(" Attributes: {}".format(json.dumps(display, indent=2, default=str))) self.set_state_wrapper(entity_id, state, attributes) - def get_arg(self, key, default=None): + def get_arg(self, arg, default=None, indirect=False, combine=False, attribute=None, index=None, domain=None, can_override=True, required_unit=None): """Return arg default (mock always returns default).""" return default @@ -1577,7 +1620,7 @@ def update_success_timestamp(self): """No-op success timestamp update.""" -async def test_sigenergy_api(app_key, app_secret, base_url, system_id, test_mode, action=None): # pragma: no cover +async def test_sigenergy_api(app_key, app_secret, base_url, system_id, test_mode, action=None, mqtt_host=None, ca_cert=None, client_cert=None, client_key=None): # pragma: no cover """Run one cycle of the Sigenergy API and optionally test a control mode or boarding action. Args: @@ -1607,6 +1650,10 @@ async def test_sigenergy_api(app_key, app_secret, base_url, system_id, test_mode app_key=app_key, app_secret=app_secret, base_url=base_url, + mqtt_host=mqtt_host, + ca_cert=ca_cert, + client_cert=client_cert, + client_key=client_key, system_id=system_id, automatic=True, enable_controls=(test_mode is not None), @@ -1719,7 +1766,7 @@ def _window(offset_start_min, offset_end_min): return 0 -async def test_mqtt_connection(app_key, app_secret, base_url, system_id=None, topic_filter="#"): # pragma: no cover +async def test_mqtt_connection(app_key, app_secret, base_url, system_id=None, topic_filter=None, ca_cert=None, client_cert=None, client_key=None): # pragma: no cover """Connect to the Sigenergy MQTT broker, request push data, and print all incoming messages. Authenticates using app_key/app_secret, then: @@ -1740,7 +1787,9 @@ async def test_mqtt_connection(app_key, app_secret, base_url, system_id=None, to system_id: Optional system ID (or comma-separated list) to subscribe to. When None, a full system scan is performed first to discover all authorised system IDs. - topic_filter: MQTT protocol-level topic filter (default '#' = all topics). + topic_filter: MQTT protocol-level topic filter. When None the function + builds per-app-key wildcard topics for change, period and + alarm data. Pass '#' to receive every broker message. Returns: 0 on clean exit, 1 on error. @@ -1753,7 +1802,13 @@ async def test_mqtt_connection(app_key, app_secret, base_url, system_id=None, to print("Sigenergy MQTT test mode") print("Base URL : {}".format(base_url)) print("App Key : {}...".format(app_key[:10] if len(app_key) >= 10 else app_key)) - print("Topic : {}".format(topic_filter)) + default_topics = [ + SIGENERGY_MQTT_TOPIC_CHANGE.format(app_key=app_key, system_id="#"), + SIGENERGY_MQTT_TOPIC_PERIOD.format(app_key=app_key, system_id="#"), + SIGENERGY_MQTT_TOPIC_ALARM.format(app_key=app_key, system_id="#"), + ] + topics_to_subscribe = [topic_filter] if topic_filter else default_topics + print("Topics : {}".format(", ".join(topics_to_subscribe))) print("{}\n".format("=" * 60)) mock_base = MockBase() @@ -1762,6 +1817,9 @@ async def test_mqtt_connection(app_key, app_secret, base_url, system_id=None, to app_key=app_key, app_secret=app_secret, base_url=base_url, + ca_cert=ca_cert, + client_cert=client_cert, + client_key=client_key, system_id=system_id, ) @@ -1770,6 +1828,11 @@ async def test_mqtt_connection(app_key, app_secret, base_url, system_id=None, to print("x Authentication failed") return 1 print("+ Authentication successful") + print("+ MQTT broker: {}:{}".format(sig.mqtt_host, SIGENERGY_MQTT_PORT)) + if sig.ca_cert: + print("+ CA cert : {}".format(sig.ca_cert)) + if sig.client_cert: + print("+ Client cert: {}".format(sig.client_cert)) # Resolve system ID list for the subscription request if system_id: @@ -1786,40 +1849,77 @@ async def test_mqtt_connection(app_key, app_secret, base_url, system_id=None, to mqtt_host = sig.mqtt_host print("+ Connecting to MQTT broker {}:{} (TLS) ...".format(mqtt_host, SIGENERGY_MQTT_PORT)) - tls_context = ssl.create_default_context() + tls_context = sig._build_tls_context() + reconnect_delay = 5 + attempt = 0 try: - async with aiomqtt.Client( - hostname=mqtt_host, - port=SIGENERGY_MQTT_PORT, - username=app_key, - password=token, - tls_context=tls_context, - keepalive=60, - ) as client: - # MQTT protocol-level subscribe so we receive everything the broker sends us - await client.subscribe(topic_filter) - print("+ MQTT subscribe to '{}' OK".format(topic_filter)) - - # Sigenergy application-level subscription: publish to openapi/subscription/period - # so the broker starts pushing periodic telemetry (spec doc 45) - sub_payload = {"accessToken": token, "systemIdList": system_id_list} - await client.publish("openapi/subscription/period", payload=json.dumps(sub_payload), qos=1) - print("+ Published subscription request for systems: {}".format(system_id_list)) - print(" Waiting for messages (Ctrl+C to stop) ...\n") - - async for message in client.messages: - payload_bytes = message.payload if isinstance(message.payload, (bytes, bytearray)) else str(message.payload).encode() - payload_str = payload_bytes.decode("utf-8", errors="replace") - try: - payload_display = json.dumps(json.loads(payload_str), indent=2) - except (json.JSONDecodeError, ValueError): - payload_display = payload_str - print("[{}] Topic: {}".format(datetime.now().strftime("%H:%M:%S.%f")[:-3], message.topic)) - print(" QoS: {} Retain: {}".format(message.qos, message.retain)) - print(" Payload: {}".format(payload_display)) - print() + while True: + attempt += 1 + ts = datetime.now().strftime("%H:%M:%S") + print("[{}] Connection attempt #{} ...".format(ts, attempt)) + try: + # Refresh token on each (re)connect in case it expired + print("[{}] Refreshing access token ...".format(datetime.now().strftime("%H:%M:%S"))) + token = await sig.get_access_token() + if not token: + print("[{}] x Token refresh failed; retrying in {}s ...".format(datetime.now().strftime("%H:%M:%S"), reconnect_delay)) + await asyncio.sleep(reconnect_delay) + continue + print("[{}] Token OK".format(datetime.now().strftime("%H:%M:%S"))) + + print("[{}] Opening MQTT connection ...".format(datetime.now().strftime("%H:%M:%S"))) + async with aiomqtt.Client( + hostname=sig.mqtt_host, + port=SIGENERGY_MQTT_PORT, + username=app_key, + password=token, + tls_context=tls_context, + keepalive=60, + ) as client: + print("[{}] MQTT connected".format(datetime.now().strftime("%H:%M:%S"))) + # MQTT protocol-level subscribe to the relevant topics + for topic in topics_to_subscribe: + await client.subscribe(topic) + print("[{}] + Subscribed to '{}'".format(datetime.now().strftime("%H:%M:%S"), topic)) + + # Sigenergy application-level subscriptions: publish to each subscription topic + # so the broker starts pushing the respective data types. + sub_payload = {"accessToken": token, "systemIdList": system_id_list} + sub_json = json.dumps(sub_payload) + for sub_topic in ("openapi/subscription/period", "openapi/subscription/change", "openapi/subscription/alarm"): + await client.publish(sub_topic, payload=sub_json, qos=1) + print("[{}] + Published subscription: {}".format(datetime.now().strftime("%H:%M:%S"), sub_topic)) + print("[{}] Waiting for messages (Ctrl+C to stop) ...\n".format(datetime.now().strftime("%H:%M:%S"))) + + async for message in client.messages: + payload_bytes = message.payload if isinstance(message.payload, (bytes, bytearray)) else str(message.payload).encode() + payload_str = payload_bytes.decode("utf-8", errors="replace") + try: + payload_display = json.dumps(json.loads(payload_str), indent=2) + except (json.JSONDecodeError, ValueError): + payload_display = payload_str + print("[{}] Topic: {}".format(datetime.now().strftime("%H:%M:%S.%f")[:-3], message.topic)) + print(" QoS: {} Retain: {}".format(message.qos, message.retain)) + print(" Payload: {}".format(payload_display)) + print() + + # If we reach here the broker closed the connection cleanly (no exception) + print("[{}] ! MQTT message loop ended (broker closed connection) — reconnecting in {}s ...".format(datetime.now().strftime("%H:%M:%S"), reconnect_delay)) + await asyncio.sleep(reconnect_delay) + + except aiomqtt.MqttError as e: + import traceback + print("[{}] ! MqttError: {} — reconnecting in {}s ...".format(datetime.now().strftime("%H:%M:%S"), e, reconnect_delay)) + print(traceback.format_exc()) + await asyncio.sleep(reconnect_delay) + except Exception as e: + import traceback + print("[{}] ! Unexpected error ({}): {} — reconnecting in {}s ...".format(datetime.now().strftime("%H:%M:%S"), type(e).__name__, e, reconnect_delay)) + print(traceback.format_exc()) + await asyncio.sleep(reconnect_delay) + except KeyboardInterrupt: - pass + print("\n[{}] Ctrl+C received".format(datetime.now().strftime("%H:%M:%S"))) print("\n+ MQTT test session ended") return 0 @@ -1841,7 +1941,12 @@ def main(): # pragma: no cover choices=["eco", "charge", "freeze_charge", "export", "freeze_export"], help="Control mode to test", ) - parser.add_argument("--mqtt-topic", default="#", help="MQTT topic filter used with --mqtt-test (default: '#' = all topics)") + parser.add_argument("--mqtt-host", default=None, help="Override MQTT broker hostname (default: derived from --base-url)") + parser.add_argument("--mqtt-topic", default=None, help="MQTT topic filter for --mqtt-test; omit to use per-app-key topic patterns") + parser.add_argument("--cert-dir", default=None, help="Directory containing ca.pem, client.pem and client.key TLS certificate files") + parser.add_argument("--ca-cert", default=None, help="Path to CA certificate PEM file (overrides --cert-dir/ca.pem)") + parser.add_argument("--client-cert", default=None, help="Path to client certificate PEM file (overrides --cert-dir/client.pem)") + parser.add_argument("--client-key", default=None, help="Path to client private key file (overrides --cert-dir/client.key)") action_group = parser.add_mutually_exclusive_group() action_group.add_argument( @@ -1861,11 +1966,18 @@ def main(): # pragma: no cover ) args = parser.parse_args() + + # Resolve TLS certificate paths — individual flags override cert-dir + import os + ca_cert = args.ca_cert or (os.path.join(args.cert_dir, "ca.pem") if args.cert_dir else None) + client_cert = args.client_cert or (os.path.join(args.cert_dir, "client.pem") if args.cert_dir else None) + client_key = args.client_key or (os.path.join(args.cert_dir, "client.key") if args.cert_dir else None) + if args.mqtt_test: - result = asyncio.run(test_mqtt_connection(args.app_key, args.app_secret, args.base_url, args.system_id, args.mqtt_topic)) + result = asyncio.run(test_mqtt_connection(args.app_key, args.app_secret, args.base_url, args.system_id, args.mqtt_topic, ca_cert=ca_cert, client_cert=client_cert, client_key=client_key)) else: action = "onboard" if args.onboard else ("offboard" if args.offboard else None) - result = asyncio.run(test_sigenergy_api(args.app_key, args.app_secret, args.base_url, args.system_id, args.test_mode, action)) + result = asyncio.run(test_sigenergy_api(args.app_key, args.app_secret, args.base_url, args.system_id, args.test_mode, action, mqtt_host=args.mqtt_host, ca_cert=ca_cert, client_cert=client_cert, client_key=client_key)) raise SystemExit(result or 0) From 65ac6ee39f3c086edf22dc6f36a09aadbced6296 Mon Sep 17 00:00:00 2001 From: Trefor Southwell Date: Mon, 25 May 2026 14:45:59 +0100 Subject: [PATCH 10/18] MQTT rework --- apps/predbat/sigenergy.py | 476 +++++++++++++++++++++------ apps/predbat/tests/test_sigenergy.py | 235 ++++++++++++- 2 files changed, 585 insertions(+), 126 deletions(-) diff --git a/apps/predbat/sigenergy.py b/apps/predbat/sigenergy.py index ff9eddc3e..ad3155516 100644 --- a/apps/predbat/sigenergy.py +++ b/apps/predbat/sigenergy.py @@ -244,6 +244,10 @@ def initialize(self, app_key, app_secret, base_url=None, mqtt_host=None, ca_cert # Delay between mode-switch and battery command (seconds); set to 0 in tests self._command_delay = 1.0 + # Background MQTT listener task + self._mqtt_task = None + self.last_mqtt_update = 0.0 # UNIX timestamp of last MQTT message received + self.log("SigenergyAPI: Initialised, base_url={}".format(self.base_url)) # ----------------------------------------------------------------------- @@ -581,14 +585,16 @@ async def fetch_inverter_realtime(self, system_id): pass bat_soc = _safe_float(rt.get("batSoc", 0)) - # batPower: appears to be negative for discharge, positive for charge — invert to match energyFlow convention - bat_power_kw = _safe_float(rt.get("batPower", 0)) + # batPower: realtimeInfo convention is positive=discharging, negative=charging. + # energyFlow convention (used everywhere else) is positive=charging, negative=discharging. + # Negate to match the energyFlow convention. + bat_power_kw = -_safe_float(rt.get("batPower", 0)) pv_power_kw = _safe_float(rt.get("pvPower", 0)) - # activePower: Active power of the inverter itself, positive is generation, negative is consumption. Generation includes discharging the battery. - active_power_kw = _safe_float(rt.get("activePower", 0)) - grid_power_kw = 0 # Unknown... + # activePower: positive=export (net generation to grid), negative=import. + # Maps directly to gridPower in the energyFlow convention (positive=export). + grid_power_kw = _safe_float(rt.get("activePower", 0)) # Derive load: pv + battery_discharge - grid_export - # battery_discharge = -bat_power_kw when bat_power_kw < 0 + # battery_discharge = -bat_power_kw when bat_power_kw < 0 (bat_power_kw is now in charging-positive convention) battery_discharge_kw = max(0.0, -bat_power_kw) load_power_kw = max(0.0, pv_power_kw + battery_discharge_kw - grid_power_kw) @@ -817,6 +823,21 @@ async def send_battery_command(self, system_id, active_mode, duration_minutes, c Returns: True on success, False on failure. + + Payload fields: + Name Type Required Description + accessToken String Yes Authorization token obtained from Chapter 2 + systemId String Yes Unique code of the power station + activeMode String Yes System active mode + startTime Long Yes Command start time, in seconds + duration Integer Yes Command duration, in minutes + chargingPower Double No Max energy storage charging/discharging power (KW) + pvPower Double No Max photovoltaic charging power (KW) + maxSellPower Double No Max export power to the grid (KW) + maxPurchasePower Double No Max purchase power from the grid (KW) + chargePriorityType Enum No Charging priority (PV/GRID) + dischargePriorityType Enum No Discharging priority (PV/BATTERY) + """ token = await self.get_access_token() if not token: @@ -898,6 +919,293 @@ def _get_inverter_max_power_kw(self, system_id): power += _safe_float(attr.get("ratedActivePower", 0)) return power + # ----------------------------------------------------------------------- + # MQTT message handlers + # ----------------------------------------------------------------------- + + def _handle_mqtt_period(self, system_id, value_dict): + """Handle a Sigenergy ``openapi/period`` MQTT message. + + Overwrites ``self.energy_flow[system_id]`` with fresh real-time data. + The ``period`` message is broadcast every ~5 s by the broker and carries + inverter and storage power/SOC values. + + Field sign convention matches the REST energyFlow convention used + throughout the rest of the code: + batteryPower — positive = charging, negative = discharging + gridPower — positive = export, negative = import + pvPower — always positive + loadPower — derived as ``pvPower − batteryPower − gridPower`` + + Example message: + { + "PV power": "0.0", + "gridPhaseCReactivePowerVar": "0.0", + "inverterReactivePowerVar": "9.0", + "inverterActivePowerW": "2681.0", + "inverterPhaseBReactivePowerVar": "0.0", + "inverterMaxAbsorptionActivePowerW": "12000.0", + "onOffGridStatus": "0.0", + "inverterPhaseAActivePowerW": "1345.0", + "inverterPhaseBActivePowerW": "0.0", + "gridActivePowerW": "3.0", + "inverterMaxFeedInActivePowerW": "12000.0", + "inverterPhaseAReactivePowerVar": "22.0", + "inverterMaxFeedInReactivePowerVar": "7200.0", + "storageChargeCapacityWh": "9520.0", + "storageDischargeCapacityWh": "37410.0", + "gridPhaseBReactivePowerVar": "0.0", + "gridPhaseAActivePowerW": "3.0", + "storageChargeDischargePowerW": "-2927.0", + "operationalMode": "6.0", + "storageSOC%": "79.7", + "systemStatus": "1.0", + "gridReactivePowerVar": "-238.0", + "inverterMaxAbsorptionReactivePowerVar": "7200.0", + "gridPhaseAReactivePowerVar": "-257.0", + "batteryMaxDischargePowerW": "36051.0", + "batteryMaxChargePowerW": "22032.0" + } + Args: + system_id: Sigenergy system identifier extracted from the MQTT topic. + value_dict: ``value`` sub-dict from the parsed MQTT payload (string→string). + """ + # Note: storageChargeDischargePowerW is negative when discharging — same + # sign convention as the REST batteryPower field (positive=charging). + bat_power_kw = _safe_float(value_dict.get("storageChargeDischargePowerW", 0)) / 1000.0 + pv_power_kw = _safe_float(value_dict.get("PV power", 0)) / 1000.0 + grid_power_kw = _safe_float(value_dict.get("gridActivePowerW", 0)) / 1000.0 + load_power_kw = pv_power_kw - bat_power_kw - grid_power_kw + + flow = { + "batterySoc": _safe_float(value_dict.get("storageSOC%", 0)), + "batteryPower": bat_power_kw, + "pvPower": pv_power_kw, + "gridPower": grid_power_kw, + "loadPower": max(0.0, load_power_kw), + "evPower": 0.0, + "inverterPower": _safe_float(value_dict.get("inverterActivePowerW", 0)) / 1000.0, + "chargeCapacityKwh": _safe_float(value_dict.get("storageChargeCapacityWh", 0)) / 1000.0, + "dischargeCapacityKwh": _safe_float(value_dict.get("storageDischargeCapacityWh", 0)) / 1000.0, + "batteryMaxChargePowerKw": _safe_float(value_dict.get("batteryMaxChargePowerW", 0)) / 1000.0, + "batteryMaxDischargePowerKw": _safe_float(value_dict.get("batteryMaxDischargePowerW", 0)) / 1000.0, + "operationalMode": _safe_float(value_dict.get("operationalMode", 0)), + "systemStatus": _safe_float(value_dict.get("systemStatus", 0)), + } + self.energy_flow[system_id] = flow + self.log( + "SigenergyAPI: MQTT period {}: SOC {:.0f}% bat {:.2f}kW pv {:.2f}kW grid {:.2f}kW load {:.2f}kW".format( + system_id, flow["batterySoc"], bat_power_kw, pv_power_kw, grid_power_kw, flow["loadPower"] + ) + ) + + def _handle_mqtt_change(self, system_id, value_dict): + """Handle a Sigenergy ``openapi/change`` MQTT message. + + Updates ``self.controls[system_id]`` with the SOC limit values and + updates capacity/power fields in ``self.systems[system_id]``. + + The ``change`` topic fires whenever the inverter's configuration + changes (e.g. via the Sigenergy app or a remote API command). + + Per the user's clarification: + backupCutOffSOC% → reserve (backup/emergency minimum) + chargeCutOffSOC% → charge target_soc + dischargeCutOffSOC% → export target_soc (minimum discharge floor) + + + example message: + { + "batteryRatedChargePowerW": "22000.0", + "batteryRatedCapabilityWh": "45200.0", + "backupCutOffSOC%": "15.0", + "inverterMaxAbsorptionPowerW": "12000.0", + "peakShavingStatus": "off", + "stormWatchStatus": "off", + "batteryRatedDischargePowerW": "24000.0", + "inverterMaxActivePowerW": "12000.0", + "dischargeCutOffSOC%": "5.0", + "chargeCutOffSOC%": "100.0", + "peakShavingCutOffSOC%": "0.0", + "inverterMaxApprentPowerVar": "12000.0", + "gridMaxBackfeedPowerW": "5000.0" + } + + Args: + system_id: Sigenergy system identifier. + value_dict: ``value`` sub-dict from the parsed MQTT payload. + """ + # Update controls + if system_id not in self.controls: + self.controls[system_id] = {} + + reserve = _safe_float(value_dict.get("backupCutOffSOC%", None), None) + if reserve is not None: + self.controls[system_id]["reserve"] = reserve + + charge_target = _safe_float(value_dict.get("chargeCutOffSOC%", None), None) + if charge_target is not None: + if "charge" not in self.controls[system_id]: + self.controls[system_id]["charge"] = {} + self.controls[system_id]["charge"]["target_soc"] = charge_target + + export_target = _safe_float(value_dict.get("dischargeCutOffSOC%", None), None) + if export_target is not None: + if "export" not in self.controls[system_id]: + self.controls[system_id]["export"] = {} + self.controls[system_id]["export"]["target_soc"] = export_target + + # Update system capacity / power limits + if system_id not in self.systems: + self.systems[system_id] = {} + + capacity_wh = _safe_float(value_dict.get("batteryRatedCapabilityWh", None)) + if capacity_wh: + self.systems[system_id]["batteryCapacity"] = capacity_wh / 1000.0 + + for mqtt_field, sys_key in ( + ("batteryRatedChargePowerW", "ratedChargePowerKw"), + ("batteryRatedDischargePowerW", "ratedDischargePowerKw"), + ("inverterMaxActivePowerW", "inverterMaxActivePowerKw"), + ("gridMaxBackfeedPowerW", "gridMaxBackfeedPowerKw"), + ): + val = _safe_float(value_dict.get(mqtt_field, None)) + if val: + self.systems[system_id][sys_key] = val / 1000.0 + + self.log( + "SigenergyAPI: MQTT change {}: reserve={}% charge_target={}% export_target={}%".format( + system_id, reserve, charge_target, export_target + ) + ) + + def _handle_mqtt_alarm(self, system_id, payload_list): + """Handle a Sigenergy ``openapi/alarm`` MQTT message. + + Args: + system_id: Sigenergy system identifier. + payload_list: Parsed payload list from the MQTT message. + """ + self.log("Warn: SigenergyAPI: MQTT alarm for system {}: {}".format(system_id, payload_list)) + + async def _mqtt_listener_loop(self): + """Persistent MQTT listener coroutine. + + Runs for the lifetime of the component (until ``self.api_stop`` is set). + On each (re)connect cycle: + 1. Refreshes the access token. + 2. Opens a TLS MQTT connection to the Sigenergy broker. + 3. Subscribes to wildcard topics for change, period and alarm data. + 4. Publishes Sigenergy application-level subscription requests. + 5. Dispatches incoming messages to the appropriate handler. + 6. Publishes updated HA entities when data changes. + 7. Reconnects automatically on any error or clean broker disconnect. + + This coroutine is started as an ``asyncio.Task`` from ``run()`` after + first successful authentication, and cancelled in ``final()``. + """ + if not HAS_AIOMQTT: + self.log("Error: SigenergyAPI: aiomqtt is not installed — MQTT listener cannot start") + return + + reconnect_delay = 5 + attempt = 0 + system_id_list = list(self.systems.keys()) if self.systems else [] + # Build per-app-key wildcard topics + topics = [ + SIGENERGY_MQTT_TOPIC_CHANGE.format(app_key=self.app_key, system_id="#"), + SIGENERGY_MQTT_TOPIC_PERIOD.format(app_key=self.app_key, system_id="#"), + SIGENERGY_MQTT_TOPIC_ALARM.format(app_key=self.app_key, system_id="#"), + ] + + while not self.api_stop: + attempt += 1 + self.log("SigenergyAPI: MQTT listener connecting (attempt #{}) ...".format(attempt)) + try: + token = await self.get_access_token() + if not token: + self.log("Warn: SigenergyAPI: MQTT token refresh failed; retrying in {}s".format(reconnect_delay)) + await asyncio.sleep(reconnect_delay) + continue + + # Refresh system list for subscription if not yet known + if not system_id_list: + system_id_list = list(self.systems.keys()) + + tls_context = self._build_tls_context() + async with aiomqtt.Client( + hostname=self.mqtt_host, + port=SIGENERGY_MQTT_PORT, + username=self.app_key, + password=token, + tls_context=tls_context, + keepalive=60, + ) as client: + self.log("SigenergyAPI: MQTT connected to {}:{}".format(self.mqtt_host, SIGENERGY_MQTT_PORT)) + for topic in topics: + await client.subscribe(topic) + + sub_payload = json.dumps({"accessToken": token, "systemIdList": system_id_list}) + for sub_topic in ("openapi/subscription/period", "openapi/subscription/change", "openapi/subscription/alarm"): + await client.publish(sub_topic, payload=sub_payload, qos=1) + self.log("SigenergyAPI: MQTT subscriptions published for {} system(s)".format(len(system_id_list))) + + async for message in client.messages: + if self.api_stop: + break + self.last_mqtt_update = time.time() + + # Parse topic: openapi/{type}/{app_key}/{system_id} + topic_str = str(message.topic) + parts = topic_str.split("/") + # Expected: ['openapi', type, app_key, system_id] + if len(parts) < 4: + continue + msg_type = parts[1] # change / period / alarm + msg_sid = parts[3] # system ID + + # Decode payload + raw = message.payload if isinstance(message.payload, (bytes, bytearray)) else str(message.payload).encode() + try: + payload = json.loads(raw.decode("utf-8", errors="replace")) + except (json.JSONDecodeError, ValueError): + self.log("Warn: SigenergyAPI: MQTT non-JSON payload on {}: {}".format(topic_str, raw[:120])) + continue + + # Each message is a list of device-level entries; process each + entries = payload if isinstance(payload, list) else [payload] + for entry in entries: + entry_sid = entry.get("systemId", msg_sid) + value_dict = entry.get("value", {}) + self.log("SigenergyAPI: MQTT message on {} for system {}: type={} value={}".format(topic_str, entry_sid, msg_type, value_dict)) + if msg_type == "period": + self._handle_mqtt_period(entry_sid, value_dict) + if self.api_started: + await self.publish_system_entities(entry_sid) + elif msg_type == "change": + self._handle_mqtt_change(entry_sid, value_dict) + if self.api_started: + await self.publish_controls(entry_sid) + await self.publish_system_entities(entry_sid) + elif msg_type == "alarm": + self._handle_mqtt_alarm(entry_sid, entries) + + # Broker closed connection cleanly + self.log("Warn: SigenergyAPI: MQTT connection closed by broker — reconnecting in {}s".format(reconnect_delay)) + await asyncio.sleep(reconnect_delay) + + except aiomqtt.MqttError as e: + self.log("Warn: SigenergyAPI: MQTT error: {} — reconnecting in {}s".format(e, reconnect_delay)) + await asyncio.sleep(reconnect_delay) + except asyncio.CancelledError: + self.log("SigenergyAPI: MQTT listener cancelled") + return + except Exception as e: + self.log("Warn: SigenergyAPI: MQTT unexpected error ({}): {} — reconnecting in {}s".format(type(e).__name__, e, reconnect_delay)) + await asyncio.sleep(reconnect_delay) + + self.log("SigenergyAPI: MQTT listener stopped") + async def publish_system_entities(self, system_id): """Publish Home Assistant entities for a system. @@ -1533,6 +1841,16 @@ async def run(self, seconds, first): self.log("Warn: SigenergyAPI: Failed to discover systems, will retry") return False + # Start (or restart) the background MQTT listener task after systems are known + if self.systems and (self._mqtt_task is None or self._mqtt_task.done()): + if self._mqtt_task is not None and self._mqtt_task.done(): + exc = self._mqtt_task.exception() if not self._mqtt_task.cancelled() else None + if exc: + self.log("Warn: SigenergyAPI: MQTT listener task exited with error: {} — restarting".format(exc)) + else: + self.log("SigenergyAPI: MQTT listener task ended — restarting") + self._mqtt_task = asyncio.ensure_future(self._mqtt_listener_loop()) + # Refresh device inventory periodically if first or seconds % SIGENERGY_DEVICE_POLL_INTERVAL == 0: for sid in list(self.systems.keys()): @@ -1544,11 +1862,17 @@ async def run(self, seconds, first): await self.fetch_controls(sid) await self.publish_controls() - # Realtime data refresh + # Realtime data refresh — skip live power/SOC fetch when MQTT is providing fresh data if first or seconds % SIGENERGY_POLL_INTERVAL == 0: + mqtt_age = time.time() - self.last_mqtt_update + mqtt_fresh = self.last_mqtt_update > 0 and mqtt_age < SIGENERGY_POLL_INTERVAL for sid in list(self.systems.keys()): - if not await self.fetch_inverter_realtime(sid): - await self.fetch_energy_flow(sid) + if mqtt_fresh: + self.log("SigenergyAPI: Skipping REST energy poll for {} (MQTT data {:.0f}s old)".format(sid, mqtt_age)) + else: + if not await self.fetch_inverter_realtime(sid): + await self.fetch_energy_flow(sid) + # Always poll daily summary — not provided by MQTT await self.fetch_daily_summary(sid) # Publish entities @@ -1573,6 +1897,17 @@ async def run(self, seconds, first): self.update_success_timestamp() return True + async def final(self): + """Cancel the background MQTT listener task on component shutdown.""" + if self._mqtt_task is not None and not self._mqtt_task.done(): + self.log("SigenergyAPI: Cancelling MQTT listener task") + self._mqtt_task.cancel() + try: + await self._mqtt_task + except (asyncio.CancelledError, Exception): + pass + self.log("SigenergyAPI: final() complete") + class MockBase: # pragma: no cover """Mock base class for standalone testing.""" @@ -1769,27 +2104,21 @@ def _window(offset_start_min, offset_end_min): async def test_mqtt_connection(app_key, app_secret, base_url, system_id=None, topic_filter=None, ca_cert=None, client_cert=None, client_key=None): # pragma: no cover """Connect to the Sigenergy MQTT broker, request push data, and print all incoming messages. - Authenticates using app_key/app_secret, then: - 1. Subscribes at MQTT protocol level to *topic_filter* to receive all broker messages. - 2. Publishes to ``openapi/subscription/period`` with the Sigenergy subscription - request payload (accessToken + systemIdList) so the broker starts pushing - periodic telemetry data. - 3. Prints every received message to stdout until the user presses Ctrl+C. - - The subscription payload follows the spec at - https://developer.sigencloud.com/user/api/document/45: - { "accessToken": "", "systemIdList": ["", ...] } + Delegates to ``SigenergyAPI._mqtt_listener_loop()`` which is the single + implementation of the reconnect/subscribe/dispatch logic used by both the + test CLI and the live component. Received messages are printed to stdout + via ``SigenergyAPI.log()`` (which maps to ``print()`` in the mock base). Args: app_key: Sigenergy Application Key. app_secret: Sigenergy Application Secret. base_url: REST API base URL (MQTT host is derived from this). system_id: Optional system ID (or comma-separated list) to subscribe to. - When None, a full system scan is performed first to discover - all authorised system IDs. - topic_filter: MQTT protocol-level topic filter. When None the function - builds per-app-key wildcard topics for change, period and - alarm data. Pass '#' to receive every broker message. + topic_filter: Unused in this implementation; wildcard topics are built + automatically from the app_key. + ca_cert: Path to CA certificate PEM file for TLS verification. + client_cert: Path to client certificate PEM file for mutual TLS. + client_key: Path to client private key PEM file for mutual TLS. Returns: 0 on clean exit, 1 on error. @@ -1802,13 +2131,6 @@ async def test_mqtt_connection(app_key, app_secret, base_url, system_id=None, to print("Sigenergy MQTT test mode") print("Base URL : {}".format(base_url)) print("App Key : {}...".format(app_key[:10] if len(app_key) >= 10 else app_key)) - default_topics = [ - SIGENERGY_MQTT_TOPIC_CHANGE.format(app_key=app_key, system_id="#"), - SIGENERGY_MQTT_TOPIC_PERIOD.format(app_key=app_key, system_id="#"), - SIGENERGY_MQTT_TOPIC_ALARM.format(app_key=app_key, system_id="#"), - ] - topics_to_subscribe = [topic_filter] if topic_filter else default_topics - print("Topics : {}".format(", ".join(topics_to_subscribe))) print("{}\n".format("=" * 60)) mock_base = MockBase() @@ -1823,6 +2145,7 @@ async def test_mqtt_connection(app_key, app_secret, base_url, system_id=None, to system_id=system_id, ) + # Authenticate and discover systems so the listener knows what to subscribe to token = await sig.get_access_token() if not token: print("x Authentication failed") @@ -1834,95 +2157,30 @@ async def test_mqtt_connection(app_key, app_secret, base_url, system_id=None, to if sig.client_cert: print("+ Client cert: {}".format(sig.client_cert)) - # Resolve system ID list for the subscription request if system_id: - system_id_list = [s.strip() for s in system_id.split(",") if s.strip()] + # Populate systems dict from the provided IDs so the listener uses them + for sid in [s.strip() for s in system_id.split(",") if s.strip()]: + if sid not in sig.systems: + sig.systems[sid] = {} else: print("+ No --system-id provided; scanning for authorised systems ...") await sig.fetch_system_list() - system_id_list = list(sig.systems.keys()) - if not system_id_list: + if not sig.systems: print("x No systems found; cannot build subscription request") return 1 - print("+ Found systems: {}".format(system_id_list)) + print("+ Found systems: {}".format(list(sig.systems.keys()))) - mqtt_host = sig.mqtt_host - print("+ Connecting to MQTT broker {}:{} (TLS) ...".format(mqtt_host, SIGENERGY_MQTT_PORT)) + print("+ Topics: change, period, alarm for app_key wildcard") + print("+ Waiting for messages (Ctrl+C to stop) ...\n") - tls_context = sig._build_tls_context() - reconnect_delay = 5 - attempt = 0 + # Run the shared listener loop — it handles reconnect, subscribe, dispatch + # and prints via self.log() → MockBase.log() → print() + sig.api_stop = False try: - while True: - attempt += 1 - ts = datetime.now().strftime("%H:%M:%S") - print("[{}] Connection attempt #{} ...".format(ts, attempt)) - try: - # Refresh token on each (re)connect in case it expired - print("[{}] Refreshing access token ...".format(datetime.now().strftime("%H:%M:%S"))) - token = await sig.get_access_token() - if not token: - print("[{}] x Token refresh failed; retrying in {}s ...".format(datetime.now().strftime("%H:%M:%S"), reconnect_delay)) - await asyncio.sleep(reconnect_delay) - continue - print("[{}] Token OK".format(datetime.now().strftime("%H:%M:%S"))) - - print("[{}] Opening MQTT connection ...".format(datetime.now().strftime("%H:%M:%S"))) - async with aiomqtt.Client( - hostname=sig.mqtt_host, - port=SIGENERGY_MQTT_PORT, - username=app_key, - password=token, - tls_context=tls_context, - keepalive=60, - ) as client: - print("[{}] MQTT connected".format(datetime.now().strftime("%H:%M:%S"))) - # MQTT protocol-level subscribe to the relevant topics - for topic in topics_to_subscribe: - await client.subscribe(topic) - print("[{}] + Subscribed to '{}'".format(datetime.now().strftime("%H:%M:%S"), topic)) - - # Sigenergy application-level subscriptions: publish to each subscription topic - # so the broker starts pushing the respective data types. - sub_payload = {"accessToken": token, "systemIdList": system_id_list} - sub_json = json.dumps(sub_payload) - for sub_topic in ("openapi/subscription/period", "openapi/subscription/change", "openapi/subscription/alarm"): - await client.publish(sub_topic, payload=sub_json, qos=1) - print("[{}] + Published subscription: {}".format(datetime.now().strftime("%H:%M:%S"), sub_topic)) - print("[{}] Waiting for messages (Ctrl+C to stop) ...\n".format(datetime.now().strftime("%H:%M:%S"))) - - async for message in client.messages: - payload_bytes = message.payload if isinstance(message.payload, (bytes, bytearray)) else str(message.payload).encode() - payload_str = payload_bytes.decode("utf-8", errors="replace") - try: - payload_display = json.dumps(json.loads(payload_str), indent=2) - except (json.JSONDecodeError, ValueError): - payload_display = payload_str - print("[{}] Topic: {}".format(datetime.now().strftime("%H:%M:%S.%f")[:-3], message.topic)) - print(" QoS: {} Retain: {}".format(message.qos, message.retain)) - print(" Payload: {}".format(payload_display)) - print() - - # If we reach here the broker closed the connection cleanly (no exception) - print("[{}] ! MQTT message loop ended (broker closed connection) — reconnecting in {}s ...".format(datetime.now().strftime("%H:%M:%S"), reconnect_delay)) - await asyncio.sleep(reconnect_delay) - - except aiomqtt.MqttError as e: - import traceback - print("[{}] ! MqttError: {} — reconnecting in {}s ...".format(datetime.now().strftime("%H:%M:%S"), e, reconnect_delay)) - print(traceback.format_exc()) - await asyncio.sleep(reconnect_delay) - except Exception as e: - import traceback - print("[{}] ! Unexpected error ({}): {} — reconnecting in {}s ...".format(datetime.now().strftime("%H:%M:%S"), type(e).__name__, e, reconnect_delay)) - print(traceback.format_exc()) - await asyncio.sleep(reconnect_delay) - + await sig._mqtt_listener_loop() except KeyboardInterrupt: + sig.api_stop = True print("\n[{}] Ctrl+C received".format(datetime.now().strftime("%H:%M:%S"))) - - print("\n+ MQTT test session ended") - return 0 def main(): # pragma: no cover diff --git a/apps/predbat/tests/test_sigenergy.py b/apps/predbat/tests/test_sigenergy.py index f3e93b53e..739057bad 100644 --- a/apps/predbat/tests/test_sigenergy.py +++ b/apps/predbat/tests/test_sigenergy.py @@ -12,7 +12,7 @@ import asyncio from datetime import datetime, timezone, timedelta -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch from sigenergy import ( SigenergyAPI, @@ -22,7 +22,20 @@ _safe_float, _safe_int, ) -from tests.test_infra import run_async +from tests.test_infra import run_async as _base_run_async + + +def run_async(coro): + """Like test_infra.run_async but makes all sleeps in sigenergy instant. + + Patches the retry/rate-limit delay constants to 0 so that asyncio.sleep(0) + completes in a single event loop tick. Also patches asyncio.sleep itself + with AsyncMock as a belt-and-suspenders measure. + """ + with patch("sigenergy.SIGENERGY_COMMAND_RETRY_DELAY", 0): + with patch("sigenergy.SIGENERGY_MIN_REQUEST_INTERVAL", 0): + with patch("sigenergy.asyncio.sleep", new_callable=AsyncMock): + return _base_run_async(coro) def _make_mock_response(status=200, json_data=None): @@ -100,6 +113,9 @@ def __init__(self, prefix="predbat"): automatic=False, enable_controls=True, ) + # ComponentBase attributes not set by initialize() — wire them manually + self.api_started = False + self.api_stop = False # Skip mode-switch → command delay in unit tests self._command_delay = 0 @@ -142,21 +158,6 @@ async def _publish_mqtt(self, topic, payload_dict): return True -# --------------------------------------------------------------------------- -# Helper -# --------------------------------------------------------------------------- - - -def run_async(coro): - """Run a coroutine synchronously for test purposes.""" - try: - loop = asyncio.get_event_loop() - except RuntimeError: - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - return loop.run_until_complete(coro) - - # --------------------------------------------------------------------------- # Tests # --------------------------------------------------------------------------- @@ -872,6 +873,202 @@ async def mock_get_access_token(): return failed +def test_sigenergy_handle_mqtt_period(my_predbat): + """Test _handle_mqtt_period populates energy_flow correctly from a period message.""" + failed = False + api = MockSigenergyAPI() + + value_dict = { + "storageSOC%": "79.7", + "storageChargeDischargePowerW": "-2927.0", # negative = discharging + "PV power": "0.0", + "gridActivePowerW": "3.0", + "inverterActivePowerW": "2681.0", + "storageChargeCapacityWh": "9520.0", + "storageDischargeCapacityWh": "37410.0", + "batteryMaxChargePowerW": "22032.0", + "batteryMaxDischargePowerW": "36051.0", + "operationalMode": "6.0", + "systemStatus": "1.0", + } + + api._handle_mqtt_period("SYS1", value_dict) + + flow = api.energy_flow.get("SYS1", {}) + assert abs(flow["batterySoc"] - 79.7) < 0.01, "batterySoc = 79.7%" + # storageChargeDischargePowerW -2927 W = -2.927 kW (discharging, negative = energyFlow convention) + assert abs(flow["batteryPower"] - (-2.927)) < 0.01, "batteryPower = -2.927 kW" + assert abs(flow["pvPower"] - 0.0) < 0.001, "pvPower = 0.0" + assert abs(flow["gridPower"] - 0.003) < 0.001, "gridPower = 0.003 kW" + # loadPower = pv - bat - grid = 0 - (-2.927) - 0.003 = 2.924 + assert abs(flow["loadPower"] - 2.924) < 0.01, "loadPower derived = 2.924 kW" + assert abs(flow["inverterPower"] - 2.681) < 0.001, "inverterPower = 2.681 kW" + assert abs(flow["chargeCapacityKwh"] - 9.52) < 0.01, "chargeCapacityKwh = 9.52" + assert abs(flow["dischargeCapacityKwh"] - 37.41) < 0.01, "dischargeCapacityKwh = 37.41" + assert abs(flow["batteryMaxChargePowerKw"] - 22.032) < 0.01, "batteryMaxChargePowerKw = 22.032" + assert abs(flow["batteryMaxDischargePowerKw"] - 36.051) < 0.01, "batteryMaxDischargePowerKw = 36.051" + assert flow["operationalMode"] == 6.0, "operationalMode = 6.0" + assert flow["systemStatus"] == 1.0, "systemStatus = 1.0" + assert any("MQTT period" in m and "80" in m for m in api.log_messages), "Period data logged" + + return failed + + +def test_sigenergy_handle_mqtt_change(my_predbat): + """Test _handle_mqtt_change updates controls and systems from a change message.""" + failed = False + api = MockSigenergyAPI() + api.systems["SYS1"] = {"systemName": "Test System"} + + value_dict = { + "batteryRatedChargePowerW": "22000.0", + "batteryRatedCapabilityWh": "45200.0", + "backupCutOffSOC%": "15.0", + "batteryRatedDischargePowerW": "24000.0", + "inverterMaxActivePowerW": "12000.0", + "dischargeCutOffSOC%": "5.0", + "chargeCutOffSOC%": "100.0", + "gridMaxBackfeedPowerW": "5000.0", + } + + api._handle_mqtt_change("SYS1", value_dict) + + # Controls + assert api.controls["SYS1"]["reserve"] == 15, "reserve = 15 (backupCutOffSOC%)" + assert api.controls["SYS1"]["charge"]["target_soc"] == 100, "charge target_soc = 100 (chargeCutOffSOC%)" + assert api.controls["SYS1"]["export"]["target_soc"] == 5, "export target_soc = 5 (dischargeCutOffSOC%)" + + # System capacity and power limits + sys = api.systems["SYS1"] + assert abs(sys["batteryCapacity"] - 45.2) < 0.01, "batteryCapacity = 45.2 kWh" + assert abs(sys["ratedChargePowerKw"] - 22.0) < 0.01, "ratedChargePowerKw = 22.0" + assert abs(sys["ratedDischargePowerKw"] - 24.0) < 0.01, "ratedDischargePowerKw = 24.0" + assert abs(sys["inverterMaxActivePowerKw"] - 12.0) < 0.01, "inverterMaxActivePowerKw = 12.0" + assert abs(sys["gridMaxBackfeedPowerKw"] - 5.0) < 0.01, "gridMaxBackfeedPowerKw = 5.0" + assert any("MQTT change" in m for m in api.log_messages), "Change data logged" + + return failed + + +def test_sigenergy_handle_mqtt_alarm(my_predbat): + """Test _handle_mqtt_alarm logs a warning.""" + failed = False + api = MockSigenergyAPI() + api._handle_mqtt_alarm("SYS1", [{"alarmCode": "E001", "alarmMsg": "Overvoltage"}]) + assert any("alarm" in m.lower() and "SYS1" in m for m in api.log_messages), "Alarm warning logged" + return failed + + +def test_sigenergy_mqtt_listener_loop(my_predbat): + """Test _mqtt_listener_loop dispatches period and change messages and stops on api_stop.""" + failed = False + import json as _json + api = MockSigenergyAPI() + api.access_token = "tok" + api.token_expires_at = 9_999_999_999 + api.systems["XRTKQ1773829273"] = {"systemName": "Test"} + api.api_stop = False + + period_payload = _json.dumps([{ + "deviceType": "system", + "systemId": "XRTKQ1773829273", + "value": { + "storageSOC%": "55.0", + "storageChargeDischargePowerW": "1000.0", + "PV power": "2000.0", + "gridActivePowerW": "500.0", + }, + }]).encode() + + change_payload = _json.dumps([{ + "deviceType": "system", + "systemId": "XRTKQ1773829273", + "value": { + "backupCutOffSOC%": "20.0", + "chargeCutOffSOC%": "95.0", + "dischargeCutOffSOC%": "10.0", + }, + }]).encode() + + # Build fake MQTT messages + class FakeMessage: + def __init__(self, topic, payload): + self.topic = topic + self.payload = payload + self.qos = 0 + self.retain = False + + messages_to_deliver = [ + FakeMessage("openapi/period/test_app_key/XRTKQ1773829273", period_payload), + FakeMessage("openapi/change/test_app_key/XRTKQ1773829273", change_payload), + ] + + publishes = [] + + class FakeMQTTClient: + """Async context manager that yields two messages then exits cleanly.""" + + async def __aenter__(self): + return self + + async def __aexit__(self, *args): + pass + + async def subscribe(self, topic): + pass + + async def publish(self, topic, payload=None, qos=0, **kwargs): + publishes.append((topic, payload)) + + # Make client.messages an async iterable that yields the two messages + # and then sets api_stop so the outer loop exits after one connection cycle. + class _Messages: + def __init__(self, msgs, api): + self._msgs = iter(msgs) + self._api = api + + def __aiter__(self): + return self + + async def __anext__(self): + try: + return next(self._msgs) + except StopIteration: + # Signal the outer loop to stop, then end this iteration + self._api.api_stop = True + raise StopAsyncIteration + + @property + def messages(self): + return FakeMQTTClient._Messages(messages_to_deliver, api) + + with patch("sigenergy.aiomqtt.Client", return_value=FakeMQTTClient()): + with patch("sigenergy.ssl.create_default_context", return_value=MagicMock()): + run_async(api._mqtt_listener_loop()) + + # Period message: energy_flow updated + flow = api.energy_flow.get("XRTKQ1773829273", {}) + assert abs(flow.get("batterySoc", 0) - 55.0) < 0.01, "batterySoc from MQTT period = 55%" + assert abs(flow.get("batteryPower", 0) - 1.0) < 0.01, "batteryPower = 1.0 kW (charging)" + + # Change message: controls updated + ctrl = api.controls.get("XRTKQ1773829273", {}) + assert ctrl.get("reserve") == 20, "reserve = 20 from MQTT change" + assert ctrl.get("charge", {}).get("target_soc") == 95, "charge target_soc = 95" + assert ctrl.get("export", {}).get("target_soc") == 10, "export target_soc = 10" + + # Subscription requests published (3: period, change, alarm) + sub_topics = [t for t, _ in publishes] + assert "openapi/subscription/period" in sub_topics, "period subscription published" + assert "openapi/subscription/change" in sub_topics, "change subscription published" + assert "openapi/subscription/alarm" in sub_topics, "alarm subscription published" + + # last_mqtt_update was set + assert api.last_mqtt_update > 0, "last_mqtt_update was set" + + return failed + + def test_sigenergy_fetch_inverter_realtime(my_predbat): """Test fetch_inverter_realtime maps realtimeInfo fields to energy_flow correctly.""" failed = False @@ -1011,6 +1208,10 @@ def run_sigenergy_tests(my_predbat): ("publish_mqtt_failure", test_sigenergy_publish_mqtt_failure), ("send_battery_command_mqtt", test_sigenergy_send_battery_command_mqtt), ("send_battery_command_no_token", test_sigenergy_send_battery_command_no_token), + ("handle_mqtt_period", test_sigenergy_handle_mqtt_period), + ("handle_mqtt_change", test_sigenergy_handle_mqtt_change), + ("handle_mqtt_alarm", test_sigenergy_handle_mqtt_alarm), + ("mqtt_listener_loop", test_sigenergy_mqtt_listener_loop), ("fetch_inverter_realtime", test_sigenergy_fetch_inverter_realtime), ("fetch_inverter_realtime_no_inverter", test_sigenergy_fetch_inverter_realtime_no_inverter), ("get_inverter_serial", test_sigenergy_get_inverter_serial), From 821e14212b054656609a6f0fb4a7d1d15706c715 Mon Sep 17 00:00:00 2001 From: Trefor Southwell Date: Mon, 25 May 2026 15:17:02 +0100 Subject: [PATCH 11/18] Ongoing dev --- apps/predbat/sigenergy.py | 136 ++++++++++++++++----------- apps/predbat/tests/test_sigenergy.py | 30 +++--- 2 files changed, 94 insertions(+), 72 deletions(-) diff --git a/apps/predbat/sigenergy.py b/apps/predbat/sigenergy.py index ad3155516..4e4120468 100644 --- a/apps/predbat/sigenergy.py +++ b/apps/predbat/sigenergy.py @@ -124,6 +124,7 @@ # Operating mode enums (REST mode switch endpoint — MSC and FFG only; NBI is not used) SIGENERGY_MODE_MSC = 0 # Maximum Self-Consumption (eco) SIGENERGY_MODE_FFG = 5 # Fully Feed-in to Grid +SIGENERGY_MODE_VPP = 6 # VPP mode SIGENERGY_MODE_NBI = 8 # NorthBound (defined for completeness; not switched to by this component) # Battery command activeMode strings @@ -234,9 +235,8 @@ def initialize(self, app_key, app_secret, base_url=None, mqtt_host=None, ca_cert # Control state keyed by systemId self.controls = {} # systemId → {charge: {…}, export: {…}, reserve: …} - # Mode-change deduplication - self.current_mode_hash = {} # systemId → hash of last applied command - self.current_mode_hash_timestamp = {} # systemId → datetime of last applied command + # Battery command de-duplication — keyed by systemId → (command_hash, startTime) + self._last_battery_command = {} # Rate-limit tracking self._last_request_time = 0.0 @@ -679,6 +679,7 @@ async def fetch_current_mode(self, system_id): mode_int = _safe_int(data.get("energyStorageOperationMode", SIGENERGY_MODE_MSC)) self.current_mode[system_id] = mode_int + self.log("SigenergyAPI: System {} current operating mode: {}".format(system_id, mode_int)) return True # ----------------------------------------------------------------------- @@ -802,13 +803,24 @@ async def _publish_mqtt(self, topic, payload_dict): keepalive=30, ) as client: await client.publish(topic, payload=json.dumps(payload_dict), qos=1) - self.log("SigenergyAPI: MQTT published to {}".format(topic)) + self.log("SigenergyAPI: MQTT published to {} - {}".format(topic, payload_dict)) return True except Exception as e: self.log("Warn: SigenergyAPI: MQTT publish to {} failed: {}".format(topic, e)) return False - async def send_battery_command(self, system_id, active_mode, duration_minutes, charging_power_kw=None): + async def send_battery_command( + self, + system_id, + active_mode, + duration_minutes, + charging_power_kw=None, + pv_power_kw=None, + max_sell_power_kw=None, + max_purchase_power_kw=None, + charge_priority_type=None, + discharge_priority_type=None, + ): """Send a battery command via MQTT to the Sigenergy broker. Publishes to the MQTT topic ``openapi/instruction/command``. A fresh @@ -818,27 +830,39 @@ async def send_battery_command(self, system_id, active_mode, duration_minutes, c system_id: Sigenergy system unique identifier. active_mode: One of the SIGENERGY_ACTIVE_MODE_* string constants. duration_minutes: Command duration in minutes (max ~720). - charging_power_kw: Charging/discharging power in kW. Required for - charge and discharge modes; optional otherwise. + charging_power_kw: Max energy storage charging/discharging power (kW). + pv_power_kw: Max photovoltaic charging power (kW). + max_sell_power_kw: Max export power to the grid (kW). + max_purchase_power_kw: Max purchase power from the grid (kW). + charge_priority_type: Charging priority enum string ('PV' or 'GRID'). + discharge_priority_type: Discharging priority enum string ('PV' or 'BATTERY'). Returns: True on success, False on failure. - Payload fields: - Name Type Required Description - accessToken String Yes Authorization token obtained from Chapter 2 - systemId String Yes Unique code of the power station - activeMode String Yes System active mode - startTime Long Yes Command start time, in seconds - duration Integer Yes Command duration, in minutes - chargingPower Double No Max energy storage charging/discharging power (KW) - pvPower Double No Max photovoltaic charging power (KW) - maxSellPower Double No Max export power to the grid (KW) - maxPurchasePower Double No Max purchase power from the grid (KW) - chargePriorityType Enum No Charging priority (PV/GRID) - dischargePriorityType Enum No Discharging priority (PV/BATTERY) + Payload fields: + Name Type Required Description + accessToken String Yes Authorization token obtained from Chapter 2 + systemId String Yes Unique code of the power station + activeMode String Yes System active mode + startTime Long Yes Command start time, in seconds + duration Integer Yes Command duration, in minutes + chargingPower Double No Max energy storage charging/discharging power (KW) + pvPower Double No Max photovoltaic charging power (KW) + maxSellPower Double No Max export power to the grid (KW) + maxPurchasePower Double No Max purchase power from the grid (KW) + chargePriorityType Enum No Charging priority (PV/GRID) + dischargePriorityType Enum No Discharging priority (PV/BATTERY) """ + # De-duplication: skip if identical command sent within the last 5 minutes + command_hash = hash((active_mode, charging_power_kw, pv_power_kw, max_sell_power_kw, max_purchase_power_kw, charge_priority_type, discharge_priority_type)) + last_hash, last_start_time = self._last_battery_command.get(system_id, (None, 0)) + now_ts = int(time.time()) + if last_hash == command_hash and (now_ts - last_start_time) < 300: + self.log("SigenergyAPI: Skipping duplicate battery command {} for system {} ({:.1f} min ago)".format(active_mode, system_id, (now_ts - last_start_time) / 60)) + return True + token = await self.get_access_token() if not token: self.log("Warn: SigenergyAPI: No access token for MQTT battery command") @@ -848,16 +872,29 @@ async def send_battery_command(self, system_id, active_mode, duration_minutes, c "accessToken": token, "systemId": system_id, "activeMode": active_mode, - "startTime": int(time.time()), + "startTime": now_ts, "duration": int(duration_minutes), } if charging_power_kw is not None: payload["chargingPower"] = round(charging_power_kw, 2) + if pv_power_kw is not None: + payload["pvPower"] = round(pv_power_kw, 2) + if max_sell_power_kw is not None: + payload["maxSellPower"] = round(max_sell_power_kw, 2) + if max_purchase_power_kw is not None: + payload["maxPurchasePower"] = round(max_purchase_power_kw, 2) + if charge_priority_type is not None: + payload["chargePriorityType"] = charge_priority_type + if discharge_priority_type is not None: + payload["dischargePriorityType"] = discharge_priority_type self.log("SigenergyAPI: Sending MQTT battery command {} ({} min, {:.2f}kW) to system {}".format( active_mode, duration_minutes, charging_power_kw or 0.0, system_id)) - return await self._publish_mqtt(SIGENERGY_MQTT_TOPIC_COMMAND, payload) + ok = await self._publish_mqtt(SIGENERGY_MQTT_TOPIC_COMMAND, payload) + if ok: + self._last_battery_command[system_id] = (command_hash, now_ts) + return ok # ----------------------------------------------------------------------- # HA entity publishing @@ -1725,6 +1762,9 @@ async def apply_controls(self, system_id): export_target_soc = _safe_int(self.controls[system_id].get("export", {}).get("target_soc", 0), 0) export_rate_w = _safe_int(self.controls[system_id].get("export", {}).get("rate", round(battery_max_kw * 1000)), round(battery_max_kw * 1000)) reserve_soc = _safe_int(self.controls[system_id].get("reserve", 10), 10) + charge_power_kw = None + charge_priority_type=None + discharge_priority_type=None def parse_window(start_str, end_str): """Return (start_dt, end_dt) adjusted for midnight-spanning windows.""" @@ -1759,12 +1799,11 @@ def parse_window(start_str, end_str): if effective_target >= battery_soc_pct: # Already at or below target — freeze (idle) new_mode = "freeze_export" - active_mode = SIGENERGY_ACTIVE_MODE_IDLE - power_kw = 0.0 + active_mode = SIGENERGY_ACTIVE_MODE_SELF_GRID else: new_mode = "export" active_mode = SIGENERGY_ACTIVE_MODE_DISCHARGE - power_kw = export_rate_w / 1000.0 + discharge_priority_type = "PV" elif charge_window and charge_start_dt and charge_end_dt: duration_min = max(1, int((charge_end_dt - now).total_seconds() / 60)) effective_target = max(charge_target_soc, reserve_soc) @@ -1772,45 +1811,27 @@ def parse_window(start_str, end_str): # Freeze charge — stay at current SOC new_mode = "freeze_charge" active_mode = SIGENERGY_ACTIVE_MODE_SELF - power_kw = 0.0 + charge_power_kw = 0 elif effective_target < battery_soc_pct: # Target below current — go to eco new_mode = "eco" active_mode = SIGENERGY_ACTIVE_MODE_SELF - power_kw = 0.0 else: new_mode = "charge" active_mode = SIGENERGY_ACTIVE_MODE_CHARGE - power_kw = charge_rate_w / 1000.0 + charge_power_kw = charge_rate_w / 1000.0 + charge_priority_type = "PV" else: - duration_min = 60 + duration_min = 720 new_mode = "eco" active_mode = SIGENERGY_ACTIVE_MODE_SELF - power_kw = 0.0 duration_min = min(duration_min, 720) - # Deduplication — skip if mode unchanged in last 15 minutes - new_hash = hash((new_mode, round(power_kw, 2), duration_min)) - old_hash = self.current_mode_hash.get(system_id) - old_ts = self.current_mode_hash_timestamp.get(system_id) - if old_hash is not None and old_hash == new_hash and old_ts is not None: - age = (now - old_ts).total_seconds() - if age < 15 * 60: - self.log("SigenergyAPI: Mode unchanged for system {} ({} — {:.1f} min ago), skipping".format(system_id, new_mode, age / 60)) - return True - - self.log("SigenergyAPI: Applying mode={} power={:.2f}kW duration={}min to system {}".format(new_mode, power_kw, duration_min, system_id)) - - # Send battery command via MQTT — no mode pre-switch required - ok = await self.send_battery_command(system_id, active_mode, duration_min, charging_power_kw=power_kw if power_kw > 0 else None) - success = ok + self.log("SigenergyAPI: Applying mode={} charge_power_kw={}kW duration={}min charge_priority_type={} discharge_priority_type={} to system {}".format(new_mode, "{:.2f}".format(charge_power_kw) if charge_power_kw is not None else "None", duration_min, charge_priority_type, discharge_priority_type, system_id)) - if success: - self.current_mode_hash[system_id] = new_hash - self.current_mode_hash_timestamp[system_id] = now - - return success + # Send battery command via MQTT — de-duplication handled inside send_battery_command + return await self.send_battery_command(system_id, active_mode, duration_min, charging_power_kw=charge_power_kw, charge_priority_type=charge_priority_type, discharge_priority_type=discharge_priority_type) # ----------------------------------------------------------------------- # Main run loop @@ -2015,14 +2036,17 @@ async def test_sigenergy_api(app_key, app_secret, base_url, system_id, test_mode print(" Results: {}".format(board_result)) return 0 - result = await sig.run(first=True, seconds=0) - if not result: - print("x Initialisation failed") - return 1 - print("+ Initialisation successful") + if not test_mode: + result = await sig.run(first=True, seconds=0) + if not result: + print("x Initialisation failed") + return 1 + print("+ Initialisation successful") - if test_mode and sig.systems: + if test_mode: + await sig.fetch_system_list() sid = list(sig.systems.keys())[0] + await sig.fetch_current_mode(sid) flow = sig.energy_flow.get(sid, {}) battery_soc_pct = _safe_float(flow.get("batterySoc", 50)) now = datetime.now(sig.local_tz) diff --git a/apps/predbat/tests/test_sigenergy.py b/apps/predbat/tests/test_sigenergy.py index 739057bad..0c13108c1 100644 --- a/apps/predbat/tests/test_sigenergy.py +++ b/apps/predbat/tests/test_sigenergy.py @@ -666,7 +666,7 @@ async def mock_send_battery_command(sid, active_mode, duration_min, charging_pow def test_sigenergy_apply_controls_deduplication(my_predbat): - """Test that apply_controls skips redundant commands within 15 minutes.""" + """Test that send_battery_command skips redundant commands within 5 minutes.""" failed = False api = MockSigenergyAPI() system_id = "SIG001" @@ -678,29 +678,27 @@ def test_sigenergy_apply_controls_deduplication(my_predbat): "export": {"enable": False, "start_time": "00:00", "end_time": "00:00", "target_soc": 0, "rate": 3000}, "reserve": 10, } + # Provide a valid token so send_battery_command doesn't bail early + api.access_token = "fake_token" + api.token_expires_at = 9_999_999_999 - call_count = {"count": 0} - - async def mock_set_operating_mode(sid, mode): - call_count["count"] += 1 - return True + publish_count = {"count": 0} - async def mock_send_battery_command(sid, active_mode, duration_min, charging_power_kw=None): - call_count["count"] += 1 + async def mock_publish_mqtt(topic, payload_dict): + publish_count["count"] += 1 return True - api.set_operating_mode = mock_set_operating_mode - api.send_battery_command = mock_send_battery_command + api._publish_mqtt = mock_publish_mqtt - # First call + # First call — should publish run_async(api.apply_controls(system_id)) - first_count = call_count["count"] - assert first_count >= 1, "Commands sent on first call" + first_count = publish_count["count"] + assert first_count >= 1, "Command published on first call" - # Second call immediately after — mode unchanged, should skip + # Second call immediately after — same command, should be de-duplicated run_async(api.apply_controls(system_id)) - second_count = call_count["count"] - assert second_count == first_count, "No additional commands sent within 15-min dedup window (first={}, second={})".format(first_count, second_count) + second_count = publish_count["count"] + assert second_count == first_count, "No additional publish within 5-min dedup window (first={}, second={})".format(first_count, second_count) return failed From cba12cc34202d200bfcb5ae342a914c6381df022 Mon Sep 17 00:00:00 2001 From: Trefor Southwell Date: Mon, 25 May 2026 16:33:43 +0100 Subject: [PATCH 12/18] Fix control commands --- apps/predbat/sigenergy.py | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/apps/predbat/sigenergy.py b/apps/predbat/sigenergy.py index 4e4120468..f499aa609 100644 --- a/apps/predbat/sigenergy.py +++ b/apps/predbat/sigenergy.py @@ -870,10 +870,12 @@ async def send_battery_command( payload = { "accessToken": token, - "systemId": system_id, - "activeMode": active_mode, - "startTime": now_ts, - "duration": int(duration_minutes), + "commands": [{ + "systemId": system_id, + "activeMode": active_mode, + "startTime": now_ts, + "duration": int(duration_minutes) + }] } if charging_power_kw is not None: payload["chargingPower"] = round(charging_power_kw, 2) @@ -1101,10 +1103,10 @@ def _handle_mqtt_change(self, system_id, value_dict): self.systems[system_id]["batteryCapacity"] = capacity_wh / 1000.0 for mqtt_field, sys_key in ( - ("batteryRatedChargePowerW", "ratedChargePowerKw"), - ("batteryRatedDischargePowerW", "ratedDischargePowerKw"), - ("inverterMaxActivePowerW", "inverterMaxActivePowerKw"), - ("gridMaxBackfeedPowerW", "gridMaxBackfeedPowerKw"), + ("batteryRatedChargePowerW", "ratedChargePower"), + ("batteryRatedDischargePowerW", "ratedDischargePower"), + ("inverterMaxActivePowerW", "ratedActivePower"), + ("gridMaxBackfeedPowerW", "gridMaxBackfeedPower"), ): val = _safe_float(value_dict.get(mqtt_field, None)) if val: @@ -2046,7 +2048,7 @@ async def test_sigenergy_api(app_key, app_secret, base_url, system_id, test_mode if test_mode: await sig.fetch_system_list() sid = list(sig.systems.keys())[0] - await sig.fetch_current_mode(sid) + #await sig.fetch_current_mode(sid) flow = sig.energy_flow.get(sid, {}) battery_soc_pct = _safe_float(flow.get("batterySoc", 50)) now = datetime.now(sig.local_tz) From 6dbc19cff8e228fc3ce33cbe641e75b59b851015 Mon Sep 17 00:00:00 2001 From: Trefor Southwell Date: Mon, 25 May 2026 18:18:04 +0100 Subject: [PATCH 13/18] Kind of works --- apps/predbat/components.py | 2 +- apps/predbat/config.py | 29 ++++++ apps/predbat/sigenergy.py | 136 ++++++++++++++++++++++----- apps/predbat/tests/test_sigenergy.py | 80 +++++++++++++--- apps/predbat/web.py | 2 +- 5 files changed, 211 insertions(+), 38 deletions(-) diff --git a/apps/predbat/components.py b/apps/predbat/components.py index f7b4f0b8c..ac39a8bc0 100644 --- a/apps/predbat/components.py +++ b/apps/predbat/components.py @@ -351,6 +351,7 @@ "name": "Sigenergy Cloud API", "event_filter": "predbat_sigenergy_", "args": { + "system_id": {"required": True, "config": "sigenergy_system_id"}, "app_key": {"required": True, "config": "sigenergy_app_key"}, "app_secret": {"required": True, "config": "sigenergy_app_secret"}, "base_url": {"required": False, "config": "sigenergy_base_url", "default": "https://openapi-eu.sigencloud.com"}, @@ -358,7 +359,6 @@ "ca_cert": {"required": False, "config": "sigenergy_ca_pem"}, "client_cert": {"required": False, "config": "sigenergy_client_pem"}, "client_key": {"required": False, "config": "sigenergy_client_key"}, - "system_id": {"required": False, "config": "sigenergy_system_id"}, "automatic": {"required": False, "config": "sigenergy_automatic", "default": False}, "enable_controls": {"required": False, "config": "sigenergy_enable_controls", "default": True}, }, diff --git a/apps/predbat/config.py b/apps/predbat/config.py index 4a79baca6..ede209001 100644 --- a/apps/predbat/config.py +++ b/apps/predbat/config.py @@ -1951,6 +1951,35 @@ "charge_discharge_with_rate": False, "target_soc_used_for_discharge": True, }, + "SIGCLOUD": { + "name": "SigEnergyCloud", + "has_rest_api": False, + "has_mqtt_api": False, + "output_charge_control": "power", + "charge_control_immediate": False, + "has_charge_enable_time": True, + "has_discharge_enable_time": True, + "has_target_soc": True, + "has_reserve_soc": False, + "has_timed_pause": False, + "charge_time_format": "HH:MM:SS", + "charge_time_entity_is_option": True, + "soc_units": "%", + "num_load_entities": 1, + "has_ge_inverter_mode": False, + "has_ge_eco_toggle": False, + "has_fox_inverter_mode": False, + "time_button_press": False, + "clock_time_format": "%Y-%m-%d %H:%M:%S", + "write_and_poll_sleep": 2, + "has_time_window": False, + "support_charge_freeze": True, + "support_discharge_freeze": True, + "has_idle_time": False, + "can_span_midnight": True, + "charge_discharge_with_rate": False, + "target_soc_used_for_discharge": False, + }, "GWMQTT": { "name": "ESP32 Gateway MQTT", "has_rest_api": False, diff --git a/apps/predbat/sigenergy.py b/apps/predbat/sigenergy.py index f499aa609..7c34b6875 100644 --- a/apps/predbat/sigenergy.py +++ b/apps/predbat/sigenergy.py @@ -37,6 +37,18 @@ config.py so no changes are needed there. Registered in components.py under key 'sigenergy'. + + +Example apps.yaml config: + + sigenergy_system_id: 'XRTKQ1773829273' + sigenergy_app_key: !secret sigenergy_app_key + sigenergy_app_secret: !secret sigenergy_app_secret + sigenergy_client_pem: !secret sigenergy_client_pem + sigenergy_client_key: !secret sigenergy_client_key + sigenergy_ca_pem: !secret sigenergy_ca_pem + sigenergy_automatic: True + """ import argparse @@ -44,8 +56,10 @@ import base64 import json import ssl +import tempfile import time import traceback +import os try: import aiohttp @@ -179,9 +193,9 @@ def initialize(self, app_key, app_secret, base_url=None, mqtt_host=None, ca_cert base_url: Override the API base URL (default: SIGENERGY_DEFAULT_BASE_URL). mqtt_host: Override the MQTT broker hostname (default: derived from SIGENERGY_DEFAULT_MQTT_HOST by replacing the regional prefix to match *base_url*). - ca_cert: Path to CA certificate PEM file for verifying the broker's TLS certificate. - client_cert: Path to client certificate PEM file for mutual TLS authentication. - client_key: Path to client private key file for mutual TLS authentication. + ca_cert: PEM text of the CA certificate for verifying the broker's TLS certificate. + client_cert: PEM text of the client certificate for mutual TLS authentication. + client_key: PEM text of the client private key for mutual TLS authentication. system_id: Optional system ID filter. When None all authorised systems are used. When a string or list, only matching systems are used. @@ -238,6 +252,9 @@ def initialize(self, app_key, app_secret, base_url=None, mqtt_host=None, ca_cert # Battery command de-duplication — keyed by systemId → (command_hash, startTime) self._last_battery_command = {} + # Last non-zero API response code — set by _request for callers to inspect + self._last_api_code = 0 + # Rate-limit tracking self._last_request_time = 0.0 @@ -246,7 +263,8 @@ def initialize(self, app_key, app_secret, base_url=None, mqtt_host=None, ca_cert # Background MQTT listener task self._mqtt_task = None - self.last_mqtt_update = 0.0 # UNIX timestamp of last MQTT message received + self.last_mqtt_update = {} # UNIX timestamp of last MQTT message received, keyed by system_id + self._tls_context = None # cached SSLContext built once on first use self.log("SigenergyAPI: Initialised, base_url={}".format(self.base_url)) @@ -412,6 +430,7 @@ async def _request(self, method, path, params=None, json_data=None, retries=SIGE code = body.get("code", -1) if code != 0: + self._last_api_code = code self.log("Warn: SigenergyAPI: API error code={} msg={} for {}".format(code, body.get("msg", ""), path)) if code == SIGENERGY_CODE_ACCESS_RESTRICTION: # Rate-limited — exponential backoff then retry @@ -757,15 +776,20 @@ async def offboard_systems(self, system_ids): def _build_tls_context(self): """Build an SSL context for the MQTT connection. - Uses ca_cert if provided (for custom/self-signed CA), otherwise the - system default trust store. Loads client_cert + client_key when both - are provided (mutual TLS). + Uses ca_cert if provided (PEM text, for custom/self-signed CA), otherwise the + system default trust store. Loads client_cert + client_key PEM text when both + are provided (mutual TLS), writing them to temporary files once and caching the + resulting SSLContext for all subsequent calls. Returns: ssl.SSLContext ready for use with aiomqtt. """ + if self._tls_context is not None: + return self._tls_context + if self.ca_cert: - tls_context = ssl.create_default_context(cafile=self.ca_cert) + tls_context = ssl.create_default_context() + tls_context.load_verify_locations(cadata=self.ca_cert) # Relax strict RFC 5280 key-usage enforcement; Sigenergy's CA cert # may not include the keyUsage/basicConstraints extensions that # Python 3.14+ enforces by default. @@ -773,7 +797,18 @@ def _build_tls_context(self): else: tls_context = ssl.create_default_context() if self.client_cert and self.client_key: - tls_context.load_cert_chain(certfile=self.client_cert, keyfile=self.client_key) + with tempfile.NamedTemporaryFile(mode="w", suffix=".pem", delete=False) as f_cert: + f_cert.write(self.client_cert) + cert_path = f_cert.name + with tempfile.NamedTemporaryFile(mode="w", suffix=".pem", delete=False) as f_key: + f_key.write(self.client_key) + key_path = f_key.name + try: + tls_context.load_cert_chain(certfile=cert_path, keyfile=key_path) + finally: + os.unlink(cert_path) + os.unlink(key_path) + self._tls_context = tls_context return tls_context async def _publish_mqtt(self, topic, payload_dict): @@ -1192,7 +1227,6 @@ async def _mqtt_listener_loop(self): async for message in client.messages: if self.api_stop: break - self.last_mqtt_update = time.time() # Parse topic: openapi/{type}/{app_key}/{system_id} topic_str = str(message.topic) @@ -1215,6 +1249,7 @@ async def _mqtt_listener_loop(self): entries = payload if isinstance(payload, list) else [payload] for entry in entries: entry_sid = entry.get("systemId", msg_sid) + self.last_mqtt_update[entry_sid] = time.time() value_dict = entry.get("value", {}) self.log("SigenergyAPI: MQTT message on {} for system {}: type={} value={}".format(topic_str, entry_sid, msg_type, value_dict)) if msg_type == "period": @@ -1421,6 +1456,24 @@ async def publish_system_entities(self, system_id): app="sigenergy", ) + # --- Last MQTT update time --- + last_mqtt_ts = self.last_mqtt_update.get(system_id, 0) + if last_mqtt_ts > 0: + from datetime import timezone + last_update_str = datetime.fromtimestamp(last_mqtt_ts, tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%S+00:00") + else: + last_update_str = "unknown" + self.dashboard_item( + "sensor.{}_sigenergy_{}_time".format(self.prefix, slug), + state=last_update_str, + attributes={ + "friendly_name": "Sigenergy {} Last Update".format(system_name), + "icon": "mdi:clock", + "state_class": "timestamp", + }, + app="sigenergy", + ) + # --- System status --- system_status = system_info.get("status", "Unknown") self.dashboard_item( @@ -1461,7 +1514,7 @@ async def automatic_config(self): slugs = [self._system_slug(sid) for sid in system_ids] self.set_arg("num_inverters", num) - self.set_arg("inverter_type", ["SIG" for _ in range(num)]) + self.set_arg("inverter_type", ["SIGCLOUD" for _ in range(num)]) self.set_arg("soc_kw", ["sensor.{}_sigenergy_{}_battery_soc".format(self.prefix, s) for s in slugs]) self.set_arg("soc_max", ["sensor.{}_sigenergy_{}_battery_capacity".format(self.prefix, s) for s in slugs]) @@ -1472,6 +1525,7 @@ async def automatic_config(self): self.set_arg("grid_power", ["sensor.{}_sigenergy_{}_grid_power".format(self.prefix, s) for s in slugs]) self.set_arg("load_power", ["sensor.{}_sigenergy_{}_load_power".format(self.prefix, s) for s in slugs]) self.set_arg("pv_today", ["sensor.{}_sigenergy_{}_pv_today".format(self.prefix, s) for s in slugs]) + self.set_arg("inverter_time", ["sensor.{}_sigenergy_{}_time".format(self.prefix, s) for s in slugs]) # Control entities self.set_arg("charge_start_time", ["select.{}_sigenergy_{}_charge_start_time".format(self.prefix, s) for s in slugs]) @@ -1855,13 +1909,31 @@ async def run(self, seconds, first): """ if first: self.log("SigenergyAPI: First run — discovering systems") + if not self.system_id_filter: + self.log("Warn: SigenergyAPI: No system_id configured — will use all authorised systems") token = await self.get_access_token() if not token: self.log("Warn: SigenergyAPI: Authentication failed — cannot proceed") return False - ok = await self.fetch_system_list() - if not ok: - self.log("Warn: SigenergyAPI: Failed to discover systems, will retry") + await self.fetch_system_list() + + # For each expected system ID not yet visible, attempt onboarding + missing_ids = self.system_id_filter - set(self.systems.keys()) if self.system_id_filter else set() + for sid in missing_ids: + self.log("SigenergyAPI: System {} not found in authorised list — attempting onboard".format(sid)) + self._last_api_code = 0 + result = await self.onboard_systems([sid]) + if result is None: + if self._last_api_code == SIGENERGY_CODE_SYSTEM_PENDING_REVIEW: + self.log("Warn: SigenergyAPI: System {} is pending review approval — cannot proceed yet".format(sid)) + else: + self.log("Warn: SigenergyAPI: Failed to onboard system {} (code={}) — cannot proceed".format(sid, self._last_api_code)) + return False + self.log("SigenergyAPI: Onboard accepted for system {} — re-fetching system list".format(sid)) + await self.fetch_system_list() + + if not self.systems: + self.log("Warn: SigenergyAPI: No systems available after discovery, will retry") return False # Start (or restart) the background MQTT listener task after systems are known @@ -1887,9 +1959,11 @@ async def run(self, seconds, first): # Realtime data refresh — skip live power/SOC fetch when MQTT is providing fresh data if first or seconds % SIGENERGY_POLL_INTERVAL == 0: - mqtt_age = time.time() - self.last_mqtt_update - mqtt_fresh = self.last_mqtt_update > 0 and mqtt_age < SIGENERGY_POLL_INTERVAL + now_ts = time.time() for sid in list(self.systems.keys()): + last_update = self.last_mqtt_update.get(sid, 0) + mqtt_age = now_ts - last_update + mqtt_fresh = last_update > 0 and mqtt_age < SIGENERGY_POLL_INTERVAL if mqtt_fresh: self.log("SigenergyAPI: Skipping REST energy poll for {} (MQTT data {:.0f}s old)".format(sid, mqtt_age)) else: @@ -2142,9 +2216,9 @@ async def test_mqtt_connection(app_key, app_secret, base_url, system_id=None, to system_id: Optional system ID (or comma-separated list) to subscribe to. topic_filter: Unused in this implementation; wildcard topics are built automatically from the app_key. - ca_cert: Path to CA certificate PEM file for TLS verification. - client_cert: Path to client certificate PEM file for mutual TLS. - client_key: Path to client private key PEM file for mutual TLS. + ca_cert: PEM text of the CA certificate for TLS verification. + client_cert: PEM text of the client certificate for mutual TLS. + client_key: PEM text of the client private key for mutual TLS. Returns: 0 on clean exit, 1 on error. @@ -2251,11 +2325,27 @@ def main(): # pragma: no cover args = parser.parse_args() - # Resolve TLS certificate paths — individual flags override cert-dir + # Resolve TLS certificate file paths — individual flags override cert-dir — then read contents import os - ca_cert = args.ca_cert or (os.path.join(args.cert_dir, "ca.pem") if args.cert_dir else None) - client_cert = args.client_cert or (os.path.join(args.cert_dir, "client.pem") if args.cert_dir else None) - client_key = args.client_key or (os.path.join(args.cert_dir, "client.key") if args.cert_dir else None) + + def _read_cert(path): + """Read a certificate or key file and return its text content, or None.""" + if not path: + return None + try: + with open(path) as f: + return f.read() + except OSError as e: + print("Error: Cannot read TLS file {}: {}".format(path, e)) + raise SystemExit(1) + + ca_cert_path = args.ca_cert or (os.path.join(args.cert_dir, "ca.pem") if args.cert_dir else None) + client_cert_path = args.client_cert or (os.path.join(args.cert_dir, "client.pem") if args.cert_dir else None) + client_key_path = args.client_key or (os.path.join(args.cert_dir, "client.key") if args.cert_dir else None) + + ca_cert = _read_cert(ca_cert_path) + client_cert = _read_cert(client_cert_path) + client_key = _read_cert(client_key_path) if args.mqtt_test: result = asyncio.run(test_mqtt_connection(args.app_key, args.app_secret, args.base_url, args.system_id, args.mqtt_topic, ca_cert=ca_cert, client_cert=client_cert, client_key=client_key)) diff --git a/apps/predbat/tests/test_sigenergy.py b/apps/predbat/tests/test_sigenergy.py index 0c13108c1..046d084b2 100644 --- a/apps/predbat/tests/test_sigenergy.py +++ b/apps/predbat/tests/test_sigenergy.py @@ -311,11 +311,13 @@ def test_sigenergy_automatic_config(my_predbat): assert "num_inverters" in api.set_args, "num_inverters set" assert api.set_args["num_inverters"] == 2, "num_inverters == 2" - assert api.set_args.get("inverter_type") == ["SIG", "SIG"], "inverter_type wired" + assert api.set_args.get("inverter_type") == ["SIGCLOUD", "SIGCLOUD"], "inverter_type wired" assert "soc_kw" in api.set_args, "soc_kw wired" assert "battery_power" in api.set_args, "battery_power wired" assert "pv_power" in api.set_args, "pv_power wired" assert "grid_power" in api.set_args, "grid_power wired" + assert "inverter_time" in api.set_args, "inverter_time wired" + assert len(api.set_args["inverter_time"]) == 2, "inverter_time has one entry per system" return failed @@ -611,7 +613,7 @@ async def mock_set_operating_mode(sid, mode): commands_sent.append(("set_mode", sid, mode)) return True - async def mock_send_battery_command(sid, active_mode, duration_min, charging_power_kw=None): + async def mock_send_battery_command(sid, active_mode, duration_min, charging_power_kw=None, **kwargs): commands_sent.append(("battery_cmd", sid, active_mode, duration_min, charging_power_kw)) return True @@ -648,7 +650,7 @@ async def mock_set_operating_mode(sid, mode): commands_sent.append(("set_mode", sid, mode)) return True - async def mock_send_battery_command(sid, active_mode, duration_min, charging_power_kw=None): + async def mock_send_battery_command(sid, active_mode, duration_min, charging_power_kw=None, **kwargs): commands_sent.append(("battery_cmd", sid, active_mode, duration_min, charging_power_kw)) return True @@ -727,7 +729,7 @@ async def mock_set_operating_mode(sid, mode): commands_sent.append(("set_mode", sid, mode)) return True - async def mock_send_battery_command(sid, active_mode, duration_min, charging_power_kw=None): + async def mock_send_battery_command(sid, active_mode, duration_min, charging_power_kw=None, **kwargs): commands_sent.append(("battery_cmd", sid, active_mode, duration_min, charging_power_kw)) return True @@ -842,9 +844,10 @@ async def mock_publish_mqtt(topic, payload_dict): topic, payload = published[0] assert topic == "openapi/instruction/command", "Correct MQTT topic" assert payload["accessToken"] == "reused_token", "Token in payload" - assert payload["systemId"] == "SIG001", "systemId in payload" - assert payload["activeMode"] == "charge", "activeMode in payload" - assert payload["duration"] == 60, "duration in payload" + cmd = payload["commands"][0] + assert cmd["systemId"] == "SIG001", "systemId in commands[0]" + assert cmd["activeMode"] == "charge", "activeMode in commands[0]" + assert cmd["duration"] == 60, "duration in commands[0]" assert abs(payload["chargingPower"] - 3.5) < 0.01, "chargingPower in payload" return failed @@ -939,10 +942,10 @@ def test_sigenergy_handle_mqtt_change(my_predbat): # System capacity and power limits sys = api.systems["SYS1"] assert abs(sys["batteryCapacity"] - 45.2) < 0.01, "batteryCapacity = 45.2 kWh" - assert abs(sys["ratedChargePowerKw"] - 22.0) < 0.01, "ratedChargePowerKw = 22.0" - assert abs(sys["ratedDischargePowerKw"] - 24.0) < 0.01, "ratedDischargePowerKw = 24.0" - assert abs(sys["inverterMaxActivePowerKw"] - 12.0) < 0.01, "inverterMaxActivePowerKw = 12.0" - assert abs(sys["gridMaxBackfeedPowerKw"] - 5.0) < 0.01, "gridMaxBackfeedPowerKw = 5.0" + assert abs(sys["ratedChargePower"] - 22.0) < 0.01, "ratedChargePower = 22.0" + assert abs(sys["ratedDischargePower"] - 24.0) < 0.01, "ratedDischargePower = 24.0" + assert abs(sys["ratedActivePower"] - 12.0) < 0.01, "ratedActivePower = 12.0" + assert abs(sys["gridMaxBackfeedPower"] - 5.0) < 0.01, "gridMaxBackfeedPower = 5.0" assert any("MQTT change" in m for m in api.log_messages), "Change data logged" return failed @@ -1061,8 +1064,8 @@ def messages(self): assert "openapi/subscription/change" in sub_topics, "change subscription published" assert "openapi/subscription/alarm" in sub_topics, "alarm subscription published" - # last_mqtt_update was set - assert api.last_mqtt_update > 0, "last_mqtt_update was set" + # last_mqtt_update was set per system + assert api.last_mqtt_update.get("XRTKQ1773829273", 0) > 0, "last_mqtt_update was set for system" return failed @@ -1171,6 +1174,56 @@ def test_sigenergy_get_inverter_serial(my_predbat): # --------------------------------------------------------------------------- # Test registration entry point +def test_sigenergy_build_tls_context(my_predbat): + """Test _build_tls_context builds an SSL context from PEM text content.""" + import os + import ssl as ssl_mod + import glob + + failed = False + + # Locate the real PEM files relative to this test file's repo root + repo_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) + key_dir = os.path.join(repo_root, "sigenergy_mqtt_key") + ca_pem_path = os.path.join(key_dir, "ca.pem") + client_pem_path = os.path.join(key_dir, "client.pem") + client_key_path = os.path.join(key_dir, "client.key") + + if not os.path.exists(ca_pem_path): + # No real certs available — test the no-cert path only + api = MockSigenergyAPI() + ctx = api._build_tls_context() + assert isinstance(ctx, ssl_mod.SSLContext), "Default context returned when no certs" + return failed + + with open(ca_pem_path) as f: + ca_text = f.read() + with open(client_pem_path) as f: + client_cert_text = f.read() + with open(client_key_path) as f: + client_key_text = f.read() + + # Test with CA cert text only + api = MockSigenergyAPI() + api.ca_cert = ca_text + ctx = api._build_tls_context() + assert isinstance(ctx, ssl_mod.SSLContext), "SSLContext built from CA cert text" + + # Test with all three — CA + client cert + key + api2 = MockSigenergyAPI() + api2.ca_cert = ca_text + api2.client_cert = client_cert_text + api2.client_key = client_key_text + ctx2 = api2._build_tls_context() + assert isinstance(ctx2, ssl_mod.SSLContext), "SSLContext built from CA + client cert + key text" + + # Confirm no temp files were left behind + leftover = glob.glob("/tmp/*.pem") + assert not any("sigenergy" in p for p in leftover), "No temp PEM files left behind" + + return failed + + # --------------------------------------------------------------------------- @@ -1213,6 +1266,7 @@ def run_sigenergy_tests(my_predbat): ("fetch_inverter_realtime", test_sigenergy_fetch_inverter_realtime), ("fetch_inverter_realtime_no_inverter", test_sigenergy_fetch_inverter_realtime_no_inverter), ("get_inverter_serial", test_sigenergy_get_inverter_serial), + ("build_tls_context", test_sigenergy_build_tls_context), ] for name, fn in tests: diff --git a/apps/predbat/web.py b/apps/predbat/web.py index c7c3988cd..2ce81a344 100644 --- a/apps/predbat/web.py +++ b/apps/predbat/web.py @@ -3378,7 +3378,7 @@ async def html_apps(self, request): for arg in args: value = args[arg] raw_value = self.resolve_value_raw(arg, value) - if ("_key" in arg) or ("_password" in arg) or ("_secret" in arg): + if ("_key" in arg) or ("_password" in arg) or ("_secret" in arg) or ("_pem" in arg): value = ' (hidden)'.format(value) arg_errors = self.base.arg_errors.get(arg, "") From 40ffae1c22b7743976e2dc532f51a5bbc4938fab Mon Sep 17 00:00:00 2001 From: Trefor Southwell Date: Mon, 25 May 2026 18:24:31 +0100 Subject: [PATCH 14/18] NOtes --- apps/predbat/sigenergy.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/predbat/sigenergy.py b/apps/predbat/sigenergy.py index 7c34b6875..12a149891 100644 --- a/apps/predbat/sigenergy.py +++ b/apps/predbat/sigenergy.py @@ -49,6 +49,11 @@ sigenergy_ca_pem: !secret sigenergy_ca_pem sigenergy_automatic: True + +TODO: +- Need a way for the user to toggle onboard vs offboard as when onboard they can't control in the app. +- Need to check why test system reports as 24kw max battery rate but 12kw inverter limit, is this real? + """ import argparse From 0443067eb223ff72d3917ee71cabd431b7ffc70e Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Mon, 25 May 2026 17:26:52 +0000 Subject: [PATCH 15/18] [pre-commit.ci lite] apply automatic fixes --- apps/predbat/sigenergy.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/predbat/sigenergy.py b/apps/predbat/sigenergy.py index 12a149891..71785629e 100644 --- a/apps/predbat/sigenergy.py +++ b/apps/predbat/sigenergy.py @@ -50,7 +50,7 @@ sigenergy_automatic: True -TODO: +TODO: - Need a way for the user to toggle onboard vs offboard as when onboard they can't control in the app. - Need to check why test system reports as 24kw max battery rate but 12kw inverter limit, is this real? From 16b9daef3b1ecb272ea08f9f950843785c9e5a0a Mon Sep 17 00:00:00 2001 From: Trefor Southwell Date: Mon, 25 May 2026 19:41:44 +0100 Subject: [PATCH 16/18] Add record api call --- apps/predbat/sigenergy.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/apps/predbat/sigenergy.py b/apps/predbat/sigenergy.py index 12a149891..6e6f8ed2f 100644 --- a/apps/predbat/sigenergy.py +++ b/apps/predbat/sigenergy.py @@ -82,6 +82,8 @@ from datetime import datetime, timedelta from component_base import ComponentBase +from predbat_metrics import record_api_call + # --------------------------------------------------------------------------- # Constants @@ -411,6 +413,7 @@ async def _request(self, method, path, params=None, json_data=None, retries=SIGE async with ctx as response: if response.status == 401: self.log("Warn: SigenergyAPI: 401 Unauthorised — refreshing token") + record_api_call("sigenergy", False, "auth_error") self.access_token = None token = await self.get_access_token() if not token: @@ -420,6 +423,7 @@ async def _request(self, method, path, params=None, json_data=None, retries=SIGE if response.status not in (200, 201): self.log("Warn: SigenergyAPI: HTTP {} for {} {}".format(response.status, method, path)) + record_api_call("sigenergy", False, "server_error") if attempt < retries - 1: await asyncio.sleep(SIGENERGY_COMMAND_RETRY_DELAY) continue @@ -437,6 +441,7 @@ async def _request(self, method, path, params=None, json_data=None, retries=SIGE if code != 0: self._last_api_code = code self.log("Warn: SigenergyAPI: API error code={} msg={} for {}".format(code, body.get("msg", ""), path)) + record_api_call("sigenergy", False, "server_error") if code == SIGENERGY_CODE_ACCESS_RESTRICTION: # Rate-limited — exponential backoff then retry wait = SIGENERGY_RATE_LIMIT_BACKOFF[min(attempt, len(SIGENERGY_RATE_LIMIT_BACKOFF) - 1)] @@ -464,14 +469,17 @@ async def _request(self, method, path, params=None, json_data=None, retries=SIGE pass decoded.append(item) data = decoded + record_api_call("sigenergy") return data except asyncio.TimeoutError: self.log("Warn: SigenergyAPI: Timeout on {} {} (attempt {}/{})".format(method, path, attempt + 1, retries)) + record_api_call("sigenergy", False, "connection_error") if attempt < retries - 1: await asyncio.sleep(SIGENERGY_COMMAND_RETRY_DELAY) except Exception as e: self.log("Warn: SigenergyAPI: Exception on {} {}: {}\n{}".format(method, path, e, traceback.format_exc())) + record_api_call("sigenergy", False, "connection_error") if attempt < retries - 1: await asyncio.sleep(SIGENERGY_COMMAND_RETRY_DELAY) From d6e1a0bb3f076832bfa06ff0575c983636226a14 Mon Sep 17 00:00:00 2001 From: Trefor Southwell Date: Mon, 25 May 2026 20:56:43 +0100 Subject: [PATCH 17/18] Documentation --- .cspell/custom-dictionary-workspace.txt | 9 +++ apps/predbat/sigenergy.py | 78 ++++++++++++++++++--- apps/predbat/tests/test_sigenergy.py | 60 ++++++++++++++-- docs/components.md | 79 +++++++++++++++++++++ docs/inverter-setup.md | 91 ++++++++++++++++++++++++- 5 files changed, 298 insertions(+), 19 deletions(-) diff --git a/.cspell/custom-dictionary-workspace.txt b/.cspell/custom-dictionary-workspace.txt index 2961962f6..20cb0740b 100644 --- a/.cspell/custom-dictionary-workspace.txt +++ b/.cspell/custom-dictionary-workspace.txt @@ -15,6 +15,7 @@ apexcharts apikey appdaemon appkey +Apprent appsyaml argname ASHP @@ -26,6 +27,7 @@ autopep autoupdate axvline axvspan +Backfeed backprop Backpropagate backpropagation @@ -39,6 +41,7 @@ beforeunload bierner brickatius byok +cadata calib Cantarell cexxxx @@ -109,6 +112,7 @@ energydataservice energythroughput epod euids +evergen evse exog exportlimit @@ -265,6 +269,7 @@ onmouseout onmouseover openweathermap overfitting +overvoltage ownerapi pbgw pdata @@ -330,7 +335,9 @@ Selfuse semodbus Sergoe SFMB +sigcloud sigen +sigencloud sigenergy sigenstor Slee @@ -415,6 +422,7 @@ xaxistooltip xlabel xlim xload +xrtkq xticks yaxis yaxistooltip @@ -422,3 +430,4 @@ yday ylabel yuanzhi zappi +zigbee diff --git a/apps/predbat/sigenergy.py b/apps/predbat/sigenergy.py index 318a147d1..76fdc688b 100644 --- a/apps/predbat/sigenergy.py +++ b/apps/predbat/sigenergy.py @@ -148,6 +148,22 @@ SIGENERGY_MODE_VPP = 6 # VPP mode SIGENERGY_MODE_NBI = 8 # NorthBound (defined for completeness; not switched to by this component) +# Human-readable names for operationalMode integer values +SIGENERGY_MODE_NAMES = { + 0: "Maximum Self-Consumption", + 5: "Fully Feed-in to Grid", + 6: "VPP", + 8: "Northbound Integration", +} + +# Human-readable names for systemStatus integer values +SIGENERGY_SYSTEM_STATUS_NAMES = { + 0: "Off", + 1: "Online", + 2: "Standby", + 3: "Fault", +} + # Battery command activeMode strings SIGENERGY_ACTIVE_MODE_CHARGE = "charge" SIGENERGY_ACTIVE_MODE_DISCHARGE = "discharge" @@ -250,6 +266,7 @@ def initialize(self, app_key, app_secret, base_url=None, mqtt_host=None, ca_cert self.systems = {} # systemId → system info dict self.devices = {} # systemId → list of device dicts self.energy_flow = {} # systemId → latest energyFlow dict + self.system_status = {} # systemId → latest systemStatus dict self.daily_summary = {} # systemId → latest summary dict self.current_mode = {} # systemId → energyStorageOperationMode int @@ -665,6 +682,21 @@ async def fetch_energy_flow(self, system_id): Returns: True on success, False on failure. + + Example data: + { + "code": 0, + "msg": "success", + "data": { + "pvPower": 10.1, + "gridPower": 10.1, + "evPower": 0, + "loadPower": 0, + "heatPumpPower": 0, + "batteryPower": 0, + "batterySoc": 100 + } + } """ data = await self._request("GET", "/openapi/systems/{}/energyFlow".format(system_id)) if data is None: @@ -983,19 +1015,18 @@ def _get_battery_max_power_kw(self, system_id): """Return the combined rated charge/discharge power in kW for a system.""" # Prefer device-level ratedChargePower power = 0.0 + inverter_power = self._get_inverter_max_power_kw(system_id) for device in self.devices.get(system_id, []): if device.get("deviceType") == SIGENERGY_DEVICE_BATTERY: attr = device.get("attrMap", {}) power += _safe_float(attr.get("ratedChargePower", 0)) - if power > 0: - return power # Fallback: inverter rated power - for device in self.devices.get(system_id, []): - if device.get("deviceType") == SIGENERGY_DEVICE_INVERTER: - attr = device.get("attrMap", {}) - power += _safe_float(attr.get("ratedActivePower", 0)) - return power + # Also cap battery power at inverter power + if power > 0: + return min(power, inverter_power) if inverter_power > 0 else power + else: + return inverter_power def _get_inverter_max_power_kw(self, system_id): """Return the combined inverter rated active power in kW.""" @@ -1072,14 +1103,17 @@ def _handle_mqtt_period(self, system_id, value_dict): "loadPower": max(0.0, load_power_kw), "evPower": 0.0, "inverterPower": _safe_float(value_dict.get("inverterActivePowerW", 0)) / 1000.0, - "chargeCapacityKwh": _safe_float(value_dict.get("storageChargeCapacityWh", 0)) / 1000.0, - "dischargeCapacityKwh": _safe_float(value_dict.get("storageDischargeCapacityWh", 0)) / 1000.0, - "batteryMaxChargePowerKw": _safe_float(value_dict.get("batteryMaxChargePowerW", 0)) / 1000.0, - "batteryMaxDischargePowerKw": _safe_float(value_dict.get("batteryMaxDischargePowerW", 0)) / 1000.0, + } + flow_status = { + "chargeCapacity": _safe_float(value_dict.get("storageChargeCapacityWh", 0)) / 1000.0, + "dischargeCapacity": _safe_float(value_dict.get("storageDischargeCapacityWh", 0)) / 1000.0, + "ratedChargePower": _safe_float(value_dict.get("batteryMaxChargePowerW", 0)) / 1000.0, + "ratedDischargePower": _safe_float(value_dict.get("batteryMaxDischargePowerW", 0)) / 1000.0, "operationalMode": _safe_float(value_dict.get("operationalMode", 0)), "systemStatus": _safe_float(value_dict.get("systemStatus", 0)), } self.energy_flow[system_id] = flow + self.system_status[system_id] = flow_status self.log( "SigenergyAPI: MQTT period {}: SOC {:.0f}% bat {:.2f}kW pv {:.2f}kW grid {:.2f}kW load {:.2f}kW".format( system_id, flow["batterySoc"], bat_power_kw, pv_power_kw, grid_power_kw, flow["loadPower"] @@ -1306,6 +1340,7 @@ async def publish_system_entities(self, system_id): system_info = self.systems.get(system_id, {}) system_name = system_info.get("systemName", system_id) flow = self.energy_flow.get(system_id, {}) + flow_status = self.system_status.get(system_id, {}) summary = self.daily_summary.get(system_id, {}) battery_soc_pct = _safe_float(flow.get("batterySoc", 0)) @@ -1469,6 +1504,27 @@ async def publish_system_entities(self, system_id): app="sigenergy", ) + # --- Operational mode (string) --- + op_mode_int = int(_safe_float(flow_status.get("operationalMode", -1))) + op_mode_str = SIGENERGY_MODE_NAMES.get(op_mode_int, "Unknown ({})".format(op_mode_int) if op_mode_int >= 0 else "Unknown") + sys_status_int = int(_safe_float(flow_status.get("systemStatus", -1))) + sys_status_str = SIGENERGY_SYSTEM_STATUS_NAMES.get(sys_status_int, "Unknown ({})".format(sys_status_int) if sys_status_int >= 0 else "Unknown") + self.dashboard_item( + "sensor.{}_sigenergy_{}_operational_mode".format(self.prefix, slug), + state=op_mode_str, + attributes={ + "friendly_name": "Sigenergy {} Operational Mode".format(system_name), + "mode_id": op_mode_int if op_mode_int >= 0 else None, + "system_status": sys_status_str, + "system_status_id": sys_status_int if sys_status_int >= 0 else None, + "charge_capacity_kwh": flow_status.get("chargeCapacity", 0), + "discharge_capacity_kwh": flow_status.get("dischargeCapacity", 0), + "rated_charge_power_kw": flow_status.get("ratedChargePower", 0), + "rated_discharge_power_kw": flow_status.get("ratedDischargePower", 0), + }, + app="sigenergy", + ) + # --- Last MQTT update time --- last_mqtt_ts = self.last_mqtt_update.get(system_id, 0) if last_mqtt_ts > 0: diff --git a/apps/predbat/tests/test_sigenergy.py b/apps/predbat/tests/test_sigenergy.py index 046d084b2..18c77be3c 100644 --- a/apps/predbat/tests/test_sigenergy.py +++ b/apps/predbat/tests/test_sigenergy.py @@ -228,6 +228,48 @@ def test_sigenergy_system_slug(my_predbat): return failed +def test_sigenergy_battery_max_power(my_predbat): + """Test _get_battery_max_power_kw with capping at inverter power.""" + failed = False + api = MockSigenergyAPI() + + # Case 1: battery power below inverter limit — uncapped + api.devices["sys1"] = [ + {"deviceType": "Battery", "attrMap": {"ratedChargePower": 6.0}}, + {"deviceType": "Inverter", "attrMap": {"ratedActivePower": 10.0}}, + ] + assert api._get_battery_max_power_kw("sys1") == 6.0, "Battery power below inverter limit: not capped" + + # Case 2: battery power exceeds inverter limit — capped at inverter power + api.devices["sys2"] = [ + {"deviceType": "Battery", "attrMap": {"ratedChargePower": 15.0}}, + {"deviceType": "Inverter", "attrMap": {"ratedActivePower": 10.0}}, + ] + assert api._get_battery_max_power_kw("sys2") == 10.0, "Battery power capped at inverter limit" + + # Case 3: no battery device, falls back to inverter power + api.devices["sys3"] = [ + {"deviceType": "Inverter", "attrMap": {"ratedActivePower": 8.0}}, + ] + assert api._get_battery_max_power_kw("sys3") == 8.0, "Fallback to inverter power when no battery device" + + # Case 4: no inverter device — battery power returned uncapped + api.devices["sys4"] = [ + {"deviceType": "Battery", "attrMap": {"ratedChargePower": 7.0}}, + ] + assert api._get_battery_max_power_kw("sys4") == 7.0, "No inverter: battery power returned uncapped" + + # Case 5: multiple batteries — sum capped at inverter + api.devices["sys5"] = [ + {"deviceType": "Battery", "attrMap": {"ratedChargePower": 6.0}}, + {"deviceType": "Battery", "attrMap": {"ratedChargePower": 6.0}}, + {"deviceType": "Inverter", "attrMap": {"ratedActivePower": 10.0}}, + ] + assert api._get_battery_max_power_kw("sys5") == 10.0, "Sum of two batteries (12kW) capped at inverter (10kW)" + + return failed + + def test_sigenergy_battery_capacity(my_predbat): """Test _get_battery_capacity_kwh falls back to device data.""" failed = False @@ -895,6 +937,7 @@ def test_sigenergy_handle_mqtt_period(my_predbat): api._handle_mqtt_period("SYS1", value_dict) + # Realtime power fields land in energy_flow flow = api.energy_flow.get("SYS1", {}) assert abs(flow["batterySoc"] - 79.7) < 0.01, "batterySoc = 79.7%" # storageChargeDischargePowerW -2927 W = -2.927 kW (discharging, negative = energyFlow convention) @@ -904,12 +947,16 @@ def test_sigenergy_handle_mqtt_period(my_predbat): # loadPower = pv - bat - grid = 0 - (-2.927) - 0.003 = 2.924 assert abs(flow["loadPower"] - 2.924) < 0.01, "loadPower derived = 2.924 kW" assert abs(flow["inverterPower"] - 2.681) < 0.001, "inverterPower = 2.681 kW" - assert abs(flow["chargeCapacityKwh"] - 9.52) < 0.01, "chargeCapacityKwh = 9.52" - assert abs(flow["dischargeCapacityKwh"] - 37.41) < 0.01, "dischargeCapacityKwh = 37.41" - assert abs(flow["batteryMaxChargePowerKw"] - 22.032) < 0.01, "batteryMaxChargePowerKw = 22.032" - assert abs(flow["batteryMaxDischargePowerKw"] - 36.051) < 0.01, "batteryMaxDischargePowerKw = 36.051" - assert flow["operationalMode"] == 6.0, "operationalMode = 6.0" - assert flow["systemStatus"] == 1.0, "systemStatus = 1.0" + assert "chargeCapacityKwh" not in flow, "capacity/status fields must not be in energy_flow" + + # Capacity, power and mode fields land in system_status (not energy_flow) + status = api.system_status.get("SYS1", {}) + assert abs(status["chargeCapacity"] - 9.52) < 0.01, "chargeCapacity = 9.52 kWh" + assert abs(status["dischargeCapacity"] - 37.41) < 0.01, "dischargeCapacity = 37.41 kWh" + assert abs(status["ratedChargePower"] - 22.032) < 0.01, "ratedChargePower = 22.032 kW" + assert abs(status["ratedDischargePower"] - 36.051) < 0.01, "ratedDischargePower = 36.051 kW" + assert status["operationalMode"] == 6.0, "operationalMode = 6.0" + assert status["systemStatus"] == 1.0, "systemStatus = 1.0" assert any("MQTT period" in m and "80" in m for m in api.log_messages), "Period data logged" return failed @@ -1267,6 +1314,7 @@ def run_sigenergy_tests(my_predbat): ("fetch_inverter_realtime_no_inverter", test_sigenergy_fetch_inverter_realtime_no_inverter), ("get_inverter_serial", test_sigenergy_get_inverter_serial), ("build_tls_context", test_sigenergy_build_tls_context), + ("battery_max_power", test_sigenergy_battery_max_power), ] for name, fn in tests: diff --git a/docs/components.md b/docs/components.md index e00c2e595..e1a954298 100644 --- a/docs/components.md +++ b/docs/components.md @@ -19,6 +19,7 @@ This document provides a comprehensive overview of all Predbat components, their - [Fox ESS API (fox)](#fox-ess-api-fox) - [Solax Cloud API (Solax)](#solax-cloud-api-solax) - [Solis Cloud API (Solis)](#solis-cloud-api-solis) + - [Sigenergy Cloud API (Sigenergy)](#sigenergy-cloud-api-sigenergy) - [Alert Feed (alert_feed)](#alert-feed-alert_feed) - [Carbon Intensity API (carbon)](#carbon-intensity-api-carbon) - [Temperature API (temperature)](#temperature-api-temperature) @@ -557,6 +558,84 @@ Integrates with Solis inverters for monitoring and controlling Solis battery sys --- +### Sigenergy Cloud API (sigenergy) + +**Can be restarted:** Yes + +#### What it does (sigenergy) + +Integrates with Sigenergy (SigenStor) inverter and battery systems via the Sigenergy OpenAPI (REST) and MQTT broker. +No local Home Assistant integration is required — Predbat connects directly to the Sigenergy cloud, publishes all needed sensor entities, and can automatically wire itself to use them. + +Supports real-time monitoring (SOC, power flows, operational mode) and full charge/discharge control including reserve, charge target, and export target SoC. + +#### When to enable (sigenergy) + +- You have a Sigenergy (SigenStor) inverter with battery storage +- You want cloud-based control without a local modbus + +#### Important notes (sigenergy) + +- **EXPERIMENTAL**: This is a new integration and may have issues +- The Sigenergy Developer Portal application must have **VPP Mode** enabled or charge/discharge commands will be rejected +- On first startup Sigenergy sends an **onboarding approval email** — you must click the approval link before live MQTT data starts flowing +- MQTT certificates (CA, client cert, client key) are required for TLS-authenticated connections to the broker + +#### Configuration Options (sigenergy) + +| Option | Type | Required | Default | Config Key | Description | +| ------ | ---- | -------- | ------- | ---------- | ----------- | +| `app_key` | String | Yes | - | `sigenergy_app_key` | Your Sigenergy Application Key from the Developer Portal | +| `app_secret` | String | Yes | - | `sigenergy_app_secret` | Your Sigenergy Application Secret | +| `ca_cert` | String | No | System CAs | `sigenergy_ca_pem` | PEM text of the CA certificate for TLS verification | +| `client_cert` | String | No | - | `sigenergy_client_pem` | PEM text of the client certificate for mutual TLS | +| `client_key` | String | No | - | `sigenergy_client_key` | PEM text of the client private key for mutual TLS | +| `system_id` | String/List | Yes | n/a | `sigenergy_system_id` | Must be set to onboard systems. Find your System ID in the SigEnergy app under **Settings → System Settings → About → System ID** (tap to copy) | +| `automatic` | Boolean | No | false | `sigenergy_automatic` | Set to `true` to automatically configure Predbat sensors and controls (recommended) | +| `enable_controls` | Boolean | No | true | `sigenergy_enable_controls` | Set to `false` for monitoring only — no charge/discharge commands will be sent | +| `base_url` | String | No | EU endpoint | `sigenergy_base_url` | Override the REST API base URL (e.g. for non-EU regions) | +| `mqtt_host` | String | No | Derived from base_url | `sigenergy_mqtt_host` | Override the MQTT broker hostname | + +#### Configuration example (sigenergy) + +In `apps.yaml`: + +```yaml + sigenergy_app_key: !secret sigenergy_app_key + sigenergy_app_secret: !secret sigenergy_app_secret + sigenergy_ca_cert: !secret sigenergy_ca_pem + sigenergy_client_cert: !secret sigenergy_client_pem + sigenergy_client_key: !secret sigenergy_client_key + sigenergy_automatic: true + sigenergy_system_id: MY_SYSTEM_ID +``` + +In `secrets.yaml` (certificates use YAML literal block scalars — every line of the PEM must be indented): + +```yaml +sigenergy_app_key: "your-app-key-here" +sigenergy_app_secret: "your-app-secret-here" + +sigenergy_ca_pem: | + -----BEGIN CERTIFICATE----- + ... note entire key must be indented 2 spaces + -----END CERTIFICATE----- + +sigenergy_client_pem: | + -----BEGIN CERTIFICATE----- + ... note entire key must be indented 2 spaces + -----END CERTIFICATE----- + +sigenergy_client_key: | + -----BEGIN RSA PRIVATE KEY----- + ... note entire key must be indented 2 spaces + -----END RSA PRIVATE KEY----- +``` + +See [Sigenergy Cloud setup](inverter-setup.md#sigenergy-cloud) for the full credential-acquisition walkthrough. + +--- + ### Alert Feed (alert_feed) **Can be restarted:** Yes diff --git a/docs/inverter-setup.md b/docs/inverter-setup.md index 174e3e873..6587cf730 100644 --- a/docs/inverter-setup.md +++ b/docs/inverter-setup.md @@ -47,6 +47,7 @@ Once you get everything working please share the configuration as a github issue | [Kostal Plenticore](#kostal-plenticore) | [Kostal Plenticore](https://www.home-assistant.io/integrations/kostal_plenticore) | [kostal.yaml](https://raw.githubusercontent.com/springfall2008/batpred/main/templates/kostal.yaml) | | [LuxPower](#luxpower) | [LuxPython](https://github.com/guybw/LuxPython_DEV) | [luxpower.yaml](https://raw.githubusercontent.com/springfall2008/batpred/main/templates/luxpower.yaml) | | [SigEnergy](#sigenergy-sigenstor) | [SigEnergy](https://github.com/TypQxQ/Sigenergy-Home-Assistant-Integration) | [sigenergy_sigenstor.yaml](https://raw.githubusercontent.com/springfall2008/batpred/main/templates/sigenergy_sigenstor.yaml) | + | [SigEnergy Cloud](#sigenergy-cloud) | Predbat built-in | [sigenergy_cloud.yaml](https://raw.githubusercontent.com/springfall2008/batpred/main/templates/sigenergy_cloud.yaml) | | [Sofar inverters](#sofar-inverters) | [Sofar MQTT integration](https://github.com/cmcgerty/Sofar2mqtt) | [sofar.yaml](https://raw.githubusercontent.com/springfall2008/batpred/main/templates/sofar.yaml) | | [SolarEdge inverters](#solaredge-inverters) | [Solaredge Modbus Multi](https://github.com/WillCodeForCats/solaredge-modbus-multi) | [solaredge.yaml](https://raw.githubusercontent.com/springfall2008/batpred/main/templates/solaredge.yaml) | | [Solax Cloud](#solax-cloud) | Predbat | [solax_cloud.yaml](https://raw.githubusercontent.com/springfall2008/batpred/refs/heads/main/templates/solax_cloud.yaml) | @@ -1704,9 +1705,95 @@ so you may need to adapt the above automations and `apps.yaml` (or rename your e *Important:* Depending upon your electricity supply, you may need to change where **number.sigen_plant_grid_import_limitation** is set to 100 in the first integration to any lower import limit that your electricity supplier may have imposed, e.g. 18kW roughly corresponds to an 80A supply. -## Sofar Inverters +## Sigenergy Cloud + +**Experimental** + +Predbat has a built-in Sigenergy Cloud integration that connects directly to the Sigenergy OpenAPI and MQTT broker — no local Home Assistant integration is required. +It publishes all necessary sensor entities itself and can automatically configure Predbat to use them. + +See the [Components - Sigenergy Cloud](components.md#sigenergy-cloud-api-sigenergy) documentation for full configuration options. + +### Obtaining Sigenergy Cloud API credentials + +1. Log in to the [Sigenergy Developer Portal](https://developer.sigencloud.com). + +2. Create a new application (if you do not already have one): + - Give it a descriptive name, e.g. *PredBat home battery prediction* + - Make sure you tick **VPP Mode** — this is required for Predbat to send charge and discharge commands + +3. Submit the application for approval. Approval may take a day or two. + +4. Once approved, go to **Dashboard → (your application) → Settings**. + +5. Copy the **App Key** shown on the settings page. + +6. Click **Reset** next to App Secret and copy the secret that is displayed. + **Save it immediately** — it will not be shown again. + +7. Go to **Data Subscription → MQTT Certificates** (expand the section). + +8. Download all three certificate files: + - **CA Certificate** (`.pem`) + - **Client Certificate** (`.pem`) + - **Client Key** (`.key` or `.pem`) + +### Storing credentials in secrets.yaml + +The certificate files contain multi-line PEM text. YAML supports multi-line strings with the `|` (literal block scalar) syntax — each line of the certificate must be indented consistently below the key name. + +Add the following to your `secrets.yaml`: -For this integration, the key elements are: +```yaml +sigenergy_app_key: "your-app-key-here" +sigenergy_app_secret: "your-app-secret-here" + +sigenergy_ca_pem: | + -----BEGIN CERTIFICATE----- + ... note entire key must be indented 2 spaces + -----END CERTIFICATE----- + +sigenergy_client_pem: | + -----BEGIN CERTIFICATE----- + ... note entire key must be indented 2 spaces + -----END CERTIFICATE----- + +sigenergy_client_key: | + -----BEGIN RSA PRIVATE KEY----- + ... note entire key must be indented 2 spaces + -----END RSA PRIVATE KEY----- +``` + +### Configuring apps.yaml + +Copy the template [sigenergy_cloud.yaml](https://raw.githubusercontent.com/springfall2008/batpred/main/templates/sigenergy_cloud.yaml) over your `apps.yaml` and configure the Sigenergy Cloud component section: + +```yaml + sigenergy_app_key: !secret sigenergy_app_key + sigenergy_app_secret: !secret sigenergy_app_secret + sigenergy_ca_cert: !secret sigenergy_ca_pem + sigenergy_client_cert: !secret sigenergy_client_pem + sigenergy_client_key: !secret sigenergy_client_key + sigenergy_automatic: true + sigenergy_system_id: + - "YOUR_SYSTEM_ID" +``` + +You must set at least one system ID as it is required to onboard your system. +The System ID can be found in the **SigEnergy app** under **Settings → System Settings → About → System ID**. Tap the System ID to copy it to the clipboard. + +With `automatic: true`, Predbat will wire all sensor and control entities automatically — no manual `apps.yaml` sensor configuration is needed. + +```yaml +``` + +### First run — onboarding approval + +The first time Predbat starts with the Sigenergy Cloud integration enabled, Sigenergy sends an **onboarding approval email** to the account holder. +You must click the approval link in that email before Predbat can subscribe to live data from the MQTT broker. +Once approved, the authorisation persists and no further action is required. + +## Sofar Inverters - Hardware - [sofar2mqtt EPS board](https://www.instructables.com/Sofar2mqtt-Remote-Control-for-Sofar-Solar-Inverter/) - Relatively easy to solder and flash, or can be bought pre-made. From 3755221b9ed1793756fae49311c819a82199f3eb Mon Sep 17 00:00:00 2001 From: Trefor Southwell <48591903+springfall2008@users.noreply.github.com> Date: Sat, 30 May 2026 14:32:44 +0100 Subject: [PATCH 18/18] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- apps/predbat/sigenergy.py | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/predbat/sigenergy.py b/apps/predbat/sigenergy.py index 76fdc688b..ec6a8ee69 100644 --- a/apps/predbat/sigenergy.py +++ b/apps/predbat/sigenergy.py @@ -1669,7 +1669,6 @@ def _control_info(self, system_id, direction, field): field_type = "number" field_units = "%" - ha_name = "{}.{}_{}_{}".format(field_type, self.prefix, "sigenergy", item_name.replace("sigenergy_{}_".format(slug), slug + "_", 1)) ha_name = "{}.{}_{}".format(field_type, self.prefix, item_name) return item_name, ha_name, friendly_name, field_type, field_units, default, min_value, max_value