'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: [], deviceGroups: [], deviceGroupEnergy: {}, schedules: [], automations: [], flows: [], accessTokens: [], settings: null, system: {}, outdoorTemperature: null, token: localStorage.getItem('gree_controller_token') || '', ws: null, wsTimer: null, currentView: 'dashboard', loading: false, bootstrapReloadPending: false, language: preferredLanguage, theme: preferredTheme, connectionStatus: 'connecting', languages: [], translations: {}, locales: {}, historyTab: 'overview', historyData: { zones: [], devices: [], sensors: [] }, historyCounts: {}, historyZone: 'all', historyDevice: 'all', historySensor: 'all', historyNetworkTarget: 'all', historyNetworkShowJitter: true, historyNetwork: [], historyNetworkTargets: [], historyEnergyDevice: '', historyEnergyTargets: [], historyEnergyInterval: 'daily', historyEnergyCompare: 'none', historyEnergy: [], historyLoading: false, historyReloadPending: false, customChartSeries: [], savedCharts: [], customChartEditingId: null, customChartNameDraft: null, 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, haManualFallbackVisible: false, haSupervisorTestState: null, haEntityCatalog: [], haEntityCatalogConfigured: false, haEntityCatalogLoadedAt: 0, haEntityCatalogLoading: false, 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 UI_ICON_PATHS = Object.freeze({ 'arrow-left': '', 'automation': '', 'check': '', 'chevron-down': '', 'chevron-right': '', 'circle': '', 'close': '', 'devices': '', 'edit': '', 'events': '', 'expand-vertical': '', 'fit': '', 'flow': '', 'fullscreen': '', 'groups': '', 'history': '', 'home': '', 'integration': '', 'menu': '', 'minus': '', 'moon': '', 'more-horizontal': '', 'play': '', 'plus': '', 'power': '', 'refresh': '', 'schedule': '', 'settings': '', 'sliders': '', 'star': '', 'star-filled': '', 'status-dot': '', 'sun': '', 'system': '', 'system-info': '', 'zones': '' }); function uiIcon(name, className = '') { const body = UI_ICON_PATHS[name]; if (!body) return ''; const classes = ['ui-icon', className].filter(Boolean).join(' '); return ``; } function hydrateUiIcons(root = document) { $$('[data-ui-icon]', root).forEach(node => { const name = node.dataset.uiIcon; const className = node.dataset.uiIconClass || ''; node.innerHTML = uiIcon(name, className); }); } 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 deviceCommandFieldLabel = key => { const translationKey = { fan_speed: 'common.fan', swing_vertical: 'devices.swingVertical', swing_horizontal: 'devices.swingHorizontal', quiet: 'devices.quiet', turbo: 'devices.turbo', light: 'devices.light', air: 'devices.air', xfan: 'devices.xfan', health: 'devices.health', sleep: 'devices.sleep', }[key]; return translationKey ? tr(translationKey) : key; }; 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; const zoneHaEntityId = zone => zone?.ha_entity_id || ''; 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 iconName = { system: 'system', light: 'sun', dark: 'moon' }[app.theme] || 'system'; if (themeIcon) themeIcon.innerHTML = uiIcon(iconName); 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(); } async function loadLanguagePack(language) { if (app.translations[language]) return; const item = app.languages.find(entry => entry.code === language); if (!item) throw new Error(`Unknown language: ${language}`); const response = await fetch(withBase(item.path || `/lang/${encodeURIComponent(item.code)}.json`)); if (!response.ok) throw new Error(`Language ${item.code} HTTP ${response.status}`); const pack = await response.json(); app.translations[item.code] = pack.translations || {}; app.locales[item.code] = pack.meta?.locale || item.locale || item.code; } async function setLanguage(language) { const available = app.languages.some(item => item.code === language); const nextLanguage = available ? language : DEFAULT_LANGUAGE; const previousLanguage = app.language; const select = $('#languageSelect'); if (nextLanguage === previousLanguage && app.translations[nextLanguage]) return; if (select) select.disabled = true; try { await loadLanguagePack(nextLanguage); app.language = nextLanguage; setCookie('gree_controller_language', app.language); applyTranslations(); } catch (error) { console.error(`Unable to load language ${nextLanguage}:`, error); if (select) select.value = previousLanguage; toast(error.message, true); } finally { if (select) select.disabled = false; } } 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 response = await fetch(withBase('/lang/index.json')); 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'); app.languages = languages; app.translations = {}; app.locales = Object.fromEntries(languages.map(item => [item.code, 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; try { await loadLanguagePack(app.language); } catch (error) { if (app.language === DEFAULT_LANGUAGE) throw error; console.error(`Unable to load preferred language ${app.language}:`, error); app.language = DEFAULT_LANGUAGE; await loadLanguagePack(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(); }