v0.14.21
This commit is contained in:
@@ -1,16 +0,0 @@
|
||||
# Frontend JavaScript sources
|
||||
|
||||
`build.rs` concatenates the files listed in `APP_JS_MODULES` into one generated `app.bundle.js` in Cargo's `OUT_DIR`.
|
||||
The server exposes that bundle as `/app-<content-hash>.js`, so the browser downloads one application script and can cache it immutably.
|
||||
|
||||
Source responsibilities:
|
||||
|
||||
- `router.js` owns application URLs, browser history, route parsing and URL-to-view mapping.
|
||||
- `navigation.js` owns view/tab UI transitions and navigation-related controls.
|
||||
- the remaining files are split by feature or shared responsibility.
|
||||
|
||||
The Rust HTTP router serves `index.html` only for the explicitly supported SPA routes. Unknown paths return the standalone `web/404.html` page with HTTP `404` instead of silently opening the dashboard.
|
||||
|
||||
`web/app.js` is intentionally not used and should not exist. Do not recreate it manually; the bundle is generated at build time.
|
||||
|
||||
Keep the module order in `build.rs` explicit. These files intentionally share one application scope and are bundled before being served to the browser.
|
||||
Vendored
-85
@@ -1,85 +0,0 @@
|
||||
function settingsFromSnapshot(sections, houseMode = app.settings?.house_mode || 'cool') {
|
||||
const application = sections?.application || {};
|
||||
const gree = sections?.gree || {};
|
||||
const greeCloud = sections?.gree_cloud || {};
|
||||
const history = sections?.history || {};
|
||||
const influxdb = sections?.influxdb || {};
|
||||
const notifications = sections?.notifications || {};
|
||||
const night = sections?.night || {};
|
||||
const homeAssistant = sections?.home_assistant || {};
|
||||
const debug = sections?.debug || {};
|
||||
return {
|
||||
simulator_enabled: !!application.simulator_enabled,
|
||||
controller_id: gree.controller_id,
|
||||
poll_interval_seconds: Number(gree.poll_interval_seconds),
|
||||
zone_interval_seconds: Number(gree.zone_interval_seconds),
|
||||
discovery_timeout_ms: Number(gree.discovery_timeout_ms),
|
||||
discovery_broadcast: gree.discovery_broadcast,
|
||||
ping_metrics_enabled: gree.ping_metrics_enabled !== false,
|
||||
ping_interval_seconds: Number(gree.ping_interval_seconds || 60),
|
||||
ping_sample_count: Number(gree.ping_sample_count || 3),
|
||||
suppress_device_beep: !!gree.suppress_device_beep,
|
||||
gree_cloud: greeCloud,
|
||||
compressor_protection_enabled: gree.compressor_protection_enabled !== false,
|
||||
compressor_protection_seconds: Number(gree.compressor_protection_seconds),
|
||||
history_retention_days: Number(history.retention_days),
|
||||
history_compaction_enabled: history.compaction_enabled !== false,
|
||||
event_log_retention_days: Number(history.event_retention_days),
|
||||
influxdb,
|
||||
notifications,
|
||||
night_mode: night,
|
||||
home_assistant: homeAssistant,
|
||||
outdoor_assist_enabled: !!homeAssistant.outdoor_assist_enabled,
|
||||
debug,
|
||||
house_mode: houseMode || 'cool',
|
||||
};
|
||||
}
|
||||
|
||||
function applyBootstrapSnapshot(data) {
|
||||
app.devices = data.devices || [];
|
||||
app.deviceGroups = data.device_groups || [];
|
||||
app.deviceGroupEnergy = data.device_group_energy || {};
|
||||
app.zones = data.zones || [];
|
||||
app.groups = data.groups || [];
|
||||
app.schedules = data.schedules || [];
|
||||
app.automations = data.automations || [];
|
||||
app.flows = data.flows || [];
|
||||
app.accessTokens = data.access_tokens || [];
|
||||
app.settings = settingsFromSnapshot(data.settings, data.house?.mode || 'cool');
|
||||
app.sensorAliases = { ...(app.settings.home_assistant?.sensor_aliases || {}) };
|
||||
app.flowSharedInputs = JSON.parse(JSON.stringify(app.settings.home_assistant?.flow_inputs || []));
|
||||
app.system = data.system || {};
|
||||
app.systemSnapshotAt = Date.now();
|
||||
const outdoorTemperature = data.outdoor_temperature;
|
||||
app.outdoorTemperature = outdoorTemperature === null || outdoorTemperature === undefined || outdoorTemperature === ''
|
||||
? null
|
||||
: (Number.isFinite(Number(outdoorTemperature)) ? Number(outdoorTemperature) : null);
|
||||
|
||||
const hasControlPlan = !!data.control_plan;
|
||||
if (hasControlPlan) app.controlPlan = data.control_plan;
|
||||
if (Number.isFinite(Number(data.control_plan_revision))) app.controlPlanRevision = Number(data.control_plan_revision);
|
||||
return hasControlPlan;
|
||||
}
|
||||
|
||||
async function loadBootstrap(showMessage = false) {
|
||||
if (app.loading) { app.bootstrapReloadPending = true; return; }
|
||||
app.loading = true;
|
||||
try {
|
||||
const data = await api('/api/bootstrap');
|
||||
const hasControlPlan = applyBootstrapSnapshot(data);
|
||||
renderAll();
|
||||
if (!hasControlPlan) scheduleControlPlanLoad(0);
|
||||
if (app.settings?.debug?.overlay_enabled) loadDebugBacklog();
|
||||
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;
|
||||
if (app.bootstrapReloadPending) {
|
||||
app.bootstrapReloadPending = false;
|
||||
setTimeout(() => loadBootstrap(), 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,738 +0,0 @@
|
||||
function encodeChartSpec(series) {
|
||||
try {
|
||||
const raw = JSON.stringify(series || []);
|
||||
return btoa(raw).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
} catch (_) { return ''; }
|
||||
}
|
||||
|
||||
function decodeChartSpec(encoded) {
|
||||
try {
|
||||
const padded = String(encoded || '').replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(String(encoded || '').length / 4) * 4, '=');
|
||||
const value = JSON.parse(atob(padded));
|
||||
return Array.isArray(value) ? value.filter(item => typeof item === 'string').slice(0, 16) : [];
|
||||
} catch (_) { return []; }
|
||||
}
|
||||
|
||||
function historyEntityOptions() {
|
||||
const zones = app.zones.map(zone => `<option value="${esc(zone.id)}">${esc(zone.name)}</option>`).join('');
|
||||
const devices = app.devices.map(device => `<option value="${esc(device.id)}">${esc(device.name)}</option>`).join('');
|
||||
const entities = [...new Set([
|
||||
...app.historyData.sensors.map(row => row.entity_id),
|
||||
...app.zones.map(zoneHaEntityId).filter(Boolean),
|
||||
...app.zones.map(zone => zone.ha_outdoor_entity_id).filter(Boolean),
|
||||
app.settings?.home_assistant?.outdoor_entity_id,
|
||||
].filter(Boolean))].sort();
|
||||
const sensors = entities.map(entity => `<option value="${esc(entity)}">${esc(haSensorLabel(entity))}</option>`).join('');
|
||||
return { zones, devices, sensors, entities };
|
||||
}
|
||||
|
||||
function energyHistoryTargets() {
|
||||
const configuredGroups = (app.deviceGroups || []).filter(group => !!group.energy_device_id || !!group.ha_energy_entity_id);
|
||||
const groups = configuredGroups.map(group => ({
|
||||
id: `group:${group.id}`,
|
||||
label: `${group.name} · ${deviceInstallationKindLabel(group)}`,
|
||||
type: 'group',
|
||||
group,
|
||||
}));
|
||||
const groupedIds = new Set(configuredGroups.flatMap(group => group.device_ids || []));
|
||||
const devices = app.devices
|
||||
.filter(device => (device.capabilities?.energy_meter === true || !!device.ha_energy_entity_id) && !groupedIds.has(device.id))
|
||||
.map(device => ({ id: device.id, label: `${device.name} · ${deviceTransportLabel(device)}`, type: 'device', device }));
|
||||
return [...groups, ...devices];
|
||||
}
|
||||
|
||||
function normalizeEnergyHistoryTargets() {
|
||||
const available = energyHistoryTargets();
|
||||
const ids = new Set(available.map(item => item.id));
|
||||
app.historyEnergyTargets = (app.historyEnergyTargets || []).filter(id => ids.has(id));
|
||||
if (!app.historyEnergyTargets.length && app.historyEnergyDevice && ids.has(app.historyEnergyDevice)) app.historyEnergyTargets = [app.historyEnergyDevice];
|
||||
if (!app.historyEnergyTargets.length && available.length) app.historyEnergyTargets = [available[0].id];
|
||||
app.historyEnergyTargets = app.historyEnergyTargets.slice(0, 8);
|
||||
app.historyEnergyDevice = app.historyEnergyTargets[0] || '';
|
||||
return available;
|
||||
}
|
||||
|
||||
function energyTargetPickerSummary(targets) {
|
||||
const selected = targets.filter(target => app.historyEnergyTargets.includes(target.id));
|
||||
if (!selected.length) return tr('energy.chooseTargets');
|
||||
if (selected.length === 1) return selected[0].label;
|
||||
return tr('energy.selectedCount', { count: selected.length });
|
||||
}
|
||||
|
||||
function resetHistoryRangeControl(host) {
|
||||
const range = $('#historyRangeControl');
|
||||
const toolbar = host?.closest('.chart-toolbar');
|
||||
const refresh = $('#historyRefresh');
|
||||
if (range && toolbar && range.parentElement === host) toolbar.insertBefore(range, refresh);
|
||||
}
|
||||
|
||||
function configureHistoryRangeControl(tab, host, hasEnergyTargets = true) {
|
||||
const range = $('#historyRangeControl');
|
||||
if (!range) return;
|
||||
const toolbar = range.closest('.chart-toolbar') || host?.closest('.chart-toolbar');
|
||||
const toolbarPanel = toolbar?.closest('.history-toolbar-panel');
|
||||
const refresh = $('#historyRefresh');
|
||||
const label = range.querySelector('span');
|
||||
const hint = $('#historyRangeHint');
|
||||
const sixHours = range.querySelector('option[value="6"]');
|
||||
const energy = tab === 'energy';
|
||||
const energyUnavailable = energy && !hasEnergyTargets;
|
||||
const rangeLabel = tr(energy ? 'energy.period' : 'history.range');
|
||||
if (label) label.textContent = rangeLabel;
|
||||
range.setAttribute('aria-label', rangeLabel);
|
||||
if (hint) { hint.textContent = energy ? tr('energy.periodHint') : ''; hint.hidden = !energy; }
|
||||
if (sixHours) { sixHours.hidden = energy; sixHours.disabled = energy; }
|
||||
if (energy && $('#historyHours')?.value === '6') $('#historyHours').value = '24';
|
||||
range.hidden = energyUnavailable;
|
||||
if (refresh) refresh.hidden = energyUnavailable;
|
||||
if (toolbarPanel) toolbarPanel.hidden = energyUnavailable;
|
||||
range.classList.toggle('energy-period-control', energy);
|
||||
if (toolbar) toolbar.classList.toggle('energy-toolbar', energy);
|
||||
if (energy && host && hasEnergyTargets) host.appendChild(range);
|
||||
}
|
||||
|
||||
function renderHistoryNavigation() {
|
||||
$$('#historyTabs [data-history-tab]').forEach(button => button.classList.toggle('active', button.dataset.historyTab === app.historyTab));
|
||||
const energyPickerWasOpen = $('#historyEnergyTargetPicker')?.open === true;
|
||||
const host = $('#historyContextControls'); if (!host) return;
|
||||
let hasEnergyTargets = true;
|
||||
resetHistoryRangeControl(host);
|
||||
host.classList.toggle('energy-context-controls', app.historyTab === 'energy');
|
||||
const options = historyEntityOptions();
|
||||
if (app.historyTab === 'zones') {
|
||||
host.innerHTML = `<label><span>${esc(tr('common.zone'))}</span><select id="historyZoneSelect"><option value="all">${esc(tr('history.allZones'))}</option>${options.zones}</select></label>`;
|
||||
const select = $('#historyZoneSelect'); if ([...select.options].some(option => option.value === app.historyZone)) select.value = app.historyZone;
|
||||
} else if (app.historyTab === 'devices') {
|
||||
host.innerHTML = `<label><span>${esc(tr('common.device'))}</span><select id="historyDeviceSelect"><option value="all">${esc(tr('history.allDevices'))}</option>${options.devices}</select></label>`;
|
||||
const select = $('#historyDeviceSelect'); if ([...select.options].some(option => option.value === app.historyDevice)) select.value = app.historyDevice;
|
||||
} else if (app.historyTab === 'energy') {
|
||||
const targets = normalizeEnergyHistoryTargets();
|
||||
hasEnergyTargets = targets.length > 0;
|
||||
const targetOptions = targets.map(target => `<label class="history-energy-option"><input type="checkbox" data-history-energy-target="${esc(target.id)}" ${app.historyEnergyTargets.includes(target.id) ? 'checked' : ''}><span>${esc(target.label)}</span></label>`).join('');
|
||||
host.innerHTML = targets.length
|
||||
? `<div class="history-energy-targets"><span class="history-control-label">${esc(tr('energy.targets'))}</span><details class="history-energy-picker" id="historyEnergyTargetPicker"><summary><span>${esc(energyTargetPickerSummary(targets))}</span><b>${app.historyEnergyTargets.length}/8</b></summary><div class="history-energy-options">${targetOptions}</div></details><small>${esc(tr('energy.multiselectHint'))}</small></div><label><span>${esc(tr('history.bucket'))}</span><select id="historyEnergyInterval"><option value="hourly">${esc(tr('energy.hourly'))}</option><option value="daily">${esc(tr('energy.daily'))}</option><option value="weekly">${esc(tr('energy.weekly'))}</option><option value="monthly">${esc(tr('energy.monthly'))}</option></select></label><label><span>${esc(tr('energy.compare'))}</span><select id="historyEnergyCompare"><option value="none">${esc(tr('energy.compareNone'))}</option><option value="previous_day">${esc(tr('energy.comparePreviousDay'))}</option><option value="previous_period">${esc(tr('energy.comparePreviousPeriod'))}</option><option value="previous_year">${esc(tr('energy.comparePreviousYear'))}</option></select></label>`
|
||||
: '';
|
||||
const intervalSelect = $('#historyEnergyInterval'); if (intervalSelect) intervalSelect.value = app.historyEnergyInterval;
|
||||
const compareSelect = $('#historyEnergyCompare'); if (compareSelect) compareSelect.value = app.historyEnergyCompare;
|
||||
if (energyPickerWasOpen && $('#historyEnergyTargetPicker')) $('#historyEnergyTargetPicker').open = true;
|
||||
} else if (app.historyTab === 'network') {
|
||||
const targets = app.historyNetworkTargets || [];
|
||||
const targetOptions = targets.map(target => `<option value="${esc(target.id)}">${esc(target.name)}</option>`).join('');
|
||||
const jitterEnabled = app.historyNetworkShowJitter !== false;
|
||||
host.innerHTML = `<div class="history-network-controls"><label><span>${esc(tr('history.networkTarget'))}</span><select id="historyNetworkSelect"><option value="all">${esc(tr('history.allNetworkTargets'))}</option>${targetOptions}</select></label><button type="button" class="secondary history-jitter-toggle${jitterEnabled ? ' active' : ''}" data-action="toggle-network-jitter" aria-pressed="${jitterEnabled ? 'true' : 'false'}" title="${esc(tr('history.networkJitterToggleHint'))}">${esc(tr(jitterEnabled ? 'history.networkJitterOn' : 'history.networkJitterOff'))}</button></div>`;
|
||||
const select = $('#historyNetworkSelect'); if ([...select.options].some(option => option.value === app.historyNetworkTarget)) select.value = app.historyNetworkTarget;
|
||||
} else if (app.historyTab === 'sensors') {
|
||||
host.innerHTML = `<label><span>${esc(tr('history.haSensor'))}</span><select id="historySensorSelect"><option value="all">${esc(tr('history.allSensors'))}</option>${options.sensors}</select></label>`;
|
||||
const select = $('#historySensorSelect'); if ([...select.options].some(option => option.value === app.historySensor)) select.value = app.historySensor;
|
||||
} else if (app.historyTab === 'custom') {
|
||||
host.innerHTML = `<span class="history-context-hint">${esc(tr('history.customHint'))}</span>`;
|
||||
} else {
|
||||
host.innerHTML = `<span class="history-context-hint">${esc(tr('history.overviewHint'))}</span>`;
|
||||
}
|
||||
configureHistoryRangeControl(app.historyTab, host, hasEnergyTargets);
|
||||
}
|
||||
|
||||
async function loadHistory() {
|
||||
if (app.historyLoading) { app.historyReloadPending = true; return; }
|
||||
app.historyLoading = true;
|
||||
const hours = $('#historyHours')?.value || '24';
|
||||
try {
|
||||
if (app.historyTab === 'energy') {
|
||||
await loadEnergyHistory();
|
||||
renderHistoryNavigation();
|
||||
renderHistoryPage();
|
||||
if (app.currentView === 'history') updateBrowserUrl(currentHistoryPath(), true);
|
||||
return;
|
||||
}
|
||||
if (app.historyTab === 'network') {
|
||||
const target = app.historyNetworkTarget && app.historyNetworkTarget !== 'all' ? `&target_id=${encodeURIComponent(app.historyNetworkTarget)}` : '';
|
||||
const data = await api(`/api/history/network?hours=${encodeURIComponent(hours)}&limit=20000${target}`);
|
||||
app.historyNetwork = data.readings || [];
|
||||
app.historyNetworkTargets = data.targets || [];
|
||||
renderHistoryNavigation();
|
||||
renderHistoryPage();
|
||||
if (app.currentView === 'history') updateBrowserUrl(currentHistoryPath(), true);
|
||||
return;
|
||||
}
|
||||
const data = await api(`/api/history?scope=overview&hours=${encodeURIComponent(hours)}&limit=20000`);
|
||||
app.historyData = { zones: data.zones || [], devices: data.devices || [], sensors: data.sensors || [] };
|
||||
app.historyCounts = data.counts || {};
|
||||
renderHistoryNavigation();
|
||||
renderHistoryPage();
|
||||
if (app.currentView === 'history') updateBrowserUrl(currentHistoryPath(), true);
|
||||
} catch (error) {
|
||||
toast(error.message, true);
|
||||
renderHistoryPage();
|
||||
} finally {
|
||||
app.historyLoading = false;
|
||||
if (app.historyReloadPending) { app.historyReloadPending = false; setTimeout(() => loadHistory(), 0); }
|
||||
}
|
||||
}
|
||||
|
||||
const HISTORY_COLORS = ['--accent', '--info', '--warning', '--purple', '--danger', '--teal', '--orange', '--blue'];
|
||||
const MAX_CHART_ZOOM = 16;
|
||||
const chartRuntime = new Map();
|
||||
const chartFullscreenState = new WeakMap();
|
||||
|
||||
function chartSeriesKey(item, index) {
|
||||
return item.key || `${index}:${item.label}`;
|
||||
}
|
||||
|
||||
function isChartSeriesHidden(chartId, item, index) {
|
||||
return !!app.chartHiddenSeries[chartId]?.[chartSeriesKey(item, index)];
|
||||
}
|
||||
|
||||
function setChartSeriesHidden(chartId, item, index, hidden) {
|
||||
if (!app.chartHiddenSeries[chartId]) app.chartHiddenSeries[chartId] = {};
|
||||
const key = chartSeriesKey(item, index);
|
||||
if (hidden) app.chartHiddenSeries[chartId][key] = true;
|
||||
else delete app.chartHiddenSeries[chartId][key];
|
||||
}
|
||||
|
||||
function updateChartZoomControls(id) {
|
||||
const canvas = document.getElementById(id);
|
||||
const card = canvas?.closest('.chart-card');
|
||||
if (!card) return;
|
||||
const zoom = clamp(Number(app.chartZooms[id] || 1), 1, MAX_CHART_ZOOM);
|
||||
const reset = card.querySelector('[data-chart-zoom="reset"]');
|
||||
const out = card.querySelector('[data-chart-zoom="out"]');
|
||||
const zoomIn = card.querySelector('[data-chart-zoom="in"]');
|
||||
if (reset) reset.textContent = `${Math.round(zoom * 100)}%`;
|
||||
if (out) out.disabled = zoom <= 1;
|
||||
if (zoomIn) zoomIn.disabled = zoom >= MAX_CHART_ZOOM;
|
||||
}
|
||||
|
||||
function redrawHistoryChart(id) {
|
||||
const runtime = chartRuntime.get(id);
|
||||
const canvas = document.getElementById(id);
|
||||
if (!runtime || !canvas) return;
|
||||
canvas.dataset.chartZoom = String(clamp(Number(app.chartZooms[id] || 1), 1, MAX_CHART_ZOOM));
|
||||
if (runtime.kind === 'energy') {
|
||||
drawEnergyBarChart(canvas, runtime.series, runtime.options);
|
||||
} else {
|
||||
drawLineChart(canvas, runtime.series, runtime.rows, runtime.options);
|
||||
renderLegend(document.getElementById(`${id}Legend`), runtime.series);
|
||||
}
|
||||
updateChartZoomControls(id);
|
||||
}
|
||||
|
||||
function setChartZoom(id, value, anchorRatio = null, anchorViewportX = null) {
|
||||
const canvas = document.getElementById(id);
|
||||
const wrap = canvas?.parentElement;
|
||||
if (!canvas || !wrap) return;
|
||||
const oldWidth = canvas.getBoundingClientRect().width || 1;
|
||||
const ratio = Number.isFinite(anchorRatio) ? clamp(anchorRatio, 0, 1) : clamp((wrap.scrollLeft + wrap.clientWidth / 2) / oldWidth, 0, 1);
|
||||
const viewportX = Number.isFinite(anchorViewportX) ? anchorViewportX : wrap.clientWidth / 2;
|
||||
const next = clamp(Number(value) || 1, 1, MAX_CHART_ZOOM);
|
||||
app.chartZooms[id] = next;
|
||||
redrawHistoryChart(id);
|
||||
const nextCanvas = document.getElementById(id);
|
||||
const nextWrap = nextCanvas?.parentElement;
|
||||
if (!nextCanvas || !nextWrap) return;
|
||||
const nextWidth = nextCanvas.getBoundingClientRect().width || 1;
|
||||
const previousBehavior = nextWrap.style.scrollBehavior;
|
||||
nextWrap.style.scrollBehavior = 'auto';
|
||||
nextWrap.scrollLeft = Math.max(0, ratio * nextWidth - viewportX);
|
||||
nextWrap.style.scrollBehavior = previousBehavior;
|
||||
}
|
||||
|
||||
function cssColor(name, fallback) {
|
||||
const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
|
||||
return value || fallback;
|
||||
}
|
||||
|
||||
function timeLabel(ts, hours) {
|
||||
const options = Number(hours) > 48 ? { day: '2-digit', month: '2-digit', hour: '2-digit' } : { hour: '2-digit', minute: '2-digit' };
|
||||
return new Date(ts).toLocaleString(locale(), options);
|
||||
}
|
||||
|
||||
function chartCardIsFullscreen(card) {
|
||||
return !!card && card.classList.contains('chart-fullscreen-fallback');
|
||||
}
|
||||
|
||||
function updateChartFullscreenButton(card) {
|
||||
if (!card) return;
|
||||
const button = card.querySelector('[data-chart-fullscreen]');
|
||||
if (!button) return;
|
||||
const active = chartCardIsFullscreen(card);
|
||||
button.innerHTML = uiIcon(active ? 'close' : 'fullscreen');
|
||||
button.title = tr(active ? 'history.exitFullscreen' : 'history.fullscreen');
|
||||
button.setAttribute('aria-label', button.title);
|
||||
}
|
||||
|
||||
function restoreChartPreviewLayout(card) {
|
||||
const state = chartFullscreenState.get(card);
|
||||
if (!state) return null;
|
||||
const canvas = card.querySelector('canvas[id]');
|
||||
const wrap = canvas?.parentElement;
|
||||
if (canvas) {
|
||||
if (state.canvasStyleWidth) canvas.style.width = state.canvasStyleWidth; else canvas.style.removeProperty('width');
|
||||
if (state.canvasStyleHeight) canvas.style.height = state.canvasStyleHeight; else canvas.style.removeProperty('height');
|
||||
canvas.width = state.canvasWidth;
|
||||
canvas.height = state.canvasHeight;
|
||||
}
|
||||
if (wrap) {
|
||||
if (state.wrapStyleHeight) wrap.style.height = state.wrapStyleHeight; else wrap.style.removeProperty('height');
|
||||
if (state.wrapStyleMinHeight) wrap.style.minHeight = state.wrapStyleMinHeight; else wrap.style.removeProperty('min-height');
|
||||
if (state.wrapStyleMaxHeight) wrap.style.maxHeight = state.wrapStyleMaxHeight; else wrap.style.removeProperty('max-height');
|
||||
wrap.scrollLeft = state.scrollLeft;
|
||||
wrap.scrollTop = state.scrollTop;
|
||||
}
|
||||
chartFullscreenState.delete(card);
|
||||
return state;
|
||||
}
|
||||
|
||||
function closeChartPreview(card) {
|
||||
if (!card) return;
|
||||
card.classList.remove('chart-fullscreen-fallback');
|
||||
card.removeAttribute('role');
|
||||
card.removeAttribute('aria-modal');
|
||||
card.removeAttribute('aria-label');
|
||||
document.body.classList.remove('chart-fullscreen-open');
|
||||
const state = restoreChartPreviewLayout(card);
|
||||
updateChartFullscreenButton(card);
|
||||
const canvas = card.querySelector('canvas[id]');
|
||||
if (!canvas?.id) return;
|
||||
|
||||
// Force layout after dropping the fullscreen class before sizing the canvas again.
|
||||
// This prevents a fullscreen-sized canvas from keeping a dialog artificially tall.
|
||||
card.getBoundingClientRect();
|
||||
requestAnimationFrame(() => {
|
||||
redrawHistoryChart(canvas.id);
|
||||
const wrap = canvas.parentElement;
|
||||
if (wrap && state) {
|
||||
wrap.scrollLeft = state.scrollLeft;
|
||||
wrap.scrollTop = state.scrollTop;
|
||||
}
|
||||
const dialog = card.closest('dialog');
|
||||
if (dialog?.open) {
|
||||
dialog.style.removeProperty('height');
|
||||
dialog.style.removeProperty('max-height');
|
||||
requestAnimationFrame(() => {
|
||||
dialog.style.removeProperty('height');
|
||||
dialog.style.removeProperty('max-height');
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function toggleChartFullscreen(id) {
|
||||
if (!window.matchMedia('(min-width: 761px)').matches) return;
|
||||
const canvas = document.getElementById(id);
|
||||
const card = canvas?.closest('.chart-card');
|
||||
if (!card) return;
|
||||
|
||||
const active = chartCardIsFullscreen(card);
|
||||
const opened = document.querySelector('.chart-card.chart-fullscreen-fallback');
|
||||
if (opened && opened !== card) closeChartPreview(opened);
|
||||
|
||||
if (active) {
|
||||
closeChartPreview(card);
|
||||
return;
|
||||
}
|
||||
|
||||
const wrap = canvas.parentElement;
|
||||
chartFullscreenState.set(card, {
|
||||
canvasStyleWidth: canvas.style.width,
|
||||
canvasStyleHeight: canvas.style.height,
|
||||
canvasWidth: canvas.width,
|
||||
canvasHeight: canvas.height,
|
||||
wrapStyleHeight: wrap?.style.height || '',
|
||||
wrapStyleMinHeight: wrap?.style.minHeight || '',
|
||||
wrapStyleMaxHeight: wrap?.style.maxHeight || '',
|
||||
scrollLeft: wrap?.scrollLeft || 0,
|
||||
scrollTop: wrap?.scrollTop || 0,
|
||||
});
|
||||
card.classList.add('chart-fullscreen-fallback');
|
||||
card.setAttribute('role', 'dialog');
|
||||
card.setAttribute('aria-modal', 'true');
|
||||
card.setAttribute('aria-label', card.querySelector('h3')?.textContent || tr('history.fullscreen'));
|
||||
document.body.classList.add('chart-fullscreen-open');
|
||||
updateChartFullscreenButton(card);
|
||||
requestAnimationFrame(() => redrawHistoryChart(id));
|
||||
}
|
||||
|
||||
function prepareCanvas(canvas, height) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const wrap = canvas.parentElement;
|
||||
const wrapWidth = Math.floor(wrap?.clientWidth || rect.width || 720);
|
||||
const fullscreen = chartCardIsFullscreen(canvas.closest('.chart-card'));
|
||||
const wrapHeight = fullscreen ? Math.floor(wrap?.clientHeight || 0) : 0;
|
||||
const actualHeight = wrapHeight || height;
|
||||
const zoom = clamp(Number(canvas.dataset.chartZoom || 1), 1, MAX_CHART_ZOOM);
|
||||
// Keep the original chart geometry on narrow screens and let the wrapper scroll horizontally.
|
||||
const baseWidth = Math.max(720, wrapWidth);
|
||||
const width = Math.max(1, Math.floor(baseWidth * zoom));
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
canvas.width = width * dpr; canvas.height = actualHeight * dpr;
|
||||
canvas.style.width = `${width}px`; canvas.style.height = `${actualHeight}px`;
|
||||
const ctx = canvas.getContext('2d'); ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
ctx.clearRect(0, 0, width, actualHeight);
|
||||
return { ctx, width, height: actualHeight };
|
||||
}
|
||||
|
||||
function drawEmptyChart(canvas, height = 340) {
|
||||
if (!canvas) return;
|
||||
const prepared = prepareCanvas(canvas, height);
|
||||
const { ctx, width } = prepared;
|
||||
height = prepared.height;
|
||||
ctx.fillStyle = cssColor('--muted', '#888'); ctx.font = '13px system-ui'; ctx.textAlign = 'center';
|
||||
ctx.fillText(tr('history.noData'), width / 2, height / 2);
|
||||
const wrap = canvas.parentElement;
|
||||
const tooltip = wrap?.querySelector('.chart-tooltip');
|
||||
const line = wrap?.querySelector('.chart-hover-line');
|
||||
if (tooltip) tooltip.hidden = true;
|
||||
if (line) line.hidden = true;
|
||||
canvas.onpointermove = null; canvas.onpointerleave = null; canvas.onpointerdown = null; canvas.onpointerup = null; canvas.onpointercancel = null; canvas.onkeydown = null;
|
||||
}
|
||||
|
||||
function chartPreciseTime(timestamp) {
|
||||
return new Intl.DateTimeFormat(locale(), {
|
||||
year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit'
|
||||
}).format(new Date(timestamp));
|
||||
}
|
||||
|
||||
function nearestPoint(points, targetTs) {
|
||||
if (!points.length) return null;
|
||||
let lo = 0, hi = points.length - 1;
|
||||
while (lo < hi) {
|
||||
const mid = Math.floor((lo + hi) / 2);
|
||||
if (points[mid].ts < targetTs) lo = mid + 1; else hi = mid;
|
||||
}
|
||||
const after = points[lo];
|
||||
const before = lo > 0 ? points[lo - 1] : null;
|
||||
if (!before) return after;
|
||||
return Math.abs(before.ts - targetTs) <= Math.abs(after.ts - targetTs) ? before : after;
|
||||
}
|
||||
|
||||
function formatChartTooltipValue(item, point, binaryLabels, tooltipUnit = ' °C') {
|
||||
if (!point || !Number.isFinite(point.value)) return '—';
|
||||
if (typeof item.tooltipValue === 'function') return String(item.tooltipValue(point.value, point.row));
|
||||
const formatted = Number(point.value).toLocaleString(locale(), { minimumFractionDigits: 1, maximumFractionDigits: 2 });
|
||||
return binaryLabels ? formatted : `${formatted}${tooltipUnit}`;
|
||||
}
|
||||
|
||||
function bindChartTooltip(canvas, series, sortedRows, geometry) {
|
||||
const wrap = canvas?.parentElement;
|
||||
const tooltip = wrap?.querySelector('.chart-tooltip');
|
||||
const line = wrap?.querySelector('.chart-hover-line');
|
||||
const selection = wrap?.querySelector('.chart-selection');
|
||||
if (!canvas || !wrap || !tooltip || !line) return;
|
||||
|
||||
const { pad, width, height, firstTs, lastTs, binaryLabels, tooltipUnit } = geometry;
|
||||
const plotWidth = width - pad.left - pad.right;
|
||||
const timestamps = [...new Set(sortedRows.map(row => new Date(row.timestamp).getTime()).filter(Number.isFinite))].sort((a, b) => a - b);
|
||||
const timestampPoints = timestamps.map(ts => ({ ts, value: ts }));
|
||||
const pointsBySeries = series.map(item => ({
|
||||
item,
|
||||
points: sortedRows.map(row => ({ row, ts: new Date(row.timestamp).getTime(), value: item.value(row) }))
|
||||
.filter(point => Number.isFinite(point.ts) && Number.isFinite(point.value))
|
||||
.sort((a, b) => a.ts - b.ts)
|
||||
}));
|
||||
let drag = null;
|
||||
|
||||
const hide = () => { tooltip.hidden = true; line.hidden = true; };
|
||||
const hideSelection = () => { if (selection) selection.hidden = true; };
|
||||
const canvasX = clientX => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
return clamp(clientX - rect.left, pad.left, width - pad.right);
|
||||
};
|
||||
const showAtTimestamp = (targetTs) => {
|
||||
if (!timestamps.length) return hide();
|
||||
const snapped = nearestPoint(timestampPoints, targetTs)?.ts;
|
||||
if (!Number.isFinite(snapped)) return hide();
|
||||
const px = pad.left + (snapped - firstTs) / Math.max(1, lastTs - firstTs) * plotWidth;
|
||||
const values = pointsBySeries.map(({ item, points }) => ({ item, point: nearestPoint(points, snapped) })).filter(entry => entry.point);
|
||||
if (!values.length) return hide();
|
||||
|
||||
$$('.chart-tooltip').forEach(node => { if (node !== tooltip) node.hidden = true; });
|
||||
$$('.chart-hover-line').forEach(node => { if (node !== line) node.hidden = true; });
|
||||
tooltip.innerHTML = `<strong class="chart-tooltip-time">${esc(chartPreciseTime(snapped))}</strong><div class="chart-tooltip-values">${values.map(({ item, point }) => {
|
||||
const sampleTime = Math.abs(point.ts - snapped) > 1000 ? `<small class="chart-tooltip-sample-time">${esc(chartPreciseTime(point.ts))}</small>` : '';
|
||||
return `<div class="chart-tooltip-row"><span class="chart-tooltip-label"><i style="--tooltip-color:${esc(item.color)}"></i><span>${esc(item.label)}</span></span><span class="chart-tooltip-value">${esc(formatChartTooltipValue(item, point, binaryLabels, tooltipUnit))}${sampleTime}</span></div>`;
|
||||
}).join('')}</div>`;
|
||||
tooltip.hidden = false;
|
||||
line.hidden = false;
|
||||
line.style.left = `${px}px`;
|
||||
line.style.top = `${pad.top}px`;
|
||||
line.style.height = `${Math.max(0, height - pad.top - pad.bottom)}px`;
|
||||
|
||||
const minLeft = wrap.scrollLeft + 8;
|
||||
const maxLeft = wrap.scrollLeft + wrap.clientWidth - tooltip.offsetWidth - 8;
|
||||
let tooltipLeft = px + 12;
|
||||
if (tooltipLeft + tooltip.offsetWidth > wrap.scrollLeft + wrap.clientWidth - 8) tooltipLeft = px - tooltip.offsetWidth - 12;
|
||||
tooltip.style.left = `${Math.max(minLeft, Math.min(tooltipLeft, Math.max(minLeft, maxLeft)))}px`;
|
||||
tooltip.style.top = `${pad.top + 8}px`;
|
||||
canvas.dataset.chartHoverTs = String(snapped);
|
||||
};
|
||||
|
||||
const showFromClientX = (clientX) => {
|
||||
const localX = canvasX(clientX);
|
||||
const targetTs = firstTs + ((localX - pad.left) / Math.max(1, plotWidth)) * (lastTs - firstTs);
|
||||
showAtTimestamp(targetTs);
|
||||
};
|
||||
|
||||
const updateSelection = (currentX) => {
|
||||
if (!drag || !selection) return;
|
||||
const left = Math.min(drag.startX, currentX);
|
||||
const right = Math.max(drag.startX, currentX);
|
||||
drag.lastX = currentX;
|
||||
drag.moved = Math.abs(right - left) >= 7;
|
||||
if (!drag.moved) return hideSelection();
|
||||
selection.hidden = false;
|
||||
selection.style.left = `${left}px`;
|
||||
selection.style.width = `${Math.max(1, right - left)}px`;
|
||||
selection.style.top = `${pad.top}px`;
|
||||
selection.style.height = `${Math.max(0, height - pad.top - pad.bottom)}px`;
|
||||
};
|
||||
|
||||
canvas.onpointerdown = event => {
|
||||
if (event.pointerType !== 'mouse' || event.button !== 0) return;
|
||||
const startX = canvasX(event.clientX);
|
||||
drag = { pointerId: event.pointerId, startX, lastX: startX, moved: false };
|
||||
hide(); hideSelection();
|
||||
canvas.setPointerCapture?.(event.pointerId);
|
||||
event.preventDefault();
|
||||
};
|
||||
|
||||
canvas.onpointermove = event => {
|
||||
if (event.pointerType === 'touch') return;
|
||||
if (event.pointerType === 'mouse' && drag?.pointerId === event.pointerId) {
|
||||
updateSelection(canvasX(event.clientX));
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
showFromClientX(event.clientX);
|
||||
};
|
||||
|
||||
const finishPointer = event => {
|
||||
if (event.pointerType !== 'mouse' || drag?.pointerId !== event.pointerId) return;
|
||||
const currentX = canvasX(event.clientX);
|
||||
const selectedWidth = Math.abs(currentX - drag.startX);
|
||||
const center = (currentX + drag.startX) / 2;
|
||||
const moved = drag.moved && selectedWidth >= 12;
|
||||
drag = null; hideSelection();
|
||||
try { canvas.releasePointerCapture?.(event.pointerId); } catch (_) { }
|
||||
if (moved) {
|
||||
const currentZoom = clamp(Number(app.chartZooms[canvas.id] || 1), 1, MAX_CHART_ZOOM);
|
||||
const factor = Math.max(1, wrap.clientWidth / Math.max(1, selectedWidth));
|
||||
setChartZoom(canvas.id, currentZoom * factor, center / Math.max(1, width), wrap.clientWidth / 2);
|
||||
} else showFromClientX(event.clientX);
|
||||
};
|
||||
|
||||
canvas.onpointerup = finishPointer;
|
||||
canvas.onpointercancel = event => {
|
||||
if (event.pointerType === 'mouse' && drag?.pointerId === event.pointerId) { drag = null; hideSelection(); }
|
||||
};
|
||||
canvas.onpointerleave = event => { if (event.pointerType !== 'touch' && !drag) hide(); };
|
||||
canvas.onkeydown = event => {
|
||||
if (!['ArrowLeft', 'ArrowRight'].includes(event.key) || !timestamps.length) return;
|
||||
event.preventDefault();
|
||||
const current = Number(canvas.dataset.chartHoverTs);
|
||||
let index = Number.isFinite(current) ? timestamps.findIndex(ts => ts === current) : -1;
|
||||
if (index < 0) index = event.key === 'ArrowRight' ? 0 : timestamps.length - 1;
|
||||
else index = clamp(index + (event.key === 'ArrowRight' ? 1 : -1), 0, timestamps.length - 1);
|
||||
showAtTimestamp(timestamps[index]);
|
||||
};
|
||||
}
|
||||
|
||||
function drawLineChart(canvas, series, rows, { height = 340, minValue = null, maxValue = null, binaryLabels = false, axisSuffix = '°', tooltipUnit = ' °C', axisDigits = 1 } = {}) {
|
||||
if (!canvas) return;
|
||||
const options = { height, minValue, maxValue, binaryLabels, axisSuffix, tooltipUnit, axisDigits };
|
||||
if (canvas.id) chartRuntime.set(canvas.id, { series, rows, options });
|
||||
const visibleSeries = series.filter((item, index) => !isChartSeriesHidden(canvas.id, item, index));
|
||||
if (!rows.length || !visibleSeries.length) return drawEmptyChart(canvas, height);
|
||||
const sortedRows = [...rows].sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp));
|
||||
const prepared = prepareCanvas(canvas, height);
|
||||
const { ctx, width } = prepared;
|
||||
height = prepared.height;
|
||||
const text = cssColor('--muted', '#888'), grid = cssColor('--grid', '#333');
|
||||
const compactPlot = width < 520;
|
||||
const pad = compactPlot ? { left: 43, right: 12, top: 16, bottom: 34 } : { left: 54, right: 20, top: 20, bottom: 42 };
|
||||
const allValues = [];
|
||||
visibleSeries.forEach(item => sortedRows.forEach(row => { const value = item.value(row); if (Number.isFinite(value)) allValues.push(value); }));
|
||||
if (!allValues.length) return drawEmptyChart(canvas, height);
|
||||
let min = minValue == null ? Math.floor(Math.min(...allValues) - 1) : minValue;
|
||||
let max = maxValue == null ? Math.ceil(Math.max(...allValues) + 1) : maxValue;
|
||||
if (max - min < 2) { min -= 1; max += 1; }
|
||||
const firstTs = new Date(sortedRows[0].timestamp).getTime();
|
||||
const lastTs = new Date(sortedRows[sortedRows.length - 1].timestamp).getTime();
|
||||
const span = Math.max(1, lastTs - firstTs);
|
||||
const x = row => pad.left + (new Date(row.timestamp).getTime() - firstTs) / span * (width - pad.left - pad.right);
|
||||
const y = value => pad.top + (max - value) / (max - min) * (height - pad.top - pad.bottom);
|
||||
ctx.font = '10px system-ui'; ctx.fillStyle = text; ctx.strokeStyle = grid; ctx.lineWidth = 1;
|
||||
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(binaryLabels ? value.toFixed(0) : `${value.toFixed(axisDigits)}${axisSuffix}`, pad.left - 8, py + 3);
|
||||
}
|
||||
const ticks = width < 420 ? 2 : width < 620 ? 3 : 5;
|
||||
for (let i = 0; i <= ticks; i++) {
|
||||
const idx = Math.min(sortedRows.length - 1, Math.round(i * (sortedRows.length - 1) / ticks)); const px = x(sortedRows[idx]);
|
||||
ctx.textAlign = 'center'; ctx.fillText(timeLabel(sortedRows[idx].timestamp, $('#historyHours')?.value), px, height - 14);
|
||||
}
|
||||
visibleSeries.forEach(item => {
|
||||
ctx.beginPath(); ctx.strokeStyle = item.color; ctx.lineWidth = item.width || 2; ctx.setLineDash(item.dash || []);
|
||||
let started = false, prev = null;
|
||||
sortedRows.forEach(row => {
|
||||
const value = item.value(row); if (!Number.isFinite(value)) return;
|
||||
const px = x(row), py = y(value);
|
||||
if (!started || prev === null) { ctx.moveTo(px, py); started = true; }
|
||||
else if (item.step) { ctx.lineTo(px, y(prev)); ctx.lineTo(px, py); } else ctx.lineTo(px, py);
|
||||
prev = value;
|
||||
});
|
||||
ctx.stroke(); ctx.setLineDash([]);
|
||||
});
|
||||
bindChartTooltip(canvas, visibleSeries, sortedRows, { pad, width, height, firstTs, lastTs, binaryLabels, tooltipUnit });
|
||||
}
|
||||
|
||||
function energyBucketLabel(timestamp, interval) {
|
||||
const date = new Date(timestamp);
|
||||
if (interval === 'hourly') return new Intl.DateTimeFormat(locale(), { hour: '2-digit', minute: '2-digit' }).format(date);
|
||||
if (interval === 'monthly') return new Intl.DateTimeFormat(locale(), { month: 'short', year: '2-digit' }).format(date);
|
||||
return new Intl.DateTimeFormat(locale(), { day: '2-digit', month: '2-digit', ...(interval === 'weekly' ? { year: '2-digit' } : {}) }).format(date);
|
||||
}
|
||||
|
||||
function energyBucketTooltipLabel(timestamp, interval) {
|
||||
const date = new Date(timestamp);
|
||||
if (interval === 'hourly') return chartPreciseTime(timestamp);
|
||||
if (interval === 'monthly') return new Intl.DateTimeFormat(locale(), { month: 'long', year: 'numeric' }).format(date);
|
||||
if (interval === 'weekly') return new Intl.DateTimeFormat(locale(), { day: '2-digit', month: '2-digit', year: 'numeric' }).format(date);
|
||||
return new Intl.DateTimeFormat(locale(), { day: '2-digit', month: '2-digit', year: 'numeric' }).format(date);
|
||||
}
|
||||
|
||||
function bindEnergyChartTooltip(canvas, series, starts, maps, geometry) {
|
||||
const wrap = canvas?.parentElement;
|
||||
const tooltip = wrap?.querySelector('.chart-tooltip');
|
||||
const line = wrap?.querySelector('.chart-hover-line');
|
||||
if (!canvas || !wrap || !tooltip || !line || !starts.length) return;
|
||||
const { pad, width, height, slot, interval } = geometry;
|
||||
let activeIndex = -1;
|
||||
|
||||
const hide = () => { tooltip.hidden = true; line.hidden = true; activeIndex = -1; };
|
||||
const showIndex = index => {
|
||||
index = clamp(index, 0, starts.length - 1);
|
||||
const start = starts[index];
|
||||
const px = pad.left + slot * index + slot / 2;
|
||||
const rows = series.map((item, seriesIndex) => ({ item, value: maps[seriesIndex].get(start) || 0 }));
|
||||
$$('.chart-tooltip').forEach(node => { if (node !== tooltip) node.hidden = true; });
|
||||
$$('.chart-hover-line').forEach(node => { if (node !== line) node.hidden = true; });
|
||||
tooltip.innerHTML = `<strong class="chart-tooltip-time">${esc(energyBucketTooltipLabel(start, interval))}</strong><div class="chart-tooltip-values">${rows.map(({ item, value }) => `<div class="chart-tooltip-row"><span class="chart-tooltip-label"><i style="--tooltip-color:${esc(item.color)}"></i><span>${esc(item.label)}</span></span><span class="chart-tooltip-value">${esc(Number(value).toLocaleString(locale(), { minimumFractionDigits: value > 0 && value < 0.1 ? 3 : 2, maximumFractionDigits: 3 }))} kWh</span></div>`).join('')}</div>`;
|
||||
tooltip.hidden = false;
|
||||
line.hidden = false;
|
||||
line.style.left = `${px}px`;
|
||||
line.style.top = `${pad.top}px`;
|
||||
line.style.height = `${Math.max(0, height - pad.top - pad.bottom)}px`;
|
||||
const minLeft = wrap.scrollLeft + 8;
|
||||
const maxLeft = wrap.scrollLeft + wrap.clientWidth - tooltip.offsetWidth - 8;
|
||||
let left = px + 12;
|
||||
if (left + tooltip.offsetWidth > wrap.scrollLeft + wrap.clientWidth - 8) left = px - tooltip.offsetWidth - 12;
|
||||
tooltip.style.left = `${Math.max(minLeft, Math.min(left, Math.max(minLeft, maxLeft)))}px`;
|
||||
tooltip.style.top = `${pad.top + 8}px`;
|
||||
activeIndex = index;
|
||||
};
|
||||
const indexFromClientX = clientX => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const x = clamp(clientX - rect.left, pad.left, width - pad.right - 1);
|
||||
return clamp(Math.floor((x - pad.left) / Math.max(1, slot)), 0, starts.length - 1);
|
||||
};
|
||||
|
||||
canvas.onpointermove = event => { if (event.pointerType !== 'touch') showIndex(indexFromClientX(event.clientX)); };
|
||||
canvas.onpointerleave = event => { if (event.pointerType !== 'touch') hide(); };
|
||||
canvas.onpointerdown = null; canvas.onpointerup = null; canvas.onpointercancel = null;
|
||||
canvas.onkeydown = event => {
|
||||
if (!['ArrowLeft', 'ArrowRight'].includes(event.key)) return;
|
||||
event.preventDefault();
|
||||
if (activeIndex < 0) activeIndex = event.key === 'ArrowRight' ? 0 : starts.length - 1;
|
||||
else activeIndex = clamp(activeIndex + (event.key === 'ArrowRight' ? 1 : -1), 0, starts.length - 1);
|
||||
showIndex(activeIndex);
|
||||
};
|
||||
}
|
||||
|
||||
function drawEnergyBarChart(canvas, seriesInput, { height = 340, interval = app.historyEnergyInterval } = {}) {
|
||||
if (!canvas) return;
|
||||
const series = Array.isArray(seriesInput) && seriesInput.length && Array.isArray(seriesInput[0]?.buckets)
|
||||
? seriesInput
|
||||
: [{ key: 'energy', label: 'kWh', color: historySeriesColor(0), buckets: Array.isArray(seriesInput) ? seriesInput : [] }];
|
||||
const options = { height, interval };
|
||||
if (canvas.id) chartRuntime.set(canvas.id, { kind: 'energy', series, options });
|
||||
const visibleSeries = series.filter((item, index) => !isChartSeriesHidden(canvas.id, item, index));
|
||||
const starts = [...new Set(visibleSeries.flatMap(item => (item.buckets || []).map(row => row.start)))].sort((a, b) => new Date(a) - new Date(b));
|
||||
if (!starts.length || !visibleSeries.length) {
|
||||
drawEmptyChart(canvas, height);
|
||||
renderLegend(document.getElementById(`${canvas.id}Legend`), series);
|
||||
updateChartZoomControls(canvas.id);
|
||||
return;
|
||||
}
|
||||
const maps = visibleSeries.map(item => new Map((item.buckets || []).map(row => [row.start, Math.max(0, Number(row.consumption_kwh) || 0)])));
|
||||
const values = maps.flatMap(map => starts.map(start => map.get(start) || 0));
|
||||
const prepared = prepareCanvas(canvas, height);
|
||||
const { ctx, width } = prepared;
|
||||
height = prepared.height;
|
||||
const text = cssColor('--muted', '#888');
|
||||
const grid = cssColor('--grid', '#333');
|
||||
const pad = { left: 58, right: 18, top: 20, bottom: 48 };
|
||||
const max = Math.max(0.1, ...values) * 1.1;
|
||||
const plotW = width - pad.left - pad.right;
|
||||
const plotH = height - pad.top - pad.bottom;
|
||||
const slot = plotW / Math.max(1, starts.length);
|
||||
const groupW = Math.max(4, Math.min(slot * 0.78, 56));
|
||||
const barW = Math.max(2, groupW / Math.max(1, visibleSeries.length));
|
||||
ctx.font = '10px system-ui'; ctx.fillStyle = text; ctx.strokeStyle = grid; ctx.lineWidth = 1;
|
||||
for (let i = 0; i <= 5; i++) {
|
||||
const value = max * i / 5;
|
||||
const y = pad.top + plotH - (value / max) * plotH;
|
||||
ctx.beginPath(); ctx.moveTo(pad.left, y); ctx.lineTo(width - pad.right, y); ctx.stroke();
|
||||
ctx.textAlign = 'right'; ctx.fillText(`${value.toFixed(value < 1 ? 2 : 1)}`, pad.left - 8, y + 3);
|
||||
}
|
||||
starts.forEach((start, bucketIndex) => {
|
||||
const baseX = pad.left + slot * bucketIndex + (slot - groupW) / 2;
|
||||
visibleSeries.forEach((item, seriesIndex) => {
|
||||
const value = maps[seriesIndex].get(start) || 0;
|
||||
const barH = (value / max) * plotH;
|
||||
ctx.save();
|
||||
ctx.globalAlpha = item.comparison ? 0.45 : 0.92;
|
||||
ctx.fillStyle = item.color || historySeriesColor(seriesIndex);
|
||||
ctx.fillRect(baseX + seriesIndex * barW, pad.top + plotH - barH, Math.max(1, barW - 1), barH);
|
||||
ctx.restore();
|
||||
});
|
||||
});
|
||||
ctx.fillStyle = text;
|
||||
const ticks = Math.min(6, starts.length);
|
||||
for (let i = 0; i < ticks; i++) {
|
||||
const index = ticks === 1 ? 0 : Math.round(i * (starts.length - 1) / (ticks - 1));
|
||||
const x = pad.left + slot * index + slot / 2;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(energyBucketLabel(starts[index], interval), x, height - 18);
|
||||
}
|
||||
ctx.textAlign = 'left'; ctx.fillText('kWh', 8, pad.top + 4);
|
||||
bindEnergyChartTooltip(canvas, visibleSeries, starts, maps, { pad, width, height, slot, interval });
|
||||
renderLegend(document.getElementById(`${canvas.id}Legend`), series);
|
||||
updateChartZoomControls(canvas.id);
|
||||
}
|
||||
|
||||
function renderLegend(host, series) {
|
||||
if (!host) return;
|
||||
const chartId = host.id.replace(/Legend$/, '');
|
||||
host.innerHTML = series.map((item, index) => {
|
||||
const hidden = isChartSeriesHidden(chartId, item, index);
|
||||
const title = tr(hidden ? 'history.legendShow' : 'history.legendHide', { name: item.label });
|
||||
return `<button type="button" class="legend-item${hidden ? ' is-hidden' : ''}" data-chart-id="${esc(chartId)}" data-chart-legend="${index}" aria-pressed="${hidden ? 'false' : 'true'}" title="${esc(title)}"><i class="legend-line${item.dash?.length ? ' is-dashed' : ''}" style="--legend-color:${esc(item.color)}"></i><span>${esc(item.label)}</span></button>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function historyChartMarkup(id, title, hint, compact = false) {
|
||||
const zoom = clamp(Number(app.chartZooms[id] || 1), 1, MAX_CHART_ZOOM);
|
||||
const percent = Math.round(zoom * 100);
|
||||
return `<div class="panel chart-panel chart-card"><div class="chart-title-row"><div><h3>${esc(title)}</h3><p>${esc(hint || '')}</p><small class="chart-hover-hint">${esc(tr('history.hoverHint'))}</small></div><div class="chart-title-actions"><div class="chart-zoom-controls" aria-label="${esc(tr('history.zoomControls'))}"><button type="button" data-chart-zoom="out" data-chart-id="${esc(id)}" title="${esc(tr('history.zoomOut'))}" ${zoom <= 1 ? 'disabled' : ''}>${uiIcon('minus')}</button><button type="button" class="chart-zoom-reset" data-chart-zoom="reset" data-chart-id="${esc(id)}" title="${esc(tr('history.zoomReset'))}">${percent}%</button><button type="button" data-chart-zoom="in" data-chart-id="${esc(id)}" title="${esc(tr('history.zoomIn'))}" ${zoom >= MAX_CHART_ZOOM ? 'disabled' : ''}>${uiIcon('plus')}</button></div><button type="button" class="chart-fullscreen-button" data-chart-fullscreen="${esc(id)}" title="${esc(tr('history.fullscreen'))}" aria-label="${esc(tr('history.fullscreen'))}">${uiIcon('fullscreen')}</button></div></div><div class="chart-wrap ${compact ? 'compact-chart' : ''}"><canvas id="${esc(id)}" data-chart-zoom="${zoom}" width="1000" height="${compact ? 300 : 420}" tabindex="0" aria-label="${esc(title)}"></canvas><div class="chart-hover-line" hidden></div><div class="chart-selection" hidden></div><div class="chart-tooltip" hidden></div></div><div class="legend" id="${esc(id)}Legend"></div></div>`;
|
||||
}
|
||||
|
||||
function historySeriesColor(index) {
|
||||
return cssColor(HISTORY_COLORS[index % HISTORY_COLORS.length], `hsl(${(index * 67) % 360} 68% 55%)`);
|
||||
}
|
||||
|
||||
|
||||
document.addEventListener('keydown', event => {
|
||||
if (event.key === 'Escape') closeChartPreview(document.querySelector('.chart-card.chart-fullscreen-fallback'));
|
||||
});
|
||||
|
||||
-425
@@ -1,425 +0,0 @@
|
||||
'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: [], 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: {},
|
||||
historyTab: 'overview', historyData: { zones: [], devices: [], sensors: [] }, historyCounts: {},
|
||||
historyZone: 'all', historyDevice: 'all', historySensor: 'all', historyNetworkTarget: 'all', historyNetworkShowJitter: true, historyNetwork: [], historyNetworkTargets: [], historyEnergyDevice: '', historyEnergyTargets: [], 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[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 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 || 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 : 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.some(item => item.code === DEFAULT_LANGUAGE)) throw new Error('Default English language pack is missing');
|
||||
app.languages = languages;
|
||||
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
|
||||
: (manifest.default || DEFAULT_LANGUAGE);
|
||||
if (!app.languages.some(item => item.code === app.language)) app.language = DEFAULT_LANGUAGE;
|
||||
try {
|
||||
await loadLanguagePack(app.language);
|
||||
} catch (error) {
|
||||
if (app.language === DEFAULT_LANGUAGE) throw error;
|
||||
console.error(`Unable to load preferred language ${app.language}:`, error);
|
||||
app.language = DEFAULT_LANGUAGE;
|
||||
await loadLanguagePack(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(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();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,368 @@
|
||||
'use strict';
|
||||
|
||||
const PUBLIC_CHART_COLORS = ['--accent', '--info', '--warning', '--purple', '--danger', '--teal', '--orange', '--blue'];
|
||||
const $ = selector => document.querySelector(selector);
|
||||
const clamp = (value, min, max) => Math.min(max, Math.max(min, value));
|
||||
const PUBLIC_CHART_RANGES = [6, 24, 168, 720, 2160, 8760];
|
||||
|
||||
function basePathFromScript() {
|
||||
const src = document.currentScript?.src || '';
|
||||
try {
|
||||
const path = new URL(src, location.href).pathname;
|
||||
const slash = path.lastIndexOf('/');
|
||||
return path.slice(0, slash).replace(/\/$/, '');
|
||||
} catch (_) { return ''; }
|
||||
}
|
||||
|
||||
const PUBLIC_BASE = basePathFromScript();
|
||||
const withBase = path => `${PUBLIC_BASE}${path.startsWith('/') ? path : `/${path}`}`;
|
||||
const PUBLIC_DEFAULT_LANGUAGE = String(document.documentElement.lang || '').trim();
|
||||
const PUBLIC_RANGE_KEYS = ['history.6h', 'history.24h', 'history.7d', 'history.30d', 'history.90d', 'history.1y'];
|
||||
let publicLanguageManifest = null;
|
||||
let publicChartLanguage = PUBLIC_DEFAULT_LANGUAGE;
|
||||
let publicChartTranslations = {};
|
||||
let publicChartLocale = PUBLIC_DEFAULT_LANGUAGE;
|
||||
|
||||
function publicCookie(name) {
|
||||
const row = document.cookie.split('; ').find(item => item.startsWith(`${name}=`));
|
||||
return row ? decodeURIComponent(row.split('=').slice(1).join('=')) : '';
|
||||
}
|
||||
|
||||
function publicTr(key, params = {}) {
|
||||
const template = publicChartTranslations[key] ?? key;
|
||||
return String(template).replace(/\{([a-zA-Z0-9_]+)\}/g, (_, name) => params[name] ?? `{${name}}`);
|
||||
}
|
||||
|
||||
async function loadPublicLanguageManifest() {
|
||||
if (publicLanguageManifest) return publicLanguageManifest;
|
||||
const response = await fetch(withBase('/lang/index.json'), { headers: { Accept: 'application/json' }, cache: 'no-store' });
|
||||
if (!response.ok) throw new Error(`language index HTTP ${response.status}`);
|
||||
publicLanguageManifest = await response.json();
|
||||
return publicLanguageManifest;
|
||||
}
|
||||
|
||||
async function setPublicChartLanguage(language) {
|
||||
const manifest = await loadPublicLanguageManifest();
|
||||
const languages = Array.isArray(manifest.languages) ? manifest.languages : [];
|
||||
const fallbackCode = manifest.default || PUBLIC_DEFAULT_LANGUAGE;
|
||||
const item = languages.find(entry => entry.code === language)
|
||||
|| languages.find(entry => entry.code === fallbackCode)
|
||||
|| languages[0];
|
||||
if (!item) throw new Error('language pack unavailable');
|
||||
const response = await fetch(withBase(item.path || `/lang/${encodeURIComponent(item.code)}.json`), { headers: { Accept: 'application/json' } });
|
||||
if (!response.ok) throw new Error(`language ${item.code} HTTP ${response.status}`);
|
||||
const pack = await response.json();
|
||||
publicChartLanguage = item.code;
|
||||
publicChartTranslations = pack.translations || {};
|
||||
publicChartLocale = pack.meta?.locale || item.locale || item.code;
|
||||
document.documentElement.lang = item.code;
|
||||
document.title = `GREE Controller · ${publicTr('publicChart.title')}`;
|
||||
setPublicRangeLanguage();
|
||||
}
|
||||
|
||||
function cssColor(name, fallback) {
|
||||
const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
|
||||
return value || fallback;
|
||||
}
|
||||
|
||||
function timeLabel(timestamp, hours, locale) {
|
||||
const options = Number(hours) > 48
|
||||
? { day: '2-digit', month: '2-digit', hour: '2-digit' }
|
||||
: { hour: '2-digit', minute: '2-digit' };
|
||||
return new Date(timestamp).toLocaleString(locale, options);
|
||||
}
|
||||
|
||||
function preciseTime(timestamp, locale) {
|
||||
return new Intl.DateTimeFormat(locale, {
|
||||
year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit'
|
||||
}).format(new Date(timestamp));
|
||||
}
|
||||
|
||||
function prepareCanvas(canvas, height = 520) {
|
||||
const wrap = canvas.parentElement;
|
||||
const width = Math.max(720, Math.floor(wrap?.clientWidth || canvas.getBoundingClientRect().width || 720));
|
||||
const actualHeight = Math.max(180, Math.floor(wrap?.clientHeight || height));
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
canvas.width = Math.floor(width * dpr);
|
||||
canvas.height = Math.floor(actualHeight * dpr);
|
||||
canvas.style.width = `${width}px`;
|
||||
canvas.style.height = `${actualHeight}px`;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
ctx.clearRect(0, 0, width, actualHeight);
|
||||
return { ctx, width, height: actualHeight };
|
||||
}
|
||||
|
||||
function renderLegend(series) {
|
||||
const host = $('#publicCustomChartLegend');
|
||||
host.innerHTML = series.map((item, index) => {
|
||||
const color = cssColor(PUBLIC_CHART_COLORS[index % PUBLIC_CHART_COLORS.length], '#3ecf8e');
|
||||
const dashed = item.dashed ? ' is-dashed' : '';
|
||||
return `<span class="legend-item public-chart-legend-item"><i class="legend-line${dashed}" style="--legend-color:${color}"></i><span>${escapeHtml(item.label)}</span></span>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? '').replace(/[&<>'"]/g, char => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' }[char]));
|
||||
}
|
||||
|
||||
function drawEmpty(canvas, message) {
|
||||
const { ctx, width, height } = prepareCanvas(canvas);
|
||||
ctx.fillStyle = cssColor('--muted', '#888');
|
||||
ctx.font = '13px system-ui';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(message, width / 2, height / 2);
|
||||
}
|
||||
|
||||
function nearestPoint(points, targetTs) {
|
||||
if (!points.length) return null;
|
||||
let lo = 0, hi = points.length - 1;
|
||||
while (lo < hi) {
|
||||
const mid = Math.floor((lo + hi) / 2);
|
||||
if (points[mid].ts < targetTs) lo = mid + 1; else hi = mid;
|
||||
}
|
||||
const after = points[lo];
|
||||
const before = lo > 0 ? points[lo - 1] : null;
|
||||
if (!before) return after;
|
||||
return Math.abs(before.ts - targetTs) <= Math.abs(after.ts - targetTs) ? before : after;
|
||||
}
|
||||
|
||||
function bindTooltip(canvas, series, geometry, locale) {
|
||||
const wrap = canvas.parentElement;
|
||||
const tooltip = wrap.querySelector('.chart-tooltip');
|
||||
const line = wrap.querySelector('.chart-hover-line');
|
||||
const { pad, width, height, firstTs, lastTs } = geometry;
|
||||
const plotWidth = width - pad.left - pad.right;
|
||||
const timestamps = [...new Set(series.flatMap(item => item.points.map(point => new Date(point.timestamp).getTime())).filter(Number.isFinite))].sort((a, b) => a - b);
|
||||
const timestampPoints = timestamps.map(ts => ({ ts }));
|
||||
const seriesPoints = series.map((item, index) => ({
|
||||
item,
|
||||
color: cssColor(PUBLIC_CHART_COLORS[index % PUBLIC_CHART_COLORS.length], '#3ecf8e'),
|
||||
points: item.points.map(point => ({ ts: new Date(point.timestamp).getTime(), value: Number(point.value) }))
|
||||
.filter(point => Number.isFinite(point.ts) && Number.isFinite(point.value))
|
||||
.sort((a, b) => a.ts - b.ts)
|
||||
}));
|
||||
|
||||
const hide = () => { tooltip.hidden = true; line.hidden = true; };
|
||||
const showAt = clientX => {
|
||||
if (!timestamps.length) return hide();
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const localX = clamp(clientX - rect.left, pad.left, width - pad.right);
|
||||
const targetTs = firstTs + ((localX - pad.left) / Math.max(1, plotWidth)) * (lastTs - firstTs);
|
||||
const snapped = nearestPoint(timestampPoints, targetTs)?.ts;
|
||||
if (!Number.isFinite(snapped)) return hide();
|
||||
const values = seriesPoints.map(entry => ({ ...entry, point: nearestPoint(entry.points, snapped) })).filter(entry => entry.point);
|
||||
if (!values.length) return hide();
|
||||
|
||||
const px = pad.left + (snapped - firstTs) / Math.max(1, lastTs - firstTs) * plotWidth;
|
||||
tooltip.innerHTML = `<strong class="chart-tooltip-time">${escapeHtml(preciseTime(snapped, locale))}</strong><div class="chart-tooltip-values">${values.map(({ item, point, color }) => `<div class="chart-tooltip-row"><span class="chart-tooltip-label"><i style="--tooltip-color:${color}"></i><span>${escapeHtml(item.label)}</span></span><span class="chart-tooltip-value">${Number(point.value).toLocaleString(locale, { minimumFractionDigits: 1, maximumFractionDigits: 2 })} °C</span></div>`).join('')}</div>`;
|
||||
tooltip.hidden = false;
|
||||
line.hidden = false;
|
||||
line.style.left = `${px}px`;
|
||||
line.style.top = `${pad.top}px`;
|
||||
line.style.height = `${Math.max(0, height - pad.top - pad.bottom)}px`;
|
||||
const minLeft = wrap.scrollLeft + 8;
|
||||
const maxLeft = wrap.scrollLeft + wrap.clientWidth - tooltip.offsetWidth - 8;
|
||||
let tooltipLeft = px + 12;
|
||||
if (tooltipLeft + tooltip.offsetWidth > wrap.scrollLeft + wrap.clientWidth - 8) tooltipLeft = px - tooltip.offsetWidth - 12;
|
||||
tooltip.style.left = `${Math.max(minLeft, Math.min(tooltipLeft, Math.max(minLeft, maxLeft)))}px`;
|
||||
tooltip.style.top = `${pad.top + 8}px`;
|
||||
};
|
||||
canvas.onpointermove = event => { if (event.pointerType !== 'touch') showAt(event.clientX); };
|
||||
canvas.onpointerleave = event => { if (event.pointerType !== 'touch') hide(); };
|
||||
}
|
||||
|
||||
function drawChart(canvas, payload, locale) {
|
||||
const series = (payload.series || []).map((item, index) => ({
|
||||
...item,
|
||||
color: cssColor(PUBLIC_CHART_COLORS[index % PUBLIC_CHART_COLORS.length], '#3ecf8e'),
|
||||
points: Array.isArray(item.points) ? item.points : []
|
||||
}));
|
||||
renderLegend(series);
|
||||
const allPoints = series.flatMap(item => item.points.map(point => ({ ...point, value: Number(point.value) })))
|
||||
.filter(point => Number.isFinite(point.value) && Number.isFinite(new Date(point.timestamp).getTime()));
|
||||
if (!allPoints.length) return drawEmpty(canvas, payload.no_data_label || publicTr('publicChart.noData'));
|
||||
|
||||
const { ctx, width, height } = prepareCanvas(canvas);
|
||||
const text = cssColor('--muted', '#888');
|
||||
const grid = cssColor('--grid', '#333');
|
||||
const compact = width < 520;
|
||||
const pad = compact ? { left: 43, right: 12, top: 16, bottom: 34 } : { left: 54, right: 20, top: 20, bottom: 42 };
|
||||
const values = allPoints.map(point => point.value);
|
||||
let min = Math.floor(Math.min(...values) - 1);
|
||||
let max = Math.ceil(Math.max(...values) + 1);
|
||||
if (max - min < 2) { min -= 1; max += 1; }
|
||||
const firstTs = Math.min(...allPoints.map(point => new Date(point.timestamp).getTime()));
|
||||
const lastTs = Math.max(...allPoints.map(point => new Date(point.timestamp).getTime()));
|
||||
const span = Math.max(1, lastTs - firstTs);
|
||||
const x = ts => pad.left + (ts - firstTs) / span * (width - pad.left - pad.right);
|
||||
const y = value => pad.top + (max - value) / (max - min) * (height - pad.top - pad.bottom);
|
||||
|
||||
ctx.font = '10px system-ui';
|
||||
ctx.fillStyle = text;
|
||||
ctx.strokeStyle = grid;
|
||||
ctx.lineWidth = 1;
|
||||
for (let i = 0; i <= 5; i += 1) {
|
||||
const value = min + (max - min) * i / 5;
|
||||
const 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 + 3);
|
||||
}
|
||||
const ticks = width < 420 ? 2 : width < 620 ? 3 : 5;
|
||||
for (let i = 0; i <= ticks; i += 1) {
|
||||
const ts = firstTs + (span * i / ticks);
|
||||
const px = x(ts);
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(timeLabel(ts, payload.hours, locale), px, height - 14);
|
||||
}
|
||||
series.forEach(item => {
|
||||
const points = item.points.map(point => ({ ts: new Date(point.timestamp).getTime(), value: Number(point.value) }))
|
||||
.filter(point => Number.isFinite(point.ts) && Number.isFinite(point.value))
|
||||
.sort((a, b) => a.ts - b.ts);
|
||||
if (!points.length) return;
|
||||
ctx.beginPath();
|
||||
ctx.strokeStyle = item.color;
|
||||
ctx.lineWidth = 2;
|
||||
ctx.setLineDash(item.dashed ? [6, 4] : []);
|
||||
points.forEach((point, index) => {
|
||||
const px = x(point.ts), py = y(point.value);
|
||||
if (index === 0) ctx.moveTo(px, py); else ctx.lineTo(px, py);
|
||||
});
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
});
|
||||
bindTooltip(canvas, series, { pad, width, height, firstTs, lastTs }, locale);
|
||||
}
|
||||
|
||||
let publicChartPayload = null;
|
||||
let publicChartResizeFrame = 0;
|
||||
|
||||
function publicChartToken() {
|
||||
return decodeURIComponent(location.pathname.split('/').filter(Boolean).pop() || '');
|
||||
}
|
||||
|
||||
function requestedPublicChartHours() {
|
||||
const value = Number(new URLSearchParams(location.search).get('hours'));
|
||||
return Number.isInteger(value) && value >= 1 && value <= 87600 ? value : null;
|
||||
}
|
||||
|
||||
function setPublicRangeLanguage() {
|
||||
const label = $('#publicChartRangeLabel');
|
||||
const select = $('#publicChartHours');
|
||||
if (label) label.textContent = publicTr('history.range');
|
||||
if (!select) return;
|
||||
[...select.options].forEach((option, index) => {
|
||||
const key = PUBLIC_RANGE_KEYS[index];
|
||||
if (key) option.textContent = publicTr(key);
|
||||
});
|
||||
select.setAttribute('aria-label', publicTr('publicChart.rangeAria'));
|
||||
}
|
||||
|
||||
function syncPublicRangeControl(hours) {
|
||||
const select = $('#publicChartHours');
|
||||
if (!select) return;
|
||||
const value = String(hours);
|
||||
if (![...select.options].some(option => option.value === value)) {
|
||||
const option = document.createElement('option');
|
||||
option.value = value;
|
||||
option.textContent = publicTr('publicChart.hoursShort', { hours: value });
|
||||
select.append(option);
|
||||
}
|
||||
select.value = value;
|
||||
}
|
||||
|
||||
function publicChartApiPath(token, hours) {
|
||||
const value = Number(hours);
|
||||
const hasHours = hours !== null && hours !== undefined && Number.isInteger(value) && value >= 1 && value <= 87600;
|
||||
const query = hasHours ? `?hours=${encodeURIComponent(String(value))}` : '';
|
||||
return `/api/public/charts/custom/${encodeURIComponent(token)}${query}`;
|
||||
}
|
||||
|
||||
function redrawPublicChart() {
|
||||
if (!publicChartPayload) return;
|
||||
drawChart($('#publicCustomChart'), publicChartPayload, publicChartLocale);
|
||||
}
|
||||
|
||||
async function loadPublicChart(hours = requestedPublicChartHours()) {
|
||||
const token = publicChartToken();
|
||||
const title = $('#publicChartTitle');
|
||||
const hint = $('#publicChartHint');
|
||||
const error = $('#publicChartError');
|
||||
const canvas = $('#publicCustomChart');
|
||||
const range = $('#publicChartHours');
|
||||
|
||||
if (!token.startsWith('chart_')) {
|
||||
title.textContent = publicTr('publicChart.title');
|
||||
hint.textContent = '';
|
||||
error.hidden = false;
|
||||
error.textContent = publicTr('publicChart.invalidLink');
|
||||
drawEmpty(canvas, error.textContent);
|
||||
return;
|
||||
}
|
||||
|
||||
if (range) range.disabled = true;
|
||||
if (hint) hint.textContent = publicTr('publicChart.loading');
|
||||
error.hidden = true;
|
||||
const response = await fetch(withBase(publicChartApiPath(token, hours)), { headers: { Accept: 'application/json' }, cache: 'no-store' });
|
||||
if (!response.ok) {
|
||||
let message = `${response.status}`;
|
||||
try { message = (await response.json()).error || message; } catch (_) { }
|
||||
throw new Error(message);
|
||||
}
|
||||
const payload = await response.json();
|
||||
await setPublicChartLanguage(payload.lang || publicChartLanguage);
|
||||
title.textContent = payload.title || publicTr('publicChart.title');
|
||||
hint.textContent = payload.hint || publicTr('publicChart.periodHint', { hours: payload.hours });
|
||||
canvas.setAttribute('aria-label', title.textContent);
|
||||
syncPublicRangeControl(payload.hours);
|
||||
const localizedSeries = (payload.series || []).map(item => ({
|
||||
...item,
|
||||
label: item.label_key ? `${item.label} · ${publicTr(item.label_key)}` : item.label,
|
||||
}));
|
||||
publicChartPayload = { ...payload, series: localizedSeries, no_data_label: payload.no_data_label || publicTr('publicChart.noData') };
|
||||
drawChart(canvas, publicChartPayload, publicChartLocale);
|
||||
if (range) range.disabled = false;
|
||||
}
|
||||
|
||||
$('#publicChartHours')?.addEventListener('change', async event => {
|
||||
const hours = Number(event.currentTarget.value);
|
||||
if (!Number.isInteger(hours) || hours < 1 || hours > 87600) return;
|
||||
const url = new URL(location.href);
|
||||
url.searchParams.set('hours', String(hours));
|
||||
history.replaceState(null, '', url);
|
||||
try {
|
||||
await loadPublicChart(hours);
|
||||
} catch (error) {
|
||||
const host = $('#publicChartError');
|
||||
host.hidden = false;
|
||||
host.textContent = publicTr('publicChart.loadFailed', { error: error.message });
|
||||
event.currentTarget.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
cancelAnimationFrame(publicChartResizeFrame);
|
||||
publicChartResizeFrame = requestAnimationFrame(redrawPublicChart);
|
||||
});
|
||||
|
||||
async function initPublicChart() {
|
||||
try {
|
||||
await setPublicChartLanguage(publicCookie('gree_controller_language') || PUBLIC_DEFAULT_LANGUAGE);
|
||||
} catch (error) {
|
||||
console.error('Public chart language initialization failed:', error);
|
||||
}
|
||||
try {
|
||||
await loadPublicChart();
|
||||
} catch (error) {
|
||||
const host = $('#publicChartError');
|
||||
host.hidden = false;
|
||||
host.textContent = publicChartTranslations['publicChart.loadFailed']
|
||||
? publicTr('publicChart.loadFailed', { error: error.message })
|
||||
: error.message;
|
||||
$('#publicChartHint').textContent = '';
|
||||
$('#publicChartHours').disabled = false;
|
||||
drawEmpty($('#publicCustomChart'), host.textContent);
|
||||
}
|
||||
}
|
||||
|
||||
initPublicChart();
|
||||
@@ -1,451 +0,0 @@
|
||||
function renderAll() {
|
||||
renderSummary();
|
||||
renderHouseClimate();
|
||||
renderGroups();
|
||||
renderControlPlan();
|
||||
renderSimulationPage();
|
||||
renderDevices();
|
||||
renderZones();
|
||||
renderSchedules();
|
||||
renderAutomations();
|
||||
renderFlows();
|
||||
renderAccessTokens();
|
||||
fillSelects();
|
||||
renderSettings();
|
||||
renderNightSettings();
|
||||
renderHomeAssistantSettings();
|
||||
renderSimulationModeBanner();
|
||||
renderDebugOverlay();
|
||||
}
|
||||
|
||||
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;
|
||||
const toolbarStatus = $('#toolbarRuntimeStatus');
|
||||
if (toolbarStatus) toolbarStatus.textContent = tr('dashboard.toolbarWorking', { active, total: app.devices.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('');
|
||||
renderSystemInfo();
|
||||
}
|
||||
|
||||
function renderHouseClimate() {
|
||||
const node = $('#houseClimate'); if (!node || !app.settings) return;
|
||||
const mode = app.settings.house_mode || 'cool';
|
||||
const zonePresets = (app.zones || []).map(zone => zone.manual_preset || 'auto');
|
||||
const preset = zonePresets.length && zonePresets.every(value => value === zonePresets[0]) ? zonePresets[0] : null;
|
||||
const outdoor = app.outdoorTemperature == null ? tr('common.unavailable') : fmtTemp(app.outdoorTemperature);
|
||||
const powerOn = $('#housePowerOn'), powerOff = $('#housePowerOff');
|
||||
|
||||
if (powerOn) { powerOn.classList.remove('active'); powerOn.removeAttribute('aria-pressed'); }
|
||||
if (powerOff) { powerOff.classList.remove('active'); powerOff.removeAttribute('aria-pressed'); }
|
||||
node.innerHTML = `<div class="house-climate-head"><div><span class="eyebrow">${esc(tr('house.seasonMode'))}</span><h3>${esc(tr('house.smartThermostat'))}</h3><p>${esc(tr('house.setpointStrategy'))}</p></div><button type="button" class="outside-pill" data-action="open-outdoor-history" title="${esc(tr('house.outdoorHistoryOpen'))}" aria-label="${esc(tr('house.outdoorHistoryOpen'))}"><small>${esc(tr('house.outdoor'))}</small><strong>${esc(outdoor)}</strong></button></div>
|
||||
<div class="house-mode-row">
|
||||
<button class="${mode === 'cool' ? 'active' : ''}" data-action="house-mode" data-value="cool" aria-pressed="${mode === 'cool'}">${esc(tr('mode.cool'))}</button>
|
||||
<button class="${mode === 'heat' ? 'active' : ''}" data-action="house-mode" data-value="heat" aria-pressed="${mode === 'heat'}">${esc(tr('mode.heat'))}</button>
|
||||
<button class="${mode === 'off' ? 'active' : ''}" data-action="house-mode" data-value="off" aria-pressed="${mode === 'off'}">${esc(tr('house.noControl'))}</button>
|
||||
</div>
|
||||
<div class="preset-row house-preset-row">${['auto', 'comfort', 'sleep', 'away'].map(p => `<button class="${preset === p ? 'active' : ''}" data-action="house-preset" data-value="${p}" aria-pressed="${preset === p}">${esc(p === 'sleep' ? tr('house.sleepAll') : p === 'comfort' ? tr('house.comfortAll') : p === 'away' ? tr('house.awayAll') : tr('house.autoAll'))}</button>`).join('')}</div>`;
|
||||
}
|
||||
|
||||
function planEventMarkup(event) {
|
||||
const when = event?.at ? new Date(event.at).toLocaleString(locale(), { weekday: 'short', hour: '2-digit', minute: '2-digit' }) : '—';
|
||||
const target = event?.target_temperature == null ? '' : ` · ${fmtTemp(event.target_temperature)}`;
|
||||
return `<li><time>${esc(when)}</time><span>${esc(event?.label || event?.kind || tr('plan.event'))}${esc(target)}</span></li>`;
|
||||
}
|
||||
|
||||
function automationTriggerLabel(item) {
|
||||
if (item.trigger_kind === 'time') return tr('automations.triggerAt', { time: item.at_time || '—' });
|
||||
if (item.trigger_kind === 'flow') return tr('flow.generated');
|
||||
const key = item.trigger_kind === 'temperature_above' ? 'automations.triggerAbove' : 'automations.triggerBelow';
|
||||
return tr(key, { temperature: fmtTemp(item.threshold) });
|
||||
}
|
||||
|
||||
function planZoneEffectiveEnabled(zone) {
|
||||
if (!zone) return undefined;
|
||||
return zone.effective_enabled ?? zone.enabled ?? false;
|
||||
}
|
||||
|
||||
function renderControlPlan() {
|
||||
const host = $('#controlPlan'); if (!host) return;
|
||||
const section = $('#controlPlanSection');
|
||||
const plan = app.controlPlan;
|
||||
if (!plan) {
|
||||
if (section) section.hidden = false;
|
||||
host.innerHTML = `<div class="panel plan-loading">${esc(tr('plan.loading'))}</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const allZones = plan.zones || [];
|
||||
const houseEvents = (plan.next_events || []).slice(0, 3);
|
||||
const house = houseEvents.length ? `<article class="panel plan-card plan-house"><div class="plan-card-head"><div><span class="eyebrow">${esc(tr('plan.house'))}</span><h3>${esc(houseModeLabel(plan.house_mode || 'off'))}</h3></div><span class="badge active">${esc((plan.control_strategy || 'setpoint') === 'setpoint' ? tr('plan.strategySetpoint') : (plan.control_strategy || 'setpoint'))}</span></div><p>${esc(tr('plan.houseSummary', { zones: allZones.filter(planZoneEffectiveEnabled).length, demand: allZones.filter(zone => planZoneEffectiveEnabled(zone) && zone.demand).length }))}</p><ul class="plan-events">${houseEvents.map(planEventMarkup).join('')}</ul></article>` : '';
|
||||
|
||||
const groupCards = (app.groups || []).map(group => {
|
||||
const memberIds = new Set(group.zone_ids || []);
|
||||
const members = allZones.filter(zone => memberIds.has(zone.zone_id));
|
||||
const state = groupState(group);
|
||||
const powerEnabled = group.power_enabled !== false;
|
||||
const demand = members.filter(zone => planZoneEffectiveEnabled(zone) && zone.demand).length;
|
||||
const mode = state.mode === 'house' ? tr('groups.followHouse') : state.mode === 'mixed' ? tr('groups.mixed') : modeLabel(state.mode);
|
||||
const preset = state.preset === 'mixed' ? tr('groups.mixed') : zonePresetLabel(state.preset);
|
||||
const events = members.flatMap(zone => (zone.next_events || []).map(event => ({ ...event, label: `${zone.zone_name}: ${event.label}` })))
|
||||
.sort((a, b) => new Date(a.at) - new Date(b.at)).slice(0, 2);
|
||||
if (!events.length) return '';
|
||||
return `<article class="panel plan-card plan-group ${powerEnabled ? '' : 'disabled'}"><div class="plan-card-head"><div><span class="eyebrow">${esc(tr('groups.group'))}</span><h3>${esc(group.name)}</h3></div><span class="badge ${powerEnabled ? 'active' : ''}">${esc(powerEnabled ? tr('common.on') : tr('common.off'))}</span></div><div class="plan-group-state"><strong>${esc(mode)}</strong><span>·</span><strong>${esc(preset)}</strong></div><p>${esc(tr('plan.groupSummary', { zones: members.length, demand }))}</p><ul class="plan-events">${events.map(planEventMarkup).join('')}</ul></article>`;
|
||||
}).join('');
|
||||
|
||||
const zoneGroups = new Map();
|
||||
(app.groups || []).forEach(group => (group.zone_ids || []).forEach(zoneId => {
|
||||
const names = zoneGroups.get(zoneId) || [];
|
||||
names.push(group.name);
|
||||
zoneGroups.set(zoneId, names);
|
||||
}));
|
||||
const zones = allZones.map(zone => {
|
||||
const events = (zone.next_events || []).slice(0, 2);
|
||||
if (!events.length) return '';
|
||||
const target = zone.target_temperature == null ? '--' : Number(zone.target_temperature).toFixed(1);
|
||||
const groupNames = zoneGroups.get(zone.zone_id) || [];
|
||||
const scope = groupNames.length ? `${tr('groups.group')}: ${groupNames.join(' · ')}` : (zone.device_name || tr('common.noDevice'));
|
||||
const planState = zoneRuntimeStatusLabel(zone, zone.mode || 'off');
|
||||
return `<article class="panel plan-card plan-zone ${planZoneEffectiveEnabled(zone) ? '' : 'disabled'}"><div class="plan-card-head"><div><span class="eyebrow">${esc(scope)}</span><h3>${esc(zone.zone_name)}</h3></div><span class="badge ${zone.device_manual_override ? 'manual-override' : (planZoneEffectiveEnabled(zone) && zone.demand ? 'active' : '')}">${esc(planState)}</span></div><div class="plan-temp"><span>${fmtTemp(zone.current_temperature)}</span><b>→</b><strong>${esc(target)}<small>°C</small></strong></div><p>${esc(houseModeLabel(zone.mode || 'off'))} · ${esc(zonePresetLabel(zone.preset))}${zone.current_schedule_name ? ` · ${esc(zone.current_schedule_name)}` : ''}</p><ul class="plan-events">${events.map(planEventMarkup).join('')}</ul></article>`;
|
||||
}).join('');
|
||||
|
||||
const rules = (plan.rules || []).filter(rule => rule.enabled);
|
||||
const ruleCard = rules.length ? `<article class="panel plan-card plan-rules"><div class="plan-card-head"><div><span class="eyebrow">${esc(tr('plan.rules'))}</span><h3>${esc(tr('plan.ruleCount', { count: rules.length }))}</h3></div></div><ul class="plan-events">${rules.slice(0, 3).map(rule => {
|
||||
const automation = (app.automations || []).find(item => item.id === rule.id);
|
||||
const flow = automation?.flow_id ? (app.flows || []).find(item => item.id === automation.flow_id) : null;
|
||||
const displayName = flow?.name || rule.name;
|
||||
const target = rule.action_group_name ? `${tr('groups.group')}: ${rule.action_group_name}` : (rule.action_device_name || '');
|
||||
return `<li><time>${esc(automationTriggerLabel(rule))}</time><span class="plan-rule-summary"><strong>${esc(displayName)}</strong>${target ? `<small>→ ${esc(target)}</small>` : ''}</span></li>`;
|
||||
}).join('')}</ul></article>` : '';
|
||||
|
||||
const content = house + groupCards + zones + ruleCard;
|
||||
host.innerHTML = content;
|
||||
if (section) section.hidden = !content;
|
||||
}
|
||||
function applyControlPlan(plan, revision = null) {
|
||||
if (!plan || typeof plan !== 'object') return false;
|
||||
const hasRevision = revision !== null && revision !== undefined && revision !== '';
|
||||
const hasCurrentRevision = app.controlPlanRevision !== null && app.controlPlanRevision !== undefined && app.controlPlanRevision !== '';
|
||||
const nextRevision = hasRevision ? Number(revision) : NaN;
|
||||
const currentRevision = hasCurrentRevision ? Number(app.controlPlanRevision) : NaN;
|
||||
if (Number.isFinite(nextRevision) && Number.isFinite(currentRevision) && nextRevision < currentRevision) return false;
|
||||
app.controlPlan = plan;
|
||||
if (Number.isFinite(nextRevision)) app.controlPlanRevision = nextRevision;
|
||||
renderControlPlan();
|
||||
renderSimulationPage();
|
||||
return true;
|
||||
}
|
||||
|
||||
function controlPlanWebSocketReady() {
|
||||
return !!app.ws && app.ws.readyState === WebSocket.OPEN && app.controlPlanPushReady;
|
||||
}
|
||||
|
||||
async function loadControlPlan() {
|
||||
try {
|
||||
const plan = await api('/api/control-plan');
|
||||
// A fallback request may have started while disconnected and finish after WS resync.
|
||||
// Never let that older unversioned HTTP response overwrite a revisioned pushed plan.
|
||||
if (!controlPlanWebSocketReady()) applyControlPlan(plan);
|
||||
}
|
||||
catch (error) { console.warn('Unable to load control plan:', error); }
|
||||
finally {
|
||||
if (!controlPlanWebSocketReady()) {
|
||||
clearTimeout(app.controlPlanTimer);
|
||||
app.controlPlanTimer = setTimeout(loadControlPlan, 10000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function simulationTime(value, options = {}) {
|
||||
if (!value) return '—';
|
||||
const date = new Date(value);
|
||||
return date.toLocaleString(locale(), { weekday: options.withDay === false ? undefined : 'short', hour: '2-digit', minute: '2-digit' }).replace(',', '');
|
||||
}
|
||||
|
||||
function simulationNumeric(value) {
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
function simulationOutdoorAssist(mode, outdoor, room, target) {
|
||||
if (outdoor == null || room == null || target == null) return 0;
|
||||
const roomError = Math.abs(room - target);
|
||||
const weather = mode === 'heat' ? clamp((5 - outdoor) / 15, 0, 1) : clamp((outdoor - 30) / 10, 0, 1);
|
||||
return clamp(weather * clamp(roomError, 0, 2) * 0.5, 0, 1);
|
||||
}
|
||||
|
||||
function simulationRoundDeviceSetpoint(mode, demand, value) {
|
||||
const safe = clamp(Number(value) || 0, 16, 30);
|
||||
if (mode === 'heat' && demand) return Math.ceil(safe);
|
||||
if (mode === 'heat' && !demand) return Math.floor(safe);
|
||||
if (mode !== 'heat' && demand) return Math.floor(safe);
|
||||
return Math.ceil(safe);
|
||||
}
|
||||
|
||||
function simulationSmartFanSpeed(mode, room, target, outdoor, demand) {
|
||||
if (!demand) return 1;
|
||||
const error = Math.abs(room - target);
|
||||
const extremeWeather = mode === 'heat' ? outdoor != null && outdoor <= 0 : outdoor != null && outdoor >= 32;
|
||||
if (error >= 2 || extremeWeather) return 3;
|
||||
if (error >= 1) return 2;
|
||||
return 0;
|
||||
}
|
||||
|
||||
function simulationStatusInfo(status) {
|
||||
const map = {
|
||||
disabled: { label: tr('common.disabled'), badge: '' },
|
||||
off: { label: tr('house.noControl'), badge: '' },
|
||||
waiting: { label: tr('simulation.waitingForData'), badge: '' },
|
||||
demand: { label: tr('simulation.stateDemand'), badge: 'active' },
|
||||
satisfied: { label: tr('simulation.stateSatisfied'), badge: '' },
|
||||
};
|
||||
return map[status] || { label: status, badge: '' };
|
||||
}
|
||||
|
||||
function buildZoneSimulation(zone, planZone) {
|
||||
const device = app.devices.find(item => item.id === (planZone?.device_id || zone?.device_id));
|
||||
const mode = planZone?.mode || zone?.effective_mode || zone?.mode || app.settings?.house_mode || 'off';
|
||||
const current = simulationNumeric(planZone?.current_temperature ?? zone?.current_temperature ?? zone?.device_temperature ?? device?.current_temperature);
|
||||
const target = mode === 'off' ? null : simulationNumeric(planZone?.target_temperature ?? zone?.effective_setpoint ?? zone?.manual_setpoint ?? zone?.setpoint);
|
||||
const outdoor = simulationNumeric(app.outdoorTemperature ?? app.controlPlan?.outdoor_temperature);
|
||||
const hysteresis = Math.max(simulationNumeric(zoneHysteresisForMode(zone, mode)) || 0.4, 0.1);
|
||||
const half = hysteresis / 2;
|
||||
let status = 'waiting';
|
||||
let demand = false;
|
||||
if (!zone?.enabled || planZoneEffectiveEnabled(planZone) === false) status = 'disabled';
|
||||
else if (mode === 'off') status = 'off';
|
||||
else if (current == null || target == null) status = 'waiting';
|
||||
else {
|
||||
if (mode === 'heat') {
|
||||
if (current <= target - half) demand = true;
|
||||
else if (current >= target + half) demand = false;
|
||||
else demand = !!zone?.demand;
|
||||
} else {
|
||||
if (current >= target + half) demand = true;
|
||||
else if (current <= target - half) demand = false;
|
||||
else demand = !!zone?.demand;
|
||||
}
|
||||
status = demand ? 'demand' : 'satisfied';
|
||||
}
|
||||
const standbyOffset = Math.max(simulationNumeric(zone?.standby_offset_c) || 0.5, 0.5);
|
||||
const assist = current == null || target == null ? 0 : simulationOutdoorAssist(mode, outdoor, current, target);
|
||||
const activeTarget = current == null || target == null ? null : (mode === 'heat' ? target + assist : target - assist);
|
||||
const standbyTarget = target == null ? null : (mode === 'heat' ? target - standbyOffset : target + standbyOffset);
|
||||
const desiredDeviceTarget = current == null || target == null ? null : simulationRoundDeviceSetpoint(mode, demand, demand ? activeTarget : standbyTarget);
|
||||
const nightActive = !!app.controlPlan?.night_mode_active;
|
||||
const nightMaxFan = clamp(Number(app.controlPlan?.night_mode_max_fan_speed || 1), 1, 5);
|
||||
let fanSpeed = simulationNumeric(device?.fan_speed);
|
||||
if (mode !== 'off') {
|
||||
fanSpeed = zone?.smart_fan && current != null && target != null ? simulationSmartFanSpeed(mode, current, target, outdoor, demand) : fanSpeed;
|
||||
if (nightActive) {
|
||||
if (zone?.smart_fan) fanSpeed = fanSpeed === 0 ? 1 : Math.min(fanSpeed ?? nightMaxFan, nightMaxFan);
|
||||
else if (fanSpeed === 0 || fanSpeed == null || fanSpeed > nightMaxFan) fanSpeed = nightMaxFan;
|
||||
}
|
||||
}
|
||||
const quiet = mode === 'off'
|
||||
? tr('simulation.manual')
|
||||
: nightActive && app.settings?.night_mode?.force_quiet
|
||||
? tr('simulation.quietIfSupported')
|
||||
: (zone?.smart_fan ? (!demand ? tr('simulation.quietIfSupported') : tr('common.off')) : tr('simulation.manual'));
|
||||
const nativeSleep = mode !== 'off' && nightActive && app.settings?.night_mode?.use_native_sleep && device?.supports_sleep === true;
|
||||
const reasoning = !zone?.enabled || planZoneEffectiveEnabled(planZone) === false
|
||||
? tr('simulation.reasonDisabled')
|
||||
: mode === 'off'
|
||||
? tr('simulation.reasonHouseOff')
|
||||
: current == null || target == null
|
||||
? tr('simulation.reasonWaiting')
|
||||
: demand
|
||||
? tr('simulation.reasonDemand', { mode: modeLabel(mode), target: target.toFixed(1), hysteresis: hysteresis.toFixed(1) })
|
||||
: tr('simulation.reasonSatisfied', { target: target.toFixed(1), standby: standbyTarget.toFixed(1) });
|
||||
return { device, mode, current, target, demand, status, outdoor, hysteresis, assist, activeTarget, standbyTarget, desiredDeviceTarget, fanSpeed, quiet, nativeSleep, reasoning };
|
||||
}
|
||||
|
||||
function simulationRuleAction(rule) {
|
||||
const bits = [];
|
||||
if (rule.action?.power != null) bits.push(`${tr('common.power')}: ${rule.action.power ? tr('common.on') : tr('common.off')}`);
|
||||
if (rule.action?.mode) bits.push(`${tr('common.mode')}: ${rule.action_group_id && rule.action.mode === 'auto' ? tr('groups.followHouse') : modeLabel(rule.action.mode)}`);
|
||||
if (rule.action?.target_temperature != null) bits.push(`${tr('common.temperature')}: ${fmtTemp(rule.action.target_temperature)}`);
|
||||
if (rule.action_preset) bits.push(`${tr('groups.profile')}: ${zonePresetLabel(rule.action_preset)}`);
|
||||
return bits.length ? bits.join(' · ') : tr('simulation.noActionPreview');
|
||||
}
|
||||
|
||||
function simulationGroupsForZone(zoneId) {
|
||||
return app.groups.filter(group => (group.zone_ids || []).includes(zoneId));
|
||||
}
|
||||
|
||||
function renderSimulationScopeControls(plan) {
|
||||
const scope = $('#simulationScope');
|
||||
const target = $('#simulationTarget');
|
||||
if (!scope || !target) return;
|
||||
if (!['units', 'groups'].includes(app.simulationScope)) app.simulationScope = 'units';
|
||||
scope.value = app.simulationScope;
|
||||
const zones = plan?.zones || [];
|
||||
const options = app.simulationScope === 'groups'
|
||||
? [{ id: 'all', name: tr('simulation.allGroups') }, ...app.groups.map(group => ({ id: group.id, name: group.name }))]
|
||||
: [{ id: 'all', name: tr('simulation.allUnits') }, ...zones.map(zone => ({ id: zone.zone_id, name: `${zone.zone_name} · ${zone.device_name || tr('common.device')}` }))];
|
||||
if (!options.some(option => option.id === app.simulationTarget)) app.simulationTarget = 'all';
|
||||
target.innerHTML = options.map(option => `<option value="${esc(option.id)}">${esc(option.name)}</option>`).join('');
|
||||
target.value = app.simulationTarget;
|
||||
}
|
||||
|
||||
function simulationFilteredZones(plan) {
|
||||
const zones = plan?.zones || [];
|
||||
if (app.simulationScope === 'groups') {
|
||||
if (app.simulationTarget === 'all') {
|
||||
const grouped = new Set(app.groups.flatMap(group => group.zone_ids || []));
|
||||
return zones.filter(zone => grouped.has(zone.zone_id));
|
||||
}
|
||||
const group = app.groups.find(item => item.id === app.simulationTarget);
|
||||
const wanted = new Set(group?.zone_ids || []);
|
||||
return zones.filter(zone => wanted.has(zone.zone_id));
|
||||
}
|
||||
return app.simulationTarget === 'all' ? zones : zones.filter(zone => zone.zone_id === app.simulationTarget);
|
||||
}
|
||||
|
||||
function simulationRuleVisible(rule, zones) {
|
||||
if (app.simulationTarget === 'all') return true;
|
||||
const zoneIds = new Set(zones.map(zone => zone.zone_id));
|
||||
const deviceIds = new Set(zones.map(zone => zone.device_id).filter(Boolean));
|
||||
if (app.simulationScope === 'groups') {
|
||||
if (rule.action_group_id === app.simulationTarget) return true;
|
||||
return deviceIds.has(rule.trigger_device_id) || deviceIds.has(rule.action_device_id);
|
||||
}
|
||||
const directZone = [...zoneIds][0];
|
||||
if (simulationGroupsForZone(directZone).some(group => group.id === rule.action_group_id)) return true;
|
||||
return deviceIds.has(rule.trigger_device_id) || deviceIds.has(rule.action_device_id);
|
||||
}
|
||||
|
||||
function simulationLaneContext(planZone) {
|
||||
if (app.simulationScope !== 'groups') return planZone.device_name || '';
|
||||
const groups = simulationGroupsForZone(planZone.zone_id);
|
||||
return groups.length ? groups.map(group => group.name).join(' · ') : (planZone.device_name || '');
|
||||
}
|
||||
|
||||
function updateSimulationUrl() {
|
||||
if (app.currentView !== 'simulation') return;
|
||||
const params = new URLSearchParams();
|
||||
if (app.standaloneSimulation) params.set('standalone', '1');
|
||||
if (app.simulationScope !== 'units') params.set('scope', app.simulationScope);
|
||||
if (app.simulationTarget !== 'all') params.set('target', app.simulationTarget);
|
||||
const query = params.toString();
|
||||
updateBrowserUrl(`/simulation${query ? `?${query}` : ''}`, true);
|
||||
}
|
||||
|
||||
function renderSimulationPage() {
|
||||
const summaryHost = $('#simulationSummary');
|
||||
const timelineHost = $('#simulationTimeline');
|
||||
const boardHost = $('#simulationFlowBoard');
|
||||
const rulesHost = $('#simulationRules');
|
||||
if (!summaryHost || !timelineHost || !boardHost || !rulesHost) return;
|
||||
const plan = app.controlPlan;
|
||||
if (!plan) {
|
||||
summaryHost.innerHTML = `<div class="panel plan-loading">${esc(tr('plan.loading'))}</div>`;
|
||||
timelineHost.innerHTML = ''; boardHost.innerHTML = ''; rulesHost.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
renderSimulationScopeControls(plan);
|
||||
const zones = simulationFilteredZones(plan);
|
||||
const enabledZones = zones.filter(zone => zone.enabled);
|
||||
const demandingZones = enabledZones.filter(zone => zone.demand);
|
||||
const nightOn = !!plan.night_mode_active;
|
||||
summaryHost.innerHTML = [
|
||||
{ label: tr('simulation.houseMode'), value: houseModeLabel(plan.house_mode || 'off'), note: tr('simulation.strategy', { strategy: (plan.control_strategy || 'setpoint') === 'setpoint' ? tr('plan.strategySetpoint') : (plan.control_strategy || 'setpoint') }) },
|
||||
{ label: tr('settings.nightMode'), value: nightOn ? tr('common.active') : (app.settings?.night_mode?.enabled ? tr('simulation.scheduled') : tr('common.disabled')), note: `${plan.night_mode_start || '22:00'} → ${plan.night_mode_end || '06:00'} · ${tr('simulation.fanMax')} ${fanLabel(plan.night_mode_max_fan_speed || 1)}` },
|
||||
{ label: tr('simulation.outdoor'), value: plan.outdoor_temperature == null ? '—' : fmtTemp(plan.outdoor_temperature), note: tr('simulation.generatedAt', { time: dateTime(plan.generated_at) }) },
|
||||
{ label: tr('simulation.activeZones'), value: String(enabledZones.length), note: `${tr('simulation.requestingZones', { count: demandingZones.length })} · ${tr('simulation.rulesLabel')}: ${(plan.rules || []).filter(rule => rule.enabled && simulationRuleVisible(rule, zones)).length}` },
|
||||
].map(card => `<article class="panel simulation-summary-card"><small>${esc(card.label)}</small><strong>${esc(card.value)}</strong><span>${esc(card.note)}</span></article>`).join('');
|
||||
|
||||
const nodeW = 190, nodeH = 106, rowH = 190, topY = 155;
|
||||
const x = { sensor: 45, thermostat: 305, decision: 565, unit: 825, event: 1085 };
|
||||
const boardW = 1325, boardH = Math.max(390, topY + zones.length * rowH + 35);
|
||||
const nodes = [];
|
||||
const links = [];
|
||||
const pathBetween = (ax, ay, bx, by, cls = '') => {
|
||||
const bend = Math.max(36, (bx - ax) * .45);
|
||||
return `<path class="flow-link ${cls}" d="M ${ax} ${ay} C ${ax + bend} ${ay}, ${bx - bend} ${by}, ${bx} ${by}"/>`;
|
||||
};
|
||||
const verticalLink = (ax, ay, bx, by, cls = 'bus') => `<path class="flow-link ${cls}" d="M ${ax} ${ay} C ${ax} ${ay + 35}, ${bx} ${by - 35}, ${bx} ${by}"/>`;
|
||||
const node = ({ left, top, kind, eyebrow, title, value, meta = '', badge = '', badgeIcon = '', badgeClass = '' }) => `<article class="diagram-node ${kind}" style="left:${left}px;top:${top}px;width:${nodeW}px;min-height:${nodeH}px"><div class="flow-node-top"><span>${esc(eyebrow)}</span>${badge || badgeIcon ? `<b class="flow-node-badge ${badgeClass}">${badgeIcon ? uiIcon(badgeIcon) : esc(badge)}</b>` : ''}</div><h3>${esc(title)}</h3><strong>${esc(value)}</strong>${meta ? `<p>${esc(meta)}</p>` : ''}<i class="diagram-port in"></i><i class="diagram-port out"></i></article>`;
|
||||
|
||||
const globalHouseLeft = 305, globalNightLeft = 565, globalTop = 28;
|
||||
nodes.push(node({ left: globalHouseLeft, top: globalTop, kind: 'logic global', eyebrow: tr('simulation.globalInput'), title: tr('simulation.houseMode'), value: houseModeLabel(plan.house_mode || 'off'), meta: tr('simulation.strategy', { strategy: (plan.control_strategy || 'setpoint') === 'setpoint' ? tr('plan.strategySetpoint') : (plan.control_strategy || 'setpoint') }), badge: tr('simulation.house') }));
|
||||
nodes.push(node({ left: globalNightLeft, top: globalTop, kind: `logic global ${nightOn ? 'night-active' : ''}`, eyebrow: tr('simulation.globalInput'), title: tr('settings.nightMode'), value: nightOn ? tr('common.active') : (app.settings?.night_mode?.enabled ? tr('simulation.scheduled') : tr('common.disabled')), meta: `${plan.night_mode_start || '22:00'} → ${plan.night_mode_end || '06:00'} · ${fanLabel(plan.night_mode_max_fan_speed || 1)}`, badgeIcon: nightOn ? 'moon' : 'circle', badgeClass: nightOn ? 'active' : '' }));
|
||||
|
||||
zones.forEach((planZone, index) => {
|
||||
const zone = app.zones.find(item => item.id === planZone.zone_id);
|
||||
const sim = buildZoneSimulation(zone, planZone);
|
||||
const status = simulationStatusInfo(sim.status);
|
||||
const y = topY + index * rowH;
|
||||
const mid = y + nodeH / 2;
|
||||
const next = (planZone.next_events || [])[0];
|
||||
const source = zoneControlSourceLabel(planZone.control_source);
|
||||
const sensorName = planZone.control_source === 'external' || planZone.control_source === 'combined'
|
||||
? haSensorLabel(zoneHaEntityId(zone) || source)
|
||||
: (planZone.device_name || tr('common.device'));
|
||||
const fanText = sim.fanSpeed == null ? '—' : fanLabel(sim.fanSpeed);
|
||||
const quietText = sim.quiet;
|
||||
const command = sim.desiredDeviceTarget == null ? tr('simulation.noCommand') : `${fmtTemp(sim.desiredDeviceTarget)} · ${fanText}`;
|
||||
const eventValue = next ? simulationTime(next.at) : tr('simulation.noEventShort');
|
||||
const eventMeta = next ? `${next.label}${next.target_temperature == null ? '' : ` · ${fmtTemp(next.target_temperature)}`}` : tr('plan.noEvents');
|
||||
|
||||
nodes.push(`<div class="flow-lane-label" style="top:${y - 27}px"><strong>${esc(planZone.zone_name)}</strong><span>${esc(simulationLaneContext(planZone))}</span></div>`);
|
||||
nodes.push(node({ left: x.sensor, top: y, kind: 'input', eyebrow: tr('simulation.stepRoom'), title: sensorName, value: fmtTemp(sim.current), meta: source, badgeIcon: 'status-dot', badgeClass: sim.current == null ? '' : 'active' }));
|
||||
nodes.push(node({ left: x.thermostat, top: y, kind: 'logic', eyebrow: tr('simulation.thermostat'), title: planZone.zone_name, value: sim.target == null ? '—' : fmtTemp(sim.target), meta: `${zonePresetLabel(planZone.preset)} · ${tr('simulation.hysteresis', { value: sim.hysteresis.toFixed(1) })}`, badge: houseModeLabel(sim.mode) }));
|
||||
nodes.push(node({ left: x.decision, top: y, kind: `logic decision ${sim.demand ? 'demand' : 'satisfied'}`, eyebrow: tr('simulation.stepDecision'), title: status.label, value: sim.demand ? tr('simulation.callForComfort') : tr('simulation.standby'), meta: sim.reasoning, badgeIcon: sim.demand ? 'play' : 'check', badgeClass: status.badge }));
|
||||
const sleepMeta = sim.nativeSleep ? ` · ${tr('devices.sleep')}: ${tr('common.on')}` : '';
|
||||
nodes.push(node({ left: x.unit, top: y, kind: 'action', eyebrow: tr('simulation.stepCommand'), title: planZone.device_name || tr('common.device'), value: command, meta: `${tr('devices.quiet')}: ${quietText}${sleepMeta}`, badge: sim.mode === 'off' ? tr('simulation.manual') : (sim.demand ? tr('simulation.running') : tr('simulation.idle')), badgeClass: sim.demand ? 'active' : '' }));
|
||||
nodes.push(node({ left: x.event, top: y, kind: 'event', eyebrow: tr('simulation.nextEvent'), title: eventValue, value: next?.preset ? zonePresetLabel(next.preset) : tr('simulation.schedule'), meta: eventMeta, badgeIcon: 'chevron-right' }));
|
||||
|
||||
links.push(pathBetween(x.sensor + nodeW, mid, x.thermostat, mid, 'input-link'));
|
||||
links.push(pathBetween(x.thermostat + nodeW, mid, x.decision, mid, 'logic-link'));
|
||||
links.push(pathBetween(x.decision + nodeW, mid, x.unit, mid, sim.demand ? 'active-link' : 'logic-link'));
|
||||
links.push(pathBetween(x.unit + nodeW, mid, x.event, mid, 'action-link'));
|
||||
links.push(verticalLink(globalHouseLeft + nodeW / 2, globalTop + nodeH, x.thermostat + nodeW / 2, y, 'bus'));
|
||||
if (app.settings?.night_mode?.enabled) links.push(verticalLink(globalNightLeft + nodeW / 2, globalTop + nodeH, x.decision + nodeW / 2, y, nightOn ? 'night-link' : 'bus'));
|
||||
});
|
||||
|
||||
boardHost.style.width = `${boardW}px`;
|
||||
boardHost.style.height = `${boardH}px`;
|
||||
boardHost.innerHTML = `<svg class="flow-links" viewBox="0 0 ${boardW} ${boardH}" width="${boardW}" height="${boardH}" aria-hidden="true">${links.join('')}</svg>${nodes.join('')}`;
|
||||
|
||||
const timelineItems = [];
|
||||
if (app.simulationTarget === 'all') (plan.next_events || []).forEach(event => timelineItems.push({ scope: tr('simulation.house'), event }));
|
||||
zones.forEach(zone => (zone.next_events || []).forEach(event => timelineItems.push({ scope: zone.zone_name, event, device: zone.device_name })));
|
||||
timelineItems.sort((a, b) => new Date(a.event.at) - new Date(b.event.at));
|
||||
const unique = [], seen = new Set();
|
||||
for (const item of timelineItems) {
|
||||
const key = `${item.scope}|${item.event.at}|${item.event.label}`;
|
||||
if (seen.has(key)) continue; seen.add(key); unique.push(item); if (unique.length >= 16) break;
|
||||
}
|
||||
timelineHost.innerHTML = unique.length ? unique.map(item => {
|
||||
const target = item.event.target_temperature == null ? '' : ` · ${fmtTemp(item.event.target_temperature)}`;
|
||||
return `<article class="simulation-timeline-item"><time>${esc(simulationTime(item.event.at))}</time><div><strong>${esc(item.scope)}</strong><p>${esc(item.event.label)}${esc(target)}${item.device ? ` · ${esc(item.device)}` : ''}</p></div></article>`;
|
||||
}).join('') : `<div class="empty">${esc(tr('plan.noEvents'))}</div>`;
|
||||
|
||||
const activeRules = (plan.rules || []).filter(rule => rule.enabled && simulationRuleVisible(rule, zones));
|
||||
rulesHost.innerHTML = activeRules.length ? activeRules.map(rule => `<article class="simulation-rule-card"><div class="simulation-rule-head"><div><span class="eyebrow">${esc(tr('common.automation'))}</span><h3>${esc(rule.name)}</h3></div><span class="badge active">${esc(automationTriggerLabel(rule))}</span></div><p>${esc(tr('simulation.ruleOnDevice', { device: rule.action_device_name || tr('common.noDevice') }))}</p><div class="simulation-rule-action">${esc(simulationRuleAction(rule))}</div><div class="simulation-rule-meta"><small>${esc(tr('automations.last'))}: ${esc(dateTime(rule.last_fired_at))}</small><small>${esc(tr('simulation.nextReady'))}: ${esc(dateTime(rule.next_ready_at))}</small></div></article>`).join('') : `<div class="empty">${esc(tr('simulation.noRules'))}</div>`;
|
||||
}
|
||||
|
||||
function scheduleControlPlanLoad(delay = 180) {
|
||||
if (controlPlanWebSocketReady()) return;
|
||||
clearTimeout(app.controlPlanTimer);
|
||||
app.controlPlanTimer = setTimeout(loadControlPlan, delay);
|
||||
}
|
||||
|
||||
function stopControlPlanFallback() {
|
||||
clearTimeout(app.controlPlanTimer);
|
||||
app.controlPlanTimer = null;
|
||||
}
|
||||
|
||||
@@ -1,744 +0,0 @@
|
||||
function deviceFeaturePanel(device) {
|
||||
const allFeatures = [
|
||||
['light', 'supports_light', tr('devices.light')],
|
||||
['xfan', 'supports_xfan', tr('devices.xfan')],
|
||||
['health', 'supports_health', tr('devices.health')],
|
||||
['air', 'supports_air', tr('devices.air')],
|
||||
['sleep', 'supports_sleep', tr('devices.sleep')],
|
||||
];
|
||||
const features = allFeatures.filter(([, support]) => device[support] === true);
|
||||
if (!features.length) return `<div class="device-capability-panel"><small>${esc(tr('devices.features'))}</small><span class="muted">${esc(tr('devices.noExtraFeatures'))}</span></div>`;
|
||||
return `<div class="device-capability-panel"><small>${esc(tr('devices.features'))}</small><div class="device-capability-buttons">${features.map(([field, , label]) => `<button class="${device[field] ? 'active' : ''}" data-action="toggle" data-field="${field}" data-device="${esc(device.id)}">${esc(label)}</button>`).join('')}</div></div>`;
|
||||
}
|
||||
|
||||
|
||||
function zoneForDevice(deviceId) {
|
||||
return app.zones.find(zone => zone.device_id === deviceId) || null;
|
||||
}
|
||||
|
||||
function disabledZoneForDevice(deviceId) {
|
||||
return app.zones.find(zone => zone.device_id === deviceId && zone.enabled === false) || null;
|
||||
}
|
||||
|
||||
function deviceTransportLabel(device) {
|
||||
return device.connection_type === 'gree_cloud' ? 'GREE Cloud' : 'Local';
|
||||
}
|
||||
|
||||
function deviceInstallationForDevice(deviceId) {
|
||||
return (app.deviceGroups || []).find(group => (group.device_ids || []).includes(deviceId)) || null;
|
||||
}
|
||||
|
||||
function deviceInstallationKindLabel(group) {
|
||||
return tr(group?.kind === 'multisplit' ? 'devices.multisplit' : 'devices.split');
|
||||
}
|
||||
|
||||
function installationEnergySourceLabel(group) {
|
||||
if (!group) return '—';
|
||||
if (group.energy_source === 'home_assistant' || (group.energy_source === 'auto' && group.ha_energy_entity_id && !group.energy_device_id)) {
|
||||
return group.ha_energy_entity_id ? `Home Assistant · ${group.ha_energy_entity_id}` : 'Home Assistant';
|
||||
}
|
||||
if (group.energy_source === 'gree_cloud' || group.energy_device_id) {
|
||||
const source = app.devices.find(device => device.id === group.energy_device_id);
|
||||
return source ? `GREE Cloud · ${source.name}` : 'GREE Cloud';
|
||||
}
|
||||
return tr('energy.auto');
|
||||
}
|
||||
|
||||
function effectiveDeviceOutdoorTemperature(device) {
|
||||
const group = deviceInstallationForDevice(device?.id);
|
||||
if (group?.outdoor_temperature_device_id) {
|
||||
const source = app.devices.find(item => item.id === group.outdoor_temperature_device_id);
|
||||
if (source?.outdoor_temperature != null) return source.outdoor_temperature;
|
||||
}
|
||||
return device?.outdoor_temperature;
|
||||
}
|
||||
|
||||
function installationEnergySnapshot(group) {
|
||||
if (!group) return null;
|
||||
const selectedSource = group.energy_source === 'gree_cloud' || (group.energy_source === 'auto' && !!group.energy_device_id)
|
||||
? 'gree_cloud'
|
||||
: (group.energy_source === 'home_assistant' || (group.energy_source === 'auto' && !!group.ha_energy_entity_id) ? 'home_assistant' : '');
|
||||
if (selectedSource === 'gree_cloud' && group.energy_device_id) {
|
||||
const device = app.devices.find(item => item.id === group.energy_device_id);
|
||||
const live = device?.total_energy_kwh == null ? NaN : Number(device.total_energy_kwh);
|
||||
if (Number.isFinite(live) && live >= 0) {
|
||||
return { total_kwh: live, timestamp: device.last_cloud_sync || device.last_seen || null, source: 'gree_cloud', origin: 'cloud' };
|
||||
}
|
||||
}
|
||||
const cached = app.deviceGroupEnergy?.[group.id] || null;
|
||||
return cached?.source === selectedSource ? cached : null;
|
||||
}
|
||||
|
||||
function formatEnergyMeterTotal(value) {
|
||||
const number = Number(value);
|
||||
if (!Number.isFinite(number) || number < 0) return tr('common.unavailable');
|
||||
return `${number.toLocaleString(locale(), { maximumFractionDigits: 3 })} kWh`;
|
||||
}
|
||||
|
||||
function installationEnergySourceShortLabel(group, snapshot) {
|
||||
if (snapshot?.source === 'home_assistant') return 'Home Assistant';
|
||||
if (snapshot?.source === 'gree_cloud') return 'GREE Cloud';
|
||||
if (group?.energy_source === 'home_assistant' || (group?.energy_source === 'auto' && group?.ha_energy_entity_id && !group?.energy_device_id)) return 'Home Assistant';
|
||||
if (group?.energy_source === 'gree_cloud' || group?.energy_device_id) return 'GREE Cloud';
|
||||
return tr('energy.auto');
|
||||
}
|
||||
|
||||
function deviceConnectionStatusLabel(device) {
|
||||
const key = {
|
||||
online: 'status.online',
|
||||
offline: 'status.offline',
|
||||
cloud_disconnected: 'status.cloudDisconnected',
|
||||
authentication_error: 'status.authenticationError',
|
||||
unknown: 'status.unknown',
|
||||
}[device.connection_status || (device.online ? 'online' : 'offline')];
|
||||
return key ? tr(key) : String(device.connection_status || tr('status.unknown')).replaceAll('_', ' ');
|
||||
}
|
||||
|
||||
function deviceProtocolLabel(device) {
|
||||
if (device.connection_type === 'gree_cloud') return 'MQTT / TLS';
|
||||
return device.simulated ? tr('devices.simulator') : device.protocol_version === 2 ? 'V2 AES-GCM' : device.protocol_version === 1 ? 'V1 AES-ECB' : tr('devices.protocolAuto');
|
||||
}
|
||||
|
||||
function deviceResponseTimeLabel(device) {
|
||||
const value = Number(device.response_time_ms);
|
||||
return Number.isFinite(value) ? `${Math.max(0, Math.round(value))} ms` : '— ms';
|
||||
}
|
||||
|
||||
function manualDeviceStatus(device) {
|
||||
const label = deviceConnectionStatusLabel(device);
|
||||
const detail = device.last_error ? `<span class="manual-device-status-error" title="${esc(device.last_error)}">${esc(device.last_error)}</span>` : '';
|
||||
const pending = device.pending_command ? `<span class="manual-device-status-pending">${esc(tr('common.pending'))}</span>` : '';
|
||||
return `<p class="manual-device-status" role="status"><span class="status ${device.online ? 'online' : ''}">${esc(label)}</span>${detail}${pending}</p>`;
|
||||
}
|
||||
|
||||
// Keep Local Manual Control visually and behaviorally identical to the pre-Cloud UI.
|
||||
// The only transport-specific addition is the existing Local badge in the title row.
|
||||
function manualDeviceProblem(device) {
|
||||
if (!device.enabled) {
|
||||
return `<div class="manual-device-problem error" role="status"><strong>${esc(tr('devices.manualDeviceDisabled'))}</strong></div>`;
|
||||
}
|
||||
if (!device.online) {
|
||||
return `<div class="manual-device-problem error" role="status"><strong>${esc(tr('devices.manualNoCommunication'))}</strong>${device.last_error ? `<small>${esc(device.last_error)}</small>` : ''}</div>`;
|
||||
}
|
||||
if (Number(device.communication_failures || 0) > 0 || device.last_error) {
|
||||
return `<div class="manual-device-problem warning" role="status"><strong>${esc(tr('devices.manualCommunicationProblem'))}</strong>${device.last_error ? `<small>${esc(device.last_error)}</small>` : ''}</div>`;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function powerIconMarkup() {
|
||||
return uiIcon('power', 'power-icon');
|
||||
}
|
||||
|
||||
function manualLocalDeviceCard(device) {
|
||||
const caps = device.capabilities || {};
|
||||
const modes = ['auto', 'cool', 'dry', 'fan', 'heat'];
|
||||
const fans = [0, 1, 3, 5];
|
||||
const disabledZone = disabledZoneForDevice(device.id);
|
||||
const managedZone = app.zones.find(zone => zone.device_id === device.id && zone.compressor_pending_action) || zoneForDevice(device.id);
|
||||
const warning = disabledZone ? `<div class="manual-zone-warning" role="note"><strong>${esc(tr('devices.manualDisabledZoneTitle'))}</strong><p>${esc(tr('devices.manualDisabledZoneWarning', { zone: disabledZone.name }))}</p></div>` : '';
|
||||
return `<article class="device-card quick-control-card quick-device-control ${device.power ? '' : 'off'} ${disabledZone ? 'manual-zone-blocked' : ''}" data-device-card="${esc(device.id)}">
|
||||
<div class="device-head">
|
||||
<div class="device-title"><h3>${esc(device.name)}</h3><span class="badge">Local</span></div>
|
||||
<button class="power-button ${device.power ? 'on' : ''}" data-action="power" data-device="${esc(device.id)}" aria-label="${esc(tr('devices.power'))}">${powerIconMarkup()}</button>
|
||||
</div>
|
||||
${warning}
|
||||
${manualDeviceProblem(device)}
|
||||
<div class="temperature-control">
|
||||
<button data-action="temperature" data-delta="-1" data-device="${esc(device.id)}">${uiIcon('minus')}</button>
|
||||
<div class="target-temp editable-target" data-temperature-kind="device" data-id="${esc(device.id)}" data-value="${Number(device.target_temperature)}" data-editable="true" tabindex="0" role="button" title="${esc(tr('common.clickTemperatureToEdit'))}">${Number(device.target_temperature).toFixed(1)}<small>°C</small></div>
|
||||
<button data-action="temperature" data-delta="1" data-device="${esc(device.id)}">${uiIcon('plus')}</button>
|
||||
</div>
|
||||
<div class="current-line">${esc(tr('devices.currentTemperature'))}: <strong>${fmtTemp(device.current_temperature)}</strong>${effectiveDeviceOutdoorTemperature(device) == null ? '' : ` · ${esc(tr('devices.outdoor'))} ${fmtTemp(effectiveDeviceOutdoorTemperature(device))}`}</div>
|
||||
${compressorQueuePanel(managedZone)}
|
||||
<div class="mode-row quick-control-row quick-control-row-5">${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 quick-control-row quick-control-row-4">${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 quick-control-row quick-control-row-4">
|
||||
${caps.vertical_swing === false ? '' : `<button class="${device.swing_vertical ? 'active' : ''}" data-action="toggle" data-field="swing_vertical" data-device="${esc(device.id)}">${esc(tr('devices.swingVertical'))}</button>`}
|
||||
${caps.horizontal_swing === false ? '' : `<button class="${device.swing_horizontal ? 'active' : ''}" data-action="toggle" data-field="swing_horizontal" data-device="${esc(device.id)}">${esc(tr('devices.swingHorizontal'))}</button>`}
|
||||
${device.supports_quiet === false ? '' : `<button class="${device.quiet ? 'active' : ''}" data-action="toggle" data-field="quiet" data-device="${esc(device.id)}">${esc(tr('devices.quiet'))}</button>`}
|
||||
${device.supports_turbo === false ? '' : `<button class="${device.turbo ? 'active' : ''}" data-action="toggle" data-field="turbo" data-device="${esc(device.id)}">${esc(tr('devices.turbo'))}</button>`}
|
||||
</div>
|
||||
${deviceFeaturePanel(device)}
|
||||
</article>`;
|
||||
}
|
||||
|
||||
function manualCloudDeviceCard(device) {
|
||||
const caps = device.capabilities || {};
|
||||
const modes = caps.mode === false ? [] : ['auto', 'cool', 'dry', 'fan', 'heat'];
|
||||
const reportedFans = Array.isArray(caps.fan_modes) ? caps.fan_modes.map(Number).filter(Number.isFinite) : [];
|
||||
const fans = reportedFans.length ? reportedFans : [0, 1, 3, 5];
|
||||
const tempStep = Number(caps.temperature_step) > 0 ? Number(caps.temperature_step) : 1;
|
||||
const minTemp = Number.isFinite(Number(caps.min_temperature)) ? Number(caps.min_temperature) : 8;
|
||||
const maxTemp = Number.isFinite(Number(caps.max_temperature)) ? Number(caps.max_temperature) : 30;
|
||||
const disabledZone = disabledZoneForDevice(device.id);
|
||||
const managedZone = app.zones.find(zone => zone.device_id === device.id && zone.compressor_pending_action) || zoneForDevice(device.id);
|
||||
const warning = disabledZone ? `<div class="manual-zone-warning" role="note"><strong>${esc(tr('devices.manualDisabledZoneTitle'))}</strong><p>${esc(tr('devices.manualDisabledZoneWarning', { zone: disabledZone.name }))}</p></div>` : '';
|
||||
return `<article class="device-card quick-control-card quick-device-control cloud-manual-device ${device.pending_command ? 'cloud-command-pending' : ''} ${device.power ? '' : 'off'} ${disabledZone ? 'manual-zone-blocked' : ''}" data-device-card="${esc(device.id)}">
|
||||
<div class="device-head">
|
||||
<div class="device-title">
|
||||
<div class="manual-device-title-row"><h3>${esc(device.name)}</h3><span class="badge">GREE Cloud</span></div>
|
||||
${manualDeviceStatus(device)}
|
||||
</div>
|
||||
<button class="power-button ${device.power ? 'on' : ''}" data-action="power" data-device="${esc(device.id)}" aria-label="${esc(tr('devices.power'))}">${powerIconMarkup()}</button>
|
||||
</div>
|
||||
${warning}
|
||||
<div class="temperature-control">
|
||||
<button data-action="temperature" data-delta="${-tempStep}" data-device="${esc(device.id)}">${uiIcon('minus')}</button>
|
||||
<div class="target-temp editable-target" data-temperature-kind="device" data-id="${esc(device.id)}" data-value="${Number(device.target_temperature)}" data-temp-step="${tempStep}" data-temp-min="${minTemp}" data-temp-max="${maxTemp}" data-editable="true" tabindex="0" role="button" title="${esc(tr('common.clickTemperatureToEdit'))}">${Number(device.target_temperature).toFixed(1)}<small>°C</small></div>
|
||||
<button data-action="temperature" data-delta="${tempStep}" data-device="${esc(device.id)}">${uiIcon('plus')}</button>
|
||||
</div>
|
||||
<div class="current-line">${esc(tr('devices.currentTemperature'))}: <strong>${fmtTemp(device.current_temperature)}</strong>${effectiveDeviceOutdoorTemperature(device) == null ? '' : ` · ${esc(tr('devices.outdoor'))} ${fmtTemp(effectiveDeviceOutdoorTemperature(device))}`}</div>
|
||||
${compressorQueuePanel(managedZone)}
|
||||
${modes.length ? `<div class="mode-row quick-control-row quick-control-row-5">${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>` : ''}
|
||||
${fans.length ? `<div class="fan-row quick-control-row quick-control-row-${Math.min(6, fans.length)}">${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 quick-control-row quick-control-row-4">
|
||||
${caps.vertical_swing === false ? '' : `<button class="${device.swing_vertical ? 'active' : ''}" data-action="toggle" data-field="swing_vertical" data-device="${esc(device.id)}">${esc(tr('devices.swingVertical'))}</button>`}
|
||||
${caps.horizontal_swing === false ? '' : `<button class="${device.swing_horizontal ? 'active' : ''}" data-action="toggle" data-field="swing_horizontal" data-device="${esc(device.id)}">${esc(tr('devices.swingHorizontal'))}</button>`}
|
||||
${device.supports_quiet === false ? '' : `<button class="${device.quiet ? 'active' : ''}" data-action="toggle" data-field="quiet" data-device="${esc(device.id)}">${esc(tr('devices.quiet'))}</button>`}
|
||||
${device.supports_turbo === false ? '' : `<button class="${device.turbo ? 'active' : ''}" data-action="toggle" data-field="turbo" data-device="${esc(device.id)}">${esc(tr('devices.turbo'))}</button>`}
|
||||
</div>
|
||||
${deviceFeaturePanel(device)}
|
||||
</article>`;
|
||||
}
|
||||
|
||||
function manualDeviceCard(device) {
|
||||
return device.connection_type === 'gree_cloud' ? manualCloudDeviceCard(device) : manualLocalDeviceCard(device);
|
||||
}
|
||||
|
||||
function technicalDeviceCard(device) {
|
||||
if (device.connection_type === 'gree_cloud') return cloudTechnicalDeviceCard(device);
|
||||
const protocol = deviceProtocolLabel(device);
|
||||
const responseTime = deviceResponseTimeLabel(device);
|
||||
const lastSeen = device.last_seen ? dateTime(device.last_seen) : tr('common.unavailable');
|
||||
const model = device.model || tr('common.unavailable');
|
||||
const firmware = device.firmware || tr('common.unavailable');
|
||||
const cid = device.cid || tr('common.unavailable');
|
||||
const error = device.last_error ? `<div class="inline-alert error"><span>${esc(tr('devices.lastError'))}</span><strong title="${esc(device.last_error)}">${esc(device.last_error)}</strong></div>` : '';
|
||||
return `<article class="device-card technical-device-card" data-device-card="${esc(device.id)}">
|
||||
<div class="technical-device-head">
|
||||
<div class="device-title"><span class="eyebrow">${esc(tr('devices.technicalUnit'))} · ${esc(deviceTransportLabel(device))}</span><h3>${esc(device.name)}</h3><p><span class="status ${device.online ? 'online' : ''}">${esc(device.connection_type === 'gree_cloud' ? (device.connection_status || 'unknown').replaceAll('_', ' ') : tr(device.online ? 'status.online' : 'status.offline'))}</span> · ${esc(model)}</p></div>
|
||||
${device.connection_type === 'gree_cloud' ? `<span class="badge">GREE Cloud</span>` : `<button class="device-ping-button" type="button" data-action="open-ping" data-device="${esc(device.id)}" title="${esc(tr('devices.pingOpen'))}"><span>${esc(tr('devices.ping'))}</span><strong>${esc(responseTime)}</strong></button>`}
|
||||
</div>
|
||||
<div class="technical-device-grid">
|
||||
<div><span>${esc(tr('devices.address'))}</span><strong>${device.connection_type === 'gree_cloud' ? 'GREE Cloud' : `${esc(device.ip)}:${esc(device.port)}`}</strong></div>
|
||||
<div><span>MAC</span><strong>${esc(device.mac)}</strong></div>
|
||||
<div><span>CID</span><strong>${esc(cid)}</strong></div>
|
||||
<div><span>${esc(tr('devices.protocol'))}</span><strong>${esc(protocol)}</strong></div>
|
||||
<div><span>${esc(tr('devices.modelFirmware'))}</span><strong>${esc(model)}</strong><small>${esc(firmware)}</small></div>
|
||||
<div><span>${esc(tr('devices.lastSeen'))}</span><strong>${esc(lastSeen)}</strong></div>
|
||||
<div><span>${esc(tr('devices.communicationFailures'))}</span><strong>${esc(device.communication_failures ?? 0)}</strong></div>
|
||||
${deviceInstallationForDevice(device.id) ? `<div><span>${esc(tr('devices.installation'))}</span><strong>${esc(deviceInstallationForDevice(device.id).name)}</strong><small>${esc(deviceInstallationKindLabel(deviceInstallationForDevice(device.id)))}</small></div>` : ''}
|
||||
</div>
|
||||
${error}
|
||||
<div class="technical-device-actions">
|
||||
<button type="button" data-action="poll" data-device="${esc(device.id)}">${esc(tr('devices.readStatus'))}</button>
|
||||
<button type="button" data-action="rename-device" data-device="${esc(device.id)}">${esc(tr('devices.technicalConfig'))}</button>
|
||||
<button type="button" data-action="energy-config" data-device="${esc(device.id)}">${esc(tr('energy.title'))}</button>
|
||||
${device.simulated || device.connection_type === 'gree_cloud' ? '' : `<button type="button" data-action="bind" data-device="${esc(device.id)}">${esc(tr('actions.bind'))}</button>`}
|
||||
<details class="card-overflow"><summary aria-label="${esc(tr('actions.more'))}" title="${esc(tr('actions.more'))}">${uiIcon('more-horizontal')}</summary><div class="card-overflow-menu"><button class="danger" data-action="delete-device" data-device="${esc(device.id)}">${esc(tr('actions.delete'))}</button></div></details>
|
||||
</div>
|
||||
</article>`;
|
||||
}
|
||||
|
||||
function cloudTechnicalDeviceCard(device) {
|
||||
const lastSync = device.last_cloud_sync ? dateTime(device.last_cloud_sync) : tr('devices.noCloudSyncYet');
|
||||
const lastSeen = device.last_seen ? dateTime(device.last_seen) : tr('devices.noCloudResponseYet');
|
||||
const cloudId = device.cloud_device_id || device.mac || tr('common.unavailable');
|
||||
const status = deviceConnectionStatusLabel(device);
|
||||
const responseTime = deviceResponseTimeLabel(device);
|
||||
const modelCell = device.model ? `<div><span>${esc(tr('devices.model'))}</span><strong>${esc(device.model)}</strong></div>` : '';
|
||||
const firmwareCell = device.firmware ? `<div><span>${esc(tr('devices.firmware'))}</span><strong>${esc(device.firmware)}</strong></div>` : '';
|
||||
const error = device.last_error ? `<div class="inline-alert error"><span>${esc(tr('devices.lastError'))}</span><strong title="${esc(device.last_error)}">${esc(device.last_error)}</strong></div>` : '';
|
||||
return `<article class="device-card technical-device-card" data-device-card="${esc(device.id)}">
|
||||
<div class="technical-device-head">
|
||||
<div class="device-title"><span class="eyebrow">${esc(tr('devices.cloudUnit'))}</span><h3>${esc(device.name)}</h3><p><span class="status ${device.online ? 'online' : ''}">${esc(status)}</span> · GREE Cloud</p></div>
|
||||
<span class="badge">GREE Cloud</span>
|
||||
</div>
|
||||
<div class="technical-device-grid">
|
||||
<div><span>${esc(tr('devices.connection'))}</span><strong>GREE Cloud</strong></div>
|
||||
<div><span>${esc(tr('devices.transport'))}</span><strong>MQTT / TLS</strong></div>
|
||||
<div><span>${esc(tr('devices.cloudDeviceId'))}</span><strong>${esc(cloudId)}</strong></div>
|
||||
<div><span>MAC</span><strong>${esc(device.mac || cloudId)}</strong></div>
|
||||
${modelCell}
|
||||
${firmwareCell}
|
||||
<div><span>${esc(tr('devices.lastSync'))}</span><strong>${esc(lastSync)}</strong></div>
|
||||
<div><span>${esc(tr('devices.lastResponse'))}</span><strong>${esc(lastSeen)}</strong><small>${esc(responseTime)}</small></div>
|
||||
<div><span>${esc(tr('devices.communicationFailures'))}</span><strong>${esc(device.communication_failures ?? 0)}</strong></div>
|
||||
${deviceInstallationForDevice(device.id) ? `<div><span>${esc(tr('devices.installation'))}</span><strong>${esc(deviceInstallationForDevice(device.id).name)}</strong><small>${esc(deviceInstallationKindLabel(deviceInstallationForDevice(device.id)))}</small></div>` : ''}
|
||||
</div>
|
||||
${error}
|
||||
<div class="technical-device-actions">
|
||||
<button type="button" data-action="poll" data-device="${esc(device.id)}">${esc(tr('devices.readStatus'))}</button>
|
||||
<button type="button" data-action="cloud-details" data-device="${esc(device.id)}">${esc(tr('devices.cloudDetails'))}</button>
|
||||
<button type="button" data-action="cloud-diagnostics" data-device="${esc(device.id)}">${esc(tr('devices.cloudDiagnostics'))}</button>
|
||||
<details class="card-overflow"><summary aria-label="${esc(tr('actions.more'))}" title="${esc(tr('actions.more'))}">${uiIcon('more-horizontal')}</summary><div class="card-overflow-menu"><button class="danger" data-action="delete-device" data-device="${esc(device.id)}">${esc(tr('actions.delete'))}</button></div></details>
|
||||
</div>
|
||||
</article>`;
|
||||
}
|
||||
|
||||
function renderDeviceInstallationsSummary() {
|
||||
const host = $('#deviceGroupsSummary');
|
||||
if (!host) return;
|
||||
const groups = app.deviceGroups || [];
|
||||
host.innerHTML = groups.length ? groups.map(group => {
|
||||
const members = (group.device_ids || []).map(id => app.devices.find(device => device.id === id)?.name).filter(Boolean);
|
||||
const outdoorDevice = app.devices.find(device => device.id === group.outdoor_temperature_device_id);
|
||||
const outdoorValue = outdoorDevice?.outdoor_temperature == null ? NaN : Number(outdoorDevice.outdoor_temperature);
|
||||
const energy = installationEnergySnapshot(group);
|
||||
const sourceTitle = installationEnergySourceLabel(group);
|
||||
const sourceShort = installationEnergySourceShortLabel(group, energy);
|
||||
const lastReading = energy?.timestamp ? `${tr('energy.lastReading')}: ${dateTime(energy.timestamp)}` : '';
|
||||
const outdoor = outdoorDevice
|
||||
? `${tr('devices.sharedOutdoorMetric')}: ${Number.isFinite(outdoorValue) ? fmtTemp(outdoorValue) : tr('common.unavailable')} · ${outdoorDevice.name}`
|
||||
: '';
|
||||
return `<article class="installation-summary-card"><div><span class="eyebrow">${esc(deviceInstallationKindLabel(group))}</span><strong>${esc(group.name)}</strong><small>${esc(members.join(' · ') || '—')}</small></div><div class="installation-energy-metric"><span>${esc(tr('energy.totalMeter'))}</span><b title="${esc(sourceTitle)}">${esc(formatEnergyMeterTotal(energy?.total_kwh))}</b><small>${esc(sourceShort)}${lastReading ? ` · ${esc(lastReading)}` : ''}</small>${outdoor ? `<small>${esc(outdoor)}</small>` : ''}</div><button type="button" class="secondary" data-action="edit-device-group" data-id="${esc(group.id)}">${esc(tr('actions.edit'))}</button></article>`;
|
||||
}).join('') : '';
|
||||
}
|
||||
|
||||
function renderDevices() {
|
||||
renderDeviceInstallationsSummary();
|
||||
const empty = `<div class="empty"><strong>${esc(tr('devices.emptyTitle'))}</strong>${esc(tr('devices.emptyText'))}</div>`;
|
||||
$('#dashboardDevices').innerHTML = app.devices.length ? app.devices.map(manualDeviceCard).join('') : empty;
|
||||
$('#deviceList').innerHTML = app.devices.length ? app.devices.map(technicalDeviceCard).join('') : empty;
|
||||
const dashboardCount = $('#dashboardDeviceCount');
|
||||
if (dashboardCount) dashboardCount.textContent = String(app.devices.length);
|
||||
if ($('#pingDialog')?.open) renderPingDialog();
|
||||
}
|
||||
|
||||
function zoneStrategyLabel(zone) {
|
||||
if (zone.sensor_source === 'combined') return tr('zones.combinedSource');
|
||||
if (zone.sensor_source === 'home_assistant') return tr('zones.externalSource');
|
||||
return tr('zones.greeSource');
|
||||
}
|
||||
|
||||
function zoneControlSourceLabel(source) {
|
||||
return ({
|
||||
device: tr('zones.sourceDevice'),
|
||||
external: tr('zones.sourceExternal'),
|
||||
combined: tr('zones.sourceCombined'),
|
||||
device_fallback: tr('zones.sourceFallback'),
|
||||
device_discrepancy_fallback: tr('zones.sourceDiscrepancy'),
|
||||
unavailable: tr('zones.sourceUnavailable'),
|
||||
})[source] || source || tr('zones.sourceUnavailable');
|
||||
}
|
||||
|
||||
function zonePresetLabel(preset) {
|
||||
const key = `preset.${preset || 'comfort'}`;
|
||||
return tr(key) === key ? (preset || 'comfort') : tr(key);
|
||||
}
|
||||
|
||||
function groupControlHue(groupId) {
|
||||
let hash = 0;
|
||||
for (const char of String(groupId || 'group')) hash = ((hash * 31) + char.charCodeAt(0)) >>> 0;
|
||||
return (hash % 300) + 20;
|
||||
}
|
||||
|
||||
function groupControlStyle(group) {
|
||||
return group ? ` style="--group-control-color:hsl(${groupControlHue(group.id)} 72% 48%)"` : '';
|
||||
}
|
||||
|
||||
function groupControlForZone(zone) {
|
||||
if (zone?.device_manual_override || zone?.local_thermostat_power != null) return null;
|
||||
const source = String(zone?.control_source || '');
|
||||
if (!source.startsWith('group:')) return null;
|
||||
const groupId = source.slice(6);
|
||||
return app.groups.find(group => group.id === groupId && group.power_enabled !== false && (group.zone_ids || []).includes(zone.id)) || null;
|
||||
}
|
||||
|
||||
function zoneLockoutActive(zone) {
|
||||
const until = zone?.lockout_until ? new Date(zone.lockout_until).getTime() : NaN;
|
||||
return Number.isFinite(until) && until > Date.now();
|
||||
}
|
||||
|
||||
function compressorPendingDescription(zone) {
|
||||
const action = String(zone?.compressor_pending_action || '');
|
||||
if (!action) return '';
|
||||
const [kind, mode, target] = action.split(':');
|
||||
const targetValue = Number(target);
|
||||
if (kind === 'mode_change') return tr('zones.queuedModeChange', { mode: modeLabel(mode), temperature: Number.isFinite(targetValue) ? targetValue.toFixed(1) : '—' });
|
||||
if (kind === 'power_on') return tr('zones.queuedPowerOn', { mode: modeLabel(mode), temperature: Number.isFinite(targetValue) ? targetValue.toFixed(1) : '—' });
|
||||
if (kind === 'global_power_on') return tr('zones.queuedGlobalPowerOn');
|
||||
return tr('zones.queuedAction');
|
||||
}
|
||||
|
||||
function compressorQueuePanel(zone) {
|
||||
if (!zone?.compressor_pending_action) return '';
|
||||
const until = zone.compressor_pending_until || zone.lockout_until;
|
||||
const untilMs = until ? new Date(until).getTime() : NaN;
|
||||
const when = Number.isFinite(untilMs) && untilMs > Date.now() ? dateTime(until) : tr('zones.afterProtection');
|
||||
return `<div class="compressor-queue-panel"><div><strong>${esc(tr('zones.compressorQueueTitle'))}</strong><span>${esc(compressorPendingDescription(zone))}</span><small>${esc(tr('zones.compressorQueueUntil', { time: when }))}</small></div><button type="button" class="secondary" data-action="cancel-compressor-task" data-id="${esc(zone.id)}">${esc(tr('actions.cancel'))}</button></div>`;
|
||||
}
|
||||
|
||||
function compressorQueueRemainingText(until) {
|
||||
const timestamp = until ? new Date(until).getTime() : NaN;
|
||||
if (!Number.isFinite(timestamp)) return tr('zones.afterProtection');
|
||||
const seconds = Math.max(0, Math.ceil((timestamp - Date.now()) / 1000));
|
||||
return seconds > 0 ? tr('zones.queueRemaining', { time: formatExtendedCountdown(seconds) }) : tr('zones.queueReady');
|
||||
}
|
||||
|
||||
function renderCompressorQueueModal() {
|
||||
const list = $('#compressorQueueList');
|
||||
const countNode = $('#compressorQueueModalCount');
|
||||
const cancelAll = $('#compressorQueueCancelAll');
|
||||
if (!list || !countNode || !cancelAll) return;
|
||||
const pending = app.zones
|
||||
.filter(zone => !!zone.compressor_pending_action)
|
||||
.slice()
|
||||
.sort((a, b) => {
|
||||
const at = new Date(a.compressor_pending_until || 0).getTime();
|
||||
const bt = new Date(b.compressor_pending_until || 0).getTime();
|
||||
return (Number.isFinite(at) ? at : Number.MAX_SAFE_INTEGER) - (Number.isFinite(bt) ? bt : Number.MAX_SAFE_INTEGER);
|
||||
});
|
||||
countNode.textContent = String(pending.length);
|
||||
cancelAll.disabled = pending.length === 0;
|
||||
cancelAll.hidden = pending.length === 0;
|
||||
cancelAll.textContent = pending.length ? `${tr('zones.cancelAllQueued')} (${pending.length})` : tr('zones.cancelAllQueued');
|
||||
if (!pending.length) {
|
||||
list.innerHTML = `<div class="compressor-queue-empty"><span aria-hidden="true">${uiIcon('check')}</span><strong>${esc(tr('zones.queueEmptyTitle'))}</strong><p>${esc(tr('zones.queueEmptyText'))}</p></div>`;
|
||||
return;
|
||||
}
|
||||
list.innerHTML = pending.map((zone, index) => {
|
||||
const device = app.devices.find(item => item.id === zone.device_id);
|
||||
const until = zone.compressor_pending_until || zone.lockout_until;
|
||||
const group = groupControlForZone(zone);
|
||||
const source = zone.compressor_pending_action === 'global_power_on'
|
||||
? tr('zones.queueSourceGlobal')
|
||||
: (group ? `${zoneControlOwnerLabel(zone)}: ${group.name}` : zoneControlOwnerLabel(zone));
|
||||
const exact = until ? dateTime(until) : tr('zones.afterProtection');
|
||||
return `<article class="compressor-queue-item">
|
||||
<div class="compressor-queue-index">${index + 1}</div>
|
||||
<div class="compressor-queue-item-main">
|
||||
<div class="compressor-queue-item-head"><div><span class="eyebrow">${esc(zone.name)}</span><h3>${esc(device?.name || tr('common.noDevice'))}</h3></div><span class="compressor-queue-reason">${esc(tr('zones.queueReasonProtection'))}</span></div>
|
||||
<p class="compressor-queue-command">${esc(compressorPendingDescription(zone))}</p>
|
||||
<div class="compressor-queue-meta">
|
||||
<span>${esc(tr('zones.queueSource', { source }))}</span>
|
||||
<span>${esc(tr('zones.compressorQueueUntil', { time: exact }))}</span>
|
||||
<strong data-compressor-queue-countdown="${esc(until || '')}">${esc(compressorQueueRemainingText(until))}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="secondary compressor-queue-cancel" data-action="cancel-compressor-task" data-id="${esc(zone.id)}">${esc(tr('actions.cancel'))}</button>
|
||||
</article>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function updateCompressorQueueCountdowns() {
|
||||
$$('[data-compressor-queue-countdown]').forEach(node => {
|
||||
node.textContent = compressorQueueRemainingText(node.dataset.compressorQueueCountdown);
|
||||
});
|
||||
}
|
||||
|
||||
function openCompressorQueueDialog() {
|
||||
renderCompressorQueueModal();
|
||||
openDialog('compressorQueueDialog');
|
||||
updateCompressorQueueCountdowns();
|
||||
}
|
||||
|
||||
function zoneControlOwnerLabel(zone) {
|
||||
const owner = zone.control_owner || (zone.device_manual_override ? 'direct_manual' : (zone.local_thermostat_power != null ? 'local_thermostat' : 'automation'));
|
||||
const source = zone.control_source || '';
|
||||
if (owner === 'global_off') return tr('zones.ownerGlobalOff');
|
||||
if (owner === 'direct_manual') {
|
||||
if (source === 'home_assistant_direct') return tr('zones.ownerHaDirect');
|
||||
if (source === 'web_direct') return tr('zones.ownerWebDirect');
|
||||
return tr('zones.ownerExternal');
|
||||
}
|
||||
if (owner === 'local_thermostat') return tr('zones.ownerLocalThermostat');
|
||||
if (source.startsWith('group:')) return tr('zones.ownerGroup');
|
||||
return tr('zones.ownerAutomation');
|
||||
}
|
||||
|
||||
function zoneControlOwnerMeta(zone) {
|
||||
const parts = [];
|
||||
const controlGroup = groupControlForZone(zone);
|
||||
if (controlGroup) parts.push(`${tr('groups.group')}: ${controlGroup.name}`);
|
||||
if (zone.control_since) parts.push(`${tr('zones.ownerSince')} ${dateTime(zone.control_since)}`);
|
||||
if (zone.control_resume_at) parts.push(`${tr('zones.ownerResume')} ${dateTime(zone.control_resume_at)}`);
|
||||
if (zone.lockout_until && new Date(zone.lockout_until) > new Date()) parts.push(`${tr('zones.lockoutUntil')} ${dateTime(zone.lockout_until)}`);
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
function zoneRuntimeStatusLabel(zone, effectiveMode, device = null) {
|
||||
if (zone.device_manual_override === true) return tr('zones.manualDeviceControl');
|
||||
if (!zone.enabled) return tr('common.disabled');
|
||||
if (zone.local_thermostat_power === false) return tr('zones.localThermostatOff');
|
||||
if ((effectiveMode || 'off') === 'off') return tr('zones.waiting');
|
||||
if (device?.enabled === false) return tr('zones.waitingDeviceDisabled');
|
||||
if (device && (!device.online || Number(device.communication_failures || 0) > 0)) return tr('zones.waitingOffline');
|
||||
if (zone.current_temperature == null) return tr('zones.noMeasurement');
|
||||
if (zone.compressor_pending_action && zoneLockoutActive(zone)) return tr('zones.waitingLockout');
|
||||
if (zone.compressor_cancelled_action && device && (!device.power || (['heat', 'cool'].includes(effectiveMode) && device.mode !== effectiveMode))) return tr('zones.queueCancelledStatus');
|
||||
if (zone.demand && device && !device.power) return tr('zones.waitingStart');
|
||||
if (zone.demand && device?.power && ['heat', 'cool'].includes(effectiveMode) && device.mode !== effectiveMode) return tr('zones.waitingMode');
|
||||
return zone.demand ? tr('zones.runningDemand') : tr('zones.satisfied');
|
||||
}
|
||||
|
||||
function zoneCard(zone, detailed = true) {
|
||||
const device = app.devices.find(d => d.id === zone.device_id);
|
||||
const state = zone.enabled ? tr('common.active') : tr('common.disabled');
|
||||
const sensorDetails = zone.sensor_source === 'device'
|
||||
? `${tr('zones.greeTemp')}: ${fmtTemp(zone.device_temperature ?? device?.current_temperature)}`
|
||||
: `${tr('zones.greeTemp')}: ${fmtTemp(zone.device_temperature)} · ${tr('zones.externalTemp')}: ${fmtTemp(zone.external_temperature)} · ${tr('zones.usedSource')}: ${zoneControlSourceLabel(zone.control_temperature_source)}`;
|
||||
const roomTemperature = zone.current_temperature ?? zone.device_temperature ?? device?.current_temperature;
|
||||
const manual = zone.manual_preset || 'auto';
|
||||
const mode = zone.inherit_house_mode ? 'house' : zone.mode;
|
||||
const effectiveMode = zone.effective_mode || (zone.inherit_house_mode ? (app.settings?.house_mode || 'off') : zone.mode) || 'off';
|
||||
const idleWithoutTarget = effectiveMode === 'off' && zone.effective_setpoint == null && zone.manual_setpoint == null && zone.manual_preset == null;
|
||||
const target = Number(zone.manual_setpoint ?? zone.effective_setpoint ?? zone.setpoint);
|
||||
const displayPreset = idleWithoutTarget ? 'auto' : (zone.manual_preset || zone.active_preset || 'comfort');
|
||||
const globalModeAvailable = ['heat', 'cool'].includes(app.settings?.house_mode);
|
||||
const globalModeDisabled = globalModeAvailable ? '' : ` disabled aria-disabled="true" title="${esc(tr('zones.globalModeUnavailable'))}"`;
|
||||
const hasManualOverride = zone.manual_preset != null || zone.manual_setpoint != null;
|
||||
const deviceManualOverride = zone.device_manual_override === true;
|
||||
const controlGroup = groupControlForZone(zone);
|
||||
const deviceUnavailable = !!device && (device.enabled === false || !device.online || Number(device.communication_failures || 0) > 0);
|
||||
const lockoutWaiting = !!zone.compressor_pending_action && zoneLockoutActive(zone);
|
||||
const startWaiting = zone.demand && !!device && !deviceUnavailable && !device.power;
|
||||
const modeWaiting = zone.demand && !!device?.power && !deviceUnavailable && ['heat', 'cool'].includes(effectiveMode) && device.mode !== effectiveMode;
|
||||
const visualGroup = controlGroup;
|
||||
const waitingDemand = lockoutWaiting || startWaiting || modeWaiting || (zone.demand && deviceUnavailable);
|
||||
const workingDemand = zone.demand && !!device?.power && !deviceUnavailable && !waitingDemand;
|
||||
const localThermostatPower = zone.local_thermostat_power;
|
||||
const localThermostatResumeAt = zone.local_thermostat_resume_at || null;
|
||||
const pausedGroup = localThermostatPower === false && localThermostatResumeAt
|
||||
? (app.groups || []).find(group => group.power_enabled === false && (group.zone_ids || []).includes(zone.id)) || null
|
||||
: null;
|
||||
const temporarySession = zone.temporary_quick_thermostat || null;
|
||||
const temporaryStatus = temporarySession ? temporarySessionStatus(zone) : null;
|
||||
const localThermostatOverride = localThermostatPower === true || localThermostatPower === false;
|
||||
const override = deviceManualOverride
|
||||
? (zone.device_manual_override_until
|
||||
? tr('zones.manualDeviceUntil', { time: new Date(zone.device_manual_override_until).toLocaleTimeString(locale(), { hour: '2-digit', minute: '2-digit' }) })
|
||||
: tr('zones.manualDeviceNoBoundary'))
|
||||
: temporarySession
|
||||
? tr('zones.temporaryOverride', { time: temporaryStatus?.countdown || '—' })
|
||||
: localThermostatOverride
|
||||
? tr(localThermostatPower ? 'zones.localThermostatOn' : 'zones.localThermostatOff')
|
||||
: (zone.manual_override_until
|
||||
? `${tr('zones.overrideUntil')} ${new Date(zone.manual_override_until).toLocaleTimeString(locale(), { hour: '2-digit', minute: '2-digit' })}`
|
||||
: (hasManualOverride ? tr('zones.manualNoBoundary') : tr('zones.scheduleControl')));
|
||||
const manualTakeover = deviceManualOverride
|
||||
? `<div class="manual-override-panel"><div><strong>${esc(tr('zones.manualDeviceControl'))}</strong><p>${esc(tr('zones.manualDeviceDescription'))}</p></div><button type="button" data-action="zone-resume-automation" data-resume="device" data-id="${esc(zone.id)}">${esc(tr('zones.resumeAutomation'))}</button></div>`
|
||||
: localThermostatOverride && !temporarySession
|
||||
? `<div class="manual-override-panel local-thermostat-panel"><div><strong>${esc(tr('zones.localThermostatControl'))}</strong>${localThermostatPower === false && localThermostatResumeAt
|
||||
? `<p data-local-resume-countdown="${esc(localThermostatResumeAt)}"${pausedGroup ? ` data-local-resume-group="${esc(pausedGroup.name)}"` : ''}>${esc(tr(pausedGroup ? 'zones.groupOffDescriptionTimed' : 'zones.localThermostatOffDescriptionTimed', { group: pausedGroup?.name || '', time: formatCountdown(localResumeSeconds(localThermostatResumeAt)) }))}</p>`
|
||||
: `<p>${esc(tr(localThermostatPower ? 'zones.localThermostatOnDescription' : 'zones.localThermostatOffDescription'))}</p>`}</div><button type="button" data-action="zone-resume-automation" data-resume="local" data-id="${esc(zone.id)}">${esc(tr(localThermostatPower === false ? 'zones.resumeNow' : 'zones.resumeAutomation'))}</button></div>`
|
||||
: '';
|
||||
const localPowerDisabled = !device || device.enabled === false ? ' disabled' : '';
|
||||
const localRequestedOn = localThermostatPower === true ? true : (localThermostatPower === false ? false : !!device?.power);
|
||||
const localThermostatPowerControl = `<button type="button" class="zone-unit-power-toggle ${localRequestedOn ? 'active' : ''}" title="${esc(tr('zones.unitPowerLocalHint'))}" data-action="zone-device-power" data-id="${esc(zone.id)}" data-value="${localRequestedOn ? 'false' : 'true'}" aria-pressed="${localRequestedOn ? 'true' : 'false'}"${localPowerDisabled}><span aria-hidden="true">${powerIconMarkup()}</span><b>${esc(tr(localRequestedOn ? 'common.on' : 'common.off'))}</b></button>`;
|
||||
const temporaryThermostatControl = temporarySession
|
||||
? `<button type="button" class="temporary-thermostat-toggle active ${temporaryStatus?.pending ? 'scheduled' : ''}" data-action="zone-open-temporary" data-id="${esc(zone.id)}" title="${esc(temporaryStatus?.detail || tr('zones.temporaryActive'))}"><b data-temporary-countdown-zone="${esc(zone.id)}">${esc(temporaryStatus?.countdown || '—')}</b></button>`
|
||||
: `<button type="button" class="temporary-thermostat-toggle" data-action="zone-open-temporary" data-id="${esc(zone.id)}" title="${esc(tr('zones.temporaryStartHint'))}"${localPowerDisabled}><b>${esc(tr('zones.temporaryShort'))}</b></button>`;
|
||||
const disabledManualControlNotice = zone.enabled ? '' : `<div class="manual-zone-warning zone-disabled-manual-note" role="note"><strong>${esc(tr('zones.disabledManualControlTitle'))}</strong><p>${esc(tr('zones.disabledManualControlHint'))}</p></div>`;
|
||||
|
||||
if (detailed) {
|
||||
const groupNames = (app.groups || []).filter(group => (group.zone_ids || []).includes(zone.id)).map(group => group.name);
|
||||
const groupText = groupNames.length ? groupNames.join(' · ') : tr('zones.noGroup');
|
||||
const policy = zone.inherit_house_mode ? tr('zones.followHouse') : tr(zone.mode === 'heat' ? 'zones.heatOnly' : 'zones.coolOnly');
|
||||
return `<article class="list-card zone-config-card ${workingDemand ? 'demanding' : ''} ${waitingDemand ? 'lockout-waiting' : ''} ${zone.enabled ? '' : 'zone-disabled'} ${deviceManualOverride ? 'manual-takeover' : ''} ${controlGroup ? 'group-controlled' : ''}" data-zone-config="${esc(zone.id)}"${groupControlStyle(visualGroup)}>
|
||||
<div class="list-card-head"><div><span class="eyebrow">${esc(tr('zones.configuration'))}</span><h3>${esc(zone.name)}</h3><p>${esc(device?.name || tr('common.noDevice'))} · ${esc(tr('groups.group'))}: ${esc(groupText)}</p></div><button type="button" class="enable-toggle ${zone.enabled ? 'active' : ''}" data-action="zone-enabled" data-id="${esc(zone.id)}" data-value="${zone.enabled ? 'false' : 'true'}" aria-label="${esc(tr(zone.enabled ? 'zones.disable' : 'zones.enable'))}" title="${esc(tr('zones.automationToggleHint'))}"><span>${uiIcon(zone.enabled ? 'check' : 'circle')}</span>${esc(state)}</button></div>
|
||||
<div class="zone-config-status">
|
||||
<div class="zone-config-temperature"><small>${esc(tr('zones.currentStatus'))}</small><div><span>${fmtTemp(roomTemperature)}</span><b>→</b><strong>${Number.isFinite(target) ? `${target.toFixed(1)}°C` : '—'}</strong></div></div>
|
||||
<div class="zone-config-runtime"><span class="badge ${workingDemand ? 'active' : ''}">${esc(zoneRuntimeStatusLabel(zone, effectiveMode, device))}</span><span>${esc(houseModeLabel(effectiveMode))} · ${esc(zonePresetLabel(displayPreset))}</span><small>${esc(override)}</small><small><strong>${esc(tr('zones.controlOwner'))}:</strong> ${esc(zoneControlOwnerLabel(zone))}${zoneControlOwnerMeta(zone) ? ` · ${esc(zoneControlOwnerMeta(zone))}` : ''}</small></div>
|
||||
</div>
|
||||
<div class="zone-config-grid">
|
||||
<div><small>${esc(tr('zones.modePolicy'))}</small><strong>${esc(policy)}</strong></div>
|
||||
<div><small>${esc(tr('zones.source'))}</small><strong>${esc(zoneStrategyLabel(zone))}</strong></div>
|
||||
<div><small>${esc(tr('zones.hysteresis'))}</small><strong>${zone.separate_hysteresis ? `${esc(tr('mode.cool'))} ${Number(zone.cool_hysteresis ?? zone.hysteresis ?? 0.6).toFixed(1)}°C · ${esc(tr('mode.heat'))} ${Number(zone.heat_hysteresis ?? zone.hysteresis ?? 0.6).toFixed(1)}°C` : `${Number(zone.hysteresis ?? 0.6).toFixed(1)}°C`}</strong></div>
|
||||
<div><small>${esc(tr('zones.smartFan'))}</small><strong>${esc(tr(zone.smart_fan === false ? 'common.off' : 'common.on'))}</strong></div>
|
||||
</div>
|
||||
<div class="sensor-detail">${esc(sensorDetails)}</div>
|
||||
${compressorQueuePanel(zone)}
|
||||
${manualTakeover}
|
||||
<div class="card-footer"><small>${esc(tr('zones.controlOnDashboard'))}</small><div class="card-menu"><button class="primary" data-action="zone-go-control" data-id="${esc(zone.id)}">${esc(tr('zones.controlNow'))}</button><button data-action="edit-zone" data-id="${esc(zone.id)}">${esc(tr('actions.edit'))}</button><details class="card-overflow"><summary aria-label="${esc(tr('actions.more'))}" title="${esc(tr('actions.more'))}">${uiIcon('more-horizontal')}</summary><div class="card-overflow-menu"><button class="danger" data-action="delete-zone" data-id="${esc(zone.id)}">${esc(tr('actions.delete'))}</button></div></details></div></div>
|
||||
</article>`;
|
||||
}
|
||||
|
||||
const runtimeStatus = zoneRuntimeStatusLabel(zone, effectiveMode, device);
|
||||
return `<article class="list-card zone-thermostat quick-control-card quick-thermostat-control ${workingDemand ? 'demanding' : ''} ${waitingDemand ? 'lockout-waiting' : ''} ${zone.enabled ? '' : 'zone-disabled'} ${deviceManualOverride ? 'manual-takeover' : ''} ${controlGroup ? 'group-controlled' : ''}" data-zone-card="${esc(zone.id)}"${groupControlStyle(visualGroup)}>
|
||||
<div class="list-card-head"><div><h3>${esc(zone.name)}</h3><p>${esc(device?.name || tr('common.noDevice'))} · ${esc(zonePresetLabel(displayPreset))}</p></div><div class="zone-quick-actions">${localThermostatPowerControl}${temporaryThermostatControl}</div></div>
|
||||
${disabledManualControlNotice}
|
||||
<div class="thermostat-main"><div><small>${esc(tr('zones.measurement'))}</small><strong>${fmtTemp(roomTemperature)}</strong></div><div class="temperature-control compact"><button data-action="zone-temperature" data-id="${esc(zone.id)}" data-delta="-0.5">${uiIcon('minus')}</button><div class="target-temp compact editable-target" data-temperature-kind="zone" data-id="${esc(zone.id)}" data-value="${Number.isFinite(target) ? target : ''}" tabindex="0" role="button" title="${esc(tr('common.clickTemperatureToEdit'))}">${Number.isFinite(target) ? target.toFixed(1) : '--'}<small>°C</small></div><button data-action="zone-temperature" data-id="${esc(zone.id)}" data-delta="0.5">${uiIcon('plus')}</button></div><div><small>${esc(tr('zones.deviceTarget'))}</small><strong>${fmtTemp(zone.device_setpoint)}</strong></div></div>
|
||||
<div class="preset-row quick-control-row quick-control-row-4">
|
||||
${['auto', 'comfort', 'sleep', 'away'].map(preset => `<button class="${manual === preset ? 'active' : ''}" data-action="zone-preset" data-id="${esc(zone.id)}" data-value="${preset}">${esc(preset === 'sleep' ? tr('zones.sleepNow') : zonePresetLabel(preset))}</button>`).join('')}
|
||||
</div>
|
||||
<div class="mode-row zone-mode-row quick-control-row quick-control-row-3"><button class="${mode === 'house' && globalModeAvailable ? 'active' : ''}" data-action="zone-mode" data-id="${esc(zone.id)}" data-value="house"${globalModeDisabled}>${esc(tr('zones.followHouseShort'))}</button><button class="${mode === 'heat' ? 'active' : ''}" data-action="zone-mode" data-id="${esc(zone.id)}" data-value="heat">${esc(modeLabel('heat'))}</button><button class="${mode === 'cool' ? 'active' : ''}" data-action="zone-mode" data-id="${esc(zone.id)}" data-value="cool">${esc(modeLabel('cool'))}</button></div>
|
||||
${device ? `<div class="device-toggles thermostat-swing-row quick-control-row quick-control-row-2">${device.capabilities?.vertical_swing === false ? '' : `<button class="${device.swing_vertical ? 'active' : ''}" data-action="zone-swing" data-field="swing_vertical" data-device="${esc(device.id)}"${deviceUnavailable ? ' disabled' : ''}>${esc(tr('devices.swingVertical'))}</button>`}${device.capabilities?.horizontal_swing === false ? '' : `<button class="${device.swing_horizontal ? 'active' : ''}" data-action="zone-swing" data-field="swing_horizontal" data-device="${esc(device.id)}"${deviceUnavailable ? ' disabled' : ''}>${esc(tr('devices.swingHorizontal'))}</button>`}</div>` : ''}
|
||||
<div class="zone-state-line"><span class="zone-runtime-status" title="${esc(runtimeStatus)}">${esc(runtimeStatus)}${controlGroup ? ` <b class="group-control-tag">${esc(controlGroup.name)}</b>` : ''}</span><span>${esc(override)}</span></div>
|
||||
<div class="zone-state-line control-owner-line"><span><strong>${esc(tr('zones.controlOwner'))}:</strong> ${esc(zoneControlOwnerLabel(zone))}</span><span>${esc(zoneControlOwnerMeta(zone))}</span></div>
|
||||
${compressorQueuePanel(zone)}
|
||||
${manualTakeover}
|
||||
</article>`;
|
||||
}
|
||||
|
||||
function renderZones() {
|
||||
const empty = `<div class="empty"><strong>${esc(tr('zones.emptyTitle'))}</strong>${esc(tr('zones.emptyText'))}</div>`;
|
||||
$('#zoneList').innerHTML = app.zones.length ? app.zones.map(zone => zoneCard(zone, true)).join('') : empty;
|
||||
const dashboard = $('#dashboardZones');
|
||||
if (dashboard) dashboard.innerHTML = app.zones.length ? app.zones.map(zone => zoneCard(zone, false)).join('') : empty;
|
||||
const dashboardCount = $('#dashboardZoneCount');
|
||||
if (dashboardCount) dashboardCount.textContent = String(app.zones.length);
|
||||
const pendingCount = app.zones.filter(zone => !!zone.compressor_pending_action).length;
|
||||
$$('[data-compressor-queue-count]').forEach(node => { node.textContent = String(pendingCount); });
|
||||
$$('.queue-overview-button').forEach(button => button.classList.toggle('has-items', pendingCount > 0));
|
||||
renderCompressorQueueModal();
|
||||
}
|
||||
|
||||
function groupZones(group) {
|
||||
const wanted = new Set(group?.zone_ids || []);
|
||||
return app.zones.filter(zone => wanted.has(zone.id));
|
||||
}
|
||||
|
||||
function groupState(group) {
|
||||
const zones = groupZones(group);
|
||||
const modes = [...new Set(zones.map(zone => zone.inherit_house_mode ? 'house' : (zone.mode || 'cool')))];
|
||||
const presets = [...new Set(zones.map(zone => zone.manual_preset || 'auto'))];
|
||||
const customTargets = zones
|
||||
.filter(zone => (zone.manual_preset || 'auto') === 'custom')
|
||||
.map(zone => Number(zone.manual_setpoint ?? zone.effective_setpoint ?? zone.setpoint))
|
||||
.filter(Number.isFinite);
|
||||
const customTemperature = customTargets.length === zones.length && customTargets.length && customTargets.every(value => Math.abs(value - customTargets[0]) < 0.05)
|
||||
? customTargets[0]
|
||||
: null;
|
||||
return {
|
||||
zones,
|
||||
mode: modes.length === 1 ? modes[0] : (modes.length ? 'mixed' : 'house'),
|
||||
preset: presets.length === 1 ? presets[0] : (presets.length ? 'mixed' : 'auto'),
|
||||
customTemperature,
|
||||
};
|
||||
}
|
||||
|
||||
function groupCard(group, detailed = false) {
|
||||
const state = groupState(group);
|
||||
const memberNames = state.zones.map(zone => zone.name);
|
||||
const memberText = memberNames.length ? memberNames.join(' · ') : tr('groups.noMembers');
|
||||
const powerEnabled = group.power_enabled !== false;
|
||||
const modeLabelForGroup = value => value === 'house' ? tr('groups.followHouse') : (value === 'mixed' ? tr('groups.mixed') : modeLabel(value));
|
||||
const presetLabelForGroup = value => value === 'mixed' ? tr('groups.mixed') : zonePresetLabel(value);
|
||||
const fallbackTarget = Number(state.zones[0]?.manual_setpoint ?? state.zones[0]?.effective_setpoint ?? state.zones[0]?.setpoint ?? 23);
|
||||
const currentCustomTarget = Number.isFinite(state.customTemperature) ? state.customTemperature : (Number.isFinite(fallbackTarget) ? fallbackTarget : 23);
|
||||
const customDraft = app.groupCustomDrafts?.[group.id];
|
||||
const draftTarget = Number(customDraft?.value);
|
||||
const customTarget = Number.isFinite(draftTarget) ? draftTarget : currentCustomTarget;
|
||||
const customEditorVisible = powerEnabled && (state.preset === 'custom' || customDraft?.open === true);
|
||||
const customDraftPending = powerEnabled && customDraft?.open === true && state.preset !== 'custom';
|
||||
const groupIsControlSource = powerEnabled && state.zones.some(zone => String(zone.control_source || '') === `group:${group.id}` && !zone.device_manual_override && zone.local_thermostat_power == null);
|
||||
const climateControlsDisabled = powerEnabled ? '' : ` disabled aria-disabled="true" title="${esc(tr('groups.enableControlFirst'))}"`;
|
||||
const groupStatusBadge = groupIsControlSource
|
||||
? `<span class="group-control-badge">${esc(tr('groups.activeControl'))}</span>`
|
||||
: `<span class="group-control-badge ${powerEnabled ? 'group-on-badge' : 'group-off-badge'}">${esc(tr(powerEnabled ? 'groups.gateOn' : 'groups.gateOff'))}</span>`;
|
||||
return `<article class="list-card group-card group-linked ${powerEnabled ? '' : 'group-off'} ${groupIsControlSource ? 'group-controlled' : ''}"${groupControlStyle(group)}>
|
||||
<div class="list-card-head"><div><span class="eyebrow">${esc(tr('groups.group'))}</span><h3>${esc(group.name)}</h3><p>${esc(memberText)}</p></div>${groupStatusBadge}</div>
|
||||
${detailed ? `<div class="group-state-summary"><div><small>${esc(tr('groups.mode'))}</small><strong>${esc(modeLabelForGroup(state.mode))}</strong></div><div><small>${esc(tr('groups.profile'))}</small><strong>${esc(presetLabelForGroup(state.preset))}</strong></div><div><small>${esc(tr('groups.members'))}</small><strong>${state.zones.length}</strong></div></div>` : ''}
|
||||
<div class="group-control-block"><small>${esc(tr('groups.controlToggle'))}</small><div class="group-button-row"><button class="${powerEnabled ? 'active' : ''}" data-action="group-power" data-id="${esc(group.id)}" data-value="true">${esc(tr('common.on'))}</button><button class="${!powerEnabled ? 'active' : ''}" data-action="group-power" data-id="${esc(group.id)}" data-value="false">${esc(tr('common.off'))}</button></div></div>
|
||||
<div class="group-control-block"><small>${esc(tr('groups.mode'))}</small><div class="mode-row group-mode-row">${['house', 'heat', 'cool'].map(mode => `<button class="${state.mode === mode ? 'active' : ''}" data-action="group-mode" data-id="${esc(group.id)}" data-value="${mode}"${climateControlsDisabled}>${esc(mode === 'house' ? tr('groups.followHouse') : modeLabel(mode))}</button>`).join('')}</div></div>
|
||||
<div class="group-control-block"><small>${esc(tr('groups.profile'))}</small><div class="preset-row group-preset-row">${['auto', 'comfort', 'sleep', 'away'].map(preset => `<button class="${state.preset === preset ? 'active' : ''}" data-action="group-preset" data-id="${esc(group.id)}" data-value="${preset}"${climateControlsDisabled}>${esc(zonePresetLabel(preset))}</button>`).join('')}<button class="${state.preset === 'custom' ? 'active' : (customDraft?.open === true ? 'editing' : '')}" data-action="group-custom-open" data-id="${esc(group.id)}" title="${esc(tr('groups.customOpenHint'))}"${climateControlsDisabled}>${esc(tr('preset.custom'))}</button></div>
|
||||
<div class="group-custom-temperature" data-group-custom-editor="${esc(group.id)}" ${customEditorVisible ? '' : 'hidden'}><label><span>${esc(tr('groups.customTemperature'))}</span><div class="group-custom-temperature-row"><input type="text" inputmode="decimal" min="8" max="30" step="0.1" value="${customTarget.toFixed(1)}" data-group-custom-temperature="${esc(group.id)}" aria-label="${esc(tr('groups.customTemperature'))}" title="${esc(tr('groups.customTemperatureHint'))}"><span>°C</span><button type="button" class="primary" data-action="group-custom-temperature" data-id="${esc(group.id)}" title="${esc(tr('groups.applyCustomTemperatureHint'))}">${esc(tr('actions.apply'))}</button><button type="button" class="secondary" data-action="group-custom-cancel" data-id="${esc(group.id)}">${esc(tr('actions.cancel'))}</button></div></label>${customDraftPending ? `<small class="group-custom-draft-note">${esc(tr('groups.customDraftPending'))}</small>` : ''}</div></div>
|
||||
${detailed ? `<div class="card-footer"><small>${esc(tr('groups.memberCount', { count: state.zones.length }))}</small><div class="card-menu"><button data-action="edit-group" data-id="${esc(group.id)}">${esc(tr('actions.edit'))}</button><details class="card-overflow"><summary aria-label="${esc(tr('actions.more'))}" title="${esc(tr('actions.more'))}">${uiIcon('more-horizontal')}</summary><div class="card-overflow-menu"><button class="danger" data-action="delete-group" data-id="${esc(group.id)}">${esc(tr('actions.delete'))}</button></div></details></div></div>` : ''}
|
||||
</article>`;
|
||||
}
|
||||
|
||||
function renderGroups() {
|
||||
const empty = `<div class="empty"><strong>${esc(tr('groups.emptyTitle'))}</strong>${esc(tr('groups.emptyText'))}</div>`;
|
||||
const dashboard = $('#dashboardGroups');
|
||||
if (dashboard) dashboard.innerHTML = app.groups.length ? app.groups.map(group => groupCard(group, false)).join('') : empty;
|
||||
const dashboardCount = $('#dashboardGroupCount');
|
||||
if (dashboardCount) dashboardCount.textContent = String(app.groups.length);
|
||||
const list = $('#groupList');
|
||||
if (list) list.innerHTML = app.groups.length ? app.groups.map(group => groupCard(group, true)).join('') : empty;
|
||||
}
|
||||
|
||||
async function sendGroupControl(id, patch) {
|
||||
try {
|
||||
await enqueueClimateControlTask(async () => {
|
||||
const result = await api(`/api/groups/${encodeURIComponent(id)}/control`, { method: 'POST', body: patch });
|
||||
if (result.group) {
|
||||
const index = app.groups.findIndex(group => group.id === result.group.id);
|
||||
if (index >= 0) app.groups[index] = result.group; else app.groups.push(result.group);
|
||||
}
|
||||
(result.zones || []).forEach(zone => {
|
||||
const index = app.zones.findIndex(item => item.id === zone.id);
|
||||
if (index >= 0) app.zones[index] = zone; else app.zones.push(zone);
|
||||
});
|
||||
(result.devices || []).forEach(updateDevice);
|
||||
if (patch.preset === 'custom' && patch.setpoint != null) delete app.groupCustomDrafts[id];
|
||||
renderAll(); scheduleControlPlanLoad();
|
||||
const failed = Array.isArray(result.failed) ? result.failed.length : 0;
|
||||
if (failed) toast(tr('groups.partial', { count: failed }), true);
|
||||
else toast(tr('groups.controlUpdated'));
|
||||
return result;
|
||||
});
|
||||
} catch (error) {
|
||||
await loadBootstrap();
|
||||
toast(error.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
function renderGroupZoneChoices(selectedIds = null) {
|
||||
const host = $('#groupZoneChoices'); if (!host) return;
|
||||
const selected = new Set(selectedIds || []);
|
||||
host.innerHTML = app.zones.length ? app.zones.map(zone => {
|
||||
const device = app.devices.find(item => item.id === zone.device_id);
|
||||
return `<label class="group-zone-choice"><input type="checkbox" name="zone_ids" value="${esc(zone.id)}" ${selected.has(zone.id) ? 'checked' : ''}><span><strong>${esc(zone.name)}</strong><small>${esc(device?.name || tr('common.noDevice'))}</small></span></label>`;
|
||||
}).join('') : `<div class="empty compact">${esc(tr('groups.noZones'))}</div>`;
|
||||
}
|
||||
|
||||
function populateGroup(id) {
|
||||
const item = app.groups.find(group => group.id === id); if (!item) return;
|
||||
const form = $('#groupForm'); form.reset();
|
||||
form.elements.id.value = item.id;
|
||||
form.elements.name.value = item.name;
|
||||
renderGroupZoneChoices(item.zone_ids || []);
|
||||
openDialog('groupDialog');
|
||||
}
|
||||
|
||||
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 ${item.flow_id ? 'flow-generated-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('schedules.enabledState') : tr('schedules.disabledState'))}</span></div>
|
||||
<div class="card-stats"><div class="card-stat"><small>${esc(tr('common.from'))}</small><strong>${esc(item.start_time)}</strong></div><div class="card-stat"><small>${esc(tr('common.to'))}</small><strong>${esc(item.end_time)}</strong></div><div class="card-stat"><small>${esc(tr('schedules.profile'))}</small><strong>${esc(item.preset === 'custom' ? fmtTemp(item.setpoint) : zonePresetLabel(item.preset))}</strong></div></div>
|
||||
<div class="card-footer"><small>${esc(item.flow_id ? tr('flow.generatedReadOnly') : tr('schedules.crossMidnight'))}</small><div class="card-menu">${item.flow_id ? `<button data-action="edit-flow" data-id="${esc(item.flow_id)}">${esc(tr('flow.openEditor'))}</button>` : `<button data-action="edit-schedule" data-id="${esc(item.id)}">${esc(tr('actions.edit'))}</button><details class="card-overflow"><summary aria-label="${esc(tr('actions.more'))}" title="${esc(tr('actions.more'))}">${uiIcon('more-horizontal')}</summary><div class="card-overflow-menu"><button class="danger" data-action="delete-schedule" data-id="${esc(item.id)}">${esc(tr('actions.delete'))}</button></div></details>`}</div></div></article>`;
|
||||
}).join('') : `<div class="empty"><strong>${esc(tr('schedules.emptyTitle'))}</strong>${esc(tr('schedules.emptyText'))}</div>`;
|
||||
}
|
||||
|
||||
function renderAutomations() {
|
||||
$('#automationList').innerHTML = app.automations.length ? app.automations.map(item => {
|
||||
const actionZone = item.action_zone_id ? app.zones.find(zone => zone.id === item.action_zone_id) : null;
|
||||
const actionGroup = item.action_group_id ? app.groups.find(group => group.id === item.action_group_id) : null;
|
||||
const actionDevice = app.devices.find(device => device.id === item.action_device_id);
|
||||
const targetName = actionZone?.name || actionGroup?.name || actionDevice?.name || tr('common.noDevice');
|
||||
const mode = item.action.mode === 'auto' && (actionGroup || actionZone) ? tr('groups.followHouse') : (item.action.mode ? modeLabel(item.action.mode) : '—');
|
||||
const preset = item.action_zone_preset ? zonePresetLabel(item.action_zone_preset) : (item.action_preset ? zonePresetLabel(item.action_preset) : '—');
|
||||
return `<article class="list-card ${item.flow_id ? 'flow-generated-card' : ''}"><div class="list-card-head"><div><h3>${esc(item.name)}</h3><p>${esc(tr('automations.triggerSummary', { trigger: automationTriggerLabel(item), device: targetName }))}</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>${esc(mode)}</strong></div><div class="card-stat"><small>${esc(tr('groups.profile'))}</small><strong>${esc(preset)}</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(item.flow_id ? tr('flow.generatedReadOnly') : (actionGroup ? `${tr('groups.group')}: ${targetName}` : `${tr('common.device')}: ${targetName}`))} · ${esc(tr('common.cooldown'))} ${item.cooldown_seconds}s</small><div class="card-menu">${item.flow_id ? `<button data-action="edit-flow" data-id="${esc(item.flow_id)}">${esc(tr('flow.openEditor'))}</button>` : `<button data-action="edit-automation" data-id="${esc(item.id)}">${esc(tr('actions.edit'))}</button><details class="card-overflow"><summary aria-label="${esc(tr('actions.more'))}" title="${esc(tr('actions.more'))}">${uiIcon('more-horizontal')}</summary><div class="card-overflow-menu"><button class="danger" data-action="delete-automation" data-id="${esc(item.id)}">${esc(tr('actions.delete'))}</button></div></details>`}</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('');
|
||||
const groupOptions = app.groups.map(g => `<option value="${esc(g.id)}">${esc(g.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 automationGroupSelect = $('#automationForm [name=action_group_id]');
|
||||
if (automationGroupSelect) { const currentGroup = automationGroupSelect.value; automationGroupSelect.innerHTML = groupOptions; if ([...automationGroupSelect.options].some(o => o.value === currentGroup)) automationGroupSelect.value = currentGroup; }
|
||||
const zoneSelect = $('#scheduleForm [name=zone_id]');
|
||||
if (zoneSelect) { const currentZone = zoneSelect.value; zoneSelect.innerHTML = zoneOptions; if ([...zoneSelect.options].some(o => o.value === currentZone)) zoneSelect.value = currentZone; }
|
||||
const presetZone = $('#schedulePresetZone');
|
||||
if (presetZone) { const currentZone = presetZone.value; presetZone.innerHTML = zoneOptions; if ([...presetZone.options].some(o => o.value === currentZone)) presetZone.value = currentZone; }
|
||||
const copyZone = $('#copyZoneSource');
|
||||
if (copyZone) {
|
||||
const current = copyZone.value;
|
||||
const editingZoneId = $('#zoneForm')?.dataset.editingZoneId || '';
|
||||
const sources = app.zones.filter(zone => zone.id !== editingZoneId);
|
||||
copyZone.innerHTML = `<option value="">${esc(tr('zones.copyFrom'))}</option>` + sources.map(z => `<option value="${esc(z.id)}">${esc(z.name)}</option>`).join('');
|
||||
copyZone.value = [...copyZone.options].some(option => option.value === current) ? current : '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,656 +0,0 @@
|
||||
|
||||
document.addEventListener('keydown', event => {
|
||||
const target = event.target.closest?.('[data-temperature-kind]');
|
||||
if (!target || event.target.matches('input')) return;
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
beginInlineTemperatureEdit(target);
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('click', async event => {
|
||||
const inlineTarget = event.target.closest('[data-temperature-kind]');
|
||||
if (inlineTarget && !event.target.closest('button')) { beginInlineTemperatureEdit(inlineTarget); return; }
|
||||
const historyRoute = event.target.closest('a[data-history-route]');
|
||||
if (historyRoute) {
|
||||
event.preventDefault();
|
||||
const hours = historyRoute.dataset.historyHours;
|
||||
if (hours && $('#historyHours')) $('#historyHours').value = hours;
|
||||
else if ($('#historyHours')) $('#historyHours').value = '24';
|
||||
const dialog = historyRoute.closest('dialog');
|
||||
closeChartPreview(dialog?.querySelector('.chart-card.chart-fullscreen-fallback'));
|
||||
if (dialog?.open) dialog.close();
|
||||
app.historyTab = 'overview';
|
||||
showView('history');
|
||||
return;
|
||||
}
|
||||
const button = event.target.closest('button'); if (!button) return;
|
||||
if (button.dataset.settingsTab) { setSettingsTab(button.dataset.settingsTab); return; }
|
||||
if (button.dataset.debugFilter) { app.debugFilter = button.dataset.debugFilter; renderDebugOverlay(); return; }
|
||||
if (button.dataset.dashboardTab) { setDashboardTab(button.dataset.dashboardTab); return; }
|
||||
if (button.dataset.nav) {
|
||||
if (button.dataset.nav === 'more') openDialog('moreDialog'); else showView(button.dataset.nav);
|
||||
return;
|
||||
}
|
||||
if (button.dataset.go) { const more = $('#moreDialog'); if (more?.open) more.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') {
|
||||
if (form) { form.dataset.editingZoneId = ''; form.dataset.separateHysteresisInitialized = 'false'; if (form.elements.id) form.elements.id.value = ''; }
|
||||
updateZoneSensorFields();
|
||||
updateZoneHysteresisFields();
|
||||
}
|
||||
if (button.dataset.open === 'groupDialog') {
|
||||
if (form?.elements.id) form.elements.id.value = '';
|
||||
renderGroupZoneChoices([]);
|
||||
}
|
||||
if (button.dataset.open === 'automationDialog') updateAutomationTargetFields();
|
||||
if (button.dataset.open === 'scheduleDialog') updateSchedulePresetField();
|
||||
openDialog(button.dataset.open);
|
||||
return;
|
||||
}
|
||||
if (button.hasAttribute('data-close')) { requestDialogClose(button.closest('dialog')); return; }
|
||||
if (button.dataset.chartLegend !== undefined) {
|
||||
const id = button.dataset.chartId;
|
||||
const runtime = chartRuntime.get(id);
|
||||
const index = Number(button.dataset.chartLegend);
|
||||
const item = runtime?.series?.[index];
|
||||
if (item) {
|
||||
setChartSeriesHidden(id, item, index, !isChartSeriesHidden(id, item, index));
|
||||
redrawHistoryChart(id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (button.dataset.chartFullscreen) {
|
||||
await toggleChartFullscreen(button.dataset.chartFullscreen);
|
||||
return;
|
||||
}
|
||||
if (button.dataset.chartZoom) {
|
||||
const id = button.dataset.chartId;
|
||||
const current = clamp(Number(app.chartZooms[id] || 1), 1, MAX_CHART_ZOOM);
|
||||
const next = button.dataset.chartZoom === 'in' ? Math.min(MAX_CHART_ZOOM, current * 1.5) : button.dataset.chartZoom === 'out' ? Math.max(1, current / 1.5) : 1;
|
||||
setChartZoom(id, next);
|
||||
return;
|
||||
}
|
||||
if (button.dataset.historyTab) { showHistoryTab(button.dataset.historyTab); return; }
|
||||
if (button.dataset.historyAction) { await handleHistoryAction(button); return; }
|
||||
if (button.dataset.scheduleTemplate) {
|
||||
const zoneId = $('#schedulePresetZone')?.value; if (!zoneId) return toast(tr('schedules.chooseZone'), true);
|
||||
if (!confirm(tr('schedules.replaceConfirm'))) return;
|
||||
try {
|
||||
button.disabled = true;
|
||||
await api(`/api/zones/${encodeURIComponent(zoneId)}/schedule-template`, { method: 'POST', body: { template: button.dataset.scheduleTemplate } });
|
||||
await loadBootstrap(); toast(tr('schedules.templateApplied'));
|
||||
} catch (error) { toast(error.message, true); } finally { button.disabled = false; }
|
||||
return;
|
||||
}
|
||||
const action = button.dataset.action; if (!action) return;
|
||||
if (action === 'open-outdoor-history') { await openOutdoorHistory(); return; }
|
||||
if (action === 'add-cloud-device') {
|
||||
const cloudId = button.dataset.cloudId; if (!cloudId) return;
|
||||
button.disabled = true;
|
||||
try {
|
||||
await api(`/api/integrations/gree-cloud/devices/${encodeURIComponent(cloudId)}/add`, { method: 'POST' });
|
||||
await loadBootstrap();
|
||||
await loadCloudDiscovery({ open: false });
|
||||
toast(tr('devices.cloudAdded'));
|
||||
} catch (error) { toast(error.message, true); }
|
||||
finally { button.disabled = false; }
|
||||
return;
|
||||
}
|
||||
if (action === 'edit-device-group') { await openDeviceGroupsDialog(button.dataset.id || ''); return; }
|
||||
if (action === 'delete-device-group') {
|
||||
const group = (app.deviceGroups || []).find(item => item.id === button.dataset.id);
|
||||
if (!group || !confirm(`${tr('actions.delete')} ${group.name}?`)) return;
|
||||
try {
|
||||
await api(`/api/device-groups/${encodeURIComponent(group.id)}`, { method: 'DELETE' });
|
||||
await loadBootstrap();
|
||||
renderDeviceGroupsDialogList();
|
||||
await populateDeviceGroupForm(null);
|
||||
toast(tr('devices.installationDeleted'));
|
||||
} catch (error) { toast(error.message, true); }
|
||||
return;
|
||||
}
|
||||
const device = app.devices.find(v => v.id === button.dataset.device);
|
||||
if (action === 'toggle-network-jitter') { app.historyNetworkShowJitter = app.historyNetworkShowJitter === false; renderHistoryNavigation(); renderNetworkHistory(); return; }
|
||||
if (action === 'open-ping' && device) { openDevicePing(device.id); return; }
|
||||
if (action === 'ping-toggle') { if (app.pingMonitor.running) stopPingMonitor(); else startPingMonitor(); return; }
|
||||
if (action === 'power' && device) return sendDeviceCommand(device.id, current => ({ power: !current?.power }));
|
||||
if (action === 'temperature' && device) return queueDeviceTemperature(device.id, Number(button.dataset.delta));
|
||||
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, current => ({ [button.dataset.field]: !current?.[button.dataset.field] }));
|
||||
if (action === 'poll' && device) { try { button.disabled = true; updateDevice(await api(`/api/devices/${encodeURIComponent(device.id)}/poll`, { method: 'POST' })); renderAll(); toast(tr('devices.readDone')); } catch (e) { toast(e.message, true); } finally { button.disabled = false; } return; }
|
||||
if (action === 'bind' && device) { try { button.disabled = true; updateDevice(await api(`/api/devices/${encodeURIComponent(device.id)}/bind`, { method: 'POST' })); renderAll(); toast(tr('devices.bound')); } catch (e) { toast(e.message, true); } finally { button.disabled = false; } return; }
|
||||
if (action === 'rename-device' && device) return populateDeviceRename(device.id);
|
||||
if (action === 'energy-config' && device) return openDeviceDetails(device.id);
|
||||
if (action === 'cloud-details' && device) return openDeviceDetails(device.id);
|
||||
if (action === 'cloud-diagnostics' && device) return openCloudDiagnostics(device.id);
|
||||
if (action === 'delete-device') return deleteEntity('devices', button.dataset.device, 'label.device');
|
||||
if (action === 'house-mode') {
|
||||
try {
|
||||
await enqueueClimateControlTask(async () => {
|
||||
await api('/api/house/control', { method: 'POST', body: { mode: button.dataset.value } });
|
||||
await loadBootstrap();
|
||||
});
|
||||
toast(tr('house.modeUpdated'));
|
||||
} catch (error) { toast(error.message, true); }
|
||||
return;
|
||||
}
|
||||
if (action === 'house-power') {
|
||||
const power = button.dataset.value === 'true';
|
||||
if (!power && !confirm(tr('house.powerOffConfirm'))) return;
|
||||
try {
|
||||
button.disabled = true;
|
||||
const result = await enqueueClimateControlTask(async () => {
|
||||
const response = await api('/api/house/power', { method: 'POST', body: { power } });
|
||||
await loadBootstrap();
|
||||
return response;
|
||||
});
|
||||
const failed = Array.isArray(result.failed) ? result.failed.length : 0;
|
||||
if (failed) toast(tr('house.powerPartial', { count: failed }), true);
|
||||
else toast(tr(power ? 'house.powerOnDone' : 'house.powerOffDone'));
|
||||
} catch (error) { toast(error.message, true); }
|
||||
finally { button.disabled = false; }
|
||||
return;
|
||||
}
|
||||
if (action === 'house-preset') {
|
||||
try {
|
||||
const result = await enqueueClimateControlTask(async () => {
|
||||
const response = await api('/api/house/preset', { method: 'POST', body: { preset: button.dataset.value } });
|
||||
await loadBootstrap();
|
||||
return response;
|
||||
});
|
||||
const failed = Array.isArray(result.failed) ? result.failed.length : 0;
|
||||
if (failed) toast(tr('house.powerPartial', { count: failed }), true); else toast(tr('house.presetUpdated'));
|
||||
}
|
||||
catch (error) { toast(error.message, true); }
|
||||
return;
|
||||
}
|
||||
if (action === 'group-power') { const power = button.dataset.value === 'true'; if (!power) delete app.groupCustomDrafts[button.dataset.id]; return sendGroupControl(button.dataset.id, { power }); }
|
||||
if (action === 'group-mode') return sendGroupControl(button.dataset.id, { mode: button.dataset.value });
|
||||
if (action === 'group-preset') { delete app.groupCustomDrafts[button.dataset.id]; return sendGroupControl(button.dataset.id, { preset: button.dataset.value }); }
|
||||
if (action === 'group-custom-open') {
|
||||
const editor = button.closest('.group-card')?.querySelector('[data-group-custom-editor]');
|
||||
const input = editor?.querySelector('[data-group-custom-temperature]');
|
||||
const value = parseDecimal(input?.value);
|
||||
app.groupCustomDrafts[button.dataset.id] = { open: true, value: Number.isFinite(value) ? value : 23 };
|
||||
if (editor) { editor.hidden = false; input?.focus(); input?.select(); }
|
||||
return;
|
||||
}
|
||||
if (action === 'group-custom-cancel') {
|
||||
delete app.groupCustomDrafts[button.dataset.id];
|
||||
renderGroups();
|
||||
return;
|
||||
}
|
||||
if (action === 'group-custom-temperature') {
|
||||
const input = button.closest('.group-card')?.querySelector('[data-group-custom-temperature]');
|
||||
const value = parseDecimal(input?.value);
|
||||
if (!Number.isFinite(value) || value < 8 || value > 30) return toast(tr('groups.customTemperatureRange'), true);
|
||||
return sendGroupControl(button.dataset.id, { preset: 'custom', setpoint: Math.round(value * 10) / 10 });
|
||||
}
|
||||
if (action === 'open-compressor-queue') return openCompressorQueueDialog();
|
||||
if (action === 'cancel-compressor-task') return cancelCompressorTask(button.dataset.id);
|
||||
if (action === 'cancel-all-compressor-tasks') { if (!confirm(tr('zones.queueCancelAllConfirm', { count: app.zones.filter(zone => !!zone.compressor_pending_action).length }))) return; return cancelAllCompressorTasks(); }
|
||||
if (action === 'zone-device-power') return sendZoneLocalPower(button.dataset.id, button.dataset.value === 'true');
|
||||
if (action === 'zone-swing' && device) return sendDeviceCommand(device.id, current => ({ [button.dataset.field]: !current?.[button.dataset.field] }));
|
||||
if (action === 'zone-open-temporary') return populateTemporaryThermostat(button.dataset.id);
|
||||
if (action === 'zone-temperature') { const zone = app.zones.find(v => v.id === button.dataset.id); if (zone) { const base = Number(zone.manual_setpoint ?? zone.effective_setpoint ?? zone.setpoint); queueZoneTemperature(zone, base + Number(button.dataset.delta)); } return; }
|
||||
if (action === 'zone-mode') return sendZoneControl(button.dataset.id, { mode: button.dataset.value });
|
||||
if (action === 'zone-preset') return sendZoneControl(button.dataset.id, { preset: button.dataset.value });
|
||||
if (action === 'zone-enabled') return sendZoneControl(button.dataset.id, { enabled: button.dataset.value === 'true' });
|
||||
if (action === 'zone-resume-automation') {
|
||||
const patch = button.dataset.resume === 'local'
|
||||
? { clear_local_thermostat_override: true }
|
||||
: { clear_device_manual_override: true };
|
||||
return sendZoneControl(button.dataset.id, patch);
|
||||
}
|
||||
if (action === 'zone-go-control') {
|
||||
showView('dashboard', { scroll: false });
|
||||
setDashboardTab('thermostats', { scroll: false });
|
||||
const target = [...document.querySelectorAll('[data-zone-card]')].find(card => card.dataset.zoneCard === button.dataset.id);
|
||||
requestAnimationFrame(() => target?.scrollIntoView({ behavior: 'smooth', block: 'center' }));
|
||||
return;
|
||||
}
|
||||
if (action === 'debug-clear') { app.debugLines = []; renderDebugOverlay(); return; }
|
||||
if (action === 'edit-group') return populateGroup(button.dataset.id);
|
||||
if (action === 'delete-group') return deleteEntity('groups', button.dataset.id, 'groups.group');
|
||||
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', () => {
|
||||
const form = activeDirtySettingsForm();
|
||||
if (form && !confirmDiscardForm(form)) return;
|
||||
loadBootstrap(true);
|
||||
});
|
||||
$('#connectionRetry')?.addEventListener('click', async event => {
|
||||
const button = event.currentTarget;
|
||||
button.disabled = true;
|
||||
try {
|
||||
if (app.ws) { try { app.ws.close(); } catch (_) { } app.ws = null; }
|
||||
await loadBootstrap(false);
|
||||
connectWebSocket();
|
||||
} finally { button.disabled = false; }
|
||||
});
|
||||
$('#discoverButton').addEventListener('click', () => {
|
||||
const form = $('#discoverForm'); form.reset();
|
||||
form.protocol_version.value = '0'; form.passes.value = '3'; form.timeout_ms.value = String(Math.max(6000, Number(app.settings?.discovery_timeout_ms || 3000)));
|
||||
openDialog('discoverDialog');
|
||||
});
|
||||
$('#cloudDiscoverButton')?.addEventListener('click', async event => {
|
||||
const button = event.currentTarget;
|
||||
button.disabled = true;
|
||||
try { await loadCloudDiscovery(); }
|
||||
catch (error) { toast(error.message, true); }
|
||||
finally { button.disabled = false; }
|
||||
});
|
||||
$('#historyRefresh').addEventListener('click', loadHistory);
|
||||
$('#historyHours').addEventListener('change', () => { updateBrowserUrl(currentHistoryPath(), true); loadHistory(); });
|
||||
$('#logsRefresh').addEventListener('click', loadLogs);
|
||||
$('#languageSelect').addEventListener('change', event => setLanguage(event.target.value));
|
||||
$('#themeSelect')?.addEventListener('change', event => setTheme(event.target.value));
|
||||
$('#logLevelFilter')?.addEventListener('change', loadLogs); $('#logCategoryFilter')?.addEventListener('change', loadLogs);
|
||||
$('#settingsForm [name=notifications_provider]')?.addEventListener('change', updateNotificationFields);
|
||||
$('#settingsForm [name=compressor_protection_enabled]')?.addEventListener('change', updateCompressorProtectionFields);
|
||||
$('#settingsForm [name=ping_metrics_enabled]')?.addEventListener('change', updateConnectivityMetricFields);
|
||||
$('#settingsForm [name=gree_cloud_connectivity_metrics_enabled]')?.addEventListener('change', updateConnectivityMetricFields);
|
||||
$('#zoneForm [name=sensor_source]').addEventListener('change', updateZoneSensorFields);
|
||||
document.addEventListener('input', event => {
|
||||
const input = event.target.closest?.('[data-group-custom-temperature]');
|
||||
if (!input) return;
|
||||
const id = input.dataset.groupCustomTemperature;
|
||||
if (!id) return;
|
||||
app.groupCustomDrafts[id] = { open: true, value: input.value };
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', event => {
|
||||
const input = event.target.closest?.('[data-group-custom-temperature]');
|
||||
if (!input || event.key !== 'Enter') return;
|
||||
event.preventDefault();
|
||||
input.closest('.group-card')?.querySelector('[data-action="group-custom-temperature"]')?.click();
|
||||
});
|
||||
|
||||
$('#zoneForm [name=separate_hysteresis]')?.addEventListener('change', () => updateZoneHysteresisFields({ syncFromCommon: true }));
|
||||
$('#scheduleForm [name=preset]').addEventListener('change', updateSchedulePresetField);
|
||||
$('#automationForm [name=action_target_kind]')?.addEventListener('change', updateAutomationTargetFields);
|
||||
$('#simulationScope')?.addEventListener('change', event => { app.simulationScope = event.target.value; app.simulationTarget = 'all'; renderSimulationPage(); updateSimulationUrl(); });
|
||||
$('#simulationTarget')?.addEventListener('change', event => { app.simulationTarget = event.target.value || 'all'; renderSimulationPage(); updateSimulationUrl(); });
|
||||
$('#simulationOpenTab')?.addEventListener('click', () => { const params = new URLSearchParams({ standalone: '1' }); if (app.simulationScope !== 'units') params.set('scope', app.simulationScope); if (app.simulationTarget !== 'all') params.set('target', app.simulationTarget); window.open(`${withBase('/simulation')}?${params.toString()}`, '_blank', 'noopener'); });
|
||||
$('#simulationFullscreen')?.addEventListener('click', async () => { try { if (document.fullscreenElement) await document.exitFullscreen(); else await document.documentElement.requestFullscreen(); } catch (error) { toast(error.message, true); } });
|
||||
document.addEventListener('fullscreenchange', () => { const button = $('#simulationFullscreen'); if (button) button.textContent = tr(document.fullscreenElement ? 'simulation.exitFullscreen' : 'simulation.fullscreen'); });
|
||||
|
||||
$('#testNotifications')?.addEventListener('click', async () => { const f = $('#settingsForm'); const body = { enabled: true, mode: f.notifications_mode.value, provider: f.notifications_provider.value, pushover_app_token: f.pushover_app_token.value.trim(), pushover_user_key: f.pushover_user_key.value.trim(), slack_webhook_url: f.slack_webhook_url.value.trim(), discord_webhook_url: f.discord_webhook_url.value.trim(), cooldown_seconds: Number(f.notification_cooldown_seconds.value || 300), communication_failure_threshold: Number(f.notification_failure_threshold.value || 3), target_timeout_minutes: Number(f.notification_target_timeout.value || 60), alert_types: { stale_sensor: f.notification_alert_stale_sensor.checked, sensor_errors: f.notification_alert_sensor_errors.checked, communication: f.notification_alert_communication.checked, target_timeout: f.notification_alert_target_timeout.checked, automation: f.notification_alert_automation.checked, sensor_discrepancy: f.notification_alert_sensor_discrepancy.checked, control_errors: f.notification_alert_control_errors.checked, important_events: f.notification_alert_important_events.checked, other: f.notification_alert_other.checked } }; try { await api('/api/integrations/notifications/test', { method: 'POST', body }); toast(tr('notifications.testSent')); } catch (e) { toast(e.message, true); } });
|
||||
|
||||
$('#copyZoneSettings')?.addEventListener('click', () => {
|
||||
const f = $('#zoneForm');
|
||||
const source = app.zones.find(zone => zone.id === $('#copyZoneSource')?.value);
|
||||
if (!source) return;
|
||||
const targetId = f.dataset.editingZoneId || '';
|
||||
if (targetId && source.id === targetId) { toast(tr('zones.copyDifferent'), true); return; }
|
||||
|
||||
// Identity and sensor assignment belong to the destination profile and must never
|
||||
// be changed by copying thermostat tuning from another profile.
|
||||
const protectedValues = {
|
||||
id: f.elements.id.value,
|
||||
name: f.elements.name.value,
|
||||
device_id: f.elements.device_id.value,
|
||||
sensor_source: f.elements.sensor_source.value,
|
||||
ha_entity_id: f.elements.ha_entity_id.value,
|
||||
ha_outdoor_entity_id: f.elements.ha_outdoor_entity_id.value,
|
||||
enabled: f.elements.enabled.checked,
|
||||
};
|
||||
const copyFields = ['setpoint', 'cool_comfort_setpoint', 'cool_sleep_setpoint', 'cool_away_setpoint', 'heat_comfort_setpoint', 'heat_sleep_setpoint', 'heat_away_setpoint', 'hysteresis', 'cool_hysteresis', 'heat_hysteresis', 'min_on_seconds', 'min_off_seconds', 'min_adjust_seconds', 'standby_offset_c', 'external_sensor_weight_percent', 'max_sensor_difference', 'sensor_stale_after_seconds'];
|
||||
copyFields.forEach(name => {
|
||||
const input = f.elements[name];
|
||||
if (!input) return;
|
||||
const value = name === 'external_sensor_weight_percent' ? (source.external_sensor_weight * 100) : source[name];
|
||||
if (value !== undefined && value !== null) input.value = String(value);
|
||||
});
|
||||
f.elements.smart_fan.checked = source.smart_fan !== false;
|
||||
f.elements.separate_hysteresis.checked = source.separate_hysteresis === true;
|
||||
updateZoneHysteresisFields();
|
||||
if (f.elements.mode_policy) f.elements.mode_policy.value = source.inherit_house_mode === false ? (source.mode || 'cool') : 'house';
|
||||
|
||||
f.elements.id.value = protectedValues.id;
|
||||
f.elements.name.value = protectedValues.name;
|
||||
f.elements.device_id.value = protectedValues.device_id;
|
||||
f.elements.sensor_source.value = protectedValues.sensor_source;
|
||||
f.elements.ha_entity_id.value = protectedValues.ha_entity_id;
|
||||
f.elements.ha_outdoor_entity_id.value = protectedValues.ha_outdoor_entity_id;
|
||||
f.elements.enabled.checked = protectedValues.enabled;
|
||||
f.dataset.editingZoneId = targetId;
|
||||
updateZoneSensorFields();
|
||||
updateDirtyIndicator(f);
|
||||
toast(tr('zones.copiedFrom', { name: source.name }));
|
||||
});
|
||||
|
||||
$('#applyAutomationPreset')?.addEventListener('click', () => { const f = $('#automationForm'), p = $('#automationPreset')?.value; if (!p) return; const first = app.devices[0]?.id || ''; const presets = { hot: { name: tr('automations.presetHot'), trigger_kind: 'temperature_above', threshold: '28', action_power: 'true', action_mode: 'cool', action_target_temperature: '23', cooldown_seconds: '900' }, cold: { name: tr('automations.presetCold'), trigger_kind: 'temperature_below', threshold: '17', action_power: 'true', action_mode: 'heat', action_target_temperature: '21', cooldown_seconds: '900' }, morning: { name: tr('automations.presetMorning'), trigger_kind: 'time', at_time: '07:00', action_power: 'true', action_mode: 'auto', action_target_temperature: '22', cooldown_seconds: '3600' }, nightoff: { name: tr('automations.presetNightOff'), trigger_kind: 'time', at_time: '23:30', action_power: 'false', action_mode: '', action_target_temperature: '', cooldown_seconds: '3600' } }; const x = presets[p]; f.action_target_kind.value = 'device'; updateAutomationTargetFields(); Object.entries(x).forEach(([k, v]) => { if (f[k]) f[k].value = v }); if (!f.trigger_device_id.value) f.trigger_device_id.value = first; if (!f.action_device_id.value) f.action_device_id.value = first; });
|
||||
|
||||
$('#tokenForm').addEventListener('submit', async event => {
|
||||
event.preventDefault();
|
||||
const form = event.currentTarget;
|
||||
if (!validateForm(form) || form.dataset.submitting === 'true') return;
|
||||
setFormBusy(form, true, 'auth.connecting');
|
||||
app.token = new FormData(form).get('token').trim();
|
||||
localStorage.setItem('gree_controller_token', app.token);
|
||||
if (app.ws) app.ws.close();
|
||||
try { await loadBootstrap(); }
|
||||
finally { setFormBusy(form, false); }
|
||||
});
|
||||
|
||||
$('#discoverForm').addEventListener('submit', async event => {
|
||||
event.preventDefault(); const form = event.currentTarget, raw = Object.fromEntries(new FormData(form));
|
||||
await runFormTask(form, async () => {
|
||||
const result = await api('/api/discovery', { method: 'POST', body: { protocol_version: Number(raw.protocol_version), passes: Number(raw.passes), timeout_ms: Number(raw.timeout_ms) } });
|
||||
form.closest('dialog').close(); await loadBootstrap(); toast(tr('toast.found', { count: result.count })); showDiscoveryNames(result.new_device_ids || []);
|
||||
}, { busyKey: 'actions.discovering' });
|
||||
});
|
||||
|
||||
$('#discoveryNamesForm').addEventListener('submit', async event => {
|
||||
event.preventDefault();
|
||||
const form = event.currentTarget;
|
||||
const inputs = $$('input[data-device-id]', form);
|
||||
await runFormTask(form, async () => {
|
||||
const updated = await Promise.all(inputs.map(input => api(`/api/devices/${encodeURIComponent(input.dataset.deviceId)}`, { method: 'PATCH', body: { name: input.value.trim() } })));
|
||||
updated.forEach(updateDevice);
|
||||
form.closest('dialog').close();
|
||||
renderAll();
|
||||
toast(tr('common.saved'));
|
||||
});
|
||||
});
|
||||
|
||||
$('#renameDeviceForm').addEventListener('submit', async event => {
|
||||
event.preventDefault();
|
||||
const form = event.currentTarget, raw = Object.fromEntries(new FormData(form));
|
||||
const saveMode = event.submitter?.dataset.saveMode || 'save';
|
||||
const previous = app.devices.find(item => item.id === raw.id);
|
||||
const result = $('#deviceConfigCheckResult');
|
||||
if (result) { result.hidden = true; result.textContent = ''; result.classList.remove('success', 'error'); }
|
||||
await runFormTask(form, async () => {
|
||||
const isCloud = previous?.connection_type === 'gree_cloud';
|
||||
const patch = isCloud ? { name: raw.name.trim() } : { name: raw.name.trim(), ip: raw.ip.trim(), port: Number(raw.port), protocol_version: Number(raw.protocol_version) };
|
||||
let device = await api(`/api/devices/${encodeURIComponent(raw.id)}`, { method: 'PATCH', body: patch });
|
||||
updateDevice(device);
|
||||
if (saveMode !== 'check') {
|
||||
form.closest('dialog').close(); renderAll(); toast(tr('common.saved')); return;
|
||||
}
|
||||
try {
|
||||
if (isCloud) {
|
||||
const check = await api('/api/integrations/gree-cloud/test', { method: 'POST' });
|
||||
if (!check.ok) throw new Error(check.message || check.status || tr('settings.cloudConnectionFailed'));
|
||||
if (result) { const message = tr('settings.cloudConnected', { count: Number(check.device_count || 0) }); result.hidden = false; result.classList.add('success'); result.innerHTML = `<span>${esc(tr('status.online'))}</span><strong>${esc(message)}</strong>`; }
|
||||
markFormClean(form); renderAll(); toast(tr('devices.savedAndChecked')); return;
|
||||
}
|
||||
const protocolChanged = previous && Number(previous.protocol_version) !== Number(raw.protocol_version);
|
||||
if (!device.simulated && protocolChanged) {
|
||||
device = await api(`/api/devices/${encodeURIComponent(device.id)}/bind`, { method: 'POST' });
|
||||
updateDevice(device);
|
||||
}
|
||||
let probe;
|
||||
try {
|
||||
probe = await api(`/api/devices/${encodeURIComponent(device.id)}/probe`, { method: 'POST' });
|
||||
} catch (firstError) {
|
||||
if (device.simulated || protocolChanged) throw firstError;
|
||||
device = await api(`/api/devices/${encodeURIComponent(device.id)}/bind`, { method: 'POST' });
|
||||
updateDevice(device);
|
||||
probe = await api(`/api/devices/${encodeURIComponent(device.id)}/probe`, { method: 'POST' });
|
||||
}
|
||||
if (result) {
|
||||
result.hidden = false; result.classList.add('success');
|
||||
const message = tr('devices.connectionCheckOk', { ms: Math.round(Number(probe.response_time_ms || 0)) });
|
||||
result.innerHTML = `<span>${esc(tr('status.online'))}</span><strong>${esc(message)}</strong>`;
|
||||
}
|
||||
markFormClean(form);
|
||||
renderAll();
|
||||
toast(tr('devices.savedAndChecked'));
|
||||
} catch (error) {
|
||||
if (result) {
|
||||
result.hidden = false; result.classList.add('error');
|
||||
const message = tr('devices.connectionCheckFailed', { error: error.message || String(error) });
|
||||
result.innerHTML = `<span>${esc(tr('devices.lastError'))}</span><strong>${esc(message)}</strong>`;
|
||||
}
|
||||
markFormClean(form);
|
||||
renderAll();
|
||||
toast(tr('devices.savedCheckFailed'), true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
$('#deviceDetailsForm')?.addEventListener('submit', async event => {
|
||||
event.preventDefault();
|
||||
const form = event.currentTarget;
|
||||
const raw = Object.fromEntries(new FormData(form));
|
||||
const deviceId = form.elements.id.value;
|
||||
const installation = deviceInstallationForDevice(deviceId);
|
||||
const groupOwnsEnergy = !!installation && (!!installation.energy_device_id || !!installation.ha_energy_entity_id);
|
||||
const patch = { name: String(raw.name || '').trim() };
|
||||
if (!groupOwnsEnergy) {
|
||||
const option = form.ha_energy_entity_id.selectedOptions?.[0];
|
||||
Object.assign(patch, {
|
||||
energy_source: raw.energy_source || 'auto',
|
||||
ha_energy_entity_id: raw.ha_energy_entity_id || null,
|
||||
ha_energy_unit: option?.value ? (option.dataset.unit || null) : null,
|
||||
ha_energy_device_class: option?.value ? (option.dataset.deviceClass || null) : null,
|
||||
ha_energy_state_class: option?.value ? (option.dataset.stateClass || null) : null,
|
||||
});
|
||||
}
|
||||
await runFormTask(form, async () => {
|
||||
const device = await api(`/api/devices/${encodeURIComponent(deviceId)}`, { method: 'PATCH', body: patch });
|
||||
updateDevice(device);
|
||||
form.closest('dialog').close();
|
||||
renderAll();
|
||||
toast(tr('common.saved'));
|
||||
});
|
||||
});
|
||||
|
||||
$('#deviceDetailsForm')?.ha_energy_entity_id?.addEventListener('change', updateDeviceEnergySensorMeta);
|
||||
|
||||
|
||||
$('#deviceGroupsButton')?.addEventListener('click', () => openDeviceGroupsDialog());
|
||||
$('#deviceGroupNew')?.addEventListener('click', () => populateDeviceGroupForm(null));
|
||||
$('#deviceGroupForm')?.addEventListener('change', event => {
|
||||
const form = event.currentTarget;
|
||||
if (event.target.name === 'kind' && form.kind.value === 'split') {
|
||||
const checked = $$('#deviceGroupDeviceChoices input[name="device_ids"]:checked');
|
||||
checked.slice(1).forEach(input => { input.checked = false; });
|
||||
syncDeviceGroupMemberSelects();
|
||||
}
|
||||
if (event.target.name === 'device_ids') {
|
||||
if (form.kind.value === 'split' && event.target.checked) {
|
||||
$$('#deviceGroupDeviceChoices input[name="device_ids"]:checked').forEach(input => { if (input !== event.target) input.checked = false; });
|
||||
}
|
||||
syncDeviceGroupMemberSelects();
|
||||
}
|
||||
if (event.target.name === 'ha_energy_entity_id') updateDeviceGroupEnergySensorMeta();
|
||||
});
|
||||
$('#deviceGroupForm')?.addEventListener('submit', async event => {
|
||||
event.preventDefault();
|
||||
const form = event.currentTarget;
|
||||
const ids = selectedDeviceGroupIds();
|
||||
if (!ids.length) return showFormError(form, tr('energy.selectAtLeastOne'));
|
||||
const option = form.ha_energy_entity_id.selectedOptions?.[0];
|
||||
const body = {
|
||||
name: form.name.value.trim(),
|
||||
kind: form.kind.value || 'split',
|
||||
device_ids: ids,
|
||||
energy_source: form.energy_source.value || 'auto',
|
||||
energy_device_id: form.energy_device_id.value || null,
|
||||
ha_energy_entity_id: form.ha_energy_entity_id.value || null,
|
||||
ha_energy_unit: option?.value ? (option.dataset.unit || null) : null,
|
||||
ha_energy_device_class: option?.value ? (option.dataset.deviceClass || null) : null,
|
||||
ha_energy_state_class: option?.value ? (option.dataset.stateClass || null) : null,
|
||||
outdoor_temperature_device_id: form.outdoor_temperature_device_id.value || null,
|
||||
};
|
||||
const id = form.elements.id.value;
|
||||
await runFormTask(form, async () => {
|
||||
await api(id ? `/api/device-groups/${encodeURIComponent(id)}` : '/api/device-groups', { method: id ? 'PUT' : 'POST', body });
|
||||
await loadBootstrap();
|
||||
renderDeviceGroupsDialogList();
|
||||
await populateDeviceGroupForm(null);
|
||||
toast(tr('devices.installationSaved'));
|
||||
});
|
||||
});
|
||||
|
||||
$('#cloudDiagnosticsRefresh')?.addEventListener('click', () => {
|
||||
const id = $('#cloudDiagnosticsDialog')?.dataset.deviceId;
|
||||
if (id) openCloudDiagnostics(id);
|
||||
});
|
||||
|
||||
$('#pingDeviceSelect')?.addEventListener('change', event => {
|
||||
app.pingMonitor.targetId = event.target.value;
|
||||
renderPingDialog();
|
||||
if (app.pingMonitor.running) schedulePingCycle(0);
|
||||
});
|
||||
$('#pingAllDevices')?.addEventListener('change', event => {
|
||||
app.pingMonitor.all = event.target.checked;
|
||||
renderPingDialog();
|
||||
if (app.pingMonitor.running) schedulePingCycle(0);
|
||||
});
|
||||
$('#pingDialog')?.addEventListener('close', () => stopPingMonitor());
|
||||
|
||||
$('#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;
|
||||
await runFormTask(form, async () => {
|
||||
await api('/api/devices', { method: 'POST', body: data }); form.closest('dialog').close(); form.reset(); await loadBootstrap(); toast(tr('devices.added'));
|
||||
});
|
||||
});
|
||||
|
||||
$('#zoneForm').addEventListener('submit', async event => {
|
||||
event.preventDefault(); const form = event.currentTarget, raw = Object.fromEntries(new FormData(form));
|
||||
const id = form.dataset.editingZoneId || raw.id; const body = {
|
||||
name: raw.name, device_id: raw.device_id, enabled: form.enabled.checked,
|
||||
mode: raw.mode_policy === 'house' ? 'cool' : raw.mode_policy, inherit_house_mode: raw.mode_policy === 'house', setpoint: parseDecimal(raw.setpoint),
|
||||
cool_comfort_setpoint: parseDecimal(raw.cool_comfort_setpoint), cool_sleep_setpoint: parseDecimal(raw.cool_sleep_setpoint), cool_away_setpoint: parseDecimal(raw.cool_away_setpoint),
|
||||
heat_comfort_setpoint: parseDecimal(raw.heat_comfort_setpoint), heat_sleep_setpoint: parseDecimal(raw.heat_sleep_setpoint), heat_away_setpoint: parseDecimal(raw.heat_away_setpoint),
|
||||
hysteresis: parseDecimal(raw.hysteresis), separate_hysteresis: form.separate_hysteresis.checked, cool_hysteresis: parseDecimal(raw.cool_hysteresis), heat_hysteresis: parseDecimal(raw.heat_hysteresis), min_on_seconds: Number(raw.min_on_seconds), min_off_seconds: Number(raw.min_off_seconds),
|
||||
min_adjust_seconds: Number(raw.min_adjust_seconds), standby_offset_c: parseDecimal(raw.standby_offset_c), smart_fan: form.smart_fan.checked,
|
||||
sensor_source: raw.sensor_source, ha_entity_id: raw.ha_entity_id || null, ha_outdoor_entity_id: raw.ha_outdoor_entity_id || null, external_sensor_weight: Number(raw.external_sensor_weight_percent) / 100, max_sensor_difference: parseDecimal(raw.max_sensor_difference), sensor_stale_after_seconds: Number(raw.sensor_stale_after_seconds || 300), revision: raw.revision ? Number(raw.revision) : null
|
||||
};
|
||||
await runFormTask(form, async () => {
|
||||
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'));
|
||||
});
|
||||
});
|
||||
|
||||
$('#temporaryFinishKind')?.addEventListener('change', updateTemporaryThermostatFields);
|
||||
$('#temporaryStartKind')?.addEventListener('change', updateTemporaryThermostatFields);
|
||||
$$('[data-temporary-start-delay]').forEach(button => button.addEventListener('click', () => {
|
||||
const form = $('#temporaryThermostatForm');
|
||||
form.start_delay_minutes.value = button.dataset.temporaryStartDelay;
|
||||
}));
|
||||
$$('[data-temporary-duration]').forEach(button => button.addEventListener('click', () => {
|
||||
const form = $('#temporaryThermostatForm');
|
||||
form.duration_minutes.value = button.dataset.temporaryDuration;
|
||||
}));
|
||||
|
||||
$('#temporaryThermostatStop')?.addEventListener('click', async event => {
|
||||
const id = event.currentTarget.dataset.id; if (!id) return;
|
||||
try {
|
||||
event.currentTarget.disabled = true;
|
||||
const zone = await enqueueZoneControlRequest(id, { clear_temporary_quick_thermostat: true });
|
||||
const index = app.zones.findIndex(item => item.id === zone.id); if (index >= 0) app.zones[index] = zone;
|
||||
$('#temporaryThermostatDialog').close(); renderAll(); scheduleControlPlanLoad(); toast(tr('zones.temporaryStopped'));
|
||||
} catch (error) { toast(error.message, true); }
|
||||
finally { event.currentTarget.disabled = false; }
|
||||
});
|
||||
|
||||
$('#temporaryThermostatForm')?.addEventListener('submit', async event => {
|
||||
event.preventDefault();
|
||||
const form = event.currentTarget, raw = Object.fromEntries(new FormData(form));
|
||||
const activeSession = form.dataset.activeSession === 'true';
|
||||
const zone = app.zones.find(item => item.id === raw.zone_id);
|
||||
const existingSession = zone?.temporary_quick_thermostat;
|
||||
const startKind = activeSession ? (existingSession?.start_kind || 'now') : raw.start_kind;
|
||||
const request = { start_kind: startKind, finish_kind: raw.finish_kind, target_temperature: parseDecimal(raw.target_temperature) };
|
||||
if (!activeSession && startKind === 'delay') request.start_delay_minutes = Number(raw.start_delay_minutes);
|
||||
if (!activeSession && startKind === 'at') {
|
||||
const startAt = new Date(raw.start_at);
|
||||
if (!Number.isFinite(startAt.getTime())) return toast(tr('zones.temporaryInvalidStartAt'), true);
|
||||
request.start_at = startAt.toISOString();
|
||||
}
|
||||
if (raw.finish_kind === 'duration') request.duration_minutes = Number(raw.duration_minutes);
|
||||
if (raw.finish_kind === 'until') {
|
||||
const until = new Date(raw.until);
|
||||
if (!Number.isFinite(until.getTime())) return toast(tr('zones.temporaryInvalidUntil'), true);
|
||||
const effectiveStart = activeSession ? new Date()
|
||||
: startKind === 'delay' ? new Date(Date.now() + Number(raw.start_delay_minutes) * 60000)
|
||||
: startKind === 'at' ? new Date(raw.start_at) : new Date();
|
||||
if (Number.isFinite(effectiveStart.getTime()) && until <= effectiveStart) return toast(tr('zones.temporaryEndAfterStart'), true);
|
||||
request.until = until.toISOString();
|
||||
}
|
||||
if (['temperature_reached', 'temperature_stable'].includes(raw.finish_kind)) {
|
||||
request.temperature_operator = raw.temperature_operator;
|
||||
request.tolerance_c = parseDecimal(raw.tolerance_c);
|
||||
if (raw.finish_kind === 'temperature_stable') request.hold_minutes = Number(raw.hold_minutes);
|
||||
const safety = Number(raw.max_duration_minutes);
|
||||
if (Number.isFinite(safety) && safety > 0) request.max_duration_minutes = safety;
|
||||
}
|
||||
await runFormTask(form, async () => {
|
||||
const updatedZone = await enqueueZoneControlRequest(raw.zone_id, { temporary_quick_thermostat: request });
|
||||
const index = app.zones.findIndex(item => item.id === updatedZone.id); if (index >= 0) app.zones[index] = updatedZone;
|
||||
form.closest('dialog').close(); renderAll(); scheduleControlPlanLoad(); toast(tr(activeSession || startKind === 'now' ? 'zones.temporaryStarted' : 'zones.temporaryScheduledToast'));
|
||||
}, { busyKey: activeSession ? 'actions.saving' : 'zones.temporaryStarting' });
|
||||
});
|
||||
|
||||
$('#groupForm')?.addEventListener('submit', async event => {
|
||||
event.preventDefault();
|
||||
const form = event.currentTarget, raw = Object.fromEntries(new FormData(form));
|
||||
const id = raw.id || '';
|
||||
const zone_ids = $$('input[name=zone_ids]:checked', form).map(input => input.value);
|
||||
if (!zone_ids.length) return toast(tr('groups.chooseMember'), true);
|
||||
const existing = id ? app.groups.find(group => group.id === id) : null;
|
||||
const body = { name: raw.name.trim(), zone_ids, power_enabled: existing?.power_enabled !== false };
|
||||
await runFormTask(form, async () => {
|
||||
await api(id ? `/api/groups/${encodeURIComponent(id)}` : '/api/groups', { method: id ? 'PUT' : 'POST', body });
|
||||
form.closest('dialog').close(); form.reset(); await loadBootstrap(); toast(tr('common.saved'));
|
||||
});
|
||||
});
|
||||
|
||||
$('#scheduleForm').addEventListener('submit', async event => {
|
||||
event.preventDefault(); const form = event.currentTarget, raw = Object.fromEntries(new FormData(form));
|
||||
const id = raw.id, weekdays = $$('[name=weekday]:checked', form).map(v => Number(v.value));
|
||||
const body = { name: raw.name, zone_id: raw.zone_id, enabled: form.enabled.checked, weekdays, start_time: raw.start_time, end_time: raw.end_time, preset: raw.preset, setpoint: parseDecimal(raw.setpoint || 23) };
|
||||
await runFormTask(form, async () => {
|
||||
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'));
|
||||
});
|
||||
});
|
||||
|
||||
$('#automationForm').addEventListener('submit', async event => {
|
||||
event.preventDefault(); const form = event.currentTarget, raw = Object.fromEntries(new FormData(form)); const id = raw.id;
|
||||
const groupTarget = raw.action_target_kind === 'group';
|
||||
const action = {}; if (raw.action_power !== '') action.power = raw.action_power === 'true'; if (raw.action_mode) action.mode = raw.action_mode; if (!groupTarget && raw.action_target_temperature !== '') action.target_temperature = parseDecimal(raw.action_target_temperature); if (!groupTarget && raw.action_swing_vertical !== '') action.swing_vertical = raw.action_swing_vertical === 'true'; if (!groupTarget && raw.action_swing_horizontal !== '') action.swing_horizontal = raw.action_swing_horizontal === 'true';
|
||||
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 : parseDecimal(raw.threshold), at_time: raw.at_time || null, action_device_id: groupTarget ? '' : raw.action_device_id, action_group_id: groupTarget ? (raw.action_group_id || null) : null, action_preset: groupTarget ? (raw.action_preset || null) : null, action, cooldown_seconds: Number(raw.cooldown_seconds) };
|
||||
await runFormTask(form, async () => {
|
||||
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'));
|
||||
});
|
||||
});
|
||||
|
||||
$('#greeCloudReconnectButton')?.addEventListener('click', async event => {
|
||||
const button = event.currentTarget;
|
||||
try {
|
||||
button.disabled = true;
|
||||
await api('/api/integrations/gree-cloud/reconnect', { method: 'POST' });
|
||||
await refreshGreeCloudRuntimeStatus({ force: true });
|
||||
toast(tr('common.saved'));
|
||||
} catch (error) {
|
||||
toast(error.message, true);
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
});
|
||||
-1262
File diff suppressed because it is too large
Load Diff
-322
@@ -1,322 +0,0 @@
|
||||
function toast(message, error = false) {
|
||||
const host = $('#toastStack'); if (!host) return;
|
||||
const item = document.createElement('div');
|
||||
item.className = `toast-item ${error ? 'error' : 'success'}`;
|
||||
item.innerHTML = `<span class="toast-icon">${error ? '!' : uiIcon('check')}</span><div class="toast-copy"><strong>${esc(error ? tr('toast.errorTitle') : tr('toast.successTitle'))}</strong><span>${esc(message)}</span></div><button class="toast-close" type="button" aria-label="${esc(tr('actions.close'))}">${uiIcon('close')}</button><i class="toast-progress"></i>`;
|
||||
host.appendChild(item);
|
||||
requestAnimationFrame(() => item.classList.add('show'));
|
||||
const remove = () => { item.classList.remove('show'); item.classList.add('leaving'); setTimeout(() => item.remove(), 220); };
|
||||
item.querySelector('.toast-close').addEventListener('click', remove);
|
||||
item._timer = setTimeout(remove, error ? 5200 : 3600);
|
||||
}
|
||||
|
||||
const cleanFormSnapshots = new WeakMap();
|
||||
|
||||
function formSnapshot(form) {
|
||||
if (!form) return '';
|
||||
const rows = [];
|
||||
$$('input, select, textarea', form).forEach((field, index) => {
|
||||
if (field.type === 'submit' || field.type === 'button' || field.type === 'file') return;
|
||||
const key = field.name || field.id || field.dataset.sensorAlias || `field-${index}`;
|
||||
const value = (field.type === 'checkbox' || field.type === 'radio') ? String(field.checked) : String(field.value ?? '');
|
||||
rows.push([key, value, String(field.disabled)]);
|
||||
});
|
||||
return JSON.stringify(rows);
|
||||
}
|
||||
|
||||
function markFormClean(form) {
|
||||
if (!form) return;
|
||||
cleanFormSnapshots.set(form, formSnapshot(form));
|
||||
form.classList.remove('has-unsaved-changes');
|
||||
}
|
||||
|
||||
function updateDirtyIndicator(form) {
|
||||
if (!form || !cleanFormSnapshots.has(form)) return;
|
||||
form.classList.toggle('has-unsaved-changes', isFormDirty(form));
|
||||
}
|
||||
|
||||
function isFormDirty(form) {
|
||||
if (!form || !cleanFormSnapshots.has(form)) return false;
|
||||
return cleanFormSnapshots.get(form) !== formSnapshot(form);
|
||||
}
|
||||
|
||||
function clearFormErrors(form) {
|
||||
if (!form) return;
|
||||
$$('.field-error, .form-error-summary', form).forEach(node => node.remove());
|
||||
$$('[aria-invalid="true"]', form).forEach(field => field.removeAttribute('aria-invalid'));
|
||||
$$('.has-error', form).forEach(node => node.classList.remove('has-error'));
|
||||
}
|
||||
|
||||
function showFieldError(field, message) {
|
||||
if (!field || !message) return;
|
||||
field.setAttribute('aria-invalid', 'true');
|
||||
const label = field.closest('label');
|
||||
if (label) label.classList.add('has-error');
|
||||
const existing = field.nextElementSibling?.classList?.contains('field-error') ? field.nextElementSibling : null;
|
||||
if (existing) { existing.textContent = message; return; }
|
||||
const note = document.createElement('small');
|
||||
note.className = 'field-error';
|
||||
note.textContent = message;
|
||||
field.insertAdjacentElement('afterend', note);
|
||||
}
|
||||
|
||||
function showFormError(form, message) {
|
||||
if (!form || !message) return;
|
||||
const summary = document.createElement('div');
|
||||
summary.className = 'inline-alert error form-error-summary';
|
||||
summary.setAttribute('role', 'alert');
|
||||
summary.innerHTML = `<span>${esc(tr('devices.lastError'))}</span><strong>${esc(message)}</strong>`;
|
||||
const anchor = $('.sticky-form-actions', form) || $('.form-actions', form);
|
||||
if (anchor?.parentElement) anchor.parentElement.insertBefore(summary, anchor);
|
||||
else form.appendChild(summary);
|
||||
}
|
||||
|
||||
function validationMessageFor(field) {
|
||||
if (field.validity?.valueMissing) return tr('validation.required');
|
||||
if (field.validity?.typeMismatch) return field.type === 'url' ? tr('validation.url') : tr('validation.invalid');
|
||||
if (field.validity?.rangeUnderflow || field.validity?.rangeOverflow) {
|
||||
return tr('validation.range', { min: field.min || '—', max: field.max || '—' });
|
||||
}
|
||||
if (field.validity?.badInput || field.validity?.stepMismatch || field.validity?.patternMismatch) return tr('validation.invalid');
|
||||
return field.validity?.valid === false ? tr('validation.invalid') : '';
|
||||
}
|
||||
|
||||
function httpUrlValid(value) {
|
||||
if (!String(value || '').trim()) return true;
|
||||
try { return ['http:', 'https:'].includes(new URL(value).protocol); } catch (_) { return false; }
|
||||
}
|
||||
|
||||
function entityIdValid(value) {
|
||||
return !String(value || '').trim() || /^[a-z0-9_]+\.[a-z0-9_]+$/i.test(String(value).trim());
|
||||
}
|
||||
|
||||
function validateDecimalField(form, name, min, max) {
|
||||
const field = form?.elements?.[name];
|
||||
if (!field || field.disabled) return true;
|
||||
const value = parseDecimal(field.value);
|
||||
if (!Number.isFinite(value) || value < min || value > max) {
|
||||
showFieldError(field, tr('validation.range', { min, max }));
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function validateForm(form) {
|
||||
if (!form) return true;
|
||||
clearFormErrors(form);
|
||||
let valid = true;
|
||||
$$('input, select, textarea', form).forEach(field => {
|
||||
if (field.disabled || field.type === 'hidden' || field.type === 'file') return;
|
||||
const message = validationMessageFor(field);
|
||||
if (message) { showFieldError(field, message); valid = false; }
|
||||
});
|
||||
|
||||
if (form.id === 'homeAssistantForm') {
|
||||
const url = form.elements.ha_url;
|
||||
if (url?.value && !httpUrlValid(url.value)) { showFieldError(url, tr('validation.url')); valid = false; }
|
||||
['ha_outdoor_entity_id'].forEach(name => {
|
||||
const field = form.elements[name];
|
||||
if (field?.value && !entityIdValid(field.value)) { showFieldError(field, tr('validation.entityId')); valid = false; }
|
||||
});
|
||||
}
|
||||
|
||||
if (form.id === 'settingsForm' && form.elements.influx_enabled?.checked) {
|
||||
const url = form.elements.influx_url;
|
||||
if (!url?.value?.trim()) { showFieldError(url, tr('validation.required')); valid = false; }
|
||||
else if (!httpUrlValid(url.value)) { showFieldError(url, tr('validation.url')); valid = false; }
|
||||
}
|
||||
|
||||
if (form.id === 'temporaryThermostatForm') {
|
||||
if (!validateDecimalField(form, 'target_temperature', 8, 30)) valid = false;
|
||||
}
|
||||
|
||||
if (form.id === 'zoneForm') {
|
||||
const source = form.elements.sensor_source?.value;
|
||||
const entity = form.elements.ha_entity_id;
|
||||
const configuredEntity = entity?.value?.trim() || '';
|
||||
if (source !== 'device' && !configuredEntity) {
|
||||
showFieldError(entity, tr('zones.sensorRequired'));
|
||||
valid = false;
|
||||
} else if (source !== 'device' && !entityIdValid(configuredEntity)) {
|
||||
showFieldError(entity, tr('validation.entityId'));
|
||||
valid = false;
|
||||
}
|
||||
const outdoorEntity = form.elements.ha_outdoor_entity_id;
|
||||
const configuredOutdoorEntity = outdoorEntity?.value?.trim() || '';
|
||||
if (configuredOutdoorEntity && !entityIdValid(configuredOutdoorEntity)) {
|
||||
showFieldError(outdoorEntity, tr('validation.entityId'));
|
||||
valid = false;
|
||||
}
|
||||
const zoneDecimalFields = [
|
||||
['cool_comfort_setpoint', 8, 30], ['cool_sleep_setpoint', 8, 30], ['cool_away_setpoint', 8, 30],
|
||||
['heat_comfort_setpoint', 8, 30], ['heat_sleep_setpoint', 8, 30], ['heat_away_setpoint', 8, 30],
|
||||
['max_sensor_difference', 0.1, 20], ['standby_offset_c', 0.5, 8],
|
||||
...(form.elements.separate_hysteresis?.checked
|
||||
? [['cool_hysteresis', 0.1, 5], ['heat_hysteresis', 0.1, 5]]
|
||||
: [['hysteresis', 0.1, 5]])
|
||||
];
|
||||
zoneDecimalFields.forEach(([name, min, max]) => { if (!validateDecimalField(form, name, min, max)) valid = false; });
|
||||
}
|
||||
|
||||
if (!valid) {
|
||||
showFormError(form, tr('validation.fixFields'));
|
||||
const first = $('[aria-invalid="true"]', form);
|
||||
const pane = first?.closest?.('[data-settings-pane]');
|
||||
if (pane?.dataset.settingsPane) setSettingsTab(pane.dataset.settingsPane);
|
||||
first?.focus({ preventScroll: true });
|
||||
first?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
return valid;
|
||||
}
|
||||
|
||||
function apiErrorField(form, message) {
|
||||
const text = String(message || '').toLowerCase();
|
||||
const candidates = [];
|
||||
if (text.includes('influx')) candidates.push('influx_url');
|
||||
if (text.includes('home assistant') || text.includes('ha ')) candidates.push('ha_url');
|
||||
if (text.includes('entity')) candidates.push('ha_entity_id', 'ha_outdoor_entity_id');
|
||||
if (text.includes('device')) candidates.push('device_id', 'action_device_id', 'trigger_device_id');
|
||||
if (text.includes('temperature') || text.includes('setpoint')) candidates.push('target_temperature', 'cool_comfort_setpoint', 'action_target_temperature');
|
||||
if (text.includes('name')) candidates.push('name', 'controller_id');
|
||||
if (text.includes('token')) candidates.push('ha_token', 'influx_token', 'pushover_app_token');
|
||||
if (text.includes('url')) candidates.push('ha_url', 'influx_url', 'slack_webhook_url', 'discord_webhook_url');
|
||||
return candidates.map(name => form?.elements?.[name]).find(Boolean) || null;
|
||||
}
|
||||
|
||||
function presentFormError(form, error) {
|
||||
clearFormErrors(form);
|
||||
const message = error?.message || tr('validation.invalid');
|
||||
const field = apiErrorField(form, message);
|
||||
if (field) showFieldError(field, message);
|
||||
showFormError(form, message);
|
||||
if (field) {
|
||||
const pane = field.closest?.('[data-settings-pane]');
|
||||
if (pane?.dataset.settingsPane) setSettingsTab(pane.dataset.settingsPane);
|
||||
field.focus({ preventScroll: true });
|
||||
field.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
toast(message, true);
|
||||
}
|
||||
|
||||
function setFormBusy(form, busy, busyKey = 'actions.saving') {
|
||||
if (!form) return;
|
||||
const buttons = $$('button[type="submit"]', form);
|
||||
if (busy) {
|
||||
form.dataset.submitting = 'true';
|
||||
form.setAttribute('aria-busy', 'true');
|
||||
form.classList.add('is-saving');
|
||||
buttons.forEach(button => {
|
||||
if (!button.dataset.idleHtml) button.dataset.idleHtml = button.innerHTML;
|
||||
button.disabled = true;
|
||||
button.innerHTML = `<span class="button-spinner" aria-hidden="true"></span><span>${esc(tr(busyKey))}</span>`;
|
||||
});
|
||||
} else {
|
||||
delete form.dataset.submitting;
|
||||
form.removeAttribute('aria-busy');
|
||||
form.classList.remove('is-saving');
|
||||
buttons.forEach(button => {
|
||||
button.disabled = false;
|
||||
if (button.dataset.idleHtml) { button.innerHTML = button.dataset.idleHtml; delete button.dataset.idleHtml; }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function runFormTask(form, task, { busyKey = 'actions.saving', validate = true } = {}) {
|
||||
if (!form || form.dataset.submitting === 'true') return { ok: false, duplicate: true };
|
||||
if (validate && !validateForm(form)) return { ok: false, validation: true };
|
||||
clearFormErrors(form);
|
||||
setFormBusy(form, true, busyKey);
|
||||
try {
|
||||
const value = await task();
|
||||
markFormClean(form);
|
||||
return { ok: true, value };
|
||||
} catch (error) {
|
||||
presentFormError(form, error);
|
||||
return { ok: false, error };
|
||||
} finally {
|
||||
setFormBusy(form, false);
|
||||
}
|
||||
}
|
||||
|
||||
function restoreTrackedForm(form) {
|
||||
if (!form) return;
|
||||
if (form.id === 'settingsForm') renderSettings();
|
||||
else if (form.id === 'nightModeForm') renderNightSettings();
|
||||
else if (form.id === 'homeAssistantForm') {
|
||||
app.sensorAliases = { ...(app.settings?.home_assistant?.sensor_aliases || {}) };
|
||||
app.flowSharedInputs = JSON.parse(JSON.stringify(app.settings?.home_assistant?.flow_inputs || []));
|
||||
renderHomeAssistantSettings();
|
||||
} else markFormClean(form);
|
||||
}
|
||||
|
||||
function confirmDiscardForm(form) {
|
||||
if (!isFormDirty(form)) return true;
|
||||
if (!confirm(tr('confirm.discardChanges'))) return false;
|
||||
restoreTrackedForm(form);
|
||||
return true;
|
||||
}
|
||||
|
||||
function activeDirtySettingsForm() {
|
||||
const active = $('.view.active');
|
||||
if (!active) return null;
|
||||
const form = $('form.config-form', active);
|
||||
return form && isFormDirty(form) ? form : null;
|
||||
}
|
||||
|
||||
function canLeaveCurrentView(nextName) {
|
||||
if (nextName === app.currentView) return true;
|
||||
const form = activeDirtySettingsForm();
|
||||
return !form || confirmDiscardForm(form);
|
||||
}
|
||||
|
||||
function requestDialogClose(dialog) {
|
||||
if (!dialog) return true;
|
||||
const form = $('form', dialog);
|
||||
if (form && !confirmDiscardForm(form)) return false;
|
||||
closeChartPreview(dialog.querySelector?.('.chart-card.chart-fullscreen-fallback'));
|
||||
dialog.close();
|
||||
return true;
|
||||
}
|
||||
|
||||
function setupFormUx() {
|
||||
$$('form').forEach(form => {
|
||||
form.noValidate = true;
|
||||
if (!cleanFormSnapshots.has(form)) markFormClean(form);
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('input', event => {
|
||||
const field = event.target.closest?.('input, select, textarea');
|
||||
const form = field?.form;
|
||||
if (!field || !form) return;
|
||||
if (field.getAttribute('aria-invalid') === 'true') {
|
||||
field.removeAttribute('aria-invalid');
|
||||
field.closest('label')?.classList.remove('has-error');
|
||||
if (field.nextElementSibling?.classList?.contains('field-error')) field.nextElementSibling.remove();
|
||||
$('.form-error-summary', form)?.remove();
|
||||
}
|
||||
updateDirtyIndicator(form);
|
||||
});
|
||||
document.addEventListener('change', event => updateDirtyIndicator(event.target?.form));
|
||||
|
||||
window.addEventListener('beforeunload', event => {
|
||||
const dirty = activeDirtySettingsForm() || $$('dialog[open] form').some(form => isFormDirty(form));
|
||||
if (!dirty) return;
|
||||
event.preventDefault();
|
||||
event.returnValue = '';
|
||||
});
|
||||
|
||||
document.addEventListener('cancel', event => {
|
||||
const dialog = event.target.closest?.('dialog');
|
||||
if (!dialog) return;
|
||||
const form = $('form', dialog);
|
||||
if (!form || !isFormDirty(form)) return;
|
||||
event.preventDefault();
|
||||
if (confirmDiscardForm(form)) dialog.close();
|
||||
}, true);
|
||||
|
||||
function showTokenDialog() {
|
||||
const dialog = $('#tokenDialog');
|
||||
if (!dialog.open) dialog.showModal();
|
||||
}
|
||||
|
||||
@@ -1,472 +0,0 @@
|
||||
function outdoorHistorySeries(devices, rows) {
|
||||
const selectedIds = new Set((devices || []).map(device => device.id));
|
||||
const handled = new Set();
|
||||
const definitions = [];
|
||||
|
||||
(app.deviceGroups || []).forEach(group => {
|
||||
if (!group.outdoor_temperature_device_id) return;
|
||||
const members = (group.device_ids || []).filter(id => selectedIds.has(id));
|
||||
if (!members.length) return;
|
||||
const representative = members.includes(group.outdoor_temperature_device_id) ? group.outdoor_temperature_device_id : members[0];
|
||||
if (!rows.some(row => row.device_id === representative && Number.isFinite(historyNumber(row.outdoor_temperature)))) return;
|
||||
members.forEach(id => handled.add(id));
|
||||
definitions.push({ representative, label: `${group.name} · ${tr('history.sharedOutdoor')}` });
|
||||
});
|
||||
|
||||
(devices || []).filter(device => !handled.has(device.id)).forEach(device => {
|
||||
if (rows.some(row => row.device_id === device.id && Number.isFinite(historyNumber(row.outdoor_temperature)))) {
|
||||
definitions.push({ representative: device.id, label: device.name });
|
||||
}
|
||||
});
|
||||
|
||||
return definitions.map((item, index) => ({
|
||||
label: item.label,
|
||||
color: historySeriesColor(index),
|
||||
value: row => row.device_id === item.representative ? historyNumber(row.outdoor_temperature) : NaN,
|
||||
}));
|
||||
}
|
||||
|
||||
async function openOutdoorHistory() {
|
||||
const host = $('#outdoorHistoryChartHost');
|
||||
const current = $('#outdoorHistoryCurrent');
|
||||
if (!host || !current) return;
|
||||
current.textContent = app.outdoorTemperature == null ? tr('common.unavailable') : fmtTemp(app.outdoorTemperature);
|
||||
host.innerHTML = `<div class="panel outdoor-history-loading">${esc(tr('common.loading'))}</div>`;
|
||||
$$('#outdoorHistoryDialog [data-history-route]').forEach(link => {
|
||||
const hours = link.dataset.historyHours;
|
||||
link.href = withBase(`/history/overview${hours ? `?hours=${encodeURIComponent(hours)}` : ''}`);
|
||||
});
|
||||
openDialog('outdoorHistoryDialog');
|
||||
try {
|
||||
const data = await api('/api/history?scope=overview&hours=24&limit=20000');
|
||||
const deviceRows = data.devices || [];
|
||||
const outdoorDeviceSeries = outdoorHistorySeries(app.devices, deviceRows).map(item => ({
|
||||
...item,
|
||||
label: item.label.includes(' · ') ? item.label : `${item.label} · ${tr('history.greeOutdoor')}`,
|
||||
}));
|
||||
const sensorRows = (data.sensors || []).filter(row => row.kind === 'outdoor');
|
||||
const outdoorEntities = [...new Set(sensorRows.map(row => row.entity_id))];
|
||||
const outdoorHaSeries = outdoorEntities.map((entity, index) => ({
|
||||
label: `HA · ${haSensorLabel(entity)}`,
|
||||
color: historySeriesColor(index + outdoorDeviceSeries.length),
|
||||
dash: [6, 4],
|
||||
value: row => row.entity_id === entity ? historyNumber(row.temperature) : NaN,
|
||||
}));
|
||||
const series = [...outdoorDeviceSeries, ...outdoorHaSeries];
|
||||
const rows = [...deviceRows, ...sensorRows];
|
||||
host.innerHTML = historyChartMarkup('outdoorHistoryModalChart', tr('history.allOutdoor'), tr('house.outdoorHistoryHint'));
|
||||
drawLineChart($('#outdoorHistoryModalChart'), series, rows, { height: 350 });
|
||||
renderLegend($('#outdoorHistoryModalChartLegend'), series);
|
||||
} catch (error) {
|
||||
host.innerHTML = `<div class="empty"><strong>${esc(tr('history.noData'))}</strong><span>${esc(error.message)}</span></div>`;
|
||||
toast(error.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
function renderHistorySummary() {
|
||||
const host = $('#historySummary'); if (!host) return;
|
||||
if (app.historyTab === 'energy') { renderEnergyHistorySummary(); return; }
|
||||
if (app.historyTab === 'network') { renderNetworkHistorySummary(); return; }
|
||||
const deviceRows = app.historyData.devices, zoneRows = app.historyData.zones, sensorRows = app.historyData.sensors;
|
||||
const cards = [
|
||||
[tr('history.deviceSamples'), app.historyCounts.devices ?? deviceRows.length, tr('history.greeHistory')],
|
||||
[tr('history.zoneSamples'), app.historyCounts.zones ?? zoneRows.length, tr('history.zoneHistory')],
|
||||
[tr('history.haSamples'), app.historyCounts.ha ?? sensorRows.length, tr('history.haHistory')],
|
||||
];
|
||||
host.innerHTML = cards.map(([label, value, detail]) => `<div class="history-stat"><small>${esc(label)}</small><strong>${esc(value)}</strong><span>${esc(detail)}</span></div>`).join('');
|
||||
}
|
||||
|
||||
function renderOverviewHistory() {
|
||||
const host = $('#historyCharts');
|
||||
host.innerHTML = historyChartMarkup('overviewIndoorChart', tr('history.allGreeIndoor'), tr('history.allGreeIndoorHint'))
|
||||
+ historyChartMarkup('overviewOutdoorChart', tr('history.allOutdoor'), tr('history.allOutdoorHint'))
|
||||
+ historyChartMarkup('overviewZonesChart', tr('history.allZoneControl'), tr('history.allZoneControlHint'));
|
||||
|
||||
const deviceRows = app.historyData.devices;
|
||||
const indoorSeries = app.devices.map((device, index) => ({ label: device.name, color: historySeriesColor(index), value: row => !row.zone_id && !row.entity_id && row.device_id === device.id ? historyNumber(row.indoor_temperature) : NaN }));
|
||||
drawLineChart($('#overviewIndoorChart'), indoorSeries, deviceRows, { height: 360 }); renderLegend($('#overviewIndoorChartLegend'), indoorSeries);
|
||||
|
||||
const outdoorDeviceSeries = outdoorHistorySeries(app.devices, deviceRows).map(item => ({ ...item, label: item.label.includes(' · ') ? item.label : `${item.label} · ${tr('history.greeOutdoor')}` }));
|
||||
const outdoorEntities = [...new Set(app.historyData.sensors.filter(row => row.kind === 'outdoor').map(row => row.entity_id))];
|
||||
const outdoorHaSeries = outdoorEntities.map((entity, index) => ({ label: `HA · ${haSensorLabel(entity)}`, color: historySeriesColor(index + outdoorDeviceSeries.length), dash: [6, 4], value: row => row.entity_id === entity ? historyNumber(row.temperature) : NaN }));
|
||||
const outdoorRows = [...deviceRows, ...app.historyData.sensors.filter(row => row.kind === 'outdoor')];
|
||||
const outdoorSeries = [...outdoorDeviceSeries, ...outdoorHaSeries];
|
||||
drawLineChart($('#overviewOutdoorChart'), outdoorSeries, outdoorRows, { height: 320 }); renderLegend($('#overviewOutdoorChartLegend'), outdoorSeries);
|
||||
|
||||
const zoneRows = app.historyData.zones;
|
||||
const zoneSeries = app.zones.map((zone, index) => ({ label: zone.name, color: historySeriesColor(index), value: row => row.zone_id === zone.id ? historyNumber(row.control_temperature) : NaN }));
|
||||
drawLineChart($('#overviewZonesChart'), zoneSeries, zoneRows, { height: 340 }); renderLegend($('#overviewZonesChartLegend'), zoneSeries);
|
||||
}
|
||||
|
||||
function renderZoneHistory() {
|
||||
const selected = app.historyZone;
|
||||
const rows = selected === 'all' ? app.historyData.zones : app.historyData.zones.filter(row => row.zone_id === selected);
|
||||
const host = $('#historyCharts');
|
||||
host.innerHTML = historyChartMarkup('zoneTemperatureChart', tr('history.temperatureOverview'), tr('history.temperatureOverviewHint')) + historyChartMarkup('zoneOperationChart', tr('history.operationOverview'), tr('history.operationOverviewHint'), true);
|
||||
if (selected === 'all') {
|
||||
const series = app.zones.map((zone, index) => ({ label: zone.name, color: historySeriesColor(index), value: row => row.zone_id === zone.id ? historyNumber(row.control_temperature) : NaN }));
|
||||
drawLineChart($('#zoneTemperatureChart'), series, rows, { height: 360 }); renderLegend($('#zoneTemperatureChartLegend'), series);
|
||||
const targets = app.zones.map((zone, index) => ({ label: `${zone.name} · ${tr('history.targetShort')}`, color: historySeriesColor(index), dash: [5, 4], value: row => row.zone_id === zone.id ? historyNumber(row.target_temperature) : NaN }));
|
||||
drawLineChart($('#zoneOperationChart'), targets, rows, { height: 260 }); renderLegend($('#zoneOperationChartLegend'), targets);
|
||||
return;
|
||||
}
|
||||
const temperatureSeries = [
|
||||
{ label: tr('history.greeSensor'), color: cssColor('--accent', '#3ecf8e'), value: row => historyNumber(row.gree_temperature) },
|
||||
{ label: tr('history.roomSensor'), color: cssColor('--info', '#60a5fa'), value: row => historyNumber(row.external_temperature) },
|
||||
{ label: tr('history.controlTemperature'), color: cssColor('--teal', '#2dd4bf'), width: 2.8, value: row => historyNumber(row.control_temperature) },
|
||||
{ label: tr('history.comfortTarget'), color: cssColor('--warning', '#f59e0b'), dash: [7, 5], value: row => historyNumber(row.target_temperature) },
|
||||
{ label: tr('history.deviceSetpoint'), color: cssColor('--purple', '#a78bfa'), dash: [3, 4], value: row => historyNumber(row.device_setpoint) },
|
||||
{ label: tr('history.outdoorTemperature'), color: cssColor('--muted-strong', '#9ca3af'), dash: [2, 5], value: row => historyNumber(row.outdoor_temperature) },
|
||||
];
|
||||
drawLineChart($('#zoneTemperatureChart'), temperatureSeries, rows, { height: 360 }); renderLegend($('#zoneTemperatureChartLegend'), temperatureSeries);
|
||||
const operationSeries = [
|
||||
{ label: tr('history.fanSpeed'), color: cssColor('--info', '#60a5fa'), step: true, value: row => historyNumber(row.fan_speed), tooltipValue: value => fanLabel(Math.round(value)) },
|
||||
{ label: tr('history.demand'), color: cssColor('--accent', '#3ecf8e'), step: true, width: 2.4, value: row => row.demand ? 4.5 : 0.5, tooltipValue: (_, row) => row.demand ? tr('common.active') : tr('common.disabled') },
|
||||
{ label: tr('history.power'), color: cssColor('--warning', '#f59e0b'), step: true, dash: [5, 4], value: row => row.power ? 3.5 : 0.5, tooltipValue: (_, row) => row.power ? tr('common.on') : tr('common.off') },
|
||||
];
|
||||
drawLineChart($('#zoneOperationChart'), operationSeries, rows, { height: 260, minValue: 0, maxValue: 5, binaryLabels: true }); renderLegend($('#zoneOperationChartLegend'), operationSeries);
|
||||
}
|
||||
|
||||
function renderDeviceHistory() {
|
||||
const selected = app.historyDevice;
|
||||
const rows = selected === 'all' ? app.historyData.devices : app.historyData.devices.filter(row => row.device_id === selected);
|
||||
const host = $('#historyCharts');
|
||||
host.innerHTML = historyChartMarkup('deviceIndoorChart', tr('history.deviceIndoor'), tr('history.deviceIndoorHint')) + historyChartMarkup('deviceOutdoorChart', tr('history.deviceOutdoor'), tr('history.deviceOutdoorHint')) + historyChartMarkup('deviceTargetChart', tr('history.deviceTargets'), tr('history.deviceTargetsHint'), true);
|
||||
const devices = selected === 'all' ? app.devices : app.devices.filter(device => device.id === selected);
|
||||
const indoor = devices.map((device, index) => ({ label: device.name, color: historySeriesColor(index), value: row => row.device_id === device.id ? historyNumber(row.indoor_temperature) : NaN }));
|
||||
const outdoor = outdoorHistorySeries(devices, rows);
|
||||
const targets = devices.map((device, index) => ({ label: device.name, color: historySeriesColor(index), dash: [6, 4], value: row => row.device_id === device.id ? historyNumber(row.target_temperature) : NaN }));
|
||||
drawLineChart($('#deviceIndoorChart'), indoor, rows, { height: 350 }); renderLegend($('#deviceIndoorChartLegend'), indoor);
|
||||
drawLineChart($('#deviceOutdoorChart'), outdoor, rows, { height: 310 }); renderLegend($('#deviceOutdoorChartLegend'), outdoor);
|
||||
drawLineChart($('#deviceTargetChart'), targets, rows, { height: 260 }); renderLegend($('#deviceTargetChartLegend'), targets);
|
||||
}
|
||||
|
||||
function renderSensorHistory() {
|
||||
const selected = app.historySensor;
|
||||
const rows = selected === 'all' ? app.historyData.sensors : app.historyData.sensors.filter(row => row.entity_id === selected);
|
||||
const entities = [...new Set(rows.map(row => row.entity_id))];
|
||||
const host = $('#historyCharts');
|
||||
host.innerHTML = historyChartMarkup('haSensorsChart', tr('history.haSensors'), tr('history.haSensorsHint'));
|
||||
const series = entities.map((entity, index) => ({ label: haSensorLabel(entity), color: historySeriesColor(index), dash: rows.some(row => row.entity_id === entity && row.kind === 'outdoor') ? [6, 4] : [], value: row => row.entity_id === entity ? historyNumber(row.temperature) : NaN }));
|
||||
drawLineChart($('#haSensorsChart'), series, rows, { height: 370 }); renderLegend($('#haSensorsChartLegend'), series);
|
||||
}
|
||||
|
||||
function networkTargetRows() {
|
||||
const selected = app.historyNetworkTarget || 'all';
|
||||
return selected === 'all' ? (app.historyNetwork || []) : (app.historyNetwork || []).filter(row => row.target_id === selected);
|
||||
}
|
||||
|
||||
function networkTargetLabel(id) {
|
||||
return (app.historyNetworkTargets || []).find(target => target.id === id)?.name || id;
|
||||
}
|
||||
|
||||
function renderNetworkHistorySummary() {
|
||||
const host = $('#historySummary'); if (!host) return;
|
||||
const rows = networkTargetRows();
|
||||
const ids = [...new Set(rows.map(row => row.target_id))];
|
||||
if (!ids.length) { host.innerHTML = ''; return; }
|
||||
host.innerHTML = ids.map(id => {
|
||||
const latest = rows.filter(row => row.target_id === id).sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp))[0];
|
||||
const latency = Number.isFinite(Number(latest?.latency_ms)) ? `${Number(latest.latency_ms).toLocaleString(locale(), { maximumFractionDigits: 1 })} ms` : '—';
|
||||
const jitter = Number.isFinite(Number(latest?.jitter_ms)) ? `${Number(latest.jitter_ms).toLocaleString(locale(), { maximumFractionDigits: 1 })} ms` : '—';
|
||||
const loss = `${Number(latest?.packet_loss_pct || 0).toLocaleString(locale(), { maximumFractionDigits: 1 })}%`;
|
||||
return `<div class="history-stat"><small>${esc(networkTargetLabel(id))}</small><strong>${esc(latency)}</strong><span>${esc(tr('history.networkJitter'))}: ${esc(jitter)} · ${esc(tr('history.networkLoss'))}: ${esc(loss)} · ${esc(latest?.successful_samples ?? 0)}/${esc(latest?.sample_count ?? 0)}</span></div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function renderNetworkHistory() {
|
||||
const host = $('#historyCharts'); if (!host) return;
|
||||
renderNetworkHistorySummary();
|
||||
const rows = networkTargetRows();
|
||||
if (!rows.length) {
|
||||
host.innerHTML = `<div class="empty"><strong>${esc(tr('history.networkNoData'))}</strong><span>${esc(tr('history.networkNoDataHint'))}</span></div>`;
|
||||
return;
|
||||
}
|
||||
const ids = [...new Set(rows.map(row => row.target_id))];
|
||||
const latencySeries = [];
|
||||
const lossSeries = [];
|
||||
ids.forEach((id, index) => {
|
||||
const label = networkTargetLabel(id);
|
||||
const color = historySeriesColor(index);
|
||||
latencySeries.push({
|
||||
key: `${id}:latency`, label: `${label} · ${tr('history.networkLatency')}`, color,
|
||||
value: row => row.target_id === id ? historyNumber(row.latency_ms) : NaN,
|
||||
tooltipValue: value => `${Number(value).toLocaleString(locale(), { maximumFractionDigits: 2 })} ms`,
|
||||
});
|
||||
if (app.historyNetworkShowJitter !== false) latencySeries.push({
|
||||
key: `${id}:jitter`, label: `${label} · ${tr('history.networkJitter')}`, color, dash: [7, 5],
|
||||
value: row => row.target_id === id ? historyNumber(row.jitter_ms) : NaN,
|
||||
tooltipValue: value => `${Number(value).toLocaleString(locale(), { maximumFractionDigits: 2 })} ms`,
|
||||
});
|
||||
lossSeries.push({
|
||||
key: `${id}:loss`, label, color,
|
||||
value: row => row.target_id === id ? historyNumber(row.packet_loss_pct) : NaN,
|
||||
tooltipValue: value => `${Number(value).toLocaleString(locale(), { maximumFractionDigits: 2 })}%`,
|
||||
});
|
||||
});
|
||||
host.innerHTML = historyChartMarkup('networkLatencyChart', tr('history.networkLatencyJitter'), tr('history.networkLatencyJitterHint'))
|
||||
+ historyChartMarkup('networkLossChart', tr('history.networkLoss'), tr('history.networkLossHint'));
|
||||
drawLineChart($('#networkLatencyChart'), latencySeries, rows, { height: 360, minValue: 0, axisSuffix: ' ms', tooltipUnit: ' ms', axisDigits: 0 });
|
||||
renderLegend($('#networkLatencyChartLegend'), latencySeries);
|
||||
drawLineChart($('#networkLossChart'), lossSeries, rows, { height: 300, minValue: 0, maxValue: 100, axisSuffix: '%', tooltipUnit: '%', axisDigits: 0 });
|
||||
renderLegend($('#networkLossChartLegend'), lossSeries);
|
||||
}
|
||||
|
||||
function customSeriesOptions() {
|
||||
const items = [];
|
||||
const groupedOutdoorIds = new Set();
|
||||
(app.deviceGroups || []).forEach(group => {
|
||||
if (!group.outdoor_temperature_device_id) return;
|
||||
const representative = (group.device_ids || []).includes(group.outdoor_temperature_device_id)
|
||||
? group.outdoor_temperature_device_id
|
||||
: (group.device_ids || [])[0];
|
||||
if (!representative) return;
|
||||
(group.device_ids || []).forEach(id => groupedOutdoorIds.add(id));
|
||||
items.push([`installation|${group.id}|outdoor`, `${group.name} · ${tr('history.sharedOutdoor')}`]);
|
||||
});
|
||||
app.devices.forEach(device => {
|
||||
items.push([`device|${device.id}|indoor`, `${device.name} · ${tr('history.indoorTemperature')}`]);
|
||||
if (!groupedOutdoorIds.has(device.id)) items.push([`device|${device.id}|outdoor`, `${device.name} · ${tr('history.greeOutdoor')}`]);
|
||||
items.push([`device|${device.id}|target`, `${device.name} · ${tr('history.deviceTarget')}`]);
|
||||
});
|
||||
app.zones.forEach(zone => {
|
||||
items.push([`zone|${zone.id}|control`, `${zone.name} · ${tr('history.controlTemperature')}`]);
|
||||
items.push([`zone|${zone.id}|gree`, `${zone.name} · ${tr('history.greeSensor')}`]);
|
||||
items.push([`zone|${zone.id}|external`, `${zone.name} · ${tr('history.roomSensor')}`]);
|
||||
items.push([`zone|${zone.id}|target`, `${zone.name} · ${tr('history.comfortTarget')}`]);
|
||||
items.push([`zone|${zone.id}|device_target`, `${zone.name} · ${tr('history.deviceSetpoint')}`]);
|
||||
items.push([`zone|${zone.id}|outdoor`, `${zone.name} · ${tr('history.outdoorTemperature')}`]);
|
||||
});
|
||||
[...new Set(app.historyData.sensors.map(row => row.entity_id))].forEach(entity => items.push([`ha|${entity}|temperature`, `HA · ${haSensorLabel(entity)}`]));
|
||||
return items;
|
||||
}
|
||||
|
||||
function customSeriesDefinition(key, index = 0) {
|
||||
const [kind, id, field] = String(key).split('|');
|
||||
const color = historySeriesColor(index);
|
||||
if (kind === 'device') {
|
||||
const device = app.devices.find(item => item.id === id); if (!device) return null;
|
||||
const labels = { indoor: tr('history.indoorTemperature'), outdoor: tr('history.greeOutdoor'), target: tr('history.deviceTarget') };
|
||||
const fields = { indoor: 'indoor_temperature', outdoor: 'outdoor_temperature', target: 'target_temperature' };
|
||||
return { key, label: `${device.name} · ${labels[field] || field}`, color, dash: field === 'target' ? [6, 4] : [], value: row => !row.zone_id && !row.entity_id && row.device_id === id ? historyNumber(row[fields[field]]) : NaN };
|
||||
}
|
||||
if (kind === 'installation' && field === 'outdoor') {
|
||||
const group = (app.deviceGroups || []).find(item => item.id === id); if (!group) return null;
|
||||
const representative = (group.device_ids || []).includes(group.outdoor_temperature_device_id)
|
||||
? group.outdoor_temperature_device_id
|
||||
: (group.device_ids || [])[0];
|
||||
if (!representative) return null;
|
||||
return { key, label: `${group.name} · ${tr('history.sharedOutdoor')}`, color, value: row => !row.zone_id && !row.entity_id && row.device_id === representative ? historyNumber(row.outdoor_temperature) : NaN };
|
||||
}
|
||||
if (kind === 'zone') {
|
||||
const zone = app.zones.find(item => item.id === id); if (!zone) return null;
|
||||
const fields = { control: ['control_temperature', tr('history.controlTemperature')], gree: ['gree_temperature', tr('history.greeSensor')], external: ['external_temperature', tr('history.roomSensor')], target: ['target_temperature', tr('history.comfortTarget')], device_target: ['device_setpoint', tr('history.deviceSetpoint')], outdoor: ['outdoor_temperature', tr('history.outdoorTemperature')] };
|
||||
const info = fields[field]; if (!info) return null;
|
||||
return { key, label: `${zone.name} · ${info[1]}`, color, dash: ['target', 'device_target', 'outdoor'].includes(field) ? [6, 4] : [], value: row => row.zone_id === id ? historyNumber(row[info[0]]) : NaN };
|
||||
}
|
||||
if (kind === 'ha') return { key, label: `HA · ${haSensorLabel(id)}`, color, dash: [3, 4], value: row => row.entity_id === id ? historyNumber(row.temperature) : NaN };
|
||||
return null;
|
||||
}
|
||||
|
||||
function persistSavedCharts() {
|
||||
localStorage.setItem('gree_controller_saved_charts', JSON.stringify(app.savedCharts.slice(0, 30)));
|
||||
}
|
||||
|
||||
function savedChartRangeLabel(hours) {
|
||||
const value = Number(hours || 24);
|
||||
if (value === 168) return tr('history.range7d');
|
||||
if (value === 720) return tr('history.range30d');
|
||||
if (value === 2160) return tr('history.range90d');
|
||||
if (value === 8760) return tr('history.range1y');
|
||||
return `${value} h`;
|
||||
}
|
||||
|
||||
function renderCustomBuilder() {
|
||||
const host = $('#historyCustomBuilder'); if (!host) return;
|
||||
if (app.historyTab !== 'custom') { host.innerHTML = ''; host.classList.remove('active'); return; }
|
||||
host.classList.add('active');
|
||||
const options = customSeriesOptions();
|
||||
const selected = app.customChartSeries.map((key, index) => customSeriesDefinition(key, index)).filter(Boolean);
|
||||
const editing = app.savedCharts.find(item => item.id === app.customChartEditingId) || null;
|
||||
const draftName = app.customChartNameDraft !== null ? app.customChartNameDraft : (editing?.name || '');
|
||||
const editNote = editing ? `<span class="custom-chart-edit-note">${esc(tr('history.editingChart'))}: <strong>${esc(editing.name)}</strong></span>` : '';
|
||||
const cancelEdit = editing ? `<button class="secondary" data-history-action="cancel-chart-edit">${esc(tr('actions.cancel'))}</button>` : '';
|
||||
host.innerHTML = `<div class="panel custom-chart-panel"><div class="chart-title-row"><div><h3>${esc(tr('history.customTitle'))}</h3><p>${esc(tr('history.customDescription'))}</p></div></div>
|
||||
<div class="custom-chart-add"><select id="customSeriesSelect">${options.map(([value, label]) => `<option value="${esc(value)}">${esc(label)}</option>`).join('')}</select><button class="secondary" data-history-action="add-series">${esc(tr('history.addSeries'))}</button></div>
|
||||
<div class="custom-series-list">${selected.length ? selected.map((item, index) => `<span class="custom-series-chip"><i style="--chip-color:${esc(item.color)}"></i>${esc(item.label)}<button data-history-action="remove-series" data-index="${index}" aria-label="${esc(tr('actions.remove'))}">${uiIcon('close')}</button></span>`).join('') : `<span class="field-note">${esc(tr('history.noCustomSeries'))}</span>`}</div>
|
||||
${editNote}
|
||||
<div class="custom-chart-save"><input id="customChartName" value="${esc(draftName)}" placeholder="${esc(tr('history.chartName'))}"><button class="secondary" data-history-action="save-chart">${esc(editing ? tr('history.saveChanges') : tr('actions.save'))}</button>${cancelEdit}<button class="primary" data-history-action="copy-chart-link">${esc(tr('history.copyLink'))}</button></div>
|
||||
<div class="saved-chart-section"><div class="saved-chart-heading"><strong>${esc(tr('history.savedCharts'))}</strong><small>${app.savedCharts.length}</small></div><div class="saved-chart-list">${app.savedCharts.length ? app.savedCharts.map(item => `<div class="saved-chart-row${item.id === app.customChartEditingId ? ' is-editing' : ''}"><button class="saved-chart-open" data-history-action="load-chart" data-id="${esc(item.id)}"><strong>${esc(item.name)}</strong><small>${esc(item.series.length)} ${esc(tr('history.series'))} · ${esc(savedChartRangeLabel(item.hours))}</small></button><div class="saved-chart-actions"><button class="secondary" data-history-action="edit-chart" data-id="${esc(item.id)}" title="${esc(tr('actions.edit'))}" aria-label="${esc(tr('actions.edit'))}">${uiIcon('edit')}</button><button class="danger" data-history-action="delete-chart" data-id="${esc(item.id)}" title="${esc(tr('actions.delete'))}" aria-label="${esc(tr('actions.delete'))}">${uiIcon('close')}</button></div></div>`).join('') : `<span class="field-note">${esc(tr('history.noSavedCharts'))}</span>`}</div></div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderCustomHistory() {
|
||||
renderCustomBuilder();
|
||||
const host = $('#historyCharts');
|
||||
host.innerHTML = historyChartMarkup('customHistoryChart', tr('history.customChart'), tr('history.customChartHint'));
|
||||
const series = app.customChartSeries.map((key, index) => customSeriesDefinition(key, index)).filter(Boolean);
|
||||
const rows = [...app.historyData.devices, ...app.historyData.zones, ...app.historyData.sensors];
|
||||
drawLineChart($('#customHistoryChart'), series, rows, { height: 390 }); renderLegend($('#customHistoryChartLegend'), series);
|
||||
}
|
||||
|
||||
function renderHistoryPage() {
|
||||
renderHistorySummary();
|
||||
renderCustomBuilder();
|
||||
if (app.historyTab === 'overview') renderOverviewHistory();
|
||||
else if (app.historyTab === 'zones') renderZoneHistory();
|
||||
else if (app.historyTab === 'devices') renderDeviceHistory();
|
||||
else if (app.historyTab === 'energy') renderEnergyHistory();
|
||||
else if (app.historyTab === 'network') renderNetworkHistory();
|
||||
else if (app.historyTab === 'sensors') renderSensorHistory();
|
||||
else renderCustomHistory();
|
||||
}
|
||||
|
||||
function drawCurrentChartIfVisible() {
|
||||
if (app.currentView === 'history') renderHistoryPage();
|
||||
}
|
||||
|
||||
function publicCustomChartUrl(path) {
|
||||
const configuredPublicBase = String(app.system?.public_chart_base_url || '').trim().replace(/\/+$/, '');
|
||||
if (configuredPublicBase) return `${configuredPublicBase}${path}`;
|
||||
if (!APP_BASE.startsWith('/api/hassio_ingress/')) return `${location.origin}${APP_BASE}${path}`;
|
||||
const bind = String(app.system?.bind || '');
|
||||
const port = bind.match(/:(\d+)$/)?.[1] || '8787';
|
||||
const rawHost = location.hostname || 'localhost';
|
||||
const host = rawHost.includes(':') && !rawHost.startsWith('[') ? `[${rawHost}]` : rawHost;
|
||||
const configuredBase = String(app.system?.base_path || '').trim();
|
||||
const directBase = configuredBase === '/' ? '' : configuredBase.replace(/\/$/, '');
|
||||
return `http://${host}:${port}${directBase}${path}`;
|
||||
}
|
||||
|
||||
async function handleHistoryAction(button) {
|
||||
const action = button.dataset.historyAction;
|
||||
if (action === 'add-series') {
|
||||
if ($('#customChartName')) app.customChartNameDraft = $('#customChartName').value;
|
||||
const value = $('#customSeriesSelect')?.value;
|
||||
if (value && !app.customChartSeries.includes(value)) app.customChartSeries.push(value);
|
||||
renderCustomHistory(); updateBrowserUrl(currentHistoryPath(), true); return;
|
||||
}
|
||||
if (action === 'remove-series') {
|
||||
if ($('#customChartName')) app.customChartNameDraft = $('#customChartName').value;
|
||||
app.customChartSeries.splice(Number(button.dataset.index), 1);
|
||||
renderCustomHistory(); updateBrowserUrl(currentHistoryPath(), true); return;
|
||||
}
|
||||
if (action === 'save-chart') {
|
||||
if (!app.customChartSeries.length) return toast(tr('history.noCustomSeries'), true);
|
||||
const name = $('#customChartName')?.value.trim() || tr('history.customChart');
|
||||
const hours = $('#historyHours')?.value || '24';
|
||||
const editingIndex = app.savedCharts.findIndex(entry => entry.id === app.customChartEditingId);
|
||||
if (editingIndex >= 0) {
|
||||
app.savedCharts[editingIndex] = { ...app.savedCharts[editingIndex], name, series: [...app.customChartSeries], hours };
|
||||
app.customChartEditingId = null; app.customChartNameDraft = null;
|
||||
persistSavedCharts(); renderCustomHistory(); toast(tr('history.chartUpdated')); return;
|
||||
}
|
||||
const item = { id: `chart-${Date.now()}`, name, series: [...app.customChartSeries], hours };
|
||||
app.savedCharts.unshift(item); app.customChartNameDraft = null; persistSavedCharts(); renderCustomHistory(); toast(tr('history.chartSaved')); return;
|
||||
}
|
||||
if (action === 'load-chart') {
|
||||
const item = app.savedCharts.find(entry => entry.id === button.dataset.id); if (!item) return;
|
||||
app.customChartEditingId = null; app.customChartNameDraft = item.name || '';
|
||||
app.customChartSeries = [...item.series]; if ($('#historyHours') && item.hours) $('#historyHours').value = String(item.hours);
|
||||
renderCustomHistory(); updateBrowserUrl(currentHistoryPath()); return;
|
||||
}
|
||||
if (action === 'edit-chart') {
|
||||
const item = app.savedCharts.find(entry => entry.id === button.dataset.id); if (!item) return;
|
||||
app.customChartEditingId = item.id; app.customChartNameDraft = item.name || ''; app.customChartSeries = [...item.series];
|
||||
if ($('#historyHours') && item.hours) $('#historyHours').value = String(item.hours);
|
||||
renderCustomHistory(); updateBrowserUrl(currentHistoryPath()); $('#customChartName')?.focus(); return;
|
||||
}
|
||||
if (action === 'cancel-chart-edit') {
|
||||
app.customChartEditingId = null; app.customChartNameDraft = null; renderCustomHistory(); return;
|
||||
}
|
||||
if (action === 'delete-chart') {
|
||||
if (app.customChartEditingId === button.dataset.id) { app.customChartEditingId = null; app.customChartNameDraft = null; }
|
||||
app.savedCharts = app.savedCharts.filter(entry => entry.id !== button.dataset.id); persistSavedCharts(); renderCustomHistory(); return;
|
||||
}
|
||||
if (action === 'copy-chart-link') {
|
||||
if (!app.customChartSeries.length) return toast(tr('history.noCustomSeries'), true);
|
||||
try {
|
||||
const share = await api('/api/charts/custom/share', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
title: $('#customChartName')?.value.trim() || tr('history.customChart'),
|
||||
series: [...app.customChartSeries],
|
||||
hours: Number($('#historyHours')?.value || 24),
|
||||
lang: app.language === 'pl' ? 'pl' : 'en',
|
||||
},
|
||||
});
|
||||
const link = publicCustomChartUrl(share.path);
|
||||
try { await navigator.clipboard.writeText(link); } catch (_) { const area = document.createElement('textarea'); area.value = link; document.body.appendChild(area); area.select(); document.execCommand('copy'); area.remove(); }
|
||||
toast(tr('history.linkCopied'));
|
||||
} catch (error) {
|
||||
toast(error.message, true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function loadEnergyHistory() {
|
||||
const targets = normalizeEnergyHistoryTargets();
|
||||
if (!targets.length || !app.historyEnergyTargets.length) {
|
||||
app.historyEnergy = [];
|
||||
return;
|
||||
}
|
||||
const hours = Number($('#historyHours')?.value || 24);
|
||||
const days = Math.max(1, Math.ceil(hours / 24));
|
||||
const selected = app.historyEnergyTargets.slice(0, 8);
|
||||
app.historyEnergy = await Promise.all(selected.map(targetId => api(`/api/history/energy?target_id=${encodeURIComponent(targetId)}&interval=${encodeURIComponent(app.historyEnergyInterval)}&days=${days}&limit=100000&compare=${encodeURIComponent(app.historyEnergyCompare)}`)));
|
||||
}
|
||||
|
||||
function renderEnergyHistorySummary() {
|
||||
const host = $('#historySummary'); if (!host) return;
|
||||
const data = (app.historyEnergy || []).filter(item => item && item.source !== 'none');
|
||||
if (!data.length) { host.innerHTML = ''; return; }
|
||||
if (data.length === 1) {
|
||||
const item = data[0];
|
||||
const summary = item.summary || {};
|
||||
const rows = [
|
||||
[tr('energy.today'), summary.today],
|
||||
[tr('energy.yesterday'), summary.yesterday],
|
||||
[tr('energy.currentMonth'), summary.current_month],
|
||||
[tr('energy.previousMonth'), summary.previous_month],
|
||||
[tr('energy.periodTotal'), summary.period_total],
|
||||
];
|
||||
if (item.comparison) rows.push([tr('energy.comparisonPeriod'), item.comparison.period_total]);
|
||||
host.innerHTML = rows.map(([label, value]) => `<div class="history-stat"><small>${esc(label)}</small><strong>${Number(value || 0).toLocaleString(locale(), { maximumFractionDigits: 3 })} kWh</strong><span>${esc(item.target_name || '')} · ${esc(item.source === 'gree_cloud' ? 'GREE Cloud' : 'Home Assistant')}</span></div>`).join('');
|
||||
return;
|
||||
}
|
||||
host.innerHTML = data.map(item => `<div class="history-stat"><small>${esc(item.target_name || item.target_id)}</small><strong>${Number(item.summary?.period_total || 0).toLocaleString(locale(), { maximumFractionDigits: 3 })} kWh</strong><span>${esc(item.source === 'gree_cloud' ? 'GREE Cloud' : 'Home Assistant')}${item.comparison ? ` · ${esc(tr('energy.comparisonPeriod'))}: ${Number(item.comparison.period_total || 0).toLocaleString(locale(), { maximumFractionDigits: 3 })} kWh` : ''}</span></div>`).join('');
|
||||
}
|
||||
|
||||
function renderEnergyHistory() {
|
||||
const host = $('#historyCharts'); if (!host) return;
|
||||
const data = (app.historyEnergy || []).filter(item => item && item.source !== 'none');
|
||||
renderEnergyHistorySummary();
|
||||
if (!data.length) {
|
||||
host.innerHTML = `<div class="empty"><strong>${esc(tr('energy.noData'))}</strong></div>`;
|
||||
return;
|
||||
}
|
||||
const series = [];
|
||||
data.forEach((item, index) => {
|
||||
const color = historySeriesColor(index);
|
||||
series.push({
|
||||
key: `${item.target_id}:current`,
|
||||
label: data.length === 1 ? `${item.target_name} · ${tr('energy.currentPeriod')}` : item.target_name,
|
||||
color,
|
||||
buckets: item.buckets || [],
|
||||
});
|
||||
if (app.historyEnergyCompare !== 'none' && item.comparison?.buckets?.length) {
|
||||
series.push({
|
||||
key: `${item.target_id}:comparison`,
|
||||
label: `${item.target_name} · ${tr('energy.comparisonPeriod')}`,
|
||||
color,
|
||||
comparison: true,
|
||||
buckets: item.comparison.buckets,
|
||||
});
|
||||
}
|
||||
});
|
||||
host.innerHTML = historyChartMarkup('energyConsumptionChart', tr('energy.title'), tr('energy.chartHint'));
|
||||
drawEnergyBarChart($('#energyConsumptionChart'), series, { height: 360, interval: app.historyEnergyInterval });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
'use strict';
|
||||
(() => {
|
||||
const row = document.cookie.split('; ').find(item => item.startsWith('gree_controller_language='));
|
||||
const language = row ? decodeURIComponent(row.split('=').slice(1).join('=')) : document.documentElement.lang;
|
||||
if (language) document.documentElement.lang = language;
|
||||
document.documentElement.classList.add('i18n-loading');
|
||||
})();
|
||||
@@ -1,26 +0,0 @@
|
||||
let historyResizeTimer;
|
||||
window.addEventListener('resize', () => { if (app.currentView === 'history') { clearTimeout(historyResizeTimer); historyResizeTimer = setTimeout(drawCurrentChartIfVisible, 120); } });
|
||||
window.matchMedia('(prefers-color-scheme: light)').addEventListener('change', () => { if (app.theme === 'system') applyTheme(); });
|
||||
if ('serviceWorker' in navigator && !APP_BASE.startsWith('/api/hassio_ingress/')) window.addEventListener('load', () => navigator.serviceWorker.register(withBase('/sw.js')).catch(() => { }));
|
||||
|
||||
async function startApplication() {
|
||||
try {
|
||||
hydrateUiIcons();
|
||||
observeTopbarHeight();
|
||||
applyTheme();
|
||||
await loadLanguages();
|
||||
applyTranslations();
|
||||
} finally {
|
||||
document.documentElement.classList.remove('i18n-loading');
|
||||
}
|
||||
setupFormUx();
|
||||
updateZoneSensorFields();
|
||||
updateSchedulePresetField();
|
||||
await loadBootstrap();
|
||||
initRouter();
|
||||
}
|
||||
|
||||
setInterval(updateLocalThermostatCountdowns, 1000);
|
||||
setInterval(updateTemporaryThermostatCountdowns, 1000);
|
||||
setInterval(updateCompressorQueueCountdowns, 1000);
|
||||
startApplication().catch(error => console.error('Application startup failed:', error));
|
||||
@@ -1,910 +0,0 @@
|
||||
function setDashboardTab(tab, { scroll = true } = {}) {
|
||||
const next = ['main', 'thermostats', 'manual'].includes(tab) ? tab : 'main';
|
||||
app.dashboardTab = next;
|
||||
$$('[data-dashboard-tab]').forEach(button => {
|
||||
const active = button.dataset.dashboardTab === next;
|
||||
button.classList.toggle('active', active);
|
||||
button.setAttribute('aria-selected', String(active));
|
||||
});
|
||||
$$('[data-dashboard-panel]').forEach(panel => {
|
||||
const active = panel.dataset.dashboardPanel === next;
|
||||
panel.classList.toggle('active', active);
|
||||
panel.hidden = !active;
|
||||
});
|
||||
if (scroll && app.currentView === 'dashboard') window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
|
||||
function showView(name, { push = true, scroll = true } = {}) {
|
||||
if (!canLeaveCurrentView(name)) return false;
|
||||
if (name !== 'flows' && !$('#flowEditor')?.hidden) closeFlowEditor({ push: false, force: true });
|
||||
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' && ['devices', 'groups', 'schedules', 'automations', 'simulation', 'night', 'homeassistant', 'settings', 'logs'].includes(name))));
|
||||
if (push) updateBrowserUrl(pathForView(name));
|
||||
if (scroll) window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
if (name === 'dashboard') setDashboardTab(app.dashboardTab, { scroll: false });
|
||||
if (name === 'history') { renderHistoryNavigation(); loadHistory(); }
|
||||
if (name === 'logs') loadLogs();
|
||||
return true;
|
||||
}
|
||||
|
||||
function showHistoryTab(tab, { push = true, load = true } = {}) {
|
||||
app.historyTab = HISTORY_TABS.includes(tab) ? tab : 'overview';
|
||||
renderHistoryNavigation();
|
||||
if (push) updateBrowserUrl(currentHistoryPath());
|
||||
if (load) loadHistory();
|
||||
}
|
||||
|
||||
function confirmManualCommandForDisabledZone(id) {
|
||||
const zone = disabledZoneForDevice(id);
|
||||
if (!zone) return true;
|
||||
return confirm(tr('devices.manualDisabledZoneConfirm', { zone: zone.name }));
|
||||
}
|
||||
|
||||
function pingSamples(id) {
|
||||
if (!app.pingMonitor.samples[id]) app.pingMonitor.samples[id] = [];
|
||||
return app.pingMonitor.samples[id];
|
||||
}
|
||||
|
||||
function addPingSample(id, value, error = '') {
|
||||
const samples = pingSamples(id);
|
||||
samples.push({ at: Date.now(), value: Number.isFinite(Number(value)) ? Math.max(0, Math.round(Number(value))) : null, error: String(error || '') });
|
||||
if (samples.length > 36) samples.splice(0, samples.length - 36);
|
||||
}
|
||||
|
||||
function pingSparkline(samples) {
|
||||
const width = 360, height = 92, padX = 8, padY = 9;
|
||||
if (!samples.length) return `<div class="ping-empty">${esc(tr('devices.pingNoSamples'))}</div>`;
|
||||
|
||||
const valid = samples.map((sample, index) => ({ index, value: sample.value == null ? NaN : Number(sample.value) })).filter(item => Number.isFinite(item.value));
|
||||
const values = valid.map(item => item.value);
|
||||
const min = values.length ? Math.min(...values) : 0;
|
||||
const max = values.length ? Math.max(...values) : 10;
|
||||
const ceiling = Math.max(max, 10);
|
||||
const floor = Math.min(min, 0);
|
||||
const range = Math.max(1, ceiling - floor);
|
||||
const lastIndex = Math.max(1, samples.length - 1);
|
||||
const pointFor = (index, value) => {
|
||||
const x = padX + (index / lastIndex) * (width - padX * 2);
|
||||
const y = height - padY - ((value - floor) / range) * (height - padY * 2);
|
||||
return `${x.toFixed(1)},${y.toFixed(1)}`;
|
||||
};
|
||||
|
||||
// Keep successful ping runs separate so a packet loss never gets hidden by a line
|
||||
// connecting the samples before and after the failed request.
|
||||
const runs = [];
|
||||
let currentRun = [];
|
||||
samples.forEach((sample, index) => {
|
||||
const value = sample.value == null ? NaN : Number(sample.value);
|
||||
if (Number.isFinite(value)) {
|
||||
currentRun.push(pointFor(index, value));
|
||||
} else if (currentRun.length) {
|
||||
runs.push(currentRun);
|
||||
currentRun = [];
|
||||
}
|
||||
});
|
||||
if (currentRun.length) runs.push(currentRun);
|
||||
|
||||
const lineRuns = runs.map(points => points.length === 1
|
||||
? `<circle class="ping-success-point" cx="${points[0].split(',')[0]}" cy="${points[0].split(',')[1]}" r="1.8"></circle>`
|
||||
: `<polyline class="ping-success-line" points="${points.join(' ')}"></polyline>`
|
||||
).join('');
|
||||
|
||||
// Failed requests are packet-loss / unavailability samples. Draw a red band and
|
||||
// an X at each failed position so even a single loss is immediately visible.
|
||||
const losses = samples.map((sample, index) => ({ sample, index })).filter(({ sample }) => sample.value == null).map(({ index }) => {
|
||||
const x = padX + (index / lastIndex) * (width - padX * 2);
|
||||
const bandWidth = Math.max(4, Math.min(10, (width - padX * 2) / Math.max(samples.length, 12)));
|
||||
const left = Math.max(0, x - bandWidth / 2);
|
||||
const markerY = height - padY - 5;
|
||||
return `<g class="ping-loss-marker"><rect x="${left.toFixed(1)}" y="${padY}" width="${bandWidth.toFixed(1)}" height="${height - padY * 2}" rx="2"></rect><path d="M ${(x - 3).toFixed(1)} ${(markerY - 3).toFixed(1)} L ${(x + 3).toFixed(1)} ${(markerY + 3).toFixed(1)} M ${(x + 3).toFixed(1)} ${(markerY - 3).toFixed(1)} L ${(x - 3).toFixed(1)} ${(markerY + 3).toFixed(1)}"></path></g>`;
|
||||
}).join('');
|
||||
|
||||
const guide = [0.25, 0.5, 0.75].map(ratio => `<line x1="${padX}" y1="${(height * ratio).toFixed(1)}" x2="${width - padX}" y2="${(height * ratio).toFixed(1)}"></line>`).join('');
|
||||
return `<svg class="ping-sparkline" viewBox="0 0 ${width} ${height}" preserveAspectRatio="none" role="img" aria-label="${esc(tr('devices.pingLive'))}"><g class="ping-grid-lines">${guide}</g>${lineRuns}${losses}</svg>`;
|
||||
}
|
||||
|
||||
function pingStats(samples) {
|
||||
const values = samples.map(sample => sample.value == null ? NaN : Number(sample.value)).filter(Number.isFinite);
|
||||
if (!values.length) return { current: null, average: null, min: null, max: null };
|
||||
const current = [...samples].reverse().find(sample => sample.value != null && Number.isFinite(Number(sample.value)))?.value ?? null;
|
||||
return {
|
||||
current,
|
||||
average: Math.round(values.reduce((sum, value) => sum + value, 0) / values.length),
|
||||
min: Math.min(...values),
|
||||
max: Math.max(...values),
|
||||
};
|
||||
}
|
||||
|
||||
function pingValue(value) {
|
||||
return Number.isFinite(Number(value)) ? `${Math.round(Number(value))} ms` : '—';
|
||||
}
|
||||
|
||||
function renderPingDialog() {
|
||||
const dialog = $('#pingDialog');
|
||||
const select = $('#pingDeviceSelect');
|
||||
const all = $('#pingAllDevices');
|
||||
const toggle = $('#pingToggleButton');
|
||||
const grid = $('#pingLiveGrid');
|
||||
if (!dialog || !select || !all || !toggle || !grid) return;
|
||||
const localDevices = app.devices.filter(device => device.connection_type !== 'gree_cloud');
|
||||
if (!app.pingMonitor.targetId || !localDevices.some(device => device.id === app.pingMonitor.targetId)) app.pingMonitor.targetId = localDevices[0]?.id || '';
|
||||
select.innerHTML = localDevices.map(device => `<option value="${esc(device.id)}" ${device.id === app.pingMonitor.targetId ? 'selected' : ''}>${esc(device.name)}</option>`).join('');
|
||||
select.disabled = app.pingMonitor.all;
|
||||
all.checked = app.pingMonitor.all;
|
||||
toggle.textContent = tr(app.pingMonitor.running ? 'devices.pingStop' : 'devices.pingStart');
|
||||
toggle.classList.toggle('primary', app.pingMonitor.running);
|
||||
toggle.classList.toggle('secondary', !app.pingMonitor.running);
|
||||
const devices = app.pingMonitor.all ? localDevices : localDevices.filter(device => device.id === app.pingMonitor.targetId);
|
||||
grid.innerHTML = devices.length ? devices.map(device => {
|
||||
const samples = pingSamples(device.id);
|
||||
const stats = pingStats(samples);
|
||||
const last = samples.length ? samples[samples.length - 1] : null;
|
||||
const state = last?.error
|
||||
? `<span class="ping-state error">${esc(tr('devices.pingFailed'))}</span>`
|
||||
: last?.value != null
|
||||
? `<span class="ping-state online">${esc(tr('devices.pingResponding'))}</span>`
|
||||
: `<span class="ping-state ${device.online ? 'online' : ''}">${esc(tr(device.online ? 'status.online' : 'status.offline'))}</span>`;
|
||||
return `<article class="ping-live-card">
|
||||
<div class="ping-live-head"><div><strong>${esc(device.name)}</strong><small>${esc(device.ip)}</small></div>${state}</div>
|
||||
${pingSparkline(samples)}
|
||||
<div class="ping-stat-grid">
|
||||
<div><span>${esc(tr('devices.pingCurrent'))}</span><strong>${esc(pingValue(stats.current))}</strong></div>
|
||||
<div><span>${esc(tr('devices.pingAverage'))}</span><strong>${esc(pingValue(stats.average))}</strong></div>
|
||||
<div><span>${esc(tr('devices.pingMin'))}</span><strong>${esc(pingValue(stats.min))}</strong></div>
|
||||
<div><span>${esc(tr('devices.pingMax'))}</span><strong>${esc(pingValue(stats.max))}</strong></div>
|
||||
</div>
|
||||
<small class="ping-sample-count">${esc(tr('devices.pingSamples'))}: ${samples.length}${last?.error ? ` · ${esc(last.error)}` : ''}</small>
|
||||
</article>`;
|
||||
}).join('') : `<div class="empty"><strong>${esc(tr('devices.emptyTitle'))}</strong>${esc(tr('devices.emptyText'))}</div>`;
|
||||
}
|
||||
|
||||
function schedulePingCycle(delay = null) {
|
||||
clearTimeout(app.pingMonitor.timer);
|
||||
app.pingMonitor.timer = null;
|
||||
if (!app.pingMonitor.running || !$('#pingDialog')?.open) return;
|
||||
const interval = delay == null ? (app.pingMonitor.all ? 5000 : 3000) : delay;
|
||||
app.pingMonitor.timer = setTimeout(runPingCycle, interval);
|
||||
}
|
||||
|
||||
async function runPingCycle() {
|
||||
if (!app.pingMonitor.running || !$('#pingDialog')?.open || app.pingMonitor.inFlight) return;
|
||||
const localDevices = app.devices.filter(device => device.connection_type !== 'gree_cloud');
|
||||
const targets = app.pingMonitor.all ? [...localDevices] : localDevices.filter(device => device.id === app.pingMonitor.targetId);
|
||||
if (!targets.length) { renderPingDialog(); schedulePingCycle(); return; }
|
||||
app.pingMonitor.inFlight = true;
|
||||
await Promise.allSettled(targets.map(async (device, index) => {
|
||||
// Spread all-unit diagnostics and skip a device while explicit manual control is pending.
|
||||
// This keeps control traffic higher priority than the live diagnostic chart.
|
||||
if (app.pingMonitor.all && index > 0) await new Promise(resolve => setTimeout(resolve, index * 250));
|
||||
if (!app.pingMonitor.running || !$('#pingDialog')?.open) return;
|
||||
if (app.deviceControlQueue[device.id] || app.deviceTemperatureDrafts[device.id]) return;
|
||||
try {
|
||||
const result = await api(`/api/devices/${encodeURIComponent(device.id)}/probe`, { method: 'POST' });
|
||||
addPingSample(device.id, result.response_time_ms);
|
||||
} catch (error) {
|
||||
addPingSample(device.id, null, error.message || String(error));
|
||||
}
|
||||
}));
|
||||
app.pingMonitor.inFlight = false;
|
||||
renderPingDialog();
|
||||
schedulePingCycle();
|
||||
}
|
||||
|
||||
function startPingMonitor() {
|
||||
if (!app.devices.some(device => device.connection_type !== 'gree_cloud')) return;
|
||||
app.pingMonitor.running = true;
|
||||
renderPingDialog();
|
||||
clearTimeout(app.pingMonitor.timer);
|
||||
app.pingMonitor.timer = null;
|
||||
runPingCycle();
|
||||
}
|
||||
|
||||
function stopPingMonitor() {
|
||||
app.pingMonitor.running = false;
|
||||
clearTimeout(app.pingMonitor.timer);
|
||||
app.pingMonitor.timer = null;
|
||||
renderPingDialog();
|
||||
}
|
||||
|
||||
function openDevicePing(id) {
|
||||
const localDevices = app.devices.filter(device => device.connection_type !== 'gree_cloud');
|
||||
app.pingMonitor.targetId = (id && localDevices.some(device => device.id === id)) ? id : (localDevices[0]?.id || '');
|
||||
app.pingMonitor.all = false;
|
||||
openDialog('pingDialog');
|
||||
renderPingDialog();
|
||||
startPingMonitor();
|
||||
}
|
||||
|
||||
function applyOptimisticCloudCommand(device, command) {
|
||||
if (!device || device.connection_type !== 'gree_cloud' || !command || typeof command !== 'object') return;
|
||||
const fields = [
|
||||
'power', 'mode', 'target_temperature', 'fan_speed', 'swing_vertical',
|
||||
'swing_horizontal', 'quiet', 'turbo', 'light', 'air', 'xfan', 'health', 'sleep',
|
||||
];
|
||||
for (const field of fields) {
|
||||
if (Object.prototype.hasOwnProperty.call(command, field) && command[field] !== undefined) {
|
||||
device[field] = command[field];
|
||||
}
|
||||
}
|
||||
device.pending_command = true;
|
||||
// A stale transport error should not visually override the command the user has just sent.
|
||||
// The backend/push path will restore it if the publish actually fails.
|
||||
device.last_error = null;
|
||||
renderDevices();
|
||||
}
|
||||
|
||||
async function sendDeviceCommand(id, commandOrFactory, { disabledZoneConfirmed = false } = {}) {
|
||||
const disabledZone = disabledZoneForDevice(id);
|
||||
if (disabledZone && !disabledZoneConfirmed && !confirmManualCommandForDisabledZone(id)) return false;
|
||||
|
||||
const initialDevice = app.devices.find(device => device.id === id);
|
||||
const isCloud = initialDevice?.connection_type === 'gree_cloud';
|
||||
|
||||
// Local keeps the historical synchronous UX exactly as before Cloud support was added.
|
||||
if (!isCloud) {
|
||||
const previous = app.deviceControlQueue[id] || Promise.resolve();
|
||||
const request = previous.catch(() => { }).then(() => {
|
||||
const current = app.devices.find(device => device.id === id);
|
||||
const command = typeof commandOrFactory === 'function' ? commandOrFactory(current) : commandOrFactory;
|
||||
const body = disabledZone ? { ...command, manual_override: true } : command;
|
||||
return api(`/api/devices/${encodeURIComponent(id)}/command`, { method: 'POST', body });
|
||||
});
|
||||
app.deviceControlQueue[id] = request;
|
||||
try {
|
||||
const device = await request;
|
||||
updateDevice(device); renderAll();
|
||||
return true;
|
||||
} catch (error) {
|
||||
toast(error.message, true);
|
||||
try { await loadBootstrap(); } catch (_) { }
|
||||
return false;
|
||||
} finally {
|
||||
if (app.deviceControlQueue[id] === request) delete app.deviceControlQueue[id];
|
||||
}
|
||||
}
|
||||
|
||||
// Cloud controls update immediately in the browser. MQTT ACK/push is confirmation, not
|
||||
// a prerequisite for button/temperature feedback. Requests are still serialized per unit.
|
||||
const command = typeof commandOrFactory === 'function' ? commandOrFactory(initialDevice) : commandOrFactory;
|
||||
if (!command || typeof command !== 'object') return false;
|
||||
applyOptimisticCloudCommand(initialDevice, command);
|
||||
|
||||
const previous = app.deviceControlQueue[id] || Promise.resolve();
|
||||
const request = previous.catch(() => { }).then(() => {
|
||||
const body = disabledZone ? { ...command, manual_override: true } : command;
|
||||
return api(`/api/devices/${encodeURIComponent(id)}/command`, { method: 'POST', body });
|
||||
});
|
||||
app.deviceControlQueue[id] = request;
|
||||
try {
|
||||
const device = await request;
|
||||
updateDevice(device); renderAll();
|
||||
return true;
|
||||
} catch (error) {
|
||||
toast(error.message, true);
|
||||
try { await loadBootstrap(); } catch (_) { }
|
||||
return false;
|
||||
} finally {
|
||||
if (app.deviceControlQueue[id] === request) delete app.deviceControlQueue[id];
|
||||
}
|
||||
}
|
||||
|
||||
function queueDeviceTemperature(id, delta) {
|
||||
const device = app.devices.find(item => item.id === id);
|
||||
if (!device) return;
|
||||
let draft = app.deviceTemperatureDrafts[id];
|
||||
const disabledZone = disabledZoneForDevice(id);
|
||||
if (disabledZone && !draft?.disabledZoneConfirmed) {
|
||||
if (!confirmManualCommandForDisabledZone(id)) return;
|
||||
draft = { ...(draft || {}), disabledZoneConfirmed: true };
|
||||
}
|
||||
const caps = device.capabilities || {};
|
||||
const minTemp = Number.isFinite(Number(caps.min_temperature)) ? Number(caps.min_temperature) : 8;
|
||||
const maxTemp = Number.isFinite(Number(caps.max_temperature)) ? Number(caps.max_temperature) : 30;
|
||||
const step = Number(caps.temperature_step) > 0 ? Number(caps.temperature_step) : 1;
|
||||
const rawNext = clamp(Number(draft?.target ?? device.target_temperature) + Number(delta), minTemp, maxTemp);
|
||||
const next = Math.round(rawNext / step) * step;
|
||||
device.target_temperature = next;
|
||||
clearTimeout(draft?.timer);
|
||||
draft = {
|
||||
...(draft || {}),
|
||||
target: next,
|
||||
timer: setTimeout(async () => {
|
||||
const pending = app.deviceTemperatureDrafts[id];
|
||||
if (!pending) return;
|
||||
delete app.deviceTemperatureDrafts[id];
|
||||
await sendDeviceCommand(id, { target_temperature: pending.target }, { disabledZoneConfirmed: pending.disabledZoneConfirmed === true });
|
||||
}, 300),
|
||||
};
|
||||
app.deviceTemperatureDrafts[id] = draft;
|
||||
renderDevices();
|
||||
}
|
||||
|
||||
function enqueueClimateControlTask(task) {
|
||||
const previous = app.climateControlQueue || Promise.resolve();
|
||||
const request = previous.catch(() => { }).then(task);
|
||||
app.climateControlQueue = request;
|
||||
request.finally(() => {
|
||||
if (app.climateControlQueue === request) app.climateControlQueue = null;
|
||||
}).catch(() => { });
|
||||
return request;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
function enqueueZoneControlRequest(id, patch) {
|
||||
const previous = app.zoneControlQueue[id] || Promise.resolve();
|
||||
const request = previous.catch(() => { }).then(() =>
|
||||
api(`/api/zones/${encodeURIComponent(id)}/control`, { method: 'POST', body: patch })
|
||||
);
|
||||
app.zoneControlQueue[id] = request;
|
||||
request.finally(() => {
|
||||
if (app.zoneControlQueue[id] === request) delete app.zoneControlQueue[id];
|
||||
}).catch(() => { });
|
||||
return request;
|
||||
}
|
||||
|
||||
async function sendZoneLocalPower(id, power) {
|
||||
const sequence = (app.zoneControlSeq[id] || 0) + 1;
|
||||
app.zoneControlSeq[id] = sequence;
|
||||
try {
|
||||
const zone = await enqueueZoneControlRequest(id, { power });
|
||||
if (app.zoneControlSeq[id] !== sequence) return;
|
||||
const index = app.zones.findIndex(item => item.id === zone.id);
|
||||
if (index >= 0) app.zones[index] = zone; else app.zones.push(zone);
|
||||
renderAll(); scheduleControlPlanLoad(); toast(tr('zones.localPowerUpdated'));
|
||||
} catch (error) {
|
||||
if (app.zoneControlSeq[id] === sequence) { await loadBootstrap(); toast(error.message, true); }
|
||||
}
|
||||
}
|
||||
|
||||
async function sendZoneControl(id, patch) {
|
||||
const sequence = (app.zoneControlSeq[id] || 0) + 1;
|
||||
app.zoneControlSeq[id] = sequence;
|
||||
try {
|
||||
const zone = await enqueueZoneControlRequest(id, patch);
|
||||
if (app.zoneControlSeq[id] !== sequence) return;
|
||||
const index = app.zones.findIndex(item => item.id === zone.id);
|
||||
if (index >= 0) app.zones[index] = zone; else app.zones.push(zone);
|
||||
renderSummary(); renderZones(); scheduleControlPlanLoad();
|
||||
} catch (error) {
|
||||
if (app.zoneControlSeq[id] === sequence) { await loadBootstrap(); toast(error.message, true); }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
async function cancelCompressorTask(id) {
|
||||
try {
|
||||
const result = await api(`/api/zones/${encodeURIComponent(id)}/compressor-queue/cancel`, { method: 'POST' });
|
||||
if (result.zone) {
|
||||
const index = app.zones.findIndex(item => item.id === result.zone.id);
|
||||
if (index >= 0) app.zones[index] = result.zone; else app.zones.push(result.zone);
|
||||
}
|
||||
renderAll(); scheduleControlPlanLoad();
|
||||
toast(tr(result.cancelled ? 'zones.queuedCancelled' : 'zones.noQueuedTask'));
|
||||
} catch (error) { toast(error.message, true); }
|
||||
}
|
||||
|
||||
async function cancelAllCompressorTasks() {
|
||||
try {
|
||||
const result = await api('/api/compressor-queue/cancel-all', { method: 'POST' });
|
||||
(result.zones || []).forEach(zone => {
|
||||
const index = app.zones.findIndex(item => item.id === zone.id);
|
||||
if (index >= 0) app.zones[index] = zone; else app.zones.push(zone);
|
||||
});
|
||||
renderAll(); scheduleControlPlanLoad();
|
||||
toast(tr('zones.queuedCancelledAll', { count: Number(result.cancelled || 0) }));
|
||||
} catch (error) { toast(error.message, true); }
|
||||
}
|
||||
|
||||
function queueZoneTemperature(zone, value, { snapToHalf = true } = {}) {
|
||||
const clamped = clamp(value, 8, 30);
|
||||
const next = snapToHalf ? Math.round(clamped * 2) / 2 : Math.round(clamped * 10) / 10;
|
||||
zone.manual_setpoint = next;
|
||||
zone.setpoint = next;
|
||||
zone.effective_setpoint = next;
|
||||
renderZones();
|
||||
clearTimeout(app.zoneTemperatureTimers[zone.id]);
|
||||
app.zoneTemperatureTimers[zone.id] = setTimeout(() => sendZoneControl(zone.id, { setpoint: next }), 160);
|
||||
}
|
||||
|
||||
function beginInlineTemperatureEdit(target) {
|
||||
if (!target || target.querySelector('input') || target.dataset.editable === 'false') return;
|
||||
const kind = target.dataset.temperatureKind;
|
||||
const id = target.dataset.id;
|
||||
if (kind === 'device' && app.deviceTemperatureDrafts[id]) {
|
||||
clearTimeout(app.deviceTemperatureDrafts[id].timer);
|
||||
delete app.deviceTemperatureDrafts[id];
|
||||
}
|
||||
const value = parseDecimal(target.dataset.value);
|
||||
if (!Number.isFinite(value) || !kind || !id) return;
|
||||
const step = kind === 'device' ? Number(target.dataset.tempStep || 1) : 0.5;
|
||||
const minValue = kind === 'device' ? Number(target.dataset.tempMin || 8) : 8;
|
||||
const maxValue = kind === 'device' ? Number(target.dataset.tempMax || 30) : 30;
|
||||
const decimals = kind === 'zone' || step < 1 ? 1 : 0;
|
||||
const input = document.createElement('input');
|
||||
input.className = 'inline-temperature-input';
|
||||
input.type = 'text';
|
||||
input.inputMode = 'decimal';
|
||||
input.value = value.toFixed(decimals);
|
||||
input.setAttribute('aria-label', tr('common.targetTemperature'));
|
||||
input.title = tr('common.targetTemperature');
|
||||
target.classList.add('editing');
|
||||
target.replaceChildren(input);
|
||||
input.focus();
|
||||
input.select();
|
||||
|
||||
let finished = false;
|
||||
const finish = async commit => {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
const parsed = parseDecimal(input.value);
|
||||
if (!commit || !Number.isFinite(parsed) || parsed < minValue || parsed > maxValue) {
|
||||
if (commit && (!Number.isFinite(parsed) || parsed < minValue || parsed > maxValue)) toast(tr('validation.range', { min: minValue, max: maxValue }), true);
|
||||
renderAll();
|
||||
return;
|
||||
}
|
||||
if (kind === 'zone') {
|
||||
const zone = app.zones.find(item => item.id === id);
|
||||
if (zone) queueZoneTemperature(zone, parsed, { snapToHalf: false });
|
||||
return;
|
||||
}
|
||||
const device = app.devices.find(item => item.id === id);
|
||||
if (device) {
|
||||
const snapped = Math.round(clamp(parsed, minValue, maxValue) / step) * step;
|
||||
await sendDeviceCommand(id, { target_temperature: snapped });
|
||||
}
|
||||
};
|
||||
input.addEventListener('keydown', event => {
|
||||
if (event.key === 'Enter') { event.preventDefault(); input.blur(); }
|
||||
if (event.key === 'Escape') { event.preventDefault(); finished = true; renderAll(); }
|
||||
});
|
||||
input.addEventListener('blur', () => finish(true), { once: true });
|
||||
}
|
||||
|
||||
function showDiscoveryNames(ids) {
|
||||
const wanted = new Set(Array.isArray(ids) ? ids : []);
|
||||
const devices = app.devices.filter(device => wanted.has(device.id));
|
||||
if (!devices.length) return;
|
||||
const list = $('#discoveryNamesList');
|
||||
list.innerHTML = devices.map(device => `
|
||||
<label class="discovery-name-row">
|
||||
<span><strong>${esc(device.model || 'GREE')}</strong><small>${esc(device.ip)} · ${esc(device.mac)} · ${device.protocol_version === 2 ? 'V2 GCM' : 'V1 ECB'}</small></span>
|
||||
<input data-device-id="${esc(device.id)}" maxlength="80" required value="${esc(device.name)}">
|
||||
</label>`).join('');
|
||||
openDialog('discoveryNamesDialog');
|
||||
}
|
||||
|
||||
function populateDeviceRename(id) {
|
||||
const device = app.devices.find(item => item.id === id); if (!device) return;
|
||||
const form = $('#renameDeviceForm'); form.reset();
|
||||
const isCloud = device.connection_type === 'gree_cloud';
|
||||
form.id.value = device.id; form.name.value = device.name; form.ip.value = device.ip || ''; form.port.value = String(device.port || 7000); form.protocol_version.value = String(device.protocol_version ?? 0);
|
||||
form.ip.disabled = isCloud; form.port.disabled = isCloud; form.protocol_version.disabled = isCloud;
|
||||
const result = $('#deviceConfigCheckResult');
|
||||
if (result) { result.hidden = true; result.textContent = ''; result.classList.remove('success', 'error'); }
|
||||
openDialog('renameDeviceDialog');
|
||||
}
|
||||
|
||||
async function deleteEntity(type, id, labelKey) {
|
||||
if (!confirm(tr('confirm.delete', { label: tr(labelKey) }))) return;
|
||||
try { await api(`/api/${type}/${encodeURIComponent(id)}`, { method: 'DELETE' }); await loadBootstrap(); toast(tr('common.removed')); }
|
||||
catch (error) { toast(error.message, true); }
|
||||
}
|
||||
|
||||
function openDialog(id) {
|
||||
fillSelects();
|
||||
const dialog = document.getElementById(id);
|
||||
if (dialog && !dialog.open) {
|
||||
const form = $('form', dialog);
|
||||
if (form) { clearFormErrors(form); markFormClean(form); }
|
||||
dialog.showModal();
|
||||
}
|
||||
}
|
||||
|
||||
function updateZoneHysteresisFields({ syncFromCommon = false } = {}) {
|
||||
const form = $('#zoneForm');
|
||||
if (!form) return;
|
||||
const separate = !!form.separate_hysteresis?.checked;
|
||||
$('#commonHysteresisField').hidden = separate;
|
||||
$('#separateHysteresisFields').hidden = !separate;
|
||||
if (separate && syncFromCommon && form.dataset.separateHysteresisInitialized !== 'true') {
|
||||
const common = parseDecimal(form.hysteresis.value);
|
||||
if (Number.isFinite(common)) {
|
||||
form.cool_hysteresis.value = String(common);
|
||||
form.heat_hysteresis.value = String(common);
|
||||
}
|
||||
form.dataset.separateHysteresisInitialized = 'true';
|
||||
}
|
||||
}
|
||||
|
||||
function updateZoneSensorFields() {
|
||||
const form = $('#zoneForm');
|
||||
if (!form) return;
|
||||
const external = form.sensor_source.value !== 'device';
|
||||
$('#externalSensorFields').hidden = !external;
|
||||
form.ha_entity_id.required = external;
|
||||
}
|
||||
|
||||
function updateSchedulePresetField() {
|
||||
const form = $('#scheduleForm'); if (!form) return;
|
||||
$('#scheduleSetpointField').hidden = form.preset.value !== 'custom';
|
||||
form.setpoint.required = form.preset.value === 'custom';
|
||||
}
|
||||
|
||||
function updateAutomationTargetFields() {
|
||||
const form = $('#automationForm'); if (!form) return;
|
||||
const groupTarget = form.action_target_kind.value === 'group';
|
||||
$('#automationDeviceTarget').hidden = groupTarget;
|
||||
$('#automationGroupTarget').hidden = !groupTarget;
|
||||
$('#automationGroupPreset').hidden = !groupTarget;
|
||||
$('#automationTargetTemperature').hidden = groupTarget;
|
||||
$('#automationDeviceSwingOptions').hidden = groupTarget;
|
||||
$('#automationGroupHint').hidden = !groupTarget;
|
||||
form.action_device_id.required = !groupTarget;
|
||||
form.action_group_id.required = groupTarget;
|
||||
form.action_target_temperature.disabled = groupTarget;
|
||||
form.action_swing_vertical.disabled = groupTarget;
|
||||
form.action_swing_horizontal.disabled = groupTarget;
|
||||
if (groupTarget) {
|
||||
form.action_target_temperature.value = '';
|
||||
form.action_swing_vertical.value = '';
|
||||
form.action_swing_horizontal.value = '';
|
||||
}
|
||||
[...form.action_mode.options].forEach(option => {
|
||||
if (!['dry', 'fan'].includes(option.value)) return;
|
||||
option.disabled = groupTarget;
|
||||
option.hidden = groupTarget;
|
||||
});
|
||||
if (groupTarget && ['dry', 'fan'].includes(form.action_mode.value)) form.action_mode.value = '';
|
||||
}
|
||||
|
||||
function populateZone(id) {
|
||||
const item = app.zones.find(v => v.id === id); if (!item) return;
|
||||
const form = $('#zoneForm');
|
||||
form.reset();
|
||||
form.dataset.editingZoneId = item.id;
|
||||
form.elements.id.value = item.id;
|
||||
fillSelects();
|
||||
Object.entries(item).forEach(([key, value]) => { if (key !== 'id' && form.elements[key] && value != null && typeof value !== 'object') form.elements[key].value = value; });
|
||||
form.mode_policy.value = item.inherit_house_mode ? 'house' : (item.mode || 'cool');
|
||||
if (Number(item.profile_version || 0) === 0) {
|
||||
if ((item.mode || 'cool') === 'heat') form.heat_comfort_setpoint.value = Number(item.setpoint ?? 21);
|
||||
else form.cool_comfort_setpoint.value = Number(item.setpoint ?? 23);
|
||||
}
|
||||
form.external_sensor_weight_percent.value = Math.round(Number(item.external_sensor_weight ?? 0.4) * 100);
|
||||
form.max_sensor_difference.value = Number(item.max_sensor_difference ?? 3);
|
||||
form.min_adjust_seconds.value = Number(item.min_adjust_seconds ?? 120);
|
||||
form.standby_offset_c.value = Number(item.standby_offset_c ?? 2);
|
||||
form.smart_fan.checked = item.smart_fan !== false;
|
||||
form.separate_hysteresis.checked = item.separate_hysteresis === true;
|
||||
const commonHysteresis = Number(item.hysteresis ?? 0.6);
|
||||
form.cool_hysteresis.value = Number(item.separate_hysteresis ? (item.cool_hysteresis ?? commonHysteresis) : commonHysteresis);
|
||||
form.heat_hysteresis.value = Number(item.separate_hysteresis ? (item.heat_hysteresis ?? commonHysteresis) : commonHysteresis);
|
||||
form.dataset.separateHysteresisInitialized = item.separate_hysteresis === true ? 'true' : 'false';
|
||||
form.enabled.checked = item.enabled; updateZoneSensorFields(); updateZoneHysteresisFields(); openDialog('zoneDialog');
|
||||
}
|
||||
|
||||
const dateTimeLocalValue = value => {
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
if (!Number.isFinite(date.getTime())) return '';
|
||||
const pad = number => String(number).padStart(2, '0');
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
||||
};
|
||||
|
||||
function updateTemporaryThermostatFields() {
|
||||
const form = $('#temporaryThermostatForm'); if (!form) return;
|
||||
const startKind = form.start_kind.value;
|
||||
const kind = form.finish_kind.value;
|
||||
const startDelay = $('#temporaryStartDelayFields');
|
||||
const startAt = $('#temporaryStartAtFields');
|
||||
const duration = $('#temporaryDurationFields');
|
||||
const until = $('#temporaryUntilFields');
|
||||
const temperature = $('#temporaryTemperatureFields');
|
||||
const schedule = $('#temporaryScheduleFields');
|
||||
startDelay.hidden = startKind !== 'delay';
|
||||
startAt.hidden = startKind !== 'at';
|
||||
duration.hidden = kind !== 'duration';
|
||||
until.hidden = kind !== 'until';
|
||||
temperature.hidden = !['temperature_reached', 'temperature_stable'].includes(kind);
|
||||
schedule.hidden = kind !== 'schedule_boundary';
|
||||
$('#temporaryHoldField').hidden = kind !== 'temperature_stable';
|
||||
form.start_delay_minutes.required = startKind === 'delay';
|
||||
form.start_at.required = startKind === 'at';
|
||||
form.duration_minutes.required = kind === 'duration';
|
||||
form.until.required = kind === 'until';
|
||||
form.hold_minutes.required = kind === 'temperature_stable';
|
||||
}
|
||||
|
||||
function populateTemporaryThermostat(id) {
|
||||
const zone = app.zones.find(item => item.id === id); if (!zone) return;
|
||||
const form = $('#temporaryThermostatForm');
|
||||
form.reset();
|
||||
form.elements.zone_id.value = zone.id;
|
||||
form.target_temperature.value = Number(zone.manual_setpoint ?? zone.effective_setpoint ?? zone.setpoint ?? 23).toFixed(1);
|
||||
form.start_kind.value = 'now';
|
||||
form.start_delay_minutes.value = '30';
|
||||
form.start_at.value = dateTimeLocalValue(new Date(Date.now() + 60 * 60 * 1000));
|
||||
form.until.value = dateTimeLocalValue(new Date(Date.now() + 2 * 60 * 60 * 1000));
|
||||
const session = zone.temporary_quick_thermostat;
|
||||
const activeSession = !!session?.activated_at;
|
||||
form.dataset.activeSession = activeSession ? 'true' : 'false';
|
||||
if (session) {
|
||||
form.start_kind.value = session.start_kind || (new Date(session.started_at).getTime() > Date.now() ? 'at' : 'now');
|
||||
if (form.start_kind.value === 'delay' && session.started_at) {
|
||||
form.start_delay_minutes.value = Math.max(1, Math.ceil((new Date(session.started_at).getTime() - Date.now()) / 60000));
|
||||
}
|
||||
if (form.start_kind.value === 'at' && session.started_at) form.start_at.value = dateTimeLocalValue(session.started_at);
|
||||
form.finish_kind.value = session.finish_kind || 'duration';
|
||||
if (session.temperature_target != null) form.target_temperature.value = Number(session.temperature_target).toFixed(1);
|
||||
if (session.temperature_operator) form.temperature_operator.value = session.temperature_operator;
|
||||
form.tolerance_c.value = Number(session.tolerance_c ?? 0.3).toFixed(1);
|
||||
if (session.hold_seconds) form.hold_minutes.value = Math.max(1, Math.round(Number(session.hold_seconds) / 60));
|
||||
if (session.finish_kind === 'duration') {
|
||||
if (session.duration_seconds) {
|
||||
form.duration_minutes.value = Math.max(1, Math.round(Number(session.duration_seconds) / 60));
|
||||
} else if (session.expires_at) {
|
||||
const base = new Date(session.activated_at || session.started_at || Date.now()).getTime();
|
||||
form.duration_minutes.value = Math.max(1, Math.round((new Date(session.expires_at).getTime() - base) / 60000));
|
||||
}
|
||||
}
|
||||
if (session.expires_at && session.finish_kind !== 'duration') form.until.value = dateTimeLocalValue(session.expires_at);
|
||||
if (session.safety_duration_seconds) {
|
||||
form.max_duration_minutes.value = Math.max(1, Math.round(Number(session.safety_duration_seconds) / 60));
|
||||
} else if (session.safety_expires_at) {
|
||||
const base = new Date(session.activated_at || session.started_at || Date.now()).getTime();
|
||||
form.max_duration_minutes.value = Math.max(1, Math.round((new Date(session.safety_expires_at).getTime() - base) / 60000));
|
||||
} else if (['temperature_reached', 'temperature_stable'].includes(session.finish_kind)) {
|
||||
form.max_duration_minutes.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
form.start_kind.disabled = activeSession;
|
||||
form.start_delay_minutes.disabled = activeSession;
|
||||
form.start_at.disabled = activeSession;
|
||||
$$('[data-temporary-start-delay]').forEach(button => { button.disabled = activeSession; });
|
||||
|
||||
updateTemporaryThermostatFields();
|
||||
const active = $('#temporaryThermostatActive');
|
||||
active.hidden = !session;
|
||||
$('#temporaryThermostatStop').dataset.id = zone.id;
|
||||
$('#temporaryThermostatSubmit').textContent = tr(session ? 'zones.temporaryUpdate' : 'zones.temporaryStart');
|
||||
updateTemporaryThermostatCountdowns();
|
||||
openDialog('temporaryThermostatDialog');
|
||||
updateTemporaryThermostatCountdowns();
|
||||
}
|
||||
|
||||
function populateSchedule(id) {
|
||||
const item = app.schedules.find(v => v.id === id); if (!item) return;
|
||||
const form = $('#scheduleForm'); form.reset(); fillSelects();
|
||||
['id', 'name', 'zone_id', 'start_time', 'end_time', 'setpoint', 'preset'].forEach(key => { if (form.elements[key] && item[key] != null) form.elements[key].value = item[key]; });
|
||||
form.enabled.checked = item.enabled;
|
||||
$$('[name=weekday]', form).forEach(input => input.checked = item.weekdays.includes(Number(input.value)));
|
||||
updateSchedulePresetField(); openDialog('scheduleDialog');
|
||||
}
|
||||
|
||||
function populateAutomation(id) {
|
||||
const item = app.automations.find(v => v.id === id); if (!item) return;
|
||||
const form = $('#automationForm'); form.reset(); fillSelects();
|
||||
['id', 'name', 'trigger_kind', 'trigger_device_id', 'threshold', 'at_time', 'action_device_id', 'cooldown_seconds'].forEach(key => { if (form.elements[key] && item[key] != null) form.elements[key].value = item[key]; });
|
||||
const groupTarget = !!item.action_group_id;
|
||||
form.action_target_kind.value = groupTarget ? 'group' : 'device';
|
||||
if (groupTarget) form.action_group_id.value = item.action_group_id;
|
||||
form.action_power.value = item.action.power == null ? '' : String(item.action.power);
|
||||
form.action_mode.value = item.action.mode || '';
|
||||
form.action_preset.value = item.action_preset || '';
|
||||
form.action_target_temperature.value = item.action.target_temperature ?? '';
|
||||
form.action_swing_vertical.value = item.action.swing_vertical == null ? '' : String(item.action.swing_vertical);
|
||||
form.action_swing_horizontal.value = item.action.swing_horizontal == null ? '' : String(item.action.swing_horizontal);
|
||||
form.enabled.checked = item.enabled;
|
||||
updateAutomationTargetFields();
|
||||
openDialog('automationDialog');
|
||||
}
|
||||
|
||||
|
||||
async function openDeviceDetails(id) {
|
||||
const device = app.devices.find(item => item.id === id); if (!device) return;
|
||||
const form = $('#deviceDetailsForm'); if (!form) return;
|
||||
form.reset();
|
||||
form.elements.id.value = device.id;
|
||||
form.name.value = device.name || '';
|
||||
form.energy_source.value = device.energy_source || 'auto';
|
||||
const title = $('#deviceDetailsTitle');
|
||||
if (title) title.textContent = device.connection_type === 'gree_cloud' ? tr('devices.cloudDetails') : tr('energy.title');
|
||||
const meta = $('#deviceDetailsMeta');
|
||||
if (meta) meta.innerHTML = device.connection_type === 'gree_cloud'
|
||||
? `<div><span>${esc(tr('devices.connection'))}</span><strong>GREE Cloud</strong></div><div><span>${esc(tr('devices.transport'))}</span><strong>MQTT / TLS</strong></div><div><span>${esc(tr('devices.cloudDeviceId'))}</span><strong>${esc(device.cloud_device_id || device.mac || '—')}</strong></div><div><span>MAC</span><strong>${esc(device.mac || '—')}</strong></div>`
|
||||
: `<div><span>${esc(tr('devices.connection'))}</span><strong>Local</strong></div><div><span>${esc(tr('devices.address'))}</span><strong>${esc(device.ip || '—')}:${esc(device.port || 7000)}</strong></div><div><span>MAC</span><strong>${esc(device.mac || '—')}</strong></div>`;
|
||||
|
||||
const installation = deviceInstallationForDevice(device.id);
|
||||
const groupNote = $('#deviceEnergyGroupNote');
|
||||
const groupOwnsEnergy = !!installation && (!!installation.energy_device_id || !!installation.ha_energy_entity_id);
|
||||
if (groupNote) {
|
||||
groupNote.hidden = !groupOwnsEnergy;
|
||||
groupNote.innerHTML = groupOwnsEnergy
|
||||
? `<span>${esc(deviceInstallationKindLabel(installation))}</span><strong>${esc(tr('devices.groupedEnergyNote', { name: installation.name, source: installationEnergySourceLabel(installation) }))}</strong>`
|
||||
: '';
|
||||
}
|
||||
|
||||
const source = form.energy_source;
|
||||
if (groupOwnsEnergy) source.value = installation.energy_source || 'auto';
|
||||
source.disabled = groupOwnsEnergy;
|
||||
const cloudOption = [...source.options].find(option => option.value === 'gree_cloud');
|
||||
if (cloudOption) cloudOption.disabled = device.capabilities?.energy_meter !== true;
|
||||
const sensorSelect = form.ha_energy_entity_id;
|
||||
sensorSelect.disabled = groupOwnsEnergy;
|
||||
sensorSelect.innerHTML = '<option value="">—</option>';
|
||||
const haConfigured = Boolean(String(app.settings?.home_assistant?.url || '').trim() && app.settings?.home_assistant?.token_configured);
|
||||
if (haConfigured) {
|
||||
try {
|
||||
const response = await api('/api/integrations/home-assistant/energy-sensors');
|
||||
for (const sensor of (response.sensors || [])) {
|
||||
const option = document.createElement('option');
|
||||
option.value = sensor.entity_id;
|
||||
option.textContent = `${sensor.name || sensor.entity_id} · ${sensor.unit || ''}`;
|
||||
option.dataset.unit = sensor.unit || '';
|
||||
option.dataset.deviceClass = sensor.device_class || '';
|
||||
option.dataset.stateClass = sensor.state_class || '';
|
||||
sensorSelect.append(option);
|
||||
}
|
||||
} catch (_) { }
|
||||
}
|
||||
const energyEntity = groupOwnsEnergy ? installation.ha_energy_entity_id : device.ha_energy_entity_id;
|
||||
const energyUnit = groupOwnsEnergy ? installation.ha_energy_unit : device.ha_energy_unit;
|
||||
const energyDeviceClass = groupOwnsEnergy ? installation.ha_energy_device_class : device.ha_energy_device_class;
|
||||
const energyStateClass = groupOwnsEnergy ? installation.ha_energy_state_class : device.ha_energy_state_class;
|
||||
if (energyEntity && ![...sensorSelect.options].some(option => option.value === energyEntity)) {
|
||||
const option = document.createElement('option');
|
||||
option.value = energyEntity;
|
||||
option.textContent = energyEntity;
|
||||
option.dataset.unit = energyUnit || '';
|
||||
option.dataset.deviceClass = energyDeviceClass || '';
|
||||
option.dataset.stateClass = energyStateClass || '';
|
||||
sensorSelect.append(option);
|
||||
}
|
||||
sensorSelect.value = energyEntity || '';
|
||||
updateDeviceEnergySensorMeta();
|
||||
openDialog('deviceDetailsDialog');
|
||||
}
|
||||
|
||||
function renderDeviceGroupsDialogList() {
|
||||
const host = $('#deviceGroupsList'); if (!host) return;
|
||||
const groups = app.deviceGroups || [];
|
||||
host.innerHTML = groups.length ? groups.map(group => {
|
||||
const members = (group.device_ids || []).map(id => app.devices.find(device => device.id === id)?.name).filter(Boolean);
|
||||
return `<div class="installation-list-row"><button type="button" data-action="edit-device-group" data-id="${esc(group.id)}"><strong>${esc(group.name)}</strong><small>${esc(deviceInstallationKindLabel(group))} · ${esc(members.join(' · ') || '—')} · ${esc(installationEnergySourceLabel(group))}</small></button><div><button type="button" class="secondary" data-action="edit-device-group" data-id="${esc(group.id)}">${esc(tr('actions.edit'))}</button><button type="button" class="danger" data-action="delete-device-group" data-id="${esc(group.id)}">${esc(tr('actions.delete'))}</button></div></div>`;
|
||||
}).join('') : `<div class="empty"><strong>${esc(tr('devices.newInstallation'))}</strong><span>${esc(tr('devices.installationsHint'))}</span></div>`;
|
||||
}
|
||||
|
||||
function selectedDeviceGroupIds() {
|
||||
return $$('#deviceGroupDeviceChoices input[type="checkbox"]:checked').map(input => input.value);
|
||||
}
|
||||
|
||||
function syncDeviceGroupMemberSelects() {
|
||||
const form = $('#deviceGroupForm'); if (!form) return;
|
||||
const ids = selectedDeviceGroupIds();
|
||||
const energy = form.energy_device_id;
|
||||
const outdoor = form.outdoor_temperature_device_id;
|
||||
const oldEnergy = energy.value, oldOutdoor = outdoor.value;
|
||||
energy.innerHTML = '<option value="">—</option>' + ids.map(id => {
|
||||
const device = app.devices.find(item => item.id === id);
|
||||
if (!device) return '';
|
||||
const supported = device.connection_type === 'gree_cloud' && device.capabilities?.energy_meter === true;
|
||||
return `<option value="${esc(id)}" ${supported ? '' : 'disabled'}>${esc(device.name)}${supported ? '' : ' · —'}</option>`;
|
||||
}).join('');
|
||||
outdoor.innerHTML = `<option value="">${esc(tr('devices.noSharedOutdoor'))}</option>` + ids.map(id => {
|
||||
const device = app.devices.find(item => item.id === id);
|
||||
return device ? `<option value="${esc(id)}">${esc(device.name)}</option>` : '';
|
||||
}).join('');
|
||||
if ([...energy.options].some(option => option.value === oldEnergy && !option.disabled)) energy.value = oldEnergy;
|
||||
if ([...outdoor.options].some(option => option.value === oldOutdoor)) outdoor.value = oldOutdoor;
|
||||
}
|
||||
|
||||
function renderDeviceGroupDeviceChoices(selectedIds = []) {
|
||||
const form = $('#deviceGroupForm');
|
||||
const host = $('#deviceGroupDeviceChoices'); if (!form || !host) return;
|
||||
const currentId = form.elements.id.value;
|
||||
const occupied = new Map();
|
||||
for (const group of (app.deviceGroups || [])) {
|
||||
if (group.id === currentId) continue;
|
||||
for (const id of (group.device_ids || [])) occupied.set(id, group.name);
|
||||
}
|
||||
host.innerHTML = app.devices.map(device => {
|
||||
const owner = occupied.get(device.id);
|
||||
const checked = selectedIds.includes(device.id);
|
||||
return `<label class="check ${owner ? 'disabled' : ''}" title="${owner ? esc(owner) : ''}"><input type="checkbox" name="device_ids" value="${esc(device.id)}" ${checked ? 'checked' : ''} ${owner ? 'disabled' : ''}> <span>${esc(device.name)}${owner ? ` · ${esc(owner)}` : ''}</span></label>`;
|
||||
}).join('');
|
||||
syncDeviceGroupMemberSelects();
|
||||
}
|
||||
|
||||
async function loadDeviceGroupEnergySensors(group = null) {
|
||||
const form = $('#deviceGroupForm'); if (!form) return;
|
||||
const select = form.ha_energy_entity_id;
|
||||
select.innerHTML = '<option value="">—</option>';
|
||||
const haConfigured = Boolean(String(app.settings?.home_assistant?.url || '').trim() && app.settings?.home_assistant?.token_configured);
|
||||
if (haConfigured) {
|
||||
try {
|
||||
const response = await api('/api/integrations/home-assistant/energy-sensors');
|
||||
for (const sensor of (response.sensors || [])) {
|
||||
const option = document.createElement('option');
|
||||
option.value = sensor.entity_id;
|
||||
option.textContent = `${sensor.name || sensor.entity_id} · ${sensor.unit || ''}`;
|
||||
option.dataset.unit = sensor.unit || '';
|
||||
option.dataset.deviceClass = sensor.device_class || '';
|
||||
option.dataset.stateClass = sensor.state_class || '';
|
||||
select.append(option);
|
||||
}
|
||||
} catch (_) { }
|
||||
}
|
||||
if (group?.ha_energy_entity_id && ![...select.options].some(option => option.value === group.ha_energy_entity_id)) {
|
||||
const option = document.createElement('option');
|
||||
option.value = group.ha_energy_entity_id;
|
||||
option.textContent = group.ha_energy_entity_id;
|
||||
option.dataset.unit = group.ha_energy_unit || '';
|
||||
option.dataset.deviceClass = group.ha_energy_device_class || '';
|
||||
option.dataset.stateClass = group.ha_energy_state_class || '';
|
||||
select.append(option);
|
||||
}
|
||||
select.value = group?.ha_energy_entity_id || '';
|
||||
updateDeviceGroupEnergySensorMeta();
|
||||
}
|
||||
|
||||
async function populateDeviceGroupForm(group = null) {
|
||||
const form = $('#deviceGroupForm'); if (!form) return;
|
||||
form.reset();
|
||||
form.elements.id.value = group?.id || '';
|
||||
form.name.value = group?.name || '';
|
||||
form.kind.value = group?.kind || 'split';
|
||||
form.energy_source.value = group?.energy_source || 'auto';
|
||||
renderDeviceGroupDeviceChoices(group?.device_ids || []);
|
||||
syncDeviceGroupMemberSelects();
|
||||
form.energy_device_id.value = group?.energy_device_id || '';
|
||||
form.outdoor_temperature_device_id.value = group?.outdoor_temperature_device_id || '';
|
||||
await loadDeviceGroupEnergySensors(group);
|
||||
}
|
||||
|
||||
async function openDeviceGroupsDialog(groupId = '') {
|
||||
renderDeviceGroupsDialogList();
|
||||
const group = groupId ? (app.deviceGroups || []).find(item => item.id === groupId) : null;
|
||||
await populateDeviceGroupForm(group || null);
|
||||
openDialog('deviceGroupsDialog');
|
||||
}
|
||||
|
||||
function updateDeviceGroupEnergySensorMeta() {
|
||||
const select = $('#deviceGroupForm')?.ha_energy_entity_id;
|
||||
const meta = $('#deviceGroupEnergySensorMeta');
|
||||
if (!select || !meta) return;
|
||||
const option = select.selectedOptions?.[0];
|
||||
meta.textContent = option?.value
|
||||
? `${option.value} · ${option.dataset.deviceClass || 'energy'} · ${option.dataset.stateClass || 'total'} · ${option.dataset.unit || 'kWh'}`
|
||||
: tr('energy.noHaSensor');
|
||||
}
|
||||
|
||||
function updateDeviceEnergySensorMeta() {
|
||||
const select = $('#deviceDetailsForm')?.ha_energy_entity_id;
|
||||
const meta = $('#deviceEnergySensorMeta');
|
||||
if (!select || !meta) return;
|
||||
const option = select.selectedOptions?.[0];
|
||||
meta.textContent = option?.value
|
||||
? `${option.value} · ${option.dataset.deviceClass || 'energy'} · ${option.dataset.stateClass || 'total'} · ${option.dataset.unit || 'kWh'}`
|
||||
: tr('energy.noHaSensor');
|
||||
}
|
||||
|
||||
async function openCloudDiagnostics(id) {
|
||||
const dialog = $('#cloudDiagnosticsDialog');
|
||||
const payload = $('#cloudDiagnosticsPayload');
|
||||
if (!dialog || !payload) return;
|
||||
dialog.dataset.deviceId = id;
|
||||
payload.textContent = tr('common.loading');
|
||||
openDialog('cloudDiagnosticsDialog');
|
||||
try {
|
||||
const diagnostics = await api(`/api/devices/${encodeURIComponent(id)}/cloud-diagnostics`);
|
||||
payload.textContent = JSON.stringify(diagnostics, null, 2);
|
||||
} catch (error) {
|
||||
payload.textContent = error.message || String(error);
|
||||
}
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
function logNotificationBadge(item) {
|
||||
const notification = item?.metadata?.notification;
|
||||
if (notification?.status !== 'silent') return '';
|
||||
const reasonKey = {
|
||||
alert_type_disabled: 'logs.silentAlertTypeDisabled',
|
||||
notifications_disabled: 'logs.silentNotificationsDisabled',
|
||||
mode_filtered: 'logs.silentModeFiltered',
|
||||
cooldown: 'logs.silentCooldown',
|
||||
}[notification.reason] || 'logs.silentHint';
|
||||
return `<span class="badge silent log-notification-badge" title="${esc(tr(reasonKey))}">${esc(tr('logs.silent'))}</span>`;
|
||||
}
|
||||
|
||||
function logDisplayMessage(item) {
|
||||
const message = String(item?.message || '');
|
||||
if (!String(item?.kind || '').startsWith('ha.sensor_')) return message;
|
||||
const metadata = item?.metadata || {};
|
||||
const entityId = metadata.resolved_entity_id || metadata.configured_entity_id || '';
|
||||
if (!entityId || message.includes(entityId)) return message;
|
||||
const alias = haSensorLabel(entityId);
|
||||
const sensor = alias && alias !== entityId ? `${alias} (${entityId})` : entityId;
|
||||
const zoneName = metadata.zone_name || app.zones?.find(zone => zone.id === metadata.zone_id)?.name || '';
|
||||
return `${message} · ${tr('history.haSensor')}: ${sensor}${zoneName ? ` · ${tr('common.zone')}: ${zoneName}` : ''}`;
|
||||
}
|
||||
|
||||
|
||||
function logCategoryIcon(category) {
|
||||
return {
|
||||
device: 'devices',
|
||||
zone: 'zones',
|
||||
automation: 'automation',
|
||||
integration: 'integration',
|
||||
settings: 'sliders',
|
||||
system: 'system-info',
|
||||
}[category] || 'system-info';
|
||||
}
|
||||
|
||||
async function loadLogs() {
|
||||
renderLogRetention();
|
||||
try {
|
||||
const data = await api('/api/events?limit=150');
|
||||
const level = $('#logLevelFilter')?.value || 'all', category = $('#logCategoryFilter')?.value || 'all';
|
||||
const logs = (data.events || []).filter(item => (level === 'all' || item.level === level) && (category === 'all' || logCategory(item.kind) === category));
|
||||
$('#logList').innerHTML = logs.length
|
||||
? logs.map(item => `<div class="log-row ${esc(item.level)} category-${esc(logCategory(item.kind))}"><time>${esc(new Date(item.timestamp).toLocaleTimeString(locale()))}</time><span class="log-category">${uiIcon(logCategoryIcon(logCategory(item.kind)))}${esc(logCategory(item.kind))}</span><span class="kind">${esc(item.kind)}${logNotificationBadge(item)}</span><span class="message">${esc(logDisplayMessage(item))}</span></div>`).join('')
|
||||
: `<div class="empty"><strong>${esc(tr('logs.emptyTitle'))}</strong>${esc(tr('logs.emptyText'))}</div>`;
|
||||
} catch (error) { toast(error.message, true); }
|
||||
}
|
||||
|
||||
async function handleWebSocketMessage(event) {
|
||||
try {
|
||||
const message = JSON.parse(event.data);
|
||||
if (message.event === 'bootstrap') {
|
||||
const data = message.data || {};
|
||||
const hasControlPlan = applyBootstrapSnapshot(data);
|
||||
app.controlPlanPushReady = hasControlPlan;
|
||||
if (hasControlPlan) stopControlPlanFallback();
|
||||
renderAll();
|
||||
if (!hasControlPlan) scheduleControlPlanLoad(0);
|
||||
if (app.settings?.debug?.overlay_enabled) loadDebugBacklog();
|
||||
return;
|
||||
}
|
||||
const data = message.data || {};
|
||||
if (message.event === 'control_plan.updated') { app.controlPlanPushReady = true; stopControlPlanFallback(); applyControlPlan(data.plan, data.revision); }
|
||||
else if (message.event === 'device.updated') { updateDevice(data); renderSummary(); renderDevices(); renderGroups(); scheduleControlPlanLoad(); }
|
||||
else if (message.event === 'device.created') { updateDevice(data); renderSummary(); renderDevices(); renderGroups(); fillSelects(); scheduleControlPlanLoad(); }
|
||||
else if (message.event === 'device.deleted') { app.devices = app.devices.filter(v => v.id !== data.id); renderAll(); scheduleControlPlanLoad(); }
|
||||
else if (message.event === 'devices.discovered') { (data.devices || []).forEach(updateDevice); renderAll(); scheduleControlPlanLoad(); }
|
||||
else if (message.event === 'zone.updated') { const i = app.zones.findIndex(v => v.id === data.id); if (i >= 0) app.zones[i] = data; else app.zones.push(data); renderSummary(); renderHouseClimate(); renderZones(); renderDevices(); renderGroups(); scheduleControlPlanLoad(); }
|
||||
else if (message.event === 'zone.created') { const i = app.zones.findIndex(v => v.id === data.id); if (i >= 0) app.zones[i] = data; else app.zones.push(data); renderSummary(); renderHouseClimate(); renderZones(); renderDevices(); renderGroups(); fillSelects(); scheduleControlPlanLoad(); }
|
||||
else if (message.event === 'zone.deleted') { app.zones = app.zones.filter(v => v.id !== data.id); renderAll(); scheduleControlPlanLoad(); }
|
||||
else if (['group.updated', 'group.created'].includes(message.event)) { const i = app.groups.findIndex(v => v.id === data.id); if (i >= 0) app.groups[i] = data; else app.groups.push(data); renderGroups(); fillSelects(); scheduleControlPlanLoad(); }
|
||||
else if (message.event === 'group.deleted') { app.groups = app.groups.filter(v => v.id !== data.id); renderGroups(); fillSelects(); scheduleControlPlanLoad(); }
|
||||
else if (['device_group.updated', 'device_group.created'].includes(message.event)) { const i = app.deviceGroups.findIndex(v => v.id === data.id); if (i >= 0) app.deviceGroups[i] = data; else app.deviceGroups.push(data); renderDevices(); renderHistoryNavigation(); }
|
||||
else if (message.event === 'device_group.deleted') { app.deviceGroups = app.deviceGroups.filter(v => v.id !== data.id); renderDevices(); renderHistoryNavigation(); }
|
||||
else if (message.event === 'energy.updated') {
|
||||
const total = Number(data.total_kwh);
|
||||
if (Number.isFinite(total)) {
|
||||
const snapshot = { total_kwh: total, timestamp: data.timestamp || message.timestamp, source: data.source || '', origin: 'realtime' };
|
||||
const selectedEnergySource = group => group.energy_source === 'gree_cloud' || (group.energy_source === 'auto' && !!group.energy_device_id)
|
||||
? 'gree_cloud'
|
||||
: (group.energy_source === 'home_assistant' || (group.energy_source === 'auto' && !!group.ha_energy_entity_id) ? 'home_assistant' : '');
|
||||
if (String(data.target_id || '').startsWith('group:')) {
|
||||
const groupId = String(data.target_id).slice(6);
|
||||
const group = (app.deviceGroups || []).find(item => item.id === groupId);
|
||||
if (group && selectedEnergySource(group) === snapshot.source) app.deviceGroupEnergy[groupId] = snapshot;
|
||||
} else if (snapshot.source === 'gree_cloud') {
|
||||
(app.deviceGroups || [])
|
||||
.filter(group => group.energy_device_id === data.target_id && selectedEnergySource(group) === 'gree_cloud')
|
||||
.forEach(group => { app.deviceGroupEnergy[group.id] = snapshot; });
|
||||
}
|
||||
renderDevices();
|
||||
}
|
||||
}
|
||||
else if (['schedule.updated', 'schedule.created'].includes(message.event)) { const i = app.schedules.findIndex(v => v.id === data.id); if (i >= 0) app.schedules[i] = data; else app.schedules.push(data); renderSchedules(); fillSelects(); scheduleControlPlanLoad(); }
|
||||
else if (message.event === 'schedule.deleted') { app.schedules = app.schedules.filter(v => v.id !== data.id); renderSchedules(); fillSelects(); scheduleControlPlanLoad(); }
|
||||
else if (message.event === 'schedule.template_applied') { try { app.schedules = await api('/api/schedules'); renderSchedules(); fillSelects(); } catch (_) { } scheduleControlPlanLoad(); }
|
||||
else if (['automation.updated', 'automation.created'].includes(message.event)) { const i = app.automations.findIndex(v => v.id === data.id); if (i >= 0) app.automations[i] = data; else app.automations.push(data); renderAutomations(); fillSelects(); scheduleControlPlanLoad(); }
|
||||
else if (message.event === 'automation.deleted') { app.automations = app.automations.filter(v => v.id !== data.id); renderAutomations(); fillSelects(); scheduleControlPlanLoad(); }
|
||||
else if (['flow.updated', 'flow.created'].includes(message.event)) { const i = app.flows.findIndex(v => v.id === data.id); if (i >= 0) app.flows[i] = data; else app.flows.push(data); renderFlows(); scheduleControlPlanLoad(); }
|
||||
else if (message.event === 'flow.deleted') { app.flows = app.flows.filter(v => v.id !== data.id); renderFlows(); scheduleControlPlanLoad(); }
|
||||
else if (message.event === 'settings.application.updated') { applySettingsSection('application', data); if (!isFormDirty($('#settingsForm'))) renderSettings(); renderSimulationModeBanner(); renderSystemInfo(); }
|
||||
else if (message.event === 'settings.gree.updated') { applySettingsSection('gree', data); if (!isFormDirty($('#settingsForm'))) renderSettings(); scheduleControlPlanLoad(); }
|
||||
else if (message.event === 'settings.gree_cloud.updated') { applySettingsSection('greeCloud', data); if (!isFormDirty($('#settingsForm'))) renderSettings(); }
|
||||
else if (message.event === 'settings.history.updated') { applySettingsSection('history', data); if (!isFormDirty($('#settingsForm'))) renderSettings(); renderLogRetention(); }
|
||||
else if (message.event === 'settings.influxdb.updated') { applySettingsSection('influxdb', data); if (!isFormDirty($('#settingsForm'))) renderSettings(); }
|
||||
else if (message.event === 'settings.notifications.updated') { applySettingsSection('notifications', data); if (!isFormDirty($('#settingsForm'))) renderSettings(); }
|
||||
else if (message.event === 'settings.debug.updated') { applySettingsSection('debug', data); if (!isFormDirty($('#settingsForm'))) renderSettings(); renderDebugOverlay(); if (data.overlay_enabled) loadDebugBacklog(); }
|
||||
else if (message.event === 'settings.night.updated') { applySettingsSection('night', data); if (!isFormDirty($('#nightModeForm'))) renderNightSettings(); renderHouseClimate(); scheduleControlPlanLoad(); }
|
||||
else if (message.event === 'settings.home_assistant.updated') {
|
||||
applySettingsSection('homeAssistant', data);
|
||||
if (!isFormDirty($('#homeAssistantForm'))) { app.sensorAliases = { ...(data.sensor_aliases || {}) }; app.flowSharedInputs = JSON.parse(JSON.stringify(data.flow_inputs || [])); renderHomeAssistantSettings(); }
|
||||
renderHouseClimate(); if (app.flowDraft) renderFlowEditor(); scheduleControlPlanLoad();
|
||||
}
|
||||
else if (message.event === 'house.mode_changed') { app.settings = app.settings || {}; app.settings.house_mode = data.mode || 'cool'; renderHouseClimate(); renderGroups(); scheduleControlPlanLoad(); }
|
||||
else if (message.event === 'outdoor.updated') { app.outdoorTemperature = Number.isFinite(Number(data.temperature)) ? Number(data.temperature) : null; renderHouseClimate(); scheduleControlPlanLoad(); }
|
||||
else if (message.event === 'configuration.imported') { await loadBootstrap(); return; }
|
||||
else if (message.event === 'gree.frame_received') {
|
||||
app.system = app.system || {};
|
||||
app.system.gree_received_frames = Number(data.total || 0);
|
||||
app.system.gree_received_frames_by_device = { ...(app.system.gree_received_frames_by_device || {}) };
|
||||
if (data.device_id) app.system.gree_received_frames_by_device[data.device_id] = Number(data.device_count || 0);
|
||||
renderGreeFrameStats();
|
||||
renderSystemInfo();
|
||||
}
|
||||
else if (message.event === 'gree.frame') { if (app.settings?.debug?.overlay_enabled) debugLine('GREE', `${data.direction || '?'} ${data.protocol_version || ''}`, data.device_name || data.device_id || data.target || '', message.timestamp, data.payload); }
|
||||
else if (message.event === 'gree_cloud.request') { if (app.settings?.debug?.overlay_enabled) debugLine('CLOUD', `${data.operation || 'request'} · ${data.phase || '?'}`, `${data.device_name || data.device_id || data.transport || ''}${data.duration_ms == null ? '' : ` · ${data.duration_ms} ms`}`, message.timestamp, data); }
|
||||
else if (message.event === 'gree_cloud.mqtt') { if (app.settings?.debug?.overlay_enabled) debugLine('MQTT', `${data.direction || '?'}`, `${data.topic || data.broker || ''}${data.device_id ? ` · ${data.device_id}` : ''}`, message.timestamp, data); }
|
||||
else if (message.event === 'api.request') { if (app.settings?.debug?.overlay_enabled) debugLine('HTTP', `${data.method || '?'} ${data.status || ''}`, `${data.path || ''} · ${data.duration_ms ?? '?'} ms`, message.timestamp); }
|
||||
else if (message.event === 'log.created') { if (app.settings?.debug?.overlay_enabled) debugLine('API', data.kind || data.level || 'log', data.message || '', message.timestamp, data.metadata); if (app.currentView === 'logs') loadLogs(); }
|
||||
else if (message.event === 'log.updated') { if (app.currentView === 'logs') loadLogs(); }
|
||||
} catch (_) { }
|
||||
}
|
||||
|
||||
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}${withBase('/ws')}${query}`); app.ws = ws;
|
||||
ws.onopen = () => updateConnectionIndicator('connected');
|
||||
ws.onclose = () => { app.controlPlanPushReady = false; updateConnectionIndicator('disconnected'); scheduleControlPlanLoad(0); app.wsTimer = setTimeout(connectWebSocket, 3000); };
|
||||
ws.onerror = () => updateConnectionIndicator('connectionError');
|
||||
let messageQueue = Promise.resolve();
|
||||
ws.onmessage = event => {
|
||||
messageQueue = messageQueue.then(() => handleWebSocketMessage(event)).catch(() => { });
|
||||
};
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
const VIEW_ROUTES = Object.freeze({
|
||||
dashboard: '/dashboard',
|
||||
devices: '/devices',
|
||||
zones: '/zones',
|
||||
groups: '/groups',
|
||||
schedules: '/schedules',
|
||||
automations: '/automations',
|
||||
flows: '/flows',
|
||||
simulation: '/simulation',
|
||||
night: '/night-mode',
|
||||
homeassistant: '/home-assistant',
|
||||
settings: '/settings',
|
||||
logs: '/events',
|
||||
});
|
||||
const ROUTE_VIEWS = Object.freeze(Object.fromEntries(Object.entries(VIEW_ROUTES).map(([view, path]) => [path, view])));
|
||||
const HISTORY_TABS = Object.freeze(['overview', 'zones', 'devices', 'energy', 'network', 'sensors', 'custom']);
|
||||
let routerInitialized = false;
|
||||
|
||||
function currentHistoryPath() {
|
||||
const tab = HISTORY_TABS.includes(app.historyTab) ? app.historyTab : 'overview';
|
||||
const params = new URLSearchParams();
|
||||
const hours = $('#historyHours')?.value || new URLSearchParams(location.search).get('hours') || '24';
|
||||
if (hours !== '24') params.set('hours', hours);
|
||||
if (tab === 'zones' && app.historyZone !== 'all') params.set('zone', app.historyZone);
|
||||
if (tab === 'devices' && app.historyDevice !== 'all') params.set('device', app.historyDevice);
|
||||
if (tab === 'energy' && app.historyEnergyTargets?.length) params.set('targets', app.historyEnergyTargets.join(','));
|
||||
if (tab === 'energy' && app.historyEnergyInterval !== 'daily') params.set('interval', app.historyEnergyInterval);
|
||||
if (tab === 'energy' && app.historyEnergyCompare !== 'none') params.set('compare', app.historyEnergyCompare);
|
||||
if (tab === 'network' && app.historyNetworkTarget !== 'all') params.set('target', app.historyNetworkTarget);
|
||||
if (tab === 'sensors' && app.historySensor !== 'all') params.set('sensor', app.historySensor);
|
||||
if (tab === 'custom' && app.customChartSeries.length) params.set('chart', encodeChartSpec(app.customChartSeries));
|
||||
const query = params.toString();
|
||||
return `/history/${tab}${query ? `?${query}` : ''}`;
|
||||
}
|
||||
|
||||
function pathForView(name) {
|
||||
return name === 'history' ? currentHistoryPath() : (VIEW_ROUTES[name] || VIEW_ROUTES.dashboard);
|
||||
}
|
||||
|
||||
function appRelativePath(pathname = location.pathname) {
|
||||
if (!APP_BASE) return pathname || '/';
|
||||
if (pathname === APP_BASE) return '/';
|
||||
if (pathname.startsWith(`${APP_BASE}/`)) return pathname.slice(APP_BASE.length) || '/';
|
||||
return pathname || '/';
|
||||
}
|
||||
|
||||
function updateBrowserUrl(path, replace = false) {
|
||||
const normalized = path.startsWith('/') ? path : `/${path}`;
|
||||
const target = `${APP_BASE}${normalized}` || normalized;
|
||||
const current = `${location.pathname}${location.search}`;
|
||||
if (current === target) return;
|
||||
history[replace ? 'replaceState' : 'pushState']({}, '', target);
|
||||
}
|
||||
|
||||
function restoreCurrentRoute(previousView) {
|
||||
updateBrowserUrl(previousView === 'history' ? currentHistoryPath() : pathForView(previousView), true);
|
||||
}
|
||||
|
||||
function applyRouteFromLocation() {
|
||||
const routePath = appRelativePath();
|
||||
const parts = routePath.split('/').filter(Boolean);
|
||||
const first = parts[0] || 'dashboard';
|
||||
const params = new URLSearchParams(location.search);
|
||||
const previousView = app.currentView;
|
||||
|
||||
app.standaloneSimulation = first === 'simulation' && params.get('standalone') === '1';
|
||||
document.body.classList.toggle('simulation-standalone', app.standaloneSimulation);
|
||||
|
||||
if (first === 'simulation') {
|
||||
app.simulationScope = ['units', 'groups'].includes(params.get('scope')) ? params.get('scope') : 'units';
|
||||
app.simulationTarget = params.get('target') || 'all';
|
||||
}
|
||||
|
||||
if (first === 'history') {
|
||||
app.historyTab = HISTORY_TABS.includes(parts[1]) ? parts[1] : 'overview';
|
||||
app.historyZone = params.get('zone') || 'all';
|
||||
app.historyDevice = params.get('device') || 'all';
|
||||
if (app.historyTab === 'energy') {
|
||||
const targetParam = params.get('targets') || params.get('device') || '';
|
||||
app.historyEnergyTargets = targetParam ? targetParam.split(',').filter(Boolean).slice(0, 8) : (app.historyEnergyTargets || []);
|
||||
app.historyEnergyDevice = app.historyEnergyTargets[0] || '';
|
||||
if (['hourly', 'daily', 'weekly', 'monthly'].includes(params.get('interval'))) app.historyEnergyInterval = params.get('interval');
|
||||
if (['none', 'previous_day', 'previous_period', 'previous_year'].includes(params.get('compare'))) app.historyEnergyCompare = params.get('compare');
|
||||
}
|
||||
app.historyNetworkTarget = params.get('target') || 'all';
|
||||
app.historySensor = params.get('sensor') || 'all';
|
||||
const hours = params.get('hours');
|
||||
if (hours && ['6', '24', '168', '720', '2160', '8760'].includes(hours) && $('#historyHours')) $('#historyHours').value = hours;
|
||||
if (app.historyTab === 'custom' && params.get('chart')) app.customChartSeries = decodeChartSpec(params.get('chart'));
|
||||
if (!showView('history', { push: false, scroll: false })) restoreCurrentRoute(previousView);
|
||||
return;
|
||||
}
|
||||
|
||||
const view = first === 'index.html' ? 'dashboard' : ROUTE_VIEWS[`/${first}`];
|
||||
if (!showView(view || 'dashboard', { push: false, scroll: false })) {
|
||||
restoreCurrentRoute(previousView);
|
||||
return;
|
||||
}
|
||||
|
||||
if (first === 'flows') {
|
||||
if (parts[1]) requestAnimationFrame(() => openFlowEditor(parts[1] === 'new' ? '' : parts[1], { push: false }));
|
||||
else if (!$('#flowEditor')?.hidden) closeFlowEditor({ push: false, force: true });
|
||||
}
|
||||
if (!parts.length || first === 'index.html') updateBrowserUrl(VIEW_ROUTES.dashboard, true);
|
||||
}
|
||||
|
||||
function initRouter() {
|
||||
if (!routerInitialized) {
|
||||
window.addEventListener('popstate', applyRouteFromLocation);
|
||||
routerInitialized = true;
|
||||
}
|
||||
applyRouteFromLocation();
|
||||
}
|
||||
@@ -1,474 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const customSelectRegistry = new WeakMap();
|
||||
let customSelectOpenState = null;
|
||||
let customSelectSequence = 0;
|
||||
let customSelectObserver = null;
|
||||
let customSelectPropertiesPatched = false;
|
||||
|
||||
function patchCustomSelectProperties() {
|
||||
if (customSelectPropertiesPatched) return;
|
||||
customSelectPropertiesPatched = true;
|
||||
['value', 'selectedIndex'].forEach(property => {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, property);
|
||||
if (!descriptor?.get || !descriptor?.set || descriptor.configurable === false) return;
|
||||
try {
|
||||
Object.defineProperty(HTMLSelectElement.prototype, property, {
|
||||
configurable: descriptor.configurable,
|
||||
enumerable: descriptor.enumerable,
|
||||
get: descriptor.get,
|
||||
set(nextValue) {
|
||||
descriptor.set.call(this, nextValue);
|
||||
queueMicrotask(() => refreshCustomSelect(this));
|
||||
},
|
||||
});
|
||||
} catch (_) { }
|
||||
});
|
||||
}
|
||||
|
||||
function customSelectSelectedOption(select) {
|
||||
return select.options?.[select.selectedIndex] || null;
|
||||
}
|
||||
|
||||
function customSelectLabel(select) {
|
||||
const explicit = select.getAttribute('aria-label');
|
||||
if (explicit) return explicit;
|
||||
const labelledBy = select.getAttribute('aria-labelledby');
|
||||
if (labelledBy) {
|
||||
const text = labelledBy.split(/\s+/).map(id => document.getElementById(id)?.textContent?.trim()).filter(Boolean).join(' ');
|
||||
if (text) return text;
|
||||
}
|
||||
const label = select.closest('label');
|
||||
const labelSpan = label?.querySelector(':scope > span');
|
||||
return labelSpan?.textContent?.trim() || select.name || select.id || '';
|
||||
}
|
||||
|
||||
function customSelectMenuHost(select) {
|
||||
// A modal <dialog> makes everything outside its DOM subtree inert. Keep
|
||||
// the popover menu inside the same dialog so pointer hover/click continues
|
||||
// to work while the dialog is shown with showModal().
|
||||
return select.closest('dialog') || document.body;
|
||||
}
|
||||
|
||||
function ensureCustomSelectMenuHost(select, state) {
|
||||
const host = customSelectMenuHost(select);
|
||||
if (state.menu.parentElement !== host) host.appendChild(state.menu);
|
||||
}
|
||||
|
||||
function customSelectOptionRows(select) {
|
||||
const rows = [];
|
||||
[...select.children].forEach(child => {
|
||||
if (child instanceof HTMLOptGroupElement) {
|
||||
rows.push({ type: 'group', label: child.label });
|
||||
[...child.children].forEach(option => {
|
||||
if (option instanceof HTMLOptionElement) rows.push({ type: 'option', option, index: [...select.options].indexOf(option) });
|
||||
});
|
||||
} else if (child instanceof HTMLOptionElement) {
|
||||
rows.push({ type: 'option', option: child, index: [...select.options].indexOf(child) });
|
||||
}
|
||||
});
|
||||
return rows;
|
||||
}
|
||||
|
||||
function positionCustomSelectMenu(state) {
|
||||
if (!state?.open || !state.trigger?.isConnected || !state.menu?.isConnected) return;
|
||||
const rect = state.trigger.getBoundingClientRect();
|
||||
const margin = 8;
|
||||
const gap = 6;
|
||||
const toolbar = state.toolbar;
|
||||
const viewportWidth = document.documentElement.clientWidth || window.innerWidth;
|
||||
const viewportHeight = document.documentElement.clientHeight || window.innerHeight;
|
||||
const width = Math.min(Math.max(rect.width, toolbar ? 210 : 180), Math.max(180, viewportWidth - margin * 2));
|
||||
const below = Math.max(0, viewportHeight - rect.bottom - gap - margin);
|
||||
const above = Math.max(0, rect.top - gap - margin);
|
||||
|
||||
state.menu.style.width = `${Math.round(width)}px`;
|
||||
state.menu.style.maxHeight = '320px';
|
||||
|
||||
// Once the popover is visible, scrollHeight is the natural menu height.
|
||||
// Use that height to choose the side and, especially, to place short menus
|
||||
// directly above the trigger instead of at the top of a 320px allowance.
|
||||
const naturalHeight = Math.min(320, Math.max(0, state.menu.scrollHeight || state.menu.getBoundingClientRect().height || 0));
|
||||
const desiredHeight = naturalHeight || 180;
|
||||
const useBelow = below >= desiredHeight || (below >= above && above < desiredHeight);
|
||||
const available = Math.max(1, Math.min(320, useBelow ? below : above));
|
||||
state.menu.style.maxHeight = `${Math.round(available)}px`;
|
||||
|
||||
const renderedHeight = naturalHeight ? Math.min(naturalHeight, available) : available;
|
||||
const left = Math.min(Math.max(margin, rect.left), Math.max(margin, viewportWidth - width - margin));
|
||||
const rawTop = useBelow ? rect.bottom + gap : rect.top - gap - renderedHeight;
|
||||
const maxTop = Math.max(margin, viewportHeight - renderedHeight - margin);
|
||||
const top = Math.min(Math.max(margin, rawTop), maxTop);
|
||||
|
||||
state.menu.style.left = `${Math.round(left)}px`;
|
||||
state.menu.style.top = `${Math.round(top)}px`;
|
||||
state.menu.dataset.placement = useBelow ? 'bottom' : 'top';
|
||||
}
|
||||
|
||||
|
||||
function rebuildCustomSelectMenu(select, state) {
|
||||
const rows = customSelectOptionRows(select);
|
||||
state.menu.replaceChildren();
|
||||
|
||||
if (!rows.length) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'custom-select-empty';
|
||||
empty.textContent = '—';
|
||||
state.menu.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
rows.forEach(row => {
|
||||
if (row.type === 'group') {
|
||||
const group = document.createElement('div');
|
||||
group.className = 'custom-select-group';
|
||||
group.textContent = row.label;
|
||||
state.menu.appendChild(group);
|
||||
return;
|
||||
}
|
||||
|
||||
const { option, index } = row;
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = 'custom-select-option';
|
||||
button.dataset.index = String(index);
|
||||
button.setAttribute('role', 'option');
|
||||
button.setAttribute('aria-selected', option.selected ? 'true' : 'false');
|
||||
button.disabled = option.disabled || option.parentElement?.disabled || select.disabled;
|
||||
|
||||
const text = document.createElement('span');
|
||||
text.textContent = option.textContent || option.label || option.value || '—';
|
||||
const check = document.createElement('b');
|
||||
check.setAttribute('aria-hidden', 'true');
|
||||
check.innerHTML = option.selected ? uiIcon('check') : '';
|
||||
button.append(text, check);
|
||||
|
||||
button.addEventListener('click', event => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (button.disabled) return;
|
||||
const nextIndex = Number(button.dataset.index);
|
||||
if (!Number.isInteger(nextIndex) || !select.options[nextIndex]) return;
|
||||
const changed = select.selectedIndex !== nextIndex;
|
||||
select.selectedIndex = nextIndex;
|
||||
refreshCustomSelect(select);
|
||||
closeCustomSelect();
|
||||
if (changed) {
|
||||
select.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
select.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}
|
||||
state.trigger.focus({ preventScroll: true });
|
||||
});
|
||||
|
||||
state.menu.appendChild(button);
|
||||
});
|
||||
}
|
||||
|
||||
function refreshCustomSelect(select) {
|
||||
const state = customSelectRegistry.get(select);
|
||||
if (!state) return;
|
||||
const selected = customSelectSelectedOption(select);
|
||||
const text = selected?.textContent?.trim() || selected?.label || selected?.value || '—';
|
||||
const label = customSelectLabel(select);
|
||||
const disabled = !!select.disabled;
|
||||
|
||||
if (state.toolbar) {
|
||||
state.trigger.classList.toggle('custom-select-disabled', disabled);
|
||||
state.trigger.setAttribute('aria-disabled', disabled ? 'true' : 'false');
|
||||
state.trigger.setAttribute('aria-label', label ? `${label}: ${text}` : text);
|
||||
state.trigger.tabIndex = disabled ? -1 : 0;
|
||||
} else {
|
||||
state.value.textContent = text;
|
||||
state.trigger.disabled = disabled;
|
||||
state.trigger.setAttribute('aria-label', label ? `${label}: ${text}` : text);
|
||||
state.wrapper.hidden = !!select.hidden;
|
||||
}
|
||||
|
||||
if (state.open) {
|
||||
rebuildCustomSelectMenu(select, state);
|
||||
requestAnimationFrame(() => positionCustomSelectMenu(state));
|
||||
}
|
||||
}
|
||||
|
||||
function closeCustomSelect(state = customSelectOpenState) {
|
||||
if (!state?.open) return;
|
||||
state.open = false;
|
||||
state.trigger.setAttribute('aria-expanded', 'false');
|
||||
state.trigger.classList.remove('custom-select-open');
|
||||
state.wrapper?.classList.remove('custom-select-open');
|
||||
|
||||
if (typeof state.menu.hidePopover === 'function') {
|
||||
try {
|
||||
if (state.menu.matches(':popover-open')) state.menu.hidePopover();
|
||||
} catch (_) { }
|
||||
}
|
||||
state.menu.classList.remove('custom-select-menu-fallback-open');
|
||||
state.menu.hidden = true;
|
||||
if (customSelectOpenState === state) customSelectOpenState = null;
|
||||
}
|
||||
|
||||
function openCustomSelect(select) {
|
||||
const state = customSelectRegistry.get(select);
|
||||
if (!state || select.disabled) return;
|
||||
ensureCustomSelectMenuHost(select, state);
|
||||
if (customSelectOpenState === state && state.open) {
|
||||
closeCustomSelect(state);
|
||||
return;
|
||||
}
|
||||
closeCustomSelect();
|
||||
refreshCustomSelect(select);
|
||||
rebuildCustomSelectMenu(select, state);
|
||||
|
||||
state.open = true;
|
||||
customSelectOpenState = state;
|
||||
state.trigger.setAttribute('aria-expanded', 'true');
|
||||
state.trigger.classList.add('custom-select-open');
|
||||
state.wrapper?.classList.add('custom-select-open');
|
||||
state.menu.hidden = false;
|
||||
positionCustomSelectMenu(state);
|
||||
|
||||
if (typeof state.menu.showPopover === 'function') {
|
||||
try { state.menu.showPopover(); }
|
||||
catch (_) { state.menu.classList.add('custom-select-menu-fallback-open'); }
|
||||
} else {
|
||||
state.menu.classList.add('custom-select-menu-fallback-open');
|
||||
}
|
||||
|
||||
positionCustomSelectMenu(state);
|
||||
requestAnimationFrame(() => {
|
||||
positionCustomSelectMenu(state);
|
||||
state.menu.querySelector('.custom-select-option[aria-selected="true"]')?.scrollIntoView({ block: 'nearest' });
|
||||
});
|
||||
}
|
||||
|
||||
function customSelectKeyboard(event, select) {
|
||||
if (select.disabled) return;
|
||||
const state = customSelectRegistry.get(select);
|
||||
if (!state) return;
|
||||
|
||||
if (event.key === 'Enter' || event.key === ' ' || event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
if (!state.open) {
|
||||
openCustomSelect(select);
|
||||
return;
|
||||
}
|
||||
const enabled = [...state.menu.querySelectorAll('.custom-select-option:not(:disabled)')];
|
||||
if (!enabled.length) return;
|
||||
const active = document.activeElement;
|
||||
const current = enabled.indexOf(active);
|
||||
const delta = event.key === 'ArrowUp' ? -1 : 1;
|
||||
enabled[(current + delta + enabled.length) % enabled.length].focus();
|
||||
} else if (event.key === 'Escape' && state.open) {
|
||||
event.preventDefault();
|
||||
closeCustomSelect(state);
|
||||
state.trigger.focus({ preventScroll: true });
|
||||
} else if (event.key === 'Home' && state.open) {
|
||||
event.preventDefault();
|
||||
state.menu.querySelector('.custom-select-option:not(:disabled)')?.focus();
|
||||
} else if (event.key === 'End' && state.open) {
|
||||
event.preventDefault();
|
||||
[...state.menu.querySelectorAll('.custom-select-option:not(:disabled)')].at(-1)?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function enhanceCustomSelect(select) {
|
||||
if (!(select instanceof HTMLSelectElement) || customSelectRegistry.has(select) || select.multiple || select.size > 1 || select.dataset.nativeSelect === 'true') return;
|
||||
|
||||
const toolbarHost = select.classList.contains('toolbar-picker-select') ? select.closest('.toolbar-picker') : null;
|
||||
const originalSelectState = {
|
||||
tabIndex: select.getAttribute('tabindex'),
|
||||
ariaHidden: select.getAttribute('aria-hidden'),
|
||||
};
|
||||
const originalToolbarState = toolbarHost ? {
|
||||
role: toolbarHost.getAttribute('role'),
|
||||
ariaHaspopup: toolbarHost.getAttribute('aria-haspopup'),
|
||||
ariaControls: toolbarHost.getAttribute('aria-controls'),
|
||||
ariaExpanded: toolbarHost.getAttribute('aria-expanded'),
|
||||
ariaDisabled: toolbarHost.getAttribute('aria-disabled'),
|
||||
} : null;
|
||||
const menu = document.createElement('div');
|
||||
const menuId = `customSelectMenu${++customSelectSequence}`;
|
||||
menu.id = menuId;
|
||||
menu.className = 'custom-select-menu';
|
||||
menu.setAttribute('role', 'listbox');
|
||||
menu.setAttribute('popover', 'manual');
|
||||
menu.hidden = true;
|
||||
customSelectMenuHost(select).appendChild(menu);
|
||||
|
||||
let wrapper = null;
|
||||
let trigger = toolbarHost;
|
||||
let value = null;
|
||||
|
||||
select.classList.add('select-native-proxy');
|
||||
select.tabIndex = -1;
|
||||
select.setAttribute('aria-hidden', 'true');
|
||||
|
||||
if (toolbarHost) {
|
||||
toolbarHost.classList.add('custom-select-toolbar');
|
||||
toolbarHost.setAttribute('role', 'button');
|
||||
toolbarHost.setAttribute('aria-haspopup', 'listbox');
|
||||
toolbarHost.setAttribute('aria-controls', menuId);
|
||||
toolbarHost.setAttribute('aria-expanded', 'false');
|
||||
} else {
|
||||
wrapper = document.createElement('div');
|
||||
wrapper.className = 'custom-select';
|
||||
trigger = document.createElement('button');
|
||||
trigger.type = 'button';
|
||||
trigger.className = 'custom-select-trigger';
|
||||
trigger.setAttribute('aria-haspopup', 'listbox');
|
||||
trigger.setAttribute('aria-controls', menuId);
|
||||
trigger.setAttribute('aria-expanded', 'false');
|
||||
value = document.createElement('span');
|
||||
value.className = 'custom-select-value';
|
||||
const chevron = document.createElement('span');
|
||||
chevron.className = 'custom-select-chevron';
|
||||
chevron.setAttribute('aria-hidden', 'true');
|
||||
chevron.innerHTML = uiIcon('chevron-down');
|
||||
trigger.append(value, chevron);
|
||||
wrapper.appendChild(trigger);
|
||||
select.insertAdjacentElement('afterend', wrapper);
|
||||
}
|
||||
|
||||
const state = {
|
||||
select,
|
||||
wrapper,
|
||||
trigger,
|
||||
value,
|
||||
menu,
|
||||
toolbar: !!toolbarHost,
|
||||
open: false,
|
||||
originalSelectState,
|
||||
originalToolbarState,
|
||||
};
|
||||
customSelectRegistry.set(select, state);
|
||||
|
||||
trigger.addEventListener('click', event => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
openCustomSelect(select);
|
||||
});
|
||||
trigger.addEventListener('keydown', event => customSelectKeyboard(event, select));
|
||||
menu.addEventListener('keydown', event => customSelectKeyboard(event, select));
|
||||
|
||||
// Clicking an implicit <label> may synthesize a click on the original select.
|
||||
// Redirect that trusted interaction to the custom popup instead of opening the OS picker.
|
||||
select.addEventListener('click', event => {
|
||||
if (!event.isTrusted) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
openCustomSelect(select);
|
||||
});
|
||||
select.addEventListener('change', () => refreshCustomSelect(select));
|
||||
select.addEventListener('input', () => refreshCustomSelect(select));
|
||||
|
||||
refreshCustomSelect(select);
|
||||
}
|
||||
|
||||
function cleanupCustomSelect(select) {
|
||||
const state = customSelectRegistry.get(select);
|
||||
if (!state) return;
|
||||
if (state.open) closeCustomSelect(state);
|
||||
state.menu.remove();
|
||||
state.wrapper?.remove();
|
||||
select.classList.remove('select-native-proxy');
|
||||
if (state.originalSelectState.tabIndex == null) select.removeAttribute('tabindex');
|
||||
else select.setAttribute('tabindex', state.originalSelectState.tabIndex);
|
||||
if (state.originalSelectState.ariaHidden == null) select.removeAttribute('aria-hidden');
|
||||
else select.setAttribute('aria-hidden', state.originalSelectState.ariaHidden);
|
||||
if (state.toolbar && state.trigger) {
|
||||
state.trigger.classList.remove('custom-select-toolbar', 'custom-select-open', 'custom-select-disabled');
|
||||
const restoreAttribute = (name, value) => value == null ? state.trigger.removeAttribute(name) : state.trigger.setAttribute(name, value);
|
||||
restoreAttribute('role', state.originalToolbarState?.role);
|
||||
restoreAttribute('aria-haspopup', state.originalToolbarState?.ariaHaspopup);
|
||||
restoreAttribute('aria-controls', state.originalToolbarState?.ariaControls);
|
||||
restoreAttribute('aria-expanded', state.originalToolbarState?.ariaExpanded);
|
||||
restoreAttribute('aria-disabled', state.originalToolbarState?.ariaDisabled);
|
||||
}
|
||||
customSelectRegistry.delete(select);
|
||||
}
|
||||
|
||||
function refreshCustomSelects(root = document) {
|
||||
if (root instanceof HTMLSelectElement) refreshCustomSelect(root);
|
||||
root.querySelectorAll?.('select').forEach(select => refreshCustomSelect(select));
|
||||
}
|
||||
|
||||
function enhanceCustomSelects(root = document) {
|
||||
if (root instanceof HTMLSelectElement) enhanceCustomSelect(root);
|
||||
root.querySelectorAll?.('select').forEach(enhanceCustomSelect);
|
||||
}
|
||||
|
||||
function initCustomSelects() {
|
||||
patchCustomSelectProperties();
|
||||
enhanceCustomSelects(document);
|
||||
if (customSelectObserver) return;
|
||||
|
||||
customSelectObserver = new MutationObserver(records => {
|
||||
const toRefresh = new Set();
|
||||
records.forEach(record => {
|
||||
record.addedNodes.forEach(node => {
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) return;
|
||||
enhanceCustomSelects(node);
|
||||
const select = node.closest?.('select');
|
||||
if (select) toRefresh.add(select);
|
||||
});
|
||||
record.removedNodes.forEach(node => {
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) return;
|
||||
if (node instanceof HTMLSelectElement && !node.isConnected) cleanupCustomSelect(node);
|
||||
node.querySelectorAll?.('select').forEach(select => {
|
||||
// A DOM move is reported as a removal followed by an addition. Keep
|
||||
// the existing custom-select state when the select is still attached
|
||||
// to the document; otherwise every move would leave an old wrapper
|
||||
// behind and create another one on the next enhancement pass.
|
||||
if (!select.isConnected) cleanupCustomSelect(select);
|
||||
});
|
||||
});
|
||||
const targetElement = record.target.nodeType === Node.ELEMENT_NODE ? record.target : record.target.parentElement;
|
||||
const select = targetElement?.closest?.('select');
|
||||
if (select) toRefresh.add(select);
|
||||
});
|
||||
toRefresh.forEach(select => {
|
||||
if (!select.isConnected) {
|
||||
cleanupCustomSelect(select);
|
||||
return;
|
||||
}
|
||||
if (!customSelectRegistry.has(select)) enhanceCustomSelect(select);
|
||||
refreshCustomSelect(select);
|
||||
});
|
||||
});
|
||||
|
||||
customSelectObserver.observe(document.documentElement, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
characterData: true,
|
||||
attributes: true,
|
||||
attributeFilter: ['disabled', 'hidden', 'selected', 'label', 'value', 'aria-label', 'aria-labelledby'],
|
||||
});
|
||||
|
||||
document.addEventListener('pointerdown', event => {
|
||||
const state = customSelectOpenState;
|
||||
if (!state?.open) return;
|
||||
if (state.menu.contains(event.target) || state.trigger.contains(event.target)) return;
|
||||
closeCustomSelect(state);
|
||||
}, true);
|
||||
|
||||
document.addEventListener('reset', event => {
|
||||
requestAnimationFrame(() => refreshCustomSelects(event.target));
|
||||
}, true);
|
||||
|
||||
document.addEventListener('invalid', event => {
|
||||
const select = event.target;
|
||||
if (!(select instanceof HTMLSelectElement)) return;
|
||||
const state = customSelectRegistry.get(select);
|
||||
if (!state) return;
|
||||
requestAnimationFrame(() => state.trigger.focus({ preventScroll: true }));
|
||||
}, true);
|
||||
|
||||
window.addEventListener('resize', () => positionCustomSelectMenu(customSelectOpenState));
|
||||
document.addEventListener('scroll', event => {
|
||||
const state = customSelectOpenState;
|
||||
if (!state?.open || state.menu.contains(event.target)) return;
|
||||
closeCustomSelect(state);
|
||||
}, true);
|
||||
}
|
||||
|
||||
initCustomSelects();
|
||||
@@ -1,663 +0,0 @@
|
||||
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 metricHaEntities() {
|
||||
return new Set([
|
||||
app.settings?.home_assistant?.outdoor_entity_id,
|
||||
...app.zones.map(zone => zone.ha_outdoor_entity_id).filter(Boolean),
|
||||
...app.zones.filter(zone => ['home_assistant', 'combined'].includes(zone.sensor_source)).map(zoneHaEntityId),
|
||||
...app.historyData.sensors.map(row => row.entity_id),
|
||||
].filter(Boolean));
|
||||
}
|
||||
|
||||
function flowHaEntities() {
|
||||
return new Set((app.flowSharedInputs || []).map(item => item?.config?.entity_id).filter(Boolean));
|
||||
}
|
||||
|
||||
function knownHaEntities() {
|
||||
return [...new Set([
|
||||
...Object.keys(app.sensorAliases || {}),
|
||||
...app.zones.map(zone => zone.ha_entity_id).filter(Boolean),
|
||||
...app.zones.map(zone => zone.ha_outdoor_entity_id).filter(Boolean),
|
||||
app.settings?.home_assistant?.outdoor_entity_id,
|
||||
...(app.flowSharedInputs || []).map(item => item?.config?.entity_id).filter(Boolean),
|
||||
...app.historyData.sensors.map(row => row.entity_id),
|
||||
].filter(Boolean))].sort();
|
||||
}
|
||||
|
||||
function renderHaEntitySuggestions() {
|
||||
const host = $('#haEntitySuggestions'); if (!host) return;
|
||||
const entities = [...new Set([
|
||||
...knownHaEntities(),
|
||||
...(app.haEntityCatalog || []).map(item => item?.entity_id).filter(Boolean),
|
||||
])].sort();
|
||||
host.innerHTML = entities.map(entity => {
|
||||
const alias = haSensorLabel(entity);
|
||||
return `<option value="${esc(entity)}" label="${esc(alias === entity ? '' : alias)}"></option>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function renderSensorAliases() {
|
||||
const host = $('#sensorAliasList'); if (!host) return;
|
||||
renderHaEntitySuggestions();
|
||||
const entities = knownHaEntities();
|
||||
const metricEntities = metricHaEntities(), flowEntities = flowHaEntities();
|
||||
host.innerHTML = entities.length ? entities.map(entity => {
|
||||
const badges = `${metricEntities.has(entity) ? `<span class="sensor-source-badge metrics">${esc(tr('settings.sensorMetricBadge'))}</span>` : ''}${flowEntities.has(entity) ? `<span class="sensor-source-badge flow">${esc(tr('settings.sensorFlowBadge'))}</span>` : ''}`;
|
||||
return `<div class="sensor-alias-row"><div class="sensor-alias-entity"><span class="mono" title="${esc(entity)}">${esc(entity)}</span>${badges ? `<span class="sensor-source-badges">${badges}</span>` : ''}</div><input data-sensor-alias="${esc(entity)}" value="${esc(app.sensorAliases?.[entity] || '')}" placeholder="${esc(tr('settings.aliasPlaceholder'))}"><button type="button" class="sensor-alias-clear" data-clear-sensor-alias="${esc(entity)}" title="${esc(tr('actions.clear'))}">${uiIcon('close')}</button></div>`;
|
||||
}).join('') : `<div class="empty compact">${esc(tr('settings.noSensorAliases'))}</div>`;
|
||||
}
|
||||
|
||||
function flowSharedInputKinds() {
|
||||
return [
|
||||
['constant', 'flow.node.constant'], ['ha_state', 'flow.node.haState'], ['ha_numeric', 'flow.node.haNumeric'],
|
||||
['ha_attribute', 'flow.node.haAttribute'], ['ha_available', 'flow.node.haAvailable'],
|
||||
['outdoor_temperature', 'flow.node.outdoorTemperature'], ['device_temperature', 'flow.node.deviceTemperature'],
|
||||
['zone_temperature', 'flow.node.zoneTemperature'], ['house_mode', 'flow.node.houseMode'],
|
||||
['device_state', 'flow.node.deviceState'], ['zone_state', 'flow.node.zoneState'],
|
||||
['group_state', 'flow.node.groupState'], ['night_mode', 'flow.node.nightMode'],
|
||||
];
|
||||
}
|
||||
|
||||
function flowSharedInputDefaultConfig(kind) {
|
||||
if (kind === 'device_temperature') return { device_id: app.devices[0]?.id || '' };
|
||||
if (kind === 'zone_temperature') return { zone_id: app.zones[0]?.id || '' };
|
||||
if (kind === 'ha_state' || kind === 'ha_numeric' || kind === 'ha_available') return { entity_id: '' };
|
||||
if (kind === 'ha_attribute') return { entity_id: '', attribute: '' };
|
||||
if (kind === 'device_state') return { device_id: app.devices[0]?.id || '', field: 'online' };
|
||||
if (kind === 'zone_state') return { zone_id: app.zones[0]?.id || '', field: 'demand' };
|
||||
if (kind === 'group_state') return { group_id: app.groups[0]?.id || '', field: 'power_enabled' };
|
||||
if (kind === 'constant') return { value: true };
|
||||
return {};
|
||||
}
|
||||
|
||||
function sharedFlowInputSourceSummary(item) {
|
||||
if (!item) return '—';
|
||||
const c = item.config || {};
|
||||
if (item.kind === 'outdoor_temperature') return tr('flow.node.outdoorTemperature');
|
||||
if (item.kind === 'device_temperature') return app.devices.find(value => value.id === c.device_id)?.name || tr('common.noDevice');
|
||||
if (item.kind === 'zone_temperature') return app.zones.find(value => value.id === c.zone_id)?.name || tr('common.noZone');
|
||||
if (item.kind === 'ha_state' || item.kind === 'ha_numeric' || item.kind === 'ha_available') return c.entity_id ? haSensorLabel(c.entity_id) : 'entity_id';
|
||||
if (item.kind === 'ha_attribute') return `${c.entity_id ? haSensorLabel(c.entity_id) : 'entity_id'}.${c.attribute || 'attribute'}`;
|
||||
if (item.kind === 'house_mode') return tr('flow.houseMode');
|
||||
if (item.kind === 'device_state') return `${app.devices.find(value => value.id === c.device_id)?.name || tr('common.noDevice')} · ${c.field || 'state'}`;
|
||||
if (item.kind === 'zone_state') return `${app.zones.find(value => value.id === c.zone_id)?.name || tr('common.noZone')} · ${c.field || 'state'}`;
|
||||
if (item.kind === 'group_state') return `${app.groups.find(value => value.id === c.group_id)?.name || tr('groups.group')} · ${c.field || 'power_enabled'}`;
|
||||
return flowNodeSummary({ kind: item.kind, config: c });
|
||||
}
|
||||
|
||||
function isHaSharedInputKind(kind) { return ['ha_state', 'ha_numeric', 'ha_attribute', 'ha_available'].includes(kind); }
|
||||
|
||||
function flowSharedInputUsages(id) {
|
||||
return (app.flows || []).filter(flow => (flow.nodes || []).some(node => node.kind === 'shared_input' && node.config?.input_id === id));
|
||||
}
|
||||
|
||||
function renderFlowSharedInputs() {
|
||||
const host = $('#flowSharedInputList'); if (!host) return;
|
||||
const items = app.flowSharedInputs || [];
|
||||
host.innerHTML = items.length ? items.map(item => {
|
||||
const usages = flowSharedInputUsages(item.id);
|
||||
const usageMarkup = usages.length
|
||||
? `<div class="flow-shared-usage"><small>${esc(tr('flow.sharedInputUsedBy', { count: usages.length }))}</small><div>${usages.slice(0, 4).map(flow => `<button type="button" class="link-button" data-open-shared-flow="${esc(flow.id)}" title="${esc(tr('flow.openReferencedFlow', { name: flow.name }))}">${esc(flow.name)}</button>`).join('')}${usages.length > 4 ? `<span class="muted">+${usages.length - 4}</span>` : ''}</div></div>`
|
||||
: `<small class="flow-shared-unused">${esc(tr('flow.sharedInputUnused'))}</small>`;
|
||||
return `<div class="flow-shared-input-row">
|
||||
<div><strong>${esc(item.name)}</strong><small>${esc(flowNodeTitle(FLOW_NODE_META[item.kind] || { title: item.kind }))} · ${esc(sharedFlowInputSourceSummary(item))}</small><code>${esc(item.id)}</code>${usageMarkup}</div>
|
||||
<div class="flow-shared-input-actions"><button type="button" class="secondary" data-flow-shared-edit="${esc(item.id)}">${esc(tr('actions.edit'))}</button><button type="button" class="danger" data-flow-shared-delete="${esc(item.id)}">${esc(tr('actions.delete'))}</button></div>
|
||||
</div>`;
|
||||
}).join('') : `<div class="empty compact"><strong>${esc(tr('flow.sharedInputsEmpty'))}</strong><span>${esc(tr('flow.sharedInputsEmptyHint'))}</span></div>`;
|
||||
const revision = $('#flowSharedInputsRevision');
|
||||
if (revision) revision.value = JSON.stringify(items);
|
||||
}
|
||||
|
||||
function sharedFlowOptions(items, selected, label = item => item.name) {
|
||||
return items.map(item => `<option value="${esc(item.id)}" ${item.id === selected ? 'selected' : ''}>${esc(label(item))}</option>`).join('');
|
||||
}
|
||||
|
||||
|
||||
function haEntityMatchesSharedKind(entity, kind) {
|
||||
if (!entity?.entity_id) return false;
|
||||
if (kind === 'ha_numeric') { const state = String(entity.state ?? '').trim(); return state !== '' && Number.isFinite(Number(state)); }
|
||||
return true;
|
||||
}
|
||||
|
||||
function haEntityPickerResults(kind, query = '') {
|
||||
const needle = String(query || '').trim().toLocaleLowerCase();
|
||||
return (app.haEntityCatalog || [])
|
||||
.filter(entity => haEntityMatchesSharedKind(entity, kind))
|
||||
.filter(entity => {
|
||||
if (!needle) return true;
|
||||
return [entity.entity_id, entity.name, entity.state, entity.unit, entity.device_class]
|
||||
.some(value => String(value || '').toLocaleLowerCase().includes(needle));
|
||||
})
|
||||
.slice(0, 12);
|
||||
}
|
||||
|
||||
function renderHaEntityPickerResults(queryInput, kind) {
|
||||
const picker = queryInput?.closest?.('[data-ha-entity-picker]');
|
||||
const host = picker?.querySelector?.('[data-ha-entity-results]');
|
||||
const note = picker?.querySelector?.('[data-ha-entity-note]');
|
||||
if (!host || !note) return;
|
||||
|
||||
if (app.haEntityCatalogLoading) {
|
||||
note.textContent = tr('flow.haEntitySearchLoading');
|
||||
host.hidden = true;
|
||||
return;
|
||||
}
|
||||
if (!app.haEntityCatalogConfigured) {
|
||||
note.textContent = tr('flow.haEntitySearchConnect');
|
||||
host.hidden = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const query = String(queryInput.value || '').trim();
|
||||
const matchingCount = (app.haEntityCatalog || [])
|
||||
.filter(entity => haEntityMatchesSharedKind(entity, kind))
|
||||
.filter(entity => {
|
||||
if (!query) return true;
|
||||
const needle = query.toLocaleLowerCase();
|
||||
return [entity.entity_id, entity.name, entity.state, entity.unit, entity.device_class]
|
||||
.some(value => String(value || '').toLocaleLowerCase().includes(needle));
|
||||
}).length;
|
||||
const items = haEntityPickerResults(kind, query);
|
||||
note.textContent = query
|
||||
? tr('flow.haEntitySearchMatches', { count: matchingCount, total: app.haEntityCatalog.length })
|
||||
: tr('flow.haEntitySearchCount', { count: app.haEntityCatalog.length });
|
||||
host.innerHTML = items.length ? items.map(entity => {
|
||||
const title = entity.name || entity.entity_id;
|
||||
const state = `${entity.state || '—'}${entity.unit ? ` ${entity.unit}` : ''}`;
|
||||
return `<button type="button" class="ha-entity-option" data-ha-entity-value="${esc(entity.entity_id)}" data-ha-entity-label="${esc(title)}"><span><strong>${esc(title)}</strong><code>${esc(entity.entity_id)}</code></span><small>${esc(state)}</small></button>`;
|
||||
}).join('') : `<div class="ha-entity-empty">${esc(tr('flow.haEntitySearchEmpty'))}</div>`;
|
||||
host.hidden = false;
|
||||
}
|
||||
|
||||
async function loadHaEntityCatalog(force = false) {
|
||||
const fresh = app.haEntityCatalogLoadedAt && Date.now() - app.haEntityCatalogLoadedAt < 60000;
|
||||
if (!force && fresh) return app.haEntityCatalog;
|
||||
if (app.haEntityCatalogLoading) return app.haEntityCatalog;
|
||||
app.haEntityCatalogLoading = true;
|
||||
const activeInput = $('#flowSharedInputFields [data-ha-entity-query]');
|
||||
if (activeInput) renderHaEntityPickerResults(activeInput, $('#flowSharedInputKind')?.value || 'ha_state');
|
||||
try {
|
||||
const response = await api('/api/integrations/home-assistant/entities');
|
||||
app.haEntityCatalog = Array.isArray(response.entities) ? response.entities : [];
|
||||
app.haEntityCatalogConfigured = response.configured === true;
|
||||
app.haEntityCatalogLoadedAt = Date.now();
|
||||
renderHaEntitySuggestions();
|
||||
} catch (_) {
|
||||
app.haEntityCatalog = [];
|
||||
app.haEntityCatalogConfigured = false;
|
||||
app.haEntityCatalogLoadedAt = Date.now();
|
||||
} finally {
|
||||
app.haEntityCatalogLoading = false;
|
||||
const input = $('#flowSharedInputFields [data-ha-entity-query]');
|
||||
if (input) renderHaEntityPickerResults(input, $('#flowSharedInputKind')?.value || 'ha_state');
|
||||
}
|
||||
return app.haEntityCatalog;
|
||||
}
|
||||
|
||||
function haEntityPickerMarkup(kind, value, placeholder) {
|
||||
return `<div class="ha-entity-picker" data-ha-entity-picker>
|
||||
<label class="ha-entity-search-field"><span>${esc(tr('flow.haEntitySearchLabel'))}</span><input type="search" autocomplete="off" data-ha-entity-query placeholder="${esc(tr('flow.haEntitySearchPlaceholder'))}"></label>
|
||||
<small class="field-note" data-ha-entity-note>${esc(tr('flow.haEntitySearchHint'))}</small>
|
||||
<div class="ha-entity-results" data-ha-entity-results hidden></div>
|
||||
<label class="ha-entity-selected-field"><span>${esc(tr('flow.haEntitySelectedLabel'))}</span><input autocomplete="off" data-shared-config="entity_id" data-ha-entity-selected value="${esc(value || '')}" placeholder="${esc(placeholder)}"></label>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function bindHaEntityPicker(kind) {
|
||||
const queryInput = $('#flowSharedInputFields [data-ha-entity-query]');
|
||||
const selectedInput = $('#flowSharedInputFields [data-ha-entity-selected]');
|
||||
const results = $('#flowSharedInputFields [data-ha-entity-results]');
|
||||
if (!queryInput || !selectedInput || !results) return;
|
||||
const refresh = () => renderHaEntityPickerResults(queryInput, kind);
|
||||
queryInput.addEventListener('input', refresh);
|
||||
queryInput.addEventListener('focus', refresh);
|
||||
queryInput.addEventListener('keydown', event => {
|
||||
if (event.key === 'Escape') results.hidden = true;
|
||||
});
|
||||
results.addEventListener('click', event => {
|
||||
const option = event.target.closest?.('[data-ha-entity-value]');
|
||||
if (!option) return;
|
||||
selectedInput.value = option.dataset.haEntityValue || '';
|
||||
selectedInput.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
selectedInput.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
queryInput.value = option.dataset.haEntityLabel || option.dataset.haEntityValue || '';
|
||||
results.hidden = true;
|
||||
});
|
||||
loadHaEntityCatalog().then(refresh);
|
||||
}
|
||||
|
||||
function renderFlowSharedInputFields(kind, config = {}) {
|
||||
const host = $('#flowSharedInputFields'); if (!host) return;
|
||||
const c = config || {};
|
||||
let fields = '';
|
||||
if (kind === 'constant') fields = `<label><span>${esc(tr('flow.value'))}</span><select data-shared-config="value"><option value="true" ${c.value !== false ? 'selected' : ''}>${esc(tr('common.yes'))}</option><option value="false" ${c.value === false ? 'selected' : ''}>${esc(tr('common.no'))}</option></select></label>`;
|
||||
else if (kind === 'ha_state') fields = haEntityPickerMarkup(kind, c.entity_id, 'binary_sensor.window');
|
||||
else if (kind === 'ha_numeric') fields = haEntityPickerMarkup(kind, c.entity_id, 'sensor.energy_price');
|
||||
else if (kind === 'ha_attribute') fields = `${haEntityPickerMarkup(kind, c.entity_id, 'climate.living_room')}<label><span>${esc(tr('flow.attribute'))}</span><input data-shared-config="attribute" value="${esc(c.attribute || '')}" placeholder="hvac_action"></label>`;
|
||||
else if (kind === 'ha_available') fields = `${haEntityPickerMarkup(kind, c.entity_id, 'binary_sensor.window')}<p class="field-note">${esc(tr('flow.haAvailableHint'))}</p>`;
|
||||
else if (kind === 'outdoor_temperature') fields = `<p class="field-note">${esc(tr('flow.sharedInputSourceOnlyHint'))}</p>`;
|
||||
else if (kind === 'device_temperature') fields = `<label><span>${esc(tr('common.device'))}</span><select data-shared-config="device_id">${sharedFlowOptions(app.devices, c.device_id)}</select></label>`;
|
||||
else if (kind === 'zone_temperature') fields = `<label><span>${esc(tr('common.zone'))}</span><select data-shared-config="zone_id">${sharedFlowOptions(app.zones, c.zone_id)}</select></label>`;
|
||||
else if (kind === 'house_mode') fields = `<p class="field-note">${esc(tr('flow.sharedInputSourceOnlyHint'))}</p>`;
|
||||
else if (kind === 'device_state') fields = `<label><span>${esc(tr('common.device'))}</span><select data-shared-config="device_id">${sharedFlowOptions(app.devices, c.device_id)}</select></label><label><span>${esc(tr('flow.field'))}</span><select data-shared-config="field">${['enabled', 'online', 'power', 'mode', 'fan_speed', 'swing_vertical', 'swing_horizontal', 'quiet', 'turbo', 'light', 'air', 'xfan', 'health', 'sleep'].map(v => `<option value="${v}" ${c.field === v ? 'selected' : ''}>${esc(deviceCommandFieldLabel(v))}</option>`).join('')}</select></label>`;
|
||||
else if (kind === 'zone_state') fields = `<label><span>${esc(tr('common.zone'))}</span><select data-shared-config="zone_id">${sharedFlowOptions(app.zones, c.zone_id)}</select></label><label><span>${esc(tr('flow.field'))}</span><select data-shared-config="field">${['enabled', 'mode', 'active_preset', 'demand', 'control_owner', 'device_manual_override', 'local_thermostat_power'].map(v => `<option value="${v}" ${c.field === v ? 'selected' : ''}>${esc(v)}</option>`).join('')}</select></label>`;
|
||||
else if (kind === 'group_state') fields = `<label><span>${esc(tr('groups.group'))}</span><select data-shared-config="group_id">${sharedFlowOptions(app.groups, c.group_id)}</select></label><input type="hidden" data-shared-config="field" value="power_enabled">`;
|
||||
else if (kind === 'night_mode') fields = `<p class="field-note">${esc(tr('flow.nightModeHint'))}</p>`;
|
||||
host.innerHTML = fields;
|
||||
if (isHaSharedInputKind(kind)) bindHaEntityPicker(kind);
|
||||
const testPanel = $('#flowSharedInputTestPanel'), testResult = $('#flowSharedInputTestResult');
|
||||
if (testPanel) testPanel.hidden = !isHaSharedInputKind(kind);
|
||||
if (testResult) { testResult.hidden = true; testResult.innerHTML = ''; }
|
||||
}
|
||||
|
||||
function openFlowSharedInputEditor(id = '') {
|
||||
const form = $('#flowSharedInputForm'), dialog = $('#flowSharedInputDialog'); if (!form || !dialog) return;
|
||||
const item = id ? (app.flowSharedInputs || []).find(value => value.id === id) : null;
|
||||
const kindSelect = form.kind;
|
||||
kindSelect.innerHTML = flowSharedInputKinds().map(([kind, key]) => `<option value="${kind}">${esc(tr(key))}</option>`).join('');
|
||||
form.id.value = item?.id || '';
|
||||
form.name.value = item?.name || '';
|
||||
form.kind.value = item?.kind || 'constant';
|
||||
form.dataset.editingId = item?.id || '';
|
||||
renderFlowSharedInputFields(form.kind.value, item?.config || flowSharedInputDefaultConfig(form.kind.value));
|
||||
dialog.showModal();
|
||||
setTimeout(() => form.name.focus(), 0);
|
||||
}
|
||||
|
||||
function collectFlowSharedInputConfig(kind) {
|
||||
const config = {};
|
||||
$$('[data-shared-config]', $('#flowSharedInputFields')).forEach(field => {
|
||||
const key = field.dataset.sharedConfig;
|
||||
let value = field.value;
|
||||
if (kind === 'constant' && key === 'value') value = value === 'true';
|
||||
config[key] = value;
|
||||
});
|
||||
return config;
|
||||
}
|
||||
|
||||
function renderLogRetention() {
|
||||
const select = $('#logRetentionDays'); if (!select || !app.settings) return;
|
||||
[...select.options].forEach(option => { option.textContent = `${option.value} ${tr('common.days')}`; });
|
||||
const days = String(app.settings.event_log_retention_days || 30);
|
||||
if (![...select.options].some(option => option.value === days)) {
|
||||
const option = document.createElement('option'); option.value = days; option.textContent = `${days} ${tr('common.days')}`; select.appendChild(option);
|
||||
}
|
||||
select.value = days;
|
||||
}
|
||||
|
||||
|
||||
function updateConnectivityMetricFields() {
|
||||
const form = $('#settingsForm');
|
||||
if (!form) return;
|
||||
const localEnabled = !!form.ping_metrics_enabled?.checked;
|
||||
if (form.ping_interval_seconds) form.ping_interval_seconds.disabled = !localEnabled;
|
||||
if (form.ping_sample_count) form.ping_sample_count.disabled = !localEnabled;
|
||||
const cloudEnabled = !!form.gree_cloud_connectivity_metrics_enabled?.checked;
|
||||
if (form.gree_cloud_connectivity_metrics_interval_seconds) form.gree_cloud_connectivity_metrics_interval_seconds.disabled = !cloudEnabled;
|
||||
if (form.gree_cloud_connectivity_metrics_sample_count) form.gree_cloud_connectivity_metrics_sample_count.disabled = !cloudEnabled;
|
||||
}
|
||||
|
||||
function renderSettings() {
|
||||
if (!app.settings) return;
|
||||
const form = $('#settingsForm');
|
||||
if (!form) return;
|
||||
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.ping_metrics_enabled.checked = app.settings.ping_metrics_enabled !== false;
|
||||
form.ping_interval_seconds.value = Number(app.settings.ping_interval_seconds || 60);
|
||||
form.ping_sample_count.value = Number(app.settings.ping_sample_count || 3);
|
||||
form.simulator_enabled.checked = !!app.settings.simulator_enabled;
|
||||
form.history_retention_days.value = app.settings.history_retention_days || 30;
|
||||
form.event_log_retention_days.value = app.settings.event_log_retention_days || 30;
|
||||
form.history_compaction_enabled.checked = app.settings.history_compaction_enabled !== false;
|
||||
form.suppress_device_beep.checked = !!app.settings.suppress_device_beep;
|
||||
const cloud = app.settings.gree_cloud || {};
|
||||
form.gree_cloud_enabled.checked = !!cloud.enabled;
|
||||
form.gree_cloud_region.value = cloud.region || 'Europe';
|
||||
form.gree_cloud_username.value = cloud.username || '';
|
||||
form.gree_cloud_password.value = '';
|
||||
form.gree_cloud_password.placeholder = cloud.password_configured ? tr('settings.secretSaved') : tr('settings.secretKeep');
|
||||
form.gree_cloud_polling_interval_seconds.value = Number(cloud.polling_interval_seconds || 60);
|
||||
form.gree_cloud_connectivity_metrics_enabled.checked = !!cloud.connectivity_metrics_enabled;
|
||||
form.gree_cloud_connectivity_metrics_interval_seconds.value = Number(cloud.connectivity_metrics_interval_seconds || 300);
|
||||
form.gree_cloud_connectivity_metrics_sample_count.value = Number(cloud.connectivity_metrics_sample_count || 3);
|
||||
updateConnectivityMetricFields();
|
||||
const cloudInstance = $('#greeCloudInstanceId');
|
||||
if (cloudInstance) cloudInstance.textContent = cloud.installation_id || '—';
|
||||
const cloudLastContact = $('#greeCloudLastContact');
|
||||
if (cloudLastContact) cloudLastContact.textContent = cloud.last_successful_contact ? dateTime(cloud.last_successful_contact) : tr('common.unavailable');
|
||||
form.compressor_protection_enabled.checked = app.settings.compressor_protection_enabled !== false;
|
||||
form.compressor_protection_minutes.value = (Number(app.settings.compressor_protection_seconds || 180) / 60).toFixed(1).replace(/\.0$/, '');
|
||||
updateCompressorProtectionFields();
|
||||
form.influx_enabled.checked = !!app.settings.influxdb?.enabled;
|
||||
form.influx_version.value = String(app.settings.influxdb?.version || '2');
|
||||
form.influx_threshold_days.value = app.settings.influxdb?.history_threshold_days || 30;
|
||||
form.influx_url.value = app.settings.influxdb?.url || '';
|
||||
form.influx_database.value = app.settings.influxdb?.database || 'gree_controller';
|
||||
form.influx_username.value = app.settings.influxdb?.username || '';
|
||||
form.influx_password.value = '';
|
||||
form.influx_password.placeholder = app.settings.influxdb?.password_configured ? tr('settings.secretSaved') : tr('settings.secretKeep');
|
||||
form.influx_org.value = app.settings.influxdb?.org || '';
|
||||
form.influx_bucket.value = app.settings.influxdb?.bucket || 'gree_controller';
|
||||
form.influx_token.value = '';
|
||||
form.influx_token.placeholder = app.settings.influxdb?.token_configured ? tr('settings.secretSaved') : tr('settings.secretKeep');
|
||||
form.debug_overlay_enabled.checked = !!app.settings.debug?.overlay_enabled;
|
||||
form.debug_gree_frames.checked = !!app.settings.debug?.gree_frames;
|
||||
form.debug_cloud_requests.checked = !!app.settings.debug?.cloud_requests;
|
||||
form.debug_cloud_mqtt.checked = !!app.settings.debug?.cloud_mqtt;
|
||||
const n = app.settings.notifications || {};
|
||||
form.notifications_enabled.checked = !!n.enabled; form.notifications_mode.value = n.mode || 'problems'; form.notifications_provider.value = n.provider || 'pushover';
|
||||
form.pushover_app_token.value = ''; form.pushover_user_key.value = ''; form.slack_webhook_url.value = ''; form.discord_webhook_url.value = '';
|
||||
form.pushover_app_token.placeholder = n.pushover_configured ? tr('settings.secretSaved') : tr('notifications.applicationTokenPlaceholder');
|
||||
form.pushover_user_key.placeholder = n.pushover_configured ? tr('settings.secretSaved') : tr('notifications.userKeyPlaceholder');
|
||||
form.slack_webhook_url.placeholder = n.slack_configured ? tr('settings.secretSaved') : 'https://hooks.slack.com/services/…';
|
||||
form.discord_webhook_url.placeholder = n.discord_configured ? tr('settings.secretSaved') : 'https://discord.com/api/webhooks/…';
|
||||
form.notification_cooldown_seconds.value = n.cooldown_seconds || 300; form.notification_failure_threshold.value = n.communication_failure_threshold || 3; form.notification_target_timeout.value = n.target_timeout_minutes || 60;
|
||||
const alerts = n.alert_types || {};
|
||||
form.notification_alert_stale_sensor.checked = alerts.stale_sensor !== false;
|
||||
form.notification_alert_sensor_errors.checked = alerts.sensor_errors !== false;
|
||||
form.notification_alert_communication.checked = alerts.communication !== false;
|
||||
form.notification_alert_target_timeout.checked = alerts.target_timeout !== false;
|
||||
form.notification_alert_automation.checked = alerts.automation !== false;
|
||||
form.notification_alert_sensor_discrepancy.checked = alerts.sensor_discrepancy !== false;
|
||||
form.notification_alert_control_errors.checked = alerts.control_errors !== false;
|
||||
form.notification_alert_important_events.checked = alerts.important_events !== false;
|
||||
form.notification_alert_other.checked = alerts.other !== false;
|
||||
updateNotificationFields();
|
||||
updateInfluxFields();
|
||||
renderLogRetention();
|
||||
renderGreeFrameStats();
|
||||
renderSystemInfo();
|
||||
renderSimulationModeBanner();
|
||||
setSettingsTab(app.settingsTab);
|
||||
markFormClean(form);
|
||||
void refreshGreeCloudRuntimeStatus();
|
||||
}
|
||||
|
||||
function updateCompressorProtectionFields() {
|
||||
const form = $('#settingsForm');
|
||||
if (!form?.compressor_protection_enabled || !form?.compressor_protection_minutes) return;
|
||||
form.compressor_protection_minutes.disabled = !form.compressor_protection_enabled.checked;
|
||||
}
|
||||
|
||||
function renderSimulationModeBanner() {
|
||||
const banner = $('#simulationModeBanner');
|
||||
if (!banner) return;
|
||||
const enabled = !!app.settings?.simulator_enabled;
|
||||
banner.hidden = !enabled;
|
||||
document.body.classList.toggle('simulation-mode-enabled', enabled);
|
||||
}
|
||||
|
||||
function setSettingsTab(tab) {
|
||||
app.settingsTab = ['gree', 'cloud'].includes(tab) ? tab : 'app';
|
||||
$$('[data-settings-pane]').forEach(pane => { pane.hidden = pane.dataset.settingsPane !== app.settingsTab; });
|
||||
$$('[data-settings-tab]').forEach(button => {
|
||||
const active = button.dataset.settingsTab === app.settingsTab;
|
||||
button.classList.toggle('active', active);
|
||||
button.setAttribute('aria-selected', String(active));
|
||||
});
|
||||
}
|
||||
|
||||
function renderSystemInfo() {
|
||||
const host = $('#systemInfo');
|
||||
if (!host) return;
|
||||
const elapsed = Math.max(0, Math.floor((Date.now() - Number(app.systemSnapshotAt || Date.now())) / 1000));
|
||||
const uptime = Number(app.system?.uptime_seconds || 0) + elapsed;
|
||||
const dbPath = String(app.system?.database || '');
|
||||
const database = dbPath ? dbPath.split(/[\\/]/).filter(Boolean).pop() : '—';
|
||||
const ready = !!app.system?.control_ready;
|
||||
const connected = app.connectionStatus === 'connected';
|
||||
const items = [
|
||||
[tr('settings.version'), app.system?.version || '—', 'version'],
|
||||
[tr('settings.uptime'), formatDuration(uptime), 'uptime'],
|
||||
[tr('settings.controlEngine'), ready ? tr('settings.ready') : tr('settings.syncing'), ready ? 'ok' : 'warn'],
|
||||
[tr('settings.websocket'), tr(`status.${app.connectionStatus}`), connected ? 'ok' : 'warn'],
|
||||
[tr('settings.apiAuth'), app.system?.auth_required ? tr('settings.enabled') : tr('settings.disabled'), 'neutral'],
|
||||
[tr('settings.httpBind'), app.system?.bind || '—', 'mono'],
|
||||
[tr('settings.database'), database, 'mono'],
|
||||
[tr('settings.basePath'), app.system?.base_path || APP_BASE || '/', 'mono'],
|
||||
];
|
||||
host.innerHTML = `<div class="system-panel-head"><div><span class="eyebrow">${esc(tr('settings.systemState'))}</span><h3>${esc(tr('settings.systemStatusTitle'))}</h3><p>${esc(tr('settings.systemStateHint'))}</p></div><span class="system-health ${ready && connected ? 'ok' : 'warn'}">${esc(ready && connected ? tr('settings.healthy') : tr('settings.attention'))}</span></div><div class="system-status-grid">${items.map(([label, value, tone]) => `<div class="system-status-item ${esc(tone)}"><small>${esc(label)}</small><strong>${esc(value)}</strong></div>`).join('')}</div>`;
|
||||
}
|
||||
|
||||
function renderGreeFrameStats() {
|
||||
const host = $('#greeFrameStats');
|
||||
if (!host) return;
|
||||
const total = Number(app.system?.gree_received_frames || 0);
|
||||
const byDevice = app.system?.gree_received_frames_by_device || {};
|
||||
const deviceRows = app.devices.filter(device => !device.simulated).map(device => {
|
||||
const count = Number(byDevice[device.id] || 0);
|
||||
return `<div class="gree-frame-stat"><small>${esc(device.name || device.id)}</small><strong>${count.toLocaleString(locale())}</strong></div>`;
|
||||
}).join('');
|
||||
host.innerHTML = `<div class="gree-frame-stat total"><small>${esc(tr('settings.receivedFramesTotal'))}</small><strong>${total.toLocaleString(locale())}</strong></div>${deviceRows || `<div class="gree-frame-stat"><small>${esc(tr('settings.receivedFramesDevices'))}</small><strong>0</strong></div>`}`;
|
||||
}
|
||||
|
||||
function renderNightSettings() {
|
||||
if (!app.settings) return;
|
||||
const form = $('#nightModeForm');
|
||||
if (!form) return;
|
||||
form.night_mode_enabled.checked = !!app.settings.night_mode?.enabled;
|
||||
form.night_mode_start.value = app.settings.night_mode?.start_time || '22:00';
|
||||
form.night_mode_end.value = app.settings.night_mode?.end_time || '06:00';
|
||||
form.night_mode_max_fan_speed.value = String(app.settings.night_mode?.max_fan_speed || 1);
|
||||
form.night_mode_force_quiet.checked = app.settings.night_mode?.force_quiet !== false;
|
||||
form.night_mode_native_sleep.checked = app.settings.night_mode?.use_native_sleep !== false;
|
||||
markFormClean(form);
|
||||
}
|
||||
|
||||
function renderHomeAssistantAuthState() {
|
||||
const form = $('#homeAssistantForm');
|
||||
if (!form || !app.settings?.home_assistant) return;
|
||||
const ha = app.settings.home_assistant;
|
||||
const supervisorDetected = ha.supervisor_detected === true;
|
||||
const manualFallback = supervisorDetected && (ha.manual_auth_override === true || app.haManualFallbackVisible === true);
|
||||
form.dataset.haManualOverride = manualFallback ? 'true' : 'false';
|
||||
|
||||
$$('[data-ha-manual-field]', form).forEach(node => { node.hidden = supervisorDetected && !manualFallback; });
|
||||
form.ha_url.required = manualFallback;
|
||||
form.ha_token.required = manualFallback && !ha.manual_token_configured;
|
||||
const useSupervisor = $('#haUseSupervisor');
|
||||
if (useSupervisor) useSupervisor.hidden = !supervisorDetected || !manualFallback;
|
||||
|
||||
const status = $('#haSupervisorStatus');
|
||||
if (!status) return;
|
||||
status.hidden = !supervisorDetected;
|
||||
status.classList.remove('success', 'warning', 'error');
|
||||
if (!supervisorDetected) return;
|
||||
|
||||
const testState = app.haSupervisorTestState;
|
||||
if (manualFallback) {
|
||||
status.classList.add(testState?.ok === false ? 'error' : 'warning');
|
||||
status.innerHTML = `<strong>${esc(testState?.ok === false ? tr('settings.haSupervisorTestFailedTitle') : tr('settings.haManualFallbackTitle'))}</strong><span>${esc(testState?.ok === false ? tr('settings.haSupervisorTestFailedHint') : tr('settings.haManualFallbackHint'))}</span>`;
|
||||
} else if (testState?.ok === true) {
|
||||
status.classList.add('success');
|
||||
status.innerHTML = `<strong>${esc(tr('settings.haSupervisorVerifiedTitle'))}</strong><span>${esc(tr('settings.haSupervisorVerifiedHint'))}</span>`;
|
||||
} else if (ha.supervisor_token_detected) {
|
||||
status.classList.add('success');
|
||||
status.innerHTML = `<strong>${esc(tr('settings.haSupervisorAutoTitle'))}</strong><span>${esc(tr('settings.haSupervisorAutoHint'))}</span>`;
|
||||
} else {
|
||||
status.classList.add('warning');
|
||||
status.innerHTML = `<strong>${esc(tr('settings.haSupervisorMissingTitle'))}</strong><span>${esc(tr('settings.haSupervisorMissingHint'))}</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderHomeAssistantSettings() {
|
||||
if (!app.settings) return;
|
||||
const form = $('#homeAssistantForm');
|
||||
if (!form) return;
|
||||
const ha = app.settings.home_assistant || {};
|
||||
const supervisorDetected = ha.supervisor_detected === true;
|
||||
form.ha_url.value = supervisorDetected ? (ha.manual_url || '') : (ha.url || '');
|
||||
form.ha_url.readOnly = false;
|
||||
form.ha_token.value = '';
|
||||
form.ha_token.readOnly = false;
|
||||
form.ha_token.placeholder = (supervisorDetected ? ha.manual_token_configured : ha.token_configured)
|
||||
? tr('settings.haTokenSaved')
|
||||
: tr('settings.haLongLivedToken');
|
||||
form.ha_outdoor_entity_id.value = ha.outdoor_entity_id || '';
|
||||
form.ha_sensor_stale_after_minutes.value = String(Math.max(1, Math.round(Number(ha.sensor_stale_after_seconds || 300) / 60)));
|
||||
form.ha_allow_invalid_tls.checked = !!ha.allow_invalid_tls;
|
||||
form.outdoor_assist_enabled.checked = !!app.settings.outdoor_assist_enabled;
|
||||
renderHomeAssistantAuthState();
|
||||
renderSensorAliases();
|
||||
renderFlowSharedInputs();
|
||||
renderAccessTokens();
|
||||
markFormClean(form);
|
||||
}
|
||||
|
||||
function updateInfluxFields() {
|
||||
const version = $('#settingsForm [name=influx_version]')?.value || '2';
|
||||
$$('[data-influx-fields]').forEach(node => { node.hidden = node.dataset.influxFields !== version; });
|
||||
}
|
||||
|
||||
function updateNotificationFields() { const provider = $('#settingsForm [name=notifications_provider]')?.value || 'pushover'; $$('[data-notification-provider]').forEach(n => n.hidden = n.dataset.notificationProvider !== provider); }
|
||||
|
||||
function logCategory(kind = '') { const prefix = String(kind).split('.')[0]; return ['device', 'zone', 'automation', 'settings'].includes(prefix) ? prefix : (['home_assistant', 'influx', 'notification'].includes(prefix) ? 'integration' : 'system'); }
|
||||
function debugLine(source, kind, message, timestamp = new Date().toISOString(), data = null) {
|
||||
app.debugLines.push({ source, kind, message, timestamp, data });
|
||||
if (app.debugLines.length > 160) app.debugLines.splice(0, app.debugLines.length - 160);
|
||||
renderDebugOverlay();
|
||||
}
|
||||
|
||||
function renderDebugOverlay() {
|
||||
const overlay = $('#debugOverlay'); if (!overlay) return;
|
||||
const enabled = !!app.settings?.debug?.overlay_enabled;
|
||||
overlay.hidden = !enabled;
|
||||
if (!enabled) return;
|
||||
if (!['all', 'requests', 'gree', 'cloud', 'mqtt'].includes(app.debugFilter)) app.debugFilter = 'all';
|
||||
const status = $('#debugOverlayStatus');
|
||||
if (status) {
|
||||
const enabledSources = [
|
||||
app.settings?.debug?.gree_frames ? tr('debug.gree') : '',
|
||||
app.settings?.debug?.cloud_requests ? tr('debug.cloud') : '',
|
||||
app.settings?.debug?.cloud_mqtt ? tr('debug.mqtt') : '',
|
||||
].filter(Boolean);
|
||||
status.textContent = enabledSources.length ? `${tr('debug.liveSources')}: ${enabledSources.join(' · ')}` : tr('debug.apiOnly');
|
||||
}
|
||||
$$('[data-debug-filter]', overlay).forEach(button => {
|
||||
const active = button.dataset.debugFilter === app.debugFilter;
|
||||
button.classList.toggle('active', active);
|
||||
button.setAttribute('aria-selected', String(active));
|
||||
});
|
||||
const host = $('#debugOverlayLines'); if (!host) return;
|
||||
const visible = app.debugLines.filter(line => {
|
||||
if (app.debugFilter === 'gree') return line.source === 'GREE';
|
||||
if (app.debugFilter === 'requests') return line.source === 'HTTP';
|
||||
if (app.debugFilter === 'cloud') return line.source === 'CLOUD';
|
||||
if (app.debugFilter === 'mqtt') return line.source === 'MQTT';
|
||||
return true;
|
||||
}).slice(-120);
|
||||
host.innerHTML = visible.length ? visible.map(line => {
|
||||
const details = line.data == null ? '' : ` ${typeof line.data === 'string' ? line.data : JSON.stringify(line.data)}`;
|
||||
const sourceClass = line.source === 'GREE' ? 'gree' : line.source === 'HTTP' ? 'request' : line.source === 'CLOUD' ? 'cloud' : line.source === 'MQTT' ? 'mqtt' : 'api';
|
||||
return `<div class="debug-line source-${sourceClass}"><time>${esc(new Date(line.timestamp).toLocaleTimeString(locale()))}</time><b>${esc(line.source)}</b><span>${esc(line.kind)}: ${esc(line.message || '')}${esc(details)}</span></div>`;
|
||||
}).join('') : `<div class="debug-empty">${esc(tr(app.debugFilter === 'gree' ? 'debug.emptyGree' : app.debugFilter === 'requests' ? 'debug.emptyRequests' : app.debugFilter === 'cloud' ? 'debug.emptyCloud' : app.debugFilter === 'mqtt' ? 'debug.emptyMqtt' : 'debug.empty'))}</div>`;
|
||||
host.scrollTop = host.scrollHeight;
|
||||
}
|
||||
|
||||
async function loadDebugBacklog() {
|
||||
if (app.debugBacklogLoaded || !app.settings?.debug?.overlay_enabled) return;
|
||||
try {
|
||||
const data = await api('/api/events?limit=60');
|
||||
app.debugLines = (data.events || []).reverse().map(item => ({ source: 'API', kind: item.kind, message: item.message, timestamp: item.timestamp, data: item.metadata })).slice(-120);
|
||||
app.debugBacklogLoaded = true;
|
||||
renderDebugOverlay();
|
||||
} catch (_) { }
|
||||
}
|
||||
|
||||
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`;
|
||||
}
|
||||
|
||||
setInterval(() => { if (app.currentView === 'settings') renderSystemInfo(); }, 30000);
|
||||
|
||||
|
||||
let greeCloudRuntimeStatusCache = null;
|
||||
let greeCloudRuntimeStatusCachedAt = 0;
|
||||
let greeCloudRuntimeStatusRequest = null;
|
||||
const GREE_CLOUD_RUNTIME_STATUS_CACHE_MS = 5000;
|
||||
|
||||
function invalidateGreeCloudRuntimeStatusCache() {
|
||||
greeCloudRuntimeStatusCache = null;
|
||||
greeCloudRuntimeStatusCachedAt = 0;
|
||||
}
|
||||
|
||||
async function getGreeCloudRuntimeStatus(force = false) {
|
||||
const now = Date.now();
|
||||
if (!force && greeCloudRuntimeStatusCache && now - greeCloudRuntimeStatusCachedAt < GREE_CLOUD_RUNTIME_STATUS_CACHE_MS) {
|
||||
return greeCloudRuntimeStatusCache;
|
||||
}
|
||||
if (!force && greeCloudRuntimeStatusRequest) return greeCloudRuntimeStatusRequest;
|
||||
|
||||
const request = api('/api/integrations/gree-cloud/status');
|
||||
if (!force) greeCloudRuntimeStatusRequest = request;
|
||||
try {
|
||||
const status = await request;
|
||||
greeCloudRuntimeStatusCache = status;
|
||||
greeCloudRuntimeStatusCachedAt = Date.now();
|
||||
return status;
|
||||
} finally {
|
||||
if (greeCloudRuntimeStatusRequest === request) greeCloudRuntimeStatusRequest = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshGreeCloudRuntimeStatus({ force = false } = {}) {
|
||||
const summary = $('.cloud-runtime-summary');
|
||||
if (!summary) return;
|
||||
const metricSelectors = [
|
||||
'#greeCloudAccountStatus', '#greeCloudMqttStatus', '#greeCloudDevicesOnline', '#greeCloudRestResponseTime',
|
||||
'#greeCloudResponseTime', '#greeCloudLastDeviceResponse', '#greeCloudLastMqttMessage', '#greeCloudConnectedSince',
|
||||
'#greeCloudBroker', '#greeCloudTraffic',
|
||||
];
|
||||
const setMetric = (selector, value, visible) => {
|
||||
const node = $(selector); if (!node) return 0;
|
||||
const row = node.closest('div');
|
||||
if (row) row.hidden = !visible;
|
||||
if (visible) node.textContent = value;
|
||||
return visible ? 1 : 0;
|
||||
};
|
||||
const lastContact = $('#greeCloudLastContact');
|
||||
const lastContactRow = lastContact?.closest('small');
|
||||
try {
|
||||
const status = await getGreeCloudRuntimeStatus(force);
|
||||
const runtime = status.runtime || {};
|
||||
const enabled = status.enabled === true;
|
||||
const deviceCount = Number(status.device_count || 0);
|
||||
const onlineCount = Number(status.online_device_count || 0);
|
||||
const accountStatus = String(status.account_status || '').trim();
|
||||
const mqttStatus = String(status.mqtt_status || '').trim();
|
||||
const restMs = Number(status.last_rest_response_time_ms);
|
||||
const responseMs = Number(runtime.last_response_time_ms);
|
||||
const traffic = [Number(runtime.requests_sent || 0), Number(runtime.responses_received || 0), Number(runtime.request_timeouts || 0)];
|
||||
let visibleCount = 0;
|
||||
visibleCount += setMetric('#greeCloudAccountStatus', accountStatus.replaceAll('_', ' '), enabled && !!accountStatus && accountStatus !== 'disabled');
|
||||
visibleCount += setMetric('#greeCloudMqttStatus', mqttStatus.replaceAll('_', ' '), enabled && !!mqttStatus && (mqttStatus === 'connected' || deviceCount > 0 || !['disconnected', 'disabled'].includes(mqttStatus)));
|
||||
visibleCount += setMetric('#greeCloudDevicesOnline', `${onlineCount} / ${deviceCount}`, deviceCount > 0);
|
||||
visibleCount += setMetric('#greeCloudRestResponseTime', `${restMs} ms`, Number.isFinite(restMs) && restMs >= 0);
|
||||
visibleCount += setMetric('#greeCloudResponseTime', `${responseMs} ms`, Number.isFinite(responseMs) && responseMs >= 0);
|
||||
visibleCount += setMetric('#greeCloudLastDeviceResponse', dateTime(runtime.last_device_response), !!runtime.last_device_response);
|
||||
visibleCount += setMetric('#greeCloudLastMqttMessage', dateTime(runtime.last_mqtt_message), !!runtime.last_mqtt_message);
|
||||
visibleCount += setMetric('#greeCloudConnectedSince', dateTime(runtime.mqtt_connected_since), !!runtime.mqtt_connected_since);
|
||||
visibleCount += setMetric('#greeCloudBroker', runtime.broker_host || '', !!String(runtime.broker_host || '').trim());
|
||||
visibleCount += setMetric('#greeCloudTraffic', traffic.join(' / '), traffic.some(value => value > 0));
|
||||
summary.hidden = visibleCount === 0;
|
||||
if (lastContactRow) lastContactRow.hidden = !status.last_successful_contact;
|
||||
if (lastContact && status.last_successful_contact) lastContact.textContent = dateTime(status.last_successful_contact);
|
||||
} catch (_) {
|
||||
metricSelectors.forEach(selector => setMetric(selector, '', false));
|
||||
summary.hidden = true;
|
||||
if (lastContactRow) lastContactRow.hidden = true;
|
||||
}
|
||||
}
|
||||
@@ -1,539 +0,0 @@
|
||||
const SETTINGS_ENDPOINTS = {
|
||||
application: '/api/settings/application',
|
||||
gree: '/api/settings/gree',
|
||||
greeCloud: '/api/settings/gree-cloud',
|
||||
history: '/api/settings/history',
|
||||
influxdb: '/api/settings/influxdb',
|
||||
notifications: '/api/settings/notifications',
|
||||
night: '/api/settings/night',
|
||||
homeAssistant: '/api/settings/home-assistant',
|
||||
debug: '/api/settings/debug',
|
||||
};
|
||||
|
||||
function applySettingsSection(section, data) {
|
||||
app.settings = app.settings || {};
|
||||
if (section === 'application') app.settings.simulator_enabled = !!data.simulator_enabled;
|
||||
else if (section === 'gree') Object.assign(app.settings, data);
|
||||
else if (section === 'greeCloud') app.settings.gree_cloud = data;
|
||||
else if (section === 'history') {
|
||||
app.settings.history_retention_days = Number(data.retention_days);
|
||||
app.settings.history_compaction_enabled = data.compaction_enabled !== false;
|
||||
app.settings.event_log_retention_days = Number(data.event_retention_days);
|
||||
} else if (section === 'influxdb') app.settings.influxdb = data;
|
||||
else if (section === 'notifications') app.settings.notifications = data;
|
||||
else if (section === 'night') app.settings.night_mode = data;
|
||||
else if (section === 'homeAssistant') {
|
||||
app.settings.home_assistant = data;
|
||||
app.settings.outdoor_assist_enabled = !!data.outdoor_assist_enabled;
|
||||
} else if (section === 'debug') app.settings.debug = data;
|
||||
}
|
||||
|
||||
function applicationSettingsBodyFromForm(form) {
|
||||
return { simulator_enabled: form.simulator_enabled.checked };
|
||||
}
|
||||
|
||||
function greeSettingsBodyFromForm(form) {
|
||||
const raw = Object.fromEntries(new FormData(form));
|
||||
return {
|
||||
controller_id: raw.controller_id,
|
||||
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,
|
||||
ping_metrics_enabled: form.ping_metrics_enabled.checked,
|
||||
ping_interval_seconds: Number(raw.ping_interval_seconds || 60),
|
||||
ping_sample_count: Number(raw.ping_sample_count || 3),
|
||||
suppress_device_beep: form.suppress_device_beep.checked,
|
||||
compressor_protection_enabled: form.compressor_protection_enabled.checked,
|
||||
compressor_protection_seconds: Math.max(30, Math.min(1800, Math.round(Number(raw.compressor_protection_minutes || 3) * 60))),
|
||||
};
|
||||
}
|
||||
|
||||
function greeCloudSettingsBodyFromForm(form) {
|
||||
const raw = Object.fromEntries(new FormData(form));
|
||||
const body = {
|
||||
enabled: form.gree_cloud_enabled.checked,
|
||||
region: raw.gree_cloud_region || 'Europe',
|
||||
username: raw.gree_cloud_username || '',
|
||||
polling_interval_seconds: Number(raw.gree_cloud_polling_interval_seconds || 60),
|
||||
connectivity_metrics_enabled: form.gree_cloud_connectivity_metrics_enabled.checked,
|
||||
connectivity_metrics_interval_seconds: Number(raw.gree_cloud_connectivity_metrics_interval_seconds || 300),
|
||||
connectivity_metrics_sample_count: Number(raw.gree_cloud_connectivity_metrics_sample_count || 3),
|
||||
};
|
||||
if (raw.gree_cloud_password) body.password = raw.gree_cloud_password;
|
||||
return body;
|
||||
}
|
||||
|
||||
function historySettingsBodyFromForm(form, eventRetentionDays = null) {
|
||||
const raw = Object.fromEntries(new FormData(form));
|
||||
return {
|
||||
retention_days: Number(raw.history_retention_days),
|
||||
compaction_enabled: form.history_compaction_enabled.checked,
|
||||
event_retention_days: eventRetentionDays == null ? Number(raw.event_log_retention_days) : Number(eventRetentionDays),
|
||||
};
|
||||
}
|
||||
|
||||
function influxDbSettingsBodyFromForm(form) {
|
||||
const raw = Object.fromEntries(new FormData(form));
|
||||
const body = {
|
||||
enabled: form.influx_enabled.checked,
|
||||
version: raw.influx_version,
|
||||
url: raw.influx_url,
|
||||
database: raw.influx_database,
|
||||
username: raw.influx_username,
|
||||
org: raw.influx_org,
|
||||
bucket: raw.influx_bucket,
|
||||
history_threshold_days: Number(raw.influx_threshold_days),
|
||||
};
|
||||
if (raw.influx_password) body.password = raw.influx_password;
|
||||
if (raw.influx_token) body.token = raw.influx_token;
|
||||
return body;
|
||||
}
|
||||
|
||||
function notificationSettingsBodyFromForm(form) {
|
||||
const raw = Object.fromEntries(new FormData(form));
|
||||
const body = {
|
||||
enabled: form.notifications_enabled.checked,
|
||||
mode: raw.notifications_mode,
|
||||
provider: raw.notifications_provider,
|
||||
cooldown_seconds: Number(raw.notification_cooldown_seconds || 300),
|
||||
communication_failure_threshold: Number(raw.notification_failure_threshold || 3),
|
||||
target_timeout_minutes: Number(raw.notification_target_timeout || 60),
|
||||
alert_types: {
|
||||
stale_sensor: form.notification_alert_stale_sensor.checked,
|
||||
sensor_errors: form.notification_alert_sensor_errors.checked,
|
||||
communication: form.notification_alert_communication.checked,
|
||||
target_timeout: form.notification_alert_target_timeout.checked,
|
||||
automation: form.notification_alert_automation.checked,
|
||||
sensor_discrepancy: form.notification_alert_sensor_discrepancy.checked,
|
||||
control_errors: form.notification_alert_control_errors.checked,
|
||||
important_events: form.notification_alert_important_events.checked,
|
||||
other: form.notification_alert_other.checked,
|
||||
},
|
||||
};
|
||||
if (raw.pushover_app_token) body.pushover_app_token = raw.pushover_app_token;
|
||||
if (raw.pushover_user_key) body.pushover_user_key = raw.pushover_user_key;
|
||||
if (raw.slack_webhook_url) body.slack_webhook_url = raw.slack_webhook_url;
|
||||
if (raw.discord_webhook_url) body.discord_webhook_url = raw.discord_webhook_url;
|
||||
return body;
|
||||
}
|
||||
|
||||
function debugSettingsBodyFromForm(form) {
|
||||
return {
|
||||
overlay_enabled: form.debug_overlay_enabled.checked,
|
||||
gree_frames: form.debug_gree_frames.checked,
|
||||
cloud_requests: form.debug_cloud_requests.checked,
|
||||
cloud_mqtt: form.debug_cloud_mqtt.checked,
|
||||
};
|
||||
}
|
||||
|
||||
function nightSettingsBodyFromForm(form) {
|
||||
const raw = Object.fromEntries(new FormData(form));
|
||||
return {
|
||||
enabled: form.night_mode_enabled.checked,
|
||||
start_time: raw.night_mode_start,
|
||||
end_time: raw.night_mode_end,
|
||||
max_fan_speed: Number(raw.night_mode_max_fan_speed),
|
||||
force_quiet: form.night_mode_force_quiet.checked,
|
||||
use_native_sleep: form.night_mode_native_sleep.checked,
|
||||
};
|
||||
}
|
||||
|
||||
function homeAssistantSettingsBodyFromForm(form) {
|
||||
const raw = Object.fromEntries(new FormData(form));
|
||||
const body = {
|
||||
url: raw.ha_url,
|
||||
outdoor_entity_id: raw.ha_outdoor_entity_id,
|
||||
sensor_stale_after_seconds: Math.max(60, Math.min(86400, Math.round(Number(raw.ha_sensor_stale_after_minutes || 5) * 60))),
|
||||
allow_invalid_tls: form.ha_allow_invalid_tls.checked,
|
||||
manual_auth_override: form.dataset.haManualOverride === 'true',
|
||||
sensor_aliases: { ...(app.sensorAliases || {}) },
|
||||
flow_inputs: JSON.parse(JSON.stringify(app.flowSharedInputs || [])),
|
||||
outdoor_assist_enabled: form.outdoor_assist_enabled.checked,
|
||||
};
|
||||
if (raw.ha_token) body.token = raw.ha_token;
|
||||
return body;
|
||||
}
|
||||
|
||||
function refreshSettingsUi() {
|
||||
renderSettings();
|
||||
renderNightSettings();
|
||||
renderHomeAssistantSettings();
|
||||
renderHouseClimate();
|
||||
renderSimulationModeBanner();
|
||||
renderSystemInfo();
|
||||
renderDebugOverlay();
|
||||
if (app.flowDraft) renderFlowEditor();
|
||||
scheduleControlPlanLoad();
|
||||
if (app.settings?.debug?.overlay_enabled) loadDebugBacklog();
|
||||
}
|
||||
|
||||
async function saveMainSettings(form, notify = true) {
|
||||
const [application, gree, greeCloud, history, influxdb, notifications, debug] = await Promise.all([
|
||||
api(SETTINGS_ENDPOINTS.application, { method: 'PUT', body: applicationSettingsBodyFromForm(form) }),
|
||||
api(SETTINGS_ENDPOINTS.gree, { method: 'PUT', body: greeSettingsBodyFromForm(form) }),
|
||||
api(SETTINGS_ENDPOINTS.greeCloud, { method: 'PUT', body: greeCloudSettingsBodyFromForm(form) }),
|
||||
api(SETTINGS_ENDPOINTS.history, { method: 'PUT', body: historySettingsBodyFromForm(form) }),
|
||||
api(SETTINGS_ENDPOINTS.influxdb, { method: 'PUT', body: influxDbSettingsBodyFromForm(form) }),
|
||||
api(SETTINGS_ENDPOINTS.notifications, { method: 'PUT', body: notificationSettingsBodyFromForm(form) }),
|
||||
api(SETTINGS_ENDPOINTS.debug, { method: 'PUT', body: debugSettingsBodyFromForm(form) }),
|
||||
]);
|
||||
applySettingsSection('application', application);
|
||||
applySettingsSection('gree', gree);
|
||||
applySettingsSection('greeCloud', greeCloud);
|
||||
invalidateGreeCloudRuntimeStatusCache();
|
||||
applySettingsSection('history', history);
|
||||
applySettingsSection('influxdb', influxdb);
|
||||
applySettingsSection('notifications', notifications);
|
||||
applySettingsSection('debug', debug);
|
||||
refreshSettingsUi();
|
||||
if (notify) toast(tr('common.saved'));
|
||||
}
|
||||
|
||||
async function saveNightSettings(form, notify = true) {
|
||||
const data = await api(SETTINGS_ENDPOINTS.night, { method: 'PUT', body: nightSettingsBodyFromForm(form) });
|
||||
applySettingsSection('night', data);
|
||||
refreshSettingsUi();
|
||||
if (notify) toast(tr('common.saved'));
|
||||
}
|
||||
|
||||
async function saveHomeAssistantSettings(form, notify = true) {
|
||||
const data = await api(SETTINGS_ENDPOINTS.homeAssistant, { method: 'PUT', body: homeAssistantSettingsBodyFromForm(form) });
|
||||
applySettingsSection('homeAssistant', data);
|
||||
app.sensorAliases = { ...(data.sensor_aliases || {}) };
|
||||
app.flowSharedInputs = JSON.parse(JSON.stringify(data.flow_inputs || []));
|
||||
app.haEntityCatalogLoadedAt = 0;
|
||||
refreshSettingsUi();
|
||||
if (notify) toast(tr('common.saved'));
|
||||
}
|
||||
|
||||
$('#settingsForm').addEventListener('submit', async event => {
|
||||
event.preventDefault(); const form = event.currentTarget;
|
||||
await runFormTask(form, () => saveMainSettings(form, true));
|
||||
});
|
||||
|
||||
$('#nightModeForm')?.addEventListener('submit', async event => {
|
||||
event.preventDefault(); const form = event.currentTarget;
|
||||
await runFormTask(form, () => saveNightSettings(form, true));
|
||||
});
|
||||
|
||||
$('#homeAssistantForm')?.addEventListener('submit', async event => {
|
||||
event.preventDefault(); const form = event.currentTarget;
|
||||
await runFormTask(form, () => saveHomeAssistantSettings(form, true));
|
||||
});
|
||||
|
||||
$('#addSensorAlias')?.addEventListener('click', () => {
|
||||
const entityInput = $('#sensorAliasEntity'), aliasInput = $('#sensorAliasName');
|
||||
const entity = entityInput.value.trim(), alias = aliasInput.value.trim();
|
||||
if (!entity || !alias) return toast(tr('settings.aliasRequired'), true);
|
||||
app.sensorAliases[entity] = alias;
|
||||
entityInput.value = ''; aliasInput.value = '';
|
||||
renderSensorAliases(); renderHistoryNavigation();
|
||||
updateDirtyIndicator($('#homeAssistantForm'));
|
||||
});
|
||||
|
||||
document.addEventListener('input', event => {
|
||||
const input = event.target.closest('[data-sensor-alias]'); if (!input) return;
|
||||
const entity = input.dataset.sensorAlias, alias = input.value.trim();
|
||||
if (alias) app.sensorAliases[entity] = alias; else delete app.sensorAliases[entity];
|
||||
updateDirtyIndicator($('#homeAssistantForm'));
|
||||
});
|
||||
|
||||
document.addEventListener('click', event => {
|
||||
const button = event.target.closest('[data-clear-sensor-alias]'); if (!button) return;
|
||||
delete app.sensorAliases[button.dataset.clearSensorAlias];
|
||||
renderSensorAliases(); renderHistoryNavigation();
|
||||
updateDirtyIndicator($('#homeAssistantForm'));
|
||||
});
|
||||
|
||||
$('#saveLogRetention')?.addEventListener('click', async () => {
|
||||
const days = Number($('#logRetentionDays')?.value || 30);
|
||||
try {
|
||||
const form = $('#settingsForm');
|
||||
const result = await api(SETTINGS_ENDPOINTS.history, { method: 'PUT', body: historySettingsBodyFromForm(form, days) });
|
||||
applySettingsSection('history', result);
|
||||
form.event_log_retention_days.value = result.event_retention_days;
|
||||
renderLogRetention(); await loadLogs(); toast(tr('logs.retentionSaved', { days: result.event_retention_days }));
|
||||
} 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'));
|
||||
}
|
||||
});
|
||||
|
||||
function homeAssistantTestToast(result) {
|
||||
const sample = result?.sample;
|
||||
if (!sample?.entity_id) return tr('toast.haConnected');
|
||||
const value = `${sample.state ?? '—'}${sample.unit ? ` ${sample.unit}` : ''}`;
|
||||
const label = sample.name ? `${sample.name} (${sample.entity_id})` : sample.entity_id;
|
||||
return tr('toast.haConnectedSample', { entity: label, value });
|
||||
}
|
||||
|
||||
$('#haTest').addEventListener('click', async event => {
|
||||
const form = $('#homeAssistantForm'), button = event.currentTarget;
|
||||
clearFormErrors(form);
|
||||
const ha = app.settings?.home_assistant || {};
|
||||
const supervisorAuto = ha.supervisor_detected === true && form.dataset.haManualOverride !== 'true';
|
||||
if (!supervisorAuto && !validateForm(form)) return;
|
||||
button.disabled = true;
|
||||
const idle = button.textContent;
|
||||
button.textContent = tr('settings.testingHa');
|
||||
try {
|
||||
if (!supervisorAuto) await saveHomeAssistantSettings(form, false);
|
||||
const result = await api('/api/integrations/home-assistant/test', { method: 'POST' });
|
||||
app.haSupervisorTestState = { ok: true, sample: result.sample || null };
|
||||
if (app.settings?.home_assistant?.supervisor_detected && app.settings.home_assistant.auth_mode === 'supervisor') {
|
||||
app.haManualFallbackVisible = false;
|
||||
}
|
||||
app.haEntityCatalogLoadedAt = 0;
|
||||
renderHomeAssistantAuthState();
|
||||
markFormClean(form);
|
||||
toast(homeAssistantTestToast(result));
|
||||
} catch (error) {
|
||||
if (supervisorAuto) {
|
||||
app.haSupervisorTestState = { ok: false, message: error.message };
|
||||
app.haManualFallbackVisible = true;
|
||||
renderHomeAssistantAuthState();
|
||||
toast(`${tr('settings.haSupervisorTestFailedTitle')}: ${error.message}`, true);
|
||||
} else {
|
||||
presentFormError(form, error);
|
||||
}
|
||||
} finally { button.disabled = false; button.textContent = idle; }
|
||||
});
|
||||
|
||||
$('#haUseSupervisor')?.addEventListener('click', async event => {
|
||||
const form = $('#homeAssistantForm'), button = event.currentTarget;
|
||||
const idle = button.textContent;
|
||||
button.disabled = true;
|
||||
clearFormErrors(form);
|
||||
try {
|
||||
app.haManualFallbackVisible = false;
|
||||
form.dataset.haManualOverride = 'false';
|
||||
await saveHomeAssistantSettings(form, false);
|
||||
const result = await api('/api/integrations/home-assistant/test', { method: 'POST' });
|
||||
app.haSupervisorTestState = { ok: true, sample: result.sample || null };
|
||||
app.haEntityCatalogLoadedAt = 0;
|
||||
renderHomeAssistantSettings();
|
||||
toast(homeAssistantTestToast(result));
|
||||
} catch (error) {
|
||||
app.haManualFallbackVisible = true;
|
||||
app.haSupervisorTestState = { ok: false, message: error.message };
|
||||
renderHomeAssistantAuthState();
|
||||
toast(`${tr('settings.haSupervisorTestFailedTitle')}: ${error.message}`, true);
|
||||
} finally { button.disabled = false; button.textContent = idle; }
|
||||
});
|
||||
|
||||
$('#exportSettings').addEventListener('click', async () => {
|
||||
try {
|
||||
const data = await api('/api/configuration/export');
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
||||
const link = document.createElement('a');
|
||||
link.href = URL.createObjectURL(blob);
|
||||
link.download = `gree-controller-configuration-${new Date().toISOString().slice(0, 10)}.json`;
|
||||
document.body.appendChild(link); link.click(); link.remove(); URL.revokeObjectURL(link.href);
|
||||
toast(tr('toast.exported'));
|
||||
} catch (error) { toast(error.message, true); }
|
||||
});
|
||||
|
||||
$('#importSettings').addEventListener('click', () => $('#importSettingsFile').click());
|
||||
$('#importSettingsFile').addEventListener('change', async event => {
|
||||
const file = event.target.files?.[0]; if (!file) return;
|
||||
try {
|
||||
if (!confirm(tr('settings.importConfirm'))) return;
|
||||
const body = JSON.parse(await file.text());
|
||||
await api('/api/configuration/import', { method: 'POST', body });
|
||||
app.debugBacklogLoaded = false;
|
||||
await loadBootstrap();
|
||||
toast(tr('toast.imported'));
|
||||
} catch (error) { toast(error.message, true); }
|
||||
finally { event.target.value = ''; }
|
||||
});
|
||||
|
||||
document.addEventListener('change', event => {
|
||||
const target = event.target;
|
||||
if (target.id === 'historyZoneSelect') { app.historyZone = target.value; updateBrowserUrl(currentHistoryPath()); renderHistoryPage(); }
|
||||
else if (target.id === 'historyDeviceSelect') { app.historyDevice = target.value; updateBrowserUrl(currentHistoryPath()); renderHistoryPage(); }
|
||||
else if (target.matches?.('[data-history-energy-target]')) {
|
||||
const checked = $$('[data-history-energy-target]:checked').map(input => input.dataset.historyEnergyTarget).filter(Boolean);
|
||||
if (checked.length > 8) { target.checked = false; toast(tr('energy.maxTargets'), true); return; }
|
||||
app.historyEnergyTargets = checked;
|
||||
app.historyEnergyDevice = app.historyEnergyTargets[0] || '';
|
||||
updateBrowserUrl(currentHistoryPath());
|
||||
loadHistory();
|
||||
}
|
||||
else if (target.id === 'historyEnergyInterval') { app.historyEnergyInterval = target.value; updateBrowserUrl(currentHistoryPath()); loadHistory(); }
|
||||
else if (target.id === 'historyEnergyCompare') { app.historyEnergyCompare = target.value; updateBrowserUrl(currentHistoryPath()); loadHistory(); }
|
||||
else if (target.id === 'historyNetworkSelect') { app.historyNetworkTarget = target.value; updateBrowserUrl(currentHistoryPath()); loadHistory(); }
|
||||
else if (target.id === 'historySensorSelect') { app.historySensor = target.value; updateBrowserUrl(currentHistoryPath()); renderHistoryPage(); }
|
||||
else if (target.name === 'influx_version') updateInfluxFields();
|
||||
});
|
||||
|
||||
|
||||
|
||||
$('#addFlowSharedInput')?.addEventListener('click', () => openFlowSharedInputEditor());
|
||||
$('#flowSharedInputKind')?.addEventListener('change', event => {
|
||||
renderFlowSharedInputFields(event.target.value, flowSharedInputDefaultConfig(event.target.value));
|
||||
});
|
||||
function evaluateFlowSharedHaTest(kind, config, result) {
|
||||
if (kind === 'ha_available') return { actual: result.available === true, valid: true };
|
||||
if (kind === 'ha_attribute') {
|
||||
const actual = result.attributes?.[config.attribute];
|
||||
return { actual, valid: actual !== undefined };
|
||||
}
|
||||
if (kind === 'ha_numeric') {
|
||||
const actual = Number(result.state);
|
||||
return { actual, valid: Number.isFinite(actual) };
|
||||
}
|
||||
return { actual: result.state, valid: true };
|
||||
}
|
||||
|
||||
$('#flowSharedInputTest')?.addEventListener('click', async event => {
|
||||
const form = $('#flowSharedInputForm'), resultHost = $('#flowSharedInputTestResult');
|
||||
if (!form || !resultHost) return;
|
||||
const kind = form.kind.value, config = collectFlowSharedInputConfig(kind);
|
||||
const entityId = String(config.entity_id || '').trim();
|
||||
if (!entityId) {
|
||||
resultHost.hidden = false;
|
||||
resultHost.classList.remove('pass');
|
||||
resultHost.classList.add('fail');
|
||||
resultHost.innerHTML = `<strong>${esc(tr('flow.sharedInputTestUnavailable'))}</strong><span>${esc(tr('flow.sharedInputTestEntityRequired'))}</span>`;
|
||||
return;
|
||||
}
|
||||
const button = event.currentTarget, idle = button.textContent;
|
||||
button.disabled = true; button.textContent = tr('flow.sharedInputTesting');
|
||||
resultHost.hidden = false; resultHost.innerHTML = `<span>${esc(tr('flow.sharedInputTesting'))}</span>`;
|
||||
try {
|
||||
const result = await api('/api/integrations/home-assistant/entity', { method: 'POST', body: { entity_id: entityId } });
|
||||
const evaluation = evaluateFlowSharedHaTest(kind, config, result);
|
||||
const actual = evaluation.actual == null ? 'null' : (typeof evaluation.actual === 'object' ? JSON.stringify(evaluation.actual) : String(evaluation.actual));
|
||||
const success = evaluation.valid;
|
||||
resultHost.classList.toggle('pass', success);
|
||||
resultHost.classList.toggle('fail', !success);
|
||||
resultHost.innerHTML = `<div><strong>${esc(success ? tr('flow.sharedInputTestValueRead') : tr('flow.sharedInputTestUnavailable'))}</strong><span class="badge ${success ? 'active' : ''}">${esc(result.available ? tr('flow.sharedInputTestAvailable') : tr('flow.sharedInputTestUnavailable'))}</span></div><dl><div><dt>${esc(tr('flow.sharedInputTestCurrent'))}</dt><dd><code>${esc(actual)}</code></dd></div></dl><small>${esc(result.entity_id)}${result.last_updated ? ` · ${esc(dateTime(result.last_updated))}` : ''}</small>`;
|
||||
} catch (error) {
|
||||
resultHost.classList.remove('pass'); resultHost.classList.add('fail');
|
||||
resultHost.innerHTML = `<strong>${esc(tr('flow.sharedInputTestUnavailable'))}</strong><span>${esc(error.message)}</span>`;
|
||||
} finally { button.disabled = false; button.textContent = idle; }
|
||||
});
|
||||
$('#flowSharedInputForm')?.addEventListener('submit', event => {
|
||||
event.preventDefault();
|
||||
const form = event.currentTarget;
|
||||
const name = form.name.value.trim(), kind = form.kind.value;
|
||||
if (!name) return toast(tr('flow.sharedInputNameRequired'), true);
|
||||
const editingId = form.dataset.editingId || '';
|
||||
const id = editingId || newFlowId('shared');
|
||||
const item = { id, name, kind, config: collectFlowSharedInputConfig(kind) };
|
||||
const index = app.flowSharedInputs.findIndex(value => value.id === id);
|
||||
if (index >= 0) app.flowSharedInputs[index] = item; else app.flowSharedInputs.push(item);
|
||||
renderFlowSharedInputs();
|
||||
updateDirtyIndicator($('#homeAssistantForm'));
|
||||
$('#flowSharedInputDialog')?.close();
|
||||
});
|
||||
|
||||
document.addEventListener('click', event => {
|
||||
const edit = event.target.closest?.('[data-flow-shared-edit]');
|
||||
if (edit) { openFlowSharedInputEditor(edit.dataset.flowSharedEdit); return; }
|
||||
const openFlow = event.target.closest?.('[data-open-shared-flow]');
|
||||
if (openFlow) {
|
||||
if (showView('flows')) requestAnimationFrame(() => openFlowEditor(openFlow.dataset.openSharedFlow));
|
||||
return;
|
||||
}
|
||||
const remove = event.target.closest?.('[data-flow-shared-delete]');
|
||||
if (!remove) return;
|
||||
const id = remove.dataset.flowSharedDelete;
|
||||
const item = app.flowSharedInputs.find(value => value.id === id); if (!item) return;
|
||||
const uses = app.flows.reduce((count, flow) => count + (flow.nodes || []).filter(node => node.kind === 'shared_input' && node.config?.input_id === id).length, 0);
|
||||
const message = uses ? tr('flow.sharedInputDeleteUsed', { name: item.name, count: uses }) : tr('flow.sharedInputDeleteConfirm', { name: item.name });
|
||||
if (!confirm(message)) return;
|
||||
app.flowSharedInputs = app.flowSharedInputs.filter(value => value.id !== id);
|
||||
renderFlowSharedInputs();
|
||||
updateDirtyIndicator($('#homeAssistantForm'));
|
||||
});
|
||||
|
||||
async function saveGreeCloudSettings(form = $('#settingsForm')) {
|
||||
const data = await api(SETTINGS_ENDPOINTS.greeCloud, { method: 'PUT', body: greeCloudSettingsBodyFromForm(form) });
|
||||
applySettingsSection('greeCloud', data);
|
||||
invalidateGreeCloudRuntimeStatusCache();
|
||||
return data;
|
||||
}
|
||||
|
||||
function renderCloudDiscoveryDevices(devices) {
|
||||
const list = $('#cloudDiscoveryList');
|
||||
if (!list) return;
|
||||
const items = Array.isArray(devices) ? devices : [];
|
||||
list.innerHTML = items.length ? items.map(device => `
|
||||
<div class="discovery-name-row cloud-discovery-row">
|
||||
<span><strong>${esc(device.name || 'GREE')}</strong><small>${esc(device.model || 'GREE')} · ${esc(device.mac || device.id)} · ${esc(tr(device.online ? 'status.online' : 'status.offline'))}</small></span>
|
||||
<button type="button" class="${device.already_added ? 'secondary' : 'primary'}" data-action="add-cloud-device" data-cloud-id="${esc(device.id)}" ${device.already_added ? 'disabled' : ''}>${esc(device.already_added ? tr('devices.cloudAlreadyAdded') : tr('actions.add'))}</button>
|
||||
</div>`).join('') : `<div class="empty"><strong>${esc(tr('devices.cloudDiscoveryEmpty'))}</strong>${esc(tr('devices.cloudDiscoveryEmptyHint'))}</div>`;
|
||||
}
|
||||
|
||||
async function loadCloudDiscovery({ open = true } = {}) {
|
||||
const result = await api('/api/integrations/gree-cloud/devices');
|
||||
renderCloudDiscoveryDevices(result.devices || []);
|
||||
if (open) openDialog('cloudDiscoveryDialog');
|
||||
return result;
|
||||
}
|
||||
|
||||
$('#greeCloudTestButton')?.addEventListener('click', async event => {
|
||||
const button = event.currentTarget;
|
||||
const form = $('#settingsForm');
|
||||
const resultBox = $('#greeCloudTestResult');
|
||||
button.disabled = true;
|
||||
if (resultBox) { resultBox.hidden = true; resultBox.classList.remove('success', 'error'); }
|
||||
try {
|
||||
await saveGreeCloudSettings(form);
|
||||
const result = await api('/api/integrations/gree-cloud/test', { method: 'POST' });
|
||||
if (resultBox) {
|
||||
resultBox.hidden = false;
|
||||
resultBox.classList.add(result.ok ? 'success' : 'error');
|
||||
const message = result.ok ? `Connected. ${Number(result.device_count || 0)} device(s) found.` : (result.message || result.status || 'Connection failed');
|
||||
resultBox.innerHTML = `<span>${esc(result.ok ? tr('status.online') : tr('devices.lastError'))}</span><strong>${esc(message)}</strong>`;
|
||||
}
|
||||
if (result.ok) {
|
||||
const refreshed = await api(SETTINGS_ENDPOINTS.greeCloud);
|
||||
applySettingsSection('greeCloud', refreshed);
|
||||
renderSettings();
|
||||
}
|
||||
} catch (error) {
|
||||
if (resultBox) { resultBox.hidden = false; resultBox.classList.add('error'); resultBox.innerHTML = `<span>${esc(tr('devices.lastError'))}</span><strong>${esc(error.message)}</strong>`; }
|
||||
} finally { button.disabled = false; }
|
||||
});
|
||||
|
||||
$('#greeCloudRefreshButton')?.addEventListener('click', async event => {
|
||||
const button = event.currentTarget;
|
||||
button.disabled = true;
|
||||
try { await saveGreeCloudSettings($('#settingsForm')); await loadCloudDiscovery(); }
|
||||
catch (error) { toast(error.message, true); }
|
||||
finally { button.disabled = false; }
|
||||
});
|
||||
|
||||
$('#cloudDiscoveryRefresh')?.addEventListener('click', async event => {
|
||||
const button = event.currentTarget;
|
||||
button.disabled = true;
|
||||
try { await loadCloudDiscovery({ open: false }); }
|
||||
catch (error) { toast(error.message, true); }
|
||||
finally { button.disabled = false; }
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
const CACHE = 'gree-controller-__GREE_ASSET_CACHE__';
|
||||
const SCOPE = new URL(self.registration.scope).pathname.replace(/\/$/, '');
|
||||
const path = value => `${SCOPE}${value.startsWith('/') ? value : `/${value}`}` || '/';
|
||||
const ASSETS = [path('__GREE_STYLES_ASSET__'), path('__GREE_APP_ASSET__'), path('__GREE_THEME_INIT_ASSET__'), path('__GREE_LANG_INIT_ASSET__'), path('/favicon.svg'), path('/manifest.webmanifest')];
|
||||
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 => {
|
||||
const url = new URL(event.request.url);
|
||||
if (event.request.method !== 'GET' || url.pathname.startsWith(path('/api/')) || url.pathname.startsWith(path('/lang/')) || event.request.mode === 'navigate') return;
|
||||
event.respondWith(caches.match(event.request).then(cached => cached || fetch(event.request).then(response => {
|
||||
const copy = response.clone(); caches.open(CACHE).then(cache => cache.put(event.request, copy)); return response;
|
||||
})));
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
'use strict';
|
||||
(() => {
|
||||
const row = document.cookie.split('; ').find(item => item.startsWith('gree_controller_theme='));
|
||||
const requested = row ? decodeURIComponent(row.split('=').slice(1).join('=')) : 'system';
|
||||
const resolved = requested === 'light' || requested === 'dark'
|
||||
? requested
|
||||
: (window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark');
|
||||
document.documentElement.dataset.theme = resolved;
|
||||
})();
|
||||
Reference in New Issue
Block a user