v0.14.2-connectivity

This commit is contained in:
Mateusz Gruszczyński
2026-09-15 10:02:49 +02:00
parent 2fd7e47eb9
commit c3f6e773f8
41 changed files with 1295 additions and 108 deletions
+3
View File
@@ -15,6 +15,9 @@ function settingsFromSnapshot(sections, houseMode = app.settings?.house_mode ||
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,
+23 -8
View File
@@ -106,6 +106,11 @@ function renderHistoryNavigation() {
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('');
host.innerHTML = `<label><span>${esc(tr('history.networkTarget'))}</span><select id="historyNetworkSelect"><option value="all">${esc(tr('history.allNetworkTargets'))}</option>${targetOptions}</select></label>`;
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;
@@ -129,6 +134,16 @@ async function loadHistory() {
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 || {};
@@ -323,11 +338,11 @@ function nearestPoint(points, targetTs) {
return Math.abs(before.ts - targetTs) <= Math.abs(after.ts - targetTs) ? before : after;
}
function formatChartTooltipValue(item, point, binaryLabels) {
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} °C`;
return binaryLabels ? formatted : `${formatted}${tooltipUnit}`;
}
function bindChartTooltip(canvas, series, sortedRows, geometry) {
@@ -337,7 +352,7 @@ function bindChartTooltip(canvas, series, sortedRows, geometry) {
const selection = wrap?.querySelector('.chart-selection');
if (!canvas || !wrap || !tooltip || !line) return;
const { pad, width, height, firstTs, lastTs, binaryLabels } = geometry;
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 }));
@@ -367,7 +382,7 @@ function bindChartTooltip(canvas, series, sortedRows, geometry) {
$$('.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))}${sampleTime}</span></div>`;
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;
@@ -454,9 +469,9 @@ function bindChartTooltip(canvas, series, sortedRows, geometry) {
};
}
function drawLineChart(canvas, series, rows, { height = 340, minValue = null, maxValue = null, binaryLabels = false } = {}) {
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 };
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);
@@ -482,7 +497,7 @@ function drawLineChart(canvas, series, rows, { height = 340, minValue = null, ma
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(1)}°`, pad.left - 8, py + 3);
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++) {
@@ -501,7 +516,7 @@ function drawLineChart(canvas, series, rows, { height = 340, minValue = null, ma
});
ctx.stroke(); ctx.setLineDash([]);
});
bindChartTooltip(canvas, visibleSeries, sortedRows, { pad, width, height, firstTs, lastTs, binaryLabels });
bindChartTooltip(canvas, visibleSeries, sortedRows, { pad, width, height, firstTs, lastTs, binaryLabels, tooltipUnit });
}
function energyBucketLabel(timestamp, interval) {
+1 -1
View File
@@ -30,7 +30,7 @@ const app = {
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', historyEnergyDevice: '', historyEnergyTargets: [], historyEnergyInterval: 'daily', historyEnergyCompare: 'none', historyEnergy: [], historyLoading: false, historyReloadPending: false,
historyZone: 'all', historyDevice: 'all', historySensor: 'all', historyNetworkTarget: 'all', historyNetwork: [], historyNetworkTargets: [], historyEnergyDevice: '', historyEnergyTargets: [], historyEnergyInterval: 'daily', historyEnergyCompare: 'none', historyEnergy: [], historyLoading: false, historyReloadPending: false,
customChartSeries: [], savedCharts: [], 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,
simulationScope: 'units', simulationTarget: 'all', standaloneSimulation: false, dashboardTab: 'main', settingsTab: 'app', systemSnapshotAt: Date.now(),
+63
View File
@@ -29,6 +29,7 @@ function outdoorHistorySeries(devices, rows) {
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')],
@@ -113,6 +114,67 @@ function renderSensorHistory() {
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`,
});
latencySeries.push({
key: `${id}:jitter`, label: `${label} · ${tr('history.networkJitter')}`, color, dash: [5, 4],
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();
@@ -203,6 +265,7 @@ function renderHistoryPage() {
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();
}
+9 -6
View File
@@ -127,14 +127,15 @@ function renderPingDialog() {
const toggle = $('#pingToggleButton');
const grid = $('#pingLiveGrid');
if (!dialog || !select || !all || !toggle || !grid) return;
if (!app.pingMonitor.targetId || !app.devices.some(device => device.id === app.pingMonitor.targetId)) app.pingMonitor.targetId = app.devices[0]?.id || '';
select.innerHTML = app.devices.map(device => `<option value="${esc(device.id)}" ${device.id === app.pingMonitor.targetId ? 'selected' : ''}>${esc(device.name)}</option>`).join('');
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 ? app.devices : app.devices.filter(device => device.id === app.pingMonitor.targetId);
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);
@@ -168,7 +169,8 @@ function schedulePingCycle(delay = null) {
async function runPingCycle() {
if (!app.pingMonitor.running || !$('#pingDialog')?.open || app.pingMonitor.inFlight) return;
const targets = app.pingMonitor.all ? [...app.devices] : app.devices.filter(device => device.id === app.pingMonitor.targetId);
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) => {
@@ -190,7 +192,7 @@ async function runPingCycle() {
}
function startPingMonitor() {
if (!app.devices.length) return;
if (!app.devices.some(device => device.connection_type !== 'gree_cloud')) return;
app.pingMonitor.running = true;
renderPingDialog();
clearTimeout(app.pingMonitor.timer);
@@ -206,7 +208,8 @@ function stopPingMonitor() {
}
function openDevicePing(id) {
app.pingMonitor.targetId = id || app.devices[0]?.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();
+3 -1
View File
@@ -13,7 +13,7 @@ const VIEW_ROUTES = Object.freeze({
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', 'sensors', 'custom']);
const HISTORY_TABS = Object.freeze(['overview', 'zones', 'devices', 'energy', 'network', 'sensors', 'custom']);
let routerInitialized = false;
function currentHistoryPath() {
@@ -26,6 +26,7 @@ function currentHistoryPath() {
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();
@@ -81,6 +82,7 @@ function applyRouteFromLocation() {
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;
+6
View File
@@ -173,6 +173,9 @@ function renderSettings() {
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;
@@ -185,6 +188,9 @@ function renderSettings() {
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);
const cloudInstance = $('#greeCloudInstanceId');
if (cloudInstance) cloudInstance.textContent = cloud.installation_id || '—';
const cloudLastContact = $('#greeCloudLastContact');
+7
View File
@@ -40,6 +40,9 @@ function greeSettingsBodyFromForm(form) {
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))),
@@ -53,6 +56,9 @@ function greeCloudSettingsBodyFromForm(form) {
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;
@@ -331,6 +337,7 @@ document.addEventListener('change', event => {
}
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();
});