feat: Home Assistant provider — WebSocket subscription + bot commands

Adds Home Assistant as a service provider with two coordinated surfaces:

Notifications (subscription):
- Long-lived WebSocket client (aiohttp ws_connect) with auth handshake,
  exponential-backoff reconnect, bounded event queue, and area-registry
  enrichment cached per (re)connect
- ServiceProvider ABC gains an optional `subscribe()` method for push-style
  providers; HomeAssistantServiceProvider uses it via a per-provider
  supervisor task started in the FastAPI lifespan
- 4 event types (state_changed, automation_triggered, call_service,
  event_fired), 4 default Jinja templates (en + ru), HA-specific
  tracker filters (entity_glob, domain_allowlist, exact entity ids)
- Extracted shared dispatch pipeline (api/webhooks.py → services/
  event_dispatch.py) so subscription and webhook ingest share the same
  event_log + deferred-dispatch + quiet-hours code path

Bot commands:
- /status, /entities [glob], /state <entity_id>, /areas
- Multi-command WS session so /status and /areas cost one handshake
- Sensitive-attribute blocklist (camera access_token, entity_picture, etc.)
  and 30-attribute cap to keep /state output safe and within Telegram's
  message size
- Error-message redaction strips URL userinfo before surfacing to chat

Frontend:
- HA descriptor with toggle ConfigField type (new) and tag-input filter
  mode for free-text glob/domain lists (new TagInput component)
- 15 command slots + 4 notification slots wired into the existing
  template-config UI
This commit is contained in:
2026-05-13 14:31:56 +03:00
parent 90f958bdc6
commit 22127e2a59
79 changed files with 4042 additions and 210 deletions
+49
View File
@@ -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);
}
+154
View File
@@ -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>
+21 -1
View File
@@ -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",
+21 -1
View File
@@ -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(' · ');
},
},
};
+2
View File
@@ -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. */
+21 -8
View File
@@ -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>
<MultiEntitySelect
items={userItems.map(i => ({ ...i, icon: uf.icon }))}
values={form.filters[uf.key] || []}
onchange={(vals) => form.filters = { ...form.filters, [uf.key]: vals }}
placeholder={t(uf.placeholder)}
/>
{#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[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'}