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 `
${esc(tr('devices.features'))}${esc(tr('devices.noExtraFeatures'))}
`; return `
${esc(tr('devices.features'))}
${features.map(([field, , label]) => ``).join('')}
`; } 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 ? `${esc(device.last_error)}` : ''; const pending = device.pending_command ? `${esc(tr('common.pending'))}` : ''; return `

${esc(label)}${detail}${pending}

`; } // 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 `
${esc(tr('devices.manualDeviceDisabled'))}
`; } if (!device.online) { return `
${esc(tr('devices.manualNoCommunication'))}${device.last_error ? `${esc(device.last_error)}` : ''}
`; } if (Number(device.communication_failures || 0) > 0 || device.last_error) { return `
${esc(tr('devices.manualCommunicationProblem'))}${device.last_error ? `${esc(device.last_error)}` : ''}
`; } 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(``); if (caps.horizontal_swing !== false) controls.push(``); return controls.length ? `
${controls.join('')}
` : ''; } 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 ? `
${esc(tr('devices.manualDisabledZoneTitle'))}

${esc(tr('devices.manualDisabledZoneWarning', { zone: disabledZone.name }))}

` : ''; return `

${esc(device.name)}

Local
${warning} ${manualDeviceProblem(device)}
${Number(device.target_temperature).toFixed(1)}°C
${esc(tr('devices.currentTemperature'))}: ${fmtTemp(device.current_temperature)}${effectiveDeviceOutdoorTemperature(device) == null ? '' : ` · ${esc(tr('devices.outdoor'))} ${fmtTemp(effectiveDeviceOutdoorTemperature(device))}`}
${compressorQueuePanel(managedZone)}
${modes.map(mode => ``).join('')}
${fans.map(fan => ``).join('')}
${deviceLouverControls(device)}
${device.supports_quiet === false ? '' : ``} ${device.supports_turbo === false ? '' : ``}
${deviceFeaturePanel(device)}
`; } 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 ? `
${esc(tr('devices.manualDisabledZoneTitle'))}

${esc(tr('devices.manualDisabledZoneWarning', { zone: disabledZone.name }))}

` : ''; return `

${esc(device.name)}

GREE Cloud
${manualDeviceStatus(device)}
${warning}
${Number(device.target_temperature).toFixed(1)}°C
${esc(tr('devices.currentTemperature'))}: ${fmtTemp(device.current_temperature)}${effectiveDeviceOutdoorTemperature(device) == null ? '' : ` · ${esc(tr('devices.outdoor'))} ${fmtTemp(effectiveDeviceOutdoorTemperature(device))}`}
${compressorQueuePanel(managedZone)} ${modes.length ? `
${modes.map(mode => ``).join('')}
` : ''} ${fans.length ? `
${fans.map(fan => ``).join('')}
` : ''} ${deviceLouverControls(device)}
${device.supports_quiet === false ? '' : ``} ${device.supports_turbo === false ? '' : ``}
${deviceFeaturePanel(device)}
`; } 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 ? `
${esc(tr('devices.lastError'))}${esc(device.last_error)}
` : ''; return `
${esc(tr('devices.technicalUnit'))} · ${esc(deviceTransportLabel(device))}

${esc(device.name)}

${esc(device.connection_type === 'gree_cloud' ? (device.connection_status || 'unknown').replaceAll('_', ' ') : tr(device.online ? 'status.online' : 'status.offline'))} · ${esc(model)}

${device.connection_type === 'gree_cloud' ? `GREE Cloud` : ``}
${esc(tr('devices.address'))}${device.connection_type === 'gree_cloud' ? 'GREE Cloud' : `${esc(device.ip)}:${esc(device.port)}`}
MAC${esc(device.mac)}
CID${esc(cid)}
${esc(tr('devices.protocol'))}${esc(protocol)}
${esc(tr('devices.modelFirmware'))}${esc(model)}${esc(firmware)}
${esc(tr('devices.lastSeen'))}${esc(lastSeen)}
${esc(tr('devices.communicationFailures'))}${esc(device.communication_failures ?? 0)}
${deviceInstallationForDevice(device.id) ? `
${esc(tr('devices.installation'))}${esc(deviceInstallationForDevice(device.id).name)}${esc(deviceInstallationKindLabel(deviceInstallationForDevice(device.id)))}
` : ''}
${error}
${device.simulated || device.connection_type === 'gree_cloud' ? '' : ``}
${uiIcon('more-horizontal')}
`; } 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 ? `
${esc(tr('devices.model'))}${esc(device.model)}
` : ''; const firmwareCell = device.firmware ? `
${esc(tr('devices.firmware'))}${esc(device.firmware)}
` : ''; const error = device.last_error ? `
${esc(tr('devices.lastError'))}${esc(device.last_error)}
` : ''; return `
${esc(tr('devices.cloudUnit'))}

