feat: Home Assistant integration — WebSocket connection, automation conditions, UI
Add full Home Assistant integration via WebSocket API: - HARuntime: persistent WebSocket client with auth, auto-reconnect, entity state cache - HAManager: ref-counted runtime pool (like WeatherManager) - HomeAssistantCondition: new automation trigger type matching entity states - REST API: CRUD for HA sources + /test, /entities, /status endpoints - /api/v1/system/integrations-status: combined MQTT + HA dashboard indicators - Frontend: HA Sources tab in Streams, condition type in automation editor - Modal editor with host, token, SSL, entity filters - websockets>=13.0 dependency added
This commit is contained in:
@@ -89,3 +89,5 @@ export const fileAudio = '<path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4
|
||||
export const packageIcon = '<path d="m7.5 4.27 9 5.15"/><path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/>';
|
||||
export const heart = '<path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z"/>';
|
||||
export const github = '<path d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4"/><path d="M9 18c-4.51 2-5-2-7-2"/>';
|
||||
export const home = '<path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8"/><path d="M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>';
|
||||
export const lock = '<rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>';
|
||||
|
||||
@@ -10,7 +10,7 @@ import { DataCache } from './cache.ts';
|
||||
import type {
|
||||
Device, OutputTarget, ColorStripSource, PatternTemplate,
|
||||
ValueSource, AudioSource, PictureSource, ScenePreset,
|
||||
SyncClock, WeatherSource, Asset, Automation, Display, FilterDef, EngineInfo,
|
||||
SyncClock, WeatherSource, HomeAssistantSource, Asset, Automation, Display, FilterDef, EngineInfo,
|
||||
CaptureTemplate, PostprocessingTemplate, AudioTemplate,
|
||||
ColorStripProcessingTemplate, FilterInstance, KeyColorRectangle,
|
||||
} from '../types.ts';
|
||||
@@ -226,6 +226,7 @@ export let _cachedValueSources: ValueSource[] = [];
|
||||
// Sync clocks
|
||||
export let _cachedSyncClocks: SyncClock[] = [];
|
||||
export let _cachedWeatherSources: WeatherSource[] = [];
|
||||
export let _cachedHASources: HomeAssistantSource[] = [];
|
||||
export let _cachedAssets: Asset[] = [];
|
||||
|
||||
// Automations
|
||||
@@ -290,6 +291,12 @@ export const weatherSourcesCache = new DataCache<WeatherSource[]>({
|
||||
});
|
||||
weatherSourcesCache.subscribe(v => { _cachedWeatherSources = v; });
|
||||
|
||||
export const haSourcesCache = new DataCache<HomeAssistantSource[]>({
|
||||
endpoint: '/home-assistant/sources',
|
||||
extractData: json => json.sources || [],
|
||||
});
|
||||
haSourcesCache.subscribe(v => { _cachedHASources = v; });
|
||||
|
||||
export const assetsCache = new DataCache<Asset[]>({
|
||||
endpoint: '/assets',
|
||||
extractData: json => json.assets || [],
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Automations — automation cards, editor, condition builder, process picker, scene selector.
|
||||
*/
|
||||
|
||||
import { apiKey, _automationsLoading, set_automationsLoading, automationsCacheObj, scenePresetsCache } from '../core/state.ts';
|
||||
import { apiKey, _automationsLoading, set_automationsLoading, automationsCacheObj, scenePresetsCache, _cachedHASources } from '../core/state.ts';
|
||||
import { fetchWithAuth, escapeHtml } from '../core/api.ts';
|
||||
import { t } from '../core/i18n.ts';
|
||||
import { showToast, showConfirm, setTabRefreshing } from '../core/ui.ts';
|
||||
@@ -248,6 +248,7 @@ const CONDITION_PILL_RENDERERS: Record<string, ConditionPillRenderer> = {
|
||||
},
|
||||
mqtt: (c) => `<span class="stream-card-prop stream-card-prop-full">${ICON_RADIO} ${t('automations.condition.mqtt')}: ${escapeHtml(c.topic || '')} = ${escapeHtml(c.payload || '*')}</span>`,
|
||||
webhook: (c) => `<span class="stream-card-prop">${ICON_WEB} ${t('automations.condition.webhook')}</span>`,
|
||||
home_assistant: (c) => `<span class="stream-card-prop stream-card-prop-full">${_icon(P.home)} ${t('automations.condition.home_assistant')}: ${escapeHtml(c.entity_id || '')} = ${escapeHtml(c.state || '*')}</span>`,
|
||||
};
|
||||
|
||||
function createAutomationCard(automation: Automation, sceneMap = new Map()) {
|
||||
@@ -515,11 +516,11 @@ export function addAutomationCondition() {
|
||||
_autoGenerateAutomationName();
|
||||
}
|
||||
|
||||
const CONDITION_TYPE_KEYS = ['always', 'startup', 'application', 'time_of_day', 'system_idle', 'display_state', 'mqtt', 'webhook'];
|
||||
const CONDITION_TYPE_KEYS = ['always', 'startup', 'application', 'time_of_day', 'system_idle', 'display_state', 'mqtt', 'webhook', 'home_assistant'];
|
||||
const CONDITION_TYPE_ICONS = {
|
||||
always: P.refreshCw, startup: P.power, application: P.smartphone,
|
||||
time_of_day: P.clock, system_idle: P.moon, display_state: P.monitor,
|
||||
mqtt: P.radio, webhook: P.globe,
|
||||
mqtt: P.radio, webhook: P.globe, home_assistant: P.home,
|
||||
};
|
||||
|
||||
const MATCH_TYPE_KEYS = ['running', 'topmost', 'topmost_fullscreen', 'fullscreen'];
|
||||
@@ -726,6 +727,44 @@ function addAutomationConditionRow(condition: any) {
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
if (type === 'home_assistant') {
|
||||
const haSourceId = data.ha_source_id || '';
|
||||
const entityId = data.entity_id || '';
|
||||
const haState = data.state || '';
|
||||
const matchMode = data.match_mode || 'exact';
|
||||
// Build HA source options from cached data
|
||||
const haOptions = _cachedHASources.map((s: any) =>
|
||||
`<option value="${s.id}" ${s.id === haSourceId ? 'selected' : ''}>${escapeHtml(s.name)}</option>`
|
||||
).join('');
|
||||
container.innerHTML = `
|
||||
<div class="condition-fields">
|
||||
<small class="condition-always-desc">${t('automations.condition.home_assistant.hint')}</small>
|
||||
<div class="condition-field">
|
||||
<label>${t('automations.condition.home_assistant.ha_source')}</label>
|
||||
<select class="condition-ha-source-id">
|
||||
<option value="">—</option>
|
||||
${haOptions}
|
||||
</select>
|
||||
</div>
|
||||
<div class="condition-field">
|
||||
<label>${t('automations.condition.home_assistant.entity_id')}</label>
|
||||
<input type="text" class="condition-ha-entity-id" value="${escapeHtml(entityId)}" placeholder="binary_sensor.front_door">
|
||||
</div>
|
||||
<div class="condition-field">
|
||||
<label>${t('automations.condition.home_assistant.state')}</label>
|
||||
<input type="text" class="condition-ha-state" value="${escapeHtml(haState)}" placeholder="on">
|
||||
</div>
|
||||
<div class="condition-field">
|
||||
<label>${t('automations.condition.home_assistant.match_mode')}</label>
|
||||
<select class="condition-ha-match-mode">
|
||||
<option value="exact" ${matchMode === 'exact' ? 'selected' : ''}>${t('automations.condition.mqtt.match_mode.exact')}</option>
|
||||
<option value="contains" ${matchMode === 'contains' ? 'selected' : ''}>${t('automations.condition.mqtt.match_mode.contains')}</option>
|
||||
<option value="regex" ${matchMode === 'regex' ? 'selected' : ''}>${t('automations.condition.mqtt.match_mode.regex')}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
if (type === 'webhook') {
|
||||
if (data.token) {
|
||||
const webhookUrl = getBaseOrigin() + '/api/v1/webhooks/' + data.token;
|
||||
@@ -835,6 +874,14 @@ function getAutomationEditorConditions() {
|
||||
const cond: any = { condition_type: 'webhook' };
|
||||
if (tokenInput && tokenInput.value) cond.token = tokenInput.value;
|
||||
conditions.push(cond);
|
||||
} else if (condType === 'home_assistant') {
|
||||
conditions.push({
|
||||
condition_type: 'home_assistant',
|
||||
ha_source_id: (row.querySelector('.condition-ha-source-id') as HTMLSelectElement).value,
|
||||
entity_id: (row.querySelector('.condition-ha-entity-id') as HTMLInputElement).value.trim(),
|
||||
state: (row.querySelector('.condition-ha-state') as HTMLInputElement).value,
|
||||
match_mode: (row.querySelector('.condition-ha-match-mode') as HTMLSelectElement).value || 'exact',
|
||||
});
|
||||
} else {
|
||||
const matchType = (row.querySelector('.condition-match-type') as HTMLSelectElement).value;
|
||||
const appsText = (row.querySelector('.condition-apps') as HTMLTextAreaElement).value.trim();
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
/**
|
||||
* Home Assistant Sources — CRUD, test, cards.
|
||||
*/
|
||||
|
||||
import { _cachedHASources, haSourcesCache } from '../core/state.ts';
|
||||
import { fetchWithAuth, escapeHtml } from '../core/api.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_TEST } from '../core/icons.ts';
|
||||
import * as P from '../core/icon-paths.ts';
|
||||
import { wrapCard } from '../core/card-colors.ts';
|
||||
import { TagInput, renderTagChips } from '../core/tag-input.ts';
|
||||
import { loadPictureSources } from './streams.ts';
|
||||
import type { HomeAssistantSource } from '../types.ts';
|
||||
|
||||
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();
|
||||
|
||||
// ── 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;
|
||||
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 {
|
||||
const method = id ? 'PUT' : 'POST';
|
||||
const url = id ? `/home-assistant/sources/${id}` : '/home-assistant/sources';
|
||||
const resp = await fetchWithAuth(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.detail || `HTTP ${resp.status}`);
|
||||
}
|
||||
showToast(t(id ? 'ha_source.updated' : 'ha_source.created'), 'success');
|
||||
haSourceModal.forceClose();
|
||||
haSourcesCache.invalidate();
|
||||
await loadPictureSources();
|
||||
} catch (e: any) {
|
||||
if (e.isAuth) return;
|
||||
haSourceModal.showError(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Edit / Clone / Delete ──
|
||||
|
||||
export async function editHASource(sourceId: string): Promise<void> {
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/home-assistant/sources/${sourceId}`);
|
||||
if (!resp.ok) throw new Error(t('ha_source.error.load'));
|
||||
const data = await resp.json();
|
||||
await showHASourceModal(data);
|
||||
} catch (e: any) {
|
||||
if (e.isAuth) return;
|
||||
showToast(e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
export async function cloneHASource(sourceId: string): Promise<void> {
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/home-assistant/sources/${sourceId}`);
|
||||
if (!resp.ok) throw new Error(t('ha_source.error.load'));
|
||||
const data = await resp.json();
|
||||
delete data.id;
|
||||
data.name = data.name + ' (copy)';
|
||||
await showHASourceModal(data);
|
||||
} 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 {
|
||||
const resp = await fetchWithAuth(`/home-assistant/sources/${sourceId}`, { method: 'DELETE' });
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.detail || `HTTP ${resp.status}`);
|
||||
}
|
||||
showToast(t('ha_source.deleted'), 'success');
|
||||
haSourcesCache.invalidate();
|
||||
await loadPictureSources();
|
||||
} 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 resp = await fetchWithAuth(`/home-assistant/sources/${id}/test`, { method: 'POST' });
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
const data = await resp.json();
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Card rendering ──
|
||||
|
||||
export function createHASourceCard(source: HomeAssistantSource) {
|
||||
const statusDot = source.connected
|
||||
? `<span class="status-dot status-dot-active" title="${t('ha_source.connected')}"></span>`
|
||||
: `<span class="status-dot status-dot-inactive" title="${t('ha_source.disconnected')}"></span>`;
|
||||
|
||||
return wrapCard({
|
||||
type: 'template-card',
|
||||
dataAttr: 'data-id',
|
||||
id: source.id,
|
||||
removeOnclick: `deleteHASource('${source.id}')`,
|
||||
removeTitle: t('common.delete'),
|
||||
content: `
|
||||
<div class="template-card-header">
|
||||
<div class="template-name">${ICON_HA} ${statusDot} ${escapeHtml(source.name)}</div>
|
||||
</div>
|
||||
<div class="stream-card-props">
|
||||
<span class="stream-card-prop">
|
||||
<svg class="icon" viewBox="0 0 24 24">${P.wifi}</svg> ${escapeHtml(source.host)}
|
||||
</span>
|
||||
${source.connected ? `<span class="stream-card-prop">
|
||||
<svg class="icon" viewBox="0 0 24 24">${P.listChecks}</svg> ${source.entity_count} entities
|
||||
</span>` : ''}
|
||||
${source.use_ssl ? `<span class="stream-card-prop">
|
||||
<svg class="icon" viewBox="0 0 24 24">${P.lock}</svg> SSL
|
||||
</span>` : ''}
|
||||
</div>
|
||||
${renderTagChips(source.tags)}
|
||||
${source.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(source.description)}</div>` : ''}`,
|
||||
actions: `
|
||||
<button class="btn btn-icon btn-secondary" data-action="test" title="${t('ha_source.test')}">${ICON_TEST}</button>
|
||||
<button class="btn btn-icon btn-secondary" data-action="clone" title="${t('common.clone')}">${ICON_CLONE}</button>
|
||||
<button class="btn btn-icon btn-secondary" data-action="edit" title="${t('common.edit')}">${ICON_EDIT}</button>`,
|
||||
});
|
||||
}
|
||||
|
||||
// ── 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 resp = await fetchWithAuth(`/home-assistant/sources/${sourceId}/test`, { method: 'POST' });
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
const data = await resp.json();
|
||||
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;
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
_cachedValueSources,
|
||||
_cachedSyncClocks,
|
||||
_cachedWeatherSources,
|
||||
_cachedHASources,
|
||||
_cachedAudioTemplates,
|
||||
_cachedCSPTemplates,
|
||||
_csptModalFilters, set_csptModalFilters,
|
||||
@@ -34,7 +35,7 @@ import {
|
||||
_sourcesLoading, set_sourcesLoading,
|
||||
apiKey,
|
||||
streamsCache, ppTemplatesCache, captureTemplatesCache,
|
||||
audioSourcesCache, audioTemplatesCache, valueSourcesCache, syncClocksCache, weatherSourcesCache, assetsCache, _cachedAssets, filtersCache,
|
||||
audioSourcesCache, audioTemplatesCache, valueSourcesCache, syncClocksCache, weatherSourcesCache, haSourcesCache, assetsCache, _cachedAssets, filtersCache,
|
||||
colorStripSourcesCache,
|
||||
csptCache, stripFiltersCache,
|
||||
gradientsCache, GradientEntity,
|
||||
@@ -50,6 +51,7 @@ import { updateSubTabHash } from './tabs.ts';
|
||||
import { createValueSourceCard } from './value-sources.ts';
|
||||
import { createSyncClockCard, initSyncClockDelegation } from './sync-clocks.ts';
|
||||
import { createWeatherSourceCard, initWeatherSourceDelegation } from './weather-sources.ts';
|
||||
import { createHASourceCard, initHASourceDelegation } from './home-assistant-sources.ts';
|
||||
import { createAssetCard, initAssetDelegation } from './assets.ts';
|
||||
import { createColorStripCard } from './color-strips.ts';
|
||||
import { initAudioSourceDelegation } from './audio-sources.ts';
|
||||
@@ -98,6 +100,7 @@ const _colorStripDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon:
|
||||
const _valueSourceDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('value-sources', valueSourcesCache, 'value_source.deleted') }];
|
||||
const _syncClockDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('sync-clocks', syncClocksCache, 'sync_clock.deleted') }];
|
||||
const _weatherSourceDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('weather-sources', weatherSourcesCache, 'weather_source.deleted') }];
|
||||
const _haSourceDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('home-assistant/sources', haSourcesCache, 'ha_source.deleted') }];
|
||||
const _assetDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('assets', assetsCache, 'asset.deleted') }];
|
||||
const _csptDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('color-strip-processing-templates', csptCache, 'templates.deleted') }];
|
||||
|
||||
@@ -168,6 +171,7 @@ const csColorStrips = new CardSection('color-strips', { titleKey: 'targets.secti
|
||||
const csValueSources = new CardSection('value-sources', { titleKey: 'value_source.group.title', gridClass: 'templates-grid', addCardOnclick: "showValueSourceModal()", keyAttr: 'data-id', emptyKey: 'section.empty.value_sources', bulkActions: _valueSourceDeleteAction });
|
||||
const csSyncClocks = new CardSection('sync-clocks', { titleKey: 'sync_clock.group.title', gridClass: 'templates-grid', addCardOnclick: "showSyncClockModal()", keyAttr: 'data-id', emptyKey: 'section.empty.sync_clocks', bulkActions: _syncClockDeleteAction });
|
||||
const csWeatherSources = new CardSection('weather-sources', { titleKey: 'weather_source.group.title', gridClass: 'templates-grid', addCardOnclick: "showWeatherSourceModal()", keyAttr: 'data-id', emptyKey: 'section.empty.weather_sources', bulkActions: _weatherSourceDeleteAction });
|
||||
const csHASources = new CardSection('ha-sources', { titleKey: 'ha_source.group.title', gridClass: 'templates-grid', addCardOnclick: "showHASourceModal()", keyAttr: 'data-id', emptyKey: 'section.empty.ha_sources', bulkActions: _haSourceDeleteAction });
|
||||
const csAssets = new CardSection('assets', { titleKey: 'asset.group.title', gridClass: 'templates-grid', addCardOnclick: "showAssetUploadModal()", keyAttr: 'data-id', emptyKey: 'section.empty.assets', bulkActions: _assetDeleteAction });
|
||||
const csCSPTemplates = new CardSection('css-proc-templates', { titleKey: 'css_processing.title', gridClass: 'templates-grid', addCardOnclick: "showAddCSPTModal()", keyAttr: 'data-cspt-id', emptyKey: 'section.empty.cspt', bulkActions: _csptDeleteAction });
|
||||
const _gradientDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('gradients', gradientsCache, 'gradient.deleted') }];
|
||||
@@ -282,6 +286,7 @@ export async function loadPictureSources() {
|
||||
valueSourcesCache.fetch(),
|
||||
syncClocksCache.fetch(),
|
||||
weatherSourcesCache.fetch(),
|
||||
haSourcesCache.fetch(),
|
||||
assetsCache.fetch(),
|
||||
audioTemplatesCache.fetch(),
|
||||
colorStripSourcesCache.fetch(),
|
||||
@@ -340,6 +345,7 @@ const _streamSectionMap = {
|
||||
value: [csValueSources],
|
||||
sync: [csSyncClocks],
|
||||
weather: [csWeatherSources],
|
||||
home_assistant: [csHASources],
|
||||
};
|
||||
|
||||
type StreamCardRenderer = (stream: any) => string;
|
||||
@@ -566,6 +572,7 @@ function renderPictureSourcesList(streams: any) {
|
||||
{ key: 'value', icon: ICON_VALUE_SOURCE, titleKey: 'streams.group.value', count: _cachedValueSources.length },
|
||||
{ key: 'sync', icon: ICON_CLOCK, titleKey: 'streams.group.sync', count: _cachedSyncClocks.length },
|
||||
{ key: 'weather', icon: `<svg class="icon" viewBox="0 0 24 24">${P.cloudSun}</svg>`, titleKey: 'streams.group.weather', count: _cachedWeatherSources.length },
|
||||
{ key: 'home_assistant', icon: `<svg class="icon" viewBox="0 0 24 24">${P.home}</svg>`, titleKey: 'streams.group.home_assistant', count: _cachedHASources.length },
|
||||
{ key: 'assets', icon: ICON_ASSET, titleKey: 'streams.group.assets', count: _cachedAssets.length },
|
||||
];
|
||||
|
||||
@@ -620,6 +627,7 @@ function renderPictureSourcesList(streams: any) {
|
||||
{ key: 'value', titleKey: 'streams.group.value', icon: ICON_VALUE_SOURCE, count: _cachedValueSources.length },
|
||||
{ key: 'sync', titleKey: 'streams.group.sync', icon: ICON_CLOCK, count: _cachedSyncClocks.length },
|
||||
{ key: 'weather', titleKey: 'streams.group.weather', icon: `<svg class="icon" viewBox="0 0 24 24">${P.cloudSun}</svg>`, count: _cachedWeatherSources.length },
|
||||
{ key: 'home_assistant', titleKey: 'streams.group.home_assistant', icon: `<svg class="icon" viewBox="0 0 24 24">${P.home}</svg>`, count: _cachedHASources.length },
|
||||
{ key: 'assets', titleKey: 'streams.group.assets', icon: ICON_ASSET, count: _cachedAssets.length },
|
||||
]
|
||||
}
|
||||
@@ -781,6 +789,7 @@ function renderPictureSourcesList(streams: any) {
|
||||
const valueItems = csValueSources.applySortOrder(_cachedValueSources.map(s => ({ key: s.id, html: createValueSourceCard(s) })));
|
||||
const syncClockItems = csSyncClocks.applySortOrder(_cachedSyncClocks.map(s => ({ key: s.id, html: createSyncClockCard(s) })));
|
||||
const weatherSourceItems = csWeatherSources.applySortOrder(_cachedWeatherSources.map(s => ({ key: s.id, html: createWeatherSourceCard(s) })));
|
||||
const haSourceItems = csHASources.applySortOrder(_cachedHASources.map(s => ({ key: s.id, html: createHASourceCard(s) })));
|
||||
const assetItems = csAssets.applySortOrder(_cachedAssets.map(a => ({ key: a.id, html: createAssetCard(a) })));
|
||||
const csptItems = csCSPTemplates.applySortOrder(csptTemplates.map(t => ({ key: t.id, html: renderCSPTCard(t) })));
|
||||
|
||||
@@ -801,6 +810,7 @@ function renderPictureSourcesList(streams: any) {
|
||||
value: _cachedValueSources.length,
|
||||
sync: _cachedSyncClocks.length,
|
||||
weather: _cachedWeatherSources.length,
|
||||
home_assistant: _cachedHASources.length,
|
||||
assets: _cachedAssets.length,
|
||||
});
|
||||
csRawStreams.reconcile(rawStreamItems);
|
||||
@@ -819,6 +829,7 @@ function renderPictureSourcesList(streams: any) {
|
||||
csValueSources.reconcile(valueItems);
|
||||
csSyncClocks.reconcile(syncClockItems);
|
||||
csWeatherSources.reconcile(weatherSourceItems);
|
||||
csHASources.reconcile(haSourceItems);
|
||||
csAssets.reconcile(assetItems);
|
||||
} else {
|
||||
// First render: build full HTML
|
||||
@@ -838,6 +849,7 @@ function renderPictureSourcesList(streams: any) {
|
||||
else if (tab.key === 'value') panelContent = csValueSources.render(valueItems);
|
||||
else if (tab.key === 'sync') panelContent = csSyncClocks.render(syncClockItems);
|
||||
else if (tab.key === 'weather') panelContent = csWeatherSources.render(weatherSourceItems);
|
||||
else if (tab.key === 'home_assistant') panelContent = csHASources.render(haSourceItems);
|
||||
else if (tab.key === 'assets') panelContent = csAssets.render(assetItems);
|
||||
else if (tab.key === 'video') panelContent = csVideoStreams.render(videoItems);
|
||||
else panelContent = csStaticStreams.render(staticItems);
|
||||
@@ -845,11 +857,12 @@ function renderPictureSourcesList(streams: any) {
|
||||
}).join('');
|
||||
|
||||
container.innerHTML = panels;
|
||||
CardSection.bindAll([csRawStreams, csRawTemplates, csProcStreams, csProcTemplates, csCSPTemplates, csColorStrips, csGradients, csAudioMulti, csAudioMono, csAudioBandExtract, csAudioTemplates, csStaticStreams, csVideoStreams, csValueSources, csSyncClocks, csWeatherSources, csAssets]);
|
||||
CardSection.bindAll([csRawStreams, csRawTemplates, csProcStreams, csProcTemplates, csCSPTemplates, csColorStrips, csGradients, csAudioMulti, csAudioMono, csAudioBandExtract, csAudioTemplates, csStaticStreams, csVideoStreams, csValueSources, csSyncClocks, csWeatherSources, csHASources, csAssets]);
|
||||
|
||||
// Event delegation for card actions (replaces inline onclick handlers)
|
||||
initSyncClockDelegation(container);
|
||||
initWeatherSourceDelegation(container);
|
||||
initHASourceDelegation(container);
|
||||
initAudioSourceDelegation(container);
|
||||
initAssetDelegation(container);
|
||||
|
||||
@@ -869,6 +882,7 @@ function renderPictureSourcesList(streams: any) {
|
||||
'value-sources': 'value',
|
||||
'sync-clocks': 'sync',
|
||||
'weather-sources': 'weather',
|
||||
'ha-sources': 'home_assistant',
|
||||
'assets': 'assets',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -413,6 +413,27 @@ export interface WeatherSourceListResponse {
|
||||
count: number;
|
||||
}
|
||||
|
||||
// ── Home Assistant Source ────────────────────────────────────
|
||||
|
||||
export interface HomeAssistantSource {
|
||||
id: string;
|
||||
name: string;
|
||||
host: string;
|
||||
use_ssl: boolean;
|
||||
entity_filters: string[];
|
||||
connected: boolean;
|
||||
entity_count: number;
|
||||
description?: string;
|
||||
tags: string[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface HomeAssistantSourceListResponse {
|
||||
sources: HomeAssistantSource[];
|
||||
count: number;
|
||||
}
|
||||
|
||||
// ── Asset ────────────────────────────────────────────────────
|
||||
|
||||
export interface Asset {
|
||||
|
||||
Reference in New Issue
Block a user