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.
This commit is contained in:
2026-09-19 12:01:05 -04:00
parent fa153059cb
commit fe38e5040e
6 changed files with 397 additions and 2 deletions
+9
View File
@@ -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"
+124
View File
@@ -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)
+17 -2
View File
@@ -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)