Skip to content

Validate Freeze Export loss patch - #2

Closed
PlainSeer wants to merge 128 commits into
freeze-export-validation-basefrom
main
Closed

Validate Freeze Export loss patch#2
PlainSeer wants to merge 128 commits into
freeze-export-validation-basefrom
main

Conversation

@PlainSeer

Copy link
Copy Markdown
Owner

Temporary validation-only PR for commit 657aa6e. This is not intended to merge; it exists only to run the repository's normal pull-request checks against the exact main tree.

…ngfall2008#4562)

The dashboard fetched its favicon and header logo from
raw.githubusercontent.com on every page load, so a GitHub outage or
429 rate limit left every page hanging for ~15s before it would
render. The logo now ships alongside the app and is served from a
local route, with no network dependency.
@PlainSeer
PlainSeer force-pushed the main branch 2 times, most recently from eff1e29 to fe63a59 Compare August 18, 2026 11:31
springfall2008 and others added 27 commits August 18, 2026 16:45
…nchmark

Adds a per-scenario IOG dispatch run on its own salted rng stream, matching how
cars/iboost/load-scaling were added: regenerating the existing 20 scenarios
reproduces every prior field bit-identical and only adds this one. 13 of the
20 scenarios now carry a run, giving the random suite its first coverage of
plan.py's io_adjusted handling in sort_window_by_price_combined - previously no
generated scenario ever set io_adjusted, so that code path went unexercised by
every benchmark run.

Regenerates cases/random_scenarios.yaml and the cases/random_results.json
baseline to match.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Three scenarios (9, 10, 15) in the committed baseline didn't match a fresh,
independently-verified computation: the same values now reproduce consistently
across 6 different PYTHONHASHSEED values, isolated vs full-sequence runs, and
independently on CI's ubuntu/gcc build - matching CI's numbers to 4 decimal
places despite the different compiler/architecture. The originally-committed
baseline was a stale capture; this regenerates it against the verified values.

Also addresses review feedback: clarifies the fetch.py/octopus.py docstring
reference (was readable as one path) and makes apply_scenario_to_predbat
degrade to "no run" via .get() defaults instead of raising KeyError if an
"iog" block is enabled but missing run_start_minute/run_slots.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Design for a Sunsynk Connect cloud component modelled on the existing
DEYE and Fox integrations: standalone sunsynk.py, pure-Python RSA login
(no cryptography dependency), three auth methods, multi-inverter
discovery, and 6-slot TOU control via the settings blob.

Sunsynk is rebadged DEYE hardware, so the spec adopts an explicit rule
for the many undocumented details: assume DEYE for semantics, never for
encoding. Register-level behaviour transfers; wire format does not.

Nobody on the project has a Sunsynk account, so the build is defensive
by design - debug tracing on by default, a standalone CLI for dumping
raw traffic, and control gated behind an opt-in until field-verified.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Nine TDD tasks from constants through to the diagnostics CLI and docs,
each with a failing test, real implementation code, a run command and a
commit. Seven test modules mirror the DEYE suite's coverage.

The RSA helper's code and its test vectors were verified before writing:
a fixed 1024-bit key parses, round-trips four passwords, rejects an
oversize one and randomises its padding.

Ends with a verification checklist for the first tester, ordered by
cost-if-wrong, since every wire-format detail is inferred rather than
documented.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
_init_oauth owns self.auth_method, so passing a collapsed ternary would
rewrite password_legacy to password and make the plaintext login
unreachable. Pass auth_method straight through, as deye.py does.

MockBase is the base object a component is built around, not a mixin.
Inheriting it put ComponentBase ahead of it in the MRO, so the CLI would
hit an unset self.base. Follow deye.py's harness pattern instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Endpoints, regions, the six-slot TOU field templates, telemetry maps and
per-field wire serialisation for the Sunsynk Connect API. Inferred encoding
values carry VERIFY@SPIKE; no live hardware has confirmed them.
Sunsynk RSA-encrypts the account password at login. Implemented directly
rather than adding a cryptography dependency, which would put a Rust-built
wheel on the armv7/armhf add-on targets. Public-key encryption only.

