v0.5.3
This commit is contained in:
+11
-3
@@ -1,11 +1,19 @@
|
|||||||
# GREE Controller v0.5.2 - build and validation report
|
# GREE Controller v0.5.3 - build and validation report
|
||||||
|
|
||||||
## Scope
|
## Scope
|
||||||
|
|
||||||
Version 0.5.2 includes the 0.5.0 dashboard/metrics/diagnostics release plus thermostat noise-control fixes.
|
Version 0.5.3 builds on 0.5.2 with UI consistency fixes, quiet-hours control, a flow-board simulator, event-log retention and Home Assistant sensor aliases.
|
||||||
|
|
||||||
## Implemented changes
|
## Implemented changes
|
||||||
|
|
||||||
|
- Replaced the textual Connected/Disconnected label with a green/amber/red status dot in the top-left brand header.
|
||||||
|
- Fixed History filter layout so Zone/Device/Sensor and time-range controls keep equal heights and do not stretch after changing tabs.
|
||||||
|
- Reworked the Simulator into a Node-RED-style dotted flow board with connected live nodes for sensor input, thermostat target, decision, unit command and next event.
|
||||||
|
- Added configurable night mode (default 22:00-06:00): thermostat fan speed is capped during quiet hours and compatible units can be asked to keep Quiet enabled.
|
||||||
|
- Device cards now use **Name / protocol** instead of Rename and no longer show the fixed UDP port next to the IP address.
|
||||||
|
- Settings are split into visual blocks; the self-signed HA example uses a generic private address instead of an installation-specific address.
|
||||||
|
- Event/debug log retention is configurable and old rows are pruned automatically; the Events page can change retention and prune immediately.
|
||||||
|
- Home Assistant temperature sensors can have UI aliases; aliases replace raw entity IDs in history selectors, legends and custom-chart labels without changing stored entity IDs.
|
||||||
- Added a dedicated Web UI simulator page that visualizes thermostat/automation decisions as graphical blocks with hours, targets, demand, standby behavior, rule actions and upcoming events.
|
- Added a dedicated Web UI simulator page that visualizes thermostat/automation decisions as graphical blocks with hours, targets, demand, standby behavior, rule actions and upcoming events.
|
||||||
- Quick Thermostat cards on the dashboard can enable/disable a zone directly; the edit dialog is no longer required for this action.
|
- Quick Thermostat cards on the dashboard can enable/disable a zone directly; the edit dialog is no longer required for this action.
|
||||||
- SQLite history housekeeping is tiered to chart resolution: full recent data, 10-minute buckets after 24 hours and 30-minute buckets after 7 days.
|
- SQLite history housekeeping is tiered to chart resolution: full recent data, 10-minute buckets after 24 hours and 30-minute buckets after 7 days.
|
||||||
@@ -29,7 +37,7 @@ Version 0.5.2 includes the 0.5.0 dashboard/metrics/diagnostics release plus ther
|
|||||||
- Shell syntax validation for all `scripts/*.sh` files.
|
- Shell syntax validation for all `scripts/*.sh` files.
|
||||||
- JSON validation for EN/PL language packs, PWA manifest, Home Assistant manifest and HA translations.
|
- JSON validation for EN/PL language packs, PWA manifest, Home Assistant manifest and HA translations.
|
||||||
- EN/PL UI translation-key parity and literal UI translation-reference validation.
|
- EN/PL UI translation-key parity and literal UI translation-reference validation.
|
||||||
- SQLite schema execution plus tiered compaction tests.
|
- SQLite schema execution plus tiered compaction and event-log retention tests.
|
||||||
- SQLite archive-selection query tests for device, zone and Home Assistant histories.
|
- SQLite archive-selection query tests for device, zone and Home Assistant histories.
|
||||||
- Configuration-clear behavior verified to preserve metric history, event logs and API tokens.
|
- Configuration-clear behavior verified to preserve metric history, event logs and API tokens.
|
||||||
- Version consistency checked for Cargo package, Cargo lock root package, README and Home Assistant manifest.
|
- Version consistency checked for Cargo package, Cargo lock root package, README and Home Assistant manifest.
|
||||||
|
|||||||
Generated
+1
-1
@@ -633,7 +633,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "gree-controller"
|
name = "gree-controller"
|
||||||
version = "0.5.2"
|
version = "0.5.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes",
|
"aes",
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "gree-controller"
|
name = "gree-controller"
|
||||||
version = "0.5.2"
|
version = "0.5.3"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
authors = ["GREE Controller contributors"]
|
authors = ["GREE Controller contributors"]
|
||||||
description = "Standalone local GREE HVAC controller with Web UI, SQLite and Home Assistant sensor support"
|
description = "Standalone local GREE HVAC controller with Web UI, SQLite and Home Assistant sensor support"
|
||||||
|
|||||||
+19
-19
@@ -1,12 +1,12 @@
|
|||||||
4989a37261ebad2b981c62ee9b5be41cc298f982e101782f27151774293f7a9b ./.env.example
|
520d003bd881ccfd193bd59ed1264466903070b688bef23f9477aa7728207e7a ./.env.example
|
||||||
a4ec3874a2e3ab1bad28fb40bb620f7b01f64d01ad9b699306bf70ada31227db ./.gitignore
|
a4ec3874a2e3ab1bad28fb40bb620f7b01f64d01ad9b699306bf70ada31227db ./.gitignore
|
||||||
60e043828c8540fd46114141a01838f82e09aabe460ff1d6e7886a57d7c0cd33 ./BUILD_REPORT.md
|
148609fdc0b1465895f6c5258258ccaa240cf7feb52ac1dbd7bca35e635890be ./BUILD_REPORT.md
|
||||||
161b2168c551e4e4563161ba2b1aa417a57332c984e3cf3f729fc5d6df987921 ./Cargo.lock
|
eaf6bdc44eb92f2372a8da62fa60e88cfec3b5e07bc295866ee20899ecb3f436 ./Cargo.lock
|
||||||
043fe7cc49e07a875d87ab8dfa6eef2bca73b6317038010c18765ea9950992cd ./Cargo.toml
|
a6cbb8074f6d065adcce27198001008c6fa30b8cd0d124de38637e2eedf06927 ./Cargo.toml
|
||||||
19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE
|
19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE
|
||||||
975335835c4eae1f7a90ac903be39e4a1fe5c318c0d80ba71bfad225b2ed8488 ./README.md
|
fb59b28f19e10c01349ba7e271072b9c6950e5596c97cc93ed4c0214620eaa6a ./README.md
|
||||||
a4fa9bfee9735ed8ed95ea31456e0cce503d82502ae3f550108ffca51b0f0c3d ./build.rs
|
a4fa9bfee9735ed8ed95ea31456e0cce503d82502ae3f550108ffca51b0f0c3d ./build.rs
|
||||||
6577e178c03cdb5f9e5b55eba4c25e2733bd5ca48daa76cf7910cda776066517 ./docs/API.md
|
1ed94830fa34a957949ebb8d447c59b437f0460ac58710e10a14d26d0d6f1ca0 ./docs/API.md
|
||||||
234dd200e380a13ecd3e61b4ea455f6f08d64ce89382077dee80684acadb9703 ./docs/HOME_ASSISTANT_MIGRATION.md
|
234dd200e380a13ecd3e61b4ea455f6f08d64ce89382077dee80684acadb9703 ./docs/HOME_ASSISTANT_MIGRATION.md
|
||||||
7a88d6e76fda21e5d34ab351e26bc10dc1f8f7b3055505aefad1df7c56d65ae4 ./docs/LOCALIZATION.md
|
7a88d6e76fda21e5d34ab351e26bc10dc1f8f7b3055505aefad1df7c56d65ae4 ./docs/LOCALIZATION.md
|
||||||
10a0722e1100fb4a05e3067daeb67dc47b0c0a096b43b1cbf2bf002967ce7d98 ./docs/LXC.md
|
10a0722e1100fb4a05e3067daeb67dc47b0c0a096b43b1cbf2bf002967ce7d98 ./docs/LXC.md
|
||||||
@@ -19,7 +19,7 @@ fde31b8e020fd36be2d9d9b1e554254f5da8a1cc593240ebb8d78820154dd790 ./home-assista
|
|||||||
071f721b75af2ed68812c9be3a62b7ae63771e56e567a569f59ca976f8ff42f1 ./home-assistant/custom_components/gree_controller/const.py
|
071f721b75af2ed68812c9be3a62b7ae63771e56e567a569f59ca976f8ff42f1 ./home-assistant/custom_components/gree_controller/const.py
|
||||||
8dd51322798cad28d4d19938fe459aa2a4d3b64b075532149f3c8b62049a6c8b ./home-assistant/custom_components/gree_controller/coordinator.py
|
8dd51322798cad28d4d19938fe459aa2a4d3b64b075532149f3c8b62049a6c8b ./home-assistant/custom_components/gree_controller/coordinator.py
|
||||||
5a96fe8f5c035c34f1339370270cd078056202d09e236dec75735be11de92a7d ./home-assistant/custom_components/gree_controller/entity_map.py
|
5a96fe8f5c035c34f1339370270cd078056202d09e236dec75735be11de92a7d ./home-assistant/custom_components/gree_controller/entity_map.py
|
||||||
6d965c12b1dcfcc8beb439d2035fcb4e74e58db919682ce77b771729ea41e4a0 ./home-assistant/custom_components/gree_controller/manifest.json
|
48e66790617ee5ce8299bb388cc603a6bc8e6b6b29d2b1d05ef69d3b15e55079 ./home-assistant/custom_components/gree_controller/manifest.json
|
||||||
d0206766d76f70ea1c75a618e41bf1e8e8c0429f42ee97f40daac1ea12f16658 ./home-assistant/custom_components/gree_controller/number.py
|
d0206766d76f70ea1c75a618e41bf1e8e8c0429f42ee97f40daac1ea12f16658 ./home-assistant/custom_components/gree_controller/number.py
|
||||||
5c914c2faa18a9cb9eb442dc111c9416f89a630578f820beb021d5381aa1b2d0 ./home-assistant/custom_components/gree_controller/sensor.py
|
5c914c2faa18a9cb9eb442dc111c9416f89a630578f820beb021d5381aa1b2d0 ./home-assistant/custom_components/gree_controller/sensor.py
|
||||||
59632b92ed799234c632324b00e9a48e89ee06a5c49b8c27d29e495ee42e40e9 ./home-assistant/custom_components/gree_controller/switch.py
|
59632b92ed799234c632324b00e9a48e89ee06a5c49b8c27d29e495ee42e40e9 ./home-assistant/custom_components/gree_controller/switch.py
|
||||||
@@ -27,8 +27,8 @@ d0206766d76f70ea1c75a618e41bf1e8e8c0429f42ee97f40daac1ea12f16658 ./home-assista
|
|||||||
13f30e2dcdcedbd1b6c3f99c2335e0487108fd72c8e86922368b84f2fa2038ae ./home-assistant/custom_components/gree_controller/translations/pl.json
|
13f30e2dcdcedbd1b6c3f99c2335e0487108fd72c8e86922368b84f2fa2038ae ./home-assistant/custom_components/gree_controller/translations/pl.json
|
||||||
4513070521d3dda0efb0d974a86ba674494cfb2b66fe9e5cac5b1b0430dede97 ./home-assistant/generated/gree_controller_entities.example.json
|
4513070521d3dda0efb0d974a86ba674494cfb2b66fe9e5cac5b1b0430dede97 ./home-assistant/generated/gree_controller_entities.example.json
|
||||||
253a0bc912786e67ea7fc92a64e4a510ad973bec343a88ccfb1f28fca3e8cf01 ./lang/README.md
|
253a0bc912786e67ea7fc92a64e4a510ad973bec343a88ccfb1f28fca3e8cf01 ./lang/README.md
|
||||||
ea76d53c79adef54513e3d72c342c8b7c01e8a9ac13c56a19703c6f3642a6630 ./lang/en.json
|
40a383bb95be5d2de716262002dc4e2dc738730484919e0164c678098e745866 ./lang/en.json
|
||||||
14192dd2d83f8ccc8a4d36d54f76d8e941397b6d9b05e55edeb07083ec2426a3 ./lang/pl.json
|
299a4e8404b6e14d1c545790d04e8619237f5ceecbcceafe646427897e8070c5 ./lang/pl.json
|
||||||
028e1f16e9fbaed57cadb88eff04e65b4bd67722c50b4d6b1fb525f5a2f39abf ./make_zip.py
|
028e1f16e9fbaed57cadb88eff04e65b4bd67722c50b4d6b1fb525f5a2f39abf ./make_zip.py
|
||||||
bb89bac237e750e9b1bf73761d7df97a6b81853091615878c03f13d7b6399aa7 ./scripts/README.md
|
bb89bac237e750e9b1bf73761d7df97a6b81853091615878c03f13d7b6399aa7 ./scripts/README.md
|
||||||
3640e34a1ed5a93be3e96610848b16b8e748d5ca95975ac7e95d38cbf32695a8 ./scripts/common.sh
|
3640e34a1ed5a93be3e96610848b16b8e748d5ca95975ac7e95d38cbf32695a8 ./scripts/common.sh
|
||||||
@@ -41,24 +41,24 @@ e00d211e3885e30d7fed1e43b44e6fdad40a67019060156c0641816a93e3365f ./scripts/netw
|
|||||||
81345b6a0b51736bdbc98fd23199b62e4c721b4e7437e02dab7ea79b97dff29a ./scripts/service.sh
|
81345b6a0b51736bdbc98fd23199b62e4c721b4e7437e02dab7ea79b97dff29a ./scripts/service.sh
|
||||||
b48fc84d79aab381226363ac8473f981bcba5e4911c4cc0011261182debf4250 ./scripts/smoke.sh
|
b48fc84d79aab381226363ac8473f981bcba5e4911c4cc0011261182debf4250 ./scripts/smoke.sh
|
||||||
b50782b3742dfbf8a319c60571c968e93fdf8547db747c759edcffae68cb98bf ./scripts/update.sh
|
b50782b3742dfbf8a319c60571c968e93fdf8547db747c759edcffae68cb98bf ./scripts/update.sh
|
||||||
1e82b152e3e8badbceb2ffa6424fea027b7aad3ad87e8b1b46085caa66760ba8 ./src/api.rs
|
df42f5610d74bcd6c43766c26ae8e23197a37483dba69c044fe85e44a06e4ef4 ./src/api.rs
|
||||||
4bbbe739219730d7369965277e689b5a557202e2674869705009ca4c3239ad38 ./src/config.rs
|
94e05b5bdc7c0eeb4bec823f83ae36246836c18f2b164ebf1629a82723840eb5 ./src/config.rs
|
||||||
1ec49d30ff14fc341bb15177ff195db41fb927d67114d1c1cb8ca671c7ea73ec ./src/db.rs
|
a89bc49e359715109149130a10ac60abd26bfd2bc18c5ee5142e01696d9f9cdd ./src/db.rs
|
||||||
d6088e1b36997a5bc40618501bcba87b69ae712ae841310d6c65d286b13a7c12 ./src/engine.rs
|
9ba09db2faef9f03e7d3cd57a618dedf5a8caebb81ece8ee683521d5a2d4468d ./src/engine.rs
|
||||||
ae3b496749a3fd723b243d9bea92e5d76249f52814c80359ad9bac53abacb074 ./src/error.rs
|
ae3b496749a3fd723b243d9bea92e5d76249f52814c80359ad9bac53abacb074 ./src/error.rs
|
||||||
584a5dad5d52cafa0ab9d0883c501c7bc5d3126f515ace98a261a78a6d714c3a ./src/home_assistant.rs
|
584a5dad5d52cafa0ab9d0883c501c7bc5d3126f515ace98a261a78a6d714c3a ./src/home_assistant.rs
|
||||||
190b0a33431539676e5dd7796698077f16c179d42eae4501ca96a91bf797cbf8 ./src/influxdb.rs
|
190b0a33431539676e5dd7796698077f16c179d42eae4501ca96a91bf797cbf8 ./src/influxdb.rs
|
||||||
1936f06b4d9982024a676f97ce7d6ca225d5a8b13fd883d65ec100d8372b7bca ./src/main.rs
|
1936f06b4d9982024a676f97ce7d6ca225d5a8b13fd883d65ec100d8372b7bca ./src/main.rs
|
||||||
eae77ed4aed700c72a0c1e6d672ffb0dd0fd6f50d0e7f6e1f8b8ab41cbf5df42 ./src/models.rs
|
e4e823cf1b3c26de297ba60d1607f1f4ff572f489b353b226b569131c9bb915a ./src/models.rs
|
||||||
7fc31fbf8841a073a1544b8c7a6390f1a15b56087486ca0596a8418340fa232a ./src/protocol/crypto.rs
|
7fc31fbf8841a073a1544b8c7a6390f1a15b56087486ca0596a8418340fa232a ./src/protocol/crypto.rs
|
||||||
b76543136fb9c9742657a9b047629b7382d0e5d73ab468609c2be643e0681d25 ./src/protocol/gree.rs
|
b76543136fb9c9742657a9b047629b7382d0e5d73ab468609c2be643e0681d25 ./src/protocol/gree.rs
|
||||||
a910bd9432a393740c0f6fab52bfcb551f0ea756718d66d290fd2610767cf07c ./src/protocol/mod.rs
|
a910bd9432a393740c0f6fab52bfcb551f0ea756718d66d290fd2610767cf07c ./src/protocol/mod.rs
|
||||||
b6061a3325228fc5f70adb819d1b443e560d36964f98b8edb7c5bd079241348e ./src/queries.rs
|
d1cba5e55c77d44c75a9c9897ae76c327fd96bdf9c01ad27e34322b689348487 ./src/queries.rs
|
||||||
e3c02b232bd9d632ca1d3b6ad69a7cc61dc2e51954765d9e963d713d135f7dca ./src/state.rs
|
e3c02b232bd9d632ca1d3b6ad69a7cc61dc2e51954765d9e963d713d135f7dca ./src/state.rs
|
||||||
b92a6cb158b494fe145b43c7641e65f6fafff47201d7d76edbec2cfd8b94835c ./systemd/gree-controller.service
|
b92a6cb158b494fe145b43c7641e65f6fafff47201d7d76edbec2cfd8b94835c ./systemd/gree-controller.service
|
||||||
d22d55f28e01cba9f25296ce601628dcbb62d202ee233a74304c3cdaec720192 ./web/app.js
|
8577a2cf72a5751cad285a29c824b182d29b4793d4171ed94916a6e829c60b43 ./web/app.js
|
||||||
e98bdd7204349cce1ec6f57283509697af0bbc72280622a6c3efa6fed242db4f ./web/favicon.svg
|
e98bdd7204349cce1ec6f57283509697af0bbc72280622a6c3efa6fed242db4f ./web/favicon.svg
|
||||||
1180d4044630ef04ce90da4c79104bcb02848c0156aba378ad21fbd51efc006e ./web/index.html
|
4720c9abd96159ae9a6757ede9a6c5e0a2ccb4d7e37f8c2a8c16e814d3f0818a ./web/index.html
|
||||||
c0e628423a82c5037b6d650aad5e8befe30945492c8cb0a5d94433db7b58c77b ./web/manifest.webmanifest
|
c0e628423a82c5037b6d650aad5e8befe30945492c8cb0a5d94433db7b58c77b ./web/manifest.webmanifest
|
||||||
b54efeab2aa577b92df7842c9f2460a2a3ad054d83a4b4b0635ed6a21cd47cca ./web/styles.css
|
ff9b02c818474b15fbcd67105117b4ab7b21a29bca28141c9f162296324d3f70 ./web/styles.css
|
||||||
98bf333d3cb273ff7569c8985b459985ce956cbd5274ca4d48a634b578edf23b ./web/sw.js
|
e1cddac26b3a2ac6142c90661a3085d88079330aa2bacd6b23ff5164620efcdf ./web/sw.js
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
Standalone local GREE air-conditioner controller written in Rust. It runs on a regular Linux host or an LXC container and provides a mobile-first web interface without depending on the vendor cloud.
|
Standalone local GREE air-conditioner controller written in Rust. It runs on a regular Linux host or an LXC container and provides a mobile-first web interface without depending on the vendor cloud.
|
||||||
|
|
||||||
Current version: **0.5.2**.
|
Current version: **0.5.3**.
|
||||||
|
|
||||||
## Highlights
|
## Highlights
|
||||||
|
|
||||||
@@ -26,6 +26,9 @@ Current version: **0.5.2**.
|
|||||||
- REST API and WebSocket updates,
|
- REST API and WebSocket updates,
|
||||||
- responsive PWA optimized for phones,
|
- responsive PWA optimized for phones,
|
||||||
- dashboard automation-plan blocks showing current zone decisions and upcoming schedule changes,
|
- dashboard automation-plan blocks showing current zone decisions and upcoming schedule changes,
|
||||||
|
- Node-RED-style live automation flow board with sensor → thermostat → decision → unit command nodes,
|
||||||
|
- configurable night mode with quiet hours, maximum fan speed and optional GREE Quiet requests,
|
||||||
|
- configurable event-log retention and Home Assistant sensor aliases used by history charts/selectors,
|
||||||
- settings import/export and an optional live debug overlay with API logs and decrypted GREE frame payloads,
|
- settings import/export and an optional live debug overlay with API logs and decrypted GREE frame payloads,
|
||||||
- JSON-based UI localization loaded from embedded `lang/*.json` language packs,
|
- JSON-based UI localization loaded from embedded `lang/*.json` language packs,
|
||||||
- light, dark and system appearance modes stored in a browser cookie,
|
- light, dark and system appearance modes stored in a browser cookie,
|
||||||
@@ -42,7 +45,7 @@ See [`BUILD_REPORT.md`](BUILD_REPORT.md) for package validation details and [`do
|
|||||||
On Debian, Ubuntu or an LXC container:
|
On Debian, Ubuntu or an LXC container:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
unzip gree-controller-v0.5.2.zip
|
unzip gree-controller-v0.5.3.zip
|
||||||
cd gree-controller
|
cd gree-controller
|
||||||
chmod +x scripts/*.sh
|
chmod +x scripts/*.sh
|
||||||
./scripts/dev.sh
|
./scripts/dev.sh
|
||||||
|
|||||||
+11
@@ -227,3 +227,14 @@ When InfluxDB is enabled, samples are written in parallel. Maintenance also back
|
|||||||
## InfluxDB long-term storage
|
## InfluxDB long-term storage
|
||||||
|
|
||||||
Runtime settings support either InfluxDB 1.x (`version=1`, URL, database and optional username/password) or InfluxDB 2.x (`version=2`, URL, org, bucket and token). The same values can be supplied with `GREE_CONTROLLER_INFLUX_*` environment variables; see `.env.example`. An explicitly configured environment value overrides the persisted setting at startup.
|
Runtime settings support either InfluxDB 1.x (`version=1`, URL, database and optional username/password) or InfluxDB 2.x (`version=2`, URL, org, bucket and token). The same values can be supplied with `GREE_CONTROLLER_INFLUX_*` environment variables; see `.env.example`. An explicitly configured environment value overrides the persisted setting at startup.
|
||||||
|
|
||||||
|
|
||||||
|
## Event log retention
|
||||||
|
|
||||||
|
`GET /api/events/retention` returns the current event/debug log retention window. `PUT /api/events/retention` accepts `{"days":30}` (1-3650), persists it and immediately prunes older event rows. The same value is part of runtime settings as `event_log_retention_days` and can be set at startup with `GREE_CONTROLLER_EVENT_LOG_RETENTION_DAYS`.
|
||||||
|
|
||||||
|
## Night mode and sensor aliases
|
||||||
|
|
||||||
|
Runtime settings include `night_mode` with `enabled`, `start_time`, `end_time`, `max_fan_speed` (1-5) and `force_quiet`. Overnight windows crossing midnight are supported. During the window thermostat-generated fan commands are limited to the configured maximum; Auto is converted to a bounded low speed, and `Quiet` is requested only when the unit supports it.
|
||||||
|
|
||||||
|
`home_assistant.sensor_aliases` is a map of Home Assistant `entity_id` to a friendly UI name, for example `{"sensor.gabinet_temperature":"Gabinet"}`. Aliases affect labels in the controller UI/history only; metric storage and Home Assistant API requests continue to use the original entity ID.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"domain": "gree_controller",
|
"domain": "gree_controller",
|
||||||
"name": "GREE Controller",
|
"name": "GREE Controller",
|
||||||
"version": "0.5.2",
|
"version": "0.5.3",
|
||||||
"config_flow": true,
|
"config_flow": true,
|
||||||
"integration_type": "hub",
|
"integration_type": "hub",
|
||||||
"iot_class": "local_polling",
|
"iot_class": "local_polling",
|
||||||
|
|||||||
+43
-4
@@ -228,7 +228,7 @@
|
|||||||
"toast.tokenCopied": "Token copied",
|
"toast.tokenCopied": "Token copied",
|
||||||
"toast.tokenRevoked": "Token revoked",
|
"toast.tokenRevoked": "Token revoked",
|
||||||
"devices.protocolAuto": "Auto (V1 + V2)",
|
"devices.protocolAuto": "Auto (V1 + V2)",
|
||||||
"devices.rename": "Rename",
|
"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 and performs a new bind on the next request.",
|
||||||
"discovery.title": "Discover GREE devices",
|
"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.help": "Auto searches both AES-ECB and AES-GCM devices. Multiple passes improve discovery when several Wi-Fi modules answer the same broadcast.",
|
||||||
@@ -278,7 +278,7 @@
|
|||||||
"settings.outdoorEntity": "Outdoor temperature entity_id",
|
"settings.outdoorEntity": "Outdoor temperature entity_id",
|
||||||
"settings.outdoorAssist": "Use outdoor temperature as smart-control assist",
|
"settings.outdoorAssist": "Use outdoor temperature as smart-control assist",
|
||||||
"settings.allowInvalidTls": "Allow invalid/self-signed HTTPS certificate",
|
"settings.allowInvalidTls": "Allow invalid/self-signed HTTPS certificate",
|
||||||
"settings.allowInvalidTlsHint": "Use only for a trusted local Home Assistant server, for example https://10.87.65.2.",
|
"settings.allowInvalidTlsHint": "Use only for a trusted local Home Assistant server, for example https://192.168.50.25.",
|
||||||
"zones.sleepNow": "Sleep now",
|
"zones.sleepNow": "Sleep now",
|
||||||
"house.autoAll": "Auto schedule",
|
"house.autoAll": "Auto schedule",
|
||||||
"house.comfortAll": "Comfort all",
|
"house.comfortAll": "Comfort all",
|
||||||
@@ -404,7 +404,7 @@
|
|||||||
"nav.simulation": "Simulator",
|
"nav.simulation": "Simulator",
|
||||||
"simulation.eyebrow": "Simulator",
|
"simulation.eyebrow": "Simulator",
|
||||||
"simulation.title": "Automation simulator",
|
"simulation.title": "Automation simulator",
|
||||||
"simulation.description": "Preview how the thermostat and automation logic currently reason about each zone: temperatures, targets, demand, standby behavior and the next scheduled events.",
|
"simulation.description": "Live preview of the thermostat and automation logic. The flow board shows how sensor readings, schedules, night mode and thermostat decisions become commands sent to each GREE unit.",
|
||||||
"simulation.timelineEyebrow": "Timeline",
|
"simulation.timelineEyebrow": "Timeline",
|
||||||
"simulation.timelineTitle": "What will happen next",
|
"simulation.timelineTitle": "What will happen next",
|
||||||
"simulation.zoneEyebrow": "Zones",
|
"simulation.zoneEyebrow": "Zones",
|
||||||
@@ -443,6 +443,45 @@
|
|||||||
"simulation.reasonSatisfied": "Target is reached. The thermostat moves the unit into standby around {standby}°C to keep the room quiet and stable.",
|
"simulation.reasonSatisfied": "Target is reached. The thermostat moves the unit into standby around {standby}°C to keep the room quiet and stable.",
|
||||||
"simulation.activeTargetLabel": "Active target before device rounding: {value}°C",
|
"simulation.activeTargetLabel": "Active target before device rounding: {value}°C",
|
||||||
"simulation.standbyTargetLabel": "Standby target before device rounding: {value}°C",
|
"simulation.standbyTargetLabel": "Standby target before device rounding: {value}°C",
|
||||||
"common.fan": "Fan"
|
"common.fan": "Fan",
|
||||||
|
"devices.nameProtocol": "Name / protocol",
|
||||||
|
"history.range": "Range",
|
||||||
|
"settings.controllerHint": "Runtime and LAN discovery settings.",
|
||||||
|
"settings.nightMode": "Night mode",
|
||||||
|
"settings.nightModeHint": "During selected hours the thermostat limits fan speed and can request Quiet on supported units.",
|
||||||
|
"settings.nightModeEnabled": "Enable night mode",
|
||||||
|
"settings.nightStart": "Start",
|
||||||
|
"settings.nightEnd": "End",
|
||||||
|
"settings.nightMaxFan": "Maximum fan speed",
|
||||||
|
"settings.nightForceQuiet": "Use Quiet when supported",
|
||||||
|
"settings.metricsAndLogs": "Metrics and logs",
|
||||||
|
"settings.eventRetentionDays": "Event/log retention (days)",
|
||||||
|
"settings.debugHint": "Live diagnostics displayed on every application page.",
|
||||||
|
"settings.sensorAliases": "Sensor aliases",
|
||||||
|
"settings.sensorAliasesHint": "Friendly names are used in charts and selectors; the original entity_id remains unchanged.",
|
||||||
|
"settings.aliasPlaceholder": "Friendly name",
|
||||||
|
"settings.noSensorAliases": "No Home Assistant sensors are known yet. Add an entity_id below.",
|
||||||
|
"settings.aliasRequired": "Enter both entity_id and alias.",
|
||||||
|
"settings.saveHint": "Save changes made in the blocks above.",
|
||||||
|
"logs.retention": "Log retention",
|
||||||
|
"logs.retentionHint": "Old event rows are removed automatically during maintenance.",
|
||||||
|
"logs.keepFor": "Keep for",
|
||||||
|
"logs.retentionSaved": "Log retention set to {days} days.",
|
||||||
|
"simulation.flowEyebrow": "Live flow",
|
||||||
|
"simulation.flowTitle": "Automation flow board",
|
||||||
|
"simulation.legendInput": "Input",
|
||||||
|
"simulation.legendLogic": "Logic",
|
||||||
|
"simulation.legendAction": "Action",
|
||||||
|
"simulation.fanMax": "fan max",
|
||||||
|
"simulation.globalInput": "Global input",
|
||||||
|
"simulation.thermostat": "Thermostat",
|
||||||
|
"simulation.callForComfort": "Comfort demand",
|
||||||
|
"simulation.standby": "Quiet standby",
|
||||||
|
"simulation.nextEvent": "Next event",
|
||||||
|
"simulation.noEventShort": "No event",
|
||||||
|
"simulation.schedule": "Schedule",
|
||||||
|
"common.days": "days",
|
||||||
|
"actions.clear": "Clear",
|
||||||
|
"simulation.scheduled": "Scheduled"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+43
-4
@@ -228,7 +228,7 @@
|
|||||||
"toast.tokenCopied": "Token skopiowany",
|
"toast.tokenCopied": "Token skopiowany",
|
||||||
"toast.tokenRevoked": "Token unieważniony",
|
"toast.tokenRevoked": "Token unieważniony",
|
||||||
"devices.protocolAuto": "Auto (V1 + V2)",
|
"devices.protocolAuto": "Auto (V1 + V2)",
|
||||||
"devices.rename": "Zmień nazwę",
|
"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 i wykona ponowny bind przy następnym żądaniu.",
|
||||||
"discovery.title": "Wykrywanie urządzeń GREE",
|
"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.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.",
|
||||||
@@ -278,7 +278,7 @@
|
|||||||
"settings.outdoorEntity": "entity_id temperatury zewnętrznej",
|
"settings.outdoorEntity": "entity_id temperatury zewnętrznej",
|
||||||
"settings.outdoorAssist": "Używaj temperatury zewnętrznej jako wsparcia inteligentnego sterowania",
|
"settings.outdoorAssist": "Używaj temperatury zewnętrznej jako wsparcia inteligentnego sterowania",
|
||||||
"settings.allowInvalidTls": "Zezwól na nieważny/samopodpisany certyfikat HTTPS",
|
"settings.allowInvalidTls": "Zezwól na nieważny/samopodpisany certyfikat HTTPS",
|
||||||
"settings.allowInvalidTlsHint": "Używaj tylko dla zaufanego lokalnego Home Assistanta, np. https://10.87.65.2.",
|
"settings.allowInvalidTlsHint": "Używaj tylko dla zaufanego lokalnego serwera Home Assistant, np. https://192.168.50.25.",
|
||||||
"zones.sleepNow": "Sen teraz",
|
"zones.sleepNow": "Sen teraz",
|
||||||
"house.autoAll": "Auto harmonogram",
|
"house.autoAll": "Auto harmonogram",
|
||||||
"house.comfortAll": "Komfort wszędzie",
|
"house.comfortAll": "Komfort wszędzie",
|
||||||
@@ -404,7 +404,7 @@
|
|||||||
"nav.simulation": "Symulator",
|
"nav.simulation": "Symulator",
|
||||||
"simulation.eyebrow": "Symulator",
|
"simulation.eyebrow": "Symulator",
|
||||||
"simulation.title": "Symulator automatyzacji",
|
"simulation.title": "Symulator automatyzacji",
|
||||||
"simulation.description": "Podgląd pokazuje, jak logika termostatu i automatyzacji interpretuje teraz każdą strefę: temperatury, cele, zapotrzebowanie, tryb podtrzymania i kolejne zdarzenia.",
|
"simulation.description": "Podgląd na żywo logiki termostatu i automatyzacji. Pole przepływu pokazuje, jak pomiary, harmonogramy, tryb nocny i decyzje termostatu zamieniają się w komendy wysyłane do jednostek GREE.",
|
||||||
"simulation.timelineEyebrow": "Oś czasu",
|
"simulation.timelineEyebrow": "Oś czasu",
|
||||||
"simulation.timelineTitle": "Co wydarzy się dalej",
|
"simulation.timelineTitle": "Co wydarzy się dalej",
|
||||||
"simulation.zoneEyebrow": "Strefy",
|
"simulation.zoneEyebrow": "Strefy",
|
||||||
@@ -443,6 +443,45 @@
|
|||||||
"simulation.reasonSatisfied": "Cel został osiągnięty. Termostat przełącza jednostkę w podtrzymanie około {standby}°C, aby utrzymać ciszę i stabilność.",
|
"simulation.reasonSatisfied": "Cel został osiągnięty. Termostat przełącza jednostkę w podtrzymanie około {standby}°C, aby utrzymać ciszę i stabilność.",
|
||||||
"simulation.activeTargetLabel": "Aktywny cel przed zaokrągleniem: {value}°C",
|
"simulation.activeTargetLabel": "Aktywny cel przed zaokrągleniem: {value}°C",
|
||||||
"simulation.standbyTargetLabel": "Cel podtrzymania przed zaokrągleniem: {value}°C",
|
"simulation.standbyTargetLabel": "Cel podtrzymania przed zaokrągleniem: {value}°C",
|
||||||
"common.fan": "Wentylator"
|
"common.fan": "Wentylator",
|
||||||
|
"devices.nameProtocol": "Nazwa / protokół",
|
||||||
|
"history.range": "Zakres",
|
||||||
|
"settings.controllerHint": "Ustawienia pracy kontrolera i wykrywania urządzeń w LAN.",
|
||||||
|
"settings.nightMode": "Tryb nocny",
|
||||||
|
"settings.nightModeHint": "W wybranych godzinach termostat ogranicza obroty wentylatora i może włączać Quiet w obsługiwanych jednostkach.",
|
||||||
|
"settings.nightModeEnabled": "Włącz tryb nocny",
|
||||||
|
"settings.nightStart": "Początek",
|
||||||
|
"settings.nightEnd": "Koniec",
|
||||||
|
"settings.nightMaxFan": "Maksymalny bieg wentylatora",
|
||||||
|
"settings.nightForceQuiet": "Używaj Quiet, jeśli jednostka obsługuje",
|
||||||
|
"settings.metricsAndLogs": "Metryki i logi",
|
||||||
|
"settings.eventRetentionDays": "Retencja zdarzeń/logów (dni)",
|
||||||
|
"settings.debugHint": "Diagnostyka na żywo wyświetlana na każdej podstronie aplikacji.",
|
||||||
|
"settings.sensorAliases": "Aliasy czujników",
|
||||||
|
"settings.sensorAliasesHint": "Przyjazne nazwy są używane na wykresach i w wyborach; oryginalny entity_id pozostaje bez zmian.",
|
||||||
|
"settings.aliasPlaceholder": "Przyjazna nazwa",
|
||||||
|
"settings.noSensorAliases": "Nie wykryto jeszcze czujników Home Assistant. Możesz dodać entity_id poniżej.",
|
||||||
|
"settings.aliasRequired": "Podaj entity_id oraz alias.",
|
||||||
|
"settings.saveHint": "Zapisz zmiany wykonane w blokach powyżej.",
|
||||||
|
"logs.retention": "Retencja logów",
|
||||||
|
"logs.retentionHint": "Stare wpisy zdarzeń są automatycznie usuwane podczas konserwacji.",
|
||||||
|
"logs.keepFor": "Przechowuj przez",
|
||||||
|
"logs.retentionSaved": "Retencja logów ustawiona na {days} dni.",
|
||||||
|
"simulation.flowEyebrow": "Przepływ na żywo",
|
||||||
|
"simulation.flowTitle": "Schemat pracy automatyzacji",
|
||||||
|
"simulation.legendInput": "Wejście",
|
||||||
|
"simulation.legendLogic": "Logika",
|
||||||
|
"simulation.legendAction": "Akcja",
|
||||||
|
"simulation.fanMax": "maks. wentylator",
|
||||||
|
"simulation.globalInput": "Wejście globalne",
|
||||||
|
"simulation.thermostat": "Termostat",
|
||||||
|
"simulation.callForComfort": "Zapotrzebowanie",
|
||||||
|
"simulation.standby": "Ciche podtrzymanie",
|
||||||
|
"simulation.nextEvent": "Następne zdarzenie",
|
||||||
|
"simulation.noEventShort": "Brak zdarzenia",
|
||||||
|
"simulation.schedule": "Harmonogram",
|
||||||
|
"common.days": "dni",
|
||||||
|
"actions.clear": "Wyczyść",
|
||||||
|
"simulation.scheduled": "Zaplanowany"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+53
-1
@@ -9,7 +9,7 @@ use axum::{
|
|||||||
Json, Router,
|
Json, Router,
|
||||||
};
|
};
|
||||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
|
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
|
||||||
use chrono::{Duration as ChronoDuration, Utc};
|
use chrono::{Duration as ChronoDuration, NaiveTime, Utc};
|
||||||
use futures_util::StreamExt;
|
use futures_util::StreamExt;
|
||||||
use rand::{rngs::OsRng, RngCore};
|
use rand::{rngs::OsRng, RngCore};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
@@ -59,6 +59,7 @@ pub fn router(state: AppState) -> Router {
|
|||||||
.route("/api/history", get(history))
|
.route("/api/history", get(history))
|
||||||
.route("/api/control-plan", get(control_plan))
|
.route("/api/control-plan", get(control_plan))
|
||||||
.route("/api/events", get(events))
|
.route("/api/events", get(events))
|
||||||
|
.route("/api/events/retention", get(get_event_retention).put(update_event_retention))
|
||||||
.route("/api/settings", get(get_settings).put(update_settings))
|
.route("/api/settings", get(get_settings).put(update_settings))
|
||||||
.route("/api/settings/export", get(export_settings))
|
.route("/api/settings/export", get(export_settings))
|
||||||
.route("/api/settings/import", post(import_settings))
|
.route("/api/settings/import", post(import_settings))
|
||||||
@@ -1060,6 +1061,27 @@ async fn events(State(state): State<AppState>, Query(query): Query<EventsQuery>)
|
|||||||
Ok(Json(json!({"events": state.db.list_events(query.limit.unwrap_or(100))?})))
|
Ok(Json(json!({"events": state.db.list_events(query.limit.unwrap_or(100))?})))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct EventRetentionInput { days: u32 }
|
||||||
|
|
||||||
|
async fn get_event_retention(State(state): State<AppState>) -> Json<Value> {
|
||||||
|
let days = state.settings.read().await.event_log_retention_days;
|
||||||
|
Json(json!({"days": days}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update_event_retention(State(state): State<AppState>, Json(input): Json<EventRetentionInput>) -> Result<Json<Value>, AppError> {
|
||||||
|
let mut settings = state.settings.write().await;
|
||||||
|
settings.event_log_retention_days = input.days.clamp(1, 3650);
|
||||||
|
state.db.save_runtime_settings(&settings)?;
|
||||||
|
let days = settings.event_log_retention_days;
|
||||||
|
drop(settings);
|
||||||
|
let removed = state.db.prune_events(days as i64)?;
|
||||||
|
state.log("info", "events.retention_updated", "Event log retention updated", json!({"days": days, "removed": removed}));
|
||||||
|
let public = { let settings = state.settings.read().await; public_settings(&*settings) };
|
||||||
|
state.broadcast("settings.updated", public);
|
||||||
|
Ok(Json(json!({"days": days, "removed": removed})))
|
||||||
|
}
|
||||||
|
|
||||||
async fn get_settings(State(state): State<AppState>) -> Json<Value> {
|
async fn get_settings(State(state): State<AppState>) -> Json<Value> {
|
||||||
let settings = state.settings.read().await;
|
let settings = state.settings.read().await;
|
||||||
Json(public_settings(&*settings))
|
Json(public_settings(&*settings))
|
||||||
@@ -1080,6 +1102,9 @@ async fn update_settings(State(state): State<AppState>, Json(mut input): Json<Ru
|
|||||||
if input.controller_id.trim().is_empty() { input.controller_id = old.controller_id; }
|
if input.controller_id.trim().is_empty() { input.controller_id = old.controller_id; }
|
||||||
if input.home_assistant.token.trim().is_empty() { input.home_assistant.token = old.home_assistant.token; }
|
if input.home_assistant.token.trim().is_empty() { input.home_assistant.token = old.home_assistant.token; }
|
||||||
input.history_retention_days = input.history_retention_days.clamp(1, 3650);
|
input.history_retention_days = input.history_retention_days.clamp(1, 3650);
|
||||||
|
input.event_log_retention_days = input.event_log_retention_days.clamp(1, 3650);
|
||||||
|
normalize_sensor_aliases(&mut input);
|
||||||
|
validate_night_mode(&mut input)?;
|
||||||
input.influxdb.history_threshold_days = input.influxdb.history_threshold_days.clamp(1, 3650);
|
input.influxdb.history_threshold_days = input.influxdb.history_threshold_days.clamp(1, 3650);
|
||||||
if input.influxdb.token.trim().is_empty() { input.influxdb.token = old.influxdb.token; }
|
if input.influxdb.token.trim().is_empty() { input.influxdb.token = old.influxdb.token; }
|
||||||
if input.influxdb.password.trim().is_empty() { input.influxdb.password = old.influxdb.password; }
|
if input.influxdb.password.trim().is_empty() { input.influxdb.password = old.influxdb.password; }
|
||||||
@@ -1096,6 +1121,27 @@ async fn update_settings(State(state): State<AppState>, Json(mut input): Json<Ru
|
|||||||
Ok(Json(public_settings(&input)))
|
Ok(Json(public_settings(&input)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn normalize_sensor_aliases(settings: &mut RuntimeSettings) {
|
||||||
|
settings.home_assistant.sensor_aliases = settings.home_assistant.sensor_aliases
|
||||||
|
.iter()
|
||||||
|
.filter_map(|(entity, alias)| {
|
||||||
|
let entity = entity.trim();
|
||||||
|
let alias = alias.trim();
|
||||||
|
if entity.is_empty() || alias.is_empty() { return None; }
|
||||||
|
Some((entity.chars().take(160).collect::<String>(), alias.chars().take(80).collect::<String>()))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_night_mode(settings: &mut RuntimeSettings) -> Result<(), AppError> {
|
||||||
|
NaiveTime::parse_from_str(&settings.night_mode.start_time, "%H:%M")
|
||||||
|
.map_err(|_| AppError::BadRequest("night mode start time must use HH:MM".into()))?;
|
||||||
|
NaiveTime::parse_from_str(&settings.night_mode.end_time, "%H:%M")
|
||||||
|
.map_err(|_| AppError::BadRequest("night mode end time must use HH:MM".into()))?;
|
||||||
|
settings.night_mode.max_fan_speed = settings.night_mode.max_fan_speed.clamp(1, 5);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
async fn export_settings(State(state): State<AppState>) -> Result<Json<ConfigurationExport>, AppError> {
|
async fn export_settings(State(state): State<AppState>) -> Result<Json<ConfigurationExport>, AppError> {
|
||||||
let settings = state.settings.read().await.clone();
|
let settings = state.settings.read().await.clone();
|
||||||
Ok(Json(state.db.export_configuration(settings)?))
|
Ok(Json(state.db.export_configuration(settings)?))
|
||||||
@@ -1124,6 +1170,9 @@ fn validate_configuration_export(export: &ConfigurationExport) -> Result<(), App
|
|||||||
async fn import_settings(State(state): State<AppState>, Json(mut export): Json<ConfigurationExport>) -> Result<Json<Value>, AppError> {
|
async fn import_settings(State(state): State<AppState>, Json(mut export): Json<ConfigurationExport>) -> Result<Json<Value>, AppError> {
|
||||||
validate_configuration_export(&export)?;
|
validate_configuration_export(&export)?;
|
||||||
export.settings.history_retention_days = export.settings.history_retention_days.clamp(1, 3650);
|
export.settings.history_retention_days = export.settings.history_retention_days.clamp(1, 3650);
|
||||||
|
export.settings.event_log_retention_days = export.settings.event_log_retention_days.clamp(1, 3650);
|
||||||
|
normalize_sensor_aliases(&mut export.settings);
|
||||||
|
validate_night_mode(&mut export.settings)?;
|
||||||
export.settings.influxdb.history_threshold_days = export.settings.influxdb.history_threshold_days.clamp(1, 3650);
|
export.settings.influxdb.history_threshold_days = export.settings.influxdb.history_threshold_days.clamp(1, 3650);
|
||||||
state.db.replace_configuration(&export)?;
|
state.db.replace_configuration(&export)?;
|
||||||
state.debug_gree_frames.store(export.settings.debug.gree_frames, Ordering::Relaxed);
|
state.debug_gree_frames.store(export.settings.debug.gree_frames, Ordering::Relaxed);
|
||||||
@@ -1216,8 +1265,10 @@ fn public_settings(settings: &RuntimeSettings) -> Value {
|
|||||||
"outdoor_assist_enabled": settings.outdoor_assist_enabled,
|
"outdoor_assist_enabled": settings.outdoor_assist_enabled,
|
||||||
"history_retention_days": settings.history_retention_days,
|
"history_retention_days": settings.history_retention_days,
|
||||||
"history_compaction_enabled": settings.history_compaction_enabled,
|
"history_compaction_enabled": settings.history_compaction_enabled,
|
||||||
|
"event_log_retention_days": settings.event_log_retention_days,
|
||||||
"suppress_device_beep": settings.suppress_device_beep,
|
"suppress_device_beep": settings.suppress_device_beep,
|
||||||
"debug": settings.debug,
|
"debug": settings.debug,
|
||||||
|
"night_mode": settings.night_mode,
|
||||||
"influxdb": {
|
"influxdb": {
|
||||||
"enabled": settings.influxdb.enabled,
|
"enabled": settings.influxdb.enabled,
|
||||||
"version": settings.influxdb.version,
|
"version": settings.influxdb.version,
|
||||||
@@ -1239,6 +1290,7 @@ fn public_settings(settings: &RuntimeSettings) -> Value {
|
|||||||
"default_entity_id": settings.home_assistant.default_entity_id,
|
"default_entity_id": settings.home_assistant.default_entity_id,
|
||||||
"outdoor_entity_id": settings.home_assistant.outdoor_entity_id,
|
"outdoor_entity_id": settings.home_assistant.outdoor_entity_id,
|
||||||
"allow_invalid_tls": settings.home_assistant.allow_invalid_tls,
|
"allow_invalid_tls": settings.home_assistant.allow_invalid_tls,
|
||||||
|
"sensor_aliases": settings.home_assistant.sensor_aliases,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-1
@@ -1,7 +1,7 @@
|
|||||||
use std::{env, net::SocketAddr, path::PathBuf};
|
use std::{env, net::SocketAddr, path::PathBuf};
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use crate::models::{DebugSettings, HomeAssistantSettings, InfluxDbSettings, RuntimeSettings};
|
use crate::models::{DebugSettings, HomeAssistantSettings, InfluxDbSettings, NightModeSettings, RuntimeSettings};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Parser)]
|
#[derive(Debug, Clone, Parser)]
|
||||||
#[command(author, version, about)]
|
#[command(author, version, about)]
|
||||||
@@ -54,12 +54,20 @@ impl Config {
|
|||||||
outdoor_assist_enabled: env_bool("GREE_CONTROLLER_OUTDOOR_ASSIST_ENABLED").unwrap_or(true),
|
outdoor_assist_enabled: env_bool("GREE_CONTROLLER_OUTDOOR_ASSIST_ENABLED").unwrap_or(true),
|
||||||
history_retention_days: env_u32("GREE_CONTROLLER_HISTORY_RETENTION_DAYS").unwrap_or(30).clamp(1, 3650),
|
history_retention_days: env_u32("GREE_CONTROLLER_HISTORY_RETENTION_DAYS").unwrap_or(30).clamp(1, 3650),
|
||||||
history_compaction_enabled: env_bool("GREE_CONTROLLER_HISTORY_COMPACTION_ENABLED").unwrap_or(true),
|
history_compaction_enabled: env_bool("GREE_CONTROLLER_HISTORY_COMPACTION_ENABLED").unwrap_or(true),
|
||||||
|
event_log_retention_days: env_u32("GREE_CONTROLLER_EVENT_LOG_RETENTION_DAYS").unwrap_or(30).clamp(1, 3650),
|
||||||
suppress_device_beep: env_bool("GREE_CONTROLLER_SUPPRESS_DEVICE_BEEP").unwrap_or(false),
|
suppress_device_beep: env_bool("GREE_CONTROLLER_SUPPRESS_DEVICE_BEEP").unwrap_or(false),
|
||||||
influxdb: influx_settings_from_env(),
|
influxdb: influx_settings_from_env(),
|
||||||
debug: DebugSettings {
|
debug: DebugSettings {
|
||||||
overlay_enabled: env_bool("GREE_CONTROLLER_DEBUG_OVERLAY").unwrap_or(false),
|
overlay_enabled: env_bool("GREE_CONTROLLER_DEBUG_OVERLAY").unwrap_or(false),
|
||||||
gree_frames: env_bool("GREE_CONTROLLER_DEBUG_GREE_FRAMES").unwrap_or(false),
|
gree_frames: env_bool("GREE_CONTROLLER_DEBUG_GREE_FRAMES").unwrap_or(false),
|
||||||
},
|
},
|
||||||
|
night_mode: NightModeSettings {
|
||||||
|
enabled: env_bool("GREE_CONTROLLER_NIGHT_MODE_ENABLED").unwrap_or(false),
|
||||||
|
start_time: env::var("GREE_CONTROLLER_NIGHT_MODE_START").unwrap_or_else(|_| "22:00".into()),
|
||||||
|
end_time: env::var("GREE_CONTROLLER_NIGHT_MODE_END").unwrap_or_else(|_| "06:00".into()),
|
||||||
|
max_fan_speed: env_u8("GREE_CONTROLLER_NIGHT_MODE_MAX_FAN_SPEED").unwrap_or(1).clamp(1, 5),
|
||||||
|
force_quiet: env_bool("GREE_CONTROLLER_NIGHT_MODE_FORCE_QUIET").unwrap_or(true),
|
||||||
|
},
|
||||||
home_assistant: HomeAssistantSettings {
|
home_assistant: HomeAssistantSettings {
|
||||||
url: env::var("HA_URL").unwrap_or_default(),
|
url: env::var("HA_URL").unwrap_or_default(),
|
||||||
token: env::var("HA_TOKEN").unwrap_or_default(),
|
token: env::var("HA_TOKEN").unwrap_or_default(),
|
||||||
@@ -68,6 +76,7 @@ impl Config {
|
|||||||
allow_invalid_tls: env::var("HA_ALLOW_INVALID_TLS")
|
allow_invalid_tls: env::var("HA_ALLOW_INVALID_TLS")
|
||||||
.map(|v| matches!(v.to_ascii_lowercase().as_str(), "1" | "true" | "yes" | "on"))
|
.map(|v| matches!(v.to_ascii_lowercase().as_str(), "1" | "true" | "yes" | "on"))
|
||||||
.unwrap_or(false),
|
.unwrap_or(false),
|
||||||
|
sensor_aliases: Default::default(),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -78,9 +87,17 @@ impl Config {
|
|||||||
settings.history_retention_days = env_u32("GREE_CONTROLLER_HISTORY_RETENTION_DAYS").unwrap_or(settings.history_retention_days).clamp(1, 3650);
|
settings.history_retention_days = env_u32("GREE_CONTROLLER_HISTORY_RETENTION_DAYS").unwrap_or(settings.history_retention_days).clamp(1, 3650);
|
||||||
}
|
}
|
||||||
if let Some(value) = env_bool("GREE_CONTROLLER_HISTORY_COMPACTION_ENABLED") { settings.history_compaction_enabled = value; }
|
if let Some(value) = env_bool("GREE_CONTROLLER_HISTORY_COMPACTION_ENABLED") { settings.history_compaction_enabled = value; }
|
||||||
|
if env::var_os("GREE_CONTROLLER_EVENT_LOG_RETENTION_DAYS").is_some() {
|
||||||
|
settings.event_log_retention_days = env_u32("GREE_CONTROLLER_EVENT_LOG_RETENTION_DAYS").unwrap_or(settings.event_log_retention_days).clamp(1, 3650);
|
||||||
|
}
|
||||||
if let Some(value) = env_bool("GREE_CONTROLLER_SUPPRESS_DEVICE_BEEP") { settings.suppress_device_beep = value; }
|
if let Some(value) = env_bool("GREE_CONTROLLER_SUPPRESS_DEVICE_BEEP") { settings.suppress_device_beep = value; }
|
||||||
if let Some(value) = env_bool("GREE_CONTROLLER_DEBUG_OVERLAY") { settings.debug.overlay_enabled = value; }
|
if let Some(value) = env_bool("GREE_CONTROLLER_DEBUG_OVERLAY") { settings.debug.overlay_enabled = value; }
|
||||||
if let Some(value) = env_bool("GREE_CONTROLLER_DEBUG_GREE_FRAMES") { settings.debug.gree_frames = value; }
|
if let Some(value) = env_bool("GREE_CONTROLLER_DEBUG_GREE_FRAMES") { settings.debug.gree_frames = value; }
|
||||||
|
if let Some(value) = env_bool("GREE_CONTROLLER_NIGHT_MODE_ENABLED") { settings.night_mode.enabled = value; }
|
||||||
|
if let Ok(value) = env::var("GREE_CONTROLLER_NIGHT_MODE_START") { if !value.trim().is_empty() { settings.night_mode.start_time = value; } }
|
||||||
|
if let Ok(value) = env::var("GREE_CONTROLLER_NIGHT_MODE_END") { if !value.trim().is_empty() { settings.night_mode.end_time = value; } }
|
||||||
|
if let Some(value) = env_u8("GREE_CONTROLLER_NIGHT_MODE_MAX_FAN_SPEED") { settings.night_mode.max_fan_speed = value.clamp(1, 5); }
|
||||||
|
if let Some(value) = env_bool("GREE_CONTROLLER_NIGHT_MODE_FORCE_QUIET") { settings.night_mode.force_quiet = value; }
|
||||||
|
|
||||||
let influx_env_present = [
|
let influx_env_present = [
|
||||||
"GREE_CONTROLLER_INFLUX_ENABLED", "GREE_CONTROLLER_INFLUX_VERSION", "GREE_CONTROLLER_INFLUX_URL",
|
"GREE_CONTROLLER_INFLUX_ENABLED", "GREE_CONTROLLER_INFLUX_VERSION", "GREE_CONTROLLER_INFLUX_URL",
|
||||||
@@ -114,6 +131,7 @@ fn env_bool(name: &str) -> Option<bool> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn env_u32(name: &str) -> Option<u32> { env::var(name).ok()?.parse().ok() }
|
fn env_u32(name: &str) -> Option<u32> { env::var(name).ok()?.parse().ok() }
|
||||||
|
fn env_u8(name: &str) -> Option<u8> { env::var(name).ok()?.parse().ok() }
|
||||||
|
|
||||||
fn first_env(names: &[&str]) -> Option<String> {
|
fn first_env(names: &[&str]) -> Option<String> {
|
||||||
names.iter().find_map(|name| {
|
names.iter().find_map(|name| {
|
||||||
|
|||||||
@@ -469,6 +469,12 @@ impl Db {
|
|||||||
rows.collect::<std::result::Result<Vec<_>, _>>().map_err(Into::into)
|
rows.collect::<std::result::Result<Vec<_>, _>>().map_err(Into::into)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn prune_events(&self, retention_days: i64) -> Result<u64> {
|
||||||
|
let before = Utc::now() - Duration::days(retention_days.max(1));
|
||||||
|
let conn = self.lock()?;
|
||||||
|
Ok(conn.execute(queries::PRUNE_EVENTS, [before.to_rfc3339()])? as u64)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn list_api_tokens(&self) -> Result<Vec<ApiTokenInfo>> {
|
pub fn list_api_tokens(&self) -> Result<Vec<ApiTokenInfo>> {
|
||||||
let conn = self.lock()?;
|
let conn = self.lock()?;
|
||||||
let mut stmt = conn.prepare(queries::LIST_API_TOKENS)?;
|
let mut stmt = conn.prepare(queries::LIST_API_TOKENS)?;
|
||||||
@@ -601,6 +607,12 @@ mod tests {
|
|||||||
assert_eq!(db.list_devices().unwrap().len(), 1);
|
assert_eq!(db.list_devices().unwrap().len(), 1);
|
||||||
db.log_event("info", "test", "ok", &serde_json::json!({"a":1})).unwrap();
|
db.log_event("info", "test", "ok", &serde_json::json!({"a":1})).unwrap();
|
||||||
assert_eq!(db.list_events(10).unwrap().len(), 1);
|
assert_eq!(db.list_events(10).unwrap().len(), 1);
|
||||||
|
{
|
||||||
|
let conn = db.lock().unwrap();
|
||||||
|
conn.execute(queries::INSERT_EVENT, rusqlite::params![(Utc::now() - Duration::days(40)).to_rfc3339(), "info", "old", "old", "{}"] ).unwrap();
|
||||||
|
}
|
||||||
|
assert_eq!(db.prune_events(30).unwrap(), 1);
|
||||||
|
assert_eq!(db.list_events(10).unwrap().len(), 1);
|
||||||
|
|
||||||
let access_token = ApiTokenInfo {
|
let access_token = ApiTokenInfo {
|
||||||
id: "token-1".into(),
|
id: "token-1".into(),
|
||||||
|
|||||||
+100
-11
@@ -7,7 +7,7 @@ use crate::{
|
|||||||
error::AppError,
|
error::AppError,
|
||||||
home_assistant,
|
home_assistant,
|
||||||
influxdb,
|
influxdb,
|
||||||
models::{Automation, AutomationPlanRule, ControlPlan, ControlPlanEvent, Device, DeviceCommand, HaReading, Reading, Schedule, Zone, ZoneControlPlan, ZoneReading},
|
models::{Automation, AutomationPlanRule, ControlPlan, ControlPlanEvent, Device, DeviceCommand, HaReading, NightModeSettings, Reading, Schedule, Zone, ZoneControlPlan, ZoneReading},
|
||||||
state::AppState,
|
state::AppState,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -68,6 +68,12 @@ pub fn start(state: AppState) {
|
|||||||
Err(err) => tracing::warn!(error=?err, "cannot prune readings"),
|
Err(err) => tracing::warn!(error=?err, "cannot prune readings"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
let event_retention_days = settings.event_log_retention_days.max(1) as i64;
|
||||||
|
match maintenance_state.db.prune_events(event_retention_days) {
|
||||||
|
Ok(count) if count > 0 => tracing::info!(count, event_retention_days, "old event log rows pruned"),
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(err) => tracing::warn!(error=?err, "cannot prune event log"),
|
||||||
|
}
|
||||||
sleep(Duration::from_secs(6 * 60 * 60)).await;
|
sleep(Duration::from_secs(6 * 60 * 60)).await;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -338,6 +344,7 @@ async fn control_zones(state: &AppState) -> Result<()> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
let outdoor_assist_temperature = if settings.outdoor_assist_enabled { outdoor_temperature } else { None };
|
let outdoor_assist_temperature = if settings.outdoor_assist_enabled { outdoor_temperature } else { None };
|
||||||
|
let night_active = night_mode_active(&settings.night_mode, Local::now().time());
|
||||||
|
|
||||||
for mut zone in state.db.list_zones()? {
|
for mut zone in state.db.list_zones()? {
|
||||||
if !zone.enabled { continue; }
|
if !zone.enabled { continue; }
|
||||||
@@ -459,21 +466,36 @@ async fn control_zones(state: &AppState) -> Result<()> {
|
|||||||
let desired_device_target = round_device_setpoint(effective_mode, zone.demand, if zone.demand { active_target } else { standby_target });
|
let desired_device_target = round_device_setpoint(effective_mode, zone.demand, if zone.demand { active_target } else { standby_target });
|
||||||
zone.device_setpoint = Some(desired_device_target);
|
zone.device_setpoint = Some(desired_device_target);
|
||||||
|
|
||||||
let desired_fan = if zone.smart_fan {
|
let desired_fan = if night_active {
|
||||||
|
let max_fan = settings.night_mode.max_fan_speed.clamp(1, 5);
|
||||||
|
if zone.smart_fan {
|
||||||
|
Some(night_limited_fan_speed(
|
||||||
|
smart_fan_speed(effective_mode, temp, target, outdoor_assist_temperature, zone.demand),
|
||||||
|
max_fan,
|
||||||
|
))
|
||||||
|
} else if device.fan_speed == 0 || device.fan_speed > max_fan {
|
||||||
|
Some(max_fan)
|
||||||
|
} else {
|
||||||
|
Some(device.fan_speed)
|
||||||
|
}
|
||||||
|
} else if zone.smart_fan {
|
||||||
Some(smart_fan_speed(effective_mode, temp, target, outdoor_assist_temperature, zone.demand))
|
Some(smart_fan_speed(effective_mode, temp, target, outdoor_assist_temperature, zone.demand))
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
// When the room becomes satisfied, ask compatible units for Quiet in the same
|
// When the room becomes satisfied, ask compatible units for Quiet in the same
|
||||||
// frame as the standby setpoint and Low fan. When demand returns, disable Quiet
|
// frame as the standby setpoint and Low fan. When demand returns, disable Quiet
|
||||||
// only on that transition so a user's manual Quiet choice is not constantly
|
// on the normal smart-fan transition. When scheduled night mode owns Quiet, it
|
||||||
// overwritten while the zone is actively heating/cooling.
|
// explicitly enables it inside the window and releases it outside the window.
|
||||||
let desired_quiet = smart_quiet_command(
|
let desired_quiet = smart_quiet_command(
|
||||||
zone.smart_fan,
|
zone.smart_fan,
|
||||||
state.gree.quiet_command_supported(&device.id),
|
state.gree.quiet_command_supported(&device.id),
|
||||||
previous_demand,
|
previous_demand,
|
||||||
zone.demand,
|
zone.demand,
|
||||||
device.quiet,
|
device.quiet,
|
||||||
|
settings.night_mode.enabled,
|
||||||
|
night_active,
|
||||||
|
settings.night_mode.force_quiet,
|
||||||
);
|
);
|
||||||
|
|
||||||
let needs_command = !device.power
|
let needs_command = !device.power
|
||||||
@@ -505,6 +527,7 @@ async fn control_zones(state: &AppState) -> Result<()> {
|
|||||||
"outdoor_temperature": outdoor_temperature,
|
"outdoor_temperature": outdoor_temperature,
|
||||||
"fan_speed": updated_device.fan_speed,
|
"fan_speed": updated_device.fan_speed,
|
||||||
"quiet": updated_device.quiet,
|
"quiet": updated_device.quiet,
|
||||||
|
"night_mode": night_active,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
Err(err) => state.log("error", "zone.action_error", &err.to_string(), json!({"zone_id": zone.id})),
|
Err(err) => state.log("error", "zone.action_error", &err.to_string(), json!({"zone_id": zone.id})),
|
||||||
@@ -669,13 +692,34 @@ fn smart_quiet_command(
|
|||||||
previous_demand: bool,
|
previous_demand: bool,
|
||||||
demand: bool,
|
demand: bool,
|
||||||
device_quiet: bool,
|
device_quiet: bool,
|
||||||
|
night_enabled: bool,
|
||||||
|
night_active: bool,
|
||||||
|
night_force_quiet: bool,
|
||||||
) -> Option<bool> {
|
) -> Option<bool> {
|
||||||
if !smart_fan || !quiet_supported { return None; }
|
if !quiet_supported { return None; }
|
||||||
|
if night_enabled && night_force_quiet {
|
||||||
|
if night_active { return Some(true); }
|
||||||
|
if device_quiet { return Some(false); }
|
||||||
|
}
|
||||||
|
if !smart_fan { return None; }
|
||||||
if !demand { return Some(true); }
|
if !demand { return Some(true); }
|
||||||
if !previous_demand && device_quiet { return Some(false); }
|
if !previous_demand && device_quiet { return Some(false); }
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn night_limited_fan_speed(requested: u8, max_fan: u8) -> u8 {
|
||||||
|
let max_fan = max_fan.clamp(1, 5);
|
||||||
|
if requested == 0 { 1 } else { requested.min(max_fan) }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn night_mode_active(settings: &NightModeSettings, time: NaiveTime) -> bool {
|
||||||
|
if !settings.enabled { return false; }
|
||||||
|
let Ok(start) = NaiveTime::parse_from_str(&settings.start_time, "%H:%M") else { return false; };
|
||||||
|
let Ok(end) = NaiveTime::parse_from_str(&settings.end_time, "%H:%M") else { return false; };
|
||||||
|
if start == end { return true; }
|
||||||
|
if start < end { time >= start && time < end } else { time >= start || time < end }
|
||||||
|
}
|
||||||
|
|
||||||
fn smart_fan_speed(mode: &str, room: f64, target: f64, outdoor: Option<f64>, demand: bool) -> u8 {
|
fn smart_fan_speed(mode: &str, room: f64, target: f64, outdoor: Option<f64>, demand: bool) -> u8 {
|
||||||
// When the thermostat is satisfied, keep airflow quiet instead of leaving the
|
// When the thermostat is satisfied, keep airflow quiet instead of leaving the
|
||||||
// unit in Auto. The caller sends this together with the standby setpoint in
|
// unit in Auto. The caller sends this together with the standby setpoint in
|
||||||
@@ -777,8 +821,9 @@ pub async fn build_control_plan(state: &AppState) -> Result<ControlPlan, AppErro
|
|||||||
let schedules = state.db.list_schedules()?;
|
let schedules = state.db.list_schedules()?;
|
||||||
let devices = state.db.list_devices()?;
|
let devices = state.db.list_devices()?;
|
||||||
let now = Local::now();
|
let now = Local::now();
|
||||||
|
let night_active = night_mode_active(&settings.night_mode, now.time());
|
||||||
let mut zones_out = Vec::new();
|
let mut zones_out = Vec::new();
|
||||||
let mut house_events = Vec::new();
|
let mut house_events = next_night_mode_events(&settings.night_mode, now, 2);
|
||||||
|
|
||||||
for zone in state.db.list_zones()? {
|
for zone in state.db.list_zones()? {
|
||||||
let device = devices.iter().find(|item| item.id == zone.device_id);
|
let device = devices.iter().find(|item| item.id == zone.device_id);
|
||||||
@@ -856,6 +901,10 @@ pub async fn build_control_plan(state: &AppState) -> Result<ControlPlan, AppErro
|
|||||||
house_mode: settings.house_mode,
|
house_mode: settings.house_mode,
|
||||||
outdoor_temperature: *state.outdoor_temperature.read().await,
|
outdoor_temperature: *state.outdoor_temperature.read().await,
|
||||||
control_strategy: settings.control_strategy,
|
control_strategy: settings.control_strategy,
|
||||||
|
night_mode_active: night_active,
|
||||||
|
night_mode_start: settings.night_mode.start_time,
|
||||||
|
night_mode_end: settings.night_mode.end_time,
|
||||||
|
night_mode_max_fan_speed: settings.night_mode.max_fan_speed.clamp(1, 5),
|
||||||
next_events: house_events,
|
next_events: house_events,
|
||||||
zones: zones_out,
|
zones: zones_out,
|
||||||
rules,
|
rules,
|
||||||
@@ -863,6 +912,34 @@ pub async fn build_control_plan(state: &AppState) -> Result<ControlPlan, AppErro
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
fn next_night_mode_events(settings: &NightModeSettings, now: DateTime<Local>, limit: usize) -> Vec<ControlPlanEvent> {
|
||||||
|
if !settings.enabled || limit == 0 { return Vec::new(); }
|
||||||
|
let Ok(start) = NaiveTime::parse_from_str(&settings.start_time, "%H:%M") else { return Vec::new(); };
|
||||||
|
let Ok(end) = NaiveTime::parse_from_str(&settings.end_time, "%H:%M") else { return Vec::new(); };
|
||||||
|
let mut events = Vec::new();
|
||||||
|
for minute in 1..=(48 * 60) {
|
||||||
|
let candidate = now + chrono::Duration::minutes(minute);
|
||||||
|
let time = candidate.time();
|
||||||
|
let (kind, label) = if time.hour() == start.hour() && time.minute() == start.minute() {
|
||||||
|
let quiet = if settings.force_quiet { " + Quiet" } else { "" };
|
||||||
|
("night_mode_start", format!("Night mode -> fan max {}{}", settings.max_fan_speed.clamp(1, 5), quiet))
|
||||||
|
} else if time.hour() == end.hour() && time.minute() == end.minute() {
|
||||||
|
("night_mode_end", "Night mode ends".to_string())
|
||||||
|
} else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
events.push(ControlPlanEvent {
|
||||||
|
at: candidate.with_timezone(&Utc),
|
||||||
|
kind: kind.into(),
|
||||||
|
label,
|
||||||
|
preset: None,
|
||||||
|
target_temperature: None,
|
||||||
|
});
|
||||||
|
if events.len() >= limit { break; }
|
||||||
|
}
|
||||||
|
events
|
||||||
|
}
|
||||||
|
|
||||||
fn next_time_automation_event(item: &Automation, action_name: &str, now: DateTime<Local>) -> Option<ControlPlanEvent> {
|
fn next_time_automation_event(item: &Automation, action_name: &str, now: DateTime<Local>) -> Option<ControlPlanEvent> {
|
||||||
let expected = NaiveTime::parse_from_str(item.at_time.as_deref()?, "%H:%M").ok()?;
|
let expected = NaiveTime::parse_from_str(item.at_time.as_deref()?, "%H:%M").ok()?;
|
||||||
for minute in 1..=(24 * 60) {
|
for minute in 1..=(24 * 60) {
|
||||||
@@ -1070,11 +1147,23 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn smart_quiet_follows_satisfied_transition_only_when_supported() {
|
fn smart_quiet_follows_satisfied_transition_only_when_supported() {
|
||||||
assert_eq!(smart_quiet_command(true, true, true, false, false), Some(true));
|
assert_eq!(smart_quiet_command(true, true, true, false, false, false, false, true), Some(true));
|
||||||
assert_eq!(smart_quiet_command(true, true, false, true, true), Some(false));
|
assert_eq!(smart_quiet_command(true, true, false, true, true, false, false, true), Some(false));
|
||||||
assert_eq!(smart_quiet_command(true, true, true, true, true), None);
|
assert_eq!(smart_quiet_command(true, true, true, true, true, false, false, true), None);
|
||||||
assert_eq!(smart_quiet_command(true, false, true, false, false), None);
|
assert_eq!(smart_quiet_command(true, false, true, false, false, false, false, true), None);
|
||||||
assert_eq!(smart_quiet_command(false, true, true, false, false), None);
|
assert_eq!(smart_quiet_command(false, true, true, false, false, false, false, true), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn night_mode_handles_midnight_and_limits_auto_fan() {
|
||||||
|
let settings = NightModeSettings { enabled: true, start_time: "22:00".into(), end_time: "06:00".into(), max_fan_speed: 1, force_quiet: true };
|
||||||
|
assert!(night_mode_active(&settings, NaiveTime::from_hms_opt(23, 30, 0).unwrap()));
|
||||||
|
assert!(night_mode_active(&settings, NaiveTime::from_hms_opt(5, 59, 0).unwrap()));
|
||||||
|
assert!(!night_mode_active(&settings, NaiveTime::from_hms_opt(12, 0, 0).unwrap()));
|
||||||
|
assert_eq!(night_limited_fan_speed(0, 1), 1);
|
||||||
|
assert_eq!(night_limited_fan_speed(3, 1), 1);
|
||||||
|
assert_eq!(smart_quiet_command(false, true, true, true, false, true, true, true), Some(true));
|
||||||
|
assert_eq!(smart_quiet_command(false, true, true, true, true, true, false, true), Some(false));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use std::collections::BTreeMap;
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
@@ -27,9 +28,13 @@ fn default_heat_comfort() -> f64 { 21.0 }
|
|||||||
fn default_heat_sleep() -> f64 { 19.0 }
|
fn default_heat_sleep() -> f64 { 19.0 }
|
||||||
fn default_heat_away() -> f64 { 17.0 }
|
fn default_heat_away() -> f64 { 17.0 }
|
||||||
fn default_history_retention_days() -> u32 { 30 }
|
fn default_history_retention_days() -> u32 { 30 }
|
||||||
|
fn default_event_log_retention_days() -> u32 { 30 }
|
||||||
fn default_influx_version() -> String { "2".into() }
|
fn default_influx_version() -> String { "2".into() }
|
||||||
fn default_influx_database() -> String { "gree_controller".into() }
|
fn default_influx_database() -> String { "gree_controller".into() }
|
||||||
fn default_influx_threshold_days() -> u32 { 30 }
|
fn default_influx_threshold_days() -> u32 { 30 }
|
||||||
|
fn default_night_start() -> String { "22:00".into() }
|
||||||
|
fn default_night_end() -> String { "06:00".into() }
|
||||||
|
fn default_night_max_fan_speed() -> u8 { 1 }
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct Device {
|
pub struct Device {
|
||||||
@@ -409,6 +414,9 @@ pub struct HomeAssistantSettings {
|
|||||||
/// Accept self-signed/expired certificates for local Home Assistant HTTPS.
|
/// Accept self-signed/expired certificates for local Home Assistant HTTPS.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub allow_invalid_tls: bool,
|
pub allow_invalid_tls: bool,
|
||||||
|
/// Friendly labels used only by the controller UI/charts; entity_id remains the storage key.
|
||||||
|
#[serde(default)]
|
||||||
|
pub sensor_aliases: BTreeMap<String, String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -465,6 +473,34 @@ pub struct DebugSettings {
|
|||||||
pub gree_frames: bool,
|
pub gree_frames: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct NightModeSettings {
|
||||||
|
#[serde(default)]
|
||||||
|
pub enabled: bool,
|
||||||
|
#[serde(default = "default_night_start")]
|
||||||
|
pub start_time: String,
|
||||||
|
#[serde(default = "default_night_end")]
|
||||||
|
pub end_time: String,
|
||||||
|
/// Maximum fan speed used by thermostat control during night hours (1=Low..5=High).
|
||||||
|
#[serde(default = "default_night_max_fan_speed")]
|
||||||
|
pub max_fan_speed: u8,
|
||||||
|
/// Ask compatible GREE units to keep Quiet enabled during the whole night window.
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub force_quiet: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for NightModeSettings {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
enabled: false,
|
||||||
|
start_time: default_night_start(),
|
||||||
|
end_time: default_night_end(),
|
||||||
|
max_fan_speed: default_night_max_fan_speed(),
|
||||||
|
force_quiet: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct ConfigurationExport {
|
pub struct ConfigurationExport {
|
||||||
pub format_version: u32,
|
pub format_version: u32,
|
||||||
@@ -528,6 +564,10 @@ pub struct ControlPlan {
|
|||||||
pub house_mode: String,
|
pub house_mode: String,
|
||||||
pub outdoor_temperature: Option<f64>,
|
pub outdoor_temperature: Option<f64>,
|
||||||
pub control_strategy: String,
|
pub control_strategy: String,
|
||||||
|
pub night_mode_active: bool,
|
||||||
|
pub night_mode_start: String,
|
||||||
|
pub night_mode_end: String,
|
||||||
|
pub night_mode_max_fan_speed: u8,
|
||||||
pub next_events: Vec<ControlPlanEvent>,
|
pub next_events: Vec<ControlPlanEvent>,
|
||||||
pub zones: Vec<ZoneControlPlan>,
|
pub zones: Vec<ZoneControlPlan>,
|
||||||
pub rules: Vec<AutomationPlanRule>,
|
pub rules: Vec<AutomationPlanRule>,
|
||||||
@@ -554,6 +594,9 @@ pub struct RuntimeSettings {
|
|||||||
pub history_retention_days: u32,
|
pub history_retention_days: u32,
|
||||||
#[serde(default = "default_true")]
|
#[serde(default = "default_true")]
|
||||||
pub history_compaction_enabled: bool,
|
pub history_compaction_enabled: bool,
|
||||||
|
/// Retention window for controller event/debug log rows.
|
||||||
|
#[serde(default = "default_event_log_retention_days")]
|
||||||
|
pub event_log_retention_days: u32,
|
||||||
/// Add protocol-specific buzzer suppression fields to command frames.
|
/// Add protocol-specific buzzer suppression fields to command frames.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub suppress_device_beep: bool,
|
pub suppress_device_beep: bool,
|
||||||
@@ -561,6 +604,8 @@ pub struct RuntimeSettings {
|
|||||||
pub influxdb: InfluxDbSettings,
|
pub influxdb: InfluxDbSettings,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub debug: DebugSettings,
|
pub debug: DebugSettings,
|
||||||
|
#[serde(default)]
|
||||||
|
pub night_mode: NightModeSettings,
|
||||||
pub home_assistant: HomeAssistantSettings,
|
pub home_assistant: HomeAssistantSettings,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -382,6 +382,7 @@ pub const INSERT_EVENT: &str =
|
|||||||
"INSERT INTO event_log(timestamp,level,kind,message,metadata) VALUES(?1,?2,?3,?4,?5)";
|
"INSERT INTO event_log(timestamp,level,kind,message,metadata) VALUES(?1,?2,?3,?4,?5)";
|
||||||
pub const LIST_EVENTS: &str =
|
pub const LIST_EVENTS: &str =
|
||||||
"SELECT id,timestamp,level,kind,message,metadata FROM event_log ORDER BY id DESC LIMIT ?1";
|
"SELECT id,timestamp,level,kind,message,metadata FROM event_log ORDER BY id DESC LIMIT ?1";
|
||||||
|
pub const PRUNE_EVENTS: &str = "DELETE FROM event_log WHERE timestamp < ?1";
|
||||||
|
|
||||||
pub const LIST_API_TOKENS: &str =
|
pub const LIST_API_TOKENS: &str =
|
||||||
"SELECT id,name,token_prefix,created_at FROM api_tokens ORDER BY created_at DESC";
|
"SELECT id,name,token_prefix,created_at FROM api_tokens ORDER BY created_at DESC";
|
||||||
|
|||||||
+172
-59
@@ -20,7 +20,7 @@ const app = {
|
|||||||
historyTab: 'overview', historyData: {zones:[], devices:[], sensors:[]}, historyCounts: {},
|
historyTab: 'overview', historyData: {zones:[], devices:[], sensors:[]}, historyCounts: {},
|
||||||
historyZone: 'all', historyDevice: 'all', historySensor: 'all', historyLoading: false,
|
historyZone: 'all', historyDevice: 'all', historySensor: 'all', historyLoading: false,
|
||||||
customChartSeries: [], savedCharts: [], zoneControlSeq: {}, zoneTemperatureTimers: {},
|
customChartSeries: [], savedCharts: [], zoneControlSeq: {}, zoneTemperatureTimers: {},
|
||||||
controlPlan: null, controlPlanTimer: null, debugLines: [], debugBacklogLoaded: false,
|
controlPlan: null, controlPlanTimer: null, debugLines: [], debugBacklogLoaded: false, sensorAliases: {},
|
||||||
};
|
};
|
||||||
try { app.savedCharts = JSON.parse(localStorage.getItem('gree_controller_saved_charts') || '[]'); } catch (_) { app.savedCharts = []; }
|
try { app.savedCharts = JSON.parse(localStorage.getItem('gree_controller_saved_charts') || '[]'); } catch (_) { app.savedCharts = []; }
|
||||||
|
|
||||||
@@ -38,6 +38,17 @@ const historyNumber = value => value === null || value === undefined || value ==
|
|||||||
const modeLabel = mode => tr(`mode.${mode}`) === `mode.${mode}` ? mode : tr(`mode.${mode}`);
|
const modeLabel = mode => tr(`mode.${mode}`) === `mode.${mode}` ? mode : tr(`mode.${mode}`);
|
||||||
const fanLabel = value => ({0:'fan.auto',1:'fan.low',2:'fan.mediumLow',3:'fan.medium',4:'fan.mediumHigh',5:'fan.high'}[value] ? tr({0:'fan.auto',1:'fan.low',2:'fan.mediumLow',3:'fan.medium',4:'fan.mediumHigh',5:'fan.high'}[value]) : value);
|
const fanLabel = value => ({0:'fan.auto',1:'fan.low',2:'fan.mediumLow',3:'fan.medium',4:'fan.mediumHigh',5:'fan.high'}[value] ? tr({0:'fan.auto',1:'fan.low',2:'fan.mediumLow',3:'fan.medium',4:'fan.mediumHigh',5:'fan.high'}[value]) : value);
|
||||||
const dateTime = value => value ? new Intl.DateTimeFormat(locale(), {dateStyle:'short', timeStyle:'short'}).format(new Date(value)) : '—';
|
const dateTime = value => value ? new Intl.DateTimeFormat(locale(), {dateStyle:'short', timeStyle:'short'}).format(new Date(value)) : '—';
|
||||||
|
const haSensorLabel = entity => app.sensorAliases?.[entity] || app.settings?.home_assistant?.sensor_aliases?.[entity] || entity;
|
||||||
|
|
||||||
|
function updateConnectionIndicator(status) {
|
||||||
|
app.connectionStatus = status || 'connecting';
|
||||||
|
const node = $('#connectionLabel');
|
||||||
|
if (!node) return;
|
||||||
|
const label = tr(`status.${app.connectionStatus}`);
|
||||||
|
node.className = `connection-dot ${app.connectionStatus}`;
|
||||||
|
node.setAttribute('aria-label', label);
|
||||||
|
node.title = label;
|
||||||
|
}
|
||||||
|
|
||||||
function applyTheme() {
|
function applyTheme() {
|
||||||
const resolved = app.theme === 'light' || app.theme === 'dark'
|
const resolved = app.theme === 'light' || app.theme === 'dark'
|
||||||
@@ -61,8 +72,7 @@ function applyTranslations() {
|
|||||||
$$('[data-day]').forEach(node => { node.textContent = tr(`day.${node.dataset.day}`); });
|
$$('[data-day]').forEach(node => { node.textContent = tr(`day.${node.dataset.day}`); });
|
||||||
$('#languageSelect').value = app.language;
|
$('#languageSelect').value = app.language;
|
||||||
$('#themeSelect').value = app.theme;
|
$('#themeSelect').value = app.theme;
|
||||||
const connectionLabel = $('#connectionLabel');
|
updateConnectionIndicator(app.connectionStatus);
|
||||||
if (connectionLabel) connectionLabel.textContent = tr(`status.${app.connectionStatus}`);
|
|
||||||
renderAll();
|
renderAll();
|
||||||
if (app.currentView === 'logs') loadLogs();
|
if (app.currentView === 'logs') loadLogs();
|
||||||
if (app.currentView === 'history' && app.zones.length) loadHistory();
|
if (app.currentView === 'history' && app.zones.length) loadHistory();
|
||||||
@@ -175,6 +185,7 @@ async function loadBootstrap(showMessage = false) {
|
|||||||
app.automations = data.automations || [];
|
app.automations = data.automations || [];
|
||||||
app.accessTokens = data.access_tokens || [];
|
app.accessTokens = data.access_tokens || [];
|
||||||
app.settings = data.settings || null;
|
app.settings = data.settings || null;
|
||||||
|
app.sensorAliases = {...(app.settings?.home_assistant?.sensor_aliases || {})};
|
||||||
app.system = data.system || {};
|
app.system = data.system || {};
|
||||||
app.outdoorTemperature = Number.isFinite(Number(data.outdoor_temperature)) ? Number(data.outdoor_temperature) : null;
|
app.outdoorTemperature = Number.isFinite(Number(data.outdoor_temperature)) ? Number(data.outdoor_temperature) : null;
|
||||||
renderAll();
|
renderAll();
|
||||||
@@ -348,8 +359,16 @@ function buildZoneSimulation(zone, planZone) {
|
|||||||
const activeTarget = current == null || target == null ? null : (mode === 'heat' ? target + assist : target - assist);
|
const activeTarget = current == null || target == null ? null : (mode === 'heat' ? target + assist : target - assist);
|
||||||
const standbyTarget = target == null ? null : (mode === 'heat' ? target - standbyOffset : target + standbyOffset);
|
const standbyTarget = target == null ? null : (mode === 'heat' ? target - standbyOffset : target + standbyOffset);
|
||||||
const desiredDeviceTarget = current == null || target == null ? null : simulationRoundDeviceSetpoint(mode, demand, demand ? activeTarget : standbyTarget);
|
const desiredDeviceTarget = current == null || target == null ? null : simulationRoundDeviceSetpoint(mode, demand, demand ? activeTarget : standbyTarget);
|
||||||
const fanSpeed = zone?.smart_fan && current != null && target != null ? simulationSmartFanSpeed(mode, current, target, outdoor, demand) : simulationNumeric(device?.fan_speed);
|
const nightActive = !!app.controlPlan?.night_mode_active;
|
||||||
const quiet = zone?.smart_fan ? (!demand ? tr('simulation.quietIfSupported') : tr('common.off')) : tr('simulation.manual');
|
const nightMaxFan = clamp(Number(app.controlPlan?.night_mode_max_fan_speed || 1), 1, 5);
|
||||||
|
let fanSpeed = zone?.smart_fan && current != null && target != null ? simulationSmartFanSpeed(mode, current, target, outdoor, demand) : simulationNumeric(device?.fan_speed);
|
||||||
|
if (nightActive) {
|
||||||
|
if (zone?.smart_fan) fanSpeed = fanSpeed === 0 ? 1 : Math.min(fanSpeed ?? nightMaxFan, nightMaxFan);
|
||||||
|
else if (fanSpeed === 0 || fanSpeed == null || fanSpeed > nightMaxFan) fanSpeed = nightMaxFan;
|
||||||
|
}
|
||||||
|
const quiet = nightActive && app.settings?.night_mode?.force_quiet
|
||||||
|
? tr('simulation.quietIfSupported')
|
||||||
|
: (zone?.smart_fan ? (!demand ? tr('simulation.quietIfSupported') : tr('common.off')) : tr('simulation.manual'));
|
||||||
const reasoning = !zone?.enabled || planZone?.enabled === false
|
const reasoning = !zone?.enabled || planZone?.enabled === false
|
||||||
? tr('simulation.reasonDisabled')
|
? tr('simulation.reasonDisabled')
|
||||||
: mode === 'off'
|
: mode === 'off'
|
||||||
@@ -373,70 +392,93 @@ function simulationRuleAction(rule) {
|
|||||||
function renderSimulationPage() {
|
function renderSimulationPage() {
|
||||||
const summaryHost = $('#simulationSummary');
|
const summaryHost = $('#simulationSummary');
|
||||||
const timelineHost = $('#simulationTimeline');
|
const timelineHost = $('#simulationTimeline');
|
||||||
const zonesHost = $('#simulationZones');
|
const boardHost = $('#simulationFlowBoard');
|
||||||
const rulesHost = $('#simulationRules');
|
const rulesHost = $('#simulationRules');
|
||||||
if (!summaryHost || !timelineHost || !zonesHost || !rulesHost) return;
|
if (!summaryHost || !timelineHost || !boardHost || !rulesHost) return;
|
||||||
const plan = app.controlPlan;
|
const plan = app.controlPlan;
|
||||||
if (!plan) {
|
if (!plan) {
|
||||||
summaryHost.innerHTML = `<div class="panel plan-loading">${esc(tr('plan.loading'))}</div>`;
|
summaryHost.innerHTML = `<div class="panel plan-loading">${esc(tr('plan.loading'))}</div>`;
|
||||||
timelineHost.innerHTML = '';
|
timelineHost.innerHTML = ''; boardHost.innerHTML = ''; rulesHost.innerHTML = '';
|
||||||
zonesHost.innerHTML = '';
|
|
||||||
rulesHost.innerHTML = '';
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const enabledZones = (plan.zones || []).filter(zone => zone.enabled);
|
const enabledZones = (plan.zones || []).filter(zone => zone.enabled);
|
||||||
const demandingZones = enabledZones.filter(zone => zone.demand);
|
const demandingZones = enabledZones.filter(zone => zone.demand);
|
||||||
|
const nightOn = !!plan.night_mode_active;
|
||||||
summaryHost.innerHTML = [
|
summaryHost.innerHTML = [
|
||||||
{label: tr('simulation.houseMode'), value: modeLabel(plan.house_mode || 'off'), note: tr('simulation.strategy', {strategy: plan.control_strategy || 'setpoint'})},
|
{label: tr('simulation.houseMode'), value: modeLabel(plan.house_mode || 'off'), note: tr('simulation.strategy', {strategy: plan.control_strategy || 'setpoint'})},
|
||||||
|
{label: tr('settings.nightMode'), value: nightOn ? tr('common.active') : (app.settings?.night_mode?.enabled ? tr('simulation.scheduled') : tr('common.disabled')), note: `${plan.night_mode_start || '22:00'} → ${plan.night_mode_end || '06:00'} · ${tr('simulation.fanMax')} ${fanLabel(plan.night_mode_max_fan_speed || 1)}`},
|
||||||
{label: tr('simulation.outdoor'), value: plan.outdoor_temperature == null ? '—' : fmtTemp(plan.outdoor_temperature), note: tr('simulation.generatedAt', {time: dateTime(plan.generated_at)})},
|
{label: tr('simulation.outdoor'), value: plan.outdoor_temperature == null ? '—' : fmtTemp(plan.outdoor_temperature), note: tr('simulation.generatedAt', {time: dateTime(plan.generated_at)})},
|
||||||
{label: tr('simulation.activeZones'), value: String(enabledZones.length), note: tr('simulation.requestingZones', {count: demandingZones.length})},
|
{label: tr('simulation.activeZones'), value: String(enabledZones.length), note: `${tr('simulation.requestingZones', {count: demandingZones.length})} · ${tr('simulation.rulesLabel')}: ${(plan.rules || []).filter(rule => rule.enabled).length}`},
|
||||||
{label: tr('simulation.rulesLabel'), value: String((plan.rules || []).filter(rule => rule.enabled).length), note: tr('simulation.eventsQueued', {count: (plan.next_events || []).length})},
|
|
||||||
].map(card => `<article class="panel simulation-summary-card"><small>${esc(card.label)}</small><strong>${esc(card.value)}</strong><span>${esc(card.note)}</span></article>`).join('');
|
].map(card => `<article class="panel simulation-summary-card"><small>${esc(card.label)}</small><strong>${esc(card.value)}</strong><span>${esc(card.note)}</span></article>`).join('');
|
||||||
|
|
||||||
|
const zones = plan.zones || [];
|
||||||
|
const nodeW = 190, nodeH = 106, rowH = 190, topY = 155;
|
||||||
|
const x = {sensor:45, thermostat:305, decision:565, unit:825, event:1085};
|
||||||
|
const boardW = 1325, boardH = Math.max(390, topY + zones.length * rowH + 35);
|
||||||
|
const nodes = [];
|
||||||
|
const links = [];
|
||||||
|
const pathBetween = (ax, ay, bx, by, cls='') => {
|
||||||
|
const bend = Math.max(36, (bx - ax) * .45);
|
||||||
|
return `<path class="flow-link ${cls}" d="M ${ax} ${ay} C ${ax+bend} ${ay}, ${bx-bend} ${by}, ${bx} ${by}"/>`;
|
||||||
|
};
|
||||||
|
const verticalLink = (ax, ay, bx, by, cls='bus') => `<path class="flow-link ${cls}" d="M ${ax} ${ay} C ${ax} ${ay+35}, ${bx} ${by-35}, ${bx} ${by}"/>`;
|
||||||
|
const node = ({left, top, kind, eyebrow, title, value, meta='', badge='', badgeClass=''}) => `<article class="flow-node ${kind}" style="left:${left}px;top:${top}px;width:${nodeW}px;min-height:${nodeH}px"><div class="flow-node-top"><span>${esc(eyebrow)}</span>${badge ? `<b class="flow-node-badge ${badgeClass}">${esc(badge)}</b>` : ''}</div><h3>${esc(title)}</h3><strong>${esc(value)}</strong>${meta ? `<p>${esc(meta)}</p>` : ''}<i class="flow-port in"></i><i class="flow-port out"></i></article>`;
|
||||||
|
|
||||||
|
const globalHouseLeft = 305, globalNightLeft = 565, globalTop = 28;
|
||||||
|
nodes.push(node({left:globalHouseLeft,top:globalTop,kind:'logic global',eyebrow:tr('simulation.globalInput'),title:tr('simulation.houseMode'),value:modeLabel(plan.house_mode || 'off'),meta:tr('simulation.strategy',{strategy:plan.control_strategy || 'setpoint'}),badge:tr('simulation.house')}));
|
||||||
|
nodes.push(node({left:globalNightLeft,top:globalTop,kind:`logic global ${nightOn?'night-active':''}`,eyebrow:tr('simulation.globalInput'),title:tr('settings.nightMode'),value:nightOn?tr('common.active'):(app.settings?.night_mode?.enabled?tr('simulation.scheduled'):tr('common.disabled')),meta:`${plan.night_mode_start || '22:00'} → ${plan.night_mode_end || '06:00'} · ${fanLabel(plan.night_mode_max_fan_speed || 1)}`,badge:nightOn?'☾':'○',badgeClass:nightOn?'active':''}));
|
||||||
|
|
||||||
|
zones.forEach((planZone, index) => {
|
||||||
|
const zone = app.zones.find(item => item.id === planZone.zone_id);
|
||||||
|
const sim = buildZoneSimulation(zone, planZone);
|
||||||
|
const status = simulationStatusInfo(sim.status);
|
||||||
|
const y = topY + index * rowH;
|
||||||
|
const mid = y + nodeH / 2;
|
||||||
|
const next = (planZone.next_events || [])[0];
|
||||||
|
const source = zoneControlSourceLabel(planZone.control_source);
|
||||||
|
const sensorName = planZone.control_source === 'external' || planZone.control_source === 'combined'
|
||||||
|
? haSensorLabel(zone?.ha_entity_id || source)
|
||||||
|
: (planZone.device_name || tr('common.device'));
|
||||||
|
const fanText = sim.fanSpeed == null ? '—' : fanLabel(sim.fanSpeed);
|
||||||
|
const quietText = sim.quiet;
|
||||||
|
const command = sim.desiredDeviceTarget == null ? tr('simulation.noCommand') : `${fmtTemp(sim.desiredDeviceTarget)} · ${fanText}`;
|
||||||
|
const eventValue = next ? simulationTime(next.at) : tr('simulation.noEventShort');
|
||||||
|
const eventMeta = next ? `${next.label}${next.target_temperature == null ? '' : ` · ${fmtTemp(next.target_temperature)}`}` : tr('plan.noEvents');
|
||||||
|
|
||||||
|
nodes.push(`<div class="flow-lane-label" style="top:${y-27}px"><strong>${esc(planZone.zone_name)}</strong><span>${esc(planZone.device_name || '')}</span></div>`);
|
||||||
|
nodes.push(node({left:x.sensor,top:y,kind:'input',eyebrow:tr('simulation.stepRoom'),title:sensorName,value:fmtTemp(sim.current),meta:source,badge:'●',badgeClass:sim.current == null?'':'active'}));
|
||||||
|
nodes.push(node({left:x.thermostat,top:y,kind:'logic',eyebrow:tr('simulation.thermostat'),title:planZone.zone_name,value:sim.target == null?'—':fmtTemp(sim.target),meta:`${zonePresetLabel(planZone.preset)} · ${tr('simulation.hysteresis',{value:sim.hysteresis.toFixed(1)})}`,badge:modeLabel(sim.mode)}));
|
||||||
|
nodes.push(node({left:x.decision,top:y,kind:`logic decision ${sim.demand?'demand':'satisfied'}`,eyebrow:tr('simulation.stepDecision'),title:status.label,value:sim.demand?tr('simulation.callForComfort'):tr('simulation.standby'),meta:sim.reasoning,badge:sim.demand?'▶':'✓',badgeClass:status.badge}));
|
||||||
|
nodes.push(node({left:x.unit,top:y,kind:'action',eyebrow:tr('simulation.stepCommand'),title:planZone.device_name || tr('common.device'),value:command,meta:`${tr('devices.quiet')}: ${quietText}`,badge:sim.demand?'RUN':'IDLE',badgeClass:sim.demand?'active':''}));
|
||||||
|
nodes.push(node({left:x.event,top:y,kind:'event',eyebrow:tr('simulation.nextEvent'),title:eventValue,value:next?.preset ? zonePresetLabel(next.preset) : tr('simulation.schedule'),meta:eventMeta,badge:'›'}));
|
||||||
|
|
||||||
|
links.push(pathBetween(x.sensor+nodeW,mid,x.thermostat,mid,'input-link'));
|
||||||
|
links.push(pathBetween(x.thermostat+nodeW,mid,x.decision,mid,'logic-link'));
|
||||||
|
links.push(pathBetween(x.decision+nodeW,mid,x.unit,mid,sim.demand?'active-link':'logic-link'));
|
||||||
|
links.push(pathBetween(x.unit+nodeW,mid,x.event,mid,'action-link'));
|
||||||
|
links.push(verticalLink(globalHouseLeft+nodeW/2,globalTop+nodeH,x.thermostat+nodeW/2,y,'bus'));
|
||||||
|
if (app.settings?.night_mode?.enabled) links.push(verticalLink(globalNightLeft+nodeW/2,globalTop+nodeH,x.decision+nodeW/2,y,nightOn?'night-link':'bus'));
|
||||||
|
});
|
||||||
|
|
||||||
|
boardHost.style.width = `${boardW}px`;
|
||||||
|
boardHost.style.height = `${boardH}px`;
|
||||||
|
boardHost.innerHTML = `<svg class="flow-links" viewBox="0 0 ${boardW} ${boardH}" width="${boardW}" height="${boardH}" aria-hidden="true">${links.join('')}</svg>${nodes.join('')}`;
|
||||||
|
|
||||||
const timelineItems = [];
|
const timelineItems = [];
|
||||||
(plan.next_events || []).forEach(event => timelineItems.push({scope: tr('simulation.house'), event}));
|
(plan.next_events || []).forEach(event => timelineItems.push({scope: tr('simulation.house'), event}));
|
||||||
(plan.zones || []).forEach(zone => (zone.next_events || []).forEach(event => timelineItems.push({scope: zone.zone_name, event, device: zone.device_name})));
|
zones.forEach(zone => (zone.next_events || []).forEach(event => timelineItems.push({scope: zone.zone_name, event, device: zone.device_name})));
|
||||||
timelineItems.sort((a, b) => new Date(a.event.at) - new Date(b.event.at));
|
timelineItems.sort((a, b) => new Date(a.event.at) - new Date(b.event.at));
|
||||||
const unique = [];
|
const unique = [], seen = new Set();
|
||||||
const seen = new Set();
|
|
||||||
for (const item of timelineItems) {
|
for (const item of timelineItems) {
|
||||||
const key = `${item.scope}|${item.event.at}|${item.event.label}`;
|
const key = `${item.scope}|${item.event.at}|${item.event.label}`;
|
||||||
if (seen.has(key)) continue;
|
if (seen.has(key)) continue; seen.add(key); unique.push(item); if (unique.length >= 16) break;
|
||||||
seen.add(key);
|
|
||||||
unique.push(item);
|
|
||||||
if (unique.length >= 16) break;
|
|
||||||
}
|
}
|
||||||
timelineHost.innerHTML = unique.length ? unique.map(item => {
|
timelineHost.innerHTML = unique.length ? unique.map(item => {
|
||||||
const target = item.event.target_temperature == null ? '' : ` · ${fmtTemp(item.event.target_temperature)}`;
|
const target = item.event.target_temperature == null ? '' : ` · ${fmtTemp(item.event.target_temperature)}`;
|
||||||
return `<article class="simulation-timeline-item"><time>${esc(simulationTime(item.event.at))}</time><div><strong>${esc(item.scope)}</strong><p>${esc(item.event.label)}${esc(target)}${item.device ? ` · ${esc(item.device)}` : ''}</p></div></article>`;
|
return `<article class="simulation-timeline-item"><time>${esc(simulationTime(item.event.at))}</time><div><strong>${esc(item.scope)}</strong><p>${esc(item.event.label)}${esc(target)}${item.device ? ` · ${esc(item.device)}` : ''}</p></div></article>`;
|
||||||
}).join('') : `<div class="empty">${esc(tr('plan.noEvents'))}</div>`;
|
}).join('') : `<div class="empty">${esc(tr('plan.noEvents'))}</div>`;
|
||||||
|
|
||||||
zonesHost.innerHTML = (plan.zones || []).length ? plan.zones.map(planZone => {
|
|
||||||
const zone = app.zones.find(item => item.id === planZone.zone_id);
|
|
||||||
const sim = buildZoneSimulation(zone, planZone);
|
|
||||||
const status = simulationStatusInfo(sim.status);
|
|
||||||
const events = (planZone.next_events || []).slice(0, 4);
|
|
||||||
const sourceLabel = zoneControlSourceLabel(planZone.control_source);
|
|
||||||
const fanText = sim.fanSpeed == null ? '—' : fanLabel(sim.fanSpeed);
|
|
||||||
const commandText = sim.desiredDeviceTarget == null ? tr('simulation.noCommand') : `${fmtTemp(sim.desiredDeviceTarget)} · ${fanText} · ${tr('devices.quiet')}: ${sim.quiet}`;
|
|
||||||
return `<article class="panel simulation-zone-card ${planZone.enabled ? '' : 'disabled'}">
|
|
||||||
<div class="simulation-zone-head"><div><span class="eyebrow">${esc(planZone.device_name || tr('common.noDevice'))}</span><h3>${esc(planZone.zone_name)}</h3><p>${esc(modeLabel(sim.mode))} · ${esc(zonePresetLabel(planZone.preset))}${planZone.current_schedule_name ? ` · ${esc(planZone.current_schedule_name)}` : ''}</p></div><span class="badge ${status.badge}">${esc(status.label)}</span></div>
|
|
||||||
<div class="simulation-flow">
|
|
||||||
<div class="simulation-step"><small>${esc(tr('simulation.stepRoom'))}</small><strong>${esc(fmtTemp(sim.current))}</strong><span>${esc(sourceLabel)}</span></div>
|
|
||||||
<div class="simulation-step"><small>${esc(tr('simulation.stepTarget'))}</small><strong>${esc(sim.target == null ? '—' : `${sim.target.toFixed(1)}°C`)}</strong><span>${esc(tr('simulation.hysteresis', {value: sim.hysteresis.toFixed(1)}))}</span></div>
|
|
||||||
<div class="simulation-step"><small>${esc(tr('simulation.stepDecision'))}</small><strong>${esc(status.label)}</strong><span>${esc(sim.reasoning)}</span></div>
|
|
||||||
<div class="simulation-step accent"><small>${esc(tr('simulation.stepCommand'))}</small><strong>${esc(commandText)}</strong><span>${esc(sim.demand ? tr('simulation.activeTargetLabel', {value: sim.activeTarget == null ? '—' : sim.activeTarget.toFixed(1)}) : tr('simulation.standbyTargetLabel', {value: sim.standbyTarget == null ? '—' : sim.standbyTarget.toFixed(1)}))}</span></div>
|
|
||||||
</div>
|
|
||||||
<div class="simulation-metrics">
|
|
||||||
<div><small>${esc(tr('simulation.deviceSetpoint'))}</small><strong>${esc(sim.desiredDeviceTarget == null ? '—' : fmtTemp(sim.desiredDeviceTarget))}</strong></div>
|
|
||||||
<div><small>${esc(tr('common.fan'))}</small><strong>${esc(fanText)}</strong></div>
|
|
||||||
<div><small>${esc(tr('devices.quiet'))}</small><strong>${esc(sim.quiet)}</strong></div>
|
|
||||||
<div><small>${esc(tr('simulation.outdoor'))}</small><strong>${esc(sim.outdoor == null ? '—' : fmtTemp(sim.outdoor))}</strong></div>
|
|
||||||
</div>
|
|
||||||
<ul class="plan-events simulation-events">${events.length ? events.map(planEventMarkup).join('') : `<li class="muted">${esc(tr('plan.noEvents'))}</li>`}</ul>
|
|
||||||
</article>`;
|
|
||||||
}).join('') : `<div class="empty">${esc(tr('zones.emptyText'))}</div>`;
|
|
||||||
|
|
||||||
const activeRules = (plan.rules || []).filter(rule => rule.enabled);
|
const activeRules = (plan.rules || []).filter(rule => rule.enabled);
|
||||||
rulesHost.innerHTML = activeRules.length ? activeRules.map(rule => `<article class="panel simulation-rule-card"><div class="simulation-rule-head"><div><span class="eyebrow">${esc(tr('common.automation'))}</span><h3>${esc(rule.name)}</h3></div><span class="badge active">${esc(automationTriggerLabel(rule))}</span></div><p>${esc(tr('simulation.ruleOnDevice', {device: rule.action_device_name || tr('common.noDevice')}))}</p><div class="simulation-rule-action">${esc(simulationRuleAction(rule))}</div><div class="simulation-rule-meta"><small>${esc(tr('automations.last'))}: ${esc(dateTime(rule.last_fired_at))}</small><small>${esc(tr('simulation.nextReady'))}: ${esc(dateTime(rule.next_ready_at))}</small></div></article>`).join('') : `<div class="empty">${esc(tr('simulation.noRules'))}</div>`;
|
rulesHost.innerHTML = activeRules.length ? activeRules.map(rule => `<article class="panel simulation-rule-card"><div class="simulation-rule-head"><div><span class="eyebrow">${esc(tr('common.automation'))}</span><h3>${esc(rule.name)}</h3></div><span class="badge active">${esc(automationTriggerLabel(rule))}</span></div><p>${esc(tr('simulation.ruleOnDevice', {device: rule.action_device_name || tr('common.noDevice')}))}</p><div class="simulation-rule-action">${esc(simulationRuleAction(rule))}</div><div class="simulation-rule-meta"><small>${esc(tr('automations.last'))}: ${esc(dateTime(rule.last_fired_at))}</small><small>${esc(tr('simulation.nextReady'))}: ${esc(dateTime(rule.next_ready_at))}</small></div></article>`).join('') : `<div class="empty">${esc(tr('simulation.noRules'))}</div>`;
|
||||||
}
|
}
|
||||||
@@ -451,7 +493,7 @@ function deviceCard(device, detailed = false) {
|
|||||||
const fans = [0,1,3,5];
|
const fans = [0,1,3,5];
|
||||||
const error = device.last_error
|
const error = device.last_error
|
||||||
? `<small title="${esc(device.last_error)}">${esc(device.last_error)}</small>`
|
? `<small title="${esc(device.last_error)}">${esc(device.last_error)}</small>`
|
||||||
: `<small>${esc(device.ip)}:${esc(device.port)} · ${device.simulated ? tr('devices.simulator') : device.protocol_version === 2 ? 'V2 GCM' : device.protocol_version === 1 ? 'V1 ECB' : tr('devices.protocolAuto')}</small>`;
|
: `<small>${esc(device.ip)} · ${device.simulated ? tr('devices.simulator') : device.protocol_version === 2 ? 'V2 GCM' : device.protocol_version === 1 ? 'V1 ECB' : tr('devices.protocolAuto')}</small>`;
|
||||||
return `<article class="device-card ${device.power ? '' : 'off'}" data-device-card="${esc(device.id)}">
|
return `<article class="device-card ${device.power ? '' : 'off'}" data-device-card="${esc(device.id)}">
|
||||||
<div class="device-head">
|
<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><p><span class="status ${device.online ? 'online' : ''}">${esc(tr(device.online ? 'status.online' : 'status.offline'))}</span> · ${esc(device.model || device.mac)}</p></div>
|
||||||
@@ -470,7 +512,7 @@ function deviceCard(device, detailed = false) {
|
|||||||
<button class="${device.quiet ? 'active' : ''}" data-action="toggle" data-field="quiet" data-device="${esc(device.id)}">${esc(tr('devices.quiet'))}</button>
|
<button class="${device.quiet ? 'active' : ''}" data-action="toggle" data-field="quiet" data-device="${esc(device.id)}">${esc(tr('devices.quiet'))}</button>
|
||||||
<button class="${device.turbo ? 'active' : ''}" data-action="toggle" data-field="turbo" data-device="${esc(device.id)}">${esc(tr('devices.turbo'))}</button>
|
<button class="${device.turbo ? 'active' : ''}" data-action="toggle" data-field="turbo" data-device="${esc(device.id)}">${esc(tr('devices.turbo'))}</button>
|
||||||
</div>
|
</div>
|
||||||
${detailed ? `<div class="card-footer">${error}<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.rename'))}</button>${device.simulated ? '' : `<button data-action="bind" data-device="${esc(device.id)}">${esc(tr('actions.bind'))}</button>`}<button class="danger" data-action="delete-device" data-device="${esc(device.id)}">${esc(tr('actions.delete'))}</button></div></div>` : ''}
|
${detailed ? `<div class="card-footer">${error}<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>`}<button class="danger" data-action="delete-device" data-device="${esc(device.id)}">${esc(tr('actions.delete'))}</button></div></div>` : ''}
|
||||||
</article>`;
|
</article>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -577,6 +619,32 @@ function renderAccessTokens() {
|
|||||||
</div>`).join('') : `<div class="empty compact"><strong>${esc(tr('settings.noTokens'))}</strong>${esc(tr('settings.noTokensHint'))}</div>`;
|
</div>`).join('') : `<div class="empty compact"><strong>${esc(tr('settings.noTokens'))}</strong>${esc(tr('settings.noTokensHint'))}</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function knownHaEntities() {
|
||||||
|
return [...new Set([
|
||||||
|
...Object.keys(app.sensorAliases || {}),
|
||||||
|
...app.zones.map(zone => zone.ha_entity_id).filter(Boolean),
|
||||||
|
app.settings?.home_assistant?.default_entity_id,
|
||||||
|
app.settings?.home_assistant?.outdoor_entity_id,
|
||||||
|
...app.historyData.sensors.map(row => row.entity_id),
|
||||||
|
].filter(Boolean))].sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSensorAliases() {
|
||||||
|
const host = $('#sensorAliasList'); if (!host) return;
|
||||||
|
const entities = knownHaEntities();
|
||||||
|
host.innerHTML = entities.length ? entities.map(entity => `<div class="sensor-alias-row"><span class="mono" title="${esc(entity)}">${esc(entity)}</span><input data-sensor-alias="${esc(entity)}" value="${esc(app.sensorAliases?.[entity] || '')}" placeholder="${esc(tr('settings.aliasPlaceholder'))}"><button type="button" class="sensor-alias-clear" data-clear-sensor-alias="${esc(entity)}" title="${esc(tr('actions.clear'))}">×</button></div>`).join('') : `<div class="empty compact">${esc(tr('settings.noSensorAliases'))}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderLogRetention() {
|
||||||
|
const select = $('#logRetentionDays'); if (!select || !app.settings) return;
|
||||||
|
[...select.options].forEach(option => { option.textContent = `${option.value} ${tr('common.days')}`; });
|
||||||
|
const days = String(app.settings.event_log_retention_days || 30);
|
||||||
|
if (![...select.options].some(option => option.value === days)) {
|
||||||
|
const option = document.createElement('option'); option.value = days; option.textContent = `${days} ${tr('common.days')}`; select.appendChild(option);
|
||||||
|
}
|
||||||
|
select.value = days;
|
||||||
|
}
|
||||||
|
|
||||||
function renderSettings() {
|
function renderSettings() {
|
||||||
if (!app.settings) return;
|
if (!app.settings) return;
|
||||||
const form = $('#settingsForm');
|
const form = $('#settingsForm');
|
||||||
@@ -587,8 +655,14 @@ function renderSettings() {
|
|||||||
form.discovery_timeout_ms.value = app.settings.discovery_timeout_ms || 3000;
|
form.discovery_timeout_ms.value = app.settings.discovery_timeout_ms || 3000;
|
||||||
form.simulator_enabled.checked = !!app.settings.simulator_enabled;
|
form.simulator_enabled.checked = !!app.settings.simulator_enabled;
|
||||||
form.history_retention_days.value = app.settings.history_retention_days || 30;
|
form.history_retention_days.value = app.settings.history_retention_days || 30;
|
||||||
|
form.event_log_retention_days.value = app.settings.event_log_retention_days || 30;
|
||||||
form.history_compaction_enabled.checked = app.settings.history_compaction_enabled !== false;
|
form.history_compaction_enabled.checked = app.settings.history_compaction_enabled !== false;
|
||||||
form.suppress_device_beep.checked = !!app.settings.suppress_device_beep;
|
form.suppress_device_beep.checked = !!app.settings.suppress_device_beep;
|
||||||
|
form.night_mode_enabled.checked = !!app.settings.night_mode?.enabled;
|
||||||
|
form.night_mode_start.value = app.settings.night_mode?.start_time || '22:00';
|
||||||
|
form.night_mode_end.value = app.settings.night_mode?.end_time || '06:00';
|
||||||
|
form.night_mode_max_fan_speed.value = String(app.settings.night_mode?.max_fan_speed || 1);
|
||||||
|
form.night_mode_force_quiet.checked = app.settings.night_mode?.force_quiet !== false;
|
||||||
form.influx_enabled.checked = !!app.settings.influxdb?.enabled;
|
form.influx_enabled.checked = !!app.settings.influxdb?.enabled;
|
||||||
form.influx_version.value = String(app.settings.influxdb?.version || '2');
|
form.influx_version.value = String(app.settings.influxdb?.version || '2');
|
||||||
form.influx_threshold_days.value = app.settings.influxdb?.history_threshold_days || 30;
|
form.influx_threshold_days.value = app.settings.influxdb?.history_threshold_days || 30;
|
||||||
@@ -611,6 +685,8 @@ function renderSettings() {
|
|||||||
form.ha_allow_invalid_tls.checked = !!app.settings.home_assistant?.allow_invalid_tls;
|
form.ha_allow_invalid_tls.checked = !!app.settings.home_assistant?.allow_invalid_tls;
|
||||||
form.outdoor_assist_enabled.checked = !!app.settings.outdoor_assist_enabled;
|
form.outdoor_assist_enabled.checked = !!app.settings.outdoor_assist_enabled;
|
||||||
updateInfluxFields();
|
updateInfluxFields();
|
||||||
|
renderSensorAliases();
|
||||||
|
renderLogRetention();
|
||||||
$('#systemInfo').innerHTML = `<h3>${esc(tr('settings.systemState'))}</h3><div>${esc(tr('settings.version'))}: <strong>${esc(app.system.version || '—')}</strong></div><div>${esc(tr('settings.uptime'))}: <strong>${esc(formatDuration(app.system.uptime_seconds || 0))}</strong></div><div>${esc(tr('settings.apiAuth'))}: <strong>${esc(app.system.auth_required ? tr('settings.enabled') : tr('settings.disabled'))}</strong></div>`;
|
$('#systemInfo').innerHTML = `<h3>${esc(tr('settings.systemState'))}</h3><div>${esc(tr('settings.version'))}: <strong>${esc(app.system.version || '—')}</strong></div><div>${esc(tr('settings.uptime'))}: <strong>${esc(formatDuration(app.system.uptime_seconds || 0))}</strong></div><div>${esc(tr('settings.apiAuth'))}: <strong>${esc(app.system.auth_required ? tr('settings.enabled') : tr('settings.disabled'))}</strong></div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -855,7 +931,7 @@ function historyEntityOptions() {
|
|||||||
...app.zones.map(zone => zone.ha_entity_id).filter(Boolean),
|
...app.zones.map(zone => zone.ha_entity_id).filter(Boolean),
|
||||||
app.settings?.home_assistant?.outdoor_entity_id,
|
app.settings?.home_assistant?.outdoor_entity_id,
|
||||||
].filter(Boolean))].sort();
|
].filter(Boolean))].sort();
|
||||||
const sensors = entities.map(entity => `<option value="${esc(entity)}">${esc(entity)}</option>`).join('');
|
const sensors = entities.map(entity => `<option value="${esc(entity)}">${esc(haSensorLabel(entity))}</option>`).join('');
|
||||||
return {zones, devices, sensors, entities};
|
return {zones, devices, sensors, entities};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1004,7 +1080,7 @@ function renderOverviewHistory() {
|
|||||||
|
|
||||||
const outdoorDeviceSeries=app.devices.filter(device=>deviceRows.some(row=>row.device_id===device.id&&Number.isFinite(historyNumber(row.outdoor_temperature)))).map((device,index)=>({label:`${device.name} · ${tr('history.greeOutdoor')}`,color:historySeriesColor(index),value:row=>!row.zone_id&&!row.entity_id&&row.device_id===device.id?historyNumber(row.outdoor_temperature):NaN}));
|
const outdoorDeviceSeries=app.devices.filter(device=>deviceRows.some(row=>row.device_id===device.id&&Number.isFinite(historyNumber(row.outdoor_temperature)))).map((device,index)=>({label:`${device.name} · ${tr('history.greeOutdoor')}`,color:historySeriesColor(index),value:row=>!row.zone_id&&!row.entity_id&&row.device_id===device.id?historyNumber(row.outdoor_temperature):NaN}));
|
||||||
const outdoorEntities=[...new Set(app.historyData.sensors.filter(row=>row.kind==='outdoor').map(row=>row.entity_id))];
|
const outdoorEntities=[...new Set(app.historyData.sensors.filter(row=>row.kind==='outdoor').map(row=>row.entity_id))];
|
||||||
const outdoorHaSeries=outdoorEntities.map((entity,index)=>({label:`HA · ${entity}`,color:historySeriesColor(index+outdoorDeviceSeries.length),dash:[6,4],value:row=>row.entity_id===entity?historyNumber(row.temperature):NaN}));
|
const outdoorHaSeries=outdoorEntities.map((entity,index)=>({label:`HA · ${haSensorLabel(entity)}`,color:historySeriesColor(index+outdoorDeviceSeries.length),dash:[6,4],value:row=>row.entity_id===entity?historyNumber(row.temperature):NaN}));
|
||||||
const outdoorRows=[...deviceRows,...app.historyData.sensors.filter(row=>row.kind==='outdoor')];
|
const outdoorRows=[...deviceRows,...app.historyData.sensors.filter(row=>row.kind==='outdoor')];
|
||||||
const outdoorSeries=[...outdoorDeviceSeries,...outdoorHaSeries];
|
const outdoorSeries=[...outdoorDeviceSeries,...outdoorHaSeries];
|
||||||
drawLineChart($('#overviewOutdoorChart'),outdoorSeries,outdoorRows,{height:320}); renderLegend($('#overviewOutdoorChartLegend'),outdoorSeries);
|
drawLineChart($('#overviewOutdoorChart'),outdoorSeries,outdoorRows,{height:320}); renderLegend($('#overviewOutdoorChartLegend'),outdoorSeries);
|
||||||
@@ -1063,7 +1139,7 @@ function renderSensorHistory() {
|
|||||||
const entities=[...new Set(rows.map(row=>row.entity_id))];
|
const entities=[...new Set(rows.map(row=>row.entity_id))];
|
||||||
const host=$('#historyCharts');
|
const host=$('#historyCharts');
|
||||||
host.innerHTML=historyChartMarkup('haSensorsChart',tr('history.haSensors'),tr('history.haSensorsHint'));
|
host.innerHTML=historyChartMarkup('haSensorsChart',tr('history.haSensors'),tr('history.haSensorsHint'));
|
||||||
const series=entities.map((entity,index)=>({label:entity,color:historySeriesColor(index),dash:rows.some(row=>row.entity_id===entity&&row.kind==='outdoor')?[6,4]:[],value:row=>row.entity_id===entity?historyNumber(row.temperature):NaN}));
|
const series=entities.map((entity,index)=>({label:haSensorLabel(entity),color:historySeriesColor(index),dash:rows.some(row=>row.entity_id===entity&&row.kind==='outdoor')?[6,4]:[],value:row=>row.entity_id===entity?historyNumber(row.temperature):NaN}));
|
||||||
drawLineChart($('#haSensorsChart'),series,rows,{height:370}); renderLegend($('#haSensorsChartLegend'),series);
|
drawLineChart($('#haSensorsChart'),series,rows,{height:370}); renderLegend($('#haSensorsChartLegend'),series);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1082,7 +1158,7 @@ function customSeriesOptions() {
|
|||||||
items.push([`zone|${zone.id}|device_target`,`${zone.name} · ${tr('history.deviceSetpoint')}`]);
|
items.push([`zone|${zone.id}|device_target`,`${zone.name} · ${tr('history.deviceSetpoint')}`]);
|
||||||
items.push([`zone|${zone.id}|outdoor`,`${zone.name} · ${tr('history.outdoorTemperature')}`]);
|
items.push([`zone|${zone.id}|outdoor`,`${zone.name} · ${tr('history.outdoorTemperature')}`]);
|
||||||
});
|
});
|
||||||
[...new Set(app.historyData.sensors.map(row=>row.entity_id))].forEach(entity=>items.push([`ha|${entity}|temperature`,`HA · ${entity}`]));
|
[...new Set(app.historyData.sensors.map(row=>row.entity_id))].forEach(entity=>items.push([`ha|${entity}|temperature`,`HA · ${haSensorLabel(entity)}`]));
|
||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1101,7 +1177,7 @@ function customSeriesDefinition(key,index=0) {
|
|||||||
const info=fields[field]; if(!info) return null;
|
const info=fields[field]; if(!info) return null;
|
||||||
return {key,label:`${zone.name} · ${info[1]}`,color,dash:['target','device_target','outdoor'].includes(field)?[6,4]:[],value:row=>row.zone_id===id?historyNumber(row[info[0]]):NaN};
|
return {key,label:`${zone.name} · ${info[1]}`,color,dash:['target','device_target','outdoor'].includes(field)?[6,4]:[],value:row=>row.zone_id===id?historyNumber(row[info[0]]):NaN};
|
||||||
}
|
}
|
||||||
if(kind==='ha') return {key,label:`HA · ${id}`,color,dash:[3,4],value:row=>row.entity_id===id?historyNumber(row.temperature):NaN};
|
if(kind==='ha') return {key,label:`HA · ${haSensorLabel(id)}`,color,dash:[3,4],value:row=>row.entity_id===id?historyNumber(row.temperature):NaN};
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1180,6 +1256,7 @@ async function handleHistoryAction(button) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadLogs() {
|
async function loadLogs() {
|
||||||
|
renderLogRetention();
|
||||||
try {
|
try {
|
||||||
const data = await api('/api/events?limit=150');
|
const data = await api('/api/events?limit=150');
|
||||||
const logs = data.events || [];
|
const logs = data.events || [];
|
||||||
@@ -1195,21 +1272,21 @@ function connectWebSocket() {
|
|||||||
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
const query = app.token ? `?token=${encodeURIComponent(app.token)}` : '';
|
const query = app.token ? `?token=${encodeURIComponent(app.token)}` : '';
|
||||||
const ws = new WebSocket(`${protocol}//${location.host}/ws${query}`); app.ws = ws;
|
const ws = new WebSocket(`${protocol}//${location.host}/ws${query}`); app.ws = ws;
|
||||||
ws.onopen = () => { app.connectionStatus = 'connected'; $('#connectionLabel').textContent = tr('status.connected'); };
|
ws.onopen = () => updateConnectionIndicator('connected');
|
||||||
ws.onclose = () => { app.connectionStatus = 'disconnected'; $('#connectionLabel').textContent = tr('status.disconnected'); app.wsTimer = setTimeout(connectWebSocket, 3000); };
|
ws.onclose = () => { updateConnectionIndicator('disconnected'); app.wsTimer = setTimeout(connectWebSocket, 3000); };
|
||||||
ws.onerror = () => { app.connectionStatus = 'connectionError'; $('#connectionLabel').textContent = tr('status.connectionError'); };
|
ws.onerror = () => updateConnectionIndicator('connectionError');
|
||||||
ws.onmessage = event => {
|
ws.onmessage = event => {
|
||||||
try {
|
try {
|
||||||
const message = JSON.parse(event.data);
|
const message = JSON.parse(event.data);
|
||||||
if (message.event === 'bootstrap') {
|
if (message.event === 'bootstrap') {
|
||||||
const data=message.data; app.devices=data.devices||[]; app.zones=data.zones||[]; app.schedules=data.schedules||[]; app.automations=data.automations||[]; app.settings=data.settings||app.settings; app.system=data.system||app.system; app.outdoorTemperature=Number.isFinite(Number(data.outdoor_temperature))?Number(data.outdoor_temperature):app.outdoorTemperature; renderAll(); scheduleControlPlanLoad(); if(app.settings?.debug?.overlay_enabled) loadDebugBacklog(); return;
|
const data=message.data; app.devices=data.devices||[]; app.zones=data.zones||[]; app.schedules=data.schedules||[]; app.automations=data.automations||[]; app.settings=data.settings||app.settings; app.sensorAliases={...(app.settings?.home_assistant?.sensor_aliases||{})}; app.system=data.system||app.system; app.outdoorTemperature=Number.isFinite(Number(data.outdoor_temperature))?Number(data.outdoor_temperature):app.outdoorTemperature; renderAll(); scheduleControlPlanLoad(); if(app.settings?.debug?.overlay_enabled) loadDebugBacklog(); return;
|
||||||
}
|
}
|
||||||
const data = message.data || {};
|
const data = message.data || {};
|
||||||
if (['device.updated','device.created'].includes(message.event)) { updateDevice(data); renderSummary(); renderDevices(); scheduleControlPlanLoad(); }
|
if (['device.updated','device.created'].includes(message.event)) { updateDevice(data); renderSummary(); renderDevices(); scheduleControlPlanLoad(); }
|
||||||
else if (message.event === 'device.deleted') { app.devices=app.devices.filter(v=>v.id!==data.id); renderAll(); scheduleControlPlanLoad(); }
|
else if (message.event === 'device.deleted') { app.devices=app.devices.filter(v=>v.id!==data.id); renderAll(); scheduleControlPlanLoad(); }
|
||||||
else if (message.event === 'devices.discovered') { (data.devices||[]).forEach(updateDevice); renderAll(); scheduleControlPlanLoad(); }
|
else if (message.event === 'devices.discovered') { (data.devices||[]).forEach(updateDevice); renderAll(); scheduleControlPlanLoad(); }
|
||||||
else if (message.event === 'zone.updated') { const i=app.zones.findIndex(v=>v.id===data.id); if(i>=0) app.zones[i]=data; else app.zones.push(data); renderSummary(); renderHouseClimate(); renderZones(); scheduleControlPlanLoad(); }
|
else if (message.event === 'zone.updated') { const i=app.zones.findIndex(v=>v.id===data.id); if(i>=0) app.zones[i]=data; else app.zones.push(data); renderSummary(); renderHouseClimate(); renderZones(); scheduleControlPlanLoad(); }
|
||||||
else if (message.event === 'settings.updated') { app.settings=data; renderSettings(); renderHouseClimate(); renderDebugOverlay(); if(app.settings?.debug?.overlay_enabled) loadDebugBacklog(); scheduleControlPlanLoad(); }
|
else if (message.event === 'settings.updated') { app.settings=data; app.sensorAliases={...(app.settings?.home_assistant?.sensor_aliases||{})}; renderSettings(); renderHouseClimate(); renderDebugOverlay(); if(app.settings?.debug?.overlay_enabled) loadDebugBacklog(); scheduleControlPlanLoad(); }
|
||||||
else if (message.event === 'debug.settings') { app.settings = app.settings || {}; app.settings.debug = data; renderSettings(); renderDebugOverlay(); if(data.overlay_enabled) loadDebugBacklog(); }
|
else if (message.event === 'debug.settings') { app.settings = app.settings || {}; app.settings.debug = data; renderSettings(); renderDebugOverlay(); if(data.overlay_enabled) loadDebugBacklog(); }
|
||||||
else if (message.event === 'outdoor.updated') { app.outdoorTemperature=Number.isFinite(Number(data.temperature))?Number(data.temperature):null; renderHouseClimate(); scheduleControlPlanLoad(); }
|
else if (message.event === 'outdoor.updated') { app.outdoorTemperature=Number.isFinite(Number(data.temperature))?Number(data.temperature):null; renderHouseClimate(); scheduleControlPlanLoad(); }
|
||||||
else if (message.event === 'gree.frame') { if(app.settings?.debug?.overlay_enabled) debugLine('GREE', `${data.direction || '?'} ${data.protocol_version || ''}`, data.device_name || data.device_id || data.target || '', message.timestamp, data.payload); }
|
else if (message.event === 'gree.frame') { if(app.settings?.debug?.overlay_enabled) debugLine('GREE', `${data.direction || '?'} ${data.protocol_version || ''}`, data.device_name || data.device_id || data.target || '', message.timestamp, data.payload); }
|
||||||
@@ -1378,7 +1455,12 @@ function settingsBodyFromForm(form) {
|
|||||||
discovery_timeout_ms:Number(raw.discovery_timeout_ms), discovery_broadcast:raw.discovery_broadcast,
|
discovery_timeout_ms:Number(raw.discovery_timeout_ms), discovery_broadcast:raw.discovery_broadcast,
|
||||||
house_mode:app.settings?.house_mode||'cool', control_strategy:'setpoint', outdoor_assist_enabled:form.outdoor_assist_enabled.checked,
|
house_mode:app.settings?.house_mode||'cool', control_strategy:'setpoint', outdoor_assist_enabled:form.outdoor_assist_enabled.checked,
|
||||||
history_retention_days:Number(raw.history_retention_days), history_compaction_enabled:form.history_compaction_enabled.checked,
|
history_retention_days:Number(raw.history_retention_days), history_compaction_enabled:form.history_compaction_enabled.checked,
|
||||||
|
event_log_retention_days:Number(raw.event_log_retention_days),
|
||||||
suppress_device_beep:form.suppress_device_beep.checked,
|
suppress_device_beep:form.suppress_device_beep.checked,
|
||||||
|
night_mode:{
|
||||||
|
enabled:form.night_mode_enabled.checked,start_time:raw.night_mode_start,end_time:raw.night_mode_end,
|
||||||
|
max_fan_speed:Number(raw.night_mode_max_fan_speed),force_quiet:form.night_mode_force_quiet.checked,
|
||||||
|
},
|
||||||
influxdb:{
|
influxdb:{
|
||||||
enabled:form.influx_enabled.checked, version:raw.influx_version, url:raw.influx_url,
|
enabled:form.influx_enabled.checked, version:raw.influx_version, url:raw.influx_url,
|
||||||
database:raw.influx_database, username:raw.influx_username, password:raw.influx_password,
|
database:raw.influx_database, username:raw.influx_username, password:raw.influx_password,
|
||||||
@@ -1386,18 +1468,49 @@ function settingsBodyFromForm(form) {
|
|||||||
history_threshold_days:Number(raw.influx_threshold_days),
|
history_threshold_days:Number(raw.influx_threshold_days),
|
||||||
},
|
},
|
||||||
debug:{overlay_enabled:form.debug_overlay_enabled.checked, gree_frames:form.debug_gree_frames.checked},
|
debug:{overlay_enabled:form.debug_overlay_enabled.checked, gree_frames:form.debug_gree_frames.checked},
|
||||||
home_assistant:{url:raw.ha_url,token:raw.ha_token,default_entity_id:raw.ha_entity_id,outdoor_entity_id:raw.ha_outdoor_entity_id,allow_invalid_tls:form.ha_allow_invalid_tls.checked},
|
home_assistant:{url:raw.ha_url,token:raw.ha_token,default_entity_id:raw.ha_entity_id,outdoor_entity_id:raw.ha_outdoor_entity_id,allow_invalid_tls:form.ha_allow_invalid_tls.checked,sensor_aliases:{...(app.sensorAliases||{})}},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveSettingsForm(form, notify=true) {
|
async function saveSettingsForm(form, notify=true) {
|
||||||
app.settings=await api('/api/settings',{method:'PUT',body:settingsBodyFromForm(form)}); renderSettings(); renderHouseClimate(); renderDebugOverlay(); scheduleControlPlanLoad(); if(app.settings?.debug?.overlay_enabled) loadDebugBacklog(); if(notify) toast(tr('common.saved')); return app.settings;
|
app.settings=await api('/api/settings',{method:'PUT',body:settingsBodyFromForm(form)}); app.sensorAliases={...(app.settings?.home_assistant?.sensor_aliases||{})}; renderSettings(); renderHouseClimate(); renderDebugOverlay(); scheduleControlPlanLoad(); if(app.settings?.debug?.overlay_enabled) loadDebugBacklog(); if(notify) toast(tr('common.saved')); return app.settings;
|
||||||
}
|
}
|
||||||
|
|
||||||
$('#settingsForm').addEventListener('submit', async event => {
|
$('#settingsForm').addEventListener('submit', async event => {
|
||||||
event.preventDefault(); try { await saveSettingsForm(event.currentTarget, true); } catch(error){toast(error.message,true);}
|
event.preventDefault(); try { await saveSettingsForm(event.currentTarget, true); } catch(error){toast(error.message,true);}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$('#addSensorAlias')?.addEventListener('click', () => {
|
||||||
|
const entityInput = $('#sensorAliasEntity'), aliasInput = $('#sensorAliasName');
|
||||||
|
const entity = entityInput.value.trim(), alias = aliasInput.value.trim();
|
||||||
|
if (!entity || !alias) return toast(tr('settings.aliasRequired'), true);
|
||||||
|
app.sensorAliases[entity] = alias;
|
||||||
|
entityInput.value = ''; aliasInput.value = '';
|
||||||
|
renderSensorAliases(); renderHistoryNavigation();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('input', event => {
|
||||||
|
const input = event.target.closest('[data-sensor-alias]'); if (!input) return;
|
||||||
|
const entity = input.dataset.sensorAlias, alias = input.value.trim();
|
||||||
|
if (alias) app.sensorAliases[entity] = alias; else delete app.sensorAliases[entity];
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('click', event => {
|
||||||
|
const button = event.target.closest('[data-clear-sensor-alias]'); if (!button) return;
|
||||||
|
delete app.sensorAliases[button.dataset.clearSensorAlias];
|
||||||
|
renderSensorAliases(); renderHistoryNavigation();
|
||||||
|
});
|
||||||
|
|
||||||
|
$('#saveLogRetention')?.addEventListener('click', async () => {
|
||||||
|
const days = Number($('#logRetentionDays')?.value || 30);
|
||||||
|
try {
|
||||||
|
const result = await api('/api/events/retention', {method:'PUT', body:{days}});
|
||||||
|
app.settings.event_log_retention_days = result.days;
|
||||||
|
$('#settingsForm').event_log_retention_days.value = result.days;
|
||||||
|
renderLogRetention(); await loadLogs(); toast(tr('logs.retentionSaved', {days: result.days}));
|
||||||
|
} catch (error) { toast(error.message, true); }
|
||||||
|
});
|
||||||
|
|
||||||
$('#createAccessToken').addEventListener('click', async event => {
|
$('#createAccessToken').addEventListener('click', async event => {
|
||||||
const button = event.currentTarget;
|
const button = event.currentTarget;
|
||||||
button.disabled = true;
|
button.disabled = true;
|
||||||
|
|||||||
+107
-68
@@ -23,7 +23,7 @@
|
|||||||
<body>
|
<body>
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<div class="brand">
|
<div class="brand">
|
||||||
<div><strong>GREE Controller</strong><small id="connectionLabel" data-i18n="status.connecting">Connecting…</small></div>
|
<div class="brand-line"><strong>GREE Controller</strong><small id="connectionLabel" class="connection-dot connecting" role="status" aria-label="Connecting" title="Connecting"></small></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="top-actions">
|
<div class="top-actions">
|
||||||
<select class="toolbar-select" id="languageSelect" data-i18n-aria="controls.language" aria-label="Language">
|
<select class="toolbar-select" id="languageSelect" data-i18n-aria="controls.language" aria-label="Language">
|
||||||
@@ -93,14 +93,16 @@
|
|||||||
|
|
||||||
<section class="view" data-view="simulation">
|
<section class="view" data-view="simulation">
|
||||||
<div class="section-heading"><div><span class="eyebrow" data-i18n="simulation.eyebrow">Simulator</span><h1 data-i18n="simulation.title">Automation simulator</h1></div><button class="secondary" id="simulationRefresh" data-i18n="actions.refresh">Refresh</button></div>
|
<div class="section-heading"><div><span class="eyebrow" data-i18n="simulation.eyebrow">Simulator</span><h1 data-i18n="simulation.title">Automation simulator</h1></div><button class="secondary" id="simulationRefresh" data-i18n="actions.refresh">Refresh</button></div>
|
||||||
<p class="lead" data-i18n="simulation.description">Preview how the thermostat and automation logic currently reason about each zone: temperatures, targets, demand, standby behavior and the next scheduled events.</p>
|
<p class="lead" data-i18n="simulation.description">Live preview of the thermostat and automation logic. The flow board shows how sensor readings, schedules, night mode and thermostat decisions become commands sent to each GREE unit.</p>
|
||||||
<div class="simulation-summary-grid" id="simulationSummary"></div>
|
<div class="simulation-summary-grid" id="simulationSummary"></div>
|
||||||
|
<div class="panel simulation-flow-shell">
|
||||||
|
<div class="simulation-flow-head"><div><span class="eyebrow" data-i18n="simulation.flowEyebrow">Live flow</span><h2 data-i18n="simulation.flowTitle">Automation flow board</h2></div><div class="flow-legend"><span><i class="flow-legend-dot input"></i><b data-i18n="simulation.legendInput">Input</b></span><span><i class="flow-legend-dot logic"></i><b data-i18n="simulation.legendLogic">Logic</b></span><span><i class="flow-legend-dot action"></i><b data-i18n="simulation.legendAction">Action</b></span></div></div>
|
||||||
|
<div class="simulation-flow-scroll"><div class="simulation-flow-board" id="simulationFlowBoard"></div></div>
|
||||||
|
</div>
|
||||||
<div class="panel simulation-timeline-panel">
|
<div class="panel simulation-timeline-panel">
|
||||||
<div class="simulation-section-head"><div><span class="eyebrow" data-i18n="simulation.timelineEyebrow">Timeline</span><h2 data-i18n="simulation.timelineTitle">What will happen next</h2></div></div>
|
<div class="simulation-section-head"><div><span class="eyebrow" data-i18n="simulation.timelineEyebrow">Timeline</span><h2 data-i18n="simulation.timelineTitle">What will happen next</h2></div></div>
|
||||||
<div class="simulation-timeline" id="simulationTimeline"></div>
|
<div class="simulation-timeline" id="simulationTimeline"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="simulation-section-head"><div><span class="eyebrow" data-i18n="simulation.zoneEyebrow">Zones</span><h2 data-i18n="simulation.zoneTitle">Thermostat decision flow</h2></div></div>
|
|
||||||
<div class="simulation-zone-grid" id="simulationZones"></div>
|
|
||||||
<div class="simulation-section-head"><div><span class="eyebrow" data-i18n="simulation.rulesEyebrow">Rules</span><h2 data-i18n="simulation.rulesTitle">Automation rules</h2></div></div>
|
<div class="simulation-section-head"><div><span class="eyebrow" data-i18n="simulation.rulesEyebrow">Rules</span><h2 data-i18n="simulation.rulesTitle">Automation rules</h2></div></div>
|
||||||
<div class="simulation-rules-grid" id="simulationRules"></div>
|
<div class="simulation-rules-grid" id="simulationRules"></div>
|
||||||
</section>
|
</section>
|
||||||
@@ -117,15 +119,15 @@
|
|||||||
<div class="panel chart-panel history-toolbar-panel">
|
<div class="panel chart-panel history-toolbar-panel">
|
||||||
<div class="chart-toolbar">
|
<div class="chart-toolbar">
|
||||||
<div id="historyContextControls" class="history-context-controls"></div>
|
<div id="historyContextControls" class="history-context-controls"></div>
|
||||||
<select id="historyHours" aria-label="History range">
|
<label class="history-range-control"><span data-i18n="history.range">Range</span><select id="historyHours" aria-label="History range">
|
||||||
<option value="6" data-i18n="history.6h">6 hours</option>
|
<option value="6" data-i18n="history.6h">6 hours</option>
|
||||||
<option value="24" selected data-i18n="history.24h">24 hours</option>
|
<option value="24" selected data-i18n="history.24h">24 hours</option>
|
||||||
<option value="168" data-i18n="history.7d">7 days</option>
|
<option value="168" data-i18n="history.7d">7 days</option>
|
||||||
<option value="720" data-i18n="history.30d">30 days</option>
|
<option value="720" data-i18n="history.30d">30 days</option>
|
||||||
<option value="2160" data-i18n="history.90d">90 days</option>
|
<option value="2160" data-i18n="history.90d">90 days</option>
|
||||||
<option value="8760" data-i18n="history.1y">1 year</option>
|
<option value="8760" data-i18n="history.1y">1 year</option>
|
||||||
</select>
|
</select></label>
|
||||||
<button class="secondary" id="historyRefresh" data-i18n="actions.refresh">Refresh</button>
|
<button class="secondary history-refresh-button" id="historyRefresh" data-i18n="actions.refresh">Refresh</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="history-summary" id="historySummary"></div>
|
<div class="history-summary" id="historySummary"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -135,72 +137,109 @@
|
|||||||
|
|
||||||
<section class="view" data-view="settings">
|
<section class="view" data-view="settings">
|
||||||
<div class="section-heading"><div><span class="eyebrow" data-i18n="settings.system">System</span><h1 data-i18n="nav.settings">Settings</h1></div></div>
|
<div class="section-heading"><div><span class="eyebrow" data-i18n="settings.system">System</span><h1 data-i18n="nav.settings">Settings</h1></div></div>
|
||||||
<form class="panel form-grid" id="settingsForm">
|
<form class="settings-form" id="settingsForm">
|
||||||
<h3 data-i18n="settings.controller">Controller</h3>
|
<section class="panel settings-block">
|
||||||
<label><span data-i18n="settings.clientId">Client identifier</span><input name="controller_id" required></label>
|
<div class="settings-block-head"><span class="settings-block-icon">⚙</span><div><h3 data-i18n="settings.controller">Controller</h3><p data-i18n="settings.controllerHint">Runtime and LAN discovery settings.</p></div></div>
|
||||||
<label><span data-i18n="settings.pollInterval">Poll interval (s)</span><input type="number" name="poll_interval_seconds" min="2" max="3600" required></label>
|
<div class="settings-grid">
|
||||||
<label><span data-i18n="settings.zoneInterval">Zone interval (s)</span><input type="number" name="zone_interval_seconds" min="2" max="3600" required></label>
|
<label><span data-i18n="settings.clientId">Client identifier</span><input name="controller_id" required></label>
|
||||||
<label><span data-i18n="settings.broadcast">Broadcast address</span><input name="discovery_broadcast" placeholder="255.255.255.255:7000" required></label>
|
<label><span data-i18n="settings.pollInterval">Poll interval (s)</span><input type="number" name="poll_interval_seconds" min="2" max="3600" required></label>
|
||||||
<label><span data-i18n="settings.discoveryTimeout">Discovery timeout (ms)</span><input type="number" name="discovery_timeout_ms" min="300" max="30000" required></label>
|
<label><span data-i18n="settings.zoneInterval">Zone interval (s)</span><input type="number" name="zone_interval_seconds" min="2" max="3600" required></label>
|
||||||
<label class="check"><input type="checkbox" name="simulator_enabled"> <span data-i18n="settings.simulationMode">Simulation mode</span></label>
|
<label><span data-i18n="settings.broadcast">Broadcast address</span><input name="discovery_broadcast" placeholder="255.255.255.255:7000" required></label>
|
||||||
<hr>
|
<label><span data-i18n="settings.discoveryTimeout">Discovery timeout (ms)</span><input type="number" name="discovery_timeout_ms" min="300" max="30000" required></label>
|
||||||
<h3 data-i18n="settings.metrics">Metrics storage</h3>
|
<label class="check"><input type="checkbox" name="simulator_enabled"> <span data-i18n="settings.simulationMode">Simulation mode</span></label>
|
||||||
<p class="field-note wide" data-i18n="settings.compactionHint">SQLite keeps recent data locally and compacts older samples to the resolution used by charts.</p>
|
</div>
|
||||||
<label><span data-i18n="settings.retentionDays">Local retention (days)</span><input type="number" name="history_retention_days" min="1" max="3650" required></label>
|
</section>
|
||||||
<label class="check"><input type="checkbox" name="history_compaction_enabled"> <span data-i18n="settings.compaction">Compact old metrics</span></label>
|
|
||||||
<hr>
|
<section class="panel settings-block">
|
||||||
<h3 data-i18n="settings.greeCommands">GREE commands</h3>
|
<div class="settings-block-head"><span class="settings-block-icon">☾</span><div><h3 data-i18n="settings.nightMode">Night mode</h3><p data-i18n="settings.nightModeHint">During selected hours the thermostat limits fan speed and can request Quiet on supported units.</p></div></div>
|
||||||
<label class="check wide"><input type="checkbox" name="suppress_device_beep"> <span data-i18n="settings.suppressBeep">Try to suppress command beeps</span></label>
|
<div class="settings-grid">
|
||||||
<p class="field-note wide" data-i18n="settings.suppressBeepHint">Only changed properties are sent. Buzzer suppression is also requested when supported by the unit firmware.</p>
|
<label class="check wide"><input type="checkbox" name="night_mode_enabled"> <span data-i18n="settings.nightModeEnabled">Enable night mode</span></label>
|
||||||
<hr>
|
<label><span data-i18n="settings.nightStart">Start</span><input type="time" name="night_mode_start" value="22:00"></label>
|
||||||
<h3 data-i18n="settings.influx">Long-term InfluxDB history</h3>
|
<label><span data-i18n="settings.nightEnd">End</span><input type="time" name="night_mode_end" value="06:00"></label>
|
||||||
<p class="field-note wide" data-i18n="settings.influxHint">Optional archive for older history. InfluxDB 1.x and 2.x are supported.</p>
|
<label><span data-i18n="settings.nightMaxFan">Maximum fan speed</span><select name="night_mode_max_fan_speed"><option value="1" data-i18n="fan.low">Low</option><option value="2" data-i18n="fan.mediumLow">Medium-low</option><option value="3" data-i18n="fan.medium">Medium</option><option value="4" data-i18n="fan.mediumHigh">Medium-high</option><option value="5" data-i18n="fan.high">High</option></select></label>
|
||||||
<label class="check wide"><input type="checkbox" name="influx_enabled"> <span data-i18n="settings.influxEnabled">Enable InfluxDB archive</span></label>
|
<label class="check"><input type="checkbox" name="night_mode_force_quiet"> <span data-i18n="settings.nightForceQuiet">Use Quiet when supported</span></label>
|
||||||
<label><span data-i18n="settings.influxVersion">InfluxDB version</span><select name="influx_version"><option value="1">1.x</option><option value="2">2.x</option></select></label>
|
</div>
|
||||||
<label><span data-i18n="settings.influxThreshold">Use archive for history older than (days)</span><input type="number" name="influx_threshold_days" min="1" max="3650" value="30"></label>
|
</section>
|
||||||
<label class="wide"><span>URL</span><input type="url" name="influx_url" placeholder="http://influxdb:8086"></label>
|
|
||||||
<div class="wide influx-fields" data-influx-fields="1">
|
<section class="panel settings-block">
|
||||||
<label><span data-i18n="settings.influxDatabase">Database</span><input name="influx_database" placeholder="gree_controller"></label>
|
<div class="settings-block-head"><span class="settings-block-icon">▤</span><div><h3 data-i18n="settings.metricsAndLogs">Metrics and logs</h3><p data-i18n="settings.compactionHint">SQLite keeps recent data locally and compacts older samples to the resolution used by charts.</p></div></div>
|
||||||
<label><span data-i18n="settings.influxUsername">Username</span><input name="influx_username"></label>
|
<div class="settings-grid">
|
||||||
<label><span data-i18n="settings.influxPassword">Password</span><input type="password" name="influx_password" autocomplete="new-password" data-i18n-placeholder="settings.secretKeep" placeholder="Leave empty to keep saved secret"></label>
|
<label><span data-i18n="settings.retentionDays">Local metric retention (days)</span><input type="number" name="history_retention_days" min="1" max="3650" required></label>
|
||||||
</div>
|
<label><span data-i18n="settings.eventRetentionDays">Event/log retention (days)</span><input type="number" name="event_log_retention_days" min="1" max="3650" required></label>
|
||||||
<div class="wide influx-fields" data-influx-fields="2">
|
<label class="check wide"><input type="checkbox" name="history_compaction_enabled"> <span data-i18n="settings.compaction">Compact old metrics</span></label>
|
||||||
<label><span data-i18n="settings.influxOrg">Organization</span><input name="influx_org"></label>
|
</div>
|
||||||
<label><span data-i18n="settings.influxBucket">Bucket</span><input name="influx_bucket" placeholder="gree_controller"></label>
|
</section>
|
||||||
<label><span data-i18n="settings.influxToken">Token</span><input type="password" name="influx_token" autocomplete="new-password" data-i18n-placeholder="settings.secretKeep" placeholder="Leave empty to keep saved secret"></label>
|
|
||||||
</div>
|
<section class="panel settings-block">
|
||||||
<hr>
|
<div class="settings-block-head"><span class="settings-block-icon">◌</span><div><h3 data-i18n="settings.greeCommands">GREE commands</h3><p data-i18n="settings.suppressBeepHint">Only changed properties are sent. Buzzer suppression is also requested when supported by the unit firmware.</p></div></div>
|
||||||
<h3 data-i18n="settings.debug">On-screen debug</h3>
|
<div class="settings-grid"><label class="check wide"><input type="checkbox" name="suppress_device_beep"> <span data-i18n="settings.suppressBeep">Try to suppress command beeps</span></label></div>
|
||||||
<label class="check"><input type="checkbox" name="debug_overlay_enabled"> <span data-i18n="settings.debugOverlay">Show debug window on every page</span></label>
|
</section>
|
||||||
<label class="check"><input type="checkbox" name="debug_gree_frames"> <span data-i18n="settings.debugGreeFrames">Include GREE protocol frames</span></label>
|
|
||||||
<hr>
|
<section class="panel settings-block">
|
||||||
<h3 data-i18n="settings.haSensorInput">Home Assistant sensor input</h3>
|
<div class="settings-block-head"><span class="settings-block-icon">∞</span><div><h3 data-i18n="settings.influx">Long-term InfluxDB history</h3><p data-i18n="settings.influxHint">Optional archive for older history. InfluxDB 1.x and 2.x are supported.</p></div></div>
|
||||||
<p class="field-note wide" data-i18n="settings.haSensorInputHint">Optional. Used only when a room zone reads an external Home Assistant temperature sensor.</p>
|
<div class="settings-grid">
|
||||||
<label class="wide"><span>URL</span><input type="url" name="ha_url" placeholder="http://homeassistant.local:8123"></label>
|
<label class="check wide"><input type="checkbox" name="influx_enabled"> <span data-i18n="settings.influxEnabled">Enable InfluxDB archive</span></label>
|
||||||
<label class="wide"><span data-i18n="settings.haLongLivedToken">Long-Lived Access Token</span><input type="password" name="ha_token" autocomplete="new-password" data-i18n-placeholder="settings.haTokenKeep" placeholder="Leave empty to keep the saved token"></label>
|
<label><span data-i18n="settings.influxVersion">InfluxDB version</span><select name="influx_version"><option value="1">1.x</option><option value="2">2.x</option></select></label>
|
||||||
<label class="wide"><span data-i18n="settings.defaultEntity">Default room entity_id</span><input name="ha_entity_id" placeholder="sensor.living_room_temperature"></label>
|
<label><span data-i18n="settings.influxThreshold">Use archive for history older than (days)</span><input type="number" name="influx_threshold_days" min="1" max="3650" value="30"></label>
|
||||||
<label class="wide"><span data-i18n="settings.outdoorEntity">Outdoor temperature entity_id</span><input name="ha_outdoor_entity_id" placeholder="sensor.outdoor_temperature"></label>
|
<label class="wide"><span>URL</span><input type="url" name="influx_url" placeholder="http://influxdb:8086"></label>
|
||||||
<label class="check wide"><input type="checkbox" name="outdoor_assist_enabled"> <span data-i18n="settings.outdoorAssist">Use outdoor temperature as smart-control assist</span></label>
|
<div class="wide influx-fields" data-influx-fields="1">
|
||||||
<label class="check wide"><input type="checkbox" name="ha_allow_invalid_tls"> <span data-i18n="settings.allowInvalidTls">Allow invalid/self-signed HTTPS certificate</span></label>
|
<label><span data-i18n="settings.influxDatabase">Database</span><input name="influx_database" placeholder="gree_controller"></label>
|
||||||
<p class="field-note wide warning-note" data-i18n="settings.allowInvalidTlsHint">Use only for a trusted local Home Assistant server, for example https://10.87.65.2.</p>
|
<label><span data-i18n="settings.influxUsername">Username</span><input name="influx_username"></label>
|
||||||
<div class="form-actions wide"><button type="button" class="secondary" id="haTest" data-i18n="settings.testHa">Test HA</button><button class="primary" type="submit" data-i18n="actions.save">Save</button></div>
|
<label><span data-i18n="settings.influxPassword">Password</span><input type="password" name="influx_password" autocomplete="new-password" data-i18n-placeholder="settings.secretKeep" placeholder="Leave empty to keep saved secret"></label>
|
||||||
<hr>
|
</div>
|
||||||
<h3 data-i18n="settings.haIntegrationAccess">Home Assistant integration access</h3>
|
<div class="wide influx-fields" data-influx-fields="2">
|
||||||
<p class="field-note wide" data-i18n="settings.haIntegrationHint">Create a controller token and paste it into the GREE Controller integration in Home Assistant.</p>
|
<label><span data-i18n="settings.influxOrg">Organization</span><input name="influx_org"></label>
|
||||||
<div class="wide token-manager">
|
<label><span data-i18n="settings.influxBucket">Bucket</span><input name="influx_bucket" placeholder="gree_controller"></label>
|
||||||
<div id="accessTokenList" class="token-list"></div>
|
<label><span data-i18n="settings.influxToken">Token</span><input type="password" name="influx_token" autocomplete="new-password" data-i18n-placeholder="settings.secretKeep" placeholder="Leave empty to keep saved secret"></label>
|
||||||
<div class="form-actions"><button type="button" class="primary" id="createAccessToken" data-i18n="settings.newToken">Create new token</button></div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<hr>
|
</section>
|
||||||
<h3 data-i18n="settings.backup">Configuration backup</h3>
|
|
||||||
<p class="field-note wide warning-note" data-i18n="settings.backupHint">Export/import application configuration. Exported files can contain GREE device keys plus Home Assistant and InfluxDB secrets; metrics and API access tokens are not included.</p>
|
<section class="panel settings-block">
|
||||||
<div class="form-actions wide backup-actions"><button type="button" class="secondary" id="exportSettings" data-i18n="settings.export">Export settings</button><button type="button" class="secondary" id="importSettings" data-i18n="settings.import">Import settings</button><input type="file" id="importSettingsFile" accept="application/json,.json" hidden></div>
|
<div class="settings-block-head"><span class="settings-block-icon">⌁</span><div><h3 data-i18n="settings.debug">On-screen debug</h3><p data-i18n="settings.debugHint">Live diagnostics displayed on every application page.</p></div></div>
|
||||||
|
<div class="settings-grid">
|
||||||
|
<label class="check"><input type="checkbox" name="debug_overlay_enabled"> <span data-i18n="settings.debugOverlay">Show debug window on every page</span></label>
|
||||||
|
<label class="check"><input type="checkbox" name="debug_gree_frames"> <span data-i18n="settings.debugGreeFrames">Include GREE protocol frames</span></label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel settings-block">
|
||||||
|
<div class="settings-block-head"><span class="settings-block-icon">HA</span><div><h3 data-i18n="settings.haSensorInput">Home Assistant sensor input</h3><p data-i18n="settings.haSensorInputHint">Optional. Used when a room zone reads an external Home Assistant temperature sensor.</p></div></div>
|
||||||
|
<div class="settings-grid">
|
||||||
|
<label class="wide"><span>URL</span><input type="url" name="ha_url" placeholder="http://homeassistant.local:8123"></label>
|
||||||
|
<label class="wide"><span data-i18n="settings.haLongLivedToken">Long-Lived Access Token</span><input type="password" name="ha_token" autocomplete="new-password" data-i18n-placeholder="settings.haTokenKeep" placeholder="Leave empty to keep the saved token"></label>
|
||||||
|
<label class="wide"><span data-i18n="settings.defaultEntity">Default room entity_id</span><input name="ha_entity_id" placeholder="sensor.living_room_temperature"></label>
|
||||||
|
<label class="wide"><span data-i18n="settings.outdoorEntity">Outdoor temperature entity_id</span><input name="ha_outdoor_entity_id" placeholder="sensor.outdoor_temperature"></label>
|
||||||
|
<label class="check wide"><input type="checkbox" name="outdoor_assist_enabled"> <span data-i18n="settings.outdoorAssist">Use outdoor temperature as smart-control assist</span></label>
|
||||||
|
<label class="check wide"><input type="checkbox" name="ha_allow_invalid_tls"> <span data-i18n="settings.allowInvalidTls">Allow invalid/self-signed HTTPS certificate</span></label>
|
||||||
|
<p class="field-note wide warning-note" data-i18n="settings.allowInvalidTlsHint">Use only for a trusted local Home Assistant server, for example https://192.168.50.25.</p>
|
||||||
|
<div class="wide sensor-alias-manager">
|
||||||
|
<div class="settings-subhead"><div><strong data-i18n="settings.sensorAliases">Sensor aliases</strong><small data-i18n="settings.sensorAliasesHint">Friendly names are used in charts and selectors; the original entity_id remains unchanged.</small></div></div>
|
||||||
|
<div id="sensorAliasList" class="sensor-alias-list"></div>
|
||||||
|
<div class="sensor-alias-add"><input id="sensorAliasEntity" placeholder="sensor.gabinet_temperature"><input id="sensorAliasName" data-i18n-placeholder="settings.aliasPlaceholder" placeholder="Gabinet"><button type="button" class="secondary" id="addSensorAlias" data-i18n="actions.add">Add</button></div>
|
||||||
|
</div>
|
||||||
|
<div class="form-actions wide"><button type="button" class="secondary" id="haTest" data-i18n="settings.testHa">Test HA</button></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel settings-block">
|
||||||
|
<div class="settings-block-head"><span class="settings-block-icon">⌘</span><div><h3 data-i18n="settings.haIntegrationAccess">Home Assistant integration access</h3><p data-i18n="settings.haIntegrationHint">Create a controller token and paste it into the GREE Controller integration in Home Assistant.</p></div></div>
|
||||||
|
<div class="token-manager"><div id="accessTokenList" class="token-list"></div><div class="form-actions"><button type="button" class="primary" id="createAccessToken" data-i18n="settings.newToken">Create new token</button></div></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel settings-block">
|
||||||
|
<div class="settings-block-head"><span class="settings-block-icon">⇅</span><div><h3 data-i18n="settings.backup">Configuration backup</h3><p class="warning-note" data-i18n="settings.backupHint">Export/import application configuration. Exported files can contain GREE device keys plus Home Assistant and InfluxDB secrets; metrics and API access tokens are not included.</p></div></div>
|
||||||
|
<div class="form-actions backup-actions"><button type="button" class="secondary" id="exportSettings" data-i18n="settings.export">Export settings</button><button type="button" class="secondary" id="importSettings" data-i18n="settings.import">Import settings</button><input type="file" id="importSettingsFile" accept="application/json,.json" hidden></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="settings-save-bar"><span data-i18n="settings.saveHint">Save changes made in the blocks above.</span><button class="primary" type="submit" data-i18n="actions.save">Save</button></div>
|
||||||
</form>
|
</form>
|
||||||
<div class="panel system-panel" id="systemInfo"></div>
|
<div class="panel system-panel" id="systemInfo"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="view" data-view="logs">
|
<section class="view" data-view="logs">
|
||||||
<div class="section-heading"><div><span class="eyebrow" data-i18n="logs.diagnostics">Diagnostics</span><h1 data-i18n="nav.logs">Events</h1></div><button class="secondary" id="logsRefresh" data-i18n="actions.refresh">Refresh</button></div>
|
<div class="section-heading"><div><span class="eyebrow" data-i18n="logs.diagnostics">Diagnostics</span><h1 data-i18n="nav.logs">Events</h1></div><button class="secondary" id="logsRefresh" data-i18n="actions.refresh">Refresh</button></div>
|
||||||
|
<div class="panel logs-toolbar"><div><strong data-i18n="logs.retention">Log retention</strong><small data-i18n="logs.retentionHint">Old event rows are removed automatically during maintenance.</small></div><label><span data-i18n="logs.keepFor">Keep for</span><select id="logRetentionDays"><option value="7">7 days</option><option value="14">14 days</option><option value="30">30 days</option><option value="90">90 days</option><option value="180">180 days</option><option value="365">365 days</option></select></label><button class="secondary" id="saveLogRetention" data-i18n="actions.save">Save</button></div>
|
||||||
<div class="panel log-list" id="logList"></div>
|
<div class="panel log-list" id="logList"></div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
@@ -260,7 +299,7 @@
|
|||||||
<dialog id="renameDeviceDialog">
|
<dialog id="renameDeviceDialog">
|
||||||
<form method="dialog" id="renameDeviceForm" class="dialog-form">
|
<form method="dialog" id="renameDeviceForm" class="dialog-form">
|
||||||
<input type="hidden" name="id">
|
<input type="hidden" name="id">
|
||||||
<div class="dialog-head"><h2 data-i18n="devices.rename">Rename device</h2><button type="button" data-close>×</button></div>
|
<div class="dialog-head"><h2 data-i18n="devices.nameProtocol">Name / protocol</h2><button type="button" data-close>×</button></div>
|
||||||
<label><span data-i18n="common.name">Name</span><input name="name" required maxlength="80"></label>
|
<label><span data-i18n="common.name">Name</span><input name="name" required maxlength="80"></label>
|
||||||
<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><option value="2">V2 AES-GCM</option></select></label>
|
<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><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>
|
<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>
|
||||||
|
|||||||
@@ -506,3 +506,102 @@ html[data-theme='light'] .simulation-metrics > div, html[data-theme='light'] .si
|
|||||||
.simulation-timeline-item { grid-template-columns:1fr; gap:6px; }
|
.simulation-timeline-item { grid-template-columns:1fr; gap:6px; }
|
||||||
.simulation-flow, .simulation-metrics { grid-template-columns:1fr; }
|
.simulation-flow, .simulation-metrics { grid-template-columns:1fr; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Compact connection state: a colored dot replaces the Connected/Disconnected label. */
|
||||||
|
.brand-line { display:flex; align-items:center; gap:8px; min-width:0; }
|
||||||
|
.brand-line strong { display:block; }
|
||||||
|
.connection-dot { display:inline-block !important; width:9px; height:9px; flex:0 0 9px; margin:0 !important; border-radius:50%; background:var(--muted); box-shadow:0 0 0 3px color-mix(in srgb, var(--muted) 15%, transparent); }
|
||||||
|
.connection-dot.connected { background:#37c96b; box-shadow:0 0 0 3px rgba(55,201,107,.14), 0 0 10px rgba(55,201,107,.3); }
|
||||||
|
.connection-dot.disconnected, .connection-dot.connectionError { background:#ef5b5b; box-shadow:0 0 0 3px rgba(239,91,91,.14), 0 0 10px rgba(239,91,91,.24); }
|
||||||
|
.connection-dot.connecting { background:#e2a93b; box-shadow:0 0 0 3px rgba(226,169,59,.14); animation:connection-pulse 1.25s ease-in-out infinite; }
|
||||||
|
@keyframes connection-pulse { 50% { opacity:.45; transform:scale(.86); } }
|
||||||
|
|
||||||
|
/* v0.5.3 usability refinements */
|
||||||
|
.history-toolbar-panel .chart-toolbar { display:grid; grid-template-columns:minmax(260px,1fr) minmax(180px,.55fr) auto; gap:12px; align-items:end; }
|
||||||
|
.history-toolbar-panel .chart-toolbar > * { min-width:0; }
|
||||||
|
.history-context-controls { display:block; width:100%; min-width:0; }
|
||||||
|
.history-context-controls label, .history-range-control { width:100%; }
|
||||||
|
.history-range-control { display:grid; gap:7px; }
|
||||||
|
.history-refresh-button { min-height:45px; align-self:end; }
|
||||||
|
|
||||||
|
.settings-form { display:grid; gap:14px; }
|
||||||
|
.settings-block { display:grid; gap:18px; }
|
||||||
|
.settings-block-head { display:flex; align-items:flex-start; gap:13px; }
|
||||||
|
.settings-block-head h3 { margin:1px 0 4px; font-size:18px; }
|
||||||
|
.settings-block-head p { margin:0; color:var(--muted); font-size:12px; line-height:1.5; }
|
||||||
|
.settings-block-icon { display:grid; place-items:center; width:34px; height:34px; flex:0 0 34px; border:1px solid var(--line); border-radius:11px; color:var(--accent); background:var(--surface-muted); font-size:14px; font-weight:800; }
|
||||||
|
.settings-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:14px; }
|
||||||
|
.settings-grid .wide { grid-column:1/-1; }
|
||||||
|
.settings-subhead { display:flex; justify-content:space-between; gap:12px; margin-bottom:10px; }
|
||||||
|
.settings-subhead strong, .settings-subhead small { display:block; }
|
||||||
|
.settings-subhead small { margin-top:4px; color:var(--muted); font-size:11px; line-height:1.4; }
|
||||||
|
.settings-save-bar { position:sticky; bottom:18px; z-index:9; display:flex; align-items:center; justify-content:space-between; gap:14px; padding:13px 15px; border:1px solid var(--line-strong); border-radius:16px; background:color-mix(in srgb,var(--surface) 94%,transparent); box-shadow:0 8px 30px rgba(0,0,0,.18); backdrop-filter:blur(16px); }
|
||||||
|
.settings-save-bar span { color:var(--muted); font-size:12px; }
|
||||||
|
.sensor-alias-manager { padding:14px; border:1px solid var(--line); border-radius:16px; background:var(--surface-muted); }
|
||||||
|
.sensor-alias-list { display:grid; gap:8px; }
|
||||||
|
.sensor-alias-row { display:grid; grid-template-columns:minmax(180px,1fr) minmax(150px,1fr) 34px; gap:8px; align-items:center; }
|
||||||
|
.sensor-alias-row > span { overflow:hidden; color:var(--muted); font-size:11px; text-overflow:ellipsis; white-space:nowrap; }
|
||||||
|
.sensor-alias-row input { min-height:40px; }
|
||||||
|
.sensor-alias-clear { width:34px; height:34px; padding:0; border-radius:10px; color:var(--muted); background:transparent; }
|
||||||
|
.sensor-alias-add { display:grid; grid-template-columns:minmax(180px,1fr) minmax(150px,1fr) auto; gap:8px; margin-top:10px; }
|
||||||
|
.logs-toolbar { display:grid; grid-template-columns:1fr minmax(160px,220px) auto; gap:14px; align-items:end; margin-bottom:14px; }
|
||||||
|
.logs-toolbar > div strong, .logs-toolbar > div small { display:block; }
|
||||||
|
.logs-toolbar > div small { margin-top:4px; color:var(--muted); font-size:11px; line-height:1.4; }
|
||||||
|
|
||||||
|
.simulation-flow-shell { padding:0; overflow:hidden; margin-bottom:18px; }
|
||||||
|
.simulation-flow-head { display:flex; align-items:flex-end; justify-content:space-between; gap:16px; padding:18px 18px 14px; border-bottom:1px solid var(--line); }
|
||||||
|
.simulation-flow-head h2 { margin:3px 0 0; }
|
||||||
|
.flow-legend { display:flex; flex-wrap:wrap; gap:12px; color:var(--muted); font-size:11px; }
|
||||||
|
.flow-legend span { display:flex; align-items:center; gap:6px; }
|
||||||
|
.flow-legend b { font-weight:650; }
|
||||||
|
.flow-legend-dot { width:8px; height:8px; border-radius:50%; background:var(--muted); }
|
||||||
|
.flow-legend-dot.input { background:var(--teal); }
|
||||||
|
.flow-legend-dot.logic { background:var(--purple); }
|
||||||
|
.flow-legend-dot.action { background:var(--accent); }
|
||||||
|
.simulation-flow-scroll { overflow:auto; max-width:100%; scrollbar-width:thin; }
|
||||||
|
.simulation-flow-board { position:relative; min-width:1325px; background-color:var(--surface-muted); background-image:radial-gradient(circle,var(--grid) 1px,transparent 1.5px); background-size:20px 20px; }
|
||||||
|
.flow-links { position:absolute; inset:0; z-index:1; pointer-events:none; overflow:visible; }
|
||||||
|
.flow-link { fill:none; stroke:color-mix(in srgb,var(--muted) 48%,transparent); stroke-width:2; vector-effect:non-scaling-stroke; }
|
||||||
|
.flow-link.bus { stroke-dasharray:5 6; opacity:.55; }
|
||||||
|
.flow-link.input-link { stroke:color-mix(in srgb,var(--teal) 65%,var(--muted)); }
|
||||||
|
.flow-link.logic-link { stroke:color-mix(in srgb,var(--purple) 58%,var(--muted)); }
|
||||||
|
.flow-link.action-link { stroke:color-mix(in srgb,var(--accent) 60%,var(--muted)); }
|
||||||
|
.flow-link.active-link { stroke:var(--accent); stroke-width:3; filter:drop-shadow(0 0 4px color-mix(in srgb,var(--accent) 35%,transparent)); }
|
||||||
|
.flow-link.night-link { stroke:var(--info); stroke-dasharray:6 5; stroke-width:2.5; }
|
||||||
|
.flow-lane-label { position:absolute; z-index:2; left:45px; width:1230px; display:flex; align-items:center; gap:9px; color:var(--muted); font-size:11px; }
|
||||||
|
.flow-lane-label::after { content:""; height:1px; flex:1; background:var(--line); }
|
||||||
|
.flow-lane-label strong { color:var(--text-soft); font-size:12px; }
|
||||||
|
.flow-node { position:absolute; z-index:3; display:grid; align-content:start; gap:6px; padding:12px 13px; border:1px solid var(--line-strong); border-radius:12px; background:color-mix(in srgb,var(--surface) 96%,transparent); box-shadow:0 8px 24px rgba(0,0,0,.15); }
|
||||||
|
.flow-node.input { border-left:3px solid var(--teal); }
|
||||||
|
.flow-node.logic { border-left:3px solid var(--purple); }
|
||||||
|
.flow-node.action { border-left:3px solid var(--accent); }
|
||||||
|
.flow-node.event { border-left:3px solid var(--info); }
|
||||||
|
.flow-node.decision.demand { box-shadow:0 0 0 1px color-mix(in srgb,var(--accent) 28%,transparent),0 8px 24px rgba(0,0,0,.15); }
|
||||||
|
.flow-node.global { min-height:106px; }
|
||||||
|
.flow-node.night-active { border-color:color-mix(in srgb,var(--info) 45%,var(--line-strong)); }
|
||||||
|
.flow-node-top { display:flex; align-items:center; justify-content:space-between; gap:6px; color:var(--muted); font-size:9px; font-weight:800; letter-spacing:.06em; text-transform:uppercase; }
|
||||||
|
.flow-node h3 { overflow:hidden; margin:0; color:var(--text); font-size:13px; text-overflow:ellipsis; white-space:nowrap; }
|
||||||
|
.flow-node > strong { color:var(--text); font-size:17px; line-height:1.25; }
|
||||||
|
.flow-node p { display:-webkit-box; overflow:hidden; margin:0; color:var(--muted); font-size:10px; line-height:1.35; -webkit-box-orient:vertical; -webkit-line-clamp:2; }
|
||||||
|
.flow-node-badge { max-width:86px; overflow:hidden; padding:3px 6px; border-radius:999px; color:var(--muted); background:var(--surface-muted); font-size:8px; text-overflow:ellipsis; white-space:nowrap; }
|
||||||
|
.flow-node-badge.active { color:var(--accent); background:color-mix(in srgb,var(--accent) 12%,var(--surface)); }
|
||||||
|
.flow-port { position:absolute; top:50%; width:9px; height:9px; margin-top:-5px; border:2px solid var(--surface); border-radius:50%; background:var(--muted); }
|
||||||
|
.flow-port.in { left:-6px; }
|
||||||
|
.flow-port.out { right:-6px; }
|
||||||
|
.flow-node.input .flow-port { background:var(--teal); }
|
||||||
|
.flow-node.logic .flow-port { background:var(--purple); }
|
||||||
|
.flow-node.action .flow-port { background:var(--accent); }
|
||||||
|
.flow-node.event .flow-port { background:var(--info); }
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.history-toolbar-panel .chart-toolbar { grid-template-columns:1fr 1fr; }
|
||||||
|
.history-context-controls { grid-column:1/-1; }
|
||||||
|
.history-refresh-button { grid-column:auto; }
|
||||||
|
.settings-grid { grid-template-columns:1fr; }
|
||||||
|
.settings-grid .wide { grid-column:auto; }
|
||||||
|
.settings-save-bar { bottom:76px; }
|
||||||
|
.sensor-alias-row, .sensor-alias-add { grid-template-columns:1fr; }
|
||||||
|
.sensor-alias-clear { justify-self:end; }
|
||||||
|
.logs-toolbar { grid-template-columns:1fr; align-items:stretch; }
|
||||||
|
.simulation-flow-head { align-items:flex-start; flex-direction:column; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
const CACHE = 'gree-controller-v044';
|
const CACHE = 'gree-controller-v053';
|
||||||
const ASSETS = ['/', '/styles.css', '/app.js', '/favicon.svg', '/manifest.webmanifest', '/lang/index.json', '/lang/en.json'];
|
const ASSETS = ['/', '/styles.css', '/app.js', '/favicon.svg', '/manifest.webmanifest', '/lang/index.json', '/lang/en.json'];
|
||||||
self.addEventListener('install', event => event.waitUntil(caches.open(CACHE).then(cache => cache.addAll(ASSETS)).then(() => self.skipWaiting())));
|
self.addEventListener('install', event => event.waitUntil(caches.open(CACHE).then(cache => cache.addAll(ASSETS)).then(() => self.skipWaiting())));
|
||||||
self.addEventListener('activate', event => event.waitUntil(caches.keys().then(keys => Promise.all(keys.filter(key => key !== CACHE).map(key => caches.delete(key)))).then(() => self.clients.claim())));
|
self.addEventListener('activate', event => event.waitUntil(caches.keys().then(keys => Promise.all(keys.filter(key => key !== CACHE).map(key => caches.delete(key)))).then(() => self.clients.claim())));
|
||||||
|
|||||||
Reference in New Issue
Block a user