This commit is contained in:
Mateusz Gruszczyński
2026-08-24 15:20:29 +02:00
parent 4f70e36e89
commit 83f744e2cb
19 changed files with 744 additions and 176 deletions
+11 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+5 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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| {
+12
View File
@@ -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
View File
@@ -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]
+45
View File
@@ -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,
} }
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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>
+99
View File
@@ -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 -1
View File
@@ -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())));