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:
2026-05-13 14:31:56 +03:00
parent 90f958bdc6
commit 22127e2a59
79 changed files with 4042 additions and 210 deletions
@@ -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}
@@ -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 %}
@@ -0,0 +1 @@
List HA areas with entity counts
@@ -0,0 +1 @@
List entities (optional glob)
@@ -0,0 +1 @@
Show full state for one entity
@@ -0,0 +1 @@
Show Home Assistant connection status
@@ -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 %}
@@ -0,0 +1,4 @@
🏠 <b>Home Assistant commands</b>
{%- for cmd in commands %}
/{{ cmd.name }} — {{ cmd.description }}
{%- endfor %}
@@ -0,0 +1 @@
⏳ Too many requests. Please wait a moment and try again.
@@ -0,0 +1,3 @@
🏠 <b>Home Assistant bot</b>
Send /help to see what I can do.
@@ -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 &lt;entity_id&gt;</code>
{%- elif reason == 'not_found' %}
Entity <code>{{ entity_id }}</code> not found.
{%- else %}
Could not load state for <code>{{ entity_id }}</code>: {{ error }}
{%- endif %}
@@ -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 %}
@@ -0,0 +1 @@
/entities [glob] e.g. /entities light.*
@@ -0,0 +1 @@
/state &lt;entity_id&gt; 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
@@ -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 %}
@@ -0,0 +1 @@
Список зон HA с количеством сущностей
@@ -0,0 +1 @@
Список сущностей (можно указать glob)
@@ -0,0 +1 @@
Показать список команд
@@ -0,0 +1 @@
Полное состояние одной сущности
@@ -0,0 +1 @@
Статус подключения к Home Assistant
@@ -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 %}
@@ -0,0 +1,4 @@
🏠 <b>Команды Home Assistant</b>
{%- for cmd in commands %}
/{{ cmd.name }} — {{ cmd.description }}
{%- endfor %}
@@ -0,0 +1 @@
Нет результатов.
@@ -0,0 +1 @@
⏳ Слишком много запросов. Попробуйте снова чуть позже.
@@ -0,0 +1,3 @@
🏠 <b>Бот Home Assistant</b>
Отправьте /help, чтобы посмотреть, что я умею.
@@ -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 &lt;entity_id&gt;</code>
{%- elif reason == 'not_found' %}
Сущность <code>{{ entity_id }}</code> не найдена.
{%- else %}
Не удалось загрузить состояние <code>{{ entity_id }}</code>: {{ error }}
{%- endif %}
@@ -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 %}
@@ -0,0 +1 @@
/entities [glob] например /entities light.*
@@ -0,0 +1 @@
/state &lt;entity_id&gt; например /state light.kitchen
@@ -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
@@ -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 %}