Skip to content

Commit 982aea6

Browse files
fix(deye): fail the first cycle when the startup poll returns no telemetry
automatic_config() runs on the first cycle alone, so a startup that completed without telemetry would map only the args backed by cached ratings (soc_max, battery_rate_max, inverter_limit) and permanently skip the energy args — import_today and friends — until the next process restart. Since fetch_sensor_data() raises ValueError when load_today is unset, that aborts every Predbat run. run() now returns False on a first cycle whose live poll produced nothing. ComponentBase leaves first set when run() returns False and retries the whole startup path on its backoff (60s doubling to a 128 minute cap), so startup completes properly once the API answers. Gated on the live poll only, not on config/battery: that endpoint can be permanently unsupported on a model (2106001), and gating on it would back off to 128 minutes and never complete startup. automatic_config() already degrades gracefully when the battery config is missing. This closes the gap left by no longer caching telemetry — previously a restored live cache could have carried a failed first poll. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 2e122b0 commit 982aea6

3 files changed

Lines changed: 106 additions & 1 deletion

File tree

apps/predbat/deye.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1146,8 +1146,9 @@ async def run(self, seconds, first):
11461146
if self.tier_expired("config", DEYE_TTL_CONFIG):
11471147
await self.refresh_config()
11481148

1149+
live_ok = True
11491150
if self.tier_expired("live", DEYE_TTL_LIVE):
1150-
await self.refresh_live()
1151+
live_ok = await self.refresh_live()
11511152

11521153
# Free: reads the HA control entities rather than the API, so it runs every tick
11531154
# regardless of tier.
@@ -1187,6 +1188,15 @@ async def run(self, seconds, first):
11871188

11881189
await self._reconcile_control()
11891190

1191+
if first and not live_ok:
1192+
# Startup has not really succeeded without telemetry: automatic_config() would
1193+
# map only the args backed by cached ratings and permanently skip the rest,
1194+
# because it runs on the first cycle alone. Returning False leaves first set,
1195+
# so ComponentBase retries the whole startup path on its backoff (60s doubling
1196+
# to 128 minutes) until a poll comes back.
1197+
self.log("Warn: DEYE first poll returned no telemetry, deferring startup; it will be retried after a backoff")
1198+
return False
1199+
11901200
if first and self.automatic:
11911201
await self.automatic_config()
11921202
return True

apps/predbat/tests/test_deye_storage.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -414,6 +414,88 @@ async def fake_post(endpoint_key, body):
414414
assert not failed, "test_fresh_applied_payload_suppresses_a_redundant_write"
415415

416416

