This commit is contained in:
Mateusz Gruszczyński
2026-09-16 14:24:40 +02:00
parent d0ae3d0f6a
commit 3f9acb1675
28 changed files with 726 additions and 127 deletions
+150 -15
View File
@@ -34,7 +34,11 @@ function knownHaEntities() {
function renderHaEntitySuggestions() {
const host = $('#haEntitySuggestions'); if (!host) return;
host.innerHTML = knownHaEntities().map(entity => {
const entities = [...new Set([
...knownHaEntities(),
...(app.haEntityCatalog || []).map(item => item?.entity_id).filter(Boolean),
])].sort();
host.innerHTML = entities.map(entity => {
const alias = haSensorLabel(entity);
return `<option value="${esc(entity)}" label="${esc(alias === entity ? '' : alias)}"></option>`;
}).join('');
@@ -116,15 +120,107 @@ function sharedFlowOptions(items, selected, label = item => item.name) {
return items.map(item => `<option value="${esc(item.id)}" ${item.id === selected ? 'selected' : ''}>${esc(label(item))}</option>`).join('');
}
function haEntityMatchesSharedKind(entity, kind) {
if (!entity?.entity_id) return false;
if (kind === 'ha_numeric') { const state = String(entity.state ?? '').trim(); return state !== '' && Number.isFinite(Number(state)); }
return true;
}
function haEntityPickerResults(kind, query = '') {
const needle = String(query || '').trim().toLocaleLowerCase();
return (app.haEntityCatalog || [])
.filter(entity => haEntityMatchesSharedKind(entity, kind))
.filter(entity => {
if (!needle) return true;
return [entity.entity_id, entity.name, entity.state, entity.unit, entity.device_class]
.some(value => String(value || '').toLocaleLowerCase().includes(needle));
})
.slice(0, 12);
}
function renderHaEntityPickerResults(input, kind) {
const picker = input?.closest?.('[data-ha-entity-picker]');
const host = picker?.querySelector?.('[data-ha-entity-results]');
const note = picker?.querySelector?.('[data-ha-entity-note]');
if (!host || !note) return;
if (app.haEntityCatalogLoading) {
note.textContent = tr('flow.haEntitySearchLoading');
host.hidden = true;
return;
}
if (!app.haEntityCatalogConfigured) {
note.textContent = tr('flow.haEntitySearchConnect');
host.hidden = true;
return;
}
const items = haEntityPickerResults(kind, input.value);
note.textContent = tr('flow.haEntitySearchCount', { count: app.haEntityCatalog.length });
host.innerHTML = items.length ? items.map(entity => {
const title = entity.name || entity.entity_id;
const state = `${entity.state || '—'}${entity.unit ? ` ${entity.unit}` : ''}`;
return `<button type="button" class="ha-entity-option" data-ha-entity-value="${esc(entity.entity_id)}"><span><strong>${esc(title)}</strong><code>${esc(entity.entity_id)}</code></span><small>${esc(state)}</small></button>`;
}).join('') : `<div class="ha-entity-empty">${esc(tr('flow.haEntitySearchEmpty'))}</div>`;
host.hidden = false;
}
async function loadHaEntityCatalog(force = false) {
const fresh = app.haEntityCatalogLoadedAt && Date.now() - app.haEntityCatalogLoadedAt < 60000;
if (!force && fresh) return app.haEntityCatalog;
if (app.haEntityCatalogLoading) return app.haEntityCatalog;
app.haEntityCatalogLoading = true;
const activeInput = $('#flowSharedInputFields [data-ha-entity-search]');
if (activeInput) renderHaEntityPickerResults(activeInput, $('#flowSharedInputKind')?.value || 'ha_state');
try {
const response = await api('/api/integrations/home-assistant/entities');
app.haEntityCatalog = Array.isArray(response.entities) ? response.entities : [];
app.haEntityCatalogConfigured = response.configured === true;
app.haEntityCatalogLoadedAt = Date.now();
renderHaEntitySuggestions();
} catch (_) {
app.haEntityCatalog = [];
app.haEntityCatalogConfigured = false;
app.haEntityCatalogLoadedAt = Date.now();
} finally {
app.haEntityCatalogLoading = false;
const input = $('#flowSharedInputFields [data-ha-entity-search]');
if (input) renderHaEntityPickerResults(input, $('#flowSharedInputKind')?.value || 'ha_state');
}
return app.haEntityCatalog;
}
function haEntityPickerMarkup(kind, value, placeholder) {
return `<div class="ha-entity-picker" data-ha-entity-picker><label><span>entity_id</span><input type="search" autocomplete="off" data-shared-config="entity_id" data-ha-entity-search value="${esc(value || '')}" placeholder="${esc(placeholder)}"></label><small class="field-note" data-ha-entity-note>${esc(tr('flow.haEntitySearchHint'))}</small><div class="ha-entity-results" data-ha-entity-results hidden></div></div>`;
}
function bindHaEntityPicker(kind) {
const input = $('#flowSharedInputFields [data-ha-entity-search]');
const results = $('#flowSharedInputFields [data-ha-entity-results]');
if (!input || !results) return;
const refresh = () => renderHaEntityPickerResults(input, kind);
input.addEventListener('input', refresh);
input.addEventListener('focus', refresh);
results.addEventListener('click', event => {
const option = event.target.closest?.('[data-ha-entity-value]');
if (!option) return;
input.value = option.dataset.haEntityValue || '';
input.dispatchEvent(new Event('change', { bubbles: true }));
results.hidden = true;
});
loadHaEntityCatalog().then(refresh);
}
function renderFlowSharedInputFields(kind, config = {}) {
const host = $('#flowSharedInputFields'); if (!host) return;
const c = config || {};
let fields = '';
if (kind === 'constant') fields = `<label><span>${esc(tr('flow.value'))}</span><select data-shared-config="value"><option value="true" ${c.value !== false ? 'selected' : ''}>${esc(tr('common.yes'))}</option><option value="false" ${c.value === false ? 'selected' : ''}>${esc(tr('common.no'))}</option></select></label>`;
else if (kind === 'ha_state') fields = `<label><span>entity_id</span><input data-shared-config="entity_id" list="haEntitySuggestions" value="${esc(c.entity_id || '')}" placeholder="binary_sensor.window"></label>`;
else if (kind === 'ha_numeric') fields = `<label><span>entity_id</span><input data-shared-config="entity_id" list="haEntitySuggestions" value="${esc(c.entity_id || '')}" placeholder="sensor.energy_price"></label>`;
else if (kind === 'ha_attribute') fields = `<label><span>entity_id</span><input data-shared-config="entity_id" list="haEntitySuggestions" value="${esc(c.entity_id || '')}" placeholder="climate.living_room"></label><label><span>${esc(tr('flow.attribute'))}</span><input data-shared-config="attribute" value="${esc(c.attribute || '')}" placeholder="hvac_action"></label>`;
else if (kind === 'ha_available') fields = `<label><span>entity_id</span><input data-shared-config="entity_id" list="haEntitySuggestions" value="${esc(c.entity_id || '')}" placeholder="binary_sensor.window"></label><p class="field-note">${esc(tr('flow.haAvailableHint'))}</p>`;
else if (kind === 'ha_state') fields = haEntityPickerMarkup(kind, c.entity_id, 'binary_sensor.window');
else if (kind === 'ha_numeric') fields = haEntityPickerMarkup(kind, c.entity_id, 'sensor.energy_price');
else if (kind === 'ha_attribute') fields = `${haEntityPickerMarkup(kind, c.entity_id, 'climate.living_room')}<label><span>${esc(tr('flow.attribute'))}</span><input data-shared-config="attribute" value="${esc(c.attribute || '')}" placeholder="hvac_action"></label>`;
else if (kind === 'ha_available') fields = `${haEntityPickerMarkup(kind, c.entity_id, 'binary_sensor.window')}<p class="field-note">${esc(tr('flow.haAvailableHint'))}</p>`;
else if (kind === 'outdoor_temperature') fields = `<p class="field-note">${esc(tr('flow.sharedInputSourceOnlyHint'))}</p>`;
else if (kind === 'device_temperature') fields = `<label><span>${esc(tr('common.device'))}</span><select data-shared-config="device_id">${sharedFlowOptions(app.devices, c.device_id)}</select></label>`;
else if (kind === 'zone_temperature') fields = `<label><span>${esc(tr('common.zone'))}</span><select data-shared-config="zone_id">${sharedFlowOptions(app.zones, c.zone_id)}</select></label>`;
@@ -134,6 +230,7 @@ function renderFlowSharedInputFields(kind, config = {}) {
else if (kind === 'group_state') fields = `<label><span>${esc(tr('groups.group'))}</span><select data-shared-config="group_id">${sharedFlowOptions(app.groups, c.group_id)}</select></label><input type="hidden" data-shared-config="field" value="power_enabled">`;
else if (kind === 'night_mode') fields = `<p class="field-note">${esc(tr('flow.nightModeHint'))}</p>`;
host.innerHTML = fields;
if (isHaSharedInputKind(kind)) bindHaEntityPicker(kind);
const testPanel = $('#flowSharedInputTestPanel'), testResult = $('#flowSharedInputTestResult');
if (testPanel) testPanel.hidden = !isHaSharedInputKind(kind);
if (testResult) { testResult.hidden = true; testResult.innerHTML = ''; }
@@ -337,22 +434,60 @@ function renderNightSettings() {
markFormClean(form);
}
function renderHomeAssistantAuthState() {
const form = $('#homeAssistantForm');
if (!form || !app.settings?.home_assistant) return;
const ha = app.settings.home_assistant;
const supervisorDetected = ha.supervisor_detected === true;
const manualFallback = supervisorDetected && (ha.manual_auth_override === true || app.haManualFallbackVisible === true);
form.dataset.haManualOverride = manualFallback ? 'true' : 'false';
$$('[data-ha-manual-field]', form).forEach(node => { node.hidden = supervisorDetected && !manualFallback; });
form.ha_url.required = manualFallback;
form.ha_token.required = manualFallback && !ha.manual_token_configured;
const useSupervisor = $('#haUseSupervisor');
if (useSupervisor) useSupervisor.hidden = !supervisorDetected || !manualFallback;
const status = $('#haSupervisorStatus');
if (!status) return;
status.hidden = !supervisorDetected;
status.classList.remove('success', 'warning', 'error');
if (!supervisorDetected) return;
const testState = app.haSupervisorTestState;
if (manualFallback) {
status.classList.add(testState?.ok === false ? 'error' : 'warning');
status.innerHTML = `<strong>${esc(testState?.ok === false ? tr('settings.haSupervisorTestFailedTitle') : tr('settings.haManualFallbackTitle'))}</strong><span>${esc(testState?.ok === false ? tr('settings.haSupervisorTestFailedHint') : tr('settings.haManualFallbackHint'))}</span>`;
} else if (testState?.ok === true) {
status.classList.add('success');
status.innerHTML = `<strong>${esc(tr('settings.haSupervisorVerifiedTitle'))}</strong><span>${esc(tr('settings.haSupervisorVerifiedHint'))}</span>`;
} else if (ha.supervisor_token_detected) {
status.classList.add('success');
status.innerHTML = `<strong>${esc(tr('settings.haSupervisorAutoTitle'))}</strong><span>${esc(tr('settings.haSupervisorAutoHint'))}</span>`;
} else {
status.classList.add('warning');
status.innerHTML = `<strong>${esc(tr('settings.haSupervisorMissingTitle'))}</strong><span>${esc(tr('settings.haSupervisorMissingHint'))}</span>`;
}
}
function renderHomeAssistantSettings() {
if (!app.settings) return;
const form = $('#homeAssistantForm');
if (!form) return;
const supervisorManaged = app.settings.home_assistant?.auth_mode === 'supervisor';
form.ha_url.value = app.settings.home_assistant?.url || '';
form.ha_url.readOnly = supervisorManaged;
const ha = app.settings.home_assistant || {};
const supervisorDetected = ha.supervisor_detected === true;
form.ha_url.value = supervisorDetected ? (ha.manual_url || '') : (ha.url || '');
form.ha_url.readOnly = false;
form.ha_token.value = '';
form.ha_token.readOnly = supervisorManaged;
form.ha_token.placeholder = supervisorManaged
? tr('settings.haSupervisorToken')
: (app.settings.home_assistant?.token_configured ? tr('settings.haTokenSaved') : tr('settings.haLongLivedToken'));
form.ha_outdoor_entity_id.value = app.settings.home_assistant?.outdoor_entity_id || '';
form.ha_sensor_stale_after_minutes.value = String(Math.max(1, Math.round(Number(app.settings.home_assistant?.sensor_stale_after_seconds || 300) / 60)));
form.ha_allow_invalid_tls.checked = !!app.settings.home_assistant?.allow_invalid_tls;
form.ha_token.readOnly = false;
form.ha_token.placeholder = (supervisorDetected ? ha.manual_token_configured : ha.token_configured)
? tr('settings.haTokenSaved')
: tr('settings.haLongLivedToken');
form.ha_outdoor_entity_id.value = ha.outdoor_entity_id || '';
form.ha_sensor_stale_after_minutes.value = String(Math.max(1, Math.round(Number(ha.sensor_stale_after_seconds || 300) / 60)));
form.ha_allow_invalid_tls.checked = !!ha.allow_invalid_tls;
form.outdoor_assist_enabled.checked = !!app.settings.outdoor_assist_enabled;
renderHomeAssistantAuthState();
renderSensorAliases();
renderFlowSharedInputs();
renderAccessTokens();