This commit is contained in:
Mateusz Gruszczyński
2026-09-04 11:00:15 +02:00
parent 8152e63d63
commit 236540e0f0
27 changed files with 557 additions and 102 deletions
+3 -1
View File
@@ -16,8 +16,10 @@ async function loadBootstrap(showMessage = false) {
app.system = data.system || {};
app.systemSnapshotAt = Date.now();
app.outdoorTemperature = Number.isFinite(Number(data.outdoor_temperature)) ? Number(data.outdoor_temperature) : null;
app.controlPlan = data.control_plan || app.controlPlan;
app.controlPlanRevision = Number.isFinite(Number(data.control_plan_revision)) ? Number(data.control_plan_revision) : app.controlPlanRevision;
renderAll();
loadControlPlan();
if (!data.control_plan) scheduleControlPlanLoad(0);
if (app.settings?.debug?.overlay_enabled) loadDebugBacklog();
if (showMessage) toast(tr('common.updated'));
if ($('#tokenDialog').open) $('#tokenDialog').close();
+1 -1
View File
@@ -32,7 +32,7 @@ const app = {
historyTab: 'overview', historyData: { zones: [], devices: [], sensors: [] }, historyCounts: {},
historyZone: 'all', historyDevice: 'all', historySensor: 'all', historyLoading: false,
customChartSeries: [], savedCharts: [], chartZooms: {}, chartHiddenSeries: {}, zoneControlSeq: {}, groupControlSeq: {}, zoneControlQueue: {}, groupControlQueue: {}, deviceControlQueue: {}, houseControlQueue: null, climateControlQueue: null, groupCustomDrafts: {}, zoneTemperatureTimers: {}, deviceTemperatureDrafts: {},
controlPlan: null, controlPlanTimer: null, debugLines: [], debugBacklogLoaded: false, debugFilter: 'all', sensorAliases: {}, flowSharedInputs: [], flowSharedInputValueCache: {}, flowSharedInputValueRequests: {}, flowSharedInputValueTimer: null,
controlPlan: null, controlPlanRevision: null, controlPlanPushReady: false, controlPlanTimer: null, debugLines: [], debugBacklogLoaded: false, debugFilter: 'all', sensorAliases: {}, flowSharedInputs: [], flowSharedInputValueCache: {}, flowSharedInputValueRequests: {}, flowSharedInputValueTimer: null,
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,
+38 -3
View File
@@ -133,9 +133,38 @@ function renderControlPlan() {
host.innerHTML = content;
if (section) section.hidden = !content;
}
function applyControlPlan(plan, revision = null) {
if (!plan || typeof plan !== 'object') return false;
const hasRevision = revision !== null && revision !== undefined && revision !== '';
const hasCurrentRevision = app.controlPlanRevision !== null && app.controlPlanRevision !== undefined && app.controlPlanRevision !== '';
const nextRevision = hasRevision ? Number(revision) : NaN;
const currentRevision = hasCurrentRevision ? Number(app.controlPlanRevision) : NaN;
if (Number.isFinite(nextRevision) && Number.isFinite(currentRevision) && nextRevision < currentRevision) return false;
app.controlPlan = plan;
if (Number.isFinite(nextRevision)) app.controlPlanRevision = nextRevision;
renderControlPlan();
renderSimulationPage();
return true;
}
function controlPlanWebSocketReady() {
return !!app.ws && app.ws.readyState === WebSocket.OPEN && app.controlPlanPushReady;
}
async function loadControlPlan() {
try { app.controlPlan = await api('/api/control-plan'); renderControlPlan(); renderSimulationPage(); }
try {
const plan = await api('/api/control-plan');
// A fallback request may have started while disconnected and finish after WS resync.
// Never let that older unversioned HTTP response overwrite a revisioned pushed plan.
if (!controlPlanWebSocketReady()) applyControlPlan(plan);
}
catch (error) { console.warn('Unable to load control plan:', error); }
finally {
if (!controlPlanWebSocketReady()) {
clearTimeout(app.controlPlanTimer);
app.controlPlanTimer = setTimeout(loadControlPlan, 10000);
}
}
}
function simulationTime(value, options = {}) {
@@ -409,8 +438,14 @@ function renderSimulationPage() {
rulesHost.innerHTML = activeRules.length ? activeRules.map(rule => `<article class="simulation-rule-card"><div class="simulation-rule-head"><div><span class="eyebrow">${esc(tr('common.automation'))}</span><h3>${esc(rule.name)}</h3></div><span class="badge active">${esc(automationTriggerLabel(rule))}</span></div><p>${esc(tr('simulation.ruleOnDevice', { device: rule.action_device_name || tr('common.noDevice') }))}</p><div class="simulation-rule-action">${esc(simulationRuleAction(rule))}</div><div class="simulation-rule-meta"><small>${esc(tr('automations.last'))}: ${esc(dateTime(rule.last_fired_at))}</small><small>${esc(tr('simulation.nextReady'))}: ${esc(dateTime(rule.next_ready_at))}</small></div></article>`).join('') : `<div class="empty">${esc(tr('simulation.noRules'))}</div>`;
}
function scheduleControlPlanLoad() {
function scheduleControlPlanLoad(delay = 180) {
if (controlPlanWebSocketReady()) return;
clearTimeout(app.controlPlanTimer);
app.controlPlanTimer = setTimeout(loadControlPlan, 180);
app.controlPlanTimer = setTimeout(loadControlPlan, delay);
}
function stopControlPlanFallback() {
clearTimeout(app.controlPlanTimer);
app.controlPlanTimer = null;
}
+13 -3
View File
@@ -15,10 +15,20 @@ async function handleWebSocketMessage(event) {
const message = JSON.parse(event.data);
if (message.event === 'bootstrap') {
const data = message.data; app.devices = data.devices || []; app.zones = data.zones || []; app.groups = data.groups || []; app.schedules = data.schedules || []; app.automations = data.automations || [];
app.flows = data.flows || []; await loadSettingsSections(data.house?.mode || app.settings?.house_mode || 'cool'); app.system = data.system || app.system; app.systemSnapshotAt = Date.now(); app.outdoorTemperature = Number.isFinite(Number(data.outdoor_temperature)) ? Number(data.outdoor_temperature) : app.outdoorTemperature; renderAll(); scheduleControlPlanLoad(); if (app.settings?.debug?.overlay_enabled) loadDebugBacklog(); return;
app.flows = data.flows || []; await loadSettingsSections(data.house?.mode || app.settings?.house_mode || 'cool'); app.system = data.system || app.system; app.systemSnapshotAt = Date.now(); app.outdoorTemperature = Number.isFinite(Number(data.outdoor_temperature)) ? Number(data.outdoor_temperature) : app.outdoorTemperature;
if (data.control_plan) {
app.controlPlan = data.control_plan;
app.controlPlanRevision = Number.isFinite(Number(data.control_plan_revision)) ? Number(data.control_plan_revision) : app.controlPlanRevision;
app.controlPlanPushReady = true;
stopControlPlanFallback();
} else {
app.controlPlanPushReady = false;
}
renderAll(); if (!data.control_plan) scheduleControlPlanLoad(0); if (app.settings?.debug?.overlay_enabled) loadDebugBacklog(); return;
}
const data = message.data || {};
if (['device.updated', 'device.created'].includes(message.event)) { updateDevice(data); renderSummary(); renderDevices(); renderGroups(); scheduleControlPlanLoad(); }
if (message.event === 'control_plan.updated') { app.controlPlanPushReady = true; stopControlPlanFallback(); applyControlPlan(data.plan, data.revision); }
else if (['device.updated', 'device.created'].includes(message.event)) { updateDevice(data); renderSummary(); renderDevices(); renderGroups(); scheduleControlPlanLoad(); }
else if (message.event === 'device.deleted') { app.devices = app.devices.filter(v => v.id !== data.id); renderAll(); scheduleControlPlanLoad(); }
else if (message.event === 'devices.discovered') { (data.devices || []).forEach(updateDevice); renderAll(); scheduleControlPlanLoad(); }
else if (message.event === 'zone.updated') { const i = app.zones.findIndex(v => v.id === data.id); if (i >= 0) app.zones[i] = data; else app.zones.push(data); renderSummary(); renderHouseClimate(); renderZones(); renderDevices(); renderGroups(); scheduleControlPlanLoad(); }
@@ -63,7 +73,7 @@ function connectWebSocket() {
const query = app.token ? `?token=${encodeURIComponent(app.token)}` : '';
const ws = new WebSocket(`${protocol}//${location.host}${withBase('/ws')}${query}`); app.ws = ws;
ws.onopen = () => updateConnectionIndicator('connected');
ws.onclose = () => { updateConnectionIndicator('disconnected'); app.wsTimer = setTimeout(connectWebSocket, 3000); };
ws.onclose = () => { app.controlPlanPushReady = false; updateConnectionIndicator('disconnected'); scheduleControlPlanLoad(0); app.wsTimer = setTimeout(connectWebSocket, 3000); };
ws.onerror = () => updateConnectionIndicator('connectionError');
let messageQueue = Promise.resolve();
ws.onmessage = event => {