'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: [], flows: [], 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: {}, deviceTemperatureDrafts: {}, controlPlan: null, controlPlanRevision: null, controlPlanPushReady: false, controlPlanTimer: null, debugLines: [], debugBacklogLoaded: false, debugFilter: 'all', sensorAliases: {}, flowSharedInputs: [], flowSharedInputValueCache: {}, flowSharedInputValueRequests: {}, flowSharedInputValueTimer: null, simulationScope: 'units', simulationTarget: 'all', standaloneSimulation: false, dashboardTab: 'main', settingsTab: 'app', systemSnapshotAt: Date.now(), pingMonitor: { targetId: '', all: false, running: false, timer: null, inFlight: false, samples: {} }, flowDraft: null, flowSelectedNodeId: null, flowSelectedNodeIds: [], flowConnectFrom: null, flowDirty: false, flowZoom: 1, }; 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); const group = node.dataset.localResumeGroup || ''; node.textContent = remaining !== null && remaining > 0 ? tr(group ? 'zones.groupOffDescriptionTimed' : 'zones.localThermostatOffDescriptionTimed', { group, 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 { 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(); } 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 (_) { } const error = new Error(message); error.status = response.status; throw error; } if (response.status === 204) return null; return response.json(); }