Tested against a fixed 1024-bit key: DER parsing, padding structure,
round-trip recovery, randomisation, and oversize rejection.
_read_tlv sliced data[offset:offset + length] without checking the declared
length fits in the remaining data. Python slicing truncates silently rather
than raising, so a truncated DER key "parsed" successfully with a corrupted
trailing field. On this key, truncating by 1-3 bytes produced exponent 256,
1 or 0 with no exception; exponent 1 would send the password effectively
unencrypted, since rsa_encrypt_pkcs1v15 would compute pow(block, 1, modulus).

Added bounds checks covering the fixed-length header, the long-form length
bytes, and the final value slice, and extended
test_parse_rsa_public_key_rejects_rubbish with near-full-length truncation
cases ([:-1], [:-2], [:-3]) that reproduce the corrupted-exponent bug and
would have caught it.
…lows

RSA login (default), the pre-2025 plaintext login (opt-in) and the
Predbat.com injected-token path. Transport retries with backoff, detects
Sunsynk's 200-with-failure auth errors from the body, and returns {} on
any failure so callers fail closed.

The RSA path never downgrades to plaintext: that would turn an
externally-triggerable public-key failure into a credential leak.
… retry

_request() looped transport attempts with `for attempt in
range(SUNSYNK_RETRIES)`. When a body-level auth error was detected on the
FINAL attempt, fetch_token() would succeed but the `continue` had nowhere
left to go - the for loop was exhausted, so execution fell through to
`return None` and the successful refresh was thrown away.

Switch to a `while attempt < SUNSYNK_RETRIES` loop where `attempt` only
counts genuine transport failures (non-200 / exception), never the
refresh branch. A refresh now always earns its own retry, regardless of
how much of the transport budget was already spent, while `refreshed`
still caps it to at most one extra pass so total work stays bounded.

Also adds real coverage of _request() itself (transport retries, backoff,
the None/{} success contract, and the auth-refresh path), since every
existing sunsynk test replaced _request() with a fake rather than
exercising it - the gap that let this bug through undetected. The new
regression test was confirmed to fail against the pre-fix code before
the fix was applied.
Pages /inverters with an optional serial filter, polls the four realtime
endpoints per inverter, and derives soc_max and battery_rate_max from an
amp-hour capacity and a pack voltage inferred from the BMS charge target.

Absent fields stay absent rather than publishing an invented zero, and a
capacity with no derivable voltage reports nothing rather than a guess.
…ess page

Round 1 review found get_device_list could loop forever: a page whose
entries never carry sn left the serial count stuck at 0 while a
server-reported total stayed above it, and a bogus multi-million total
ran millions of calls before terminating even when every page carried
real serials. Overlapping pages also duplicated serials unconditionally.

Fixes with progress-based termination: dedupe into a set while keeping
first-seen order (automatic_config builds arg lists positionally from
it), break as soon as a page contributes zero new serials, and add a
SUNSYNK_MAX_DISCOVERY_PAGES hard cap as defence in depth against a total
that is corrupt or never satisfied, logging a warning if it is hit.

Also adds direct coverage for fetch_device_detail's asymmetric write
(an omitted or failed re-fetch must never clear a previously known
rated power), and documents - without changing - the cell-count
rounding assumption in nominal_pack_voltage, which is exact only when
the BMS charge target is itself an exact multiple of 3.55V/cell.
Maps Predbat's charge and export windows onto sellTimeN/sellTimeNPac/capN/
timeNon plus a global work mode, applied by read-modify-write so every
installer setting Predbat does not own survives verbatim.

The global mode follows the window active now, not a static precedence,
because Sunsynk has only one. Writes are diff-gated, clamped to the
inverter's own SOC floor, and skipped entirely if the read fails.
- build_tou_slots now skips a zero-length window (start == end, compared
  normalised) instead of leaving an unterminated action segment - an
  enabled charge/export window whose time fields have not yet been set
  by update_local_schedule was becoming a multi-hour full-power grid
  charge.
- note_settle decodes both sides through encode_setting before comparing,
  so a read-back that renders time{n}on as a string ("true"/"1") no
  longer looks like a permanent mismatch against a healthy inverter.
