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
@@ -565,6 +565,63 @@ async def preview_raw(
"count": 2,
# /rate_limited
"wait": 15,
# --- Home Assistant: /status, /entities, /state, /areas ---
"ok": True,
"message": "OK",
"provider_name": "Home Assistant",
"url": "http://homeassistant.local:8123",
"entity_count": 142,
"area_count": 8,
"entities": [
{
"entity_id": "binary_sensor.front_door",
"friendly_name": "Front Door",
"domain": "binary_sensor",
"state": "off",
"attributes": {"device_class": "door", "friendly_name": "Front Door"},
"device_class": "door",
"unit_of_measurement": None,
"last_changed": "2026-05-13T12:34:56.789+00:00",
"last_updated": "2026-05-13T12:34:56.789+00:00",
},
{
"entity_id": "sensor.kitchen_temperature",
"friendly_name": "Kitchen Temperature",
"domain": "sensor",
"state": "21.4",
"attributes": {"unit_of_measurement": "°C", "friendly_name": "Kitchen Temperature"},
"device_class": "temperature",
"unit_of_measurement": "°C",
"last_changed": "2026-05-13T12:30:00+00:00",
"last_updated": "2026-05-13T12:30:00+00:00",
},
],
"glob": "binary_sensor.*",
"total": 12,
"shown": 2,
# /state — single entity drill-down. ``found`` controls which branch
# of the template renders.
"found": True,
"entity_id": "light.kitchen",
"friendly_name": "Kitchen Light",
"domain": "light",
"state": "on",
"attributes": {
"brightness": 200,
"color_mode": "brightness",
},
"hidden_attr_count": 0,
"device_class": None,
"unit_of_measurement": None,
"last_changed": "2026-05-13T12:34:56.789+00:00",
"last_updated": "2026-05-13T12:34:56.789+00:00",
"reason": "",
"error": "",
# /areas
"areas": [
{"area_id": "kitchen", "name": "Kitchen", "entity_count": 14},
{"area_id": "entrance", "name": "Entrance", "entity_count": 4},
],
}
return render_template_preview(body.template, sample_ctx)
@@ -3,7 +3,7 @@
import logging
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, ValidationError
from pydantic import AnyHttpUrl, BaseModel, ValidationError, field_validator
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from typing import Any
@@ -103,6 +103,54 @@ class WebhookProviderConfig(BaseModel):
max_stored_payloads: int = 20 # 1-100
class HomeAssistantProviderConfig(BaseModel):
url: str
access_token: str
verify_tls: bool = True
event_types: list[str] | None = None
@field_validator("url")
@classmethod
def _validate_url(cls, raw: str) -> str:
"""Reject malformed URLs early so the user sees a clear error.
``AnyHttpUrl`` accepts the homelab-friendly forms
(``http://homeassistant.local:8123``) while rejecting garbage like
``not-a-url`` or ``ftp://...``. Validation is best-effort; we still
re-derive the WebSocket URL at runtime.
"""
try:
AnyHttpUrl(raw)
except ValueError as err:
raise ValueError(f"url must be a valid http(s) URL: {err}") from err
return raw
@field_validator("event_types")
@classmethod
def _validate_event_types(cls, raw: list[str] | None) -> list[str] | None:
"""Cap list size and per-entry length; reject obvious junk.
We don't whitelist event names — HA has unbounded custom event types
from third-party integrations. Length and count caps are enough to
keep a misconfiguration from blowing up the subscription handshake.
"""
if raw is None:
return None
if len(raw) > 50:
raise ValueError("event_types accepts at most 50 entries")
cleaned: list[str] = []
for entry in raw:
if not isinstance(entry, str):
raise ValueError("event_types entries must be strings")
entry = entry.strip()
if not entry:
continue
if len(entry) > 100:
raise ValueError("event_types entries must be <=100 chars")
cleaned.append(entry)
return cleaned or None
_PROVIDER_CONFIG_MODELS: dict[str, type[BaseModel]] = {
"immich": ImmichProviderConfig,
"gitea": GiteaProviderConfig,
@@ -111,6 +159,7 @@ _PROVIDER_CONFIG_MODELS: dict[str, type[BaseModel]] = {
"nut": NutProviderConfig,
"google_photos": GooglePhotosProviderConfig,
"webhook": WebhookProviderConfig,
"home_assistant": HomeAssistantProviderConfig,
}
@@ -160,6 +209,18 @@ async def _test_provider_connection(provider: ServiceProvider) -> dict[str, Any]
gp = make_google_photos_provider(http_session, provider)
return await gp.test_connection()
if provider.type == "home_assistant":
from notify_bridge_core.providers.home_assistant import HomeAssistantServiceProvider
ha = HomeAssistantServiceProvider(
session=http_session,
url=provider.config.get("url", ""),
access_token=provider.config.get("access_token", ""),
verify_tls=bool(provider.config.get("verify_tls", True)),
event_types=provider.config.get("event_types") or None,
name=provider.name,
)
return await ha.test_connection()
if provider.type in ("scheduler", "webhook"):
return {"ok": True, "message": "Virtual provider — always available"}
@@ -285,6 +285,8 @@ async def get_template_variables(
**_planka_variables(),
# --- NUT (UPS) slots ---
**_nut_variables(),
# --- Home Assistant slots ---
**_home_assistant_variables(),
# --- Scheduler slots ---
"message_scheduled_message": {
"description": "Notification for scheduled message events",
@@ -433,6 +435,58 @@ def _nut_variables() -> dict:
}
def _home_assistant_variables() -> dict:
common = {
"entity_id": "HA entity id (e.g. light.kitchen)",
"friendly_name": "Human-readable entity name from attributes.friendly_name",
"domain": "HA domain prefix (light, sensor, binary_sensor, ...)",
"attributes": "Full attributes dict of the new state",
"device_class": "Device class (motion, door, temperature, ...)",
"unit_of_measurement": "Unit suffix for numeric sensors",
"area": "Area name from the HA area registry (empty when not assigned)",
"ha_event_type": "Raw HA event_type (state_changed, automation_triggered, ...)",
"last_changed": "ISO timestamp of last state change",
"last_updated": "ISO timestamp of last attribute or state update",
}
return {
"message_ha_state_changed": {
"description": "Entity state changed",
"variables": {
**common,
"old_state": "Previous state string",
"new_state": "New state string ('removed' if entity deleted)",
},
},
"message_ha_automation_triggered": {
"description": "Automation triggered",
"variables": {
"entity_id": common["entity_id"],
"automation_name": "Automation name",
"trigger_source": "Why the automation fired",
"ha_event_type": common["ha_event_type"],
},
},
"message_ha_service_called": {
"description": "HA service called",
"variables": {
"service_called": "Qualified service name (e.g. light.turn_on)",
"service_domain": "Service domain",
"service_name": "Service name within domain",
"service_data": "Service payload dict",
"target_entity": "entity_id targeted by the call (comma-joined for multi-target)",
"ha_event_type": common["ha_event_type"],
},
},
"message_ha_event_fired": {
"description": "Other HA event fired (catch-all)",
"variables": {
"ha_event_type": common["ha_event_type"],
"event_data": "Raw event data dict (use {{ event_data | tojson }} to render)",
},
},
}
@router.post("", status_code=status.HTTP_201_CREATED)
async def create_config(
body: TemplateConfigCreate,
@@ -13,7 +13,6 @@ from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from notify_bridge_core.models.events import ServiceEvent
from notify_bridge_core.notifications.dispatcher import NotificationDispatcher, TargetConfig
from notify_bridge_core.providers.gitea.event_parser import parse_webhook as parse_gitea_webhook
from notify_bridge_core.providers.planka.event_parser import parse_webhook as parse_planka_webhook
from notify_bridge_core.providers.webhook.event_parser import parse_webhook as parse_generic_webhook
@@ -27,13 +26,7 @@ from ..database.models import (
ServiceProvider,
WebhookPayloadLog,
)
from ..services.dispatch_helpers import (
GateReason,
apply_tracking_display_filters,
evaluate_event_gate,
get_app_timezone,
load_link_data,
)
from ..services.event_dispatch import dispatch_provider_event
_LOGGER = logging.getLogger(__name__)
@@ -131,7 +124,7 @@ def _passes_filters(
# ---------------------------------------------------------------------------
# Shared dispatch helper
# Shared dispatch helper (legacy wrapper — body moved to services/event_dispatch.py)
# ---------------------------------------------------------------------------
async def _dispatch_webhook_event(
@@ -142,185 +135,16 @@ async def _dispatch_webhook_event(
event: ServiceEvent,
detail_keys: tuple[str, ...],
) -> int:
"""Load trackers, filter, create EventLogs, dispatch notifications, and commit.
Parameters
----------
engine:
SQLAlchemy async engine.
provider_id:
ID of the ServiceProvider that received the webhook.
provider_name:
Human-readable name of the provider (for logging).
provider_config:
The provider's ``config`` dict (passed through to target config builder).
event:
Parsed :class:`ServiceEvent` to dispatch.
detail_keys:
Keys from ``event.extra`` to include in the EventLog ``details`` dict.
Returns
-------
int
Number of successfully dispatched notifications.
"""
dispatched = 0
# ``defers_to_schedule`` is collected during the loop and flushed AFTER the
# main session commits — the only side-effect of failing to schedule is a
# delayed delivery (the startup loader / catch-up scan will reschedule),
# so this is best-effort and must not roll back the DB writes.
defers_to_schedule: set[Any] = set()
async with AsyncSession(engine) as session:
# App timezone is identical across trackers within one webhook request;
# pull it once.
app_tz = await get_app_timezone(session)
tracker_result = await session.exec(
select(NotificationTracker).where(
NotificationTracker.provider_id == provider_id,
NotificationTracker.enabled == True, # noqa: E712
)
)
trackers = tracker_result.all()
from ..services.deferred_dispatch import defer_event, is_deferrable
for tracker in trackers:
filters = tracker.filters or {}
if not _passes_filters(event, filters):
_LOGGER.debug(
"Event filtered out for tracker %d (%s)", tracker.id, tracker.name
)
continue
link_data = await load_link_data(session, tracker.id)
if not link_data:
continue
# Log event
extra_details = {k: v for k, v in event.extra.items() if k in detail_keys}
event_log_row = EventLog(
user_id=tracker.user_id,
tracker_id=tracker.id,
tracker_name=tracker.name,
provider_id=provider_id,
provider_name=provider_name,
event_type=event.event_type.value,
collection_id=event.collection_id,
collection_name=event.collection_name,
assets_count=0,
details={
"provider_type": event.provider_type.value,
**extra_details,
},
)
session.add(event_log_row)
await session.flush()
event_log_id = event_log_row.id
# Dedupe defers by parent ``link_id``: broadcast links emit one
# ``link_data`` entry per child, all sharing the same parent id —
# the deferred row is one-per-link, so we only call ``defer_event``
# once per distinct id (earliest fire_at wins on ties).
groups: dict[int, tuple[Any, list[TargetConfig]]] = {}
defers_for_event: dict[int, Any] = {}
for ld in link_data:
tc = ld["tracking_config"]
if tc is not None:
outcome = evaluate_event_gate(event, tc, app_tz)
if outcome.reason is GateReason.QUIET_HOURS:
if is_deferrable(event.event_type.value) and outcome.quiet_hours_end_at is not None:
link_id = ld.get("link_id")
if link_id is not None:
prior = defers_for_event.get(link_id)
if prior is None or outcome.quiet_hours_end_at < prior:
defers_for_event[link_id] = outcome.quiet_hours_end_at
continue
if outcome.reason is GateReason.EVENT_TYPE_DISABLED:
continue
tmpl = ld["template_config"]
target_cfg = TargetConfig(
type=ld["target_type"],
config=ld["target_config"],
template_slots=ld["template_slots"],
date_format=tmpl.date_format if tmpl else "%d.%m.%Y, %H:%M UTC",
date_only_format=tmpl.date_only_format if tmpl and tmpl.date_only_format else "%d.%m.%Y",
provider_api_key=provider_config.get("api_token"),
provider_internal_url=provider_config.get("url", ""),
provider_external_url=provider_config.get("url", ""),
receivers=ld["receivers"],
)
key = id(tc) if tc is not None else 0
if key not in groups:
groups[key] = (tc, [])
groups[key][1].append(target_cfg)
# Persist defers + stamp event_log dispatch_status in the same
# session that holds the EventLog row, so the "deferred" badge
# only appears if the underlying queue rows actually exist.
if defers_for_event:
earliest = min(defers_for_event.values())
for link_id, fire_at in defers_for_event.items():
await defer_event(
session,
event=event,
user_id=tracker.user_id,
tracker_id=tracker.id,
link_id=link_id,
event_log_id=event_log_id,
fire_at=fire_at,
)
details = dict(event_log_row.details or {})
if not details.get("dispatch_status"):
details["dispatch_status"] = "deferred"
details["deferred_until"] = earliest.isoformat()
event_log_row.details = details
session.add(event_log_row)
defers_to_schedule.update(defers_for_event.values())
# Dispatch to targets. Isolate dispatcher exceptions per group so
# a failed remote call doesn't bubble out, abort the surrounding
# transaction, and roll back the just-written defers/event_log.
from ..services.http_session import get_http_session
dispatcher = NotificationDispatcher(session=await get_http_session())
for tc, target_configs in groups.values():
if not target_configs:
continue
shaped_event = apply_tracking_display_filters(event, tc)
if shaped_event is None:
continue
try:
results = await dispatcher.dispatch(shaped_event, target_configs)
except Exception as err: # noqa: BLE001
_LOGGER.exception(
"Dispatcher raised for tracker %d: %s", tracker.id, err,
)
continue
for r in results:
if r.get("success"):
dispatched += 1
else:
_LOGGER.error(
"Notification failed for tracker %d: %s",
tracker.id, r.get("error", "unknown"),
)
await session.commit()
# Schedule drain jobs OUTSIDE the DB session so an APScheduler hiccup
# can't roll back the persisted defer rows.
if defers_to_schedule:
from ..services.scheduler import schedule_deferred_drain
for fire_at in defers_to_schedule:
try:
schedule_deferred_drain(fire_at)
except Exception: # noqa: BLE001
_LOGGER.exception(
"Failed to schedule deferred drain for %s", fire_at,
)
return dispatched
"""Webhook-flavoured dispatch — thin wrapper over ``dispatch_provider_event``."""
return await dispatch_provider_event(
engine=engine,
provider_id=provider_id,
provider_name=provider_name,
provider_config=provider_config,
event=event,
detail_keys=detail_keys,
filter_fn=_passes_filters,
)
# ---------------------------------------------------------------------------