Skip to content

Commit eeacd19

Browse files
mgazzaclaude
andcommitted
fix(sigenergy): stop Predbat and Axle fighting over the inverter
A Sigenergy accepts one controller at a time, and VPP mode (Predbat) and the NorthBound Interface (Axle's dispatch channel) are mutually exclusive. Today Predbat wins that contest silently, on a 5 minute timer, and the axle_control option that is supposed to let Axle win does not work here at all. Observed on a live system on 2026-08-06: Axle took the inverter into Northbound Integration at 19:35:18 and it was back in VPP at 19:40:12 — exactly one SIGENERGY_POLL_INTERVAL — while the log only said "controls skipped until onboard is approved". Make axle_control actually work on Sigenergy: - _axle_has_control() evaluates axle_control and the Axle event sensor LIVE rather than reading Fetch's cached set_read_only_axle. That flag is only refreshed by the 5 minute prediction loop and is still False from reset() when this phase-1 component makes its first run — precisely the case that matters, a restart during a live event, where the cached flag would have had Predbat reclaim VPP and kill the dispatch. - during an event the operating mode is left exactly as it is. If Axle has moved the system to NBI it stays there; if the event has started but Axle has not switched yet, do not drop to MSC either — that hands control to the owner's app rather than to Axle. - battery commands are suppressed for the same window. - VPP is reclaimed when the event ends, and that is logged. And where Predbat does keep ownership, make it deliberate rather than a race: - reclaim VPP every minute instead of every 5. This also lets an event start or end be picked up promptly now that ownership is evaluated here. set_operating_ mode is an MQTT publish that only fires when the mode is wrong, so it costs nothing against the REST rate limit. - log the reclaim naming the controller being displaced. - record last_contended_by on the status sensor, published on the same minute cadence and never cleared. Contention is usually shorter than a publish interval, so a marker reset on recovery would almost never be seen; "has this happened" is the useful signal, and in_vpp already answers "right now". - stop reporting a system in NBI as pending_approval. The SaaS UI renders that as an amber "approve this in the Sigenergy app" banner, so every Axle event told the user to approve something that needed no approval. Behaviour with axle_control unset is unchanged except for the faster reclaim, the clearer logs and the status fix. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent b2b4e0d commit eeacd19

2 files changed

Lines changed: 350 additions & 13 deletions

File tree

apps/predbat/sigenergy.py

Lines changed: 113 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@
8282
HAS_AIOMQTT = False
8383

8484
from datetime import datetime, timedelta
85+
from axle import fetch_axle_active
8586
from component_base import ComponentBase
8687
from mock_base import MockBase as SharedMockBase
8788
from predbat_metrics import record_api_call
@@ -132,6 +133,7 @@
132133
SIGENERGY_TOKEN_EXPIRY_BUFFER = 600 # refresh token 10 min before expiry
133134
SIGENERGY_MIN_REQUEST_INTERVAL = 6.0 # enforce ≥10 req/min API limit
134135
SIGENERGY_POLL_INTERVAL = 300 # realtime data poll every 5 minutes
136+
SIGENERGY_VPP_RECLAIM_INTERVAL = 60 # re-assert VPP ownership every minute (see _manage_vpp_registration)
135137
SIGENERGY_DEVICE_POLL_INTERVAL = 1800 # device list refresh every 30 minutes
136138
SIGENERGY_RATE_LIMIT_BACKOFF = [15, 30, 60, 120, 480] # seconds to wait after code 1201
137139
SIGENERGY_BATTERY_NOMINAL_VOLTAGE_V = 28.8 # 8S LiFePO4 pack: 8 × 3.6V; used to convert ratedEnergy (Ah) → kWh
@@ -150,6 +152,11 @@
150152
SIGENERGY_MODE_VPP = 6 # VPP mode
151153
SIGENERGY_MODE_NBI = 8 # NorthBound (defined for completeness; not switched to by this component)
152154

155+
# Modes that mean a third party is driving the inverter rather than the owner's app.
156+
# A Sigenergy accepts one controller at a time, so finding the system in one of these
157+
# means Predbat's VPP registration has been displaced — see _manage_vpp_registration.
158+
SIGENERGY_THIRD_PARTY_MODES = (SIGENERGY_MODE_NBI,)
159+
153160
# Human-readable names for operationalMode integer values
154161
SIGENERGY_MODE_NAMES = {
155162
0: "Maximum Self-Consumption",
@@ -309,6 +316,8 @@ def initialize(self, app_key, app_secret, base_url=None, mqtt_host=None, ca_cert
309316
self.history_totals = {} # systemId → {sankey node id: lifetime kWh total}
310317
self.mqtt_period_raw = {} # systemId → merged raw 'period' fields (MQTT only sends fields that changed)
311318
self.current_mode = {} # systemId → energyStorageOperationMode int
319+
self.last_contended_by = {} # systemId → mode name of the controller that last displaced Predbat
320+
self._axle_standoff_logged = {} # systemId → True while the Axle stand-down has been announced
312321
self.onboard_status = {} # systemId → onboarding status string (published for the SaaS UI)
313322

314323
# Age (datetime of last update) of each SIGENERGY_CACHE_KEYS category, used to avoid an
@@ -2161,6 +2170,27 @@ def parse_window(start_str, end_str):
21612170
# VPP registration management
21622171
# -----------------------------------------------------------------------
21632172

2173+
def _axle_has_control(self):
2174+
"""Return True while an Axle VPP event owns the inverter under the axle_control option.
2175+
2176+
Predbat's ``axle_control`` option means "let Axle drive the battery during its
2177+
events". Fetch.fetch_config_options() expresses that as ``set_read_only_axle``, but
2178+
that flag is only refreshed on the 5-minute prediction loop and is still False from
2179+
reset() when this component makes its first run — which is exactly the case that
2180+
matters, a restart in the middle of a live event. So evaluate the same condition
2181+
live here instead of reading the cached flag: the component runs every minute, so
2182+
an event start or end is picked up promptly and correctly across a restart.
2183+
2184+
Fails safe: with no ``axle_control`` and no Axle session entity this returns False,
2185+
leaving Predbat as the owner exactly as before.
2186+
2187+
Returns:
2188+
True if an Axle event currently owns the inverter, False otherwise.
2189+
"""
2190+
if not self.get_arg("axle_control", False):
2191+
return False
2192+
return fetch_axle_active(self)
2193+
21642194
async def _manage_vpp_registration(self, system_id, is_readonly, is_offboard=False):
21652195
"""Align the operating mode with the read-only and offboard switch settings.
21662196
@@ -2176,6 +2206,16 @@ async def _manage_vpp_registration(self, system_id, is_readonly, is_offboard=Fal
21762206
readonly=False + VPP active → nothing to do (ready for controls)
21772207
readonly=False + VPP inactive → switch to VPP mode to enable controls
21782208
2209+
An active Axle event under the ``axle_control`` option takes priority over every
2210+
case above: Predbat stands down and leaves the operating mode untouched so Axle can
2211+
drive the battery through the NorthBound Interface.
2212+
2213+
Otherwise Predbat is the owner. A Sigenergy accepts one controller at a time and
2214+
VPP mode and NBI are mutually exclusive, so finding the system in NBI means
2215+
reclaiming it — which overrides whatever the other controller had scheduled.
2216+
Predbat ingests Axle sessions as its own export windows (see load_axle_slot), so
2217+
the event still runs; it runs under Predbat's plan rather than Axle's dispatch.
2218+
21792219
Args:
21802220
system_id: Sigenergy system unique identifier.
21812221
is_readonly: Current state of the Predbat read-only switch.
@@ -2189,13 +2229,37 @@ async def _manage_vpp_registration(self, system_id, is_readonly, is_offboard=Fal
21892229
if is_offboard:
21902230
return False
21912231

2232+
# Axle owns the inverter for the duration of its event. Leave the mode exactly as
2233+
# it is: if Axle has already moved the system to NBI it stays there, and if the
2234+
# event has started but Axle has not switched yet, do not pull it to MSC either —
2235+
# that would hand control to the owner's app rather than to Axle.
2236+
if self._axle_has_control():
2237+
if not self._axle_standoff_logged.get(system_id):
2238+
self.log("SigenergyAPI: Axle VPP event active — leaving system {} in {} and standing down until the event ends".format(system_id, SIGENERGY_MODE_NAMES.get(self.current_mode.get(system_id, -1), "Unknown")))
2239+
self._axle_standoff_logged[system_id] = True
2240+
return False
2241+
if self._axle_standoff_logged.pop(system_id, False):
2242+
self.log("SigenergyAPI: Axle VPP event ended — resuming control of system {}".format(system_id))
2243+
21922244
if is_readonly and in_vpp:
21932245
self.log("SigenergyAPI: Read-only mode active — switching system {} from VPP to MSC".format(system_id))
21942246
await self.set_operating_mode(system_id, SIGENERGY_MODE_MSC)
21952247
return False
21962248

21972249
if not is_readonly and not in_vpp:
2198-
self.log("SigenergyAPI: System {} is not in VPP mode — switching to VPP to enable controls".format(system_id))
2250+
current = self.current_mode.get(system_id, -1)
2251+
if current in SIGENERGY_THIRD_PARTY_MODES:
2252+
# Another controller — typically an Axle dispatch running without
2253+
# axle_control set — has taken the inverter. Predbat is the owner here, so
2254+
# reclaim, and say so plainly since this displaces the other schedule.
2255+
self.last_contended_by[system_id] = SIGENERGY_MODE_NAMES.get(current, "Unknown")
2256+
self.log(
2257+
"Warn: SigenergyAPI: System {} was taken by another controller ({}) — reclaiming VPP mode, which overrides that controller's schedule".format(
2258+
system_id, SIGENERGY_MODE_NAMES.get(current, "Unknown ({})".format(current))
2259+
)
2260+
)
2261+
else:
2262+
self.log("SigenergyAPI: System {} is not in VPP mode ({}) — switching to VPP to enable controls".format(system_id, SIGENERGY_MODE_NAMES.get(current, "Unknown")))
21992263
await self.set_operating_mode(system_id, SIGENERGY_MODE_VPP)
22002264
return False # current_mode will be updated by MQTT/REST on the next cycle
22012265

@@ -2217,6 +2281,13 @@ def _publish_onboard_status(self):
22172281
"friendly_name": "Sigenergy {} Onboarding Status".format(sid),
22182282
"system_id": sid,
22192283
"in_vpp": self.current_mode.get(sid) == SIGENERGY_MODE_VPP,
2284+
# Records the last controller to displace Predbat's VPP registration.
2285+
# Deliberately never cleared: contention often lasts less than one
2286+
# publish cycle, so a marker that is reset on recovery would almost
2287+
# never be seen. Support needs "has this happened", not "is it
2288+
# happening right now" — which in_vpp already answers.
2289+
"last_contended_by": self.last_contended_by.get(sid),
2290+
"axle_has_control": self._axle_has_control(),
22202291
},
22212292
app="sigenergy",
22222293
)
@@ -2379,10 +2450,19 @@ async def run(self, seconds, first):
23792450
for sid in list(self.systems.keys()):
23802451
await self.fetch_device_list(sid)
23812452

2382-
# VPP registration management — runs at startup and every 5 minutes.
2453+
# VPP registration management — runs at startup and every minute.
2454+
#
2455+
# This used to run on the 5 minute poll interval, so when another controller took
2456+
# the system it could hold it for up to 5 minutes before Predbat noticed. The
2457+
# minute cadence also means an Axle event start or end is picked up promptly, which
2458+
# matters now that the stand-down is evaluated here rather than read from a flag
2459+
# the prediction loop refreshes. set_operating_mode is an MQTT publish and only
2460+
# fires when the mode is actually wrong, so this costs nothing against the REST
2461+
# rate limit.
2462+
#
23832463
# Skips any system whose operating mode is not yet known (REST bootstrap
23842464
# may have failed; MQTT will populate current_mode once it arrives).
2385-
if first or seconds % SIGENERGY_POLL_INTERVAL == 0:
2465+
if first or seconds % SIGENERGY_VPP_RECLAIM_INTERVAL == 0:
23862466
is_readonly_vpp = self.get_state_wrapper("switch.{}_set_read_only".format(self.prefix), default="off") == "on"
23872467
for sid in list(self.systems.keys()):
23882468
if sid not in self.current_mode:
@@ -2392,18 +2472,29 @@ async def run(self, seconds, first):
23922472
is_offboard = self.get_state_wrapper("switch.{}_sigenergy_{}_offboard".format(self.prefix, slug), default="off") == "on"
23932473
await self._manage_vpp_registration(sid, is_readonly_vpp, is_offboard)
23942474
# Derive the user-facing onboarding status for the visible system.
2475+
# A system sitting in a third-party mode is fully onboarded — another
2476+
# controller has simply taken it. Reporting "pending_approval" there makes
2477+
# the SaaS UI show an amber "waiting for your approval in the Sigenergy
2478+
# app" banner for the length of every Axle event, telling the user to go
2479+
# and approve something that needs no approval.
23952480
if is_offboard:
23962481
self.onboard_status[str(sid)] = "offboarded"
23972482
elif self.current_mode.get(sid) == SIGENERGY_MODE_VPP:
23982483
self.onboard_status[str(sid)] = "active"
2484+
elif self.current_mode.get(sid) in SIGENERGY_THIRD_PARTY_MODES:
2485+
self.onboard_status[str(sid)] = "active"
23992486
else:
24002487
self.onboard_status[str(sid)] = "pending_approval"
2401-
await self._save_cache("onboard_status", self.onboard_status)
24022488

2403-
# Publish onboarding status for the SaaS UI.
2404-
if first or seconds % SIGENERGY_POLL_INTERVAL == 0:
2489+
# Publish on the same cadence as the check above, so a contention episode
2490+
# shorter than a poll interval still reaches the sensor.
24052491
self._publish_onboard_status()
24062492

2493+
# Persist the derived status on the slower poll cadence — the check above runs
2494+
# every minute and the cache does not need rewriting that often.
2495+
if first or seconds % SIGENERGY_POLL_INTERVAL == 0:
2496+
await self._save_cache("onboard_status", self.onboard_status)
2497+
24072498
# Fetch controls from HA on first run only
24082499
if first:
24092500
for sid in list(self.systems.keys()):
@@ -2464,16 +2555,27 @@ async def run(self, seconds, first):
24642555
await self.automatic_config()
24652556

24662557
# Apply controls
2467-
is_readonly = self.get_state_wrapper("switch.{}_set_read_only".format(self.prefix), default="off") == "on"
2558+
# Treat an active Axle event as read-only: Axle is driving the battery, so Predbat
2559+
# must not also be issuing charge/discharge commands at the same inverter.
2560+
is_readonly = self.get_state_wrapper("switch.{}_set_read_only".format(self.prefix), default="off") == "on" or self._axle_has_control()
24682561
if self.enable_controls and not is_readonly:
24692562
if first or seconds % 60 == 0:
24702563
for sid in list(self.systems.keys()):
24712564
if self.current_mode.get(sid) != SIGENERGY_MODE_VPP:
2472-
self.log(
2473-
"Warn: SigenergyAPI: System {} is not in VPP mode ({}) — controls skipped until onboard is approved".format(
2474-
sid, SIGENERGY_MODE_NAMES.get(self.current_mode.get(sid, -1), "Unknown")
2565+
current = self.current_mode.get(sid, -1)
2566+
if current in SIGENERGY_THIRD_PARTY_MODES:
2567+
# Nothing to approve — another controller holds the system.
2568+
self.log(
2569+
"Warn: SigenergyAPI: System {} is held by another controller ({}) — controls skipped until VPP mode is reclaimed".format(
2570+
sid, SIGENERGY_MODE_NAMES.get(current, "Unknown")
2571+
)
2572+
)
2573+
else:
2574+
self.log(
2575+
"Warn: SigenergyAPI: System {} is not in VPP mode ({}) — controls skipped until onboard is approved".format(
2576+
sid, SIGENERGY_MODE_NAMES.get(current, "Unknown")
2577+
)
24752578
)
2476-
)
24772579
continue
24782580
await self.apply_controls(sid)
24792581
else:

0 commit comments

Comments
 (0)