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; 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' && ['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(); } async function sendDeviceCommand(id, commandOrFactory) { 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; return api(`/api/devices/${encodeURIComponent(id)}/command`, {method:'POST', body:command}); }); app.deviceControlQueue[id] = request; try { const device = await request; updateDevice(device); renderAll(); } catch (error) { toast(error.message, true); } finally { if (app.deviceControlQueue[id] === request) delete app.deviceControlQueue[id]; } } 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); } } } 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; const value = parseDecimal(target.dataset.value); if (!Number.isFinite(value) || !kind || !id) return; const decimals = kind === 'zone' ? 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 < 8 || parsed > 30) { if (commit && (!Number.isFinite(parsed) || parsed < 8 || parsed > 30)) toast(tr('validation.range', {min:8, max:30}), 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) await sendDeviceCommand(id, {target_temperature:Math.round(clamp(parsed, 8, 30))}); }; 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(); form.id.value = device.id; form.name.value = device.name; form.protocol_version.value = String(device.protocol_version ?? 0); 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; $('#automationGroupHint').hidden = !groupTarget; form.action_device_id.required = !groupTarget; form.action_group_id.required = groupTarget; form.action_target_temperature.disabled = groupTarget; if (groupTarget) form.action_target_temperature.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 = ''; } } // Once ownership has started, the historical start is informational. Editing changes only // target/finish rules; rescheduling requires stopping the session and creating a new one. 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.enabled.checked = item.enabled; updateAutomationTargetFields(); openDialog('automationDialog'); }