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.
This commit is contained in:
@@ -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
|
||||
|
||||
+66
-5
@@ -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()
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user