This commit is contained in:
Mateusz Gruszczyński
2026-08-24 14:46:57 +02:00
parent 085431efb8
commit 4f70e36e89
14 changed files with 488 additions and 51 deletions
+178 -3
View File
@@ -194,6 +194,7 @@ function renderAll() {
renderSummary();
renderHouseClimate();
renderControlPlan();
renderSimulationPage();
renderDevices();
renderZones();
renderSchedules();
@@ -267,10 +268,179 @@ function renderControlPlan() {
}
async function loadControlPlan() {
try { app.controlPlan = await api('/api/control-plan'); renderControlPlan(); }
try { app.controlPlan = await api('/api/control-plan'); renderControlPlan(); renderSimulationPage(); }
catch (error) { console.warn('Unable to load control plan:', error); }
}
function simulationTime(value, options = {}) {
if (!value) return '—';
const date = new Date(value);
return date.toLocaleString(locale(), {weekday: options.withDay === false ? undefined : 'short', hour:'2-digit', minute:'2-digit'}).replace(',', '');
}
function simulationNumeric(value) {
const n = Number(value);
return Number.isFinite(n) ? n : null;
}
function simulationOutdoorAssist(mode, outdoor, room, target) {
if (outdoor == null || room == null || target == null) return 0;
const roomError = Math.abs(room - target);
const weather = mode === 'heat' ? clamp((5 - outdoor) / 15, 0, 1) : clamp((outdoor - 30) / 10, 0, 1);
return clamp(weather * clamp(roomError, 0, 2) * 0.5, 0, 1);
}
function simulationRoundDeviceSetpoint(mode, demand, value) {
const safe = clamp(Number(value) || 0, 16, 30);
if (mode === 'heat' && demand) return Math.ceil(safe);
if (mode === 'heat' && !demand) return Math.floor(safe);
if (mode !== 'heat' && demand) return Math.floor(safe);
return Math.ceil(safe);
}
function simulationSmartFanSpeed(mode, room, target, outdoor, demand) {
if (!demand) return 1;
const error = Math.abs(room - target);
const extremeWeather = mode === 'heat' ? outdoor != null && outdoor <= 0 : outdoor != null && outdoor >= 32;
if (error >= 2 || extremeWeather) return 3;
if (error >= 1) return 2;
return 0;
}
function simulationStatusInfo(status) {
const map = {
disabled: {label: tr('common.disabled'), badge: ''},
off: {label: tr('mode.off'), badge: ''},
waiting: {label: tr('simulation.waitingForData'), badge: ''},
demand: {label: tr('simulation.stateDemand'), badge: 'active'},
satisfied: {label: tr('simulation.stateSatisfied'), badge: ''},
};
return map[status] || {label: status, badge: ''};
}
function buildZoneSimulation(zone, planZone) {
const device = app.devices.find(item => item.id === (planZone?.device_id || zone?.device_id));
const mode = planZone?.mode || zone?.effective_mode || zone?.mode || app.settings?.house_mode || 'off';
const current = simulationNumeric(planZone?.current_temperature ?? zone?.current_temperature ?? zone?.device_temperature ?? device?.current_temperature);
const target = simulationNumeric(planZone?.target_temperature ?? zone?.effective_setpoint ?? zone?.manual_setpoint ?? zone?.setpoint);
const outdoor = simulationNumeric(app.outdoorTemperature ?? app.controlPlan?.outdoor_temperature);
const hysteresis = Math.max(simulationNumeric(zone?.hysteresis) || 0.4, 0.1);
const half = hysteresis / 2;
let status = 'waiting';
let demand = false;
if (!zone?.enabled || planZone?.enabled === false) status = 'disabled';
else if (mode === 'off') status = 'off';
else if (current == null || target == null) status = 'waiting';
else {
if (mode === 'heat') {
if (current <= target - half) demand = true;
else if (current >= target + half) demand = false;
else demand = !!zone?.demand;
} else {
if (current >= target + half) demand = true;
else if (current <= target - half) demand = false;
else demand = !!zone?.demand;
}
status = demand ? 'demand' : 'satisfied';
}
const standbyOffset = Math.max(simulationNumeric(zone?.standby_offset_c) || 0.5, 0.5);
const assist = current == null || target == null ? 0 : simulationOutdoorAssist(mode, outdoor, current, target);
const activeTarget = current == null || target == null ? null : (mode === 'heat' ? target + assist : target - assist);
const standbyTarget = target == null ? null : (mode === 'heat' ? target - standbyOffset : target + standbyOffset);
const desiredDeviceTarget = current == null || target == null ? null : simulationRoundDeviceSetpoint(mode, demand, demand ? activeTarget : standbyTarget);
const fanSpeed = zone?.smart_fan && current != null && target != null ? simulationSmartFanSpeed(mode, current, target, outdoor, demand) : simulationNumeric(device?.fan_speed);
const quiet = zone?.smart_fan ? (!demand ? tr('simulation.quietIfSupported') : tr('common.off')) : tr('simulation.manual');
const reasoning = !zone?.enabled || planZone?.enabled === false
? tr('simulation.reasonDisabled')
: mode === 'off'
? tr('simulation.reasonHouseOff')
: current == null || target == null
? tr('simulation.reasonWaiting')
: demand
? tr('simulation.reasonDemand', {mode: modeLabel(mode), target: target.toFixed(1), hysteresis: hysteresis.toFixed(1)})
: tr('simulation.reasonSatisfied', {target: target.toFixed(1), standby: standbyTarget.toFixed(1)});
return {device, mode, current, target, demand, status, outdoor, hysteresis, assist, activeTarget, standbyTarget, desiredDeviceTarget, fanSpeed, quiet, reasoning};
}
function simulationRuleAction(rule) {
const bits = [];
if (rule.action?.power != null) bits.push(`${tr('common.power')}: ${rule.action.power ? tr('common.on') : tr('common.off')}`);
if (rule.action?.mode) bits.push(`${tr('common.mode')}: ${modeLabel(rule.action.mode)}`);
if (rule.action?.target_temperature != null) bits.push(`${tr('common.temperature')}: ${fmtTemp(rule.action.target_temperature)}`);
return bits.length ? bits.join(' · ') : tr('simulation.noActionPreview');
}
function renderSimulationPage() {
const summaryHost = $('#simulationSummary');
const timelineHost = $('#simulationTimeline');
const zonesHost = $('#simulationZones');
const rulesHost = $('#simulationRules');
if (!summaryHost || !timelineHost || !zonesHost || !rulesHost) return;
const plan = app.controlPlan;
if (!plan) {
summaryHost.innerHTML = `<div class="panel plan-loading">${esc(tr('plan.loading'))}</div>`;
timelineHost.innerHTML = '';
zonesHost.innerHTML = '';
rulesHost.innerHTML = '';
return;
}
const enabledZones = (plan.zones || []).filter(zone => zone.enabled);
const demandingZones = enabledZones.filter(zone => zone.demand);
summaryHost.innerHTML = [
{label: tr('simulation.houseMode'), value: modeLabel(plan.house_mode || 'off'), note: tr('simulation.strategy', {strategy: plan.control_strategy || 'setpoint'})},
{label: tr('simulation.outdoor'), value: plan.outdoor_temperature == null ? '—' : fmtTemp(plan.outdoor_temperature), note: tr('simulation.generatedAt', {time: dateTime(plan.generated_at)})},
{label: tr('simulation.activeZones'), value: String(enabledZones.length), note: tr('simulation.requestingZones', {count: demandingZones.length})},
{label: tr('simulation.rulesLabel'), value: String((plan.rules || []).filter(rule => rule.enabled).length), note: tr('simulation.eventsQueued', {count: (plan.next_events || []).length})},
].map(card => `<article class="panel simulation-summary-card"><small>${esc(card.label)}</small><strong>${esc(card.value)}</strong><span>${esc(card.note)}</span></article>`).join('');
const timelineItems = [];
(plan.next_events || []).forEach(event => timelineItems.push({scope: tr('simulation.house'), event}));
(plan.zones || []).forEach(zone => (zone.next_events || []).forEach(event => timelineItems.push({scope: zone.zone_name, event, device: zone.device_name})));
timelineItems.sort((a, b) => new Date(a.event.at) - new Date(b.event.at));
const unique = [];
const seen = new Set();
for (const item of timelineItems) {
const key = `${item.scope}|${item.event.at}|${item.event.label}`;
if (seen.has(key)) continue;
seen.add(key);
unique.push(item);
if (unique.length >= 16) break;
}
timelineHost.innerHTML = unique.length ? unique.map(item => {
const target = item.event.target_temperature == null ? '' : ` · ${fmtTemp(item.event.target_temperature)}`;
return `<article class="simulation-timeline-item"><time>${esc(simulationTime(item.event.at))}</time><div><strong>${esc(item.scope)}</strong><p>${esc(item.event.label)}${esc(target)}${item.device ? ` · ${esc(item.device)}` : ''}</p></div></article>`;
}).join('') : `<div class="empty">${esc(tr('plan.noEvents'))}</div>`;
zonesHost.innerHTML = (plan.zones || []).length ? plan.zones.map(planZone => {
const zone = app.zones.find(item => item.id === planZone.zone_id);
const sim = buildZoneSimulation(zone, planZone);
const status = simulationStatusInfo(sim.status);
const events = (planZone.next_events || []).slice(0, 4);
const sourceLabel = zoneControlSourceLabel(planZone.control_source);
const fanText = sim.fanSpeed == null ? '—' : fanLabel(sim.fanSpeed);
const commandText = sim.desiredDeviceTarget == null ? tr('simulation.noCommand') : `${fmtTemp(sim.desiredDeviceTarget)} · ${fanText} · ${tr('devices.quiet')}: ${sim.quiet}`;
return `<article class="panel simulation-zone-card ${planZone.enabled ? '' : 'disabled'}">
<div class="simulation-zone-head"><div><span class="eyebrow">${esc(planZone.device_name || tr('common.noDevice'))}</span><h3>${esc(planZone.zone_name)}</h3><p>${esc(modeLabel(sim.mode))} · ${esc(zonePresetLabel(planZone.preset))}${planZone.current_schedule_name ? ` · ${esc(planZone.current_schedule_name)}` : ''}</p></div><span class="badge ${status.badge}">${esc(status.label)}</span></div>
<div class="simulation-flow">
<div class="simulation-step"><small>${esc(tr('simulation.stepRoom'))}</small><strong>${esc(fmtTemp(sim.current))}</strong><span>${esc(sourceLabel)}</span></div>
<div class="simulation-step"><small>${esc(tr('simulation.stepTarget'))}</small><strong>${esc(sim.target == null ? '—' : `${sim.target.toFixed(1)}°C`)}</strong><span>${esc(tr('simulation.hysteresis', {value: sim.hysteresis.toFixed(1)}))}</span></div>
<div class="simulation-step"><small>${esc(tr('simulation.stepDecision'))}</small><strong>${esc(status.label)}</strong><span>${esc(sim.reasoning)}</span></div>
<div class="simulation-step accent"><small>${esc(tr('simulation.stepCommand'))}</small><strong>${esc(commandText)}</strong><span>${esc(sim.demand ? tr('simulation.activeTargetLabel', {value: sim.activeTarget == null ? '—' : sim.activeTarget.toFixed(1)}) : tr('simulation.standbyTargetLabel', {value: sim.standbyTarget == null ? '—' : sim.standbyTarget.toFixed(1)}))}</span></div>
</div>
<div class="simulation-metrics">
<div><small>${esc(tr('simulation.deviceSetpoint'))}</small><strong>${esc(sim.desiredDeviceTarget == null ? '—' : fmtTemp(sim.desiredDeviceTarget))}</strong></div>
<div><small>${esc(tr('common.fan'))}</small><strong>${esc(fanText)}</strong></div>
<div><small>${esc(tr('devices.quiet'))}</small><strong>${esc(sim.quiet)}</strong></div>
<div><small>${esc(tr('simulation.outdoor'))}</small><strong>${esc(sim.outdoor == null ? '—' : fmtTemp(sim.outdoor))}</strong></div>
</div>
<ul class="plan-events simulation-events">${events.length ? events.map(planEventMarkup).join('') : `<li class="muted">${esc(tr('plan.noEvents'))}</li>`}</ul>
</article>`;
}).join('') : `<div class="empty">${esc(tr('zones.emptyText'))}</div>`;
const activeRules = (plan.rules || []).filter(rule => rule.enabled);
rulesHost.innerHTML = activeRules.length ? activeRules.map(rule => `<article class="panel 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() {
clearTimeout(app.controlPlanTimer);
app.controlPlanTimer = setTimeout(loadControlPlan, 180);
@@ -485,7 +655,7 @@ function formatDuration(seconds) {
return `${days ? `${days}d ` : ''}${hours}h ${minutes}m`;
}
const VIEW_ROUTES = {dashboard:'/dashboard', devices:'/devices', zones:'/zones', schedules:'/schedules', automations:'/automations', settings:'/settings', logs:'/events'};
const VIEW_ROUTES = {dashboard:'/dashboard', devices:'/devices', zones:'/zones', schedules:'/schedules', automations:'/automations', simulation:'/simulation', settings:'/settings', logs:'/events'};
const HISTORY_TABS = ['overview','zones','devices','sensors','custom'];
function currentHistoryPath() {
@@ -510,7 +680,7 @@ function updateBrowserUrl(path, replace=false) {
function showView(name, {push=true, scroll=true}={}) {
app.currentView = name;
$$('.view').forEach(view => view.classList.toggle('active', view.dataset.view === name));
$$('.bottom-nav button').forEach(button => button.classList.toggle('active', button.dataset.nav === name || (button.dataset.nav === 'more' && ['schedules','automations','settings','logs'].includes(name))));
$$('.bottom-nav button').forEach(button => button.classList.toggle('active', button.dataset.nav === name || (button.dataset.nav === 'more' && ['schedules','automations','simulation','settings','logs'].includes(name))));
if (push) updateBrowserUrl(name === 'history' ? currentHistoryPath() : (VIEW_ROUTES[name] || '/dashboard'));
if (scroll) window.scrollTo({top: 0, behavior: 'smooth'});
if (name === 'history') { renderHistoryNavigation(); loadHistory(); }
@@ -1264,6 +1434,11 @@ $('#haTest').addEventListener('click', async () => {
} catch(error){toast(error.message,true);}
});
$('#simulationRefresh')?.addEventListener('click', async () => {
try { await loadControlPlan(); toast(tr('common.updated')); }
catch (error) { toast(error.message, true); }
});
$('#exportSettings').addEventListener('click', async () => {
try {
const data = await api('/api/settings/export');
+15
View File
@@ -91,6 +91,20 @@
<div class="list-grid" id="automationList"></div>
</section>
<section class="view" data-view="simulation">
<div class="section-heading"><div><span class="eyebrow" data-i18n="simulation.eyebrow">Simulator</span><h1 data-i18n="simulation.title">Automation simulator</h1></div><button class="secondary" id="simulationRefresh" data-i18n="actions.refresh">Refresh</button></div>
<p class="lead" data-i18n="simulation.description">Preview how the thermostat and automation logic currently reason about each zone: temperatures, targets, demand, standby behavior and the next scheduled events.</p>
<div class="simulation-summary-grid" id="simulationSummary"></div>
<div class="panel simulation-timeline-panel">
<div class="simulation-section-head"><div><span class="eyebrow" data-i18n="simulation.timelineEyebrow">Timeline</span><h2 data-i18n="simulation.timelineTitle">What will happen next</h2></div></div>
<div class="simulation-timeline" id="simulationTimeline"></div>
</div>
<div class="simulation-section-head"><div><span class="eyebrow" data-i18n="simulation.zoneEyebrow">Zones</span><h2 data-i18n="simulation.zoneTitle">Thermostat decision flow</h2></div></div>
<div class="simulation-zone-grid" id="simulationZones"></div>
<div class="simulation-section-head"><div><span class="eyebrow" data-i18n="simulation.rulesEyebrow">Rules</span><h2 data-i18n="simulation.rulesTitle">Automation rules</h2></div></div>
<div class="simulation-rules-grid" id="simulationRules"></div>
</section>
<section class="view" data-view="history">
<div class="section-heading"><div><span class="eyebrow" data-i18n="history.measurements">Measurements</span><h1 data-i18n="history.title">Climate history</h1></div></div>
<div class="history-tabs" id="historyTabs" role="tablist">
@@ -204,6 +218,7 @@
<div class="menu-list">
<button data-go="schedules"><span data-i18n="nav.schedules">Schedules</span><span></span></button>
<button data-go="automations"><span data-i18n="nav.automations">Automations</span><span></span></button>
<button data-go="simulation"><span data-i18n="nav.simulation">Simulator</span><span></span></button>
<button data-go="settings"><span data-i18n="nav.settings">Settings</span><span></span></button>
<button data-go="logs"><span data-i18n="nav.logsAndEvents">Events and logs</span><span></span></button>
</div>
+45
View File
@@ -461,3 +461,48 @@ legend { padding: 0 5px; color: var(--muted); font-size: 11px; }
}
.history-chart-card canvas { width: 100%; min-width: 720px; }
.simulation-summary-grid { display:grid; grid-template-columns:repeat(auto-fit, minmax(min(100%, 210px), 1fr)); gap:12px; margin-bottom:16px; }
.simulation-summary-card { display:grid; gap:6px; padding:16px; }
.simulation-summary-card small { color:var(--muted); font-size:12px; }
.simulation-summary-card strong { font-size:28px; letter-spacing:-.04em; }
.simulation-summary-card span { color:var(--muted); font-size:12px; line-height:1.45; }
.simulation-section-head { display:flex; align-items:end; justify-content:space-between; margin:24px 0 12px; }
.simulation-section-head h2 { margin:4px 0 0; }
.simulation-timeline-panel { padding:16px; margin-bottom:22px; }
.simulation-timeline { display:grid; gap:10px; }
.simulation-timeline-item { display:grid; grid-template-columns:110px 1fr; gap:12px; align-items:start; padding:12px 0; border-bottom:1px solid var(--line); }
.simulation-timeline-item:last-child { border-bottom:0; padding-bottom:0; }
.simulation-timeline-item time { color:var(--muted); font-size:12px; }
.simulation-timeline-item strong { display:block; margin-bottom:4px; }
.simulation-timeline-item p { margin:0; color:var(--muted); line-height:1.45; }
.simulation-zone-grid { display:grid; grid-template-columns:repeat(auto-fit, minmax(min(100%, 380px), 1fr)); gap:14px; }
.simulation-zone-card { display:grid; gap:14px; padding:16px; }
.simulation-zone-card.disabled { opacity:.68; }
.simulation-zone-head, .simulation-rule-head { display:flex; align-items:flex-start; justify-content:space-between; gap:10px; }
.simulation-zone-head h3, .simulation-rule-head h3 { margin:3px 0 0; }
.simulation-zone-head p, .simulation-rule-head p { margin:6px 0 0; color:var(--muted); font-size:13px; line-height:1.45; }
.simulation-flow { display:grid; grid-template-columns:repeat(4, minmax(0, 1fr)); gap:10px; }
.simulation-step { display:grid; gap:6px; padding:12px; border:1px solid var(--line); border-radius:16px; background:rgba(255,255,255,.02); min-height:112px; }
html[data-theme='light'] .simulation-step { background:rgba(0,0,0,.02); }
.simulation-step.accent { box-shadow:inset 0 0 0 1px rgba(101,163,255,.28); }
.simulation-step small, .simulation-metrics small, .simulation-rule-meta small { color:var(--muted); font-size:11px; }
.simulation-step strong { font-size:15px; line-height:1.35; }
.simulation-step span { color:var(--muted); font-size:12px; line-height:1.4; }
.simulation-metrics { display:grid; grid-template-columns:repeat(4, minmax(0, 1fr)); gap:10px; }
.simulation-metrics > div, .simulation-rule-action { padding:12px; border-radius:14px; border:1px solid var(--line); background:rgba(255,255,255,.02); }
html[data-theme='light'] .simulation-metrics > div, html[data-theme='light'] .simulation-rule-action { background:rgba(0,0,0,.02); }
.simulation-metrics strong { display:block; margin-top:4px; }
.simulation-events { padding-top:0; border-top:0; }
.simulation-rules-grid { display:grid; grid-template-columns:repeat(auto-fit, minmax(min(100%, 320px), 1fr)); gap:14px; }
.simulation-rule-card { display:grid; gap:12px; padding:16px; }
.simulation-rule-card > p { margin:0; color:var(--muted); }
.simulation-rule-action { font-size:14px; line-height:1.5; }
.simulation-rule-meta { display:flex; flex-wrap:wrap; gap:12px; color:var(--muted); }
@media (max-width: 900px) {
.simulation-flow, .simulation-metrics { grid-template-columns:repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 640px) {
.simulation-timeline-item { grid-template-columns:1fr; gap:6px; }
.simulation-flow, .simulation-metrics { grid-template-columns:1fr; }
}