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,
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user