v0.11.6
This commit is contained in:
Generated
+1
-1
@@ -633,7 +633,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gree-controller"
|
||||
version = "0.11.5"
|
||||
version = "0.11.6"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "gree-controller"
|
||||
version = "0.11.5"
|
||||
version = "0.11.6"
|
||||
edition = "2021"
|
||||
authors = ["GREE Controller contributors"]
|
||||
description = "Standalone local GREE HVAC controller with Web UI, SQLite and Home Assistant sensor support"
|
||||
|
||||
+20
-20
@@ -1,11 +1,11 @@
|
||||
d67af429e4da9ce08e9d2f2a8472849ffbd70d135b1c5da535a076026794d04c ./.env.example
|
||||
a4ec3874a2e3ab1bad28fb40bb620f7b01f64d01ad9b699306bf70ada31227db ./.gitignore
|
||||
902b5ab2e1378e66e608f69affc4e28e9d9951cab452a5f1dda5a48306544535 ./Cargo.lock
|
||||
68f29b45f29e898716bd0a8537ee36e7f4000a29498edc90df83ad9f65c3cdaa ./Cargo.toml
|
||||
d3c8a734104ca6d68b1a07c41ef6832ca475e3fc257ccbbaa0afc3a7ad85bd17 ./Cargo.lock
|
||||
0c1a0a807005fc51ac02dfa22bb1a1fa4ae372c200a456faa49b18389bc43b15 ./Cargo.toml
|
||||
19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE
|
||||
c4028102fd6bcc3c203d17c78ce6cfe1d645c4d6ead8eb14c80451acc9730d34 ./README.md
|
||||
a9d1837cefe92369e324a2f656cc4ce399884736d13c064eae44dae7e38faa7b ./README.md
|
||||
41dfdc6d099b54f87d4dd51f696122b3c88d3bf3ddf420bea8f4bc621ba920b0 ./build.rs
|
||||
84ddfc52819d506db14bf3ff548abd06df657c06e355937ed607ff180d381b20 ./docs/API.md
|
||||
797eb20f62d4e9a7351dc76989c2f60e1cf52a48a9bac6fa9d3a51f9c5b837e3 ./docs/API.md
|
||||
2e1e18fd8167dabfe2469c26e85cce62486c7cb6a502c63c6f6b0cd74d5885e0 ./docs/FLOW.md
|
||||
a0893b2a56eb1523f1a72871842e9be2139a5fafba1f51ae942fc407a6e4ca34 ./home-assistant/README.md
|
||||
f8e8559fe10fe523ac5bc9aac25c6e26e862f679d502e8f3c39f38a0a8e40911 ./home-assistant/custom_components/gree_controller/__init__.py
|
||||
@@ -17,7 +17,7 @@ ab08fbe40e9bb48ebcbfff98760aaa0c9434b61b705aaaadea0c09c255d99b0e ./home-assista
|
||||
5a96fe8f5c035c34f1339370270cd078056202d09e236dec75735be11de92a7d ./home-assistant/custom_components/gree_controller/entity_map.py
|
||||
c4fb75c246db651087900ebfc2291ff41ac87652cd6194fc0b776b0005c1cbcf ./home-assistant/custom_components/gree_controller/icon.png
|
||||
c4fb75c246db651087900ebfc2291ff41ac87652cd6194fc0b776b0005c1cbcf ./home-assistant/custom_components/gree_controller/logo.png
|
||||
071dd3548ef9467f14c340f81db8cf3de0686c313243ab762f99262f2c4b2b7c ./home-assistant/custom_components/gree_controller/manifest.json
|
||||
b088946ee13497f546363fda022000f0ce7476e78f04558a4af65374cb8c8e94 ./home-assistant/custom_components/gree_controller/manifest.json
|
||||
38d659d0273c0d1428679e44574a2666c605d1b3796d323b626baa40560788cf ./home-assistant/custom_components/gree_controller/number.py
|
||||
39c4309001b75abb56234f05662bc06e077054986876f1927937edbce528ec95 ./home-assistant/custom_components/gree_controller/select.py
|
||||
cca65482e36d48035aca178121a378fe7d578d600acff267ae81a6399c0da653 ./home-assistant/custom_components/gree_controller/sensor.py
|
||||
@@ -26,12 +26,12 @@ 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
|
||||
d366f7a5bd58457f5033ddffca783be04c1b50dff38b8d4feb9675776feeabe8 ./lang/en.json
|
||||
bde25cbd02bb46068feb344d4cfc16298760dbfd6a855cad4d4345c7d5bf73bb ./lang/pl.json
|
||||
2444b048c40ccb3ebbc161663ccff8409f8d6763858943075c033ea8f832fdef ./lang/en.json
|
||||
1b3eee0b9ece41012535bfbe07ddd7c228aa161d1aa0bc77850a39177a062928 ./lang/pl.json
|
||||
d8459024f04ca514bd8e9d6bd3af872fb942fd85a7cdd5583f4a9d28aab6faba ./make_zip.py
|
||||
1e7b1bcf4194abfb8bad0d601268af0547d58541731e98b74a3500d7629d0be0 ./mock/index.html
|
||||
007c78e0cb404778985980b23bb5f8bbc7a8f04c80015204e24c321970e3b15e ./mock/mock.js
|
||||
4bf74194383660800cdab313a15f4bf54bec4cff5fd8f93e32f733fb8ec8082f ./mock/styles.css
|
||||
5ae5a29dee689e1ab0f433339d1499eb232cae81749ff083dd5be5c62d72fa0b ./mock/styles.css
|
||||
d505d793ce7cc9485b45b78bba1c0d51887adc7451ab59a42702946e5b991382 ./mock/theme-init.js
|
||||
b14233a8987e53bbbdd6770386ba10fa166ec25c1e098275c173c544b37846fc ./presets/bedroom_window_night.json
|
||||
1960841119c0b673fb2f03621b32237552fd828045304cd221401214b997c910 ./presets/device_resilience.json
|
||||
@@ -71,7 +71,7 @@ ae21459a261712bcb8d57594528b1648432e8d03a8a38502234829c0dbfef774 ./presets/week
|
||||
fdcd9a5055d08037278b842e7ab69265345c5811f0a06867136c511d140bb191 ./presets/window_available_guard.json
|
||||
804f22123cd3e8db0fac791826c8dd9f758fb866c8e3b5655fb6d25d259dccf1 ./presets/workday_comfort.json
|
||||
01952aa92b217f8eae2493b88870e2dec595100cd15c4d561ff11ae2b936c46f ./regenerate-sha.sh
|
||||
5a8125836ed0c6e0fd98ea7e24867760771210da9f80c98d8d6ec3bcc6c5b150 ./scripts/FILE_MANIFEST.sha256
|
||||
ec5301fbad7adf463ee1205539481746388f757173301efb3c1ded6176487944 ./scripts/FILE_MANIFEST.sha256
|
||||
bb89bac237e750e9b1bf73761d7df97a6b81853091615878c03f13d7b6399aa7 ./scripts/README.md
|
||||
5bc736c7bc76ca80aaa406bb171d2aa91baf4c3aa8695dce0e09b888b6ab3146 ./scripts/common.sh
|
||||
6403786610ee6d2f628193c25aee0dd058d62e904aa1a31d5f62fdaae0e94b4f ./scripts/configure-gree-network.sh
|
||||
@@ -82,15 +82,15 @@ bb7cd2c5b27c9dceec1d1d2846fbad9600df9c08e0ad07d13fcde97af533091c ./scripts/inst
|
||||
e00d211e3885e30d7fed1e43b44e6fdad40a67019060156c0641816a93e3365f ./scripts/network-debug.sh
|
||||
01952aa92b217f8eae2493b88870e2dec595100cd15c4d561ff11ae2b936c46f ./scripts/regenerate-sha.sh
|
||||
81345b6a0b51736bdbc98fd23199b62e4c721b4e7437e02dab7ea79b97dff29a ./scripts/service.sh
|
||||
fec8b0362e763bbc115b5cc5c56f9ebe815730cee161632d81c5dc0ee1475f80 ./scripts/smoke.sh
|
||||
e16483e9afcea50ca92fa2ccf4ec5e5a2cc46b68b157865d20b09829b5fe9ed3 ./scripts/smoke.sh
|
||||
b50782b3742dfbf8a319c60571c968e93fdf8547db747c759edcffae68cb98bf ./scripts/update.sh
|
||||
4877f9e8217b6a77fb416722c3778373edac874ed3a583bb016ea76d4ffee7d4 ./scripts/verify_flow_logic.py
|
||||
da16e16dfb330d528d54d2138b07950da701edc46af1ff174a9f6a54090122b3 ./src/api.rs
|
||||
204dc035f6cbc657afb5ff17dfd19d5f567e6fae4318347e65158cacf2d60139 ./src/api.rs
|
||||
825370f409124a719043200742c01c3441bc65f483108106140d13fe7bbcba91 ./src/api/assets.rs
|
||||
6c34b1294d76b0eba4c56bfd0bf69bdd502de0e40838104f6ddf2db4e922683d ./src/api/auth.rs
|
||||
63cb241c4536abed402f2a0ad37e7f299343df264eab6a33bb5354c15421e045 ./src/api/automations.rs
|
||||
2317b7cd0c2437c128ac4982d64412c8184ecb5e774825761d61aab6e45fae4a ./src/api/debug_tokens.rs
|
||||
7ffb7ef7d3cc3a908e2b0a7b3799cd1dcc94808f3083ae1b1a0a6e26ec6b88fb ./src/api/devices.rs
|
||||
e43d25b0b65fe307cce209522ab9618f9385210720ed2c3f24abbbc43d09db45 ./src/api/devices.rs
|
||||
417940b467c6f00bbf01cc47eb4479880dee9b2f284a80e3662a6d433dc11b92 ./src/api/events.rs
|
||||
1bbe45c11b176d1595007942ef7723c7954c60fe20cc5aa18c4dbab00b6f09ff ./src/api/flows.rs
|
||||
d096000e94997cf9aaf9d3cc567214a78b8b2da7da57a76e2b3519247af6e592 ./src/api/groups.rs
|
||||
@@ -124,7 +124,7 @@ ce7e809765c8ad7dc4819b7f775ba7a632031d4d0e68780cc6ea953f2fd890e7 ./src/engine/c
|
||||
d15148382f0c84bdef36750a10b463378e2de6a0fb70eecadeee2b07b8648681 ./src/engine/groups.rs
|
||||
18a7669318949e533f206d25cf7b63771d7f0f2c0b375a72ff78a2d921baaa61 ./src/engine/history.rs
|
||||
4972c199274647bff47122c367e51bae8dab24d7ec4d652288e227a5a64c2310 ./src/engine/local_thermostat.rs
|
||||
ecab67072e756f5a371864282eb470a17de53016593e2e96540674066262c865 ./src/engine/ownership.rs
|
||||
abf67cb6c9ac393d7f2b51852bcc52cdade02944ac50e71d14c9d9ece5e31a56 ./src/engine/ownership.rs
|
||||
9c37adf514c96840fd6a5c715c579350fa86d6dcc0655a2aa97d1fd7c7e04299 ./src/engine/polling.rs
|
||||
3d48608a065aa52a1fd8b9c59356a74d8216ecee3d47f8ff8ef7d3d47170c617 ./src/engine/runtime.rs
|
||||
ffa72b93502eece187a96adb4ec94aec6048d4a6dc7af66e7c7af0931318787f ./src/engine/schedules.rs
|
||||
@@ -161,7 +161,7 @@ c935e441933ba8709432cc3ac33ed868043a89188d0f48ea6fbfe34b1f8936a3 ./src/protocol
|
||||
287d0ecd9a3a9df2dd5b838463a3802e59b7c0c20a6bbd24682c8fe453b4e276 ./src/protocol/gree/discovery.rs
|
||||
97284ad37767625cfc2b82a3de5c40f6eecddabe185bd0bcea7cf9b165e017f0 ./src/protocol/gree/merge.rs
|
||||
a96061fbbcadac6c2df2e9708a48dcf819b951eb147f32c7d0d285ac5ae2491b ./src/protocol/gree/network.rs
|
||||
45d0cd0ea40b9255bd811362547f5a667dac6bc7324074726130baf60aa41d2b ./src/protocol/gree/polling.rs
|
||||
7763855207c23357cb67256ad9a47f89f8313280bc1f07ccac8ae21967a5ec7d ./src/protocol/gree/polling.rs
|
||||
35fc903404045129e3046b7997e941b88f98ceb42485cccc872c2e4aa530c75b ./src/protocol/gree/tests.rs
|
||||
55c2d615e76c9e4b08c4067ea734e81d81915144e637f3b51c19bea4fc879137 ./src/protocol/gree/transport.rs
|
||||
a910bd9432a393740c0f6fab52bfcb551f0ea756718d66d290fd2610767cf07c ./src/protocol/mod.rs
|
||||
@@ -176,24 +176,24 @@ f13f1be3d5789539eba3fc1b59c14835f8c681eca7634e118fa3763a53aeee2b ./src/queries/
|
||||
b92a6cb158b494fe145b43c7641e65f6fafff47201d7d76edbec2cfd8b94835c ./systemd/gree-controller.service
|
||||
544a31cb2e5374227026afd6abd0d910381f3fca1638154ebd74e3c3d96a35d1 ./web/404.html
|
||||
e98bdd7204349cce1ec6f57283509697af0bbc72280622a6c3efa6fed242db4f ./web/favicon.svg
|
||||
9b94c56a402396a63f35630d91106a2f2655f03cbda555c330cd059067d488bd ./web/index.html
|
||||
f78622afea899a5e904910217eceeeee306d5fc8094bdccc049bd84752780720 ./web/index.html
|
||||
9ba3f6fa05b72aa989139b2a909982571b2a02055052e4c40104f1e81a9aa7eb ./web/js/README.md
|
||||
21b662914590e720dafefe4dc3dccc91dbeb963cce9ae91f007c835d2d0c1fe5 ./web/js/bootstrap.js
|
||||
dee7c4292889ddd721c220e86fae909f0d662b585e6d9c5d9e2f8fa85d9ed2ad ./web/js/charts.js
|
||||
11fcd1571b9d823af614a75380855c0bd2c287475b25ec6d85c73067994a8448 ./web/js/core.js
|
||||
f75449450a179f5853b8b08c69dec93205985e0dcbc806d355fd6060237761f6 ./web/js/core.js
|
||||
dfc123885a9b6b5c1d07c12d2d2625ff15aa0844e5cd3736092ccd0a360da39c ./web/js/dashboard.js
|
||||
1c9b42c1537963f703a1d8a53a15d260957df3494e19209442a9fedb6e6b81ed ./web/js/entities.js
|
||||
57218050f3e7c8481f9d6f11f395d225dae78e32ce09c9e42fb79c1e81417ded ./web/js/events.js
|
||||
e797a71616fbdc2b651c0b5174f67a581beeb22c6a8c9441dd728bf444e6162d ./web/js/entities.js
|
||||
e332b3f811958a938105272c1e8e28d6ca8ba69303001da165b4b0b85727695c ./web/js/events.js
|
||||
d4aea1d6ec595000eaddca3b6f0e3d4003901365ece7788147a6ed207cffcc78 ./web/js/flows.js
|
||||
f06557c6d338259059b990f8f79c91d68ac8ec1f245c302468f08929b5506901 ./web/js/forms.js
|
||||
dbf36223863ba882c03eccea4516ba8db7acf0c2e72c6c64490cb994f1e20f96 ./web/js/history.js
|
||||
7eaa04992ea828dc89f5eaebf674cbbfd160f53eb8d0d6ef0d4e47bd8c28c435 ./web/js/main.js
|
||||
cfa4e876d20b32cc26b8af615138deb99dd1360ef2be3ca0118c750ff44094c7 ./web/js/navigation.js
|
||||
a0959f9caf84f1873e3549261de8b1a069f4df8326b17bfe1f8c3de120299875 ./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
|
||||
e386e6bd57e7bac98f06f55b664d09c560dbd36e97debb9dac73a5d7906702a6 ./web/styles.css
|
||||
5ae5a29dee689e1ab0f433339d1499eb232cae81749ff083dd5be5c62d72fa0b ./web/styles.css
|
||||
ae1b03f30b494f474a781a5d32072f1e73eba2b7c768109fc1cc8028cb6662a4 ./web/sw.js
|
||||
d505d793ce7cc9485b45b78bba1c0d51887adc7451ab59a42702946e5b991382 ./web/theme-init.js
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Self-hosted controller for GREE-compatible air conditioners with a local Web UI, thermostat zones, schedules, Home Assistant integration, history, notifications and a documented HTTP/WebSocket API.
|
||||
|
||||
**Current release: 0.11.5**
|
||||
**Current release: 0.11.6**
|
||||
|
||||
> [Full API reference](docs/API.md) — authentication, every endpoint, request bodies, response models, WebSocket events and examples.
|
||||
|
||||
@@ -25,8 +25,8 @@ The interface is intentionally split by responsibility:
|
||||
|
||||
| Area | Purpose |
|
||||
| --- | --- |
|
||||
| Dashboard | Whole-house summary and Quick Thermostat controls. |
|
||||
| Devices | Discovery, binding and direct/technical GREE device control. |
|
||||
| Dashboard | Whole-house summary, Quick Thermostat and full manual GREE unit control. |
|
||||
| Devices | Technical unit configuration, binding, connection diagnostics and live ping. |
|
||||
| Zones | Thermostat configuration, profiles, sensors and control ownership. |
|
||||
| Groups | Climate group membership plus optional group-level mode/preset/custom-temperature control. Group ON activates member thermostat control; Group OFF powers member units down and releases group ownership so individual thermostats are not left group-blocked. |
|
||||
| Flow | Full-screen visual logic editor. Flow is the source of truth and is automatically compiled into generated schedules and automations. |
|
||||
|
||||
+18
-3
@@ -1,6 +1,6 @@
|
||||
# GREE Controller API reference
|
||||
|
||||
HTTP and WebSocket API for GREE Controller **0.11.5**.
|
||||
HTTP and WebSocket API for GREE Controller **0.11.6**.
|
||||
|
||||
[← Main documentation](../README.md)
|
||||
|
||||
@@ -118,6 +118,7 @@ Common statuses:
|
||||
| PATCH | `/api/devices/{id}` | Edit technical device configuration. |
|
||||
| DELETE | `/api/devices/{id}` | Delete a device after safety checks. |
|
||||
| POST | `/api/devices/{id}/bind` | Bind/re-bind a physical unit. |
|
||||
| POST | `/api/devices/{id}/probe` | Minimal non-mutating GREE round-trip diagnostic. |
|
||||
| POST | `/api/devices/{id}/poll` | Poll one unit immediately. |
|
||||
| POST | `/api/devices/{id}/command` | Send a direct/manual device command. |
|
||||
|
||||
@@ -213,7 +214,7 @@ Response:
|
||||
{
|
||||
"status": "ok",
|
||||
"name": "gree-controller",
|
||||
"version": "0.11.5",
|
||||
"version": "0.11.6",
|
||||
"uptime_seconds": 1234,
|
||||
"control_ready": true,
|
||||
"time": "2026-08-30T06:54:00Z"
|
||||
@@ -237,7 +238,7 @@ Returns the initial Web UI snapshot:
|
||||
"settings": {},
|
||||
"outdoor_temperature": null,
|
||||
"system": {
|
||||
"version": "0.11.5",
|
||||
"version": "0.11.6",
|
||||
"uptime_seconds": 1234,
|
||||
"auth_required": false,
|
||||
"control_ready": true,
|
||||
@@ -379,6 +380,18 @@ Returns `204`. Deletion is rejected if the device is referenced by an automation
|
||||
|
||||
Performs/repeats GREE binding and returns updated `Device`. Simulated devices return unchanged.
|
||||
|
||||
### `POST /api/devices/{id}/probe`
|
||||
|
||||
Performs a minimal, non-mutating GREE status round-trip and returns `response_time_ms`. It does **not** update device online/error counters, readings, capabilities, thermostat ownership, or persisted device state. A real device must already be bound.
|
||||
|
||||
```json
|
||||
{
|
||||
"device_id": "gree-aabbccddeeff",
|
||||
"response_time_ms": 18,
|
||||
"ok": true
|
||||
}
|
||||
```
|
||||
|
||||
### `POST /api/devices/{id}/poll`
|
||||
|
||||
Immediately polls one unit and returns updated `Device`.
|
||||
@@ -416,6 +429,8 @@ Rules:
|
||||
|
||||
The backend sends only properties that differ from the last known device state. Climate-relevant direct commands can create/continue a manual-device takeover for an enabled thermostat zone so automation does not immediately fight the user.
|
||||
|
||||
When the physical device belongs to a disabled thermostat zone, direct web/API control requires `"manual_override": true`. The web UI asks for explicit confirmation before sending that flag. Home Assistant direct-device control remains blocked for disabled zones.
|
||||
|
||||
---
|
||||
|
||||
## Zones
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"domain": "gree_controller",
|
||||
"name": "GREE Controller",
|
||||
"version": "0.11.5",
|
||||
"version": "0.11.6",
|
||||
"config_flow": true,
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
|
||||
+11
-2
@@ -89,7 +89,7 @@
|
||||
"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.pingHint": "Measures a minimal GREE round-trip without changing device state, error counters or automation ownership. The chart refreshes while monitoring is enabled.",
|
||||
"devices.pingAll": "Ping all units",
|
||||
"devices.pingStart": "Start",
|
||||
"devices.pingStop": "Stop",
|
||||
@@ -100,6 +100,10 @@
|
||||
"devices.pingSamples": "Samples",
|
||||
"devices.pingNoSamples": "No samples yet. Start monitoring.",
|
||||
"devices.pingFailed": "No response",
|
||||
"devices.pingResponding": "Responding",
|
||||
"devices.manualDeviceDisabled": "Unit is technically disabled",
|
||||
"devices.manualNoCommunication": "No communication with the unit",
|
||||
"devices.manualCommunicationProblem": "The last communication with the unit failed",
|
||||
"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?",
|
||||
@@ -264,7 +268,12 @@
|
||||
"toast.tokenRevoked": "Token revoked",
|
||||
"devices.protocolAuto": "Auto (V1 + V2)",
|
||||
"devices.rename": "Name / protocol",
|
||||
"devices.protocolChangeHint": "Changing protocol clears the saved device key and performs a new bind on the next request.",
|
||||
"devices.protocolChangeHint": "Changing protocol clears the saved device key. “Save and check connection” immediately performs the required bind and test.",
|
||||
"devices.saveAndCheck": "Save and check connection",
|
||||
"devices.connectionCheckOk": "Connection works · response {ms} ms",
|
||||
"devices.connectionCheckFailed": "Configuration was saved, but the connection test failed: {error}",
|
||||
"devices.savedAndChecked": "Saved and connection confirmed",
|
||||
"devices.savedCheckFailed": "Configuration saved, but the connection test failed",
|
||||
"discovery.title": "Discover GREE devices",
|
||||
"discovery.help": "Auto searches both AES-ECB and AES-GCM devices. Multiple passes improve discovery when several Wi-Fi modules answer the same broadcast.",
|
||||
"discovery.protocol": "Discovery protocol",
|
||||
|
||||
+11
-2
@@ -89,7 +89,7 @@
|
||||
"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.pingHint": "Pomiar wykonuje minimalne zapytanie GREE bez zmiany stanu urządzenia, liczników błędów ani własności automatyki. Wykres odświeża się, gdy monitoring jest włączony.",
|
||||
"devices.pingAll": "Pinguj wszystkie jednostki",
|
||||
"devices.pingStart": "Uruchom",
|
||||
"devices.pingStop": "Zatrzymaj",
|
||||
@@ -100,6 +100,10 @@
|
||||
"devices.pingSamples": "Próbki",
|
||||
"devices.pingNoSamples": "Brak próbek. Uruchom monitoring.",
|
||||
"devices.pingFailed": "Brak odpowiedzi",
|
||||
"devices.pingResponding": "Odpowiada",
|
||||
"devices.manualDeviceDisabled": "Jednostka jest wyłączona technicznie",
|
||||
"devices.manualNoCommunication": "Brak komunikacji z jednostką",
|
||||
"devices.manualCommunicationProblem": "Ostatnia komunikacja z jednostką nie powiodła się",
|
||||
"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?",
|
||||
@@ -264,7 +268,12 @@
|
||||
"toast.tokenRevoked": "Token unieważniony",
|
||||
"devices.protocolAuto": "Auto (V1 + V2)",
|
||||
"devices.rename": "Nazwa / protokół",
|
||||
"devices.protocolChangeHint": "Zmiana protokołu usuwa zapisany klucz urządzenia i wykona ponowny bind przy następnym żądaniu.",
|
||||
"devices.protocolChangeHint": "Zmiana protokołu usuwa zapisany klucz urządzenia. Opcja „Zapisz i sprawdź połączenie” od razu wykona wymagany bind i test.",
|
||||
"devices.saveAndCheck": "Zapisz i sprawdź połączenie",
|
||||
"devices.connectionCheckOk": "Połączenie działa · odpowiedź {ms} ms",
|
||||
"devices.connectionCheckFailed": "Konfiguracja została zapisana, ale test połączenia nie powiódł się: {error}",
|
||||
"devices.savedAndChecked": "Zapisano i potwierdzono połączenie",
|
||||
"devices.savedCheckFailed": "Zapisano konfigurację, ale test połączenia nie powiódł się",
|
||||
"discovery.title": "Wykrywanie urządzeń GREE",
|
||||
"discovery.help": "Auto wyszukuje urządzenia AES-ECB i AES-GCM. Kilka przebiegów zwiększa skuteczność, gdy wiele modułów Wi-Fi odpowiada na ten sam broadcast.",
|
||||
"discovery.protocol": "Protokół wykrywania",
|
||||
|
||||
Executable → Regular
+358
-1
@@ -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; }
|
||||
@@ -6784,3 +6784,360 @@ 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)); }
|
||||
}
|
||||
|
||||
|
||||
/* 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%; }
|
||||
}
|
||||
|
||||
@@ -8,6 +8,6 @@ bb7cd2c5b27c9dceec1d1d2846fbad9600df9c08e0ad07d13fcde97af533091c ./install.sh
|
||||
e00d211e3885e30d7fed1e43b44e6fdad40a67019060156c0641816a93e3365f ./network-debug.sh
|
||||
01952aa92b217f8eae2493b88870e2dec595100cd15c4d561ff11ae2b936c46f ./regenerate-sha.sh
|
||||
81345b6a0b51736bdbc98fd23199b62e4c721b4e7437e02dab7ea79b97dff29a ./service.sh
|
||||
fec8b0362e763bbc115b5cc5c56f9ebe815730cee161632d81c5dc0ee1475f80 ./smoke.sh
|
||||
e16483e9afcea50ca92fa2ccf4ec5e5a2cc46b68b157865d20b09829b5fe9ed3 ./smoke.sh
|
||||
b50782b3742dfbf8a319c60571c968e93fdf8547db747c759edcffae68cb98bf ./update.sh
|
||||
4877f9e8217b6a77fb416722c3778373edac874ed3a583bb016ea76d4ffee7d4 ./verify_flow_logic.py
|
||||
|
||||
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
+29
@@ -41,6 +41,11 @@ curl -fsS -X POST -H 'Content-Type: application/json' \
|
||||
"http://127.0.0.1:$PORT/api/devices/sim-salon/command" >"$TMP/command.json"
|
||||
grep -q '"power":true' "$TMP/command.json"
|
||||
|
||||
curl -fsS -X POST \
|
||||
"http://127.0.0.1:$PORT/api/devices/sim-salon/probe" >"$TMP/probe.json"
|
||||
grep -q '"ok":true' "$TMP/probe.json"
|
||||
grep -q '"response_time_ms":0' "$TMP/probe.json"
|
||||
|
||||
curl -fsS -X POST -H 'Content-Type: application/json' \
|
||||
-d '{"name":"Test","device_id":"sim-salon","enabled":true,"mode":"cool","setpoint":23,"hysteresis":0.6,"min_on_seconds":0,"min_off_seconds":0,"sensor_source":"device"}' \
|
||||
"http://127.0.0.1:$PORT/api/zones" >"$TMP/zone.json"
|
||||
@@ -181,4 +186,28 @@ curl -fsS -X POST -H "Authorization: Bearer $HA_ACCESS_TOKEN" -H 'Content-Type:
|
||||
"http://127.0.0.1:$PORT/api/integrations/home-assistant/devices/sim-salon/command" >"$TMP/ha-command.json"
|
||||
grep -q '"power":false' "$TMP/ha-command.json"
|
||||
|
||||
# Disabled thermostat zones require an explicit direct-control override on the admin API,
|
||||
# while the restricted Home Assistant direct-device endpoint stays blocked.
|
||||
curl -fsS "http://127.0.0.1:$PORT/api/zones/$ZONE_ID" >"$TMP/zone-before-disable.json"
|
||||
python3 - "$TMP/zone-before-disable.json" "$TMP/zone-disable.json" <<'PYZONE'
|
||||
import json,sys
|
||||
zone=json.load(open(sys.argv[1]))
|
||||
zone["enabled"]=False
|
||||
json.dump(zone,open(sys.argv[2],"w"))
|
||||
PYZONE
|
||||
curl -fsS -X PUT -H 'Content-Type: application/json' --data-binary @"$TMP/zone-disable.json" \
|
||||
"http://127.0.0.1:$PORT/api/zones/$ZONE_ID" >"$TMP/zone-disabled.json"
|
||||
grep -q '"enabled":false' "$TMP/zone-disabled.json"
|
||||
BLOCKED_STATUS="$(curl -sS -o "$TMP/blocked-manual.json" -w '%{http_code}' -X POST -H 'Content-Type: application/json' \
|
||||
-d '{"power":true}' "http://127.0.0.1:$PORT/api/devices/sim-salon/command")"
|
||||
[[ "$BLOCKED_STATUS" == "400" ]]
|
||||
grep -q 'manual_override=true' "$TMP/blocked-manual.json"
|
||||
curl -fsS -X POST -H 'Content-Type: application/json' \
|
||||
-d '{"power":true,"manual_override":true}' \
|
||||
"http://127.0.0.1:$PORT/api/devices/sim-salon/command" >"$TMP/override-manual.json"
|
||||
grep -q '"power":true' "$TMP/override-manual.json"
|
||||
HA_BLOCKED_STATUS="$(curl -sS -o "$TMP/ha-blocked-manual.json" -w '%{http_code}' -X POST -H "Authorization: Bearer $HA_ACCESS_TOKEN" -H 'Content-Type: application/json' \
|
||||
-d '{"power":false}' "http://127.0.0.1:$PORT/api/integrations/home-assistant/devices/sim-salon/command")"
|
||||
[[ "$HA_BLOCKED_STATUS" == "400" ]]
|
||||
|
||||
echo "Smoke test OK (port $PORT)"
|
||||
|
||||
Executable → Regular
Executable → Regular
@@ -68,6 +68,7 @@ pub fn router(state: AppState) -> Router {
|
||||
.route("/api/devices/:id", get(get_device).patch(patch_device).delete(delete_device))
|
||||
.route("/api/devices/:id/bind", post(bind_device))
|
||||
.route("/api/devices/:id/poll", post(poll_device))
|
||||
.route("/api/devices/:id/probe", post(probe_device))
|
||||
.route("/api/devices/:id/command", post(command_device))
|
||||
.route("/api/zones", get(list_zones).post(create_zone))
|
||||
.route("/api/zones/:id", get(get_zone).put(update_zone).delete(delete_zone))
|
||||
|
||||
+33
-6
@@ -208,14 +208,41 @@ async fn poll_device(State(state): State<AppState>, Path(id): Path<String>) -> R
|
||||
Ok(Json(engine::poll_one(&state, &id).await?))
|
||||
}
|
||||
|
||||
async fn command_device(State(state): State<AppState>, Path(id): Path<String>, Json(command): Json<DeviceCommand>) -> Result<Json<Device>, AppError> {
|
||||
Ok(Json(engine::send_manual_command(&state, &id, command, "device.manual_control").await?))
|
||||
async fn probe_device(State(state): State<AppState>, Path(id): Path<String>) -> Result<Json<Value>, AppError> {
|
||||
let device = state.db.get_device(&id)?.ok_or_else(|| AppError::NotFound(format!("device {id}")))?;
|
||||
let response_time_ms = state.gree.probe(&device).await.map_err(|err| AppError::Device(err.to_string()))?;
|
||||
Ok(Json(json!({
|
||||
"device_id": device.id,
|
||||
"response_time_ms": response_time_ms,
|
||||
"ok": true
|
||||
})))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ManualDeviceCommandRequest {
|
||||
#[serde(flatten)]
|
||||
command: DeviceCommand,
|
||||
#[serde(default)]
|
||||
manual_override: bool,
|
||||
}
|
||||
|
||||
async fn command_device(State(state): State<AppState>, Path(id): Path<String>, Json(request): Json<ManualDeviceCommandRequest>) -> Result<Json<Device>, AppError> {
|
||||
Ok(Json(engine::send_manual_command(
|
||||
&state,
|
||||
&id,
|
||||
request.command,
|
||||
"device.manual_control",
|
||||
request.manual_override,
|
||||
).await?))
|
||||
}
|
||||
|
||||
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 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?))
|
||||
Ok(Json(engine::send_manual_command(
|
||||
&state,
|
||||
&id,
|
||||
command,
|
||||
"home_assistant.device_manual_control",
|
||||
false,
|
||||
).await?))
|
||||
}
|
||||
|
||||
|
||||
+16
-4
@@ -236,14 +236,26 @@ async fn detect_external_device_control(state: &AppState, before: &Device, after
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn send_manual_command(state: &AppState, device_id: &str, command: DeviceCommand, source: &str) -> Result<Device, AppError> {
|
||||
// Keep zone -> device lock ordering consistent with Quick Thermostat/full-zone edits.
|
||||
// A device belongs to at most one thermostat zone, but keep this generic for legacy data.
|
||||
let mut zone_ids: Vec<String> = state.db.list_zones()?.into_iter().filter(|zone| zone.device_id == device_id).map(|zone| zone.id).collect();
|
||||
pub async fn send_manual_command(state: &AppState, device_id: &str, command: DeviceCommand, source: &str, allow_disabled_zone: bool) -> Result<Device, AppError> {
|
||||
// Stabilize device <-> zone membership while validating the manual-control safety gate.
|
||||
// Lock order remains configuration -> zone(s) -> device.
|
||||
let _configuration_guard = state.lock_configuration_operation().await;
|
||||
let zones: Vec<Zone> = state.db.list_zones()?.into_iter().filter(|zone| zone.device_id == device_id).collect();
|
||||
let mut zone_ids: Vec<String> = zones.iter().map(|zone| zone.id.clone()).collect();
|
||||
zone_ids.sort();
|
||||
zone_ids.dedup();
|
||||
let mut _zone_guards = Vec::new();
|
||||
for zone_id in &zone_ids { _zone_guards.push(state.lock_zone_operation(zone_id).await); }
|
||||
if !allow_disabled_zone {
|
||||
// Re-read after taking the zone lock(s): a quick thermostat action may have changed
|
||||
// enabled state while this request was waiting, even though membership is stable.
|
||||
if let Some(zone) = state.db.list_zones()?.into_iter().find(|zone| zone.device_id == device_id && !zone.enabled) {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"device belongs to disabled thermostat zone '{}'; explicit manual_override=true is required for direct control",
|
||||
zone.name
|
||||
)));
|
||||
}
|
||||
}
|
||||
// Keep the device lock until the zone takeover marker is persisted. Otherwise a poll
|
||||
// could observe our own just-sent command before the controller records manual ownership.
|
||||
let _device_guard = state.lock_device_operation(device_id).await;
|
||||
|
||||
@@ -1,4 +1,17 @@
|
||||
impl GreeClient {
|
||||
/// Measure a minimal GREE round-trip without mutating persisted/live device state.
|
||||
/// Diagnostics must not alter online/error counters, ownership, readings or capabilities.
|
||||
pub async fn probe(&self, device: &Device) -> Result<u64> {
|
||||
if device.simulated { return Ok(0); }
|
||||
let key = device.key.as_deref().filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| anyhow!("device is not bound"))?;
|
||||
let started = Instant::now();
|
||||
let response = self.status_request(device, key, &["Pow"]).await?;
|
||||
let mut snapshot = device.clone();
|
||||
self.apply_status(&mut snapshot, &response)?;
|
||||
Ok(started.elapsed().as_millis().min(u64::MAX as u128) as u64)
|
||||
}
|
||||
|
||||
pub async fn poll(&self, device: &mut Device) -> Result<()> {
|
||||
let key = device.key.clone().ok_or_else(|| anyhow!("device is not bound"))?;
|
||||
let full_cols = [
|
||||
|
||||
+7
-6
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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%; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user