Skip to content

Commit c8e1aa8

Browse files
mgazzaclaude
andcommitted
refactor(sigenergy): drop the durable offboard latch, the switch already persists
Rounds 2 and 3 added a persisted offboard_done set, a restore path, a _offboard_done_loaded tracking set and a reconcile function whose clearing depended on switch state. Three of round 3's six findings were in that cluster, which is the signature of machinery generating its own defects. It was redundant twice over. The offboard switch is a control entity, so its state is restored on startup like every other one — which is why the pre-existing startup guard already read it, and why customers' Demo mode and mode selections survive every image rollout. And onboard_status was already in SIGENERGY_CACHE_KEYS before any of this, so the user-visible state persisted through the existing cache regardless. So the durable latch was defending against a restart that the switch already covered, and preserving a status the existing cache already preserved. Removing it also lets the two run() blocks go back to reading the switch directly instead of reconciling it against a second source of truth. What remains is the part that fixes the bug: exit VPP explicitly rather than assuming offboard does it, require telemetry confirmation of an owner-controlled mode before offboarding, latch each step only on success, and treat per-item offboard failures as failures. The in-memory _offboard_done still prevents a repeat call within a process and is cleared when the switch goes off. 184 insertions down to 110. Suite: 84 pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 15b3641 commit c8e1aa8

2 files changed

Lines changed: 27 additions & 152 deletions

File tree

apps/predbat/sigenergy.py

Lines changed: 8 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -221,7 +221,7 @@
221221
# Storage cache keys for poll-interval system state persisted between restarts (see
222222
# load_cached_data()). System/device discovery is deliberately excluded — it is always
223223
# re-fetched fresh on startup.
224-
SIGENERGY_CACHE_KEYS = ["energy_flow", "daily_summary", "history_totals", "onboard_status", "offboard_done"]
224+
SIGENERGY_CACHE_KEYS = ["energy_flow", "daily_summary", "history_totals", "onboard_status"]
225225

