Phase 2: usage-hour over-counting, HVAC accuracy, and the vanishing calendar - #9
Open
bhaggs wants to merge 1 commit into
Open
Phase 2: usage-hour over-counting, HVAC accuracy, and the vanishing calendar#9bhaggs wants to merge 1 commit into
bhaggs wants to merge 1 commit into
Conversation
Phase 2 of the review. Four independent problems, each with a regression test that failed first. H2 -- downtime counted as runtime. Usage state was reconciled against the tracked entity only for brand-new filters, but state changes while HA is down raise no events and async_track_state_change_event does not fire for states that already exist when the listener attaches. A fan that was on at shutdown therefore kept accruing across the entire outage: a test fixture 24 days stale reported 600 hours instead of 1, and committed 2160591 seconds permanently to storage at the entity's next real state change. Startup now always reconciles against the live state and discards the gap -- there is no evidence about what the entity did while nothing was watching, and crediting it was strictly worse than dropping it. HVAC accuracy -- climate tracking counted hvac_mode, so a thermostat left on "heat" all winter accrued 24/7 while the furnace actually ran for an hour a day. Now prefers the hvac_action attribute when the entity reports it, falling back to the mode when it does not. The derived "is running" flag is persisted alongside the raw state, because for climate entities the state string alone cannot answer the question. Added without a store version bump: absence reads as None and falls back to the old rule, where a bump without an async_migrate_func would read the whole file as empty and lose the user's hours. Because climate entities emit a state-change event for every attribute update, the handler now ignores changes that don't flip the running flag, rather than writing storage every time the current temperature ticks. H3 -- every entity became a usage tracker. All seven entities receive the config-update signal, and the resubscribe path keyed off a field that was None for six of them, so changing the usage sensor made all seven subscribe and write the same store. Measured at 8 writes for a single state change; now exactly 1, gated on an explicit flag set at construction. H5 -- the shared calendar was guarded by a bare "already created" flag that only cleared when the last entry unloaded. Reloading the entry that owned it left the calendar permanently unavailable while other filters remained, and deleting that entry lost it entirely. Ownership is now tracked by entry id, released on unload, and re-homed onto a surviving filter on removal. Note the calendar is not deleted on unload -- HA marks it unavailable and restored -- so a test asserting mere existence passes while the calendar is dead. The assertions check availability. M7/M8 -- the dispatcher handlers were plain functions, so HA ran them in an executor thread, which is why they needed the thread-safe schedule_update_ha_state. Marked as callbacks so they run on the event loop, and switched to async_write_ha_state. The async state-change handler had the inverse problem: it carried a @callback decorator it should never have had, and worked only because HA checks iscoroutinefunction first.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Phase 2 of the code review. Four independent problems, each with a regression test written first and confirmed failing.
H2 — downtime counted as runtime
Usage state was reconciled against the tracked entity only for brand-new filters (
_last_usage_changed is None). But changes while HA is down raise no events, andasync_track_state_change_eventdoesn't fire for states that already exist when the listener attaches. A fan that was on at shutdown kept accruing across the whole outage:The second one is the worse half — the bogus total was written to storage at the entity's next real state change.
Startup now always reconciles against live state and discards the gap. There's no evidence about what the entity did while nothing was watching, and crediting it was strictly worse than dropping it. This under-counts brief restarts; that's the conservative direction and a deliberate trade.
HVAC accuracy
Climate tracking counted
hvac_mode, so a thermostat left onheatall winter accrued 24/7 while the furnace actually ran an hour a day — the single worst case for the main furnace-filter use case. Now prefershvac_actionwhen reported, falling back to the mode when not.The derived "is running" flag is persisted alongside the raw state, because for climate entities the state string alone can't answer the question. Added without a store version bump: absence reads as
Noneand falls back to the old rule. A bump without anasync_migrate_funcwould makeStoreread the whole file as empty and lose the user's hours.Since climate entities emit a state-change event for every attribute update, the handler now ignores changes that don't flip the running flag rather than writing storage each time the current temperature ticks.
H3 — every entity became a usage tracker
All seven entities receive the config-update signal, and the resubscribe path keyed off a field that was
Nonefor six of them. Changing the usage sensor made all seven subscribe and write the same store, racing read-modify-write on one file. Measured at 8 writes for one state change; now exactly 1.H5 — the vanishing calendar
Guarded by a bare "already created" flag that only cleared when the last entry unloaded. Reloading the owning entry left the calendar permanently unavailable while other filters remained; deleting it lost the calendar entirely. Ownership is now tracked by entry id, released on unload, and re-homed onto a surviving filter on removal.
Worth flagging for review: HA does not delete the calendar on unload — it marks it
unavailablewithrestored=True. My first version of this test asserted the entity merely existed and passed against the bug. The assertions check availability.M7/M8 — thread safety, both directions
The dispatcher handlers were plain sync functions, so HA ran them in an executor thread — which is why they needed the thread-safe
schedule_update_ha_state. They're now marked@callbackand useasync_write_ha_state. The async state-change handler had the inverse problem: a@callbackdecorator it should never have had, working only because HA checksiscoroutinefunctionfirst.Tests
39 total, up from 28. New:
test_usage_tracking.py,test_calendar_ownership.py, and two fixtures (a filter active at shutdown, a climate-tracked furnace filter). The upgrade gate still passes, so v0.5.0 stores load with their hours intact.Still open
The options flow still uses bespoke dispatchers rather than HA's reload pattern (Phase 3), and the efficiency items in Phase 4 —
should_poll, delayed saves, midnight refresh.🤖 Generated with Claude Code