${esc(device.name)}

${esc(status)} · GREE Cloud

GREE Cloud
${esc(tr('devices.connection'))}GREE Cloud
${esc(tr('devices.transport'))}MQTT / TLS
${esc(tr('devices.cloudDeviceId'))}${esc(cloudId)}
MAC${esc(device.mac || cloudId)}
${modelCell} ${firmwareCell}
${esc(tr('devices.lastSync'))}${esc(lastSync)}
${esc(tr('devices.lastResponse'))}${esc(lastSeen)}${esc(responseTime)}
${esc(tr('devices.communicationFailures'))}${esc(device.communication_failures ?? 0)}
${deviceInstallationForDevice(device.id) ? `
${esc(tr('devices.installation'))}${esc(deviceInstallationForDevice(device.id).name)}${esc(deviceInstallationKindLabel(deviceInstallationForDevice(device.id)))}
` : ''}
${error}
${uiIcon('more-horizontal')}
`; } 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 `
${esc(deviceInstallationKindLabel(group))}${esc(group.name)}${esc(members.join(' · ') || '—')}
${esc(tr('energy.totalMeter'))}${esc(formatEnergyMeterTotal(energy?.total_kwh))}${esc(sourceShort)}${lastReading ? ` · ${esc(lastReading)}` : ''}${outdoor ? `${esc(outdoor)}` : ''}
`; }).join('') : ''; } function renderDevices() { renderDeviceInstallationsSummary(); const empty = `
${esc(tr('devices.emptyTitle'))}${esc(tr('devices.emptyText'))}
`; $('#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 `
${esc(tr('zones.compressorQueueTitle'))}${esc(compressorPendingDescription(zone))}${esc(tr('zones.compressorQueueUntil', { time: when }))}
`; } 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 = `
${esc(tr('zones.queueEmptyTitle'))}

${esc(tr('zones.queueEmptyText'))}

`; 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 `
${index + 1}
${esc(zone.name)}

${esc(device?.name || tr('common.noDevice'))}

${esc(tr('zones.queueReasonProtection'))}

${esc(compressorPendingDescription(zone))}

${esc(tr('zones.queueSource', { source }))} ${esc(tr('zones.compressorQueueUntil', { time: exact }))} ${esc(compressorQueueRemainingText(until))}
`; }).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 ? `
${esc(tr('zones.manualDeviceControl'))}

${esc(tr('zones.manualDeviceDescription'))}

` : localThermostatOverride && !temporarySession ? `
${esc(tr('zones.localThermostatControl'))}${localThermostatPower === false && localThermostatResumeAt ? `

${esc(tr(pausedGroup ? 'zones.groupOffDescriptionTimed' : 'zones.localThermostatOffDescriptionTimed', { group: pausedGroup?.name || '', time: formatCountdown(localResumeSeconds(localThermostatResumeAt)) }))}

` : `

${esc(tr(localThermostatPower ? 'zones.localThermostatOnDescription' : 'zones.localThermostatOffDescription'))}

`}
` : ''; const localPowerDisabled = !device || device.enabled === false ? ' disabled' : ''; const localRequestedOn = localThermostatPower === true ? true : (localThermostatPower === false ? false : !!device?.power); const localThermostatPowerControl = ``; const temporaryThermostatControl = temporarySession ? `` : ``; const disabledManualControlNotice = zone.enabled ? '' : `
${esc(tr('zones.disabledManualControlTitle'))}

${esc(tr('zones.disabledManualControlHint'))}

`; 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 `
${esc(tr('zones.configuration'))}

${esc(zone.name)}

${esc(device?.name || tr('common.noDevice'))} · ${esc(tr('groups.group'))}: ${esc(groupText)}

