Files
gree-controller/web/js/entities.js
T
2026-09-01 15:14:45 +02:00

501 lines
43 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
function deviceFeaturePanel(device) {
const features = [
['light', 'supports_light', tr('devices.light')],
['xfan', 'supports_xfan', tr('devices.xfan')],
['health', 'supports_health', tr('devices.health')],
['air', 'supports_air', tr('devices.air')],
['sleep', 'supports_sleep', tr('devices.sleep')],
].filter(([, support]) => device[support] === true);
if (!features.length) return `<div class="device-capability-panel"><small>${esc(tr('devices.features'))}</small><span class="muted">${esc(tr('devices.noExtraFeatures'))}</span></div>`;
return `<div class="device-capability-panel"><small>${esc(tr('devices.features'))}</small><div class="device-capability-buttons">${features.map(([field,,label]) => `<button class="${device[field] ? 'active' : ''}" data-action="toggle" data-field="${field}" data-device="${esc(device.id)}">${esc(label)}</button>`).join('')}</div></div>`;
}
function deviceZoneDisabled(deviceId) {
return app.zones.some(zone => zone.device_id === deviceId && zone.enabled === false);
}
function zoneForDevice(deviceId) {
return app.zones.find(zone => zone.device_id === deviceId) || null;
}
function deviceCard(device, detailed = false) {
const modes = ['auto','cool','dry','fan','heat'];
const fans = [0,1,3,5];
const hasResponseTime = device.response_time_ms !== null && device.response_time_ms !== undefined && Number.isFinite(Number(device.response_time_ms));
const responseTime = hasResponseTime ? `${Math.max(0, Math.round(Number(device.response_time_ms)))} ms` : '— ms';
const protocol = device.simulated ? tr('devices.simulator') : device.protocol_version === 2 ? 'V2 GCM' : device.protocol_version === 1 ? 'V1 ECB' : tr('devices.protocolAuto');
const networkInfo = `<div class="device-network-info"><small>${esc(device.ip)} · ${esc(responseTime)} · ${esc(protocol)}</small>${device.last_error ? `<small class="device-network-error" title="${esc(device.last_error)}">${esc(device.last_error)}</small>` : ''}</div>`;
const managedZone = app.zones.find(zone => zone.device_id === device.id && zone.compressor_pending_action) || zoneForDevice(device.id);
const zoneLocked = !detailed && deviceZoneDisabled(device.id);
const locked = zoneLocked ? ' disabled' : '';
const quickClass = detailed ? '' : ' quick-control-card quick-device-control';
const quickRow = detailed ? '' : ' quick-control-row';
return `<article class="device-card${quickClass} ${device.power ? '' : 'off'}" data-device-card="${esc(device.id)}">
<div class="device-head">
<div class="device-title"><h3>${esc(device.name)}</h3><p><span class="status ${device.online ? 'online' : ''}">${esc(tr(device.online ? 'status.online' : 'status.offline'))}</span> · ${esc(device.model || device.mac)}</p></div>
<button class="power-button ${device.power ? 'on' : ''}" data-action="power" data-device="${esc(device.id)}" aria-label="${esc(tr('devices.power'))}"${locked}>⏻</button>
</div>
<div class="temperature-control">
<button data-action="temperature" data-delta="-1" data-device="${esc(device.id)}"${locked}></button>
<div class="target-temp editable-target" data-temperature-kind="device" data-id="${esc(device.id)}" data-value="${Number(device.target_temperature)}" data-editable="${zoneLocked ? 'false' : 'true'}" tabindex="${zoneLocked ? '-1' : '0'}" role="button" title="${esc(tr('common.clickTemperatureToEdit'))}">${Number(device.target_temperature).toFixed(1)}<small>°C</small></div>
<button data-action="temperature" data-delta="1" data-device="${esc(device.id)}"${locked}>+</button>
</div>
<div class="current-line">${esc(tr('devices.currentTemperature'))}: <strong>${fmtTemp(device.current_temperature)}</strong>${device.outdoor_temperature == null ? '' : ` · ${esc(tr('devices.outdoor'))} ${fmtTemp(device.outdoor_temperature)}`}</div>
${compressorQueuePanel(managedZone)}
${zoneLocked ? `<div class="field-note warning-note">${esc(tr('devices.disabledZoneTechnicalOnly'))}</div>` : ''}
<div class="mode-row${quickRow}${detailed ? '' : ' quick-control-row-5'}">${modes.map(mode => `<button class="${device.mode === mode ? 'active' : ''}" data-action="mode" data-value="${mode}" data-device="${esc(device.id)}"${locked}>${esc(modeLabel(mode))}</button>`).join('')}</div>
<div class="fan-row${quickRow}${detailed ? '' : ' quick-control-row-4'}">${fans.map(fan => `<button class="${Number(device.fan_speed) === fan ? 'active' : ''}" data-action="fan" data-value="${fan}" data-device="${esc(device.id)}"${locked}>${esc(fanLabel(fan))}</button>`).join('')}</div>
<div class="device-toggles${quickRow}${detailed ? '' : ' quick-control-row-3'}">
<button class="${device.swing_vertical ? 'active' : ''}" data-action="toggle" data-field="swing_vertical" data-device="${esc(device.id)}"${locked}>${esc(tr('devices.swing'))}</button>
${device.supports_quiet === false ? '' : `<button class="${device.quiet ? 'active' : ''}" data-action="toggle" data-field="quiet" data-device="${esc(device.id)}"${locked}>${esc(tr('devices.quiet'))}</button>`}
${device.supports_turbo === false ? '' : `<button class="${device.turbo ? 'active' : ''}" data-action="toggle" data-field="turbo" data-device="${esc(device.id)}"${locked}>${esc(tr('devices.turbo'))}</button>`}
</div>
${detailed ? deviceFeaturePanel(device) : ''}
${detailed ? `<div class="card-footer">${networkInfo}<div class="card-menu"><button data-action="poll" data-device="${esc(device.id)}">${esc(tr('actions.read'))}</button><button data-action="rename-device" data-device="${esc(device.id)}">${esc(tr('devices.nameProtocol'))}</button>${device.simulated ? '' : `<button data-action="bind" data-device="${esc(device.id)}">${esc(tr('actions.bind'))}</button>`}<button class="danger" data-action="delete-device" data-device="${esc(device.id)}">${esc(tr('actions.delete'))}</button></div></div>` : ''}
</article>`;
}
function renderDevices() {
const empty = `<div class="empty"><strong>${esc(tr('devices.emptyTitle'))}</strong>${esc(tr('devices.emptyText'))}</div>`;
$('#dashboardDevices').innerHTML = app.devices.length ? app.devices.map(d => deviceCard(d, false)).join('') : empty;
$('#deviceList').innerHTML = app.devices.length ? app.devices.map(d => deviceCard(d, true)).join('') : empty;
const dashboardCount = $('#dashboardDeviceCount');
if (dashboardCount) dashboardCount.textContent = String(app.devices.length);
}
function zoneStrategyLabel(zone) {
if (zone.sensor_source === 'combined') return tr('zones.combinedSource');
if (zone.sensor_source === 'home_assistant') return tr('zones.externalSource');
return tr('zones.greeSource');
}
function zoneControlSourceLabel(source) {
return ({
device: tr('zones.sourceDevice'),
external: tr('zones.sourceExternal'),
combined: tr('zones.sourceCombined'),
device_fallback: tr('zones.sourceFallback'),
device_discrepancy_fallback: tr('zones.sourceDiscrepancy'),
unavailable: tr('zones.sourceUnavailable'),
})[source] || source || tr('zones.sourceUnavailable');
}
function zonePresetLabel(preset) {
const key = `preset.${preset || 'comfort'}`;
return tr(key) === key ? (preset || 'comfort') : tr(key);
}
function groupControlHue(groupId) {
let hash=0;
for(const char of String(groupId || 'group')) hash=((hash*31)+char.charCodeAt(0))>>>0;
return (hash % 300) + 20;
}
function groupControlStyle(group) {
return group ? ` style="--group-control-color:hsl(${groupControlHue(group.id)} 72% 48%)"` : '';
}
function groupControlForZone(zone) {
if(zone?.device_manual_override || zone?.local_thermostat_power != null) return null;
const source=String(zone?.control_source || '');
if(!source.startsWith('group:')) return null;
const groupId=source.slice(6);
return app.groups.find(group => group.id===groupId && group.power_enabled !== false && (group.zone_ids || []).includes(zone.id)) || null;
}
function zoneLockoutActive(zone) {
const until=zone?.lockout_until ? new Date(zone.lockout_until).getTime() : NaN;
return Number.isFinite(until) && until>Date.now();
}
function compressorPendingDescription(zone) {
const action=String(zone?.compressor_pending_action || '');
if(!action) return '';
const [kind, mode, target] = action.split(':');
const targetValue = Number(target);
if(kind==='mode_change') return tr('zones.queuedModeChange', {mode:modeLabel(mode), temperature:Number.isFinite(targetValue)?targetValue.toFixed(1):'—'});
if(kind==='power_on') return tr('zones.queuedPowerOn', {mode:modeLabel(mode), temperature:Number.isFinite(targetValue)?targetValue.toFixed(1):'—'});
if(kind==='global_power_on') return tr('zones.queuedGlobalPowerOn');
return tr('zones.queuedAction');
}
function compressorQueuePanel(zone) {
if(!zone?.compressor_pending_action) return '';
const until=zone.compressor_pending_until || zone.lockout_until;
const untilMs=until ? new Date(until).getTime() : NaN;
const when=Number.isFinite(untilMs) && untilMs>Date.now() ? dateTime(until) : tr('zones.afterProtection');
return `<div class="compressor-queue-panel"><div><strong>${esc(tr('zones.compressorQueueTitle'))}</strong><span>${esc(compressorPendingDescription(zone))}</span><small>${esc(tr('zones.compressorQueueUntil', {time:when}))}</small></div><button type="button" class="secondary" data-action="cancel-compressor-task" data-id="${esc(zone.id)}">${esc(tr('actions.cancel'))}</button></div>`;
}
function compressorQueueRemainingText(until) {
const timestamp = until ? new Date(until).getTime() : NaN;
if (!Number.isFinite(timestamp)) return tr('zones.afterProtection');
const seconds = Math.max(0, Math.ceil((timestamp - Date.now()) / 1000));
return seconds > 0 ? tr('zones.queueRemaining', {time:formatExtendedCountdown(seconds)}) : tr('zones.queueReady');
}
function renderCompressorQueueModal() {
const list = $('#compressorQueueList');
const countNode = $('#compressorQueueModalCount');
const cancelAll = $('#compressorQueueCancelAll');
if (!list || !countNode || !cancelAll) return;
const pending = app.zones
.filter(zone => !!zone.compressor_pending_action)
.slice()
.sort((a,b) => {
const at = new Date(a.compressor_pending_until || 0).getTime();
const bt = new Date(b.compressor_pending_until || 0).getTime();
return (Number.isFinite(at) ? at : Number.MAX_SAFE_INTEGER) - (Number.isFinite(bt) ? bt : Number.MAX_SAFE_INTEGER);
});
countNode.textContent = String(pending.length);
cancelAll.disabled = pending.length === 0;
cancelAll.hidden = pending.length === 0;
cancelAll.textContent = pending.length ? `${tr('zones.cancelAllQueued')} (${pending.length})` : tr('zones.cancelAllQueued');
if (!pending.length) {
list.innerHTML = `<div class="compressor-queue-empty"><span aria-hidden="true">✓</span><strong>${esc(tr('zones.queueEmptyTitle'))}</strong><p>${esc(tr('zones.queueEmptyText'))}</p></div>`;
return;
}
list.innerHTML = pending.map((zone,index) => {
const device = app.devices.find(item => item.id === zone.device_id);
const until = zone.compressor_pending_until || zone.lockout_until;
const group = groupControlForZone(zone);
const source = zone.compressor_pending_action === 'global_power_on'
? tr('zones.queueSourceGlobal')
: (group ? `${zoneControlOwnerLabel(zone)}: ${group.name}` : zoneControlOwnerLabel(zone));
const exact = until ? dateTime(until) : tr('zones.afterProtection');
return `<article class="compressor-queue-item">
<div class="compressor-queue-index">${index + 1}</div>
<div class="compressor-queue-item-main">
<div class="compressor-queue-item-head"><div><span class="eyebrow">${esc(zone.name)}</span><h3>${esc(device?.name || tr('common.noDevice'))}</h3></div><span class="compressor-queue-reason">${esc(tr('zones.queueReasonProtection'))}</span></div>
<p class="compressor-queue-command">${esc(compressorPendingDescription(zone))}</p>
<div class="compressor-queue-meta">
<span>${esc(tr('zones.queueSource', {source}))}</span>
<span>${esc(tr('zones.compressorQueueUntil', {time:exact}))}</span>
<strong data-compressor-queue-countdown="${esc(until || '')}">${esc(compressorQueueRemainingText(until))}</strong>
</div>
</div>
<button type="button" class="secondary compressor-queue-cancel" data-action="cancel-compressor-task" data-id="${esc(zone.id)}">${esc(tr('actions.cancel'))}</button>
</article>`;
}).join('');
}
function updateCompressorQueueCountdowns() {
$$('[data-compressor-queue-countdown]').forEach(node => {
node.textContent = compressorQueueRemainingText(node.dataset.compressorQueueCountdown);
});
}
function openCompressorQueueDialog() {
renderCompressorQueueModal();
openDialog('compressorQueueDialog');
updateCompressorQueueCountdowns();
}
function zoneControlOwnerLabel(zone) {
const owner=zone.control_owner || (zone.device_manual_override ? 'direct_manual' : (zone.local_thermostat_power != null ? 'local_thermostat' : 'automation'));
const source=zone.control_source || '';
if(owner==='global_off') return tr('zones.ownerGlobalOff');
if(owner==='direct_manual') {
if(source==='home_assistant_direct') return tr('zones.ownerHaDirect');
if(source==='web_direct') return tr('zones.ownerWebDirect');
return tr('zones.ownerExternal');
}
if(owner==='local_thermostat') return tr('zones.ownerLocalThermostat');
if(source.startsWith('group:')) return tr('zones.ownerGroup');
return tr('zones.ownerAutomation');
}
function zoneControlOwnerMeta(zone) {
const parts=[];
const controlGroup=groupControlForZone(zone);
if(controlGroup) parts.push(`${tr('groups.group')}: ${controlGroup.name}`);
if(zone.control_since) parts.push(`${tr('zones.ownerSince')} ${dateTime(zone.control_since)}`);
if(zone.control_resume_at) parts.push(`${tr('zones.ownerResume')} ${dateTime(zone.control_resume_at)}`);
if(zone.lockout_until && new Date(zone.lockout_until)>new Date()) parts.push(`${tr('zones.lockoutUntil')} ${dateTime(zone.lockout_until)}`);
return parts.join(' · ');
}
function zoneRuntimeStatusLabel(zone, effectiveMode, device = null) {
if (zone.device_manual_override === true) return tr('zones.manualDeviceControl');
if (!zone.enabled) return tr('common.disabled');
if (zone.local_thermostat_power === false) return tr('zones.localThermostatOff');
if ((effectiveMode || 'off') === 'off') return tr('zones.waiting');
if (device?.enabled === false) return tr('zones.waitingDeviceDisabled');
if (device && (!device.online || Number(device.communication_failures || 0) > 0)) return tr('zones.waitingOffline');
if (zone.current_temperature == null) return tr('zones.noMeasurement');
if (zone.compressor_pending_action && zoneLockoutActive(zone)) return tr('zones.waitingLockout');
if (zone.compressor_cancelled_action && device && (!device.power || (['heat','cool'].includes(effectiveMode) && device.mode !== effectiveMode))) return tr('zones.queueCancelledStatus');
if (zone.demand && device && !device.power) return tr('zones.waitingStart');
if (zone.demand && device?.power && ['heat','cool'].includes(effectiveMode) && device.mode !== effectiveMode) return tr('zones.waitingMode');
return zone.demand ? tr('zones.runningDemand') : tr('zones.satisfied');
}
function zoneCard(zone, detailed = true) {
const device = app.devices.find(d => d.id === zone.device_id);
const state = zone.enabled ? tr('common.active') : tr('common.disabled');
const sensorDetails = zone.sensor_source === 'device'
? `${tr('zones.greeTemp')}: ${fmtTemp(zone.device_temperature ?? device?.current_temperature)}`
: `${tr('zones.greeTemp')}: ${fmtTemp(zone.device_temperature)} · ${tr('zones.externalTemp')}: ${fmtTemp(zone.external_temperature)} · ${tr('zones.usedSource')}: ${zoneControlSourceLabel(zone.control_temperature_source)}`;
const roomTemperature = zone.current_temperature ?? zone.device_temperature ?? device?.current_temperature;
const manual = zone.manual_preset || 'auto';
const mode = zone.inherit_house_mode ? 'house' : zone.mode;
const effectiveMode = zone.effective_mode || (zone.inherit_house_mode ? (app.settings?.house_mode || 'off') : zone.mode) || 'off';
const idleWithoutTarget = effectiveMode === 'off' && zone.effective_setpoint == null && zone.manual_setpoint == null && zone.manual_preset == null;
// OFF means "no active demand", not "forget the thermostat setting". Keep the last
// backend-persisted setpoint visible so Group/Global OFF does not blank the temperature UI.
const target = Number(zone.manual_setpoint ?? zone.effective_setpoint ?? zone.setpoint);
const displayPreset = idleWithoutTarget ? 'auto' : (zone.manual_preset || zone.active_preset || 'comfort');
const globalModeAvailable = ['heat', 'cool'].includes(app.settings?.house_mode);
const globalModeDisabled = globalModeAvailable ? '' : ` disabled aria-disabled="true" title="${esc(tr('zones.globalModeUnavailable'))}"`;
const hasManualOverride = zone.manual_preset != null || zone.manual_setpoint != null;
const deviceManualOverride = zone.device_manual_override === true;
const controlGroup = groupControlForZone(zone);
const deviceUnavailable = !!device && (device.enabled === false || !device.online || Number(device.communication_failures || 0) > 0);
const lockoutWaiting = !!zone.compressor_pending_action && zoneLockoutActive(zone);
const startWaiting = zone.demand && !!device && !deviceUnavailable && !device.power;
const modeWaiting = zone.demand && !!device?.power && !deviceUnavailable && ['heat','cool'].includes(effectiveMode) && device.mode !== effectiveMode;
const visualGroup = controlGroup;
const waitingDemand = lockoutWaiting || startWaiting || modeWaiting || (zone.demand && deviceUnavailable);
const workingDemand = zone.demand && !!device?.power && !deviceUnavailable && !waitingDemand;
const localThermostatPower = zone.local_thermostat_power;
const localThermostatResumeAt = zone.local_thermostat_resume_at || null;
const pausedGroup = localThermostatPower === false && localThermostatResumeAt
? (app.groups || []).find(group => group.power_enabled === false && (group.zone_ids || []).includes(zone.id)) || null
: null;
const temporarySession = zone.temporary_quick_thermostat || null;
const temporaryStatus = temporarySession ? temporarySessionStatus(zone) : null;
const localThermostatOverride = localThermostatPower === true || localThermostatPower === false;
const override = deviceManualOverride
? (zone.device_manual_override_until
? tr('zones.manualDeviceUntil', {time:new Date(zone.device_manual_override_until).toLocaleTimeString(locale(), {hour:'2-digit',minute:'2-digit'})})
: tr('zones.manualDeviceNoBoundary'))
: temporarySession
? tr('zones.temporaryOverride', {time:temporaryStatus?.countdown || '—'})
: localThermostatOverride
? tr(localThermostatPower ? 'zones.localThermostatOn' : 'zones.localThermostatOff')
: (zone.manual_override_until
? `${tr('zones.overrideUntil')} ${new Date(zone.manual_override_until).toLocaleTimeString(locale(), {hour:'2-digit',minute:'2-digit'})}`
: (hasManualOverride ? tr('zones.manualNoBoundary') : tr('zones.scheduleControl')));
const manualTakeover = deviceManualOverride
? `<div class="manual-override-panel"><div><strong>${esc(tr('zones.manualDeviceControl'))}</strong><p>${esc(tr('zones.manualDeviceDescription'))}</p></div><button type="button" data-action="zone-resume-automation" data-resume="device" data-id="${esc(zone.id)}">${esc(tr('zones.resumeAutomation'))}</button></div>`
: localThermostatOverride && !temporarySession
? `<div class="manual-override-panel local-thermostat-panel"><div><strong>${esc(tr('zones.localThermostatControl'))}</strong>${localThermostatPower === false && localThermostatResumeAt
? `<p data-local-resume-countdown="${esc(localThermostatResumeAt)}"${pausedGroup ? ` data-local-resume-group="${esc(pausedGroup.name)}"` : ''}>${esc(tr(pausedGroup ? 'zones.groupOffDescriptionTimed' : 'zones.localThermostatOffDescriptionTimed', {group:pausedGroup?.name || '', time:formatCountdown(localResumeSeconds(localThermostatResumeAt))}))}</p>`
: `<p>${esc(tr(localThermostatPower ? 'zones.localThermostatOnDescription' : 'zones.localThermostatOffDescription'))}</p>`}</div><button type="button" data-action="zone-resume-automation" data-resume="local" data-id="${esc(zone.id)}">${esc(tr(localThermostatPower === false ? 'zones.resumeNow' : 'zones.resumeAutomation'))}</button></div>`
: '';
const localPowerDisabled = !device || device.enabled === false ? ' disabled' : '';
const localRequestedOn = localThermostatPower === true ? true : (localThermostatPower === false ? false : !!device?.power);
const localThermostatPowerControl = `<button type="button" class="zone-unit-power-toggle ${localRequestedOn ? 'active' : ''}" title="${esc(tr('zones.unitPowerLocalHint'))}" data-action="zone-device-power" data-id="${esc(zone.id)}" data-value="${localRequestedOn ? 'false' : 'true'}" aria-pressed="${localRequestedOn ? 'true' : 'false'}"${localPowerDisabled}><span aria-hidden="true">⏻</span><b>${esc(tr(localRequestedOn ? 'common.on' : 'common.off'))}</b></button>`;
const temporaryThermostatControl = temporarySession
? `<button type="button" class="temporary-thermostat-toggle active ${temporaryStatus?.pending ? 'scheduled' : ''}" data-action="zone-open-temporary" data-id="${esc(zone.id)}" title="${esc(temporaryStatus?.detail || tr('zones.temporaryActive'))}"><span aria-hidden="true">⏱</span><b data-temporary-countdown-zone="${esc(zone.id)}">${esc(temporaryStatus?.countdown || '—')}</b></button>`
: `<button type="button" class="temporary-thermostat-toggle" data-action="zone-open-temporary" data-id="${esc(zone.id)}" title="${esc(tr('zones.temporaryStartHint'))}"${localPowerDisabled}><span aria-hidden="true">⏱</span><b>${esc(tr('zones.temporaryShort'))}</b></button>`;
if (detailed) {
const groupNames = (app.groups || []).filter(group => (group.zone_ids || []).includes(zone.id)).map(group => group.name);
const groupText = groupNames.length ? groupNames.join(' · ') : tr('zones.noGroup');
const policy = zone.inherit_house_mode ? tr('zones.followHouse') : tr(zone.mode === 'heat' ? 'zones.heatOnly' : 'zones.coolOnly');
return `<article class="list-card zone-config-card ${workingDemand ? 'demanding' : ''} ${waitingDemand ? 'lockout-waiting' : ''} ${zone.enabled ? '' : 'zone-disabled'} ${deviceManualOverride ? 'manual-takeover' : ''} ${controlGroup ? 'group-controlled' : ''}" data-zone-config="${esc(zone.id)}"${groupControlStyle(visualGroup)}>
<div class="list-card-head"><div><span class="eyebrow">${esc(tr('zones.configuration'))}</span><h3>${esc(zone.name)}</h3><p>${esc(device?.name || tr('common.noDevice'))} · ${esc(tr('groups.group'))}: ${esc(groupText)}</p></div><button type="button" class="zone-enable-toggle ${zone.enabled ? 'active' : ''}" data-action="zone-enabled" data-id="${esc(zone.id)}" data-value="${zone.enabled ? 'false' : 'true'}" aria-label="${esc(tr(zone.enabled ? 'zones.disable' : 'zones.enable'))}" title="${esc(tr('zones.automationToggleHint'))}"><span>${zone.enabled ? '✓' : '○'}</span>${esc(state)}</button></div>
<div class="zone-config-status">
<div class="zone-config-temperature"><small>${esc(tr('zones.currentStatus'))}</small><div><span>${fmtTemp(roomTemperature)}</span><b>→</b><strong>${Number.isFinite(target) ? `${target.toFixed(1)}°C` : '—'}</strong></div></div>
<div class="zone-config-runtime"><span class="badge ${workingDemand ? 'active' : ''}">${esc(zoneRuntimeStatusLabel(zone, effectiveMode, device))}</span><span>${esc(houseModeLabel(effectiveMode))} · ${esc(zonePresetLabel(displayPreset))}</span><small>${esc(override)}</small><small><strong>${esc(tr('zones.controlOwner'))}:</strong> ${esc(zoneControlOwnerLabel(zone))}${zoneControlOwnerMeta(zone) ? ` · ${esc(zoneControlOwnerMeta(zone))}` : ''}</small></div>
</div>
<div class="zone-config-grid">
<div><small>${esc(tr('zones.modePolicy'))}</small><strong>${esc(policy)}</strong></div>
<div><small>${esc(tr('zones.source'))}</small><strong>${esc(zoneStrategyLabel(zone))}</strong></div>
<div><small>${esc(tr('zones.hysteresis'))}</small><strong>${zone.separate_hysteresis ? `${esc(tr('mode.cool'))} ${Number(zone.cool_hysteresis ?? zone.hysteresis ?? 0.6).toFixed(1)}°C · ${esc(tr('mode.heat'))} ${Number(zone.heat_hysteresis ?? zone.hysteresis ?? 0.6).toFixed(1)}°C` : `${Number(zone.hysteresis ?? 0.6).toFixed(1)}°C`}</strong></div>
<div><small>${esc(tr('zones.smartFan'))}</small><strong>${esc(tr(zone.smart_fan === false ? 'common.off' : 'common.on'))}</strong></div>
</div>
<div class="sensor-detail">${esc(sensorDetails)}</div>
${compressorQueuePanel(zone)}
${manualTakeover}
<div class="card-footer"><small>${esc(tr('zones.controlOnDashboard'))}</small><div class="card-menu"><button class="primary" data-action="zone-go-control" data-id="${esc(zone.id)}">${esc(tr('zones.controlNow'))}</button><button data-action="edit-zone" data-id="${esc(zone.id)}">${esc(tr('actions.edit'))}</button><button class="danger" data-action="delete-zone" data-id="${esc(zone.id)}">${esc(tr('actions.delete'))}</button></div></div>
</article>`;
}
const runtimeStatus = zoneRuntimeStatusLabel(zone, effectiveMode, device);
return `<article class="list-card zone-thermostat quick-control-card quick-thermostat-control ${workingDemand ? 'demanding' : ''} ${waitingDemand ? 'lockout-waiting' : ''} ${zone.enabled ? '' : 'zone-disabled'} ${deviceManualOverride ? 'manual-takeover' : ''} ${controlGroup ? 'group-controlled' : ''}" data-zone-card="${esc(zone.id)}"${groupControlStyle(visualGroup)}>
<div class="list-card-head"><div><h3>${esc(zone.name)}</h3><p>${esc(device?.name || tr('common.noDevice'))} · ${esc(zonePresetLabel(displayPreset))}</p></div><div class="zone-quick-actions">${localThermostatPowerControl}${temporaryThermostatControl}</div></div>
<div class="thermostat-main"><div><small>${esc(tr('zones.measurement'))}</small><strong>${fmtTemp(roomTemperature)}</strong></div><div class="temperature-control compact"><button data-action="zone-temperature" data-id="${esc(zone.id)}" data-delta="-0.5"></button><div class="target-temp compact editable-target" data-temperature-kind="zone" data-id="${esc(zone.id)}" data-value="${Number.isFinite(target) ? target : ''}" tabindex="0" role="button" title="${esc(tr('common.clickTemperatureToEdit'))}">${Number.isFinite(target)?target.toFixed(1):'--'}<small>°C</small></div><button data-action="zone-temperature" data-id="${esc(zone.id)}" data-delta="0.5">+</button></div><div><small>${esc(tr('zones.deviceTarget'))}</small><strong>${fmtTemp(zone.device_setpoint)}</strong></div></div>
<div class="preset-row quick-control-row quick-control-row-4">
${['auto','comfort','sleep','away'].map(preset=>`<button class="${manual===preset?'active':''}" data-action="zone-preset" data-id="${esc(zone.id)}" data-value="${preset}">${esc(preset==='sleep'?tr('zones.sleepNow'):zonePresetLabel(preset))}</button>`).join('')}
</div>
<div class="mode-row zone-mode-row quick-control-row quick-control-row-3"><button class="${mode==='house'&&globalModeAvailable?'active':''}" data-action="zone-mode" data-id="${esc(zone.id)}" data-value="house"${globalModeDisabled}>${esc(tr('zones.followHouseShort'))}</button><button class="${mode==='heat'?'active':''}" data-action="zone-mode" data-id="${esc(zone.id)}" data-value="heat">${esc(modeLabel('heat'))}</button><button class="${mode==='cool'?'active':''}" data-action="zone-mode" data-id="${esc(zone.id)}" data-value="cool">${esc(modeLabel('cool'))}</button></div>
<div class="zone-state-line"><span class="zone-runtime-status" title="${esc(runtimeStatus)}">${esc(runtimeStatus)}${controlGroup ? ` <b class="group-control-tag">${esc(controlGroup.name)}</b>` : ''}</span><span>${esc(override)}</span></div>
<div class="zone-state-line control-owner-line"><span><strong>${esc(tr('zones.controlOwner'))}:</strong> ${esc(zoneControlOwnerLabel(zone))}</span><span>${esc(zoneControlOwnerMeta(zone))}</span></div>
${compressorQueuePanel(zone)}
${manualTakeover}
</article>`;
}
function renderZones() {
const empty = `<div class="empty"><strong>${esc(tr('zones.emptyTitle'))}</strong>${esc(tr('zones.emptyText'))}</div>`;
$('#zoneList').innerHTML = app.zones.length ? app.zones.map(zone => zoneCard(zone, true)).join('') : empty;
const dashboard = $('#dashboardZones');
if (dashboard) dashboard.innerHTML = app.zones.length ? app.zones.map(zone => zoneCard(zone, false)).join('') : empty;
const dashboardCount = $('#dashboardZoneCount');
if (dashboardCount) dashboardCount.textContent = String(app.zones.length);
const pendingCount = app.zones.filter(zone => !!zone.compressor_pending_action).length;
$$('[data-compressor-queue-count]').forEach(node => { node.textContent = String(pendingCount); });
$$('.queue-overview-button').forEach(button => button.classList.toggle('has-items', pendingCount > 0));
renderCompressorQueueModal();
}
function groupZones(group) {
const wanted = new Set(group?.zone_ids || []);
return app.zones.filter(zone => wanted.has(zone.id));
}
function groupState(group) {
const zones = groupZones(group);
const modes = [...new Set(zones.map(zone => zone.inherit_house_mode ? 'house' : (zone.mode || 'cool')))];
const presets = [...new Set(zones.map(zone => zone.manual_preset || 'auto'))];
const customTargets = zones
.filter(zone => (zone.manual_preset || 'auto') === 'custom')
.map(zone => Number(zone.manual_setpoint ?? zone.effective_setpoint ?? zone.setpoint))
.filter(Number.isFinite);
const customTemperature = customTargets.length === zones.length && customTargets.length && customTargets.every(value => Math.abs(value - customTargets[0]) < 0.05)
? customTargets[0]
: null;
return {
zones,
mode: modes.length === 1 ? modes[0] : (modes.length ? 'mixed' : 'house'),
preset: presets.length === 1 ? presets[0] : (presets.length ? 'mixed' : 'auto'),
customTemperature,
};
}
function groupCard(group, detailed = false) {
const state = groupState(group);
const memberNames = state.zones.map(zone => zone.name);
const memberText = memberNames.length ? memberNames.join(' · ') : tr('groups.noMembers');
const powerEnabled = group.power_enabled !== false;
const modeLabelForGroup = value => value === 'house' ? tr('groups.followHouse') : (value === 'mixed' ? tr('groups.mixed') : modeLabel(value));
const presetLabelForGroup = value => value === 'mixed' ? tr('groups.mixed') : zonePresetLabel(value);
const fallbackTarget = Number(state.zones[0]?.manual_setpoint ?? state.zones[0]?.effective_setpoint ?? state.zones[0]?.setpoint ?? 23);
const currentCustomTarget = Number.isFinite(state.customTemperature) ? state.customTemperature : (Number.isFinite(fallbackTarget) ? fallbackTarget : 23);
const customDraft = app.groupCustomDrafts?.[group.id];
const draftTarget = Number(customDraft?.value);
const customTarget = Number.isFinite(draftTarget) ? draftTarget : currentCustomTarget;
const customEditorVisible = powerEnabled && (state.preset === 'custom' || customDraft?.open === true);
const customDraftPending = powerEnabled && customDraft?.open === true && state.preset !== 'custom';
const groupIsControlSource = powerEnabled && state.zones.some(zone => String(zone.control_source || '') === `group:${group.id}` && !zone.device_manual_override && zone.local_thermostat_power == null);
const climateControlsDisabled = powerEnabled ? '' : ` disabled aria-disabled="true" title="${esc(tr('groups.enableControlFirst'))}"`;
const groupStatusBadge = groupIsControlSource
? `<span class="group-control-badge">${esc(tr('groups.activeControl'))}</span>`
: (!powerEnabled ? `<span class="group-control-badge group-off-badge">${esc(tr('groups.gateOff'))}</span>` : '');
return `<article class="list-card group-card group-linked ${powerEnabled ? '' : 'group-off'} ${groupIsControlSource ? 'group-controlled' : ''}"${groupControlStyle(group)}>
<div class="list-card-head"><div><span class="eyebrow">${esc(tr('groups.group'))}</span><h3>${esc(group.name)}</h3><p>${esc(memberText)}</p></div>${groupStatusBadge}</div>
${detailed ? `<div class="group-state-summary"><div><small>${esc(tr('groups.mode'))}</small><strong>${esc(modeLabelForGroup(state.mode))}</strong></div><div><small>${esc(tr('groups.profile'))}</small><strong>${esc(presetLabelForGroup(state.preset))}</strong></div><div><small>${esc(tr('groups.members'))}</small><strong>${state.zones.length}</strong></div></div>` : ''}
<div class="group-control-block"><small>${esc(tr('groups.controlToggle'))}</small><div class="group-button-row"><button class="${powerEnabled ? 'active' : ''}" data-action="group-power" data-id="${esc(group.id)}" data-value="true">${esc(tr('common.on'))}</button><button class="${!powerEnabled ? 'active' : ''}" data-action="group-power" data-id="${esc(group.id)}" data-value="false">${esc(tr('common.off'))}</button></div></div>
<div class="group-control-block"><small>${esc(tr('groups.mode'))}</small><div class="mode-row group-mode-row">${['house','heat','cool'].map(mode => `<button class="${state.mode === mode ? 'active' : ''}" data-action="group-mode" data-id="${esc(group.id)}" data-value="${mode}"${climateControlsDisabled}>${esc(mode === 'house' ? tr('groups.followHouse') : modeLabel(mode))}</button>`).join('')}</div></div>
<div class="group-control-block"><small>${esc(tr('groups.profile'))}</small><div class="preset-row group-preset-row">${['auto','comfort','sleep','away'].map(preset => `<button class="${state.preset === preset ? 'active' : ''}" data-action="group-preset" data-id="${esc(group.id)}" data-value="${preset}"${climateControlsDisabled}>${esc(zonePresetLabel(preset))}</button>`).join('')}<button class="${state.preset === 'custom' ? 'active' : (customDraft?.open === true ? 'editing' : '')}" data-action="group-custom-open" data-id="${esc(group.id)}" title="${esc(tr('groups.customOpenHint'))}"${climateControlsDisabled}>${esc(tr('preset.custom'))}</button></div>
<div class="group-custom-temperature" data-group-custom-editor="${esc(group.id)}" ${customEditorVisible ? '' : 'hidden'}><label><span>${esc(tr('groups.customTemperature'))}</span><div class="group-custom-temperature-row"><input type="text" inputmode="decimal" min="8" max="30" step="0.1" value="${customTarget.toFixed(1)}" data-group-custom-temperature="${esc(group.id)}" aria-label="${esc(tr('groups.customTemperature'))}" title="${esc(tr('groups.customTemperatureHint'))}"><span>°C</span><button type="button" class="primary" data-action="group-custom-temperature" data-id="${esc(group.id)}" title="${esc(tr('groups.applyCustomTemperatureHint'))}">${esc(tr('actions.apply'))}</button><button type="button" class="secondary" data-action="group-custom-cancel" data-id="${esc(group.id)}">${esc(tr('actions.cancel'))}</button></div></label>${customDraftPending ? `<small class="group-custom-draft-note">${esc(tr('groups.customDraftPending'))}</small>` : ''}</div></div>
${detailed ? `<div class="card-footer"><small>${esc(tr('groups.memberCount', {count: state.zones.length}))}</small><div class="card-menu"><button data-action="edit-group" data-id="${esc(group.id)}">${esc(tr('actions.edit'))}</button><button class="danger" data-action="delete-group" data-id="${esc(group.id)}">${esc(tr('actions.delete'))}</button></div></div>` : ''}
</article>`;
}
function renderGroups() {
const empty = `<div class="empty"><strong>${esc(tr('groups.emptyTitle'))}</strong>${esc(tr('groups.emptyText'))}</div>`;
const dashboard = $('#dashboardGroups');
if (dashboard) dashboard.innerHTML = app.groups.length ? app.groups.map(group => groupCard(group, false)).join('') : empty;
const dashboardCount = $('#dashboardGroupCount');
if (dashboardCount) dashboardCount.textContent = String(app.groups.length);
const list = $('#groupList');
if (list) list.innerHTML = app.groups.length ? app.groups.map(group => groupCard(group, true)).join('') : empty;
}
async function sendGroupControl(id, patch) {
try {
await enqueueClimateControlTask(async () => {
const result = await api(`/api/groups/${encodeURIComponent(id)}/control`, {method:'POST', body:patch});
if (result.group) {
const index = app.groups.findIndex(group => group.id === result.group.id);
if (index >= 0) app.groups[index] = result.group; else app.groups.push(result.group);
}
(result.zones || []).forEach(zone => {
const index = app.zones.findIndex(item => item.id === zone.id);
if (index >= 0) app.zones[index] = zone; else app.zones.push(zone);
});
(result.devices || []).forEach(updateDevice);
if (typeof result.master_power_enabled === 'boolean' && app.settings) app.settings.house_power_enabled = result.master_power_enabled;
if (patch.preset === 'custom' && patch.setpoint != null) delete app.groupCustomDrafts[id];
renderAll(); scheduleControlPlanLoad();
const failed = Array.isArray(result.failed) ? result.failed.length : 0;
if (failed) toast(tr('groups.partial', {count: failed}), true);
else toast(tr('groups.controlUpdated'));
return result;
});
} catch (error) {
await loadBootstrap();
toast(error.message, true);
}
}
function renderGroupZoneChoices(selectedIds = null) {
const host = $('#groupZoneChoices'); if (!host) return;
const selected = new Set(selectedIds || []);
host.innerHTML = app.zones.length ? app.zones.map(zone => {
const device = app.devices.find(item => item.id === zone.device_id);
return `<label class="group-zone-choice"><input type="checkbox" name="zone_ids" value="${esc(zone.id)}" ${selected.has(zone.id) ? 'checked' : ''}><span><strong>${esc(zone.name)}</strong><small>${esc(device?.name || tr('common.noDevice'))}</small></span></label>`;
}).join('') : `<div class="empty compact">${esc(tr('groups.noZones'))}</div>`;
}
function populateGroup(id) {
const item = app.groups.find(group => group.id === id); if (!item) return;
const form = $('#groupForm'); form.reset();
form.elements.id.value = item.id;
form.elements.name.value = item.name;
renderGroupZoneChoices(item.zone_ids || []);
openDialog('groupDialog');
}
function renderSchedules() {
const dayNames = Array.from({length:7}, (_, index) => tr(`day.${index + 1}`));
$('#scheduleList').innerHTML = app.schedules.length ? app.schedules.map(item => {
const zone = app.zones.find(z => z.id === item.zone_id);
const days = item.weekdays.map(day => dayNames[day-1]).join(', ');
return `<article class="list-card"><div class="list-card-head"><div><h3>${esc(item.name)}</h3><p>${esc(zone?.name || tr('common.noZone'))} · ${esc(days)}</p></div><span class="badge ${item.enabled ? 'active' : ''}">${esc(item.enabled ? tr('common.enabled') : tr('common.disabled'))}</span></div>
<div class="card-stats"><div class="card-stat"><small>${esc(tr('common.from'))}</small><strong>${esc(item.start_time)}</strong></div><div class="card-stat"><small>${esc(tr('common.to'))}</small><strong>${esc(item.end_time)}</strong></div><div class="card-stat"><small>${esc(tr('schedules.profile'))}</small><strong>${esc(item.preset === 'custom' ? fmtTemp(item.setpoint) : zonePresetLabel(item.preset))}</strong></div></div>
<div class="card-footer"><small>${esc(tr('schedules.crossMidnight'))}</small><div class="card-menu"><button data-action="edit-schedule" data-id="${esc(item.id)}">${esc(tr('actions.edit'))}</button><button class="danger" data-action="delete-schedule" data-id="${esc(item.id)}">${esc(tr('actions.delete'))}</button></div></div></article>`;
}).join('') : `<div class="empty"><strong>${esc(tr('schedules.emptyTitle'))}</strong>${esc(tr('schedules.emptyText'))}</div>`;
}
function renderAutomations() {
$('#automationList').innerHTML = app.automations.length ? app.automations.map(item => {
const actionGroup = item.action_group_id ? app.groups.find(group => group.id === item.action_group_id) : null;
const actionDevice = app.devices.find(device => device.id === item.action_device_id);
const targetName = actionGroup?.name || actionDevice?.name || tr('common.noDevice');
const mode = item.action.mode === 'auto' && actionGroup ? tr('groups.followHouse') : (item.action.mode ? modeLabel(item.action.mode) : '—');
const preset = item.action_preset ? zonePresetLabel(item.action_preset) : '—';
return `<article class="list-card"><div class="list-card-head"><div><h3>${esc(item.name)}</h3><p>${esc(tr('automations.triggerSummary', {trigger: automationTriggerLabel(item), device: targetName}))}</p></div><span class="badge ${item.enabled ? 'active' : ''}">${esc(item.enabled ? tr('common.active') : tr('common.disabled'))}</span></div>
<div class="card-stats"><div class="card-stat"><small>${esc(tr('common.power'))}</small><strong>${item.action.power == null ? '—' : item.action.power ? tr('common.on') : tr('common.off')}</strong></div><div class="card-stat"><small>${esc(tr('common.mode'))}</small><strong>${esc(mode)}</strong></div><div class="card-stat"><small>${esc(tr('groups.profile'))}</small><strong>${esc(preset)}</strong></div><div class="card-stat"><small>${esc(tr('automations.last'))}</small><strong>${item.last_fired_at ? new Date(item.last_fired_at).toLocaleTimeString(locale(),{hour:'2-digit',minute:'2-digit'}) : '—'}</strong></div></div>
<div class="card-footer"><small>${esc(actionGroup ? `${tr('groups.group')}: ${targetName}` : `${tr('common.device')}: ${targetName}`)} · ${esc(tr('common.cooldown'))} ${item.cooldown_seconds}s</small><div class="card-menu"><button data-action="edit-automation" data-id="${esc(item.id)}">${esc(tr('actions.edit'))}</button><button class="danger" data-action="delete-automation" data-id="${esc(item.id)}">${esc(tr('actions.delete'))}</button></div></div></article>`;
}).join('') : `<div class="empty"><strong>${esc(tr('automations.emptyTitle'))}</strong>${esc(tr('automations.emptyText'))}</div>`;
}
function fillSelects() {
const deviceOptions = app.devices.map(d => `<option value="${esc(d.id)}">${esc(d.name)}</option>`).join('');
const zoneOptions = app.zones.map(z => `<option value="${esc(z.id)}">${esc(z.name)}</option>`).join('');
const groupOptions = app.groups.map(g => `<option value="${esc(g.id)}">${esc(g.name)}</option>`).join('');
['#zoneForm [name=device_id]', '#automationForm [name=trigger_device_id]', '#automationForm [name=action_device_id]'].forEach(selector => {
const select = $(selector); if (!select) return;
const current = select.value; select.innerHTML = deviceOptions; if ([...select.options].some(o => o.value === current)) select.value = current;
});
const automationGroupSelect = $('#automationForm [name=action_group_id]');
if (automationGroupSelect) { const currentGroup = automationGroupSelect.value; automationGroupSelect.innerHTML = groupOptions; if ([...automationGroupSelect.options].some(o => o.value === currentGroup)) automationGroupSelect.value = currentGroup; }
const zoneSelect = $('#scheduleForm [name=zone_id]');
if (zoneSelect) { const currentZone = zoneSelect.value; zoneSelect.innerHTML = zoneOptions; if ([...zoneSelect.options].some(o => o.value === currentZone)) zoneSelect.value = currentZone; }
const presetZone = $('#schedulePresetZone');
if (presetZone) { const currentZone = presetZone.value; presetZone.innerHTML = zoneOptions; if ([...presetZone.options].some(o => o.value === currentZone)) presetZone.value = currentZone; }
const copyZone=$('#copyZoneSource');
if(copyZone){
const current=copyZone.value;
const editingZoneId=$('#zoneForm')?.dataset.editingZoneId || '';
const sources=app.zones.filter(zone=>zone.id!==editingZoneId);
copyZone.innerHTML=`<option value="">${esc(tr('zones.copyFrom'))}</option>`+sources.map(z=>`<option value="${esc(z.id)}">${esc(z.name)}</option>`).join('');
copyZone.value=[...copyZone.options].some(option=>option.value===current)?current:'';
}
}