This commit is contained in:
Mateusz Gruszczyński
2026-09-03 10:26:18 +02:00
parent 8f6169fbe3
commit 4c9f8ff403
31 changed files with 673 additions and 71 deletions
+7 -6
View File
@@ -929,11 +929,12 @@
<option value="1">V1 AES-ECB</option>
<option value="2">V2 AES-GCM</option>
</select></label>
<p class="field-note" data-i18n="devices.protocolChangeHint">Changing protocol clears the saved device key and
performs a new bind on the next request.</p>
<div class="form-actions"><button type="button" class="secondary" data-close
data-i18n="actions.cancel">Cancel</button><button class="primary" type="submit"
data-i18n="actions.save">Save</button></div>
<p class="field-note" data-i18n="devices.protocolChangeHint">Changing protocol clears the saved device key. Save and check connection immediately performs the required bind and test.</p>
<div id="deviceConfigCheckResult" class="config-check-result" hidden></div>
<div class="form-actions technical-config-actions"><button type="button" class="secondary" data-close
data-i18n="actions.cancel">Cancel</button><button class="secondary" type="submit" data-save-mode="save"
data-i18n="actions.save">Save</button><button class="primary" type="submit" data-save-mode="check"
data-i18n="devices.saveAndCheck">Save and check connection</button></div>
</form>
</dialog>
@@ -943,7 +944,7 @@
<div><span class="eyebrow" data-i18n="devices.diagnostics">Diagnostics</span><h2 data-i18n="devices.pingLive">Live ping</h2></div>
<button type="button" data-close>×</button>
</div>
<p class="field-note" data-i18n="devices.pingHint">Measures the GREE controller round-trip by reading device status. The chart refreshes while monitoring is enabled.</p>
<p class="field-note" data-i18n="devices.pingHint">Measures a minimal GREE round-trip without changing device state, error counters or automation ownership. The chart refreshes while monitoring is enabled.</p>
<div class="ping-toolbar">
<label><span data-i18n="common.device">Device</span><select id="pingDeviceSelect"></select></label>
<label class="check ping-all-toggle"><input type="checkbox" id="pingAllDevices"> <span data-i18n="devices.pingAll">Ping all units</span></label>
+1 -1
View File
@@ -31,7 +31,7 @@ const app = {
languages: [], translations: {}, locales: {},
historyTab: 'overview', historyData: { zones: [], devices: [], sensors: [] }, historyCounts: {},
historyZone: 'all', historyDevice: 'all', historySensor: 'all', historyLoading: false,
customChartSeries: [], savedCharts: [], chartZooms: {}, chartHiddenSeries: {}, zoneControlSeq: {}, groupControlSeq: {}, zoneControlQueue: {}, groupControlQueue: {}, deviceControlQueue: {}, houseControlQueue: null, climateControlQueue: null, groupCustomDrafts: {}, zoneTemperatureTimers: {},
customChartSeries: [], savedCharts: [], chartZooms: {}, chartHiddenSeries: {}, zoneControlSeq: {}, groupControlSeq: {}, zoneControlQueue: {}, groupControlQueue: {}, deviceControlQueue: {}, houseControlQueue: null, climateControlQueue: null, groupCustomDrafts: {}, zoneTemperatureTimers: {}, deviceTemperatureDrafts: {},
controlPlan: null, 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(),
pingMonitor: { targetId: '', all: false, running: false, timer: null, inFlight: false, samples: {} },
+15 -1
View File
@@ -31,6 +31,19 @@ function deviceResponseTimeLabel(device) {
return Number.isFinite(value) ? `${Math.max(0, Math.round(value))} ms` : '— ms';
}
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 manualDeviceCard(device) {
const modes = ['auto', 'cool', 'dry', 'fan', 'heat'];
const fans = [0, 1, 3, 5];
@@ -39,10 +52,11 @@ function manualDeviceCard(device) {
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><p><span class="status ${device.online ? 'online' : ''}">${esc(tr(device.online ? 'status.online' : 'status.offline'))}</span> · ${esc(device.model || device.mac)}</p></div>
<div class="device-title"><h3>${esc(device.name)}</h3></div>
<button class="power-button ${device.power ? 'on' : ''}" data-action="power" data-device="${esc(device.id)}" aria-label="${esc(tr('devices.power'))}">⏻</button>
</div>
${warning}
${manualDeviceProblem(device)}
<div class="temperature-control">
<button data-action="temperature" data-delta="-1" data-device="${esc(device.id)}"></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>
+43 -4
View File
@@ -77,7 +77,7 @@ document.addEventListener('click', async event => {
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 sendDeviceCommand(device.id, current => ({ target_temperature: clamp(Number(current?.target_temperature ?? device.target_temperature) + Number(button.dataset.delta), 8, 30) }));
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] }));
@@ -321,10 +321,49 @@ $('#discoveryNamesForm').addEventListener('submit', async event => {
});
$('#renameDeviceForm').addEventListener('submit', async event => {
event.preventDefault(); const form = event.currentTarget, raw = Object.fromEntries(new FormData(form));
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 device = await api(`/api/devices/${encodeURIComponent(raw.id)}`, { method: 'PATCH', body: { name: raw.name.trim(), ip: raw.ip.trim(), port: Number(raw.port), protocol_version: Number(raw.protocol_version) } });
updateDevice(device); form.closest('dialog').close(); renderAll(); toast(tr('common.saved'));
let device = await api(`/api/devices/${encodeURIComponent(raw.id)}`, { method: 'PATCH', body: { name: raw.name.trim(), ip: raw.ip.trim(), port: Number(raw.port), protocol_version: Number(raw.protocol_version) } });
updateDevice(device);
if (saveMode !== 'check') {
form.closest('dialog').close(); renderAll(); toast(tr('common.saved')); return;
}
try {
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');
result.textContent = tr('devices.connectionCheckOk', { ms: Math.round(Number(probe.response_time_ms || 0)) });
}
markFormClean(form);
renderAll();
toast(tr('devices.savedAndChecked'));
} catch (error) {
if (result) {
result.hidden = false; result.classList.add('error');
result.textContent = tr('devices.connectionCheckFailed', { error: error.message || String(error) });
}
markFormClean(form);
renderAll();
toast(tr('devices.savedCheckFailed'), true);
}
});
});
+59 -13
View File
@@ -107,7 +107,11 @@ function renderPingDialog() {
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>` : `<span class="ping-state ${device.online ? 'online' : ''}">${esc(tr(device.online ? 'status.online' : 'status.offline'))}</span>`;
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)}
@@ -122,11 +126,12 @@ function renderPingDialog() {
}).join('') : `<div class="empty"><strong>${esc(tr('devices.emptyTitle'))}</strong>${esc(tr('devices.emptyText'))}</div>`;
}
function schedulePingCycle(delay = 1800) {
function schedulePingCycle(delay = null) {
clearTimeout(app.pingMonitor.timer);
app.pingMonitor.timer = null;
if (!app.pingMonitor.running || !$('#pingDialog')?.open) return;
app.pingMonitor.timer = setTimeout(runPingCycle, delay);
const interval = delay == null ? (app.pingMonitor.all ? 5000 : 3000) : delay;
app.pingMonitor.timer = setTimeout(runPingCycle, interval);
}
async function runPingCycle() {
@@ -134,17 +139,20 @@ async function runPingCycle() {
const targets = app.pingMonitor.all ? [...app.devices] : app.devices.filter(device => device.id === app.pingMonitor.targetId);
if (!targets.length) { renderPingDialog(); schedulePingCycle(); return; }
app.pingMonitor.inFlight = true;
await Promise.allSettled(targets.map(async device => {
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 updated = await api(`/api/devices/${encodeURIComponent(device.id)}/poll`, { method: 'POST' });
updateDevice(updated);
addPingSample(device.id, updated.response_time_ms);
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;
renderDevices();
renderPingDialog();
schedulePingCycle();
}
@@ -173,24 +181,56 @@ function openDevicePing(id) {
startPingMonitor();
}
async function sendDeviceCommand(id, commandOrFactory) {
if (!confirmManualCommandForDisabledZone(id)) return;
async function sendDeviceCommand(id, commandOrFactory, { disabledZoneConfirmed = false } = {}) {
const disabledZone = disabledZoneForDevice(id);
if (disabledZone && !disabledZoneConfirmed && !confirmManualCommandForDisabledZone(id)) return false;
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;
return api(`/api/devices/${encodeURIComponent(id)}/command`, { method: 'POST', body: command });
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();
} catch (error) { toast(error.message, true); }
finally {
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 next = clamp(Number(draft?.target ?? device.target_temperature) + Number(delta), 8, 30);
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);
@@ -287,6 +327,10 @@ 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 decimals = kind === 'zone' ? 1 : 0;
@@ -344,6 +388,8 @@ function populateDeviceRename(id) {
const device = app.devices.find(item => item.id === id); if (!device) return;
const form = $('#renameDeviceForm'); form.reset();
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);
const result = $('#deviceConfigCheckResult');
if (result) { result.hidden = true; result.textContent = ''; result.classList.remove('success', 'error'); }
openDialog('renameDeviceDialog');
}
+31 -1
View File
@@ -6731,7 +6731,7 @@ textarea:focus { border-color:var(--accent); outline:2px solid color-mix(in srgb
.flow-generated-card .card-footer > small { color:var(--accent); }
/* 0.11.5 production layout guards and in-app chart preview. */
/* 0.11.6 production layout guards and in-app chart preview. */
html,body { max-width:100%; overflow-x:clip; }
main,.view { min-width:0; max-width:100%; }
.flow-info-panel,.flow-info-panel > div { min-width:0; }
@@ -7111,3 +7111,33 @@ body.chart-fullscreen-open::before { content:""; position:fixed; inset:0; z-inde
#pingToggleButton { justify-self: end; }
.ping-stat-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
/* 0.11.6 diagnostics, manual-control safety and technical connection checks. */
.manual-device-problem {
display: grid;
gap: 4px;
margin: 0 0 12px;
padding: 9px 11px;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--surface-2);
}
.manual-device-problem.error { border-color: color-mix(in srgb, var(--danger) 48%, var(--border)); }
.manual-device-problem.warning { border-color: color-mix(in srgb, var(--warning) 48%, var(--border)); }
.manual-device-problem strong { font-size: .82rem; }
.manual-device-problem small { color: var(--muted); overflow-wrap: anywhere; }
.config-check-result {
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: 10px;
font-size: .88rem;
font-weight: 650;
overflow-wrap: anywhere;
}
.config-check-result.success { border-color: color-mix(in srgb, var(--accent) 48%, var(--border)); }
.config-check-result.error { border-color: color-mix(in srgb, var(--danger) 48%, var(--border)); }
.technical-config-actions { flex-wrap: wrap; }
@media (max-width: 640px) {
.technical-config-actions button { flex: 1 1 100%; }
}