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