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
0 commit comments