'use strict'; 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=/; 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: [], schedules: [], automations: [], accessTokens: [], settings: null, system: {}, token: localStorage.getItem('gree_controller_token') || '', ws: null, wsTimer: null, currentView: 'dashboard', loading: false, language: preferredLanguage, theme: preferredTheme, connectionStatus: 'connecting', languages: [], translations: {}, locales: {}, }; 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 => Number.isFinite(Number(value)) ? `${Number(value).toFixed(1)}°C` : '--'; const modeLabel = mode => tr(`mode.${mode}`) === `mode.${mode}` ? mode : tr(`mode.${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)) : '—'; 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' ? '#f5f7f6' : '#0a1110'; 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; $('#themeSelect').value = app.theme; const connectionLabel = $('#connectionLabel'); if (connectionLabel) connectionLabel.textContent = tr(`status.${app.connectionStatus}`); renderAll(); if (app.currentView === 'logs') loadLogs(); if (app.currentView === 'history' && app.devices.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; } async function loadLanguages() { try { const response = await fetch('/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(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(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 node = $('#toast'); node.textContent = message; node.className = error ? 'show error' : 'show'; clearTimeout(node._timer); node._timer = setTimeout(() => node.className = '', 3200); } 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.schedules = data.schedules || []; app.automations = data.automations || []; app.accessTokens = data.access_tokens || []; app.settings = data.settings || null; app.system = data.system || {}; renderAll(); 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(); renderDevices(); renderZones(); renderSchedules(); renderAutomations(); renderAccessTokens(); fillSelects(); renderSettings(); } 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 deviceCard(device, detailed = false) { const modes = ['auto','cool','dry','fan','heat']; const fans = [0,1,3,5]; const error = device.last_error ? `${esc(device.last_error)}` : `${esc(device.ip)}:${esc(device.port)} · ${device.simulated ? tr('devices.simulator') : `V${device.protocol_version}`}`; 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)}`}
${modes.map(mode => ``).join('')}
${fans.map(fan => ``).join('')}
${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; } 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 renderZones() { $('#zoneList').innerHTML = app.zones.length ? app.zones.map(zone => { 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)}`; return `

${esc(zone.name)}

${esc(device?.name || tr('common.noDevice'))} · ${esc(zoneStrategyLabel(zone))}${zone.ha_entity_id ? ` · ${esc(zone.ha_entity_id)}` : ''}

${esc(state)}
${esc(tr('zones.measurement'))}${fmtTemp(zone.current_temperature)}
${esc(tr('common.target'))}${fmtTemp(zone.setpoint)}
${esc(tr('zones.demand'))}${esc(tr(zone.demand ? 'common.on' : 'common.off'))}
${esc(sensorDetails)}
`; }).join('') : `
${esc(tr('zones.emptyTitle'))}${esc(tr('zones.emptyText'))}
`; } 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('common.target'))}${fmtTemp(item.setpoint)}
`; }).join('') : `
${esc(tr('schedules.emptyTitle'))}${esc(tr('schedules.emptyText'))}
`; } function renderAutomations() { const triggerLabel = item => item.trigger_kind === 'time' ? tr('automations.triggerAt', {time: item.at_time}) : tr(item.trigger_kind === 'temperature_above' ? 'automations.triggerAbove' : 'automations.triggerBelow', {temperature: fmtTemp(item.threshold)}); $('#automationList').innerHTML = app.automations.length ? app.automations.map(item => { const actionDevice = app.devices.find(d => d.id === item.action_device_id); return `

${esc(item.name)}

${esc(tr('automations.triggerSummary', {trigger: triggerLabel(item), device: actionDevice?.name || tr('common.noDevice')}))}

${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'))}${item.action.mode ? esc(modeLabel(item.action.mode)) : '—'}
${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(''); ['#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 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 history = $('#historyDevice'); if (history) { const historyCurrent = history.value; history.innerHTML = deviceOptions; if ([...history.options].some(o => o.value === historyCurrent)) history.value = historyCurrent; } } 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 renderSettings() { if (!app.settings) return; const form = $('#settingsForm'); 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.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 || ''; $('#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 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`; } function showView(name) { 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' && ['schedules','automations','settings','logs'].includes(name)))); window.scrollTo({top: 0, behavior: 'smooth'}); if (name === 'history' && app.devices.length) loadHistory(); if (name === 'logs') loadLogs(); } 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 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 populateZone(id) { const item = app.zones.find(v => v.id === id); if (!item) return; const form = $('#zoneForm'); form.reset(); fillSelects(); Object.entries(item).forEach(([key,value]) => { if (form.elements[key] && value != null && typeof value !== 'object') form.elements[key].value = value; }); 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.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'].forEach(key => form.elements[key].value = item[key]); form.enabled.checked = item.enabled; $$('[name=weekday]', form).forEach(input => input.checked = item.weekdays.includes(Number(input.value))); 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]; }); form.action_power.value = item.action.power == null ? '' : String(item.action.power); form.action_mode.value = item.action.mode || ''; form.action_target_temperature.value = item.action.target_temperature ?? ''; form.enabled.checked = item.enabled; openDialog('automationDialog'); } async function loadHistory() { const deviceId = $('#historyDevice').value; if (!deviceId) return drawChart([]); try { const data = await api(`/api/readings?device_id=${encodeURIComponent(deviceId)}&hours=${encodeURIComponent($('#historyHours').value)}&limit=2500`); drawChart(data.readings || []); } catch (error) { toast(error.message, true); } } function drawChart(readings) { const canvas = $('#historyChart'); const rect = canvas.getBoundingClientRect(); const width = Math.max(680, Math.floor(rect.width || 680)); const height = 340; 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.scale(dpr, dpr); const styles = getComputedStyle(document.documentElement); const text = styles.getPropertyValue('--muted').trim(), grid = styles.getPropertyValue('--grid').trim(), indoor = styles.getPropertyValue('--accent').trim(), target = styles.getPropertyValue('--warning').trim(); ctx.clearRect(0,0,width,height); const pad = {left:52,right:20,top:22,bottom:40}; if (!readings.length) { ctx.fillStyle = text; ctx.font = '14px system-ui'; ctx.textAlign='center'; ctx.fillText(tr('history.noData'), width/2, height/2); return; } const values = readings.flatMap(r => [r.indoor_temperature, r.target_temperature]).filter(Number.isFinite); let min = Math.floor(Math.min(...values) - 1), max = Math.ceil(Math.max(...values) + 1); if (max-min < 4) { min -= 2; max += 2; } const x = i => pad.left + i / Math.max(1, readings.length-1) * (width-pad.left-pad.right); const y = value => pad.top + (max-value)/(max-min)*(height-pad.top-pad.bottom); ctx.lineWidth = 1; ctx.font = '11px system-ui'; ctx.fillStyle = text; ctx.strokeStyle = grid; 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(`${value.toFixed(1)}°`,pad.left-8,py+4); } const ticks = Math.min(5, readings.length-1); for (let i=0;i<=ticks;i++) { const idx=Math.round(i*(readings.length-1)/Math.max(1,ticks)), px=x(idx); ctx.textAlign='center'; ctx.fillText(new Date(readings[idx].timestamp).toLocaleString(locale(),{day:'2-digit',month:'2-digit',hour:'2-digit',minute:'2-digit'}),px,height-13); } function line(field, color, dashed=false) { ctx.beginPath(); ctx.strokeStyle=color; ctx.lineWidth=2.3; ctx.setLineDash(dashed?[6,5]:[]); let started=false; readings.forEach((row,index)=>{ const value=Number(row[field]); if(!Number.isFinite(value)) return; if(!started){ctx.moveTo(x(index),y(value));started=true;}else ctx.lineTo(x(index),y(value)); }); ctx.stroke(); ctx.setLineDash([]); } line('target_temperature', target, true); line('indoor_temperature', indoor, false); canvas._readings = readings; } function drawCurrentChartIfVisible() { const canvas = $('#historyChart'); if (app.currentView === 'history' && canvas?._readings) drawChart(canvas._readings); } async function loadLogs() { try { const data = await api('/api/events?limit=150'); const logs = data.events || []; $('#logList').innerHTML = logs.length ? logs.map(item => `
${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}/ws${query}`); app.ws = ws; ws.onopen = () => { app.connectionStatus = 'connected'; $('#connectionLabel').textContent = tr('status.connected'); }; ws.onclose = () => { app.connectionStatus = 'disconnected'; $('#connectionLabel').textContent = tr('status.disconnected'); app.wsTimer = setTimeout(connectWebSocket, 3000); }; ws.onerror = () => { app.connectionStatus = 'connectionError'; $('#connectionLabel').textContent = tr('status.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.schedules=data.schedules||[]; app.automations=data.automations||[]; app.settings=data.settings||app.settings; app.system=data.system||app.system; renderAll(); return; } const data = message.data || {}; if (['device.updated','device.created'].includes(message.event)) { updateDevice(data); renderSummary(); renderDevices(); } else if (message.event === 'device.deleted') { app.devices=app.devices.filter(v=>v.id!==data.id); renderAll(); } else if (message.event === 'devices.discovered') { (data.devices||[]).forEach(updateDevice); renderAll(); } 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(); renderZones(); } else if (message.event === 'settings.updated') { app.settings=data; renderSettings(); } else if (message.event === 'log.created' && app.currentView === 'logs') loadLogs(); } 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) { $('#moreDialog').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') updateZoneSensorFields(); openDialog(button.dataset.open); return; } if (button.hasAttribute('data-close')) { button.closest('dialog')?.close(); return; } const action = button.dataset.action; if (!action) return; const device = app.devices.find(v => v.id === button.dataset.device); 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,32)}); 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 === 'delete-device') return deleteEntity('devices', button.dataset.device, 'label.device'); 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', async event => { const button=event.currentTarget; button.disabled=true; button.textContent=tr('actions.discovering'); try { const result=await api('/api/discovery',{method:'POST',body:{}}); await loadBootstrap(); toast(tr('toast.found',{count:result.count})); } catch(error){toast(error.message,true);} finally{button.disabled=false;button.textContent=tr('actions.discover');} }); $('#historyRefresh').addEventListener('click', loadHistory); $('#historyDevice').addEventListener('change', loadHistory); $('#historyHours').addEventListener('change', loadHistory); $('#logsRefresh').addEventListener('click', loadLogs); $('#languageSelect').addEventListener('change', event => setLanguage(event.target.value)); $('#themeSelect').addEventListener('change', event => setTheme(event.target.value)); $('#zoneForm [name=sensor_source]').addEventListener('change', updateZoneSensorFields); $('#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(); }); $('#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=raw.id; const body={name:raw.name,device_id:raw.device_id,enabled:form.enabled.checked,mode:raw.mode,setpoint:Number(raw.setpoint),hysteresis:Number(raw.hysteresis),min_on_seconds:Number(raw.min_on_seconds),min_off_seconds:Number(raw.min_off_seconds),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:Number(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);} }); $('#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,setpoint:Number(raw.setpoint)}; 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 action={}; if(raw.action_power!=='') action.power=raw.action_power==='true'; if(raw.action_mode) action.mode=raw.action_mode; if(raw.action_target_temperature!=='') action.target_temperature=Number(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:Number(raw.threshold),at_time:raw.at_time||null,action_device_id:raw.action_device_id,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);} }); $('#settingsForm').addEventListener('submit', async event => { event.preventDefault(); const form=event.currentTarget, raw=Object.fromEntries(new FormData(form)); const body={controller_id:raw.controller_id,simulator_enabled:form.simulator_enabled.checked,poll_interval_seconds:Number(raw.poll_interval_seconds),zone_interval_seconds:Number(raw.zone_interval_seconds),discovery_timeout_ms:Number(raw.discovery_timeout_ms),discovery_broadcast:raw.discovery_broadcast,home_assistant:{url:raw.ha_url,token:raw.ha_token,default_entity_id:raw.ha_entity_id}}; try { app.settings=await api('/api/settings',{method:'PUT',body}); renderSettings(); toast(tr('common.saved')); } 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=$('#settingsForm'); if (form.ha_token.value || form.ha_url.value !== (app.settings?.home_assistant?.url || '')) { form.requestSubmit(); await new Promise(resolve=>setTimeout(resolve,250)); } try { const result=await api('/api/integrations/home-assistant/test',{method:'POST',body:{entity_id:form.ha_entity_id.value||null}}); toast(tr('toast.haTemperature',{temperature:result.temperature_c.toFixed(1)})); } catch(error){toast(error.message,true);} }); window.addEventListener('resize', () => { if (app.currentView === 'history') loadHistory(); }); 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(); await loadBootstrap(); } startApplication().catch(error => console.error('Application startup failed:', error));