v0.9.5
This commit is contained in:
+57
-57
@@ -19,22 +19,22 @@ function renderAll() {
|
||||
|
||||
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 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});
|
||||
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}) : '';
|
||||
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.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('');
|
||||
].map(([label, value]) => `<div class="metric"><span>${esc(label)}</span><strong>${esc(value)}</strong></div>`).join('');
|
||||
renderSystemInfo();
|
||||
}
|
||||
|
||||
@@ -45,28 +45,28 @@ function renderHouseClimate() {
|
||||
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>
|
||||
<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>`;
|
||||
<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 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 === '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)});
|
||||
return tr(key, { temperature: fmtTemp(item.threshold) });
|
||||
}
|
||||
|
||||
function planZoneEffectiveEnabled(zone) {
|
||||
@@ -84,7 +84,7 @@ function renderControlPlan() {
|
||||
|
||||
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 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 || []);
|
||||
@@ -94,9 +94,9 @@ function renderControlPlan() {
|
||||
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>`;
|
||||
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();
|
||||
@@ -115,7 +115,7 @@ function renderControlPlan() {
|
||||
}).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>` : '';
|
||||
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() {
|
||||
@@ -126,7 +126,7 @@ async function loadControlPlan() {
|
||||
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(',', '');
|
||||
return date.toLocaleString(locale(), { weekday: options.withDay === false ? undefined : 'short', hour: '2-digit', minute: '2-digit' }).replace(',', '');
|
||||
}
|
||||
|
||||
function simulationNumeric(value) {
|
||||
@@ -160,13 +160,13 @@ function simulationSmartFanSpeed(mode, room, target, outdoor, demand) {
|
||||
|
||||
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: ''},
|
||||
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: ''};
|
||||
return map[status] || { label: status, badge: '' };
|
||||
}
|
||||
|
||||
function buildZoneSimulation(zone, planZone) {
|
||||
@@ -222,9 +222,9 @@ function buildZoneSimulation(zone, planZone) {
|
||||
: 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};
|
||||
? 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) {
|
||||
@@ -244,12 +244,12 @@ function renderSimulationScopeControls(plan) {
|
||||
const scope = $('#simulationScope');
|
||||
const target = $('#simulationTarget');
|
||||
if (!scope || !target) return;
|
||||
if (!['units','groups'].includes(app.simulationScope)) app.simulationScope = 'units';
|
||||
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')}`}))];
|
||||
? [{ 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;
|
||||
@@ -317,27 +317,27 @@ function renderSimulationPage() {
|
||||
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}`},
|
||||
{ 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 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 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}"/>`;
|
||||
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 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':''}));
|
||||
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);
|
||||
@@ -356,20 +356,20 @@ function renderSimulationPage() {
|
||||
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}));
|
||||
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:'›'}));
|
||||
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'));
|
||||
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`;
|
||||
@@ -377,8 +377,8 @@ function renderSimulationPage() {
|
||||
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})));
|
||||
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) {
|
||||
@@ -391,7 +391,7 @@ function renderSimulationPage() {
|
||||
}).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>`;
|
||||
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() {
|
||||
|
||||
Reference in New Issue
Block a user