diff --git a/FILE_MANIFEST.sha256 b/FILE_MANIFEST.sha256 index 27643b2..9413a87 100644 --- a/FILE_MANIFEST.sha256 +++ b/FILE_MANIFEST.sha256 @@ -26,8 +26,8 @@ cca65482e36d48035aca178121a378fe7d578d600acff267ae81a6399c0da653 ./home-assista 13f30e2dcdcedbd1b6c3f99c2335e0487108fd72c8e86922368b84f2fa2038ae ./home-assistant/custom_components/gree_controller/translations/pl.json 4513070521d3dda0efb0d974a86ba674494cfb2b66fe9e5cac5b1b0430dede97 ./home-assistant/generated/gree_controller_entities.example.json 253a0bc912786e67ea7fc92a64e4a510ad973bec343a88ccfb1f28fca3e8cf01 ./lang/README.md -7414e94d61d2dfd9734ee92a2a1423cae67e91e8733c213098b9bcb6a2885a22 ./lang/en.json -1ff6dab25d8a29d971cd10de8d0df1b6173670ba1e6f65c0c669bf6b134bcf23 ./lang/pl.json +d366f7a5bd58457f5033ddffca783be04c1b50dff38b8d4feb9675776feeabe8 ./lang/en.json +bde25cbd02bb46068feb344d4cfc16298760dbfd6a855cad4d4345c7d5bf73bb ./lang/pl.json d8459024f04ca514bd8e9d6bd3af872fb942fd85a7cdd5583f4a9d28aab6faba ./make_zip.py 1e7b1bcf4194abfb8bad0d601268af0547d58541731e98b74a3500d7629d0be0 ./mock/index.html 007c78e0cb404778985980b23bb5f8bbc7a8f04c80015204e24c321970e3b15e ./mock/mock.js @@ -90,7 +90,7 @@ da16e16dfb330d528d54d2138b07950da701edc46af1ff174a9f6a54090122b3 ./src/api.rs 6c34b1294d76b0eba4c56bfd0bf69bdd502de0e40838104f6ddf2db4e922683d ./src/api/auth.rs 63cb241c4536abed402f2a0ad37e7f299343df264eab6a33bb5354c15421e045 ./src/api/automations.rs 2317b7cd0c2437c128ac4982d64412c8184ecb5e774825761d61aab6e45fae4a ./src/api/debug_tokens.rs -4f82206fb48a291bdc643c96d4d6c917d669c7bdf2a35312c0a056dddecc7062 ./src/api/devices.rs +7ffb7ef7d3cc3a908e2b0a7b3799cd1dcc94808f3083ae1b1a0a6e26ec6b88fb ./src/api/devices.rs 417940b467c6f00bbf01cc47eb4479880dee9b2f284a80e3662a6d433dc11b92 ./src/api/events.rs 1bbe45c11b176d1595007942ef7723c7954c60fe20cc5aa18c4dbab00b6f09ff ./src/api/flows.rs d096000e94997cf9aaf9d3cc567214a78b8b2da7da57a76e2b3519247af6e592 ./src/api/groups.rs @@ -176,24 +176,24 @@ f13f1be3d5789539eba3fc1b59c14835f8c681eca7634e118fa3763a53aeee2b ./src/queries/ b92a6cb158b494fe145b43c7641e65f6fafff47201d7d76edbec2cfd8b94835c ./systemd/gree-controller.service 544a31cb2e5374227026afd6abd0d910381f3fca1638154ebd74e3c3d96a35d1 ./web/404.html e98bdd7204349cce1ec6f57283509697af0bbc72280622a6c3efa6fed242db4f ./web/favicon.svg -4f273e189008df110cd3cad1d323e5f07e2651f805419b427811d89fdd04245f ./web/index.html +9b94c56a402396a63f35630d91106a2f2655f03cbda555c330cd059067d488bd ./web/index.html 9ba3f6fa05b72aa989139b2a909982571b2a02055052e4c40104f1e81a9aa7eb ./web/js/README.md 21b662914590e720dafefe4dc3dccc91dbeb963cce9ae91f007c835d2d0c1fe5 ./web/js/bootstrap.js dee7c4292889ddd721c220e86fae909f0d662b585e6d9c5d9e2f8fa85d9ed2ad ./web/js/charts.js -87064e7d504dc2afbc1aa3a1926fcec0cc80b31e2a6a6d931d7af7fc4f519b20 ./web/js/core.js +11fcd1571b9d823af614a75380855c0bd2c287475b25ec6d85c73067994a8448 ./web/js/core.js dfc123885a9b6b5c1d07c12d2d2625ff15aa0844e5cd3736092ccd0a360da39c ./web/js/dashboard.js -3f5b9a4d62c994edded0ce24fb9ea45141d935a1928d4f68fb67f8ca287bafe1 ./web/js/entities.js -cdd11ab0fac33b7b1749b8db4e30486e9d0258b65fe54fd56c3a520b2b72b2d7 ./web/js/events.js +1c9b42c1537963f703a1d8a53a15d260957df3494e19209442a9fedb6e6b81ed ./web/js/entities.js +57218050f3e7c8481f9d6f11f395d225dae78e32ce09c9e42fb79c1e81417ded ./web/js/events.js d4aea1d6ec595000eaddca3b6f0e3d4003901365ece7788147a6ed207cffcc78 ./web/js/flows.js f06557c6d338259059b990f8f79c91d68ac8ec1f245c302468f08929b5506901 ./web/js/forms.js dbf36223863ba882c03eccea4516ba8db7acf0c2e72c6c64490cb994f1e20f96 ./web/js/history.js 7eaa04992ea828dc89f5eaebf674cbbfd160f53eb8d0d6ef0d4e47bd8c28c435 ./web/js/main.js -3dcfc24f238ad1310155707268b60c5a415f2a8977ceba48c7e695b7135d5705 ./web/js/navigation.js +cfa4e876d20b32cc26b8af615138deb99dd1360ef2be3ca0118c750ff44094c7 ./web/js/navigation.js c56059693d09e35261dc5dd1940289ffc67c82ea7c547750d57e6c79fe75b05b ./web/js/realtime.js c8c82e88c3715b1bfabf155e36266a4c94f5e2fc03eb39dcdd9d08a1985a997c ./web/js/router.js bdeb55546a1dff5e5bdfde4b114d6330f025df1b62e528bf18a93ad6dd4c3e83 ./web/js/settings-ui.js aa6a2ba22648de44547efd09f41c4335ebe38ed564f4684e942d7827df40f1a2 ./web/js/settings.js 6b653e4fe2d4db4ffae6ff8f39b8ce57a10a990dc0000ee4aae51d949afadd52 ./web/manifest.webmanifest -11034c405898fe345b308cfb7da094c1dd995576c138241e19b29b063b51e6ba ./web/styles.css +14dd66007e17ebb96ab1d3cc5936801ddbc07e0ea4c3662072f3ddd344c2b16f ./web/styles.css ae1b03f30b494f474a781a5d32072f1e73eba2b7c768109fc1cc8028cb6662a4 ./web/sw.js d505d793ce7cc9485b45b78bba1c0d51887adc7451ab59a42702946e5b991382 ./web/theme-init.js diff --git a/lang/en.json b/lang/en.json index cd7de8b..c8c2f2a 100644 --- a/lang/en.json +++ b/lang/en.json @@ -75,7 +75,35 @@ "devices.readDone": "Device state updated", "devices.bound": "Device bound", "devices.added": "Device added", - "devices.disabledZoneTechnicalOnly": "This zone is disabled. Manual control of its unit is available only from the Devices view.", + "dashboard.manualControlHint": "Direct GREE control: power, temperature, mode, fan and supported unit functions.", + "devices.technicalDescription": "Technical configuration and diagnostics only. Current operating settings are available in Manual control.", + "devices.technicalUnit": "Technical unit", + "devices.address": "Address", + "devices.modelFirmware": "Model / firmware", + "devices.lastSeen": "Last response", + "devices.communicationFailures": "Communication failures", + "devices.lastError": "Last error", + "devices.readStatus": "Read status", + "devices.technicalConfig": "Technical configuration", + "devices.diagnostics": "Diagnostics", + "devices.ping": "Ping", + "devices.pingOpen": "Open live ping view", + "devices.pingLive": "Live ping", + "devices.pingHint": "The measurement shows GREE controller round-trip time while reading device status. The chart refreshes while monitoring is enabled.", + "devices.pingAll": "Ping all units", + "devices.pingStart": "Start", + "devices.pingStop": "Stop", + "devices.pingCurrent": "Current", + "devices.pingAverage": "Average", + "devices.pingMin": "Min", + "devices.pingMax": "Max", + "devices.pingSamples": "Samples", + "devices.pingNoSamples": "No samples yet. Start monitoring.", + "devices.pingFailed": "No response", + "devices.manualDisabledZoneTitle": "Warning: zone is disabled", + "devices.manualDisabledZoneWarning": "This unit belongs to disabled zone “{zone}”. Manual control is still available, but the command is sent directly outside that zone automation.", + "devices.manualDisabledZoneConfirm": "Zone “{zone}” is disabled. This command will be sent directly to the unit, outside zone automation. Automation will stay disabled until it is enabled / resumed again. Send the command?", + "devices.disabledZoneTechnicalOnly": "This zone is disabled. Explicit direct operation requires confirmation in Manual control.", "zones.automation": "Automation", "zones.new": "New zone", "zones.description": "Zones define thermostat logic and sensor configuration. Use Quick thermostats on the Dashboard for current temperature, preset and mode changes.", diff --git a/lang/pl.json b/lang/pl.json index a5384c8..b5dd9b1 100644 --- a/lang/pl.json +++ b/lang/pl.json @@ -75,7 +75,35 @@ "devices.readDone": "Odczyt zakończony", "devices.bound": "Urządzenie powiązane", "devices.added": "Urządzenie dodane", - "devices.disabledZoneTechnicalOnly": "Strefa jest wyłączona. Ręczne sterowanie tą jednostką jest dostępne tylko w zakładce Urządzenia.", + "dashboard.manualControlHint": "Bezpośrednie sterowanie GREE: zasilanie, temperatura, tryb, nawiew i obsługiwane funkcje jednostki.", + "devices.technicalDescription": "Wyłącznie konfiguracja techniczna i diagnostyka jednostek. Bieżące nastawy znajdują się w Sterowaniu ręcznym.", + "devices.technicalUnit": "Jednostka techniczna", + "devices.address": "Adres", + "devices.modelFirmware": "Model / firmware", + "devices.lastSeen": "Ostatnia odpowiedź", + "devices.communicationFailures": "Błędy komunikacji", + "devices.lastError": "Ostatni błąd", + "devices.readStatus": "Odczytaj status", + "devices.technicalConfig": "Konfiguracja techniczna", + "devices.diagnostics": "Diagnostyka", + "devices.ping": "Ping", + "devices.pingOpen": "Otwórz podgląd pingu", + "devices.pingLive": "Ping na żywo", + "devices.pingHint": "Pomiar pokazuje czas odpowiedzi kontrolera GREE podczas odczytu statusu urządzenia. Wykres odświeża się, gdy monitoring jest włączony.", + "devices.pingAll": "Pinguj wszystkie jednostki", + "devices.pingStart": "Uruchom", + "devices.pingStop": "Zatrzymaj", + "devices.pingCurrent": "Teraz", + "devices.pingAverage": "Średnio", + "devices.pingMin": "Min.", + "devices.pingMax": "Maks.", + "devices.pingSamples": "Próbki", + "devices.pingNoSamples": "Brak próbek. Uruchom monitoring.", + "devices.pingFailed": "Brak odpowiedzi", + "devices.manualDisabledZoneTitle": "Uwaga: strefa jest wyłączona", + "devices.manualDisabledZoneWarning": "Jednostka należy do wyłączonej strefy „{zone}”. Sterowanie ręczne nadal jest dostępne, ale polecenie zostanie wysłane bezpośrednio poza automatyką tej strefy.", + "devices.manualDisabledZoneConfirm": "Strefa „{zone}” jest wyłączona. Ta komenda zostanie wysłana bezpośrednio do jednostki, poza automatyką strefy. Automatyka pozostanie wyłączona do jej ponownego włączenia / wznowienia. Wysłać polecenie?", + "devices.disabledZoneTechnicalOnly": "Strefa jest wyłączona. Bezpośrednie sterowanie wymaga świadomego potwierdzenia w Sterowaniu ręcznym.", "zones.automation": "Automatyka", "zones.new": "Nowa strefa", "zones.description": "Strefy definiują logikę termostatu i konfigurację czujników. Bieżącą temperaturę, profil i tryb zmieniaj w „Szybkich termostatach” na Pulpicie.", diff --git a/src/api/devices.rs b/src/api/devices.rs index eb0e3f7..6b6db52 100644 --- a/src/api/devices.rs +++ b/src/api/devices.rs @@ -214,7 +214,7 @@ async fn command_device(State(state): State, Path(id): Path, J async fn command_home_assistant_device(State(state): State, Path(id): Path, Json(command): Json) -> Result, AppError> { if state.db.list_zones()?.iter().any(|zone| zone.device_id == id && !zone.enabled) { - return Err(AppError::BadRequest("device belongs to a disabled thermostat zone; use technical device control for manual operation".into())); + return Err(AppError::BadRequest("device belongs to a disabled thermostat zone; use Manual control in the web UI for explicit direct operation".into())); } Ok(Json(engine::send_manual_command(&state, &id, command, "home_assistant.device_manual_control").await?)) } diff --git a/web/index.html b/web/index.html index 447a0c7..e724140 100644 --- a/web/index.html +++ b/web/index.html @@ -130,6 +130,7 @@
Devices

Manual control

+

Direct GREE control: power, temperature, mode, fan and supported unit functions.

0
@@ -147,6 +148,7 @@ data-i18n="actions.discover">Discover +

Technical configuration and diagnostics only. Current operating settings are available in Manual control.

@@ -918,9 +920,10 @@
-

Name / protocol

+

Technical configuration

+
+ + + +
+ + + diff --git a/web/js/core.js b/web/js/core.js index 81591eb..4cb0b05 100644 --- a/web/js/core.js +++ b/web/js/core.js @@ -34,6 +34,7 @@ const app = { customChartSeries: [], savedCharts: [], chartZooms: {}, chartHiddenSeries: {}, zoneControlSeq: {}, groupControlSeq: {}, zoneControlQueue: {}, groupControlQueue: {}, deviceControlQueue: {}, houseControlQueue: null, climateControlQueue: null, groupCustomDrafts: {}, zoneTemperatureTimers: {}, controlPlan: null, controlPlanTimer: null, debugLines: [], debugBacklogLoaded: false, debugFilter: 'all', sensorAliases: {}, 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: {} }, 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 = []; } diff --git a/web/js/entities.js b/web/js/entities.js index 8ad6c82..d6e2728 100644 --- a/web/js/entities.js +++ b/web/js/entities.js @@ -18,49 +18,88 @@ function zoneForDevice(deviceId) { return app.zones.find(zone => zone.device_id === deviceId) || null; } -function deviceCard(device, detailed = false) { +function disabledZoneForDevice(deviceId) { + return app.zones.find(zone => zone.device_id === deviceId && zone.enabled === false) || null; +} + +function deviceProtocolLabel(device) { + 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 manualDeviceCard(device) { const modes = ['auto', 'cool', 'dry', 'fan', 'heat']; const fans = [0, 1, 3, 5]; - const hasResponseTime = device.response_time_ms !== null && device.response_time_ms !== undefined && Number.isFinite(Number(device.response_time_ms)); - const responseTime = hasResponseTime ? `${Math.max(0, Math.round(Number(device.response_time_ms)))} ms` : '— ms'; - const protocol = device.simulated ? tr('devices.simulator') : device.protocol_version === 2 ? 'V2 GCM' : device.protocol_version === 1 ? 'V1 ECB' : tr('devices.protocolAuto'); - const networkInfo = `
${esc(device.ip)} · ${esc(responseTime)} · ${esc(protocol)}${device.last_error ? `${esc(device.last_error)}` : ''}
`; + const disabledZone = disabledZoneForDevice(device.id); const managedZone = app.zones.find(zone => zone.device_id === device.id && zone.compressor_pending_action) || zoneForDevice(device.id); - const zoneLocked = !detailed && deviceZoneDisabled(device.id); - const locked = zoneLocked ? ' disabled' : ''; - const quickClass = detailed ? '' : ' quick-control-card quick-device-control'; - const quickRow = detailed ? '' : ' quick-control-row'; - return `
+ const warning = disabledZone ? `
${esc(tr('devices.manualDisabledZoneTitle'))}

${esc(tr('devices.manualDisabledZoneWarning', { zone: disabledZone.name }))}

` : ''; + return `

${esc(device.name)}

${esc(tr(device.online ? 'status.online' : 'status.offline'))} · ${esc(device.model || device.mac)}

- +
+ ${warning}
- -
${Number(device.target_temperature).toFixed(1)}°C
- + +
${Number(device.target_temperature).toFixed(1)}°C
+
${esc(tr('devices.currentTemperature'))}: ${fmtTemp(device.current_temperature)}${device.outdoor_temperature == null ? '' : ` · ${esc(tr('devices.outdoor'))} ${fmtTemp(device.outdoor_temperature)}`}
${compressorQueuePanel(managedZone)} - ${zoneLocked ? `
${esc(tr('devices.disabledZoneTechnicalOnly'))}
` : ''} -
${modes.map(mode => ``).join('')}
-
${fans.map(fan => ``).join('')}
-
- - ${device.supports_quiet === false ? '' : ``} - ${device.supports_turbo === false ? '' : ``} +
${modes.map(mode => ``).join('')}
+
${fans.map(fan => ``).join('')}
+
+ + ${device.supports_quiet === false ? '' : ``} + ${device.supports_turbo === false ? '' : ``} +
+ ${deviceFeaturePanel(device)} +
`; +} + +function technicalDeviceCard(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 ? `
${esc(tr('devices.lastError'))}${esc(device.last_error)}
` : ''; + return `
+
+
${esc(tr('devices.technicalUnit'))}

${esc(device.name)}

${esc(tr(device.online ? 'status.online' : 'status.offline'))} · ${esc(model)}

+ +
+
+
${esc(tr('devices.address'))}${esc(device.ip)}:${esc(device.port)}
+
MAC${esc(device.mac)}
+
CID${esc(cid)}
+
${esc(tr('devices.protocol'))}${esc(protocol)}
+
${esc(tr('devices.modelFirmware'))}${esc(model)}${esc(firmware)}
+
${esc(tr('devices.lastSeen'))}${esc(lastSeen)}
+
${esc(tr('devices.communicationFailures'))}${esc(device.communication_failures ?? 0)}
+
+ ${error} +
+ + + ${device.simulated ? '' : ``} +
•••
- ${detailed ? deviceFeaturePanel(device) : ''} - ${detailed ? `` : ''}
`; } function renderDevices() { const empty = `
${esc(tr('devices.emptyTitle'))}${esc(tr('devices.emptyText'))}
`; - $('#dashboardDevices').innerHTML = app.devices.length ? app.devices.map(d => deviceCard(d, false)).join('') : empty; - $('#deviceList').innerHTML = app.devices.length ? app.devices.map(d => deviceCard(d, true)).join('') : empty; + $('#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) { diff --git a/web/js/events.js b/web/js/events.js index a853582..5e25f69 100644 --- a/web/js/events.js +++ b/web/js/events.js @@ -74,9 +74,8 @@ document.addEventListener('click', async event => { } const action = button.dataset.action; if (!action) return; const device = app.devices.find(v => v.id === button.dataset.device); - if (device && app.currentView !== 'devices' && deviceZoneDisabled(device.id) && ['power', 'temperature', 'mode', 'fan', 'toggle'].includes(action)) { - return toast(tr('devices.disabledZoneTechnicalOnly'), true); - } + 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 === 'mode' && device) return sendDeviceCommand(device.id, { mode: button.dataset.value, power: true }); @@ -324,11 +323,23 @@ $('#discoveryNamesForm').addEventListener('submit', async event => { $('#renameDeviceForm').addEventListener('submit', async event => { event.preventDefault(); const form = event.currentTarget, raw = Object.fromEntries(new FormData(form)); await runFormTask(form, async () => { - const device = await api(`/api/devices/${encodeURIComponent(raw.id)}`, { method: 'PATCH', body: { name: raw.name.trim(), protocol_version: Number(raw.protocol_version) } }); + 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')); }); }); +$('#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; diff --git a/web/js/navigation.js b/web/js/navigation.js index fcde5f0..1130593 100644 --- a/web/js/navigation.js +++ b/web/js/navigation.js @@ -35,7 +35,146 @@ function showHistoryTab(tab, { push = true, load = true } = {}) { 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; + const valid = samples.map((sample, index) => ({ index, value: sample.value == null ? NaN : Number(sample.value) })).filter(item => Number.isFinite(item.value)); + if (!valid.length) return `
${esc(tr('devices.pingNoSamples'))}
`; + const values = valid.map(item => item.value); + const min = Math.min(...values); + const max = Math.max(...values); + 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 points = valid.map(item => { + const x = padX + (item.index / lastIndex) * (width - padX * 2); + const y = height - padY - ((item.value - floor) / range) * (height - padY * 2); + return `${x.toFixed(1)},${y.toFixed(1)}`; + }).join(' '); + const guide = [0.25, 0.5, 0.75].map(ratio => ``).join(''); + return `${guide}`; +} + +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; + 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 => ``).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); + 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 ? `${esc(tr('devices.pingFailed'))}` : `${esc(tr(device.online ? 'status.online' : 'status.offline'))}`; + return `
+
${esc(device.name)}${esc(device.ip)}
${state}
+ ${pingSparkline(samples)} +
+
${esc(tr('devices.pingCurrent'))}${esc(pingValue(stats.current))}
+
${esc(tr('devices.pingAverage'))}${esc(pingValue(stats.average))}
+
${esc(tr('devices.pingMin'))}${esc(pingValue(stats.min))}
+
${esc(tr('devices.pingMax'))}${esc(pingValue(stats.max))}
+
+ ${esc(tr('devices.pingSamples'))}: ${samples.length}${last?.error ? ` · ${esc(last.error)}` : ''} +
`; + }).join('') : `
${esc(tr('devices.emptyTitle'))}${esc(tr('devices.emptyText'))}
`; +} + +function schedulePingCycle(delay = 1800) { + clearTimeout(app.pingMonitor.timer); + app.pingMonitor.timer = null; + if (!app.pingMonitor.running || !$('#pingDialog')?.open) return; + app.pingMonitor.timer = setTimeout(runPingCycle, delay); +} + +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); + if (!targets.length) { renderPingDialog(); schedulePingCycle(); return; } + app.pingMonitor.inFlight = true; + await Promise.allSettled(targets.map(async device => { + try { + const updated = await api(`/api/devices/${encodeURIComponent(device.id)}/poll`, { method: 'POST' }); + updateDevice(updated); + addPingSample(device.id, updated.response_time_ms); + } catch (error) { + addPingSample(device.id, null, error.message || String(error)); + } + })); + app.pingMonitor.inFlight = false; + renderDevices(); + renderPingDialog(); + schedulePingCycle(); +} + +function startPingMonitor() { + if (!app.devices.length) 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) { + app.pingMonitor.targetId = id || app.devices[0]?.id || ''; + app.pingMonitor.all = false; + openDialog('pingDialog'); + renderPingDialog(); + startPingMonitor(); +} + async function sendDeviceCommand(id, commandOrFactory) { + if (!confirmManualCommandForDisabledZone(id)) return; const previous = app.deviceControlQueue[id] || Promise.resolve(); const request = previous.catch(() => { }).then(() => { const current = app.devices.find(device => device.id === id); @@ -204,7 +343,7 @@ function showDiscoveryNames(ids) { 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.protocol_version.value = String(device.protocol_version ?? 0); + 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); openDialog('renameDeviceDialog'); } diff --git a/web/styles.css b/web/styles.css index 4641eb1..942ce7a 100644 --- a/web/styles.css +++ b/web/styles.css @@ -6784,3 +6784,330 @@ body.chart-fullscreen-open::before { content:""; position:fixed; inset:0; z-inde .chart-title-actions { gap:5px; } .chart-zoom-controls button { height:36px; } } + +/* Manual unit control and technical device diagnostics. */ +.manual-zone-blocked { + border-color: color-mix(in srgb, var(--warning) 62%, var(--line)); +} + +.manual-zone-warning { + display: grid; + gap: 4px; + margin: 10px 0 2px; + padding: 9px 10px; + border: 1px solid color-mix(in srgb, var(--warning) 48%, var(--line)); + border-radius: 11px; + background: var(--warning-soft); +} + +.manual-zone-warning strong { + color: var(--warning); + font-size: 11px; +} + +.manual-zone-warning p { + margin: 0; + color: var(--text-soft); + font-size: 10px; + line-height: 1.4; +} + +.technical-device-card { + display: grid; + gap: 14px; + padding: 18px; + overflow: visible; +} + +.technical-device-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 14px; +} + +.technical-device-head .device-title h3 { + margin-top: 3px; +} + +.device-ping-button { + display: grid; + justify-items: end; + gap: 2px; + min-width: 88px; + min-height: 50px; + padding: 8px 10px; + border: 1px solid var(--line); + border-radius: 12px; + background: var(--surface-muted); +} + +.device-ping-button span { + color: var(--muted); + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: .06em; +} + +.device-ping-button strong { + color: var(--text); + font-size: 15px; + font-variant-numeric: tabular-nums; +} + +.device-ping-button:hover { + border-color: var(--accent-border); + background: var(--surface-2); +} + +.technical-device-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; +} + +.technical-device-grid > div { + display: grid; + min-width: 0; + gap: 3px; + padding: 10px 11px; + border: 1px solid var(--line); + border-radius: 11px; + background: var(--surface-muted); +} + +.technical-device-grid span, +.technical-device-error span { + color: var(--muted); + font-size: 9px; + font-weight: 750; + text-transform: uppercase; + letter-spacing: .06em; +} + +.technical-device-grid strong, +.technical-device-grid small { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.technical-device-grid strong { + color: var(--text-soft); + font-size: 12px; +} + +.technical-device-grid small { + color: var(--muted); + font-size: 10px; +} + +.technical-device-error { + display: grid; + gap: 4px; + padding: 9px 10px; + border: 1px solid color-mix(in srgb, var(--danger) 36%, var(--line)); + border-radius: 10px; + background: var(--danger-soft); +} + +.technical-device-error strong { + overflow: hidden; + color: var(--danger); + font-size: 11px; + font-weight: 650; + text-overflow: ellipsis; + white-space: nowrap; +} + +.technical-device-actions { + display: flex; + align-items: center; + justify-content: flex-end; + flex-wrap: wrap; + gap: 6px; + padding-top: 2px; +} + +.technical-device-actions > button { + min-height: 36px; + padding: 7px 10px; + background: transparent; + color: var(--text-soft); + font-size: 11px; +} + +.ping-dialog .dialog-form { + gap: 12px; +} + +.ping-toolbar { + display: grid; + grid-template-columns: minmax(180px, 1fr) auto auto; + align-items: end; + gap: 10px; +} + +.ping-toolbar > label:first-child { + display: grid; + gap: 5px; +} + +.ping-all-toggle { + min-height: 42px; + margin: 0; + padding: 0 4px; + align-items: center; +} + +#pingToggleButton { + min-width: 96px; + min-height: 42px; +} + +.ping-live-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 300px), 1fr)); + gap: 10px; +} + +.ping-live-card { + display: grid; + gap: 10px; + padding: 12px; + border: 1px solid var(--line); + border-radius: 13px; + background: var(--surface-muted); +} + +.ping-live-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 10px; +} + +.ping-live-head > div { + min-width: 0; +} + +.ping-live-head strong, +.ping-live-head small { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ping-live-head strong { + font-size: 13px; +} + +.ping-live-head small { + margin-top: 2px; + color: var(--muted); + font-size: 10px; +} + +.ping-state { + flex: 0 0 auto; + padding: 4px 7px; + border-radius: 99px; + color: var(--muted); + background: var(--surface-2); + font-size: 9px; + font-weight: 750; +} + +.ping-state.online { + color: var(--accent); + background: var(--accent-soft); +} + +.ping-state.error { + color: var(--danger); + background: var(--danger-soft); +} + +.ping-sparkline { + width: 100%; + height: 92px; + overflow: visible; + border: 1px solid var(--line); + border-radius: 10px; + background: var(--surface); +} + +.ping-sparkline .ping-grid-lines line { + stroke: var(--line); + stroke-width: 1; + vector-effect: non-scaling-stroke; +} + +.ping-sparkline polyline { + fill: none; + stroke: var(--accent); + stroke-width: 2; + stroke-linejoin: round; + stroke-linecap: round; + vector-effect: non-scaling-stroke; +} + +.ping-empty { + display: grid; + place-items: center; + min-height: 92px; + border: 1px dashed var(--line); + border-radius: 10px; + color: var(--muted); + font-size: 11px; + text-align: center; +} + +.ping-stat-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 6px; +} + +.ping-stat-grid > div { + display: grid; + gap: 2px; + min-width: 0; +} + +.ping-stat-grid span { + color: var(--muted); + font-size: 8px; + font-weight: 700; + text-transform: uppercase; +} + +.ping-stat-grid strong { + overflow: hidden; + color: var(--text-soft); + font-size: 11px; + font-variant-numeric: tabular-nums; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ping-sample-count { + overflow: hidden; + color: var(--muted); + font-size: 9px; + text-overflow: ellipsis; + white-space: nowrap; +} + +@media (max-width: 620px) { + .technical-device-grid { grid-template-columns: 1fr; } + .technical-device-head { align-items: center; } + .device-ping-button { min-width: 76px; } + .ping-toolbar { grid-template-columns: 1fr auto; } + .ping-toolbar > label:first-child { grid-column: 1 / -1; } + .ping-all-toggle { justify-self: start; } + #pingToggleButton { justify-self: end; } + .ping-stat-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } +}