Repository navigation
Conversation
…a stale read no longer overwrites a newer write GECloudDirect.run() awaited the whole periodic settings refresh inline. A register the inverter lists but cannot serve (AC charge slots 8/9 on a three-phase inverter) burns its full retry budget on every refresh, so one refresh could hold the component for ten minutes or more with no status or meter updates (a 656 s gap was observed in production). - The periodic refresh now runs as its own task. run() waits up to SETTINGS_REFRESH_WAIT_SECONDS (20 s) for it, then carries on with live data. A refresh that is still running is never overlapped. Startup and device-list changes still read the settings inline, because automatic config is built from them; a running background refresh is cancelled first. final() cancels the task and waits for it, and a cancelled settings read cancels and waits for its own read tasks. - A write confirmed while a read of the same register is in flight now wins over the older value read back. Pending writes are kept while a refresh is running, applied when the results are merged, and also written into the cached register by async_write_inverter_setting, so a register replaced mid-write is not left stale. A register written twice in one cycle reads back as its latest value. - A device removed from the account while its settings are read in the background is not put back into the cache, the entity map or the backoff state. - A register whose read fails keeps its last known value, now marked read_failed and published with a read_failed attribute rather than passed off as a fresh readback. - On periodic refreshes, a register that has failed 3 refreshes in a row is backed off: skipped for 1, 2, 4 and then at most 6 refreshes, then tried again. It is never hidden for good, and startup and new-device reads always read every register. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018BaJjLpuMEtQYfvmTRcFJf
- A write now wins over a settings read only when the write was confirmed after that register's read was issued, using a write sequence number. pending_writes is cleared every cycle again, as before, so Predbat's own earlier write no longer masks a change made in the GivEnergy app for the length of a slow refresh. - A read failure only counts towards backoff when the device answered other reads in the same refresh. An inverter or GE Cloud outage no longer backs every register off and blinds the refreshes after it. - Devices that were due a read while a refresh was still running (such as the hourly re-read of EMS member inverters) are read by the next refresh instead of waiting for their next slot. - The default options pass and the AC charge gate run on fresh settings. When the refresh outlasts the wait, it runs them itself on completion instead of the cycle using the old snapshot. - A failed background refresh logs a traceback and fails the next cycle, so it is counted as an error as it was when the refresh ran inline. - The register list of a device removed during a refresh is dropped. - Test: the held refresh in the serial loop test now runs to completion and must store the new value it reads. Previously it was cancelled at stop and the test could not see the merge. Added outage, owed-device, deferred-defaults, failure-reporting and external-change cases. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018BaJjLpuMEtQYfvmTRcFJf
…outage clears it The backoff was counted in refreshes, so on an EMS member inverter (read once an hour) the 6-refresh cap left a register unread for six hours. It is now time-based: not read for 15, then 30, then at most 60 minutes, whatever the device's refresh cadence. A refresh in which no register answers (the inverter or GE Cloud is down) now clears the device's backoff, so every register is read again the moment the device is back. Such a refresh already did not count towards backing a register off. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018BaJjLpuMEtQYfvmTRcFJf
…resh; test the hourly backoff cap If the background settings refresh raised within the wait, the cycle still treated the settings as fresh, and ran the default options pass and the AC charge gate re-check on the settings left from before. They now wait for a cycle with fresh settings. Added a test that a register which keeps failing on an hourly-refreshed EMS member inverter is still read on every hourly refresh and is never left unread for more than an hour. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018BaJjLpuMEtQYfvmTRcFJf
springfall2008
left a comment
There was a problem hiding this comment.
Thanks, this is a careful piece of work. I'd merge it as is. The three points below are small and could go in this PR or a follow-up.
I ran unit_test.py --test ge_cloud on fb81519b: 144 passed.
What I checked
- Shutdown.
ComponentBase.start()awaitsfinal()on stop.GECloudDirectdidn't definefinal()before, andCarChargerControldoesn't define one either, so the new one doesn't hide any existing cleanup. - Write callers. All six callers of
async_write_inverter_settingsetsetting["value"]themselves after the write:switch_event,number_event,select_event,enable_default_options,ensure_ac_charge_gateandtest_gecloud_direct. Updating the cache inside the write doesn't conflict with any of them. It only makes the callers' own update redundant, and it fixes the case where the refresh replaced the register mid-write. - Write-wins merge.
issue_seqis taken when each read is actually issued. Skipped registers fall back torefresh_seq. An older write left inrecent_writescan't beat a later refresh. There is noawaitbetween the merge andself.settings[device] = results, so a write can't land in that gap. - No overlap. The task counts as running until
apply_settings_defaultsfinishes, so the defaults pass can't overlap the next refresh. Cancellation also waits for theReadSetting-*tasks. - Test isolation. The new tests build their own
MockGECloudDirectand leave the shared fixture alone, so they can't cause order-dependent failures in later tests.
Nits
1. A backed-off register is logged as a failed read on every refresh. A skipped register has no value, so it is marked read_failed=True (L2859). refresh_settings then logs Warn: ... could not read setting ids [...] (L2608) every 10 minutes while the backoff lasts.
The read_failed attribute is fine. The log should still separate skipped (backed off) from read and failed, because someone triaging a log will otherwise conclude the device is still timing out on those ids.
2. device_answered also counts readbacks that never reach the device. device_answered (L2831) counts any result that has a value. async_read_inverter_setting answers a register with a pending write from pending_writes without touching the device (L2675, context: "predbat").
So one confirmed write in the same cycle can make a real outage look as if the device answered. The failing registers then count towards backoff instead of the backoff being cleared. This is unlikely, because a write normally fails during an outage too. Excluding context == "predbat" results would make the check exact.
3. Cancelling during the defaults pass can drop the 24-hour pass for some devices. apply_settings_defaults sets default_options_stamp (L2548) before it loops over the devices (L2555).
A device-list change can cancel a detached refresh while it is part way through that loop. Every device the loop hasn't reached then misses its default options until the next 24-hour pass. This couldn't happen when the refresh ran inline. Setting the stamp after the loop, or tracking which devices have been done, would close it.
Observations (no change needed)
- Pending-write readback window. The
pending_writesreadback now protects a write only until the end of the current cycle, about 20 seconds later. Before, it covered the whole inline refresh. Reads issued after that go to the inverter. The PR description says this is intended ("a read issued after the write keeps the inverter's own value"). It is fine provided GE Cloud's setting read is live rather than eventually consistent. - Startup reads ignore the backoff. The inline startup and device-change reads run with
backoff=False, so a register the inverter can't serve still slows those paths down. The PR documents this, and it is reasonable because automatic config needs the full snapshot.
Problem
1. A slow settings refresh holds up live data.
GECloudDirect.run()waits for the whole periodic settings refresh before it returns.ComponentBase.start()callsrun()one cycle at a time, so a slow refresh delays the next status and meter update.Settings are read one register at a time. A register the inverter lists but cannot serve uses up its whole retry budget on every refresh. One example is AC charge slots 8/9, setting ids 120–125, on a three-phase GivEnergy inverter. In production we saw a 656 s gap between telemetry updates while those ids failed repeatedly.
2. A stale settings read can overwrite a newer confirmed write. Suppose a bulk settings read is in flight and
switch_eventwrites the same register. The read then finishes with the older value, and that older value replaces the confirmed write inself.settings, even thoughpending_writesstill holds the write. This was reproduced on current main using the realasync_get_inverter_settings,async_read_inverter_setting,async_write_inverter_settingandswitch_event.There is a second form of the same race. A write can still be in flight when the refresh stores its results. The event handler then updates the register object that the refresh has just replaced, so the cache and the published entity keep the old value.
Fix
Isolation
seconds % 600) now runs as its own task.run()waits up toSETTINGS_REFRESH_WAIT_SECONDS(20 s) for it, then carries on. A fast refresh therefore behaves exactly as before.final()cancels the task and waits for it. A cancelled settings read cancels and waits for its ownReadSetting-*tasks.run(). It is counted as an error, as it was when the refresh ran inline.settings, the published entities,register_entity_map,register_listor the backoff state.Write vs read
pending_writesis still cleared every cycle, as before.async_write_inverter_settingalso updates the register currently held inself.settings. This covers a refresh that replaced the register while the write was in flight.Honest readbacks and backoff
read_failedand published with aread_failedattribute. Each refresh logs which ids failed.Tests
unit_test.py -t ge_cloud: 144 passed (138 existing and 6 new). I ran the first five new tests against main'sgecloud.pyand all five fail there. The sixth tests behaviour added in the review round. I also ran mutation checks: removing each mechanism (the write-wins merge and its issue-sequence check, the outage gate, the owed devices, the deferred defaults, error reporting, the write-path cache update and the shutdown cancel) fails at least one test.settings_refresh_live_dataComponentBase.start()serial loop. While a refresh is stuck on a read, live status keeps being fetched and no overlapping refresh starts. The held refresh then runs to completion and stores the new value it read, while a write made during it still wins.settings_refresh_cancelled_on_stopsettings_read_vs_writesettings_read_backoffread_failedkept and published, recovery, no backoff on full reads, and no backoff from a device-wide outage.settings_refresh_follow_upssettings_refresh_departed_devicerun(600)blocksI also ran the rest of the
--quicksuite and it passes. The exception is two Prometheus metrics tests (data_age_metrics,control_conflicts_metrics), which also fail on unmodified main in my environment becauseprometheus_clientis not installed.Reproduction proofs (outside the repo, synthetic IO, no real inverter)
GECloudDirectwithrun()callbacks awaited one after another, and the read of id 120 held from 600 s.[0], status publishes 2, held read not cancelled on stop. FAIL[0, 600, 720, 840, 1200], 5 status publishes, one held read, read cancelled on stop. PASSswitch_event.False. FAILTrue, the pending write is kept. PASS🤖 Generated with Claude Code
https://claude.ai/code/session_018BaJjLpuMEtQYfvmTRcFJf