417+
def test_first_run_fails_when_telemetry_is_unavailable():
418+
"""A first cycle with no telemetry returns False so ComponentBase retries startup.
419+
420+
automatic_config() runs on the first cycle alone, so completing startup without
421+
telemetry would permanently skip the energy args (import_today and friends) until the
422+
next process restart. Failing the run leaves first set and the driver backs off and
423+
retries instead.
424+
"""
425+
failed = False
426+
d = StorageDeye(auth_method="oauth")
427+
d._mock_storage = _warm_cache()
428+
configured = []
429+
430+
async def fake_post(endpoint_key, body):
431+
"""Fake DEYE POST: device/latest reports a failure body."""
432+
if endpoint_key == "device_latest":
433+
return {"success": False, "msg": "device offline"}
434+
return {"success": True}
435+
436+
async def record_config():
437+
"""Record that automatic_config was reached."""
438+
configured.append(True)
439+
440+
with patch.object(d, "_post", side_effect=fake_post):
441+
with patch.object(d, "check_and_refresh_oauth_token", side_effect=_true):
442+
with patch.object(d, "publish_data", side_effect=_noop):
443+
with patch.object(d, "publish_schedule_settings_ha", side_effect=_noop_arg):
444+
with patch.object(d, "_reconcile_control", side_effect=_noop):
445+
with patch.object(d, "automatic_config", side_effect=record_config):
446+
d.automatic = True
447+
result = run_async(d.run(seconds=0, first=True))
448+
449+
if result is not False:
450+
print(f"ERROR: a first run without telemetry must return False, got {result!r}")
451+
failed = True
452+
if configured:
453+
print("ERROR: automatic_config must not run on an incomplete first cycle")
454+
failed = True
455+
if not any("deferring startup" in m for m in d.log_messages):
456+
print(f"ERROR: expected a deferred-startup warning: {d.log_messages}")
457+
failed = True
458+
assert not failed, "test_first_run_fails_when_telemetry_is_unavailable"
459+
460+
461+
def test_first_run_succeeds_once_telemetry_arrives():
462+
"""The retry completes startup: telemetry polls, automatic_config runs, run returns True."""
463+
failed = False
464+
d = StorageDeye(auth_method="oauth")
465+
d._mock_storage = _warm_cache()
466+
configured = []
467+
468+
async def fake_post(endpoint_key, body):
469+
"""Fake DEYE POST: device/latest now succeeds."""
470+
if endpoint_key == "device_latest":
471+
return {"success": True, "deviceDataList": [{"deviceSn": "INV1", "dataList": LIVE_DATA_LIST}]}
472+
return {"success": True}
473+
474+
async def record_config():
475+
"""Record that automatic_config was reached."""
476+
configured.append(True)
477+
478+
with patch.object(d, "_post", side_effect=fake_post):
479+
with patch.object(d, "check_and_refresh_oauth_token", side_effect=_true):
480+
with patch.object(d, "publish_data", side_effect=_noop):
481+
with patch.object(d, "publish_schedule_settings_ha", side_effect=_noop_arg):
482+
with patch.object(d, "_reconcile_control", side_effect=_noop):
483+
with patch.object(d, "automatic_config", side_effect=record_config):
484+
d.automatic = True
485+
result = run_async(d.run(seconds=0, first=True))
486+
487+
if result is not True:
488+
print(f"ERROR: a first run with telemetry must return True, got {result!r}")
489+
failed = True
490+
if not configured:
491+
print("ERROR: automatic_config should run once telemetry is available")
492+
failed = True
493+
if not d.device_energy.get("INV1"):
494+
print(f"ERROR: energy counters should be populated for automatic_config: {d.device_energy}")
495+
failed = True
496+
assert not failed, "test_first_run_succeeds_once_telemetry_arrives"
497+
498+
417499
def test_storage_absent_behaves_as_before():
418500
"""With no storage component every load/save no-ops and each tier simply refreshes."""
419501
failed = False
@@ -576,6 +658,8 @@ def run_deye_storage_tests(my_predbat):
576658
("restore_primes_ratings", test_restore_primes_the_ratings_signature),
577659
("stale_applied_payload", test_stale_applied_payload_is_discarded_but_orders_are_not),
578660
("fresh_applied_payload_suppresses", test_fresh_applied_payload_suppresses_a_redundant_write),
661+
("first_fails_without_telemetry", test_first_run_fails_when_telemetry_is_unavailable),
662+
("first_succeeds_with_telemetry", test_first_run_succeeds_once_telemetry_arrives),
579663
("storage_absent", test_storage_absent_behaves_as_before),
580664
("corrupt_cache_isolated", test_corrupt_cache_only_affects_its_own_tier),
581665
("shape_validation", test_shape_validation_rejects_garbage),

docs/superpowers/specs/2026-07-28-deye-storage-persistence-design.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,15 @@ actually performs, and forces a corrective rewrite after any longer outage.
152152
next tick. No other tier is affected.
153153
- Restored data is shape-validated (`isinstance` dict/list) before use, as
154154
`gecloud.py` does, so a truncated file cannot poison in-memory state.
155+
- A first cycle whose live poll returns nothing makes `run()` return **False**.
156+
`automatic_config()` runs on the first cycle alone, so completing startup
157+
without telemetry would permanently skip the energy args until the next process
158+
restart. Returning False leaves `first` set and `ComponentBase` retries the
159+
whole startup path on its backoff (60s doubling to 128 minutes). Deliberately
160+
gated on the live poll only, not on `config/battery`: that endpoint can be
161+
permanently unsupported on a model (`2106001`), and gating on it would back off
162+
to 128 minutes and never complete startup, whereas `automatic_config()` already
163+
degrades gracefully when battery config is missing.
155164
- A failed refresh must **not** save — saving would reset the TTL and skip the
156165
retry — and must not clobber good in-memory state. This is the same principle
157166
as the `RatedPower` clobber fix: absence of data is not zero.
@@ -178,6 +187,8 @@ Cases:
178187
- `applied_payload` restored inside 15 minutes suppresses a redundant write;
179188
outside 15 minutes it is discarded and the next apply writes.
180189
- A discovery that returns nothing does not clobber an already-known device list.
190+
- A first cycle without telemetry returns False and does not run
191+
`automatic_config()`; the retry with telemetry returns True and does.
181192
- A pending order restored from storage resumes polling to completion.
182193

183194
## Files touched

0 commit comments

Comments
 (0)