22127e2a59
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
97 lines
3.2 KiB
TypeScript
97 lines
3.2 KiB
TypeScript
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(' · ');
|
|
},
|
|
},
|
|
};
|