Skip to content

Phase 2: usage-hour over-counting, HVAC accuracy, and the vanishing calendar - #9

Open
bhaggs wants to merge 1 commit into
mainfrom
fix/usage-tracking-and-calendar
Open

Phase 2: usage-hour over-counting, HVAC accuracy, and the vanishing calendar#9
bhaggs wants to merge 1 commit into
mainfrom
fix/usage-tracking-and-calendar

Conversation

@bhaggs

@bhaggs bhaggs commented Aug 26, 2026

Copy link
Copy Markdown
Owner

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, and async_track_state_change_event doesn't fire for states that already exist when the listener attaches. A fan that was on at shutdown kept accruing across the whole outage:

assert 600.16 == 1.0 ± 0.1              # displayed hours
downtime was committed to storage: 2160591.838692s   # ~25 days, permanently

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 on heat all 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 prefers hvac_action when 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 None and falls back to the old rule. A bump without an async_migrate_func would make Store read 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 None for 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 unavailable with restored=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 @callback and use async_write_ha_state. The async state-change handler had the inverse problem: a @callback decorator it should never have had, working only because HA checks iscoroutinefunction first.

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

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.
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.

1 participant