feat: entity picker for HA light mapping — searchable EntitySelect for light entities
Some checks failed
Lint & Test / test (push) Failing after 11m19s
Some checks failed
Lint & Test / test (push) Failing after 11m19s
Replaces plain text input with EntitySelect dropdown that fetches available light.* entities from the selected HA source. Changing the HA source refreshes the entity list across all mapping rows.
This commit is contained in:
@@ -7,7 +7,7 @@ import { fetchWithAuth, escapeHtml } from '../core/api.ts';
|
|||||||
import { t } from '../core/i18n.ts';
|
import { t } from '../core/i18n.ts';
|
||||||
import { Modal } from '../core/modal.ts';
|
import { Modal } from '../core/modal.ts';
|
||||||
import { showToast, showConfirm } from '../core/ui.ts';
|
import { showToast, showConfirm } from '../core/ui.ts';
|
||||||
import { ICON_CLONE, ICON_EDIT, ICON_START, ICON_STOP } from '../core/icons.ts';
|
import { ICON_CLONE, ICON_EDIT, ICON_START, ICON_STOP, ICON_TRASH } from '../core/icons.ts';
|
||||||
import * as P from '../core/icon-paths.ts';
|
import * as P from '../core/icon-paths.ts';
|
||||||
import { EntitySelect } from '../core/entity-palette.ts';
|
import { EntitySelect } from '../core/entity-palette.ts';
|
||||||
import { wrapCard } from '../core/card-colors.ts';
|
import { wrapCard } from '../core/card-colors.ts';
|
||||||
@@ -22,7 +22,9 @@ const _icon = (d: string) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
|
|||||||
let _haLightTagsInput: TagInput | null = null;
|
let _haLightTagsInput: TagInput | null = null;
|
||||||
let _haSourceEntitySelect: EntitySelect | null = null;
|
let _haSourceEntitySelect: EntitySelect | null = null;
|
||||||
let _cssSourceEntitySelect: EntitySelect | null = null;
|
let _cssSourceEntitySelect: EntitySelect | null = null;
|
||||||
|
let _mappingEntitySelects: EntitySelect[] = [];
|
||||||
let _editorCssSources: any[] = [];
|
let _editorCssSources: any[] = [];
|
||||||
|
let _cachedHAEntities: any[] = []; // fetched from selected HA source
|
||||||
|
|
||||||
class HALightEditorModal extends Modal {
|
class HALightEditorModal extends Modal {
|
||||||
constructor() { super('ha-light-editor-modal'); }
|
constructor() { super('ha-light-editor-modal'); }
|
||||||
@@ -31,6 +33,7 @@ class HALightEditorModal extends Modal {
|
|||||||
if (_haLightTagsInput) { _haLightTagsInput.destroy(); _haLightTagsInput = null; }
|
if (_haLightTagsInput) { _haLightTagsInput.destroy(); _haLightTagsInput = null; }
|
||||||
if (_haSourceEntitySelect) { _haSourceEntitySelect.destroy(); _haSourceEntitySelect = null; }
|
if (_haSourceEntitySelect) { _haSourceEntitySelect.destroy(); _haSourceEntitySelect = null; }
|
||||||
if (_cssSourceEntitySelect) { _cssSourceEntitySelect.destroy(); _cssSourceEntitySelect = null; }
|
if (_cssSourceEntitySelect) { _cssSourceEntitySelect.destroy(); _cssSourceEntitySelect = null; }
|
||||||
|
_destroyMappingEntitySelects();
|
||||||
}
|
}
|
||||||
|
|
||||||
snapshotValues() {
|
snapshotValues() {
|
||||||
@@ -48,12 +51,17 @@ class HALightEditorModal extends Modal {
|
|||||||
|
|
||||||
const haLightEditorModal = new HALightEditorModal();
|
const haLightEditorModal = new HALightEditorModal();
|
||||||
|
|
||||||
|
function _destroyMappingEntitySelects(): void {
|
||||||
|
for (const es of _mappingEntitySelects) es.destroy();
|
||||||
|
_mappingEntitySelects = [];
|
||||||
|
}
|
||||||
|
|
||||||
function _getMappingsJSON(): string {
|
function _getMappingsJSON(): string {
|
||||||
const rows = document.querySelectorAll('#ha-light-mappings-list .ha-light-mapping-row');
|
const rows = document.querySelectorAll('#ha-light-mappings-list .ha-light-mapping-row');
|
||||||
const mappings: any[] = [];
|
const mappings: any[] = [];
|
||||||
rows.forEach(row => {
|
rows.forEach(row => {
|
||||||
mappings.push({
|
mappings.push({
|
||||||
entity_id: (row.querySelector('.ha-mapping-entity') as HTMLInputElement).value.trim(),
|
entity_id: (row.querySelector('.ha-mapping-entity') as HTMLSelectElement).value.trim(),
|
||||||
led_start: parseInt((row.querySelector('.ha-mapping-led-start') as HTMLInputElement).value) || 0,
|
led_start: parseInt((row.querySelector('.ha-mapping-led-start') as HTMLInputElement).value) || 0,
|
||||||
led_end: parseInt((row.querySelector('.ha-mapping-led-end') as HTMLInputElement).value) || -1,
|
led_end: parseInt((row.querySelector('.ha-mapping-led-end') as HTMLInputElement).value) || -1,
|
||||||
brightness_scale: parseFloat((row.querySelector('.ha-mapping-brightness') as HTMLInputElement).value) || 1.0,
|
brightness_scale: parseFloat((row.querySelector('.ha-mapping-brightness') as HTMLInputElement).value) || 1.0,
|
||||||
@@ -62,36 +70,134 @@ function _getMappingsJSON(): string {
|
|||||||
return JSON.stringify(mappings);
|
return JSON.stringify(mappings);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function _getEntityItems() {
|
||||||
|
return _cachedHAEntities
|
||||||
|
.filter((e: any) => e.domain === 'light')
|
||||||
|
.map((e: any) => ({
|
||||||
|
value: e.entity_id,
|
||||||
|
label: e.friendly_name || e.entity_id,
|
||||||
|
icon: _icon(P.lightbulb),
|
||||||
|
desc: e.state || '',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _fetchHAEntities(haSourceId: string): Promise<void> {
|
||||||
|
if (!haSourceId) { _cachedHAEntities = []; return; }
|
||||||
|
try {
|
||||||
|
const resp = await fetchWithAuth(`/home-assistant/sources/${haSourceId}/entities`);
|
||||||
|
if (!resp.ok) { _cachedHAEntities = []; return; }
|
||||||
|
const data = await resp.json();
|
||||||
|
_cachedHAEntities = data.entities || [];
|
||||||
|
} catch {
|
||||||
|
_cachedHAEntities = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _rebuildMappingEntityOptions(): void {
|
||||||
|
// Update all existing mapping entity <select> elements with new options
|
||||||
|
const lightEntities = _cachedHAEntities.filter((e: any) => e.domain === 'light');
|
||||||
|
const rows = document.querySelectorAll('#ha-light-mappings-list .ha-light-mapping-row');
|
||||||
|
rows.forEach(row => {
|
||||||
|
const sel = row.querySelector('.ha-mapping-entity') as HTMLSelectElement;
|
||||||
|
if (!sel) return;
|
||||||
|
const currentVal = sel.value;
|
||||||
|
sel.innerHTML = `<option value="">${t('ha_light.mapping.select_entity')}</option>` +
|
||||||
|
lightEntities.map((e: any) =>
|
||||||
|
`<option value="${e.entity_id}" ${e.entity_id === currentVal ? 'selected' : ''}>${escapeHtml(e.friendly_name || e.entity_id)}</option>`
|
||||||
|
).join('');
|
||||||
|
// Restore value if it was set but not in list
|
||||||
|
if (currentVal && !lightEntities.some((e: any) => e.entity_id === currentVal)) {
|
||||||
|
sel.innerHTML += `<option value="${escapeHtml(currentVal)}" selected>${escapeHtml(currentVal)}</option>`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Rebuild EntitySelect instances (destroy old, create new)
|
||||||
|
_destroyMappingEntitySelects();
|
||||||
|
rows.forEach(row => {
|
||||||
|
const sel = row.querySelector('.ha-mapping-entity') as HTMLSelectElement;
|
||||||
|
if (!sel) return;
|
||||||
|
const es = new EntitySelect({
|
||||||
|
target: sel,
|
||||||
|
getItems: _getEntityItems,
|
||||||
|
placeholder: t('ha_light.mapping.search_entity'),
|
||||||
|
});
|
||||||
|
_mappingEntitySelects.push(es);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ── Mapping rows ──
|
// ── Mapping rows ──
|
||||||
|
|
||||||
export function addHALightMapping(data: any = null): void {
|
export function addHALightMapping(data: any = null): void {
|
||||||
const list = document.getElementById('ha-light-mappings-list');
|
const list = document.getElementById('ha-light-mappings-list');
|
||||||
if (!list) return;
|
if (!list) return;
|
||||||
|
|
||||||
|
const idx = list.querySelectorAll('.ha-light-mapping-row').length + 1;
|
||||||
const row = document.createElement('div');
|
const row = document.createElement('div');
|
||||||
row.className = 'ha-light-mapping-row condition-fields';
|
row.className = 'ha-light-mapping-row';
|
||||||
|
|
||||||
|
// Build options from cached entities (light domain only)
|
||||||
|
const lightEntities = _cachedHAEntities.filter((e: any) => e.domain === 'light');
|
||||||
|
const selectedId = data?.entity_id || '';
|
||||||
|
const entityOptions = `<option value="">${t('ha_light.mapping.select_entity')}</option>` +
|
||||||
|
lightEntities.map((e: any) =>
|
||||||
|
`<option value="${e.entity_id}" ${e.entity_id === selectedId ? 'selected' : ''}>${escapeHtml(e.friendly_name || e.entity_id)}</option>`
|
||||||
|
).join('');
|
||||||
|
// If editing and the entity isn't in the list (HA disconnected), add it manually
|
||||||
|
const hasSelected = !selectedId || lightEntities.some((e: any) => e.entity_id === selectedId);
|
||||||
|
const extraOption = (!hasSelected && selectedId)
|
||||||
|
? `<option value="${escapeHtml(selectedId)}" selected>${escapeHtml(selectedId)}</option>`
|
||||||
|
: '';
|
||||||
|
|
||||||
row.innerHTML = `
|
row.innerHTML = `
|
||||||
<div class="condition-field">
|
<div class="ha-mapping-header">
|
||||||
<label>${t('ha_light.mapping.entity_id')}</label>
|
<span class="ha-mapping-label">${_icon(P.lightbulb)} #${idx}</span>
|
||||||
<input type="text" class="ha-mapping-entity" value="${escapeHtml(data?.entity_id || '')}" placeholder="light.living_room">
|
<button type="button" class="btn-remove-mapping" onclick="removeHALightMapping(this)" title="${t('common.delete')}">${ICON_TRASH}</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="condition-field" style="display:flex; gap:0.5rem;">
|
<div class="ha-mapping-fields">
|
||||||
<div style="flex:1">
|
<div class="ha-mapping-field">
|
||||||
|
<label>${t('ha_light.mapping.entity_id')}</label>
|
||||||
|
<select class="ha-mapping-entity">${entityOptions}${extraOption}</select>
|
||||||
|
</div>
|
||||||
|
<div class="ha-mapping-range-row">
|
||||||
|
<div>
|
||||||
<label>${t('ha_light.mapping.led_start')}</label>
|
<label>${t('ha_light.mapping.led_start')}</label>
|
||||||
<input type="number" class="ha-mapping-led-start" value="${data?.led_start ?? 0}" min="0" step="1">
|
<input type="number" class="ha-mapping-led-start" value="${data?.led_start ?? 0}" min="0" step="1">
|
||||||
</div>
|
</div>
|
||||||
<div style="flex:1">
|
<div>
|
||||||
<label>${t('ha_light.mapping.led_end')}</label>
|
<label>${t('ha_light.mapping.led_end')}</label>
|
||||||
<input type="number" class="ha-mapping-led-end" value="${data?.led_end ?? -1}" min="-1" step="1">
|
<input type="number" class="ha-mapping-led-end" value="${data?.led_end ?? -1}" min="-1" step="1">
|
||||||
</div>
|
</div>
|
||||||
<div style="flex:1">
|
<div>
|
||||||
<label>${t('ha_light.mapping.brightness')}</label>
|
<label>${t('ha_light.mapping.brightness')}</label>
|
||||||
<input type="number" class="ha-mapping-brightness" value="${data?.brightness_scale ?? 1.0}" min="0" max="1" step="0.1">
|
<input type="number" class="ha-mapping-brightness" value="${data?.brightness_scale ?? 1.0}" min="0" max="1" step="0.1">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="btn btn-sm btn-danger ha-mapping-remove" onclick="this.closest('.ha-light-mapping-row').remove()">×</button>
|
</div>
|
||||||
`;
|
`;
|
||||||
list.appendChild(row);
|
list.appendChild(row);
|
||||||
|
|
||||||
|
// Attach EntitySelect to the entity dropdown
|
||||||
|
const entitySelect = row.querySelector('.ha-mapping-entity') as HTMLSelectElement;
|
||||||
|
const es = new EntitySelect({
|
||||||
|
target: entitySelect,
|
||||||
|
getItems: _getEntityItems,
|
||||||
|
placeholder: t('ha_light.mapping.search_entity'),
|
||||||
|
});
|
||||||
|
_mappingEntitySelects.push(es);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeHALightMapping(btn: HTMLElement): void {
|
||||||
|
const row = btn.closest('.ha-light-mapping-row');
|
||||||
|
if (!row) return;
|
||||||
|
// Find and destroy the EntitySelect for this row
|
||||||
|
const select = row.querySelector('.ha-mapping-entity') as HTMLSelectElement;
|
||||||
|
if (select) {
|
||||||
|
const idx = _mappingEntitySelects.findIndex(es => (es as any)._target === select || (es as any).target === select);
|
||||||
|
if (idx >= 0) {
|
||||||
|
_mappingEntitySelects[idx].destroy();
|
||||||
|
_mappingEntitySelects.splice(idx, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
row.remove();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Show / Close ──
|
// ── Show / Close ──
|
||||||
@@ -124,6 +230,7 @@ export async function showHALightEditor(targetId: string | null = null, cloneDat
|
|||||||
).join('');
|
).join('');
|
||||||
|
|
||||||
// Clear mappings
|
// Clear mappings
|
||||||
|
_destroyMappingEntitySelects();
|
||||||
document.getElementById('ha-light-mappings-list')!.innerHTML = '';
|
document.getElementById('ha-light-mappings-list')!.innerHTML = '';
|
||||||
|
|
||||||
let editData: any = null;
|
let editData: any = null;
|
||||||
@@ -153,7 +260,10 @@ export async function showHALightEditor(targetId: string | null = null, cloneDat
|
|||||||
document.getElementById('ha-light-editor-transition-display')!.textContent = (editData.ha_transition ?? 0.5).toFixed(1);
|
document.getElementById('ha-light-editor-transition-display')!.textContent = (editData.ha_transition ?? 0.5).toFixed(1);
|
||||||
(document.getElementById('ha-light-editor-description') as HTMLInputElement).value = editData.description || '';
|
(document.getElementById('ha-light-editor-description') as HTMLInputElement).value = editData.description || '';
|
||||||
|
|
||||||
// Load mappings
|
// Fetch entities from the selected HA source before loading mappings
|
||||||
|
await _fetchHAEntities(editData.ha_source_id || '');
|
||||||
|
|
||||||
|
// Load mappings (with entity picker populated)
|
||||||
const mappings = editData.ha_light_mappings || [];
|
const mappings = editData.ha_light_mappings || [];
|
||||||
mappings.forEach((m: any) => addHALightMapping(m));
|
mappings.forEach((m: any) => addHALightMapping(m));
|
||||||
} else {
|
} else {
|
||||||
@@ -163,6 +273,11 @@ export async function showHALightEditor(targetId: string | null = null, cloneDat
|
|||||||
(document.getElementById('ha-light-editor-transition') as HTMLInputElement).value = '0.5';
|
(document.getElementById('ha-light-editor-transition') as HTMLInputElement).value = '0.5';
|
||||||
document.getElementById('ha-light-editor-transition-display')!.textContent = '0.5';
|
document.getElementById('ha-light-editor-transition-display')!.textContent = '0.5';
|
||||||
(document.getElementById('ha-light-editor-description') as HTMLInputElement).value = '';
|
(document.getElementById('ha-light-editor-description') as HTMLInputElement).value = '';
|
||||||
|
|
||||||
|
// Fetch entities from the first HA source
|
||||||
|
const firstSource = haSources[0]?.id || '';
|
||||||
|
await _fetchHAEntities(firstSource);
|
||||||
|
|
||||||
// Add one empty mapping by default
|
// Add one empty mapping by default
|
||||||
addHALightMapping();
|
addHALightMapping();
|
||||||
}
|
}
|
||||||
@@ -176,6 +291,11 @@ export async function showHALightEditor(targetId: string | null = null, cloneDat
|
|||||||
desc: s.connected ? t('ha_source.connected') : t('ha_source.disconnected'),
|
desc: s.connected ? t('ha_source.connected') : t('ha_source.disconnected'),
|
||||||
})),
|
})),
|
||||||
placeholder: t('palette.search'),
|
placeholder: t('palette.search'),
|
||||||
|
onChange: async (newSourceId: string) => {
|
||||||
|
// Refetch entities when HA source changes, then rebuild mapping selects
|
||||||
|
await _fetchHAEntities(newSourceId);
|
||||||
|
_rebuildMappingEntityOptions();
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (_cssSourceEntitySelect) { _cssSourceEntitySelect.destroy(); _cssSourceEntitySelect = null; }
|
if (_cssSourceEntitySelect) { _cssSourceEntitySelect.destroy(); _cssSourceEntitySelect = null; }
|
||||||
@@ -373,3 +493,4 @@ window.saveHALightEditor = saveHALightEditor;
|
|||||||
window.editHALightTarget = editHALightTarget;
|
window.editHALightTarget = editHALightTarget;
|
||||||
window.cloneHALightTarget = cloneHALightTarget;
|
window.cloneHALightTarget = cloneHALightTarget;
|
||||||
window.addHALightMapping = addHALightMapping;
|
window.addHALightMapping = addHALightMapping;
|
||||||
|
window.removeHALightMapping = removeHALightMapping;
|
||||||
|
|||||||
@@ -1842,6 +1842,8 @@
|
|||||||
"ha_light.error.ha_source_required": "HA connection is required",
|
"ha_light.error.ha_source_required": "HA connection is required",
|
||||||
"ha_light.created": "HA light target created",
|
"ha_light.created": "HA light target created",
|
||||||
"ha_light.updated": "HA light target updated",
|
"ha_light.updated": "HA light target updated",
|
||||||
|
"ha_light.mapping.select_entity": "Select a light entity...",
|
||||||
|
"ha_light.mapping.search_entity": "Search light entities...",
|
||||||
"section.empty.ha_light_targets": "No HA light targets yet. Click + to add one.",
|
"section.empty.ha_light_targets": "No HA light targets yet. Click + to add one.",
|
||||||
"automations.condition.home_assistant": "Home Assistant",
|
"automations.condition.home_assistant": "Home Assistant",
|
||||||
"automations.condition.home_assistant.desc": "HA entity state",
|
"automations.condition.home_assistant.desc": "HA entity state",
|
||||||
|
|||||||
Reference in New Issue
Block a user