Backfill materialization at setup and expose regenerate service (Feature 07, task 7)

This commit is contained in:
2026-09-17 15:20:18 -04:00
parent be6367b600
commit 0127362ebf
6 changed files with 267 additions and 13 deletions
+66 -12
View File
@@ -23,6 +23,7 @@ from .const import (
DEFAULT_HORIZON_DAYS,
DOMAIN,
LOGGER,
SERVICE_REGENERATE,
)
from .db import NestQuestDatabase
from .materialize import materialize as _materialize_run
@@ -236,6 +237,7 @@ class NestQuestRuntimeData:
database: NestQuestDatabase
remove_update_listener: Callable[[], Any]
remove_time_change_listener: Callable[[], Any]
remove_service_listener: Callable[[], Any]
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)
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(
hass: HomeAssistant,
database: NestQuestDatabase,
@@ -280,28 +305,46 @@ def _register_day_rollover_listener(
"""Register the daily materialization listener and return its remover.
Fires once per day at the configured local ``day_rollover_time`` and
materializes 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.
materializes the rolling horizon ``[today, today + DEFAULT_HORIZON_DAYS]``
through the shared :func:`_run_horizon_materialization` path.
"""
hour, minute = _day_rollover_hour_minute(options)
async def _run_materialization(_now: datetime.datetime) -> None:
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)
await _run_horizon_materialization(hass, database)
return async_track_time_change(
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:
"""Set up the NestQuest integration. YAML configuration is not used, returns 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(
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:
await database.close()
raise
@@ -396,6 +445,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
database=database,
remove_update_listener=remove_update_listener,
remove_time_change_listener=remove_time_change_listener,
remove_service_listener=remove_service_listener,
)
entry.runtime_data = 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(
runtime_data, "remove_time_change_listener", None
)
remove_service_listener = getattr(
runtime_data, "remove_service_listener", None
)
unload_error: BaseException | None = None
try:
for remove_listener in (
remove_update_listener,
remove_time_change_listener,
remove_service_listener,
):
if remove_listener is None:
continue
+3
View File
@@ -9,6 +9,9 @@ LOGGER = logging.getLogger(LOGGER_NAME)
PLATFORMS: list[str] = []
#: Service name for the on-demand regeneration service (``nestquest.regenerate``).
SERVICE_REGENERATE = "regenerate"
SQLITE_DB_FILENAME = "nestquest.db"
# Configuration keys and default values
+12
View File
@@ -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.