diff --git a/apps/predbat/sigenergy.py b/apps/predbat/sigenergy.py index 0dfaa5bb6..000bc83a3 100644 --- a/apps/predbat/sigenergy.py +++ b/apps/predbat/sigenergy.py @@ -318,6 +318,8 @@ def initialize(self, app_key, app_secret, base_url=None, mqtt_host=None, ca_cert self.current_mode = {} # systemId → energyStorageOperationMode int self.last_contended_by = {} # systemId → mode name of the controller that last displaced Predbat self._axle_standoff_logged = {} # systemId → True while the Axle stand-down has been announced + 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) # Age (datetime of last update) of each SIGENERGY_CACHE_KEYS category, used to avoid an @@ -2003,9 +2005,18 @@ async def _update_control(self, entity_id, value, direction, field, system_id): self.log("SigenergyAPI: Control update system={} direction={} field={} value={}".format(system_id, direction, field, value)) await self.publish_controls(system_id) - if field == "offboard" and value is True: - self.log("SigenergyAPI: Offboard toggle turned on for {} — offboarding".format(system_id)) - await self.offboard_systems(system_id) + if field == "offboard": + if value is True: + self.log("SigenergyAPI: Offboard toggle turned on for {}".format(system_id)) + if not await self._offboard_system_if_needed(system_id): + self.log("SigenergyAPI: Offboard of {} incomplete — the periodic check will retry".format(system_id)) + else: + # Re-onboarding — let a future offboard run both steps again. + self._offboard_vpp_exit_done.discard(system_id) + self._offboard_done.discard(system_id) + self.onboard_status[str(system_id)] = "not_onboarded" + await self._save_cache("onboard_status", self.onboard_status) + self._publish_onboard_status() def _parse_entity_system(self, entity_id): """Extract (system_id, direction, field) from a control entity ID. @@ -2191,6 +2202,79 @@ def _axle_has_control(self): return False return fetch_axle_active(self) + async def _exit_vpp_for_offboard(self, system_id): + """Leave VPP mode so the owner's app regains control of an offboarded system. + + Sigenergy's offboard endpoint is not documented to drop the system out of VPP, + so we do it explicitly rather than relying on it as a side-effect. Getting this + wrong strands the owner in the worst possible state: still in VPP, so their + mySigen app cannot control the battery, but with Predbat no longer driving it + either. + + Only an owner-controlled mode observed in current_mode is latched. A successful MQTT publish only + confirms that the command was accepted by the broker; it does not confirm that + the inverter has changed mode. Offboarding must wait for MQTT/REST telemetry to + report MSC or another owner-controlled mode, otherwise the REST offboard can race + the mode command and revoke our authorisation while the owner's app is still blocked. + + Args: + system_id: Sigenergy system unique identifier. + + Returns: + True if telemetry confirms the owner's app has control, False while the + switch is unknown, failed, or still waiting for confirmation. + """ + current_mode = self.current_mode.get(system_id) + if current_mode == SIGENERGY_MODE_VPP or current_mode in SIGENERGY_THIRD_PARTY_MODES: + # Live telemetry always wins over a stale latch: the system may have been + # moved back under platform control while an earlier offboard attempt was + # failing. NBI also blocks the owner's app, so an explicit offboard takes + # priority over the Axle stand-down and exits that mode too. + self._offboard_vpp_exit_done.discard(system_id) + self.log("SigenergyAPI: Offboarding system {} — switching {} to MSC so the owner's app regains control".format(system_id, SIGENERGY_MODE_NAMES.get(current_mode, "platform control"))) + if not await self.set_operating_mode(system_id, SIGENERGY_MODE_MSC): + self.log("Warn: SigenergyAPI: Could not return {} to owner control — deferring offboard rather than locking the owner out".format(system_id)) + else: + self.log("SigenergyAPI: Owner control requested for {} — waiting for operating-mode confirmation before offboarding".format(system_id)) + return False + if current_mode not in (SIGENERGY_MODE_MSC, SIGENERGY_MODE_FFG): + self.log("Warn: SigenergyAPI: Operating mode unknown for {} — deferring offboard until owner control can be confirmed".format(system_id)) + return False + if system_id not in self._offboard_vpp_exit_done: + self._offboard_vpp_exit_done.add(system_id) + return True + + async def _offboard_system_if_needed(self, system_id): + """Take a system out of VPP and then off the platform, in that order. + + Ordering matters: once offboarded we may no longer be authorised to set the + operating mode, so the VPP exit has to land first. Both steps are latched only + on success, so a transient failure of either is retried by the next poll + instead of being silently abandoned half-done. + + Args: + system_id: Sigenergy system unique identifier. + + Returns: + True once the system has been offboarded, False while steps remain. + """ + if system_id in self._offboard_done: + return True + if not await self._exit_vpp_for_offboard(system_id): + return False + self.log("SigenergyAPI: Offboarding system {}".format(system_id)) + result = await self.offboard_systems(system_id) + result_items = result if isinstance(result, list) else [result] + item_failed = any(isinstance(item, dict) and item.get("result") is False for item in result_items) + if result is None or item_failed: + self.log("Warn: SigenergyAPI: Offboard failed for {} — will retry on the next poll".format(system_id)) + return False + self._offboard_done.add(system_id) + self.onboard_status[str(system_id)] = "offboarded" + await self._save_cache("onboard_status", self.onboard_status) + self._publish_onboard_status() + return True + async def _manage_vpp_registration(self, system_id, is_readonly, is_offboard=False): """Align the operating mode with the read-only and offboard switch settings. @@ -2199,16 +2283,17 @@ async def _manage_vpp_registration(self, system_id, is_readonly, is_offboard=Fal block in run(). Cases (offboard takes priority over readonly): - offboard=True + VPP active → switch to MSC so the user's app regains control - offboard=True + VPP inactive → nothing to do (already out of VPP) + offboard=True + VPP/NBI active → switch to MSC so the user's app regains control + offboard=True + owner mode → offboard the system readonly=True + VPP active → switch to MSC so the user's app regains control readonly=True + VPP inactive → nothing to do readonly=False + VPP active → nothing to do (ready for controls) readonly=False + VPP inactive → switch to VPP mode to enable controls - An active Axle event under the ``axle_control`` option takes priority over every - case above: Predbat stands down and leaves the operating mode untouched so Axle can - drive the battery through the NorthBound Interface. + An explicit offboard takes priority over an active Axle event so it can return the + system from NBI to the owner's app before removing authorisation. For every other + case an active Axle event under the ``axle_control`` option makes Predbat stand down + and leave the operating mode untouched. Otherwise Predbat is the owner. A Sigenergy accepts one controller at a time and VPP mode and NBI are mutually exclusive, so finding the system in NBI means @@ -2227,6 +2312,9 @@ async def _manage_vpp_registration(self, system_id, is_readonly, is_offboard=Fal in_vpp = self.current_mode.get(system_id) == SIGENERGY_MODE_VPP if is_offboard: + # Retries here until both steps land, so a failed mode switch or a failed + # offboard is picked up on the next poll rather than left half-done. + await self._offboard_system_if_needed(system_id) return False # Axle owns the inverter for the duration of its event. Leave the mode exactly as @@ -2375,6 +2463,7 @@ async def load_cached_data(self): if onboard_status is not None: self.onboard_status = onboard_status + self.log("SigenergyAPI: Restored cached poll-interval state from storage") # ----------------------------------------------------------------------- @@ -2414,6 +2503,9 @@ async def run(self, seconds, first): for sid in missing_ids: self.onboard_status.setdefault(str(sid), "not_onboarded") slug = self._system_slug(sid) + # The switch is the source of truth: it is a control entity, so its state + # is restored on startup like every other one. Onboarding a system the + # owner deliberately left would cost them a fresh approval email. is_offboard_at_start = self.get_state_wrapper("switch.{}_sigenergy_{}_offboard".format(self.prefix, slug), default="off") == "on" if is_offboard_at_start: self.log("SigenergyAPI: System {} offboard toggle is on — skipping onboard attempt".format(sid)) @@ -2426,7 +2518,12 @@ async def run(self, seconds, first): return False await self.fetch_system_list() + if not self.systems: + # An intentionally offboarded system is absent from the authorised list. + # Publish the restored completion state before returning for retry so a + # restart does not replace a truthful "offboarded" sensor with silence. + self._publish_onboard_status() self.log("Warn: SigenergyAPI: No systems available after discovery, will retry") return False @@ -2477,8 +2574,12 @@ async def run(self, seconds, first): # the SaaS UI show an amber "waiting for your approval in the Sigenergy # app" banner for the length of every Axle event, telling the user to go # and approve something that needs no approval. - if is_offboard: + if is_offboard and sid in self._offboard_done: self.onboard_status[str(sid)] = "offboarded" + elif is_offboard: + # The system is still authorised while the VPP exit or offboard call + # is pending. It is not waiting for onboarding approval. + self.onboard_status[str(sid)] = "active" elif self.current_mode.get(sid) == SIGENERGY_MODE_VPP: self.onboard_status[str(sid)] = "active" elif self.current_mode.get(sid) in SIGENERGY_THIRD_PARTY_MODES: diff --git a/apps/predbat/tests/test_sigenergy.py b/apps/predbat/tests/test_sigenergy.py index 3bc9b6c4f..f50692f99 100644 --- a/apps/predbat/tests/test_sigenergy.py +++ b/apps/predbat/tests/test_sigenergy.py @@ -2251,7 +2251,7 @@ def test_sigenergy_update_control_time_validation(my_predbat): def test_sigenergy_offboard_toggle_in_vpp(my_predbat): - """offboard=True → return False immediately regardless of VPP state (no mode switch).""" + """offboard=True + VPP active waits for observed MSC before offboarding.""" failed = False sid = "SIG001" api = _make_api_with_system(sid) @@ -2264,10 +2264,20 @@ async def mock_set_mode(system_id, mode_int): return True api.set_operating_mode = mock_set_mode + api.offboard_systems = AsyncMock(return_value=[]) result = run_async(api._manage_vpp_registration(sid, is_readonly=False, is_offboard=True)) assert result is False, "offboard=True should return False" - assert not modes_set, "Should not call set_operating_mode — offboard API already changes mode" + assert modes_set == [SIGENERGY_MODE_MSC], "Should leave VPP explicitly rather than assume offboard does it" + api.offboard_systems.assert_not_awaited() + + # MQTT publish success is not mode confirmation. Only observed telemetry allows the + # REST offboard to follow, preventing a race between the two transports. + api.current_mode[sid] = SIGENERGY_MODE_MSC + result = run_async(api._manage_vpp_registration(sid, is_readonly=False, is_offboard=True)) + assert result is False, "offboard=True should still return False" + assert modes_set == [SIGENERGY_MODE_MSC], "A confirmed VPP exit should not be repeated" + api.offboard_systems.assert_awaited_once_with(sid) return failed @@ -2286,30 +2296,209 @@ async def mock_set_mode(system_id, mode_int): return True api.set_operating_mode = mock_set_mode + api.offboard_systems = AsyncMock(return_value=[]) result = run_async(api._manage_vpp_registration(sid, is_readonly=False, is_offboard=True)) assert result is False, "offboard=True should return False" assert not modes_set, "No mode switch needed" + api.offboard_systems.assert_awaited_once_with(sid) + + return failed + + +def test_sigenergy_offboard_overrides_axle_control(my_predbat): + """An explicit offboard exits Axle's NBI mode before removing authorisation.""" + failed = False + sid = "SIG001" + api = _make_api_with_system(sid) + api.current_mode[sid] = SIGENERGY_MODE_NBI + api.args["axle_control"] = True + api.args["axle_session"] = "binary_sensor.predbat_axle_event" + api.dashboard_items["binary_sensor.predbat_axle_event"] = {"state": "on"} + api.set_operating_mode = AsyncMock(return_value=True) + api.offboard_systems = AsyncMock(return_value=[]) + + run_async(api._manage_vpp_registration(sid, is_readonly=False, is_offboard=True)) + run_async(api._manage_vpp_registration(sid, is_readonly=False, is_offboard=True)) + + assert api.set_operating_mode.await_count == 2, "MSC is retried while telemetry still reports NBI" + api.set_operating_mode.assert_awaited_with(sid, SIGENERGY_MODE_MSC) + api.offboard_systems.assert_not_awaited() + + api.current_mode[sid] = SIGENERGY_MODE_MSC + run_async(api._manage_vpp_registration(sid, is_readonly=False, is_offboard=True)) + api.offboard_systems.assert_awaited_once_with(sid) + + return failed + + +def test_sigenergy_offboard_defers_when_the_mode_switch_fails(my_predbat): + """A failed VPP exit must NOT offboard — that is the lockout this guards against.""" + failed = False + sid = "SIG001" + api = _make_api_with_system(sid) + api.controls[sid] = {"offboard": False} + api.current_mode[sid] = SIGENERGY_MODE_VPP + + offboarded = [] + mode_attempts = [] + + async def mock_set_mode(system_id, mode_int): + mode_attempts.append(mode_int) + return False # broker down / token expired + + async def mock_offboard(system_ids): + offboarded.append(system_ids) + return True + + async def mock_publish_controls(system_id=None): + pass + + api.set_operating_mode = mock_set_mode + api.offboard_systems = mock_offboard + api.publish_controls = mock_publish_controls + + run_async(api._update_control("switch.predbat_sigenergy_sig001_offboard", "turn_on", None, "offboard", sid)) + assert not offboarded, "Must not revoke authorisation while the system is still in VPP" + + # Retried by the periodic check. A successful MQTT publish still is not enough. + run_async(api._manage_vpp_registration(sid, is_readonly=False, is_offboard=True)) + assert len(mode_attempts) == 2, "A failed VPP exit must be retried, not latched" + assert not offboarded, "Still no offboard while the exit keeps failing" + + api.set_operating_mode = _make_ok_set_mode(mode_attempts) + run_async(api._manage_vpp_registration(sid, is_readonly=False, is_offboard=True)) + assert not offboarded, "MQTT acceptance alone must not allow offboarding" + + api.current_mode[sid] = SIGENERGY_MODE_MSC + run_async(api._manage_vpp_registration(sid, is_readonly=False, is_offboard=True)) + assert offboarded, "Offboard proceeds once telemetry confirms the system is out of VPP" + + return failed + + +def _make_ok_set_mode(recorder): + """Return a set_operating_mode stub that records and succeeds.""" + + async def _ok(system_id, mode_int): + recorder.append(mode_int) + return True + + return _ok + + +def test_sigenergy_offboard_retries_when_the_api_call_fails(my_predbat): + """A failed offboard is retried rather than reported as done.""" + failed = False + sid = "SIG001" + api = _make_api_with_system(sid) + api.current_mode[sid] = SIGENERGY_MODE_MSC # already out of VPP + + attempts = [] + + async def mock_offboard(system_ids): + attempts.append(system_ids) + return None if len(attempts) == 1 else [] + + api.offboard_systems = mock_offboard + + done = run_async(api._offboard_system_if_needed(sid)) + assert done is False, "A failed offboard must not be latched as complete" + + done = run_async(api._offboard_system_if_needed(sid)) + assert done is True, "The retry succeeds" + assert len(attempts) == 2, "Offboard retried exactly once after the failure" + + run_async(api._offboard_system_if_needed(sid)) + assert len(attempts) == 2, "A completed offboard is not repeated" + + return failed + + +def test_sigenergy_offboard_retries_per_item_failure(my_predbat): + """A per-system failure payload must not be latched as a successful offboard.""" + failed = False + sid = "SIG001" + api = _make_api_with_system(sid) + api.current_mode[sid] = SIGENERGY_MODE_MSC + api.offboard_systems = AsyncMock(side_effect=[[{"systemId": sid, "result": False, "codeList": [1200]}], []]) + + assert run_async(api._offboard_system_if_needed(sid)) is False + assert sid not in api._offboard_done, "Per-item failure must leave the completion latch clear" + assert run_async(api._offboard_system_if_needed(sid)) is True + assert api.offboard_systems.await_count == 2, "Per-item failure is retried" + + return failed + + +def test_sigenergy_offboard_unknown_mode_defers(my_predbat): + """An absent or unrecognised mode is not proof that the owner's app has control.""" + failed = False + sid = "SIG001" + api = _make_api_with_system(sid) + api.offboard_systems = AsyncMock(return_value=[]) + + assert run_async(api._offboard_system_if_needed(sid)) is False + api.offboard_systems.assert_not_awaited() + assert sid not in api._offboard_vpp_exit_done, "Unknown mode must not latch the VPP exit" + + api.current_mode[sid] = 42 + assert run_async(api._offboard_system_if_needed(sid)) is False + api.offboard_systems.assert_not_awaited() + assert sid not in api._offboard_vpp_exit_done, "Unrecognised mode must not latch the VPP exit" + + return failed + + +def test_sigenergy_offboard_switch_survives_restart(my_predbat): + """A restart must not re-onboard a system the owner deliberately left. + + The offboard switch is a control entity, so its state is restored on startup like + every other one — no separate durable latch is needed, and re-onboarding would cost + the owner a fresh approval email. + """ + failed = False + sid = "SIG001" + api = MockSigenergyAPI() + api.system_id_filter = {sid} + api.systems = {} + slug = api._system_slug(sid) + api.dashboard_items["switch.predbat_sigenergy_{}_offboard".format(slug)] = {"state": "on"} + + onboarded = [] + + async def mock_onboard(system_ids): + onboarded.append(system_ids) + return True + + api.onboard_systems = mock_onboard + + run_async(api.run(seconds=0, first=True)) + assert not onboarded, "A restart must not re-onboard a system whose offboard switch is on" return failed def test_sigenergy_offboard_toggle_switch_event(my_predbat): - """Turning on the offboard switch triggers offboard_systems (no mode switch).""" + """Turning on offboard leaves VPP, then waits for telemetry before offboarding.""" failed = False sid = "SIG001" api = _make_api_with_system(sid) api.controls[sid] = {"offboard": False} + api.current_mode[sid] = SIGENERGY_MODE_VPP offboarded = [] modes_set = [] + order = [] async def mock_offboard(system_ids): offboarded.append(system_ids) + order.append("offboard") return True async def mock_set_mode(system_id, mode_int): modes_set.append((system_id, mode_int)) + order.append("mode") return True async def mock_publish_controls(system_id=None): @@ -2322,8 +2511,22 @@ async def mock_publish_controls(system_id=None): run_async(api._update_control("switch.predbat_sigenergy_sig001_offboard", "turn_on", None, "offboard", sid)) assert api.controls[sid]["offboard"] is True, "offboard control should be True after turn_on" - assert offboarded, "offboard_systems should be called" - assert not modes_set, "set_operating_mode should NOT be called — offboard API changes the mode" + assert not offboarded, "offboard_systems must wait for telemetry confirmation" + assert modes_set == [(sid, SIGENERGY_MODE_MSC)], "Should hand control back to the owner's app explicitly" + assert order == ["mode"], "Only the mode request is sent while telemetry still says VPP" + + api.current_mode[sid] = SIGENERGY_MODE_MSC + run_async(api._manage_vpp_registration(sid, is_readonly=False, is_offboard=True)) + assert offboarded, "offboard_systems follows once MSC is observed" + assert order == ["mode", "offboard"], "Must confirm the VPP exit before offboarding" + + # Toggling back off clears the latch so a later offboard exits VPP again. + api.current_mode[sid] = SIGENERGY_MODE_VPP + run_async(api._update_control("switch.predbat_sigenergy_sig001_offboard", "turn_off", None, "offboard", sid)) + assert api.controls[sid]["offboard"] is False, "offboard control should be False after turn_off" + run_async(api._update_control("switch.predbat_sigenergy_sig001_offboard", "turn_on", None, "offboard", sid)) + assert modes_set == [(sid, SIGENERGY_MODE_MSC), (sid, SIGENERGY_MODE_MSC)], "Re-onboard then offboard should exit VPP again" + assert order == ["mode", "offboard", "mode"], "Second offboard also waits for live confirmation" return failed @@ -2442,12 +2645,20 @@ def _make_api(mode, offboard_on=False): assert api_pending.onboard_status[sid] == "pending_approval", "pending_approval derived from MSC mode" assert api_pending.dashboard_items[sensor_key]["state"] == "pending_approval" - # Offboard toggle on → offboarded regardless of mode + # Offboard COMPLETED → offboarded regardless of mode. api_offboard = _make_api(SIGENERGY_MODE_VPP, offboard_on=True) + api_offboard._offboard_done.add(sid) run_async(api_offboard.run(seconds=300, first=False)) - assert api_offboard.onboard_status[sid] == "offboarded", "offboarded when toggle is on" + assert api_offboard.onboard_status[sid] == "offboarded", "offboarded once the offboard has landed" assert api_offboard.dashboard_items[sensor_key]["state"] == "offboarded" + # Toggle on but the offboard has NOT landed yet (mode switch or API call still + # failing): the status must not claim offboarded, or support reads a lie while + # the system is still live on the platform. + api_inflight = _make_api(SIGENERGY_MODE_MSC, offboard_on=True) + run_async(api_inflight.run(seconds=300, first=False)) + assert api_inflight.onboard_status[sid] == "active", "pending offboard stays active rather than falsely requesting onboarding approval" + return failed @@ -2763,7 +2974,13 @@ def run_sigenergy_tests(my_predbat): ("apply_controls_skipped_when_not_vpp", test_sigenergy_apply_controls_skipped_when_not_vpp), ("offboard_toggle_in_vpp", test_sigenergy_offboard_toggle_in_vpp), ("offboard_toggle_not_in_vpp", test_sigenergy_offboard_toggle_not_in_vpp), + ("offboard_overrides_axle_control", test_sigenergy_offboard_overrides_axle_control), ("offboard_toggle_switch_event", test_sigenergy_offboard_toggle_switch_event), + ("offboard_defers_when_mode_switch_fails", test_sigenergy_offboard_defers_when_the_mode_switch_fails), + ("offboard_retries_when_api_call_fails", test_sigenergy_offboard_retries_when_the_api_call_fails), + ("offboard_retries_per_item_failure", test_sigenergy_offboard_retries_per_item_failure), + ("offboard_unknown_mode_defers", test_sigenergy_offboard_unknown_mode_defers), + ("offboard_switch_survives_restart", test_sigenergy_offboard_switch_survives_restart), ("publish_onboard_status_sensors", test_sigenergy_publish_onboard_status_sensors), ("run_derives_onboard_status", test_sigenergy_run_derives_onboard_status), ("run_pending_publishes_before_early_exit", test_sigenergy_run_pending_publishes_before_early_exit),