diff --git a/.gitignore b/.gitignore index d64cc9f..ceb625d 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ docs/_build .vscode .*.swp .idea +*.egg-info \ No newline at end of file diff --git a/custom_components/pyscript/decorator_abc.py b/custom_components/pyscript/decorator_abc.py index c762dba..e31a78e 100644 --- a/custom_components/pyscript/decorator_abc.py +++ b/custom_components/pyscript/decorator_abc.py @@ -85,7 +85,8 @@ async def validate(self) -> None: # Keep this wording for transition compatibility. Once the legacy # subsystem is removed, update the message and related tests. if len(err.path) == 1: - if "extra keys not allowed" in err.msg: + # not a valid option new error message in 2026.09 + if "extra keys not allowed" in err.msg or "not a valid option" in err.msg: message = f"invalid keyword argument '{err.path[0]}'" else: message = f"keyword '{err.path[0]}' {err}" @@ -266,6 +267,7 @@ async def validate(self) -> None: trig_decorators_reqd = { "event_trigger", "mqtt_trigger", + "sentence_trigger", "state_trigger", "time_trigger", "webhook_trigger", diff --git a/custom_components/pyscript/decorators/__init__.py b/custom_components/pyscript/decorators/__init__.py index c5f0ad3..95adef1 100644 --- a/custom_components/pyscript/decorators/__init__.py +++ b/custom_components/pyscript/decorators/__init__.py @@ -2,6 +2,7 @@ from .event import EventTriggerDecorator from .mqtt import MQTTTriggerDecorator +from .sentence import SentenceTriggerDecorator from .service import ServiceDecorator from .state import StateActiveDecorator, StateTriggerDecorator from .task import TaskUniqueDecorator @@ -16,6 +17,7 @@ TaskUniqueDecorator, EventTriggerDecorator, MQTTTriggerDecorator, + SentenceTriggerDecorator, WebhookTriggerDecorator, WebhookHandlerDecorator, ServiceDecorator, diff --git a/custom_components/pyscript/decorators/sentence.py b/custom_components/pyscript/decorators/sentence.py new file mode 100644 index 0000000..e8856e1 --- /dev/null +++ b/custom_components/pyscript/decorators/sentence.py @@ -0,0 +1,168 @@ +"""Sentence trigger decorator.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Callable +import logging +from typing import TYPE_CHECKING, Any + +import voluptuous as vol + +from homeassistant.core import CALLBACK_TYPE +from homeassistant.helpers import config_validation as cv + +from ..decorator_abc import CallResultHandlerDecorator, DispatchData, TriggerDecorator +from .base import AutoKwargsDecorator + +if TYPE_CHECKING: + from homeassistant.components.conversation import ConversationInput, RecognizeResult + +_LOGGER = logging.getLogger(__name__) + +_SENTENCE_RESULT_FUTURE = "sentence_result_future" + + +Validator = Callable[[list[str]], list[str]] + + +def _import_validators() -> list[Validator] | None: + """ + Import the sentence validators supported by HA. + + Delay imports to avoid breaking installs that haven't added the conversation component yet. + """ + try: + from homeassistant.components.conversation import trigger as conversation_trigger + except ImportError: + return None + + validators = [ + conversation_trigger.has_one_non_empty_item, + conversation_trigger.has_no_punctuation, + ] + + # is_valid_setence only available after HA 2026.7 + if is_valid_sentence := getattr(conversation_trigger, "is_valid_sentence", None): + validators.append(is_valid_sentence) + + return validators + + +def _validate_sentences(sentences: list[str]) -> list[str]: + """Run HA's sentence validators when the conversation component is available.""" + if validators := _import_validators(): + return vol.All(*validators)(sentences) + return sentences + + +def _flatten_sentence_args(args: list[Any]) -> list[Any]: + """Flatten string and list positional arguments into one sentence list.""" + return [sentence for arg in args for sentence in cv.ensure_list(arg)] + + +class SentenceTriggerDecorator(TriggerDecorator, AutoKwargsDecorator, CallResultHandlerDecorator): + """ + Implementation for @sentence_trigger. + + Registers sentences with HA's conversation agent manager. The decorated + function's return value (if not None) becomes the spoken response. + """ + + name = "sentence_trigger" + args_schema = vol.Schema( + vol.All( + _flatten_sentence_args, + [cv.string], + vol.Length(min=1, msg="at least one sentence is required"), + _validate_sentences, + ) + ) + kwargs_schema = vol.Schema( + { + vol.Optional("timeout", default=10.0): vol.All(vol.Coerce(float), vol.Range(min=0)), + } + ) + + sentences: list[str] + timeout: float + _unregister: CALLBACK_TYPE | None + + async def validate(self): + """Validate the sentence trigger configuration.""" + await super().validate() + self.sentences = self.args + self._unregister = None + + async def _trigger_callback(self, user_input: ConversationInput, result: RecognizeResult) -> str | None: + """Handle a matched sentence from the conversation agent.""" + details = { + entity_name: { + "name": entity_name, + "text": entity.text.strip() if isinstance(entity.text, str) else entity.text, + "value": (entity.value.strip() if isinstance(entity.value, str) else entity.value), + } + for entity_name, entity in result.entities.items() + } + + func_args: dict[str, Any] = { + "trigger_type": "sentence", + "sentence": user_input.text, + "slots": {n: d["value"] for n, d in details.items()}, + "details": details, + "device_id": user_input.device_id, + "satellite_id": user_input.satellite_id, + } + + future = self.dm.hass.loop.create_future() + data = DispatchData(func_args, trigger_context={_SENTENCE_RESULT_FUTURE: future}) + await self.dispatch(data) + + try: + response = await asyncio.wait_for(future, timeout=self.timeout) + except TimeoutError: + _LOGGER.warning( + "sentence_trigger %s timed out after %ss", + self.dm.name, + self.timeout, + ) + return None + + return str(response) if response is not None else None + + async def handle_call_result(self, data: DispatchData, result: Any) -> None: + """Forward the function return value as the spoken response.""" + if data.trigger is not self: + return + self._resolve_future(data, result) + + @staticmethod + def _resolve_future(data: DispatchData, result: Any) -> None: + future = data.trigger_context.get(_SENTENCE_RESULT_FUTURE) + if future is not None and not future.done(): + future.set_result(result) + + async def start(self): + """Register sentences with the conversation agent manager.""" + await super().start() + try: + from homeassistant.components.conversation.agent_manager import get_agent_manager + + mgr = get_agent_manager(self.dm.hass) + self._unregister = mgr.register_trigger( + sentences=self.sentences, + trigger_callback=self._trigger_callback, + ) + except Exception as err: + _LOGGER.warning( + "sentence_trigger %s failed to register; conversations unavailable: %s", self.dm.name, err + ) + return + _LOGGER.debug("sentence_trigger %s registered sentences: %s", self.dm.name, self.sentences) + + async def stop(self): + """Unregister sentences from the conversation agent manager.""" + await super().stop() + if self._unregister: + self._unregister() + self._unregister = None diff --git a/custom_components/pyscript/eval.py b/custom_components/pyscript/eval.py index 8a9df38..ffd7fe3 100644 --- a/custom_components/pyscript/eval.py +++ b/custom_components/pyscript/eval.py @@ -73,6 +73,8 @@ TRIGGER_KWARGS = { "context", + "details", + "device_id", "event_type", "old_value", "payload", @@ -80,6 +82,9 @@ "qos", "request", "retain", + "satellite_id", + "sentence", + "slots", "topic", "trigger_type", "trigger_time", diff --git a/custom_components/pyscript/stubs/pyscript_builtins.py b/custom_components/pyscript/stubs/pyscript_builtins.py index ee77592..dc73fe1 100644 --- a/custom_components/pyscript/stubs/pyscript_builtins.py +++ b/custom_components/pyscript/stubs/pyscript_builtins.py @@ -187,6 +187,33 @@ def webhook_handler( ... +def sentence_trigger( + *sentences: str | list[str], + timeout: float = 10.0, + kwargs: dict | None = None, +) -> Callable[..., Any]: + """Trigger when a spoken sentence matches the given template(s). + + Uses Home Assistant's conversation agent to match sentences with hassil + wildcard syntax (e.g. ``{slot_name}``). The function's return value, if + not None, becomes the spoken response. + + Args: + sentences: One or more sentence templates or lists of templates using hassil ``{slot}`` wildcards. + timeout: Seconds to wait for the function to return before giving up on a spoken response. + kwargs: Extra keyword arguments merged into each invocation. + + Trigger kwargs: + - ``trigger_type`` = ``"sentence"`` + - ``sentence``: the raw spoken text + - ``slots``: ``dict[str, Any]`` mapping slot names to matched values + - ``details``: full hassil match info per slot (name, text, value) + - ``device_id``: HA device registry id of the voice hardware (or None) + - ``satellite_id``: entity_id of the assist_satellite entity (or None) + """ + ... + + def pyscript_compile() -> Callable[..., Any]: """Compile the wrapped function into native (synchronous) Python. diff --git a/docs/reference.rst b/docs/reference.rst index 21f0d64..50eba4c 100644 --- a/docs/reference.rst +++ b/docs/reference.rst @@ -977,6 +977,77 @@ For full control over the response, return an ``aiohttp.web.Response``: def webhook_redirect(): return Response(status=302, headers={"Location": "https://example.com/"}) +@sentence_trigger +^^^^^^^^^^^^^^^^^ + +.. code:: python + + @sentence_trigger(*sentences, timeout=10.0, kwargs=None) + +``@sentence_trigger`` registers one or more sentence templates with Home Assistant's conversation agent. When a spoken (or typed) sentence matches a template, the decorated function is called. If the function returns a non-``None`` value, it becomes the spoken response. + +Sentence templates use `hassil `__ wildcard syntax. Wrap slot names in curly braces to capture them: ``"turn on {name} in the {area}"``. The matched slot values are passed to the function via the ``slots`` and ``details`` kwargs. + +Arguments: + +``sentences`` + One or more sentence template strings or lists of templates. Each template is registered independently with the conversation agent. + +``timeout`` + Seconds to wait for the function to return before giving up on a spoken response. Defaults to ``10.0``. If the function does not finish in time, the spoken response will be the default "Done" (the function continues running in the background). + +``kwargs`` + Optional dict of additional keyword arguments merged into each call. + +When the trigger fires, these keyword arguments are passed to the function: + +.. code:: python + + kwargs = { + "trigger_type": "sentence", + "sentence": "turn on lights in the kitchen", + "slots": {"name": "lights", "area": "kitchen"}, + "details": { + "name": {"name": "name", "text": "lights", "value": "lights"}, + "area": {"name": "area", "text": "kitchen", "value": "kitchen"}, + }, + "device_id": "abc123...", # or None + "satellite_id": "assist_satellite.kitchen", # or None + } + +- ``sentence`` is the raw spoken text. +- ``slots`` is a flat ``dict[str, Any]`` of slot name to matched value for quick access. +- ``details`` contains the full hassil match information per slot (name, raw text, resolved value). +- ``device_id`` is the HA device registry ID of the physical voice hardware that captured the command. Use it for hardware-level identity. +- ``satellite_id`` is the ``entity_id`` of the ``assist_satellite`` entity representing the voice satellite. Use it to resolve area context (the satellite entity carries area assignment and pipeline config). + +The function's return value becomes the spoken response: + +- Return a ``str`` to have it spoken back. +- Return ``None`` (or omit a return statement) for default response of "Done". + +Examples: + +.. code:: python + + @sentence_trigger("turn on {name} in the {area}") + def voice_on(sentence, slots): + service.call("light", "turn_on", entity_id=f"light.{slots['name']}") + return f"Turned on {slots['name']} in the {slots['area']}" + + @sentence_trigger("what time is it", "tell me the time") + def voice_time(): + from datetime import datetime + return f"It is {datetime.now().strftime('%I:%M %p')}" + + @sentence_trigger("set {name} to {level}") + def voice_set(slots, device_id, satellite_id): + # Use satellite_id to determine which area the command came from + log.info(f"Command from satellite {satellite_id} (device {device_id})") + input_number.set_value(entity_id=f"input_number.{slots['name']}", value=int(slots['level'])) + +NOTE: The `Conversation Integration `__ must be set up for ``@sentence_trigger`` to work. + @state_active ^^^^^^^^^^^^^ diff --git a/tests/decorators/test_sentence.py b/tests/decorators/test_sentence.py new file mode 100644 index 0000000..5a1da61 --- /dev/null +++ b/tests/decorators/test_sentence.py @@ -0,0 +1,345 @@ +"""Test pyscript @sentence_trigger decorator.""" + +from dataclasses import dataclass, field +import logging +from typing import Any +from unittest.mock import patch + +import pytest + +from custom_components.pyscript.decorators import sentence as sentence_decorator +from homeassistant.components.conversation import trigger as conversation_trigger + + +@dataclass +class MockEntity: + """Minimal hassil entity match.""" + + text: str + value: str | Any + + +@dataclass +class MockRecognizeResult: + """Minimal hassil RecognizeResult stand-in.""" + + entities: dict[str, MockEntity] = field(default_factory=dict) + + +@dataclass +class MockConversationInput: + """Minimal ConversationInput stand-in.""" + + text: str = "" + device_id: str | None = None + satellite_id: str | None = None + + +class MockAgentManager: + """Stand-in for the conversation AgentManager that captures trigger registrations.""" + + def __init__(self) -> None: + """Initialize an empty trigger registry.""" + self.triggers: list[dict] = [] + self._counter = 0 + + def register_trigger(self, sentences: list[str], trigger_callback: Any) -> Any: + """Register a trigger and return its removal callback.""" + entry = {"sentences": sentences, "callback": trigger_callback, "id": self._counter} + self._counter += 1 + self.triggers.append(entry) + + def unregister(): + self.triggers.remove(entry) + + return unregister + + +@pytest.fixture +def agent_manager(): + """Provide a mock agent manager that intercepts register_trigger calls.""" + mgr = MockAgentManager() + with patch( + "homeassistant.components.conversation.agent_manager.get_agent_manager", + return_value=mgr, + ): + yield mgr + + +@pytest.mark.asyncio +async def test_sentence_trigger_basic(pyscript, agent_manager): + """A matched sentence fires the function with slots.""" + await pyscript.start(""" +@sentence_trigger("turn on {name}") +def voice_on(trigger_type, sentence, slots): + pyscript.done = [trigger_type, sentence, slots] +""") + + assert len(agent_manager.triggers) == 1 + assert agent_manager.triggers[0]["sentences"] == ["turn on {name}"] + + user_input = MockConversationInput(text="turn on lights", device_id="dev1", satellite_id="sat1") + result = MockRecognizeResult(entities={"name": MockEntity(text="lights", value="lights")}) + + response = await agent_manager.triggers[0]["callback"](user_input, result) + assert response is None # no return -> None spoken response + + await pyscript.wait_done(["sentence", "turn on lights", {"name": "lights"}]) + + +@pytest.mark.asyncio +async def test_sentence_trigger_return_response(pyscript, agent_manager): + """The function's return value becomes the spoken response.""" + await pyscript.start(""" +@sentence_trigger("what is {thing}") +def voice_what(slots): + return f"The {slots['thing']} is great" +""") + + user_input = MockConversationInput(text="what is weather") + result = MockRecognizeResult(entities={"thing": MockEntity(text="weather", value="weather")}) + + response = await agent_manager.triggers[0]["callback"](user_input, result) + assert response == "The weather is great" + + +@pytest.mark.asyncio +async def test_sentence_trigger_multiple_sentences(pyscript, agent_manager): + """A list of sentences registers all of them.""" + await pyscript.start(""" +@sentence_trigger(["what time is it", "tell me the time"]) +def voice_time(): + return "noon" +""") + + assert len(agent_manager.triggers) == 1 + assert agent_manager.triggers[0]["sentences"] == ["what time is it", "tell me the time"] + + user_input = MockConversationInput(text="what time is it") + result = MockRecognizeResult(entities={}) + + response = await agent_manager.triggers[0]["callback"](user_input, result) + assert response == "noon" + + +@pytest.mark.asyncio +async def test_sentence_trigger_positional_and_list_sentences(pyscript, agent_manager): + """Separate positional arguments and lists are flattened.""" + await pyscript.start(""" +@sentence_trigger("hello", ["hi", "hey"]) +def voice_greetings(): + pass +""") + + assert agent_manager.triggers[0]["sentences"] == ["hello", "hi", "hey"] + + +@pytest.mark.asyncio +async def test_sentence_trigger_apostrophe(pyscript, agent_manager): + """An apostrophe is valid sentence punctuation.""" + await pyscript.start(""" +@sentence_trigger("It's party time") +def voice_party(): + pass +""") + + assert agent_manager.triggers[0]["sentences"] == ["It's party time"] + + +@pytest.mark.parametrize( + ("sentence", "error"), + [ + ("hello?", "sentence should not contain punctuation"), + ("hello!", "sentence should not contain punctuation"), + ("4 a.m.", "sentence should not contain punctuation"), + ([], "at least one sentence is required"), + ("", "sentence too short"), + ], +) +@pytest.mark.asyncio +async def test_sentence_trigger_invalid_logs(pyscript, agent_manager, caplog, sentence, error): + """Invalid sentences are rejected and logged.""" + with caplog.at_level(logging.ERROR): + await pyscript.start(f""" +@sentence_trigger({sentence!r}) +def voice_invalid(): + pass +""") + await pyscript.wait_exception(TypeError, match=error) + + assert not agent_manager.triggers + assert error in caplog.text + + +@pytest.mark.asyncio +async def test_sentence_trigger_requires_sentence(pyscript, agent_manager): + """At least one positional sentence is required.""" + await pyscript.start(""" +@sentence_trigger() +def voice_empty(): + pass +""") + await pyscript.wait_exception(TypeError, match="at least one sentence is required") + + assert not agent_manager.triggers + + +@pytest.mark.asyncio +async def test_sentence_trigger_old_ha_validation(pyscript, agent_manager, monkeypatch): + """Older HA versions use the sentence validators they provide.""" + monkeypatch.delattr(conversation_trigger, "is_valid_sentence", raising=False) + + await pyscript.start(""" +@sentence_trigger("play something") +def voice_old_ha(): + pass +""") + + assert agent_manager.triggers[0]["sentences"] == ["play something"] + + +@pytest.mark.asyncio +async def test_sentence_trigger_without_conversation_validation(pyscript, agent_manager, monkeypatch): + """Missing conversation dependencies do not prevent pyscript from loading.""" + monkeypatch.setattr(sentence_decorator, "_import_validators", lambda: None) + + await pyscript.start(""" +@sentence_trigger("hello?") +def voice_without_conversation(): + pass +""") + + assert agent_manager.triggers[0]["sentences"] == ["hello?"] + + +@pytest.mark.asyncio +async def test_sentence_trigger_device_and_satellite(pyscript, agent_manager): + """device_id and satellite_id are passed through.""" + await pyscript.start(""" +@sentence_trigger("hello") +def voice_hello(device_id, satellite_id): + pyscript.done = [device_id, satellite_id] +""") + + user_input = MockConversationInput( + text="hello", device_id="dev_abc", satellite_id="assist_satellite.kitchen" + ) + result = MockRecognizeResult(entities={}) + + await agent_manager.triggers[0]["callback"](user_input, result) + await pyscript.wait_done(["dev_abc", "assist_satellite.kitchen"]) + + +@pytest.mark.asyncio +async def test_sentence_trigger_details(pyscript, agent_manager): + """The details dict contains full hassil match info.""" + await pyscript.start(""" +@sentence_trigger("set {name} to {level}") +def voice_set(details): + pyscript.done = details +""") + + user_input = MockConversationInput(text="set brightness to 50") + result = MockRecognizeResult( + entities={ + "name": MockEntity(text=" brightness ", value="brightness"), + "level": MockEntity(text=" 50 ", value=50), + } + ) + + await agent_manager.triggers[0]["callback"](user_input, result) + await pyscript.wait_done( + { + "name": {"name": "name", "text": "brightness", "value": "brightness"}, + "level": {"name": "level", "text": "50", "value": 50}, + } + ) + + +@pytest.mark.asyncio +async def test_sentence_trigger_none_return(pyscript, agent_manager): + """Explicit None return gives no spoken response.""" + await pyscript.start(""" +@sentence_trigger("do something") +def voice_do(): + pyscript.done = "ran" + return None +""") + + user_input = MockConversationInput(text="do something") + result = MockRecognizeResult(entities={}) + + response = await agent_manager.triggers[0]["callback"](user_input, result) + assert response is None + await pyscript.wait_done("ran") + + +@pytest.mark.parametrize("expected_lingering_tasks", [True]) +@pytest.mark.asyncio +async def test_sentence_trigger_timeout(pyscript, agent_manager): + """A function that doesn't finish in time returns None (no spoken response).""" + await pyscript.start(""" +@sentence_trigger("slow thing", timeout=0.05) +def voice_slow(): + task.sleep(0.2) + return "too late" +""") + + user_input = MockConversationInput(text="slow thing") + result = MockRecognizeResult(entities={}) + + response = await agent_manager.triggers[0]["callback"](user_input, result) + assert response is None + + +@pytest.mark.asyncio +async def test_sentence_trigger_exception_returns_none(pyscript, agent_manager): + """An exception in the function yields None spoken response.""" + await pyscript.start(""" +@sentence_trigger("crash") +def voice_crash(): + raise ValueError("boom") +""") + + user_input = MockConversationInput(text="crash") + result = MockRecognizeResult(entities={}) + + response = await agent_manager.triggers[0]["callback"](user_input, result) + assert response is None + await pyscript.wait_exception(ValueError, match="boom") + + +@pytest.mark.asyncio +async def test_sentence_trigger_unregisters_on_stop(pyscript, agent_manager): + """Reloading unregisters the sentence from the agent manager.""" + await pyscript.start(""" +@sentence_trigger("temp trigger") +def voice_temp(): + pass +""") + + assert len(agent_manager.triggers) == 1 + + # Unload the integration to trigger stop() + await pyscript.hass.config_entries.async_unload( + pyscript.hass.config_entries.async_entries("pyscript")[0].entry_id + ) + await pyscript.hass.async_block_till_done() + + assert len(agent_manager.triggers) == 0 + + +@pytest.mark.asyncio +async def test_sentence_trigger_kwargs(pyscript, agent_manager): + """Extra kwargs are merged into the function call.""" + await pyscript.start(""" +@sentence_trigger("with extra", kwargs={"extra": 42}) +def voice_extra(extra, trigger_type): + pyscript.done = [extra, trigger_type] +""") + + user_input = MockConversationInput(text="with extra") + result = MockRecognizeResult(entities={}) + + await agent_manager.triggers[0]["callback"](user_input, result) + await pyscript.wait_done([42, "sentence"]) diff --git a/tests/requirements_test.txt b/tests/requirements_test.txt index 5089b25..6a45589 100644 --- a/tests/requirements_test.txt +++ b/tests/requirements_test.txt @@ -1,5 +1,7 @@ coverage==7.13.5 croniter==6.0.0 +hassil==3.5.0 +home-assistant-intents==2026.3.24 watchdog==6.0.0 mock-open==1.4.0 mypy==1.10.1 diff --git a/tests/test_decorator_errors.py b/tests/test_decorator_errors.py index 94879ae..8f1ff65 100644 --- a/tests/test_decorator_errors.py +++ b/tests/test_decorator_errors.py @@ -208,7 +208,7 @@ def func4(): """, ) assert ( - "func4 defined in file.hello: needs at least one trigger decorator (ie: event_trigger, mqtt_trigger, state_trigger, time_trigger, webhook_trigger)" + "func4 defined in file.hello: needs at least one trigger decorator (ie: event_trigger, mqtt_trigger, sentence_trigger, state_trigger, time_trigger, webhook_trigger)" in caplog.text )