Files
gree-controller/web/js-dynamic/entities.js
T
2026-09-17 21:46:10 +02:00

767 lines
63 KiB
JavaScript

function deviceFeaturePanel(device) {
const allFeatures = [
['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')],
];
const features = allFeatures.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 zoneForDevice(deviceId) {
return app.zones.find(zone => zone.device_id === deviceId) || null;
}
function disabledZoneForDevice(deviceId) {
return app.zones.find(zone => zone.device_id === deviceId && zone.enabled === false) || null;
}
function deviceTransportLabel(device) {
return device.connection_type === 'gree_cloud' ? 'GREE Cloud' : 'Local';
}
function deviceInstallationForDevice(deviceId) {
return (app.deviceGroups || []).find(group => (group.device_ids || []).includes(deviceId)) || null;
}
function deviceInstallationKindLabel(group) {
return tr(group?.kind === 'multisplit' ? 'devices.multisplit' : 'devices.split');
}
function installationEnergySourceLabel(group) {
if (!group) return '—';
if (group.energy_source === 'home_assistant' || (group.energy_source === 'auto' && group.ha_energy_entity_id && !group.energy_device_id)) {
return group.ha_energy_entity_id ? `Home Assistant · ${group.ha_energy_entity_id}` : 'Home Assistant';
}
if (group.energy_source === 'gree_cloud' || group.energy_device_id) {
const source = app.devices.find(device => device.id === group.energy_device_id);
return source ? `GREE Cloud · ${source.name}` : 'GREE Cloud';
}
return tr('energy.auto');
}
function effectiveDeviceOutdoorTemperature(device) {
const group = deviceInstallationForDevice(device?.id);
if (group?.outdoor_temperature_device_id) {
const source = app.devices.find(item => item.id === group.outdoor_temperature_device_id);
if (source?.outdoor_temperature != null) return source.outdoor_temperature;
}
return device?.outdoor_temperature;
}
function installationEnergySnapshot(group) {
if (!group) return null;
const selectedSource = group.energy_source === 'gree_cloud' || (group.energy_source === 'auto' && !!group.energy_device_id)
? 'gree_cloud'
: (group.energy_source === 'home_assistant' || (group.energy_source === 'auto' && !!group.ha_energy_entity_id) ? 'home_assistant' : '');
if (selectedSource === 'gree_cloud' && group.energy_device_id) {
const device = app.devices.find(item => item.id === group.energy_device_id);
const live = device?.total_energy_kwh == null ? NaN : Number(device.total_energy_kwh);
if (Number.isFinite(live) && live >= 0) {
return { total_kwh: live, timestamp: device.last_cloud_sync || device.last_seen || null, source: 'gree_cloud', origin: 'cloud' };
}
}
const cached = app.deviceGroupEnergy?.[group.id] || null;
return cached?.source === selectedSource ? cached : null;
}
function formatEnergyMeterTotal(value) {
const number = Number(value);
if (!Number.isFinite(number) || number < 0) return tr('common.unavailable');
return `${number.toLocaleString(locale(), { maximumFractionDigits: 3 })} kWh`;
}
function installationEnergySourceShortLabel(group, snapshot) {
if (snapshot?.source === 'home_assistant') return 'Home Assistant';
if (snapshot?.source === 'gree_cloud') return 'GREE Cloud';
if (group?.energy_source === 'home_assistant' || (group?.energy_source === 'auto' && group?.ha_energy_entity_id && !group?.energy_device_id)) return 'Home Assistant';
if (group?.energy_source === 'gree_cloud' || group?.energy_device_id) return 'GREE Cloud';
return tr('energy.auto');
}
function deviceConnectionStatusLabel(device) {
const key = {
online: 'status.online',
offline: 'status.offline',
cloud_disconnected: 'status.cloudDisconnected',
authentication_error: 'status.authenticationError',
unknown: 'status.unknown',
}[device.connection_status || (device.online ? 'online' : 'offline')];
return key ? tr(key) : String(device.connection_status || tr('status.unknown')).replaceAll('_', ' ');
}
function deviceProtocolLabel(device) {
if (device.connection_type === 'gree_cloud') return 'MQTT / TLS';
return device.simulated ? tr('devices.simulator') : device.protocol_version === 2 ? 'V2 AES-GCM' : device.protocol_version === 1 ? 'V1 AES-ECB' : tr('devices.protocolAuto');
}
function deviceResponseTimeLabel(device) {
const value = Number(device.response_time_ms);
return Number.isFinite(value) ? `${Math.max(0, Math.round(value))} ms` : '— ms';
}
function manualDeviceStatus(device) {
const label = deviceConnectionStatusLabel(device);
const detail = device.last_error ? `<span class="manual-device-status-error" title="${esc(device.last_error)}">${esc(device.last_error)}</span>` : '';
const pending = device.pending_command ? `<span class="manual-device-status-pending">${esc(tr('common.pending'))}</span>` : '';
return `<p class="manual-device-status" role="status"><span class="status ${device.online ? 'online' : ''}">${esc(label)}</span>${detail}${pending}</p>`;
}
// Keep Local Manual Control visually and behaviorally identical to the pre-Cloud UI.
// The only transport-specific addition is the existing Local badge in the title row.
function manualDeviceProblem(device) {
if (!device.enabled) {
return `<div class="manual-device-problem error" role="status"><strong>${esc(tr('devices.manualDeviceDisabled'))}</strong></div>`;
}
if (!device.online) {
return `<div class="manual-device-problem error" role="status"><strong>${esc(tr('devices.manualNoCommunication'))}</strong>${device.last_error ? `<small>${esc(device.last_error)}</small>` : ''}</div>`;
}
if (Number(device.communication_failures || 0) > 0 || device.last_error) {
return `<div class="manual-device-problem warning" role="status"><strong>${esc(tr('devices.manualCommunicationProblem'))}</strong>${device.last_error ? `<small>${esc(device.last_error)}</small>` : ''}</div>`;
}
return '';
}
function powerIconMarkup() {
return uiIcon('power', 'power-icon');
}
function deviceLouverControls(device, { disabled = false } = {}) {
if (!device) return '';
const caps = device.capabilities || {};
const disabledAttr = disabled ? ' disabled' : '';
const controls = [];
if (caps.vertical_swing !== false) controls.push(`<label class="louver-control"><span>${esc(tr('devices.swingVertical'))}</span><select data-louver-field="swing_vertical" data-device="${esc(device.id)}"${disabledAttr}>${louverOptions('vertical', device.swing_vertical)}</select></label>`);
if (caps.horizontal_swing !== false) controls.push(`<label class="louver-control"><span>${esc(tr('devices.swingHorizontal'))}</span><select data-louver-field="swing_horizontal" data-device="${esc(device.id)}"${disabledAttr}>${louverOptions('horizontal', device.swing_horizontal)}</select></label>`);
return controls.length ? `<div class="louver-controls">${controls.join('')}</div>` : '';
}
function manualLocalDeviceCard(device) {
const caps = device.capabilities || {};
const modes = ['auto', 'cool', 'dry', 'fan', 'heat'];
const fans = [0, 1, 3, 5];
const disabledZone = disabledZoneForDevice(device.id);
const managedZone = app.zones.find(zone => zone.device_id === device.id && zone.compressor_pending_action) || zoneForDevice(device.id);
const warning = disabledZone ? `<div class="manual-zone-warning" role="note"><strong>${esc(tr('devices.manualDisabledZoneTitle'))}</strong><p>${esc(tr('devices.manualDisabledZoneWarning', { zone: disabledZone.name }))}</p></div>` : '';
return `<article class="device-card quick-control-card quick-device-control ${device.power ? '' : 'off'} ${disabledZone ? 'manual-zone-blocked' : ''}" data-device-card="${esc(device.id)}">
<div class="device-head">
<div class="device-title"><h3>${esc(device.name)}</h3><span class="badge">Local</span></div>
<button class="power-button ${device.power ? 'on' : ''}" data-action="power" data-device="${esc(device.id)}" aria-label="${esc(tr('devices.power'))}">${powerIconMarkup()}</button>
</div>
${warning}
${manualDeviceProblem(device)}
<div class="temperature-control">
<button data-action="temperature" data-delta="-1" data-device="${esc(device.id)}">${uiIcon('minus')}</button>
<div class="target-temp editable-target" data-temperature-kind="device" data-id="${esc(device.id)}" data-value="${Number(device.target_temperature)}" data-editable="true" tabindex="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)}">${uiIcon('plus')}</button>
</div>
<div class="current-line">${esc(tr('devices.currentTemperature'))}: <strong>${fmtTemp(device.current_temperature)}</strong>${effectiveDeviceOutdoorTemperature(device) == null ? '' : ` · ${esc(tr('devices.outdoor'))} ${fmtTemp(effectiveDeviceOutdoorTemperature(device))}`}</div>
${compressorQueuePanel(managedZone)}
<div class="mode-row quick-control-row quick-control-row-5">${modes.map(mode => `<button class="${device.mode === mode ? 'active' : ''}" data-action="mode" data-value="${mode}" data-device="${esc(device.id)}">${esc(modeLabel(mode))}</button>`).join('')}</div>
<div class="fan-row quick-control-row 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)}">${esc(fanLabel(fan))}</button>`).join('')}</div>
${deviceLouverControls(device)}
<div class="device-toggles quick-control-row quick-control-row-2">
${device.supports_quiet === false ? '' : `<button class="${device.quiet ? 'active' : ''}" data-action="toggle" data-field="quiet" data-device="${esc(device.id)}">${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)}">${esc(tr('devices.turbo'))}</button>`}
</div>
${deviceFeaturePanel(device)}
</article>`;
}
function manualCloudDeviceCard(device) {
const caps = device.capabilities || {};
const modes = caps.mode === false ? [] : ['auto', 'cool', 'dry', 'fan', 'heat'];
const reportedFans = Array.isArray(caps.fan_modes) ? caps.fan_modes.map(Number).filter(Number.isFinite) : [];
const fans = reportedFans.length ? reportedFans : [0, 1, 3, 5];
const tempStep = Number(caps.temperature_step) > 0 ? Number(caps.temperature_step) : 1;
const minTemp = Number.isFinite(Number(caps.min_temperature)) ? Number(caps.min_temperature) : 8;
const maxTemp = Number.isFinite(Number(caps.max_temperature)) ? Number(caps.max_temperature) : 30;
const disabledZone = disabledZoneForDevice(device.id);
const managedZone = app.zones.find(zone => zone.device_id === device.id && zone.compressor_pending_action) || zoneForDevice(device.id);
const warning = disabledZone ? `<div class="manual-zone-warning" role="note"><strong>${esc(tr('devices.manualDisabledZoneTitle'))}</strong><p>${esc(tr('devices.manualDisabledZoneWarning', { zone: disabledZone.name }))}</p></div>` : '';
return `<article class="device-card quick-control-card quick-device-control cloud-manual-device ${device.pending_command ? 'cloud-command-pending' : ''} ${device.power ? '' : 'off'} ${disabledZone ? 'manual-zone-blocked' : ''}" data-device-card="${esc(device.id)}">
<div class="device-head">
<div class="device-title">
<div class="manual-device-title-row"><h3>${esc(device.name)}</h3><span class="badge">GREE Cloud</span></div>
${manualDeviceStatus(device)}
</div>
<button class="power-button ${device.power ? 'on' : ''}" data-action="power" data-device="${esc(device.id)}" aria-label="${esc(tr('devices.power'))}">${powerIconMarkup()}</button>
</div>
${warning}
<div class="temperature-control">
<button data-action="temperature" data-delta="${-tempStep}" data-device="${esc(device.id)}">${uiIcon('minus')}</button>
<div class="target-temp editable-target" data-temperature-kind="device" data-id="${esc(device.id)}" data-value="${Number(device.target_temperature)}" data-temp-step="${tempStep}" data-temp-min="${minTemp}" data-temp-max="${maxTemp}" data-editable="true" tabindex="0" role="button" title="${esc(tr('common.clickTemperatureToEdit'))}">${Number(device.target_temperature).toFixed(1)}<small>°C</small></div>
<button data-action="temperature" data-delta="${tempStep}" data-device="${esc(device.id)}">${uiIcon('plus')}</button>
</div>
<div class="current-line">${esc(tr('devices.currentTemperature'))}: <strong>${fmtTemp(device.current_temperature)}</strong>${effectiveDeviceOutdoorTemperature(device) == null ? '' : ` · ${esc(tr('devices.outdoor'))} ${fmtTemp(effectiveDeviceOutdoorTemperature(device))}`}</div>
${compressorQueuePanel(managedZone)}
${modes.length ? `<div class="mode-row quick-control-row quick-control-row-5">${modes.map(mode => `<button class="${device.mode === mode ? 'active' : ''}" data-action="mode" data-value="${mode}" data-device="${esc(device.id)}">${esc(modeLabel(mode))}</button>`).join('')}</div>` : ''}
${fans.length ? `<div class="fan-row quick-control-row quick-control-row-${Math.min(6, fans.length)}">${fans.map(fan => `<button class="${Number(device.fan_speed) === fan ? 'active' : ''}" data-action="fan" data-value="${fan}" data-device="${esc(device.id)}">${esc(fanLabel(fan))}</button>`).join('')}</div>` : ''}
${deviceLouverControls(device)}
<div class="device-toggles quick-control-row quick-control-row-2">
${device.supports_quiet === false ? '' : `<button class="${device.quiet ? 'active' : ''}" data-action="toggle" data-field="quiet" data-device="${esc(device.id)}">${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)}">${esc(tr('devices.turbo'))}</button>`}
</div>
${deviceFeaturePanel(device)}
</article>`;
}
function manualDeviceCard(device) {
return device.connection_type === 'gree_cloud' ? manualCloudDeviceCard(device) : manualLocalDeviceCard(device);
}
function technicalDeviceCard(device) {
if (device.connection_type === 'gree_cloud') return cloudTechnicalDeviceCard(device);
const protocol = deviceProtocolLabel(device);
const responseTime = deviceResponseTimeLabel(device);
const lastSeen = device.last_seen ? dateTime(device.last_seen) : tr('common.unavailable');
const model = device.model || tr('common.unavailable');
const firmware = device.firmware || tr('common.unavailable');
const cid = device.cid || tr('common.unavailable');
const error = device.last_error ? `<div class="inline-alert error"><span>${esc(tr('devices.lastError'))}</span><strong title="${esc(device.last_error)}">${esc(device.last_error)}</strong></div>` : '';
return `<article class="device-card technical-device-card" data-device-card="${esc(device.id)}">
<div class="technical-device-head">
<div class="device-title"><span class="eyebrow">${esc(tr('devices.technicalUnit'))} · ${esc(deviceTransportLabel(device))}</span><h3>${esc(device.name)}</h3><p><span class="status ${device.online ? 'online' : ''}">${esc(device.connection_type === 'gree_cloud' ? (device.connection_status || 'unknown').replaceAll('_', ' ') : tr(device.online ? 'status.online' : 'status.offline'))}</span> · ${esc(model)}</p></div>
${device.connection_type === 'gree_cloud' ? `<span class="badge">GREE Cloud</span>` : `<button class="device-ping-button" type="button" data-action="open-ping" data-device="${esc(device.id)}" title="${esc(tr('devices.pingOpen'))}"><span>${esc(tr('devices.ping'))}</span><strong>${esc(responseTime)}</strong></button>`}
</div>
<div class="technical-device-grid">
<div><span>${esc(tr('devices.address'))}</span><strong>${device.connection_type === 'gree_cloud' ? 'GREE Cloud' : `${esc(device.ip)}:${esc(device.port)}`}</strong></div>
<div><span>MAC</span><strong>${esc(device.mac)}</strong></div>
<div><span>CID</span><strong>${esc(cid)}</strong></div>
<div><span>${esc(tr('devices.protocol'))}</span><strong>${esc(protocol)}</strong></div>
<div><span>${esc(tr('devices.modelFirmware'))}</span><strong>${esc(model)}</strong><small>${esc(firmware)}</small></div>
<div><span>${esc(tr('devices.lastSeen'))}</span><strong>${esc(lastSeen)}</strong></div>
<div><span>${esc(tr('devices.communicationFailures'))}</span><strong>${esc(device.communication_failures ?? 0)}</strong></div>
${deviceInstallationForDevice(device.id) ? `<div><span>${esc(tr('devices.installation'))}</span><strong>${esc(deviceInstallationForDevice(device.id).name)}</strong><small>${esc(deviceInstallationKindLabel(deviceInstallationForDevice(device.id)))}</small></div>` : ''}
</div>
${error}
<div class="technical-device-actions">
<button type="button" data-action="poll" data-device="${esc(device.id)}">${esc(tr('devices.readStatus'))}</button>
<button type="button" data-action="rename-device" data-device="${esc(device.id)}">${esc(tr('devices.technicalConfig'))}</button>
<button type="button" data-action="energy-config" data-device="${esc(device.id)}">${esc(tr('energy.title'))}</button>
${device.simulated || device.connection_type === 'gree_cloud' ? '' : `<button type="button" data-action="bind" data-device="${esc(device.id)}">${esc(tr('actions.bind'))}</button>`}
<details class="card-overflow"><summary aria-label="${esc(tr('actions.more'))}" title="${esc(tr('actions.more'))}">${uiIcon('more-horizontal')}</summary><div class="card-overflow-menu"><button class="danger" data-action="delete-device" data-device="${esc(device.id)}">${esc(tr('actions.delete'))}</button></div></details>
</div>
</article>`;
}
function cloudTechnicalDeviceCard(device) {
const lastSync = device.last_cloud_sync ? dateTime(device.last_cloud_sync) : tr('devices.noCloudSyncYet');
const lastSeen = device.last_seen ? dateTime(device.last_seen) : tr('devices.noCloudResponseYet');
const cloudId = device.cloud_device_id || device.mac || tr('common.unavailable');
const status = deviceConnectionStatusLabel(device);
const responseTime = deviceResponseTimeLabel(device);
const modelCell = device.model ? `<div><span>${esc(tr('devices.model'))}</span><strong>${esc(device.model)}</strong></div>` : '';
const firmwareCell = device.firmware ? `<div><span>${esc(tr('devices.firmware'))}</span><strong>${esc(device.firmware)}</strong></div>` : '';
const error = device.last_error ? `<div class="inline-alert error"><span>${esc(tr('devices.lastError'))}</span><strong title="${esc(device.last_error)}">${esc(device.last_error)}</strong></div>` : '';
return `<article class="device-card technical-device-card" data-device-card="${esc(device.id)}">
<div class="technical-device-head">
<div class="device-title"><span class="eyebrow">${esc(tr('devices.cloudUnit'))}</span><h3>${esc(device.name)}</h3><p><span class="status ${device.online ? 'online' : ''}">${esc(status)}</span> · GREE Cloud</p></div>
<span class="badge">GREE Cloud</span>
</div>
<div class="technical-device-grid">
<div><span>${esc(tr('devices.connection'))}</span><strong>GREE Cloud</strong></div>
<div><span>${esc(tr('devices.transport'))}</span><strong>MQTT / TLS</strong></div>
<div><span>${esc(tr('devices.cloudDeviceId'))}</span><strong>${esc(cloudId)}</strong></div>
<div><span>MAC</span><strong>${esc(device.mac || cloudId)}</strong></div>
${modelCell}
${firmwareCell}
<div><span>${esc(tr('devices.lastSync'))}</span><strong>${esc(lastSync)}</strong></div>
<div><span>${esc(tr('devices.lastResponse'))}</span><strong>${esc(lastSeen)}</strong><small>${esc(responseTime)}</small></div>
<div><span>${esc(tr('devices.communicationFailures'))}</span><strong>${esc(device.communication_failures ?? 0)}</strong></div>
${deviceInstallationForDevice(device.id) ? `<div><span>${esc(tr('devices.installation'))}</span><strong>${esc(deviceInstallationForDevice(device.id).name)}</strong><small>${esc(deviceInstallationKindLabel(deviceInstallationForDevice(device.id)))}</small></div>` : ''}
</div>
${error}
<div class="technical-device-actions">
<button type="button" data-action="poll" data-device="${esc(device.id)}">${esc(tr('devices.readStatus'))}</button>
<button type="button" data-action="cloud-details" data-device="${esc(device.id)}">${esc(tr('devices.cloudDetails'))}</button>
<button type="button" data-action="cloud-diagnostics" data-device="${esc(device.id)}">${esc(tr('devices.cloudDiagnostics'))}</button>
<details class="card-overflow"><summary aria-label="${esc(tr('actions.more'))}" title="${esc(tr('actions.more'))}">${uiIcon('more-horizontal')}</summary><div class="card-overflow-menu"><button class="danger" data-action="delete-device" data-device="${esc(device.id)}">${esc(tr('actions.delete'))}</button></div></details>
</div>
</article>`;
}
function renderDeviceInstallationsSummary() {
const host = $('#deviceGroupsSummary');
if (!host) return;
const groups = app.deviceGroups || [];
host.innerHTML = groups.length ? groups.map(group => {
const members = (group.device_ids || []).map(id => app.devices.find(device => device.id === id)?.name).filter(Boolean);
const outdoorDevice = app.devices.find(device => device.id === group.outdoor_temperature_device_id);
const outdoorValue = outdoorDevice?.outdoor_temperature == null ? NaN : Number(outdoorDevice.outdoor_temperature);
const energy = installationEnergySnapshot(group);
const sourceTitle = installationEnergySourceLabel(group);
const sourceShort = installationEnergySourceShortLabel(group, energy);
const lastReading = energy?.timestamp ? `${tr('energy.lastReading')}: ${dateTime(energy.timestamp)}` : '';
const outdoor = outdoorDevice
? `${tr('devices.sharedOutdoorMetric')}: ${Number.isFinite(outdoorValue) ? fmtTemp(outdoorValue) : tr('common.unavailable')} · ${outdoorDevice.name}`
: '';
return `<article class="installation-summary-card"><div><span class="eyebrow">${esc(deviceInstallationKindLabel(group))}</span><strong>${esc(group.name)}</strong><small>${esc(members.join(' · ') || '—')}</small></div><div class="installation-energy-metric"><span>${esc(tr('energy.totalMeter'))}</span><b title="${esc(sourceTitle)}">${esc(formatEnergyMeterTotal(energy?.total_kwh))}</b><small>${esc(sourceShort)}${lastReading ? ` · ${esc(lastReading)}` : ''}</small>${outdoor ? `<small>${esc(outdoor)}</small>` : ''}</div><button type="button" class="secondary" data-action="edit-device-group" data-id="${esc(group.id)}">${esc(tr('actions.edit'))}</button></article>`;
}).join('') : '';
}
function renderDevices() {
renderDeviceInstallationsSummary();
const empty = `<div class="empty"><strong>${esc(tr('devices.emptyTitle'))}</strong>${esc(tr('devices.emptyText'))}</div>`;
$('#dashboardDevices').innerHTML = app.devices.length ? app.devices.map(manualDeviceCard).join('') : empty;
$('#deviceList').innerHTML = app.devices.length ? app.devices.map(technicalDeviceCard).join('') : empty;
const dashboardCount = $('#dashboardDeviceCount');
if (dashboardCount) dashboardCount.textContent = String(app.devices.length);
if ($('#pingDialog')?.open) renderPingDialog();
}
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">${uiIcon('check')}</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;
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">${powerIconMarkup()}</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'))}"><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}><b>${esc(tr('zones.temporaryShort'))}</b></button>`;
const disabledManualControlNotice = zone.enabled ? '' : `<div class="manual-zone-warning zone-disabled-manual-note" role="note"><strong>${esc(tr('zones.disabledManualControlTitle'))}</strong><p>${esc(tr('zones.disabledManualControlHint'))}</p></div>`;
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="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>${uiIcon(zone.enabled ? 'check' : 'circle')}</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><details class="card-overflow"><summary aria-label="${esc(tr('actions.more'))}" title="${esc(tr('actions.more'))}">${uiIcon('more-horizontal')}</summary><div class="card-overflow-menu"><button class="danger" data-action="delete-zone" data-id="${esc(zone.id)}">${esc(tr('actions.delete'))}</button></div></details></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>
${disabledManualControlNotice}
<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">${uiIcon('minus')}</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">${uiIcon('plus')}</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>
${device ? deviceLouverControls(device, { disabled: deviceUnavailable }) : ''}
<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>`
: `<span class="group-control-badge ${powerEnabled ? 'group-on-badge' : 'group-off-badge'}">${esc(tr(powerEnabled ? 'groups.gateOn' : '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><details class="card-overflow"><summary aria-label="${esc(tr('actions.more'))}" title="${esc(tr('actions.more'))}">${uiIcon('more-horizontal')}</summary><div class="card-overflow-menu"><button class="danger" data-action="delete-group" data-id="${esc(group.id)}">${esc(tr('actions.delete'))}</button></div></details></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 (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 ${item.flow_id ? 'flow-generated-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('schedules.enabledState') : tr('schedules.disabledState'))}</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(item.flow_id ? tr('flow.generatedReadOnly') : tr('schedules.crossMidnight'))}</small><div class="card-menu">${item.flow_id ? `<button data-action="edit-flow" data-id="${esc(item.flow_id)}">${esc(tr('flow.openEditor'))}</button>` : `<button data-action="edit-schedule" data-id="${esc(item.id)}">${esc(tr('actions.edit'))}</button><details class="card-overflow"><summary aria-label="${esc(tr('actions.more'))}" title="${esc(tr('actions.more'))}">${uiIcon('more-horizontal')}</summary><div class="card-overflow-menu"><button class="danger" data-action="delete-schedule" data-id="${esc(item.id)}">${esc(tr('actions.delete'))}</button></div></details>`}</div></div></article>`;
}).join('') : `<div class="empty"><strong>${esc(tr('schedules.emptyTitle'))}</strong>${esc(tr('schedules.emptyText'))}</div>`;
}
function automationDeviceFunctionSummary(action = {}) {
const parts = [];
if (action.target_temperature != null) parts.push(fmtTemp(action.target_temperature));
if (action.fan_speed != null) parts.push(`${deviceCommandFieldLabel('fan_speed')}: ${fanLabel(action.fan_speed)}`);
if (action.swing_vertical != null) parts.push(`${deviceCommandFieldLabel('swing_vertical')}: ${louverPositionLabel('vertical', action.swing_vertical)}`);
if (action.swing_horizontal != null) parts.push(`${deviceCommandFieldLabel('swing_horizontal')}: ${louverPositionLabel('horizontal', action.swing_horizontal)}`);
['quiet', 'turbo', 'light', 'air', 'xfan', 'health', 'sleep'].forEach(key => {
if (action[key] != null) parts.push(`${deviceCommandFieldLabel(key)}: ${action[key] ? tr('common.on') : tr('common.off')}`);
});
return parts.join(' · ') || '—';
}
function renderAutomations() {
$('#automationList').innerHTML = app.automations.length ? app.automations.map(item => {
const actionZone = item.action_zone_id ? app.zones.find(zone => zone.id === item.action_zone_id) : null;
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 = actionZone?.name || actionGroup?.name || actionDevice?.name || tr('common.noDevice');
const mode = item.action.mode === 'auto' && (actionGroup || actionZone) ? tr('groups.followHouse') : (item.action.mode ? modeLabel(item.action.mode) : '—');
const preset = item.action_zone_preset ? zonePresetLabel(item.action_zone_preset) : (item.action_preset ? zonePresetLabel(item.action_preset) : '—');
const deviceFunction = actionZone || actionGroup ? preset : automationDeviceFunctionSummary(item.action);
const deviceFunctionLabel = actionZone || actionGroup ? tr('groups.profile') : tr('automations.deviceFunction');
return `<article class="list-card ${item.flow_id ? 'flow-generated-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(deviceFunctionLabel)}</small><strong>${esc(deviceFunction)}</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(item.flow_id ? tr('flow.generatedReadOnly') : (actionGroup ? `${tr('groups.group')}: ${targetName}` : `${tr('common.device')}: ${targetName}`))} · ${esc(tr('common.cooldown'))} ${item.cooldown_seconds}s</small><div class="card-menu">${item.flow_id ? `<button data-action="edit-flow" data-id="${esc(item.flow_id)}">${esc(tr('flow.openEditor'))}</button>` : `<button data-action="edit-automation" data-id="${esc(item.id)}">${esc(tr('actions.edit'))}</button><details class="card-overflow"><summary aria-label="${esc(tr('actions.more'))}" title="${esc(tr('actions.more'))}">${uiIcon('more-horizontal')}</summary><div class="card-overflow-menu"><button class="danger" data-action="delete-automation" data-id="${esc(item.id)}">${esc(tr('actions.delete'))}</button></div></details>`}</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 : '';
}
}