- build_settings_payload returns {} when there is no baseline in
  device_settings for the serial, and apply_settings treats an empty
  payload as "skip" - the guard against an owned-keys-only payload now
  lives in the function that builds it, not only in one caller.
- apply_settings gates on an owned-fields-only diff (schedule,
  current_soc, now_minutes and the cached battery_reserve_min) BEFORE
  any network call, so a no-op tick costs zero GETs and zero POSTs
  instead of reading and comparing the whole settings blob every cycle -
  which also means an unowned field that changes on every read (e.g. a
  server timestamp) can no longer force a write every tick. Only a real
  change (or force=True) triggers the read-modify-write, immediately
  before the POST.

Also corrected test_payload_clamps_to_the_inverter_soc_floor, which
exercised the hold_charge fallback (reserve, not the requested target)
rather than a genuine sub-floor charge target.
Sensors carry units and are omitted entirely when underivable, so Predbat
is never pointed at an entity that never appears. Control entities use
HH:MM:SS to match INVERTER_DEF, and the reserve entity deliberately
publishes what Predbat wrote rather than the inverter floor, to avoid a
write-read-mismatch retry storm.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…loads

The write-button handler passed force=True, bypassing apply_settings'
applied-payload change-detection gate on every routine per-cycle button
press (INVERTER_DEF time_button_press), not only when the plan changed.
deye.py hit this exact bug first: PR springfall2008#4371 (3e1de75) measured 40 button
presses producing 36 byte-identical control orders over two hours on a
live site once the button forced the write. Sunsynk's write button now
calls apply_schedule(sn) unforced, same as deye.py's fix.

Also extends the publish/omit tests to cover the battery_voltage
telemetry leaf and the battery_reserve_min rating, which were previously
implemented but untested.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
One file per tier so each gets an independent storage.age() clock, seeded
at startup so the refresh cadence survives a restart. Telemetry is not
cached; HA already retains the last published value.

The applied-payload cache restores only within a 15-minute window: it
asserts the inverter still holds what Predbat wrote, and after a longer
outage that assertion is false and the next write must not be skipped.

Fixes a call-site bug versus the real Storage.load(module, filename)
signature (no `default` kwarg) that would have made restore_state() a
silent no-op in production while passing tests against a looser fake.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… on a storage failure

restore_state() called self.storage.age(...) directly with no try/except,
unlike load_cache/save_cache. A raising storage backend or self.storage
being None (the normal standalone-CLI state, per mock_base.py) propagated
an unhandled exception out of the method. Worse, _cache_restored was set
True before any of this ran, so the outer run loop survived the crash but
restore_state() then permanently no-op'd on every later call - one
transient storage hiccup on first boot silently disabled cache restoration
for the life of the process.

Adds an age_cache(name) helper alongside load_cache/save_cache that fails
soft the same way. Moves the _cache_restored assignment to the end of the
method and gates it on a new _restore_had_error flag that load_cache/
age_cache set when they actually catch a storage exception (as opposed to
a cache simply not existing yet, which is not an error) - so a failed
attempt is retried on a later call once storage recovers, while a
successful attempt still sets the guard so restore can never run twice and
clobber live state with stale cached state.

Also tightens the test double's FakeStorage.load/save signatures to match
the real Storage component exactly (no `default` kwarg on load; format/
expiry on save), per the reviewer's note that the previous, more
permissive fake was the exact mechanism that had masked the earlier
default=None bug. Adds regression tests for a raising storage backend and
for storage being None, both confirmed to fail against the pre-fix code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…tion

Registers SunsynkCloud in INVERTER_DEF (identical capability flags to
DeyeCloud, because the same registers sit behind both clouds), adds the
sunsynk_* apps.yaml schema, and wires the component into COMPONENT_LIST
gated on having at least one usable auth path.

automatic_config maps an arg only when every discovered inverter reports
the underlying value, warning the user to set the rest in apps.yaml.

run() explicitly returns True/False rather than falling through to an
implicit None on success, which ComponentBase treats identically to a
failure - without it the component would never clear its startup "first"
flag and would stay in the ever-growing 60s-to-128min backoff forever.

