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)}°C`; 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]) => `
${esc(label)}${esc(value)}
`).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 = `
${esc(tr('house.seasonMode'))}

${esc(tr('house.smartThermostat'))}

${esc(tr('house.setpointStrategy'))}

${['auto', 'comfort', 'sleep', 'away'].map(p => ``).join('')}
`; } 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 `
  • ${esc(event?.label || event?.kind || tr('plan.event'))}${esc(target)}
  • `; } 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 = `
    ${esc(tr('plan.loading'))}
    `; return; } const allZones = plan.zones || []; const houseEvents = (plan.next_events || []).slice(0, 3); const house = houseEvents.length ? `
    ${esc(tr('plan.house'))}

    ${esc(houseModeLabel(plan.house_mode || 'off'))}

    ${esc((plan.control_strategy || 'setpoint') === 'setpoint' ? tr('plan.strategySetpoint') : (plan.control_strategy || 'setpoint'))}

    ${esc(tr('plan.houseSummary', { zones: allZones.filter(planZoneEffectiveEnabled).length, demand: allZones.filter(zone => planZoneEffectiveEnabled(zone) && zone.demand).length }))}

    ` : ''; 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 `
    ${esc(tr('groups.group'))}

    ${esc(group.name)}

    ${esc(powerEnabled ? tr('common.on') : tr('common.off'))}
    ${esc(mode)}·${esc(preset)}

    ${esc(tr('plan.groupSummary', { zones: members.length, demand }))}

    `; }).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 `
    ${esc(scope)}

    ${esc(zone.zone_name)}

    ${esc(planState)}
    ${fmtTemp(zone.current_temperature)}${esc(target)}°C

    ${esc(houseModeLabel(zone.mode || 'off'))} · ${esc(zonePresetLabel(zone.preset))}${zone.current_schedule_name ? ` · ${esc(zone.current_schedule_name)}` : ''}

    `; }).join(''); const rules = (plan.rules || []).filter(rule => rule.enabled); const ruleCard = rules.length ? `
    ${esc(tr('plan.rules'))}

    ${esc(tr('plan.ruleCount', { count: rules.length }))}

    ` : ''; 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 => ``).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 = `
    ${esc(tr('plan.loading'))}
    `; 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 => `
    ${esc(card.label)}${esc(card.value)}${esc(card.note)}
    `).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 ``; }; const verticalLink = (ax, ay, bx, by, cls = 'bus') => ``; const node = ({ left, top, kind, eyebrow, title, value, meta = '', badge = '', badgeIcon = '', badgeClass = '' }) => `
    ${esc(eyebrow)}${badge || badgeIcon ? `${badgeIcon ? uiIcon(badgeIcon) : esc(badge)}` : ''}

    ${esc(title)}

    ${esc(value)}${meta ? `

    ${esc(meta)}

    ` : ''}
    `; 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(`
    ${esc(planZone.zone_name)}${esc(simulationLaneContext(planZone))}
    `); 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 = `${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 `
    ${esc(item.scope)}

    ${esc(item.event.label)}${esc(target)}${item.device ? ` · ${esc(item.device)}` : ''}

    `; }).join('') : `
    ${esc(tr('plan.noEvents'))}
    `; const activeRules = (plan.rules || []).filter(rule => rule.enabled && simulationRuleVisible(rule, zones)); rulesHost.innerHTML = activeRules.length ? activeRules.map(rule => `
    ${esc(tr('common.automation'))}

    ${esc(rule.name)}

    ${esc(automationTriggerLabel(rule))}

    ${esc(tr('simulation.ruleOnDevice', { device: rule.action_device_name || tr('common.noDevice') }))}

    ${esc(simulationRuleAction(rule))}
    ${esc(tr('automations.last'))}: ${esc(dateTime(rule.last_fired_at))}${esc(tr('simulation.nextReady'))}: ${esc(dateTime(rule.next_ready_at))}
    `).join('') : `
    ${esc(tr('simulation.noRules'))}
    `; } function scheduleControlPlanLoad(delay = 180) { if (controlPlanWebSocketReady()) return; clearTimeout(app.controlPlanTimer); app.controlPlanTimer = setTimeout(loadControlPlan, delay); } function stopControlPlanFallback() { clearTimeout(app.controlPlanTimer); app.controlPlanTimer = null; }