'use strict'; const APP_BASE = (() => { const src = document.currentScript?.src || ''; try { const path = new URL(src, location.href).pathname; const slash = path.lastIndexOf('/'); const filename = path.slice(slash + 1); return /^app(?:-[a-f0-9]+)?\.js$/i.test(filename) ? path.slice(0, slash).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: [], chartZooms: {}, chartHiddenSeries: {}, zoneControlSeq: {}, groupControlSeq: {}, zoneControlQueue: {}, groupControlQueue: {}, deviceControlQueue: {}, houseControlQueue: null, climateControlQueue: null, groupCustomDrafts: {}, zoneTemperatureTimers: {}, controlPlan: null, controlPlanTimer: null, debugLines: [], debugBacklogLoaded: false, debugFilter: 'all', sensorAliases: {}, simulationScope: 'units', simulationTarget: 'all', standaloneSimulation: false, dashboardTab: 'main', settingsTab: 'app', systemSnapshotAt: Date.now(), }; 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 zoneHysteresisForMode = (zone, mode) => { const common = Number(zone?.hysteresis ?? 0.6); if (!zone?.separate_hysteresis) return Number.isFinite(common) ? common : 0.6; const value = Number(mode === 'heat' ? zone?.heat_hysteresis : zone?.cool_hysteresis); return Number.isFinite(value) ? value : (Number.isFinite(common) ? common : 0.6); }; 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 localResumeSeconds = value => { const timestamp = value ? new Date(value).getTime() : NaN; return Number.isFinite(timestamp) ? Math.max(0, Math.ceil((timestamp - Date.now()) / 1000)) : null; }; const formatCountdown = seconds => { if (!Number.isFinite(seconds)) return '--:--'; const safe = Math.max(0, Math.floor(seconds)); const minutes = Math.floor(safe / 60); const remainder = safe % 60; return `${String(minutes).padStart(2,'0')}:${String(remainder).padStart(2,'0')}`; }; const formatExtendedCountdown = seconds => { if (!Number.isFinite(seconds)) return '∞'; const safe = Math.max(0, Math.floor(seconds)); const hours = Math.floor(safe / 3600); const minutes = Math.floor((safe % 3600) / 60); const remainder = safe % 60; return hours > 0 ? `${String(hours).padStart(2,'0')}:${String(minutes).padStart(2,'0')}:${String(remainder).padStart(2,'0')}` : `${String(minutes).padStart(2,'0')}:${String(remainder).padStart(2,'0')}`; }; function updateLocalThermostatCountdowns() { $$('[data-local-resume-countdown]').forEach(node => { const remaining = localResumeSeconds(node.dataset.localResumeCountdown); node.textContent = remaining !== null && remaining > 0 ? tr('zones.localThermostatOffDescriptionTimed', {time:formatCountdown(remaining)}) : tr('zones.localThermostatResuming'); }); } function temporarySessionStatus(zone) { const session = zone?.temporary_quick_thermostat; if (!session) return null; const now = Date.now(); const secondsUntil = value => { const timestamp = value ? new Date(value).getTime() : NaN; return Number.isFinite(timestamp) ? Math.max(0, Math.ceil((timestamp - now) / 1000)) : null; }; const safetyRemaining = secondsUntil(session.safety_expires_at); const hardRemaining = secondsUntil(session.expires_at); const startRemaining = secondsUntil(session.started_at); const pending = !session.activated_at; const target = Number(session.temperature_target ?? zone.effective_setpoint ?? zone.setpoint); const targetText = Number.isFinite(target) ? target.toFixed(1) : '--'; const tolerance = Number(session.tolerance_c ?? 0.3).toFixed(1); const operatorKey = ({within:'zones.temporaryWithinShort',at_or_below:'zones.temporaryAtOrBelowShort',at_or_above:'zones.temporaryAtOrAboveShort'})[session.temperature_operator || 'within'] || 'zones.temporaryWithinShort'; const condition = tr(operatorKey, {target:targetText, tolerance}); if (session.state === 'waiting_master') { return {countdown:startRemaining != null ? formatExtendedCountdown(startRemaining) : '00:00', detail:tr('zones.temporaryWaitingMaster'), kind:session.finish_kind, pending:true}; } if (session.state === 'paused_manual') { return {countdown:'—', detail:tr('zones.temporaryPausedManual'), kind:session.finish_kind, pending}; } if (pending) { return { countdown:startRemaining != null && startRemaining > 0 ? formatExtendedCountdown(startRemaining) : '00:00', detail:startRemaining != null && startRemaining > 0 ? tr('zones.temporaryScheduledStatus', {time:dateTime(session.started_at)}) : tr('zones.temporaryStartingStatus'), kind:session.finish_kind, pending:true, }; } if (session.finish_kind === 'temperature_stable') { const started = session.condition_started_at ? new Date(session.condition_started_at).getTime() : NaN; const holdSeconds = Number(session.hold_seconds || 0); if (Number.isFinite(started)) { const holdRemaining = Math.max(0, Math.ceil((started + holdSeconds * 1000 - now) / 1000)); const effectiveRemaining = safetyRemaining == null ? holdRemaining : Math.min(holdRemaining, safetyRemaining); return {countdown:formatExtendedCountdown(effectiveRemaining), detail:tr('zones.temporaryHoldingStatus', {condition}), kind:session.finish_kind}; } return {countdown:safetyRemaining == null ? '∞' : formatExtendedCountdown(safetyRemaining), detail:tr('zones.temporaryWaitingStable', {condition}), kind:session.finish_kind}; } if (session.finish_kind === 'temperature_reached') { return {countdown:safetyRemaining == null ? '∞' : formatExtendedCountdown(safetyRemaining), detail:tr('zones.temporaryWaitingReached', {condition}), kind:session.finish_kind}; } const detailKey = session.finish_kind === 'schedule_boundary' ? 'zones.temporaryUntilScheduleStatus' : 'zones.temporaryTimeStatus'; return {countdown:formatExtendedCountdown(hardRemaining), detail:tr(detailKey), kind:session.finish_kind}; } function updateTemporaryThermostatCountdowns() { $$('[data-temporary-countdown-zone]').forEach(node => { const zone = app.zones.find(item => item.id === node.dataset.temporaryCountdownZone); const status = temporarySessionStatus(zone); if (!status) return; node.textContent = status.countdown; node.title = status.detail; }); const dialog = $('#temporaryThermostatDialog'); if (dialog?.open) { const zone = app.zones.find(item => item.id === $('#temporaryThermostatForm')?.elements.zone_id.value); const status = temporarySessionStatus(zone); if (status) { $('#temporaryThermostatActiveCountdown').textContent = status.countdown; $('#temporaryThermostatActiveDetail').textContent = status.detail; const label = $('#temporaryThermostatActiveLabel'); if (label) label.textContent = tr(status.pending ? 'zones.temporaryScheduled' : 'zones.temporaryActive'); } } } const haSensorLabel = entity => app.sensorAliases?.[entity] || app.settings?.home_assistant?.sensor_aliases?.[entity] || entity; function syncTopbarHeight() { const topbar = $('.topbar'); if (!topbar) return; document.documentElement.style.setProperty('--topbar-height', `${Math.ceil(topbar.getBoundingClientRect().height)}px`); } function observeTopbarHeight() { const topbar = $('.topbar'); if (!topbar) return; syncTopbarHeight(); if ('ResizeObserver' in window) { const observer = new ResizeObserver(syncTopbarHeight); observer.observe(topbar); } else { window.addEventListener('resize', syncTopbarHeight); } } function updateConnectionIndicator(status) { app.connectionStatus = status || 'connecting'; const node = $('#connectionLabel'); const label = tr(`status.${app.connectionStatus}`); if (node) { node.className = `connection-dot ${app.connectionStatus}`; node.setAttribute('aria-label', label); node.title = label; } const banner = $('#connectionBanner'); const disconnected = ['disconnected','connectionError'].includes(app.connectionStatus); document.body.classList.toggle('connection-lost', disconnected); if (banner) { banner.hidden = !disconnected; const title = $('#connectionBannerTitle'); const text = $('#connectionBannerText'); if (title) title.textContent = label; if (text) text.textContent = tr('status.offlineHint'); } renderSystemInfo(); } function updateToolbarControls() { const languageLabel = $('#languageLabel'); if (languageLabel) languageLabel.textContent = String(app.language || DEFAULT_LANGUAGE).toUpperCase(); const themeSelect = $('#themeSelect'); const themeIcon = $('#themeIcon'); const icons = {system:'◐',light:'☀',dark:'☾'}; if (themeIcon) themeIcon.textContent = icons[app.theme] || icons.system; if (themeSelect) { themeSelect.value = app.theme; const label = `${tr('controls.theme')}: ${tr(`theme.${app.theme}`)}`; themeSelect.setAttribute('aria-label', label); themeSelect.closest('.toolbar-picker')?.setAttribute('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'; updateToolbarControls(); 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; updateToolbarControls(); 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(); return ``; }).join(''); select.value = app.language; updateToolbarControls(); } async function loadLanguages() { try { // Language packs must never come from a stale Service Worker/browser cache. // A cache-busting nonce is intentional: translations can change without the JS asset hash changing. const languageNonce = Date.now().toString(36); const languageUrl = path => { const separator = String(path).includes('?') ? '&' : '?'; return `${withBase(path)}${separator}v=${languageNonce}`; }; const response = await fetch(languageUrl('/lang/index.json'), {cache: 'no-store'}); 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(languageUrl(item.path || `/lang/${encodeURIComponent(item.code)}.json`), {cache: 'no-store'}); 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(); } function cycleTheme() { const modes = ['system', 'light', 'dark']; const index = modes.indexOf(app.theme); setTheme(modes[(index + 1) % modes.length]); } 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(); }