325 lines
16 KiB
JavaScript
325 lines
16 KiB
JavaScript
'use strict';
|
|
|
|
const APP_BASE = (() => {
|
|
const src = document.currentScript?.src || '';
|
|
try {
|
|
const path = new URL(src, location.href).pathname;
|
|
const slash = path.lastIndexOf('/');
|
|
const filename = path.slice(slash + 1);
|
|
return /^app(?:-[a-f0-9]+)?\.js$/i.test(filename) ? path.slice(0, slash).replace(/\/$/, '') : '';
|
|
} catch (_) { return ''; }
|
|
})();
|
|
const withBase = path => `${APP_BASE}${path.startsWith('/') ? path : `/${path}`}`;
|
|
const parseDecimal = value => { const normalized = String(value ?? '').trim().replace(',', '.'); const parsed = Number(normalized); return Number.isFinite(parsed) ? parsed : NaN; };
|
|
|
|
const getCookie = name => {
|
|
const row = document.cookie.split('; ').find(item => item.startsWith(`${name}=`));
|
|
return row ? decodeURIComponent(row.split('=').slice(1).join('=')) : '';
|
|
};
|
|
const setCookie = (name, value) => {
|
|
document.cookie = `${name}=${encodeURIComponent(value)}; Max-Age=31536000; Path=${APP_BASE || '/'}; SameSite=Lax`;
|
|
};
|
|
|
|
const DEFAULT_LANGUAGE = 'en';
|
|
const preferredLanguage = getCookie('gree_controller_language') || DEFAULT_LANGUAGE;
|
|
const preferredTheme = ['system', 'light', 'dark'].includes(getCookie('gree_controller_theme')) ? getCookie('gree_controller_theme') : 'system';
|
|
|
|
const app = {
|
|
devices: [], zones: [], groups: [], 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: {},
|
|
historyTab: 'overview', historyData: {zones:[], devices:[], sensors:[]}, historyCounts: {},
|
|
historyZone: 'all', historyDevice: 'all', historySensor: 'all', historyLoading: false,
|
|
customChartSeries: [], savedCharts: [], chartZooms: {}, chartHiddenSeries: {}, zoneControlSeq: {}, zoneTemperatureTimers: {},
|
|
controlPlan: null, controlPlanTimer: null, debugLines: [], debugBacklogLoaded: false, debugFilter: 'all', sensorAliases: {},
|
|
simulationScope: 'units', simulationTarget: 'all', standaloneSimulation: false, dashboardTab: 'main', settingsTab: 'app', systemSnapshotAt: Date.now(),
|
|
};
|
|
try { app.savedCharts = JSON.parse(localStorage.getItem('gree_controller_saved_charts') || '[]'); } catch (_) { app.savedCharts = []; }
|
|
|
|
const $ = (selector, root = document) => root.querySelector(selector);
|
|
const $$ = (selector, root = document) => [...root.querySelectorAll(selector)];
|
|
const clamp = (value, min, max) => Math.min(max, Math.max(min, value));
|
|
const esc = value => String(value ?? '').replace(/[&<>'"]/g, char => ({'&':'&','<':'<','>':'>',"'":''','"':'"'}[char]));
|
|
const locale = () => app.locales[app.language] || app.locales[DEFAULT_LANGUAGE] || 'en-GB';
|
|
const tr = (key, params = {}) => {
|
|
const template = app.translations[app.language]?.[key] ?? app.translations[DEFAULT_LANGUAGE]?.[key] ?? key;
|
|
return String(template).replace(/\{([a-zA-Z0-9_]+)\}/g, (_, name) => params[name] ?? `{${name}}`);
|
|
};
|
|
const fmtTemp = value => value !== null && value !== undefined && value !== '' && Number.isFinite(Number(value)) ? `${Number(value).toFixed(1)}°C` : '--';
|
|
const historyNumber = value => value === null || value === undefined || value === '' ? NaN : Number(value);
|
|
const modeLabel = mode => tr(`mode.${mode}`) === `mode.${mode}` ? mode : tr(`mode.${mode}`);
|
|
const houseModeLabel = mode => mode === 'off' ? tr('house.noControl') : modeLabel(mode);
|
|
const fanLabel = value => ({0:'fan.auto',1:'fan.low',2:'fan.mediumLow',3:'fan.medium',4:'fan.mediumHigh',5:'fan.high'}[value] ? tr({0:'fan.auto',1:'fan.low',2:'fan.mediumLow',3:'fan.medium',4:'fan.mediumHigh',5:'fan.high'}[value]) : value);
|
|
const dateTime = value => value ? new Intl.DateTimeFormat(locale(), {dateStyle:'short', timeStyle:'short'}).format(new Date(value)) : '—';
|
|
const localResumeSeconds = value => {
|
|
const timestamp = value ? new Date(value).getTime() : NaN;
|
|
return Number.isFinite(timestamp) ? Math.max(0, Math.ceil((timestamp - Date.now()) / 1000)) : null;
|
|
};
|
|
const formatCountdown = seconds => {
|
|
if (!Number.isFinite(seconds)) return '--:--';
|
|
const safe = Math.max(0, Math.floor(seconds));
|
|
const minutes = Math.floor(safe / 60);
|
|
const remainder = safe % 60;
|
|
return `${String(minutes).padStart(2,'0')}:${String(remainder).padStart(2,'0')}`;
|
|
};
|
|
const formatExtendedCountdown = seconds => {
|
|
if (!Number.isFinite(seconds)) return '∞';
|
|
const safe = Math.max(0, Math.floor(seconds));
|
|
const hours = Math.floor(safe / 3600);
|
|
const minutes = Math.floor((safe % 3600) / 60);
|
|
const remainder = safe % 60;
|
|
return hours > 0
|
|
? `${String(hours).padStart(2,'0')}:${String(minutes).padStart(2,'0')}:${String(remainder).padStart(2,'0')}`
|
|
: `${String(minutes).padStart(2,'0')}:${String(remainder).padStart(2,'0')}`;
|
|
};
|
|
function updateLocalThermostatCountdowns() {
|
|
$$('[data-local-resume-countdown]').forEach(node => {
|
|
const remaining = localResumeSeconds(node.dataset.localResumeCountdown);
|
|
node.textContent = remaining !== null && remaining > 0
|
|
? tr('zones.localThermostatOffDescriptionTimed', {time:formatCountdown(remaining)})
|
|
: tr('zones.localThermostatResuming');
|
|
});
|
|
}
|
|
function temporarySessionStatus(zone) {
|
|
const session = zone?.temporary_quick_thermostat;
|
|
if (!session) return null;
|
|
const now = Date.now();
|
|
const secondsUntil = value => {
|
|
const timestamp = value ? new Date(value).getTime() : NaN;
|
|
return Number.isFinite(timestamp) ? Math.max(0, Math.ceil((timestamp - now) / 1000)) : null;
|
|
};
|
|
const safetyRemaining = secondsUntil(session.safety_expires_at);
|
|
const hardRemaining = secondsUntil(session.expires_at);
|
|
const startRemaining = secondsUntil(session.started_at);
|
|
const pending = !session.activated_at;
|
|
const target = Number(session.temperature_target ?? zone.effective_setpoint ?? zone.setpoint);
|
|
const targetText = Number.isFinite(target) ? target.toFixed(1) : '--';
|
|
const tolerance = Number(session.tolerance_c ?? 0.3).toFixed(1);
|
|
const operatorKey = ({within:'zones.temporaryWithinShort',at_or_below:'zones.temporaryAtOrBelowShort',at_or_above:'zones.temporaryAtOrAboveShort'})[session.temperature_operator || 'within'] || 'zones.temporaryWithinShort';
|
|
const condition = tr(operatorKey, {target:targetText, tolerance});
|
|
|
|
if (session.state === 'waiting_master') {
|
|
return {countdown:startRemaining != null ? formatExtendedCountdown(startRemaining) : '00:00', detail:tr('zones.temporaryWaitingMaster'), kind:session.finish_kind, pending:true};
|
|
}
|
|
if (session.state === 'paused_manual') {
|
|
return {countdown:'—', detail:tr('zones.temporaryPausedManual'), kind:session.finish_kind, pending};
|
|
}
|
|
if (pending) {
|
|
return {
|
|
countdown:startRemaining != null && startRemaining > 0 ? formatExtendedCountdown(startRemaining) : '00:00',
|
|
detail:startRemaining != null && startRemaining > 0
|
|
? tr('zones.temporaryScheduledStatus', {time:dateTime(session.started_at)})
|
|
: tr('zones.temporaryStartingStatus'),
|
|
kind:session.finish_kind,
|
|
pending:true,
|
|
};
|
|
}
|
|
|
|
if (session.finish_kind === 'temperature_stable') {
|
|
const started = session.condition_started_at ? new Date(session.condition_started_at).getTime() : NaN;
|
|
const holdSeconds = Number(session.hold_seconds || 0);
|
|
if (Number.isFinite(started)) {
|
|
const holdRemaining = Math.max(0, Math.ceil((started + holdSeconds * 1000 - now) / 1000));
|
|
const effectiveRemaining = safetyRemaining == null ? holdRemaining : Math.min(holdRemaining, safetyRemaining);
|
|
return {countdown:formatExtendedCountdown(effectiveRemaining), detail:tr('zones.temporaryHoldingStatus', {condition}), kind:session.finish_kind};
|
|
}
|
|
return {countdown:safetyRemaining == null ? '∞' : formatExtendedCountdown(safetyRemaining), detail:tr('zones.temporaryWaitingStable', {condition}), kind:session.finish_kind};
|
|
}
|
|
if (session.finish_kind === 'temperature_reached') {
|
|
return {countdown:safetyRemaining == null ? '∞' : formatExtendedCountdown(safetyRemaining), detail:tr('zones.temporaryWaitingReached', {condition}), kind:session.finish_kind};
|
|
}
|
|
const detailKey = session.finish_kind === 'schedule_boundary' ? 'zones.temporaryUntilScheduleStatus' : 'zones.temporaryTimeStatus';
|
|
return {countdown:formatExtendedCountdown(hardRemaining), detail:tr(detailKey), kind:session.finish_kind};
|
|
}
|
|
function updateTemporaryThermostatCountdowns() {
|
|
$$('[data-temporary-countdown-zone]').forEach(node => {
|
|
const zone = app.zones.find(item => item.id === node.dataset.temporaryCountdownZone);
|
|
const status = temporarySessionStatus(zone);
|
|
if (!status) return;
|
|
node.textContent = status.countdown;
|
|
node.title = status.detail;
|
|
});
|
|
const dialog = $('#temporaryThermostatDialog');
|
|
if (dialog?.open) {
|
|
const zone = app.zones.find(item => item.id === $('#temporaryThermostatForm')?.elements.zone_id.value);
|
|
const status = temporarySessionStatus(zone);
|
|
if (status) {
|
|
$('#temporaryThermostatActiveCountdown').textContent = status.countdown;
|
|
$('#temporaryThermostatActiveDetail').textContent = status.detail;
|
|
const label = $('#temporaryThermostatActiveLabel');
|
|
if (label) label.textContent = tr(status.pending ? 'zones.temporaryScheduled' : 'zones.temporaryActive');
|
|
}
|
|
}
|
|
}
|
|
const haSensorLabel = entity => app.sensorAliases?.[entity] || app.settings?.home_assistant?.sensor_aliases?.[entity] || entity;
|
|
|
|
function syncTopbarHeight() {
|
|
const topbar = $('.topbar');
|
|
if (!topbar) return;
|
|
document.documentElement.style.setProperty('--topbar-height', `${Math.ceil(topbar.getBoundingClientRect().height)}px`);
|
|
}
|
|
|
|
function observeTopbarHeight() {
|
|
const topbar = $('.topbar');
|
|
if (!topbar) return;
|
|
syncTopbarHeight();
|
|
if ('ResizeObserver' in window) {
|
|
const observer = new ResizeObserver(syncTopbarHeight);
|
|
observer.observe(topbar);
|
|
} else {
|
|
window.addEventListener('resize', syncTopbarHeight);
|
|
}
|
|
}
|
|
|
|
function updateConnectionIndicator(status) {
|
|
app.connectionStatus = status || 'connecting';
|
|
const node = $('#connectionLabel');
|
|
const label = tr(`status.${app.connectionStatus}`);
|
|
if (node) {
|
|
node.className = `connection-dot ${app.connectionStatus}`;
|
|
node.setAttribute('aria-label', label);
|
|
node.title = label;
|
|
}
|
|
const banner = $('#connectionBanner');
|
|
const disconnected = ['disconnected','connectionError'].includes(app.connectionStatus);
|
|
document.body.classList.toggle('connection-lost', disconnected);
|
|
if (banner) {
|
|
banner.hidden = !disconnected;
|
|
const title = $('#connectionBannerTitle');
|
|
const text = $('#connectionBannerText');
|
|
if (title) title.textContent = label;
|
|
if (text) text.textContent = tr('status.offlineHint');
|
|
}
|
|
renderSystemInfo();
|
|
}
|
|
|
|
function updateToolbarControls() {
|
|
const languageLabel = $('#languageLabel');
|
|
if (languageLabel) languageLabel.textContent = String(app.language || DEFAULT_LANGUAGE).toUpperCase();
|
|
const themeSelect = $('#themeSelect');
|
|
const themeIcon = $('#themeIcon');
|
|
const icons = {system:'◐',light:'☀',dark:'☾'};
|
|
if (themeIcon) themeIcon.textContent = icons[app.theme] || icons.system;
|
|
if (themeSelect) {
|
|
themeSelect.value = app.theme;
|
|
const label = `${tr('controls.theme')}: ${tr(`theme.${app.theme}`)}`;
|
|
themeSelect.setAttribute('aria-label', label);
|
|
themeSelect.closest('.toolbar-picker')?.setAttribute('title', label);
|
|
}
|
|
}
|
|
|
|
function applyTheme() {
|
|
const resolved = app.theme === 'light' || app.theme === 'dark'
|
|
? app.theme
|
|
: (window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark');
|
|
document.documentElement.dataset.theme = resolved;
|
|
const meta = $('#themeColorMeta');
|
|
if (meta) meta.content = resolved === 'light' ? '#f8faf9' : '#151515';
|
|
updateToolbarControls();
|
|
drawCurrentChartIfVisible();
|
|
}
|
|
|
|
function applyTranslations() {
|
|
document.documentElement.lang = app.language;
|
|
$$('[data-i18n]').forEach(node => { node.textContent = tr(node.dataset.i18n); });
|
|
$$('[data-i18n-placeholder]').forEach(node => { node.placeholder = tr(node.dataset.i18nPlaceholder); });
|
|
$$('[data-i18n-title]').forEach(node => { node.title = tr(node.dataset.i18nTitle); });
|
|
$$('[data-i18n-aria]').forEach(node => { node.setAttribute('aria-label', tr(node.dataset.i18nAria)); });
|
|
$$('[data-i18n-content]').forEach(node => { node.setAttribute('content', tr(node.dataset.i18nContent)); });
|
|
$$('[data-day]').forEach(node => { node.textContent = tr(`day.${node.dataset.day}`); });
|
|
$('#languageSelect').value = app.language;
|
|
updateToolbarControls();
|
|
updateConnectionIndicator(app.connectionStatus);
|
|
renderAll();
|
|
if (app.currentView === 'logs') loadLogs();
|
|
if (app.currentView === 'history' && app.zones.length) loadHistory();
|
|
}
|
|
|
|
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;
|
|
updateToolbarControls();
|
|
}
|
|
|
|
async function loadLanguages() {
|
|
try {
|
|
const response = await fetch(withBase('/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(withBase(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();
|
|
}
|
|
|
|
function cycleTheme() {
|
|
const modes = ['system', 'light', 'dark'];
|
|
const index = modes.indexOf(app.theme);
|
|
setTheme(modes[(index + 1) % modes.length]);
|
|
}
|
|
|
|
async function api(path, options = {}) {
|
|
const headers = new Headers(options.headers || {});
|
|
headers.set('Accept', 'application/json');
|
|
if (app.token) headers.set('Authorization', `Bearer ${app.token}`);
|
|
let body = options.body;
|
|
if (body !== undefined && body !== null && typeof body !== 'string') {
|
|
headers.set('Content-Type', 'application/json');
|
|
body = JSON.stringify(body);
|
|
}
|
|
const response = await fetch(withBase(path), {...options, headers, body});
|
|
if (response.status === 401) {
|
|
showTokenDialog();
|
|
throw new Error(tr('auth.invalid'));
|
|
}
|
|
if (!response.ok) {
|
|
let message = tr('error.http', {status: response.status});
|
|
try { message = (await response.json()).error || message; } catch (_) {}
|
|
throw new Error(message);
|
|
}
|
|
if (response.status === 204) return null;
|
|
return response.json();
|
|
}
|
|
|