feat: Home Assistant provider — WebSocket subscription + bot commands
Adds Home Assistant as a service provider with two coordinated surfaces: Notifications (subscription): - Long-lived WebSocket client (aiohttp ws_connect) with auth handshake, exponential-backoff reconnect, bounded event queue, and area-registry enrichment cached per (re)connect - ServiceProvider ABC gains an optional `subscribe()` method for push-style providers; HomeAssistantServiceProvider uses it via a per-provider supervisor task started in the FastAPI lifespan - 4 event types (state_changed, automation_triggered, call_service, event_fired), 4 default Jinja templates (en + ru), HA-specific tracker filters (entity_glob, domain_allowlist, exact entity ids) - Extracted shared dispatch pipeline (api/webhooks.py → services/ event_dispatch.py) so subscription and webhook ingest share the same event_log + deferred-dispatch + quiet-hours code path Bot commands: - /status, /entities [glob], /state <entity_id>, /areas - Multi-command WS session so /status and /areas cost one handshake - Sensitive-attribute blocklist (camera access_token, entity_picture, etc.) and 30-attribute cap to keep /state output safe and within Telegram's message size - Error-message redaction strips URL userinfo before surfacing to chat Frontend: - HA descriptor with toggle ConfigField type (new) and tag-input filter mode for free-text glob/domain lists (new TagInput component) - 15 command slots + 4 notification slots wired into the existing template-config UI
This commit is contained in:
@@ -65,6 +65,12 @@ class EventType(str, Enum):
|
||||
UPS_REPLACE_BATTERY = "ups_replace_battery"
|
||||
UPS_OVERLOAD = "ups_overload"
|
||||
|
||||
# Home Assistant events
|
||||
HA_STATE_CHANGED = "ha_state_changed"
|
||||
HA_AUTOMATION_TRIGGERED = "ha_automation_triggered"
|
||||
HA_SERVICE_CALLED = "ha_service_called"
|
||||
HA_EVENT_FIRED = "ha_event_fired"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ServiceEvent:
|
||||
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from enum import Enum
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from typing import TYPE_CHECKING, Any, Awaitable, Callable
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from notify_bridge_core.models.events import ServiceEvent
|
||||
@@ -21,6 +21,13 @@ class ServiceProviderType(str, Enum):
|
||||
NUT = "nut"
|
||||
GOOGLE_PHOTOS = "google_photos"
|
||||
WEBHOOK = "webhook"
|
||||
HOME_ASSISTANT = "home_assistant"
|
||||
|
||||
|
||||
# Callback signature for push-style providers: a coroutine that accepts a
|
||||
# parsed ServiceEvent and is expected to enqueue it for dispatch. Returning
|
||||
# None keeps the contract narrow — error handling stays inside the callback.
|
||||
EventEmitCallback = Callable[["ServiceEvent"], Awaitable[None]]
|
||||
|
||||
|
||||
class ServiceProvider(ABC):
|
||||
@@ -28,10 +35,27 @@ class ServiceProvider(ABC):
|
||||
|
||||
A service provider connects to an external service (e.g., Immich photo server)
|
||||
and can poll for changes, producing generic ServiceEvent objects.
|
||||
|
||||
Two ingest modes coexist on this base class:
|
||||
|
||||
* Polling providers (Immich, NUT, Google Photos, Scheduler) implement
|
||||
:meth:`poll` and leave :attr:`supports_subscription` False.
|
||||
* Webhook providers (Gitea, Planka, generic Webhook) no-op :meth:`poll`
|
||||
and receive events out-of-band via ``api/webhooks.py``.
|
||||
* Subscription providers (Home Assistant) flip
|
||||
:attr:`supports_subscription` to True and implement :meth:`subscribe`
|
||||
to run a long-lived task that pushes events through an
|
||||
``emit`` callback. They typically no-op :meth:`poll`.
|
||||
"""
|
||||
|
||||
provider_type: ServiceProviderType
|
||||
|
||||
# When True, the lifecycle layer (server-side subscription manager) starts
|
||||
# a long-running task that calls :meth:`subscribe` instead of registering
|
||||
# this provider with the polling scheduler. Default False keeps the
|
||||
# legacy poll/webhook flow intact for every existing provider.
|
||||
supports_subscription: bool = False
|
||||
|
||||
@abstractmethod
|
||||
async def connect(self) -> bool:
|
||||
"""Connect to the service and verify connectivity.
|
||||
@@ -59,6 +83,27 @@ class ServiceProvider(ABC):
|
||||
Tuple of (list of events detected, updated state dict).
|
||||
"""
|
||||
|
||||
async def subscribe(self, emit: EventEmitCallback) -> None:
|
||||
"""Run a long-lived subscription that calls ``emit`` for each event.
|
||||
|
||||
Override on providers with :attr:`supports_subscription` = True. The
|
||||
implementation is expected to:
|
||||
|
||||
* Loop until cancelled (the subscription manager uses
|
||||
:func:`asyncio.Task.cancel` on shutdown).
|
||||
* Handle its own reconnect with exponential backoff — never propagate
|
||||
transient network errors to the caller.
|
||||
* Pass parsed :class:`ServiceEvent` instances to ``emit`` for
|
||||
enqueueing/dispatch. The callback is responsible for routing.
|
||||
|
||||
The default implementation raises :class:`NotImplementedError` so
|
||||
accidental wiring of a polling provider into the subscription manager
|
||||
fails loudly rather than silently doing nothing.
|
||||
"""
|
||||
raise NotImplementedError(
|
||||
f"{type(self).__name__} does not support subscription-based ingest"
|
||||
)
|
||||
|
||||
@abstractmethod
|
||||
def get_available_variables(self) -> list[TemplateVariableDefinition]:
|
||||
"""Return the template variables this provider makes available."""
|
||||
|
||||
@@ -444,6 +444,76 @@ WEBHOOK_CAPABILITIES = ProviderCapabilities(
|
||||
],
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Home Assistant provider capabilities
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
HOME_ASSISTANT_CAPABILITIES = ProviderCapabilities(
|
||||
provider_type="home_assistant",
|
||||
display_name="Home Assistant",
|
||||
webhook_based=False,
|
||||
supported_filters=[
|
||||
{
|
||||
"key": "collections",
|
||||
"label": "Entities",
|
||||
"type": "tags",
|
||||
"placeholder": "light.kitchen",
|
||||
},
|
||||
{
|
||||
"key": "entity_glob",
|
||||
"label": "Entity glob",
|
||||
"type": "tags",
|
||||
"placeholder": "light.*",
|
||||
},
|
||||
{
|
||||
"key": "domain_allowlist",
|
||||
"label": "Domains",
|
||||
"type": "tags",
|
||||
"placeholder": "light, binary_sensor",
|
||||
},
|
||||
],
|
||||
notification_slots=[
|
||||
{"name": "message_ha_state_changed", "description": "Entity state changed"},
|
||||
{"name": "message_ha_automation_triggered", "description": "Automation triggered"},
|
||||
{"name": "message_ha_service_called", "description": "HA service called"},
|
||||
{"name": "message_ha_event_fired", "description": "Other HA event fired"},
|
||||
],
|
||||
events=[
|
||||
{"name": "ha_state_changed", "description": "Entity state changed"},
|
||||
{"name": "ha_automation_triggered", "description": "Automation triggered"},
|
||||
{"name": "ha_service_called", "description": "HA service called"},
|
||||
{"name": "ha_event_fired", "description": "Other HA event fired (catch-all)"},
|
||||
],
|
||||
command_slots=[
|
||||
# Response templates
|
||||
{"name": "start", "description": "/start greeting message"},
|
||||
{"name": "help", "description": "/help command listing"},
|
||||
{"name": "status", "description": "/status connection summary"},
|
||||
{"name": "entities", "description": "/entities matching glob"},
|
||||
{"name": "state", "description": "/state single-entity drill-down"},
|
||||
{"name": "areas", "description": "/areas with entity counts"},
|
||||
{"name": "rate_limited", "description": "Rate limit warning message"},
|
||||
{"name": "no_results", "description": "Empty results fallback"},
|
||||
# Description slots
|
||||
{"name": "desc_help", "description": "Menu description for /help"},
|
||||
{"name": "desc_status", "description": "Menu description for /status"},
|
||||
{"name": "desc_entities", "description": "Menu description for /entities"},
|
||||
{"name": "desc_state", "description": "Menu description for /state"},
|
||||
{"name": "desc_areas", "description": "Menu description for /areas"},
|
||||
# Usage examples
|
||||
{"name": "usage_entities", "description": "Usage example for /entities"},
|
||||
{"name": "usage_state", "description": "Usage example for /state"},
|
||||
],
|
||||
commands=[
|
||||
{"name": "status", "description": "Show connection status"},
|
||||
{"name": "entities", "description": "List entities (optional glob)"},
|
||||
{"name": "state", "description": "Show state for one entity"},
|
||||
{"name": "areas", "description": "List HA areas with entity counts"},
|
||||
{"name": "help", "description": "Show commands"},
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Registry
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -456,6 +526,7 @@ _REGISTRY: dict[str, ProviderCapabilities] = {
|
||||
"nut": NUT_CAPABILITIES,
|
||||
"google_photos": GOOGLE_PHOTOS_CAPABILITIES,
|
||||
"webhook": WEBHOOK_CAPABILITIES,
|
||||
"home_assistant": HOME_ASSISTANT_CAPABILITIES,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Home Assistant service provider implementation."""
|
||||
|
||||
from notify_bridge_core.providers.base import ServiceProviderType
|
||||
from notify_bridge_core.templates.variables import registry
|
||||
|
||||
from .client import (
|
||||
HomeAssistantApiError,
|
||||
HomeAssistantAuthError,
|
||||
HomeAssistantWSClient,
|
||||
_redact as redact_ha_message,
|
||||
)
|
||||
from .event_parser import parse_event
|
||||
from .provider import (
|
||||
DEFAULT_HA_EVENT_TYPES,
|
||||
HOME_ASSISTANT_VARIABLES,
|
||||
HomeAssistantServiceProvider,
|
||||
)
|
||||
|
||||
# Register HA variables in the global registry — same pattern as the other
|
||||
# providers in this package.
|
||||
registry.register_provider_variables(
|
||||
ServiceProviderType.HOME_ASSISTANT, HOME_ASSISTANT_VARIABLES,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_HA_EVENT_TYPES",
|
||||
"HOME_ASSISTANT_VARIABLES",
|
||||
"HomeAssistantApiError",
|
||||
"HomeAssistantAuthError",
|
||||
"HomeAssistantServiceProvider",
|
||||
"HomeAssistantWSClient",
|
||||
"parse_event",
|
||||
"redact_ha_message",
|
||||
]
|
||||
@@ -0,0 +1,506 @@
|
||||
"""Home Assistant WebSocket client.
|
||||
|
||||
Implements the slice of the HA WebSocket API we need for Phase 1:
|
||||
|
||||
* Authenticate with a long-lived access token.
|
||||
* Subscribe to events (optionally filtered by ``event_type``).
|
||||
* Fetch the state list (``get_states``) for entity picker UI.
|
||||
* Fetch the entity and area registries to build an ``entity_id -> area_id``
|
||||
lookup that the parser uses to enrich ``state_changed`` events with the
|
||||
area name.
|
||||
* Run an indefinite subscription loop with exponential backoff reconnect.
|
||||
|
||||
The HA protocol reference is at
|
||||
https://developers.home-assistant.io/docs/api/websocket/ — message ids are
|
||||
ascending integers, server replies use the same id, and authentication must
|
||||
complete before any other command is accepted.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import itertools
|
||||
import logging
|
||||
import random
|
||||
import time
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Any, AsyncIterator, Awaitable, Callable
|
||||
from urllib.parse import urlparse, urlunparse
|
||||
|
||||
import aiohttp
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class HomeAssistantAuthError(Exception):
|
||||
"""Raised when HA rejects our access token. Fatal — no point retrying."""
|
||||
|
||||
|
||||
class HomeAssistantApiError(Exception):
|
||||
"""Raised when an HA WS command returns ``success: false``."""
|
||||
|
||||
|
||||
# Default reconnect backoff: 2s, 4s, 8s, ..., capped at 60s with jitter.
|
||||
_RECONNECT_BASE_SECONDS = 2.0
|
||||
_RECONNECT_MAX_SECONDS = 60.0
|
||||
_RECONNECT_JITTER_RATIO = 0.2
|
||||
|
||||
# Bounded queue between the WS receive loop and the emit consumer. Overflow
|
||||
# drops the oldest event (FIFO) and logs at WARNING — better to lose one
|
||||
# state_changed than fall behind the firehose indefinitely.
|
||||
_EMIT_QUEUE_SIZE = 1000
|
||||
|
||||
|
||||
def _ws_url_from_base(base_url: str) -> str:
|
||||
"""Derive the HA WebSocket URL from the user-provided HTTP(S) base URL.
|
||||
|
||||
``http://homeassistant.local:8123`` -> ``ws://homeassistant.local:8123/api/websocket``.
|
||||
The user enters their normal HA URL; we transform the scheme + append
|
||||
the API path. This keeps the UI single-field and avoids confusion about
|
||||
which URL form to use.
|
||||
|
||||
Userinfo (``user:pass@host``) is **stripped** — credentials embedded in
|
||||
the URL would otherwise flow into log lines and exception strings via
|
||||
``aiohttp`` error messages. The HA WS protocol uses an access-token
|
||||
handshake; HTTP basic auth in the URL is never the intended path.
|
||||
"""
|
||||
parsed = urlparse(base_url.rstrip("/"))
|
||||
if parsed.scheme in ("ws", "wss"):
|
||||
scheme = parsed.scheme
|
||||
elif parsed.scheme == "https":
|
||||
scheme = "wss"
|
||||
else:
|
||||
scheme = "ws"
|
||||
# ``netloc`` may contain ``user:pass@host:port``; ``hostname`` + ``port``
|
||||
# rebuild it without the credential prefix.
|
||||
host = parsed.hostname or ""
|
||||
if parsed.port is not None:
|
||||
netloc = f"{host}:{parsed.port}"
|
||||
else:
|
||||
netloc = host
|
||||
return urlunparse(
|
||||
(scheme, netloc, "/api/websocket", "", "", "")
|
||||
)
|
||||
|
||||
|
||||
def _redact(text: str) -> str:
|
||||
"""Strip embedded credentials from text before logging.
|
||||
|
||||
``aiohttp`` exception strings include the URL, so a malformed
|
||||
``https://token@host`` would otherwise expose the token. This is a
|
||||
defense-in-depth measure — ``_ws_url_from_base`` already strips
|
||||
userinfo from the connect URL, but third-party libs may quote the
|
||||
user-supplied input separately.
|
||||
"""
|
||||
if not text:
|
||||
return text
|
||||
# Match ``scheme://[user[:pass]@]host`` and drop the userinfo segment.
|
||||
import re
|
||||
return re.sub(
|
||||
r"(?P<scheme>\w+://)(?:[^/@\s]+@)",
|
||||
r"\g<scheme>",
|
||||
text,
|
||||
)
|
||||
|
||||
|
||||
class HomeAssistantWSClient:
|
||||
"""Single-instance WebSocket client for one HA server."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
session: aiohttp.ClientSession,
|
||||
base_url: str,
|
||||
access_token: str,
|
||||
verify_tls: bool = True,
|
||||
) -> None:
|
||||
self._session = session
|
||||
self._ws_url = _ws_url_from_base(base_url)
|
||||
self._access_token = access_token
|
||||
self._verify_tls = verify_tls
|
||||
self._id_counter = itertools.count(1)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Connection primitives
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@asynccontextmanager
|
||||
async def _connect(self) -> AsyncIterator[aiohttp.ClientWebSocketResponse]:
|
||||
"""Open a fresh WS, complete the auth handshake, and yield the socket.
|
||||
|
||||
Raises :class:`HomeAssistantAuthError` on invalid token (fatal) and
|
||||
:class:`HomeAssistantApiError` on other handshake failures (caller
|
||||
decides whether to retry).
|
||||
"""
|
||||
ws = await self._session.ws_connect(
|
||||
self._ws_url,
|
||||
ssl=None if self._verify_tls else False,
|
||||
heartbeat=30,
|
||||
autoping=True,
|
||||
)
|
||||
try:
|
||||
await self._authenticate(ws)
|
||||
yield ws
|
||||
finally:
|
||||
await ws.close()
|
||||
|
||||
async def _authenticate(self, ws: aiohttp.ClientWebSocketResponse) -> None:
|
||||
"""Run the HA auth handshake on a freshly-opened socket."""
|
||||
greeting = await ws.receive_json(timeout=10)
|
||||
if greeting.get("type") != "auth_required":
|
||||
raise HomeAssistantApiError(
|
||||
f"Expected auth_required, got {greeting.get('type')!r}"
|
||||
)
|
||||
await ws.send_json({"type": "auth", "access_token": self._access_token})
|
||||
result = await ws.receive_json(timeout=10)
|
||||
msg_type = result.get("type")
|
||||
if msg_type == "auth_ok":
|
||||
return
|
||||
if msg_type == "auth_invalid":
|
||||
raise HomeAssistantAuthError(
|
||||
result.get("message") or "Home Assistant rejected the access token"
|
||||
)
|
||||
raise HomeAssistantApiError(
|
||||
f"Unexpected auth response: {msg_type!r}"
|
||||
)
|
||||
|
||||
async def _send_command(
|
||||
self,
|
||||
ws: aiohttp.ClientWebSocketResponse,
|
||||
payload: dict[str, Any],
|
||||
) -> int:
|
||||
"""Send a command with an auto-assigned id; return that id."""
|
||||
msg_id = next(self._id_counter)
|
||||
await ws.send_json({"id": msg_id, **payload})
|
||||
return msg_id
|
||||
|
||||
async def _await_result(
|
||||
self,
|
||||
ws: aiohttp.ClientWebSocketResponse,
|
||||
msg_id: int,
|
||||
timeout: float = 15.0,
|
||||
) -> Any:
|
||||
"""Wait for a ``result`` message matching ``msg_id`` and return its payload.
|
||||
|
||||
``time.monotonic`` is the right clock here — wall-clock deadlines
|
||||
would jump on NTP sync, and ``asyncio.get_event_loop().time()``
|
||||
is deprecated when called outside a running-loop context.
|
||||
"""
|
||||
deadline = time.monotonic() + timeout
|
||||
while True:
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
raise HomeAssistantApiError(
|
||||
f"Timed out waiting for result of command id={msg_id}"
|
||||
)
|
||||
msg = await ws.receive_json(timeout=remaining)
|
||||
if msg.get("id") != msg_id:
|
||||
# Ignore unsolicited events that arrive between sending a
|
||||
# request-style command and its result.
|
||||
continue
|
||||
if msg.get("type") != "result":
|
||||
continue
|
||||
if not msg.get("success", False):
|
||||
err = msg.get("error", {})
|
||||
raise HomeAssistantApiError(
|
||||
f"HA command failed: {err.get('code')} {err.get('message')}"
|
||||
)
|
||||
return msg.get("result")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Multi-command session
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@asynccontextmanager
|
||||
async def session(self) -> AsyncIterator["HomeAssistantSession"]:
|
||||
"""Open one authenticated WS and let the caller run multiple commands.
|
||||
|
||||
Each one-shot method (``get_states``, ``get_area_registry``, ...)
|
||||
opens a brand-new connection with a full TCP + WS + auth handshake.
|
||||
For callers that need to chain several queries (e.g. /status: connection
|
||||
check + entity list + area count) that overhead adds up — 3 separate
|
||||
TLS handshakes and 3 auth round-trips for what is really one logical
|
||||
request.
|
||||
|
||||
Usage:
|
||||
|
||||
async with client.session() as sess:
|
||||
states = await sess.get_states()
|
||||
areas = await sess.get_area_registry()
|
||||
|
||||
The session shares the same id counter as the client, so message ids
|
||||
are unique across both one-shot calls and session-scoped calls if
|
||||
they happen to run concurrently against the same client instance.
|
||||
"""
|
||||
async with self._connect() as ws:
|
||||
yield HomeAssistantSession(self, ws)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# One-shot commands
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def test_connection(self) -> tuple[bool, str]:
|
||||
"""Connect, authenticate, and immediately close. Returns ``(ok, message)``."""
|
||||
try:
|
||||
async with self._connect() as _ws:
|
||||
return True, "OK"
|
||||
except HomeAssistantAuthError as err:
|
||||
return False, f"Auth failed: {err}"
|
||||
except (aiohttp.ClientError, asyncio.TimeoutError) as err:
|
||||
return False, f"Connection failed: {err}"
|
||||
except HomeAssistantApiError as err:
|
||||
return False, str(err)
|
||||
|
||||
async def get_states(self) -> list[dict[str, Any]]:
|
||||
"""Fetch the current state of every entity HA knows about."""
|
||||
async with self._connect() as ws:
|
||||
msg_id = await self._send_command(ws, {"type": "get_states"})
|
||||
result = await self._await_result(ws, msg_id)
|
||||
return list(result or [])
|
||||
|
||||
async def get_area_registry(self) -> list[dict[str, Any]]:
|
||||
"""Fetch the area registry (``area_id`` -> name + metadata)."""
|
||||
async with self._connect() as ws:
|
||||
msg_id = await self._send_command(
|
||||
ws, {"type": "config/area_registry/list"}
|
||||
)
|
||||
result = await self._await_result(ws, msg_id)
|
||||
return list(result or [])
|
||||
|
||||
async def get_entity_registry(self) -> list[dict[str, Any]]:
|
||||
"""Fetch the entity registry (entity_id -> area_id + metadata)."""
|
||||
async with self._connect() as ws:
|
||||
msg_id = await self._send_command(
|
||||
ws, {"type": "config/entity_registry/list"}
|
||||
)
|
||||
result = await self._await_result(ws, msg_id)
|
||||
return list(result or [])
|
||||
|
||||
async def get_entity_to_area_lookup(self) -> dict[str, str]:
|
||||
"""Build ``{entity_id: area_name}`` using the entity + area registries.
|
||||
|
||||
Best-effort: returns an empty dict on any failure so the parser still
|
||||
works without area enrichment.
|
||||
"""
|
||||
try:
|
||||
entities = await self.get_entity_registry()
|
||||
areas = await self.get_area_registry()
|
||||
except (HomeAssistantApiError, aiohttp.ClientError, asyncio.TimeoutError) as err:
|
||||
_LOGGER.warning("Could not fetch HA registry, areas disabled: %s", err)
|
||||
return {}
|
||||
area_names = {a.get("area_id"): a.get("name") for a in areas if a.get("area_id")}
|
||||
lookup: dict[str, str] = {}
|
||||
for entry in entities:
|
||||
entity_id = entry.get("entity_id")
|
||||
area_id = entry.get("area_id")
|
||||
if not isinstance(entity_id, str) or not area_id:
|
||||
continue
|
||||
name = area_names.get(area_id)
|
||||
if name:
|
||||
lookup[entity_id] = str(name)
|
||||
return lookup
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Subscription loop with reconnect
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def run_subscription(
|
||||
self,
|
||||
on_event: Callable[[dict[str, Any]], Awaitable[None]],
|
||||
event_types: list[str] | None = None,
|
||||
on_status_change: Callable[[str, str | None], None] | None = None,
|
||||
refresh_areas: Callable[[], Awaitable[dict[str, str]]] | None = None,
|
||||
) -> None:
|
||||
"""Run an indefinite subscription loop, reconnecting on drop.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
on_event:
|
||||
Coroutine called with the inner ``event`` dict (the WS envelope is
|
||||
stripped). Slow callbacks apply TCP backpressure naturally; the
|
||||
internal queue prevents unbounded memory growth if the callback
|
||||
stalls.
|
||||
event_types:
|
||||
Restrict the subscription to these HA event types. ``None`` or
|
||||
empty subscribes to everything (very loud — only use for debug).
|
||||
on_status_change:
|
||||
Callback invoked with ``("connected", None)`` after a successful
|
||||
handshake and ``("disconnected", reason)`` when a connection drops.
|
||||
Useful for surfacing connection state in the event log.
|
||||
refresh_areas:
|
||||
Optional coroutine called on each (re)connect to refresh the
|
||||
area lookup. The result is not used by ``run_subscription``
|
||||
itself — the caller stores it where its ``on_event`` can read.
|
||||
"""
|
||||
attempt = 0
|
||||
queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue(maxsize=_EMIT_QUEUE_SIZE)
|
||||
overflow_count = 0
|
||||
|
||||
async def _drain() -> None:
|
||||
while True:
|
||||
evt = await queue.get()
|
||||
try:
|
||||
await on_event(evt)
|
||||
except Exception: # noqa: BLE001
|
||||
_LOGGER.exception("on_event callback raised; continuing")
|
||||
finally:
|
||||
queue.task_done()
|
||||
|
||||
drain_task = asyncio.create_task(_drain(), name="ha-emit-drain")
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
async with self._connect() as ws:
|
||||
attempt = 0
|
||||
if on_status_change is not None:
|
||||
on_status_change("connected", None)
|
||||
if refresh_areas is not None:
|
||||
try:
|
||||
# Note: refresh_areas opens its own WS in our
|
||||
# current design (each one-shot command does).
|
||||
# Fine for v1 — a few hundred ms once per
|
||||
# (re)connect.
|
||||
await refresh_areas()
|
||||
except Exception: # noqa: BLE001
|
||||
_LOGGER.exception("Area refresh failed; continuing without")
|
||||
|
||||
# Subscribe. Passing per-event-type subscriptions is
|
||||
# cheaper than subscribing to everything and filtering
|
||||
# in Python — HA does the filtering.
|
||||
if event_types:
|
||||
for evt_type in event_types:
|
||||
sub_id = await self._send_command(
|
||||
ws,
|
||||
{"type": "subscribe_events", "event_type": evt_type},
|
||||
)
|
||||
await self._await_result(ws, sub_id)
|
||||
else:
|
||||
sub_id = await self._send_command(
|
||||
ws, {"type": "subscribe_events"}
|
||||
)
|
||||
await self._await_result(ws, sub_id)
|
||||
|
||||
async for msg in ws:
|
||||
if msg.type == aiohttp.WSMsgType.TEXT:
|
||||
payload = msg.json()
|
||||
if payload.get("type") != "event":
|
||||
continue
|
||||
event_obj = payload.get("event")
|
||||
if not isinstance(event_obj, dict):
|
||||
continue
|
||||
try:
|
||||
queue.put_nowait(event_obj)
|
||||
except asyncio.QueueFull:
|
||||
overflow_count += 1
|
||||
if overflow_count % 50 == 1:
|
||||
_LOGGER.warning(
|
||||
"HA event queue full, dropped %d events so far "
|
||||
"(consumer is slower than HA event rate)",
|
||||
overflow_count,
|
||||
)
|
||||
# Drop oldest, retry put. This keeps the
|
||||
# most recent state visible at the cost
|
||||
# of older transient changes.
|
||||
try:
|
||||
queue.get_nowait()
|
||||
queue.task_done()
|
||||
except asyncio.QueueEmpty:
|
||||
pass
|
||||
try:
|
||||
queue.put_nowait(event_obj)
|
||||
except asyncio.QueueFull:
|
||||
pass
|
||||
elif msg.type in (
|
||||
aiohttp.WSMsgType.CLOSED,
|
||||
aiohttp.WSMsgType.CLOSING,
|
||||
aiohttp.WSMsgType.ERROR,
|
||||
):
|
||||
raise aiohttp.ClientConnectionError(
|
||||
f"WS closed: {msg.type.name}"
|
||||
)
|
||||
else:
|
||||
# PING/PONG handled by aiohttp autoping=True;
|
||||
# BINARY/CONTINUATION are not used by HA today.
|
||||
# Log at debug so a future protocol change is
|
||||
# visible without spamming production logs.
|
||||
_LOGGER.debug(
|
||||
"Ignored WS message of type %s", msg.type.name,
|
||||
)
|
||||
except HomeAssistantAuthError as err:
|
||||
# Fatal — caller must fix the access token. Reraise so
|
||||
# the provider can mark itself unhealthy.
|
||||
if on_status_change is not None:
|
||||
on_status_change("disconnected", _redact(f"auth: {err}"))
|
||||
raise
|
||||
except asyncio.CancelledError:
|
||||
if on_status_change is not None:
|
||||
on_status_change("disconnected", "cancelled")
|
||||
raise
|
||||
except Exception as err: # noqa: BLE001
|
||||
redacted = _redact(str(err))
|
||||
if on_status_change is not None:
|
||||
on_status_change("disconnected", redacted)
|
||||
delay = min(
|
||||
_RECONNECT_BASE_SECONDS * (2 ** attempt),
|
||||
_RECONNECT_MAX_SECONDS,
|
||||
)
|
||||
delay *= 1 + random.uniform(-_RECONNECT_JITTER_RATIO, _RECONNECT_JITTER_RATIO)
|
||||
_LOGGER.warning(
|
||||
"HA WS connection lost (%s); reconnecting in %.1fs",
|
||||
redacted, delay,
|
||||
)
|
||||
attempt = min(attempt + 1, 10)
|
||||
await asyncio.sleep(delay)
|
||||
finally:
|
||||
drain_task.cancel()
|
||||
# Drain task may finish via CancelledError (normal) or via an
|
||||
# unhandled exception thrown by on_event. Either way is fine here
|
||||
# — we're tearing down. Split the two cases for clarity rather
|
||||
# than catching `Exception` + `CancelledError` in one clause.
|
||||
try:
|
||||
await drain_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except Exception: # noqa: BLE001
|
||||
_LOGGER.exception("HA drain task raised during shutdown")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Multi-command session
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class HomeAssistantSession:
|
||||
"""A multi-command HA WS session bound to a single authenticated socket.
|
||||
|
||||
Created via :meth:`HomeAssistantWSClient.session`. Use when you need to
|
||||
issue several commands in a row — sharing the connection saves the TCP
|
||||
+ WS + auth round trips for every command after the first.
|
||||
|
||||
The session forwards id assignment to the parent client's monotonic
|
||||
counter so ids stay unique across all sessions sharing the same client.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
client: HomeAssistantWSClient,
|
||||
ws: aiohttp.ClientWebSocketResponse,
|
||||
) -> None:
|
||||
self._client = client
|
||||
self._ws = ws
|
||||
|
||||
async def send(self, payload: dict[str, Any], timeout: float = 15.0) -> Any:
|
||||
"""Send one command and wait for its ``result`` envelope."""
|
||||
msg_id = await self._client._send_command(self._ws, payload)
|
||||
return await self._client._await_result(self._ws, msg_id, timeout=timeout)
|
||||
|
||||
async def get_states(self) -> list[dict[str, Any]]:
|
||||
result = await self.send({"type": "get_states"})
|
||||
return list(result or [])
|
||||
|
||||
async def get_area_registry(self) -> list[dict[str, Any]]:
|
||||
result = await self.send({"type": "config/area_registry/list"})
|
||||
return list(result or [])
|
||||
|
||||
async def get_entity_registry(self) -> list[dict[str, Any]]:
|
||||
result = await self.send({"type": "config/entity_registry/list"})
|
||||
return list(result or [])
|
||||
@@ -0,0 +1,267 @@
|
||||
"""Home Assistant event parser — HA WebSocket event dict -> ServiceEvent.
|
||||
|
||||
The HA event bus delivers events with this envelope:
|
||||
|
||||
.. code-block:: json
|
||||
|
||||
{
|
||||
"id": 7,
|
||||
"type": "event",
|
||||
"event": {
|
||||
"event_type": "state_changed",
|
||||
"data": { ... event-type-specific ... },
|
||||
"origin": "LOCAL",
|
||||
"time_fired": "2026-05-13T12:34:56.789Z",
|
||||
"context": { ... }
|
||||
}
|
||||
}
|
||||
|
||||
The parser accepts the inner ``event`` dict (the WS client strips the outer
|
||||
envelope before calling us) and emits a :class:`ServiceEvent` ready for the
|
||||
existing dispatch path. Areas are looked up via an optional ``area_lookup``
|
||||
mapping so the parser stays pure — the WS client maintains the registry
|
||||
cache and passes its current snapshot on each call.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from notify_bridge_core.models.events import EventType, ServiceEvent
|
||||
from notify_bridge_core.providers.base import ServiceProviderType
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Defensive caps for fields that get persisted to the event_log row. Home
|
||||
# Assistant's own constraints keep entity ids well under 70 chars, but a
|
||||
# misbehaving custom integration could emit kilobyte-sized strings that
|
||||
# would bloat the JSON details column.
|
||||
_MAX_ENTITY_ID_LEN = 255
|
||||
_MAX_EVENT_DATA_BYTES = 4096
|
||||
|
||||
|
||||
def _parse_time_fired(raw: Any) -> datetime:
|
||||
"""Parse HA's ``time_fired`` ISO string, falling back to now() on garbage.
|
||||
|
||||
HA always sends UTC with a ``Z`` suffix or explicit ``+00:00``. Datetime
|
||||
parsing is wrapped because a malformed payload should not break the
|
||||
pipeline — better to dispatch with a slightly-off timestamp than drop.
|
||||
"""
|
||||
if isinstance(raw, str):
|
||||
try:
|
||||
# ``datetime.fromisoformat`` accepts ``+00:00`` natively; rewrite
|
||||
# the trailing ``Z`` since pre-3.11 stdlib rejects it.
|
||||
cleaned = raw[:-1] + "+00:00" if raw.endswith("Z") else raw
|
||||
return datetime.fromisoformat(cleaned)
|
||||
except ValueError:
|
||||
_LOGGER.debug("Unparseable HA time_fired %r, using now()", raw)
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _domain_of(entity_id: str) -> str:
|
||||
"""Return the HA domain prefix (``light.kitchen`` -> ``light``)."""
|
||||
if "." in entity_id:
|
||||
return entity_id.split(".", 1)[0]
|
||||
return ""
|
||||
|
||||
|
||||
def _friendly_name(state_obj: dict[str, Any] | None, entity_id: str) -> str:
|
||||
"""Pull ``friendly_name`` from attributes or fall back to entity_id."""
|
||||
if not state_obj:
|
||||
return entity_id
|
||||
attrs = state_obj.get("attributes") or {}
|
||||
name = attrs.get("friendly_name")
|
||||
return str(name) if name else entity_id
|
||||
|
||||
|
||||
def parse_event(
|
||||
ha_event: dict[str, Any],
|
||||
provider_name: str,
|
||||
area_lookup: dict[str, str] | None = None,
|
||||
) -> ServiceEvent | None:
|
||||
"""Parse one HA event dict into a :class:`ServiceEvent`.
|
||||
|
||||
Returns None for malformed payloads (missing ``event_type`` etc.) so the
|
||||
caller can drop without raising. Genuine network/parsing exceptions
|
||||
bubble up — only known-bad payload shapes return None.
|
||||
"""
|
||||
if not isinstance(ha_event, dict):
|
||||
return None
|
||||
event_type_raw = ha_event.get("event_type")
|
||||
if not isinstance(event_type_raw, str):
|
||||
return None
|
||||
|
||||
data = ha_event.get("data") or {}
|
||||
timestamp = _parse_time_fired(ha_event.get("time_fired"))
|
||||
area_lookup = area_lookup or {}
|
||||
|
||||
if event_type_raw == "state_changed":
|
||||
return _parse_state_changed(data, timestamp, provider_name, area_lookup)
|
||||
if event_type_raw == "automation_triggered":
|
||||
return _parse_automation_triggered(data, timestamp, provider_name)
|
||||
if event_type_raw == "call_service":
|
||||
return _parse_call_service(data, timestamp, provider_name)
|
||||
# Everything else maps to the generic "event_fired" slot. Tracking
|
||||
# configs decide whether to enable this loud catch-all.
|
||||
return _parse_generic_event(event_type_raw, data, timestamp, provider_name)
|
||||
|
||||
|
||||
def _parse_state_changed(
|
||||
data: dict[str, Any],
|
||||
timestamp: datetime,
|
||||
provider_name: str,
|
||||
area_lookup: dict[str, str],
|
||||
) -> ServiceEvent | None:
|
||||
entity_id = data.get("entity_id")
|
||||
if not isinstance(entity_id, str):
|
||||
return None
|
||||
entity_id = entity_id[:_MAX_ENTITY_ID_LEN]
|
||||
|
||||
old_state_obj = data.get("old_state") if isinstance(data.get("old_state"), dict) else None
|
||||
new_state_obj = data.get("new_state") if isinstance(data.get("new_state"), dict) else None
|
||||
|
||||
# ``new_state`` is None when an entity is removed — surface it as a
|
||||
# transition to the literal string "removed" so templates can branch.
|
||||
old_state_val = old_state_obj.get("state") if old_state_obj else None
|
||||
new_state_val = new_state_obj.get("state") if new_state_obj else "removed"
|
||||
|
||||
attributes = (new_state_obj or {}).get("attributes") or {}
|
||||
friendly_name = _friendly_name(new_state_obj or old_state_obj, entity_id)
|
||||
domain = _domain_of(entity_id)
|
||||
|
||||
extra: dict[str, Any] = {
|
||||
"entity_id": entity_id,
|
||||
"friendly_name": friendly_name,
|
||||
"domain": domain,
|
||||
"old_state": old_state_val,
|
||||
"new_state": new_state_val,
|
||||
"attributes": attributes,
|
||||
"device_class": attributes.get("device_class"),
|
||||
"unit_of_measurement": attributes.get("unit_of_measurement"),
|
||||
"area": area_lookup.get(entity_id),
|
||||
"ha_event_type": "state_changed",
|
||||
}
|
||||
if new_state_obj and "last_changed" in new_state_obj:
|
||||
extra["last_changed"] = new_state_obj["last_changed"]
|
||||
if new_state_obj and "last_updated" in new_state_obj:
|
||||
extra["last_updated"] = new_state_obj["last_updated"]
|
||||
|
||||
return ServiceEvent(
|
||||
event_type=EventType.HA_STATE_CHANGED,
|
||||
provider_type=ServiceProviderType.HOME_ASSISTANT,
|
||||
provider_name=provider_name,
|
||||
collection_id=entity_id,
|
||||
collection_name=friendly_name,
|
||||
timestamp=timestamp,
|
||||
extra=extra,
|
||||
)
|
||||
|
||||
|
||||
def _parse_automation_triggered(
|
||||
data: dict[str, Any],
|
||||
timestamp: datetime,
|
||||
provider_name: str,
|
||||
) -> ServiceEvent | None:
|
||||
entity_id = data.get("entity_id")
|
||||
if isinstance(entity_id, str):
|
||||
entity_id = entity_id[:_MAX_ENTITY_ID_LEN]
|
||||
automation_name = data.get("name") or (entity_id if isinstance(entity_id, str) else "automation")
|
||||
source = data.get("source") or ""
|
||||
|
||||
collection_id = entity_id if isinstance(entity_id, str) else f"automation.{automation_name}"
|
||||
collection_id = collection_id[:_MAX_ENTITY_ID_LEN]
|
||||
return ServiceEvent(
|
||||
event_type=EventType.HA_AUTOMATION_TRIGGERED,
|
||||
provider_type=ServiceProviderType.HOME_ASSISTANT,
|
||||
provider_name=provider_name,
|
||||
collection_id=collection_id,
|
||||
collection_name=str(automation_name),
|
||||
timestamp=timestamp,
|
||||
extra={
|
||||
"entity_id": entity_id,
|
||||
"automation_name": str(automation_name),
|
||||
"trigger_source": str(source),
|
||||
"ha_event_type": "automation_triggered",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _parse_call_service(
|
||||
data: dict[str, Any],
|
||||
timestamp: datetime,
|
||||
provider_name: str,
|
||||
) -> ServiceEvent | None:
|
||||
domain = data.get("domain")
|
||||
service = data.get("service")
|
||||
if not isinstance(domain, str) or not isinstance(service, str):
|
||||
return None
|
||||
domain = domain[:_MAX_ENTITY_ID_LEN]
|
||||
service = service[:_MAX_ENTITY_ID_LEN]
|
||||
service_data = data.get("service_data") if isinstance(data.get("service_data"), dict) else {}
|
||||
qualified = f"{domain}.{service}"
|
||||
target_entity = None
|
||||
if isinstance(service_data, dict):
|
||||
raw_target = service_data.get("entity_id")
|
||||
if isinstance(raw_target, str):
|
||||
target_entity = raw_target
|
||||
elif isinstance(raw_target, list) and raw_target:
|
||||
target_entity = ", ".join(str(x) for x in raw_target)
|
||||
|
||||
return ServiceEvent(
|
||||
event_type=EventType.HA_SERVICE_CALLED,
|
||||
provider_type=ServiceProviderType.HOME_ASSISTANT,
|
||||
provider_name=provider_name,
|
||||
collection_id=qualified,
|
||||
collection_name=qualified,
|
||||
timestamp=timestamp,
|
||||
extra={
|
||||
"service_domain": domain,
|
||||
"service_name": service,
|
||||
"service_called": qualified,
|
||||
"service_data": service_data,
|
||||
"target_entity": target_entity,
|
||||
"ha_event_type": "call_service",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _parse_generic_event(
|
||||
event_type_raw: str,
|
||||
data: dict[str, Any],
|
||||
timestamp: datetime,
|
||||
provider_name: str,
|
||||
) -> ServiceEvent | None:
|
||||
event_type_raw = event_type_raw[:_MAX_ENTITY_ID_LEN]
|
||||
# Cap the serialized payload so a custom HA integration that emits
|
||||
# a multi-megabyte event_data dict doesn't blow up the event_log JSON
|
||||
# column. Templates can still reference fields up to the cap; beyond it
|
||||
# the dict is replaced with a marker so the limit is visible to authors.
|
||||
capped_data: Any = data
|
||||
try:
|
||||
serialized = json.dumps(data, default=str)
|
||||
except (TypeError, ValueError):
|
||||
# Unserializable payload — keep the dict in-memory so templates can
|
||||
# still read scalar fields, but flag the size as 0 to avoid surprises.
|
||||
serialized = ""
|
||||
if len(serialized.encode("utf-8")) > _MAX_EVENT_DATA_BYTES:
|
||||
capped_data = {
|
||||
"_truncated": True,
|
||||
"_original_size_bytes": len(serialized.encode("utf-8")),
|
||||
"_note": f"event_data exceeded {_MAX_EVENT_DATA_BYTES}B and was dropped",
|
||||
}
|
||||
|
||||
return ServiceEvent(
|
||||
event_type=EventType.HA_EVENT_FIRED,
|
||||
provider_type=ServiceProviderType.HOME_ASSISTANT,
|
||||
provider_name=provider_name,
|
||||
collection_id=event_type_raw,
|
||||
collection_name=event_type_raw,
|
||||
timestamp=timestamp,
|
||||
extra={
|
||||
"ha_event_type": event_type_raw,
|
||||
"event_data": capped_data,
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,312 @@
|
||||
"""Home Assistant service provider — WebSocket subscription based.
|
||||
|
||||
Unlike polling providers (Immich, NUT, Google Photos) and webhook providers
|
||||
(Gitea, Planka), the HA provider maintains a long-lived WebSocket connection
|
||||
to the HA server and pushes events into the dispatch pipeline as they
|
||||
arrive. The lifecycle is owned by the server-side subscription manager
|
||||
(see ``services/ha_subscription.py``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
|
||||
from notify_bridge_core.models.events import ServiceEvent
|
||||
from notify_bridge_core.providers.base import (
|
||||
EventEmitCallback,
|
||||
ServiceProvider,
|
||||
ServiceProviderType,
|
||||
)
|
||||
from notify_bridge_core.templates.variables import TemplateVariableDefinition
|
||||
|
||||
from .client import HomeAssistantWSClient
|
||||
from .event_parser import parse_event
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Home Assistant template variables exposed to Jinja2.
|
||||
HOME_ASSISTANT_VARIABLES: list[TemplateVariableDefinition] = [
|
||||
TemplateVariableDefinition(
|
||||
name="entity_id",
|
||||
type="string",
|
||||
description="HA entity id (e.g. light.kitchen)",
|
||||
example="light.kitchen",
|
||||
provider_type=ServiceProviderType.HOME_ASSISTANT,
|
||||
),
|
||||
TemplateVariableDefinition(
|
||||
name="friendly_name",
|
||||
type="string",
|
||||
description="Human-readable entity name from attributes.friendly_name",
|
||||
example="Kitchen Light",
|
||||
provider_type=ServiceProviderType.HOME_ASSISTANT,
|
||||
),
|
||||
TemplateVariableDefinition(
|
||||
name="domain",
|
||||
type="string",
|
||||
description="HA domain prefix of the entity (e.g. light, sensor, binary_sensor)",
|
||||
example="light",
|
||||
provider_type=ServiceProviderType.HOME_ASSISTANT,
|
||||
),
|
||||
TemplateVariableDefinition(
|
||||
name="old_state",
|
||||
type="string",
|
||||
description="Previous state string before the change",
|
||||
example="off",
|
||||
provider_type=ServiceProviderType.HOME_ASSISTANT,
|
||||
),
|
||||
TemplateVariableDefinition(
|
||||
name="new_state",
|
||||
type="string",
|
||||
description="New state string (literal 'removed' when entity was deleted)",
|
||||
example="on",
|
||||
provider_type=ServiceProviderType.HOME_ASSISTANT,
|
||||
),
|
||||
TemplateVariableDefinition(
|
||||
name="attributes",
|
||||
type="dict",
|
||||
description="Full attributes dict of the new state",
|
||||
example='{"brightness": 255, "color_mode": "brightness"}',
|
||||
provider_type=ServiceProviderType.HOME_ASSISTANT,
|
||||
),
|
||||
TemplateVariableDefinition(
|
||||
name="device_class",
|
||||
type="string",
|
||||
description="Device class from attributes (motion, door, temperature, ...)",
|
||||
example="motion",
|
||||
provider_type=ServiceProviderType.HOME_ASSISTANT,
|
||||
),
|
||||
TemplateVariableDefinition(
|
||||
name="unit_of_measurement",
|
||||
type="string",
|
||||
description="Unit suffix for numeric sensors",
|
||||
example="°C",
|
||||
provider_type=ServiceProviderType.HOME_ASSISTANT,
|
||||
),
|
||||
TemplateVariableDefinition(
|
||||
name="area",
|
||||
type="string",
|
||||
description="Area name from the HA area registry (empty when not assigned)",
|
||||
example="Kitchen",
|
||||
provider_type=ServiceProviderType.HOME_ASSISTANT,
|
||||
),
|
||||
TemplateVariableDefinition(
|
||||
name="last_changed",
|
||||
type="string",
|
||||
description="ISO timestamp of last state change",
|
||||
example="2026-05-13T12:34:56.789+00:00",
|
||||
provider_type=ServiceProviderType.HOME_ASSISTANT,
|
||||
),
|
||||
TemplateVariableDefinition(
|
||||
name="last_updated",
|
||||
type="string",
|
||||
description="ISO timestamp of last attribute or state update",
|
||||
example="2026-05-13T12:34:56.789+00:00",
|
||||
provider_type=ServiceProviderType.HOME_ASSISTANT,
|
||||
),
|
||||
TemplateVariableDefinition(
|
||||
name="automation_name",
|
||||
type="string",
|
||||
description="Automation name (automation_triggered events)",
|
||||
example="Front Door Notification",
|
||||
provider_type=ServiceProviderType.HOME_ASSISTANT,
|
||||
),
|
||||
TemplateVariableDefinition(
|
||||
name="trigger_source",
|
||||
type="string",
|
||||
description="Why an automation fired (automation_triggered events)",
|
||||
example="state of binary_sensor.front_door",
|
||||
provider_type=ServiceProviderType.HOME_ASSISTANT,
|
||||
),
|
||||
TemplateVariableDefinition(
|
||||
name="service_called",
|
||||
type="string",
|
||||
description="Qualified service name (call_service events)",
|
||||
example="light.turn_on",
|
||||
provider_type=ServiceProviderType.HOME_ASSISTANT,
|
||||
),
|
||||
TemplateVariableDefinition(
|
||||
name="service_domain",
|
||||
type="string",
|
||||
description="Service domain (call_service events)",
|
||||
example="light",
|
||||
provider_type=ServiceProviderType.HOME_ASSISTANT,
|
||||
),
|
||||
TemplateVariableDefinition(
|
||||
name="service_name",
|
||||
type="string",
|
||||
description="Service name within domain (call_service events)",
|
||||
example="turn_on",
|
||||
provider_type=ServiceProviderType.HOME_ASSISTANT,
|
||||
),
|
||||
TemplateVariableDefinition(
|
||||
name="service_data",
|
||||
type="dict",
|
||||
description="Service payload (call_service events)",
|
||||
example='{"entity_id": "light.kitchen"}',
|
||||
provider_type=ServiceProviderType.HOME_ASSISTANT,
|
||||
),
|
||||
TemplateVariableDefinition(
|
||||
name="target_entity",
|
||||
type="string",
|
||||
description="entity_id targeted by a service call (comma-joined for multi-target)",
|
||||
example="light.kitchen",
|
||||
provider_type=ServiceProviderType.HOME_ASSISTANT,
|
||||
),
|
||||
TemplateVariableDefinition(
|
||||
name="ha_event_type",
|
||||
type="string",
|
||||
description="Raw HA event_type (state_changed, automation_triggered, ...)",
|
||||
example="state_changed",
|
||||
provider_type=ServiceProviderType.HOME_ASSISTANT,
|
||||
),
|
||||
TemplateVariableDefinition(
|
||||
name="event_data",
|
||||
type="dict",
|
||||
description="Raw event data (generic event_fired events)",
|
||||
example='{"key": "value"}',
|
||||
provider_type=ServiceProviderType.HOME_ASSISTANT,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
# Default event types subscribed to when the user does not override. Only
|
||||
# state_changed is on by default — the others are loud and opt-in via the
|
||||
# tracking-config event checkboxes.
|
||||
DEFAULT_HA_EVENT_TYPES: tuple[str, ...] = ("state_changed",)
|
||||
|
||||
|
||||
class HomeAssistantServiceProvider(ServiceProvider):
|
||||
"""Home Assistant WebSocket subscription provider."""
|
||||
|
||||
provider_type = ServiceProviderType.HOME_ASSISTANT
|
||||
supports_subscription = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
session: aiohttp.ClientSession,
|
||||
url: str,
|
||||
access_token: str,
|
||||
verify_tls: bool = True,
|
||||
event_types: list[str] | None = None,
|
||||
name: str = "Home Assistant",
|
||||
) -> None:
|
||||
self._client = HomeAssistantWSClient(
|
||||
session=session,
|
||||
base_url=url,
|
||||
access_token=access_token,
|
||||
verify_tls=verify_tls,
|
||||
)
|
||||
self._name = name
|
||||
self._event_types = list(event_types) if event_types else list(DEFAULT_HA_EVENT_TYPES)
|
||||
# ``_area_lookup`` is refreshed on every (re)connect by run_subscription's
|
||||
# ``refresh_areas`` hook so the parser can enrich state_changed events
|
||||
# with the current area name.
|
||||
self._area_lookup: dict[str, str] = {}
|
||||
|
||||
@property
|
||||
def client(self) -> HomeAssistantWSClient:
|
||||
return self._client
|
||||
|
||||
async def connect(self) -> bool:
|
||||
ok, _ = await self._client.test_connection()
|
||||
return ok
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
# Session lifecycle is managed by the caller; the WS connection is
|
||||
# owned by run_subscription which exits on cancel.
|
||||
return None
|
||||
|
||||
async def poll(
|
||||
self,
|
||||
collection_ids: list[str],
|
||||
tracker_state: dict[str, Any],
|
||||
) -> tuple[list[ServiceEvent], dict[str, Any]]:
|
||||
# Subscription-based ingest. The polling scheduler MUST NOT call us
|
||||
# — the subscription manager owns this provider's lifecycle instead.
|
||||
return [], tracker_state
|
||||
|
||||
async def subscribe(self, emit: EventEmitCallback) -> None:
|
||||
async def _on_event(ha_event: dict[str, Any]) -> None:
|
||||
event = parse_event(
|
||||
ha_event,
|
||||
provider_name=self._name,
|
||||
area_lookup=self._area_lookup,
|
||||
)
|
||||
if event is None:
|
||||
return
|
||||
await emit(event)
|
||||
|
||||
async def _refresh_areas() -> dict[str, str]:
|
||||
try:
|
||||
self._area_lookup = await self._client.get_entity_to_area_lookup()
|
||||
except Exception: # noqa: BLE001
|
||||
# Best-effort: keep the previous lookup on failure.
|
||||
_LOGGER.exception("Failed to refresh HA area lookup")
|
||||
return self._area_lookup
|
||||
|
||||
await self._client.run_subscription(
|
||||
on_event=_on_event,
|
||||
event_types=self._event_types,
|
||||
refresh_areas=_refresh_areas,
|
||||
)
|
||||
|
||||
def get_available_variables(self) -> list[TemplateVariableDefinition]:
|
||||
return list(HOME_ASSISTANT_VARIABLES)
|
||||
|
||||
def get_provider_config_schema(self) -> dict[str, Any]:
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"url": {
|
||||
"type": "string",
|
||||
"description": "Home Assistant base URL (http://homeassistant.local:8123)",
|
||||
"example": "http://homeassistant.local:8123",
|
||||
},
|
||||
"access_token": {
|
||||
"type": "string",
|
||||
"description": "Long-lived access token (HA Profile -> Long-Lived Access Tokens)",
|
||||
"secret": True,
|
||||
},
|
||||
"verify_tls": {
|
||||
"type": "boolean",
|
||||
"description": "Validate TLS certificate. Disable only for self-signed HA setups on trusted networks.",
|
||||
"default": True,
|
||||
},
|
||||
"event_types": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "HA event types to subscribe to. Defaults to ['state_changed'].",
|
||||
"default": list(DEFAULT_HA_EVENT_TYPES),
|
||||
},
|
||||
},
|
||||
"required": ["url", "access_token"],
|
||||
}
|
||||
|
||||
async def list_collections(self) -> list[dict[str, Any]]:
|
||||
"""Return the current entity list for the entity-picker UI."""
|
||||
try:
|
||||
states = await self._client.get_states()
|
||||
except Exception as err: # noqa: BLE001
|
||||
_LOGGER.warning("Could not fetch HA states: %s", err)
|
||||
return []
|
||||
out: list[dict[str, Any]] = []
|
||||
for state in states:
|
||||
entity_id = state.get("entity_id")
|
||||
if not isinstance(entity_id, str):
|
||||
continue
|
||||
attrs = state.get("attributes") or {}
|
||||
out.append({
|
||||
"id": entity_id,
|
||||
"name": attrs.get("friendly_name") or entity_id,
|
||||
"state": state.get("state"),
|
||||
"domain": entity_id.split(".", 1)[0] if "." in entity_id else "",
|
||||
})
|
||||
return out
|
||||
|
||||
async def test_connection(self) -> dict[str, Any]:
|
||||
ok, message = await self._client.test_connection()
|
||||
return {"ok": ok, "message": message}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
🗺️ <b>Areas</b>
|
||||
{%- if areas %}
|
||||
{%- for a in areas %}
|
||||
<b>{{ a.name }}</b> — {{ a.entity_count }} entity(ies)
|
||||
{%- endfor %}
|
||||
<i>Total: {{ total }}</i>
|
||||
{%- else %}
|
||||
No areas configured in Home Assistant.
|
||||
{%- endif %}
|
||||
+1
@@ -0,0 +1 @@
|
||||
List HA areas with entity counts
|
||||
+1
@@ -0,0 +1 @@
|
||||
List entities (optional glob)
|
||||
+1
@@ -0,0 +1 @@
|
||||
Show available commands
|
||||
+1
@@ -0,0 +1 @@
|
||||
Show full state for one entity
|
||||
+1
@@ -0,0 +1 @@
|
||||
Show Home Assistant connection status
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
🔍 <b>Entities</b>{% if glob %} matching <code>{{ glob }}</code>{% endif %}
|
||||
{%- if entities %}
|
||||
{%- for e in entities %}
|
||||
<code>{{ e.entity_id }}</code> — <b>{{ e.state }}</b>{% if e.unit_of_measurement %} {{ e.unit_of_measurement }}{% endif %}{% if e.friendly_name and e.friendly_name != e.entity_id %} · <i>{{ e.friendly_name }}</i>{% endif %}
|
||||
{%- endfor %}
|
||||
{%- if total > shown %}
|
||||
<i>Showing {{ shown }} of {{ total }} — refine the glob to narrow further.</i>
|
||||
{%- endif %}
|
||||
{%- else %}
|
||||
No entities matched.
|
||||
{%- endif %}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
🏠 <b>Home Assistant commands</b>
|
||||
{%- for cmd in commands %}
|
||||
/{{ cmd.name }} — {{ cmd.description }}
|
||||
{%- endfor %}
|
||||
+1
@@ -0,0 +1 @@
|
||||
No results.
|
||||
+1
@@ -0,0 +1 @@
|
||||
⏳ Too many requests. Please wait a moment and try again.
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
🏠 <b>Home Assistant bot</b>
|
||||
|
||||
Send /help to see what I can do.
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
{%- if found %}
|
||||
🏠 <b>{{ friendly_name }}</b>
|
||||
<code>{{ entity_id }}</code>
|
||||
State: <b>{{ state }}</b>{% if unit_of_measurement %} {{ unit_of_measurement }}{% endif %}
|
||||
{%- if device_class %}
|
||||
Class: <i>{{ device_class }}</i>
|
||||
{%- endif %}
|
||||
{%- if last_changed %}
|
||||
Last changed: <i>{{ last_changed }}</i>
|
||||
{%- endif %}
|
||||
{%- if attributes %}
|
||||
|
||||
<b>Attributes</b>
|
||||
{%- for key, value in attributes.items() %}
|
||||
• {{ key }}: <code>{{ (value if value is string else value | tojson) | string | truncate(120) }}</code>
|
||||
{%- endfor %}
|
||||
{%- if hidden_attr_count and hidden_attr_count > 0 %}
|
||||
<i>… and {{ hidden_attr_count }} more attribute(s) hidden (sensitive or truncated for length)</i>
|
||||
{%- endif %}
|
||||
{%- endif %}
|
||||
{%- elif reason == 'missing_arg' %}
|
||||
Usage: <code>/state <entity_id></code>
|
||||
{%- elif reason == 'not_found' %}
|
||||
Entity <code>{{ entity_id }}</code> not found.
|
||||
{%- else %}
|
||||
Could not load state for <code>{{ entity_id }}</code>: {{ error }}
|
||||
{%- endif %}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
🏠 <b>{{ provider_name }}</b>
|
||||
{%- if ok %}
|
||||
<i>Connected</i> · {{ url }}
|
||||
Entities: <b>{{ entity_count }}</b> · Areas: <b>{{ area_count }}</b>
|
||||
{%- else %}
|
||||
<i>Disconnected</i>
|
||||
<code>{{ message }}</code>
|
||||
{%- endif %}
|
||||
+1
@@ -0,0 +1 @@
|
||||
/entities [glob] e.g. /entities light.*
|
||||
+1
@@ -0,0 +1 @@
|
||||
/state <entity_id> e.g. /state light.kitchen
|
||||
@@ -64,6 +64,15 @@ PROVIDER_COMMAND_SLOTS: dict[str, list[str]] = {
|
||||
# Usage example slots
|
||||
"usage_latest", "usage_search", "usage_random",
|
||||
],
|
||||
"home_assistant": [
|
||||
# Response templates
|
||||
"start", "help", "status", "entities", "state", "areas",
|
||||
"rate_limited", "no_results",
|
||||
# Description slots
|
||||
"desc_help", "desc_status", "desc_entities", "desc_state", "desc_areas",
|
||||
# Usage examples
|
||||
"usage_entities", "usage_state",
|
||||
],
|
||||
}
|
||||
|
||||
# Backward-compatible aliases
|
||||
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
🗺️ <b>Зоны</b>
|
||||
{%- if areas %}
|
||||
{%- for a in areas %}
|
||||
<b>{{ a.name }}</b> — {{ a.entity_count }} сущность(ей)
|
||||
{%- endfor %}
|
||||
<i>Всего: {{ total }}</i>
|
||||
{%- else %}
|
||||
В Home Assistant не настроено ни одной зоны.
|
||||
{%- endif %}
|
||||
+1
@@ -0,0 +1 @@
|
||||
Список зон HA с количеством сущностей
|
||||
+1
@@ -0,0 +1 @@
|
||||
Список сущностей (можно указать glob)
|
||||
+1
@@ -0,0 +1 @@
|
||||
Показать список команд
|
||||
+1
@@ -0,0 +1 @@
|
||||
Полное состояние одной сущности
|
||||
+1
@@ -0,0 +1 @@
|
||||
Статус подключения к Home Assistant
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
🔍 <b>Сущности</b>{% if glob %} по шаблону <code>{{ glob }}</code>{% endif %}
|
||||
{%- if entities %}
|
||||
{%- for e in entities %}
|
||||
<code>{{ e.entity_id }}</code> — <b>{{ e.state }}</b>{% if e.unit_of_measurement %} {{ e.unit_of_measurement }}{% endif %}{% if e.friendly_name and e.friendly_name != e.entity_id %} · <i>{{ e.friendly_name }}</i>{% endif %}
|
||||
{%- endfor %}
|
||||
{%- if total > shown %}
|
||||
<i>Показано {{ shown }} из {{ total }} — уточните шаблон, чтобы сузить.</i>
|
||||
{%- endif %}
|
||||
{%- else %}
|
||||
Совпадений не найдено.
|
||||
{%- endif %}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
🏠 <b>Команды Home Assistant</b>
|
||||
{%- for cmd in commands %}
|
||||
/{{ cmd.name }} — {{ cmd.description }}
|
||||
{%- endfor %}
|
||||
+1
@@ -0,0 +1 @@
|
||||
Нет результатов.
|
||||
+1
@@ -0,0 +1 @@
|
||||
⏳ Слишком много запросов. Попробуйте снова чуть позже.
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
🏠 <b>Бот Home Assistant</b>
|
||||
|
||||
Отправьте /help, чтобы посмотреть, что я умею.
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
{%- if found %}
|
||||
🏠 <b>{{ friendly_name }}</b>
|
||||
<code>{{ entity_id }}</code>
|
||||
Состояние: <b>{{ state }}</b>{% if unit_of_measurement %} {{ unit_of_measurement }}{% endif %}
|
||||
{%- if device_class %}
|
||||
Класс: <i>{{ device_class }}</i>
|
||||
{%- endif %}
|
||||
{%- if last_changed %}
|
||||
Последнее изменение: <i>{{ last_changed }}</i>
|
||||
{%- endif %}
|
||||
{%- if attributes %}
|
||||
|
||||
<b>Атрибуты</b>
|
||||
{%- for key, value in attributes.items() %}
|
||||
• {{ key }}: <code>{{ (value if value is string else value | tojson) | string | truncate(120) }}</code>
|
||||
{%- endfor %}
|
||||
{%- if hidden_attr_count and hidden_attr_count > 0 %}
|
||||
<i>… и ещё {{ hidden_attr_count }} атрибут(ов) скрыты (содержат секреты или обрезаны по длине)</i>
|
||||
{%- endif %}
|
||||
{%- endif %}
|
||||
{%- elif reason == 'missing_arg' %}
|
||||
Использование: <code>/state <entity_id></code>
|
||||
{%- elif reason == 'not_found' %}
|
||||
Сущность <code>{{ entity_id }}</code> не найдена.
|
||||
{%- else %}
|
||||
Не удалось загрузить состояние <code>{{ entity_id }}</code>: {{ error }}
|
||||
{%- endif %}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
🏠 <b>{{ provider_name }}</b>
|
||||
{%- if ok %}
|
||||
<i>Подключено</i> · {{ url }}
|
||||
Сущностей: <b>{{ entity_count }}</b> · Зон: <b>{{ area_count }}</b>
|
||||
{%- else %}
|
||||
<i>Отключено</i>
|
||||
<code>{{ message }}</code>
|
||||
{%- endif %}
|
||||
+1
@@ -0,0 +1 @@
|
||||
/entities [glob] например /entities light.*
|
||||
+1
@@ -0,0 +1 @@
|
||||
/state <entity_id> например /state light.kitchen
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
⚙️ Automation triggered: <b>{{ automation_name }}</b>
|
||||
{%- if trigger_source %}
|
||||
<i>Source:</i> {{ trigger_source }}
|
||||
{%- endif %}
|
||||
{%- if entity_id %}
|
||||
<code>{{ entity_id }}</code>
|
||||
{%- endif %}
|
||||
@@ -0,0 +1,4 @@
|
||||
📡 HA event: <b>{{ ha_event_type }}</b>
|
||||
{%- if event_data %}
|
||||
<pre>{{ event_data | tojson(indent=2) }}</pre>
|
||||
{%- endif %}
|
||||
@@ -0,0 +1,4 @@
|
||||
🔧 Service called: <b>{{ service_called }}</b>
|
||||
{%- if target_entity %}
|
||||
<i>Target:</i> <code>{{ target_entity }}</code>
|
||||
{%- endif %}
|
||||
@@ -0,0 +1,11 @@
|
||||
🏠 <b>{{ friendly_name }}</b>{% if area %} <i>({{ area }})</i>{% endif %}
|
||||
{%- if old_state %}
|
||||
{{ old_state }} → <b>{{ new_state }}</b>{% if unit_of_measurement %} {{ unit_of_measurement }}{% endif %}
|
||||
{%- else %}
|
||||
<b>{{ new_state }}</b>{% if unit_of_measurement %} {{ unit_of_measurement }}{% endif %}
|
||||
{%- endif %}
|
||||
{%- if device_class %}
|
||||
<i>{{ device_class }}</i> · <code>{{ entity_id }}</code>
|
||||
{%- else %}
|
||||
<code>{{ entity_id }}</code>
|
||||
{%- endif %}
|
||||
@@ -73,6 +73,12 @@ PROVIDER_SLOT_FILE_MAP: dict[str, dict[str, str]] = {
|
||||
"message_ups_replace_battery": "nut_ups_replace_battery.jinja2",
|
||||
"message_ups_overload": "nut_ups_overload.jinja2",
|
||||
},
|
||||
"home_assistant": {
|
||||
"message_ha_state_changed": "ha_state_changed.jinja2",
|
||||
"message_ha_automation_triggered": "ha_automation_triggered.jinja2",
|
||||
"message_ha_service_called": "ha_service_called.jinja2",
|
||||
"message_ha_event_fired": "ha_event_fired.jinja2",
|
||||
},
|
||||
}
|
||||
|
||||
# Backward-compatible alias
|
||||
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
⚙️ Автоматизация сработала: <b>{{ automation_name }}</b>
|
||||
{%- if trigger_source %}
|
||||
<i>Триггер:</i> {{ trigger_source }}
|
||||
{%- endif %}
|
||||
{%- if entity_id %}
|
||||
<code>{{ entity_id }}</code>
|
||||
{%- endif %}
|
||||
@@ -0,0 +1,4 @@
|
||||
📡 Событие HA: <b>{{ ha_event_type }}</b>
|
||||
{%- if event_data %}
|
||||
<pre>{{ event_data | tojson(indent=2) }}</pre>
|
||||
{%- endif %}
|
||||
@@ -0,0 +1,4 @@
|
||||
🔧 Вызвана служба: <b>{{ service_called }}</b>
|
||||
{%- if target_entity %}
|
||||
<i>Цель:</i> <code>{{ target_entity }}</code>
|
||||
{%- endif %}
|
||||
@@ -0,0 +1,11 @@
|
||||
🏠 <b>{{ friendly_name }}</b>{% if area %} <i>({{ area }})</i>{% endif %}
|
||||
{%- if old_state %}
|
||||
{{ old_state }} → <b>{{ new_state }}</b>{% if unit_of_measurement %} {{ unit_of_measurement }}{% endif %}
|
||||
{%- else %}
|
||||
<b>{{ new_state }}</b>{% if unit_of_measurement %} {{ unit_of_measurement }}{% endif %}
|
||||
{%- endif %}
|
||||
{%- if device_class %}
|
||||
<i>{{ device_class }}</i> · <code>{{ entity_id }}</code>
|
||||
{%- else %}
|
||||
<code>{{ entity_id }}</code>
|
||||
{%- endif %}
|
||||
Reference in New Issue
Block a user