'use strict'; const APP_BASE = (() => { const src = document.currentScript?.src || ''; try { const path = new URL(src, location.href).pathname; return path.endsWith('/app.js') ? path.slice(0, -7).replace(/\/$/, '') : ''; } catch (_) { return ''; } })(); const withBase = path => `${APP_BASE}${path.startsWith('/') ? path : `/${path}`}`; const parseDecimal = value => { const normalized = String(value ?? '').trim().replace(',', '.'); const parsed = Number(normalized); return Number.isFinite(parsed) ? parsed : NaN; }; const getCookie = name => { const row = document.cookie.split('; ').find(item => item.startsWith(`${name}=`)); return row ? decodeURIComponent(row.split('=').slice(1).join('=')) : ''; }; const setCookie = (name, value) => { document.cookie = `${name}=${encodeURIComponent(value)}; Max-Age=31536000; Path=${APP_BASE || '/'}; SameSite=Lax`; }; const DEFAULT_LANGUAGE = 'en'; const preferredLanguage = getCookie('gree_controller_language') || DEFAULT_LANGUAGE; const preferredTheme = ['system', 'light', 'dark'].includes(getCookie('gree_controller_theme')) ? getCookie('gree_controller_theme') : 'system'; const app = { devices: [], zones: [], groups: [], schedules: [], automations: [], accessTokens: [], settings: null, system: {}, outdoorTemperature: null, token: localStorage.getItem('gree_controller_token') || '', ws: null, wsTimer: null, currentView: 'dashboard', loading: false, language: preferredLanguage, theme: preferredTheme, connectionStatus: 'connecting', languages: [], translations: {}, locales: {}, historyTab: 'overview', historyData: {zones:[], devices:[], sensors:[]}, historyCounts: {}, historyZone: 'all', historyDevice: 'all', historySensor: 'all', historyLoading: false, customChartSeries: [], savedCharts: [], zoneControlSeq: {}, zoneTemperatureTimers: {}, controlPlan: null, controlPlanTimer: null, debugLines: [], debugBacklogLoaded: false, sensorAliases: {}, simulationScope: 'units', simulationTarget: 'all', standaloneSimulation: false, }; try { app.savedCharts = JSON.parse(localStorage.getItem('gree_controller_saved_charts') || '[]'); } catch (_) { app.savedCharts = []; } const $ = (selector, root = document) => root.querySelector(selector); const $$ = (selector, root = document) => [...root.querySelectorAll(selector)]; const clamp = (value, min, max) => Math.min(max, Math.max(min, value)); const esc = value => String(value ?? '').replace(/[&<>'"]/g, char => ({'&':'&','<':'<','>':'>',"'":''','"':'"'}[char])); const locale = () => app.locales[app.language] || app.locales[DEFAULT_LANGUAGE] || 'en-GB'; const tr = (key, params = {}) => { const template = app.translations[app.language]?.[key] ?? app.translations[DEFAULT_LANGUAGE]?.[key] ?? key; return String(template).replace(/\{([a-zA-Z0-9_]+)\}/g, (_, name) => params[name] ?? `{${name}}`); }; const fmtTemp = value => value !== null && value !== undefined && value !== '' && Number.isFinite(Number(value)) ? `${Number(value).toFixed(1)}°C` : '--'; const historyNumber = value => value === null || value === undefined || value === '' ? NaN : Number(value); const modeLabel = mode => tr(`mode.${mode}`) === `mode.${mode}` ? mode : tr(`mode.${mode}`); const houseModeLabel = mode => mode === 'off' ? tr('house.noControl') : modeLabel(mode); const fanLabel = value => ({0:'fan.auto',1:'fan.low',2:'fan.mediumLow',3:'fan.medium',4:'fan.mediumHigh',5:'fan.high'}[value] ? tr({0:'fan.auto',1:'fan.low',2:'fan.mediumLow',3:'fan.medium',4:'fan.mediumHigh',5:'fan.high'}[value]) : value); const dateTime = value => value ? new Intl.DateTimeFormat(locale(), {dateStyle:'short', timeStyle:'short'}).format(new Date(value)) : '—'; const haSensorLabel = entity => app.sensorAliases?.[entity] || app.settings?.home_assistant?.sensor_aliases?.[entity] || entity; function updateConnectionIndicator(status) { app.connectionStatus = status || 'connecting'; const node = $('#connectionLabel'); if (!node) return; const label = tr(`status.${app.connectionStatus}`); node.className = `connection-dot ${app.connectionStatus}`; node.setAttribute('aria-label', label); node.title = label; } function applyTheme() { const resolved = app.theme === 'light' || app.theme === 'dark' ? app.theme : (window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark'); document.documentElement.dataset.theme = resolved; const meta = $('#themeColorMeta'); if (meta) meta.content = resolved === 'light' ? '#f8faf9' : '#151515'; const select = $('#themeSelect'); if (select) select.value = app.theme; drawCurrentChartIfVisible(); } function applyTranslations() { document.documentElement.lang = app.language; $$('[data-i18n]').forEach(node => { node.textContent = tr(node.dataset.i18n); }); $$('[data-i18n-placeholder]').forEach(node => { node.placeholder = tr(node.dataset.i18nPlaceholder); }); $$('[data-i18n-title]').forEach(node => { node.title = tr(node.dataset.i18nTitle); }); $$('[data-i18n-aria]').forEach(node => { node.setAttribute('aria-label', tr(node.dataset.i18nAria)); }); $$('[data-i18n-content]').forEach(node => { node.setAttribute('content', tr(node.dataset.i18nContent)); }); $$('[data-day]').forEach(node => { node.textContent = tr(`day.${node.dataset.day}`); }); $('#languageSelect').value = app.language; const themeSelect = $('#themeSelect'); if (themeSelect) { const icons={system:'◐',light:'☀',dark:'☾'}; [...themeSelect.options].forEach(o => { const key=`theme.${o.value}`; o.textContent=`${icons[o.value]||'◐'} ${tr(key)}`; }); themeSelect.value=app.theme; } updateConnectionIndicator(app.connectionStatus); renderAll(); if (app.currentView === 'logs') loadLogs(); if (app.currentView === 'history' && app.zones.length) loadHistory(); } function setLanguage(language) { const available = app.languages.some(item => item.code === language); app.language = available ? language : DEFAULT_LANGUAGE; setCookie('gree_controller_language', app.language); applyTranslations(); } function renderLanguageOptions() { const select = $('#languageSelect'); if (!select) return; select.innerHTML = app.languages.map(item => { const label = item.native_name || item.name || item.code.toUpperCase(); const flag = ({pl:'🇵🇱',en:'🇬🇧'})[item.code] || '🌐'; return ``; }).join(''); select.value = app.language; } async function loadLanguages() { try { const response = await fetch(withBase('/lang/index.json'), {cache: 'no-cache'}); if (!response.ok) throw new Error(`Language index HTTP ${response.status}`); const manifest = await response.json(); const languages = Array.isArray(manifest.languages) ? manifest.languages : []; if (!languages.some(item => item.code === DEFAULT_LANGUAGE)) throw new Error('Default English language pack is missing'); const loaded = await Promise.all(languages.map(async item => { const packResponse = await fetch(withBase(item.path || `/lang/${encodeURIComponent(item.code)}.json`), {cache: 'no-cache'}); if (!packResponse.ok) throw new Error(`Language ${item.code} HTTP ${packResponse.status}`); const pack = await packResponse.json(); return {item, pack}; })); app.languages = loaded.map(({item}) => item); app.translations = Object.fromEntries(loaded.map(({item, pack}) => [item.code, pack.translations || {}])); app.locales = Object.fromEntries(loaded.map(({item, pack}) => [item.code, pack.meta?.locale || item.locale || item.code])); app.language = app.languages.some(item => item.code === preferredLanguage) ? preferredLanguage : (manifest.default || DEFAULT_LANGUAGE); if (!app.languages.some(item => item.code === app.language)) app.language = DEFAULT_LANGUAGE; renderLanguageOptions(); } catch (error) { console.error('Unable to load language packs:', error); app.languages = [{code: DEFAULT_LANGUAGE, name: 'English', native_name: 'English', locale: 'en-GB'}]; app.translations = {[DEFAULT_LANGUAGE]: {}}; app.locales = {[DEFAULT_LANGUAGE]: 'en-GB'}; app.language = DEFAULT_LANGUAGE; renderLanguageOptions(); } } function setTheme(theme) { app.theme = ['system', 'light', 'dark'].includes(theme) ? theme : 'system'; setCookie('gree_controller_theme', app.theme); applyTheme(); } async function api(path, options = {}) { const headers = new Headers(options.headers || {}); headers.set('Accept', 'application/json'); if (app.token) headers.set('Authorization', `Bearer ${app.token}`); let body = options.body; if (body !== undefined && body !== null && typeof body !== 'string') { headers.set('Content-Type', 'application/json'); body = JSON.stringify(body); } const response = await fetch(withBase(path), {...options, headers, body}); if (response.status === 401) { showTokenDialog(); throw new Error(tr('auth.invalid')); } if (!response.ok) { let message = tr('error.http', {status: response.status}); try { message = (await response.json()).error || message; } catch (_) {} throw new Error(message); } if (response.status === 204) return null; return response.json(); } function toast(message, error = false) { const host = $('#toastStack'); if (!host) return; const item = document.createElement('div'); item.className = `toast-item ${error ? 'error' : 'success'}`; item.innerHTML = `${error ? '!' : '✓'}
${esc(error ? tr('toast.errorTitle') : tr('toast.successTitle'))}${esc(message)}
`; host.appendChild(item); requestAnimationFrame(() => item.classList.add('show')); const remove = () => { item.classList.remove('show'); item.classList.add('leaving'); setTimeout(() => item.remove(), 220); }; item.querySelector('.toast-close').addEventListener('click', remove); item._timer = setTimeout(remove, error ? 5200 : 3600); } function showTokenDialog() { const dialog = $('#tokenDialog'); if (!dialog.open) dialog.showModal(); } async function loadBootstrap(showMessage = false) { if (app.loading) return; app.loading = true; try { const data = await api('/api/bootstrap'); app.devices = data.devices || []; app.zones = data.zones || []; app.groups = data.groups || []; app.schedules = data.schedules || []; app.automations = data.automations || []; app.accessTokens = data.access_tokens || []; app.settings = data.settings || null; app.sensorAliases = {...(app.settings?.home_assistant?.sensor_aliases || {})}; app.system = data.system || {}; app.outdoorTemperature = Number.isFinite(Number(data.outdoor_temperature)) ? Number(data.outdoor_temperature) : null; renderAll(); loadControlPlan(); if (app.settings?.debug?.overlay_enabled) loadDebugBacklog(); if (showMessage) toast(tr('common.updated')); if ($('#tokenDialog').open) $('#tokenDialog').close(); connectWebSocket(); } catch (error) { if (!String(error.message).toLowerCase().includes('token')) toast(error.message, true); } finally { app.loading = false; } } function renderAll() { renderSummary(); renderHouseClimate(); renderGroups(); renderControlPlan(); renderSimulationPage(); renderDevices(); renderZones(); renderSchedules(); renderAutomations(); renderAccessTokens(); fillSelects(); renderSettings(); renderNightSettings(); renderHomeAssistantSettings(); renderDebugOverlay(); } function renderSummary() { const temperatures = app.devices.map(d => d.current_temperature).filter(Number.isFinite); const average = temperatures.length ? temperatures.reduce((a,b) => a+b, 0) / temperatures.length : null; const online = app.devices.filter(d => d.online).length; const active = app.devices.filter(d => d.power).length; const demand = app.zones.filter(z => z.enabled && z.demand).length; $('#heroTemperature').innerHTML = `${average === null ? '--' : average.toFixed(1)}°C`; const activeSuffix = active ? tr('dashboard.summaryActive', {count: active}) : ''; $('#summaryText').textContent = app.devices.length ? tr('dashboard.summary', {online, total: app.devices.length, active: activeSuffix}) : tr('dashboard.empty'); $('#metrics').innerHTML = [ [tr('dashboard.metricOnline'), `${online}/${app.devices.length}`], [tr('dashboard.metricActive'), active], [tr('dashboard.metricDemand'), demand], ].map(([label,value]) => `
${esc(label)}${esc(value)}
`).join(''); } function renderHouseClimate() { const node = $('#houseClimate'); if (!node || !app.settings) return; const mode = app.settings.house_mode || 'cool'; const zonePresets = (app.zones || []).map(zone => zone.manual_preset || 'auto'); const preset = zonePresets.length && zonePresets.every(value => value === zonePresets[0]) ? zonePresets[0] : null; const outdoor = app.outdoorTemperature == null ? tr('common.unavailable') : fmtTemp(app.outdoorTemperature); const powerEnabled = app.settings.house_power_enabled !== false; const powerOn = $('#housePowerOn'), powerOff = $('#housePowerOff'); if (powerOn) { powerOn.classList.toggle('active', powerEnabled); powerOn.setAttribute('aria-pressed', String(powerEnabled)); } if (powerOff) { powerOff.classList.toggle('active', !powerEnabled); powerOff.setAttribute('aria-pressed', String(!powerEnabled)); } node.innerHTML = `
${esc(tr('house.seasonMode'))}

${esc(tr('house.smartThermostat'))}

${esc(tr('house.setpointStrategy'))}

${esc(tr('house.outdoor'))}${esc(outdoor)}
${['auto','comfort','sleep','away'].map(p=>``).join('')}
`; } function planEventMarkup(event) { const when = event?.at ? new Date(event.at).toLocaleString(locale(), {weekday:'short', hour:'2-digit', minute:'2-digit'}) : '—'; const target = event?.target_temperature == null ? '' : ` · ${fmtTemp(event.target_temperature)}`; return `
  • ${esc(event?.label || event?.kind || tr('plan.event'))}${esc(target)}
  • `; } function automationTriggerLabel(item) { if (item.trigger_kind === 'time') return tr('automations.triggerAt', {time: item.at_time || '—'}); const key = item.trigger_kind === 'temperature_above' ? 'automations.triggerAbove' : 'automations.triggerBelow'; return tr(key, {temperature: fmtTemp(item.threshold)}); } function planZoneEffectiveEnabled(zone) { if (!zone) return undefined; return zone.effective_enabled ?? zone.enabled ?? false; } function renderControlPlan() { const host = $('#controlPlan'); if (!host) return; const plan = app.controlPlan; if (!plan) { host.innerHTML = `
    ${esc(tr('plan.loading'))}
    `; return; } const allZones = plan.zones || []; const houseEvents = (plan.next_events || []).slice(0, 3); const house = `
    ${esc(tr('plan.house'))}

    ${esc(houseModeLabel(plan.house_mode || 'off'))}

    ${esc(plan.control_strategy || 'setpoint')}

    ${esc(tr('plan.houseSummary', {zones:allZones.filter(planZoneEffectiveEnabled).length, demand:allZones.filter(zone=>planZoneEffectiveEnabled(zone) && zone.demand).length}))}

    `; const groupCards = (app.groups || []).map(group => { const memberIds = new Set(group.zone_ids || []); const members = allZones.filter(zone => memberIds.has(zone.zone_id)); const state = groupState(group); const powerEnabled = group.power_enabled !== false; const demand = members.filter(zone => planZoneEffectiveEnabled(zone) && zone.demand).length; const mode = state.mode === 'house' ? tr('groups.followHouse') : state.mode === 'mixed' ? tr('groups.mixed') : modeLabel(state.mode); const preset = state.preset === 'mixed' ? tr('groups.mixed') : zonePresetLabel(state.preset); const events = members.flatMap(zone => (zone.next_events || []).map(event => ({...event, label:`${zone.zone_name}: ${event.label}`}))) .sort((a,b) => new Date(a.at) - new Date(b.at)).slice(0, 2); return `
    ${esc(tr('groups.group'))}

    ${esc(group.name)}

    ${esc(powerEnabled ? tr('common.on') : tr('common.off'))}
    ${esc(mode)}·${esc(preset)}

    ${esc(tr('plan.groupSummary', {zones:members.length, demand}))}

    `; }).join(''); const zoneGroups = new Map(); (app.groups || []).forEach(group => (group.zone_ids || []).forEach(zoneId => { const names = zoneGroups.get(zoneId) || []; names.push(group.name); zoneGroups.set(zoneId, names); })); const zones = allZones.map(zone => { const events = (zone.next_events || []).slice(0, 2); const target = zone.target_temperature == null ? '--' : Number(zone.target_temperature).toFixed(1); const groupNames = zoneGroups.get(zone.zone_id) || []; const scope = groupNames.length ? `${tr('groups.group')}: ${groupNames.join(' · ')}` : (zone.device_name || tr('common.noDevice')); const planState = zoneRuntimeStatusLabel(zone, zone.mode || 'off'); return `
    ${esc(scope)}

    ${esc(zone.zone_name)}

    ${esc(planState)}
    ${fmtTemp(zone.current_temperature)}${esc(target)}°C

    ${esc(houseModeLabel(zone.mode || 'off'))} · ${esc(zonePresetLabel(zone.preset))}${zone.current_schedule_name ? ` · ${esc(zone.current_schedule_name)}` : ''}

    `; }).join(''); const rules = (plan.rules || []).filter(rule => rule.enabled); const ruleCard = rules.length ? `
    ${esc(tr('plan.rules'))}

    ${esc(tr('plan.ruleCount', {count:rules.length}))}

    ` : ''; host.innerHTML = house + groupCards + zones + ruleCard; } async function loadControlPlan() { try { app.controlPlan = await api('/api/control-plan'); renderControlPlan(); renderSimulationPage(); } catch (error) { console.warn('Unable to load control plan:', error); } } function simulationTime(value, options = {}) { if (!value) return '—'; const date = new Date(value); return date.toLocaleString(locale(), {weekday: options.withDay === false ? undefined : 'short', hour:'2-digit', minute:'2-digit'}).replace(',', ''); } function simulationNumeric(value) { const n = Number(value); return Number.isFinite(n) ? n : null; } function simulationOutdoorAssist(mode, outdoor, room, target) { if (outdoor == null || room == null || target == null) return 0; const roomError = Math.abs(room - target); const weather = mode === 'heat' ? clamp((5 - outdoor) / 15, 0, 1) : clamp((outdoor - 30) / 10, 0, 1); return clamp(weather * clamp(roomError, 0, 2) * 0.5, 0, 1); } function simulationRoundDeviceSetpoint(mode, demand, value) { const safe = clamp(Number(value) || 0, 16, 30); if (mode === 'heat' && demand) return Math.ceil(safe); if (mode === 'heat' && !demand) return Math.floor(safe); if (mode !== 'heat' && demand) return Math.floor(safe); return Math.ceil(safe); } function simulationSmartFanSpeed(mode, room, target, outdoor, demand) { if (!demand) return 1; const error = Math.abs(room - target); const extremeWeather = mode === 'heat' ? outdoor != null && outdoor <= 0 : outdoor != null && outdoor >= 32; if (error >= 2 || extremeWeather) return 3; if (error >= 1) return 2; return 0; } function simulationStatusInfo(status) { const map = { disabled: {label: tr('common.disabled'), badge: ''}, off: {label: tr('house.noControl'), badge: ''}, waiting: {label: tr('simulation.waitingForData'), badge: ''}, demand: {label: tr('simulation.stateDemand'), badge: 'active'}, satisfied: {label: tr('simulation.stateSatisfied'), badge: ''}, }; return map[status] || {label: status, badge: ''}; } function buildZoneSimulation(zone, planZone) { const device = app.devices.find(item => item.id === (planZone?.device_id || zone?.device_id)); const mode = planZone?.mode || zone?.effective_mode || zone?.mode || app.settings?.house_mode || 'off'; const current = simulationNumeric(planZone?.current_temperature ?? zone?.current_temperature ?? zone?.device_temperature ?? device?.current_temperature); const target = mode === 'off' ? null : simulationNumeric(planZone?.target_temperature ?? zone?.effective_setpoint ?? zone?.manual_setpoint ?? zone?.setpoint); const outdoor = simulationNumeric(app.outdoorTemperature ?? app.controlPlan?.outdoor_temperature); const hysteresis = Math.max(simulationNumeric(zone?.hysteresis) || 0.4, 0.1); const half = hysteresis / 2; let status = 'waiting'; let demand = false; if (!zone?.enabled || planZoneEffectiveEnabled(planZone) === false) status = 'disabled'; else if (mode === 'off') status = 'off'; else if (current == null || target == null) status = 'waiting'; else { if (mode === 'heat') { if (current <= target - half) demand = true; else if (current >= target + half) demand = false; else demand = !!zone?.demand; } else { if (current >= target + half) demand = true; else if (current <= target - half) demand = false; else demand = !!zone?.demand; } status = demand ? 'demand' : 'satisfied'; } const standbyOffset = Math.max(simulationNumeric(zone?.standby_offset_c) || 0.5, 0.5); const assist = current == null || target == null ? 0 : simulationOutdoorAssist(mode, outdoor, current, target); const activeTarget = current == null || target == null ? null : (mode === 'heat' ? target + assist : target - assist); const standbyTarget = target == null ? null : (mode === 'heat' ? target - standbyOffset : target + standbyOffset); const desiredDeviceTarget = current == null || target == null ? null : simulationRoundDeviceSetpoint(mode, demand, demand ? activeTarget : standbyTarget); const nightActive = !!app.controlPlan?.night_mode_active; const nightMaxFan = clamp(Number(app.controlPlan?.night_mode_max_fan_speed || 1), 1, 5); let fanSpeed = simulationNumeric(device?.fan_speed); if (mode !== 'off') { fanSpeed = zone?.smart_fan && current != null && target != null ? simulationSmartFanSpeed(mode, current, target, outdoor, demand) : fanSpeed; if (nightActive) { if (zone?.smart_fan) fanSpeed = fanSpeed === 0 ? 1 : Math.min(fanSpeed ?? nightMaxFan, nightMaxFan); else if (fanSpeed === 0 || fanSpeed == null || fanSpeed > nightMaxFan) fanSpeed = nightMaxFan; } } const quiet = mode === 'off' ? tr('simulation.manual') : nightActive && app.settings?.night_mode?.force_quiet ? tr('simulation.quietIfSupported') : (zone?.smart_fan ? (!demand ? tr('simulation.quietIfSupported') : tr('common.off')) : tr('simulation.manual')); const nativeSleep = mode !== 'off' && nightActive && app.settings?.night_mode?.use_native_sleep && device?.supports_sleep === true; const reasoning = !zone?.enabled || planZoneEffectiveEnabled(planZone) === false ? tr('simulation.reasonDisabled') : mode === 'off' ? tr('simulation.reasonHouseOff') : current == null || target == null ? tr('simulation.reasonWaiting') : demand ? tr('simulation.reasonDemand', {mode: modeLabel(mode), target: target.toFixed(1), hysteresis: hysteresis.toFixed(1)}) : tr('simulation.reasonSatisfied', {target: target.toFixed(1), standby: standbyTarget.toFixed(1)}); return {device, mode, current, target, demand, status, outdoor, hysteresis, assist, activeTarget, standbyTarget, desiredDeviceTarget, fanSpeed, quiet, nativeSleep, reasoning}; } function simulationRuleAction(rule) { const bits = []; if (rule.action?.power != null) bits.push(`${tr('common.power')}: ${rule.action.power ? tr('common.on') : tr('common.off')}`); if (rule.action?.mode) bits.push(`${tr('common.mode')}: ${rule.action_group_id && rule.action.mode === 'auto' ? tr('groups.followHouse') : modeLabel(rule.action.mode)}`); if (rule.action?.target_temperature != null) bits.push(`${tr('common.temperature')}: ${fmtTemp(rule.action.target_temperature)}`); if (rule.action_preset) bits.push(`${tr('groups.profile')}: ${zonePresetLabel(rule.action_preset)}`); return bits.length ? bits.join(' · ') : tr('simulation.noActionPreview'); } function simulationGroupsForZone(zoneId) { return app.groups.filter(group => (group.zone_ids || []).includes(zoneId)); } function renderSimulationScopeControls(plan) { const scope = $('#simulationScope'); const target = $('#simulationTarget'); if (!scope || !target) return; if (!['units','groups'].includes(app.simulationScope)) app.simulationScope = 'units'; scope.value = app.simulationScope; const zones = plan?.zones || []; const options = app.simulationScope === 'groups' ? [{id:'all', name:tr('simulation.allGroups')}, ...app.groups.map(group => ({id:group.id, name:group.name}))] : [{id:'all', name:tr('simulation.allUnits')}, ...zones.map(zone => ({id:zone.zone_id, name:`${zone.zone_name} · ${zone.device_name || tr('common.device')}`}))]; if (!options.some(option => option.id === app.simulationTarget)) app.simulationTarget = 'all'; target.innerHTML = options.map(option => ``).join(''); target.value = app.simulationTarget; } function simulationFilteredZones(plan) { const zones = plan?.zones || []; if (app.simulationScope === 'groups') { if (app.simulationTarget === 'all') { const grouped = new Set(app.groups.flatMap(group => group.zone_ids || [])); return zones.filter(zone => grouped.has(zone.zone_id)); } const group = app.groups.find(item => item.id === app.simulationTarget); const wanted = new Set(group?.zone_ids || []); return zones.filter(zone => wanted.has(zone.zone_id)); } return app.simulationTarget === 'all' ? zones : zones.filter(zone => zone.zone_id === app.simulationTarget); } function simulationRuleVisible(rule, zones) { if (app.simulationTarget === 'all') return true; const zoneIds = new Set(zones.map(zone => zone.zone_id)); const deviceIds = new Set(zones.map(zone => zone.device_id).filter(Boolean)); if (app.simulationScope === 'groups') { if (rule.action_group_id === app.simulationTarget) return true; return deviceIds.has(rule.trigger_device_id) || deviceIds.has(rule.action_device_id); } const directZone = [...zoneIds][0]; if (simulationGroupsForZone(directZone).some(group => group.id === rule.action_group_id)) return true; return deviceIds.has(rule.trigger_device_id) || deviceIds.has(rule.action_device_id); } function simulationLaneContext(planZone) { if (app.simulationScope !== 'groups') return planZone.device_name || ''; const groups = simulationGroupsForZone(planZone.zone_id); return groups.length ? groups.map(group => group.name).join(' · ') : (planZone.device_name || ''); } function updateSimulationUrl() { if (app.currentView !== 'simulation') return; const params = new URLSearchParams(); if (app.standaloneSimulation) params.set('standalone', '1'); if (app.simulationScope !== 'units') params.set('scope', app.simulationScope); if (app.simulationTarget !== 'all') params.set('target', app.simulationTarget); const query = params.toString(); updateBrowserUrl(`/simulation${query ? `?${query}` : ''}`, true); } function renderSimulationPage() { const summaryHost = $('#simulationSummary'); const timelineHost = $('#simulationTimeline'); const boardHost = $('#simulationFlowBoard'); const rulesHost = $('#simulationRules'); if (!summaryHost || !timelineHost || !boardHost || !rulesHost) return; const plan = app.controlPlan; if (!plan) { summaryHost.innerHTML = `
    ${esc(tr('plan.loading'))}
    `; timelineHost.innerHTML = ''; boardHost.innerHTML = ''; rulesHost.innerHTML = ''; return; } renderSimulationScopeControls(plan); const zones = simulationFilteredZones(plan); const enabledZones = zones.filter(zone => zone.enabled); const demandingZones = enabledZones.filter(zone => zone.demand); const nightOn = !!plan.night_mode_active; summaryHost.innerHTML = [ {label: tr('simulation.houseMode'), value: houseModeLabel(plan.house_mode || 'off'), note: tr('simulation.strategy', {strategy: plan.control_strategy || 'setpoint'})}, {label: tr('settings.nightMode'), value: nightOn ? tr('common.active') : (app.settings?.night_mode?.enabled ? tr('simulation.scheduled') : tr('common.disabled')), note: `${plan.night_mode_start || '22:00'} → ${plan.night_mode_end || '06:00'} · ${tr('simulation.fanMax')} ${fanLabel(plan.night_mode_max_fan_speed || 1)}`}, {label: tr('simulation.outdoor'), value: plan.outdoor_temperature == null ? '—' : fmtTemp(plan.outdoor_temperature), note: tr('simulation.generatedAt', {time: dateTime(plan.generated_at)})}, {label: tr('simulation.activeZones'), value: String(enabledZones.length), note: `${tr('simulation.requestingZones', {count: demandingZones.length})} · ${tr('simulation.rulesLabel')}: ${(plan.rules || []).filter(rule => rule.enabled && simulationRuleVisible(rule, zones)).length}`}, ].map(card => `
    ${esc(card.label)}${esc(card.value)}${esc(card.note)}
    `).join(''); const nodeW = 190, nodeH = 106, rowH = 190, topY = 155; const x = {sensor:45, thermostat:305, decision:565, unit:825, event:1085}; const boardW = 1325, boardH = Math.max(390, topY + zones.length * rowH + 35); const nodes = []; const links = []; const pathBetween = (ax, ay, bx, by, cls='') => { const bend = Math.max(36, (bx - ax) * .45); return ``; }; const verticalLink = (ax, ay, bx, by, cls='bus') => ``; const node = ({left, top, kind, eyebrow, title, value, meta='', badge='', badgeClass=''}) => `
    ${esc(eyebrow)}${badge ? `${esc(badge)}` : ''}

    ${esc(title)}

    ${esc(value)}${meta ? `

    ${esc(meta)}

    ` : ''}
    `; const globalHouseLeft = 305, globalNightLeft = 565, globalTop = 28; nodes.push(node({left:globalHouseLeft,top:globalTop,kind:'logic global',eyebrow:tr('simulation.globalInput'),title:tr('simulation.houseMode'),value:houseModeLabel(plan.house_mode || 'off'),meta:tr('simulation.strategy',{strategy:plan.control_strategy || 'setpoint'}),badge:tr('simulation.house')})); nodes.push(node({left:globalNightLeft,top:globalTop,kind:`logic global ${nightOn?'night-active':''}`,eyebrow:tr('simulation.globalInput'),title:tr('settings.nightMode'),value:nightOn?tr('common.active'):(app.settings?.night_mode?.enabled?tr('simulation.scheduled'):tr('common.disabled')),meta:`${plan.night_mode_start || '22:00'} → ${plan.night_mode_end || '06:00'} · ${fanLabel(plan.night_mode_max_fan_speed || 1)}`,badge:nightOn?'☾':'○',badgeClass:nightOn?'active':''})); zones.forEach((planZone, index) => { const zone = app.zones.find(item => item.id === planZone.zone_id); const sim = buildZoneSimulation(zone, planZone); const status = simulationStatusInfo(sim.status); const y = topY + index * rowH; const mid = y + nodeH / 2; const next = (planZone.next_events || [])[0]; const source = zoneControlSourceLabel(planZone.control_source); const sensorName = planZone.control_source === 'external' || planZone.control_source === 'combined' ? haSensorLabel(zone?.ha_entity_id || source) : (planZone.device_name || tr('common.device')); const fanText = sim.fanSpeed == null ? '—' : fanLabel(sim.fanSpeed); const quietText = sim.quiet; const command = sim.desiredDeviceTarget == null ? tr('simulation.noCommand') : `${fmtTemp(sim.desiredDeviceTarget)} · ${fanText}`; const eventValue = next ? simulationTime(next.at) : tr('simulation.noEventShort'); const eventMeta = next ? `${next.label}${next.target_temperature == null ? '' : ` · ${fmtTemp(next.target_temperature)}`}` : tr('plan.noEvents'); nodes.push(`
    ${esc(planZone.zone_name)}${esc(simulationLaneContext(planZone))}
    `); nodes.push(node({left:x.sensor,top:y,kind:'input',eyebrow:tr('simulation.stepRoom'),title:sensorName,value:fmtTemp(sim.current),meta:source,badge:'●',badgeClass:sim.current == null?'':'active'})); nodes.push(node({left:x.thermostat,top:y,kind:'logic',eyebrow:tr('simulation.thermostat'),title:planZone.zone_name,value:sim.target == null?'—':fmtTemp(sim.target),meta:`${zonePresetLabel(planZone.preset)} · ${tr('simulation.hysteresis',{value:sim.hysteresis.toFixed(1)})}`,badge:houseModeLabel(sim.mode)})); nodes.push(node({left:x.decision,top:y,kind:`logic decision ${sim.demand?'demand':'satisfied'}`,eyebrow:tr('simulation.stepDecision'),title:status.label,value:sim.demand?tr('simulation.callForComfort'):tr('simulation.standby'),meta:sim.reasoning,badge:sim.demand?'▶':'✓',badgeClass:status.badge})); const sleepMeta = sim.nativeSleep ? ` · ${tr('devices.sleep')}: ${tr('common.on')}` : ''; nodes.push(node({left:x.unit,top:y,kind:'action',eyebrow:tr('simulation.stepCommand'),title:planZone.device_name || tr('common.device'),value:command,meta:`${tr('devices.quiet')}: ${quietText}${sleepMeta}`,badge:sim.mode==='off'?tr('simulation.manual'):(sim.demand?'RUN':'IDLE'),badgeClass:sim.demand?'active':''})); nodes.push(node({left:x.event,top:y,kind:'event',eyebrow:tr('simulation.nextEvent'),title:eventValue,value:next?.preset ? zonePresetLabel(next.preset) : tr('simulation.schedule'),meta:eventMeta,badge:'›'})); links.push(pathBetween(x.sensor+nodeW,mid,x.thermostat,mid,'input-link')); links.push(pathBetween(x.thermostat+nodeW,mid,x.decision,mid,'logic-link')); links.push(pathBetween(x.decision+nodeW,mid,x.unit,mid,sim.demand?'active-link':'logic-link')); links.push(pathBetween(x.unit+nodeW,mid,x.event,mid,'action-link')); links.push(verticalLink(globalHouseLeft+nodeW/2,globalTop+nodeH,x.thermostat+nodeW/2,y,'bus')); if (app.settings?.night_mode?.enabled) links.push(verticalLink(globalNightLeft+nodeW/2,globalTop+nodeH,x.decision+nodeW/2,y,nightOn?'night-link':'bus')); }); boardHost.style.width = `${boardW}px`; boardHost.style.height = `${boardH}px`; boardHost.innerHTML = `${nodes.join('')}`; const timelineItems = []; if (app.simulationTarget === 'all') (plan.next_events || []).forEach(event => timelineItems.push({scope: tr('simulation.house'), event})); zones.forEach(zone => (zone.next_events || []).forEach(event => timelineItems.push({scope: zone.zone_name, event, device: zone.device_name}))); timelineItems.sort((a, b) => new Date(a.event.at) - new Date(b.event.at)); const unique = [], seen = new Set(); for (const item of timelineItems) { const key = `${item.scope}|${item.event.at}|${item.event.label}`; if (seen.has(key)) continue; seen.add(key); unique.push(item); if (unique.length >= 16) break; } timelineHost.innerHTML = unique.length ? unique.map(item => { const target = item.event.target_temperature == null ? '' : ` · ${fmtTemp(item.event.target_temperature)}`; return `
    ${esc(item.scope)}

    ${esc(item.event.label)}${esc(target)}${item.device ? ` · ${esc(item.device)}` : ''}

    `; }).join('') : `
    ${esc(tr('plan.noEvents'))}
    `; const activeRules = (plan.rules || []).filter(rule => rule.enabled && simulationRuleVisible(rule, zones)); rulesHost.innerHTML = activeRules.length ? activeRules.map(rule => `
    ${esc(tr('common.automation'))}

    ${esc(rule.name)}

    ${esc(automationTriggerLabel(rule))}

    ${esc(tr('simulation.ruleOnDevice', {device: rule.action_device_name || tr('common.noDevice')}))}

    ${esc(simulationRuleAction(rule))}
    ${esc(tr('automations.last'))}: ${esc(dateTime(rule.last_fired_at))}${esc(tr('simulation.nextReady'))}: ${esc(dateTime(rule.next_ready_at))}
    `).join('') : `
    ${esc(tr('simulation.noRules'))}
    `; } function scheduleControlPlanLoad() { clearTimeout(app.controlPlanTimer); app.controlPlanTimer = setTimeout(loadControlPlan, 180); } function deviceFeaturePanel(device) { const features = [ ['light', 'supports_light', tr('devices.light')], ['xfan', 'supports_xfan', tr('devices.xfan')], ['health', 'supports_health', tr('devices.health')], ['air', 'supports_air', tr('devices.air')], ['sleep', 'supports_sleep', tr('devices.sleep')], ].filter(([, support]) => device[support] === true); if (!features.length) return `
    ${esc(tr('devices.features'))}${esc(tr('devices.noExtraFeatures'))}
    `; return `
    ${esc(tr('devices.features'))}
    ${features.map(([field,,label]) => ``).join('')}
    `; } function deviceZoneDisabled(deviceId) { return app.zones.some(zone => zone.device_id === deviceId && zone.enabled === false); } function deviceCard(device, detailed = false) { const modes = ['auto','cool','dry','fan','heat']; const fans = [0,1,3,5]; const hasResponseTime = device.response_time_ms !== null && device.response_time_ms !== undefined && Number.isFinite(Number(device.response_time_ms)); const responseTime = hasResponseTime ? `${Math.max(0, Math.round(Number(device.response_time_ms)))} ms` : '— ms'; const protocol = device.simulated ? tr('devices.simulator') : device.protocol_version === 2 ? 'V2 GCM' : device.protocol_version === 1 ? 'V1 ECB' : tr('devices.protocolAuto'); const networkInfo = `
    ${esc(device.ip)} · ${esc(responseTime)} · ${esc(protocol)}${device.last_error ? `${esc(device.last_error)}` : ''}
    `; const zoneLocked = !detailed && deviceZoneDisabled(device.id); const locked = zoneLocked ? ' disabled' : ''; const quickClass = detailed ? '' : ' quick-control-card quick-device-control'; const quickRow = detailed ? '' : ' quick-control-row'; return `

    ${esc(device.name)}

    ${esc(tr(device.online ? 'status.online' : 'status.offline'))} · ${esc(device.model || device.mac)}

    ${Number(device.target_temperature).toFixed(1)}°C
    ${esc(tr('devices.currentTemperature'))}: ${fmtTemp(device.current_temperature)}${device.outdoor_temperature == null ? '' : ` · ${esc(tr('devices.outdoor'))} ${fmtTemp(device.outdoor_temperature)}`}
    ${zoneLocked ? `
    ${esc(tr('devices.disabledZoneTechnicalOnly'))}
    ` : ''}
    ${modes.map(mode => ``).join('')}
    ${fans.map(fan => ``).join('')}
    ${device.supports_quiet === false ? '' : ``} ${device.supports_turbo === false ? '' : ``}
    ${detailed ? deviceFeaturePanel(device) : ''} ${detailed ? `` : ''}
    `; } function renderDevices() { const empty = `
    ${esc(tr('devices.emptyTitle'))}${esc(tr('devices.emptyText'))}
    `; $('#dashboardDevices').innerHTML = app.devices.length ? app.devices.map(d => deviceCard(d, false)).join('') : empty; $('#deviceList').innerHTML = app.devices.length ? app.devices.map(d => deviceCard(d, true)).join('') : empty; const dashboardCount = $('#dashboardDeviceCount'); if (dashboardCount) dashboardCount.textContent = String(app.devices.length); } function zoneStrategyLabel(zone) { if (zone.sensor_source === 'combined') return tr('zones.combinedSource'); if (zone.sensor_source === 'home_assistant') return tr('zones.externalSource'); return tr('zones.greeSource'); } function zoneControlSourceLabel(source) { return ({ device: tr('zones.sourceDevice'), external: tr('zones.sourceExternal'), combined: tr('zones.sourceCombined'), device_fallback: tr('zones.sourceFallback'), device_discrepancy_fallback: tr('zones.sourceDiscrepancy'), unavailable: tr('zones.sourceUnavailable'), })[source] || source || tr('zones.sourceUnavailable'); } function zonePresetLabel(preset) { const key = `preset.${preset || 'comfort'}`; return tr(key) === key ? (preset || 'comfort') : tr(key); } function zoneRuntimeStatusLabel(zone, effectiveMode) { 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 (zone.current_temperature == null) return tr('zones.noMeasurement'); return zone.demand ? tr('zones.requesting') : 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 target = Number(zone.effective_setpoint ?? zone.setpoint); const manual = zone.manual_preset || 'auto'; const displayPreset = zone.manual_preset || zone.active_preset || 'comfort'; 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 hasManualOverride = zone.manual_preset != null || zone.manual_setpoint != null; const deviceManualOverride = zone.device_manual_override === true; const localThermostatPower = zone.local_thermostat_power; 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')) : 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 ? `
    ${esc(tr('zones.localThermostatControl'))}

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

    ${esc(zone.name)}

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

    ${localThermostatPowerControl}
    ${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('')}
    ${esc(zoneRuntimeStatusLabel(zone, effectiveMode))}${esc(override)}
    ${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); } 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'))]; return { zones, mode: modes.length === 1 ? modes[0] : (modes.length ? 'mixed' : 'house'), preset: presets.length === 1 ? presets[0] : (presets.length ? 'mixed' : 'auto'), }; } 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 masterOff = app.settings?.house_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); return `
    ${esc(tr('groups.group'))}

    ${esc(group.name)}

    ${esc(memberText)}

    ${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('common.power'))}
    ${esc(tr('groups.mode'))}
    ${['house','heat','cool'].map(mode => ``).join('')}
    ${esc(tr('groups.profile'))}
    ${['auto','comfort','sleep','away'].map(preset => ``).join('')}
    ${masterOff ? `

    ${esc(tr('groups.masterOff'))}

    ` : ''} ${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 { const result = await api(`/api/groups/${encodeURIComponent(id)}/control`, {method:'POST', body:patch}); if (result.group) { const index = app.groups.findIndex(group => group.id === result.group.id); if (index >= 0) app.groups[index] = result.group; else app.groups.push(result.group); } (result.zones || []).forEach(zone => { const index = app.zones.findIndex(item => item.id === zone.id); if (index >= 0) app.zones[index] = zone; else app.zones.push(zone); }); (result.devices || []).forEach(updateDevice); if (typeof result.master_power_enabled === 'boolean' && app.settings) app.settings.house_power_enabled = result.master_power_enabled; 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')); } catch (error) { 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('common.enabled') : tr('common.disabled'))}
    ${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 renderAutomations() { $('#automationList').innerHTML = app.automations.length ? app.automations.map(item => { const actionGroup = item.action_group_id ? app.groups.find(group => group.id === item.action_group_id) : null; const actionDevice = app.devices.find(device => device.id === item.action_device_id); const targetName = actionGroup?.name || actionDevice?.name || tr('common.noDevice'); const mode = item.action.mode === 'auto' && actionGroup ? tr('groups.followHouse') : (item.action.mode ? modeLabel(item.action.mode) : '—'); const preset = item.action_preset ? zonePresetLabel(item.action_preset) : '—'; return `

    ${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(tr('groups.profile'))}${esc(preset)}
    ${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:''; } } function renderAccessTokens() { const list = $('#accessTokenList'); if (!list) return; list.innerHTML = app.accessTokens.length ? app.accessTokens.map(item => `
    ${esc(item.name)}${esc(item.token_prefix)}${esc(tr('settings.created'))}: ${esc(dateTime(item.created_at))}
    `).join('') : `
    ${esc(tr('settings.noTokens'))}${esc(tr('settings.noTokensHint'))}
    `; } function knownHaEntities() { return [...new Set([ ...Object.keys(app.sensorAliases || {}), ...app.zones.map(zone => zone.ha_entity_id).filter(Boolean), app.settings?.home_assistant?.default_entity_id, app.settings?.home_assistant?.outdoor_entity_id, ...app.historyData.sensors.map(row => row.entity_id), ].filter(Boolean))].sort(); } function renderSensorAliases() { const host = $('#sensorAliasList'); if (!host) return; const entities = knownHaEntities(); host.innerHTML = entities.length ? entities.map(entity => `
    ${esc(entity)}
    `).join('') : `
    ${esc(tr('settings.noSensorAliases'))}
    `; } function renderLogRetention() { const select = $('#logRetentionDays'); if (!select || !app.settings) return; [...select.options].forEach(option => { option.textContent = `${option.value} ${tr('common.days')}`; }); const days = String(app.settings.event_log_retention_days || 30); if (![...select.options].some(option => option.value === days)) { const option = document.createElement('option'); option.value = days; option.textContent = `${days} ${tr('common.days')}`; select.appendChild(option); } select.value = days; } function renderSettings() { if (!app.settings) return; const form = $('#settingsForm'); if (!form) return; form.controller_id.value = app.settings.controller_id || ''; form.poll_interval_seconds.value = app.settings.poll_interval_seconds || 15; form.zone_interval_seconds.value = app.settings.zone_interval_seconds || 5; form.discovery_broadcast.value = app.settings.discovery_broadcast || '255.255.255.255:7000'; form.discovery_timeout_ms.value = app.settings.discovery_timeout_ms || 3000; form.simulator_enabled.checked = !!app.settings.simulator_enabled; form.history_retention_days.value = app.settings.history_retention_days || 30; form.event_log_retention_days.value = app.settings.event_log_retention_days || 30; form.history_compaction_enabled.checked = app.settings.history_compaction_enabled !== false; form.suppress_device_beep.checked = !!app.settings.suppress_device_beep; form.influx_enabled.checked = !!app.settings.influxdb?.enabled; form.influx_version.value = String(app.settings.influxdb?.version || '2'); form.influx_threshold_days.value = app.settings.influxdb?.history_threshold_days || 30; form.influx_url.value = app.settings.influxdb?.url || ''; form.influx_database.value = app.settings.influxdb?.database || 'gree_controller'; form.influx_username.value = app.settings.influxdb?.username || ''; form.influx_password.value = ''; form.influx_password.placeholder = app.settings.influxdb?.password_configured ? tr('settings.secretSaved') : tr('settings.secretKeep'); form.influx_org.value = app.settings.influxdb?.org || ''; form.influx_bucket.value = app.settings.influxdb?.bucket || 'gree_controller'; form.influx_token.value = ''; form.influx_token.placeholder = app.settings.influxdb?.token_configured ? tr('settings.secretSaved') : tr('settings.secretKeep'); form.debug_overlay_enabled.checked = !!app.settings.debug?.overlay_enabled; form.debug_gree_frames.checked = !!app.settings.debug?.gree_frames; const n=app.settings.notifications||{}; form.notifications_enabled.checked=!!n.enabled; form.notifications_mode.value=n.mode||'problems'; form.notifications_provider.value=n.provider||'pushover'; form.pushover_app_token.value=''; form.pushover_user_key.value=''; form.slack_webhook_url.value=''; form.discord_webhook_url.value=''; form.pushover_app_token.placeholder=n.pushover_configured?'Saved - leave empty to keep':'Application token'; form.pushover_user_key.placeholder=n.pushover_configured?'Saved - leave empty to keep':'User/group key'; form.slack_webhook_url.placeholder=n.slack_configured?'Saved - leave empty to keep':'https://hooks.slack.com/services/…'; form.discord_webhook_url.placeholder=n.discord_configured?'Saved - leave empty to keep':'https://discord.com/api/webhooks/…'; form.notification_cooldown_seconds.value=n.cooldown_seconds||300; form.notification_failure_threshold.value=n.communication_failure_threshold||3; form.notification_target_timeout.value=n.target_timeout_minutes||60; updateNotificationFields(); updateInfluxFields(); renderLogRetention(); $('#systemInfo').innerHTML = `

    ${esc(tr('settings.systemState'))}

    ${esc(tr('settings.version'))}: ${esc(app.system.version || '—')}
    ${esc(tr('settings.uptime'))}: ${esc(formatDuration(app.system.uptime_seconds || 0))}
    ${esc(tr('settings.apiAuth'))}: ${esc(app.system.auth_required ? tr('settings.enabled') : tr('settings.disabled'))}
    `; } function renderNightSettings() { if (!app.settings) return; const form = $('#nightModeForm'); if (!form) return; form.night_mode_enabled.checked = !!app.settings.night_mode?.enabled; form.night_mode_start.value = app.settings.night_mode?.start_time || '22:00'; form.night_mode_end.value = app.settings.night_mode?.end_time || '06:00'; form.night_mode_max_fan_speed.value = String(app.settings.night_mode?.max_fan_speed || 1); form.night_mode_force_quiet.checked = app.settings.night_mode?.force_quiet !== false; form.night_mode_native_sleep.checked = app.settings.night_mode?.use_native_sleep !== false; } function renderHomeAssistantSettings() { if (!app.settings) return; const form = $('#homeAssistantForm'); if (!form) return; form.ha_url.value = app.settings.home_assistant?.url || ''; form.ha_token.value = ''; form.ha_token.placeholder = app.settings.home_assistant?.token_configured ? tr('settings.haTokenSaved') : tr('settings.haLongLivedToken'); form.ha_entity_id.value = app.settings.home_assistant?.default_entity_id || ''; form.ha_outdoor_entity_id.value = app.settings.home_assistant?.outdoor_entity_id || ''; form.ha_allow_invalid_tls.checked = !!app.settings.home_assistant?.allow_invalid_tls; form.outdoor_assist_enabled.checked = !!app.settings.outdoor_assist_enabled; renderSensorAliases(); renderAccessTokens(); } function updateInfluxFields() { const version = $('#settingsForm [name=influx_version]')?.value || '2'; $$('[data-influx-fields]').forEach(node => { node.hidden = node.dataset.influxFields !== version; }); } function updateNotificationFields(){ const provider=$('#settingsForm [name=notifications_provider]')?.value||'pushover'; $$('[data-notification-provider]').forEach(n=>n.hidden=n.dataset.notificationProvider!==provider); } function logCategory(kind=''){ const prefix=String(kind).split('.')[0]; return ['device','zone','automation','settings'].includes(prefix)?prefix:(['home_assistant','influx','notification'].includes(prefix)?'integration':'system'); } function debugLine(source, kind, message, timestamp = new Date().toISOString(), data = null) { app.debugLines.push({source, kind, message, timestamp, data}); if (app.debugLines.length > 160) app.debugLines.splice(0, app.debugLines.length - 160); renderDebugOverlay(); } function renderDebugOverlay() { const overlay = $('#debugOverlay'); if (!overlay) return; const enabled = !!app.settings?.debug?.overlay_enabled; overlay.hidden = !enabled; if (!enabled) return; const status = $('#debugOverlayStatus'); if (status) status.textContent = app.settings?.debug?.gree_frames ? tr('debug.apiAndGree') : tr('debug.apiOnly'); const host = $('#debugOverlayLines'); if (!host) return; host.innerHTML = app.debugLines.length ? app.debugLines.slice(-120).map(line => { const details = line.data == null ? '' : ` ${typeof line.data === 'string' ? line.data : JSON.stringify(line.data)}`; return `
    ${esc(line.source)}${esc(line.kind)}: ${esc(line.message || '')}${esc(details)}
    `; }).join('') : `
    ${esc(tr('debug.empty'))}
    `; host.scrollTop = host.scrollHeight; } async function loadDebugBacklog() { if (app.debugBacklogLoaded || !app.settings?.debug?.overlay_enabled) return; try { const data = await api('/api/events?limit=60'); app.debugLines = (data.events || []).reverse().map(item => ({source:'API', kind:item.kind, message:item.message, timestamp:item.timestamp, data:item.metadata})).slice(-120); app.debugBacklogLoaded = true; renderDebugOverlay(); } catch (_) {} } function formatDuration(seconds) { const days = Math.floor(seconds / 86400), hours = Math.floor((seconds % 86400) / 3600), minutes = Math.floor((seconds % 3600) / 60); return `${days ? `${days}d ` : ''}${hours}h ${minutes}m`; } const VIEW_ROUTES = {dashboard:'/dashboard', devices:'/devices', zones:'/zones', groups:'/groups', schedules:'/schedules', automations:'/automations', simulation:'/simulation', night:'/night-mode', homeassistant:'/home-assistant', settings:'/settings', logs:'/events'}; const HISTORY_TABS = ['overview','zones','devices','sensors','custom']; function currentHistoryPath() { const tab = HISTORY_TABS.includes(app.historyTab) ? app.historyTab : 'overview'; const params = new URLSearchParams(); const hours = $('#historyHours')?.value || new URLSearchParams(location.search).get('hours') || '24'; if (hours !== '24') params.set('hours', hours); if (tab === 'zones' && app.historyZone !== 'all') params.set('zone', app.historyZone); if (tab === 'devices' && app.historyDevice !== 'all') params.set('device', app.historyDevice); if (tab === 'sensors' && app.historySensor !== 'all') params.set('sensor', app.historySensor); if (tab === 'custom' && app.customChartSeries.length) params.set('chart', encodeChartSpec(app.customChartSeries)); const query = params.toString(); return `/history/${tab}${query ? `?${query}` : ''}`; } function updateBrowserUrl(path, replace=false) { const target = `${APP_BASE}${path}` || path; const current = `${location.pathname}${location.search}`; if (current === target) return; history[replace ? 'replaceState' : 'pushState']({}, '', target); } function showView(name, {push=true, scroll=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' && ['groups','schedules','automations','simulation','night','homeassistant','settings','logs'].includes(name)))); if (push) updateBrowserUrl(name === 'history' ? currentHistoryPath() : (VIEW_ROUTES[name] || '/dashboard')); if (scroll) window.scrollTo({top: 0, behavior: 'smooth'}); if (name === 'history') { renderHistoryNavigation(); loadHistory(); } if (name === 'logs') loadLogs(); } function showHistoryTab(tab, {push=true, load=true}={}) { app.historyTab = HISTORY_TABS.includes(tab) ? tab : 'overview'; renderHistoryNavigation(); if (push) updateBrowserUrl(currentHistoryPath()); if (load) loadHistory(); } function applyRouteFromLocation() { const routePath = APP_BASE && location.pathname.startsWith(APP_BASE) ? location.pathname.slice(APP_BASE.length) : location.pathname; const parts = routePath.split('/').filter(Boolean); const first = parts[0] || 'dashboard'; const params = new URLSearchParams(location.search); app.standaloneSimulation = first === 'simulation' && params.get('standalone') === '1'; document.body.classList.toggle('simulation-standalone', app.standaloneSimulation); if (first === 'simulation') { app.simulationScope = ['units','groups'].includes(params.get('scope')) ? params.get('scope') : 'units'; app.simulationTarget = params.get('target') || 'all'; } if (first === 'history') { app.historyTab = HISTORY_TABS.includes(parts[1]) ? parts[1] : 'overview'; app.historyZone = params.get('zone') || 'all'; app.historyDevice = params.get('device') || 'all'; app.historySensor = params.get('sensor') || 'all'; const hours = params.get('hours'); if (hours && ['6','24','168','720','2160','8760'].includes(hours) && $('#historyHours')) $('#historyHours').value = hours; if (app.historyTab === 'custom' && params.get('chart')) app.customChartSeries = decodeChartSpec(params.get('chart')); showView('history', {push:false, scroll:false}); return; } const reverse = Object.entries(VIEW_ROUTES).find(([,path]) => path === `/${first}`); showView(reverse?.[0] || 'dashboard', {push:false, scroll:false}); } async function sendDeviceCommand(id, command) { try { const device = await api(`/api/devices/${encodeURIComponent(id)}/command`, {method:'POST', body:command}); updateDevice(device); renderAll(); } catch (error) { toast(error.message, true); } } 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); } async function sendZoneLocalPower(id, power) { try { const zone = await api(`/api/zones/${encodeURIComponent(id)}/control`, {method:'POST', body:{power}}); 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) { toast(error.message, true); } } async function sendZoneControl(id, patch) { const sequence = (app.zoneControlSeq[id] || 0) + 1; app.zoneControlSeq[id] = sequence; try { const zone = await api(`/api/zones/${encodeURIComponent(id)}/control`, {method:'POST', body: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) { const next = Math.round(clamp(value, 8, 30) * 2) / 2; 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 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) dialog.showModal(); } 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.enabled.checked = item.enabled; updateZoneSensorFields(); openDialog('zoneDialog'); } 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'); } function encodeChartSpec(series) { try { const raw = JSON.stringify(series || []); return btoa(raw).replace(/\+/g,'-').replace(/\//g,'_').replace(/=+$/,''); } catch (_) { return ''; } } function decodeChartSpec(encoded) { try { const padded = String(encoded || '').replace(/-/g,'+').replace(/_/g,'/').padEnd(Math.ceil(String(encoded || '').length / 4) * 4, '='); const value = JSON.parse(atob(padded)); return Array.isArray(value) ? value.filter(item => typeof item === 'string').slice(0, 16) : []; } catch (_) { return []; } } function historyEntityOptions() { const zones = app.zones.map(zone => ``).join(''); const devices = app.devices.map(device => ``).join(''); const entities = [...new Set([ ...app.historyData.sensors.map(row => row.entity_id), ...app.zones.map(zone => zone.ha_entity_id).filter(Boolean), app.settings?.home_assistant?.outdoor_entity_id, ].filter(Boolean))].sort(); const sensors = entities.map(entity => ``).join(''); return {zones, devices, sensors, entities}; } function renderHistoryNavigation() { $$('#historyTabs [data-history-tab]').forEach(button => button.classList.toggle('active', button.dataset.historyTab === app.historyTab)); const host = $('#historyContextControls'); if (!host) return; const options = historyEntityOptions(); if (app.historyTab === 'zones') { host.innerHTML = ``; const select=$('#historyZoneSelect'); if ([...select.options].some(option=>option.value===app.historyZone)) select.value=app.historyZone; } else if (app.historyTab === 'devices') { host.innerHTML = ``; const select=$('#historyDeviceSelect'); if ([...select.options].some(option=>option.value===app.historyDevice)) select.value=app.historyDevice; } else if (app.historyTab === 'sensors') { host.innerHTML = ``; const select=$('#historySensorSelect'); if ([...select.options].some(option=>option.value===app.historySensor)) select.value=app.historySensor; } else if (app.historyTab === 'custom') { host.innerHTML = `${esc(tr('history.customHint'))}`; } else { host.innerHTML = `${esc(tr('history.overviewHint'))}`; } } async function loadHistory() { if (app.historyLoading) return; app.historyLoading = true; const hours = $('#historyHours')?.value || '24'; try { const data = await api(`/api/history?scope=overview&hours=${encodeURIComponent(hours)}&limit=20000`); app.historyData = {zones:data.zones || [], devices:data.devices || [], sensors:data.sensors || []}; app.historyCounts = data.counts || {}; renderHistoryNavigation(); renderHistoryPage(); if (app.currentView === 'history') updateBrowserUrl(currentHistoryPath(), true); } catch (error) { toast(error.message, true); renderHistoryPage(); } finally { app.historyLoading = false; } } const HISTORY_COLORS = ['--accent','--info','--warning','--purple','--danger','--teal','--orange','--blue']; function cssColor(name, fallback) { const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim(); return value || fallback; } function timeLabel(ts, hours) { const options = Number(hours) > 48 ? {day:'2-digit', month:'2-digit', hour:'2-digit'} : {hour:'2-digit', minute:'2-digit'}; return new Date(ts).toLocaleString(locale(), options); } function prepareCanvas(canvas, height) { const rect = canvas.getBoundingClientRect(); const width = Math.max(720, Math.floor(rect.width || 720)); const dpr = window.devicePixelRatio || 1; canvas.width = width * dpr; canvas.height = height * dpr; canvas.style.width = `${width}px`; canvas.style.height = `${height}px`; const ctx = canvas.getContext('2d'); ctx.setTransform(dpr,0,0,dpr,0,0); ctx.clearRect(0,0,width,height); return {ctx,width,height}; } function drawEmptyChart(canvas, height=340) { if (!canvas) return; const {ctx,width} = prepareCanvas(canvas,height); ctx.fillStyle = cssColor('--muted','#888'); ctx.font = '13px system-ui'; ctx.textAlign='center'; ctx.fillText(tr('history.noData'), width/2, height/2); } function drawLineChart(canvas, series, rows, {height=340, minValue=null, maxValue=null, binaryLabels=false}={}) { if (!canvas || !rows.length || !series.length) return drawEmptyChart(canvas, height); const sortedRows = [...rows].sort((a,b)=>new Date(a.timestamp)-new Date(b.timestamp)); const {ctx,width} = prepareCanvas(canvas,height); const text=cssColor('--muted','#888'), grid=cssColor('--grid','#333'); const pad={left:54,right:20,top:20,bottom:42}; const allValues=[]; series.forEach(item => sortedRows.forEach(row => { const value=item.value(row); if(Number.isFinite(value)) allValues.push(value); })); if (!allValues.length) return drawEmptyChart(canvas,height); let min = minValue == null ? Math.floor(Math.min(...allValues)-1) : minValue; let max = maxValue == null ? Math.ceil(Math.max(...allValues)+1) : maxValue; if (max-min < 2) { min-=1; max+=1; } const firstTs = new Date(sortedRows[0].timestamp).getTime(); const lastTs = new Date(sortedRows[sortedRows.length-1].timestamp).getTime(); const span = Math.max(1,lastTs-firstTs); const x = row => pad.left + (new Date(row.timestamp).getTime()-firstTs)/span*(width-pad.left-pad.right); const y = value => pad.top + (max-value)/(max-min)*(height-pad.top-pad.bottom); ctx.font='10px system-ui'; ctx.fillStyle=text; ctx.strokeStyle=grid; ctx.lineWidth=1; for(let i=0;i<=5;i++){ const value=min+(max-min)*i/5, py=y(value); ctx.beginPath();ctx.moveTo(pad.left,py);ctx.lineTo(width-pad.right,py);ctx.stroke(); ctx.textAlign='right'; ctx.fillText(binaryLabels ? value.toFixed(0) : `${value.toFixed(1)}°`,pad.left-8,py+3); } const ticks=5; for(let i=0;i<=ticks;i++){ const idx=Math.min(sortedRows.length-1,Math.round(i*(sortedRows.length-1)/ticks)); const px=x(sortedRows[idx]); ctx.textAlign='center'; ctx.fillText(timeLabel(sortedRows[idx].timestamp,$('#historyHours')?.value),px,height-14); } series.forEach(item => { ctx.beginPath(); ctx.strokeStyle=item.color; ctx.lineWidth=item.width||2; ctx.setLineDash(item.dash||[]); let started=false, prev=null; sortedRows.forEach(row => { const value=item.value(row); if(!Number.isFinite(value)) return; const px=x(row),py=y(value); if(!started||prev===null){ctx.moveTo(px,py);started=true;} else if(item.step){ctx.lineTo(px,y(prev));ctx.lineTo(px,py);} else ctx.lineTo(px,py); prev=value; }); ctx.stroke(); ctx.setLineDash([]); }); } function renderLegend(host, series) { if (!host) return; host.innerHTML=series.map(item=>`${esc(item.label)}`).join(''); } function historyChartMarkup(id, title, hint, compact=false) { return `

    ${esc(title)}

    ${esc(hint || '')}

    `; } function historySeriesColor(index) { return cssColor(HISTORY_COLORS[index % HISTORY_COLORS.length], `hsl(${(index*67)%360} 68% 55%)`); } function renderHistorySummary() { const host=$('#historySummary'); if(!host) return; const deviceRows=app.historyData.devices, zoneRows=app.historyData.zones, sensorRows=app.historyData.sensors; const cards=[ [tr('history.deviceSamples'), app.historyCounts.devices ?? deviceRows.length, tr('history.greeHistory')], [tr('history.zoneSamples'), app.historyCounts.zones ?? zoneRows.length, tr('history.zoneHistory')], [tr('history.haSamples'), app.historyCounts.ha ?? sensorRows.length, tr('history.haHistory')], ]; host.innerHTML=cards.map(([label,value,detail])=>`
    ${esc(label)}${esc(value)}${esc(detail)}
    `).join(''); } function renderOverviewHistory() { const host=$('#historyCharts'); host.innerHTML = historyChartMarkup('overviewIndoorChart',tr('history.allGreeIndoor'),tr('history.allGreeIndoorHint')) + historyChartMarkup('overviewOutdoorChart',tr('history.allOutdoor'),tr('history.allOutdoorHint')) + historyChartMarkup('overviewZonesChart',tr('history.allZoneControl'),tr('history.allZoneControlHint')); const deviceRows=app.historyData.devices; const indoorSeries=app.devices.map((device,index)=>({label:device.name,color:historySeriesColor(index),value:row=>!row.zone_id&&!row.entity_id&&row.device_id===device.id?historyNumber(row.indoor_temperature):NaN})); drawLineChart($('#overviewIndoorChart'),indoorSeries,deviceRows,{height:360}); renderLegend($('#overviewIndoorChartLegend'),indoorSeries); const outdoorDeviceSeries=app.devices.filter(device=>deviceRows.some(row=>row.device_id===device.id&&Number.isFinite(historyNumber(row.outdoor_temperature)))).map((device,index)=>({label:`${device.name} · ${tr('history.greeOutdoor')}`,color:historySeriesColor(index),value:row=>!row.zone_id&&!row.entity_id&&row.device_id===device.id?historyNumber(row.outdoor_temperature):NaN})); const outdoorEntities=[...new Set(app.historyData.sensors.filter(row=>row.kind==='outdoor').map(row=>row.entity_id))]; const outdoorHaSeries=outdoorEntities.map((entity,index)=>({label:`HA · ${haSensorLabel(entity)}`,color:historySeriesColor(index+outdoorDeviceSeries.length),dash:[6,4],value:row=>row.entity_id===entity?historyNumber(row.temperature):NaN})); const outdoorRows=[...deviceRows,...app.historyData.sensors.filter(row=>row.kind==='outdoor')]; const outdoorSeries=[...outdoorDeviceSeries,...outdoorHaSeries]; drawLineChart($('#overviewOutdoorChart'),outdoorSeries,outdoorRows,{height:320}); renderLegend($('#overviewOutdoorChartLegend'),outdoorSeries); const zoneRows=app.historyData.zones; const zoneSeries=app.zones.map((zone,index)=>({label:zone.name,color:historySeriesColor(index),value:row=>row.zone_id===zone.id?historyNumber(row.control_temperature):NaN})); drawLineChart($('#overviewZonesChart'),zoneSeries,zoneRows,{height:340}); renderLegend($('#overviewZonesChartLegend'),zoneSeries); } function renderZoneHistory() { const selected=app.historyZone; const rows=selected==='all'?app.historyData.zones:app.historyData.zones.filter(row=>row.zone_id===selected); const host=$('#historyCharts'); host.innerHTML=historyChartMarkup('zoneTemperatureChart',tr('history.temperatureOverview'),tr('history.temperatureOverviewHint'))+historyChartMarkup('zoneOperationChart',tr('history.operationOverview'),tr('history.operationOverviewHint'),true); if(selected==='all'){ const series=app.zones.map((zone,index)=>({label:zone.name,color:historySeriesColor(index),value:row=>row.zone_id===zone.id?historyNumber(row.control_temperature):NaN})); drawLineChart($('#zoneTemperatureChart'),series,rows,{height:360}); renderLegend($('#zoneTemperatureChartLegend'),series); const targets=app.zones.map((zone,index)=>({label:`${zone.name} · ${tr('history.targetShort')}`,color:historySeriesColor(index),dash:[5,4],value:row=>row.zone_id===zone.id?historyNumber(row.target_temperature):NaN})); drawLineChart($('#zoneOperationChart'),targets,rows,{height:260}); renderLegend($('#zoneOperationChartLegend'),targets); return; } const temperatureSeries=[ {label:tr('history.greeSensor'),color:cssColor('--accent','#3ecf8e'),value:row=>historyNumber(row.gree_temperature)}, {label:tr('history.roomSensor'),color:cssColor('--info','#60a5fa'),value:row=>historyNumber(row.external_temperature)}, {label:tr('history.controlTemperature'),color:cssColor('--teal','#2dd4bf'),width:2.8,value:row=>historyNumber(row.control_temperature)}, {label:tr('history.comfortTarget'),color:cssColor('--warning','#f59e0b'),dash:[7,5],value:row=>historyNumber(row.target_temperature)}, {label:tr('history.deviceSetpoint'),color:cssColor('--purple','#a78bfa'),dash:[3,4],value:row=>historyNumber(row.device_setpoint)}, {label:tr('history.outdoorTemperature'),color:cssColor('--muted-strong','#9ca3af'),dash:[2,5],value:row=>historyNumber(row.outdoor_temperature)}, ]; drawLineChart($('#zoneTemperatureChart'),temperatureSeries,rows,{height:360}); renderLegend($('#zoneTemperatureChartLegend'),temperatureSeries); const operationSeries=[ {label:tr('history.fanSpeed'),color:cssColor('--info','#60a5fa'),step:true,value:row=>historyNumber(row.fan_speed)}, {label:tr('history.demand'),color:cssColor('--accent','#3ecf8e'),step:true,width:2.4,value:row=>row.demand?4.5:0.5}, {label:tr('history.power'),color:cssColor('--warning','#f59e0b'),step:true,dash:[5,4],value:row=>row.power?3.5:0.5}, ]; drawLineChart($('#zoneOperationChart'),operationSeries,rows,{height:260,minValue:0,maxValue:5,binaryLabels:true}); renderLegend($('#zoneOperationChartLegend'),operationSeries); } function renderDeviceHistory() { const selected=app.historyDevice; const rows=selected==='all'?app.historyData.devices:app.historyData.devices.filter(row=>row.device_id===selected); const host=$('#historyCharts'); host.innerHTML=historyChartMarkup('deviceIndoorChart',tr('history.deviceIndoor'),tr('history.deviceIndoorHint'))+historyChartMarkup('deviceOutdoorChart',tr('history.deviceOutdoor'),tr('history.deviceOutdoorHint'))+historyChartMarkup('deviceTargetChart',tr('history.deviceTargets'),tr('history.deviceTargetsHint'),true); const devices=selected==='all'?app.devices:app.devices.filter(device=>device.id===selected); const indoor=devices.map((device,index)=>({label:device.name,color:historySeriesColor(index),value:row=>row.device_id===device.id?historyNumber(row.indoor_temperature):NaN})); const outdoor=devices.filter(device=>rows.some(row=>row.device_id===device.id&&Number.isFinite(historyNumber(row.outdoor_temperature)))).map((device,index)=>({label:device.name,color:historySeriesColor(index),value:row=>row.device_id===device.id?historyNumber(row.outdoor_temperature):NaN})); const targets=devices.map((device,index)=>({label:device.name,color:historySeriesColor(index),dash:[6,4],value:row=>row.device_id===device.id?historyNumber(row.target_temperature):NaN})); drawLineChart($('#deviceIndoorChart'),indoor,rows,{height:350}); renderLegend($('#deviceIndoorChartLegend'),indoor); drawLineChart($('#deviceOutdoorChart'),outdoor,rows,{height:310}); renderLegend($('#deviceOutdoorChartLegend'),outdoor); drawLineChart($('#deviceTargetChart'),targets,rows,{height:260}); renderLegend($('#deviceTargetChartLegend'),targets); } function renderSensorHistory() { const selected=app.historySensor; const rows=selected==='all'?app.historyData.sensors:app.historyData.sensors.filter(row=>row.entity_id===selected); const entities=[...new Set(rows.map(row=>row.entity_id))]; const host=$('#historyCharts'); host.innerHTML=historyChartMarkup('haSensorsChart',tr('history.haSensors'),tr('history.haSensorsHint')); const series=entities.map((entity,index)=>({label:haSensorLabel(entity),color:historySeriesColor(index),dash:rows.some(row=>row.entity_id===entity&&row.kind==='outdoor')?[6,4]:[],value:row=>row.entity_id===entity?historyNumber(row.temperature):NaN})); drawLineChart($('#haSensorsChart'),series,rows,{height:370}); renderLegend($('#haSensorsChartLegend'),series); } function customSeriesOptions() { const items=[]; app.devices.forEach(device=>{ items.push([`device|${device.id}|indoor`,`${device.name} · ${tr('history.indoorTemperature')}`]); items.push([`device|${device.id}|outdoor`,`${device.name} · ${tr('history.greeOutdoor')}`]); items.push([`device|${device.id}|target`,`${device.name} · ${tr('history.deviceTarget')}`]); }); app.zones.forEach(zone=>{ items.push([`zone|${zone.id}|control`,`${zone.name} · ${tr('history.controlTemperature')}`]); items.push([`zone|${zone.id}|gree`,`${zone.name} · ${tr('history.greeSensor')}`]); items.push([`zone|${zone.id}|external`,`${zone.name} · ${tr('history.roomSensor')}`]); items.push([`zone|${zone.id}|target`,`${zone.name} · ${tr('history.comfortTarget')}`]); items.push([`zone|${zone.id}|device_target`,`${zone.name} · ${tr('history.deviceSetpoint')}`]); items.push([`zone|${zone.id}|outdoor`,`${zone.name} · ${tr('history.outdoorTemperature')}`]); }); [...new Set(app.historyData.sensors.map(row=>row.entity_id))].forEach(entity=>items.push([`ha|${entity}|temperature`,`HA · ${haSensorLabel(entity)}`])); return items; } function customSeriesDefinition(key,index=0) { const [kind,id,field]=String(key).split('|'); const color=historySeriesColor(index); if(kind==='device'){ const device=app.devices.find(item=>item.id===id); if(!device) return null; const labels={indoor:tr('history.indoorTemperature'),outdoor:tr('history.greeOutdoor'),target:tr('history.deviceTarget')}; const fields={indoor:'indoor_temperature',outdoor:'outdoor_temperature',target:'target_temperature'}; return {key,label:`${device.name} · ${labels[field]||field}`,color,dash:field==='target'?[6,4]:[],value:row=>!row.zone_id&&!row.entity_id&&row.device_id===id?historyNumber(row[fields[field]]):NaN}; } if(kind==='zone'){ const zone=app.zones.find(item=>item.id===id); if(!zone) return null; const fields={control:['control_temperature',tr('history.controlTemperature')],gree:['gree_temperature',tr('history.greeSensor')],external:['external_temperature',tr('history.roomSensor')],target:['target_temperature',tr('history.comfortTarget')],device_target:['device_setpoint',tr('history.deviceSetpoint')],outdoor:['outdoor_temperature',tr('history.outdoorTemperature')]}; const info=fields[field]; if(!info) return null; return {key,label:`${zone.name} · ${info[1]}`,color,dash:['target','device_target','outdoor'].includes(field)?[6,4]:[],value:row=>row.zone_id===id?historyNumber(row[info[0]]):NaN}; } if(kind==='ha') return {key,label:`HA · ${haSensorLabel(id)}`,color,dash:[3,4],value:row=>row.entity_id===id?historyNumber(row.temperature):NaN}; return null; } function persistSavedCharts() { localStorage.setItem('gree_controller_saved_charts',JSON.stringify(app.savedCharts.slice(0,30))); } function renderCustomBuilder() { const host=$('#historyCustomBuilder'); if(!host) return; if(app.historyTab!=='custom'){host.innerHTML='';host.classList.remove('active');return;} host.classList.add('active'); const options=customSeriesOptions(); const selected=app.customChartSeries.map((key,index)=>customSeriesDefinition(key,index)).filter(Boolean); host.innerHTML=`

    ${esc(tr('history.customTitle'))}

    ${esc(tr('history.customDescription'))}

    ${selected.length?selected.map((item,index)=>`${esc(item.label)}`).join(''):`${esc(tr('history.noCustomSeries'))}`}
    ${app.savedCharts.length?app.savedCharts.map(item=>`
    `).join(''):`${esc(tr('history.noSavedCharts'))}`}
    `; } function renderCustomHistory() { renderCustomBuilder(); const host=$('#historyCharts'); host.innerHTML=historyChartMarkup('customHistoryChart',tr('history.customChart'),tr('history.customChartHint')); const series=app.customChartSeries.map((key,index)=>customSeriesDefinition(key,index)).filter(Boolean); const rows=[...app.historyData.devices,...app.historyData.zones,...app.historyData.sensors]; drawLineChart($('#customHistoryChart'),series,rows,{height:390}); renderLegend($('#customHistoryChartLegend'),series); } function renderHistoryPage() { renderHistorySummary(); renderCustomBuilder(); if(app.historyTab==='overview') renderOverviewHistory(); else if(app.historyTab==='zones') renderZoneHistory(); else if(app.historyTab==='devices') renderDeviceHistory(); else if(app.historyTab==='sensors') renderSensorHistory(); else renderCustomHistory(); } function drawCurrentChartIfVisible() { if (app.currentView === 'history') renderHistoryPage(); } async function handleHistoryAction(button) { const action=button.dataset.historyAction; if(action==='add-series'){ const value=$('#customSeriesSelect')?.value; if(value && !app.customChartSeries.includes(value)) app.customChartSeries.push(value); renderCustomHistory(); updateBrowserUrl(currentHistoryPath(), true); return; } if(action==='remove-series'){ app.customChartSeries.splice(Number(button.dataset.index),1); renderCustomHistory(); updateBrowserUrl(currentHistoryPath(), true); return; } if(action==='save-chart'){ if(!app.customChartSeries.length) return toast(tr('history.noCustomSeries'), true); const name=$('#customChartName')?.value.trim() || tr('history.customChart'); const item={id:`chart-${Date.now()}`,name,series:[...app.customChartSeries],hours:$('#historyHours')?.value||'24'}; app.savedCharts.unshift(item); persistSavedCharts(); renderCustomHistory(); toast(tr('history.chartSaved')); return; } if(action==='load-chart'){ const item=app.savedCharts.find(entry=>entry.id===button.dataset.id); if(!item) return; app.customChartSeries=[...item.series]; if($('#historyHours')&&item.hours) $('#historyHours').value=String(item.hours); renderCustomHistory(); updateBrowserUrl(currentHistoryPath()); return; } if(action==='delete-chart'){ app.savedCharts=app.savedCharts.filter(entry=>entry.id!==button.dataset.id); persistSavedCharts(); renderCustomHistory(); return; } if(action==='copy-chart-link'){ if(!app.customChartSeries.length) return toast(tr('history.noCustomSeries'), true); const path=currentHistoryPath(); updateBrowserUrl(path, true); const link=`${location.origin}${APP_BASE}${path}`; try { await navigator.clipboard.writeText(link); } catch (_) { const area=document.createElement('textarea'); area.value=link; document.body.appendChild(area); area.select(); document.execCommand('copy'); area.remove(); } toast(tr('history.linkCopied')); return; } } async function loadLogs() { renderLogRetention(); try { const data = await api('/api/events?limit=150'); const level=$('#logLevelFilter')?.value||'all', category=$('#logCategoryFilter')?.value||'all'; const logs = (data.events || []).filter(item => (level==='all'||item.level===level) && (category==='all'||logCategory(item.kind)===category)); $('#logList').innerHTML = logs.length ? logs.map(item => `
    ${esc(logCategory(item.kind))}${esc(item.kind)}${esc(item.message)}
    `).join('') : `
    ${esc(tr('logs.emptyTitle'))}${esc(tr('logs.emptyText'))}
    `; } catch (error) { toast(error.message, true); } } function connectWebSocket() { if (app.ws && [WebSocket.OPEN, WebSocket.CONNECTING].includes(app.ws.readyState)) return; clearTimeout(app.wsTimer); const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'; const query = app.token ? `?token=${encodeURIComponent(app.token)}` : ''; const ws = new WebSocket(`${protocol}//${location.host}${withBase('/ws')}${query}`); app.ws = ws; ws.onopen = () => updateConnectionIndicator('connected'); ws.onclose = () => { updateConnectionIndicator('disconnected'); app.wsTimer = setTimeout(connectWebSocket, 3000); }; ws.onerror = () => updateConnectionIndicator('connectionError'); ws.onmessage = event => { try { const message = JSON.parse(event.data); if (message.event === 'bootstrap') { const data=message.data; app.devices=data.devices||[]; app.zones=data.zones||[]; app.groups=data.groups||[]; app.schedules=data.schedules||[]; app.automations=data.automations||[]; app.settings=data.settings||app.settings; app.sensorAliases={...(app.settings?.home_assistant?.sensor_aliases||{})}; app.system=data.system||app.system; app.outdoorTemperature=Number.isFinite(Number(data.outdoor_temperature))?Number(data.outdoor_temperature):app.outdoorTemperature; renderAll(); scheduleControlPlanLoad(); if(app.settings?.debug?.overlay_enabled) loadDebugBacklog(); return; } const data = message.data || {}; if (['device.updated','device.created'].includes(message.event)) { updateDevice(data); renderSummary(); renderDevices(); renderGroups(); scheduleControlPlanLoad(); } else if (message.event === 'device.deleted') { app.devices=app.devices.filter(v=>v.id!==data.id); renderAll(); scheduleControlPlanLoad(); } else if (message.event === 'devices.discovered') { (data.devices||[]).forEach(updateDevice); renderAll(); scheduleControlPlanLoad(); } else if (message.event === 'zone.updated') { const i=app.zones.findIndex(v=>v.id===data.id); if(i>=0) app.zones[i]=data; else app.zones.push(data); renderSummary(); renderHouseClimate(); renderZones(); renderGroups(); scheduleControlPlanLoad(); } else if (message.event === 'zone.deleted') { app.zones=app.zones.filter(v=>v.id!==data.id); renderAll(); scheduleControlPlanLoad(); } else if (['group.updated','group.created'].includes(message.event)) { const i=app.groups.findIndex(v=>v.id===data.id); if(i>=0) app.groups[i]=data; else app.groups.push(data); renderGroups(); fillSelects(); scheduleControlPlanLoad(); } else if (message.event === 'group.deleted') { app.groups=app.groups.filter(v=>v.id!==data.id); renderGroups(); fillSelects(); scheduleControlPlanLoad(); } else if (message.event === 'settings.updated') { app.settings=data; app.sensorAliases={...(app.settings?.home_assistant?.sensor_aliases||{})}; renderSettings(); renderNightSettings(); renderHomeAssistantSettings(); renderHouseClimate(); renderDebugOverlay(); if(app.settings?.debug?.overlay_enabled) loadDebugBacklog(); scheduleControlPlanLoad(); } else if (message.event === 'house.power_changed') { app.settings=app.settings||{}; app.settings.house_power_enabled=data.house_power_enabled !== false; renderHouseClimate(); renderGroups(); scheduleControlPlanLoad(); } else if (message.event === 'debug.settings') { app.settings = app.settings || {}; app.settings.debug = data; renderSettings(); renderDebugOverlay(); if(data.overlay_enabled) loadDebugBacklog(); } else if (message.event === 'outdoor.updated') { app.outdoorTemperature=Number.isFinite(Number(data.temperature))?Number(data.temperature):null; renderHouseClimate(); scheduleControlPlanLoad(); } else if (message.event === 'gree.frame') { if(app.settings?.debug?.overlay_enabled) debugLine('GREE', `${data.direction || '?'} ${data.protocol_version || ''}`, data.device_name || data.device_id || data.target || '', message.timestamp, data.payload); } else if (message.event === 'api.request') { if(app.settings?.debug?.overlay_enabled) debugLine('HTTP', `${data.method || '?'} ${data.status || ''}`, `${data.path || ''} · ${data.duration_ms ?? '?'} ms`, message.timestamp); } else if (message.event === 'log.created') { if(app.settings?.debug?.overlay_enabled) debugLine('API', data.kind || data.level || 'log', data.message || '', message.timestamp, data.metadata); if(app.currentView === 'logs') loadLogs(); } else if (message.event.startsWith('schedule.') || message.event.startsWith('automation.')) scheduleControlPlanLoad(); } catch (_) {} }; } document.addEventListener('click', async event => { const button = event.target.closest('button'); if (!button) return; if (button.dataset.nav) { if (button.dataset.nav === 'more') openDialog('moreDialog'); else showView(button.dataset.nav); return; } if (button.dataset.go) { const more=$('#moreDialog'); if(more?.open) more.close(); showView(button.dataset.go); return; } if (button.dataset.open) { const form = document.getElementById(button.dataset.open.replace('Dialog','Form')); if (form) form.reset(); if (button.dataset.open === 'zoneDialog') { if (form) { form.dataset.editingZoneId = ''; if (form.elements.id) form.elements.id.value = ''; } updateZoneSensorFields(); } if (button.dataset.open === 'groupDialog') { if (form?.elements.id) form.elements.id.value = ''; renderGroupZoneChoices([]); } if (button.dataset.open === 'automationDialog') updateAutomationTargetFields(); if (button.dataset.open === 'scheduleDialog') updateSchedulePresetField(); openDialog(button.dataset.open); return; } if (button.hasAttribute('data-close')) { button.closest('dialog')?.close(); return; } if (button.dataset.historyTab) { showHistoryTab(button.dataset.historyTab); return; } if (button.dataset.historyAction) { await handleHistoryAction(button); return; } if (button.dataset.scheduleTemplate) { const zoneId = $('#schedulePresetZone')?.value; if (!zoneId) return toast(tr('schedules.chooseZone'), true); if (!confirm(tr('schedules.replaceConfirm'))) return; try { button.disabled = true; await api(`/api/zones/${encodeURIComponent(zoneId)}/schedule-template`, {method:'POST', body:{template:button.dataset.scheduleTemplate}}); await loadBootstrap(); toast(tr('schedules.templateApplied')); } catch (error) { toast(error.message, true); } finally { button.disabled = false; } return; } const action = button.dataset.action; if (!action) return; const device = app.devices.find(v => v.id === button.dataset.device); if (device && app.currentView !== 'devices' && deviceZoneDisabled(device.id) && ['power','temperature','mode','fan','toggle'].includes(action)) { return toast(tr('devices.disabledZoneTechnicalOnly'), true); } if (action === 'power' && device) return sendDeviceCommand(device.id, {power:!device.power}); if (action === 'temperature' && device) return sendDeviceCommand(device.id, {target_temperature:clamp(Number(device.target_temperature)+Number(button.dataset.delta),8,30)}); if (action === 'mode' && device) return sendDeviceCommand(device.id, {mode:button.dataset.value, power:true}); if (action === 'fan' && device) return sendDeviceCommand(device.id, {fan_speed:Number(button.dataset.value)}); if (action === 'toggle' && device) return sendDeviceCommand(device.id, {[button.dataset.field]:!device[button.dataset.field]}); if (action === 'poll' && device) { try { button.disabled=true; updateDevice(await api(`/api/devices/${encodeURIComponent(device.id)}/poll`,{method:'POST'})); renderAll(); toast(tr('devices.readDone')); } catch(e){toast(e.message,true);} finally{button.disabled=false;} return; } if (action === 'bind' && device) { try { button.disabled=true; updateDevice(await api(`/api/devices/${encodeURIComponent(device.id)}/bind`,{method:'POST'})); renderAll(); toast(tr('devices.bound')); } catch(e){toast(e.message,true);} finally{button.disabled=false;} return; } if (action === 'rename-device' && device) return populateDeviceRename(device.id); if (action === 'delete-device') return deleteEntity('devices', button.dataset.device, 'label.device'); if (action === 'house-mode') { try { app.settings = await api('/api/house/control',{method:'POST',body:{mode:button.dataset.value}}); renderHouseClimate(); scheduleControlPlanLoad(); toast(tr('house.modeUpdated')); } catch(error){ toast(error.message,true); } return; } if (action === 'house-power') { const power = button.dataset.value === 'true'; try { button.disabled = true; const result = await api('/api/house/power',{method:'POST',body:{power}}); app.settings = result.settings || app.settings; app.devices = result.devices || app.devices; app.groups = result.groups || app.groups; renderAll(); scheduleControlPlanLoad(); const failed = Array.isArray(result.failed) ? result.failed.length : 0; if (failed) toast(tr('house.powerPartial', {count: failed}), true); else toast(tr(power ? 'house.powerOnDone' : 'house.powerOffDone')); } catch(error) { toast(error.message, true); } finally { button.disabled = false; } return; } if (action === 'house-preset') { try { const result=await api('/api/house/preset',{method:'POST',body:{preset:button.dataset.value}}); app.settings=result.settings||app.settings; app.zones=result.zones||app.zones; app.devices=result.devices||app.devices; renderAll(); scheduleControlPlanLoad(); const failed=Array.isArray(result.failed)?result.failed.length:0; if(failed) toast(tr('house.powerPartial',{count:failed}),true); else toast(tr('house.presetUpdated')); } catch(error){ toast(error.message,true); } return; } if (action === 'group-power') return sendGroupControl(button.dataset.id,{power:button.dataset.value==='true'}); if (action === 'group-mode') return sendGroupControl(button.dataset.id,{mode:button.dataset.value}); if (action === 'group-preset') return sendGroupControl(button.dataset.id,{preset:button.dataset.value}); if (action === 'zone-device-power') return sendZoneLocalPower(button.dataset.id, button.dataset.value === 'true'); if (action === 'zone-temperature') { const zone=app.zones.find(v=>v.id===button.dataset.id); if(zone) { const base=Number(zone.manual_setpoint ?? zone.effective_setpoint ?? zone.setpoint); queueZoneTemperature(zone, base+Number(button.dataset.delta)); } return; } if (action === 'zone-mode') return sendZoneControl(button.dataset.id,{mode:button.dataset.value}); if (action === 'zone-preset') return sendZoneControl(button.dataset.id,{preset:button.dataset.value}); if (action === 'zone-enabled') return sendZoneControl(button.dataset.id,{enabled:button.dataset.value==='true'}); if (action === 'zone-resume-automation') { const patch = button.dataset.resume === 'local' ? {clear_local_thermostat_override:true} : {clear_device_manual_override:true}; return sendZoneControl(button.dataset.id, patch); } if (action === 'zone-go-control') { showView('dashboard', {scroll:false}); const target = [...document.querySelectorAll('[data-zone-card]')].find(card => card.dataset.zoneCard === button.dataset.id); const disclosure = target?.closest('details.dashboard-disclosure'); if (disclosure) disclosure.open = true; requestAnimationFrame(() => target?.scrollIntoView({behavior:'smooth', block:'center'})); return; } if (action === 'debug-clear') { app.debugLines = []; renderDebugOverlay(); return; } if (action === 'edit-group') return populateGroup(button.dataset.id); if (action === 'delete-group') return deleteEntity('groups', button.dataset.id, 'groups.group'); if (action === 'edit-zone') return populateZone(button.dataset.id); if (action === 'delete-zone') return deleteEntity('zones', button.dataset.id, 'label.zone'); if (action === 'edit-schedule') return populateSchedule(button.dataset.id); if (action === 'delete-schedule') return deleteEntity('schedules', button.dataset.id, 'label.schedule'); if (action === 'edit-automation') return populateAutomation(button.dataset.id); if (action === 'delete-automation') return deleteEntity('automations', button.dataset.id, 'label.automation'); if (action === 'revoke-access-token') { if (!confirm(tr('confirm.revokeToken'))) return; try { await api(`/api/access-tokens/${encodeURIComponent(button.dataset.id)}`, {method:'DELETE'}); app.accessTokens = app.accessTokens.filter(item => item.id !== button.dataset.id); renderAccessTokens(); toast(tr('toast.tokenRevoked')); } catch (error) { toast(error.message, true); } return; } }); $('#refreshButton').addEventListener('click', () => loadBootstrap(true)); $('#discoverButton').addEventListener('click', () => { const form=$('#discoverForm'); form.reset(); form.protocol_version.value='0'; form.passes.value='3'; form.timeout_ms.value=String(Math.max(6000, Number(app.settings?.discovery_timeout_ms || 3000))); openDialog('discoverDialog'); }); $('#historyRefresh').addEventListener('click', loadHistory); $('#historyHours').addEventListener('change', () => { updateBrowserUrl(currentHistoryPath(), true); loadHistory(); }); $('#logsRefresh').addEventListener('click', loadLogs); $('#languageSelect').addEventListener('change', event => setLanguage(event.target.value)); $('#themeSelect').addEventListener('change', event => setTheme(event.target.value)); $('#logLevelFilter')?.addEventListener('change', loadLogs); $('#logCategoryFilter')?.addEventListener('change', loadLogs); $('#settingsForm [name=notifications_provider]')?.addEventListener('change', updateNotificationFields); $('#zoneForm [name=sensor_source]').addEventListener('change', updateZoneSensorFields); $('#scheduleForm [name=preset]').addEventListener('change', updateSchedulePresetField); $('#automationForm [name=action_target_kind]')?.addEventListener('change', updateAutomationTargetFields); $('#simulationScope')?.addEventListener('change', event => { app.simulationScope=event.target.value; app.simulationTarget='all'; renderSimulationPage(); updateSimulationUrl(); }); $('#simulationTarget')?.addEventListener('change', event => { app.simulationTarget=event.target.value || 'all'; renderSimulationPage(); updateSimulationUrl(); }); $('#simulationOpenTab')?.addEventListener('click', () => { const params=new URLSearchParams({standalone:'1'}); if(app.simulationScope!=='units')params.set('scope',app.simulationScope); if(app.simulationTarget!=='all')params.set('target',app.simulationTarget); window.open(`${withBase('/simulation')}?${params.toString()}`, '_blank', 'noopener'); }); $('#simulationFullscreen')?.addEventListener('click', async () => { try { if(document.fullscreenElement) await document.exitFullscreen(); else await document.documentElement.requestFullscreen(); } catch(error) { toast(error.message, true); } }); document.addEventListener('fullscreenchange', () => { const button=$('#simulationFullscreen'); if(button) button.textContent=tr(document.fullscreenElement?'simulation.exitFullscreen':'simulation.fullscreen'); }); $('#testNotifications')?.addEventListener('click', async () => { const f=$('#settingsForm'); const saved=app.settings?.notifications||{}; const body={enabled:true,mode:f.notifications_mode.value,provider:f.notifications_provider.value,pushover_app_token:f.pushover_app_token.value.trim(),pushover_user_key:f.pushover_user_key.value.trim(),slack_webhook_url:f.slack_webhook_url.value.trim(),discord_webhook_url:f.discord_webhook_url.value.trim(),cooldown_seconds:Number(f.notification_cooldown_seconds.value||300),communication_failure_threshold:Number(f.notification_failure_threshold.value||3),target_timeout_minutes:Number(f.notification_target_timeout.value||60)}; try{await api('/api/integrations/notifications/test',{method:'POST',body});toast('Test notification sent');}catch(e){toast(e.message,true);} }); $('#copyZoneSettings')?.addEventListener('click',()=>{ const f=$('#zoneForm'); const source=app.zones.find(zone=>zone.id===$('#copyZoneSource')?.value); if(!source)return; const targetId=f.dataset.editingZoneId || ''; if(targetId && source.id===targetId){ toast(tr('zones.copyDifferent'),true); return; } // Identity and sensor assignment belong to the destination profile and must never // be changed by copying thermostat tuning from another profile. const protectedValues={ id:f.elements.id.value, name:f.elements.name.value, device_id:f.elements.device_id.value, sensor_source:f.elements.sensor_source.value, ha_entity_id:f.elements.ha_entity_id.value, enabled:f.elements.enabled.checked, }; const copyFields=['setpoint','cool_comfort_setpoint','cool_sleep_setpoint','cool_away_setpoint','heat_comfort_setpoint','heat_sleep_setpoint','heat_away_setpoint','hysteresis','min_on_seconds','min_off_seconds','min_adjust_seconds','standby_offset_c','external_sensor_weight_percent','max_sensor_difference']; copyFields.forEach(name=>{ const input=f.elements[name]; if(!input)return; const value=name==='external_sensor_weight_percent'?(source.external_sensor_weight*100):source[name]; if(value!==undefined&&value!==null)input.value=String(value); }); f.elements.smart_fan.checked=source.smart_fan!==false; if(f.elements.mode_policy) f.elements.mode_policy.value=source.inherit_house_mode===false?(source.mode||'cool'):'house'; f.elements.id.value=protectedValues.id; f.elements.name.value=protectedValues.name; f.elements.device_id.value=protectedValues.device_id; f.elements.sensor_source.value=protectedValues.sensor_source; f.elements.ha_entity_id.value=protectedValues.ha_entity_id; f.elements.enabled.checked=protectedValues.enabled; f.dataset.editingZoneId=targetId; updateZoneSensorFields(); toast(tr('zones.copiedFrom',{name:source.name})); }); $('#applyAutomationPreset')?.addEventListener('click',()=>{ const f=$('#automationForm'), p=$('#automationPreset')?.value; if(!p)return; const first=app.devices[0]?.id||''; const presets={hot:{name:'High temperature cooling',trigger_kind:'temperature_above',threshold:'28',action_power:'true',action_mode:'cool',action_target_temperature:'23',cooldown_seconds:'900'},cold:{name:'Low temperature heating',trigger_kind:'temperature_below',threshold:'17',action_power:'true',action_mode:'heat',action_target_temperature:'21',cooldown_seconds:'900'},morning:{name:'Morning comfort',trigger_kind:'time',at_time:'07:00',action_power:'true',action_mode:'auto',action_target_temperature:'22',cooldown_seconds:'3600'},nightoff:{name:'Night power off',trigger_kind:'time',at_time:'23:30',action_power:'false',action_mode:'',action_target_temperature:'',cooldown_seconds:'3600'}}; const x=presets[p]; f.action_target_kind.value='device'; updateAutomationTargetFields(); Object.entries(x).forEach(([k,v])=>{if(f[k])f[k].value=v}); if(!f.trigger_device_id.value)f.trigger_device_id.value=first; if(!f.action_device_id.value)f.action_device_id.value=first; }); $('#tokenForm').addEventListener('submit', event => { event.preventDefault(); app.token = new FormData(event.currentTarget).get('token').trim(); localStorage.setItem('gree_controller_token', app.token); if (app.ws) app.ws.close(); loadBootstrap(); }); $('#discoverForm').addEventListener('submit', async event => { event.preventDefault(); const form=event.currentTarget, raw=Object.fromEntries(new FormData(form)); const submit=form.querySelector('button[type=submit]'); submit.disabled=true; submit.textContent=tr('actions.discovering'); try { const result=await api('/api/discovery',{method:'POST',body:{protocol_version:Number(raw.protocol_version),passes:Number(raw.passes),timeout_ms:Number(raw.timeout_ms)}}); form.closest('dialog').close(); await loadBootstrap(); toast(tr('toast.found',{count:result.count})); showDiscoveryNames(result.new_device_ids || []); } catch(error){toast(error.message,true);} finally {submit.disabled=false;submit.textContent=tr('actions.discover');} }); $('#discoveryNamesForm').addEventListener('submit', async event => { event.preventDefault(); const form = event.currentTarget; const inputs = $$('input[data-device-id]', form); try { const updated = await Promise.all(inputs.map(input => api(`/api/devices/${encodeURIComponent(input.dataset.deviceId)}`, {method:'PATCH', body:{name:input.value.trim()}}))); updated.forEach(updateDevice); form.closest('dialog').close(); renderAll(); toast(tr('common.saved')); } catch (error) { toast(error.message, true); } }); $('#renameDeviceForm').addEventListener('submit', async event => { event.preventDefault(); const form=event.currentTarget, raw=Object.fromEntries(new FormData(form)); try { const device=await api(`/api/devices/${encodeURIComponent(raw.id)}`,{method:'PATCH',body:{name:raw.name.trim(),protocol_version:Number(raw.protocol_version)}}); updateDevice(device); form.closest('dialog').close(); renderAll(); toast(tr('common.saved')); } catch(error){toast(error.message,true);} }); $('#deviceForm').addEventListener('submit', async event => { event.preventDefault(); const form=event.currentTarget, data=Object.fromEntries(new FormData(form)); data.port=Number(data.port); data.protocol_version=Number(data.protocol_version); data.simulated=form.simulated.checked; try { await api('/api/devices',{method:'POST',body:data}); form.closest('dialog').close(); form.reset(); await loadBootstrap(); toast(tr('devices.added')); } catch(error){toast(error.message,true);} }); $('#zoneForm').addEventListener('submit', async event => { event.preventDefault(); const form=event.currentTarget, raw=Object.fromEntries(new FormData(form)); const id=form.dataset.editingZoneId || raw.id; const body={ name:raw.name,device_id:raw.device_id,enabled:form.enabled.checked, mode:raw.mode_policy==='house'?'cool':raw.mode_policy,inherit_house_mode:raw.mode_policy==='house',setpoint:parseDecimal(raw.setpoint), cool_comfort_setpoint:parseDecimal(raw.cool_comfort_setpoint),cool_sleep_setpoint:parseDecimal(raw.cool_sleep_setpoint),cool_away_setpoint:parseDecimal(raw.cool_away_setpoint), heat_comfort_setpoint:parseDecimal(raw.heat_comfort_setpoint),heat_sleep_setpoint:parseDecimal(raw.heat_sleep_setpoint),heat_away_setpoint:parseDecimal(raw.heat_away_setpoint), hysteresis:parseDecimal(raw.hysteresis),min_on_seconds:Number(raw.min_on_seconds),min_off_seconds:Number(raw.min_off_seconds), min_adjust_seconds:Number(raw.min_adjust_seconds),standby_offset_c:parseDecimal(raw.standby_offset_c),smart_fan:form.smart_fan.checked, sensor_source:raw.sensor_source,ha_entity_id:raw.ha_entity_id||null,external_sensor_weight:Number(raw.external_sensor_weight_percent)/100,max_sensor_difference:parseDecimal(raw.max_sensor_difference) }; try { await api(id?`/api/zones/${encodeURIComponent(id)}`:'/api/zones',{method:id?'PUT':'POST',body}); form.closest('dialog').close(); form.reset(); await loadBootstrap(); toast(tr('common.saved')); } catch(error){toast(error.message,true);} }); $('#groupForm')?.addEventListener('submit', async event => { event.preventDefault(); const form=event.currentTarget, raw=Object.fromEntries(new FormData(form)); const id=raw.id || ''; const zone_ids=$$('input[name=zone_ids]:checked', form).map(input => input.value); if (!zone_ids.length) return toast(tr('groups.chooseMember'), true); const existing=id ? app.groups.find(group => group.id===id) : null; const body={name:raw.name.trim(),zone_ids,power_enabled:existing?.power_enabled !== false}; try { await api(id?`/api/groups/${encodeURIComponent(id)}`:'/api/groups',{method:id?'PUT':'POST',body}); form.closest('dialog').close(); form.reset(); await loadBootstrap(); toast(tr('common.saved')); } catch(error){toast(error.message,true);} }); $('#scheduleForm').addEventListener('submit', async event => { event.preventDefault(); const form=event.currentTarget, raw=Object.fromEntries(new FormData(form)); const id=raw.id, weekdays=$$('[name=weekday]:checked',form).map(v=>Number(v.value)); const body={name:raw.name,zone_id:raw.zone_id,enabled:form.enabled.checked,weekdays,start_time:raw.start_time,end_time:raw.end_time,preset:raw.preset,setpoint:parseDecimal(raw.setpoint||23)}; try { await api(id?`/api/schedules/${encodeURIComponent(id)}`:'/api/schedules',{method:id?'PUT':'POST',body}); form.closest('dialog').close(); form.reset(); await loadBootstrap(); toast(tr('common.saved')); } catch(error){toast(error.message,true);} }); $('#automationForm').addEventListener('submit', async event => { event.preventDefault(); const form=event.currentTarget, raw=Object.fromEntries(new FormData(form)); const id=raw.id; const groupTarget=raw.action_target_kind==='group'; const action={}; if(raw.action_power!=='') action.power=raw.action_power==='true'; if(raw.action_mode) action.mode=raw.action_mode; if(!groupTarget && raw.action_target_temperature!=='') action.target_temperature=parseDecimal(raw.action_target_temperature); const body={name:raw.name,enabled:form.enabled.checked,trigger_kind:raw.trigger_kind,trigger_device_id:raw.trigger_device_id||null,threshold:raw.threshold===''?null:parseDecimal(raw.threshold),at_time:raw.at_time||null,action_device_id:groupTarget?'':raw.action_device_id,action_group_id:groupTarget?(raw.action_group_id||null):null,action_preset:groupTarget?(raw.action_preset||null):null,action,cooldown_seconds:Number(raw.cooldown_seconds)}; try { await api(id?`/api/automations/${encodeURIComponent(id)}`:'/api/automations',{method:id?'PUT':'POST',body}); form.closest('dialog').close(); form.reset(); await loadBootstrap(); toast(tr('common.saved')); } catch(error){toast(error.message,true);} }); function currentSettingsBody() { const settings = app.settings || {}; const influx = settings.influxdb || {}; const ha = settings.home_assistant || {}; return { controller_id: settings.controller_id || 'gree-controller', simulator_enabled: !!settings.simulator_enabled, poll_interval_seconds: Number(settings.poll_interval_seconds || 15), zone_interval_seconds: Number(settings.zone_interval_seconds || 5), discovery_timeout_ms: Number(settings.discovery_timeout_ms || 3000), discovery_broadcast: settings.discovery_broadcast || '255.255.255.255:7000', house_mode: settings.house_mode || 'cool', house_power_enabled: settings.house_power_enabled !== false, control_strategy: 'setpoint', outdoor_assist_enabled: !!settings.outdoor_assist_enabled, history_retention_days: Number(settings.history_retention_days || 30), history_compaction_enabled: settings.history_compaction_enabled !== false, event_log_retention_days: Number(settings.event_log_retention_days || 30), suppress_device_beep: !!settings.suppress_device_beep, notifications: { enabled: !!settings.notifications?.enabled, mode: settings.notifications?.mode || 'problems', provider: settings.notifications?.provider || 'pushover', pushover_app_token: '', pushover_user_key: '', slack_webhook_url: '', discord_webhook_url: '', cooldown_seconds: Number(settings.notifications?.cooldown_seconds || 300), communication_failure_threshold: Number(settings.notifications?.communication_failure_threshold || 3), target_timeout_minutes: Number(settings.notifications?.target_timeout_minutes || 60), }, night_mode: { enabled: !!settings.night_mode?.enabled, start_time: settings.night_mode?.start_time || '22:00', end_time: settings.night_mode?.end_time || '06:00', max_fan_speed: Number(settings.night_mode?.max_fan_speed || 1), force_quiet: settings.night_mode?.force_quiet !== false, use_native_sleep: settings.night_mode?.use_native_sleep !== false, }, influxdb: { enabled: !!influx.enabled, version: String(influx.version || '2'), url: influx.url || '', database: influx.database || 'gree_controller', username: influx.username || '', password: '', org: influx.org || '', bucket: influx.bucket || 'gree_controller', token: '', history_threshold_days: Number(influx.history_threshold_days || 30), }, debug: { overlay_enabled: !!settings.debug?.overlay_enabled, gree_frames: !!settings.debug?.gree_frames, }, home_assistant: { url: ha.url || '', token: '', default_entity_id: ha.default_entity_id || '', outdoor_entity_id: ha.outdoor_entity_id || '', allow_invalid_tls: !!ha.allow_invalid_tls, sensor_aliases: {...(ha.sensor_aliases || {})}, }, }; } function settingsBodyFromForm(form) { const raw = Object.fromEntries(new FormData(form)); const body = currentSettingsBody(); body.controller_id = raw.controller_id; body.simulator_enabled = form.simulator_enabled.checked; body.poll_interval_seconds = Number(raw.poll_interval_seconds); body.zone_interval_seconds = Number(raw.zone_interval_seconds); body.discovery_timeout_ms = Number(raw.discovery_timeout_ms); body.discovery_broadcast = raw.discovery_broadcast; body.history_retention_days = Number(raw.history_retention_days); body.history_compaction_enabled = form.history_compaction_enabled.checked; body.event_log_retention_days = Number(raw.event_log_retention_days); body.suppress_device_beep = form.suppress_device_beep.checked; body.influxdb = { enabled: form.influx_enabled.checked, version: raw.influx_version, url: raw.influx_url, database: raw.influx_database, username: raw.influx_username, password: raw.influx_password, org: raw.influx_org, bucket: raw.influx_bucket, token: raw.influx_token, history_threshold_days: Number(raw.influx_threshold_days), }; body.debug = {overlay_enabled: form.debug_overlay_enabled.checked, gree_frames: form.debug_gree_frames.checked}; body.notifications = { enabled: form.notifications_enabled.checked, mode: raw.notifications_mode, provider: raw.notifications_provider, pushover_app_token: raw.pushover_app_token || '', pushover_user_key: raw.pushover_user_key || '', slack_webhook_url: raw.slack_webhook_url || '', discord_webhook_url: raw.discord_webhook_url || '', cooldown_seconds: Number(raw.notification_cooldown_seconds || 300), communication_failure_threshold: Number(raw.notification_failure_threshold || 3), target_timeout_minutes: Number(raw.notification_target_timeout || 60), }; return body; } function nightSettingsBodyFromForm(form) { const raw = Object.fromEntries(new FormData(form)); const body = currentSettingsBody(); body.night_mode = { enabled: form.night_mode_enabled.checked, start_time: raw.night_mode_start, end_time: raw.night_mode_end, max_fan_speed: Number(raw.night_mode_max_fan_speed), force_quiet: form.night_mode_force_quiet.checked, use_native_sleep: form.night_mode_native_sleep.checked, }; return body; } function homeAssistantSettingsBodyFromForm(form) { const raw = Object.fromEntries(new FormData(form)); const body = currentSettingsBody(); body.outdoor_assist_enabled = form.outdoor_assist_enabled.checked; body.home_assistant = { url: raw.ha_url, token: raw.ha_token, default_entity_id: raw.ha_entity_id, outdoor_entity_id: raw.ha_outdoor_entity_id, allow_invalid_tls: form.ha_allow_invalid_tls.checked, sensor_aliases: {...(app.sensorAliases || {})}, }; return body; } async function saveRuntimeSettings(body, notify = true) { app.settings = await api('/api/settings', {method:'PUT', body}); app.sensorAliases = {...(app.settings?.home_assistant?.sensor_aliases || {})}; renderSettings(); renderNightSettings(); renderHomeAssistantSettings(); renderHouseClimate(); renderDebugOverlay(); scheduleControlPlanLoad(); if (app.settings?.debug?.overlay_enabled) loadDebugBacklog(); if (notify) toast(tr('common.saved')); return app.settings; } $('#settingsForm').addEventListener('submit', async event => { event.preventDefault(); try { await saveRuntimeSettings(settingsBodyFromForm(event.currentTarget), true); } catch(error) { toast(error.message, true); } }); $('#nightModeForm')?.addEventListener('submit', async event => { event.preventDefault(); try { await saveRuntimeSettings(nightSettingsBodyFromForm(event.currentTarget), true); } catch(error) { toast(error.message, true); } }); $('#homeAssistantForm')?.addEventListener('submit', async event => { event.preventDefault(); try { await saveRuntimeSettings(homeAssistantSettingsBodyFromForm(event.currentTarget), true); } catch(error) { toast(error.message, true); } }); $('#addSensorAlias')?.addEventListener('click', () => { const entityInput = $('#sensorAliasEntity'), aliasInput = $('#sensorAliasName'); const entity = entityInput.value.trim(), alias = aliasInput.value.trim(); if (!entity || !alias) return toast(tr('settings.aliasRequired'), true); app.sensorAliases[entity] = alias; entityInput.value = ''; aliasInput.value = ''; renderSensorAliases(); renderHistoryNavigation(); }); document.addEventListener('input', event => { const input = event.target.closest('[data-sensor-alias]'); if (!input) return; const entity = input.dataset.sensorAlias, alias = input.value.trim(); if (alias) app.sensorAliases[entity] = alias; else delete app.sensorAliases[entity]; }); document.addEventListener('click', event => { const button = event.target.closest('[data-clear-sensor-alias]'); if (!button) return; delete app.sensorAliases[button.dataset.clearSensorAlias]; renderSensorAliases(); renderHistoryNavigation(); }); $('#saveLogRetention')?.addEventListener('click', async () => { const days = Number($('#logRetentionDays')?.value || 30); try { const result = await api('/api/events/retention', {method:'PUT', body:{days}}); app.settings.event_log_retention_days = result.days; $('#settingsForm').event_log_retention_days.value = result.days; renderLogRetention(); await loadLogs(); toast(tr('logs.retentionSaved', {days: result.days})); } catch (error) { toast(error.message, true); } }); $('#createAccessToken').addEventListener('click', async event => { const button = event.currentTarget; button.disabled = true; try { const result = await api('/api/access-tokens', {method:'POST', body:{name:'Home Assistant'}}); if (result.item) app.accessTokens.unshift(result.item); renderAccessTokens(); $('#generatedAccessToken').value = result.token || ''; openDialog('generatedTokenDialog'); toast(tr('toast.tokenCreated')); } catch (error) { toast(error.message, true); } finally { button.disabled = false; } }); $('#copyAccessToken').addEventListener('click', async () => { const input = $('#generatedAccessToken'); try { await navigator.clipboard.writeText(input.value); toast(tr('toast.tokenCopied')); } catch (_) { input.select(); document.execCommand('copy'); toast(tr('toast.tokenCopied')); } }); $('#haTest').addEventListener('click', async () => { const form=$('#homeAssistantForm'); try { await saveRuntimeSettings(homeAssistantSettingsBodyFromForm(form), false); const entity = form.ha_entity_id.value || form.ha_outdoor_entity_id.value || null; const result=await api('/api/integrations/home-assistant/test',{method:'POST',body:{entity_id:entity}}); toast(tr('toast.haTemperature',{temperature:result.temperature_c.toFixed(1)})); } catch(error){toast(error.message,true);} }); $('#simulationRefresh')?.addEventListener('click', async () => { try { await loadControlPlan(); toast(tr('common.updated')); } catch (error) { toast(error.message, true); } }); $('#exportSettings').addEventListener('click', async () => { try { const data = await api('/api/settings/export'); const blob = new Blob([JSON.stringify(data, null, 2)], {type:'application/json'}); const link = document.createElement('a'); link.href = URL.createObjectURL(blob); link.download = `gree-controller-settings-${new Date().toISOString().slice(0,10)}.json`; document.body.appendChild(link); link.click(); link.remove(); URL.revokeObjectURL(link.href); toast(tr('toast.exported')); } catch (error) { toast(error.message, true); } }); $('#importSettings').addEventListener('click', () => $('#importSettingsFile').click()); $('#importSettingsFile').addEventListener('change', async event => { const file = event.target.files?.[0]; if (!file) return; try { if (!confirm(tr('settings.importConfirm'))) return; const body = JSON.parse(await file.text()); await api('/api/settings/import', {method:'POST', body}); app.debugBacklogLoaded = false; await loadBootstrap(); toast(tr('toast.imported')); } catch (error) { toast(error.message, true); } finally { event.target.value = ''; } }); document.addEventListener('change', event => { const target=event.target; if(target.id==='historyZoneSelect'){app.historyZone=target.value;updateBrowserUrl(currentHistoryPath());renderHistoryPage();} else if(target.id==='historyDeviceSelect'){app.historyDevice=target.value;updateBrowserUrl(currentHistoryPath());renderHistoryPage();} else if(target.id==='historySensorSelect'){app.historySensor=target.value;updateBrowserUrl(currentHistoryPath());renderHistoryPage();} else if(target.name==='influx_version') updateInfluxFields(); }); window.addEventListener('popstate', applyRouteFromLocation); let historyResizeTimer; window.addEventListener('resize', () => { if (app.currentView === 'history') { clearTimeout(historyResizeTimer); historyResizeTimer=setTimeout(drawCurrentChartIfVisible,120); } }); window.matchMedia('(prefers-color-scheme: light)').addEventListener('change', () => { if (app.theme === 'system') applyTheme(); }); if ('serviceWorker' in navigator) window.addEventListener('load', () => navigator.serviceWorker.register('/sw.js').catch(()=>{})); async function startApplication() { applyTheme(); await loadLanguages(); applyTranslations(); updateZoneSensorFields(); updateSchedulePresetField(); await loadBootstrap(); applyRouteFromLocation(); if (location.pathname === '/' || location.pathname === `${APP_BASE}/`) updateBrowserUrl('/dashboard', true); } startApplication().catch(error => console.error('Application startup failed:', error));