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:
2026-09-18 15:59:02 -04:00
parent c4fb2fbac5
commit 2d30f0ce77
6 changed files with 1194 additions and 42 deletions
+29 -26
View File
@@ -24,11 +24,11 @@ from .const import (
DEFAULT_HORIZON_DAYS,
DOMAIN,
LOGGER,
SERVICE_REGENERATE,
)
from .db import NestQuestDatabase
from .materialize import materialize as _materialize_run
from .migrations import apply_migrations
from .services import async_deregister_services, async_register_services
from .store import async_get_db_path
from .admin_allowlist import seed_setup_admin
@@ -356,21 +356,8 @@ def _find_live_runtime_data(hass: HomeAssistant) -> NestQuestRuntimeData | None:
return None
def _register_regenerate_service(hass: HomeAssistant) -> None:
"""Register ``nestquest.regenerate`` once at domain scope.
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
def _make_regenerate_handler(hass: HomeAssistant):
"""Return the Feature 07 ``regenerate`` handler closed over ``hass``."""
async def _regenerate(_call: Any) -> None:
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.services.async_register(DOMAIN, SERVICE_REGENERATE, _regenerate)
return _regenerate
def _deregister_regenerate_service(hass: HomeAssistant) -> None:
"""Remove ``nestquest.regenerate`` when the last entry unloads."""
if hass.services.has_service(DOMAIN, SERVICE_REGENERATE):
hass.services.async_remove(DOMAIN, SERVICE_REGENERATE)
def _register_services(hass: HomeAssistant) -> None:
"""Register domain-global NestQuest services once.
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:
@@ -489,7 +492,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
await _run_horizon_materialization(
hass, database, _configured_horizon_days(dict(entry.options))
)
_register_regenerate_service(hass)
_register_services(hass)
except BaseException:
# A failure anywhere after the database is open must not leak the
# 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)
if database is not None:
await database.close()
# The ``regenerate`` service is DOMAIN-global: it is torn down only
# once the FINAL entry unloads (``hass.data[DOMAIN]`` is now empty),
# never when a sibling entry is still loaded.
# Domain-global services are torn down only once the FINAL entry
# unloads (``hass.data[DOMAIN]`` is now empty), never when a sibling
# entry is still loaded.
if is_last:
_deregister_regenerate_service(hass)
_deregister_services(hass)
if getattr(entry, "runtime_data", None) is not None:
entry.runtime_data = None
if unload_error is not None:
+26 -1
View File
@@ -9,9 +9,34 @@ LOGGER = logging.getLogger(LOGGER_NAME)
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"
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"
# Configuration keys and default values
+557
View File
@@ -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)
+315 -1
View File
@@ -9,4 +9,318 @@ regenerate:
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.
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
View File
@@ -177,7 +177,11 @@ _data_entry_flow_mock.RESULT_TYPE_ABORT = "abort"
_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.
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
import voluptuous as vol
@@ -325,7 +330,7 @@ class ServiceRegistry:
self._services: dict[tuple[str, str], object] = {}
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
def has_service(self, domain, service):
@@ -335,11 +340,26 @@ class ServiceRegistry:
self._services.pop((domain, service), None)
return None
async def call(self, domain, service, data=None):
func = self._services.get((domain, service))
if func is None:
async def call(self, domain, service, data=None, context=None):
registered = self._services.get((domain, service))
if registered is None:
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)
if inspect.isawaitable(result):
await result
+241 -8
View File
@@ -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
import re
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 = (
Path(__file__).parent.parent
/ "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:
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:
text = SERVICES_PATH.read_text(encoding="utf-8")
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
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()]
section = _service_section(text, "regenerate")
assert re.search(r"description:", section), (
"regenerate must carry a description"
)
assert "fields:" not in section, (
"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"},
)