apply_settings/apply_schedule gain a settings_fresh flag so a tick where
the config tier happens to fire AND a genuine plan change lands reads the
settings object once, not twice - the run loop passes it through only
when refresh_config actually ran this tick.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…config, guard its cache save

Round-1 review findings on the Task 8 run loop:

1. (CRITICAL) settings_fresh let apply_settings reuse a cached settings
   baseline instead of reading fresh before a write. The unguarded case was
   a STALE, non-empty cache: fetch_settings only updates device_settings on
   a successful read, so a per-serial config-tier read failure left a stale
   baseline with no signal that it hadn't just refreshed, and restore_state
   seeds device_settings from the config cache with no age bound at all
   (unlike the control cache's SUNSYNK_RESTORE_MAX_CONTROL). Traced through
   run() with two inverters and a failing config read for one of them, this
   could revert a user's phone-app change and lower the battery's
   protective floor. Removed entirely - apply_settings/apply_schedule/run()
   revert to always reading immediately before a write. The saving
   (~10 GETs/day/inverter, ~2% of daily calls) doesn't pay for weakening a
   safety invariant on the one call that writes to the battery.

2. test_run_first_cycle_polls_and_publishes discarded run()'s return value,
   so a regression of the "must return True/False" fix (Task 8's headline
   catch) would go unnoticed. Now asserts the return value, plus new cases
   for a login failure and an empty device_list both returning False.

3. refresh_config let fetch_settings overwrite device_settings in place
   before comparing old vs new, so an external change (e.g. the phone app)
   was silently absorbed instead of triggering note_external_change - the
   design spec's named mitigation for the unavoidable read-modify-write
   race. Fixed by capturing each inverter's previous baseline before the
   read.

4. refresh_config called save_config() unconditionally, so a tick where
   every read failed re-stamped a possibly days-stale on-disk cache as
   fresh; after a restart the config tier would then be skipped for a full
   TTL while running on stale settings. Mirrors deye.py's got_any pattern.

Minor: publish_schedule_settings_ha now runs every tick, not just first
(matches deye.py and the spec); the duplicated empty-schedule literal is
now one _empty_schedule() helper; automatic_config uses set_arg_auto so an
apps.yaml override gets logged once instead of silently discarded.

New tests for all of the above were written first and confirmed to fail
against the pre-fix code (verified via git stash against the parent
commit), then confirmed to pass once each fix landed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The CLI logs in, dumps detail, telemetry and the settings object, and can
build a harmless self-use payload and offer to send it. --auth-method lets
a tester establish which login their region serves in one command.

Nobody on the project has a Sunsynk account, so this is the tool that makes
remote verification of the inferred wire format possible. Docs cover the
component config table, an inverter-setup walkthrough distinct from the
existing local-Modbus SunSynk integration, and the apps.yaml keys - all
stressing that control_enable defaults off, password never silently
downgrades to password_legacy, writes land via the dongle's next poll, and
the phone app and Predbat can race on the single whole-object endpoint.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Fix round 1 on the Sunsynk diagnostics CLI, from review:
- input() at the write-test confirmation was unguarded, so a closed or
  redirected stdin (SSH, CI, a container with no TTY) raised an uncaught
  EOFError and killed the CLI with a traceback instead of the clean "not
  sent" it already produced for a typed "n". Now EOFError and
  KeyboardInterrupt are both caught and treated as "no". This is the only
  verification tool a remote tester has, so it must survive a
  non-interactive channel.
- _build_sunsynk passed the real args into a second initialize() call after
  ComponentBase.__init__ already ran it once with all-default args via the
  constructor - harmless, but it printed a duplicate "SunsynkAPI
  initialising" pair before the CLI had even reported which region it is
  using. Now built in one call, matching deye.py's _build_deye.
- Removed the dead `client.device_list = [sn]` per-serial assignment in
  run_cli; none of the methods it calls read device_list, they all take sn
  directly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…view

run() applied a schedule for every inverter on every non-first tick, and seeded
the first tick from an empty schedule. Two consequences, both reproduced:

- With sunsynk_control_enable true, tick 2 posted a full settings object built
  from control entities Predbat had never written - sellTime1..6 replaced by the
  filler times, cap1..6 forced to 20, peakAndVallery flipped on, the day flags
  turned on and sysWorkMode rewritten - wiping the user's own time-of-use
  programme on first startup, before there was any plan.
- dashboard_item reaches set_state_wrapper, so publishing the empty first-cycle
  seed overwrote Predbat's live plan in the control entities. A restart at 03:00
  inside a 02:00-05:00 charge left charge_enable off and a 00:00 window, and the
  next tick turned grid charge off mid-window.

Mirror deye.py: read the control entities on every tick including the first, and
only re-apply for inverters whose write button has been pressed (control_active,
until now a vestigial attribute that was never added to or read).

Also, all DEYE-parity gaps from the same review:

- redact() rewrote top-level keys only, but _request traces the whole {code, msg,
  success, data} envelope and the login response nests its tokens inside `data`,
  so the bearer token was logged verbatim by a debug trace that is on by default
  precisely so testers paste it into issue reports. Now recursive over dicts and
  lists, as deye.py's is.
- oauth mode never refreshed its injected token: run() now calls
  check_and_refresh_oauth_token(), and a body-level auth failure goes through a
  reauthenticate() helper that tries handle_oauth_401() before fetch_token().
- refresh_live() now reports whether any telemetry arrived and run() defers
  startup when the first poll returns nothing, so automatic_config() - which runs
  on the first cycle alone - can no longer lose soc_max and friends for the whole
  session. The live tier clock only starts on a successful poll, so the retry
  really re-polls.
- refresh_static() keeps the known inverters when discovery comes back empty, and
  neither marks the tier fresh nor writes {'device_list': []} to the cache.
- refresh_config, refresh_live, the schedule read and the apply are each wrapped
  per serial, so one inverter's failure no longer aborts the tick for the others
  or skips publish_data()/update_success_timestamp().
- sysWorkMode now routes through encode_setting like every other owned field;
  note_settle's comment corrected (config-tier polls, ~45 minutes, not cycles);
  dead cached_values/device_capacity/device_pack_voltage and the unreferenced
  TOU_FIELD_PRESERVED/SUNSYNK_PACK_VOLTAGE_FIELD removed.

Tests: the new run()-altitude tests drive run() with control enabled and only the
transport patched - the altitude no previous test reached, which is why the
unplanned write survived nine scoped reviews - and the redaction test now uses a
realistic nested envelope. Every new test was confirmed failing before the fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Rename run_cli to test_sunsynk_api (the house name every other component's
diagnostics entry point uses - test_deye_api, test_fox_api, test_solis_api,
etc.), add the missing # pragma: no cover on main(), and restructure the
default path to call client.run(seconds=0, first=True) instead of the
individual fetch_* methods. Nobody on the project has a Sunsynk account, so
this CLI is the only tool a remote tester has, and run() is where the real
orchestration lives (refresh tiers, the control_active gate, publish_data,
automatic_config) - a CLI that bypassed it could not smoke-test what
actually runs. run()'s False return (failed login, empty device list, or
failed first telemetry) is now reported explicitly instead of falling
through into an empty dump. --dump-settings, --write-test, --auth-method,
--region and --serial are preserved; --serial now filters discovery itself
via inverter_sn, matching deye.py's _build_deye.

