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)
|
LOGGER = logging.getLogger(LOGGER_NAME)
|
||||||
|
|
||||||
# Platforms set up per config entry (Feature 10): the per-child day
|
# Platforms set up per config entry (Feature 10): the per-child day
|
||||||
# sensors. binary_sensor joins when task 4's entities land.
|
# sensors and binary sensors.
|
||||||
PLATFORMS: list[str] = ["sensor"]
|
PLATFORMS: list[str] = ["sensor", "binary_sensor"]
|
||||||
|
|
||||||
#: Canonical domain-global service names (design/ENTITIES-AND-SERVICES.md §2
|
#: Canonical domain-global service names (design/ENTITIES-AND-SERVICES.md §2
|
||||||
#: plus the existing Feature 07 regenerate service).
|
#: 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:
|
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 hasattr(const, "PLATFORMS")
|
||||||
assert isinstance(const.PLATFORMS, list)
|
assert isinstance(const.PLATFORMS, list)
|
||||||
assert const.PLATFORMS == ["sensor"]
|
assert const.PLATFORMS == ["sensor", "binary_sensor"]
|
||||||
|
|
||||||
|
|
||||||
def test_sqlite_db_filename() -> None:
|
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
|
hass, make_entry
|
||||||
)
|
)
|
||||||
database = entry.runtime_data.database
|
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 _complete_first_instance(database, coordinator, ada)
|
||||||
await coordinator.async_refresh()
|
await coordinator.async_refresh()
|
||||||
|
|
||||||
# Two quest children plus the zero-quest child: three children x
|
# Two quest children plus the zero-quest child: three children x
|
||||||
# five sensors, no duplicates.
|
# (five sensors + two binary sensors), no duplicates.
|
||||||
assert len(hass.entities) == 15
|
assert len(hass.entities) == 21
|
||||||
expected = {
|
expected = {
|
||||||
ada.id: ("Ada", 1, 1, 0, 100),
|
ada.id: ("Ada", 1, 1, 0, 100),
|
||||||
bo.id: ("Bo", 1, 0, 1, 0),
|
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()
|
||||||
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(
|
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)
|
entry = wire_entry_to_registry(make_entry(), hass.registry)
|
||||||
assert await async_setup_entry(hass, entry) is True
|
assert await async_setup_entry(hass, entry) is True
|
||||||
assert await async_unload_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(
|
async def test_rename_propagates_to_name_and_device_label(
|
||||||
|
|||||||
Reference in New Issue
Block a user