diff --git a/custom_components/nestquest/binary_sensor.py b/custom_components/nestquest/binary_sensor.py new file mode 100644 index 0000000..7cd45b3 --- /dev/null +++ b/custom_components/nestquest/binary_sensor.py @@ -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__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__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) diff --git a/custom_components/nestquest/const.py b/custom_components/nestquest/const.py index 82ef273..3d1d89a 100644 --- a/custom_components/nestquest/const.py +++ b/custom_components/nestquest/const.py @@ -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). diff --git a/tests/test_binary_sensors.py b/tests/test_binary_sensors.py new file mode 100644 index 0000000..2c31abf --- /dev/null +++ b/tests/test_binary_sensors.py @@ -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", + } diff --git a/tests/test_const.py b/tests/test_const.py index dc9e255..361ca83 100644 --- a/tests/test_const.py +++ b/tests/test_const.py @@ -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: diff --git a/tests/test_sensors.py b/tests/test_sensors.py index cf0478b..14cd27b 100644 --- a/tests/test_sensors.py +++ b/tests/test_sensors.py @@ -104,13 +104,16 @@ 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), no duplicates. + assert len(hass.entities) == 21 expected = { ada.id: ("Ada", 1, 1, 0, 100), bo.id: ("Bo", 1, 0, 1, 0), @@ -324,7 +327,7 @@ 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) == 21 async def test_setup_with_no_children_registers_no_entities( @@ -339,7 +342,10 @@ 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(