The rename is safe for the test runner: TEST_REGISTRY in unit_test.py is a
manually curated list of explicit `from tests.test_X import ...` imports,
not a glob-based collector, so a module-level test_* function inside
apps/predbat/sunsynk.py is never auto-discovered.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
note_settle hand-rolled its own field list (work mode plus slot
time/soc/grid_charge), so a partial apply that dropped a per-slot power
limit, solarSell, peakAndVallery or a day flag compared equal on
everything checked and silently reset the settle counter, masking a
real divergence. Switch to _owned_fields() (minus the echoed sn) so
the settle check tracks the same fields the component actually writes.

Compare only over keys present in the read-back, and leave the counter
untouched when none are: nobody has a Sunsynk account to confirm every
owned field is echoed by a real /read, so a missing key means "no
information", not "diverged" - treating it as divergence would
reintroduce the cry-wolf failure (warning every poll against a healthy
inverter) this component was already built to avoid.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…stage

Real user feedback from the Sunsynk diagnostics CLI: a bad-credentials login
produced eight unrelated "NoneType has no attribute" warnings (load_cache/
save_cache/age_cache all assume a Storage component exists, but a standalone
CLI run has none) and a bare "run() returned False" with three possible
causes, forcing the user to dig through Warn: lines for the API's own
"Account or password error".

