v0.14.13
This commit is contained in:
@@ -33,6 +33,7 @@ const app = {
|
||||
historyZone: 'all', historyDevice: 'all', historySensor: 'all', historyNetworkTarget: 'all', historyNetworkShowJitter: true, historyNetwork: [], historyNetworkTargets: [], historyEnergyDevice: '', historyEnergyTargets: [], historyEnergyInterval: 'daily', historyEnergyCompare: 'none', historyEnergy: [], historyLoading: false, historyReloadPending: false,
|
||||
customChartSeries: [], savedCharts: [], chartZooms: {}, chartHiddenSeries: {}, zoneControlSeq: {}, groupControlSeq: {}, zoneControlQueue: {}, groupControlQueue: {}, deviceControlQueue: {}, houseControlQueue: null, climateControlQueue: null, groupCustomDrafts: {}, zoneTemperatureTimers: {}, deviceTemperatureDrafts: {},
|
||||
controlPlan: null, controlPlanRevision: null, controlPlanPushReady: false, controlPlanTimer: null, debugLines: [], debugBacklogLoaded: false, debugFilter: 'all', sensorAliases: {}, flowSharedInputs: [], flowSharedInputValueCache: {}, flowSharedInputValueRequests: {}, flowSharedInputValueTimer: null,
|
||||
haManualFallbackVisible: false, haSupervisorTestState: null, haEntityCatalog: [], haEntityCatalogConfigured: false, haEntityCatalogLoadedAt: 0, haEntityCatalogLoading: false,
|
||||
simulationScope: 'units', simulationTarget: 'all', standaloneSimulation: false, dashboardTab: 'main', settingsTab: 'app', systemSnapshotAt: Date.now(),
|
||||
pingMonitor: { targetId: '', all: false, running: false, timer: null, inFlight: false, samples: {} },
|
||||
flowDraft: null, flowSelectedNodeId: null, flowSelectedNodeIds: [], flowConnectFrom: null, flowDirty: false, flowZoom: 1,
|
||||
|
||||
+150
-15
@@ -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();
|
||||
|
||||
+55
-6
@@ -146,6 +146,7 @@ function homeAssistantSettingsBodyFromForm(form) {
|
||||
outdoor_entity_id: raw.ha_outdoor_entity_id,
|
||||
sensor_stale_after_seconds: Math.max(60, Math.min(86400, Math.round(Number(raw.ha_sensor_stale_after_minutes || 5) * 60))),
|
||||
allow_invalid_tls: form.ha_allow_invalid_tls.checked,
|
||||
manual_auth_override: form.dataset.haManualOverride === 'true',
|
||||
sensor_aliases: { ...(app.sensorAliases || {}) },
|
||||
flow_inputs: JSON.parse(JSON.stringify(app.flowSharedInputs || [])),
|
||||
outdoor_assist_enabled: form.outdoor_assist_enabled.checked,
|
||||
@@ -200,6 +201,7 @@ async function saveHomeAssistantSettings(form, notify = true) {
|
||||
applySettingsSection('homeAssistant', data);
|
||||
app.sensorAliases = { ...(data.sensor_aliases || {}) };
|
||||
app.flowSharedInputs = JSON.parse(JSON.stringify(data.flow_inputs || []));
|
||||
app.haEntityCatalogLoadedAt = 0;
|
||||
refreshSettingsUi();
|
||||
if (notify) toast(tr('common.saved'));
|
||||
}
|
||||
@@ -280,19 +282,66 @@ $('#copyAccessToken').addEventListener('click', async () => {
|
||||
}
|
||||
});
|
||||
|
||||
function homeAssistantTestToast(result) {
|
||||
const sample = result?.sample;
|
||||
if (!sample?.entity_id) return tr('toast.haConnected');
|
||||
const value = `${sample.state ?? '—'}${sample.unit ? ` ${sample.unit}` : ''}`;
|
||||
const label = sample.name ? `${sample.name} (${sample.entity_id})` : sample.entity_id;
|
||||
return tr('toast.haConnectedSample', { entity: label, value });
|
||||
}
|
||||
|
||||
$('#haTest').addEventListener('click', async event => {
|
||||
const form = $('#homeAssistantForm'), button = event.currentTarget;
|
||||
if (!validateForm(form)) return;
|
||||
clearFormErrors(form);
|
||||
const ha = app.settings?.home_assistant || {};
|
||||
const supervisorAuto = ha.supervisor_detected === true && form.dataset.haManualOverride !== 'true';
|
||||
if (!supervisorAuto && !validateForm(form)) return;
|
||||
button.disabled = true;
|
||||
const idle = button.textContent;
|
||||
button.textContent = tr('settings.testingHa');
|
||||
try {
|
||||
await saveHomeAssistantSettings(form, false);
|
||||
await api('/api/integrations/home-assistant/test', { method: 'POST' });
|
||||
if (!supervisorAuto) await saveHomeAssistantSettings(form, false);
|
||||
const result = await api('/api/integrations/home-assistant/test', { method: 'POST' });
|
||||
app.haSupervisorTestState = { ok: true, sample: result.sample || null };
|
||||
if (app.settings?.home_assistant?.supervisor_detected && app.settings.home_assistant.auth_mode === 'supervisor') {
|
||||
app.haManualFallbackVisible = false;
|
||||
}
|
||||
app.haEntityCatalogLoadedAt = 0;
|
||||
renderHomeAssistantAuthState();
|
||||
markFormClean(form);
|
||||
toast(tr('toast.haConnected'));
|
||||
} catch (error) { presentFormError(form, error); }
|
||||
finally { button.disabled = false; button.textContent = idle; }
|
||||
toast(homeAssistantTestToast(result));
|
||||
} catch (error) {
|
||||
if (supervisorAuto) {
|
||||
app.haSupervisorTestState = { ok: false, message: error.message };
|
||||
app.haManualFallbackVisible = true;
|
||||
renderHomeAssistantAuthState();
|
||||
toast(`${tr('settings.haSupervisorTestFailedTitle')}: ${error.message}`, true);
|
||||
} else {
|
||||
presentFormError(form, error);
|
||||
}
|
||||
} finally { button.disabled = false; button.textContent = idle; }
|
||||
});
|
||||
|
||||
$('#haUseSupervisor')?.addEventListener('click', async event => {
|
||||
const form = $('#homeAssistantForm'), button = event.currentTarget;
|
||||
const idle = button.textContent;
|
||||
button.disabled = true;
|
||||
clearFormErrors(form);
|
||||
try {
|
||||
app.haManualFallbackVisible = false;
|
||||
form.dataset.haManualOverride = 'false';
|
||||
await saveHomeAssistantSettings(form, false);
|
||||
const result = await api('/api/integrations/home-assistant/test', { method: 'POST' });
|
||||
app.haSupervisorTestState = { ok: true, sample: result.sample || null };
|
||||
app.haEntityCatalogLoadedAt = 0;
|
||||
renderHomeAssistantSettings();
|
||||
toast(homeAssistantTestToast(result));
|
||||
} catch (error) {
|
||||
app.haManualFallbackVisible = true;
|
||||
app.haSupervisorTestState = { ok: false, message: error.message };
|
||||
renderHomeAssistantAuthState();
|
||||
toast(`${tr('settings.haSupervisorTestFailedTitle')}: ${error.message}`, true);
|
||||
} finally { button.disabled = false; button.textContent = idle; }
|
||||
});
|
||||
|
||||
$('#exportSettings').addEventListener('click', async () => {
|
||||
|
||||
Reference in New Issue
Block a user