function setDashboardTab(tab, { scroll = true } = {}) { const next = ['main', 'thermostats', 'manual'].includes(tab) ? tab : 'main'; app.dashboardTab = next; $$('[data-dashboard-tab]').forEach(button => { const active = button.dataset.dashboardTab === next; button.classList.toggle('active', active); button.setAttribute('aria-selected', String(active)); }); $$('[data-dashboard-panel]').forEach(panel => { const active = panel.dataset.dashboardPanel === next; panel.classList.toggle('active', active); panel.hidden = !active; }); if (scroll && app.currentView === 'dashboard') window.scrollTo({ top: 0, behavior: 'smooth' }); } function showView(name, { push = true, scroll = true } = {}) { if (!canLeaveCurrentView(name)) return false; if (name !== 'flows' && !$('#flowEditor')?.hidden) closeFlowEditor({ push: false, force: true }); app.currentView = name; $$('.view').forEach(view => view.classList.toggle('active', view.dataset.view === name)); $$('.bottom-nav button').forEach(button => button.classList.toggle('active', button.dataset.nav === name || (button.dataset.nav === 'more' && ['devices', 'groups', 'schedules', 'automations', 'simulation', 'night', 'homeassistant', 'settings', 'logs'].includes(name)))); if (push) updateBrowserUrl(pathForView(name)); if (scroll) window.scrollTo({ top: 0, behavior: 'smooth' }); if (name === 'dashboard') setDashboardTab(app.dashboardTab, { scroll: false }); if (name === 'history') { renderHistoryNavigation(); loadHistory(); } if (name === 'logs') loadLogs(); return true; } function showHistoryTab(tab, { push = true, load = true } = {}) { app.historyTab = HISTORY_TABS.includes(tab) ? tab : 'overview'; renderHistoryNavigation(); if (push) updateBrowserUrl(currentHistoryPath()); if (load) loadHistory(); } function confirmManualCommandForDisabledZone(id) { const zone = disabledZoneForDevice(id); if (!zone) return true; return confirm(tr('devices.manualDisabledZoneConfirm', { zone: zone.name })); } function pingSamples(id) { if (!app.pingMonitor.samples[id]) app.pingMonitor.samples[id] = []; return app.pingMonitor.samples[id]; } function addPingSample(id, value, error = '') { const samples = pingSamples(id); samples.push({ at: Date.now(), value: Number.isFinite(Number(value)) ? Math.max(0, Math.round(Number(value))) : null, error: String(error || '') }); if (samples.length > 36) samples.splice(0, samples.length - 36); } function pingSparkline(samples) { const width = 360, height = 92, padX = 8, padY = 9; if (!samples.length) return `
${esc(tr('devices.pingNoSamples'))}
`; const valid = samples.map((sample, index) => ({ index, value: sample.value == null ? NaN : Number(sample.value) })).filter(item => Number.isFinite(item.value)); const values = valid.map(item => item.value); const min = values.length ? Math.min(...values) : 0; const max = values.length ? Math.max(...values) : 10; const ceiling = Math.max(max, 10); const floor = Math.min(min, 0); const range = Math.max(1, ceiling - floor); const lastIndex = Math.max(1, samples.length - 1); const pointFor = (index, value) => { const x = padX + (index / lastIndex) * (width - padX * 2); const y = height - padY - ((value - floor) / range) * (height - padY * 2); return `${x.toFixed(1)},${y.toFixed(1)}`; }; // Keep successful ping runs separate so a packet loss never gets hidden by a line // connecting the samples before and after the failed request. const runs = []; let currentRun = []; samples.forEach((sample, index) => { const value = sample.value == null ? NaN : Number(sample.value); if (Number.isFinite(value)) { currentRun.push(pointFor(index, value)); } else if (currentRun.length) { runs.push(currentRun); currentRun = []; } }); if (currentRun.length) runs.push(currentRun); const lineRuns = runs.map(points => points.length === 1 ? `` : `` ).join(''); // Failed requests are packet-loss / unavailability samples. Draw a red band and // an X at each failed position so even a single loss is immediately visible. const losses = samples.map((sample, index) => ({ sample, index })).filter(({ sample }) => sample.value == null).map(({ index }) => { const x = padX + (index / lastIndex) * (width - padX * 2); const bandWidth = Math.max(4, Math.min(10, (width - padX * 2) / Math.max(samples.length, 12))); const left = Math.max(0, x - bandWidth / 2); const markerY = height - padY - 5; return ``; }).join(''); const guide = [0.25, 0.5, 0.75].map(ratio => ``).join(''); return `${guide}${lineRuns}${losses}`; } function pingStats(samples) { const values = samples.map(sample => sample.value == null ? NaN : Number(sample.value)).filter(Number.isFinite); if (!values.length) return { current: null, average: null, min: null, max: null }; const current = [...samples].reverse().find(sample => sample.value != null && Number.isFinite(Number(sample.value)))?.value ?? null; return { current, average: Math.round(values.reduce((sum, value) => sum + value, 0) / values.length), min: Math.min(...values), max: Math.max(...values), }; } function pingValue(value) { return Number.isFinite(Number(value)) ? `${Math.round(Number(value))} ms` : '—'; } function renderPingDialog() { const dialog = $('#pingDialog'); const select = $('#pingDeviceSelect'); const all = $('#pingAllDevices'); const toggle = $('#pingToggleButton'); const grid = $('#pingLiveGrid'); if (!dialog || !select || !all || !toggle || !grid) return; const localDevices = app.devices.filter(device => device.connection_type !== 'gree_cloud'); if (!app.pingMonitor.targetId || !localDevices.some(device => device.id === app.pingMonitor.targetId)) app.pingMonitor.targetId = localDevices[0]?.id || ''; select.innerHTML = localDevices.map(device => ``).join(''); select.disabled = app.pingMonitor.all; all.checked = app.pingMonitor.all; toggle.textContent = tr(app.pingMonitor.running ? 'devices.pingStop' : 'devices.pingStart'); toggle.classList.toggle('primary', app.pingMonitor.running); toggle.classList.toggle('secondary', !app.pingMonitor.running); const devices = app.pingMonitor.all ? localDevices : localDevices.filter(device => device.id === app.pingMonitor.targetId); grid.innerHTML = devices.length ? devices.map(device => { const samples = pingSamples(device.id); const stats = pingStats(samples); const last = samples.length ? samples[samples.length - 1] : null; const state = last?.error ? `${esc(tr('devices.pingFailed'))}` : last?.value != null ? `${esc(tr('devices.pingResponding'))}` : `${esc(tr(device.online ? 'status.online' : 'status.offline'))}`; return `
${esc(device.name)}${esc(device.ip)}
${state}
${pingSparkline(samples)}
${esc(tr('devices.pingCurrent'))}${esc(pingValue(stats.current))}
${esc(tr('devices.pingAverage'))}${esc(pingValue(stats.average))}
${esc(tr('devices.pingMin'))}${esc(pingValue(stats.min))}
${esc(tr('devices.pingMax'))}${esc(pingValue(stats.max))}
${esc(tr('devices.pingSamples'))}: ${samples.length}${last?.error ? ` · ${esc(last.error)}` : ''}
`; }).join('') : `
${esc(tr('devices.emptyTitle'))}${esc(tr('devices.emptyText'))}
`; } function schedulePingCycle(delay = null) { clearTimeout(app.pingMonitor.timer); app.pingMonitor.timer = null; if (!app.pingMonitor.running || !$('#pingDialog')?.open) return; const interval = delay == null ? (app.pingMonitor.all ? 5000 : 3000) : delay; app.pingMonitor.timer = setTimeout(runPingCycle, interval); } async function runPingCycle() { if (!app.pingMonitor.running || !$('#pingDialog')?.open || app.pingMonitor.inFlight) return; const localDevices = app.devices.filter(device => device.connection_type !== 'gree_cloud'); const targets = app.pingMonitor.all ? [...localDevices] : localDevices.filter(device => device.id === app.pingMonitor.targetId); if (!targets.length) { renderPingDialog(); schedulePingCycle(); return; } app.pingMonitor.inFlight = true; await Promise.allSettled(targets.map(async (device, index) => { // Spread all-unit diagnostics and skip a device while explicit manual control is pending. // This keeps control traffic higher priority than the live diagnostic chart. if (app.pingMonitor.all && index > 0) await new Promise(resolve => setTimeout(resolve, index * 250)); if (!app.pingMonitor.running || !$('#pingDialog')?.open) return; if (app.deviceControlQueue[device.id] || app.deviceTemperatureDrafts[device.id]) return; try { const result = await api(`/api/devices/${encodeURIComponent(device.id)}/probe`, { method: 'POST' }); addPingSample(device.id, result.response_time_ms); } catch (error) { addPingSample(device.id, null, error.message || String(error)); } })); app.pingMonitor.inFlight = false; renderPingDialog(); schedulePingCycle(); } function startPingMonitor() { if (!app.devices.some(device => device.connection_type !== 'gree_cloud')) return; app.pingMonitor.running = true; renderPingDialog(); clearTimeout(app.pingMonitor.timer); app.pingMonitor.timer = null; runPingCycle(); } function stopPingMonitor() { app.pingMonitor.running = false; clearTimeout(app.pingMonitor.timer); app.pingMonitor.timer = null; renderPingDialog(); } function openDevicePing(id) { const localDevices = app.devices.filter(device => device.connection_type !== 'gree_cloud'); app.pingMonitor.targetId = (id && localDevices.some(device => device.id === id)) ? id : (localDevices[0]?.id || ''); app.pingMonitor.all = false; openDialog('pingDialog'); renderPingDialog(); startPingMonitor(); } function applyOptimisticCloudCommand(device, command) { if (!device || device.connection_type !== 'gree_cloud' || !command || typeof command !== 'object') return; const fields = [ 'power', 'mode', 'target_temperature', 'fan_speed', 'swing_vertical', 'swing_horizontal', 'quiet', 'turbo', 'light', 'air', 'xfan', 'health', 'sleep', ]; for (const field of fields) { if (Object.prototype.hasOwnProperty.call(command, field) && command[field] !== undefined) { device[field] = command[field]; } } device.pending_command = true; // A stale transport error should not visually override the command the user has just sent. // The backend/push path will restore it if the publish actually fails. device.last_error = null; renderDevices(); } async function sendDeviceCommand(id, commandOrFactory, { disabledZoneConfirmed = false } = {}) { const disabledZone = disabledZoneForDevice(id); if (disabledZone && !disabledZoneConfirmed && !confirmManualCommandForDisabledZone(id)) return false; const initialDevice = app.devices.find(device => device.id === id); const isCloud = initialDevice?.connection_type === 'gree_cloud'; // Local keeps the historical synchronous UX exactly as before Cloud support was added. if (!isCloud) { const previous = app.deviceControlQueue[id] || Promise.resolve(); const request = previous.catch(() => { }).then(() => { const current = app.devices.find(device => device.id === id); const command = typeof commandOrFactory === 'function' ? commandOrFactory(current) : commandOrFactory; const body = disabledZone ? { ...command, manual_override: true } : command; return api(`/api/devices/${encodeURIComponent(id)}/command`, { method: 'POST', body }); }); app.deviceControlQueue[id] = request; try { const device = await request; updateDevice(device); renderAll(); return true; } catch (error) { toast(error.message, true); try { await loadBootstrap(); } catch (_) { } return false; } finally { if (app.deviceControlQueue[id] === request) delete app.deviceControlQueue[id]; } } // Cloud controls update immediately in the browser. MQTT ACK/push is confirmation, not // a prerequisite for button/temperature feedback. Requests are still serialized per unit. const command = typeof commandOrFactory === 'function' ? commandOrFactory(initialDevice) : commandOrFactory; if (!command || typeof command !== 'object') return false; applyOptimisticCloudCommand(initialDevice, command); const previous = app.deviceControlQueue[id] || Promise.resolve(); const request = previous.catch(() => { }).then(() => { const body = disabledZone ? { ...command, manual_override: true } : command; return api(`/api/devices/${encodeURIComponent(id)}/command`, { method: 'POST', body }); }); app.deviceControlQueue[id] = request; try { const device = await request; updateDevice(device); renderAll(); return true; } catch (error) { toast(error.message, true); try { await loadBootstrap(); } catch (_) { } return false; } finally { if (app.deviceControlQueue[id] === request) delete app.deviceControlQueue[id]; } } function queueDeviceTemperature(id, delta) { const device = app.devices.find(item => item.id === id); if (!device) return; let draft = app.deviceTemperatureDrafts[id]; const disabledZone = disabledZoneForDevice(id); if (disabledZone && !draft?.disabledZoneConfirmed) { if (!confirmManualCommandForDisabledZone(id)) return; draft = { ...(draft || {}), disabledZoneConfirmed: true }; } const caps = device.capabilities || {}; 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 step = Number(caps.temperature_step) > 0 ? Number(caps.temperature_step) : 1; const rawNext = clamp(Number(draft?.target ?? device.target_temperature) + Number(delta), minTemp, maxTemp); const next = Math.round(rawNext / step) * step; device.target_temperature = next; clearTimeout(draft?.timer); draft = { ...(draft || {}), target: next, timer: setTimeout(async () => { const pending = app.deviceTemperatureDrafts[id]; if (!pending) return; delete app.deviceTemperatureDrafts[id]; await sendDeviceCommand(id, { target_temperature: pending.target }, { disabledZoneConfirmed: pending.disabledZoneConfirmed === true }); }, 300), }; app.deviceTemperatureDrafts[id] = draft; renderDevices(); } function enqueueClimateControlTask(task) { const previous = app.climateControlQueue || Promise.resolve(); const request = previous.catch(() => { }).then(task); app.climateControlQueue = request; request.finally(() => { if (app.climateControlQueue === request) app.climateControlQueue = null; }).catch(() => { }); return request; } function updateDevice(device) { const index = app.devices.findIndex(item => item.id === device.id); if (index >= 0) app.devices[index] = device; else app.devices.push(device); } function enqueueZoneControlRequest(id, patch) { const previous = app.zoneControlQueue[id] || Promise.resolve(); const request = previous.catch(() => { }).then(() => api(`/api/zones/${encodeURIComponent(id)}/control`, { method: 'POST', body: patch }) ); app.zoneControlQueue[id] = request; request.finally(() => { if (app.zoneControlQueue[id] === request) delete app.zoneControlQueue[id]; }).catch(() => { }); return request; } async function sendZoneLocalPower(id, power) { const sequence = (app.zoneControlSeq[id] || 0) + 1; app.zoneControlSeq[id] = sequence; try { const zone = await enqueueZoneControlRequest(id, { power }); if (app.zoneControlSeq[id] !== sequence) return; const index = app.zones.findIndex(item => item.id === zone.id); if (index >= 0) app.zones[index] = zone; else app.zones.push(zone); renderAll(); scheduleControlPlanLoad(); toast(tr('zones.localPowerUpdated')); } catch (error) { if (app.zoneControlSeq[id] === sequence) { await loadBootstrap(); toast(error.message, true); } } } async function sendZoneControl(id, patch) { const sequence = (app.zoneControlSeq[id] || 0) + 1; app.zoneControlSeq[id] = sequence; try { const zone = await enqueueZoneControlRequest(id, patch); if (app.zoneControlSeq[id] !== sequence) return; const index = app.zones.findIndex(item => item.id === zone.id); if (index >= 0) app.zones[index] = zone; else app.zones.push(zone); renderSummary(); renderZones(); scheduleControlPlanLoad(); } catch (error) { if (app.zoneControlSeq[id] === sequence) { await loadBootstrap(); toast(error.message, true); } } } async function cancelCompressorTask(id) { try { const result = await api(`/api/zones/${encodeURIComponent(id)}/compressor-queue/cancel`, { method: 'POST' }); if (result.zone) { const index = app.zones.findIndex(item => item.id === result.zone.id); if (index >= 0) app.zones[index] = result.zone; else app.zones.push(result.zone); } renderAll(); scheduleControlPlanLoad(); toast(tr(result.cancelled ? 'zones.queuedCancelled' : 'zones.noQueuedTask')); } catch (error) { toast(error.message, true); } } async function cancelAllCompressorTasks() { try { const result = await api('/api/compressor-queue/cancel-all', { method: 'POST' }); (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); }); renderAll(); scheduleControlPlanLoad(); toast(tr('zones.queuedCancelledAll', { count: Number(result.cancelled || 0) })); } catch (error) { toast(error.message, true); } } function queueZoneTemperature(zone, value, { snapToHalf = true } = {}) { const clamped = clamp(value, 8, 30); const next = snapToHalf ? Math.round(clamped * 2) / 2 : Math.round(clamped * 10) / 10; zone.manual_setpoint = next; zone.setpoint = next; zone.effective_setpoint = next; renderZones(); clearTimeout(app.zoneTemperatureTimers[zone.id]); app.zoneTemperatureTimers[zone.id] = setTimeout(() => sendZoneControl(zone.id, { setpoint: next }), 160); } function beginInlineTemperatureEdit(target) { if (!target || target.querySelector('input') || target.dataset.editable === 'false') return; const kind = target.dataset.temperatureKind; const id = target.dataset.id; if (kind === 'device' && app.deviceTemperatureDrafts[id]) { clearTimeout(app.deviceTemperatureDrafts[id].timer); delete app.deviceTemperatureDrafts[id]; } const value = parseDecimal(target.dataset.value); if (!Number.isFinite(value) || !kind || !id) return; const step = kind === 'device' ? Number(target.dataset.tempStep || 1) : 0.5; const minValue = kind === 'device' ? Number(target.dataset.tempMin || 8) : 8; const maxValue = kind === 'device' ? Number(target.dataset.tempMax || 30) : 30; const decimals = kind === 'zone' || step < 1 ? 1 : 0; const input = document.createElement('input'); input.className = 'inline-temperature-input'; input.type = 'text'; input.inputMode = 'decimal'; input.value = value.toFixed(decimals); input.setAttribute('aria-label', tr('common.targetTemperature')); input.title = tr('common.targetTemperature'); target.classList.add('editing'); target.replaceChildren(input); input.focus(); input.select(); let finished = false; const finish = async commit => { if (finished) return; finished = true; const parsed = parseDecimal(input.value); if (!commit || !Number.isFinite(parsed) || parsed < minValue || parsed > maxValue) { if (commit && (!Number.isFinite(parsed) || parsed < minValue || parsed > maxValue)) toast(tr('validation.range', { min: minValue, max: maxValue }), true); renderAll(); return; } if (kind === 'zone') { const zone = app.zones.find(item => item.id === id); if (zone) queueZoneTemperature(zone, parsed, { snapToHalf: false }); return; } const device = app.devices.find(item => item.id === id); if (device) { const snapped = Math.round(clamp(parsed, minValue, maxValue) / step) * step; await sendDeviceCommand(id, { target_temperature: snapped }); } }; input.addEventListener('keydown', event => { if (event.key === 'Enter') { event.preventDefault(); input.blur(); } if (event.key === 'Escape') { event.preventDefault(); finished = true; renderAll(); } }); input.addEventListener('blur', () => finish(true), { once: true }); } function showDiscoveryNames(ids) { const wanted = new Set(Array.isArray(ids) ? ids : []); const devices = app.devices.filter(device => wanted.has(device.id)); if (!devices.length) return; const list = $('#discoveryNamesList'); list.innerHTML = devices.map(device => ` `).join(''); openDialog('discoveryNamesDialog'); } function populateDeviceRename(id) { const device = app.devices.find(item => item.id === id); if (!device) return; const form = $('#renameDeviceForm'); form.reset(); const isCloud = device.connection_type === 'gree_cloud'; form.id.value = device.id; form.name.value = device.name; form.ip.value = device.ip || ''; form.port.value = String(device.port || 7000); form.protocol_version.value = String(device.protocol_version ?? 0); form.ip.disabled = isCloud; form.port.disabled = isCloud; form.protocol_version.disabled = isCloud; const result = $('#deviceConfigCheckResult'); if (result) { result.hidden = true; result.textContent = ''; result.classList.remove('success', 'error'); } openDialog('renameDeviceDialog'); } async function deleteEntity(type, id, labelKey) { if (!confirm(tr('confirm.delete', { label: tr(labelKey) }))) return; try { await api(`/api/${type}/${encodeURIComponent(id)}`, { method: 'DELETE' }); await loadBootstrap(); toast(tr('common.removed')); } catch (error) { toast(error.message, true); } } function openDialog(id) { fillSelects(); const dialog = document.getElementById(id); if (dialog && !dialog.open) { const form = $('form', dialog); if (form) { clearFormErrors(form); markFormClean(form); } dialog.showModal(); } } function updateZoneHysteresisFields({ syncFromCommon = false } = {}) { const form = $('#zoneForm'); if (!form) return; const separate = !!form.separate_hysteresis?.checked; $('#commonHysteresisField').hidden = separate; $('#separateHysteresisFields').hidden = !separate; if (separate && syncFromCommon && form.dataset.separateHysteresisInitialized !== 'true') { const common = parseDecimal(form.hysteresis.value); if (Number.isFinite(common)) { form.cool_hysteresis.value = String(common); form.heat_hysteresis.value = String(common); } form.dataset.separateHysteresisInitialized = 'true'; } } function updateZoneSensorFields() { const form = $('#zoneForm'); if (!form) return; const external = form.sensor_source.value !== 'device'; $('#externalSensorFields').hidden = !external; form.ha_entity_id.required = external; } function updateSchedulePresetField() { const form = $('#scheduleForm'); if (!form) return; $('#scheduleSetpointField').hidden = form.preset.value !== 'custom'; form.setpoint.required = form.preset.value === 'custom'; } function updateAutomationTargetFields() { const form = $('#automationForm'); if (!form) return; const groupTarget = form.action_target_kind.value === 'group'; $('#automationDeviceTarget').hidden = groupTarget; $('#automationGroupTarget').hidden = !groupTarget; $('#automationGroupPreset').hidden = !groupTarget; $('#automationTargetTemperature').hidden = groupTarget; $('#automationDeviceSwingOptions').hidden = groupTarget; $('#automationGroupHint').hidden = !groupTarget; form.action_device_id.required = !groupTarget; form.action_group_id.required = groupTarget; form.action_target_temperature.disabled = groupTarget; form.action_swing_vertical.disabled = groupTarget; form.action_swing_horizontal.disabled = groupTarget; if (groupTarget) { form.action_target_temperature.value = ''; form.action_swing_vertical.value = ''; form.action_swing_horizontal.value = ''; } [...form.action_mode.options].forEach(option => { if (!['dry', 'fan'].includes(option.value)) return; option.disabled = groupTarget; option.hidden = groupTarget; }); if (groupTarget && ['dry', 'fan'].includes(form.action_mode.value)) form.action_mode.value = ''; } function populateZone(id) { const item = app.zones.find(v => v.id === id); if (!item) return; const form = $('#zoneForm'); form.reset(); form.dataset.editingZoneId = item.id; form.elements.id.value = item.id; fillSelects(); Object.entries(item).forEach(([key, value]) => { if (key !== 'id' && form.elements[key] && value != null && typeof value !== 'object') form.elements[key].value = value; }); form.mode_policy.value = item.inherit_house_mode ? 'house' : (item.mode || 'cool'); if (Number(item.profile_version || 0) === 0) { if ((item.mode || 'cool') === 'heat') form.heat_comfort_setpoint.value = Number(item.setpoint ?? 21); else form.cool_comfort_setpoint.value = Number(item.setpoint ?? 23); } form.external_sensor_weight_percent.value = Math.round(Number(item.external_sensor_weight ?? 0.4) * 100); form.max_sensor_difference.value = Number(item.max_sensor_difference ?? 3); form.min_adjust_seconds.value = Number(item.min_adjust_seconds ?? 120); form.standby_offset_c.value = Number(item.standby_offset_c ?? 2); form.smart_fan.checked = item.smart_fan !== false; form.separate_hysteresis.checked = item.separate_hysteresis === true; const commonHysteresis = Number(item.hysteresis ?? 0.6); form.cool_hysteresis.value = Number(item.separate_hysteresis ? (item.cool_hysteresis ?? commonHysteresis) : commonHysteresis); form.heat_hysteresis.value = Number(item.separate_hysteresis ? (item.heat_hysteresis ?? commonHysteresis) : commonHysteresis); form.dataset.separateHysteresisInitialized = item.separate_hysteresis === true ? 'true' : 'false'; form.enabled.checked = item.enabled; updateZoneSensorFields(); updateZoneHysteresisFields(); openDialog('zoneDialog'); } const dateTimeLocalValue = value => { const date = value instanceof Date ? value : new Date(value); if (!Number.isFinite(date.getTime())) return ''; const pad = number => String(number).padStart(2, '0'); return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`; }; function updateTemporaryThermostatFields() { const form = $('#temporaryThermostatForm'); if (!form) return; const startKind = form.start_kind.value; const kind = form.finish_kind.value; const startDelay = $('#temporaryStartDelayFields'); const startAt = $('#temporaryStartAtFields'); const duration = $('#temporaryDurationFields'); const until = $('#temporaryUntilFields'); const temperature = $('#temporaryTemperatureFields'); const schedule = $('#temporaryScheduleFields'); startDelay.hidden = startKind !== 'delay'; startAt.hidden = startKind !== 'at'; duration.hidden = kind !== 'duration'; until.hidden = kind !== 'until'; temperature.hidden = !['temperature_reached', 'temperature_stable'].includes(kind); schedule.hidden = kind !== 'schedule_boundary'; $('#temporaryHoldField').hidden = kind !== 'temperature_stable'; form.start_delay_minutes.required = startKind === 'delay'; form.start_at.required = startKind === 'at'; form.duration_minutes.required = kind === 'duration'; form.until.required = kind === 'until'; form.hold_minutes.required = kind === 'temperature_stable'; } function populateTemporaryThermostat(id) { const zone = app.zones.find(item => item.id === id); if (!zone) return; const form = $('#temporaryThermostatForm'); form.reset(); form.elements.zone_id.value = zone.id; form.target_temperature.value = Number(zone.manual_setpoint ?? zone.effective_setpoint ?? zone.setpoint ?? 23).toFixed(1); form.start_kind.value = 'now'; form.start_delay_minutes.value = '30'; form.start_at.value = dateTimeLocalValue(new Date(Date.now() + 60 * 60 * 1000)); form.until.value = dateTimeLocalValue(new Date(Date.now() + 2 * 60 * 60 * 1000)); const session = zone.temporary_quick_thermostat; const activeSession = !!session?.activated_at; form.dataset.activeSession = activeSession ? 'true' : 'false'; if (session) { form.start_kind.value = session.start_kind || (new Date(session.started_at).getTime() > Date.now() ? 'at' : 'now'); if (form.start_kind.value === 'delay' && session.started_at) { form.start_delay_minutes.value = Math.max(1, Math.ceil((new Date(session.started_at).getTime() - Date.now()) / 60000)); } if (form.start_kind.value === 'at' && session.started_at) form.start_at.value = dateTimeLocalValue(session.started_at); form.finish_kind.value = session.finish_kind || 'duration'; if (session.temperature_target != null) form.target_temperature.value = Number(session.temperature_target).toFixed(1); if (session.temperature_operator) form.temperature_operator.value = session.temperature_operator; form.tolerance_c.value = Number(session.tolerance_c ?? 0.3).toFixed(1); if (session.hold_seconds) form.hold_minutes.value = Math.max(1, Math.round(Number(session.hold_seconds) / 60)); if (session.finish_kind === 'duration') { if (session.duration_seconds) { form.duration_minutes.value = Math.max(1, Math.round(Number(session.duration_seconds) / 60)); } else if (session.expires_at) { const base = new Date(session.activated_at || session.started_at || Date.now()).getTime(); form.duration_minutes.value = Math.max(1, Math.round((new Date(session.expires_at).getTime() - base) / 60000)); } } if (session.expires_at && session.finish_kind !== 'duration') form.until.value = dateTimeLocalValue(session.expires_at); if (session.safety_duration_seconds) { form.max_duration_minutes.value = Math.max(1, Math.round(Number(session.safety_duration_seconds) / 60)); } else if (session.safety_expires_at) { const base = new Date(session.activated_at || session.started_at || Date.now()).getTime(); form.max_duration_minutes.value = Math.max(1, Math.round((new Date(session.safety_expires_at).getTime() - base) / 60000)); } else if (['temperature_reached', 'temperature_stable'].includes(session.finish_kind)) { form.max_duration_minutes.value = ''; } } form.start_kind.disabled = activeSession; form.start_delay_minutes.disabled = activeSession; form.start_at.disabled = activeSession; $$('[data-temporary-start-delay]').forEach(button => { button.disabled = activeSession; }); updateTemporaryThermostatFields(); const active = $('#temporaryThermostatActive'); active.hidden = !session; $('#temporaryThermostatStop').dataset.id = zone.id; $('#temporaryThermostatSubmit').textContent = tr(session ? 'zones.temporaryUpdate' : 'zones.temporaryStart'); updateTemporaryThermostatCountdowns(); openDialog('temporaryThermostatDialog'); updateTemporaryThermostatCountdowns(); } function populateSchedule(id) { const item = app.schedules.find(v => v.id === id); if (!item) return; const form = $('#scheduleForm'); form.reset(); fillSelects(); ['id', 'name', 'zone_id', 'start_time', 'end_time', 'setpoint', 'preset'].forEach(key => { if (form.elements[key] && item[key] != null) form.elements[key].value = item[key]; }); form.enabled.checked = item.enabled; $$('[name=weekday]', form).forEach(input => input.checked = item.weekdays.includes(Number(input.value))); updateSchedulePresetField(); openDialog('scheduleDialog'); } function populateAutomation(id) { const item = app.automations.find(v => v.id === id); if (!item) return; const form = $('#automationForm'); form.reset(); fillSelects(); ['id', 'name', 'trigger_kind', 'trigger_device_id', 'threshold', 'at_time', 'action_device_id', 'cooldown_seconds'].forEach(key => { if (form.elements[key] && item[key] != null) form.elements[key].value = item[key]; }); const groupTarget = !!item.action_group_id; form.action_target_kind.value = groupTarget ? 'group' : 'device'; if (groupTarget) form.action_group_id.value = item.action_group_id; form.action_power.value = item.action.power == null ? '' : String(item.action.power); form.action_mode.value = item.action.mode || ''; form.action_preset.value = item.action_preset || ''; form.action_target_temperature.value = item.action.target_temperature ?? ''; form.action_swing_vertical.value = item.action.swing_vertical == null ? '' : String(item.action.swing_vertical); form.action_swing_horizontal.value = item.action.swing_horizontal == null ? '' : String(item.action.swing_horizontal); form.enabled.checked = item.enabled; updateAutomationTargetFields(); openDialog('automationDialog'); } async function openDeviceDetails(id) { const device = app.devices.find(item => item.id === id); if (!device) return; const form = $('#deviceDetailsForm'); if (!form) return; form.reset(); form.elements.id.value = device.id; form.name.value = device.name || ''; form.energy_source.value = device.energy_source || 'auto'; const title = $('#deviceDetailsTitle'); if (title) title.textContent = device.connection_type === 'gree_cloud' ? tr('devices.cloudDetails') : tr('energy.title'); const meta = $('#deviceDetailsMeta'); if (meta) meta.innerHTML = device.connection_type === 'gree_cloud' ? `
${esc(tr('devices.connection'))}GREE Cloud
${esc(tr('devices.transport'))}MQTT / TLS
${esc(tr('devices.cloudDeviceId'))}${esc(device.cloud_device_id || device.mac || '—')}
MAC${esc(device.mac || '—')}
` : `
${esc(tr('devices.connection'))}Local
${esc(tr('devices.address'))}${esc(device.ip || '—')}:${esc(device.port || 7000)}
MAC${esc(device.mac || '—')}
`; const installation = deviceInstallationForDevice(device.id); const groupNote = $('#deviceEnergyGroupNote'); const groupOwnsEnergy = !!installation && (!!installation.energy_device_id || !!installation.ha_energy_entity_id); if (groupNote) { groupNote.hidden = !groupOwnsEnergy; groupNote.innerHTML = groupOwnsEnergy ? `${esc(deviceInstallationKindLabel(installation))}${esc(tr('devices.groupedEnergyNote', { name: installation.name, source: installationEnergySourceLabel(installation) }))}` : ''; } const source = form.energy_source; if (groupOwnsEnergy) source.value = installation.energy_source || 'auto'; source.disabled = groupOwnsEnergy; const cloudOption = [...source.options].find(option => option.value === 'gree_cloud'); if (cloudOption) cloudOption.disabled = device.capabilities?.energy_meter !== true; const sensorSelect = form.ha_energy_entity_id; sensorSelect.disabled = groupOwnsEnergy; sensorSelect.innerHTML = ''; try { const response = await api('/api/integrations/home-assistant/energy-sensors'); for (const sensor of (response.sensors || [])) { const option = document.createElement('option'); option.value = sensor.entity_id; option.textContent = `${sensor.name || sensor.entity_id} · ${sensor.unit || ''}`; option.dataset.unit = sensor.unit || ''; option.dataset.deviceClass = sensor.device_class || ''; option.dataset.stateClass = sensor.state_class || ''; sensorSelect.append(option); } } catch (_) { } const energyEntity = groupOwnsEnergy ? installation.ha_energy_entity_id : device.ha_energy_entity_id; const energyUnit = groupOwnsEnergy ? installation.ha_energy_unit : device.ha_energy_unit; const energyDeviceClass = groupOwnsEnergy ? installation.ha_energy_device_class : device.ha_energy_device_class; const energyStateClass = groupOwnsEnergy ? installation.ha_energy_state_class : device.ha_energy_state_class; if (energyEntity && ![...sensorSelect.options].some(option => option.value === energyEntity)) { const option = document.createElement('option'); option.value = energyEntity; option.textContent = energyEntity; option.dataset.unit = energyUnit || ''; option.dataset.deviceClass = energyDeviceClass || ''; option.dataset.stateClass = energyStateClass || ''; sensorSelect.append(option); } sensorSelect.value = energyEntity || ''; updateDeviceEnergySensorMeta(); openDialog('deviceDetailsDialog'); } function renderDeviceGroupsDialogList() { const host = $('#deviceGroupsList'); 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); return `
`; }).join('') : `
${esc(tr('devices.newInstallation'))}${esc(tr('devices.installationsHint'))}
`; } function selectedDeviceGroupIds() { return $$('#deviceGroupDeviceChoices input[type="checkbox"]:checked').map(input => input.value); } function syncDeviceGroupMemberSelects() { const form = $('#deviceGroupForm'); if (!form) return; const ids = selectedDeviceGroupIds(); const energy = form.energy_device_id; const outdoor = form.outdoor_temperature_device_id; const oldEnergy = energy.value, oldOutdoor = outdoor.value; energy.innerHTML = '' + ids.map(id => { const device = app.devices.find(item => item.id === id); if (!device) return ''; const supported = device.connection_type === 'gree_cloud' && device.capabilities?.energy_meter === true; return ``; }).join(''); outdoor.innerHTML = `` + ids.map(id => { const device = app.devices.find(item => item.id === id); return device ? `` : ''; }).join(''); if ([...energy.options].some(option => option.value === oldEnergy && !option.disabled)) energy.value = oldEnergy; if ([...outdoor.options].some(option => option.value === oldOutdoor)) outdoor.value = oldOutdoor; } function renderDeviceGroupDeviceChoices(selectedIds = []) { const form = $('#deviceGroupForm'); const host = $('#deviceGroupDeviceChoices'); if (!form || !host) return; const currentId = form.elements.id.value; const occupied = new Map(); for (const group of (app.deviceGroups || [])) { if (group.id === currentId) continue; for (const id of (group.device_ids || [])) occupied.set(id, group.name); } host.innerHTML = app.devices.map(device => { const owner = occupied.get(device.id); const checked = selectedIds.includes(device.id); return ``; }).join(''); syncDeviceGroupMemberSelects(); } async function loadDeviceGroupEnergySensors(group = null) { const form = $('#deviceGroupForm'); if (!form) return; const select = form.ha_energy_entity_id; select.innerHTML = ''; try { const response = await api('/api/integrations/home-assistant/energy-sensors'); for (const sensor of (response.sensors || [])) { const option = document.createElement('option'); option.value = sensor.entity_id; option.textContent = `${sensor.name || sensor.entity_id} · ${sensor.unit || ''}`; option.dataset.unit = sensor.unit || ''; option.dataset.deviceClass = sensor.device_class || ''; option.dataset.stateClass = sensor.state_class || ''; select.append(option); } } catch (_) { } if (group?.ha_energy_entity_id && ![...select.options].some(option => option.value === group.ha_energy_entity_id)) { const option = document.createElement('option'); option.value = group.ha_energy_entity_id; option.textContent = group.ha_energy_entity_id; option.dataset.unit = group.ha_energy_unit || ''; option.dataset.deviceClass = group.ha_energy_device_class || ''; option.dataset.stateClass = group.ha_energy_state_class || ''; select.append(option); } select.value = group?.ha_energy_entity_id || ''; updateDeviceGroupEnergySensorMeta(); } async function populateDeviceGroupForm(group = null) { const form = $('#deviceGroupForm'); if (!form) return; form.reset(); form.elements.id.value = group?.id || ''; form.name.value = group?.name || ''; form.kind.value = group?.kind || 'split'; form.energy_source.value = group?.energy_source || 'auto'; renderDeviceGroupDeviceChoices(group?.device_ids || []); syncDeviceGroupMemberSelects(); form.energy_device_id.value = group?.energy_device_id || ''; form.outdoor_temperature_device_id.value = group?.outdoor_temperature_device_id || ''; await loadDeviceGroupEnergySensors(group); } async function openDeviceGroupsDialog(groupId = '') { renderDeviceGroupsDialogList(); const group = groupId ? (app.deviceGroups || []).find(item => item.id === groupId) : null; await populateDeviceGroupForm(group || null); openDialog('deviceGroupsDialog'); } function updateDeviceGroupEnergySensorMeta() { const select = $('#deviceGroupForm')?.ha_energy_entity_id; const meta = $('#deviceGroupEnergySensorMeta'); if (!select || !meta) return; const option = select.selectedOptions?.[0]; meta.textContent = option?.value ? `${option.value} · ${option.dataset.deviceClass || 'energy'} · ${option.dataset.stateClass || 'total'} · ${option.dataset.unit || 'kWh'}` : tr('energy.noHaSensor'); } function updateDeviceEnergySensorMeta() { const select = $('#deviceDetailsForm')?.ha_energy_entity_id; const meta = $('#deviceEnergySensorMeta'); if (!select || !meta) return; const option = select.selectedOptions?.[0]; meta.textContent = option?.value ? `${option.value} · ${option.dataset.deviceClass || 'energy'} · ${option.dataset.stateClass || 'total'} · ${option.dataset.unit || 'kWh'}` : tr('energy.noHaSensor'); } async function openCloudDiagnostics(id) { const dialog = $('#cloudDiagnosticsDialog'); const payload = $('#cloudDiagnosticsPayload'); if (!dialog || !payload) return; dialog.dataset.deviceId = id; payload.textContent = tr('common.loading'); openDialog('cloudDiagnosticsDialog'); try { const diagnostics = await api(`/api/devices/${encodeURIComponent(id)}/cloud-diagnostics`); payload.textContent = JSON.stringify(diagnostics, null, 2); } catch (error) { payload.textContent = error.message || String(error); } }