diff --git a/apps/predbat/sigenergy.py b/apps/predbat/sigenergy.py index 0b5c54daa..2e7b93080 100644 --- a/apps/predbat/sigenergy.py +++ b/apps/predbat/sigenergy.py @@ -228,7 +228,7 @@ # Storage cache keys for poll-interval system state persisted between restarts (see # load_cached_data()). System/device discovery is deliberately excluded — it is always # re-fetched fresh on startup. -SIGENERGY_CACHE_KEYS = ["energy_flow", "daily_summary", "history_totals", "onboard_status"] +SIGENERGY_CACHE_KEYS = ["energy_flow", "daily_summary", "history_totals", "onboard_status", "grid_export"] # Sentinel returned by _request() when the API responds with code=0 but an empty/null data field. # Distinguishes "success with no payload" from None which always means "request failed". @@ -489,6 +489,7 @@ def initialize(self, app_key, app_secret, base_url=None, mqtt_host=None, ca_cert self._offboard_vpp_exit_done = set() # systemIds confirmed out of VPP ahead of an offboard self._offboard_done = set() # systemIds successfully offboarded this process self.onboard_status = {} # systemId → onboarding status string (published for the SaaS UI) + self.grid_export = {} # systemId → {"antiReflux": "on"/"off", "gridMaxBackfeedPowerW": W} from openapi/change, cached # Age (datetime of last update) of each SIGENERGY_CACHE_KEYS category, used to avoid an # unnecessary API refresh on restart when the persisted cache is still fresh @@ -1768,6 +1769,23 @@ def _get_inverter_max_power_kw(self, system_id): power += _safe_float(attr.get("ratedActivePower", 0)) return power + def _get_export_limit_w(self, system_id): + """Return the grid export limit in W, or 99999 (no limit) when the system is not limiting export. + + Both fields only arrive in an ``openapi/change`` MQTT message (see _handle_mqtt_change): + antiReflux "off" → 99999, whatever gridMaxBackfeedPowerW says + antiReflux "on" → gridMaxBackfeedPowerW, including 0 (zero export) + antiReflux never reported → gridMaxBackfeedPowerW when above 0, otherwise 99999 + """ + grid_export = self.grid_export.get(system_id, {}) + anti_reflux = grid_export.get("antiReflux", None) + backfeed_w = grid_export.get("gridMaxBackfeedPowerW", None) + if anti_reflux == "off" or backfeed_w is None: + return 99999 + if anti_reflux == "on" or backfeed_w > 0: + return round(backfeed_w) + return 99999 + # ----------------------------------------------------------------------- # MQTT message handlers # ----------------------------------------------------------------------- @@ -1882,33 +1900,36 @@ def _field(raw_key, previous, flow_key, scale=1.0, negate=False): 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]``. + Updates ``self.controls[system_id]`` with the SOC limit values, + capacity/power fields in ``self.systems[system_id]`` and the export + limiting fields in ``self.grid_export[system_id]``. The ``change`` topic fires whenever the inverter's configuration - changes (e.g. via the Sigenergy app or a remote API command). + changes (e.g. via the Sigenergy app or a remote API command), which can + be days apart - hence grid_export is cached across restarts. Per the user's clarification: backupCutOffSOC% → reserve (backup/emergency minimum) chargeCutOffSOC% → charge target_soc dischargeCutOffSOC% → export target_soc (minimum discharge floor) + antiReflux + gridMaxBackfeedPowerW → export limit (see _get_export_limit_w) - - example message: + Example message, from a plant with a 3.68 kW G98 export cap on a 12 kW inverter: { - "batteryRatedChargePowerW": "22000.0", - "batteryRatedCapabilityWh": "45200.0", - "backupCutOffSOC%": "15.0", + "antiReflux": "on", + "batteryRatedChargePowerW": "13200.0", + "batteryRatedCapabilityWh": "27120.0", + "backupCutOffSOC%": "16.0", "inverterMaxAbsorptionPowerW": "12000.0", "peakShavingStatus": "off", "stormWatchStatus": "off", - "batteryRatedDischargePowerW": "24000.0", + "batteryRatedDischargePowerW": "14400.0", "inverterMaxActivePowerW": "12000.0", - "dischargeCutOffSOC%": "5.0", + "dischargeCutOffSOC%": "0.0", "chargeCutOffSOC%": "100.0", "peakShavingCutOffSOC%": "0.0", - "inverterMaxApprentPowerVar": "12000.0", - "gridMaxBackfeedPowerW": "5000.0" + "gridMaxBackfeedPowerW": "3680.0", + "inverterMaxApprentPowerVar": "12000.0" } Args: @@ -1947,12 +1968,20 @@ def _handle_mqtt_change(self, system_id, value_dict): ("batteryRatedChargePowerW", "ratedChargePower"), ("batteryRatedDischargePowerW", "ratedDischargePower"), ("inverterMaxActivePowerW", "ratedActivePower"), - ("gridMaxBackfeedPowerW", "gridMaxBackfeedPower"), ): val = _safe_float(value_dict.get(mqtt_field, None)) if val: self.systems[system_id][sys_key] = val / 1000.0 + # Export limiting - merged field by field, since a message may carry only what changed. Unlike the + # rated powers above a 0 W backfeed is kept, as with antiReflux on it means zero export. + anti_reflux = value_dict.get("antiReflux", None) + if isinstance(anti_reflux, str) and anti_reflux.strip(): + self.grid_export.setdefault(system_id, {})["antiReflux"] = anti_reflux.strip().lower() + backfeed_w = _safe_float(value_dict.get("gridMaxBackfeedPowerW", None), None) + if backfeed_w is not None and backfeed_w >= 0: + self.grid_export.setdefault(system_id, {})["gridMaxBackfeedPowerW"] = backfeed_w + self.log( "SigenergyAPI: MQTT change {}: reserve={}% charge_target={}% export_target={}%".format( system_id, reserve, charge_target, export_target @@ -2288,6 +2317,18 @@ async def publish_system_entities(self, system_id): app="sigenergy", ) + # --- Export limit (W) --- + self.dashboard_item( + "sensor.{}_sigenergy_{}_export_limit".format(self.prefix, slug), + state=self._get_export_limit_w(system_id), + attributes={ + "friendly_name": "Sigenergy {} Export Limit".format(system_name), + "unit_of_measurement": "W", + "device_class": "power", + }, + app="sigenergy", + ) + # --- Operational mode (string) --- # flow_status (from MQTT) is preferred as it's the freshest, but fall back to the # REST-sourced current_mode (fetch_current_mode) so this doesn't read "Unknown" before @@ -2380,6 +2421,8 @@ async def automatic_config(self): 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]) + # An export_limit the user wrote in apps.yaml wins - the sensor reads 99999 until the plant has reported its limit + self.set_arg_auto("export_limit", ["sensor.{}_sigenergy_{}_export_limit".format(self.prefix, s) for s in slugs], overwrite=False) 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]) @@ -3088,6 +3131,8 @@ async def load_cached_data(self): reboot during the API's own nightly dip window can't let a lower reading through. The rest (energy_flow, daily_summary, onboard_status) is restored too so entities publish sensible values immediately rather than "unknown"/0 while the first poll is in flight. + grid_export is restored because nothing but an openapi/change message reports it, and + those can be days apart - without it the export limit would read 99999 until the next one. """ if not self.storage: return @@ -3108,6 +3153,10 @@ async def load_cached_data(self): if onboard_status is not None: self.onboard_status = onboard_status + grid_export = await self._load_cache("grid_export") + if grid_export is not None: + self.grid_export = grid_export + self.log("SigenergyAPI: Restored cached poll-interval state from storage") @@ -3256,6 +3305,10 @@ async def run(self, seconds, first): # every minute and the cache does not need rewriting that often. if first or seconds % SIGENERGY_POLL_INTERVAL == 0: await self._save_cache("onboard_status", self.onboard_status) + # Rewritten every poll, not just when a change message arrives, because the cache expires + # after a day and the plant may go longer than that without reporting its export limit again + if self.grid_export: + await self._save_cache("grid_export", self.grid_export) # Fetch controls from HA on first run only if first: diff --git a/apps/predbat/tests/test_sigenergy.py b/apps/predbat/tests/test_sigenergy.py index 95522da49..e47e0fa20 100644 --- a/apps/predbat/tests/test_sigenergy.py +++ b/apps/predbat/tests/test_sigenergy.py @@ -12,6 +12,7 @@ import asyncio from datetime import datetime, timezone, timedelta +from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch from sigenergy import ( @@ -134,6 +135,8 @@ def __init__(self, prefix="predbat"): self.dashboard_items = {} self.set_args = {} self.args = {} + # The parts of PredBat that ComponentBase.set_arg_auto() reads: the apps.yaml snapshot and its one-time warning set + self.base = SimpleNamespace(args_from_apps_yaml={}, apps_yaml_override_warned=set()) # Now call the SigenergyAPI initialize directly self.initialize( @@ -504,6 +507,197 @@ def test_sigenergy_automatic_config(my_predbat): assert api.set_args.get("load_today") == ["sensor.predbat_sigenergy_sig001_load_lifetime", "sensor.predbat_sigenergy_sig002_load_lifetime"], "load_today wired to lifetime totals" assert api.set_args.get("import_today") == ["sensor.predbat_sigenergy_sig001_grid_import_lifetime", "sensor.predbat_sigenergy_sig002_grid_import_lifetime"], "import_today wired to lifetime totals" assert api.set_args.get("export_today") == ["sensor.predbat_sigenergy_sig001_grid_export_lifetime", "sensor.predbat_sigenergy_sig002_grid_export_lifetime"], "export_today wired to lifetime totals" + assert api.set_args.get("export_limit") == ["sensor.predbat_sigenergy_sig001_export_limit", "sensor.predbat_sigenergy_sig002_export_limit"], "export_limit wired to the grid backfeed limit sensors" + + return failed + + +def test_sigenergy_automatic_config_keeps_user_export_limit(my_predbat): + """An export_limit the user set in apps.yaml survives automatic_config() rather than being replaced by the sensor.""" + failed = False + api = MockSigenergyAPI() + api.automatic = True + api.systems = {"SIG001": {"systemName": "Home"}} + api.base.args_from_apps_yaml = {"export_limit": 3680} + api.args["export_limit"] = 3680 + + run_async(api.automatic_config()) + + assert "export_limit" not in api.set_args, "automatic_config must not set export_limit over the user's own, got {}".format(api.set_args.get("export_limit")) + assert api.args["export_limit"] == 3680, "The user's 3680 W export_limit is kept, got {}".format(api.args["export_limit"]) + assert any("keeping your apps.yaml setting" in m for m in api.log_messages), "Keeping the user's export_limit is logged" + + return failed + + +def test_sigenergy_publish_export_limit(my_predbat): + """The grid backfeed limit from an MQTT change message is published as the export limit sensor in W.""" + failed = False + api = MockSigenergyAPI() + + system_id = "SIG12345" + slug = api._system_slug(system_id) + api.systems[system_id] = {"systemName": "My Site"} + api.devices[system_id] = [{"deviceType": "Inverter", "attrMap": {"ratedActivePower": 12.0}}] + + api._handle_mqtt_change(system_id, {"inverterMaxActivePowerW": "12000.0", "gridMaxBackfeedPowerW": "5000.0"}) + run_async(api.publish_system_entities(system_id)) + + key = "sensor.predbat_sigenergy_{}_export_limit".format(slug) + assert key in api.dashboard_items, "Export limit entity published" + assert api.dashboard_items[key]["state"] == 5000, "Backfeed limit 5kW published as 5000W, got {}".format(api.dashboard_items[key]["state"]) + assert api.dashboard_items[key]["attributes"].get("unit_of_measurement") == "W", "Export limit is in W so get_arg(required_unit='W') accepts it" + + return failed + + +def test_sigenergy_publish_export_limit_unknown(my_predbat): + """With no backfeed limit reported yet the export limit sensor reads 99999 W, i.e. no limit beyond the inverter's own.""" + failed = False + api = MockSigenergyAPI() + + system_id = "SIG12345" + slug = api._system_slug(system_id) + api.systems[system_id] = {"systemName": "My Site"} + api.devices[system_id] = [{"deviceType": "Inverter", "attrMap": {"ratedActivePower": 12.0}}] + + run_async(api.publish_system_entities(system_id)) + + key = "sensor.predbat_sigenergy_{}_export_limit".format(slug) + assert key in api.dashboard_items, "Export limit entity published" + assert api.dashboard_items[key]["state"] == 99999, "Unknown backfeed limit published as 99999W, got {}".format(api.dashboard_items[key]["state"]) + + return failed + + +# A real openapi/change message from a plant with a 3.68 kW G98 export cap on a 12 kW inverter (PR #5681 review) +SIGENERGY_LIVE_CHANGE_MESSAGE = { + "antiReflux": "on", + "batteryRatedChargePowerW": "13200.0", + "batteryRatedCapabilityWh": "27120.0", + "backupCutOffSOC%": "16.0", + "inverterMaxAbsorptionPowerW": "12000.0", + "peakShavingStatus": "off", + "stormWatchStatus": "off", + "batteryRatedDischargePowerW": "14400.0", + "inverterMaxActivePowerW": "12000.0", + "dischargeCutOffSOC%": "0.0", + "chargeCutOffSOC%": "100.0", + "peakShavingCutOffSOC%": "0.0", + "gridMaxBackfeedPowerW": "3680.0", + "inverterMaxApprentPowerVar": "12000.0", +} + + +def _published_export_limit(api, system_id): + """Publish a system's entities and return the state of its export limit sensor.""" + run_async(api.publish_system_entities(system_id)) + return api.dashboard_items["sensor.predbat_sigenergy_{}_export_limit".format(api._system_slug(system_id))]["state"] + + +def _make_export_limit_api(system_id="SIG12345"): + """Return a MockSigenergyAPI with one 12 kW system and no export limit reported yet.""" + api = MockSigenergyAPI() + api.systems[system_id] = {"systemName": "My Site"} + api.devices[system_id] = [{"deviceType": "Inverter", "attrMap": {"ratedActivePower": 12.0}}] + return api + + +def test_sigenergy_export_limit_live_change_message(my_predbat): + """The live change message (antiReflux on, 3680 W backfeed) publishes a 3680 W export limit.""" + failed = False + api = _make_export_limit_api() + + api._handle_mqtt_change("SIG12345", dict(SIGENERGY_LIVE_CHANGE_MESSAGE)) + + state = _published_export_limit(api, "SIG12345") + assert state == 3680, "antiReflux on with a 3680 W backfeed limit publishes 3680 W, got {}".format(state) + + return failed + + +def test_sigenergy_export_limit_anti_reflux_off(my_predbat): + """With antiReflux off the plant is not limiting export, so the backfeed value is ignored and 99999 W is published.""" + failed = False + api = _make_export_limit_api() + + api._handle_mqtt_change("SIG12345", dict(SIGENERGY_LIVE_CHANGE_MESSAGE, antiReflux="off")) + + state = _published_export_limit(api, "SIG12345") + assert state == 99999, "antiReflux off publishes 99999 W whatever gridMaxBackfeedPowerW says, got {}".format(state) + + return failed + + +def test_sigenergy_export_limit_anti_reflux_on_zero(my_predbat): + """With antiReflux on a 0 W backfeed limit means zero export, so 0 W is published rather than no limit.""" + failed = False + api = _make_export_limit_api() + + api._handle_mqtt_change("SIG12345", dict(SIGENERGY_LIVE_CHANGE_MESSAGE, gridMaxBackfeedPowerW="0.0")) + + state = _published_export_limit(api, "SIG12345") + assert state == 0, "antiReflux on with a 0 W backfeed limit publishes 0 W (zero export), got {}".format(state) + + return failed + + +def test_sigenergy_export_limit_zero_without_anti_reflux(my_predbat): + """Without antiReflux a 0 W backfeed limit is ambiguous, so it keeps reading as no limit (99999 W).""" + failed = False + api = _make_export_limit_api() + + api._handle_mqtt_change("SIG12345", {"gridMaxBackfeedPowerW": "0.0"}) + + state = _published_export_limit(api, "SIG12345") + assert state == 99999, "A 0 W backfeed limit with no antiReflux flag publishes 99999 W, got {}".format(state) + + return failed + + +def test_sigenergy_export_limit_partial_change_keeps_limit(my_predbat): + """A later change message without the export fields leaves the reported limit in place.""" + failed = False + api = _make_export_limit_api() + + api._handle_mqtt_change("SIG12345", dict(SIGENERGY_LIVE_CHANGE_MESSAGE)) + api._handle_mqtt_change("SIG12345", {"backupCutOffSOC%": "20.0"}) + + state = _published_export_limit(api, "SIG12345") + assert state == 3680, "A change message carrying only the reserve keeps the 3680 W limit, got {}".format(state) + + return failed + + +def test_sigenergy_export_limit_survives_restart(my_predbat): + """run() persists the reported export limit and load_cached_data() restores it, so a restart doesn't fall back to 99999 W.""" + failed = False + sid = "SIG12345" + storage = FakeStorage() + + api = _make_export_limit_api(sid) + api._mock_storage = storage + api.current_mode = {sid: SIGENERGY_MODE_VPP} + api.system_id_filter = {sid} + task = MagicMock() + task.done = MagicMock(return_value=False) + api._mqtt_task = task + api._manage_vpp_registration = AsyncMock(return_value=True) + api.fetch_inverter_realtime = AsyncMock(return_value=True) + api.fetch_energy_flow = AsyncMock(return_value=True) + api.fetch_daily_summary = AsyncMock(return_value=True) + api.fetch_history_totals = AsyncMock(return_value=True) + api.apply_controls = AsyncMock() + api._handle_mqtt_change(sid, dict(SIGENERGY_LIVE_CHANGE_MESSAGE)) + + run_async(api.run(seconds=300, first=False)) + + restarted = _make_export_limit_api(sid) + restarted._mock_storage = storage + run_async(restarted.load_cached_data()) + + state = _published_export_limit(restarted, sid) + assert state == 3680, "The 3680 W limit is restored from the cache after a restart, got {}".format(state) return failed @@ -1805,7 +1999,7 @@ def test_sigenergy_handle_mqtt_change(my_predbat): 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 api.grid_export["SYS1"] == {"gridMaxBackfeedPowerW": 5000.0}, "Backfeed limit kept in W for the export limit, got {}".format(api.grid_export.get("SYS1")) assert any("MQTT change" in m for m in api.log_messages), "Change data logged" return failed @@ -4294,6 +4488,15 @@ def run_sigenergy_tests(my_predbat): ("publish_system_entities_operational_mode_fallback", test_sigenergy_publish_system_entities_operational_mode_fallback), ("publish_system_entities_operational_mode_invalid_value", test_sigenergy_publish_system_entities_operational_mode_invalid_value), ("automatic_config", test_sigenergy_automatic_config), + ("publish_export_limit", test_sigenergy_publish_export_limit), + ("publish_export_limit_unknown", test_sigenergy_publish_export_limit_unknown), + ("automatic_config_keeps_user_export_limit", test_sigenergy_automatic_config_keeps_user_export_limit), + ("export_limit_live_change_message", test_sigenergy_export_limit_live_change_message), + ("export_limit_anti_reflux_off", test_sigenergy_export_limit_anti_reflux_off), + ("export_limit_anti_reflux_on_zero", test_sigenergy_export_limit_anti_reflux_on_zero), + ("export_limit_zero_without_anti_reflux", test_sigenergy_export_limit_zero_without_anti_reflux), + ("export_limit_partial_change_keeps_limit", test_sigenergy_export_limit_partial_change_keeps_limit), + ("export_limit_survives_restart", test_sigenergy_export_limit_survives_restart), ("fetch_controls", test_sigenergy_fetch_controls), ("publish_controls", test_sigenergy_publish_controls), ("parse_entity_system", test_sigenergy_parse_entity_system), diff --git a/docs/components.md b/docs/components.md index 3265401e9..6a2f0c72c 100644 --- a/docs/components.md +++ b/docs/components.md @@ -1360,6 +1360,7 @@ Supports real-time monitoring (SOC, power flows, operational mode) and full char - 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 +- With `automatic: true` Predbat models your grid export limit from `sensor.predbat_sigenergy__export_limit`: the plant's grid max backfeed power while export limiting (`antiReflux`) is on, including 0 W for zero export, and 99999 W (no limit) while it is off. Sigenergy only reports these when the plant's configuration changes, so the last reported values are cached across restarts; a plant that has never reported reads 99999 W and exports are capped by the inverter limit alone. An `export_limit` you set yourself in `apps.yaml` is kept rather than replaced #### Configuration Options (sigenergy)