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
+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>