Register canonical NestQuest HA services with schemas
Domain-global handlers for the ENTITIES-AND-SERVICES inventory plus regenerate. No permission decorator yet.
This commit is contained in:
@@ -24,11 +24,11 @@ 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
|
||||||
from .migrations import apply_migrations
|
from .migrations import apply_migrations
|
||||||
|
from .services import async_deregister_services, async_register_services
|
||||||
from .store import async_get_db_path
|
from .store import async_get_db_path
|
||||||
from .admin_allowlist import seed_setup_admin
|
from .admin_allowlist import seed_setup_admin
|
||||||
|
|
||||||
@@ -356,21 +356,8 @@ def _find_live_runtime_data(hass: HomeAssistant) -> NestQuestRuntimeData | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _register_regenerate_service(hass: HomeAssistant) -> None:
|
def _make_regenerate_handler(hass: HomeAssistant):
|
||||||
"""Register ``nestquest.regenerate`` once at domain scope.
|
"""Return the Feature 07 ``regenerate`` handler closed over ``hass``."""
|
||||||
|
|
||||||
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. It is DOMAIN-global — registered exactly once
|
|
||||||
(guarded by ``has_service``) and NOT bound to any config entry — so the
|
|
||||||
handler resolves the currently-live database at call time and a name
|
|
||||||
collision or duplicate setup is impossible. Removal is handled by the
|
|
||||||
unload path only once the LAST entry is removed (see
|
|
||||||
:func:`_deregister_regenerate_service`).
|
|
||||||
"""
|
|
||||||
if hass.services.has_service(DOMAIN, SERVICE_REGENERATE):
|
|
||||||
return
|
|
||||||
|
|
||||||
async def _regenerate(_call: Any) -> None:
|
async def _regenerate(_call: Any) -> None:
|
||||||
runtime_data = _find_live_runtime_data(hass)
|
runtime_data = _find_live_runtime_data(hass)
|
||||||
@@ -385,13 +372,29 @@ def _register_regenerate_service(hass: HomeAssistant) -> None:
|
|||||||
hass, runtime_data.database, horizon_days
|
hass, runtime_data.database, horizon_days
|
||||||
)
|
)
|
||||||
|
|
||||||
hass.services.async_register(DOMAIN, SERVICE_REGENERATE, _regenerate)
|
return _regenerate
|
||||||
|
|
||||||
|
|
||||||
def _deregister_regenerate_service(hass: HomeAssistant) -> None:
|
def _register_services(hass: HomeAssistant) -> None:
|
||||||
"""Remove ``nestquest.regenerate`` when the last entry unloads."""
|
"""Register domain-global NestQuest services once.
|
||||||
if hass.services.has_service(DOMAIN, SERVICE_REGENERATE):
|
|
||||||
hass.services.async_remove(DOMAIN, SERVICE_REGENERATE)
|
Services are DOMAIN-global — registered exactly once (guarded by
|
||||||
|
``has_service`` on regenerate) and NOT bound to any config entry —
|
||||||
|
so each handler resolves the currently-live database at call time
|
||||||
|
and a name collision or duplicate setup is impossible. Removal is
|
||||||
|
handled by the unload path only once the LAST entry is removed
|
||||||
|
(see :func:`_deregister_services`).
|
||||||
|
"""
|
||||||
|
async_register_services(
|
||||||
|
hass,
|
||||||
|
find_runtime=_find_live_runtime_data,
|
||||||
|
regenerate_handler=_make_regenerate_handler(hass),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _deregister_services(hass: HomeAssistant) -> None:
|
||||||
|
"""Remove domain-global NestQuest services when the last entry unloads."""
|
||||||
|
async_deregister_services(hass)
|
||||||
|
|
||||||
|
|
||||||
async def async_setup(hass: HomeAssistant, config: dict[str, Any]) -> bool:
|
async def async_setup(hass: HomeAssistant, config: dict[str, Any]) -> bool:
|
||||||
@@ -489,7 +492,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
await _run_horizon_materialization(
|
await _run_horizon_materialization(
|
||||||
hass, database, _configured_horizon_days(dict(entry.options))
|
hass, database, _configured_horizon_days(dict(entry.options))
|
||||||
)
|
)
|
||||||
_register_regenerate_service(hass)
|
_register_services(hass)
|
||||||
except BaseException:
|
except BaseException:
|
||||||
# A failure anywhere after the database is open must not leak the
|
# A failure anywhere after the database is open must not leak the
|
||||||
# listeners already registered against that (now-closed) database:
|
# listeners already registered against that (now-closed) database:
|
||||||
@@ -562,11 +565,11 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
database = getattr(runtime_data, "database", None)
|
database = getattr(runtime_data, "database", None)
|
||||||
if database is not None:
|
if database is not None:
|
||||||
await database.close()
|
await database.close()
|
||||||
# The ``regenerate`` service is DOMAIN-global: it is torn down only
|
# Domain-global services are torn down only once the FINAL entry
|
||||||
# once the FINAL entry unloads (``hass.data[DOMAIN]`` is now empty),
|
# unloads (``hass.data[DOMAIN]`` is now empty), never when a sibling
|
||||||
# never when a sibling entry is still loaded.
|
# entry is still loaded.
|
||||||
if is_last:
|
if is_last:
|
||||||
_deregister_regenerate_service(hass)
|
_deregister_services(hass)
|
||||||
if getattr(entry, "runtime_data", None) is not None:
|
if getattr(entry, "runtime_data", None) is not None:
|
||||||
entry.runtime_data = None
|
entry.runtime_data = None
|
||||||
if unload_error is not None:
|
if unload_error is not None:
|
||||||
|
|||||||
@@ -9,9 +9,34 @@ LOGGER = logging.getLogger(LOGGER_NAME)
|
|||||||
|
|
||||||
PLATFORMS: list[str] = []
|
PLATFORMS: list[str] = []
|
||||||
|
|
||||||
#: Service name for the on-demand regeneration service (``nestquest.regenerate``).
|
#: Canonical domain-global service names (design/ENTITIES-AND-SERVICES.md §2
|
||||||
|
#: plus the existing Feature 07 regenerate service).
|
||||||
|
SERVICE_COMPLETE_QUEST = "complete_quest"
|
||||||
|
SERVICE_UNCOMPLETE_QUEST = "uncomplete_quest"
|
||||||
|
SERVICE_CREATE_QUEST_DEFINITION = "create_quest_definition"
|
||||||
|
SERVICE_UPDATE_QUEST_DEFINITION = "update_quest_definition"
|
||||||
|
SERVICE_SET_QUEST_DEFINITION_ACTIVE = "set_quest_definition_active"
|
||||||
|
SERVICE_SET_PRESENCE_PATTERN = "set_presence_pattern"
|
||||||
|
SERVICE_CREATE_PRESENCE_OVERRIDE = "create_presence_override"
|
||||||
|
SERVICE_DELETE_PRESENCE_OVERRIDE = "delete_presence_override"
|
||||||
|
SERVICE_EXPORT_HISTORY_CSV = "export_history_csv"
|
||||||
|
SERVICE_MANAGE_CHILD = "manage_child"
|
||||||
SERVICE_REGENERATE = "regenerate"
|
SERVICE_REGENERATE = "regenerate"
|
||||||
|
|
||||||
|
DOMAIN_SERVICES: tuple[str, ...] = (
|
||||||
|
SERVICE_COMPLETE_QUEST,
|
||||||
|
SERVICE_UNCOMPLETE_QUEST,
|
||||||
|
SERVICE_CREATE_QUEST_DEFINITION,
|
||||||
|
SERVICE_UPDATE_QUEST_DEFINITION,
|
||||||
|
SERVICE_SET_QUEST_DEFINITION_ACTIVE,
|
||||||
|
SERVICE_SET_PRESENCE_PATTERN,
|
||||||
|
SERVICE_CREATE_PRESENCE_OVERRIDE,
|
||||||
|
SERVICE_DELETE_PRESENCE_OVERRIDE,
|
||||||
|
SERVICE_EXPORT_HISTORY_CSV,
|
||||||
|
SERVICE_MANAGE_CHILD,
|
||||||
|
SERVICE_REGENERATE,
|
||||||
|
)
|
||||||
|
|
||||||
SQLITE_DB_FILENAME = "nestquest.db"
|
SQLITE_DB_FILENAME = "nestquest.db"
|
||||||
|
|
||||||
# Configuration keys and default values
|
# Configuration keys and default values
|
||||||
|
|||||||
@@ -0,0 +1,557 @@
|
|||||||
|
"""Domain-global NestQuest service schemas and handlers (Feature 09).
|
||||||
|
|
||||||
|
Registration is domain-scoped and happens once (see
|
||||||
|
:func:`async_register_services`); handlers resolve a live database at
|
||||||
|
call time through the injected ``find_runtime`` callable. Permission
|
||||||
|
checks are Feature 09 later tasks — this module only validates payload
|
||||||
|
shape and forwards to the Feature 03/06/08 business layers (and the
|
||||||
|
presence DAOs). ``export_history_csv`` is registered with a schema but
|
||||||
|
raises until Feature 13. ``regenerate`` is supplied by the caller so
|
||||||
|
the Feature 07 handler stays the one in ``__init__``.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Awaitable, Callable
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import voluptuous as vol
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
|
|
||||||
|
from . import children as children_layer
|
||||||
|
from . import completion as completion_layer
|
||||||
|
from . import quest_definitions as quest_definitions_layer
|
||||||
|
from .const import (
|
||||||
|
DOMAIN,
|
||||||
|
DOMAIN_SERVICES,
|
||||||
|
QUEST_WINDOWS,
|
||||||
|
SERVICE_COMPLETE_QUEST,
|
||||||
|
SERVICE_CREATE_PRESENCE_OVERRIDE,
|
||||||
|
SERVICE_CREATE_QUEST_DEFINITION,
|
||||||
|
SERVICE_DELETE_PRESENCE_OVERRIDE,
|
||||||
|
SERVICE_EXPORT_HISTORY_CSV,
|
||||||
|
SERVICE_MANAGE_CHILD,
|
||||||
|
SERVICE_REGENERATE,
|
||||||
|
SERVICE_SET_PRESENCE_PATTERN,
|
||||||
|
SERVICE_SET_QUEST_DEFINITION_ACTIVE,
|
||||||
|
SERVICE_UNCOMPLETE_QUEST,
|
||||||
|
SERVICE_UPDATE_QUEST_DEFINITION,
|
||||||
|
)
|
||||||
|
from .dao_presence import PresenceOverridesDao, PresenceSchedulesDao
|
||||||
|
from .presence import PresenceSchedule
|
||||||
|
from .recurrence import ScheduleRule
|
||||||
|
|
||||||
|
FindRuntime = Callable[[HomeAssistant], Any]
|
||||||
|
ServiceHandler = Callable[[Any], Awaitable[None]]
|
||||||
|
|
||||||
|
ACTOR_PANEL = "panel"
|
||||||
|
ACTOR_USER = "user"
|
||||||
|
MANAGE_CHILD_CREATE = "create"
|
||||||
|
MANAGE_CHILD_EDIT = "edit"
|
||||||
|
MANAGE_CHILD_SET_ACTIVE = "set_active"
|
||||||
|
MANAGE_CHILD_REORDER = "reorder"
|
||||||
|
|
||||||
|
|
||||||
|
def _strict_int(value: object) -> int:
|
||||||
|
"""Reject bools and non-ints; voluptuous ``int`` would accept True."""
|
||||||
|
if isinstance(value, bool) or not isinstance(value, int):
|
||||||
|
raise vol.Invalid(f"expected int, got {value!r}")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _strict_bool(value: object) -> bool:
|
||||||
|
"""Reject ints and other truthy values; require a real bool."""
|
||||||
|
if type(value) is not bool:
|
||||||
|
raise vol.Invalid(f"expected bool, got {value!r}")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _weekday_list(value: object) -> list[int]:
|
||||||
|
if type(value) is not list:
|
||||||
|
raise vol.Invalid(f"expected a list of weekday ints, got {value!r}")
|
||||||
|
return [_strict_int(item) for item in value]
|
||||||
|
|
||||||
|
|
||||||
|
def _presence_pattern(value: object) -> dict[int, list[int]]:
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
raise vol.Invalid(f"pattern must be a mapping, got {value!r}")
|
||||||
|
pattern: dict[int, list[int]] = {}
|
||||||
|
for week, days in value.items():
|
||||||
|
if isinstance(week, str):
|
||||||
|
try:
|
||||||
|
week_index = int(week)
|
||||||
|
except ValueError as err:
|
||||||
|
raise vol.Invalid(
|
||||||
|
f"pattern week index must be an integer, got {week!r}"
|
||||||
|
) from err
|
||||||
|
if str(week_index) != week:
|
||||||
|
raise vol.Invalid(
|
||||||
|
f"pattern week index must be an integer, got {week!r}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
week_index = _strict_int(week)
|
||||||
|
pattern[week_index] = _weekday_list(days)
|
||||||
|
return pattern
|
||||||
|
|
||||||
|
|
||||||
|
def _window_entry(value: object) -> str | tuple[str, str | None]:
|
||||||
|
if isinstance(value, str):
|
||||||
|
if value not in QUEST_WINDOWS:
|
||||||
|
raise vol.Invalid(f"unknown window {value!r}")
|
||||||
|
return value
|
||||||
|
if isinstance(value, dict):
|
||||||
|
parsed = vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Required("window"): vol.In(QUEST_WINDOWS),
|
||||||
|
vol.Optional("due_time"): str,
|
||||||
|
}
|
||||||
|
)(value)
|
||||||
|
due_time = parsed.get("due_time")
|
||||||
|
if due_time is None:
|
||||||
|
return parsed["window"]
|
||||||
|
return (parsed["window"], due_time)
|
||||||
|
raise vol.Invalid(
|
||||||
|
f"windows entry must be a window name or object, got {value!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _windows(value: object) -> list[str | tuple[str, str | None]]:
|
||||||
|
if type(value) is not list:
|
||||||
|
raise vol.Invalid(f"windows must be a list, got {value!r}")
|
||||||
|
if not value:
|
||||||
|
raise vol.Invalid("windows must not be empty")
|
||||||
|
return [_window_entry(entry) for entry in value]
|
||||||
|
|
||||||
|
|
||||||
|
def _int_list(value: object) -> list[int]:
|
||||||
|
if type(value) is not list:
|
||||||
|
raise vol.Invalid(f"expected a list of ints, got {value!r}")
|
||||||
|
return [_strict_int(item) for item in value]
|
||||||
|
|
||||||
|
|
||||||
|
def _rule_dict(value: object) -> dict:
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
raise vol.Invalid(f"rule must be a dict, got {value!r}")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
SCHEMA_COMPLETE_QUEST = vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Required("instance_id"): _strict_int,
|
||||||
|
vol.Required("actor"): vol.In([ACTOR_PANEL, ACTOR_USER]),
|
||||||
|
vol.Optional("actor_child_id"): _strict_int,
|
||||||
|
vol.Optional("actor_user_id"): str,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
SCHEMA_UNCOMPLETE_QUEST = SCHEMA_COMPLETE_QUEST
|
||||||
|
|
||||||
|
SCHEMA_CREATE_QUEST_DEFINITION = vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Required("title"): str,
|
||||||
|
vol.Required("rule"): _rule_dict,
|
||||||
|
vol.Required("assignee_child_ids"): vol.All(
|
||||||
|
_int_list, vol.Length(min=1)
|
||||||
|
),
|
||||||
|
vol.Required("windows"): _windows,
|
||||||
|
vol.Optional("description"): str,
|
||||||
|
vol.Optional("icon"): str,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
SCHEMA_UPDATE_QUEST_DEFINITION = vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Required("definition_id"): _strict_int,
|
||||||
|
vol.Optional("title"): str,
|
||||||
|
vol.Optional("description"): vol.Any(str, None),
|
||||||
|
vol.Optional("icon"): vol.Any(str, None),
|
||||||
|
vol.Optional("rule"): _rule_dict,
|
||||||
|
vol.Optional("windows"): _windows,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
SCHEMA_SET_QUEST_DEFINITION_ACTIVE = vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Required("definition_id"): _strict_int,
|
||||||
|
vol.Required("is_active"): _strict_bool,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
SCHEMA_SET_PRESENCE_PATTERN = vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Required("child_id"): _strict_int,
|
||||||
|
vol.Required("cycle_length_weeks"): _strict_int,
|
||||||
|
vol.Required("anchor_date"): str,
|
||||||
|
vol.Required("pattern"): _presence_pattern,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
SCHEMA_CREATE_PRESENCE_OVERRIDE = vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Required("child_id"): _strict_int,
|
||||||
|
vol.Required("start_date"): str,
|
||||||
|
vol.Required("end_date"): str,
|
||||||
|
vol.Required("is_present"): _strict_bool,
|
||||||
|
vol.Optional("note"): str,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
SCHEMA_DELETE_PRESENCE_OVERRIDE = vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Required("override_id"): _strict_int,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
SCHEMA_EXPORT_HISTORY_CSV = vol.Schema({})
|
||||||
|
|
||||||
|
SCHEMA_MANAGE_CHILD = vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Required("action"): vol.In(
|
||||||
|
[
|
||||||
|
MANAGE_CHILD_CREATE,
|
||||||
|
MANAGE_CHILD_EDIT,
|
||||||
|
MANAGE_CHILD_SET_ACTIVE,
|
||||||
|
MANAGE_CHILD_REORDER,
|
||||||
|
]
|
||||||
|
),
|
||||||
|
vol.Optional("child_id"): _strict_int,
|
||||||
|
vol.Optional("display_name"): str,
|
||||||
|
vol.Optional("colour"): str,
|
||||||
|
vol.Optional("avatar_ref"): str,
|
||||||
|
vol.Optional("sort_order"): _strict_int,
|
||||||
|
vol.Optional("is_active"): _strict_bool,
|
||||||
|
vol.Optional("ordered_ids"): _int_list,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _require_database(hass: HomeAssistant, find_runtime: FindRuntime):
|
||||||
|
runtime = find_runtime(hass)
|
||||||
|
if runtime is None:
|
||||||
|
raise HomeAssistantError(
|
||||||
|
"NestQuest has no live database; cannot run this service"
|
||||||
|
)
|
||||||
|
return runtime.database
|
||||||
|
|
||||||
|
|
||||||
|
def _actor_kwargs(call: Any) -> dict[str, Any]:
|
||||||
|
actor = call.data["actor"]
|
||||||
|
if actor == ACTOR_PANEL:
|
||||||
|
actor_child_id = call.data.get("actor_child_id")
|
||||||
|
if actor_child_id is None:
|
||||||
|
raise HomeAssistantError(
|
||||||
|
"actor_child_id is required when actor is panel"
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"actor_source": ACTOR_PANEL,
|
||||||
|
"actor_child_id": actor_child_id,
|
||||||
|
"actor_user_id": None,
|
||||||
|
}
|
||||||
|
context = getattr(call, "context", None)
|
||||||
|
user_id = getattr(context, "user_id", None) if context is not None else None
|
||||||
|
if not user_id:
|
||||||
|
user_id = call.data.get("actor_user_id")
|
||||||
|
return {
|
||||||
|
"actor_source": ACTOR_USER,
|
||||||
|
"actor_user_id": user_id,
|
||||||
|
"actor_child_id": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _with_errors(handler: ServiceHandler) -> ServiceHandler:
|
||||||
|
async def _wrapped(call: Any) -> None:
|
||||||
|
try:
|
||||||
|
await handler(call)
|
||||||
|
except HomeAssistantError:
|
||||||
|
raise
|
||||||
|
except ValueError as err:
|
||||||
|
raise HomeAssistantError(str(err)) from err
|
||||||
|
|
||||||
|
return _wrapped
|
||||||
|
|
||||||
|
|
||||||
|
def _complete_quest(
|
||||||
|
hass: HomeAssistant, find_runtime: FindRuntime
|
||||||
|
) -> ServiceHandler:
|
||||||
|
async def _handler(call: Any) -> None:
|
||||||
|
database = _require_database(hass, find_runtime)
|
||||||
|
await completion_layer.complete_instance(
|
||||||
|
database,
|
||||||
|
call.data["instance_id"],
|
||||||
|
**_actor_kwargs(call),
|
||||||
|
)
|
||||||
|
|
||||||
|
return _with_errors(_handler)
|
||||||
|
|
||||||
|
|
||||||
|
def _uncomplete_quest(
|
||||||
|
hass: HomeAssistant, find_runtime: FindRuntime
|
||||||
|
) -> ServiceHandler:
|
||||||
|
async def _handler(call: Any) -> None:
|
||||||
|
database = _require_database(hass, find_runtime)
|
||||||
|
await completion_layer.uncomplete_instance(
|
||||||
|
database,
|
||||||
|
call.data["instance_id"],
|
||||||
|
**_actor_kwargs(call),
|
||||||
|
)
|
||||||
|
|
||||||
|
return _with_errors(_handler)
|
||||||
|
|
||||||
|
|
||||||
|
def _create_quest_definition(
|
||||||
|
hass: HomeAssistant, find_runtime: FindRuntime
|
||||||
|
) -> ServiceHandler:
|
||||||
|
async def _handler(call: Any) -> None:
|
||||||
|
database = _require_database(hass, find_runtime)
|
||||||
|
data = call.data
|
||||||
|
await quest_definitions_layer.create_quest_definition(
|
||||||
|
database,
|
||||||
|
data["title"],
|
||||||
|
ScheduleRule.from_dict(data["rule"]),
|
||||||
|
data["assignee_child_ids"],
|
||||||
|
data["windows"],
|
||||||
|
description=data.get("description"),
|
||||||
|
icon=data.get("icon"),
|
||||||
|
)
|
||||||
|
|
||||||
|
return _with_errors(_handler)
|
||||||
|
|
||||||
|
|
||||||
|
def _update_quest_definition(
|
||||||
|
hass: HomeAssistant, find_runtime: FindRuntime
|
||||||
|
) -> ServiceHandler:
|
||||||
|
async def _handler(call: Any) -> None:
|
||||||
|
database = _require_database(hass, find_runtime)
|
||||||
|
data = call.data
|
||||||
|
kwargs: dict[str, Any] = {}
|
||||||
|
if "title" in data:
|
||||||
|
kwargs["title"] = data["title"]
|
||||||
|
if "description" in data:
|
||||||
|
kwargs["description"] = data["description"]
|
||||||
|
if "icon" in data:
|
||||||
|
kwargs["icon"] = data["icon"]
|
||||||
|
if "rule" in data:
|
||||||
|
kwargs["rule"] = ScheduleRule.from_dict(data["rule"])
|
||||||
|
if "windows" in data:
|
||||||
|
kwargs["windows"] = data["windows"]
|
||||||
|
await quest_definitions_layer.edit_quest_definition(
|
||||||
|
database, data["definition_id"], **kwargs
|
||||||
|
)
|
||||||
|
|
||||||
|
return _with_errors(_handler)
|
||||||
|
|
||||||
|
|
||||||
|
def _set_quest_definition_active(
|
||||||
|
hass: HomeAssistant, find_runtime: FindRuntime
|
||||||
|
) -> ServiceHandler:
|
||||||
|
async def _handler(call: Any) -> None:
|
||||||
|
database = _require_database(hass, find_runtime)
|
||||||
|
await quest_definitions_layer.set_quest_definition_active(
|
||||||
|
database,
|
||||||
|
call.data["definition_id"],
|
||||||
|
call.data["is_active"],
|
||||||
|
)
|
||||||
|
|
||||||
|
return _with_errors(_handler)
|
||||||
|
|
||||||
|
|
||||||
|
def _set_presence_pattern(
|
||||||
|
hass: HomeAssistant, find_runtime: FindRuntime
|
||||||
|
) -> ServiceHandler:
|
||||||
|
async def _handler(call: Any) -> None:
|
||||||
|
database = _require_database(hass, find_runtime)
|
||||||
|
data = call.data
|
||||||
|
schedule = PresenceSchedule(
|
||||||
|
data["child_id"],
|
||||||
|
data["cycle_length_weeks"],
|
||||||
|
data["anchor_date"],
|
||||||
|
data["pattern"],
|
||||||
|
)
|
||||||
|
await PresenceSchedulesDao(database).upsert_by_child(
|
||||||
|
schedule.child_id,
|
||||||
|
schedule.cycle_length_weeks,
|
||||||
|
schedule.anchor_date.isoformat(),
|
||||||
|
schedule.encode(),
|
||||||
|
)
|
||||||
|
|
||||||
|
return _with_errors(_handler)
|
||||||
|
|
||||||
|
|
||||||
|
def _create_presence_override(
|
||||||
|
hass: HomeAssistant, find_runtime: FindRuntime
|
||||||
|
) -> ServiceHandler:
|
||||||
|
async def _handler(call: Any) -> None:
|
||||||
|
database = _require_database(hass, find_runtime)
|
||||||
|
data = call.data
|
||||||
|
await PresenceOverridesDao(database).create(
|
||||||
|
data["child_id"],
|
||||||
|
data["start_date"],
|
||||||
|
data["end_date"],
|
||||||
|
data["is_present"],
|
||||||
|
note=data.get("note"),
|
||||||
|
)
|
||||||
|
|
||||||
|
return _with_errors(_handler)
|
||||||
|
|
||||||
|
|
||||||
|
def _delete_presence_override(
|
||||||
|
hass: HomeAssistant, find_runtime: FindRuntime
|
||||||
|
) -> ServiceHandler:
|
||||||
|
async def _handler(call: Any) -> None:
|
||||||
|
database = _require_database(hass, find_runtime)
|
||||||
|
await PresenceOverridesDao(database).delete(call.data["override_id"])
|
||||||
|
|
||||||
|
return _with_errors(_handler)
|
||||||
|
|
||||||
|
|
||||||
|
async def _export_history_csv(_call: Any) -> None:
|
||||||
|
raise HomeAssistantError(
|
||||||
|
"CSV history export is not implemented until Feature 13"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _manage_child(
|
||||||
|
hass: HomeAssistant, find_runtime: FindRuntime
|
||||||
|
) -> ServiceHandler:
|
||||||
|
async def _handler(call: Any) -> None:
|
||||||
|
database = _require_database(hass, find_runtime)
|
||||||
|
data = call.data
|
||||||
|
action = data["action"]
|
||||||
|
if action == MANAGE_CHILD_CREATE:
|
||||||
|
display_name = data.get("display_name")
|
||||||
|
if display_name is None:
|
||||||
|
raise HomeAssistantError(
|
||||||
|
"display_name is required when action is create"
|
||||||
|
)
|
||||||
|
kwargs: dict[str, Any] = {}
|
||||||
|
if "colour" in data:
|
||||||
|
kwargs["colour"] = data["colour"]
|
||||||
|
if "avatar_ref" in data:
|
||||||
|
kwargs["avatar_ref"] = data["avatar_ref"]
|
||||||
|
if "sort_order" in data:
|
||||||
|
kwargs["sort_order"] = data["sort_order"]
|
||||||
|
await children_layer.create_child(
|
||||||
|
database, display_name, **kwargs
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if action == MANAGE_CHILD_EDIT:
|
||||||
|
child_id = data.get("child_id")
|
||||||
|
if child_id is None:
|
||||||
|
raise HomeAssistantError(
|
||||||
|
"child_id is required when action is edit"
|
||||||
|
)
|
||||||
|
kwargs = {}
|
||||||
|
if "display_name" in data:
|
||||||
|
kwargs["display_name"] = data["display_name"]
|
||||||
|
if "colour" in data:
|
||||||
|
kwargs["colour"] = data["colour"]
|
||||||
|
if "avatar_ref" in data:
|
||||||
|
kwargs["avatar_ref"] = data["avatar_ref"]
|
||||||
|
if "sort_order" in data:
|
||||||
|
kwargs["sort_order"] = data["sort_order"]
|
||||||
|
await children_layer.edit_child(database, child_id, **kwargs)
|
||||||
|
return
|
||||||
|
if action == MANAGE_CHILD_SET_ACTIVE:
|
||||||
|
child_id = data.get("child_id")
|
||||||
|
is_active = data.get("is_active")
|
||||||
|
if child_id is None:
|
||||||
|
raise HomeAssistantError(
|
||||||
|
"child_id is required when action is set_active"
|
||||||
|
)
|
||||||
|
if is_active is None:
|
||||||
|
raise HomeAssistantError(
|
||||||
|
"is_active is required when action is set_active"
|
||||||
|
)
|
||||||
|
await children_layer.set_child_active(
|
||||||
|
database, child_id, is_active
|
||||||
|
)
|
||||||
|
return
|
||||||
|
ordered_ids = data.get("ordered_ids")
|
||||||
|
if ordered_ids is None:
|
||||||
|
raise HomeAssistantError(
|
||||||
|
"ordered_ids is required when action is reorder"
|
||||||
|
)
|
||||||
|
await children_layer.reorder_children(database, ordered_ids)
|
||||||
|
|
||||||
|
return _with_errors(_handler)
|
||||||
|
|
||||||
|
|
||||||
|
def async_register_services(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
*,
|
||||||
|
find_runtime: FindRuntime,
|
||||||
|
regenerate_handler: ServiceHandler,
|
||||||
|
) -> None:
|
||||||
|
"""Register every canonical service once at domain scope."""
|
||||||
|
if hass.services.has_service(DOMAIN, SERVICE_REGENERATE):
|
||||||
|
return
|
||||||
|
hass.services.async_register(
|
||||||
|
DOMAIN, SERVICE_REGENERATE, regenerate_handler
|
||||||
|
)
|
||||||
|
hass.services.async_register(
|
||||||
|
DOMAIN,
|
||||||
|
SERVICE_COMPLETE_QUEST,
|
||||||
|
_complete_quest(hass, find_runtime),
|
||||||
|
schema=SCHEMA_COMPLETE_QUEST,
|
||||||
|
)
|
||||||
|
hass.services.async_register(
|
||||||
|
DOMAIN,
|
||||||
|
SERVICE_UNCOMPLETE_QUEST,
|
||||||
|
_uncomplete_quest(hass, find_runtime),
|
||||||
|
schema=SCHEMA_UNCOMPLETE_QUEST,
|
||||||
|
)
|
||||||
|
hass.services.async_register(
|
||||||
|
DOMAIN,
|
||||||
|
SERVICE_CREATE_QUEST_DEFINITION,
|
||||||
|
_create_quest_definition(hass, find_runtime),
|
||||||
|
schema=SCHEMA_CREATE_QUEST_DEFINITION,
|
||||||
|
)
|
||||||
|
hass.services.async_register(
|
||||||
|
DOMAIN,
|
||||||
|
SERVICE_UPDATE_QUEST_DEFINITION,
|
||||||
|
_update_quest_definition(hass, find_runtime),
|
||||||
|
schema=SCHEMA_UPDATE_QUEST_DEFINITION,
|
||||||
|
)
|
||||||
|
hass.services.async_register(
|
||||||
|
DOMAIN,
|
||||||
|
SERVICE_SET_QUEST_DEFINITION_ACTIVE,
|
||||||
|
_set_quest_definition_active(hass, find_runtime),
|
||||||
|
schema=SCHEMA_SET_QUEST_DEFINITION_ACTIVE,
|
||||||
|
)
|
||||||
|
hass.services.async_register(
|
||||||
|
DOMAIN,
|
||||||
|
SERVICE_SET_PRESENCE_PATTERN,
|
||||||
|
_set_presence_pattern(hass, find_runtime),
|
||||||
|
schema=SCHEMA_SET_PRESENCE_PATTERN,
|
||||||
|
)
|
||||||
|
hass.services.async_register(
|
||||||
|
DOMAIN,
|
||||||
|
SERVICE_CREATE_PRESENCE_OVERRIDE,
|
||||||
|
_create_presence_override(hass, find_runtime),
|
||||||
|
schema=SCHEMA_CREATE_PRESENCE_OVERRIDE,
|
||||||
|
)
|
||||||
|
hass.services.async_register(
|
||||||
|
DOMAIN,
|
||||||
|
SERVICE_DELETE_PRESENCE_OVERRIDE,
|
||||||
|
_delete_presence_override(hass, find_runtime),
|
||||||
|
schema=SCHEMA_DELETE_PRESENCE_OVERRIDE,
|
||||||
|
)
|
||||||
|
hass.services.async_register(
|
||||||
|
DOMAIN,
|
||||||
|
SERVICE_EXPORT_HISTORY_CSV,
|
||||||
|
_export_history_csv,
|
||||||
|
schema=SCHEMA_EXPORT_HISTORY_CSV,
|
||||||
|
)
|
||||||
|
hass.services.async_register(
|
||||||
|
DOMAIN,
|
||||||
|
SERVICE_MANAGE_CHILD,
|
||||||
|
_manage_child(hass, find_runtime),
|
||||||
|
schema=SCHEMA_MANAGE_CHILD,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def async_deregister_services(hass: HomeAssistant) -> None:
|
||||||
|
"""Remove every canonical service when the last entry unloads."""
|
||||||
|
for service in DOMAIN_SERVICES:
|
||||||
|
if hass.services.has_service(DOMAIN, service):
|
||||||
|
hass.services.async_remove(DOMAIN, service)
|
||||||
@@ -9,4 +9,318 @@ regenerate:
|
|||||||
horizon, using the same idempotent materialization as the daily rollover
|
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
|
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
|
same rows instead of duplicating them, and completed instances are never
|
||||||
touched.
|
touched.
|
||||||
|
|
||||||
|
complete_quest:
|
||||||
|
name: Complete quest
|
||||||
|
description: Mark a quest instance as completed.
|
||||||
|
fields:
|
||||||
|
instance_id:
|
||||||
|
name: Instance ID
|
||||||
|
description: The quest instance to complete.
|
||||||
|
required: true
|
||||||
|
selector:
|
||||||
|
number:
|
||||||
|
mode: box
|
||||||
|
actor:
|
||||||
|
name: Actor
|
||||||
|
description: Who completed the quest (panel or user).
|
||||||
|
required: true
|
||||||
|
selector:
|
||||||
|
select:
|
||||||
|
options:
|
||||||
|
- panel
|
||||||
|
- user
|
||||||
|
actor_child_id:
|
||||||
|
name: Actor child ID
|
||||||
|
description: The tapped child profile. Required when actor is panel.
|
||||||
|
required: false
|
||||||
|
selector:
|
||||||
|
number:
|
||||||
|
mode: box
|
||||||
|
actor_user_id:
|
||||||
|
name: Actor user ID
|
||||||
|
description: Home Assistant user id when actor is user and no call context is present.
|
||||||
|
required: false
|
||||||
|
selector:
|
||||||
|
text:
|
||||||
|
|
||||||
|
uncomplete_quest:
|
||||||
|
name: Uncomplete quest
|
||||||
|
description: Undo a quest instance completion.
|
||||||
|
fields:
|
||||||
|
instance_id:
|
||||||
|
name: Instance ID
|
||||||
|
description: The quest instance to uncomplete.
|
||||||
|
required: true
|
||||||
|
selector:
|
||||||
|
number:
|
||||||
|
mode: box
|
||||||
|
actor:
|
||||||
|
name: Actor
|
||||||
|
description: Who is undoing the completion (panel or user).
|
||||||
|
required: true
|
||||||
|
selector:
|
||||||
|
select:
|
||||||
|
options:
|
||||||
|
- panel
|
||||||
|
- user
|
||||||
|
actor_child_id:
|
||||||
|
name: Actor child ID
|
||||||
|
description: The tapped child profile. Required when actor is panel.
|
||||||
|
required: false
|
||||||
|
selector:
|
||||||
|
number:
|
||||||
|
mode: box
|
||||||
|
actor_user_id:
|
||||||
|
name: Actor user ID
|
||||||
|
description: Home Assistant user id when actor is user and no call context is present.
|
||||||
|
required: false
|
||||||
|
selector:
|
||||||
|
text:
|
||||||
|
|
||||||
|
create_quest_definition:
|
||||||
|
name: Create quest definition
|
||||||
|
description: Create a quest definition with its schedule rule, assignees and windows.
|
||||||
|
fields:
|
||||||
|
title:
|
||||||
|
name: Title
|
||||||
|
description: Display title for the quest.
|
||||||
|
required: true
|
||||||
|
selector:
|
||||||
|
text:
|
||||||
|
rule:
|
||||||
|
name: Rule
|
||||||
|
description: Recurrence rule as a mapping (rule_type plus shape fields).
|
||||||
|
required: true
|
||||||
|
selector:
|
||||||
|
object:
|
||||||
|
assignee_child_ids:
|
||||||
|
name: Assignee child IDs
|
||||||
|
description: Child ids this definition is assigned to.
|
||||||
|
required: true
|
||||||
|
selector:
|
||||||
|
object:
|
||||||
|
windows:
|
||||||
|
name: Windows
|
||||||
|
description: Day windows (morning, afternoon, evening), optionally with a due time.
|
||||||
|
required: true
|
||||||
|
selector:
|
||||||
|
object:
|
||||||
|
description:
|
||||||
|
name: Description
|
||||||
|
description: Optional longer description.
|
||||||
|
required: false
|
||||||
|
selector:
|
||||||
|
text:
|
||||||
|
icon:
|
||||||
|
name: Icon
|
||||||
|
description: Optional icon name.
|
||||||
|
required: false
|
||||||
|
selector:
|
||||||
|
text:
|
||||||
|
|
||||||
|
update_quest_definition:
|
||||||
|
name: Update quest definition
|
||||||
|
description: Edit a quest definition's title, description, icon, rule or windows.
|
||||||
|
fields:
|
||||||
|
definition_id:
|
||||||
|
name: Definition ID
|
||||||
|
description: The quest definition to update.
|
||||||
|
required: true
|
||||||
|
selector:
|
||||||
|
number:
|
||||||
|
mode: box
|
||||||
|
title:
|
||||||
|
name: Title
|
||||||
|
description: Replacement display title.
|
||||||
|
required: false
|
||||||
|
selector:
|
||||||
|
text:
|
||||||
|
description:
|
||||||
|
name: Description
|
||||||
|
description: Replacement description, or null to clear.
|
||||||
|
required: false
|
||||||
|
selector:
|
||||||
|
text:
|
||||||
|
icon:
|
||||||
|
name: Icon
|
||||||
|
description: Replacement icon, or null to clear.
|
||||||
|
required: false
|
||||||
|
selector:
|
||||||
|
text:
|
||||||
|
rule:
|
||||||
|
name: Rule
|
||||||
|
description: Replacement recurrence rule as a mapping.
|
||||||
|
required: false
|
||||||
|
selector:
|
||||||
|
object:
|
||||||
|
windows:
|
||||||
|
name: Windows
|
||||||
|
description: Replacement day windows.
|
||||||
|
required: false
|
||||||
|
selector:
|
||||||
|
object:
|
||||||
|
|
||||||
|
set_quest_definition_active:
|
||||||
|
name: Set quest definition active
|
||||||
|
description: Deactivate or reactivate a quest definition.
|
||||||
|
fields:
|
||||||
|
definition_id:
|
||||||
|
name: Definition ID
|
||||||
|
description: The quest definition to update.
|
||||||
|
required: true
|
||||||
|
selector:
|
||||||
|
number:
|
||||||
|
mode: box
|
||||||
|
is_active:
|
||||||
|
name: Active
|
||||||
|
description: Whether the definition should generate future instances.
|
||||||
|
required: true
|
||||||
|
selector:
|
||||||
|
boolean:
|
||||||
|
|
||||||
|
set_presence_pattern:
|
||||||
|
name: Set presence pattern
|
||||||
|
description: Upsert a child's repeating presence schedule.
|
||||||
|
fields:
|
||||||
|
child_id:
|
||||||
|
name: Child ID
|
||||||
|
description: The child whose schedule is being set.
|
||||||
|
required: true
|
||||||
|
selector:
|
||||||
|
number:
|
||||||
|
mode: box
|
||||||
|
cycle_length_weeks:
|
||||||
|
name: Cycle length weeks
|
||||||
|
description: Number of weeks in the repeating cycle (1 to 4).
|
||||||
|
required: true
|
||||||
|
selector:
|
||||||
|
number:
|
||||||
|
mode: box
|
||||||
|
min: 1
|
||||||
|
max: 4
|
||||||
|
anchor_date:
|
||||||
|
name: Anchor date
|
||||||
|
description: Cycle anchor as a YYYY-MM-DD date.
|
||||||
|
required: true
|
||||||
|
selector:
|
||||||
|
date:
|
||||||
|
pattern:
|
||||||
|
name: Pattern
|
||||||
|
description: Mapping of week index to present weekday ints (Monday is 0).
|
||||||
|
required: true
|
||||||
|
selector:
|
||||||
|
object:
|
||||||
|
|
||||||
|
create_presence_override:
|
||||||
|
name: Create presence override
|
||||||
|
description: Create a date-range presence override for a child.
|
||||||
|
fields:
|
||||||
|
child_id:
|
||||||
|
name: Child ID
|
||||||
|
description: The child the override applies to.
|
||||||
|
required: true
|
||||||
|
selector:
|
||||||
|
number:
|
||||||
|
mode: box
|
||||||
|
start_date:
|
||||||
|
name: Start date
|
||||||
|
description: Inclusive start date (YYYY-MM-DD).
|
||||||
|
required: true
|
||||||
|
selector:
|
||||||
|
date:
|
||||||
|
end_date:
|
||||||
|
name: End date
|
||||||
|
description: Inclusive end date (YYYY-MM-DD).
|
||||||
|
required: true
|
||||||
|
selector:
|
||||||
|
date:
|
||||||
|
is_present:
|
||||||
|
name: Present
|
||||||
|
description: Whether the child is present for this range.
|
||||||
|
required: true
|
||||||
|
selector:
|
||||||
|
boolean:
|
||||||
|
note:
|
||||||
|
name: Note
|
||||||
|
description: Optional note stored with the override.
|
||||||
|
required: false
|
||||||
|
selector:
|
||||||
|
text:
|
||||||
|
|
||||||
|
delete_presence_override:
|
||||||
|
name: Delete presence override
|
||||||
|
description: Delete a presence override by id.
|
||||||
|
fields:
|
||||||
|
override_id:
|
||||||
|
name: Override ID
|
||||||
|
description: The override to delete.
|
||||||
|
required: true
|
||||||
|
selector:
|
||||||
|
number:
|
||||||
|
mode: box
|
||||||
|
|
||||||
|
export_history_csv:
|
||||||
|
name: Export history CSV
|
||||||
|
description: Export completion history as CSV. Not implemented until Feature 13.
|
||||||
|
|
||||||
|
manage_child:
|
||||||
|
name: Manage child
|
||||||
|
description: Create, edit, activate or reorder child profiles.
|
||||||
|
fields:
|
||||||
|
action:
|
||||||
|
name: Action
|
||||||
|
description: create, edit, set_active, or reorder.
|
||||||
|
required: true
|
||||||
|
selector:
|
||||||
|
select:
|
||||||
|
options:
|
||||||
|
- create
|
||||||
|
- edit
|
||||||
|
- set_active
|
||||||
|
- reorder
|
||||||
|
child_id:
|
||||||
|
name: Child ID
|
||||||
|
description: Required for edit and set_active.
|
||||||
|
required: false
|
||||||
|
selector:
|
||||||
|
number:
|
||||||
|
mode: box
|
||||||
|
display_name:
|
||||||
|
name: Display name
|
||||||
|
description: Required for create; optional for edit.
|
||||||
|
required: false
|
||||||
|
selector:
|
||||||
|
text:
|
||||||
|
colour:
|
||||||
|
name: Colour
|
||||||
|
description: Optional colour string.
|
||||||
|
required: false
|
||||||
|
selector:
|
||||||
|
text:
|
||||||
|
avatar_ref:
|
||||||
|
name: Avatar
|
||||||
|
description: Optional avatar reference.
|
||||||
|
required: false
|
||||||
|
selector:
|
||||||
|
text:
|
||||||
|
sort_order:
|
||||||
|
name: Sort order
|
||||||
|
description: Optional panel sort order.
|
||||||
|
required: false
|
||||||
|
selector:
|
||||||
|
number:
|
||||||
|
mode: box
|
||||||
|
is_active:
|
||||||
|
name: Active
|
||||||
|
description: Required for set_active.
|
||||||
|
required: false
|
||||||
|
selector:
|
||||||
|
boolean:
|
||||||
|
ordered_ids:
|
||||||
|
name: Ordered IDs
|
||||||
|
description: Complete permutation of child ids. Required for reorder.
|
||||||
|
required: false
|
||||||
|
selector:
|
||||||
|
object:
|
||||||
|
|||||||
+26
-6
@@ -177,7 +177,11 @@ _data_entry_flow_mock.RESULT_TYPE_ABORT = "abort"
|
|||||||
_data_entry_flow_mock.FlowResult = dict
|
_data_entry_flow_mock.FlowResult = dict
|
||||||
|
|
||||||
|
|
||||||
class ConfigEntryNotReady(Exception):
|
class HomeAssistantError(Exception):
|
||||||
|
"""Stand-in mirroring homeassistant.exceptions.HomeAssistantError."""
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigEntryNotReady(HomeAssistantError):
|
||||||
"""Stand-in mirroring homeassistant.exceptions.ConfigEntryNotReady.
|
"""Stand-in mirroring homeassistant.exceptions.ConfigEntryNotReady.
|
||||||
|
|
||||||
HA retries setup when this is raised; the tests assert the corrupt
|
HA retries setup when this is raised; the tests assert the corrupt
|
||||||
@@ -185,6 +189,7 @@ class ConfigEntryNotReady(Exception):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
_exceptions_mock.HomeAssistantError = HomeAssistantError
|
||||||
_exceptions_mock.ConfigEntryNotReady = ConfigEntryNotReady
|
_exceptions_mock.ConfigEntryNotReady = ConfigEntryNotReady
|
||||||
|
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
@@ -325,7 +330,7 @@ class ServiceRegistry:
|
|||||||
self._services: dict[tuple[str, str], object] = {}
|
self._services: dict[tuple[str, str], object] = {}
|
||||||
|
|
||||||
def async_register(self, domain, service, func, schema=None, supports_response=None):
|
def async_register(self, domain, service, func, schema=None, supports_response=None):
|
||||||
self._services[(domain, service)] = func
|
self._services[(domain, service)] = (func, schema)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def has_service(self, domain, service):
|
def has_service(self, domain, service):
|
||||||
@@ -335,11 +340,26 @@ class ServiceRegistry:
|
|||||||
self._services.pop((domain, service), None)
|
self._services.pop((domain, service), None)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def call(self, domain, service, data=None):
|
async def call(self, domain, service, data=None, context=None):
|
||||||
func = self._services.get((domain, service))
|
registered = self._services.get((domain, service))
|
||||||
if func is None:
|
if registered is None:
|
||||||
raise KeyError(f"service {domain}.{service} not registered")
|
raise KeyError(f"service {domain}.{service} not registered")
|
||||||
call = SimpleNamespace(domain=domain, service=service, data=data or {})
|
func, schema = registered
|
||||||
|
payload = dict(data or {})
|
||||||
|
if schema is not None:
|
||||||
|
payload = schema(payload)
|
||||||
|
if context is None:
|
||||||
|
context_obj = SimpleNamespace(user_id=None)
|
||||||
|
elif isinstance(context, dict):
|
||||||
|
context_obj = SimpleNamespace(**context)
|
||||||
|
else:
|
||||||
|
context_obj = context
|
||||||
|
call = SimpleNamespace(
|
||||||
|
domain=domain,
|
||||||
|
service=service,
|
||||||
|
data=payload,
|
||||||
|
context=context_obj,
|
||||||
|
)
|
||||||
result = func(call)
|
result = func(call)
|
||||||
if inspect.isawaitable(result):
|
if inspect.isawaitable(result):
|
||||||
await result
|
await result
|
||||||
|
|||||||
+241
-8
@@ -1,9 +1,28 @@
|
|||||||
"""Tests for the NestQuest services.yaml declaration file."""
|
"""Tests for NestQuest service declarations and domain-global handlers."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import voluptuous as vol
|
||||||
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
|
|
||||||
|
from conftest import wire_entry_to_registry
|
||||||
|
|
||||||
|
from custom_components.nestquest import async_setup_entry, async_unload_entry
|
||||||
|
from custom_components.nestquest.const import (
|
||||||
|
DOMAIN,
|
||||||
|
DOMAIN_SERVICES,
|
||||||
|
SERVICE_COMPLETE_QUEST,
|
||||||
|
SERVICE_CREATE_QUEST_DEFINITION,
|
||||||
|
SERVICE_EXPORT_HISTORY_CSV,
|
||||||
|
SERVICE_MANAGE_CHILD,
|
||||||
|
SERVICE_REGENERATE,
|
||||||
|
SERVICE_SET_QUEST_DEFINITION_ACTIVE,
|
||||||
|
SERVICE_UNCOMPLETE_QUEST,
|
||||||
|
)
|
||||||
|
|
||||||
SERVICES_PATH = (
|
SERVICES_PATH = (
|
||||||
Path(__file__).parent.parent
|
Path(__file__).parent.parent
|
||||||
/ "custom_components"
|
/ "custom_components"
|
||||||
@@ -12,10 +31,37 @@ SERVICES_PATH = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _wire(entry, registry):
|
||||||
|
return wire_entry_to_registry(entry, registry)
|
||||||
|
|
||||||
|
|
||||||
|
def _service_section(text: str, name: str) -> str:
|
||||||
|
match = re.search(rf"^{name}:\s*$", text, re.MULTILINE)
|
||||||
|
assert match is not None, f"services.yaml must declare {name!r}"
|
||||||
|
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()]
|
||||||
|
return section
|
||||||
|
|
||||||
|
|
||||||
def test_services_yaml_exists() -> None:
|
def test_services_yaml_exists() -> None:
|
||||||
assert SERVICES_PATH.exists(), f"services.yaml missing at {SERVICES_PATH}"
|
assert SERVICES_PATH.exists(), f"services.yaml missing at {SERVICES_PATH}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_services_yaml_declares_every_canonical_service() -> None:
|
||||||
|
text = SERVICES_PATH.read_text(encoding="utf-8")
|
||||||
|
declared = re.findall(r"^(?![#\s])(\w+):\s*$", text, re.MULTILINE)
|
||||||
|
assert declared == list(DOMAIN_SERVICES) or set(declared) == set(
|
||||||
|
DOMAIN_SERVICES
|
||||||
|
)
|
||||||
|
for name in DOMAIN_SERVICES:
|
||||||
|
section = _service_section(text, name)
|
||||||
|
assert re.search(r"description:", section), (
|
||||||
|
f"{name} must carry a description"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_services_yaml_declares_regenerate() -> None:
|
def test_services_yaml_declares_regenerate() -> None:
|
||||||
text = SERVICES_PATH.read_text(encoding="utf-8")
|
text = SERVICES_PATH.read_text(encoding="utf-8")
|
||||||
assert re.search(r"^regenerate:\s*$", text, re.MULTILINE), (
|
assert re.search(r"^regenerate:\s*$", text, re.MULTILINE), (
|
||||||
@@ -27,15 +73,202 @@ def test_services_yaml_regenerate_has_description_and_no_fields() -> None:
|
|||||||
"""The regenerate service is documented with a clear description and no
|
"""The regenerate service is documented with a clear description and no
|
||||||
required fields (there is no ``fields`` block under it)."""
|
required fields (there is no ``fields`` block under it)."""
|
||||||
text = SERVICES_PATH.read_text(encoding="utf-8")
|
text = SERVICES_PATH.read_text(encoding="utf-8")
|
||||||
match = re.search(r"^regenerate:\s*$", text, re.MULTILINE)
|
section = _service_section(text, "regenerate")
|
||||||
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), (
|
assert re.search(r"description:", section), (
|
||||||
"regenerate must carry a description"
|
"regenerate must carry a description"
|
||||||
)
|
)
|
||||||
assert "fields:" not in section, (
|
assert "fields:" not in section, (
|
||||||
"regenerate declares no required fields"
|
"regenerate declares no required fields"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_services_yaml_other_services_declare_fields() -> None:
|
||||||
|
text = SERVICES_PATH.read_text(encoding="utf-8")
|
||||||
|
for name in DOMAIN_SERVICES:
|
||||||
|
if name in (SERVICE_REGENERATE, SERVICE_EXPORT_HISTORY_CSV):
|
||||||
|
continue
|
||||||
|
section = _service_section(text, name)
|
||||||
|
assert "fields:" in section, f"{name} must declare fields"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_all_canonical_services_registered_after_setup(
|
||||||
|
hass, make_entry
|
||||||
|
) -> None:
|
||||||
|
entry = _wire(make_entry(), hass.registry)
|
||||||
|
assert await async_setup_entry(hass, entry) is True
|
||||||
|
for service in DOMAIN_SERVICES:
|
||||||
|
assert hass.services.has_service(DOMAIN, service), service
|
||||||
|
|
||||||
|
|
||||||
|
async def test_unload_last_entry_removes_all_services(hass, make_entry) -> None:
|
||||||
|
entry = _wire(make_entry(), hass.registry)
|
||||||
|
assert await async_setup_entry(hass, entry) is True
|
||||||
|
assert await async_unload_entry(hass, entry) is True
|
||||||
|
for service in DOMAIN_SERVICES:
|
||||||
|
assert not hass.services.has_service(DOMAIN, service), service
|
||||||
|
|
||||||
|
|
||||||
|
async def test_unload_one_of_two_entries_keeps_services(
|
||||||
|
hass, make_entry
|
||||||
|
) -> None:
|
||||||
|
entry_a = _wire(make_entry(entry_id="entry_a"), hass.registry)
|
||||||
|
entry_b = _wire(make_entry(entry_id="entry_b"), hass.registry)
|
||||||
|
assert await async_setup_entry(hass, entry_a) is True
|
||||||
|
assert await async_setup_entry(hass, entry_b) is True
|
||||||
|
assert await async_unload_entry(hass, entry_a) is True
|
||||||
|
for service in DOMAIN_SERVICES:
|
||||||
|
assert hass.services.has_service(DOMAIN, service), service
|
||||||
|
assert await async_unload_entry(hass, entry_b) is True
|
||||||
|
for service in DOMAIN_SERVICES:
|
||||||
|
assert not hass.services.has_service(DOMAIN, service), service
|
||||||
|
|
||||||
|
|
||||||
|
async def test_manage_child_create_persists_child(hass, make_entry) -> None:
|
||||||
|
from custom_components.nestquest.children import list_children
|
||||||
|
|
||||||
|
entry = _wire(make_entry(), hass.registry)
|
||||||
|
assert await async_setup_entry(hass, entry) is True
|
||||||
|
await hass.services.call(
|
||||||
|
DOMAIN,
|
||||||
|
SERVICE_MANAGE_CHILD,
|
||||||
|
{"action": "create", "display_name": "Ada"},
|
||||||
|
)
|
||||||
|
children = await list_children(entry.runtime_data.database)
|
||||||
|
assert [child.display_name for child in children] == ["Ada"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_create_and_set_quest_definition_active(
|
||||||
|
hass, make_entry
|
||||||
|
) -> None:
|
||||||
|
from custom_components.nestquest.children import list_children
|
||||||
|
from custom_components.nestquest.quest_definitions import (
|
||||||
|
list_active_definitions,
|
||||||
|
)
|
||||||
|
from custom_components.nestquest.dao_rules import QuestDefinitionsDao
|
||||||
|
|
||||||
|
entry = _wire(make_entry(), hass.registry)
|
||||||
|
assert await async_setup_entry(hass, entry) is True
|
||||||
|
await hass.services.call(
|
||||||
|
DOMAIN,
|
||||||
|
SERVICE_MANAGE_CHILD,
|
||||||
|
{"action": "create", "display_name": "Ada"},
|
||||||
|
)
|
||||||
|
child = (await list_children(entry.runtime_data.database))[0]
|
||||||
|
await hass.services.call(
|
||||||
|
DOMAIN,
|
||||||
|
SERVICE_CREATE_QUEST_DEFINITION,
|
||||||
|
{
|
||||||
|
"title": "Brush teeth",
|
||||||
|
"rule": {"rule_type": "daily"},
|
||||||
|
"assignee_child_ids": [child.id],
|
||||||
|
"windows": ["morning"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
active = await list_active_definitions(entry.runtime_data.database)
|
||||||
|
assert len(active) == 1
|
||||||
|
definition_id = active[0].definition.id
|
||||||
|
await hass.services.call(
|
||||||
|
DOMAIN,
|
||||||
|
SERVICE_SET_QUEST_DEFINITION_ACTIVE,
|
||||||
|
{"definition_id": definition_id, "is_active": False},
|
||||||
|
)
|
||||||
|
assert await list_active_definitions(entry.runtime_data.database) == []
|
||||||
|
stored = await QuestDefinitionsDao(entry.runtime_data.database).get(
|
||||||
|
definition_id
|
||||||
|
)
|
||||||
|
assert stored is not None
|
||||||
|
assert stored.is_active is False
|
||||||
|
|
||||||
|
|
||||||
|
async def test_complete_and_uncomplete_quest_call_business_layer(
|
||||||
|
hass, make_entry
|
||||||
|
) -> None:
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
from custom_components.nestquest.children import list_children
|
||||||
|
from custom_components.nestquest.completion import instance_state
|
||||||
|
from custom_components.nestquest.const import DEFAULT_HORIZON_DAYS
|
||||||
|
from custom_components.nestquest.dao_instances import QuestInstancesDao
|
||||||
|
|
||||||
|
entry = _wire(make_entry(), hass.registry)
|
||||||
|
assert await async_setup_entry(hass, entry) is True
|
||||||
|
await hass.services.call(
|
||||||
|
DOMAIN,
|
||||||
|
SERVICE_MANAGE_CHILD,
|
||||||
|
{"action": "create", "display_name": "Ada"},
|
||||||
|
)
|
||||||
|
child = (await list_children(entry.runtime_data.database))[0]
|
||||||
|
await hass.services.call(
|
||||||
|
DOMAIN,
|
||||||
|
SERVICE_CREATE_QUEST_DEFINITION,
|
||||||
|
{
|
||||||
|
"title": "Brush teeth",
|
||||||
|
"rule": {"rule_type": "daily"},
|
||||||
|
"assignee_child_ids": [child.id],
|
||||||
|
"windows": ["morning"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await hass.services.call(DOMAIN, SERVICE_REGENERATE)
|
||||||
|
today = datetime.date.today().isoformat()
|
||||||
|
end = (
|
||||||
|
datetime.date.today() + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)
|
||||||
|
).isoformat()
|
||||||
|
records = await QuestInstancesDao(
|
||||||
|
entry.runtime_data.database
|
||||||
|
).list_by_date_range(child.id, today, end)
|
||||||
|
assert records
|
||||||
|
instance_id = records[0].id
|
||||||
|
await hass.services.call(
|
||||||
|
DOMAIN,
|
||||||
|
SERVICE_COMPLETE_QUEST,
|
||||||
|
{
|
||||||
|
"instance_id": instance_id,
|
||||||
|
"actor": "panel",
|
||||||
|
"actor_child_id": child.id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
await instance_state(entry.runtime_data.database, instance_id)
|
||||||
|
== "done"
|
||||||
|
)
|
||||||
|
await hass.services.call(
|
||||||
|
DOMAIN,
|
||||||
|
SERVICE_UNCOMPLETE_QUEST,
|
||||||
|
{
|
||||||
|
"instance_id": instance_id,
|
||||||
|
"actor": "user",
|
||||||
|
"actor_user_id": "parent-1",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
await instance_state(entry.runtime_data.database, instance_id)
|
||||||
|
== "open"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_export_history_csv_raises_home_assistant_error(
|
||||||
|
hass, make_entry
|
||||||
|
) -> None:
|
||||||
|
entry = _wire(make_entry(), hass.registry)
|
||||||
|
assert await async_setup_entry(hass, entry) is True
|
||||||
|
with pytest.raises(HomeAssistantError, match="Feature 13"):
|
||||||
|
await hass.services.call(DOMAIN, SERVICE_EXPORT_HISTORY_CSV)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_invalid_schema_data_surfaces_as_error(hass, make_entry) -> None:
|
||||||
|
entry = _wire(make_entry(), hass.registry)
|
||||||
|
assert await async_setup_entry(hass, entry) is True
|
||||||
|
with pytest.raises((vol.Invalid, ValueError, HomeAssistantError)):
|
||||||
|
await hass.services.call(DOMAIN, SERVICE_COMPLETE_QUEST, {})
|
||||||
|
with pytest.raises((vol.Invalid, ValueError, HomeAssistantError)):
|
||||||
|
await hass.services.call(
|
||||||
|
DOMAIN,
|
||||||
|
SERVICE_COMPLETE_QUEST,
|
||||||
|
{"instance_id": True, "actor": "panel", "actor_child_id": 1},
|
||||||
|
)
|
||||||
|
with pytest.raises((vol.Invalid, ValueError, HomeAssistantError)):
|
||||||
|
await hass.services.call(
|
||||||
|
DOMAIN,
|
||||||
|
SERVICE_MANAGE_CHILD,
|
||||||
|
{"action": "create"},
|
||||||
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user