v0.14.21
This commit is contained in:
@@ -0,0 +1,429 @@
|
||||
'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 = String(document.documentElement.lang || '').trim();
|
||||
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: [], deviceGroups: [], deviceGroupEnergy: {}, schedules: [], automations: [], flows: [], accessTokens: [], settings: null, system: {}, outdoorTemperature: null,
|
||||
token: localStorage.getItem('gree_controller_token') || '', ws: null, wsTimer: null,
|
||||
currentView: 'dashboard', loading: false, bootstrapReloadPending: false, language: preferredLanguage, theme: preferredTheme, connectionStatus: 'connecting',
|
||||
languages: [], translations: {}, locales: {}, defaultLanguage: DEFAULT_LANGUAGE,
|
||||
historyTab: 'overview', historyData: { zones: [], devices: [], sensors: [] }, historyCounts: {},
|
||||
historyZone: 'all', historyDevice: 'all', historySensor: 'all', historyNetworkTarget: 'all', historyNetworkShowJitter: true, historyNetwork: [], historyNetworkTargets: [], historyEnergyDevice: '', historyEnergyTargets: [], historyEnergyHours: '720', historyEnergyInterval: 'daily', historyEnergyCompare: 'none', historyEnergy: [], historyLoading: false, historyReloadPending: false,
|
||||
customChartSeries: [], savedCharts: [], customChartEditingId: null, customChartNameDraft: null, chartZooms: {}, chartHiddenSeries: {}, zoneControlSeq: {}, groupControlSeq: {}, zoneControlQueue: {}, groupControlQueue: {}, deviceControlQueue: {}, houseControlQueue: null, climateControlQueue: null, groupCustomDrafts: {}, zoneTemperatureTimers: {}, deviceTemperatureDrafts: {},
|
||||
controlPlan: null, controlPlanRevision: null, controlPlanPushReady: false, controlPlanTimer: null, debugLines: [], debugBacklogLoaded: false, debugFilter: 'all', sensorAliases: {}, flowSharedInputs: [], flowSharedInputValueCache: {}, flowSharedInputValueRequests: {}, flowSharedInputValueTimer: null,
|
||||
haManualFallbackVisible: false, haSupervisorTestState: null, haEntityCatalog: [], haEntityCatalogConfigured: false, haEntityCatalogLoadedAt: 0, haEntityCatalogLoading: false,
|
||||
simulationScope: 'units', simulationTarget: 'all', standaloneSimulation: false, dashboardTab: 'main', settingsTab: 'app', systemSnapshotAt: Date.now(),
|
||||
pingMonitor: { targetId: '', all: false, running: false, timer: null, inFlight: false, samples: {} },
|
||||
flowDraft: null, flowSelectedNodeId: null, flowSelectedNodeIds: [], flowConnectFrom: null, flowDirty: false, flowZoom: 1,
|
||||
};
|
||||
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 UI_ICON_PATHS = Object.freeze({
|
||||
'arrow-left': '<path d="M19 12H5"/><path d="m12 19-7-7 7-7"/>',
|
||||
'automation': '<path d="M6 3v12"/><path d="M18 9v12"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="6" r="3"/><path d="M9 18h3a6 6 0 0 0 6-6V9"/>',
|
||||
'check': '<path d="m5 12 4 4L19 6"/>',
|
||||
'chevron-down': '<path d="m6 9 6 6 6-6"/>',
|
||||
'chevron-right': '<path d="m9 6 6 6-6 6"/>',
|
||||
'circle': '<circle cx="12" cy="12" r="7"/>',
|
||||
'close': '<path d="M6 6l12 12M18 6 6 18"/>',
|
||||
'devices': '<rect x="4" y="3" width="16" height="7" rx="2"/><rect x="4" y="14" width="16" height="7" rx="2"/><path d="M8 6.5h.01M8 17.5h.01"/>',
|
||||
'edit': '<path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L8 18l-4 1 1-4Z"/>',
|
||||
'events': '<path d="M8 6h12M8 12h12M8 18h12"/><circle cx="4" cy="6" r="1" fill="currentColor" stroke="none"/><circle cx="4" cy="12" r="1" fill="currentColor" stroke="none"/><circle cx="4" cy="18" r="1" fill="currentColor" stroke="none"/>',
|
||||
'expand-vertical': '<path d="m8 7 4-4 4 4M12 3v18M8 17l4 4 4-4"/>',
|
||||
'fit': '<path d="M8 3H3v5M16 3h5v5M8 21H3v-5M16 21h5v-5"/><path d="m3 8 5-5M21 8l-5-5M3 16l5 5M21 16l-5 5"/>',
|
||||
'flow': '<circle cx="5" cy="6" r="2"/><circle cx="19" cy="6" r="2"/><circle cx="12" cy="18" r="2"/><path d="M7 6h10M6.5 7.5l4.3 8.7M17.5 7.5l-4.3 8.7"/>',
|
||||
'fullscreen': '<path d="M8 3H3v5M16 3h5v5M8 21H3v-5M16 21h5v-5"/>',
|
||||
'groups': '<circle cx="9" cy="8" r="3"/><circle cx="17" cy="9" r="2.5"/><path d="M3 20v-1a6 6 0 0 1 12 0v1M14 15.5a5 5 0 0 1 7 4.5"/>',
|
||||
'history': '<path d="M4 19V5"/><path d="M4 19h16"/><path d="m7 15 4-4 3 2 5-6"/>',
|
||||
'home': '<path d="m3 11 9-8 9 8"/><path d="M5 10v11h14V10M9 21v-6h6v6"/>',
|
||||
'integration': '<path d="M7 7h11l-3-3M17 17H6l3 3"/><path d="m18 7-3 3M6 17l3-3"/>',
|
||||
'menu': '<path d="M4 6h16M4 12h16M4 18h16"/>',
|
||||
'minus': '<path d="M5 12h14"/>',
|
||||
'moon': '<path d="M20 15.5A8 8 0 0 1 8.5 4 8 8 0 1 0 20 15.5Z"/>',
|
||||
'more-horizontal': '<circle cx="5" cy="12" r="1.5" fill="currentColor" stroke="none"/><circle cx="12" cy="12" r="1.5" fill="currentColor" stroke="none"/><circle cx="19" cy="12" r="1.5" fill="currentColor" stroke="none"/>',
|
||||
'play': '<path d="m8 5 11 7-11 7Z" fill="currentColor" stroke="none"/>',
|
||||
'plus': '<path d="M12 5v14M5 12h14"/>',
|
||||
'power': '<path d="M12 2.75v8.5"/><path d="M7.12 5.12a7.25 7.25 0 1 0 9.76 0"/>',
|
||||
'refresh': '<path d="M20 6v5h-5"/><path d="M19 11a7 7 0 1 0 1 5"/>',
|
||||
'schedule': '<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/>',
|
||||
'settings': '<circle cx="12" cy="12" r="3"/><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.09a2 2 0 0 1 1 1.74v.5a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.38a2 2 0 0 0-.73-2.73l-.15-.09a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2Z"/>',
|
||||
'sliders': '<path d="M4 6h10M18 6h2M4 12h3M11 12h9M4 18h7M15 18h5"/><circle cx="16" cy="6" r="2"/><circle cx="9" cy="12" r="2"/><circle cx="13" cy="18" r="2"/>',
|
||||
'star': '<path d="m12 3 2.8 5.7 6.2.9-4.5 4.4 1.1 6.2-5.6-3-5.6 3 1.1-6.2L3 9.6l6.2-.9Z"/>',
|
||||
'star-filled': '<path d="m12 3 2.8 5.7 6.2.9-4.5 4.4 1.1 6.2-5.6-3-5.6 3 1.1-6.2L3 9.6l6.2-.9Z" fill="currentColor" stroke="none"/>',
|
||||
'status-dot': '<circle cx="12" cy="12" r="5" fill="currentColor" stroke="none"/>',
|
||||
'sun': '<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.42 1.42M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.42-1.42M17.66 6.34l1.41-1.41"/>',
|
||||
'system': '<circle cx="12" cy="12" r="8"/><path d="M12 4a8 8 0 0 1 0 16Z" fill="currentColor" stroke="none"/>',
|
||||
'system-info': '<circle cx="12" cy="12" r="9"/><path d="M12 11v6M12 7h.01"/>',
|
||||
'zones': '<circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="4"/><circle cx="12" cy="12" r="1" fill="currentColor" stroke="none"/>'
|
||||
});
|
||||
|
||||
function uiIcon(name, className = '') {
|
||||
const body = UI_ICON_PATHS[name];
|
||||
if (!body) return '';
|
||||
const classes = ['ui-icon', className].filter(Boolean).join(' ');
|
||||
return `<svg class="${esc(classes)}" viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${body}</svg>`;
|
||||
}
|
||||
|
||||
function hydrateUiIcons(root = document) {
|
||||
$$('[data-ui-icon]', root).forEach(node => {
|
||||
const name = node.dataset.uiIcon;
|
||||
const className = node.dataset.uiIconClass || '';
|
||||
node.innerHTML = uiIcon(name, className);
|
||||
});
|
||||
}
|
||||
const locale = () => app.locales[app.language] || app.locales[app.defaultLanguage] || app.language || app.defaultLanguage || DEFAULT_LANGUAGE;
|
||||
const tr = (key, params = {}) => {
|
||||
const template = app.translations[app.language]?.[key] ?? app.translations[app.defaultLanguage]?.[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 zoneHysteresisForMode = (zone, mode) => {
|
||||
const common = Number(zone?.hysteresis ?? 0.6);
|
||||
if (!zone?.separate_hysteresis) return Number.isFinite(common) ? common : 0.6;
|
||||
const value = Number(mode === 'heat' ? zone?.heat_hysteresis : zone?.cool_hysteresis);
|
||||
return Number.isFinite(value) ? value : (Number.isFinite(common) ? common : 0.6);
|
||||
};
|
||||
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 deviceCommandFieldLabel = key => {
|
||||
const translationKey = {
|
||||
fan_speed: 'common.fan',
|
||||
swing_vertical: 'devices.swingVertical',
|
||||
swing_horizontal: 'devices.swingHorizontal',
|
||||
quiet: 'devices.quiet',
|
||||
turbo: 'devices.turbo',
|
||||
light: 'devices.light',
|
||||
air: 'devices.air',
|
||||
xfan: 'devices.xfan',
|
||||
health: 'devices.health',
|
||||
sleep: 'devices.sleep',
|
||||
}[key];
|
||||
return translationKey ? tr(translationKey) : key;
|
||||
};
|
||||
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);
|
||||
const group = node.dataset.localResumeGroup || '';
|
||||
node.textContent = remaining !== null && remaining > 0
|
||||
? tr(group ? 'zones.groupOffDescriptionTimed' : 'zones.localThermostatOffDescriptionTimed', { group, 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;
|
||||
const zoneHaEntityId = zone => zone?.ha_entity_id || '';
|
||||
|
||||
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 || app.defaultLanguage || DEFAULT_LANGUAGE).toUpperCase();
|
||||
const themeSelect = $('#themeSelect');
|
||||
const themeIcon = $('#themeIcon');
|
||||
const iconName = { system: 'system', light: 'sun', dark: 'moon' }[app.theme] || 'system';
|
||||
if (themeIcon) themeIcon.innerHTML = uiIcon(iconName);
|
||||
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();
|
||||
}
|
||||
|
||||
async function loadLanguagePack(language) {
|
||||
if (app.translations[language]) return;
|
||||
const item = app.languages.find(entry => entry.code === language);
|
||||
if (!item) throw new Error(`Unknown language: ${language}`);
|
||||
const response = await fetch(withBase(item.path || `/lang/${encodeURIComponent(item.code)}.json`));
|
||||
if (!response.ok) throw new Error(`Language ${item.code} HTTP ${response.status}`);
|
||||
const pack = await response.json();
|
||||
app.translations[item.code] = pack.translations || {};
|
||||
app.locales[item.code] = pack.meta?.locale || item.locale || item.code;
|
||||
}
|
||||
|
||||
async function setLanguage(language) {
|
||||
const available = app.languages.some(item => item.code === language);
|
||||
const nextLanguage = available ? language : (app.defaultLanguage || app.languages[0]?.code || DEFAULT_LANGUAGE);
|
||||
const previousLanguage = app.language;
|
||||
const select = $('#languageSelect');
|
||||
if (nextLanguage === previousLanguage && app.translations[nextLanguage]) return;
|
||||
if (select) select.disabled = true;
|
||||
try {
|
||||
await loadLanguagePack(nextLanguage);
|
||||
app.language = nextLanguage;
|
||||
setCookie('gree_controller_language', app.language);
|
||||
applyTranslations();
|
||||
} catch (error) {
|
||||
console.error(`Unable to load language ${nextLanguage}:`, error);
|
||||
if (select) select.value = previousLanguage;
|
||||
toast(error.message, true);
|
||||
} finally {
|
||||
if (select) select.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
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'));
|
||||
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.length) throw new Error('Language manifest is empty');
|
||||
app.languages = languages;
|
||||
app.defaultLanguage = app.languages.some(item => item.code === manifest.default)
|
||||
? manifest.default
|
||||
: app.languages[0].code;
|
||||
app.translations = {};
|
||||
app.locales = Object.fromEntries(languages.map(item => [item.code, item.locale || item.code]));
|
||||
app.language = app.languages.some(item => item.code === preferredLanguage)
|
||||
? preferredLanguage
|
||||
: app.defaultLanguage;
|
||||
if (!app.languages.some(item => item.code === app.language)) app.language = app.defaultLanguage;
|
||||
try {
|
||||
await loadLanguagePack(app.language);
|
||||
} catch (error) {
|
||||
if (app.language === app.defaultLanguage) throw error;
|
||||
console.error(`Unable to load preferred language ${app.language}:`, error);
|
||||
app.language = app.defaultLanguage;
|
||||
await loadLanguagePack(app.defaultLanguage);
|
||||
}
|
||||
renderLanguageOptions();
|
||||
} catch (error) {
|
||||
console.error('Unable to load language packs:', error);
|
||||
const fallbackLanguage = app.defaultLanguage || DEFAULT_LANGUAGE;
|
||||
app.languages = fallbackLanguage ? [{ code: fallbackLanguage, name: fallbackLanguage.toUpperCase(), native_name: fallbackLanguage.toUpperCase(), locale: fallbackLanguage }] : [];
|
||||
app.translations = fallbackLanguage ? { [fallbackLanguage]: {} } : {};
|
||||
app.locales = fallbackLanguage ? { [fallbackLanguage]: fallbackLanguage } : {};
|
||||
app.language = fallbackLanguage;
|
||||
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(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 (_) { }
|
||||
const error = new Error(message);
|
||||
error.status = response.status;
|
||||
throw error;
|
||||
}
|
||||
if (response.status === 204) return null;
|
||||
return response.json();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user