Extract the engines and business layer into core/ (task 510c1f78)

Move presence.py, materialize.py, completion.py, events.py, children.py,
quest_definitions.py, admin_allowlist.py and service_policy.py into
custom_components/nestquest/core/, leaving re-export shims at the
original paths so the integration keeps working unchanged.

core/events.py is refactored to PAYLOAD CONSTRUCTION ONLY: it builds
and returns (event_type, payload) tuples and never touches hass.bus or
hass.config. The HA-local 'today' the day-complete rule needs is now a
parameter (read from hass.config.time_zone by the integration shim).
The events.py shim retains fire_quest_completed/fire_quest_uncompleted,
wiring the returned events to hass.bus.async_fire so the four event
names and payload shapes stay identical (nestquest_quest_completed with
was_on_time, nestquest_quest_uncompleted, nestquest_child_day_complete
with quests_due/quests_completed, occurred_at strict UTC ISO-8601).

Update two test logger-name references (children now logs as
...core.children) and the materialize _now_stamp monkeypatch target
(now patches the core module the walk reads globals from).

1497 passed (baseline). No homeassistant import under core/, no
top-level core import under the integration, no repo-root core/.
This commit is contained in:
2026-09-21 14:52:58 -04:00
parent 1503774e85
commit 08cb31ce4c
18 changed files with 2726 additions and 2560 deletions
+31 -100
View File
@@ -1,73 +1,27 @@
"""HA bus events fired by the NestQuest service paths (Feature 10).
"""Re-export / HA wiring of :mod:`custom_components.nestquest.core.events` for the integration package.
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.
The transition-event payload construction lives in
:mod:`custom_components.nestquest.core.events` (extracted in
task 510c1f78) and is HA-free: it builds and RETURNS the events. This
shim keeps the integration's old ``fire_quest_completed`` /
``fire_quest_uncompleted`` API (the complete/uncomplete service
handlers call them with ``hass``), reads the HA-local timezone from
``hass.config.time_zone`` to derive ``today``, asks the core builder
for the events, and fires each on ``hass.bus`` with the documented
payloads. The four blueprints keep working with identical payloads.
"""
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 .core.db import NestQuestDatabase
from .core.events import (
build_quest_completed_events,
build_quest_uncompleted_events,
)
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,
}
__all__ = ["fire_quest_completed", "fire_quest_uncompleted"]
async def fire_quest_completed(
@@ -80,51 +34,28 @@ async def fire_quest_completed(
"""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, with the completion's ``was_on_time`` —
computed under the completion lock and carried on the result — so
the payload never re-reads mutable latest state a racing reversal
could have replaced. A no-op re-complete never reaches here.
Thin HA wiring over the HA-free
:func:`custom_components.nestquest.core.events.build_quest_completed_events`:
the HA-local ``today`` (the day-complete rule's input) is read
here from ``hass.config.time_zone``; the core builder does every
payload-shape and day-complete decision and hands back the events
to fire. Identical payloads to the pre-extraction implementation.
"""
now_stamp = _now_stamp()
payload = await _instance_payload(database, instance_id, now_stamp)
payload["was_on_time"] = was_on_time
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).
# Only a completion of TODAY'S instance evaluates it: completing a
# back-dated or future instance must not announce a cleared day.
today = datetime.datetime.now(ZoneInfo(hass.config.time_zone)).date()
if payload["due_date"] != today.isoformat():
return
instances = await QuestInstancesDao(database).list_by_date_range(
payload["child_id"], today.isoformat(), today.isoformat()
events = await build_quest_completed_events(
database,
instance_id,
was_on_time=was_on_time,
today=today,
)
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,
},
)
for event_type, payload in events:
hass.bus.async_fire(event_type, payload)
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)
events = await build_quest_uncompleted_events(database, instance_id)
for event_type, payload in events:
hass.bus.async_fire(event_type, payload)