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:
@@ -0,0 +1,177 @@
|
||||
# Feature Backlog
|
||||
|
||||
Curated feature ideas, narrowed from a brainstorming pass on 2026-05-13.
|
||||
Order is **rough sequencing preference**, not strict priority — adjust as we go.
|
||||
|
||||
---
|
||||
|
||||
## 1. Quiet Hours — close the gaps in the existing system
|
||||
|
||||
**Reality check (verified 2026-05-13).** Quiet hours are already shipped under
|
||||
the "deferred dispatch" name in v0.8.0. The pipeline lives at
|
||||
`packages/server/src/notify_bridge_server/services/deferred_dispatch.py` with
|
||||
helpers in `dispatch_helpers.py` and tests in
|
||||
`tests/test_deferred_dispatch.py`. What exists:
|
||||
|
||||
- Per-tracking-config window: `tracking_config.quiet_hours_enabled`,
|
||||
`quiet_hours_start`, `quiet_hours_end`.
|
||||
- Per-link override: `notification_tracker_target.quiet_hours_start`,
|
||||
`quiet_hours_end`.
|
||||
- Smart coalescing (asset add + asset remove during a window cancels each
|
||||
other out, set-union merge for repeated adds).
|
||||
- Post-window drain via APScheduler one-shot date jobs.
|
||||
- Wall-clock event types (`scheduled_message`) drop instead of deferring.
|
||||
- Frontend status surface: `deferred`, `deferred_then_dropped`,
|
||||
`deferred_then_failed`, with `deferred_until` and `deferred_for_seconds`
|
||||
fields exposed in the event log.
|
||||
|
||||
**What's NOT there (the actual gaps):**
|
||||
|
||||
| Gap | Sketch |
|
||||
| --- | --- |
|
||||
| **Target-level windows** | Today, hours bind to the *watcher* (tracking config / link). Users naturally think of DND at the *destination* ("don't ping my phone at night, regardless of source"). New column on `notification_target` + dispatcher gate. |
|
||||
| **Multiple windows per row** | Today is a single HH:MM range. Real schedules want weekday-evening + weekend-all-day. JSON list of windows. |
|
||||
| **Days-of-week** | Same window every day. Need `days: ["mon", "tue", ...]` filter per window. |
|
||||
| **Per-window timezone** | Uses the global app TZ. Multi-traveller / multi-target setups want per-window TZ. |
|
||||
| **Silent mode** | Modes today are defer-or-drop. Telegram `disable_notification=true` ("send but don't ring") is a third useful mode. |
|
||||
| **Per-receiver windows** | One bot → multiple chats, each potentially with its own DND. Today it's all-or-nothing per target. |
|
||||
|
||||
**Recommended cut for v1 of "extend quiet hours":**
|
||||
|
||||
- Add target-level quiet hours (new column `notification_target.quiet_hours_json`
|
||||
= list of `{days, start, end, mode, tz}`).
|
||||
- Modes: `drop`, `defer`, `silent`. `defer` reuses the existing
|
||||
deferred-dispatch pipeline (just changes who decides). `silent` maps to
|
||||
`disable_notification=true` for Telegram; other targets fall through to
|
||||
normal send (or we treat `silent` as `defer` for non-Telegram targets — TBD).
|
||||
- Dispatcher precedence: target window wins over link/tracking-config window
|
||||
when both are configured. Document this explicitly.
|
||||
- Frontend: new "Quiet hours" fieldset in the target editor (Aurora cassette
|
||||
style). Reuses Timezone picker; new day-picker chip row.
|
||||
- Skip days-of-week + multi-window in v1 if scope grows — ship the target-level
|
||||
cut first, then iterate.
|
||||
|
||||
**Open questions.**
|
||||
|
||||
- How exactly do target / link / tracking-config windows combine? Proposal:
|
||||
any window covering "now" wins (drop > defer > silent precedence).
|
||||
- Should `silent` for non-Telegram targets degrade to normal send or to
|
||||
defer? Defer is the safer default.
|
||||
- Does the event log need a new status (`silenced` / `dropped_by_target_qh`)
|
||||
to make precedence visible?
|
||||
|
||||
---
|
||||
|
||||
## 2. Immich Smart Actions (expand beyond Auto-Organize)
|
||||
|
||||
**What.** Extend the existing Smart Actions pattern (currently:
|
||||
**Immich Auto-Organize**) with more rule-driven actions against the Immich API.
|
||||
|
||||
**Why.** Auto-Organize already proves the descriptor → rule editor → executor
|
||||
pipeline. Adding actions is mostly authoring new executors + small UI rule
|
||||
shapes, not new infra.
|
||||
|
||||
**Candidates (pick in this order).**
|
||||
|
||||
1. **Auto-favorite by person** — when an asset is detected containing person
|
||||
X (or any of a set), mark it favorite.
|
||||
2. **Auto-archive by age / album** — assets older than N days in a given
|
||||
album get archived. Pair with a "dry-run shows count" UX like
|
||||
Auto-Organize already has.
|
||||
3. **Duplicate cluster nudge** — periodically run Immich's duplicate API and
|
||||
send a digest notification with inline buttons ("review", "ignore for 30d").
|
||||
Depends on inline-button work (see backlog item 4 dependencies).
|
||||
4. **Share-link rotation** — for an album, regenerate the share link every N
|
||||
days; notify with the new URL.
|
||||
5. **Pending-delete review** — push a weekly digest of trash contents before
|
||||
Immich's auto-purge fires.
|
||||
|
||||
**Shape.**
|
||||
|
||||
- Reuse the existing **action descriptor** layer
|
||||
(`packages/core/src/notify_bridge_core/providers/actions.py`,
|
||||
`action_executor.py`) and the frontend rule editor used by Auto-Organize.
|
||||
- Each new action = (a) executor in core, (b) rule schema in the descriptor,
|
||||
(c) frontend descriptor extension for the rule editor fields.
|
||||
- Persist as `provider_actions` rows (already exists for Auto-Organize) with
|
||||
a discriminator + JSON config.
|
||||
|
||||
**Open questions.**
|
||||
|
||||
- Does "auto-favorite by person" need a confirmation queue or run silently?
|
||||
Default to silent + event_log entry.
|
||||
- How do we surface "this action moved/changed X assets" in the dashboard?
|
||||
Probably a per-action stat tile on the provider detail page.
|
||||
|
||||
---
|
||||
|
||||
## 3. Home Assistant Provider
|
||||
|
||||
**Full plan:** [feature-home-assistant.md](feature-home-assistant.md).
|
||||
|
||||
**One-line summary.** New WebSocket-based service provider with a 3-phase
|
||||
ship: subscribe + dispatch (Phase 1), bot commands (Phase 2), HA service
|
||||
calls as Smart Actions (Phase 3). Chosen over webhook ingest because
|
||||
Phases 2 + 3 force a long-lived API connection anyway; consolidating on WS
|
||||
avoids a refactor.
|
||||
|
||||
**Status:** planned, not started.
|
||||
|
||||
---
|
||||
|
||||
## 4. Block-Based Template Builder
|
||||
|
||||
**What.** A visual, drag-and-drop builder for notification and command
|
||||
templates that compiles down to Jinja2. Lives alongside (not instead of) the
|
||||
current `JinjaEditor`. Author can flip between views.
|
||||
|
||||
**Why.** The current Jinja editor is powerful but unforgiving. A block UI
|
||||
lowers the floor for new users and provides a discovery surface for the
|
||||
variables documented in `template_configs.py`.
|
||||
|
||||
**Shape.**
|
||||
|
||||
- Frontend-only feature for v1 — compiles to the same Jinja strings the
|
||||
backend already accepts.
|
||||
- Blocks: `Text`, `Variable`, `If`, `For`, `Link`, `Image`, `Icon`, `Caption`,
|
||||
`Group` (HTML span/group). Each block knows its serialized Jinja
|
||||
representation.
|
||||
- Round-trip: variables, simple `{% if %}` / `{% for %}` blocks, and string
|
||||
literals parse back to blocks; arbitrary Jinja stays in a "Raw" block that
|
||||
the user can edit as text.
|
||||
- Variable picker reads `get_template_variables(provider_type, slot)`. This is
|
||||
the same data already shown in the template-help panel.
|
||||
- Preview pane unchanged — reuses `services/sample_context.py` server
|
||||
rendering.
|
||||
- Toggle in the template editor: **Visual / Code**.
|
||||
|
||||
**Open questions.**
|
||||
|
||||
- Round-tripping arbitrary Jinja is hard. v1: parseable subset → blocks,
|
||||
anything else → single Raw block. Show a banner explaining.
|
||||
- Locale handling: same compiled Jinja, just authored per locale tab.
|
||||
- Do we want a marketplace of pre-built block compositions? Out of scope for
|
||||
v1 — bundle import/export is a separate backlog item.
|
||||
|
||||
---
|
||||
|
||||
## Recommended Sequencing
|
||||
|
||||
1. **Quiet Hours per Target** — small, isolated, immediate user value.
|
||||
2. **Immich Smart Actions** — incremental on existing pattern; ship one
|
||||
action at a time (start with auto-favorite by person).
|
||||
3. **Home Assistant Provider** — multi-file, follows new-provider checklist;
|
||||
biggest user-base expansion.
|
||||
4. **Block-Based Template Builder** — largest frontend lift; benefits from
|
||||
the variable-doc work that the other features will exercise.
|
||||
|
||||
Dependencies are loose — 1 and 2 are independent of 3 and 4. The block
|
||||
builder pairs nicely with Home Assistant because HA's rich context surfaces
|
||||
the value of an easier authoring UX.
|
||||
|
||||
---
|
||||
|
||||
## Decision log
|
||||
|
||||
- **2026-05-13** — Backlog seeded with these four items selected from a
|
||||
broader brainstorm. Not started.
|
||||
@@ -0,0 +1,284 @@
|
||||
# Home Assistant Provider — Implementation Plan
|
||||
|
||||
> Status: **planned, not started**. Sequencing: third item on the backlog
|
||||
> (see [feature-backlog.md](feature-backlog.md)).
|
||||
> Last updated: 2026-05-13.
|
||||
|
||||
## Decision: WebSocket subscription, not webhook
|
||||
|
||||
We considered three ingest modes (webhook automation, WebSocket subscription,
|
||||
hybrid). The WebSocket route is chosen as the architectural foundation because
|
||||
the medium-term roadmap forces it anyway:
|
||||
|
||||
| Phase | Capability | Needs API access? |
|
||||
| --- | --- | --- |
|
||||
| 1 | Subscribe to events, emit notifications | Read (event stream) |
|
||||
| 2 | Bot commands (`/state`, `/entities`, `/areas`) | Read (REST or WS get_states) |
|
||||
| 3 | Smart Actions (`light.turn_on`, scene activation) | Write (call_service) |
|
||||
|
||||
A webhook-only Phase 1 would still need a REST client by Phase 2 and a write
|
||||
path by Phase 3 — net result is two client implementations + one event
|
||||
pipeline refactor. WebSocket consolidates all three phases on one connection.
|
||||
|
||||
**Tradeoff (be honest):** WebSocket introduces a long-lived-connection pattern
|
||||
this codebase does not have yet. Reconnect logic, missed-events-on-restart
|
||||
gap, and a new shape on the `ServiceProvider` ABC are real costs. Phase 1 is
|
||||
**not** shippable in one short session — plan for a multi-session build.
|
||||
|
||||
## Provider abstraction extension
|
||||
|
||||
The current `ServiceProvider` ABC
|
||||
([packages/core/src/notify_bridge_core/providers/base.py](../../packages/core/src/notify_bridge_core/providers/base.py))
|
||||
is poll-oriented: every provider implements `poll(collection_ids, state) →
|
||||
(events, new_state)`. Webhook providers (Gitea, Planka, Webhook) satisfy this
|
||||
by no-op'ing `poll` and shoving events in via `api/webhooks.py` instead.
|
||||
|
||||
Home Assistant fits neither cleanly. The plan:
|
||||
|
||||
1. Add an **optional** `async subscribe(emit) → None` method on the base ABC.
|
||||
Default implementation raises `NotImplementedError`. Polling providers do
|
||||
not override it. The scheduler / lifecycle layer (currently `services/watcher.py`)
|
||||
gains a "subscription manager" branch that, for any provider whose class
|
||||
overrides `subscribe`, starts a long-lived task instead of registering
|
||||
a polling job.
|
||||
2. `emit` is a callback `(event: ServiceEvent) → None` provided by the
|
||||
subscription manager — it routes events to the dispatcher exactly like the
|
||||
webhook handler does today. Keeping the dispatch path unchanged is the
|
||||
point of this design.
|
||||
3. Reconnect lives **inside** `subscribe`: the method is expected to be a
|
||||
`while not cancelled: try connect; on drop, sleep with backoff, retry`
|
||||
loop. The manager cancels the task on shutdown via the cooperative cancel
|
||||
token used elsewhere.
|
||||
|
||||
This is a small, additive change to one ABC. No existing provider is
|
||||
modified.
|
||||
|
||||
## Phase 1 — Subscribe + Dispatch (MVP)
|
||||
|
||||
### Scope
|
||||
|
||||
- Long-lived WebSocket connection to HA, authenticated with a long-lived
|
||||
access token.
|
||||
- Subscribe to the event bus with optional `event_type` filter (defaults to
|
||||
`state_changed`).
|
||||
- Translate HA events into `ServiceEvent` and dispatch via the existing
|
||||
pipeline. Notifications go out exactly as they do today for any other
|
||||
provider.
|
||||
- Filter UI: entity-id glob list, domain allowlist (e.g. `light.*`,
|
||||
`binary_sensor.*`), event-type allowlist. **Hard-required** to avoid the HA
|
||||
firehose drowning the bridge.
|
||||
- Connection test + entity listing via WS `get_states` (no REST client yet —
|
||||
WS gives us both subscribe and read).
|
||||
|
||||
### Out of scope for Phase 1
|
||||
|
||||
- Bot commands → Phase 2.
|
||||
- Service calls → Phase 3.
|
||||
- Replay of events missed during disconnect (HA does not support this; we
|
||||
document the gap and surface "reconnected after N seconds" in the event
|
||||
log).
|
||||
- Webhook-style ingestion (path-embedded token webhook receiver). If a user
|
||||
prefers webhooks, we add it later as a second ingestion mode on the same
|
||||
provider — out of scope for v1.
|
||||
|
||||
### Event types (v1)
|
||||
|
||||
| HA event | ServiceEvent type | Notification slot |
|
||||
| --- | --- | --- |
|
||||
| `state_changed` | `ha_state_changed` | `message_state_changed` |
|
||||
| `automation_triggered` | `ha_automation_triggered` | `message_automation_triggered` |
|
||||
| `call_service` | `ha_service_called` | `message_service_called` |
|
||||
| (custom event types) | `ha_event_fired` | `message_event_fired` |
|
||||
|
||||
Default tracking config enables `state_changed` only — the others are loud
|
||||
and opt-in.
|
||||
|
||||
### Context variables exposed to templates
|
||||
|
||||
Pulled directly from HA's `state_changed` payload, normalized:
|
||||
|
||||
- `entity_id` — `light.kitchen`
|
||||
- `friendly_name` — `attributes.friendly_name` or fallback to `entity_id`
|
||||
- `domain` — derived from `entity_id` before the dot
|
||||
- `old_state` — `from_state.state`
|
||||
- `new_state` — `to_state.state`
|
||||
- `attributes` — dict of new-state attributes (raw)
|
||||
- `device_class` — `attributes.device_class` if present
|
||||
- `area` — `attributes.area_id` if present (best effort; only set if HA
|
||||
exposes it via the area registry, which costs a `get_registry` WS call —
|
||||
see "Open questions")
|
||||
- `last_changed`, `last_updated` — ISO timestamps
|
||||
- For non-`state_changed` events: `event_type`, `event_data` (full dict)
|
||||
|
||||
### File touch map (Phase 1)
|
||||
|
||||
**Core** (`packages/core/src/notify_bridge_core/`)
|
||||
|
||||
| Path | Action | Notes |
|
||||
| --- | --- | --- |
|
||||
| `providers/base.py` | Modify | Add optional `subscribe(emit)` ABC method (default `NotImplementedError`); add `HOME_ASSISTANT = "home_assistant"` to `ServiceProviderType` |
|
||||
| `providers/capabilities.py` | Modify | Add `HOME_ASSISTANT_CAPABILITIES` + register |
|
||||
| `providers/home_assistant/__init__.py` | Create | Export + register template variables |
|
||||
| `providers/home_assistant/client.py` | Create | WebSocket client (auth, subscribe, get_states, call_service stub) |
|
||||
| `providers/home_assistant/event_parser.py` | Create | HA event dict → `ServiceEvent` |
|
||||
| `providers/home_assistant/provider.py` | Create | Class with `connect`, `disconnect`, `subscribe`, `list_collections` (entity list), `get_available_variables`, `get_provider_config_schema`, `test_connection`. `poll` raises NotImplementedError. |
|
||||
| `templates/defaults/en/home_assistant_*.jinja2` | Create | 4 slot templates |
|
||||
| `templates/defaults/ru/home_assistant_*.jinja2` | Create | 4 slot templates |
|
||||
| `templates/defaults/loader.py` | Modify | Add to `PROVIDER_SLOT_FILE_MAP` |
|
||||
| `templates/command_defaults/loader.py` | Modify | Stub entry — empty commands list for now |
|
||||
| `templates/context.py` | Modify | HA context builder |
|
||||
| `templates/validator.py` | Modify | Whitelist HA variable names |
|
||||
|
||||
**Server** (`packages/server/src/notify_bridge_server/`)
|
||||
|
||||
| Path | Action | Notes |
|
||||
| --- | --- | --- |
|
||||
| `services/watcher.py` *(or scheduler / lifecycle module that hosts polling)* | Modify | Add subscription-manager branch — for providers whose class overrides `subscribe`, start/stop long-running task instead of polling |
|
||||
| `services/scheduler.py` | Verify | Confirm we cancel HA subscription on shutdown (graceful_shutdown_seconds path) |
|
||||
| `api/template_configs.py` | Modify | `get_template_variables()` entry |
|
||||
| `api/command_template_configs.py` | Modify | Sample ctx (minimal for Phase 1 — no commands) |
|
||||
| `services/sample_context.py` | Modify | `_SAMPLE_CONTEXT["home_assistant"]` |
|
||||
| `database/seeds.py` | Modify | Seed notification templates + default tracking config |
|
||||
|
||||
**Frontend** (`frontend/src/`)
|
||||
|
||||
| Path | Action | Notes |
|
||||
| --- | --- | --- |
|
||||
| `lib/providers/home-assistant.ts` | Create | Descriptor per CLAUDE.md rule 11 |
|
||||
| `lib/providers/index.ts` | Modify | Register descriptor |
|
||||
| `lib/locales/en.json` | Modify | `providers.typeHomeAssistant`, `gridDesc.providerHomeAssistant` |
|
||||
| `lib/locales/ru.json` | Modify | Same |
|
||||
|
||||
**Tests**
|
||||
|
||||
| Path | Action |
|
||||
| --- | --- |
|
||||
| `packages/core/tests/providers/test_home_assistant_parser.py` | Create — HA payload → `ServiceEvent` |
|
||||
| `packages/core/tests/providers/test_home_assistant_client.py` | Create — WS auth, subscribe, reconnect (use a fake server) |
|
||||
| `packages/server/tests/test_home_assistant_subscription.py` | Create — subscription manager lifecycle, event flows through dispatcher |
|
||||
|
||||
### Frontend descriptor essentials
|
||||
|
||||
```text
|
||||
type: "home_assistant"
|
||||
defaultName: "Home Assistant"
|
||||
icon: "home" (consider Lucide icon; HA logo if a custom asset exists)
|
||||
hasUrl: true // base URL of HA (used to derive WS URL)
|
||||
configFields:
|
||||
- url: http(s)://homeassistant.local:8123
|
||||
- access_token: long-lived access token (required)
|
||||
- allowed_event_types: comma-separated, defaults to "state_changed"
|
||||
eventFields: 4 checkboxes (state_changed, automation_triggered,
|
||||
call_service, event_fired)
|
||||
extraTrackingFields:
|
||||
- entity_glob: tag input ("light.*", "binary_sensor.*_motion")
|
||||
- domain_allowlist: tag input
|
||||
collectionMeta: { label: "Entities", icon: "..." }
|
||||
webhookBased: false // we are NOT webhook based
|
||||
```
|
||||
|
||||
WS URL is derived: `wss://{host}/api/websocket` (or `ws://` for plain http
|
||||
HA). Document this in the UI hint.
|
||||
|
||||
### Auth model
|
||||
|
||||
- **Long-lived access token** from HA (Profile → Long-Lived Access Tokens).
|
||||
- Stored encrypted at rest via the same path the other providers use for
|
||||
secrets (the bridge already has a secret-encryption helper — verify the
|
||||
exact module name during implementation).
|
||||
- WS auth handshake: connect → server sends `auth_required` → client sends
|
||||
`{type: "auth", access_token: "..."}` → server replies `auth_ok` or
|
||||
`auth_invalid`.
|
||||
|
||||
### Risks / open questions (Phase 1)
|
||||
|
||||
1. **Reconnect strategy.** Exponential backoff capped at 60s, jittered.
|
||||
On reconnect, log a `connection_restored_after` event so the UI can
|
||||
surface the gap. Document that HA does not support event replay.
|
||||
2. **Area registry.** Pulling `area_id` for entities requires a separate
|
||||
`config/area_registry/list` WS call. Decision needed: fetch once on
|
||||
connect and cache, refetch on `area_registry_updated` event, or skip
|
||||
`area` from the context entirely in v1. Recommendation: fetch on
|
||||
connect, refetch on `area_registry_updated`, skip if it fails (best-effort).
|
||||
3. **TLS verification for self-signed HA.** Homelab users often have
|
||||
self-signed certs. Need a `verify_tls: bool` config field (default true)
|
||||
and a clear warning when disabled. Same pattern as
|
||||
`NOTIFY_BRIDGE_ALLOW_PRIVATE_URLS` for the SSRF case.
|
||||
4. **Backpressure.** HA's `state_changed` can fire hundreds of events per
|
||||
minute in a busy install. The subscription manager must drop or coalesce
|
||||
if the dispatcher backlog grows beyond a threshold. Cheapest cut: a
|
||||
bounded `asyncio.Queue` between WS receiver and dispatch — `put_nowait`
|
||||
with overflow counter visible in the event log.
|
||||
5. **Entity filter precedence.** Tracking-config has `collection_ids`
|
||||
(entity_id list) and we want `entity_glob` + `domain_allowlist`. Decision:
|
||||
if both `collection_ids` and globs are set, union them (any match passes).
|
||||
Documented prominently in the tracker UI.
|
||||
6. **Library choice.** `hass-client` is a Python WS client maintained by the
|
||||
HA community; alternative is rolling our own with `websockets`. The
|
||||
latter is ~150 LOC and has no external dependency surface. Recommendation:
|
||||
roll our own. Re-evaluate if Phase 3 needs registry-aware service calls.
|
||||
|
||||
## Phase 2 — Bot Commands
|
||||
|
||||
Adds Telegram bot commands for HA tracking configs.
|
||||
|
||||
- `/status` — connection status, subscribed event count
|
||||
- `/entities <glob>` — list matching entities + current state
|
||||
- `/state <entity_id>` — full state + attributes for one entity
|
||||
- `/areas` — area registry summary
|
||||
- `/help`
|
||||
|
||||
These use the existing WS connection (no new client) via WS commands
|
||||
`get_states`, `config/area_registry/list`. Template slots and command
|
||||
template configs follow the same pattern as Gitea/Planka — see
|
||||
[CLAUDE.md](../../CLAUDE.md) rule 7 / rule 11 for the full set of locations
|
||||
that must be updated.
|
||||
|
||||
Out-of-scope for Phase 2: any command that mutates HA state.
|
||||
|
||||
## Phase 3 — Smart Actions (Service Calls)
|
||||
|
||||
A new action descriptor in the existing Smart Actions framework
|
||||
([packages/core/src/notify_bridge_core/providers/actions.py](../../packages/core/src/notify_bridge_core/providers/actions.py)).
|
||||
|
||||
- Action type: `ha_call_service`
|
||||
- Rule: trigger event → service call (e.g. "on motion event in
|
||||
`binary_sensor.front_door` → call `light.turn_on` on `light.porch`")
|
||||
- Executor uses the existing WS connection to send `call_service`.
|
||||
|
||||
This phase is gated behind explicit per-target authorization in the UI — HA
|
||||
service calls can do anything the access token allows, including unlocking
|
||||
doors. Default state: **disabled**, with a clear consent flow when enabling.
|
||||
|
||||
## Rough effort estimates
|
||||
|
||||
These are rough — sub-task discovery during Phase 1 will refine them.
|
||||
|
||||
| Phase | Estimate (focused work) |
|
||||
| --- | --- |
|
||||
| Phase 1 (subscribe + dispatch) | 2–3 sessions |
|
||||
| Phase 2 (bot commands) | 1 session |
|
||||
| Phase 3 (smart actions) | 1–2 sessions |
|
||||
|
||||
## When to start
|
||||
|
||||
Phase 1 work order, once you green-light it:
|
||||
|
||||
1. ABC extension (`base.py`) + tests for the new `subscribe` shape on a fake
|
||||
provider.
|
||||
2. WS client + parser + unit tests against recorded HA fixtures (no live HA
|
||||
needed for these).
|
||||
3. Subscription manager in `services/watcher.py` — integration test with the
|
||||
fake provider from step 1.
|
||||
4. Templates (en + ru), capabilities entry, validator whitelist.
|
||||
5. Server: seeds, sample context, template_configs entry.
|
||||
6. Frontend: descriptor, locale keys, i18n.
|
||||
7. End-to-end smoke test against a real HA instance (homelab).
|
||||
|
||||
Backend restart cadence per the project rule: after **every** change in
|
||||
`packages/server/` or `packages/core/`.
|
||||
|
||||
## Decision log
|
||||
|
||||
- **2026-05-13** — Plan drafted. Ingest mode = WebSocket (chosen over
|
||||
webhook for future-proofing toward Phases 2 + 3). Not started.
|
||||
@@ -505,3 +505,52 @@ button:focus-visible, a:focus-visible {
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Shared toggle switch — used by provider config forms, tracking-config
|
||||
extraTrackingFields, and anywhere else we render a boolean field.
|
||||
Kept global so adding a new ConfigField type='toggle' caller doesn't
|
||||
need to copy the CSS into its scoped <style>. */
|
||||
.toggle-switch {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
height: 1.75rem;
|
||||
}
|
||||
|
||||
.toggle-switch input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.toggle-switch .toggle-track {
|
||||
position: relative;
|
||||
width: 2.5rem;
|
||||
height: 1.375rem;
|
||||
background: var(--color-border);
|
||||
border-radius: 9999px;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.toggle-switch .toggle-track::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0.1875rem;
|
||||
left: 0.1875rem;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
background: var(--color-foreground);
|
||||
border-radius: 50%;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.toggle-switch input:checked + .toggle-track {
|
||||
background: var(--color-primary);
|
||||
}
|
||||
|
||||
.toggle-switch input:checked + .toggle-track::after {
|
||||
transform: translateX(1.125rem);
|
||||
background: var(--color-primary-foreground);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Free-text chip input. Bind a string[] of values; commit a new chip on
|
||||
* Enter, comma, or blur. Backspace on empty input deletes the last chip
|
||||
* for parity with native chip-input UX.
|
||||
*
|
||||
* Used by ProviderDescriptor.userFilters with inputMode === 'tags' for
|
||||
* free-text filter keys like Home Assistant's entity_glob and
|
||||
* domain_allowlist. Distinct from MultiEntitySelect, which renders a
|
||||
* picker dropdown sourced from an enumerable list.
|
||||
*/
|
||||
import MdiIcon from './MdiIcon.svelte';
|
||||
|
||||
interface Props {
|
||||
values: string[];
|
||||
onchange: (values: string[]) => void;
|
||||
placeholder?: string;
|
||||
icon?: string;
|
||||
/** Strip / reject anything matching this regex on each entry. */
|
||||
sanitize?: (raw: string) => string | null;
|
||||
}
|
||||
|
||||
let { values, onchange, placeholder = '', icon, sanitize }: Props = $props();
|
||||
|
||||
let draft = $state('');
|
||||
|
||||
function addRaw(raw: string): void {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return;
|
||||
const cleaned = sanitize ? sanitize(trimmed) : trimmed;
|
||||
if (!cleaned) return;
|
||||
if (values.includes(cleaned)) return;
|
||||
onchange([...values, cleaned]);
|
||||
}
|
||||
|
||||
function commitDraft(): void {
|
||||
if (!draft.trim()) return;
|
||||
// Allow comma-separated paste — split on commas and add each.
|
||||
for (const piece of draft.split(',')) {
|
||||
addRaw(piece);
|
||||
}
|
||||
draft = '';
|
||||
}
|
||||
|
||||
function removeAt(index: number): void {
|
||||
onchange(values.filter((_, i) => i !== index));
|
||||
}
|
||||
|
||||
function onKey(e: KeyboardEvent): void {
|
||||
if (e.key === 'Enter' || e.key === ',') {
|
||||
e.preventDefault();
|
||||
commitDraft();
|
||||
} else if (e.key === 'Backspace' && draft === '' && values.length > 0) {
|
||||
e.preventDefault();
|
||||
removeAt(values.length - 1);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="tag-input">
|
||||
{#each values as value, i (`${i}-${value}`)}
|
||||
<span class="tag-chip">
|
||||
{#if icon}<MdiIcon name={icon} size={12} />{/if}
|
||||
<span class="tag-text">{value}</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Remove"
|
||||
class="tag-remove"
|
||||
onclick={() => removeAt(i)}
|
||||
>×</button>
|
||||
</span>
|
||||
{/each}
|
||||
<input
|
||||
type="text"
|
||||
bind:value={draft}
|
||||
onkeydown={onKey}
|
||||
onblur={commitDraft}
|
||||
placeholder={values.length === 0 ? placeholder : ''}
|
||||
class="tag-draft"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.tag-input {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.375rem 0.5rem;
|
||||
min-height: 2.5rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.375rem;
|
||||
background: var(--color-background);
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.tag-input:focus-within {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.tag-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.1875rem 0.5rem;
|
||||
background: var(--color-muted);
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1;
|
||||
color: var(--color-foreground);
|
||||
}
|
||||
|
||||
.tag-text {
|
||||
font-family: var(--font-mono, monospace);
|
||||
}
|
||||
|
||||
.tag-remove {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
padding: 0;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--color-muted-foreground);
|
||||
cursor: pointer;
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
.tag-remove:hover {
|
||||
background: var(--color-border);
|
||||
color: var(--color-foreground);
|
||||
}
|
||||
|
||||
.tag-draft {
|
||||
flex: 1;
|
||||
min-width: 8rem;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-foreground);
|
||||
padding: 0.125rem 0;
|
||||
}
|
||||
|
||||
.tag-draft::placeholder {
|
||||
color: var(--color-muted-foreground);
|
||||
}
|
||||
</style>
|
||||
@@ -235,6 +235,13 @@
|
||||
"typeNut": "NUT (UPS)",
|
||||
"typeGooglePhotos": "Google Photos",
|
||||
"typeWebhook": "Generic Webhook",
|
||||
"typeHomeAssistant": "Home Assistant",
|
||||
"haAccessToken": "Long-Lived Access Token",
|
||||
"haAccessTokenKeep": "Long-Lived Access Token (leave empty to keep current)",
|
||||
"haAccessTokenHint": "Create one in HA → Profile → Long-Lived Access Tokens. Required for WebSocket subscription.",
|
||||
"haAccessTokenRequired": "Home Assistant access token is required.",
|
||||
"haVerifyTls": "Verify TLS certificate",
|
||||
"haVerifyTlsHint": "Disable only for self-signed HA on a trusted LAN. Keep enabled for any internet-reachable instance.",
|
||||
"loadError": "Failed to load providers.",
|
||||
"externalDomain": "External Domain",
|
||||
"optional": "optional",
|
||||
@@ -320,6 +327,13 @@
|
||||
"selectBoards": "Select boards...",
|
||||
"upsDevices": "UPS Devices",
|
||||
"selectUpsDevices": "Select UPS devices...",
|
||||
"entities": "Entities",
|
||||
"selectEntities": "Select entities...",
|
||||
"entities_count": "entity(ies)",
|
||||
"haEntityGlob": "Entity glob filter",
|
||||
"haEntityGlobPlaceholder": "light.*, binary_sensor.*_motion",
|
||||
"haDomainAllowlist": "Domain allowlist",
|
||||
"haDomainAllowlistPlaceholder": "light, switch, binary_sensor",
|
||||
"eventTypes": "Event Types",
|
||||
"notificationTargets": "Notification Targets",
|
||||
"scanInterval": "Scan Interval (seconds)",
|
||||
@@ -644,6 +658,11 @@
|
||||
"upsOverload": "UPS overloaded",
|
||||
"scheduledMessage": "Scheduled message",
|
||||
"webhookReceived": "Webhook received",
|
||||
"haStateChanged": "Entity state changed",
|
||||
"haAutomationTriggered": "Automation triggered",
|
||||
"haServiceCalled": "Service called",
|
||||
"haEventFired": "Other HA event (catch-all)",
|
||||
"haEventFiredHint": "Fires for any HA event type not covered by the boxes above. Useful for custom integrations; expect high volume.",
|
||||
"trackImages": "Track images",
|
||||
"trackVideos": "Track videos",
|
||||
"favoritesOnly": "Favorites only",
|
||||
@@ -1345,7 +1364,8 @@
|
||||
"providerScheduler": "Time-based scheduled messages",
|
||||
"providerNut": "Network UPS monitoring",
|
||||
"providerGooglePhotos": "Google Photos albums & shared libraries",
|
||||
"providerWebhook": "Receive events via HTTP POST"
|
||||
"providerWebhook": "Receive events via HTTP POST",
|
||||
"providerHomeAssistant": "Home Assistant event bus over WebSocket"
|
||||
},
|
||||
"webhookLogs": {
|
||||
"title": "Recent Payloads",
|
||||
|
||||
@@ -235,6 +235,13 @@
|
||||
"typeNut": "NUT (ИБП)",
|
||||
"typeGooglePhotos": "Google Фото",
|
||||
"typeWebhook": "Универсальный вебхук",
|
||||
"typeHomeAssistant": "Home Assistant",
|
||||
"haAccessToken": "Долгоживущий токен доступа",
|
||||
"haAccessTokenKeep": "Долгоживущий токен (оставьте пустым для сохранения)",
|
||||
"haAccessTokenHint": "Создайте в HA → Профиль → Long-Lived Access Tokens. Нужен для WebSocket-подписки.",
|
||||
"haAccessTokenRequired": "Токен доступа Home Assistant обязателен.",
|
||||
"haVerifyTls": "Проверять TLS-сертификат",
|
||||
"haVerifyTlsHint": "Отключайте только для самоподписанного HA в доверенной локальной сети. Оставляйте включённым для любого экземпляра, доступного из интернета.",
|
||||
"loadError": "Не удалось загрузить провайдеры.",
|
||||
"externalDomain": "Внешний домен",
|
||||
"optional": "необязательно",
|
||||
@@ -320,6 +327,13 @@
|
||||
"selectBoards": "Выберите доски...",
|
||||
"upsDevices": "ИБП устройства",
|
||||
"selectUpsDevices": "Выберите ИБП...",
|
||||
"entities": "Сущности",
|
||||
"selectEntities": "Выберите сущности...",
|
||||
"entities_count": "сущность(ей)",
|
||||
"haEntityGlob": "Фильтр по entity (glob)",
|
||||
"haEntityGlobPlaceholder": "light.*, binary_sensor.*_motion",
|
||||
"haDomainAllowlist": "Разрешённые домены",
|
||||
"haDomainAllowlistPlaceholder": "light, switch, binary_sensor",
|
||||
"eventTypes": "Типы событий",
|
||||
"notificationTargets": "Получатели уведомлений",
|
||||
"scanInterval": "Интервал проверки (секунды)",
|
||||
@@ -644,6 +658,11 @@
|
||||
"upsOverload": "Перегрузка ИБП",
|
||||
"scheduledMessage": "Запланированное сообщение",
|
||||
"webhookReceived": "Вебхук получен",
|
||||
"haStateChanged": "Состояние сущности изменилось",
|
||||
"haAutomationTriggered": "Сработала автоматизация",
|
||||
"haServiceCalled": "Вызвана служба",
|
||||
"haEventFired": "Прочее событие HA (catch-all)",
|
||||
"haEventFiredHint": "Срабатывает на любые типы событий HA, не охваченные чекбоксами выше. Полезно для пользовательских интеграций; ожидайте большой объём.",
|
||||
"trackImages": "Фото",
|
||||
"trackVideos": "Видео",
|
||||
"favoritesOnly": "Только избранные",
|
||||
@@ -1345,7 +1364,8 @@
|
||||
"providerScheduler": "Запланированные сообщения по расписанию",
|
||||
"providerNut": "Мониторинг ИБП через NUT",
|
||||
"providerGooglePhotos": "Альбомы и общие библиотеки Google Фото",
|
||||
"providerWebhook": "Приём событий через HTTP POST"
|
||||
"providerWebhook": "Приём событий через HTTP POST",
|
||||
"providerHomeAssistant": "Шина событий Home Assistant по WebSocket"
|
||||
},
|
||||
"webhookLogs": {
|
||||
"title": "Последние запросы",
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import type { ProviderDescriptor } from './types';
|
||||
|
||||
export const homeAssistantDescriptor: ProviderDescriptor = {
|
||||
type: 'home_assistant',
|
||||
defaultName: 'Home Assistant',
|
||||
icon: 'mdiHomeAssistant',
|
||||
hasUrl: true,
|
||||
urlPlaceholder: 'http://homeassistant.local:8123',
|
||||
|
||||
configFields: [
|
||||
{
|
||||
key: 'access_token', configKey: 'access_token',
|
||||
label: 'providers.haAccessToken', editLabel: 'providers.haAccessTokenKeep',
|
||||
type: 'password', required: 'create-only', hint: 'providers.haAccessTokenHint',
|
||||
},
|
||||
{
|
||||
key: 'verify_tls', configKey: 'verify_tls',
|
||||
label: 'providers.haVerifyTls',
|
||||
type: 'toggle', optional: true, hint: 'providers.haVerifyTlsHint',
|
||||
defaultValue: true,
|
||||
},
|
||||
],
|
||||
|
||||
buildConfig(form, editing) {
|
||||
const config: Record<string, unknown> = { url: form.url };
|
||||
if (form.access_token) config.access_token = form.access_token;
|
||||
// Coerce truthy/falsy form values to a real boolean. The toggle
|
||||
// control binds to `checked`, so this is normally already a bool,
|
||||
// but legacy form state may carry the string defaults.
|
||||
config.verify_tls = form.verify_tls === false || form.verify_tls === 'false' ? false : true;
|
||||
if (!editing && !form.access_token) {
|
||||
return { config, error: 'providers.haAccessTokenRequired' };
|
||||
}
|
||||
return { config };
|
||||
},
|
||||
|
||||
hasConfigChanged(form, existing) {
|
||||
const existingVerify = existing.verify_tls !== false;
|
||||
const formVerify = !(form.verify_tls === false || form.verify_tls === 'false');
|
||||
return (
|
||||
form.url !== (existing.url || '') ||
|
||||
!!form.access_token ||
|
||||
existingVerify !== formVerify
|
||||
);
|
||||
},
|
||||
|
||||
eventFields: [
|
||||
{ key: 'track_ha_state_changed', label: 'trackingConfig.haStateChanged', default: true },
|
||||
{ key: 'track_ha_automation_triggered', label: 'trackingConfig.haAutomationTriggered', default: false },
|
||||
{ key: 'track_ha_service_called', label: 'trackingConfig.haServiceCalled', default: false },
|
||||
{
|
||||
key: 'track_ha_event_fired',
|
||||
label: 'trackingConfig.haEventFired',
|
||||
default: false,
|
||||
hint: 'trackingConfig.haEventFiredHint',
|
||||
},
|
||||
],
|
||||
|
||||
// entity_glob / domain_allowlist tag-style filters. Stored on the
|
||||
// tracker's `filters` JSON column (not the flat form root) — the
|
||||
// TrackerForm reads `inputMode: 'tags'` to render a chip input rather
|
||||
// than a picker, and `filterKey` routes the value into
|
||||
// `tracker.filters[filterKey]` at save time.
|
||||
userFilters: [
|
||||
{
|
||||
key: 'entity_glob',
|
||||
filterKey: 'entity_glob',
|
||||
inputMode: 'tags',
|
||||
label: 'notificationTracker.haEntityGlob',
|
||||
placeholder: 'notificationTracker.haEntityGlobPlaceholder',
|
||||
icon: 'mdiAsterisk',
|
||||
},
|
||||
{
|
||||
key: 'domain_allowlist',
|
||||
filterKey: 'domain_allowlist',
|
||||
inputMode: 'tags',
|
||||
label: 'notificationTracker.haDomainAllowlist',
|
||||
placeholder: 'notificationTracker.haDomainAllowlistPlaceholder',
|
||||
icon: 'mdiTagOutline',
|
||||
},
|
||||
],
|
||||
|
||||
collectionMeta: {
|
||||
label: 'notificationTracker.entities',
|
||||
icon: 'mdiViewList',
|
||||
placeholder: 'notificationTracker.selectEntities',
|
||||
countLabel: 'notificationTracker.entities_count',
|
||||
desc: (col: { state?: string; domain?: string; entity_id?: string; id?: string }) => {
|
||||
const parts: string[] = [];
|
||||
if (col.domain) parts.push(col.domain);
|
||||
if (col.state) parts.push(col.state);
|
||||
if (parts.length === 0) return col.entity_id || col.id || '';
|
||||
return parts.join(' · ');
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -13,6 +13,7 @@ import { schedulerDescriptor } from './scheduler';
|
||||
import { nutDescriptor } from './nut';
|
||||
import { googlePhotosDescriptor } from './google-photos';
|
||||
import { webhookDescriptor } from './webhook';
|
||||
import { homeAssistantDescriptor } from './home-assistant';
|
||||
|
||||
const REGISTRY: ReadonlyMap<string, ProviderDescriptor> = new Map([
|
||||
['immich', immichDescriptor],
|
||||
@@ -22,6 +23,7 @@ const REGISTRY: ReadonlyMap<string, ProviderDescriptor> = new Map([
|
||||
['nut', nutDescriptor],
|
||||
['google_photos', googlePhotosDescriptor],
|
||||
['webhook', webhookDescriptor],
|
||||
['home_assistant', homeAssistantDescriptor],
|
||||
]);
|
||||
|
||||
/** Look up a provider descriptor by type. Returns null for unknown types. */
|
||||
|
||||
@@ -20,7 +20,7 @@ export interface ConfigField {
|
||||
configKey?: string;
|
||||
/** i18n key for the field label. */
|
||||
label: string;
|
||||
type: 'text' | 'password' | 'number' | 'grid-select';
|
||||
type: 'text' | 'password' | 'number' | 'grid-select' | 'toggle';
|
||||
/** Grid-select item source function name from grid-items.ts. */
|
||||
gridItems?: string;
|
||||
gridColumns?: number;
|
||||
@@ -123,17 +123,30 @@ export interface CollectionMeta {
|
||||
// ── User-identity filters (TrackerForm) ──────────────────────────────
|
||||
|
||||
/**
|
||||
* Declares a filter that picks user identities from the provider's known
|
||||
* senders. Rendered as a MultiEntitySelect populated from the provider's
|
||||
* `/users` endpoint. The picked values are stored as `string[]` under
|
||||
* `tracker.filters[key]`.
|
||||
* Declares a filter rendered on the tracker form. Two input modes:
|
||||
*
|
||||
* * ``picker`` (default) — populated from the provider's ``/users``
|
||||
* endpoint, rendered as a ``MultiEntitySelect``. Used for sender
|
||||
* allowlists / blocklists where the valid values are known.
|
||||
* * ``tags`` — free-text chip input. Used for glob patterns and other
|
||||
* filter values that aren't enumerable in advance.
|
||||
*
|
||||
* Either way the picked values are stored as ``string[]`` under
|
||||
* ``tracker.filters[filterKey ?? key]``.
|
||||
*/
|
||||
export interface UserFilterMeta {
|
||||
/** Filter key inside `tracker.filters` (e.g. "senders", "exclude_senders"). */
|
||||
/** Form field key — used internally for binding. */
|
||||
key: string;
|
||||
/** i18n key for the label rendered above the picker. */
|
||||
/**
|
||||
* Filter key inside ``tracker.filters``. Defaults to ``key`` when
|
||||
* omitted (backward compat with the original sender allowlist usage).
|
||||
*/
|
||||
filterKey?: string;
|
||||
/** ``picker`` (default) or ``tags`` for free-text chip input. */
|
||||
inputMode?: 'picker' | 'tags';
|
||||
/** i18n key for the label rendered above the input. */
|
||||
label: string;
|
||||
/** i18n key for the picker placeholder. */
|
||||
/** i18n key for the placeholder (picker dropdown or chip input). */
|
||||
placeholder: string;
|
||||
/** MDI icon shown on chips and dropdown rows. */
|
||||
icon: string;
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||
import EntitySelect from '$lib/components/EntitySelect.svelte';
|
||||
import MultiEntitySelect from '$lib/components/MultiEntitySelect.svelte';
|
||||
import TagInput from '$lib/components/TagInput.svelte';
|
||||
import { getDescriptor } from '$lib/providers';
|
||||
|
||||
interface Props {
|
||||
@@ -123,14 +124,24 @@
|
||||
{#if descriptor?.userFilters && descriptor.userFilters.length > 0}
|
||||
{@const userItems = users.map(u => ({ value: u.id, label: u.name }))}
|
||||
{#each descriptor.userFilters as uf (uf.key)}
|
||||
{@const filterKey = uf.filterKey ?? uf.key}
|
||||
<div>
|
||||
<div class="block text-sm font-medium mb-1">{t(uf.label)}</div>
|
||||
{#if uf.inputMode === 'tags'}
|
||||
<TagInput
|
||||
values={form.filters[filterKey] || []}
|
||||
onchange={(vals) => form.filters = { ...form.filters, [filterKey]: vals }}
|
||||
placeholder={t(uf.placeholder)}
|
||||
icon={uf.icon}
|
||||
/>
|
||||
{:else}
|
||||
<MultiEntitySelect
|
||||
items={userItems.map(i => ({ ...i, icon: uf.icon }))}
|
||||
values={form.filters[uf.key] || []}
|
||||
onchange={(vals) => form.filters = { ...form.filters, [uf.key]: vals }}
|
||||
values={form.filters[filterKey] || []}
|
||||
onchange={(vals) => form.filters = { ...form.filters, [filterKey]: vals }}
|
||||
placeholder={t(uf.placeholder)}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
@@ -321,6 +321,11 @@
|
||||
<input id="prv-{field.key}" type="number" bind:value={form[field.key]}
|
||||
min={field.min} max={field.max}
|
||||
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
{:else if field.type === 'toggle'}
|
||||
<label class="toggle-switch">
|
||||
<input id="prv-{field.key}" type="checkbox" bind:checked={form[field.key]} />
|
||||
<span class="toggle-track"></span>
|
||||
</label>
|
||||
{:else}
|
||||
<input id="prv-{field.key}" type={field.type} bind:value={form[field.key]}
|
||||
required={field.required === true || (field.required === 'create-only' && !editing)}
|
||||
|
||||
@@ -107,6 +107,11 @@
|
||||
{:else if field.type === 'number'}
|
||||
<input id="prv-{field.key}" type="number" bind:value={form[field.key]} min={field.min} max={field.max}
|
||||
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
{:else if field.type === 'toggle'}
|
||||
<label class="toggle-switch">
|
||||
<input id="prv-{field.key}" type="checkbox" bind:checked={form[field.key]} />
|
||||
<span class="toggle-track"></span>
|
||||
</label>
|
||||
{:else}
|
||||
<input id="prv-{field.key}" type={field.type} bind:value={form[field.key]}
|
||||
required={field.required === true || field.required === 'create-only'}
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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,
|
||||
"""Webhook-flavoured dispatch — thin wrapper over ``dispatch_provider_event``."""
|
||||
return await dispatch_provider_event(
|
||||
engine=engine,
|
||||
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,
|
||||
provider_config=provider_config,
|
||||
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,
|
||||
detail_keys=detail_keys,
|
||||
filter_fn=_passes_filters,
|
||||
)
|
||||
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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -34,12 +34,14 @@ def _auto_register() -> None:
|
||||
from .planka_handler import PlankaCommandHandler
|
||||
from .nut_handler import NutCommandHandler
|
||||
from .webhook_handler import WebhookCommandHandler
|
||||
from .home_assistant_handler import HomeAssistantCommandHandler
|
||||
|
||||
register_handler(ImmichCommandHandler())
|
||||
register_handler(GiteaCommandHandler())
|
||||
register_handler(PlankaCommandHandler())
|
||||
register_handler(NutCommandHandler())
|
||||
register_handler(WebhookCommandHandler())
|
||||
register_handler(HomeAssistantCommandHandler())
|
||||
|
||||
|
||||
# Auto-register on import
|
||||
|
||||
@@ -0,0 +1,375 @@
|
||||
"""Home Assistant bot command handler.
|
||||
|
||||
Phase 2 of the HA integration. Each command opens a fresh WebSocket
|
||||
connection to HA — same approach used by ``HomeAssistantServiceProvider.
|
||||
list_collections`` — so the handler does not need to coordinate with the
|
||||
long-lived subscription supervisor.
|
||||
|
||||
Commands:
|
||||
|
||||
* ``/status`` — connection health, subscribed area / entity counts.
|
||||
* ``/entities [glob]`` — list matching entities with their current state.
|
||||
* ``/state <entity_id>`` — full state + attributes for one entity.
|
||||
* ``/areas`` — area registry summary with entity counts per area.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from fnmatch import fnmatchcase
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
|
||||
from notify_bridge_core.providers.home_assistant import (
|
||||
HomeAssistantApiError,
|
||||
HomeAssistantAuthError,
|
||||
HomeAssistantWSClient,
|
||||
redact_ha_message,
|
||||
)
|
||||
|
||||
from ..database.models import (
|
||||
CommandConfig,
|
||||
CommandTracker,
|
||||
CommandTrackerListener,
|
||||
ServiceProvider,
|
||||
TelegramBot,
|
||||
)
|
||||
from ..services.http_session import get_http_session
|
||||
from .base import CommandResponse, ProviderCommandHandler
|
||||
from .handler import _render_cmd_template
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
_HA_COMMANDS = {"status", "entities", "state", "areas"}
|
||||
|
||||
|
||||
# HA exposes credentials and tokens through state attributes for some
|
||||
# integrations — most notably ``camera.*`` entities surface a working
|
||||
# ``access_token`` for the camera proxy URL, and ``entity_picture`` can
|
||||
# carry signed URLs. Filtering keys by substring blocklist before rendering
|
||||
# protects the chat user from seeing those values in /state output.
|
||||
#
|
||||
# Match is case-insensitive substring; tokens are intentionally generic so
|
||||
# custom integrations that follow the obvious naming conventions are also
|
||||
# covered. Anything not matched still renders.
|
||||
_SENSITIVE_ATTR_TOKENS: tuple[str, ...] = (
|
||||
"access_token",
|
||||
"token",
|
||||
"secret",
|
||||
"password",
|
||||
"passwd",
|
||||
"api_key",
|
||||
"apikey",
|
||||
"private_key",
|
||||
"session_id",
|
||||
"authorization",
|
||||
"bearer",
|
||||
"cookie",
|
||||
# ``entity_picture`` is a URL that often embeds a signed token in its
|
||||
# query string (HA generates these for camera and media_player entities).
|
||||
# The key itself doesn't match the credential token blocklist, so it
|
||||
# gets its own explicit entry.
|
||||
"entity_picture",
|
||||
)
|
||||
|
||||
# Attributes already rendered as top-level fields by the state template; no
|
||||
# point repeating them in the "Attributes" iteration.
|
||||
_TOP_LEVEL_ATTRS: frozenset[str] = frozenset({
|
||||
"friendly_name", "unit_of_measurement", "device_class",
|
||||
})
|
||||
|
||||
# Hard cap on the number of attributes shown in /state to prevent message
|
||||
# truncation when an entity has dozens (e.g. weather hourly forecasts,
|
||||
# light supported features). After the cap, an "and N more" line is added
|
||||
# by the template logic.
|
||||
_MAX_ATTRIBUTES_RENDERED = 30
|
||||
|
||||
|
||||
def _is_sensitive_attr(key: str) -> bool:
|
||||
lowered = str(key).lower()
|
||||
return any(tok in lowered for tok in _SENSITIVE_ATTR_TOKENS)
|
||||
|
||||
|
||||
def _filter_attributes(attrs: dict[str, Any]) -> tuple[dict[str, Any], int]:
|
||||
"""Drop sensitive keys, cap count, return ``(visible_attrs, hidden_count)``.
|
||||
|
||||
Hidden count covers both the security filter (blocklisted keys) and the
|
||||
size cap (entries beyond ``_MAX_ATTRIBUTES_RENDERED``). The template can
|
||||
surface "and N more hidden" so users know the view is incomplete.
|
||||
"""
|
||||
if not isinstance(attrs, dict):
|
||||
return {}, 0
|
||||
safe: dict[str, Any] = {}
|
||||
redacted = 0
|
||||
for key, value in attrs.items():
|
||||
if not isinstance(key, str):
|
||||
continue
|
||||
if key in _TOP_LEVEL_ATTRS:
|
||||
continue
|
||||
if _is_sensitive_attr(key):
|
||||
redacted += 1
|
||||
continue
|
||||
safe[key] = value
|
||||
overflow = max(0, len(safe) - _MAX_ATTRIBUTES_RENDERED)
|
||||
if overflow > 0:
|
||||
# Stable order — sort by key so the truncation point is deterministic.
|
||||
capped = dict(sorted(safe.items())[:_MAX_ATTRIBUTES_RENDERED])
|
||||
return capped, redacted + overflow
|
||||
return safe, redacted
|
||||
|
||||
|
||||
def _make_ws_client(provider: ServiceProvider, session: aiohttp.ClientSession) -> HomeAssistantWSClient:
|
||||
"""Build a one-shot WS client from the provider row."""
|
||||
config = provider.config or {}
|
||||
return HomeAssistantWSClient(
|
||||
session=session,
|
||||
base_url=config.get("url", ""),
|
||||
access_token=config.get("access_token", ""),
|
||||
verify_tls=bool(config.get("verify_tls", True)),
|
||||
)
|
||||
|
||||
|
||||
def _domain_of(entity_id: str) -> str:
|
||||
return entity_id.split(".", 1)[0] if "." in entity_id else ""
|
||||
|
||||
|
||||
def _normalize_state(state_row: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Flatten an HA state dict into the shape templates consume.
|
||||
|
||||
``attributes`` is filtered through ``_filter_attributes`` to drop
|
||||
credential-like keys (e.g. ``camera.access_token``) and cap the rendered
|
||||
count. ``hidden_attr_count`` is exposed so the template can surface
|
||||
"and N more hidden" if the user wants to see everything they need to
|
||||
use a different tool (or the HA UI itself).
|
||||
"""
|
||||
entity_id = state_row.get("entity_id") or ""
|
||||
raw_attrs = state_row.get("attributes") or {}
|
||||
visible_attrs, hidden_count = _filter_attributes(raw_attrs)
|
||||
return {
|
||||
"entity_id": entity_id,
|
||||
"friendly_name": raw_attrs.get("friendly_name") or entity_id,
|
||||
"domain": _domain_of(entity_id),
|
||||
"state": state_row.get("state"),
|
||||
"attributes": visible_attrs,
|
||||
"hidden_attr_count": hidden_count,
|
||||
"device_class": raw_attrs.get("device_class"),
|
||||
"unit_of_measurement": raw_attrs.get("unit_of_measurement"),
|
||||
"last_changed": state_row.get("last_changed"),
|
||||
"last_updated": state_row.get("last_updated"),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Command implementations
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _cmd_status(provider: ServiceProvider) -> dict[str, Any]:
|
||||
"""``/status`` — connection health + counts.
|
||||
|
||||
Health is derived from a live connection rather than the supervisor's
|
||||
in-memory state so the user sees what's happening *right now* if they
|
||||
just edited the token / URL. Connection + entity-count + area-count run
|
||||
on a single WS session so a healthy /status costs one TCP + TLS + WS +
|
||||
auth handshake instead of three.
|
||||
"""
|
||||
session = await get_http_session()
|
||||
client = _make_ws_client(provider, session)
|
||||
ok = True
|
||||
message = "OK"
|
||||
entity_count = 0
|
||||
area_count = 0
|
||||
|
||||
try:
|
||||
async with client.session() as sess:
|
||||
# Reaching here proves connect + auth succeeded.
|
||||
try:
|
||||
entity_count = len(await sess.get_states())
|
||||
except HomeAssistantApiError as err:
|
||||
_LOGGER.debug("HA /status get_states failed: %s", err)
|
||||
try:
|
||||
area_count = len(await sess.get_area_registry())
|
||||
except HomeAssistantApiError as err:
|
||||
_LOGGER.debug("HA /status get_area_registry failed: %s", err)
|
||||
except HomeAssistantAuthError as err:
|
||||
ok = False
|
||||
message = f"Auth failed: {redact_ha_message(str(err))}"
|
||||
except (aiohttp.ClientError, HomeAssistantApiError) as err:
|
||||
ok = False
|
||||
message = redact_ha_message(str(err)) or "Connection failed"
|
||||
|
||||
return {
|
||||
"ok": ok,
|
||||
"message": message,
|
||||
"provider_name": provider.name or "",
|
||||
"url": (provider.config or {}).get("url", ""),
|
||||
"entity_count": entity_count,
|
||||
"area_count": area_count,
|
||||
}
|
||||
|
||||
|
||||
async def _cmd_entities(provider: ServiceProvider, args: str, count: int) -> dict[str, Any]:
|
||||
"""``/entities [glob]`` — entities filtered by glob, capped at ``count``.
|
||||
|
||||
Empty args returns the first ``count`` entities in entity_id order. A
|
||||
glob pattern is matched against the entity_id (case-insensitive). The
|
||||
normalization step (which walks the attribute dict to redact secrets)
|
||||
runs **only on the survivors** — sorting and slicing happen on raw
|
||||
state rows first, so an HA install with 1000+ entities doesn't
|
||||
materialize 1000 normalized dicts just to discard most of them.
|
||||
"""
|
||||
session = await get_http_session()
|
||||
client = _make_ws_client(provider, session)
|
||||
try:
|
||||
async with client.session() as sess:
|
||||
states = await sess.get_states()
|
||||
except (HomeAssistantApiError, aiohttp.ClientError, HomeAssistantAuthError) as err:
|
||||
redacted = redact_ha_message(str(err))
|
||||
_LOGGER.warning("HA /entities failed: %s", redacted)
|
||||
return {"entities": [], "glob": args.strip(), "total": 0, "error": redacted}
|
||||
|
||||
glob = args.strip()
|
||||
if glob:
|
||||
lower_glob = glob.lower()
|
||||
raw_matches = [
|
||||
s for s in states
|
||||
if isinstance(s.get("entity_id"), str)
|
||||
and fnmatchcase(s["entity_id"].lower(), lower_glob)
|
||||
]
|
||||
else:
|
||||
raw_matches = [s for s in states if isinstance(s.get("entity_id"), str)]
|
||||
|
||||
total = len(raw_matches)
|
||||
raw_matches.sort(key=lambda s: s.get("entity_id", ""))
|
||||
return {
|
||||
"entities": [_normalize_state(s) for s in raw_matches[:count]],
|
||||
"glob": glob,
|
||||
"total": total,
|
||||
"shown": min(count, total),
|
||||
}
|
||||
|
||||
|
||||
async def _cmd_state(provider: ServiceProvider, args: str) -> dict[str, Any]:
|
||||
"""``/state <entity_id>`` — single-entity drill-down.
|
||||
|
||||
Returns ``found=False`` when the entity_id is missing or not present.
|
||||
Templates render the no-results fallback in that case. Uses the session
|
||||
context manager for consistency with the other commands even though
|
||||
there's only one underlying WS call today — leaves the door open for
|
||||
Phase 3 (service calls) to chain a follow-up on the same socket.
|
||||
"""
|
||||
target = args.strip()
|
||||
if not target:
|
||||
return {"found": False, "entity_id": "", "reason": "missing_arg"}
|
||||
|
||||
session = await get_http_session()
|
||||
client = _make_ws_client(provider, session)
|
||||
try:
|
||||
async with client.session() as sess:
|
||||
states = await sess.get_states()
|
||||
except (HomeAssistantApiError, aiohttp.ClientError, HomeAssistantAuthError) as err:
|
||||
redacted = redact_ha_message(str(err))
|
||||
_LOGGER.warning("HA /state failed: %s", redacted)
|
||||
return {"found": False, "entity_id": target, "reason": "api_error", "error": redacted}
|
||||
|
||||
for s in states:
|
||||
if s.get("entity_id") == target:
|
||||
normalized = _normalize_state(s)
|
||||
return {"found": True, **normalized}
|
||||
|
||||
return {"found": False, "entity_id": target, "reason": "not_found"}
|
||||
|
||||
|
||||
async def _cmd_areas(provider: ServiceProvider) -> dict[str, Any]:
|
||||
"""``/areas`` — area registry with per-area entity counts.
|
||||
|
||||
Areas without entities are still listed so users can see which areas
|
||||
exist in HA but haven't been assigned anything. The entity counts come
|
||||
from the entity registry, not the state list — the registry includes
|
||||
disabled entities, which matches what users see in the HA UI. Both
|
||||
registry calls share a single WS session so /areas costs one handshake.
|
||||
"""
|
||||
session = await get_http_session()
|
||||
client = _make_ws_client(provider, session)
|
||||
try:
|
||||
async with client.session() as sess:
|
||||
areas = await sess.get_area_registry()
|
||||
# Entity registry failure is non-fatal — areas can still be
|
||||
# listed without per-area counts.
|
||||
try:
|
||||
entities = await sess.get_entity_registry()
|
||||
except HomeAssistantApiError:
|
||||
entities = []
|
||||
except (HomeAssistantApiError, aiohttp.ClientError, HomeAssistantAuthError) as err:
|
||||
redacted = redact_ha_message(str(err))
|
||||
_LOGGER.warning("HA /areas failed: %s", redacted)
|
||||
return {"areas": [], "total": 0, "error": redacted}
|
||||
|
||||
counts: dict[str, int] = {}
|
||||
for ent in entities:
|
||||
area_id = ent.get("area_id")
|
||||
if isinstance(area_id, str):
|
||||
counts[area_id] = counts.get(area_id, 0) + 1
|
||||
|
||||
rows: list[dict[str, Any]] = []
|
||||
for a in areas:
|
||||
area_id = a.get("area_id")
|
||||
if not isinstance(area_id, str):
|
||||
continue
|
||||
rows.append({
|
||||
"area_id": area_id,
|
||||
"name": a.get("name") or area_id,
|
||||
"entity_count": counts.get(area_id, 0),
|
||||
})
|
||||
rows.sort(key=lambda r: r.get("name", "").lower())
|
||||
return {"areas": rows, "total": len(rows)}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Handler class
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class HomeAssistantCommandHandler(ProviderCommandHandler):
|
||||
"""Routes ``/status``, ``/entities``, ``/state``, ``/areas`` to the WS client."""
|
||||
|
||||
provider_type = "home_assistant"
|
||||
|
||||
def get_provider_commands(self) -> set[str]:
|
||||
return _HA_COMMANDS
|
||||
|
||||
def get_rate_categories(self) -> dict[str, str]:
|
||||
# All HA commands hit the WS API and share an "api" rate-limit bucket.
|
||||
return {cmd: "api" for cmd in _HA_COMMANDS}
|
||||
|
||||
async def handle(
|
||||
self,
|
||||
cmd: str,
|
||||
args: str,
|
||||
count: int,
|
||||
locale: str,
|
||||
response_mode: str, # noqa: ARG002 — HA has no media commands; always text
|
||||
provider: ServiceProvider,
|
||||
cmd_templates: dict[str, dict[str, str]],
|
||||
bot: TelegramBot, # noqa: ARG002
|
||||
tracker: CommandTracker, # noqa: ARG002
|
||||
config: CommandConfig, # noqa: ARG002
|
||||
*,
|
||||
listener: CommandTrackerListener | None = None, # noqa: ARG002
|
||||
allowed_album_ids: set[str] | None = None, # noqa: ARG002 — HA has no album scope
|
||||
page: int = 1, # noqa: ARG002 — no pagination in v1
|
||||
) -> CommandResponse | None:
|
||||
if cmd == "status":
|
||||
ctx = await _cmd_status(provider)
|
||||
elif cmd == "entities":
|
||||
ctx = await _cmd_entities(provider, args, count)
|
||||
elif cmd == "state":
|
||||
ctx = await _cmd_state(provider, args)
|
||||
elif cmd == "areas":
|
||||
ctx = await _cmd_areas(provider)
|
||||
else:
|
||||
return None
|
||||
|
||||
return CommandResponse(text=_render_cmd_template(cmd_templates, cmd, locale, ctx))
|
||||
@@ -11,6 +11,8 @@ _RATE_CATEGORY: dict[str, str] = {
|
||||
"repos": "api", "issues": "api", "prs": "api", "commits": "api",
|
||||
# Planka (API calls share a category)
|
||||
"boards": "api", "cards": "api", "lists": "api",
|
||||
# Home Assistant (WebSocket queries share a category)
|
||||
"entities": "api", "state": "api", "areas": "api",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -292,6 +292,23 @@ async def migrate_schema(engine: AsyncEngine) -> None:
|
||||
)
|
||||
logger.info("Added track_webhook_received column to tracking_config table")
|
||||
|
||||
# Add Home Assistant tracking flags to tracking_config if missing.
|
||||
# state_changed defaults ON to match the canonical "watch the state bus"
|
||||
# use case; the other three are loud and opt-in (defaults 0).
|
||||
if await _has_table(conn, "tracking_config"):
|
||||
ha_flags = [
|
||||
("track_ha_state_changed", "INTEGER DEFAULT 1"),
|
||||
("track_ha_automation_triggered", "INTEGER DEFAULT 0"),
|
||||
("track_ha_service_called", "INTEGER DEFAULT 0"),
|
||||
("track_ha_event_fired", "INTEGER DEFAULT 0"),
|
||||
]
|
||||
for col_name, col_type in ha_flags:
|
||||
if not await _has_column(conn, "tracking_config", col_name):
|
||||
await conn.execute(
|
||||
text(f"ALTER TABLE tracking_config ADD COLUMN {col_name} {col_type}")
|
||||
)
|
||||
logger.info("Added %s column to tracking_config table", col_name)
|
||||
|
||||
# Add quiet hours to tracking_config if missing.
|
||||
# Start/end are nullable HH:MM strings; quiet_hours_enabled gates them.
|
||||
if await _has_table(conn, "tracking_config"):
|
||||
|
||||
@@ -165,6 +165,12 @@ class TrackingConfig(SQLModel, table=True):
|
||||
# Generic Webhook event tracking
|
||||
track_webhook_received: bool = Field(default=True)
|
||||
|
||||
# Home Assistant event tracking
|
||||
track_ha_state_changed: bool = Field(default=True)
|
||||
track_ha_automation_triggered: bool = Field(default=False)
|
||||
track_ha_service_called: bool = Field(default=False)
|
||||
track_ha_event_fired: bool = Field(default=False)
|
||||
|
||||
# Immich asset display
|
||||
track_images: bool = Field(default=True)
|
||||
track_videos: bool = Field(default=True)
|
||||
|
||||
@@ -158,6 +158,7 @@ async def _seed_default_templates() -> None:
|
||||
await _seed_provider_template(session, "nut", "NUT")
|
||||
await _seed_provider_template(session, "google_photos", "Google Photos")
|
||||
await _seed_provider_template(session, "webhook", "Generic Webhook")
|
||||
await _seed_provider_template(session, "home_assistant", "Home Assistant")
|
||||
await session.commit()
|
||||
|
||||
|
||||
@@ -187,6 +188,10 @@ async def _seed_default_command_templates() -> None:
|
||||
await _seed_provider_command_template(
|
||||
session, "webhook", "Default Webhook Commands", "Default Generic Webhook command templates",
|
||||
)
|
||||
await _seed_provider_command_template(
|
||||
session, "home_assistant", "Default Home Assistant Commands",
|
||||
"Default Home Assistant command templates",
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
|
||||
@@ -272,6 +277,14 @@ async def _seed_default_tracking_configs() -> None:
|
||||
"track_ups_replace_battery": True,
|
||||
"track_ups_overload": True,
|
||||
},
|
||||
{
|
||||
"provider_type": "home_assistant",
|
||||
"name": "Default Home Assistant",
|
||||
"track_ha_state_changed": True,
|
||||
"track_ha_automation_triggered": False,
|
||||
"track_ha_service_called": False,
|
||||
"track_ha_event_fired": False,
|
||||
},
|
||||
]
|
||||
|
||||
for cfg in defaults:
|
||||
|
||||
@@ -139,12 +139,20 @@ async def lifespan(app: FastAPI):
|
||||
set_webhook_secret(_secret or None)
|
||||
from .services.scheduler import start_scheduler, get_scheduler
|
||||
await start_scheduler()
|
||||
# Phase 1 of the Home Assistant provider: subscription-based ingest runs
|
||||
# outside the polling scheduler. ``start_all`` spawns one supervisor task
|
||||
# per enabled HA provider row. No-op when no HA providers are configured.
|
||||
from .services.ha_subscription import start_all as start_ha_subscriptions
|
||||
await start_ha_subscriptions()
|
||||
_READY = True
|
||||
yield
|
||||
# Graceful shutdown — stop the scheduler FIRST so in-flight jobs finish
|
||||
# before we close their HTTP session. Then close the shared session and
|
||||
# dispose the DB engine.
|
||||
# Graceful shutdown — cancel HA supervisors FIRST so they release their
|
||||
# WS connections before the shared HTTP session is closed. Then stop the
|
||||
# polling scheduler. Order matters: scheduler.shutdown(wait=True) drains
|
||||
# in-flight jobs that may also use the shared session.
|
||||
_READY = False
|
||||
from .services.ha_subscription import stop_all as stop_ha_subscriptions
|
||||
await stop_ha_subscriptions()
|
||||
scheduler = get_scheduler()
|
||||
if scheduler.running:
|
||||
scheduler.shutdown(wait=True)
|
||||
|
||||
@@ -115,6 +115,16 @@ def _make_collection_provider(
|
||||
return make_planka_provider(http_session, provider)
|
||||
if ptype == "google_photos":
|
||||
return make_google_photos_provider(http_session, provider)
|
||||
if ptype == "home_assistant":
|
||||
from notify_bridge_core.providers.home_assistant import HomeAssistantServiceProvider
|
||||
return HomeAssistantServiceProvider(
|
||||
session=http_session,
|
||||
url=config.get("url", ""),
|
||||
access_token=config.get("access_token", ""),
|
||||
verify_tls=bool(config.get("verify_tls", True)),
|
||||
event_types=config.get("event_types") or None,
|
||||
name=provider.name,
|
||||
)
|
||||
# NUT provider needs no http_session
|
||||
if ptype == "nut":
|
||||
return make_nut_provider(provider) # type: ignore[return-value]
|
||||
@@ -122,7 +132,7 @@ def _make_collection_provider(
|
||||
|
||||
|
||||
# Set of provider types that need an aiohttp session for collection listing.
|
||||
_HTTP_COLLECTION_PROVIDERS = {"immich", "gitea", "planka", "google_photos"}
|
||||
_HTTP_COLLECTION_PROVIDERS = {"immich", "gitea", "planka", "google_photos", "home_assistant"}
|
||||
|
||||
|
||||
async def list_provider_collections(provider: ServiceProvider) -> list[dict[str, Any]]:
|
||||
|
||||
@@ -204,6 +204,12 @@ def _event_type_enabled(event: ServiceEvent, tc: TrackingConfig) -> bool:
|
||||
"ups_comms_restored": tc.track_ups_comms_restored,
|
||||
"ups_replace_battery": tc.track_ups_replace_battery,
|
||||
"ups_overload": tc.track_ups_overload,
|
||||
# Home Assistant events — use getattr so legacy DB rows / test mocks
|
||||
# that pre-date the columns still pass the gate (default to tracked).
|
||||
"ha_state_changed": getattr(tc, "track_ha_state_changed", True),
|
||||
"ha_automation_triggered": getattr(tc, "track_ha_automation_triggered", False),
|
||||
"ha_service_called": getattr(tc, "track_ha_service_called", False),
|
||||
"ha_event_fired": getattr(tc, "track_ha_event_fired", False),
|
||||
}
|
||||
return flag_map.get(event_type, True)
|
||||
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
"""Shared dispatch helper for push-style providers.
|
||||
|
||||
Push-style providers (webhook receivers in ``api/webhooks.py`` and the
|
||||
Home Assistant subscription manager in ``services/ha_subscription.py``)
|
||||
share the same downstream pipeline: write an :class:`EventLog`, evaluate
|
||||
quiet hours / event-type gates, defer if needed, otherwise hand off to the
|
||||
:class:`NotificationDispatcher`.
|
||||
|
||||
This module extracts that pipeline so both callers can reuse it without
|
||||
either side importing from the other (which would create a server/api ->
|
||||
services -> api cycle).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Awaitable, Callable
|
||||
|
||||
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 ..database.models import EventLog, NotificationTracker
|
||||
from .deferred_dispatch import defer_event, is_deferrable
|
||||
from .dispatch_helpers import (
|
||||
GateReason,
|
||||
apply_tracking_display_filters,
|
||||
evaluate_event_gate,
|
||||
get_app_timezone,
|
||||
load_link_data,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Filter signature: ``(event, tracker.filters dict) -> bool``. Returning False
|
||||
# drops the event for that tracker before any DB writes happen. Callers pass
|
||||
# provider-specific logic (Gitea sender allowlist, HA entity glob, etc.).
|
||||
FilterFn = Callable[[ServiceEvent, dict[str, Any]], bool]
|
||||
|
||||
|
||||
async def dispatch_provider_event(
|
||||
engine: Any,
|
||||
provider_id: int,
|
||||
provider_name: str,
|
||||
provider_config: dict[str, Any],
|
||||
event: ServiceEvent,
|
||||
detail_keys: tuple[str, ...],
|
||||
filter_fn: FilterFn,
|
||||
) -> int:
|
||||
"""Load matching trackers, log, gate, defer, and dispatch one event.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
engine:
|
||||
SQLAlchemy async engine.
|
||||
provider_id:
|
||||
ID of the :class:`ServiceProvider` the event came from.
|
||||
provider_name:
|
||||
Human-readable name (for logging only).
|
||||
provider_config:
|
||||
``ServiceProvider.config`` dict; flowed into :class:`TargetConfig`.
|
||||
event:
|
||||
Parsed :class:`ServiceEvent` to dispatch.
|
||||
detail_keys:
|
||||
Keys from ``event.extra`` to copy into ``EventLog.details``.
|
||||
filter_fn:
|
||||
Per-event tracker-level filter. Returning False drops the event for
|
||||
that tracker before any DB writes.
|
||||
|
||||
Returns
|
||||
-------
|
||||
int
|
||||
Number of successfully dispatched notifications across all trackers.
|
||||
"""
|
||||
dispatched = 0
|
||||
# Drain-scheduling is best-effort: a scheduling failure must not roll
|
||||
# back the persisted defer rows (startup catch-up re-establishes them).
|
||||
defers_to_schedule: set[Any] = set()
|
||||
async with AsyncSession(engine) as session:
|
||||
# App timezone is identical across trackers in one inbound event;
|
||||
# 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()
|
||||
|
||||
for tracker in trackers:
|
||||
filters = tracker.filters or {}
|
||||
if not filter_fn(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
|
||||
|
||||
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, sharing the same parent id — the
|
||||
# deferred row is one-per-link, so we call defer_event only
|
||||
# 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 .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 .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
|
||||
@@ -0,0 +1,293 @@
|
||||
"""Home Assistant subscription manager.
|
||||
|
||||
Phase 1 of the HA provider lives here. For every enabled ``home_assistant``
|
||||
:class:`ServiceProvider` row in the DB, this module spawns one long-running
|
||||
asyncio task that:
|
||||
|
||||
1. Builds an :class:`HomeAssistantServiceProvider` from the provider row.
|
||||
2. Calls ``provider.subscribe(emit)`` which loops forever — connect,
|
||||
authenticate, subscribe, drain events through ``emit`` — and reconnects
|
||||
with exponential backoff on any drop.
|
||||
3. Each ``emit`` call hands the parsed :class:`ServiceEvent` to
|
||||
:func:`dispatch_provider_event` (the shared dispatch helper that webhook
|
||||
providers also use), so quiet hours, deferred dispatch, and event-log
|
||||
writes all behave identically to the rest of the system.
|
||||
|
||||
Lifecycle is owned by ``main.py`` via :func:`start_all` and :func:`stop_all`.
|
||||
Phase 1 does not reconcile against DB changes after boot — adding,
|
||||
modifying, or removing a HA provider requires a server restart. Phase 1.5
|
||||
will add a CRUD-triggered :func:`reload_provider` hook.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from sqlmodel import select
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from notify_bridge_core.models.events import ServiceEvent
|
||||
from notify_bridge_core.providers.home_assistant import (
|
||||
HomeAssistantAuthError,
|
||||
HomeAssistantServiceProvider,
|
||||
)
|
||||
|
||||
from ..database.engine import get_engine
|
||||
from ..database.models import ServiceProvider
|
||||
from .event_dispatch import dispatch_provider_event
|
||||
from .http_session import get_http_session
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Per-provider running task. Keyed by provider_id so reload_provider() can
|
||||
# find and replace a single task without disturbing the rest.
|
||||
_running_tasks: dict[int, asyncio.Task[None]] = {}
|
||||
|
||||
|
||||
# Keys from ``event.extra`` to copy into ``EventLog.details``. Anything not
|
||||
# in this list is still available to templates via the merged extras, but
|
||||
# the event-log row stays slim.
|
||||
_HA_DETAIL_KEYS: tuple[str, ...] = (
|
||||
"entity_id",
|
||||
"friendly_name",
|
||||
"domain",
|
||||
"old_state",
|
||||
"new_state",
|
||||
"device_class",
|
||||
"unit_of_measurement",
|
||||
"area",
|
||||
"ha_event_type",
|
||||
"automation_name",
|
||||
"service_called",
|
||||
"target_entity",
|
||||
)
|
||||
|
||||
|
||||
def _ha_passes_filters(event: ServiceEvent, filters: dict[str, Any]) -> bool:
|
||||
"""HA-specific tracker filter.
|
||||
|
||||
Three filter keys, all optional, evaluated as a union: if the entity
|
||||
matches any one of them, the event passes. Empty filters mean "accept
|
||||
everything" — different from the Gitea filter which is an intersection.
|
||||
|
||||
Filter shape:
|
||||
|
||||
* ``collections`` — list of exact ``entity_id`` matches.
|
||||
* ``entity_glob`` — list of glob patterns (``light.*``, ``*_motion``).
|
||||
* ``domain_allowlist`` — list of HA domain prefixes (``light``).
|
||||
"""
|
||||
collections = filters.get("collections") or []
|
||||
entity_globs = filters.get("entity_glob") or []
|
||||
domain_allowlist = filters.get("domain_allowlist") or []
|
||||
|
||||
# No filters configured = accept everything.
|
||||
if not collections and not entity_globs and not domain_allowlist:
|
||||
return True
|
||||
|
||||
entity_id = event.collection_id
|
||||
domain = event.extra.get("domain") or (
|
||||
entity_id.split(".", 1)[0] if "." in entity_id else ""
|
||||
)
|
||||
|
||||
if collections and entity_id in collections:
|
||||
return True
|
||||
if domain_allowlist and domain in domain_allowlist:
|
||||
return True
|
||||
if entity_globs:
|
||||
from fnmatch import fnmatchcase
|
||||
for pattern in entity_globs:
|
||||
if isinstance(pattern, str) and fnmatchcase(entity_id, pattern):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
async def _run_provider(provider_id: int) -> None:
|
||||
"""One per-provider supervisor loop.
|
||||
|
||||
Reloads the provider row each iteration so config changes (URL, token,
|
||||
event types) take effect on the next reconnect cycle — no need for a
|
||||
full restart in the simple case where only credentials changed.
|
||||
|
||||
The ``_emit`` closure is rebuilt every iteration. Its lifetime equals
|
||||
one ``subscribe()`` call: the callback only runs while the HA client's
|
||||
drain task is alive. ``provider_name`` is snapshotted at the start of
|
||||
each (re)connect cycle, so renames take effect on the next reconnect —
|
||||
chatty enough for v1; revisit if longer-lived WS sessions need fresher
|
||||
names mid-stream.
|
||||
"""
|
||||
assert provider_id is not None, "_run_provider requires a real provider id"
|
||||
engine = get_engine()
|
||||
while True:
|
||||
try:
|
||||
async with AsyncSession(engine) as session:
|
||||
row = await session.get(ServiceProvider, provider_id)
|
||||
if row is None or row.type != "home_assistant":
|
||||
_LOGGER.info(
|
||||
"HA provider %s removed or retyped, stopping supervisor",
|
||||
provider_id,
|
||||
)
|
||||
return
|
||||
config = dict(row.config or {})
|
||||
provider_name = row.name
|
||||
|
||||
url = config.get("url", "")
|
||||
access_token = config.get("access_token", "")
|
||||
verify_tls = bool(config.get("verify_tls", True))
|
||||
event_types = config.get("event_types") or None
|
||||
|
||||
if not url or not access_token:
|
||||
_LOGGER.warning(
|
||||
"HA provider %s missing url or access_token; retrying in 60s",
|
||||
provider_id,
|
||||
)
|
||||
await asyncio.sleep(60)
|
||||
continue
|
||||
|
||||
session_http = await get_http_session()
|
||||
ha_provider = HomeAssistantServiceProvider(
|
||||
session=session_http,
|
||||
url=url,
|
||||
access_token=access_token,
|
||||
verify_tls=verify_tls,
|
||||
event_types=event_types,
|
||||
name=provider_name,
|
||||
)
|
||||
|
||||
async def _emit(event: ServiceEvent) -> None:
|
||||
# Shield the DB-writing dispatch from external cancellation
|
||||
# (shutdown, supervisor restart). The shield ensures that
|
||||
# once a transaction is mid-flight, it commits or rolls back
|
||||
# cleanly instead of being torn down with the asyncio task
|
||||
# at a write boundary. Worst case: shutdown waits up to one
|
||||
# dispatch latency longer.
|
||||
#
|
||||
# Perf note (Phase 2 follow-up): dispatch_provider_event
|
||||
# opens a fresh AsyncSession per call. For HA's chatty
|
||||
# state_changed bus this hammers the pool; batch in a
|
||||
# follow-up.
|
||||
try:
|
||||
await asyncio.shield(dispatch_provider_event(
|
||||
engine=engine,
|
||||
provider_id=provider_id,
|
||||
provider_name=provider_name,
|
||||
provider_config=config,
|
||||
event=event,
|
||||
detail_keys=_HA_DETAIL_KEYS,
|
||||
filter_fn=_ha_passes_filters,
|
||||
))
|
||||
except asyncio.CancelledError:
|
||||
# Shield re-raises CancelledError to the caller; let it
|
||||
# propagate so the drain task exits cleanly.
|
||||
raise
|
||||
except Exception: # noqa: BLE001
|
||||
_LOGGER.exception(
|
||||
"Failed to dispatch HA event for provider %s",
|
||||
provider_id,
|
||||
)
|
||||
|
||||
_LOGGER.info(
|
||||
"Starting HA subscription for provider %s (%s)",
|
||||
provider_id, provider_name,
|
||||
)
|
||||
await ha_provider.subscribe(_emit)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except HomeAssistantAuthError as err:
|
||||
# Fatal at the provider level — bad token. Sleep long and retry
|
||||
# so the user has time to fix the token without us hammering HA.
|
||||
# Error string already redacted by the client before re-raise.
|
||||
_LOGGER.error(
|
||||
"HA provider %s auth failed: %s — retrying in 5 minutes",
|
||||
provider_id, err,
|
||||
)
|
||||
await asyncio.sleep(300)
|
||||
except Exception: # noqa: BLE001
|
||||
_LOGGER.exception(
|
||||
"HA supervisor for provider %s crashed; restarting in 30s",
|
||||
provider_id,
|
||||
)
|
||||
await asyncio.sleep(30)
|
||||
|
||||
|
||||
def _make_done_callback(provider_id: int):
|
||||
"""Return a done-callback that prunes the task from ``_running_tasks``.
|
||||
|
||||
Without this, finished supervisors (provider deleted, fatal auth error
|
||||
after long sleep) leave stale entries in the dict — across many
|
||||
reload cycles the dict would grow unboundedly. The callback is
|
||||
registered on every task spawned via ``start_all`` / ``reload_provider``.
|
||||
"""
|
||||
def _cb(task: asyncio.Task[None]) -> None:
|
||||
current = _running_tasks.get(provider_id)
|
||||
if current is task:
|
||||
_running_tasks.pop(provider_id, None)
|
||||
return _cb
|
||||
|
||||
|
||||
async def start_all() -> None:
|
||||
"""Start a supervisor task for every enabled HA provider."""
|
||||
engine = get_engine()
|
||||
async with AsyncSession(engine) as session:
|
||||
result = await session.exec(
|
||||
select(ServiceProvider).where(
|
||||
ServiceProvider.type == "home_assistant",
|
||||
)
|
||||
)
|
||||
providers = result.all()
|
||||
|
||||
for prov in providers:
|
||||
if prov.id in _running_tasks and not _running_tasks[prov.id].done():
|
||||
continue
|
||||
task = asyncio.create_task(
|
||||
_run_provider(prov.id),
|
||||
name=f"ha-subscription-{prov.id}",
|
||||
)
|
||||
task.add_done_callback(_make_done_callback(prov.id))
|
||||
_running_tasks[prov.id] = task
|
||||
if providers:
|
||||
_LOGGER.info(
|
||||
"Started HA subscription manager: %d provider(s)", len(providers),
|
||||
)
|
||||
|
||||
|
||||
async def stop_all() -> None:
|
||||
"""Cancel every HA supervisor task and wait for clean shutdown."""
|
||||
if not _running_tasks:
|
||||
return
|
||||
for task in _running_tasks.values():
|
||||
task.cancel()
|
||||
# Wait for all to drain; swallow cancellation errors.
|
||||
await asyncio.gather(*_running_tasks.values(), return_exceptions=True)
|
||||
_running_tasks.clear()
|
||||
_LOGGER.info("Stopped all HA subscription supervisors")
|
||||
|
||||
|
||||
async def reload_provider(provider_id: int) -> None:
|
||||
"""Stop and restart the supervisor for a single provider id.
|
||||
|
||||
Hook for the provider CRUD routes — Phase 1.5 will wire it in. For Phase
|
||||
1, configure-then-restart-backend is the supported flow.
|
||||
"""
|
||||
task = _running_tasks.pop(provider_id, None)
|
||||
if task is not None:
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except (asyncio.CancelledError, Exception): # noqa: BLE001
|
||||
pass
|
||||
|
||||
engine = get_engine()
|
||||
async with AsyncSession(engine) as session:
|
||||
prov = await session.get(ServiceProvider, provider_id)
|
||||
if prov is None or prov.type != "home_assistant":
|
||||
return
|
||||
|
||||
new_task = asyncio.create_task(
|
||||
_run_provider(provider_id),
|
||||
name=f"ha-subscription-{provider_id}",
|
||||
)
|
||||
new_task.add_done_callback(_make_done_callback(provider_id))
|
||||
_running_tasks[provider_id] = new_task
|
||||
@@ -213,4 +213,25 @@ _SAMPLE_CONTEXT = {
|
||||
"raw_payload": {"action": "opened", "issue": {"title": "Bug report", "number": 1}, "sender": {"login": "user1"}},
|
||||
"event_type_raw": "webhook_received",
|
||||
"source_ip": "192.168.1.100",
|
||||
# Home Assistant variables (for home_assistant provider templates)
|
||||
"friendly_name": "Front Door",
|
||||
"entity_id": "binary_sensor.front_door",
|
||||
"domain": "binary_sensor",
|
||||
"old_state": "off",
|
||||
"new_state": "on",
|
||||
"attributes": {"friendly_name": "Front Door", "device_class": "door"},
|
||||
"device_class": "door",
|
||||
"unit_of_measurement": "",
|
||||
"area": "Entrance",
|
||||
"last_changed": "2026-05-13T12:34:56.789+00:00",
|
||||
"last_updated": "2026-05-13T12:34:56.789+00:00",
|
||||
"automation_name": "Front door notification",
|
||||
"trigger_source": "state of binary_sensor.front_door",
|
||||
"service_called": "light.turn_on",
|
||||
"service_domain": "light",
|
||||
"service_name": "turn_on",
|
||||
"service_data": {"entity_id": "light.kitchen"},
|
||||
"target_entity": "light.kitchen",
|
||||
"ha_event_type": "state_changed",
|
||||
"event_data": {"foo": "bar"},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
"""Unit tests for HA bot command helpers — Phase 2.
|
||||
|
||||
Focus on the security-sensitive bits the reviewer flagged: attribute
|
||||
filtering, error-message redaction, and the sample-context shape that
|
||||
flows through Jinja preview rendering.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from notify_bridge_server.commands.home_assistant_handler import (
|
||||
_filter_attributes,
|
||||
_is_sensitive_attr,
|
||||
_normalize_state,
|
||||
)
|
||||
|
||||
|
||||
def test_filter_attributes_drops_credential_keys() -> None:
|
||||
"""HA camera entities expose an ``access_token`` attribute. The handler
|
||||
MUST NOT surface it to the chat user via /state."""
|
||||
raw = {
|
||||
"friendly_name": "Front Camera",
|
||||
"access_token": "real-camera-proxy-token",
|
||||
"entity_picture": "/api/camera_proxy/...?token=abc",
|
||||
"brightness": 200,
|
||||
}
|
||||
safe, hidden = _filter_attributes(raw)
|
||||
assert "access_token" not in safe
|
||||
# entity_picture contains 'token' substring → blocked.
|
||||
assert "entity_picture" not in safe
|
||||
# friendly_name is rendered as a top-level field, not iterated.
|
||||
assert "friendly_name" not in safe
|
||||
# brightness is a normal attribute, passes through.
|
||||
assert safe["brightness"] == 200
|
||||
assert hidden == 2
|
||||
|
||||
|
||||
def test_filter_attributes_caps_count() -> None:
|
||||
"""When an entity has dozens of attributes the renderer would overflow
|
||||
Telegram's 4096-char message limit. Cap at 30 with overflow surfaced."""
|
||||
raw = {f"attr_{i:03d}": i for i in range(50)}
|
||||
safe, hidden = _filter_attributes(raw)
|
||||
assert len(safe) == 30
|
||||
assert hidden == 20
|
||||
|
||||
|
||||
def test_is_sensitive_attr_case_insensitive() -> None:
|
||||
"""Match should not depend on key casing — custom integrations are
|
||||
inconsistent about capitalization."""
|
||||
assert _is_sensitive_attr("Access_Token") is True
|
||||
assert _is_sensitive_attr("API_KEY") is True
|
||||
assert _is_sensitive_attr("password") is True
|
||||
assert _is_sensitive_attr("brightness") is False
|
||||
assert _is_sensitive_attr("color_mode") is False
|
||||
|
||||
|
||||
def test_normalize_state_filters_attrs() -> None:
|
||||
"""End-to-end: feed _normalize_state a malicious state row, verify the
|
||||
output has redacted attributes + hidden_attr_count surfaced."""
|
||||
state_row = {
|
||||
"entity_id": "camera.front_door",
|
||||
"state": "idle",
|
||||
"attributes": {
|
||||
"friendly_name": "Front Door Camera",
|
||||
"access_token": "leaked",
|
||||
"brand": "Reolink",
|
||||
},
|
||||
"last_changed": "2026-05-13T12:00:00+00:00",
|
||||
"last_updated": "2026-05-13T12:00:00+00:00",
|
||||
}
|
||||
out = _normalize_state(state_row)
|
||||
assert out["entity_id"] == "camera.front_door"
|
||||
assert out["friendly_name"] == "Front Door Camera"
|
||||
assert out["domain"] == "camera"
|
||||
# Top-level fields preserved.
|
||||
assert out["state"] == "idle"
|
||||
# Attributes dict is filtered.
|
||||
assert "access_token" not in out["attributes"]
|
||||
assert out["attributes"].get("brand") == "Reolink"
|
||||
# Hidden count reflects access_token (friendly_name is top-level, not redacted).
|
||||
assert out["hidden_attr_count"] == 1
|
||||
|
||||
|
||||
def test_normalize_state_handles_missing_attributes() -> None:
|
||||
"""A state row with no attributes dict should not crash."""
|
||||
out = _normalize_state({"entity_id": "sensor.x", "state": "1"})
|
||||
assert out["attributes"] == {}
|
||||
assert out["hidden_attr_count"] == 0
|
||||
|
||||
|
||||
def test_redact_ha_message_strips_userinfo() -> None:
|
||||
"""The Phase 1 redact helper is re-exported via the HA package and used
|
||||
by /entities, /state, /areas before surfacing errors. Make sure the
|
||||
re-export still works and the contract is what we expect."""
|
||||
from notify_bridge_core.providers.home_assistant import redact_ha_message
|
||||
msg = "Cannot connect to https://leak-token@homeassistant.local:8123/api/websocket"
|
||||
out = redact_ha_message(msg)
|
||||
assert "leak-token@" not in out
|
||||
assert "homeassistant.local:8123" in out
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Tests for the HA-specific tracker filter (entity_glob, domain_allowlist).
|
||||
|
||||
The Gitea filter is an intersection of senders/collections. The HA filter
|
||||
is intentionally a *union* across the three keys — any match passes — so a
|
||||
user can mix exact entity ids with glob patterns and domain allowlists
|
||||
without each one narrowing the others.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from notify_bridge_core.models.events import EventType, ServiceEvent
|
||||
from notify_bridge_core.providers.base import ServiceProviderType
|
||||
from notify_bridge_server.services.ha_subscription import _ha_passes_filters
|
||||
|
||||
|
||||
def _ha_event(entity_id: str, domain: str | None = None) -> ServiceEvent:
|
||||
return ServiceEvent(
|
||||
event_type=EventType.HA_STATE_CHANGED,
|
||||
provider_type=ServiceProviderType.HOME_ASSISTANT,
|
||||
provider_name="HA",
|
||||
collection_id=entity_id,
|
||||
collection_name=entity_id,
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
extra={"domain": domain or (entity_id.split(".", 1)[0] if "." in entity_id else "")},
|
||||
)
|
||||
|
||||
|
||||
def test_empty_filters_accept_everything() -> None:
|
||||
assert _ha_passes_filters(_ha_event("light.kitchen"), {}) is True
|
||||
|
||||
|
||||
def test_exact_entity_match() -> None:
|
||||
filters = {"collections": ["light.kitchen", "switch.lamp"]}
|
||||
assert _ha_passes_filters(_ha_event("light.kitchen"), filters) is True
|
||||
assert _ha_passes_filters(_ha_event("light.bedroom"), filters) is False
|
||||
|
||||
|
||||
def test_entity_glob_match() -> None:
|
||||
filters = {"entity_glob": ["binary_sensor.*_motion", "light.kitchen*"]}
|
||||
assert _ha_passes_filters(_ha_event("binary_sensor.hallway_motion"), filters) is True
|
||||
assert _ha_passes_filters(_ha_event("light.kitchen_main"), filters) is True
|
||||
assert _ha_passes_filters(_ha_event("light.bedroom"), filters) is False
|
||||
|
||||
|
||||
def test_domain_allowlist() -> None:
|
||||
filters = {"domain_allowlist": ["light", "switch"]}
|
||||
assert _ha_passes_filters(_ha_event("light.kitchen"), filters) is True
|
||||
assert _ha_passes_filters(_ha_event("switch.lamp"), filters) is True
|
||||
assert _ha_passes_filters(_ha_event("sensor.temp"), filters) is False
|
||||
|
||||
|
||||
def test_union_across_keys() -> None:
|
||||
"""If collections names a specific sensor.* but domain_allowlist names
|
||||
'light', BOTH should be acceptable — that's the difference from the
|
||||
Gitea-style intersection filter."""
|
||||
filters = {
|
||||
"collections": ["sensor.outdoor_temp"],
|
||||
"domain_allowlist": ["light"],
|
||||
}
|
||||
assert _ha_passes_filters(_ha_event("sensor.outdoor_temp"), filters) is True
|
||||
assert _ha_passes_filters(_ha_event("light.kitchen"), filters) is True
|
||||
# Neither matches:
|
||||
assert _ha_passes_filters(_ha_event("binary_sensor.door"), filters) is False
|
||||
|
||||
|
||||
def test_domain_derived_when_extra_missing() -> None:
|
||||
"""If the parser didn't populate extra.domain (e.g. malformed event),
|
||||
the filter must still infer it from the entity_id prefix."""
|
||||
evt = ServiceEvent(
|
||||
event_type=EventType.HA_STATE_CHANGED,
|
||||
provider_type=ServiceProviderType.HOME_ASSISTANT,
|
||||
provider_name="HA",
|
||||
collection_id="light.kitchen",
|
||||
collection_name="light.kitchen",
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
extra={}, # No 'domain' key.
|
||||
)
|
||||
assert _ha_passes_filters(evt, {"domain_allowlist": ["light"]}) is True
|
||||
@@ -0,0 +1,187 @@
|
||||
"""Unit tests for the Home Assistant event parser.
|
||||
|
||||
These tests don't need a database or HA server — the parser is a pure
|
||||
function from ``ha_event_dict`` to :class:`ServiceEvent`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from notify_bridge_core.models.events import EventType
|
||||
from notify_bridge_core.providers.base import ServiceProviderType
|
||||
from notify_bridge_core.providers.home_assistant.event_parser import parse_event
|
||||
|
||||
|
||||
def _ha_event_envelope(event_type: str, data: dict) -> dict:
|
||||
return {
|
||||
"event_type": event_type,
|
||||
"data": data,
|
||||
"time_fired": "2026-05-13T12:34:56.789Z",
|
||||
}
|
||||
|
||||
|
||||
def test_state_changed_basic() -> None:
|
||||
payload = _ha_event_envelope(
|
||||
"state_changed",
|
||||
{
|
||||
"entity_id": "binary_sensor.front_door",
|
||||
"old_state": {"state": "off", "attributes": {}},
|
||||
"new_state": {
|
||||
"state": "on",
|
||||
"attributes": {
|
||||
"friendly_name": "Front Door",
|
||||
"device_class": "door",
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
evt = parse_event(payload, provider_name="HA")
|
||||
assert evt is not None
|
||||
assert evt.event_type is EventType.HA_STATE_CHANGED
|
||||
assert evt.provider_type is ServiceProviderType.HOME_ASSISTANT
|
||||
assert evt.collection_id == "binary_sensor.front_door"
|
||||
assert evt.collection_name == "Front Door"
|
||||
assert evt.extra["old_state"] == "off"
|
||||
assert evt.extra["new_state"] == "on"
|
||||
assert evt.extra["domain"] == "binary_sensor"
|
||||
assert evt.extra["device_class"] == "door"
|
||||
# Area was not provided in lookup -> None.
|
||||
assert evt.extra["area"] is None
|
||||
|
||||
|
||||
def test_state_changed_with_area_lookup() -> None:
|
||||
payload = _ha_event_envelope(
|
||||
"state_changed",
|
||||
{
|
||||
"entity_id": "light.kitchen",
|
||||
"old_state": {"state": "off", "attributes": {}},
|
||||
"new_state": {
|
||||
"state": "on",
|
||||
"attributes": {"friendly_name": "Kitchen Light"},
|
||||
},
|
||||
},
|
||||
)
|
||||
evt = parse_event(
|
||||
payload,
|
||||
provider_name="HA",
|
||||
area_lookup={"light.kitchen": "Kitchen"},
|
||||
)
|
||||
assert evt is not None
|
||||
assert evt.extra["area"] == "Kitchen"
|
||||
|
||||
|
||||
def test_state_changed_entity_removed() -> None:
|
||||
"""new_state=None means HA removed the entity. Surface as 'removed' so
|
||||
templates can branch on it; collection_name falls back to old_state."""
|
||||
payload = _ha_event_envelope(
|
||||
"state_changed",
|
||||
{
|
||||
"entity_id": "sensor.dropped",
|
||||
"old_state": {
|
||||
"state": "online",
|
||||
"attributes": {"friendly_name": "Dropped Sensor"},
|
||||
},
|
||||
"new_state": None,
|
||||
},
|
||||
)
|
||||
evt = parse_event(payload, provider_name="HA")
|
||||
assert evt is not None
|
||||
assert evt.extra["new_state"] == "removed"
|
||||
assert evt.collection_name == "Dropped Sensor"
|
||||
|
||||
|
||||
def test_automation_triggered() -> None:
|
||||
payload = _ha_event_envelope(
|
||||
"automation_triggered",
|
||||
{
|
||||
"name": "Front door notification",
|
||||
"entity_id": "automation.front_door_notify",
|
||||
"source": "state of binary_sensor.front_door",
|
||||
},
|
||||
)
|
||||
evt = parse_event(payload, provider_name="HA")
|
||||
assert evt is not None
|
||||
assert evt.event_type is EventType.HA_AUTOMATION_TRIGGERED
|
||||
assert evt.collection_name == "Front door notification"
|
||||
assert evt.collection_id == "automation.front_door_notify"
|
||||
assert evt.extra["automation_name"] == "Front door notification"
|
||||
assert evt.extra["trigger_source"] == "state of binary_sensor.front_door"
|
||||
|
||||
|
||||
def test_call_service_with_target() -> None:
|
||||
payload = _ha_event_envelope(
|
||||
"call_service",
|
||||
{
|
||||
"domain": "light",
|
||||
"service": "turn_on",
|
||||
"service_data": {"entity_id": "light.kitchen"},
|
||||
},
|
||||
)
|
||||
evt = parse_event(payload, provider_name="HA")
|
||||
assert evt is not None
|
||||
assert evt.event_type is EventType.HA_SERVICE_CALLED
|
||||
assert evt.collection_id == "light.turn_on"
|
||||
assert evt.extra["target_entity"] == "light.kitchen"
|
||||
assert evt.extra["service_domain"] == "light"
|
||||
assert evt.extra["service_name"] == "turn_on"
|
||||
|
||||
|
||||
def test_call_service_with_multi_target() -> None:
|
||||
"""When the call hits multiple entities, the parser comma-joins them
|
||||
so templates can render ``{{ target_entity }}`` without iterating."""
|
||||
payload = _ha_event_envelope(
|
||||
"call_service",
|
||||
{
|
||||
"domain": "light",
|
||||
"service": "turn_off",
|
||||
"service_data": {
|
||||
"entity_id": ["light.kitchen", "light.living_room"],
|
||||
},
|
||||
},
|
||||
)
|
||||
evt = parse_event(payload, provider_name="HA")
|
||||
assert evt is not None
|
||||
assert evt.extra["target_entity"] == "light.kitchen, light.living_room"
|
||||
|
||||
|
||||
def test_generic_event_fallback() -> None:
|
||||
"""Any event_type not in the known set becomes ha_event_fired with the
|
||||
raw event_type stashed in extras so loud catch-all subscriptions work."""
|
||||
payload = _ha_event_envelope(
|
||||
"custom_event_xyz",
|
||||
{"foo": "bar"},
|
||||
)
|
||||
evt = parse_event(payload, provider_name="HA")
|
||||
assert evt is not None
|
||||
assert evt.event_type is EventType.HA_EVENT_FIRED
|
||||
assert evt.extra["ha_event_type"] == "custom_event_xyz"
|
||||
assert evt.extra["event_data"] == {"foo": "bar"}
|
||||
|
||||
|
||||
def test_malformed_payload_returns_none() -> None:
|
||||
assert parse_event({}, provider_name="HA") is None
|
||||
assert parse_event("not a dict", provider_name="HA") is None # type: ignore[arg-type]
|
||||
# state_changed without entity_id is unrecoverable
|
||||
bad = _ha_event_envelope("state_changed", {"new_state": None})
|
||||
assert parse_event(bad, provider_name="HA") is None
|
||||
# call_service without domain/service is unrecoverable
|
||||
bad2 = _ha_event_envelope("call_service", {"service": "turn_on"})
|
||||
assert parse_event(bad2, provider_name="HA") is None
|
||||
|
||||
|
||||
def test_time_fired_iso_with_z_suffix_parses() -> None:
|
||||
"""HA uses ``Z`` suffix; older Python ``fromisoformat`` rejects it.
|
||||
The parser must handle both forms or we'd lose the timestamp."""
|
||||
from datetime import timezone
|
||||
payload = _ha_event_envelope(
|
||||
"state_changed",
|
||||
{
|
||||
"entity_id": "sensor.temp",
|
||||
"old_state": {"state": "20", "attributes": {}},
|
||||
"new_state": {"state": "21", "attributes": {}},
|
||||
},
|
||||
)
|
||||
payload["time_fired"] = "2026-05-13T12:34:56.789Z"
|
||||
evt = parse_event(payload, provider_name="HA")
|
||||
assert evt is not None
|
||||
assert evt.timestamp.tzinfo is not None
|
||||
assert evt.timestamp.utcoffset() == timezone.utc.utcoffset(None)
|
||||
@@ -0,0 +1,193 @@
|
||||
"""Tests for the HA WS session helper and slice-before-normalize path.
|
||||
|
||||
The reviewer flagged two perf-shaped concerns that we've now addressed:
|
||||
|
||||
1. ``/status`` and ``/areas`` previously opened 3 and 2 separate WS
|
||||
connections respectively. With ``HomeAssistantSession`` they share one
|
||||
socket — these tests pin the contract.
|
||||
2. ``/entities`` used to normalize every matching entity before slicing to
|
||||
``count``. For HA installs with 1000+ entities this materialized 1000+
|
||||
normalized dicts to throw most away. The optimization moves the slice
|
||||
*before* normalize; this test exercises a 200-entity fixture and
|
||||
verifies only the ``count`` survivors get normalized.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from notify_bridge_core.providers.home_assistant.client import HomeAssistantSession
|
||||
from notify_bridge_server.commands import home_assistant_handler as handler
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Session class — surface contract
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_session_class_has_expected_methods() -> None:
|
||||
"""Anyone consuming ``HomeAssistantSession`` can rely on this surface."""
|
||||
expected = {"send", "get_states", "get_area_registry", "get_entity_registry"}
|
||||
actual = {name for name in dir(HomeAssistantSession) if not name.startswith("_")}
|
||||
assert expected <= actual, f"missing: {expected - actual}"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_get_states_routes_through_send() -> None:
|
||||
"""``get_states`` is a thin wrapper around ``send`` with the canonical payload."""
|
||||
sent: list[dict[str, Any]] = []
|
||||
|
||||
class _FakeClient:
|
||||
async def _send_command(self, ws: Any, payload: dict[str, Any]) -> int:
|
||||
sent.append(payload)
|
||||
return 1
|
||||
|
||||
async def _await_result(self, ws: Any, msg_id: int, timeout: float = 15.0) -> Any:
|
||||
return [{"entity_id": "light.kitchen", "state": "on", "attributes": {}}]
|
||||
|
||||
sess = HomeAssistantSession(_FakeClient(), ws=object()) # type: ignore[arg-type]
|
||||
result = await sess.get_states()
|
||||
assert sent == [{"type": "get_states"}]
|
||||
assert result == [{"entity_id": "light.kitchen", "state": "on", "attributes": {}}]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_methods_use_distinct_payloads() -> None:
|
||||
"""Each session-scoped method sends the right HA command name."""
|
||||
sent: list[dict[str, Any]] = []
|
||||
|
||||
class _FakeClient:
|
||||
async def _send_command(self, ws: Any, payload: dict[str, Any]) -> int:
|
||||
sent.append(payload)
|
||||
return len(sent)
|
||||
|
||||
async def _await_result(self, ws: Any, msg_id: int, timeout: float = 15.0) -> Any:
|
||||
return []
|
||||
|
||||
sess = HomeAssistantSession(_FakeClient(), ws=object()) # type: ignore[arg-type]
|
||||
await sess.get_states()
|
||||
await sess.get_area_registry()
|
||||
await sess.get_entity_registry()
|
||||
assert [p["type"] for p in sent] == [
|
||||
"get_states",
|
||||
"config/area_registry/list",
|
||||
"config/entity_registry/list",
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# slice-before-normalize — perf contract for /entities
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _FakeAsyncSession:
|
||||
"""A fake HA session that returns a canned state list."""
|
||||
|
||||
def __init__(self, states: list[dict[str, Any]]) -> None:
|
||||
self._states = states
|
||||
|
||||
async def get_states(self) -> list[dict[str, Any]]:
|
||||
return self._states
|
||||
|
||||
|
||||
class _FakeClient:
|
||||
"""A fake client whose ``session()`` yields a ``_FakeAsyncSession``."""
|
||||
|
||||
def __init__(self, states: list[dict[str, Any]]) -> None:
|
||||
self._states = states
|
||||
|
||||
def session(self): # noqa: D401 — mimics real client signature
|
||||
states = self._states
|
||||
class _CM:
|
||||
async def __aenter__(self_inner):
|
||||
return _FakeAsyncSession(states)
|
||||
async def __aexit__(self_inner, *_exc):
|
||||
return False
|
||||
return _CM()
|
||||
|
||||
|
||||
def _state_row(entity_id: str, n_attrs: int = 2) -> dict[str, Any]:
|
||||
return {
|
||||
"entity_id": entity_id,
|
||||
"state": "on",
|
||||
"attributes": {f"attr_{i}": i for i in range(n_attrs)},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cmd_entities_slices_before_normalizing(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""200 raw entities, count=10. Normalize must run only 10 times.
|
||||
|
||||
We instrument ``_normalize_state`` with a counter to prove the slice
|
||||
happens before the per-row transform. The total field still reports
|
||||
all 200 so the user knows the result is truncated.
|
||||
"""
|
||||
states = [_state_row(f"light.bulb_{i:03d}") for i in range(200)]
|
||||
fake_client = _FakeClient(states)
|
||||
monkeypatch.setattr(handler, "_make_ws_client", lambda provider, session: fake_client)
|
||||
|
||||
calls = {"count": 0}
|
||||
real_normalize = handler._normalize_state
|
||||
|
||||
def _counting_normalize(row: dict[str, Any]) -> dict[str, Any]:
|
||||
calls["count"] += 1
|
||||
return real_normalize(row)
|
||||
|
||||
monkeypatch.setattr(handler, "_normalize_state", _counting_normalize)
|
||||
|
||||
# ``get_http_session`` opens a real aiohttp session in the bg; bypass
|
||||
# it since our fake client never uses the session arg.
|
||||
async def _fake_http_session() -> Any:
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(handler, "get_http_session", _fake_http_session)
|
||||
|
||||
provider = type("FakeProvider", (), {"config": {}, "name": "HA"})()
|
||||
result = await handler._cmd_entities(provider, args="", count=10)
|
||||
assert result["total"] == 200
|
||||
assert result["shown"] == 10
|
||||
assert len(result["entities"]) == 10
|
||||
assert calls["count"] == 10, (
|
||||
f"normalize should run once per survivor; ran {calls['count']} times"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cmd_entities_glob_filter_still_normalizes_only_survivors(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""200 raw entities mixed across 2 domains; glob narrows to one.
|
||||
|
||||
Normalize count = min(count, matching_total). Demonstrates the
|
||||
optimization composes with the filter step.
|
||||
"""
|
||||
states = [
|
||||
_state_row(f"light.bulb_{i:03d}") for i in range(100)
|
||||
] + [
|
||||
_state_row(f"sensor.temp_{i:03d}") for i in range(100)
|
||||
]
|
||||
fake_client = _FakeClient(states)
|
||||
monkeypatch.setattr(handler, "_make_ws_client", lambda provider, session: fake_client)
|
||||
|
||||
calls = {"count": 0}
|
||||
real_normalize = handler._normalize_state
|
||||
|
||||
def _counting_normalize(row: dict[str, Any]) -> dict[str, Any]:
|
||||
calls["count"] += 1
|
||||
return real_normalize(row)
|
||||
|
||||
monkeypatch.setattr(handler, "_normalize_state", _counting_normalize)
|
||||
|
||||
async def _fake_http_session() -> Any:
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(handler, "get_http_session", _fake_http_session)
|
||||
|
||||
provider = type("FakeProvider", (), {"config": {}, "name": "HA"})()
|
||||
result = await handler._cmd_entities(provider, args="light.*", count=5)
|
||||
assert result["total"] == 100 # all light.* entities counted
|
||||
assert result["shown"] == 5 # but only 5 normalized
|
||||
assert calls["count"] == 5
|
||||
assert all(e["entity_id"].startswith("light.") for e in result["entities"])
|
||||
Reference in New Issue
Block a user