Allow removing devices the integration no longer provides - #489
Allow removing devices the integration no longer provides#489notexpected wants to merge 4 commits into
Conversation
Code reviewNot ready to merge. Adversarial review (gpt-5.6-sol + gemini-3.1-pro, independently) surfaced six defects; I verified each against the source. Three of them are the severe class this PR explicitly set out to prevent — permitting deletion of a device that is still live. The root problem is that "provided" is computed from a single coordinator cycle, but battery presence in any one cycle is a subset, not the truth. 1. A partial battery payload authorizes deleting a live module. The placeholder guard only fires when the parent's After a restart the round-robin and carry-forward caches are cold. On an 8-module bank whose first poll exposes modules 1–4, a live 2. Battery identifiers without a hyphen bypass the guard completely. The guard keys on
3. A degraded parallel-group discovery permits deleting a live group. If the device list succeeds but The remaining three are the inverse — the feature can't do what it was built for: 4. The last physically removed module can never be deleted. Once the coordinator evicts the final module, the steady-state payload is 5. A leftover battery bank is never removable. But actual bank registration requires bank data and 6. A plant's former sole inverter can never be deleted. Suggested direction1–3 need presence to be judged over more than one cycle. The coordinator already tracks Worth noting for whatever lands: the tests currently encode the unsafe assumptions rather than catching them — |
|
Thank you — the review was right that the single-cycle foundation was unsound, so 4a8105c replaces it with your suggested direction rather than patching the heuristics: an observation ledger. The coordinator stamps every provided identifier with a monotonic last-seen mark on each successful refresh (never on the 3-strike cached fallback), and deletion requires the identifier to have been absent for its class's full window within the current unbroken run of successful refreshes — 15 minutes for device-class identifiers (serials, parallel groups, station), and Per item:
Additionally, while any device is degraded — an Adversarial follow-up — mirroring your process, an independent second review was run against the fix itself before pushing. Its HIGH finding is folded in: the first draft measured window coverage from the session's first success, so time spent blind behind failed cycles counted as absence — a >6 h outage could both wall-clock-evict the #258 carry-forward and satisfy the window at the moment of recovery, re-admitting a narrow live-module deletion. The clock is now Full suite (2282), CI-pinned ruff, and strict mypy are clean. Same disclosure as the PR body: this response and the fix commits were written by Claude Fable 5 (model |
Code review — fix roundThe observation ledger is the right shape, and the care shows: deliberately not stamping it on the 3-strike cached-fallback path ("served cache is old evidence, not a fresh sighting") is exactly correct reasoning. But adversarial review (gpt-5.6-sol at xhigh) found the round delays the unsafe deletions rather than eliminating them — a live device is still deletable in several reachable degraded states. I verified the sharpest one against the source myself. Three confirmed HIGH, ordered by how easily they trigger. 1. A failed cloud device-list at cold start permits deleting a live inverter after six hours.
Sequence: HA restarts → plant-details succeeds → the first device-list request fails → the live inverter's registry device remains → the hierarchy stays empty until another reload → six hours later Delete returns This is specifically what replacing the blanket empty-table refusal with timed authorization costs, absent a discovery-completeness signal. 2. A battery-endpoint failure permits deleting live modules after six hours. Refresh subtasks run with After a restart the ledger and carry-forward caches are both cold, so a live module absent from every partial battery response ages out and becomes deletable. Reachable in all three modes: CLOUD with 3. The first two failed updates are treated as healthy. eg4_web_monitor/custom_components/eg4_web_monitor/coordinator.py Lines 556 to 566 in 4a8105c Under three consecutive failures eg4_web_monitor/custom_components/eg4_web_monitor/device_removal.py Lines 167 to 172 in 4a8105c So during a real outage the hook judges against a stale cached table it believes is healthy. Not stamping the ledger on that path — right in itself — means absence keeps aging through the outage. At the 10-minute cloud interval two suppressed failures already exceed the 15-minute device window. The comment calling these failures immaterial doesn't hold for device-class identifiers. Two more, non-blocking: 4. 5. Because the ledger starts empty on every coordinator construction, every never-seen identifier — inverter, GridBOSS, station, legacy parallel group — takes the conservative battery-class six-hour window. The CHANGELOG advertises 15 minutes after restart, which isn't what device-class entries get. (Also minor: ledger entries are never pruned, so it grows for the session.) Direction1 and 2 are the same gap: the guard trusts "the coordinator returned data" as "discovery was complete", and both cloud discovery and battery fetch can fail silently while the cycle still reports success. An explicit completeness signal — set only when the device list and the battery fetch actually succeeded — would close both, and is what 4 and 5 in my earlier review were also asking for. 3 wants the hook to consult Worth saying plainly: the asymmetry still governs. Refusing a legitimate deletion is an annoyance; permitting one destroys entity customizations and breaks registry-pinned automations irreversibly. Where the two conflict, err toward refusing. |
The observation ledger delayed the unsafe deletions rather than eliminating them (PR joyfulhouse#489 fix-round review): a cycle can report success while discovery silently failed, and the ledger treated that empty-but- "successful" snapshot as a complete observation. Three reachable states let a live device age out: 1. A cold-start cloud device-list failure. pylxpweb's Station._load_devices swallows the API error and continues with an empty device table, and the hierarchy is not reloaded, so the coordinator publishes a successful {"devices": {}} while a live inverter still sits in the registry -- deletable after the window. 2. A battery-endpoint failure. The per-device battery fetch is gathered with return_exceptions and swallowed inside pylxpweb's own inverter.refresh(), so a normal inverter row carries an empty batteries dict while runtime succeeds -- a live module absent from the failing fetch ages out. 3. The first two failed updates. The 3-strike tolerance returns cached data and leaves last_update_success True, so absence kept aging through an outage the hook could not see. The fix adds an explicit per-cycle completeness verdict (assess_discovery_completeness) and judges absence only over COMPLETE observed time: - device_list_ok: LOCAL/DONGLE/MODBUS enumerate no devices remotely, so their config-defined set is always complete. For CLOUD/HYBRID a non-empty table is self-evidently loaded; an EMPTY table is confirmed with a fresh get_devices call -- a call that succeeds (even returning zero rows) proves a genuinely empty plant, so the sole inverter of an emptied plant still ages out (the prior round's finding 6), while a raising call marks the list unconfirmed. Rows returned while our station is empty force a hierarchy reload. - battery_ok: false when any device carries an "error" row or a transport-link-down verdict, or when a battery-capable inverter's pylxpweb _battery_cache_time is still None -- that stamp advances only on a SUCCESSFUL battery fetch (both the transport and HTTP legs keep cached data untouched on failure), so a cold-restart battery-endpoint outage never masquerades as an empty bank. Each identifier class now has its own observation clock, the start of the current contiguous run of cycles that observed THAT class completely. A clock is None whenever the run breaks -- a failed cycle (recovery), the cached-fallback path (the coordinator clears both clocks there, since last_update_success stays True across a <=2-strike blip), or an incomplete cycle for that class. The hook additionally refuses while _consecutive_update_failures is non-zero: the current table is cached-through-an-outage, not a fresh sighting. Device and battery deletions are thus gated on their own class's complete-observation run, so a device-list failure blocks inverter deletion without a battery outage blocking it, and vice versa. Also in this round: a never-seen identifier is honestly documented as held to the conservative 6-hour window regardless of type (its class is unknowable), and the ledger is pruned of entries far past any deletion decision so it cannot grow unbounded from identifier churn. The completeness bookkeeping is wrapped so a fault in it clears the clocks (deletion refused, fail-safe) rather than failing the data update every sensor depends on. Two residuals are conservative by design, under the review's asymmetry (a refused deletion is a recoverable annoyance; a wrongly permitted one is irreversible): the battery clock is global, so one enumerated-but- degraded inverter blocks battery cleanup for the whole entry until it recovers; and a HYBRID system whose local battery read succeeds while its cloud supplement for the >4th..Nth module fails from a cold restart can still age those modules out -- functionally the deep-sleep residual joyfulhouse#258 already accepts, since its carry-forward declares such a module gone and drops its entities anyway, and it re-registers on its next appearance. Tests: the six review scenarios are each pinned (cold-start device-list failure refuses a live inverter; a 7-hour battery-endpoint outage refuses a never-seen live module; serving cached data refuses; the sole inverter of a CONFIRMED empty plant still deletes; the last battery module and a stale station still age out), plus assess_discovery_completeness's own verdicts (LOCAL always device-complete, empty-plant confirmation success/ raise/rows-force-reload, degraded/link-down/unconfirmed-fetch battery incompleteness), the two-clock restart logic, and ledger pruning. Full suite 2297 passed, ruff and strict mypy clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Thank you — the fix-round review was right that the ledger delayed the unsafe deletions rather than eliminating them. Per item: 1. Failed cloud device-list at cold start. 2. Battery-endpoint failure. 3. The first two suppressed failures. The hook now refuses whenever Each identifier class now has its own observation clock (the start of the current contiguous run of cycles that observed that class completely), so a device-list failure blocks inverter deletion without a battery outage blocking it, and vice versa. A clock is unset on a failed cycle, an incomplete cycle, or a cached fallback, and a complete recovery cycle restarts it at The two non-blocking items and the nit: 4 (global battery clock) -- now closed. Moved to per-parent granularity ( 5 (never-seen window). Left at the conservative 6 h regardless of type — an identifier the running session has never once observed has an unknowable class — but the CHANGELOG and TROUBLESHOOTING now say exactly that (a ghost already gone before the restart waits the 6 h window, not 15 min), so the doc no longer over-promises. Ledger growth. Pruned on each stamp of entries older than One residual remains, now traced rather than merely asserted consistent with #258: a HYBRID system whose local battery read succeeds (stamping the parent attestable) while its cloud supplement for a 5th..Nth module fails can age that module out. I walked the boundary in pylxpweb source: #258's carry-forward re-presents a module seen even once, with a frozen The completeness bookkeeping is wrapped so a fault in it clears the clocks (deletion refused — fail-safe) rather than failing the data update every sensor depends on. The scenarios are each pinned as tests (cold-start device-list failure refuses a live inverter; a 7-hour battery outage refuses a never-seen live module; serving cached data refuses; the sole inverter of a confirmed-empty plant still deletes; the last module and a stale station still age out; and for finding 4, a healthy parent's module ages out while a sibling is degraded and the global clock never advances, while a departed parent's module falls back to the global clock), alongside Same disclosure as before: this response and the fix commit were written by Claude Fable 5 (model |
Implement async_remove_config_entry_device (joyfulhouse#174), in a new device_removal module re-exported from __init__, so stale devices -- an inverter removed from the station or configuration, a battery module no longer reported, a dissolved parallel group, or a legacy-format duplicate left by an older version -- can be deleted from their device page instead of deleting and re-adding the whole config entry. Removal is gated on healthy coordinator data: a device is deletable only when none of its identifiers is currently provided (device-table serials, {serial}_battery_bank, per-module battery keys, and station_{plant_id} are all checked). Live devices are refused since their entities would recreate them under fresh registry entries, losing customizations (joyfulhouse#217). A failed update cycle, an empty device table, or a LOCAL-mode placeholder cycle (parent present with an empty "batteries" dict) refuses removal outright, so degraded state can never make a live device look stale. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JwHbYjtEufAi3gk2cncLXG
Review response -- the root problem was that "provided" was computed from one coordinator cycle whose payload is a subset of the truth (battery slot rotation joyfulhouse#252, subset-omitting cloud payloads, a failed getParallelGroupDetails dropping the group row, the LOCAL placeholder first refresh). Every heuristic built on that foundation is replaced by one mechanism: the coordinator stamps every provided identifier with a monotonic last-seen mark each successful refresh (never the 3-strike cached fallback), and deletion requires the identifier to have been absent for its class's full window within the CURRENT unbroken run of successful refreshes -- 15 minutes for device-class identifiers (serials, parallel groups, station), the joyfulhouse#258 six-hour battery eviction window (BATTERY_CARRY_FORWARD_MAX_AGE) for battery modules and banks. The observation clock (observed_since) restarts on the first success after any failed cycle, so blind time behind an outage never counts as absence -- without this, a long outage could both evict the joyfulhouse#258 carry-forward and satisfy the window at the moment of recovery, re-admitting a live-module deletion. A never-seen identifier is held to the conservative battery window. Per review item: 1. Partial battery payloads: a module seen 90 s ago is refused no matter what this cycle's dict contains; a cold session refuses everything until the window is covered. 2. Identifier shapes: keys are tracked VERBATIM (serial-based, BAT001-style, @pos-suffixed, hyphenated parent serials) -- all identifier parsing is gone. 3. Degraded parallel-group discovery: the vanished group row was seen minutes ago and stays refused for the 15-minute window. 4. The last removed module ages out normally -- an empty batteries dict is steady state, not a permanent placeholder. 5. The bank identifier is recorded only under the joyfulhouse#169 registration gate (bank sensors + battery_bank_count > 0), so an orphan bank on a shared-battery secondary becomes deletable. 6. The blanket empty-table refusal is gone -- a plant's former sole inverter ages out against a legitimately empty table, while outages are covered by last_update_success and the windows. Additionally, while any device is degraded -- an "error" row (LOCAL link-down, cloud per-device failure) or a mode-independent is_transport_link_down verdict (HYBRID serves cloud fallback with no row marker) -- battery-class deletions are refused outright (a degraded parent cannot attest its modules' absence). The tests that encoded the unsafe single-cycle assumptions are rewritten around the ledger, with each review scenario, the post-outage recovery attack, the HYBRID link-down guard, and the coordinator wiring (stamp on success, no stamp on the cached fallback) pinned explicitly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JwHbYjtEufAi3gk2cncLXG
The observation ledger delayed the unsafe deletions rather than eliminating them (PR joyfulhouse#489 fix-round review): a cycle can report success while discovery silently failed, and the ledger treated that empty-but- "successful" snapshot as a complete observation. Three reachable states let a live device age out: 1. A cold-start cloud device-list failure. pylxpweb's Station._load_devices swallows the API error and continues with an empty device table, and the hierarchy is not reloaded, so the coordinator publishes a successful {"devices": {}} while a live inverter still sits in the registry -- deletable after the window. 2. A battery-endpoint failure. The per-device battery fetch is gathered with return_exceptions and swallowed inside pylxpweb's own inverter.refresh(), so a normal inverter row carries an empty batteries dict while runtime succeeds -- a live module absent from the failing fetch ages out. 3. The first two failed updates. The 3-strike tolerance returns cached data and leaves last_update_success True, so absence kept aging through an outage the hook could not see. The fix adds an explicit per-cycle completeness verdict (assess_discovery_completeness) and judges absence only over COMPLETE observed time: - device_list_ok: LOCAL/DONGLE/MODBUS enumerate no devices remotely, so their config-defined set is always complete. For CLOUD/HYBRID a non-empty table is self-evidently loaded; an EMPTY table is confirmed with a fresh get_devices call -- a call that succeeds (even returning zero rows) proves a genuinely empty plant, so the sole inverter of an emptied plant still ages out (the prior round's finding 6), while a raising call marks the list unconfirmed. Rows returned while our station is empty force a hierarchy reload. - battery_ok: false when any device carries an "error" row or a transport-link-down verdict, or when a battery-capable inverter's pylxpweb _battery_cache_time is still None -- that stamp advances only on a SUCCESSFUL battery fetch (both the transport and HTTP legs keep cached data untouched on failure), so a cold-restart battery-endpoint outage never masquerades as an empty bank. Each identifier class now has its own observation clock, the start of the current contiguous run of cycles that observed THAT class completely. A clock is None whenever the run breaks -- a failed cycle (recovery), the cached-fallback path (the coordinator clears both clocks there, since last_update_success stays True across a <=2-strike blip), or an incomplete cycle for that class. The hook additionally refuses while _consecutive_update_failures is non-zero: the current table is cached-through-an-outage, not a fresh sighting. Device and battery deletions are thus gated on their own class's complete-observation run, so a device-list failure blocks inverter deletion without a battery outage blocking it, and vice versa. Also in this round: a never-seen identifier is honestly documented as held to the conservative 6-hour window regardless of type (its class is unknowable), and the ledger is pruned of entries far past any deletion decision so it cannot grow unbounded from identifier churn. The completeness bookkeeping is wrapped so a fault in it clears the clocks (deletion refused, fail-safe) rather than failing the data update every sensor depends on. Two residuals are conservative by design, under the review's asymmetry (a refused deletion is a recoverable annoyance; a wrongly permitted one is irreversible): the battery clock is global, so one enumerated-but- degraded inverter blocks battery cleanup for the whole entry until it recovers; and a HYBRID system whose local battery read succeeds while its cloud supplement for the >4th..Nth module fails from a cold restart can still age those modules out -- functionally the deep-sleep residual joyfulhouse#258 already accepts, since its carry-forward declares such a module gone and drops its entities anyway, and it re-registers on its next appearance. Tests: the six review scenarios are each pinned (cold-start device-list failure refuses a live inverter; a 7-hour battery-endpoint outage refuses a never-seen live module; serving cached data refuses; the sole inverter of a CONFIRMED empty plant still deletes; the last battery module and a stale station still age out), plus assess_discovery_completeness's own verdicts (LOCAL always device-complete, empty-plant confirmation success/ raise/rows-force-reload, degraded/link-down/unconfirmed-fetch battery incompleteness), the two-clock restart logic, and ledger pruning. Full suite 2297 passed, ruff and strict mypy clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Close the one residual left acknowledged in the fix round: the battery observation clock was GLOBAL, so any single degraded inverter -- an "error" row, a down local transport link, or one that had not yet confirmed a battery fetch -- blocked battery-module deletion for the WHOLE entry until it recovered. A healthy inverter's stale module could not be cleaned up while an unrelated sibling was down. Each battery-capable parent now keeps its own observation clock, judged on that parent's own signals (_parent_battery_ok mirrors the global _battery_fetch_ok but scoped to one inverter, skipping MID/GridBOSS rows that carry no bank). A module ages toward deletion only while ITS parent has been battery-attestable; a degraded sibling never resets it. The ledger now records each battery identifier's parent serial so a module absent from the current payload is still routed to the right clock. Two cases have no parent to attribute and fall back to the conservative GLOBAL clock (retained): a module whose recorded parent has since left the device table (its per-parent clock is pruned with it), and a module the running session has never once observed (parent, and class, unknowable). A failed device list drops every per-parent clock, and the coordinator clears them alongside the global clocks on the cached fallback and the defensive-bookkeeping paths. Also verified and re-documented the HYBRID cloud-supplement residual rather than leaving it as an open worry: a module seen even once is re-presented by pylxpweb's joyfulhouse#258 carry-forward until its own staleness gate evicts it, so removal's window starts only after joyfulhouse#258 concluded the module is gone -- removal is strictly downstream of and consistent with joyfulhouse#258. Only the cold-restart, never-seen sub-case remains (the supplement stamps its timestamp even on failure, so there is no library success signal to gate on); it self-heals when the module reappears. Tests: two new cases pin the closure -- a healthy parent's module ages out while a sibling is degraded and the global clock never advances, and a departed parent's module falls back to the global clock. Ledger-tuple assertions updated to the (seen_at, class, parent) shape. Full suite 2299 passed, ruff and strict mypy clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
e151744 to
ef68b5b
Compare
Summary
Implements per-device removal (#174 — where the workaround is still "delete the
whole integration entry and re-add it"): the integration now defines
async_remove_config_entry_device, so every device page gets Home Assistant'sDelete action, and devices the coordinator no longer provides can be removed
individually — an inverter dropped from the station or the configuration, a
battery module no longer reported, a dissolved parallel group, or a
legacy-format duplicate left behind by an older version.
Design
A device is deletable only when none of its identifiers is currently
provided by the coordinator. The provided set covers all four identifier shapes
the integration registers:
{serial}— inverters, GridBOSS, parallel groups (the keys ofcoordinator.data["devices"]){serial}_battery_bank— kept while the parent inverter is provided,even on cycles where bank sensors are temporarily absent (link-down reads,
shared-battery secondary inverters — Secondary Inverter Battery Bank show multiple sensors as Unknown / Unavailable - Primary Inverter Battery Bank No problems #169)
device_data["batteries"]station_{plant_id}— while station data is present (a station device leftover after moving to a LOCAL connection becomes removable)
Refusals are deliberately conservative, applying the #217 smart-port-cleanup
lesson that placeholder/degraded cycles must never be read as authoritative
absence:
cycle under fresh registry entries, losing customizations and breaking
registry-pinned automations.
outage can't make the fleet look stale.
{parent}-…) whose parent is provided butcarries an empty
batteriesdict are refused — the LOCAL-mode firstrefresh reports inverters with
"batteries": {}before the first real poll,and a live battery module must not be deletable during that window. Once the
parent has authoritative battery data, a key absent from it (round-robin
eviction, module removed) is removable.
Changes
device_removal.py(new module): theasync_remove_config_entry_devicehook plus the
_provided_device_identifiershelper.__init__.py: re-exports the hook (Home Assistant resolves it by name onthe component module); no other changes.
tests/test_device_removal.py(new file): 13 tests covering every identifier shape, theplaceholder-cycle guard, the failed-update/empty-table/never-loaded refusals,
and legacy serial-based parallel-group ids.
docs/TROUBLESHOOTING.md: FAQ entry describing the flow and therefuse-if-still-provided rule.
CHANGELOG.md: Unreleased entry.Testing
pytest tests/passes (full suite).ruff check/ruff format --check(CI-pinned 0.15.5) clean.mypy --config-file tests/mypy.ini(strict) clean.3.5.0 + this patch): the registry carried a ghost FlexBOSS21
(
44700E0241, left behind by a hardware swap and parked as disabledbecause there was no delete path — the exact Remove disabled device #174 situation) plus its
battery bank. Removing the live inverter via
config/device_registry/remove_config_entrywas refused("Failed to remove device entry, rejected by integration"); removing the
ghost inverter and its battery bank succeeded, and afterwards the entity
registry held zero entities referencing the ghost serial while all live
devices and entities were untouched.
Disclosure
This PR was written by Claude Fable 5 (Anthropic's AI assistant, model
claude-fable-5, working in Claude Code) and reviewed by me beforesubmission. The live validation above was performed on my own
FlexBOSS21 + GridBOSS system.