Files
gree-controller/web/app.js
T
2026-08-23 21:34:07 +02:00

634 lines
40 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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 => ({'&':'&amp;','<':'&lt;','>':'&gt;',"'":'&#39;','"':'&quot;'}[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 `<option value="${esc(item.code)}">${esc(label)}</option>`;
}).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)}<small>°C</small>`;
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]) => `<div class="metric"><span>${esc(label)}</span><strong>${esc(value)}</strong></div>`).join('');
}
function deviceCard(device, detailed = false) {
const modes = ['auto','cool','dry','fan','heat'];
const fans = [0,1,3,5];
const error = device.last_error
? `<small title="${esc(device.last_error)}">${esc(device.last_error)}</small>`
: `<small>${esc(device.ip)}:${esc(device.port)} · ${device.simulated ? tr('devices.simulator') : `V${device.protocol_version}`}</small>`;
return `<article class="device-card ${device.power ? '' : 'off'}" data-device-card="${esc(device.id)}">
<div class="device-head">
<div class="device-title"><h3>${esc(device.name)}</h3><p><span class="status ${device.online ? 'online' : ''}">${esc(tr(device.online ? 'status.online' : 'status.offline'))}</span> · ${esc(device.model || device.mac)}</p></div>
<button class="power-button ${device.power ? 'on' : ''}" data-action="power" data-device="${esc(device.id)}" aria-label="${esc(tr('devices.power'))}">⏻</button>
</div>
<div class="temperature-control">
<button data-action="temperature" data-delta="-0.5" data-device="${esc(device.id)}"></button>
<div class="target-temp">${Number(device.target_temperature).toFixed(1)}<small>°C</small></div>
<button data-action="temperature" data-delta="0.5" data-device="${esc(device.id)}">+</button>
</div>
<div class="current-line">${esc(tr('devices.currentTemperature'))}: <strong>${fmtTemp(device.current_temperature)}</strong>${device.outdoor_temperature == null ? '' : ` · ${esc(tr('devices.outdoor'))} ${fmtTemp(device.outdoor_temperature)}`}</div>
<div class="mode-row">${modes.map(mode => `<button class="${device.mode === mode ? 'active' : ''}" data-action="mode" data-value="${mode}" data-device="${esc(device.id)}">${esc(modeLabel(mode))}</button>`).join('')}</div>
<div class="fan-row">${fans.map(fan => `<button class="${Number(device.fan_speed) === fan ? 'active' : ''}" data-action="fan" data-value="${fan}" data-device="${esc(device.id)}">${esc(fanLabel(fan))}</button>`).join('')}</div>
<div class="device-toggles">
<button class="${device.swing_vertical ? 'active' : ''}" data-action="toggle" data-field="swing_vertical" data-device="${esc(device.id)}">↕ ${esc(tr('devices.swing'))}</button>
<button class="${device.quiet ? 'active' : ''}" data-action="toggle" data-field="quiet" data-device="${esc(device.id)}">${esc(tr('devices.quiet'))}</button>
<button class="${device.turbo ? 'active' : ''}" data-action="toggle" data-field="turbo" data-device="${esc(device.id)}">${esc(tr('devices.turbo'))}</button>
</div>
${detailed ? `<div class="card-footer">${error}<div class="card-menu"><button data-action="poll" data-device="${esc(device.id)}">${esc(tr('actions.read'))}</button>${device.simulated ? '' : `<button data-action="bind" data-device="${esc(device.id)}">${esc(tr('actions.bind'))}</button>`}<button class="danger" data-action="delete-device" data-device="${esc(device.id)}">${esc(tr('actions.delete'))}</button></div></div>` : ''}
</article>`;
}
function renderDevices() {
const empty = `<div class="empty"><strong>${esc(tr('devices.emptyTitle'))}</strong>${esc(tr('devices.emptyText'))}</div>`;
$('#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 `<article class="list-card">
<div class="list-card-head"><div><h3>${esc(zone.name)}</h3><p>${esc(device?.name || tr('common.noDevice'))} · ${esc(zoneStrategyLabel(zone))}${zone.ha_entity_id ? ` · ${esc(zone.ha_entity_id)}` : ''}</p></div><span class="badge ${zone.enabled ? 'active' : ''}">${esc(state)}</span></div>
<div class="card-stats"><div class="card-stat"><small>${esc(tr('zones.measurement'))}</small><strong>${fmtTemp(zone.current_temperature)}</strong></div><div class="card-stat"><small>${esc(tr('common.target'))}</small><strong>${fmtTemp(zone.setpoint)}</strong></div><div class="card-stat"><small>${esc(tr('zones.demand'))}</small><strong>${esc(tr(zone.demand ? 'common.on' : 'common.off'))}</strong></div></div>
<div class="sensor-detail">${esc(sensorDetails)}</div>
<div class="card-footer"><small>${esc(modeLabel(zone.mode))} · ${esc(tr('zones.hysteresis').replace(' °C','').toLowerCase())} ${Number(zone.hysteresis).toFixed(1)}°C</small><div class="card-menu"><button data-action="edit-zone" data-id="${esc(zone.id)}">${esc(tr('actions.edit'))}</button><button class="danger" data-action="delete-zone" data-id="${esc(zone.id)}">${esc(tr('actions.delete'))}</button></div></div>
</article>`;
}).join('') : `<div class="empty"><strong>${esc(tr('zones.emptyTitle'))}</strong>${esc(tr('zones.emptyText'))}</div>`;
}
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 `<article class="list-card"><div class="list-card-head"><div><h3>${esc(item.name)}</h3><p>${esc(zone?.name || tr('common.noZone'))} · ${esc(days)}</p></div><span class="badge ${item.enabled ? 'active' : ''}">${esc(item.enabled ? tr('common.enabled') : tr('common.disabled'))}</span></div>
<div class="card-stats"><div class="card-stat"><small>${esc(tr('common.from'))}</small><strong>${esc(item.start_time)}</strong></div><div class="card-stat"><small>${esc(tr('common.to'))}</small><strong>${esc(item.end_time)}</strong></div><div class="card-stat"><small>${esc(tr('common.target'))}</small><strong>${fmtTemp(item.setpoint)}</strong></div></div>
<div class="card-footer"><small>${esc(tr('schedules.crossMidnight'))}</small><div class="card-menu"><button data-action="edit-schedule" data-id="${esc(item.id)}">${esc(tr('actions.edit'))}</button><button class="danger" data-action="delete-schedule" data-id="${esc(item.id)}">${esc(tr('actions.delete'))}</button></div></div></article>`;
}).join('') : `<div class="empty"><strong>${esc(tr('schedules.emptyTitle'))}</strong>${esc(tr('schedules.emptyText'))}</div>`;
}
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 `<article class="list-card"><div class="list-card-head"><div><h3>${esc(item.name)}</h3><p>${esc(tr('automations.triggerSummary', {trigger: triggerLabel(item), device: actionDevice?.name || tr('common.noDevice')}))}</p></div><span class="badge ${item.enabled ? 'active' : ''}">${esc(item.enabled ? tr('common.active') : tr('common.disabled'))}</span></div>
<div class="card-stats"><div class="card-stat"><small>${esc(tr('common.power'))}</small><strong>${item.action.power == null ? '—' : item.action.power ? tr('common.on') : tr('common.off')}</strong></div><div class="card-stat"><small>${esc(tr('common.mode'))}</small><strong>${item.action.mode ? esc(modeLabel(item.action.mode)) : '—'}</strong></div><div class="card-stat"><small>${esc(tr('automations.last'))}</small><strong>${item.last_fired_at ? new Date(item.last_fired_at).toLocaleTimeString(locale(),{hour:'2-digit',minute:'2-digit'}) : '—'}</strong></div></div>
<div class="card-footer"><small>${esc(tr('common.cooldown'))} ${item.cooldown_seconds}s</small><div class="card-menu"><button data-action="edit-automation" data-id="${esc(item.id)}">${esc(tr('actions.edit'))}</button><button class="danger" data-action="delete-automation" data-id="${esc(item.id)}">${esc(tr('actions.delete'))}</button></div></div></article>`;
}).join('') : `<div class="empty"><strong>${esc(tr('automations.emptyTitle'))}</strong>${esc(tr('automations.emptyText'))}</div>`;
}
function fillSelects() {
const deviceOptions = app.devices.map(d => `<option value="${esc(d.id)}">${esc(d.name)}</option>`).join('');
const zoneOptions = app.zones.map(z => `<option value="${esc(z.id)}">${esc(z.name)}</option>`).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 => `
<div class="token-row">
<div><strong>${esc(item.name)}</strong><small class="mono">${esc(item.token_prefix)}</small><small>${esc(tr('settings.created'))}: ${esc(dateTime(item.created_at))}</small></div>
<button type="button" class="danger" data-action="revoke-access-token" data-id="${esc(item.id)}">${esc(tr('actions.revoke'))}</button>
</div>`).join('') : `<div class="empty compact"><strong>${esc(tr('settings.noTokens'))}</strong>${esc(tr('settings.noTokensHint'))}</div>`;
}
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 = `<h3>${esc(tr('settings.systemState'))}</h3><div>${esc(tr('settings.version'))}: <strong>${esc(app.system.version || '—')}</strong></div><div>${esc(tr('settings.uptime'))}: <strong>${esc(formatDuration(app.system.uptime_seconds || 0))}</strong></div><div>${esc(tr('settings.apiAuth'))}: <strong>${esc(app.system.auth_required ? tr('settings.enabled') : tr('settings.disabled'))}</strong></div>`;
}
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 => `<div class="log-row ${esc(item.level)}"><time>${esc(new Date(item.timestamp).toLocaleTimeString(locale()))}</time><span class="kind">${esc(item.kind)}</span><span class="message">${esc(item.message)}</span></div>`).join('')
: `<div class="empty"><strong>${esc(tr('logs.emptyTitle'))}</strong>${esc(tr('logs.emptyText'))}</div>`;
} 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));