- load_cache/save_cache/age_cache now check `self.storage is None` first and
  return silently - that is the normal standalone-CLI condition, not a fault.
  A real (raising) storage failure still warns and still retries; only the
  "no storage configured at all" case goes quiet. Absent storage no longer
  sets _restore_had_error, so restore_state() completes and marks itself done
  instead of retrying the same no-op forever.
- _request() now captures the API's own failure message on last_api_error
  (never a credential - the response's `msg` field only), so fetch_token and
  the CLI can report the real reason.
- get_device_list() now tracks discovery_ok, distinguishing a genuinely empty
  account from a discovery call that failed outright (both used to coerce to
  the same empty result); refresh_static()'s warning and the CLI both use it.
- The CLI's test_sunsynk_api() now inspects component state after a failed
  run() and names precisely which stage failed - login (with the API's
  reason), discovery (empty account vs. failed call, with a --serial
  filtering hint), or the first telemetry poll (naming the serials found).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
sunsynk_control_enable now defaults to true, matching solis_control_enable.
An inverter component that does not drive the inverter is not what a user
configuring it expects, and no other Predbat inverter component gates
control behind an extra flag.

Set it to false for monitoring only. The write format is still inferred
rather than documented, so the docs now recommend running the diagnostics
CLI against your own inverter first and switching control off if anything
looks wrong, rather than requiring an opt-in to switch it on.

Pins the registered defaults in test_component_registered - control_enable
decides whether Predbat writes to a real inverter at all, so a silent flip
either way should fail the suite. Verified by mutation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
PR springfall2008#3355 only fixed predbat.temperature (temperature.py); gcoan's follow-up
list of 9 more entities with the same gap (fetch.py's battery_temperature,
execute.py's 4 "current" power sensors, plan.py's 4 "_best" predicted power
sensors) was never actioned. All 9 confirmed still missing device_class on
current main.

Fixes springfall2008#3352.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@PlainSeer
PlainSeer force-pushed the main branch 2 times, most recently from 7b2c88f to a323388 Compare August 20, 2026 20:31
springfall2008 and others added 10 commits August 21, 2026 08:51
…ining-nan

fix(ml): resolve NaN training loss on startup and preserve database zero entries
…ard-device-class-3352

fix(output): set device_class on 9 dashboard sensors missing it
…rge-start-low-power-rate

fix(inverter): discharge_start_service sends the planned rate, not always max
…us-power-down-migration

feat(octopus): Power Up/Power Down migration - join service and entity regex (points 2 & 3 of springfall2008#4548)
…oc-sentinel-constants

refactor(export): name the export_limit freeze/idle sentinels
…live

self.intelligent_devices only ever grows: once a device_id is cached it
stays forever, even after Octopus stops returning it in devices(). A
deregistered/re-paired charger or vehicle then keeps its entities and
target-time republished indefinitely and permanently occupies a car slot,
which can push num_cars up and misclassify that phantom device's slots
against home load. Prune any cached device_id absent from the latest
live response in async_update_intelligent_devices.

Reported by a customer with a single registered EV who saw two Octopus
Intelligent device IDs in Predbat.
…s-prune-stale-intelligent-devices

fix(octopus): prune Intelligent devices Octopus no longer returns as live
@PlainSeer
PlainSeer force-pushed the main branch 5 times, most recently from badf060 to 8aff023 Compare August 21, 2026 17:02
@PlainSeer
PlainSeer force-pushed the main branch 6 times, most recently from b3f696a to 842d699 Compare August 22, 2026 07:11
@PlainSeer
PlainSeer force-pushed the main branch 2 times, most recently from c6db07a to 842d699 Compare August 22, 2026 07:14
@PlainSeer PlainSeer closed this Aug 22, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants