785 lines
51 KiB
JavaScript
785 lines
51 KiB
JavaScript
'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: {}, outdoorTemperature: null,
|
||
token: localStorage.getItem('gree_controller_token') || '', ws: null, wsTimer: null,
|
||
currentView: 'dashboard', loading: false, language: preferredLanguage, theme: preferredTheme, connectionStatus: 'connecting',
|
||
languages: [], translations: {}, locales: {},
|
||
};
|
||
|
||
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' ? '#f8faf9' : '#151515';
|
||
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 || {};
|
||
app.outdoorTemperature = Number.isFinite(Number(data.outdoor_temperature)) ? Number(data.outdoor_temperature) : null;
|
||
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();
|
||
renderHouseClimate();
|
||
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 renderHouseClimate() {
|
||
const node = $('#houseClimate'); if (!node || !app.settings) return;
|
||
const mode = app.settings.house_mode || 'cool';
|
||
const outdoor = app.outdoorTemperature == null ? tr('common.unavailable') : fmtTemp(app.outdoorTemperature);
|
||
node.innerHTML = `<div class="house-climate-head"><div><span class="eyebrow">${esc(tr('house.seasonMode'))}</span><h3>${esc(tr('house.smartThermostat'))}</h3><p>${esc(tr('house.setpointStrategy'))}</p></div><div class="outside-pill"><small>${esc(tr('house.outdoor'))}</small><strong>${esc(outdoor)}</strong></div></div>
|
||
<div class="house-mode-row">
|
||
<button class="${mode==='cool'?'active':''}" data-action="house-mode" data-value="cool"><span class="mode-indicator"></span>${esc(tr('mode.cool'))}</button>
|
||
<button class="${mode==='heat'?'active':''}" data-action="house-mode" data-value="heat"><span class="mode-indicator"></span>${esc(tr('mode.heat'))}</button>
|
||
<button class="${mode==='off'?'active':''}" data-action="house-mode" data-value="off"><span class="mode-indicator"></span>${esc(tr('mode.off'))}</button>
|
||
</div>
|
||
<div class="preset-row house-preset-row">${['auto','comfort','sleep','away'].map(p=>`<button data-action="house-preset" data-value="${p}">${esc(p==='sleep'?tr('house.sleepAll'):p==='comfort'?tr('house.comfortAll'):p==='away'?tr('house.awayAll'):tr('house.autoAll'))}</button>`).join('')}</div>`;
|
||
}
|
||
|
||
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') : device.protocol_version === 2 ? 'V2 GCM' : device.protocol_version === 1 ? 'V1 ECB' : tr('devices.protocolAuto')}</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="-1" 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="1" 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><button data-action="rename-device" data-device="${esc(device.id)}">${esc(tr('devices.rename'))}</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 zonePresetLabel(preset) {
|
||
const key = `preset.${preset || 'comfort'}`;
|
||
return tr(key) === key ? (preset || 'comfort') : tr(key);
|
||
}
|
||
|
||
function zoneCard(zone, detailed = true) {
|
||
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)}`;
|
||
const target = Number(zone.effective_setpoint ?? zone.setpoint);
|
||
const manual = zone.manual_preset || 'auto';
|
||
const mode = zone.inherit_house_mode ? 'house' : zone.mode;
|
||
const override = zone.manual_override_until ? `${tr('zones.overrideUntil')} ${new Date(zone.manual_override_until).toLocaleTimeString(locale(), {hour:'2-digit',minute:'2-digit'})}` : tr('zones.scheduleControl');
|
||
return `<article class="list-card zone-thermostat ${zone.demand ? 'demanding' : ''}">
|
||
<div class="list-card-head"><div><h3>${esc(zone.name)}</h3><p>${esc(device?.name || tr('common.noDevice'))} · ${esc(zonePresetLabel(zone.active_preset))}</p></div><span class="badge ${zone.enabled ? 'active' : ''}">${esc(state)}</span></div>
|
||
<div class="thermostat-main"><div><small>${esc(tr('zones.measurement'))}</small><strong>${fmtTemp(zone.current_temperature)}</strong></div><div class="temperature-control compact"><button data-action="zone-temperature" data-id="${esc(zone.id)}" data-delta="-0.5">−</button><div class="target-temp compact">${Number.isFinite(target)?target.toFixed(1):'--'}<small>°C</small></div><button data-action="zone-temperature" data-id="${esc(zone.id)}" data-delta="0.5">+</button></div><div><small>${esc(tr('zones.deviceTarget'))}</small><strong>${fmtTemp(zone.device_setpoint)}</strong></div></div>
|
||
<div class="preset-row">
|
||
${['auto','comfort','sleep','away'].map(preset=>`<button class="${manual===preset?'active':''}" data-action="zone-preset" data-id="${esc(zone.id)}" data-value="${preset}">${esc(preset==='sleep'?tr('zones.sleepNow'):zonePresetLabel(preset))}</button>`).join('')}
|
||
</div>
|
||
<div class="mode-row zone-mode-row"><button class="${mode==='house'?'active':''}" data-action="zone-mode" data-id="${esc(zone.id)}" data-value="house">${esc(tr('zones.followHouseShort'))}</button><button class="${mode==='heat'?'active':''}" data-action="zone-mode" data-id="${esc(zone.id)}" data-value="heat">${esc(modeLabel('heat'))}</button><button class="${mode==='cool'?'active':''}" data-action="zone-mode" data-id="${esc(zone.id)}" data-value="cool">${esc(modeLabel('cool'))}</button></div>
|
||
<div class="zone-state-line"><span>${zone.demand ? esc(tr('zones.requesting')) : esc(tr('zones.satisfied'))}</span><span>${esc(override)}</span></div>
|
||
${detailed ? `<div class="sensor-detail">${esc(sensorDetails)}</div><div class="card-footer"><small>${esc(tr('zones.quickHintSmart'))}</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>`;
|
||
}
|
||
|
||
function renderZones() {
|
||
const empty = `<div class="empty"><strong>${esc(tr('zones.emptyTitle'))}</strong>${esc(tr('zones.emptyText'))}</div>`;
|
||
$('#zoneList').innerHTML = app.zones.length ? app.zones.map(zone => zoneCard(zone, true)).join('') : empty;
|
||
const dashboard = $('#dashboardZones');
|
||
if (dashboard) dashboard.innerHTML = app.zones.length ? app.zones.map(zone => zoneCard(zone, false)).join('') : empty;
|
||
}
|
||
|
||
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('schedules.profile'))}</small><strong>${esc(item.preset === 'custom' ? fmtTemp(item.setpoint) : zonePresetLabel(item.preset))}</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 presetZone = $('#schedulePresetZone');
|
||
if (presetZone) { const currentZone = presetZone.value; presetZone.innerHTML = zoneOptions; if ([...presetZone.options].some(o => o.value === currentZone)) presetZone.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 || '';
|
||
form.ha_outdoor_entity_id.value = app.settings.home_assistant?.outdoor_entity_id || '';
|
||
form.ha_allow_invalid_tls.checked = !!app.settings.home_assistant?.allow_invalid_tls;
|
||
form.outdoor_assist_enabled.checked = !!app.settings.outdoor_assist_enabled;
|
||
$('#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 sendZoneControl(id, patch) {
|
||
try {
|
||
const zone = await api(`/api/zones/${encodeURIComponent(id)}/control`, {method:'POST', body:patch});
|
||
const index = app.zones.findIndex(item => item.id === zone.id);
|
||
if (index >= 0) app.zones[index] = zone; else app.zones.push(zone);
|
||
renderSummary(); renderZones();
|
||
} catch (error) { toast(error.message, true); }
|
||
}
|
||
|
||
function showDiscoveryNames(ids) {
|
||
const wanted = new Set(Array.isArray(ids) ? ids : []);
|
||
const devices = app.devices.filter(device => wanted.has(device.id));
|
||
if (!devices.length) return;
|
||
const list = $('#discoveryNamesList');
|
||
list.innerHTML = devices.map(device => `
|
||
<label class="discovery-name-row">
|
||
<span><strong>${esc(device.model || 'GREE')}</strong><small>${esc(device.ip)} · ${esc(device.mac)} · ${device.protocol_version === 2 ? 'V2 GCM' : 'V1 ECB'}</small></span>
|
||
<input data-device-id="${esc(device.id)}" maxlength="80" required value="${esc(device.name)}">
|
||
</label>`).join('');
|
||
openDialog('discoveryNamesDialog');
|
||
}
|
||
|
||
function populateDeviceRename(id) {
|
||
const device = app.devices.find(item => item.id === id); if (!device) return;
|
||
const form = $('#renameDeviceForm'); form.reset();
|
||
form.id.value = device.id; form.name.value = device.name; form.protocol_version.value = String(device.protocol_version ?? 0);
|
||
openDialog('renameDeviceDialog');
|
||
}
|
||
|
||
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 updateSchedulePresetField() {
|
||
const form = $('#scheduleForm'); if (!form) return;
|
||
$('#scheduleSetpointField').hidden = form.preset.value !== 'custom';
|
||
form.setpoint.required = form.preset.value === 'custom';
|
||
}
|
||
|
||
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.mode_policy.value = item.inherit_house_mode ? 'house' : (item.mode || 'cool');
|
||
if (Number(item.profile_version || 0) === 0) {
|
||
if ((item.mode || 'cool') === 'heat') form.heat_comfort_setpoint.value = Number(item.setpoint ?? 21);
|
||
else form.cool_comfort_setpoint.value = Number(item.setpoint ?? 23);
|
||
}
|
||
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.min_adjust_seconds.value = Number(item.min_adjust_seconds ?? 120);
|
||
form.standby_offset_c.value = Number(item.standby_offset_c ?? 2);
|
||
form.smart_fan.checked = item.smart_fan !== false;
|
||
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','preset'].forEach(key => { if (form.elements[key] && item[key] != null) form.elements[key].value = item[key]; });
|
||
form.enabled.checked = item.enabled;
|
||
$$('[name=weekday]', form).forEach(input => input.checked = item.weekdays.includes(Number(input.value)));
|
||
updateSchedulePresetField(); 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; app.outdoorTemperature=Number.isFinite(Number(data.outdoor_temperature))?Number(data.outdoor_temperature):app.outdoorTemperature; 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(); renderHouseClimate(); renderZones(); }
|
||
else if (message.event === 'settings.updated') { app.settings=data; renderSettings(); renderHouseClimate(); }
|
||
else if (message.event === 'outdoor.updated') { app.outdoorTemperature=Number.isFinite(Number(data.temperature))?Number(data.temperature):null; renderHouseClimate(); }
|
||
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(); if (button.dataset.open === 'scheduleDialog') updateSchedulePresetField(); openDialog(button.dataset.open); return; }
|
||
if (button.hasAttribute('data-close')) { button.closest('dialog')?.close(); return; }
|
||
if (button.dataset.scheduleTemplate) {
|
||
const zoneId = $('#schedulePresetZone')?.value; if (!zoneId) return toast(tr('schedules.chooseZone'), true);
|
||
if (!confirm(tr('schedules.replaceConfirm'))) return;
|
||
try {
|
||
button.disabled = true;
|
||
await api(`/api/zones/${encodeURIComponent(zoneId)}/schedule-template`, {method:'POST', body:{template:button.dataset.scheduleTemplate}});
|
||
await loadBootstrap(); toast(tr('schedules.templateApplied'));
|
||
} catch (error) { toast(error.message, true); } finally { button.disabled = false; }
|
||
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,30)});
|
||
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 === 'rename-device' && device) return populateDeviceRename(device.id);
|
||
if (action === 'delete-device') return deleteEntity('devices', button.dataset.device, 'label.device');
|
||
if (action === 'house-mode') {
|
||
try { app.settings = await api('/api/house/control',{method:'POST',body:{mode:button.dataset.value}}); renderHouseClimate(); toast(tr('house.modeUpdated')); }
|
||
catch(error){ toast(error.message,true); } return;
|
||
}
|
||
if (action === 'house-preset') {
|
||
try { const result=await api('/api/house/preset',{method:'POST',body:{preset:button.dataset.value}}); app.zones=result.zones||app.zones; renderZones(); toast(tr('house.presetUpdated')); }
|
||
catch(error){ toast(error.message,true); } return;
|
||
}
|
||
if (action === 'zone-temperature') { const zone=app.zones.find(v=>v.id===button.dataset.id); if(zone) { const base=Number(zone.effective_setpoint ?? zone.setpoint); return sendZoneControl(zone.id,{setpoint:clamp(base+Number(button.dataset.delta),8,30)}); } }
|
||
if (action === 'zone-mode') return sendZoneControl(button.dataset.id,{mode:button.dataset.value});
|
||
if (action === 'zone-preset') return sendZoneControl(button.dataset.id,{preset:button.dataset.value});
|
||
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', () => {
|
||
const form=$('#discoverForm'); form.reset();
|
||
form.protocol_version.value='0'; form.passes.value='3'; form.timeout_ms.value=String(Math.max(6000, Number(app.settings?.discovery_timeout_ms || 3000)));
|
||
openDialog('discoverDialog');
|
||
});
|
||
$('#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);
|
||
$('#scheduleForm [name=preset]').addEventListener('change', updateSchedulePresetField);
|
||
|
||
$('#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();
|
||
});
|
||
|
||
$('#discoverForm').addEventListener('submit', async event => {
|
||
event.preventDefault(); const form=event.currentTarget, raw=Object.fromEntries(new FormData(form));
|
||
const submit=form.querySelector('button[type=submit]'); submit.disabled=true; submit.textContent=tr('actions.discovering');
|
||
try {
|
||
const result=await api('/api/discovery',{method:'POST',body:{protocol_version:Number(raw.protocol_version),passes:Number(raw.passes),timeout_ms:Number(raw.timeout_ms)}});
|
||
form.closest('dialog').close(); await loadBootstrap(); toast(tr('toast.found',{count:result.count})); showDiscoveryNames(result.new_device_ids || []);
|
||
} catch(error){toast(error.message,true);} finally {submit.disabled=false;submit.textContent=tr('actions.discover');}
|
||
});
|
||
|
||
$('#discoveryNamesForm').addEventListener('submit', async event => {
|
||
event.preventDefault();
|
||
const form = event.currentTarget;
|
||
const inputs = $$('input[data-device-id]', form);
|
||
try {
|
||
const updated = await Promise.all(inputs.map(input => api(`/api/devices/${encodeURIComponent(input.dataset.deviceId)}`, {method:'PATCH', body:{name:input.value.trim()}})));
|
||
updated.forEach(updateDevice);
|
||
form.closest('dialog').close();
|
||
renderAll();
|
||
toast(tr('common.saved'));
|
||
} catch (error) { toast(error.message, true); }
|
||
});
|
||
|
||
$('#renameDeviceForm').addEventListener('submit', async event => {
|
||
event.preventDefault(); const form=event.currentTarget, raw=Object.fromEntries(new FormData(form));
|
||
try {
|
||
const device=await api(`/api/devices/${encodeURIComponent(raw.id)}`,{method:'PATCH',body:{name:raw.name.trim(),protocol_version:Number(raw.protocol_version)}});
|
||
updateDevice(device); form.closest('dialog').close(); renderAll(); toast(tr('common.saved'));
|
||
} catch(error){toast(error.message,true);}
|
||
});
|
||
|
||
$('#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_policy==='house'?'cool':raw.mode_policy,inherit_house_mode:raw.mode_policy==='house',setpoint:Number(raw.setpoint),
|
||
cool_comfort_setpoint:Number(raw.cool_comfort_setpoint),cool_sleep_setpoint:Number(raw.cool_sleep_setpoint),cool_away_setpoint:Number(raw.cool_away_setpoint),
|
||
heat_comfort_setpoint:Number(raw.heat_comfort_setpoint),heat_sleep_setpoint:Number(raw.heat_sleep_setpoint),heat_away_setpoint:Number(raw.heat_away_setpoint),
|
||
hysteresis:Number(raw.hysteresis),min_on_seconds:Number(raw.min_on_seconds),min_off_seconds:Number(raw.min_off_seconds),
|
||
min_adjust_seconds:Number(raw.min_adjust_seconds),standby_offset_c:Number(raw.standby_offset_c),smart_fan:form.smart_fan.checked,
|
||
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,preset:raw.preset,setpoint:Number(raw.setpoint||23)};
|
||
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);}
|
||
});
|
||
|
||
function settingsBodyFromForm(form) {
|
||
const raw=Object.fromEntries(new FormData(form));
|
||
return {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,house_mode:app.settings?.house_mode||'cool',control_strategy:'setpoint',outdoor_assist_enabled:form.outdoor_assist_enabled.checked,home_assistant:{url:raw.ha_url,token:raw.ha_token,default_entity_id:raw.ha_entity_id,outdoor_entity_id:raw.ha_outdoor_entity_id,allow_invalid_tls:form.ha_allow_invalid_tls.checked}};
|
||
}
|
||
|
||
async function saveSettingsForm(form, notify=true) {
|
||
app.settings=await api('/api/settings',{method:'PUT',body:settingsBodyFromForm(form)}); renderSettings(); renderHouseClimate(); if(notify) toast(tr('common.saved')); return app.settings;
|
||
}
|
||
|
||
$('#settingsForm').addEventListener('submit', async event => {
|
||
event.preventDefault(); try { await saveSettingsForm(event.currentTarget, true); } 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');
|
||
try {
|
||
await saveSettingsForm(form, false);
|
||
const entity = form.ha_entity_id.value || form.ha_outdoor_entity_id.value || null;
|
||
const result=await api('/api/integrations/home-assistant/test',{method:'POST',body:{entity_id:entity}});
|
||
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();
|
||
updateSchedulePresetField();
|
||
await loadBootstrap();
|
||
}
|
||
|
||
startApplication().catch(error => console.error('Application startup failed:', error));
|