226226
# Sentinel returned by _request() when the API responds with code=0 but an empty/null data field.
227227
# Distinguishes "success with no payload" from None which always means "request failed".
@@ -319,8 +319,7 @@ def initialize(self, app_key, app_secret, base_url=None, mqtt_host=None, ca_cert
319319
self.last_contended_by = {} # systemId → mode name of the controller that last displaced Predbat
320320
self._axle_standoff_logged = {} # systemId → True while the Axle stand-down has been announced
321321
self._offboard_vpp_exit_done = set() # systemIds confirmed out of VPP ahead of an offboard
322-
self._offboard_done = set() # systemIds successfully offboarded
323-
self._offboard_done_loaded = set() # subset of the above restored from cache, not yet reconciled
322+
self._offboard_done = set() # systemIds successfully offboarded this process
324323
self.onboard_status = {} # systemId → onboarding status string (published for the SaaS UI)
325324

326325
# Age (datetime of last update) of each SIGENERGY_CACHE_KEYS category, used to avoid an
@@ -2015,9 +2014,7 @@ async def _update_control(self, entity_id, value, direction, field, system_id):
20152014
# Re-onboarding — let a future offboard run both steps again.
20162015
self._offboard_vpp_exit_done.discard(system_id)
20172016
self._offboard_done.discard(system_id)
2018-
self._offboard_done_loaded.discard(system_id)
20192017
self.onboard_status[str(system_id)] = "not_onboarded"
2020-
await self._save_offboard_done()
20212018
await self._save_cache("onboard_status", self.onboard_status)
20222019
self._publish_onboard_status()
20232020

@@ -2273,9 +2270,7 @@ async def _offboard_system_if_needed(self, system_id):
22732270
self.log("Warn: SigenergyAPI: Offboard failed for {} — will retry on the next poll".format(system_id))
22742271
return False
22752272
self._offboard_done.add(system_id)
2276-
self._offboard_done_loaded.discard(system_id)
22772273
self.onboard_status[str(system_id)] = "offboarded"
2278-
await self._save_offboard_done()
22792274
await self._save_cache("onboard_status", self.onboard_status)
22802275
self._publish_onboard_status()
22812276
return True
@@ -2389,45 +2384,6 @@ def _publish_onboard_status(self):
23892384
# Storage cache
23902385
# -----------------------------------------------------------------------
23912386

2392-
async def _save_offboard_done(self):
2393-
"""Persist completed offboards without the one-day telemetry-cache expiry.
2394-
2395-
Offboarding remains true until the user explicitly re-onboards, so expiring this
2396-
latch like energy telemetry would make a later process restart forget the external
2397-
action and publish an untrue status.
2398-
"""
2399-
if self.storage:
2400-
# A restored completion may be provisionally cleared in memory while a still
2401-
# visible system is offboarded again. Keep that recovery point durable until
2402-
# the retry succeeds or the user explicitly requests re-onboarding.
2403-
await self.storage.save("sigenergy", "offboard_done", sorted(self._offboard_done | self._offboard_done_loaded), format="json")
2404-
2405-
async def _reconcile_restored_offboards(self):
2406-
"""Reconcile restored completions for systems now visible as authorised.
2407-
2408-
This runs after any startup onboarding attempt, because a system that was absent
2409-
on the first discovery can become visible in the second fetch. Visibility means a
2410-
future offboard must run both steps again, but does not by itself prove that the
2411-
owner requested re-onboarding: the authorised list may still be converging after
2412-
a successful offboard. Only an explicit off switch clears the durable recovery
2413-
point; otherwise it is retained until the retry succeeds.
2414-
"""
2415-
restored_visible = set(self.systems.keys()) & self._offboard_done_loaded
2416-
if not restored_visible:
2417-
return
2418-
save_offboard_done = False
2419-
for sid in restored_visible:
2420-
slug = self._system_slug(sid)
2421-
offboard_state = self.get_state_wrapper("switch.{}_sigenergy_{}_offboard".format(self.prefix, slug), default=None)
2422-
self._offboard_done.discard(sid)
2423-
self.onboard_status[str(sid)] = "active"
2424-
if offboard_state == "off":
2425-
self._offboard_done_loaded.discard(sid)
2426-
save_offboard_done = True
2427-
if save_offboard_done:
2428-
await self._save_offboard_done()
2429-
await self._save_cache("onboard_status", self.onboard_status)
2430-
24312387
def _data_age_minutes(self, key):
24322388
"""Return the age in minutes of the in-memory data for a cache key, or None if unknown."""
24332389
timestamp = self.data_age.get(key, None)
@@ -2507,15 +2463,6 @@ async def load_cached_data(self):
25072463
if onboard_status is not None:
25082464
self.onboard_status = onboard_status
25092465

2510-
offboard_done = await self._load_cache("offboard_done")
2511-
if offboard_done is not None:
2512-
self._offboard_done = set(offboard_done)
2513-
# Keep track of restored entries separately. A freshly discovered authorised
2514-
# system must be offboarded again, but the durable completion remains a safe
2515-
# recovery point until that retry lands or re-onboarding is explicit.
2516-
self._offboard_done_loaded = set(offboard_done)
2517-
for sid in self._offboard_done:
2518-
self.onboard_status[str(sid)] = "offboarded"
25192466

25202467
self.log("SigenergyAPI: Restored cached poll-interval state from storage")
25212468

@@ -2556,18 +2503,13 @@ async def run(self, seconds, first):
25562503
for sid in missing_ids:
25572504
self.onboard_status.setdefault(str(sid), "not_onboarded")
25582505
slug = self._system_slug(sid)
2559-
offboard_state = self.get_state_wrapper("switch.{}_sigenergy_{}_offboard".format(self.prefix, slug), default=None)
2560-
is_offboard_at_start = offboard_state == "on" or (sid in self._offboard_done and offboard_state != "off")
2506+
# The switch is the source of truth: it is a control entity, so its state
2507+
# is restored on startup like every other one. Onboarding a system the
2508+
# owner deliberately left would cost them a fresh approval email.
2509+
is_offboard_at_start = self.get_state_wrapper("switch.{}_sigenergy_{}_offboard".format(self.prefix, slug), default="off") == "on"
25612510
if is_offboard_at_start:
2562-
self.log("SigenergyAPI: System {} is marked offboarded — skipping onboard attempt".format(sid))
2511+
self.log("SigenergyAPI: System {} offboard toggle is on — skipping onboard attempt".format(sid))
25632512
continue
2564-
if sid in self._offboard_done:
2565-
# An explicit off state is a re-onboarding request made while this
2566-
# component was stopped, so clear the durable completion first.
2567-
self._offboard_done.discard(sid)
2568-
self._offboard_done_loaded.discard(sid)
2569-
self.onboard_status[str(sid)] = "not_onboarded"
2570-
await self._save_offboard_done()
25712513
self.log("SigenergyAPI: System {} not found in authorised list — attempting onboard".format(sid))
25722514
result = await self.onboard_systems([sid])
25732515
if result is not True:
@@ -2576,10 +2518,6 @@ async def run(self, seconds, first):
25762518
return False
25772519
await self.fetch_system_list()
25782520

2579-
# The completion latch survives restarts. Reconcile after onboarding because
2580-
# its second fetch can make a previously absent system visible during this same
2581-
# startup, requiring either an offboard retry or an explicit re-onboarding clear.
2582-
await self._reconcile_restored_offboards()
25832521

25842522
if not self.systems:
25852523
# An intentionally offboarded system is absent from the authorised list.
@@ -2628,15 +2566,7 @@ async def run(self, seconds, first):
26282566
self.log("SigenergyAPI: Skipping VPP registration check for {} — operating mode not yet known".format(sid))
26292567
continue
26302568
slug = self._system_slug(sid)
2631-
offboard_state = self.get_state_wrapper("switch.{}_sigenergy_{}_offboard".format(self.prefix, slug), default=None)
2632-
if offboard_state == "off" and (sid in self._offboard_done or sid in self._offboard_done_loaded):
2633-
# The state may have changed while this component was stopped and no
2634-
# switch event was delivered. Treat an explicit off as re-onboarding.
2635-
self._offboard_vpp_exit_done.discard(sid)
2636-
self._offboard_done.discard(sid)
2637-
self._offboard_done_loaded.discard(sid)
2638-
await self._save_offboard_done()
2639-
is_offboard = offboard_state == "on" or (offboard_state is None and (sid in self._offboard_done or sid in self._offboard_done_loaded))
2569+
is_offboard = self.get_state_wrapper("switch.{}_sigenergy_{}_offboard".format(self.prefix, slug), default="off") == "on"
26402570
await self._manage_vpp_registration(sid, is_readonly_vpp, is_offboard)
26412571
# Derive the user-facing onboarding status for the visible system.
26422572
# A system sitting in a third-party mode is fully onboarded — another

apps/predbat/tests/test_sigenergy.py

Lines changed: 19 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -2450,85 +2450,31 @@ def test_sigenergy_offboard_unknown_mode_defers(my_predbat):
24502450
return failed
24512451

24522452

2453-
def test_sigenergy_offboard_latches_survive_restart_safely(my_predbat):
2454-
"""Restart restores only completed offboards; an in-flight exit is re-confirmed live."""
2455-
failed = False
2456-
sid = "SIG001"
2457-
storage = FakeStorage()
2458-
2459-
# A restart after the mode command landed but before offboarding has no in-memory
2460-
# exit latch. The REST/MQTT-repopulated MSC mode is enough to resume safely.
2461-
api_inflight = _make_api_with_system(sid)
2462-
api_inflight._mock_storage = storage
2463-
api_inflight.current_mode[sid] = SIGENERGY_MODE_MSC
2464-
api_inflight.offboard_systems = AsyncMock(return_value=[])
2465-
assert run_async(api_inflight._offboard_system_if_needed(sid)) is True
2466-
2467-
# Completion is persisted, so a restart after authorisation disappears does not
2468-
# lose the truthful status or retry an endpoint it may no longer be allowed to call.
2469-
api_restarted = MockSigenergyAPI()
2470-
api_restarted._mock_storage = storage
2471-
run_async(api_restarted.load_cached_data())
2472-
assert sid in api_restarted._offboard_done, "Completed offboard latch restored"
2473-
assert sid not in api_restarted._offboard_vpp_exit_done, "VPP-exit latch remains live-state only"
2474-
assert api_restarted.onboard_status[sid] == "offboarded", "Restart restores truthful status"
2475-
2476-
api_restarted.system_id_filter = {sid}
2477-
api_restarted.get_access_token = AsyncMock(return_value="tok")
2478-
api_restarted.fetch_system_list = AsyncMock() # Correctly absent after offboarding.
2479-
api_restarted.onboard_systems = AsyncMock()
2480-
assert run_async(api_restarted.run(seconds=0, first=True)) is False
2481-
api_restarted.onboard_systems.assert_not_awaited()
2482-
sensor_key = "sensor.predbat_sigenergy_sig001_onboard_status"
2483-
assert api_restarted.dashboard_items[sensor_key]["state"] == "offboarded", "Restart publishes completion even when the switch state is temporarily unavailable"
2453+
def test_sigenergy_offboard_switch_survives_restart(my_predbat):
2454+
"""A restart must not re-onboard a system the owner deliberately left.
24842455
2485-
# If the owner explicitly requests re-onboarding after restart, live visibility
2486-
# invalidates the restored completion and persists that clearing for the next process.
2487-
api_restarted.systems[sid] = {}
2488-
api_restarted.dashboard_items["switch.predbat_sigenergy_sig001_offboard"] = {"state": "off"}
2489-
run_async(api_restarted._reconcile_restored_offboards())
2490-
assert sid not in api_restarted._offboard_done, "Visible authorised system clears restored completion"
2491-
assert api_restarted.onboard_status[sid] == "active", "Re-onboarded system no longer reports offboarded"
2492-
api_after_re_onboard = MockSigenergyAPI()
2493-
api_after_re_onboard._mock_storage = storage
2494-
run_async(api_after_re_onboard.load_cached_data())
2495-
assert sid not in api_after_re_onboard._offboard_done, "Cleared completion remains cleared after another restart"
2496-
2497-
return failed
2498-
2499-
2500-
def test_sigenergy_visible_restored_offboard_keeps_recovery_point(my_predbat):
2501-
"""A lagging authorised list must not erase a completed offboard before its retry lands."""
2456+
The offboard switch is a control entity, so its state is restored on startup like
2457+
every other one — no separate durable latch is needed, and re-onboarding would cost
2458+
the owner a fresh approval email.
2459+
"""
25022460
failed = False
25032461
sid = "SIG001"
2504-
storage = FakeStorage()
2505-
storage.data[("sigenergy", "offboard_done")] = [sid]
2506-
25072462
api = MockSigenergyAPI()
2508-
api._mock_storage = storage
2509-
run_async(api.load_cached_data())
2510-
api.systems[sid] = {}
2511-
run_async(api._reconcile_restored_offboards())
2463+
api.system_id_filter = {sid}
2464+
api.systems = {}
2465+
slug = api._system_slug(sid)
2466+
api.dashboard_items["switch.predbat_sigenergy_{}_offboard".format(slug)] = {"state": "on"}
25122467

2513-
assert sid not in api._offboard_done, "Visible system is retried rather than accepted as complete"
2514-
assert sid in api._offboard_done_loaded, "Durable recovery point remains until the retry lands"
2515-
assert storage.data[("sigenergy", "offboard_done")] == [sid], "Lagging visibility must not clear durable completion"
2516-
assert api.onboard_status[sid] == "active", "Visible authorised system is reported active while retrying"
2468+
onboarded = []
25172469

2518-
api.current_mode[sid] = SIGENERGY_MODE_MSC
2519-
api.offboard_systems = AsyncMock(return_value=None)
2520-
api.enable_controls = False
2521-
mqtt_task = MagicMock()
2522-
mqtt_task.done = MagicMock(return_value=False)
2523-
api._mqtt_task = mqtt_task
2524-
assert run_async(api.run(seconds=60, first=False)) is True
2525-
api.offboard_systems.assert_awaited_once_with(sid)
2526-
assert api.onboard_status[sid] == "active", "Unavailable switch state preserves the offboard retry instead of reclaiming VPP"
2470+
async def mock_onboard(system_ids):
2471+
onboarded.append(system_ids)
2472+
return True
2473+
2474+
api.onboard_systems = mock_onboard
25272475

2528-
api_after_failure = MockSigenergyAPI()
2529-
api_after_failure._mock_storage = storage
2530-
run_async(api_after_failure.load_cached_data())
2531-
assert sid in api_after_failure._offboard_done, "A restart after retry failure restores the last confirmed completion"
2476+
run_async(api.run(seconds=0, first=True))
2477+
assert not onboarded, "A restart must not re-onboard a system whose offboard switch is on"
25322478

25332479
return failed
25342480

@@ -3034,8 +2980,7 @@ def run_sigenergy_tests(my_predbat):
30342980
("offboard_retries_when_api_call_fails", test_sigenergy_offboard_retries_when_the_api_call_fails),
30352981
("offboard_retries_per_item_failure", test_sigenergy_offboard_retries_per_item_failure),
30362982
("offboard_unknown_mode_defers", test_sigenergy_offboard_unknown_mode_defers),
3037-
("offboard_latches_survive_restart_safely", test_sigenergy_offboard_latches_survive_restart_safely),
3038-
("visible_restored_offboard_keeps_recovery_point", test_sigenergy_visible_restored_offboard_keeps_recovery_point),
2983+
("offboard_switch_survives_restart", test_sigenergy_offboard_switch_survives_restart),
30392984
("publish_onboard_status_sensors", test_sigenergy_publish_onboard_status_sensors),
30402985
("run_derives_onboard_status", test_sigenergy_run_derives_onboard_status),
30412986
("run_pending_publishes_before_early_exit", test_sigenergy_run_pending_publishes_before_early_exit),

0 commit comments

Comments
 (0)