From fe38e5040ed5707be373e9bdb811c65ab8f21aaa Mon Sep 17 00:00:00 2001 From: orchestrator-agent Date: Sat, 19 Sep 2026 12:01:05 -0400 Subject: [PATCH] Fire the documented bus events from the completion service paths quest_completed and quest_uncompleted fire on actual transitions with documented payloads; child_day_complete fires when a completion clears the child's day; quest_missed is defined as Feature 11's contract. Payloads documented in the README for automation authors. --- README.md | 29 ++++ custom_components/nestquest/const.py | 9 ++ custom_components/nestquest/events.py | 124 +++++++++++++++ custom_components/nestquest/services.py | 19 ++- tests/conftest.py | 26 ++++ tests/test_events.py | 192 ++++++++++++++++++++++++ 6 files changed, 397 insertions(+), 2 deletions(-) create mode 100644 custom_components/nestquest/events.py create mode 100644 tests/test_events.py diff --git a/README.md b/README.md index dfa59e7..441ad74 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,35 @@ After installation and restart: 2. Click **Add Integration** and search for **NestQuest**. 3. Follow the on-screen configuration flow. +## Events + +NestQuest fires Home Assistant bus events so automations can react to a +child's progress. Each payload carries `child_id`, `child_name`, +`instance_id`, `quest_title`, the instance's `window`, `due_date`, +`due_time`, and `occurred_at` (a strict UTC ISO-8601 timestamp). + +| Event | Fires when | Extra payload fields | +|---|---|---| +| `nestquest_quest_completed` | a quest is completed (an actual completion, never a re-complete no-op) | `was_on_time` (bool) | +| `nestquest_quest_uncompleted` | an admin reverses a completion | — | +| `nestquest_child_day_complete` | a completion clears the child's whole day (quests were owed and none remain; a zero-quest day never fires it) | `quests_due`, `quests_completed` | +| `nestquest_quest_missed` | the nightly sweep marks a still-open past-due quest missed (integrated in a later release) | — | + +Example automation trigger: + +```yaml +triggers: + - trigger: event + event_type: nestquest_child_day_complete +conditions: + - condition: template + value_template: "{{ trigger.payload.child_name == 'Declan' }}" +actions: + - action: notify.mobile_app_declans_phone + data: + message: "Every quest cleared — legendary!" +``` + ## Developer Setup and Testing This project uses [`uv`](https://github.com/astral-sh/uv) for fast Python package and dependency management. diff --git a/custom_components/nestquest/const.py b/custom_components/nestquest/const.py index 3d1d89a..924898f 100644 --- a/custom_components/nestquest/const.py +++ b/custom_components/nestquest/const.py @@ -11,6 +11,15 @@ LOGGER = logging.getLogger(LOGGER_NAME) # sensors and binary sensors. PLATFORMS: list[str] = ["sensor", "binary_sensor"] +# Home Assistant bus events (design/ENTITIES-AND-SERVICES.md §3). The +# panel and admin cards listen for these; Feature 10 fires completed, +# uncompleted, and child_day_complete from the service paths, and +# Feature 11's nightly sweep fires quest_missed. +EVENT_QUEST_COMPLETED = "nestquest_quest_completed" +EVENT_QUEST_UNCOMPLETED = "nestquest_quest_uncompleted" +EVENT_QUEST_MISSED = "nestquest_quest_missed" +EVENT_CHILD_DAY_COMPLETE = "nestquest_child_day_complete" + #: Canonical domain-global service names (design/ENTITIES-AND-SERVICES.md §2 #: plus the existing Feature 07 regenerate service). SERVICE_COMPLETE_QUEST = "complete_quest" diff --git a/custom_components/nestquest/events.py b/custom_components/nestquest/events.py new file mode 100644 index 0000000..d8394c6 --- /dev/null +++ b/custom_components/nestquest/events.py @@ -0,0 +1,124 @@ +"""HA bus events fired by the NestQuest service paths (Feature 10). + +The four documented events (design/ENTITIES-AND-SERVICES.md §3, names +in :mod:`~.const`): ``nestquest_quest_completed`` and +``nestquest_quest_uncompleted`` fire from the completion service +handlers on an actual transition (a no-op re-complete or re-uncomplete +fires nothing), and ``nestquest_child_day_complete`` fires when a +completion clears the child's whole day (quests were owed and none +remain — a zero-quest day can never fire it, matching the all-done +binary sensor's rule). ``nestquest_quest_missed`` is the documented +contract Feature 11's nightly sweep fires; nothing here fires it. + +Payload policy: one dict carrying ``child_id``, ``child_name``, +``instance_id``, ``quest_title``, the instance's ``window``/ +``due_date``/``due_time`` so automation authors can filter without a +database lookup, and ``occurred_at`` — the event's own strict UTC +ISO-8601 timestamp. Completion payloads additionally carry +``was_on_time``. No other personal data: names and titles are the +household's own records, and nothing beyond them leaks. +""" +from __future__ import annotations + +import datetime +from zoneinfo import ZoneInfo + +from .completion import derive_state +from .const import ( + EVENT_CHILD_DAY_COMPLETE, + EVENT_QUEST_COMPLETED, + EVENT_QUEST_UNCOMPLETED, +) +from .dao_children import ChildrenDao +from .dao_instances import CompletionEventsDao, QuestInstancesDao +from .dao_rules import QuestDefinitionsDao +from .db import NestQuestDatabase + +#: Strict UTC ISO-8601 timestamp (the DAO layers' policy): one shape, +#: explicit offset, second precision. +_UTC_TIMESTAMP_FORMAT = "%Y-%m-%dT%H:%M:%S+00:00" + + +def _now_stamp() -> str: + return datetime.datetime.now(datetime.timezone.utc).strftime( + _UTC_TIMESTAMP_FORMAT + ) + + +async def _instance_payload( + database: NestQuestDatabase, instance_id: int, now_stamp: str +) -> dict: + """Build the shared event payload for one quest instance.""" + instance = await QuestInstancesDao(database).get_by_id(instance_id) + if instance is None: + raise ValueError( + f"instance_id: quest instance {instance_id} does not exist" + ) + child = await ChildrenDao(database).get(instance.child_id) + definition = await QuestDefinitionsDao(database).get( + instance.definition_id + ) + return { + "child_id": instance.child_id, + "child_name": child.display_name if child else None, + "instance_id": instance.id, + "quest_title": definition.title if definition else None, + "window": instance.window, + "due_date": instance.due_date, + "due_time": instance.due_time, + "occurred_at": now_stamp, + } + + +async def fire_quest_completed( + hass, database: NestQuestDatabase, instance_id: int +) -> None: + """Fire ``nestquest_quest_completed`` and, when the completion + cleared the child's whole day, ``nestquest_child_day_complete``. + + Called by the complete_quest service handler AFTER an actual + completion transition; a no-op re-complete never reaches here. + """ + now_stamp = _now_stamp() + payload = await _instance_payload(database, instance_id, now_stamp) + latest = await CompletionEventsDao(database).get_latest_for_instance( + instance_id + ) + payload["was_on_time"] = ( + latest.was_on_time if latest is not None else None + ) + hass.bus.async_fire(EVENT_QUEST_COMPLETED, payload) + + # Day-complete: quests were owed today and none remain (the same + # rule as the all-done binary sensor; a zero-quest day never fires). + today = datetime.datetime.now(ZoneInfo(hass.config.time_zone)).date() + instances = await QuestInstancesDao(database).list_by_date_range( + payload["child_id"], today.isoformat(), today.isoformat() + ) + events = CompletionEventsDao(database) + remaining = 0 + for instance in instances: + latest_for_instance = await events.get_latest_for_instance( + instance.id + ) + if derive_state(instance, latest_for_instance, today) != "done": + remaining += 1 + if instances and remaining == 0: + hass.bus.async_fire( + EVENT_CHILD_DAY_COMPLETE, + { + "child_id": payload["child_id"], + "child_name": payload["child_name"], + "quests_due": len(instances), + "quests_completed": len(instances), + "occurred_at": now_stamp, + }, + ) + + +async def fire_quest_uncompleted( + hass, database: NestQuestDatabase, instance_id: int +) -> None: + """Fire ``nestquest_quest_uncompleted`` for an actual reversal.""" + payload = await _instance_payload(database, instance_id, _now_stamp()) + hass.bus.async_fire(EVENT_QUEST_UNCOMPLETED, payload) diff --git a/custom_components/nestquest/services.py b/custom_components/nestquest/services.py index 9c6d765..3ee9903 100644 --- a/custom_components/nestquest/services.py +++ b/custom_components/nestquest/services.py @@ -42,6 +42,7 @@ from .const import ( SERVICE_UPDATE_QUEST_DEFINITION, ) from .dao_presence import PresenceOverridesDao, PresenceSchedulesDao +from .events import fire_quest_completed, fire_quest_uncompleted from .materialize import regenerate_for_child from .permissions import permission_gate from .presence import PresenceSchedule @@ -293,11 +294,19 @@ def _complete_quest( ) -> ServiceHandler: async def _handler(call: Any) -> None: database = _require_runtime(hass, find_runtime).database + instance_id = call.data["instance_id"] + # Events fire on an ACTUAL transition only: an already-done + # instance is a no-op below and must not re-announce itself. + state_before = await completion_layer.instance_state( + database, instance_id + ) await completion_layer.complete_instance( database, - call.data["instance_id"], + instance_id, **_actor_kwargs(call), ) + if state_before != "done": + await fire_quest_completed(hass, database, instance_id) return _with_errors(_handler) @@ -307,11 +316,17 @@ def _uncomplete_quest( ) -> ServiceHandler: async def _handler(call: Any) -> None: database = _require_runtime(hass, find_runtime).database + instance_id = call.data["instance_id"] + state_before = await completion_layer.instance_state( + database, instance_id + ) await completion_layer.uncomplete_instance( database, - call.data["instance_id"], + instance_id, **_actor_kwargs(call), ) + if state_before == "done": + await fire_quest_uncompleted(hass, database, instance_id) return _with_errors(_handler) diff --git a/tests/conftest.py b/tests/conftest.py index d753166..8873d63 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -549,6 +549,31 @@ def _async_track_time_change(hass, action, hour=None, minute=None, second=None): _ha_mock("homeassistant.helpers.event").async_track_time_change = _async_track_time_change +class EventBus: + """Models HA's ``hass.bus`` event bus. + + ``async_fire`` is SYNCHRONOUS (matching real HA) and records + ``(event_type, payload)`` pairs in order so tests can assert + exactly which events fired with which payloads; there is no + listener dispatch (entities poll the coordinator, and the cards + are not part of this harness). + """ + + def __init__(self): + self.events: list[tuple[str, dict]] = [] + + def async_fire(self, event_type: str, payload=None): + self.events.append((event_type, dict(payload or {}))) + + def fired(self, event_type: str) -> list[dict]: + """Return every payload fired for ``event_type``, in order.""" + return [ + payload + for fired_type, payload in self.events + if fired_type == event_type + ] + + class ServiceRegistry: """Models HA's ``hass.services`` service registry. @@ -671,6 +696,7 @@ def make_hass() -> tuple: registry = ListenerRegistry(hass) hass.time_change = TimeChangeRegistry(hass) hass.services = ServiceRegistry(hass) + hass.bus = EventBus() # A valid IANA time zone (the day-rollover listener reads it to # compute "today" in HA local time). hass.config.time_zone = "UTC" diff --git a/tests/test_events.py b/tests/test_events.py new file mode 100644 index 0000000..ee3d79b --- /dev/null +++ b/tests/test_events.py @@ -0,0 +1,192 @@ +"""Tests for the NestQuest bus events (Feature 10, task 6).""" +from __future__ import annotations + +import re + +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 ( + CONF_ADMIN_USER_IDS, + DOMAIN, + EVENT_CHILD_DAY_COMPLETE, + EVENT_QUEST_COMPLETED, + EVENT_QUEST_MISSED, + EVENT_QUEST_UNCOMPLETED, + SERVICE_COMPLETE_QUEST, + SERVICE_CREATE_QUEST_DEFINITION, + SERVICE_MANAGE_CHILD, + SERVICE_REGENERATE, + SERVICE_UNCOMPLETE_QUEST, +) +from custom_components.nestquest.dao_instances import QuestInstancesDao +from custom_components.nestquest.materialize import materialize +from custom_components.nestquest.quest_definitions import ( + create_quest_definition, +) +from custom_components.nestquest.recurrence import ScheduleRule + +ADMIN_CTX = {"user_id": "admin-1"} +_TIMESTAMP = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+00:00$") + + +async def _setup_and_seed(hass, make_entry, *, windows=("morning",)): + entry = wire_entry_to_registry( + make_entry(data={CONF_ADMIN_USER_IDS: ["admin-1"]}), hass.registry + ) + assert await async_setup_entry(hass, entry) is True + database = entry.runtime_data.database + await hass.services.call( + DOMAIN, + SERVICE_MANAGE_CHILD, + {"action": "create", "display_name": "Ada"}, + context=ADMIN_CTX, + ) + child = None + from custom_components.nestquest.children import list_children + + child = (await list_children(database))[0] + await create_quest_definition( + database, + "Brush teeth", + ScheduleRule.from_dict( + {"rule_type": "daily", "start_date": __import__("datetime").date.today().isoformat()} + ), + [child.id], + list(windows), + ) + import datetime + + today = datetime.date.today().isoformat() + end = (datetime.date.today() + datetime.timedelta(days=3)).isoformat() + await materialize(database, today, end, today=datetime.date.today()) + instances = await QuestInstancesDao(database).list_by_date_range( + child.id, today, today + ) + return entry, child, instances + + +async def test_quest_completed_fires_with_documented_payload( + hass, make_entry +) -> None: + entry, child, instances = await _setup_and_seed(hass, make_entry) + instance_id = instances[0].id + await hass.services.call( + DOMAIN, + SERVICE_COMPLETE_QUEST, + {"instance_id": instance_id, "actor": "panel", "actor_child_id": child.id}, + ) + fired = hass.bus.fired(EVENT_QUEST_COMPLETED) + assert len(fired) == 1 + payload = fired[0] + assert payload["child_id"] == child.id + assert payload["child_name"] == "Ada" + assert payload["instance_id"] == instance_id + assert payload["quest_title"] == "Brush teeth" + assert payload["window"] == "morning" + assert payload["was_on_time"] is not None + assert _TIMESTAMP.match(payload["occurred_at"]), payload["occurred_at"] + + +async def test_recomplete_no_op_fires_nothing(hass, make_entry) -> None: + entry, child, instances = await _setup_and_seed(hass, make_entry) + instance_id = instances[0].id + await hass.services.call( + DOMAIN, + SERVICE_COMPLETE_QUEST, + {"instance_id": instance_id, "actor": "panel", "actor_child_id": child.id}, + ) + await hass.services.call( + DOMAIN, + SERVICE_COMPLETE_QUEST, + {"instance_id": instance_id, "actor": "panel", "actor_child_id": child.id}, + ) + assert len(hass.bus.fired(EVENT_QUEST_COMPLETED)) == 1 + + +async def test_child_day_complete_fires_when_day_clears( + hass, make_entry +) -> None: + entry, child, instances = await _setup_and_seed(hass, make_entry) + for instance in instances: + await hass.services.call( + DOMAIN, + SERVICE_COMPLETE_QUEST, + { + "instance_id": instance.id, + "actor": "panel", + "actor_child_id": child.id, + }, + ) + day_complete = hass.bus.fired(EVENT_CHILD_DAY_COMPLETE) + assert len(day_complete) == 1, "exactly one day-complete per cleared day" + payload = day_complete[0] + assert payload["child_id"] == child.id + assert payload["child_name"] == "Ada" + assert payload["quests_due"] == len(instances) + assert _TIMESTAMP.match(payload["occurred_at"]) + + +async def test_child_day_complete_does_not_fire_partway( + hass, make_entry +) -> None: + entry, child, instances = await _setup_and_seed( + hass, make_entry, windows=("morning", "evening") + ) + assert len(instances) == 2 + await hass.services.call( + DOMAIN, + SERVICE_COMPLETE_QUEST, + { + "instance_id": instances[0].id, + "actor": "panel", + "actor_child_id": child.id, + }, + ) + assert hass.bus.fired(EVENT_CHILD_DAY_COMPLETE) == [] + await hass.services.call( + DOMAIN, + SERVICE_COMPLETE_QUEST, + { + "instance_id": instances[1].id, + "actor": "panel", + "actor_child_id": child.id, + }, + ) + assert len(hass.bus.fired(EVENT_CHILD_DAY_COMPLETE)) == 1 + + +async def test_uncompleted_fires_on_reversal_only(hass, make_entry) -> None: + entry, child, instances = await _setup_and_seed(hass, make_entry) + instance_id = instances[0].id + await hass.services.call( + DOMAIN, + SERVICE_COMPLETE_QUEST, + {"instance_id": instance_id, "actor": "panel", "actor_child_id": child.id}, + ) + # Un-completing an open instance is a no-op and fires nothing. + await hass.services.call( + DOMAIN, + SERVICE_UNCOMPLETE_QUEST, + {"instance_id": instance_id, "actor": "user"}, + context=ADMIN_CTX, + ) + fired = hass.bus.fired(EVENT_QUEST_UNCOMPLETED) + assert len(fired) == 1 + payload = fired[0] + assert payload["instance_id"] == instance_id + assert payload["quest_title"] == "Brush teeth" + assert _TIMESTAMP.match(payload["occurred_at"]) + await hass.services.call( + DOMAIN, + SERVICE_UNCOMPLETE_QUEST, + {"instance_id": instance_id, "actor": "user"}, + context=ADMIN_CTX, + ) + assert len(hass.bus.fired(EVENT_QUEST_UNCOMPLETED)) == 1 + + +def test_missed_event_name_is_documented_contract() -> None: + """Feature 10 defines the missed event Feature 11's sweep fires.""" + assert EVENT_QUEST_MISSED == "nestquest_quest_missed"