v0.11.4-devices

This commit is contained in:
Mateusz Gruszczyński
2026-09-03 09:58:26 +02:00
parent 500516b0fd
commit f0909a443d
10 changed files with 635 additions and 43 deletions
+9 -9
View File
@@ -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
+29 -1
View File
@@ -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.",
+29 -1
View File
@@ -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.",
+1 -1
View File
@@ -214,7 +214,7 @@ async fn command_device(State(state): State<AppState>, Path(id): Path<String>, J
async fn command_home_assistant_device(State(state): State<AppState>, Path(id): Path<String>, Json(command): Json<DeviceCommand>) -> Result<Json<Device>, 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?))
}
+20 -1
View File
@@ -130,6 +130,7 @@
<div class="dashboard-section-head compact-head">
<div><span class="eyebrow" data-i18n="nav.devices">Devices</span>
<h2 data-i18n="dashboard.quickControl">Manual control</h2>
<p data-i18n="dashboard.manualControlHint">Direct GREE control: power, temperature, mode, fan and supported unit functions.</p>
</div>
<span class="badge" id="dashboardDeviceCount">0</span>
</div>
@@ -147,6 +148,7 @@
data-i18n="actions.discover">Discover</button><button class="secondary" data-open="deviceDialog"
data-i18n="devices.addManual">Add manually</button></div>
</div>
<p class="lead" data-i18n="devices.technicalDescription">Technical configuration and diagnostics only. Current operating settings are available in Manual control.</p>
<div class="device-grid" id="deviceList"></div>
</section>
@@ -918,9 +920,10 @@
<form method="dialog" id="renameDeviceForm" class="dialog-form">
<input type="hidden" name="id">
<div class="dialog-head">
<h2 data-i18n="devices.nameProtocol">Name / protocol</h2><button type="button" data-close>×</button>
<h2 data-i18n="devices.technicalConfig">Technical configuration</h2><button type="button" data-close>×</button>
</div>
<label><span data-i18n="common.name">Name</span><input name="name" required maxlength="80"></label>
<div class="two"><label><span data-i18n="devices.ipAddress">IP address</span><input name="ip" required></label><label><span>Port</span><input type="number" name="port" min="1" max="65535" required></label></div>
<label><span data-i18n="devices.protocol">Protocol</span><select name="protocol_version">
<option value="0" data-i18n="devices.protocolAuto">Auto (V1 + V2)</option>
<option value="1">V1 AES-ECB</option>
@@ -934,6 +937,22 @@
</form>
</dialog>
<dialog id="pingDialog" class="dialog-wide ping-dialog">
<div class="dialog-form">
<div class="dialog-head">
<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>
<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>
<button type="button" class="primary" id="pingToggleButton" data-action="ping-toggle" data-i18n="devices.pingStop">Stop</button>
</div>
<div class="ping-live-grid" id="pingLiveGrid"></div>
</div>
</dialog>
<dialog id="zoneDialog">
<form method="dialog" id="zoneForm" class="dialog-form">
<input type="hidden" name="id">
+1
View File
@@ -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 = []; }
+64 -25
View File
@@ -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 = `<div class="device-network-info"><small>${esc(device.ip)} · ${esc(responseTime)} · ${esc(protocol)}</small>${device.last_error ? `<small class="device-network-error" title="${esc(device.last_error)}">${esc(device.last_error)}</small>` : ''}</div>`;
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 `<article class="device-card${quickClass} ${device.power ? '' : 'off'}" data-device-card="${esc(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><p><span class="status ${device.online ? 'online' : ''}">${esc(tr(device.online ? 'status.online' : 'status.offline'))}</span> · ${esc(device.model || device.mac)}</p></div>
<button class="power-button ${device.power ? 'on' : ''}" data-action="power" data-device="${esc(device.id)}" aria-label="${esc(tr('devices.power'))}"${locked}>⏻</button>
<button class="power-button ${device.power ? 'on' : ''}" data-action="power" data-device="${esc(device.id)}" aria-label="${esc(tr('devices.power'))}">⏻</button>
</div>
${warning}
<div class="temperature-control">
<button data-action="temperature" data-delta="-1" data-device="${esc(device.id)}"${locked}></button>
<div class="target-temp editable-target" data-temperature-kind="device" data-id="${esc(device.id)}" data-value="${Number(device.target_temperature)}" data-editable="${zoneLocked ? 'false' : 'true'}" tabindex="${zoneLocked ? '-1' : '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)}"${locked}>+</button>
<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>
<button data-action="temperature" data-delta="1" data-device="${esc(device.id)}">+</button>
</div>
<div class="current-line">${esc(tr('devices.currentTemperature'))}: <strong>${fmtTemp(device.current_temperature)}</strong>${device.outdoor_temperature == null ? '' : ` · ${esc(tr('devices.outdoor'))} ${fmtTemp(device.outdoor_temperature)}`}</div>
${compressorQueuePanel(managedZone)}
${zoneLocked ? `<div class="field-note warning-note">${esc(tr('devices.disabledZoneTechnicalOnly'))}</div>` : ''}
<div class="mode-row${quickRow}${detailed ? '' : ' quick-control-row-5'}">${modes.map(mode => `<button class="${device.mode === mode ? 'active' : ''}" data-action="mode" data-value="${mode}" data-device="${esc(device.id)}"${locked}>${esc(modeLabel(mode))}</button>`).join('')}</div>
<div class="fan-row${quickRow}${detailed ? '' : ' 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)}"${locked}>${esc(fanLabel(fan))}</button>`).join('')}</div>
<div class="device-toggles${quickRow}${detailed ? '' : ' quick-control-row-3'}">
<button class="${device.swing_vertical ? 'active' : ''}" data-action="toggle" data-field="swing_vertical" data-device="${esc(device.id)}"${locked}>${esc(tr('devices.swing'))}</button>
${device.supports_quiet === false ? '' : `<button class="${device.quiet ? 'active' : ''}" data-action="toggle" data-field="quiet" data-device="${esc(device.id)}"${locked}>${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)}"${locked}>${esc(tr('devices.turbo'))}</button>`}
<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-3">
<button class="${device.swing_vertical ? 'active' : ''}" data-action="toggle" data-field="swing_vertical" data-device="${esc(device.id)}">${esc(tr('devices.swing'))}</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 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 ? `<div class="technical-device-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'))}</span><h3>${esc(device.name)}</h3><p><span class="status ${device.online ? 'online' : ''}">${esc(tr(device.online ? 'status.online' : 'status.offline'))}</span> · ${esc(model)}</p></div>
<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>${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>
</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>
${device.simulated ? '' : `<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'))}">•••</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>
${detailed ? deviceFeaturePanel(device) : ''}
${detailed ? `<div class="card-footer">${networkInfo}<div class="card-menu"><button data-action="poll" data-device="${esc(device.id)}">${esc(tr('actions.read'))}</button><button data-action="rename-device" data-device="${esc(device.id)}">${esc(tr('devices.nameProtocol'))}</button>${device.simulated ? '' : `<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'))}">•••</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></div>` : ''}
</article>`;
}
function renderDevices() {
const empty = `<div class="empty"><strong>${esc(tr('devices.emptyTitle'))}</strong>${esc(tr('devices.emptyText'))}</div>`;
$('#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) {
+15 -4
View File
@@ -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;
+140 -1
View File
@@ -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 `<div class="ping-empty">${esc(tr('devices.pingNoSamples'))}</div>`;
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 => `<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><polyline points="${points}"></polyline></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;
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('');
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 ? `<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>`;
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 = 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');
}
+327
View File
@@ -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)); }
}