first commit

This commit is contained in:
Mateusz Gruszczyński
2026-08-23 21:34:07 +02:00
commit 1d3dcba1a9
62 changed files with 12456 additions and 0 deletions
+633
View File
@@ -0,0 +1,633 @@
'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));
+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
<rect width="128" height="128" rx="30" fill="#0d1715"/>
<path d="M64 25v43" stroke="#5ee2a0" stroke-width="14" stroke-linecap="round"/>
<path d="M42 42a39 39 0 1 0 44 0" fill="none" stroke="#5ee2a0" stroke-width="14" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 318 B

+230
View File
@@ -0,0 +1,230 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<meta name="theme-color" content="#0a1110" id="themeColorMeta">
<meta name="description" content="Local GREE air conditioner controller" data-i18n-content="meta.description">
<link rel="manifest" href="/manifest.webmanifest">
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="stylesheet" href="/styles.css">
<script>
(() => {
const getCookie = name => document.cookie.split('; ').find(row => row.startsWith(`${name}=`))?.split('=')[1];
const requested = decodeURIComponent(getCookie('gree_controller_theme') || 'system');
const resolved = requested === 'light' || requested === 'dark'
? requested
: (window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark');
document.documentElement.dataset.theme = resolved;
})();
</script>
<title>GREE Controller</title>
</head>
<body>
<header class="topbar">
<div class="brand">
<div><strong>GREE Controller</strong><small id="connectionLabel" data-i18n="status.connecting">Connecting…</small></div>
</div>
<div class="top-actions">
<select class="toolbar-select" id="languageSelect" data-i18n-aria="controls.language" aria-label="Language">
<option value="en">English</option>
</select>
<select class="toolbar-select theme-select" id="themeSelect" data-i18n-aria="controls.theme" aria-label="Theme">
<option value="system" data-i18n="theme.system">System</option>
<option value="light" data-i18n="theme.light">Light</option>
<option value="dark" data-i18n="theme.dark">Dark</option>
</select>
<button class="icon-button" id="refreshButton" data-i18n-title="actions.refresh" data-i18n-aria="actions.refresh" title="Refresh" aria-label="Refresh"></button>
<button class="primary compact" id="discoverButton" data-i18n="actions.discover">Discover</button>
</div>
</header>
<main>
<section class="view active" data-view="dashboard">
<div class="hero">
<div><span class="eyebrow" data-i18n="dashboard.home">Home</span><h1 data-i18n="dashboard.title">Comfort under control</h1><p id="summaryText" data-i18n="dashboard.loading">Loading devices…</p></div>
<div class="hero-temp" id="heroTemperature">--<small>°C</small></div>
</div>
<div class="metrics" id="metrics"></div>
<div class="section-heading"><div><span class="eyebrow" data-i18n="nav.devices">Devices</span><h2 data-i18n="dashboard.quickControl">Quick control</h2></div></div>
<div class="device-grid" id="dashboardDevices"></div>
</section>
<section class="view" data-view="devices">
<div class="section-heading">
<div><span class="eyebrow" data-i18n="devices.lan">LAN network</span><h1 data-i18n="nav.devices">Devices</h1></div>
<button class="secondary" data-open="deviceDialog" data-i18n="devices.addManual">Add manually</button>
</div>
<div class="device-grid" id="deviceList"></div>
</section>
<section class="view" data-view="zones">
<div class="section-heading"><div><span class="eyebrow" data-i18n="zones.automation">Automation</span><h1 data-i18n="nav.zones">Zones</h1></div><button class="secondary" data-open="zoneDialog" data-i18n="zones.new">New zone</button></div>
<p class="lead" data-i18n="zones.description">A zone controls a device using temperature, hysteresis and minimum cycle time.</p>
<div class="list-grid" id="zoneList"></div>
</section>
<section class="view" data-view="schedules">
<div class="section-heading"><div><span class="eyebrow" data-i18n="schedules.calendar">Calendar</span><h1 data-i18n="nav.schedules">Schedules</h1></div><button class="secondary" data-open="scheduleDialog" data-i18n="actions.add">Add</button></div>
<div class="list-grid" id="scheduleList"></div>
</section>
<section class="view" data-view="automations">
<div class="section-heading"><div><span class="eyebrow" data-i18n="automations.rules">Rules</span><h1 data-i18n="nav.automations">Automations</h1></div><button class="secondary" data-open="automationDialog" data-i18n="actions.add">Add</button></div>
<div class="list-grid" id="automationList"></div>
</section>
<section class="view" data-view="history">
<div class="section-heading"><div><span class="eyebrow" data-i18n="history.measurements">Measurements</span><h1 data-i18n="history.title">Temperature history</h1></div></div>
<div class="panel chart-panel">
<div class="chart-toolbar">
<select id="historyDevice"></select>
<select id="historyHours">
<option value="6" data-i18n="history.6h">6 hours</option>
<option value="24" selected data-i18n="history.24h">24 hours</option>
<option value="168" data-i18n="history.7d">7 days</option>
<option value="720" data-i18n="history.30d">30 days</option>
</select>
<button class="secondary" id="historyRefresh" data-i18n="actions.show">Show</button>
</div>
<div class="chart-wrap"><canvas id="historyChart" width="1000" height="420"></canvas></div>
<div class="legend"><span><i class="dot indoor"></i><span data-i18n="common.temperature">Temperature</span></span><span><i class="dot target"></i><span data-i18n="common.target">Target</span></span></div>
</div>
</section>
<section class="view" data-view="settings">
<div class="section-heading"><div><span class="eyebrow" data-i18n="settings.system">System</span><h1 data-i18n="nav.settings">Settings</h1></div></div>
<form class="panel form-grid" id="settingsForm">
<h3 data-i18n="settings.controller">Controller</h3>
<label><span data-i18n="settings.clientId">Client identifier</span><input name="controller_id" required></label>
<label><span data-i18n="settings.pollInterval">Poll interval (s)</span><input type="number" name="poll_interval_seconds" min="2" max="3600" required></label>
<label><span data-i18n="settings.zoneInterval">Zone interval (s)</span><input type="number" name="zone_interval_seconds" min="2" max="3600" required></label>
<label><span data-i18n="settings.broadcast">Broadcast address</span><input name="discovery_broadcast" placeholder="255.255.255.255:7000" required></label>
<label><span data-i18n="settings.discoveryTimeout">Discovery timeout (ms)</span><input type="number" name="discovery_timeout_ms" min="300" max="30000" required></label>
<label class="check"><input type="checkbox" name="simulator_enabled"> <span data-i18n="settings.simulationMode">Simulation mode</span></label>
<hr>
<h3 data-i18n="settings.haSensorInput">Home Assistant sensor input</h3>
<p class="field-note wide" data-i18n="settings.haSensorInputHint">Optional. Used only when a room zone reads an external Home Assistant temperature sensor.</p>
<label class="wide"><span>URL</span><input type="url" name="ha_url" placeholder="http://homeassistant.local:8123"></label>
<label class="wide"><span data-i18n="settings.haLongLivedToken">Long-Lived Access Token</span><input type="password" name="ha_token" autocomplete="new-password" data-i18n-placeholder="settings.haTokenKeep" placeholder="Leave empty to keep the saved token"></label>
<label class="wide"><span data-i18n="settings.defaultEntity">Default entity_id</span><input name="ha_entity_id" placeholder="sensor.living_room_temperature"></label>
<div class="form-actions wide"><button type="button" class="secondary" id="haTest" data-i18n="settings.testHa">Test HA</button><button class="primary" type="submit" data-i18n="actions.save">Save</button></div>
<hr>
<h3 data-i18n="settings.haIntegrationAccess">Home Assistant integration access</h3>
<p class="field-note wide" data-i18n="settings.haIntegrationHint">Create a controller token and paste it into the GREE Controller integration in Home Assistant.</p>
<div class="wide token-manager">
<div id="accessTokenList" class="token-list"></div>
<div class="form-actions"><button type="button" class="primary" id="createAccessToken" data-i18n="settings.newToken">Create new token</button></div>
</div>
</form>
<div class="panel system-panel" id="systemInfo"></div>
</section>
<section class="view" data-view="logs">
<div class="section-heading"><div><span class="eyebrow" data-i18n="logs.diagnostics">Diagnostics</span><h1 data-i18n="nav.logs">Events</h1></div><button class="secondary" id="logsRefresh" data-i18n="actions.refresh">Refresh</button></div>
<div class="panel log-list" id="logList"></div>
</section>
</main>
<nav class="bottom-nav" data-i18n-aria="nav.navigation" aria-label="Navigation">
<button class="active" data-nav="dashboard"><span></span><b data-i18n="nav.dashboard">Dashboard</b></button>
<button data-nav="devices"><span></span><b data-i18n="nav.devices">Devices</b></button>
<button data-nav="zones"><span></span><b data-i18n="nav.zones">Zones</b></button>
<button data-nav="history"><span></span><b data-i18n="nav.history">History</b></button>
<button data-nav="more"><span>•••</span><b data-i18n="nav.more">More</b></button>
</nav>
<dialog id="moreDialog" class="sheet">
<div class="dialog-head"><h2 data-i18n="nav.more">More</h2><button data-close>×</button></div>
<div class="menu-list">
<button data-go="schedules"><span data-i18n="nav.schedules">Schedules</span><span></span></button>
<button data-go="automations"><span data-i18n="nav.automations">Automations</span><span></span></button>
<button data-go="settings"><span data-i18n="nav.settings">Settings</span><span></span></button>
<button data-go="logs"><span data-i18n="nav.logsAndEvents">Events and logs</span><span></span></button>
</div>
</dialog>
<dialog id="deviceDialog">
<form method="dialog" id="deviceForm" class="dialog-form">
<div class="dialog-head"><h2 data-i18n="devices.add">Add device</h2><button type="button" data-close>×</button></div>
<label><span data-i18n="common.name">Name</span><input name="name" required data-i18n-placeholder="placeholder.livingRoom" placeholder="Living room"></label>
<label><span>MAC / CID</span><input name="mac" required placeholder="AABBCCDDEEFF"></label>
<label><span data-i18n="devices.ipAddress">IP address</span><input name="ip" required placeholder="192.168.1.50"></label>
<div class="two"><label><span>Port</span><input type="number" name="port" value="7000" min="1" max="65535"></label><label><span data-i18n="devices.protocol">Protocol</span><select name="protocol_version"><option value="1">V1 AES-ECB</option><option value="2">V2 AES-GCM</option></select></label></div>
<label><span data-i18n="devices.keyOptional">Key (optional)</span><input name="key" data-i18n-placeholder="devices.keyPlaceholder" placeholder="Leave empty for automatic bind"></label>
<label class="check"><input type="checkbox" name="simulated"> <span data-i18n="devices.simulated">Simulated device</span></label>
<div class="form-actions"><button type="button" class="secondary" data-close data-i18n="actions.cancel">Cancel</button><button class="primary" type="submit" data-i18n="actions.add">Add</button></div>
</form>
</dialog>
<dialog id="zoneDialog">
<form method="dialog" id="zoneForm" class="dialog-form">
<input type="hidden" name="id">
<div class="dialog-head"><h2 data-i18n="common.zone">Zone</h2><button type="button" data-close>×</button></div>
<label><span data-i18n="common.name">Name</span><input name="name" required data-i18n-placeholder="placeholder.livingRoom" placeholder="Living room"></label>
<label><span data-i18n="common.device">Device</span><select name="device_id" required></select></label>
<div class="two"><label><span data-i18n="common.mode">Mode</span><select name="mode"><option value="cool" data-i18n="mode.cool">Cooling</option><option value="heat" data-i18n="mode.heat">Heating</option></select></label><label><span data-i18n="common.targetC">Target °C</span><input type="number" step="0.1" min="8" max="32" name="setpoint" value="23"></label></div>
<div class="two"><label><span data-i18n="zones.hysteresis">Hysteresis °C</span><input type="number" step="0.1" min="0.1" max="5" name="hysteresis" value="0.6"></label><label><span data-i18n="zones.source">Temperature strategy</span><select name="sensor_source"><option value="combined" selected data-i18n="zones.combinedSensor">GREE + room sensor</option><option value="device" data-i18n="zones.greeSensor">GREE only</option><option value="home_assistant" data-i18n="zones.externalSensor">Room sensor only</option></select></label></div>
<div id="externalSensorFields">
<label><span data-i18n="zones.roomSensorEntity">Room sensor entity_id</span><input name="ha_entity_id" placeholder="sensor.living_room_temperature"></label>
<div class="two"><label><span data-i18n="zones.roomSensorWeight">Room sensor weight %</span><input type="number" step="5" min="0" max="100" name="external_sensor_weight_percent" value="40"></label><label><span data-i18n="zones.maxDifference">Max. sensor difference °C</span><input type="number" step="0.1" min="0.1" max="20" name="max_sensor_difference" value="3.0"></label></div>
<p class="field-note" data-i18n="zones.sensorHelp">The room sensor is assigned only to this zone. If it becomes unavailable, the controller falls back to the GREE sensor.</p>
</div>
<div class="two"><label><span data-i18n="zones.minOn">Min. ON (s)</span><input type="number" min="0" name="min_on_seconds" value="180"></label><label><span data-i18n="zones.minOff">Min. OFF (s)</span><input type="number" min="0" name="min_off_seconds" value="180"></label></div>
<label class="check"><input type="checkbox" name="enabled" checked> <span data-i18n="common.enabled">Enabled</span></label>
<div class="form-actions"><button type="button" class="secondary" data-close data-i18n="actions.cancel">Cancel</button><button class="primary" type="submit" data-i18n="actions.save">Save</button></div>
</form>
</dialog>
<dialog id="scheduleDialog">
<form method="dialog" id="scheduleForm" class="dialog-form">
<input type="hidden" name="id">
<div class="dialog-head"><h2 data-i18n="common.schedule">Schedule</h2><button type="button" data-close>×</button></div>
<label><span data-i18n="common.name">Name</span><input name="name" required data-i18n-placeholder="placeholder.night" placeholder="Night"></label>
<label><span data-i18n="common.zone">Zone</span><select name="zone_id" required></select></label>
<fieldset><legend data-i18n="schedules.weekdays">Weekdays</legend><div class="days"><label><input type="checkbox" name="weekday" value="1" checked><span data-day="1">Mon</span></label><label><input type="checkbox" name="weekday" value="2" checked><span data-day="2">Tue</span></label><label><input type="checkbox" name="weekday" value="3" checked><span data-day="3">Wed</span></label><label><input type="checkbox" name="weekday" value="4" checked><span data-day="4">Thu</span></label><label><input type="checkbox" name="weekday" value="5" checked><span data-day="5">Fri</span></label><label><input type="checkbox" name="weekday" value="6"><span data-day="6">Sat</span></label><label><input type="checkbox" name="weekday" value="7"><span data-day="7">Sun</span></label></div></fieldset>
<div class="two"><label><span data-i18n="common.from">From</span><input type="time" name="start_time" value="22:00" required></label><label><span data-i18n="common.to">To</span><input type="time" name="end_time" value="06:00" required></label></div>
<label><span data-i18n="common.temperatureC">Temperature °C</span><input type="number" name="setpoint" step="0.1" min="8" max="32" value="23" required></label>
<label class="check"><input type="checkbox" name="enabled" checked> <span data-i18n="common.enabled">Enabled</span></label>
<div class="form-actions"><button type="button" class="secondary" data-close data-i18n="actions.cancel">Cancel</button><button class="primary" type="submit" data-i18n="actions.save">Save</button></div>
</form>
</dialog>
<dialog id="automationDialog">
<form method="dialog" id="automationForm" class="dialog-form">
<input type="hidden" name="id">
<div class="dialog-head"><h2 data-i18n="common.automation">Automation</h2><button type="button" data-close>×</button></div>
<label><span data-i18n="common.name">Name</span><input name="name" required data-i18n-placeholder="automations.namePlaceholder" placeholder="Emergency cooling"></label>
<label><span data-i18n="automations.trigger">Trigger</span><select name="trigger_kind"><option value="temperature_above" data-i18n="automations.tempAbove">Temperature above</option><option value="temperature_below" data-i18n="automations.tempBelow">Temperature below</option><option value="time" data-i18n="automations.time">Time</option></select></label>
<label><span data-i18n="automations.measurementDevice">Measurement device</span><select name="trigger_device_id"></select></label>
<div class="two"><label><span data-i18n="automations.thresholdC">Threshold °C</span><input type="number" step="0.1" name="threshold" value="27"></label><label><span data-i18n="automations.time">Time</span><input type="time" name="at_time" value="08:00"></label></div>
<hr><h3 data-i18n="automations.action">Action</h3>
<label><span data-i18n="common.device">Device</span><select name="action_device_id" required></select></label>
<div class="two"><label><span data-i18n="common.power">Power</span><select name="action_power"><option value="" data-i18n="actions.noChange">No change</option><option value="true" data-i18n="actions.turnOn">Turn on</option><option value="false" data-i18n="actions.turnOff">Turn off</option></select></label><label><span data-i18n="common.mode">Mode</span><select name="action_mode"><option value="" data-i18n="actions.noChange">No change</option><option value="cool" data-i18n="mode.cool">Cooling</option><option value="heat" data-i18n="mode.heat">Heating</option><option value="dry" data-i18n="mode.dry">Dry</option><option value="fan" data-i18n="mode.fan">Fan</option><option value="auto" data-i18n="mode.auto">Auto</option></select></label></div>
<div class="two"><label><span data-i18n="common.targetC">Target °C</span><input type="number" step="0.1" min="8" max="32" name="action_target_temperature"></label><label><span data-i18n="common.cooldownSeconds">Cooldown (s)</span><input type="number" min="30" name="cooldown_seconds" value="300"></label></div>
<label class="check"><input type="checkbox" name="enabled" checked> <span data-i18n="common.enabled">Enabled</span></label>
<div class="form-actions"><button type="button" class="secondary" data-close data-i18n="actions.cancel">Cancel</button><button class="primary" type="submit" data-i18n="actions.save">Save</button></div>
</form>
</dialog>
<dialog id="generatedTokenDialog" class="auth-dialog">
<div class="dialog-form">
<div class="dialog-head"><h2 data-i18n="settings.tokenCreated">Token created</h2><button type="button" data-close>×</button></div>
<p class="field-note" data-i18n="settings.tokenCreatedHint">Copy this token now. It will not be shown again.</p>
<label><span data-i18n="auth.token">Access token</span><input id="generatedAccessToken" class="mono" readonly></label>
<div class="form-actions"><button type="button" class="secondary" id="copyAccessToken" data-i18n="actions.copy">Copy</button><button type="button" class="primary" data-close data-i18n="actions.done">Done</button></div>
</div>
</dialog>
<dialog id="tokenDialog" class="auth-dialog">
<form method="dialog" id="tokenForm" class="dialog-form">
<div class="brand large"><div><strong>GREE Controller</strong><small data-i18n="auth.required">Authentication required</small></div></div>
<label><span data-i18n="auth.token">Access token</span><input type="password" name="token" required autocomplete="current-password"></label>
<button class="primary" type="submit" data-i18n="auth.connect">Connect</button>
</form>
</dialog>
<div id="toast" role="status" aria-live="polite"></div>
<script src="/app.js" defer></script>
</body>
</html>
+9
View File
@@ -0,0 +1,9 @@
{
"name": "GREE Controller",
"short_name": "GREE",
"start_url": "/",
"display": "standalone",
"background_color": "#f5f7f6",
"theme_color": "#0d1715",
"icons": [{"src":"/favicon.svg","sizes":"any","type":"image/svg+xml","purpose":"any maskable"}]
}
+234
View File
@@ -0,0 +1,234 @@
:root {
color-scheme: dark;
--bg: #0a1110;
--surface: #111d1a;
--surface-2: #172622;
--surface-muted: #14201d;
--input-bg: #0c1614;
--nav-bg: #0d1715;
--dialog-bg: #101c19;
--line: rgba(255,255,255,.10);
--text: #f3faf7;
--muted: #91aaa0;
--accent: #5ee2a0;
--accent-strong: #28c779;
--accent-text: #092218;
--danger: #ff8b8b;
--warning: #f6c866;
--backdrop: rgba(1,8,6,.72);
--grid: rgba(255,255,255,.08);
--radius: 22px;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
:root[data-theme="light"] {
color-scheme: light;
--bg: #f5f7f6;
--surface: #ffffff;
--surface-2: #edf2ef;
--surface-muted: #f0f4f2;
--input-bg: #ffffff;
--nav-bg: #ffffff;
--dialog-bg: #ffffff;
--line: rgba(19,42,33,.14);
--text: #14221d;
--muted: #62756d;
--accent: #20b86b;
--accent-strong: #149557;
--accent-text: #ffffff;
--danger: #c94646;
--warning: #b77900;
--backdrop: rgba(18,33,27,.28);
--grid: rgba(19,42,33,.10);
}
* { box-sizing: border-box; }
html, body { background: var(--bg); }
body { margin: 0; min-height: 100vh; color: var(--text); }
button, input, select { font: inherit; }
button { cursor: pointer; }
button:disabled { opacity: .5; cursor: wait; }
.topbar { position: sticky; top: 0; z-index: 20; display: flex; align-items: center; justify-content: space-between; gap: 1rem; min-height: 72px; padding: 12px max(18px, env(safe-area-inset-left)); border-bottom: 1px solid var(--line); background: color-mix(in srgb, var(--nav-bg) 94%, transparent); backdrop-filter: blur(14px); }
.brand { display: flex; align-items: center; min-width: 0; }
.brand strong, .brand small { display: block; }
.brand strong { font-size: 15px; letter-spacing: -.01em; white-space: nowrap; }
.brand small { margin-top: 2px; color: var(--muted); font-size: 11px; }
.brand.large { margin-bottom: 1rem; }
.brand.large strong { font-size: 21px; }
.top-actions { display: flex; align-items: center; justify-content: flex-end; gap: 7px; }
.toolbar-select { width: auto; min-width: 58px; min-height: 40px; padding: 7px 28px 7px 10px; border-radius: 11px; }
.theme-select { min-width: 94px; }
button { border: 0; border-radius: 13px; padding: 11px 15px; color: var(--text); background: var(--surface-2); transition: transform .15s ease, background .15s ease, opacity .15s ease; }
button:active { transform: scale(.97); }
.primary { background: var(--accent); color: var(--accent-text); font-weight: 800; }
.primary:hover { background: var(--accent-strong); }
.secondary { border: 1px solid var(--line); background: var(--surface-muted); font-weight: 650; }
.secondary:hover { background: var(--surface-2); }
.danger { color: var(--danger); }
.compact { padding: 10px 14px; }
.icon-button { width: 40px; height: 40px; padding: 0; font-size: 22px; background: transparent; }
main { width: min(1180px, 100%); margin: 0 auto; padding: 24px 18px 108px; }
.view { display: none; animation: reveal .2s ease; }
.view.active { display: block; }
@keyframes reveal { from { opacity: 0; transform: translateY(5px); } }
h1, h2, h3, p { margin-top: 0; }
h1 { margin-bottom: 8px; font-size: clamp(28px, 6vw, 44px); letter-spacing: -.045em; }
h2 { margin-bottom: 0; font-size: clamp(23px, 4vw, 31px); letter-spacing: -.035em; }
h3 { margin-bottom: 10px; }
.eyebrow { color: var(--accent); font-size: 11px; font-weight: 850; letter-spacing: .14em; text-transform: uppercase; }
.lead { max-width: 690px; color: var(--muted); line-height: 1.55; }
.hero { position: relative; overflow: hidden; display: flex; justify-content: space-between; align-items: flex-end; gap: 18px; min-height: 210px; padding: clamp(24px, 6vw, 44px); border: 1px solid color-mix(in srgb, var(--accent) 28%, var(--line)); border-radius: 30px; background: var(--surface); }
.hero::after { content: ""; position: absolute; right: -80px; bottom: -120px; width: 220px; height: 220px; border: 34px solid color-mix(in srgb, var(--accent) 7%, transparent); border-radius: 50%; }
.hero h1 { margin: 5px 0 8px; }
.hero p { margin: 0; color: var(--muted); }
.hero-temp { z-index: 1; white-space: nowrap; font-size: clamp(48px, 12vw, 88px); font-weight: 250; letter-spacing: -.075em; }
.hero-temp small { margin-left: 4px; color: var(--muted); font-size: .28em; font-weight: 600; letter-spacing: 0; }
.metrics { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin: 14px 0 34px; }
.metric { padding: 18px; border: 1px solid var(--line); border-radius: 18px; background: var(--surface); }
.metric span { display: block; margin-bottom: 8px; color: var(--muted); font-size: 12px; }
.metric strong { font-size: 24px; letter-spacing: -.03em; }
.section-heading { display: flex; align-items: end; justify-content: space-between; gap: 16px; margin: 8px 0 18px; }
.device-grid, .list-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 340px), 1fr)); gap: 14px; }
.device-card, .panel, .list-card { border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface); }
.device-card { overflow: hidden; padding: 20px; }
.device-card.off { opacity: .86; }
.device-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
.device-title { min-width: 0; }
.device-title h3 { overflow: hidden; margin: 0 0 4px; text-overflow: ellipsis; white-space: nowrap; font-size: 19px; }
.device-title p { overflow: hidden; margin: 0; color: var(--muted); font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
.status { display: inline-flex; align-items: center; gap: 6px; color: var(--muted); }
.status::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--danger); }
.status.online::before { background: var(--accent); }
.power-button { display: grid; place-items: center; flex: 0 0 auto; width: 46px; height: 46px; padding: 0; border-radius: 50%; color: var(--muted); font-size: 21px; }
.power-button.on { background: var(--accent); color: var(--accent-text); }
.temperature-control { display: flex; align-items: center; justify-content: center; gap: 22px; padding: 24px 0 17px; }
.temperature-control button { width: 42px; height: 42px; padding: 0; border: 1px solid var(--line); border-radius: 50%; background: transparent; font-size: 22px; }
.target-temp { min-width: 106px; text-align: center; font-size: 48px; font-weight: 300; letter-spacing: -.06em; }
.target-temp small { margin-left: 3px; color: var(--muted); font-size: 15px; font-weight: 650; }
.current-line { display: flex; justify-content: center; gap: 6px; margin: -10px 0 18px; color: var(--muted); font-size: 12px; }
.current-line strong { color: var(--text); }
.mode-row, .fan-row { display: flex; gap: 7px; overflow-x: auto; padding: 2px 0 10px; scrollbar-width: none; }
.mode-row::-webkit-scrollbar, .fan-row::-webkit-scrollbar { display: none; }
.mode-row button, .fan-row button { flex: 1 0 auto; min-width: 58px; padding: 9px 10px; color: var(--muted); background: var(--surface-muted); font-size: 12px; }
.mode-row button.active, .fan-row button.active { color: var(--accent-text); background: var(--accent); font-weight: 800; }
.device-toggles { display: grid; grid-template-columns: repeat(3, 1fr); gap: 7px; margin-top: 4px; }
.device-toggles button { padding: 9px 5px; color: var(--muted); font-size: 11px; }
.device-toggles button.active { color: var(--accent); background: color-mix(in srgb, var(--accent) 12%, var(--surface)); }
.card-footer { display: flex; justify-content: space-between; align-items: center; gap: 8px; margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--line); }
.card-footer small { overflow: hidden; color: var(--muted); text-overflow: ellipsis; white-space: nowrap; }
.card-menu { display: flex; gap: 5px; }
.card-menu button { padding: 7px 9px; background: transparent; color: var(--muted); font-size: 12px; }
.list-card { padding: 19px; }
.list-card-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
.list-card h3 { margin: 0 0 5px; font-size: 18px; }
.list-card p { margin: 0; color: var(--muted); font-size: 13px; line-height: 1.45; }
.badge { display: inline-flex; align-items: center; padding: 5px 9px; border-radius: 99px; color: var(--muted); background: var(--surface-muted); font-size: 11px; }
.badge.active { color: var(--accent); background: color-mix(in srgb, var(--accent) 12%, var(--surface)); }
.card-stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 7px; margin-top: 16px; }
.card-stat { padding: 10px; border-radius: 12px; background: var(--surface-muted); text-align: center; }
.card-stat small, .card-stat strong { display: block; }
.card-stat small { margin-bottom: 5px; color: var(--muted); font-size: 10px; }
.card-stat strong { font-size: 15px; }
.empty { grid-column: 1/-1; padding: 38px 24px; border: 1px dashed var(--line); border-radius: var(--radius); color: var(--muted); text-align: center; }
.empty strong { display: block; margin-bottom: 7px; color: var(--text); }
.panel { padding: 20px; }
.chart-panel { overflow: hidden; }
.chart-toolbar { display: flex; flex-wrap: wrap; gap: 9px; margin-bottom: 18px; }
.chart-toolbar select { flex: 1; min-width: 140px; }
.chart-wrap { position: relative; overflow-x: auto; min-height: 310px; }
#historyChart { width: 100%; min-width: 680px; height: 340px; }
.legend { display: flex; gap: 18px; margin-top: 10px; color: var(--muted); font-size: 12px; }
.legend > span { display: flex; align-items: center; gap: 7px; }
.dot { width: 9px; height: 9px; border-radius: 50%; background: var(--accent); }
.dot.target { background: var(--warning); }
.form-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 14px; }
.form-grid h3, .form-grid hr, .form-grid .wide { grid-column: 1/-1; }
.form-grid hr, .dialog-form hr { width: 100%; border: 0; border-top: 1px solid var(--line); }
label { display: grid; gap: 7px; color: var(--muted); font-size: 12px; font-weight: 650; }
input, select { width: 100%; min-height: 45px; border: 1px solid var(--line); border-radius: 12px; outline: 0; padding: 10px 12px; color: var(--text); background: var(--input-bg); }
input:focus, select:focus { border-color: var(--accent); outline: 2px solid color-mix(in srgb, var(--accent) 18%, transparent); outline-offset: 1px; }
.check { display: flex; grid-auto-flow: column; justify-content: start; align-items: center; gap: 9px; min-height: 44px; }
.check input { width: 19px; min-height: 19px; accent-color: var(--accent); }
.form-actions { display: flex; justify-content: flex-end; gap: 9px; margin-top: 6px; }
.system-panel { margin-top: 14px; color: var(--muted); line-height: 1.7; }
.system-panel strong { color: var(--text); }
.log-list { display: grid; gap: 2px; padding: 8px; }
.log-row { display: grid; grid-template-columns: 78px 150px 1fr; gap: 12px; padding: 11px 12px; border-bottom: 1px solid var(--line); font-size: 12px; }
.log-row:last-child { border-bottom: 0; }
.log-row time, .log-row .kind { color: var(--muted); }
.log-row.error .kind { color: var(--danger); }
.log-row.warn .kind { color: var(--warning); }
.bottom-nav { position: fixed; z-index: 30; right: 0; bottom: 0; left: 0; display: grid; grid-template-columns: repeat(5, 1fr); padding: 8px max(8px, env(safe-area-inset-right)) calc(8px + env(safe-area-inset-bottom)) max(8px, env(safe-area-inset-left)); border-top: 1px solid var(--line); background: color-mix(in srgb, var(--nav-bg) 96%, transparent); backdrop-filter: blur(14px); }
.bottom-nav button { display: grid; place-items: center; gap: 3px; padding: 7px 2px; border-radius: 12px; color: var(--muted); background: transparent; font-size: 10px; }
.bottom-nav button span { font-size: 20px; line-height: 1; }
.bottom-nav button b { font-weight: 650; }
.bottom-nav button.active { color: var(--accent); }
dialog { width: min(540px, calc(100% - 24px)); max-height: min(88vh, 760px); overflow: auto; border: 1px solid var(--line); border-radius: 24px; padding: 0; color: var(--text); background: var(--dialog-bg); }
dialog::backdrop { background: var(--backdrop); backdrop-filter: blur(4px); }
.dialog-form { display: grid; gap: 14px; padding: 20px; }
.dialog-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.dialog-head h2 { font-size: 23px; }
.dialog-head button { width: 38px; height: 38px; padding: 0; background: transparent; color: var(--muted); font-size: 25px; }
.two { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
#externalSensorFields { display: grid; gap: 12px; }
.field-note { margin: -4px 0 0; color: var(--muted); font-size: .8rem; line-height: 1.45; }
.sensor-detail { margin-top: 10px; color: var(--muted); font-size: .8rem; line-height: 1.45; }
fieldset { border: 1px solid var(--line); border-radius: 14px; padding: 10px; }
legend { padding: 0 5px; color: var(--muted); font-size: 11px; }
.days { display: grid; grid-template-columns: repeat(7, 1fr); gap: 4px; }
.days label { display: grid; place-items: center; gap: 5px; padding: 6px 2px; border-radius: 8px; background: var(--surface-muted); font-size: 10px; }
.days input { width: 16px; min-height: 16px; accent-color: var(--accent); }
.sheet { width: min(500px, calc(100% - 18px)); margin: auto auto 10px; }
.sheet .dialog-head { padding: 18px 18px 5px; }
.menu-list { display: grid; padding: 8px 10px 12px; }
.menu-list button { display: flex; justify-content: space-between; border-bottom: 1px solid var(--line); border-radius: 0; padding: 16px 9px; background: transparent; text-align: left; }
.menu-list button:last-child { border-bottom: 0; }
.auth-dialog { width: min(420px, calc(100% - 30px)); }
#toast { position: fixed; z-index: 100; right: 18px; bottom: 92px; max-width: min(380px, calc(100% - 36px)); transform: translateY(15px); opacity: 0; pointer-events: none; padding: 12px 15px; border: 1px solid var(--line); border-radius: 13px; background: var(--surface-2); transition: .2s ease; font-size: 13px; }
#toast.show { transform: translateY(0); opacity: 1; }
#toast.error { color: var(--danger); border-color: color-mix(in srgb, var(--danger) 42%, var(--line)); }
@media (min-width: 900px) {
.bottom-nav { top: 92px; right: auto; bottom: auto; left: max(16px, calc((100vw - 1380px)/2)); grid-template-columns: 1fr; width: 90px; border: 1px solid var(--line); border-radius: 22px; padding: 9px; background: var(--surface); }
.bottom-nav button { padding: 10px 4px; }
main { padding-left: 122px; padding-bottom: 50px; }
}
@media (max-width: 720px) {
.topbar { flex-wrap: wrap; }
.top-actions { width: 100%; }
.top-actions .primary { margin-left: auto; }
}
@media (max-width: 620px) {
main { padding: 18px 13px 105px; }
.hero { min-height: 190px; padding: 23px; }
.hero-temp { font-size: 52px; }
.metrics { gap: 7px; }
.metric { padding: 14px 10px; }
.metric strong { font-size: 20px; }
.section-heading { align-items: center; }
.section-heading button { padding: 9px 11px; font-size: 12px; }
.form-grid { grid-template-columns: 1fr; }
.form-grid h3, .form-grid hr, .form-grid .wide { grid-column: auto; }
.log-row { grid-template-columns: 62px 1fr; }
.log-row .message { grid-column: 1/-1; }
.theme-select { min-width: 86px; }
}
@media (max-width: 410px) {
.topbar { padding-inline: 12px; }
.brand strong { font-size: 13px; }
.hero { align-items: start; flex-direction: column; }
.hero-temp { align-self: flex-end; margin-top: -15px; }
.two { grid-template-columns: 1fr; }
.toolbar-select { min-width: 54px; }
.theme-select { min-width: 82px; }
}
.token-manager { display: grid; gap: 12px; }
.token-list { display: grid; gap: 8px; }
.token-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px; border: 1px solid var(--line); border-radius: 12px; background: var(--surface-muted); }
.token-row > div { min-width: 0; display: grid; gap: 3px; }
.token-row strong { color: var(--text); font-size: 13px; }
.token-row small { overflow: hidden; color: var(--muted); text-overflow: ellipsis; white-space: nowrap; }
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; }
.empty.compact { padding: 18px; }
@media (max-width: 620px) {
.token-row { align-items: stretch; flex-direction: column; }
.token-row button { width: 100%; }
}
+10
View File
@@ -0,0 +1,10 @@
const CACHE = 'gree-controller-v5';
const ASSETS = ['/', '/styles.css', '/app.js', '/favicon.svg', '/manifest.webmanifest', '/lang/index.json', '/lang/en.json'];
self.addEventListener('install', event => event.waitUntil(caches.open(CACHE).then(cache => cache.addAll(ASSETS)).then(() => self.skipWaiting())));
self.addEventListener('activate', event => event.waitUntil(caches.keys().then(keys => Promise.all(keys.filter(key => key !== CACHE).map(key => caches.delete(key)))).then(() => self.clients.claim())));
self.addEventListener('fetch', event => {
if (event.request.method !== 'GET' || new URL(event.request.url).pathname.startsWith('/api/')) return;
event.respondWith(fetch(event.request).then(response => {
const copy = response.clone(); caches.open(CACHE).then(cache => cache.put(event.request, copy)); return response;
}).catch(() => caches.match(event.request).then(response => response || caches.match('/'))));
});