Show each sensor's longest report gap; stop recording volatile attributes

Choosing room and weather freshness limits required knowing how long
each sensor normally goes without reporting, which HA does not show.
Add max_age_seconds_24h to every source in input_quality: the largest
report age seen at polls over the last 24 hours (hourly buckets, reset
on reload, underestimates by up to one poll interval).

input_quality and weather_comparison change every poll, so the Recorder
stored a new attribute row every five minutes. Mark them unrecorded;
they remain visible on the Status entity. Model coefficients and other
attributes are still recorded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
buzeraandClaude Opus 5.5 committed 2026-10-26 21:51:35 +02:00
1 parent c0e4de832b
commit 4daba5028b
6 files changed
+64 -3

No files matched your search

+5
View File
@@ -27,6 +27,11 @@ async def test_setup_reload_unload(hass, entry, inputs):
assert len(entities) == 5
ids = {e.unique_id: e.entity_id for e in entities}
assert coordinator.data["status"] == "learning"
status = hass.states.get(ids[f"{entry.entry_id}_status"])
assert status.attributes["input_quality"]["rooms"]["sensor.room"]["max_age_seconds_24h"] == 0
# Volatile diagnostics stay visible but are not written to the Recorder.
assert {"input_quality", "weather_comparison"} <= status.state_info["unrecorded_attributes"]
assert "models" not in status.state_info["unrecorded_attributes"]
proposal_id = registry.async_get_entity_id("binary_sensor", DOMAIN, f"{entry.entry_id}_proposal")
assert hass.states.get(proposal_id).state == "unavailable"
assert await hass.config_entries.async_reload(entry.entry_id)
+15
View File
@@ -524,3 +524,18 @@ class BoundaryTests(unittest.IsolatedAsyncioTestCase):
self.c._store.async_load.return_value = {'retired_learners': retired}
await self.c.async_restore_model()
self.assertEqual(list(self.c._retired), [f'sensor.old{i}' for i in range(6, 30)])
async def test_report_gap_maximum_is_exposed_per_sensor(self):
await self.sample(2400) # the room then stays silent for 40 minutes
self.states['sensor.outside'].last_reported = self.now
data = await self.sample()
room = data['input_quality']['rooms']['sensor.room']
self.assertEqual((room['status'], room['max_age_seconds_24h']), ('stale', 2400))
self.states['sensor.room'].last_reported = self.now
self.states['sensor.outside'].last_reported = self.now
data = await self.sample()
room = data['input_quality']['rooms']['sensor.room']
self.assertEqual((room['status'], room['age_seconds'], room['max_age_seconds_24h']),
('valid', 0, 2400))
weather = data['input_quality']['weather']['temperature']['sensor.outside']
self.assertEqual(weather['max_age_seconds_24h'], 0) # kept reporting; tracked separately
+12
View File
@@ -31,6 +31,18 @@ class FreshnessTests(unittest.TestCase):
with self.assertRaises(ValueError):
quality.freshness(now, now, limit)
def test_report_age_history_keeps_24_hour_maximum(self):
history = quality.ReportAgeHistory()
start = datetime(2026, 10, 26, tzinfo=timezone.utc)
self.assertEqual(history.observe('sensor.a', start, 2400), 2400)
self.assertEqual(history.observe('sensor.b', start, 60), 60)
for minutes in range(5, 24 * 60, 5):
result = history.observe('sensor.a', start + timedelta(minutes=minutes), 300)
self.assertEqual(result, 2400) # still inside the 24-hour window
later = start + timedelta(hours=24, minutes=5)
self.assertEqual(history.observe('sensor.a', later, 300), 300)
self.assertEqual(history.observe('sensor.b', later, 30), 30)
def test_freshness_settings_translated(self):
for name in ('strings.json', 'translations/en.json', 'translations/ro.json'):
data = json.loads((ROOT / name).read_text())