From 5bca24910a47d0b858a8c9a78345b4ee14ffbe13 Mon Sep 17 00:00:00 2001 From: orchestrator-agent Date: Sat, 19 Sep 2026 11:58:12 -0400 Subject: [PATCH] Add household rollup and cycle-day sensors Household quests due/completed today sum the active children's snapshots; cycle day reports the first scheduled child's cycle position (0 when nobody is scheduled), anchor-date arithmetic only. --- custom_components/nestquest/sensor.py | 121 ++++++++++++++++++++++++++ tests/test_sensors.py | 71 +++++++++++++-- 2 files changed, 187 insertions(+), 5 deletions(-) diff --git a/custom_components/nestquest/sensor.py b/custom_components/nestquest/sensor.py index d3ae546..0157750 100644 --- a/custom_components/nestquest/sensor.py +++ b/custom_components/nestquest/sensor.py @@ -369,6 +369,123 @@ def _child_day_sensors( ) +class _NestQuestHouseholdSensor(CoordinatorEntity, SensorEntity): + """Base for the household-level rollup sensors. + + Aggregates the ACTIVE children's snapshots only (the snapshot + already excludes inactive children), matching the per-child + sensors exactly — a household number is always the sum of the + per-child numbers. Grouped under one household device so the + three household sensors render together. + """ + + _attr_name: str + + def __init__(self, coordinator: NestQuestCoordinator) -> None: + super().__init__(coordinator) + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, "household")}, + name="NestQuest Household", + manufacturer="NestQuest", + ) + + +class NestQuestHouseholdDueTodaySensor(_NestQuestHouseholdSensor): + """Total quests due today across active children.""" + + _attr_name = "NestQuest household quests due today" + _attr_state_class = SensorStateClass.MEASUREMENT + _attr_unique_id = f"{DOMAIN}_household_quests_due_today" + + @property + def native_value(self) -> int | None: + data = self.coordinator.data + if data is None: + return None + return _state_within_limit( + sum(child.due_today for child in data.children) + ) + + @property + def extra_state_attributes(self) -> dict[str, Any] | None: + data = self.coordinator.data + if data is None: + return None + return { + "children": { + str(child.child_id): child.due_today + for child in data.children + } + } + + +class NestQuestHouseholdCompletedTodaySensor(_NestQuestHouseholdSensor): + """Total quests completed today across active children.""" + + _attr_name = "NestQuest household quests completed today" + _attr_state_class = SensorStateClass.MEASUREMENT + _attr_unique_id = f"{DOMAIN}_household_quests_completed_today" + + @property + def native_value(self) -> int | None: + data = self.coordinator.data + if data is None: + return None + return _state_within_limit( + sum(child.completed_today for child in data.children) + ) + + @property + def extra_state_attributes(self) -> dict[str, Any] | None: + data = self.coordinator.data + if data is None: + return None + return { + "children": { + str(child.child_id): child.completed_today + for child in data.children + } + } + + +class NestQuestCycleDaySensor(_NestQuestHouseholdSensor): + """Cycle day N of the custody cycle (0 when nobody is scheduled). + + Computed by the coordinator from the first active child with a + presence schedule, anchor-date arithmetic only (D-004 — never ISO + week parity), so a 53-week year cannot silently invert it. + """ + + _attr_name = "NestQuest cycle day" + _attr_state_class = SensorStateClass.MEASUREMENT + _attr_unique_id = f"{DOMAIN}_cycle_day" + + @property + def native_value(self) -> int | None: + data = self.coordinator.data + if data is None: + return None + return _state_within_limit(data.cycle_day) + + @property + def extra_state_attributes(self) -> dict[str, Any] | None: + data = self.coordinator.data + if data is None: + return None + return {"cycle_day": data.cycle_day, "today": data.today_iso} + + +def _household_sensors( + coordinator: NestQuestCoordinator, +) -> tuple[_NestQuestHouseholdSensor, ...]: + """Build the three household-level sensors (one set per entry).""" + return ( + NestQuestHouseholdDueTodaySensor(coordinator), + NestQuestHouseholdCompletedTodaySensor(coordinator), + NestQuestCycleDaySensor(coordinator), + ) + + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, @@ -405,6 +522,10 @@ async def async_setup_entry( data = coordinator.data if data is not None: _add_new_children(data.children) + # The household rollups are child-independent: created exactly + # once at platform setup, they aggregate whatever the snapshot + # carries (empty included — an empty household is a valid 0). + async_add_entities(_household_sensors(coordinator)) def _handle_coordinator_update() -> None: data = coordinator.data diff --git a/tests/test_sensors.py b/tests/test_sensors.py index 14cd27b..cadeca9 100644 --- a/tests/test_sensors.py +++ b/tests/test_sensors.py @@ -112,8 +112,9 @@ async def test_every_active_child_gets_five_sensors_with_documented_identity( await coordinator.async_refresh() # Two quest children plus the zero-quest child: three children x - # (five sensors + two binary sensors), no duplicates. - assert len(hass.entities) == 21 + # (five sensors + two binary sensors) + three household sensors, + # no duplicates. + assert len(hass.entities) == 24 expected = { ada.id: ("Ada", 1, 1, 0, 100), bo.id: ("Bo", 1, 0, 1, 0), @@ -327,15 +328,22 @@ async def test_repeated_refreshes_do_not_duplicate_entities( ) await coordinator.async_refresh() await coordinator.async_refresh() - assert len(hass.entities) == 21 + assert len(hass.entities) == 24 -async def test_setup_with_no_children_registers_no_entities( +async def test_setup_with_no_children_registers_only_household( hass, make_entry ) -> None: entry = wire_entry_to_registry(make_entry(), hass.registry) assert await async_setup_entry(hass, entry) is True - assert hass.entities == {} + # No children means no per-child entities; the three household + # rollups still exist (an empty household is a valid 0). + assert set(hass.entities) == { + "nestquest_household_quests_due_today", + "nestquest_household_quests_completed_today", + "nestquest_cycle_day", + } + assert hass.entities["nestquest_household_quests_due_today"].native_value == 0 async def test_unload_unloads_platforms(hass, make_entry) -> None: @@ -452,3 +460,56 @@ async def test_next_quest_state_truncates_over_the_limit( state = next_entity.native_value assert len(state) <= 255 assert next_entity.extra_state_attributes["title"] == full_title + + +async def test_household_rollups_sum_the_active_children( + hass, make_entry +) -> None: + """Household due/completed equal the sum of the per-child sensors; + the cycle-day sensor carries the coordinator's answer.""" + import datetime as _dt + + from custom_components.nestquest.dao_presence import ( + PresenceSchedulesDao, + ) + + entry, coordinator, (ada, bo, cory) = await _setup_seeded_entry( + hass, make_entry + ) + database = entry.runtime_data.database + await _complete_first_instance(database, coordinator, ada) + await coordinator.async_refresh() + + household_due = hass.entities["nestquest_household_quests_due_today"] + household_completed = hass.entities[ + "nestquest_household_quests_completed_today" + ] + cycle_day_entity = hass.entities["nestquest_cycle_day"] + per_child_due = [ + _sensor(hass, child.id, "quests_due_today").native_value + for child in (ada, bo, cory) + ] + per_child_completed = [ + _sensor(hass, child.id, "quests_completed_today").native_value + for child in (ada, bo, cory) + ] + assert household_due.native_value == sum(per_child_due) + assert household_completed.native_value == sum(per_child_completed) + assert household_due.name == "NestQuest household quests due today" + assert household_completed.name == ( + "NestQuest household quests completed today" + ) + assert cycle_day_entity.name == "NestQuest cycle day" + assert cycle_day_entity.native_value == 0, "no schedule anywhere" + + # A schedule on the FIRST active child drives cycle day. + today = _dt.date.today() + await PresenceSchedulesDao(database).upsert_by_child( + ada.id, 2, (today - _dt.timedelta(days=3)).isoformat(), + "0,1,2,3,4,5,6|", + ) + await coordinator.async_refresh() + assert cycle_day_entity.native_value == 4 + assert cycle_day_entity.extra_state_attributes["today"] == ( + today.isoformat() + )