452 lines
30 KiB
JavaScript
452 lines
30 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><div class="outside-pill"><small>${esc(tr('house.outdoor'))}</small><strong>${esc(outdoor)}</strong></div></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 = '', badgeClass = '' }) => `<article class="flow-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 ? `<b class="flow-node-badge ${badgeClass}">${esc(badge)}</b>` : ''}</div><h3>${esc(title)}</h3><strong>${esc(value)}</strong>${meta ? `<p>${esc(meta)}</p>` : ''}<i class="flow-port in"></i><i class="flow-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)}`, badge: nightOn ? '☾' : '○', 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(zone?.ha_entity_id || 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, badge: '●', 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, badge: sim.demand ? '▶' : '✓', 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, badge: '›' }));
|
||
|
||
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;
|
||
}
|
||
|