${esc(tr('zones.currentStatus'))}
${fmtTemp(roomTemperature)}${Number.isFinite(target) ? `${target.toFixed(1)}°C` : '—'}
${esc(zoneRuntimeStatusLabel(zone, effectiveMode, device))}${esc(houseModeLabel(effectiveMode))} · ${esc(zonePresetLabel(displayPreset))}${esc(override)}${esc(tr('zones.controlOwner'))}: ${esc(zoneControlOwnerLabel(zone))}${zoneControlOwnerMeta(zone) ? ` · ${esc(zoneControlOwnerMeta(zone))}` : ''}
${esc(tr('zones.modePolicy'))}${esc(policy)}
${esc(tr('zones.source'))}${esc(zoneStrategyLabel(zone))}
${esc(tr('zones.hysteresis'))}${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`}
${esc(tr('zones.smartFan'))}${esc(tr(zone.smart_fan === false ? 'common.off' : 'common.on'))}
${esc(sensorDetails)}
${compressorQueuePanel(zone)} ${manualTakeover}
`; } const runtimeStatus = zoneRuntimeStatusLabel(zone, effectiveMode, device); return `

${esc(zone.name)}

${esc(device?.name || tr('common.noDevice'))} · ${esc(zonePresetLabel(displayPreset))}

${localThermostatPowerControl}${temporaryThermostatControl}
${disabledManualControlNotice}
${esc(tr('zones.measurement'))}${fmtTemp(roomTemperature)}
${Number.isFinite(target) ? target.toFixed(1) : '--'}°C
${esc(tr('zones.deviceTarget'))}${fmtTemp(zone.device_setpoint)}
${['auto', 'comfort', 'sleep', 'away'].map(preset => ``).join('')}
${device ? deviceLouverControls(device, { disabled: deviceUnavailable }) : ''}
${esc(runtimeStatus)}${controlGroup ? ` ${esc(controlGroup.name)}` : ''}${esc(override)}
${esc(tr('zones.controlOwner'))}: ${esc(zoneControlOwnerLabel(zone))}${esc(zoneControlOwnerMeta(zone))}
${compressorQueuePanel(zone)} ${manualTakeover}
`; } function renderZones() { const empty = `
${esc(tr('zones.emptyTitle'))}${esc(tr('zones.emptyText'))}
`; $('#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 ? `${esc(tr('groups.activeControl'))}` : `${esc(tr(powerEnabled ? 'groups.gateOn' : 'groups.gateOff'))}`; return `
${esc(tr('groups.group'))}

${esc(group.name)}

${esc(memberText)}

${groupStatusBadge}
${detailed ? `
${esc(tr('groups.mode'))}${esc(modeLabelForGroup(state.mode))}
${esc(tr('groups.profile'))}${esc(presetLabelForGroup(state.preset))}
${esc(tr('groups.members'))}${state.zones.length}
` : ''}
${esc(tr('groups.controlToggle'))}
${esc(tr('groups.mode'))}
${['house', 'heat', 'cool'].map(mode => ``).join('')}
${esc(tr('groups.profile'))}
${['auto', 'comfort', 'sleep', 'away'].map(preset => ``).join('')}
${customDraftPending ? `${esc(tr('groups.customDraftPending'))}` : ''}
${detailed ? `` : ''}
`; } function renderGroups() { const empty = `
${esc(tr('groups.emptyTitle'))}${esc(tr('groups.emptyText'))}
`; 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 ``; }).join('') : `
${esc(tr('groups.noZones'))}
`; } 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 `

${esc(item.name)}

${esc(zone?.name || tr('common.noZone'))} · ${esc(days)}

${esc(item.enabled ? tr('schedules.enabledState') : tr('schedules.disabledState'))}
${esc(tr('common.from'))}${esc(item.start_time)}
${esc(tr('common.to'))}${esc(item.end_time)}
${esc(tr('schedules.profile'))}${esc(item.preset === 'custom' ? fmtTemp(item.setpoint) : zonePresetLabel(item.preset))}
`; }).join('') : `
${esc(tr('schedules.emptyTitle'))}${esc(tr('schedules.emptyText'))}
`; } 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 `

${esc(item.name)}

${esc(tr('automations.triggerSummary', { trigger: automationTriggerLabel(item), device: targetName }))}

${esc(item.enabled ? tr('common.active') : tr('common.disabled'))}
${esc(tr('common.power'))}${item.action.power == null ? '—' : item.action.power ? tr('common.on') : tr('common.off')}
${esc(tr('common.mode'))}${esc(mode)}
${esc(deviceFunctionLabel)}${esc(deviceFunction)}
${esc(tr('automations.last'))}${item.last_fired_at ? new Date(item.last_fired_at).toLocaleTimeString(locale(), { hour: '2-digit', minute: '2-digit' }) : '—'}
`; }).join('') : `
${esc(tr('automations.emptyTitle'))}${esc(tr('automations.emptyText'))}
`; } function fillSelects() { const deviceOptions = app.devices.map(d => ``).join(''); const zoneOptions = app.zones.map(z => ``).join(''); const groupOptions = app.groups.map(g => ``).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 = `` + sources.map(z => ``).join(''); copyZone.value = [...copyZone.options].some(option => option.value === current) ? current : ''; } }