Compare commits

..
Author SHA1 Message Date
orchestrator-agent 5bca24910a 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.
2026-09-19 11:58:12 -04:00
orchestrator-agent 99a8a133e2 Add per-child all-done and present-today binary sensors 2026-09-19 11:57:15 -04:00
orchestrator-agent df4c9eb21a Add per-child all-done and present-today binary sensors
all_done is on exactly when the child owed quests today and owes none
remaining (a zero-quest day stays off so celebrations never fire on
an empty day); present_today mirrors the presence engine's answer
for the HA-local date.
2026-09-19 11:56:10 -04:00
orchestrator-agent 236e8f4adf Add the per-child next-quest sensor 2026-09-19 11:55:20 -04:00
6 changed files with 524 additions and 11 deletions
@@ -0,0 +1,191 @@
"""Per-child binary sensors (Feature 10, task 4).
Two binary sensors per active child, both reading only the coordinator
snapshot (the same ChildDaySnapshot the day sensors read):
- ``binary_sensor.nestquest_<child>_all_done`` — ON exactly when the
child owes quests today (due today > 0) AND owes none of them any
more (remaining today == 0). A zero-quest day is OFF: the
celebration automation must never fire on an empty day (the
day-complete event carries the same rule, Feature 10 task 7).
- ``binary_sensor.nestquest_<child>_present_today`` — ON when the
presence engine resolves the child present on the HA-local date
(schedules and overrides, D-004 anchor-date arithmetic).
Naming and registry identity follow the day sensors' contract: names
slug to the documented entity_id shape; unique_ids are built from the
child's database id, never the display name, so a rename never
orphans an entity's history.
"""
from __future__ import annotations
from collections.abc import Callable, Iterable
from typing import Any
from homeassistant.components.binary_sensor import BinarySensorEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import ChildDaySnapshot, NestQuestCoordinator
class _NestQuestChildBinarySensor(CoordinatorEntity, BinarySensorEntity):
"""Base for the per-child binary sensors.
Registry identity is built from the child's database id and never
changes; the displayed name and device label re-derive from the
child's CURRENT snapshot on every read, so a rename propagates on
the next refresh without orphaning history. A child absent from
the latest snapshot (a deactivation; removal is a later task's
scope) yields ``None`` states rather than stale values.
"""
_name_suffix: str = ""
def __init__(
self,
coordinator: NestQuestCoordinator,
child: ChildDaySnapshot,
) -> None:
super().__init__(coordinator)
self._child_id = child.child_id
self._fallback_name = child.child_name
@property
def _child(self) -> ChildDaySnapshot | None:
"""Return the child's current day snapshot, or None."""
data = self.coordinator.data
if data is None:
return None
for child in data.children:
if child.child_id == self._child_id:
return child
return None
def _displayed_child_name(self) -> str:
child = self._child
return child.child_name if child is not None else self._fallback_name
@property
def name(self) -> str | None:
return f"NestQuest {self._displayed_child_name()} {self._name_suffix}"
@property
def device_info(self) -> DeviceInfo:
return DeviceInfo(
identifiers={(DOMAIN, f"child_{self._child_id}")},
name=f"NestQuest {self._displayed_child_name()}",
manufacturer="NestQuest",
)
@property
def extra_state_attributes(self) -> dict[str, Any] | None:
child = self._child
if child is None:
return None
return {
"child_id": child.child_id,
"child_name": child.child_name,
}
class NestQuestAllDoneSensor(_NestQuestChildBinarySensor):
"""ON when the child had quests today and owes none of them."""
_name_suffix = "all done"
def __init__(
self,
coordinator: NestQuestCoordinator,
child: ChildDaySnapshot,
) -> None:
super().__init__(coordinator, child)
self._attr_unique_id = f"{DOMAIN}_child_{child.child_id}_all_done"
@property
def is_on(self) -> bool | None:
child = self._child
if child is None:
return None
# Zero-quest days are OFF, not ON: an empty day must never fire
# the celebration automation.
return child.due_today > 0 and child.remaining_today == 0
class NestQuestPresentTodaySensor(_NestQuestChildBinarySensor):
"""ON when the child is at this house today (presence engine)."""
_name_suffix = "present today"
def __init__(
self,
coordinator: NestQuestCoordinator,
child: ChildDaySnapshot,
) -> None:
super().__init__(coordinator, child)
self._attr_unique_id = f"{DOMAIN}_child_{child.child_id}_present_today"
@property
def is_on(self) -> bool | None:
child = self._child
if child is None:
return None
return child.present
def _child_binary_sensors(
coordinator: NestQuestCoordinator, child: ChildDaySnapshot
) -> tuple[_NestQuestChildBinarySensor, ...]:
"""Build the two documented binary sensors for one active child."""
return (
NestQuestAllDoneSensor(coordinator, child),
NestQuestPresentTodaySensor(coordinator, child),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: Callable[..., None],
) -> None:
"""Create the two binary sensors per active child.
Entities are created for the snapshot's children at platform
setup; a coordinator listener adds sensors for children that first
appear on a later refresh (the day sensors' same pattern, so a new
child's whole entity set lands together). Deactivated children
are NOT removed here — removal is a later task's scope.
"""
coordinator = entry.runtime_data.coordinator
known_child_ids: set[int] = set()
def _add_new_children(children: Iterable[ChildDaySnapshot]) -> None:
new_children = [
child
for child in children
if child.child_id not in known_child_ids
]
if not new_children:
return
known_child_ids.update(child.child_id for child in new_children)
async_add_entities(
[
sensor
for child in new_children
for sensor in _child_binary_sensors(coordinator, child)
]
)
data = coordinator.data
if data is not None:
_add_new_children(data.children)
def _handle_coordinator_update() -> None:
data = coordinator.data
if data is not None:
_add_new_children(data.children)
coordinator.async_add_listener(_handle_coordinator_update)
+2 -2
View File
@@ -8,8 +8,8 @@ LOGGER_NAME = "custom_components.nestquest"
LOGGER = logging.getLogger(LOGGER_NAME)
# Platforms set up per config entry (Feature 10): the per-child day
# sensors. binary_sensor joins when task 4's entities land.
PLATFORMS: list[str] = ["sensor"]
# sensors and binary sensors.
PLATFORMS: list[str] = ["sensor", "binary_sensor"]
#: Canonical domain-global service names (design/ENTITIES-AND-SERVICES.md §2
#: plus the existing Feature 07 regenerate service).
+121
View File
@@ -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
+134
View File
@@ -0,0 +1,134 @@
"""Tests for the per-child binary sensors (Feature 10, task 4)."""
from __future__ import annotations
import datetime
from conftest import wire_entry_to_registry
from custom_components.nestquest import async_setup_entry
from custom_components.nestquest.children import create_child
from custom_components.nestquest.const import DOMAIN
from custom_components.nestquest.dao_presence import PresenceSchedulesDao
from custom_components.nestquest.materialize import materialize
from custom_components.nestquest.quest_definitions import (
create_quest_definition,
)
from custom_components.nestquest.recurrence import ScheduleRule
from custom_components.nestquest.completion import complete_instance
SEED_HORIZON_DAYS = 3
def _binary(hass, child_id: int, kind: str):
return hass.entities[f"{DOMAIN}_child_{child_id}_{kind}"]
async def _setup_entry(hass, make_entry):
entry = wire_entry_to_registry(make_entry(), hass.registry)
assert await async_setup_entry(hass, entry) is True
return entry
async def _seed_two_children(database) -> tuple:
ada = await create_child(database, "Ada")
bo = await create_child(database, "Bo")
today = datetime.date.today()
await create_quest_definition(
database,
"Brush teeth",
ScheduleRule.from_dict(
{"rule_type": "daily", "start_date": today.isoformat()}
),
[ada.id, bo.id],
["morning"],
)
end = (today + datetime.timedelta(days=SEED_HORIZON_DAYS)).isoformat()
await materialize(database, today.isoformat(), end, today=today)
return ada, bo
async def test_all_done_off_with_open_quests_and_zero_quests(
hass, make_entry
) -> None:
"""all_done is OFF while quests are owed AND on a zero-quest day;
a child with no quests never fires the celebration automation."""
entry = await _setup_entry(hass, make_entry)
coordinator = entry.runtime_data.coordinator
database = entry.runtime_data.database
ada, _bo = await _seed_two_children(database)
await create_child(database, "Cory") # zero quests
await coordinator.async_refresh()
assert _binary(hass, ada.id, "all_done").is_on is False
cory = [
child for child in coordinator.data.children
if child.child_name == "Cory"
][0]
assert _binary(hass, cory.child_id, "all_done").is_on is False
async def test_all_done_on_only_when_day_clear(hass, make_entry) -> None:
entry = await _setup_entry(hass, make_entry)
coordinator = entry.runtime_data.coordinator
database = entry.runtime_data.database
ada, _bo = await _seed_two_children(database)
await coordinator.async_refresh()
snapshot_ada = [
child for child in coordinator.data.children
if child.child_id == ada.id
][0]
instance_id = snapshot_ada.instances[0].instance_id
await complete_instance(
database,
instance_id,
actor_source="panel",
actor_child_id=ada.id,
)
await coordinator.async_refresh()
assert _binary(hass, ada.id, "all_done").is_on is True
async def test_present_today_on_without_schedule_off_when_absent(
hass, make_entry
) -> None:
"""No schedule means present every day; an all-absent pattern
today reports OFF."""
entry = await _setup_entry(hass, make_entry)
coordinator = entry.runtime_data.coordinator
database = entry.runtime_data.database
ada, _bo = await _seed_two_children(database)
await create_child(database, "Cory")
today = datetime.date.today()
await coordinator.async_refresh()
cory = [
child for child in coordinator.data.children
if child.child_name == "Cory"
][0]
await PresenceSchedulesDao(database).upsert_by_child(
cory.child_id, 1, today.isoformat(), ""
)
await coordinator.async_refresh()
assert _binary(hass, ada.id, "present_today").is_on is True
assert _binary(hass, cory.child_id, "present_today").is_on is False
async def test_binary_sensor_identity_and_attributes(hass, make_entry) -> None:
entry = await _setup_entry(hass, make_entry)
coordinator = entry.runtime_data.coordinator
database = entry.runtime_data.database
ada, _bo = await _seed_two_children(database)
await coordinator.async_refresh()
all_done = _binary(hass, ada.id, "all_done")
present = _binary(hass, ada.id, "present_today")
assert all_done.unique_id == f"nestquest_child_{ada.id}_all_done"
assert present.unique_id == f"nestquest_child_{ada.id}_present_today"
assert all_done.name == "NestQuest Ada all done"
assert present.name == "NestQuest Ada present today"
for entity in (all_done, present):
assert entity.device_info.name == "NestQuest Ada"
assert entity.extra_state_attributes == {
"child_id": ada.id,
"child_name": "Ada",
}
+2 -2
View File
@@ -28,10 +28,10 @@ def test_logger() -> None:
def test_platforms() -> None:
"""Verify PLATFORMS is defined as the sensor platform list."""
"""Verify PLATFORMS is defined as the sensor + binary_sensor list."""
assert hasattr(const, "PLATFORMS")
assert isinstance(const.PLATFORMS, list)
assert const.PLATFORMS == ["sensor"]
assert const.PLATFORMS == ["sensor", "binary_sensor"]
def test_sqlite_db_filename() -> None:
+74 -7
View File
@@ -104,13 +104,17 @@ async def test_every_active_child_gets_five_sensors_with_documented_identity(
hass, make_entry
)
database = entry.runtime_data.database
assert hass.config_entries.forwarded_platforms == ["sensor"]
assert hass.config_entries.forwarded_platforms == [
"sensor",
"binary_sensor",
]
await _complete_first_instance(database, coordinator, ada)
await coordinator.async_refresh()
# Two quest children plus the zero-quest child: three children x
# five sensors, no duplicates.
assert len(hass.entities) == 15
# (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),
@@ -324,22 +328,32 @@ async def test_repeated_refreshes_do_not_duplicate_entities(
)
await coordinator.async_refresh()
await coordinator.async_refresh()
assert len(hass.entities) == 15
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:
entry = wire_entry_to_registry(make_entry(), hass.registry)
assert await async_setup_entry(hass, entry) is True
assert await async_unload_entry(hass, entry) is True
assert hass.config_entries.unloaded_platforms == ["sensor"]
assert hass.config_entries.unloaded_platforms == [
"sensor",
"binary_sensor",
]
async def test_rename_propagates_to_name_and_device_label(
@@ -446,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()
)