402 lines
28 KiB
JavaScript
402 lines
28 KiB
JavaScript
function renderAll() {
|
||
renderSummary();
|
||
renderHouseClimate();
|
||
renderGroups();
|
||
renderControlPlan();
|
||
renderSimulationPage();
|
||
renderDevices();
|
||
renderZones();
|
||
renderSchedules();
|
||
renderAutomations();
|
||
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');
|
||
// Global power buttons are bulk thermostat actions, not a persistent master gate.
|
||
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 || '—'});
|
||
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 plan = app.controlPlan;
|
||
if (!plan) {
|
||
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 = `<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.length ? houseEvents.map(planEventMarkup).join('') : `<li class="muted">${esc(tr('plan.noEvents'))}</li>`}</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);
|
||
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.length ? events.map(planEventMarkup).join('') : `<li class="muted">${esc(tr(powerEnabled ? 'plan.noEvents' : 'plan.groupOff'))}</li>`}</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);
|
||
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.length ? events.map(planEventMarkup).join('') : `<li class="muted">${esc(tr('plan.noEvents'))}</li>`}</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=>`<li><time>${esc(automationTriggerLabel(rule))}</time><span>${esc(rule.name)} → ${esc(rule.action_group_name ? `${tr('groups.group')}: ${rule.action_group_name}` : (rule.action_device_name || ''))}</span></li>`).join('')}</ul></article>` : '';
|
||
host.innerHTML = house + groupCards + zones + ruleCard;
|
||
}
|
||
async function loadControlPlan() {
|
||
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('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="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);
|
||
}
|
||
|