Backfill materialization at setup and expose regenerate service (Feature 07, task 7)
This commit is contained in:
@@ -23,6 +23,7 @@ from .const import (
|
|||||||
DEFAULT_HORIZON_DAYS,
|
DEFAULT_HORIZON_DAYS,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
LOGGER,
|
LOGGER,
|
||||||
|
SERVICE_REGENERATE,
|
||||||
)
|
)
|
||||||
from .db import NestQuestDatabase
|
from .db import NestQuestDatabase
|
||||||
from .materialize import materialize as _materialize_run
|
from .materialize import materialize as _materialize_run
|
||||||
@@ -236,6 +237,7 @@ class NestQuestRuntimeData:
|
|||||||
database: NestQuestDatabase
|
database: NestQuestDatabase
|
||||||
remove_update_listener: Callable[[], Any]
|
remove_update_listener: Callable[[], Any]
|
||||||
remove_time_change_listener: Callable[[], Any]
|
remove_time_change_listener: Callable[[], Any]
|
||||||
|
remove_service_listener: Callable[[], Any]
|
||||||
|
|
||||||
|
|
||||||
async def _async_owner_user_ids(hass: HomeAssistant) -> list[str]:
|
async def _async_owner_user_ids(hass: HomeAssistant) -> list[str]:
|
||||||
@@ -272,6 +274,29 @@ def _day_rollover_hour_minute(options: dict[str, Any]) -> tuple[int, int]:
|
|||||||
return int(hour), int(minute)
|
return int(hour), int(minute)
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_horizon_materialization(
|
||||||
|
hass: HomeAssistant, database: NestQuestDatabase
|
||||||
|
) -> None:
|
||||||
|
"""Materialize the rolling horizon ``[today, today + DEFAULT_HORIZON_DAYS]``.
|
||||||
|
|
||||||
|
"today" is computed in HA local time (``hass.config.time_zone``) rather
|
||||||
|
than the system clock, so the horizon tracks the household's own day.
|
||||||
|
The walk itself runs through :func:`~.materialize.materialize`, whose DB
|
||||||
|
access is executor-only.
|
||||||
|
|
||||||
|
This is the ONE generation path: the startup backfill, the daily rollover
|
||||||
|
listener and the ``regenerate`` service all funnel through it, so there is
|
||||||
|
no duplicated materialization logic.
|
||||||
|
"""
|
||||||
|
time_zone = ZoneInfo(hass.config.time_zone)
|
||||||
|
today = datetime.datetime.now(time_zone).date()
|
||||||
|
start_date = today.isoformat()
|
||||||
|
end_date = (
|
||||||
|
today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)
|
||||||
|
).isoformat()
|
||||||
|
await _materialize_run(database, start_date, end_date)
|
||||||
|
|
||||||
|
|
||||||
def _register_day_rollover_listener(
|
def _register_day_rollover_listener(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
database: NestQuestDatabase,
|
database: NestQuestDatabase,
|
||||||
@@ -280,28 +305,46 @@ def _register_day_rollover_listener(
|
|||||||
"""Register the daily materialization listener and return its remover.
|
"""Register the daily materialization listener and return its remover.
|
||||||
|
|
||||||
Fires once per day at the configured local ``day_rollover_time`` and
|
Fires once per day at the configured local ``day_rollover_time`` and
|
||||||
materializes the rolling horizon ``[today, today + DEFAULT_HORIZON_DAYS]``.
|
materializes the rolling horizon ``[today, today + DEFAULT_HORIZON_DAYS]``
|
||||||
"today" is computed in HA local time (``hass.config.time_zone``) rather
|
through the shared :func:`_run_horizon_materialization` path.
|
||||||
than the system clock, so the horizon tracks the household's own day.
|
|
||||||
The walk itself runs through :func:`~.materialize.materialize`, whose DB
|
|
||||||
access is executor-only.
|
|
||||||
"""
|
"""
|
||||||
hour, minute = _day_rollover_hour_minute(options)
|
hour, minute = _day_rollover_hour_minute(options)
|
||||||
|
|
||||||
async def _run_materialization(_now: datetime.datetime) -> None:
|
async def _run_materialization(_now: datetime.datetime) -> None:
|
||||||
time_zone = ZoneInfo(hass.config.time_zone)
|
await _run_horizon_materialization(hass, database)
|
||||||
today = datetime.datetime.now(time_zone).date()
|
|
||||||
start_date = today.isoformat()
|
|
||||||
end_date = (
|
|
||||||
today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)
|
|
||||||
).isoformat()
|
|
||||||
await _materialize_run(database, start_date, end_date)
|
|
||||||
|
|
||||||
return async_track_time_change(
|
return async_track_time_change(
|
||||||
hass, _run_materialization, hour=hour, minute=minute
|
hass, _run_materialization, hour=hour, minute=minute
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _register_regenerate_service(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
database: NestQuestDatabase,
|
||||||
|
) -> Callable[[], Any]:
|
||||||
|
"""Register ``nestquest.regenerate`` and return its unregister callable.
|
||||||
|
|
||||||
|
The service triggers a full on-demand regeneration over the rolling
|
||||||
|
horizon, running the SAME idempotent materialize path
|
||||||
|
(:func:`_run_horizon_materialization`) as the startup backfill and the
|
||||||
|
daily rollover listener. Registration is guarded by ``has_service`` so a
|
||||||
|
reload can never leave a duplicate; the returned remover deregisters the
|
||||||
|
service (idempotently) so unload leaves no stale entry behind.
|
||||||
|
"""
|
||||||
|
if not hass.services.has_service(DOMAIN, SERVICE_REGENERATE):
|
||||||
|
|
||||||
|
async def _regenerate(_call: Any) -> None:
|
||||||
|
await _run_horizon_materialization(hass, database)
|
||||||
|
|
||||||
|
hass.services.async_register(DOMAIN, SERVICE_REGENERATE, _regenerate)
|
||||||
|
|
||||||
|
def _unregister() -> None:
|
||||||
|
if hass.services.has_service(DOMAIN, SERVICE_REGENERATE):
|
||||||
|
hass.services.async_unregister(DOMAIN, SERVICE_REGENERATE)
|
||||||
|
|
||||||
|
return _unregister
|
||||||
|
|
||||||
|
|
||||||
async def async_setup(hass: HomeAssistant, config: dict[str, Any]) -> bool:
|
async def async_setup(hass: HomeAssistant, config: dict[str, Any]) -> bool:
|
||||||
"""Set up the NestQuest integration. YAML configuration is not used, returns True."""
|
"""Set up the NestQuest integration. YAML configuration is not used, returns True."""
|
||||||
return True
|
return True
|
||||||
@@ -387,6 +430,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
remove_time_change_listener = _register_day_rollover_listener(
|
remove_time_change_listener = _register_day_rollover_listener(
|
||||||
hass, database, dict(entry.options)
|
hass, database, dict(entry.options)
|
||||||
)
|
)
|
||||||
|
# Backfill any days the daily listener missed while the integration
|
||||||
|
# was off (or freshly installed): run the one idempotent walk over
|
||||||
|
# [today, today + DEFAULT_HORIZON_DAYS] once at setup. This runs
|
||||||
|
# through the SAME path as the daily listener and regenerate service.
|
||||||
|
await _run_horizon_materialization(hass, database)
|
||||||
|
remove_service_listener = _register_regenerate_service(hass, database)
|
||||||
except BaseException:
|
except BaseException:
|
||||||
await database.close()
|
await database.close()
|
||||||
raise
|
raise
|
||||||
@@ -396,6 +445,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
database=database,
|
database=database,
|
||||||
remove_update_listener=remove_update_listener,
|
remove_update_listener=remove_update_listener,
|
||||||
remove_time_change_listener=remove_time_change_listener,
|
remove_time_change_listener=remove_time_change_listener,
|
||||||
|
remove_service_listener=remove_service_listener,
|
||||||
)
|
)
|
||||||
entry.runtime_data = runtime_data
|
entry.runtime_data = runtime_data
|
||||||
hass.data[DOMAIN][entry.entry_id] = runtime_data
|
hass.data[DOMAIN][entry.entry_id] = runtime_data
|
||||||
@@ -415,11 +465,15 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
remove_time_change_listener = getattr(
|
remove_time_change_listener = getattr(
|
||||||
runtime_data, "remove_time_change_listener", None
|
runtime_data, "remove_time_change_listener", None
|
||||||
)
|
)
|
||||||
|
remove_service_listener = getattr(
|
||||||
|
runtime_data, "remove_service_listener", None
|
||||||
|
)
|
||||||
unload_error: BaseException | None = None
|
unload_error: BaseException | None = None
|
||||||
try:
|
try:
|
||||||
for remove_listener in (
|
for remove_listener in (
|
||||||
remove_update_listener,
|
remove_update_listener,
|
||||||
remove_time_change_listener,
|
remove_time_change_listener,
|
||||||
|
remove_service_listener,
|
||||||
):
|
):
|
||||||
if remove_listener is None:
|
if remove_listener is None:
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ LOGGER = logging.getLogger(LOGGER_NAME)
|
|||||||
|
|
||||||
PLATFORMS: list[str] = []
|
PLATFORMS: list[str] = []
|
||||||
|
|
||||||
|
#: Service name for the on-demand regeneration service (``nestquest.regenerate``).
|
||||||
|
SERVICE_REGENERATE = "regenerate"
|
||||||
|
|
||||||
SQLITE_DB_FILENAME = "nestquest.db"
|
SQLITE_DB_FILENAME = "nestquest.db"
|
||||||
|
|
||||||
# Configuration keys and default values
|
# Configuration keys and default values
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# Service declarations for the NestQuest integration (Home Assistant
|
||||||
|
# services.yaml metadata). Services are domain-scoped: nested under the
|
||||||
|
# "nestquest" domain, not repeated per config entry.
|
||||||
|
|
||||||
|
regenerate:
|
||||||
|
name: Regenerate quest instances
|
||||||
|
description: >-
|
||||||
|
Regenerate every future quest instance for the household over the rolling
|
||||||
|
horizon, using the same idempotent materialization as the daily rollover
|
||||||
|
and the startup backfill. Safe to call at any time: a re-run refreshes the
|
||||||
|
same rows instead of duplicating them, and completed instances are never
|
||||||
|
touched.
|
||||||
@@ -307,6 +307,51 @@ 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
|
_ha_mock("homeassistant.helpers.event").async_track_time_change = _async_track_time_change
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceRegistry:
|
||||||
|
"""Models HA's ``hass.services`` service registry.
|
||||||
|
|
||||||
|
``async_register`` stores a handler under its (domain, service) key,
|
||||||
|
``has_service`` reports registration, and ``async_unregister`` removes
|
||||||
|
it. ``async_remove`` is the deprecated HA alias, kept so either
|
||||||
|
spelling resolves. ``call`` invokes a stored handler the way HA's
|
||||||
|
service bus would — awaiting a coroutine result — so tests can exercise
|
||||||
|
a registered service end-to-end.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, hass=None):
|
||||||
|
self._hass = hass
|
||||||
|
self._services: dict[tuple[str, str], object] = {}
|
||||||
|
|
||||||
|
def async_register(self, domain, service, func, schema=None, supports_response=None):
|
||||||
|
self._services[(domain, service)] = func
|
||||||
|
return None
|
||||||
|
|
||||||
|
def has_service(self, domain, service):
|
||||||
|
return (domain, service) in self._services
|
||||||
|
|
||||||
|
def async_unregister(self, domain, service):
|
||||||
|
self._services.pop((domain, service), None)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def async_remove(self, domain, service):
|
||||||
|
return self.async_unregister(domain, service)
|
||||||
|
|
||||||
|
async def call(self, domain, service, data=None):
|
||||||
|
func = self._services.get((domain, service))
|
||||||
|
if func is None:
|
||||||
|
raise KeyError(f"service {domain}.{service} not registered")
|
||||||
|
call = SimpleNamespace(domain=domain, service=service, data=data or {})
|
||||||
|
result = func(call)
|
||||||
|
if inspect.isawaitable(result):
|
||||||
|
await result
|
||||||
|
return result
|
||||||
|
|
||||||
|
@property
|
||||||
|
def registered_services(self):
|
||||||
|
"""The registered (domain, service) pairs."""
|
||||||
|
return list(self._services)
|
||||||
|
|
||||||
|
|
||||||
def make_config_entry(
|
def make_config_entry(
|
||||||
entry_id: str = "test_entry",
|
entry_id: str = "test_entry",
|
||||||
options: dict | None = None,
|
options: dict | None = None,
|
||||||
@@ -369,6 +414,7 @@ def make_hass() -> tuple:
|
|||||||
hass.async_add_executor_job = _run_executor_job
|
hass.async_add_executor_job = _run_executor_job
|
||||||
registry = ListenerRegistry(hass)
|
registry = ListenerRegistry(hass)
|
||||||
hass.time_change = TimeChangeRegistry(hass)
|
hass.time_change = TimeChangeRegistry(hass)
|
||||||
|
hass.services = ServiceRegistry(hass)
|
||||||
# A valid IANA time zone (the day-rollover listener reads it to
|
# A valid IANA time zone (the day-rollover listener reads it to
|
||||||
# compute "today" in HA local time).
|
# compute "today" in HA local time).
|
||||||
hass.config.time_zone = "UTC"
|
hass.config.time_zone = "UTC"
|
||||||
|
|||||||
+99
-1
@@ -11,6 +11,7 @@ from custom_components.nestquest import DOMAIN, async_setup_entry, async_unload_
|
|||||||
from custom_components.nestquest.const import (
|
from custom_components.nestquest.const import (
|
||||||
CONF_DAY_ROLLOVER_TIME,
|
CONF_DAY_ROLLOVER_TIME,
|
||||||
DEFAULT_HORIZON_DAYS,
|
DEFAULT_HORIZON_DAYS,
|
||||||
|
SERVICE_REGENERATE,
|
||||||
DOMAIN as DOMAIN_CONST,
|
DOMAIN as DOMAIN_CONST,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -381,4 +382,101 @@ async def test_setup_applies_all_v1_tables(hass, make_entry) -> None:
|
|||||||
"quest_definitions",
|
"quest_definitions",
|
||||||
"schedule_rules",
|
"schedule_rules",
|
||||||
]
|
]
|
||||||
await async_unload_entry(hass, entry)
|
await async_unload_entry(hass, entry)
|
||||||
|
|
||||||
|
|
||||||
|
async def _seed_daily_child_and_definition(database, today) -> int:
|
||||||
|
"""Create one always-present child + a daily definition starting today.
|
||||||
|
|
||||||
|
Returns the child's id. No presence schedule means the child is present
|
||||||
|
every day, so the daily rule yields exactly one instance per horizon day.
|
||||||
|
"""
|
||||||
|
from custom_components.nestquest.dao_children import ChildrenDao
|
||||||
|
from custom_components.nestquest.quest_definitions import (
|
||||||
|
create_quest_definition,
|
||||||
|
)
|
||||||
|
from custom_components.nestquest.recurrence import RuleType, ScheduleRule
|
||||||
|
|
||||||
|
NOW = "2026-09-14T12:00:00+00:00"
|
||||||
|
child = await ChildrenDao(database).create("Ada", NOW)
|
||||||
|
await create_quest_definition(
|
||||||
|
database,
|
||||||
|
"Daily chore",
|
||||||
|
ScheduleRule(rule_type=RuleType.DAILY, start_date=today.isoformat()),
|
||||||
|
[child.id],
|
||||||
|
["morning"],
|
||||||
|
)
|
||||||
|
return child.id
|
||||||
|
|
||||||
|
|
||||||
|
def _local_today(hass) -> "datetime.date":
|
||||||
|
import datetime
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
return datetime.datetime.now(ZoneInfo(hass.config.time_zone)).date()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_setup_backfills_instances_over_horizon(hass, make_entry) -> None:
|
||||||
|
"""Setup materializes once so a restart backfills any missed days."""
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
from custom_components.nestquest.dao_instances import QuestInstancesDao
|
||||||
|
from custom_components.nestquest.db import NestQuestDatabase
|
||||||
|
from custom_components.nestquest.migrations import apply_migrations
|
||||||
|
from custom_components.nestquest.store import async_get_db_path
|
||||||
|
|
||||||
|
# Seed the database BEFORE setup: a restart reopens an existing file that
|
||||||
|
# already holds definitions. The startup backfill must pick them up.
|
||||||
|
db_path = await async_get_db_path(hass)
|
||||||
|
pre = NestQuestDatabase(hass)
|
||||||
|
await pre.open(db_path)
|
||||||
|
await apply_migrations(pre)
|
||||||
|
child_id = await _seed_daily_child_and_definition(pre, _local_today(hass))
|
||||||
|
await pre.close()
|
||||||
|
|
||||||
|
entry = _wire(make_entry(), hass.registry)
|
||||||
|
assert await async_setup_entry(hass, entry) is True
|
||||||
|
|
||||||
|
today = _local_today(hass)
|
||||||
|
end = (today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)).isoformat()
|
||||||
|
records = await QuestInstancesDao(entry.runtime_data.database).list_by_date_range(
|
||||||
|
child_id, today.isoformat(), end
|
||||||
|
)
|
||||||
|
assert len(records) == DEFAULT_HORIZON_DAYS + 1
|
||||||
|
|
||||||
|
|
||||||
|
async def test_regenerate_service_registered_and_materializes(
|
||||||
|
hass, make_entry
|
||||||
|
) -> None:
|
||||||
|
"""``nestquest.regenerate`` is registered and calling it regenerates."""
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
from custom_components.nestquest.dao_instances import QuestInstancesDao
|
||||||
|
|
||||||
|
entry = _wire(make_entry(), hass.registry)
|
||||||
|
assert await async_setup_entry(hass, entry) is True
|
||||||
|
assert hass.services.has_service(DOMAIN, SERVICE_REGENERATE)
|
||||||
|
|
||||||
|
# A definition created AFTER setup has no instances yet (create does not
|
||||||
|
# materialize); the service must generate them on demand.
|
||||||
|
database = entry.runtime_data.database
|
||||||
|
child_id = await _seed_daily_child_and_definition(database, _local_today(hass))
|
||||||
|
|
||||||
|
await hass.services.call(DOMAIN, SERVICE_REGENERATE)
|
||||||
|
|
||||||
|
today = _local_today(hass)
|
||||||
|
end = (today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)).isoformat()
|
||||||
|
records = await QuestInstancesDao(database).list_by_date_range(
|
||||||
|
child_id, today.isoformat(), end
|
||||||
|
)
|
||||||
|
assert len(records) == DEFAULT_HORIZON_DAYS + 1
|
||||||
|
|
||||||
|
|
||||||
|
async def test_unload_deregisters_regenerate_service(hass, make_entry) -> None:
|
||||||
|
"""Unload removes the ``regenerate`` service so no stale entry leaks."""
|
||||||
|
entry = _wire(make_entry(), hass.registry)
|
||||||
|
assert await async_setup_entry(hass, entry) is True
|
||||||
|
assert hass.services.has_service(DOMAIN, SERVICE_REGENERATE)
|
||||||
|
|
||||||
|
assert await async_unload_entry(hass, entry) is True
|
||||||
|
assert not hass.services.has_service(DOMAIN, SERVICE_REGENERATE)
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"""Tests for the NestQuest services.yaml declaration file."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
SERVICES_PATH = (
|
||||||
|
Path(__file__).parent.parent
|
||||||
|
/ "custom_components"
|
||||||
|
/ "nestquest"
|
||||||
|
/ "services.yaml"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_services_yaml_exists() -> None:
|
||||||
|
assert SERVICES_PATH.exists(), f"services.yaml missing at {SERVICES_PATH}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_services_yaml_declares_regenerate() -> None:
|
||||||
|
text = SERVICES_PATH.read_text(encoding="utf-8")
|
||||||
|
assert re.search(r"^regenerate:\s*$", text, re.MULTILINE), (
|
||||||
|
"services.yaml must declare the 'regenerate' service"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_services_yaml_regenerate_has_description_and_no_fields() -> None:
|
||||||
|
"""The regenerate service is documented with a clear description and no
|
||||||
|
required fields (there is no ``fields`` block under it)."""
|
||||||
|
text = SERVICES_PATH.read_text(encoding="utf-8")
|
||||||
|
match = re.search(r"^regenerate:\s*$", text, re.MULTILINE)
|
||||||
|
assert match is not None
|
||||||
|
section = text[match.end():]
|
||||||
|
next_service = re.search(r"^(?![#\s])\w+:\s*$", section, re.MULTILINE)
|
||||||
|
if next_service is not None:
|
||||||
|
section = section[: next_service.start()]
|
||||||
|
assert re.search(r"description:", section), (
|
||||||
|
"regenerate must carry a description"
|
||||||
|
)
|
||||||
|
assert "fields:" not in section, (
|
||||||
|
"regenerate declares no required fields"
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user