bb3a316e35
H8 — automations.ts rule-type registry
Convert the two hand-rolled RuleType dispatch ladders into per-type
registries (RULE_FIELD_RENDERERS + RULE_COLLECTORS) keyed by RuleType,
joining the existing RULE_CHIP_RENDERERS. All three are typed
Record<RuleType, ...> for compile-time exhaustiveness; an import-time
_assertRuleHandlerCoverage() check logs loudly if any registry drifts
from RULE_TYPE_KEYS — mirrors the backend's _RULE_HANDLERS shape, the
one intentional divergence being that the frontend logs rather than
throws (a thrown error at module import would brick the whole bundle,
not just the editor).
M7 — shared API client + 35 file migrations
New core/api-client.ts wrapping fetchWithAuth with typed apiGet /
apiPost / apiPut / apiPatch / apiDelete. Auth, 401-relogin, retry,
timeout, and the offline toast all stay owned by fetchWithAuth; the
client just collapses the
if (!resp.ok) { detail || HTTP <status> } ... resp.json()
dance into one typed call. The detail unwrap is hardened to join
FastAPI validation arrays instead of stringifying to [object Object].
35 feature/core files migrated to it across many batches, reviewer-
approved for behaviour parity in three passes covering the riskier
divergences (bulk Promise.allSettled deletes, inline-error saves,
array-detail joins, silent-failure GETs, immutable clones).
9 files remain on fetchWithAuth — the big god-modules tied to the
pending C8/C9/C10 splits (streams, settings, targets, dashboard,
color-strips/index, graph-editor, assets, value-sources) plus
pairing-flow which by design stays on raw fetch (branches on raw
Response.status codes).
i18n — 14 new locale keys (en / ru / zh)
Added save/load/delete error keys across automations, pattern,
audio_processing, audio_template, templates, gradient, target,
device namespaces, plus backfilled gradient.error.delete_failed into
ru/zh. Scan confirms no hardcoded English errorMessage strings
remain in the migrated diff.
AUDIT_REMAINING.md updated to reflect H6, H8, and M7 status.
Verified: tsc --noEmit clean + npm run build clean after every batch.
364 lines
15 KiB
TypeScript
364 lines
15 KiB
TypeScript
/**
|
|
* Home Assistant Sources — CRUD, test, cards.
|
|
*/
|
|
|
|
import {
|
|
_cachedHASources, haSourcesCache,
|
|
_haEntityNamesCache, setHAEntityNames,
|
|
} from '../core/state.ts';
|
|
import { escapeHtml } from '../core/api.ts';
|
|
import { apiGet, apiPost, apiPut, apiDelete } from '../core/api-client.ts';
|
|
import { t } from '../core/i18n.ts';
|
|
import { Modal } from '../core/modal.ts';
|
|
import { showToast, showConfirm } from '../core/ui.ts';
|
|
import { ICON_CLONE, ICON_EDIT, ICON_REFRESH } from '../core/icons.ts';
|
|
import * as P from '../core/icon-paths.ts';
|
|
import { wrapCard } from '../core/card-colors.ts';
|
|
import type { ModCardOpts, ModChipOpts, LedState } from '../core/mod-card.ts';
|
|
import { TagInput, renderTagChips } from '../core/tag-input.ts';
|
|
import { makeCardIconFields } from '../core/card-icon.ts';
|
|
import { registerIconEntityType, makeSimpleIconAdapter } from './icon-picker.ts';
|
|
import type { HomeAssistantSource } from '../types.ts';
|
|
|
|
registerIconEntityType('ha_source', makeSimpleIconAdapter<HomeAssistantSource>({
|
|
cache: haSourcesCache,
|
|
endpointPrefix: '/home-assistant/sources',
|
|
reload: async () => {
|
|
if (typeof window.loadIntegrations === 'function') {
|
|
await window.loadIntegrations();
|
|
}
|
|
},
|
|
typeLabelKey: 'device.icon.entity.ha_source',
|
|
typeLabelFallback: 'Home Assistant source',
|
|
cardSelectors: (id) => [
|
|
`[data-card-section="ha-sources"] [data-id="${CSS.escape(id)}"]`,
|
|
],
|
|
}));
|
|
|
|
const ICON_HA = `<svg class="icon" viewBox="0 0 24 24">${P.home}</svg>`;
|
|
|
|
// ── Modal ──
|
|
|
|
let _haTagsInput: TagInput | null = null;
|
|
|
|
class HASourceModal extends Modal {
|
|
constructor() { super('ha-source-modal'); }
|
|
|
|
onForceClose() {
|
|
if (_haTagsInput) { _haTagsInput.destroy(); _haTagsInput = null; }
|
|
}
|
|
|
|
snapshotValues() {
|
|
return {
|
|
name: (document.getElementById('ha-source-name') as HTMLInputElement).value,
|
|
host: (document.getElementById('ha-source-host') as HTMLInputElement).value,
|
|
token: (document.getElementById('ha-source-token') as HTMLInputElement).value,
|
|
use_ssl: (document.getElementById('ha-source-ssl') as HTMLInputElement).checked,
|
|
entity_filters: (document.getElementById('ha-source-filters') as HTMLInputElement).value,
|
|
description: (document.getElementById('ha-source-description') as HTMLInputElement).value,
|
|
tags: JSON.stringify(_haTagsInput ? _haTagsInput.getValue() : []),
|
|
};
|
|
}
|
|
}
|
|
|
|
const haSourceModal = new HASourceModal();
|
|
|
|
// ── Entity friendly-name cache ──────────────────────────────────
|
|
//
|
|
// Fetching entities calls the live HA instance, so we only refresh
|
|
// the cache when the user actively interacts with a source. The
|
|
// Streams tab triggers `prefetchHAEntities()` for sources referenced
|
|
// by ha_entity value sources on initial load; subsequent edits in
|
|
// the value-source editor refresh via `_fetchVSHAEntities`.
|
|
|
|
/** Fetch entities for one HA source and write friendly names into the
|
|
* shared cache. Failures are silent — callers fall back to entity_id. */
|
|
export async function fetchHAEntities(haSourceId: string): Promise<void> {
|
|
if (!haSourceId) return;
|
|
try {
|
|
const data = await apiGet<{ entities?: any[] }>(`/home-assistant/sources/${haSourceId}/entities`);
|
|
setHAEntityNames(haSourceId, data.entities || []);
|
|
} catch {
|
|
// Leave any existing cache entry intact (any non-2xx or network error).
|
|
}
|
|
}
|
|
|
|
/** Prefetch entities for the given HA source IDs in parallel,
|
|
* skipping any already cached this session. */
|
|
export async function prefetchHAEntities(haSourceIds: ReadonlyArray<string>): Promise<void> {
|
|
const unique = [...new Set(haSourceIds.filter(id => id && !_haEntityNamesCache[id]))];
|
|
if (unique.length === 0) return;
|
|
await Promise.all(unique.map(id => fetchHAEntities(id)));
|
|
}
|
|
|
|
// ── Show / Close ──
|
|
|
|
export async function showHASourceModal(editData: HomeAssistantSource | null = null): Promise<void> {
|
|
const isEdit = !!editData;
|
|
const titleKey = isEdit ? 'ha_source.edit' : 'ha_source.add';
|
|
document.getElementById('ha-source-modal-title')!.innerHTML = `${ICON_HA} ${t(titleKey)}`;
|
|
(document.getElementById('ha-source-id') as HTMLInputElement).value = editData?.id || '';
|
|
(document.getElementById('ha-source-error') as HTMLElement).style.display = 'none';
|
|
|
|
if (isEdit) {
|
|
(document.getElementById('ha-source-name') as HTMLInputElement).value = editData.name || '';
|
|
(document.getElementById('ha-source-host') as HTMLInputElement).value = editData.host || '';
|
|
(document.getElementById('ha-source-token') as HTMLInputElement).value = ''; // never expose token
|
|
(document.getElementById('ha-source-ssl') as HTMLInputElement).checked = editData.use_ssl ?? false;
|
|
(document.getElementById('ha-source-filters') as HTMLInputElement).value = (editData.entity_filters || []).join(', ');
|
|
(document.getElementById('ha-source-description') as HTMLInputElement).value = editData.description || '';
|
|
} else {
|
|
(document.getElementById('ha-source-name') as HTMLInputElement).value = '';
|
|
(document.getElementById('ha-source-host') as HTMLInputElement).value = '';
|
|
(document.getElementById('ha-source-token') as HTMLInputElement).value = '';
|
|
(document.getElementById('ha-source-ssl') as HTMLInputElement).checked = false;
|
|
(document.getElementById('ha-source-filters') as HTMLInputElement).value = '';
|
|
(document.getElementById('ha-source-description') as HTMLInputElement).value = '';
|
|
}
|
|
|
|
// Tags
|
|
if (_haTagsInput) { _haTagsInput.destroy(); _haTagsInput = null; }
|
|
_haTagsInput = new TagInput(document.getElementById('ha-source-tags-container'), { placeholder: t('tags.placeholder') });
|
|
_haTagsInput.setValue(isEdit ? (editData.tags || []) : []);
|
|
|
|
// Show/hide test button based on edit mode
|
|
const testBtn = document.getElementById('ha-source-test-btn');
|
|
if (testBtn) testBtn.style.display = isEdit ? '' : 'none';
|
|
|
|
// Token hint
|
|
const tokenHint = document.getElementById('ha-source-token-hint');
|
|
if (tokenHint) tokenHint.style.display = isEdit ? '' : 'none';
|
|
|
|
haSourceModal.open();
|
|
haSourceModal.snapshot();
|
|
}
|
|
|
|
export async function closeHASourceModal(): Promise<void> {
|
|
await haSourceModal.close();
|
|
}
|
|
|
|
// ── Save ──
|
|
|
|
export async function saveHASource(): Promise<void> {
|
|
const id = (document.getElementById('ha-source-id') as HTMLInputElement).value;
|
|
if (haSourceModal.closeIfPristine(id)) return;
|
|
|
|
const name = (document.getElementById('ha-source-name') as HTMLInputElement).value.trim();
|
|
const host = (document.getElementById('ha-source-host') as HTMLInputElement).value.trim();
|
|
const token = (document.getElementById('ha-source-token') as HTMLInputElement).value.trim();
|
|
const use_ssl = (document.getElementById('ha-source-ssl') as HTMLInputElement).checked;
|
|
const filtersRaw = (document.getElementById('ha-source-filters') as HTMLInputElement).value.trim();
|
|
const entity_filters = filtersRaw ? filtersRaw.split(',').map(s => s.trim()).filter(Boolean) : [];
|
|
const description = (document.getElementById('ha-source-description') as HTMLInputElement).value.trim() || null;
|
|
|
|
if (!name) {
|
|
haSourceModal.showError(t('ha_source.error.name_required'));
|
|
return;
|
|
}
|
|
if (!id && !host) {
|
|
haSourceModal.showError(t('ha_source.error.host_required'));
|
|
return;
|
|
}
|
|
if (!id && !token) {
|
|
haSourceModal.showError(t('ha_source.error.token_required'));
|
|
return;
|
|
}
|
|
|
|
const payload: Record<string, any> = {
|
|
name, use_ssl, entity_filters, description,
|
|
tags: _haTagsInput ? _haTagsInput.getValue() : [],
|
|
};
|
|
// Only send host/token if provided (edit mode may leave token blank)
|
|
if (host) payload.host = host;
|
|
if (token) payload.token = token;
|
|
|
|
try {
|
|
if (id) {
|
|
await apiPut(`/home-assistant/sources/${id}`, payload);
|
|
} else {
|
|
await apiPost('/home-assistant/sources', payload);
|
|
}
|
|
showToast(t(id ? 'ha_source.updated' : 'ha_source.created'), 'success');
|
|
haSourceModal.forceClose();
|
|
haSourcesCache.invalidate();
|
|
if (typeof window.loadIntegrations === 'function') await window.loadIntegrations();
|
|
} catch (e: any) {
|
|
if (e.isAuth) return;
|
|
haSourceModal.showError(e.message);
|
|
}
|
|
}
|
|
|
|
// ── Edit / Clone / Delete ──
|
|
|
|
export async function editHASource(sourceId: string): Promise<void> {
|
|
try {
|
|
const data = await apiGet<HomeAssistantSource>(`/home-assistant/sources/${sourceId}`, { errorMessage: t('ha_source.error.load') });
|
|
await showHASourceModal(data);
|
|
} catch (e: any) {
|
|
if (e.isAuth) return;
|
|
showToast(e.message, 'error');
|
|
}
|
|
}
|
|
|
|
export async function cloneHASource(sourceId: string): Promise<void> {
|
|
try {
|
|
const data = await apiGet<HomeAssistantSource>(`/home-assistant/sources/${sourceId}`, { errorMessage: t('ha_source.error.load') });
|
|
const { id: _omit, ...rest } = data;
|
|
await showHASourceModal({ ...rest, name: `${data.name} (copy)` } as HomeAssistantSource);
|
|
} catch (e: any) {
|
|
if (e.isAuth) return;
|
|
showToast(e.message, 'error');
|
|
}
|
|
}
|
|
|
|
export async function deleteHASource(sourceId: string): Promise<void> {
|
|
const confirmed = await showConfirm(t('ha_source.delete.confirm'));
|
|
if (!confirmed) return;
|
|
try {
|
|
await apiDelete(`/home-assistant/sources/${sourceId}`);
|
|
showToast(t('ha_source.deleted'), 'success');
|
|
haSourcesCache.invalidate();
|
|
if (typeof window.loadIntegrations === 'function') await window.loadIntegrations();
|
|
} catch (e: any) {
|
|
if (e.isAuth) return;
|
|
showToast(e.message, 'error');
|
|
}
|
|
}
|
|
|
|
// ── Test ──
|
|
|
|
export async function testHASource(): Promise<void> {
|
|
const id = (document.getElementById('ha-source-id') as HTMLInputElement).value;
|
|
if (!id) return;
|
|
|
|
const testBtn = document.getElementById('ha-source-test-btn');
|
|
if (testBtn) testBtn.classList.add('loading');
|
|
|
|
try {
|
|
const data = await apiPost<HATestResult>(`/home-assistant/sources/${id}/test`);
|
|
if (data.success) {
|
|
showToast(`${t('ha_source.test.success')} | HA ${data.ha_version} | ${data.entity_count} entities`, 'success');
|
|
} else {
|
|
showToast(`${t('ha_source.test.failed')}: ${data.error}`, 'error');
|
|
}
|
|
} catch (e: any) {
|
|
if (e.isAuth) return;
|
|
showToast(e.message, 'error');
|
|
} finally {
|
|
if (testBtn) testBtn.classList.remove('loading');
|
|
}
|
|
}
|
|
|
|
/** Shape returned by `POST /home-assistant/sources/{id}/test`. */
|
|
interface HATestResult {
|
|
success: boolean;
|
|
ha_version?: string;
|
|
entity_count?: number;
|
|
error?: string;
|
|
}
|
|
|
|
// ── Card rendering ──
|
|
|
|
export function createHASourceCard(source: HomeAssistantSource) {
|
|
const isConnected = !!source.connected;
|
|
const leds: LedState[] = isConnected ? ['on', 'on'] : ['fault'];
|
|
const healthTitle = isConnected
|
|
? `${t('ha_source.connected')} — ${source.entity_count} entities`
|
|
: t('ha_source.disconnected');
|
|
|
|
const chips: ModChipOpts[] = [
|
|
{ icon: `<svg class="icon" viewBox="0 0 24 24">${P.wifi}</svg>`, text: source.host, title: source.host },
|
|
];
|
|
if (isConnected) {
|
|
chips.push({ icon: `<svg class="icon" viewBox="0 0 24 24">${P.listChecks}</svg>`, text: `${source.entity_count} entities` });
|
|
}
|
|
if (source.use_ssl) {
|
|
chips.push({ icon: `<svg class="icon" viewBox="0 0 24 24">${P.lock}</svg>`, text: 'SSL' });
|
|
}
|
|
|
|
const mod: ModCardOpts = {
|
|
head: {
|
|
badge: { text: 'HA · BRIDGE' },
|
|
name: source.name,
|
|
metaHtml: escapeHtml(`${source.host}${isConnected ? ` · ${source.entity_count} entities` : ''}`),
|
|
leds,
|
|
...makeCardIconFields('ha_source', source.id, source),
|
|
menu: {
|
|
duplicateOnclick: `cloneHASource('${source.id}')`,
|
|
hideOnclick: `toggleCardHidden('ha-sources','${source.id}')`,
|
|
deleteOnclick: `deleteHASource('${source.id}')`,
|
|
},
|
|
},
|
|
body: {
|
|
desc: source.description || undefined,
|
|
chips,
|
|
},
|
|
foot: {
|
|
patchState: isConnected ? 'live' : 'offline',
|
|
patchLabel: isConnected ? 'CONNECTED' : 'OFFLINE',
|
|
iconActions: [
|
|
{ icon: ICON_REFRESH, onclick: '', title: healthTitle, dataAttrs: { 'data-action': 'test' } },
|
|
{ icon: ICON_EDIT, onclick: '', title: t('common.edit'), dataAttrs: { 'data-action': 'edit' } },
|
|
],
|
|
},
|
|
running: isConnected,
|
|
};
|
|
const cardHtml = wrapCard({ type: 'template-card', dataAttr: 'data-id', id: source.id, mod });
|
|
const tagsHtml = renderTagChips(source.tags);
|
|
return tagsHtml ? cardHtml.replace(/<\/div>\s*$/, `${tagsHtml}</div>`) : cardHtml;
|
|
}
|
|
|
|
// ── Event delegation ──
|
|
|
|
const _haSourceActions: Record<string, (id: string) => void> = {
|
|
test: (id) => _testHASourceFromCard(id),
|
|
clone: cloneHASource,
|
|
edit: editHASource,
|
|
};
|
|
|
|
async function _testHASourceFromCard(sourceId: string): Promise<void> {
|
|
try {
|
|
const data = await apiPost<HATestResult>(`/home-assistant/sources/${sourceId}/test`);
|
|
if (data.success) {
|
|
showToast(`HA ${data.ha_version} | ${data.entity_count} entities`, 'success');
|
|
} else {
|
|
showToast(`${t('ha_source.test.failed')}: ${data.error}`, 'error');
|
|
}
|
|
} catch (e: any) {
|
|
if (e.isAuth) return;
|
|
showToast(e.message, 'error');
|
|
}
|
|
}
|
|
|
|
export function initHASourceDelegation(container: HTMLElement): void {
|
|
container.addEventListener('click', (e: MouseEvent) => {
|
|
const btn = (e.target as HTMLElement).closest<HTMLElement>('[data-action]');
|
|
if (!btn) return;
|
|
|
|
const section = btn.closest<HTMLElement>('[data-card-section="ha-sources"]');
|
|
if (!section) return;
|
|
const card = btn.closest<HTMLElement>('[data-id]');
|
|
if (!card) return;
|
|
|
|
const action = btn.dataset.action;
|
|
const id = card.getAttribute('data-id');
|
|
if (!action || !id) return;
|
|
|
|
const handler = _haSourceActions[action];
|
|
if (handler) {
|
|
e.stopPropagation();
|
|
handler(id);
|
|
}
|
|
});
|
|
}
|
|
|
|
// ── Expose to global scope for HTML template onclick handlers ──
|
|
|
|
window.showHASourceModal = showHASourceModal;
|
|
window.closeHASourceModal = closeHASourceModal;
|
|
window.saveHASource = saveHASource;
|
|
window.editHASource = editHASource;
|
|
window.cloneHASource = cloneHASource;
|
|
window.deleteHASource = deleteHASource;
|
|
window.testHASource = testHASource;
|