Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ docs/_build
.vscode
.*.swp
.idea
*.egg-info
4 changes: 3 additions & 1 deletion custom_components/pyscript/decorator_abc.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Expand Down Expand Up @@ -266,6 +267,7 @@ async def validate(self) -> None:
trig_decorators_reqd = {
"event_trigger",
"mqtt_trigger",
"sentence_trigger",
"state_trigger",
"time_trigger",
"webhook_trigger",
Expand Down
2 changes: 2 additions & 0 deletions custom_components/pyscript/decorators/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -16,6 +17,7 @@
TaskUniqueDecorator,
EventTriggerDecorator,
MQTTTriggerDecorator,
SentenceTriggerDecorator,
WebhookTriggerDecorator,
WebhookHandlerDecorator,
ServiceDecorator,
Expand Down
168 changes: 168 additions & 0 deletions custom_components/pyscript/decorators/sentence.py
Original file line number Diff line number Diff line change
@@ -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
5 changes: 5 additions & 0 deletions custom_components/pyscript/eval.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,13 +73,18 @@

TRIGGER_KWARGS = {
"context",
"details",
"device_id",
"event_type",
"old_value",
"payload",
"payload_obj",
"qos",
"request",
"retain",
"satellite_id",
"sentence",
"slots",
"topic",
"trigger_type",
"trigger_time",
Expand Down
27 changes: 27 additions & 0 deletions custom_components/pyscript/stubs/pyscript_builtins.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
71 changes: 71 additions & 0 deletions docs/reference.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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 <https://github.com/home-assistant/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 <https://www.home-assistant.io/integrations/conversation/>`__ must be set up for ``@sentence_trigger`` to work.

@state_active
^^^^^^^^^^^^^

Expand Down
Loading
Loading