Files
gree-controller/web/js-dynamic/dashboard.js
T
2026-09-17 08:52:02 +02:00

452 lines
31 KiB
JavaScript

function renderAll() {
renderSummary();
renderHouseClimate();
renderGroups();
renderControlPlan();
renderSimulationPage();
renderDevices();
renderZones();
renderSchedules();
renderAutomations();
renderFlows();
renderAccessTokens();
fillSelects();
renderSettings();
renderNightSettings();
renderHomeAssistantSettings();
renderSimulationModeBanner();
renderDebugOverlay();
}
function renderSummary() {
const temperatures = app.devices.map(d => d.current_temperature).filter(Number.isFinite);
const average = temperatures.length ? temperatures.reduce((a, b) => a + b, 0) / temperatures.length : null;
const online = app.devices.filter(d => d.online).length;
const active = app.devices.filter(d => d.power).length;
const demand = app.zones.filter(z => z.enabled && z.demand).length;
const toolbarStatus = $('#toolbarRuntimeStatus');
if (toolbarStatus) toolbarStatus.textContent = tr('dashboard.toolbarWorking', { active, total: app.devices.length });
$('#heroTemperature').innerHTML = `${average === null ? '--' : average.toFixed(1)}<small>°C</small>`;
const activeSuffix = active ? tr('dashboard.summaryActive', { count: active }) : '';
$('#summaryText').textContent = app.devices.length
? tr('dashboard.summary', { online, total: app.devices.length, active: activeSuffix })
: tr('dashboard.empty');
$('#metrics').innerHTML = [
[tr('dashboard.metricOnline'), `${online}/${app.devices.length}`],
[tr('dashboard.metricActive'), active],
[tr('dashboard.metricDemand'), demand],
].map(([label, value]) => `<div class="metric"><span>${esc(label)}</span><strong>${esc(value)}</strong></div>`).join('');
renderSystemInfo();
}
function renderHouseClimate() {
const node = $('#houseClimate'); if (!node || !app.settings) return;
const mode = app.settings.house_mode || 'cool';
const zonePresets = (app.zones || []).map(zone => zone.manual_preset || 'auto');
const preset = zonePresets.length && zonePresets.every(value => value === zonePresets[0]) ? zonePresets[0] : null;
const outdoor = app.outdoorTemperature == null ? tr('common.unavailable') : fmtTemp(app.outdoorTemperature);
const powerOn = $('#housePowerOn'), powerOff = $('#housePowerOff');
if (powerOn) { powerOn.classList.remove('active'); powerOn.removeAttribute('aria-pressed'); }
if (powerOff) { powerOff.classList.remove('active'); powerOff.removeAttribute('aria-pressed'); }
node.innerHTML = `<div class="house-climate-head"><div><span class="eyebrow">${esc(tr('house.seasonMode'))}</span><h3>${esc(tr('house.smartThermostat'))}</h3><p>${esc(tr('house.setpointStrategy'))}</p></div><button type="button" class="outside-pill" data-action="open-outdoor-history" title="${esc(tr('house.outdoorHistoryOpen'))}" aria-label="${esc(tr('house.outdoorHistoryOpen'))}"><small>${esc(tr('house.outdoor'))}</small><strong>${esc(outdoor)}</strong></button></div>
<div class="house-mode-row">
<button class="${mode === 'cool' ? 'active' : ''}" data-action="house-mode" data-value="cool" aria-pressed="${mode === 'cool'}">${esc(tr('mode.cool'))}</button>
<button class="${mode === 'heat' ? 'active' : ''}" data-action="house-mode" data-value="heat" aria-pressed="${mode === 'heat'}">${esc(tr('mode.heat'))}</button>
<button class="${mode === 'off' ? 'active' : ''}" data-action="house-mode" data-value="off" aria-pressed="${mode === 'off'}">${esc(tr('house.noControl'))}</button>
</div>
<div class="preset-row house-preset-row">${['auto', 'comfort', 'sleep', 'away'].map(p => `<button class="${preset === p ? 'active' : ''}" data-action="house-preset" data-value="${p}" aria-pressed="${preset === p}">${esc(p === 'sleep' ? tr('house.sleepAll') : p === 'comfort' ? tr('house.comfortAll') : p === 'away' ? tr('house.awayAll') : tr('house.autoAll'))}</button>`).join('')}</div>`;
}
function planEventMarkup(event) {
const when = event?.at ? new Date(event.at).toLocaleString(locale(), { weekday: 'short', hour: '2-digit', minute: '2-digit' }) : '—';
const target = event?.target_temperature == null ? '' : ` · ${fmtTemp(event.target_temperature)}`;
return `<li><time>${esc(when)}</time><span>${esc(event?.label || event?.kind || tr('plan.event'))}${esc(target)}</span></li>`;
}
function automationTriggerLabel(item) {
if (item.trigger_kind === 'time') return tr('automations.triggerAt', { time: item.at_time || '—' });
if (item.trigger_kind === 'flow') return tr('flow.generated');
const key = item.trigger_kind === 'temperature_above' ? 'automations.triggerAbove' : 'automations.triggerBelow';
return tr(key, { temperature: fmtTemp(item.threshold) });
}
function planZoneEffectiveEnabled(zone) {
if (!zone) return undefined;
return zone.effective_enabled ?? zone.enabled ?? false;
}
function renderControlPlan() {
const host = $('#controlPlan'); if (!host) return;
const section = $('#controlPlanSection');
const plan = app.controlPlan;
if (!plan) {
if (section) section.hidden = false;
host.innerHTML = `<div class="panel plan-loading">${esc(tr('plan.loading'))}</div>`;
return;
}
const allZones = plan.zones || [];
const houseEvents = (plan.next_events || []).slice(0, 3);
const house = houseEvents.length ? `<article class="panel plan-card plan-house"><div class="plan-card-head"><div><span class="eyebrow">${esc(tr('plan.house'))}</span><h3>${esc(houseModeLabel(plan.house_mode || 'off'))}</h3></div><span class="badge active">${esc((plan.control_strategy || 'setpoint') === 'setpoint' ? tr('plan.strategySetpoint') : (plan.control_strategy || 'setpoint'))}</span></div><p>${esc(tr('plan.houseSummary', { zones: allZones.filter(planZoneEffectiveEnabled).length, demand: allZones.filter(zone => planZoneEffectiveEnabled(zone) && zone.demand).length }))}</p><ul class="plan-events">${houseEvents.map(planEventMarkup).join('')}</ul></article>` : '';
const groupCards = (app.groups || []).map(group => {
const memberIds = new Set(group.zone_ids || []);
const members = allZones.filter(zone => memberIds.has(zone.zone_id));
const state = groupState(group);
const powerEnabled = group.power_enabled !== false;
const demand = members.filter(zone => planZoneEffectiveEnabled(zone) && zone.demand).length;
const mode = state.mode === 'house' ? tr('groups.followHouse') : state.mode === 'mixed' ? tr('groups.mixed') : modeLabel(state.mode);
const preset = state.preset === 'mixed' ? tr('groups.mixed') : zonePresetLabel(state.preset);
const events = members.flatMap(zone => (zone.next_events || []).map(event => ({ ...event, label: `${zone.zone_name}: ${event.label}` })))
.sort((a, b) => new Date(a.at) - new Date(b.at)).slice(0, 2);
if (!events.length) return '';
return `<article class="panel plan-card plan-group ${powerEnabled ? '' : 'disabled'}"><div class="plan-card-head"><div><span class="eyebrow">${esc(tr('groups.group'))}</span><h3>${esc(group.name)}</h3></div><span class="badge ${powerEnabled ? 'active' : ''}">${esc(powerEnabled ? tr('common.on') : tr('common.off'))}</span></div><div class="plan-group-state"><strong>${esc(mode)}</strong><span>·</span><strong>${esc(preset)}</strong></div><p>${esc(tr('plan.groupSummary', { zones: members.length, demand }))}</p><ul class="plan-events">${events.map(planEventMarkup).join('')}</ul></article>`;
}).join('');
const zoneGroups = new Map();
(app.groups || []).forEach(group => (group.zone_ids || []).forEach(zoneId => {
const names = zoneGroups.get(zoneId) || [];
names.push(group.name);
zoneGroups.set(zoneId, names);
}));
const zones = allZones.map(zone => {
const events = (zone.next_events || []).slice(0, 2);
if (!events.length) return '';
const target = zone.target_temperature == null ? '--' : Number(zone.target_temperature).toFixed(1);
const groupNames = zoneGroups.get(zone.zone_id) || [];
const scope = groupNames.length ? `${tr('groups.group')}: ${groupNames.join(' · ')}` : (zone.device_name || tr('common.noDevice'));
const planState = zoneRuntimeStatusLabel(zone, zone.mode || 'off');
return `<article class="panel plan-card plan-zone ${planZoneEffectiveEnabled(zone) ? '' : 'disabled'}"><div class="plan-card-head"><div><span class="eyebrow">${esc(scope)}</span><h3>${esc(zone.zone_name)}</h3></div><span class="badge ${zone.device_manual_override ? 'manual-override' : (planZoneEffectiveEnabled(zone) && zone.demand ? 'active' : '')}">${esc(planState)}</span></div><div class="plan-temp"><span>${fmtTemp(zone.current_temperature)}</span><b>→</b><strong>${esc(target)}<small>°C</small></strong></div><p>${esc(houseModeLabel(zone.mode || 'off'))} · ${esc(zonePresetLabel(zone.preset))}${zone.current_schedule_name ? ` · ${esc(zone.current_schedule_name)}` : ''}</p><ul class="plan-events">${events.map(planEventMarkup).join('')}</ul></article>`;
}).join('');
const rules = (plan.rules || []).filter(rule => rule.enabled);
const ruleCard = rules.length ? `<article class="panel plan-card plan-rules"><div class="plan-card-head"><div><span class="eyebrow">${esc(tr('plan.rules'))}</span><h3>${esc(tr('plan.ruleCount', { count: rules.length }))}</h3></div></div><ul class="plan-events">${rules.slice(0, 3).map(rule => {
const automation = (app.automations || []).find(item => item.id === rule.id);
const flow = automation?.flow_id ? (app.flows || []).find(item => item.id === automation.flow_id) : null;
const displayName = flow?.name || rule.name;
const target = rule.action_group_name ? `${tr('groups.group')}: ${rule.action_group_name}` : (rule.action_device_name || '');
return `<li><time>${esc(automationTriggerLabel(rule))}</time><span class="plan-rule-summary"><strong>${esc(displayName)}</strong>${target ? `<small>→ ${esc(target)}</small>` : ''}</span></li>`;
}).join('')}</ul></article>` : '';
const content = house + groupCards + zones + ruleCard;
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 {
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 = {}) {
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('house.noControl'), 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 = mode === 'off' ? null : 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(zoneHysteresisForMode(zone, mode)) || 0.4, 0.1);
const half = hysteresis / 2;
let status = 'waiting';
let demand = false;
if (!zone?.enabled || planZoneEffectiveEnabled(planZone) === 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 nightActive = !!app.controlPlan?.night_mode_active;
const nightMaxFan = clamp(Number(app.controlPlan?.night_mode_max_fan_speed || 1), 1, 5);
let fanSpeed = simulationNumeric(device?.fan_speed);
if (mode !== 'off') {
fanSpeed = zone?.smart_fan && current != null && target != null ? simulationSmartFanSpeed(mode, current, target, outdoor, demand) : fanSpeed;
if (nightActive) {
if (zone?.smart_fan) fanSpeed = fanSpeed === 0 ? 1 : Math.min(fanSpeed ?? nightMaxFan, nightMaxFan);
else if (fanSpeed === 0 || fanSpeed == null || fanSpeed > nightMaxFan) fanSpeed = nightMaxFan;
}
}
const quiet = mode === 'off'
? tr('simulation.manual')
: nightActive && app.settings?.night_mode?.force_quiet
? tr('simulation.quietIfSupported')
: (zone?.smart_fan ? (!demand ? tr('simulation.quietIfSupported') : tr('common.off')) : tr('simulation.manual'));
const nativeSleep = mode !== 'off' && nightActive && app.settings?.night_mode?.use_native_sleep && device?.supports_sleep === true;
const reasoning = !zone?.enabled || planZoneEffectiveEnabled(planZone) === 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, nativeSleep, 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')}: ${rule.action_group_id && rule.action.mode === 'auto' ? tr('groups.followHouse') : modeLabel(rule.action.mode)}`);
if (rule.action?.target_temperature != null) bits.push(`${tr('common.temperature')}: ${fmtTemp(rule.action.target_temperature)}`);
if (rule.action_preset) bits.push(`${tr('groups.profile')}: ${zonePresetLabel(rule.action_preset)}`);
return bits.length ? bits.join(' · ') : tr('simulation.noActionPreview');
}
function simulationGroupsForZone(zoneId) {
return app.groups.filter(group => (group.zone_ids || []).includes(zoneId));
}
function renderSimulationScopeControls(plan) {
const scope = $('#simulationScope');
const target = $('#simulationTarget');
if (!scope || !target) return;
if (!['units', 'groups'].includes(app.simulationScope)) app.simulationScope = 'units';
scope.value = app.simulationScope;
const zones = plan?.zones || [];
const options = app.simulationScope === 'groups'
? [{ id: 'all', name: tr('simulation.allGroups') }, ...app.groups.map(group => ({ id: group.id, name: group.name }))]
: [{ id: 'all', name: tr('simulation.allUnits') }, ...zones.map(zone => ({ id: zone.zone_id, name: `${zone.zone_name} · ${zone.device_name || tr('common.device')}` }))];
if (!options.some(option => option.id === app.simulationTarget)) app.simulationTarget = 'all';
target.innerHTML = options.map(option => `<option value="${esc(option.id)}">${esc(option.name)}</option>`).join('');
target.value = app.simulationTarget;
}
function simulationFilteredZones(plan) {
const zones = plan?.zones || [];
if (app.simulationScope === 'groups') {
if (app.simulationTarget === 'all') {
const grouped = new Set(app.groups.flatMap(group => group.zone_ids || []));
return zones.filter(zone => grouped.has(zone.zone_id));
}
const group = app.groups.find(item => item.id === app.simulationTarget);
const wanted = new Set(group?.zone_ids || []);
return zones.filter(zone => wanted.has(zone.zone_id));
}
return app.simulationTarget === 'all' ? zones : zones.filter(zone => zone.zone_id === app.simulationTarget);
}
function simulationRuleVisible(rule, zones) {
if (app.simulationTarget === 'all') return true;
const zoneIds = new Set(zones.map(zone => zone.zone_id));
const deviceIds = new Set(zones.map(zone => zone.device_id).filter(Boolean));
if (app.simulationScope === 'groups') {
if (rule.action_group_id === app.simulationTarget) return true;
return deviceIds.has(rule.trigger_device_id) || deviceIds.has(rule.action_device_id);
}
const directZone = [...zoneIds][0];
if (simulationGroupsForZone(directZone).some(group => group.id === rule.action_group_id)) return true;
return deviceIds.has(rule.trigger_device_id) || deviceIds.has(rule.action_device_id);
}
function simulationLaneContext(planZone) {
if (app.simulationScope !== 'groups') return planZone.device_name || '';
const groups = simulationGroupsForZone(planZone.zone_id);
return groups.length ? groups.map(group => group.name).join(' · ') : (planZone.device_name || '');
}
function updateSimulationUrl() {
if (app.currentView !== 'simulation') return;
const params = new URLSearchParams();
if (app.standaloneSimulation) params.set('standalone', '1');
if (app.simulationScope !== 'units') params.set('scope', app.simulationScope);
if (app.simulationTarget !== 'all') params.set('target', app.simulationTarget);
const query = params.toString();
updateBrowserUrl(`/simulation${query ? `?${query}` : ''}`, true);
}
function renderSimulationPage() {
const summaryHost = $('#simulationSummary');
const timelineHost = $('#simulationTimeline');
const boardHost = $('#simulationFlowBoard');
const rulesHost = $('#simulationRules');
if (!summaryHost || !timelineHost || !boardHost || !rulesHost) return;
const plan = app.controlPlan;
if (!plan) {
summaryHost.innerHTML = `<div class="panel plan-loading">${esc(tr('plan.loading'))}</div>`;
timelineHost.innerHTML = ''; boardHost.innerHTML = ''; rulesHost.innerHTML = '';
return;
}
renderSimulationScopeControls(plan);
const zones = simulationFilteredZones(plan);
const enabledZones = zones.filter(zone => zone.enabled);
const demandingZones = enabledZones.filter(zone => zone.demand);
const nightOn = !!plan.night_mode_active;
summaryHost.innerHTML = [
{ label: tr('simulation.houseMode'), value: houseModeLabel(plan.house_mode || 'off'), note: tr('simulation.strategy', { strategy: (plan.control_strategy || 'setpoint') === 'setpoint' ? tr('plan.strategySetpoint') : (plan.control_strategy || 'setpoint') }) },
{ label: tr('settings.nightMode'), value: nightOn ? tr('common.active') : (app.settings?.night_mode?.enabled ? tr('simulation.scheduled') : tr('common.disabled')), note: `${plan.night_mode_start || '22:00'}${plan.night_mode_end || '06:00'} · ${tr('simulation.fanMax')} ${fanLabel(plan.night_mode_max_fan_speed || 1)}` },
{ 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 })} · ${tr('simulation.rulesLabel')}: ${(plan.rules || []).filter(rule => rule.enabled && simulationRuleVisible(rule, zones)).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 nodeW = 190, nodeH = 106, rowH = 190, topY = 155;
const x = { sensor: 45, thermostat: 305, decision: 565, unit: 825, event: 1085 };
const boardW = 1325, boardH = Math.max(390, topY + zones.length * rowH + 35);
const nodes = [];
const links = [];
const pathBetween = (ax, ay, bx, by, cls = '') => {
const bend = Math.max(36, (bx - ax) * .45);
return `<path class="flow-link ${cls}" d="M ${ax} ${ay} C ${ax + bend} ${ay}, ${bx - bend} ${by}, ${bx} ${by}"/>`;
};
const verticalLink = (ax, ay, bx, by, cls = 'bus') => `<path class="flow-link ${cls}" d="M ${ax} ${ay} C ${ax} ${ay + 35}, ${bx} ${by - 35}, ${bx} ${by}"/>`;
const node = ({ left, top, kind, eyebrow, title, value, meta = '', badge = '', badgeIcon = '', badgeClass = '' }) => `<article class="diagram-node ${kind}" style="left:${left}px;top:${top}px;width:${nodeW}px;min-height:${nodeH}px"><div class="flow-node-top"><span>${esc(eyebrow)}</span>${badge || badgeIcon ? `<b class="flow-node-badge ${badgeClass}">${badgeIcon ? uiIcon(badgeIcon) : esc(badge)}</b>` : ''}</div><h3>${esc(title)}</h3><strong>${esc(value)}</strong>${meta ? `<p>${esc(meta)}</p>` : ''}<i class="diagram-port in"></i><i class="diagram-port out"></i></article>`;
const globalHouseLeft = 305, globalNightLeft = 565, globalTop = 28;
nodes.push(node({ left: globalHouseLeft, top: globalTop, kind: 'logic global', eyebrow: tr('simulation.globalInput'), title: tr('simulation.houseMode'), value: houseModeLabel(plan.house_mode || 'off'), meta: tr('simulation.strategy', { strategy: (plan.control_strategy || 'setpoint') === 'setpoint' ? tr('plan.strategySetpoint') : (plan.control_strategy || 'setpoint') }), badge: tr('simulation.house') }));
nodes.push(node({ left: globalNightLeft, top: globalTop, kind: `logic global ${nightOn ? 'night-active' : ''}`, eyebrow: tr('simulation.globalInput'), title: tr('settings.nightMode'), value: nightOn ? tr('common.active') : (app.settings?.night_mode?.enabled ? tr('simulation.scheduled') : tr('common.disabled')), meta: `${plan.night_mode_start || '22:00'}${plan.night_mode_end || '06:00'} · ${fanLabel(plan.night_mode_max_fan_speed || 1)}`, badgeIcon: nightOn ? 'moon' : 'circle', badgeClass: nightOn ? 'active' : '' }));
zones.forEach((planZone, index) => {
const zone = app.zones.find(item => item.id === planZone.zone_id);
const sim = buildZoneSimulation(zone, planZone);
const status = simulationStatusInfo(sim.status);
const y = topY + index * rowH;
const mid = y + nodeH / 2;
const next = (planZone.next_events || [])[0];
const source = zoneControlSourceLabel(planZone.control_source);
const sensorName = planZone.control_source === 'external' || planZone.control_source === 'combined'
? haSensorLabel(zoneHaEntityId(zone) || source)
: (planZone.device_name || tr('common.device'));
const fanText = sim.fanSpeed == null ? '—' : fanLabel(sim.fanSpeed);
const quietText = sim.quiet;
const command = sim.desiredDeviceTarget == null ? tr('simulation.noCommand') : `${fmtTemp(sim.desiredDeviceTarget)} · ${fanText}`;
const eventValue = next ? simulationTime(next.at) : tr('simulation.noEventShort');
const eventMeta = next ? `${next.label}${next.target_temperature == null ? '' : ` · ${fmtTemp(next.target_temperature)}`}` : tr('plan.noEvents');
nodes.push(`<div class="flow-lane-label" style="top:${y - 27}px"><strong>${esc(planZone.zone_name)}</strong><span>${esc(simulationLaneContext(planZone))}</span></div>`);
nodes.push(node({ left: x.sensor, top: y, kind: 'input', eyebrow: tr('simulation.stepRoom'), title: sensorName, value: fmtTemp(sim.current), meta: source, badgeIcon: 'status-dot', badgeClass: sim.current == null ? '' : 'active' }));
nodes.push(node({ left: x.thermostat, top: y, kind: 'logic', eyebrow: tr('simulation.thermostat'), title: planZone.zone_name, value: sim.target == null ? '—' : fmtTemp(sim.target), meta: `${zonePresetLabel(planZone.preset)} · ${tr('simulation.hysteresis', { value: sim.hysteresis.toFixed(1) })}`, badge: houseModeLabel(sim.mode) }));
nodes.push(node({ left: x.decision, top: y, kind: `logic decision ${sim.demand ? 'demand' : 'satisfied'}`, eyebrow: tr('simulation.stepDecision'), title: status.label, value: sim.demand ? tr('simulation.callForComfort') : tr('simulation.standby'), meta: sim.reasoning, badgeIcon: sim.demand ? 'play' : 'check', badgeClass: status.badge }));
const sleepMeta = sim.nativeSleep ? ` · ${tr('devices.sleep')}: ${tr('common.on')}` : '';
nodes.push(node({ left: x.unit, top: y, kind: 'action', eyebrow: tr('simulation.stepCommand'), title: planZone.device_name || tr('common.device'), value: command, meta: `${tr('devices.quiet')}: ${quietText}${sleepMeta}`, badge: sim.mode === 'off' ? tr('simulation.manual') : (sim.demand ? tr('simulation.running') : tr('simulation.idle')), badgeClass: sim.demand ? 'active' : '' }));
nodes.push(node({ left: x.event, top: y, kind: 'event', eyebrow: tr('simulation.nextEvent'), title: eventValue, value: next?.preset ? zonePresetLabel(next.preset) : tr('simulation.schedule'), meta: eventMeta, badgeIcon: 'chevron-right' }));
links.push(pathBetween(x.sensor + nodeW, mid, x.thermostat, mid, 'input-link'));
links.push(pathBetween(x.thermostat + nodeW, mid, x.decision, mid, 'logic-link'));
links.push(pathBetween(x.decision + nodeW, mid, x.unit, mid, sim.demand ? 'active-link' : 'logic-link'));
links.push(pathBetween(x.unit + nodeW, mid, x.event, mid, 'action-link'));
links.push(verticalLink(globalHouseLeft + nodeW / 2, globalTop + nodeH, x.thermostat + nodeW / 2, y, 'bus'));
if (app.settings?.night_mode?.enabled) links.push(verticalLink(globalNightLeft + nodeW / 2, globalTop + nodeH, x.decision + nodeW / 2, y, nightOn ? 'night-link' : 'bus'));
});
boardHost.style.width = `${boardW}px`;
boardHost.style.height = `${boardH}px`;
boardHost.innerHTML = `<svg class="flow-links" viewBox="0 0 ${boardW} ${boardH}" width="${boardW}" height="${boardH}" aria-hidden="true">${links.join('')}</svg>${nodes.join('')}`;
const timelineItems = [];
if (app.simulationTarget === 'all') (plan.next_events || []).forEach(event => timelineItems.push({ scope: tr('simulation.house'), event }));
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 = [], 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>`;
const activeRules = (plan.rules || []).filter(rule => rule.enabled && simulationRuleVisible(rule, zones));
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(delay = 180) {
if (controlPlanWebSocketReady()) return;
clearTimeout(app.controlPlanTimer);
app.controlPlanTimer = setTimeout(loadControlPlan, delay);
}
function stopControlPlanFallback() {
clearTimeout(app.controlPlanTimer);
app.controlPlanTimer = null;
}