v0.9.5
This commit is contained in:
+35
-37
@@ -29,7 +29,7 @@ const app = {
|
||||
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: {},
|
||||
historyTab: 'overview', historyData: { zones: [], devices: [], sensors: [] }, historyCounts: {},
|
||||
historyZone: 'all', historyDevice: 'all', historySensor: 'all', historyLoading: false,
|
||||
customChartSeries: [], savedCharts: [], chartZooms: {}, chartHiddenSeries: {}, zoneControlSeq: {}, groupControlSeq: {}, zoneControlQueue: {}, groupControlQueue: {}, deviceControlQueue: {}, houseControlQueue: null, climateControlQueue: null, groupCustomDrafts: {}, zoneTemperatureTimers: {},
|
||||
controlPlan: null, controlPlanTimer: null, debugLines: [], debugBacklogLoaded: false, debugFilter: 'all', sensorAliases: {},
|
||||
@@ -40,7 +40,7 @@ try { app.savedCharts = JSON.parse(localStorage.getItem('gree_controller_saved_c
|
||||
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 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;
|
||||
@@ -56,8 +56,8 @@ const zoneHysteresisForMode = (zone, mode) => {
|
||||
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 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;
|
||||
@@ -67,7 +67,7 @@ const formatCountdown = seconds => {
|
||||
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')}`;
|
||||
return `${String(minutes).padStart(2, '0')}:${String(remainder).padStart(2, '0')}`;
|
||||
};
|
||||
const formatExtendedCountdown = seconds => {
|
||||
if (!Number.isFinite(seconds)) return '∞';
|
||||
@@ -76,15 +76,15 @@ const formatExtendedCountdown = seconds => {
|
||||
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')}`;
|
||||
? `${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(group ? 'zones.groupOffDescriptionTimed' : 'zones.localThermostatOffDescriptionTimed', { group, time: formatCountdown(remaining) })
|
||||
: tr('zones.localThermostatResuming');
|
||||
});
|
||||
}
|
||||
@@ -103,23 +103,23 @@ function temporarySessionStatus(zone) {
|
||||
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});
|
||||
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};
|
||||
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};
|
||||
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)})
|
||||
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,
|
||||
kind: session.finish_kind,
|
||||
pending: true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -129,15 +129,15 @@ function temporarySessionStatus(zone) {
|
||||
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: 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};
|
||||
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};
|
||||
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};
|
||||
return { countdown: formatExtendedCountdown(hardRemaining), detail: tr(detailKey), kind: session.finish_kind };
|
||||
}
|
||||
function updateTemporaryThermostatCountdowns() {
|
||||
$$('[data-temporary-countdown-zone]').forEach(node => {
|
||||
@@ -189,7 +189,7 @@ function updateConnectionIndicator(status) {
|
||||
node.title = label;
|
||||
}
|
||||
const banner = $('#connectionBanner');
|
||||
const disconnected = ['disconnected','connectionError'].includes(app.connectionStatus);
|
||||
const disconnected = ['disconnected', 'connectionError'].includes(app.connectionStatus);
|
||||
document.body.classList.toggle('connection-lost', disconnected);
|
||||
if (banner) {
|
||||
banner.hidden = !disconnected;
|
||||
@@ -206,7 +206,7 @@ function updateToolbarControls() {
|
||||
if (languageLabel) languageLabel.textContent = String(app.language || DEFAULT_LANGUAGE).toUpperCase();
|
||||
const themeSelect = $('#themeSelect');
|
||||
const themeIcon = $('#themeIcon');
|
||||
const icons = {system:'◐',light:'☀',dark:'☾'};
|
||||
const icons = { system: '◐', light: '☀', dark: '☾' };
|
||||
if (themeIcon) themeIcon.textContent = icons[app.theme] || icons.system;
|
||||
if (themeSelect) {
|
||||
themeSelect.value = app.theme;
|
||||
@@ -263,29 +263,27 @@ function renderLanguageOptions() {
|
||||
|
||||
async function loadLanguages() {
|
||||
try {
|
||||
// Language packs must never come from a stale Service Worker/browser cache.
|
||||
// A cache-busting nonce is intentional: translations can change without the JS asset hash changing.
|
||||
const languageNonce = Date.now().toString(36);
|
||||
const languageUrl = path => {
|
||||
const separator = String(path).includes('?') ? '&' : '?';
|
||||
return `${withBase(path)}${separator}v=${languageNonce}`;
|
||||
};
|
||||
const response = await fetch(languageUrl('/lang/index.json'), {cache: 'no-store'});
|
||||
const response = await fetch(languageUrl('/lang/index.json'), { cache: 'no-store' });
|
||||
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(languageUrl(item.path || `/lang/${encodeURIComponent(item.code)}.json`), {cache: 'no-store'});
|
||||
const packResponse = await fetch(languageUrl(item.path || `/lang/${encodeURIComponent(item.code)}.json`), { cache: 'no-store' });
|
||||
if (!packResponse.ok) throw new Error(`Language ${item.code} HTTP ${packResponse.status}`);
|
||||
const pack = await packResponse.json();
|
||||
return {item, pack};
|
||||
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.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);
|
||||
@@ -293,9 +291,9 @@ async function loadLanguages() {
|
||||
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.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();
|
||||
}
|
||||
@@ -322,14 +320,14 @@ async function api(path, options = {}) {
|
||||
headers.set('Content-Type', 'application/json');
|
||||
body = JSON.stringify(body);
|
||||
}
|
||||
const response = await fetch(withBase(path), {...options, headers, 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 (_) {}
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user