v0.5.2
This commit is contained in:
+4
-2
@@ -1,11 +1,12 @@
|
|||||||
# GREE Controller v0.5.0 - build and validation report
|
# GREE Controller v0.5.2 - build and validation report
|
||||||
|
|
||||||
## Scope
|
## Scope
|
||||||
|
|
||||||
Version 0.5.0 focuses on dashboard thermostat control, metric retention/archiving, quieter GREE commands, diagnostics, configuration backup and a machine-readable automation plan shared by the Web UI and Home Assistant.
|
Version 0.5.2 includes the 0.5.0 dashboard/metrics/diagnostics release plus thermostat noise-control fixes.
|
||||||
|
|
||||||
## Implemented changes
|
## Implemented changes
|
||||||
|
|
||||||
|
- 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.
|
||||||
- Optional long-term InfluxDB archive supports both InfluxDB 1.x (database/basic auth) and 2.x (org/bucket/token), from persisted settings or environment variables.
|
- Optional long-term InfluxDB archive supports both InfluxDB 1.x (database/basic auth) and 2.x (org/bucket/token), from persisted settings or environment variables.
|
||||||
@@ -13,6 +14,7 @@ Version 0.5.0 focuses on dashboard thermostat control, metric retention/archivin
|
|||||||
- Maintenance backfills compacted legacy SQLite samples older than the configured Influx handoff age. A local row is deleted only after the Influx batch succeeds; archive failures keep the SQLite copy.
|
- Maintenance backfills compacted legacy SQLite samples older than the configured Influx handoff age. A local row is deleted only after the Influx batch succeeds; archive failures keep the SQLite copy.
|
||||||
- History requests crossing the handoff age merge old InfluxDB data with recent SQLite data and fall back to SQLite when the archive query fails.
|
- History requests crossing the handoff age merge old InfluxDB data with recent SQLite data and fall back to SQLite when the archive query fails.
|
||||||
- Device commands are reduced to fields that actually changed. Optional GREE buzzer suppression uses `Buzzer_ON_OFF`/`BuzzerCtrl`; incompatible devices automatically fall back to normal frames and are remembered for the running process.
|
- Device commands are reduced to fields that actually changed. Optional GREE buzzer suppression uses `Buzzer_ON_OFF`/`BuzzerCtrl`; incompatible devices automatically fall back to normal frames and are remembered for the running process.
|
||||||
|
- Smart Fan now uses Low speed (`WdSpd=1`) when a zone is satisfied. The thermostat sends standby setpoint + Low fan + optional `Quiet=1` in one GREE frame. If a unit rejects `Quiet`, the controller retries without it and remembers that capability fallback until restart; when demand resumes, controller-owned Quiet is released on the transition.
|
||||||
- Toast progress indicators have horizontal inset so they stay inside rounded toast corners.
|
- Toast progress indicators have horizontal inset so they stay inside rounded toast corners.
|
||||||
- Settings can be exported/imported as JSON. Metrics, event logs and generated API-token records are preserved during import. Exported configuration contains configured secrets and must be protected.
|
- Settings can be exported/imported as JSON. Metrics, event logs and generated API-token records are preserved during import. Exported configuration contains configured secrets and must be protected.
|
||||||
- Optional on-screen debug overlay is available on every Web UI route, can be changed through `/api/debug`, shows controller events plus live HTTP method/path/status timing and can include sanitized decrypted GREE request/response payloads.
|
- Optional on-screen debug overlay is available on every Web UI route, can be changed through `/api/debug`, shows controller events plus live HTTP method/path/status timing and can include sanitized decrypted GREE request/response payloads.
|
||||||
|
|||||||
Generated
+1
-1
@@ -633,7 +633,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "gree-controller"
|
name = "gree-controller"
|
||||||
version = "0.5.0"
|
version = "0.5.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes",
|
"aes",
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "gree-controller"
|
name = "gree-controller"
|
||||||
version = "0.5.0"
|
version = "0.5.2"
|
||||||
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"
|
||||||
|
|||||||
+13
-13
@@ -1,16 +1,16 @@
|
|||||||
4989a37261ebad2b981c62ee9b5be41cc298f982e101782f27151774293f7a9b ./.env.example
|
4989a37261ebad2b981c62ee9b5be41cc298f982e101782f27151774293f7a9b ./.env.example
|
||||||
a4ec3874a2e3ab1bad28fb40bb620f7b01f64d01ad9b699306bf70ada31227db ./.gitignore
|
a4ec3874a2e3ab1bad28fb40bb620f7b01f64d01ad9b699306bf70ada31227db ./.gitignore
|
||||||
2f6060e367a0289ee8029bc8d0486c2c28e54f990ae69816af7987ef5a5969ca ./BUILD_REPORT.md
|
60e043828c8540fd46114141a01838f82e09aabe460ff1d6e7886a57d7c0cd33 ./BUILD_REPORT.md
|
||||||
6273e6e09d41e084d7bf6da83a8eeee17c89da9fa05ad248898c14558dfe098a ./Cargo.lock
|
161b2168c551e4e4563161ba2b1aa417a57332c984e3cf3f729fc5d6df987921 ./Cargo.lock
|
||||||
d1bf838b0634706fdd4a533dfed712a45eb5167f9f675fbdcab2781baf2a1611 ./Cargo.toml
|
043fe7cc49e07a875d87ab8dfa6eef2bca73b6317038010c18765ea9950992cd ./Cargo.toml
|
||||||
19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE
|
19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE
|
||||||
a0d2b6a9efc28f3359ca2bbbc72c562d18a8fceca2b7d90994f60f95366f833f ./README.md
|
975335835c4eae1f7a90ac903be39e4a1fe5c318c0d80ba71bfad225b2ed8488 ./README.md
|
||||||
a4fa9bfee9735ed8ed95ea31456e0cce503d82502ae3f550108ffca51b0f0c3d ./build.rs
|
a4fa9bfee9735ed8ed95ea31456e0cce503d82502ae3f550108ffca51b0f0c3d ./build.rs
|
||||||
6577e178c03cdb5f9e5b55eba4c25e2733bd5ca48daa76cf7910cda776066517 ./docs/API.md
|
6577e178c03cdb5f9e5b55eba4c25e2733bd5ca48daa76cf7910cda776066517 ./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
|
||||||
6309591814958f059f18ad7677f925a6f6e4a0eab4b780519fc7ed2067e98d56 ./docs/PROJECT_SPEC.md
|
47e18e6b1723f1f6eb2516be6d83bbebd31b8155d9d3cdfccc0973177f3d8441 ./docs/PROJECT_SPEC.md
|
||||||
3996549f159bb1bb07139cd5afa218d948326b192123a4e7a8ace15fe1e18be9 ./home-assistant/README.md
|
3996549f159bb1bb07139cd5afa218d948326b192123a4e7a8ace15fe1e18be9 ./home-assistant/README.md
|
||||||
f8e8559fe10fe523ac5bc9aac25c6e26e862f679d502e8f3c39f38a0a8e40911 ./home-assistant/custom_components/gree_controller/__init__.py
|
f8e8559fe10fe523ac5bc9aac25c6e26e862f679d502e8f3c39f38a0a8e40911 ./home-assistant/custom_components/gree_controller/__init__.py
|
||||||
b8e7c0dd722bfac1d1d8d5c2a4b5ff6b2d6fa89525b9ff5c121ee1d09623a7fd ./home-assistant/custom_components/gree_controller/api.py
|
b8e7c0dd722bfac1d1d8d5c2a4b5ff6b2d6fa89525b9ff5c121ee1d09623a7fd ./home-assistant/custom_components/gree_controller/api.py
|
||||||
@@ -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
|
||||||
0e9288c70c0e3d3b9944ac79f6fe227fdef1d18ed844c04ede2bbe2e1bc16962 ./home-assistant/custom_components/gree_controller/manifest.json
|
6d965c12b1dcfcc8beb439d2035fcb4e74e58db919682ce77b771729ea41e4a0 ./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
|
||||||
6e3489bedfea68bd0040686616cbfaab944182a1cebd0adf756b12469dcd925c ./lang/en.json
|
ea76d53c79adef54513e3d72c342c8b7c01e8a9ac13c56a19703c6f3642a6630 ./lang/en.json
|
||||||
95039d760d81ec46208762e0cc34570a060e4f52a43fe58b5f95eafe826d4335 ./lang/pl.json
|
14192dd2d83f8ccc8a4d36d54f76d8e941397b6d9b05e55edeb07083ec2426a3 ./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
|
||||||
@@ -44,21 +44,21 @@ b50782b3742dfbf8a319c60571c968e93fdf8547db747c759edcffae68cb98bf ./scripts/upda
|
|||||||
1e82b152e3e8badbceb2ffa6424fea027b7aad3ad87e8b1b46085caa66760ba8 ./src/api.rs
|
1e82b152e3e8badbceb2ffa6424fea027b7aad3ad87e8b1b46085caa66760ba8 ./src/api.rs
|
||||||
4bbbe739219730d7369965277e689b5a557202e2674869705009ca4c3239ad38 ./src/config.rs
|
4bbbe739219730d7369965277e689b5a557202e2674869705009ca4c3239ad38 ./src/config.rs
|
||||||
1ec49d30ff14fc341bb15177ff195db41fb927d67114d1c1cb8ca671c7ea73ec ./src/db.rs
|
1ec49d30ff14fc341bb15177ff195db41fb927d67114d1c1cb8ca671c7ea73ec ./src/db.rs
|
||||||
6359748b86a420334f5a090640773abb18ef4057f74342af32d5a250be5e0d4a ./src/engine.rs
|
d6088e1b36997a5bc40618501bcba87b69ae712ae841310d6c65d286b13a7c12 ./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
|
eae77ed4aed700c72a0c1e6d672ffb0dd0fd6f50d0e7f6e1f8b8ab41cbf5df42 ./src/models.rs
|
||||||
7fc31fbf8841a073a1544b8c7a6390f1a15b56087486ca0596a8418340fa232a ./src/protocol/crypto.rs
|
7fc31fbf8841a073a1544b8c7a6390f1a15b56087486ca0596a8418340fa232a ./src/protocol/crypto.rs
|
||||||
a34005e60b1048d1fe7ed1457f4cd28055b486dbcc5d9da2886fdef5cf3f8c78 ./src/protocol/gree.rs
|
b76543136fb9c9742657a9b047629b7382d0e5d73ab468609c2be643e0681d25 ./src/protocol/gree.rs
|
||||||
a910bd9432a393740c0f6fab52bfcb551f0ea756718d66d290fd2610767cf07c ./src/protocol/mod.rs
|
a910bd9432a393740c0f6fab52bfcb551f0ea756718d66d290fd2610767cf07c ./src/protocol/mod.rs
|
||||||
b6061a3325228fc5f70adb819d1b443e560d36964f98b8edb7c5bd079241348e ./src/queries.rs
|
b6061a3325228fc5f70adb819d1b443e560d36964f98b8edb7c5bd079241348e ./src/queries.rs
|
||||||
e3c02b232bd9d632ca1d3b6ad69a7cc61dc2e51954765d9e963d713d135f7dca ./src/state.rs
|
e3c02b232bd9d632ca1d3b6ad69a7cc61dc2e51954765d9e963d713d135f7dca ./src/state.rs
|
||||||
b92a6cb158b494fe145b43c7641e65f6fafff47201d7d76edbec2cfd8b94835c ./systemd/gree-controller.service
|
b92a6cb158b494fe145b43c7641e65f6fafff47201d7d76edbec2cfd8b94835c ./systemd/gree-controller.service
|
||||||
21b0a8e6f9852d5156d60268691134ef6e395814fa3a345b0ad6181471afd8bc ./web/app.js
|
d22d55f28e01cba9f25296ce601628dcbb62d202ee233a74304c3cdaec720192 ./web/app.js
|
||||||
e98bdd7204349cce1ec6f57283509697af0bbc72280622a6c3efa6fed242db4f ./web/favicon.svg
|
e98bdd7204349cce1ec6f57283509697af0bbc72280622a6c3efa6fed242db4f ./web/favicon.svg
|
||||||
7452a04bd4bcb7178f998c4f042976da6a532fcf14f0f90b43fccf4fbe3a4b43 ./web/index.html
|
1180d4044630ef04ce90da4c79104bcb02848c0156aba378ad21fbd51efc006e ./web/index.html
|
||||||
c0e628423a82c5037b6d650aad5e8befe30945492c8cb0a5d94433db7b58c77b ./web/manifest.webmanifest
|
c0e628423a82c5037b6d650aad5e8befe30945492c8cb0a5d94433db7b58c77b ./web/manifest.webmanifest
|
||||||
081dd439c49eee1f325a58c286a7b77a89642df9706a182b0bfa927702b9a876 ./web/styles.css
|
b54efeab2aa577b92df7842c9f2460a2a3ad054d83a4b4b0635ed6a21cd47cca ./web/styles.css
|
||||||
98bf333d3cb273ff7569c8985b459985ce956cbd5274ca4d48a634b578edf23b ./web/sw.js
|
98bf333d3cb273ff7569c8985b459985ce956cbd5274ca4d48a634b578edf23b ./web/sw.js
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
Standalone local GREE air-conditioner controller written in Rust. It runs on a regular Linux host or an LXC container and provides a mobile-first web interface without depending on the vendor cloud.
|
Standalone local GREE air-conditioner controller written in Rust. It runs on a regular Linux host or an LXC container and provides a mobile-first web interface without depending on the vendor cloud.
|
||||||
|
|
||||||
Current version: **0.5.0**.
|
Current version: **0.5.2**.
|
||||||
|
|
||||||
## Highlights
|
## Highlights
|
||||||
|
|
||||||
@@ -42,7 +42,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.0.zip
|
unzip gree-controller-v0.5.2.zip
|
||||||
cd gree-controller
|
cd gree-controller
|
||||||
chmod +x scripts/*.sh
|
chmod +x scripts/*.sh
|
||||||
./scripts/dev.sh
|
./scripts/dev.sh
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ During normal heating/cooling the controller minimizes unit power cycling. It ke
|
|||||||
- when the zone requires conditioning, use the active target (optionally assisted slightly by outdoor weather),
|
- when the zone requires conditioning, use the active target (optionally assisted slightly by outdoor weather),
|
||||||
- when the zone is satisfied, move the device setpoint to the non-demand side of the room target so the inverter/compressor can stop naturally,
|
- when the zone is satisfied, move the device setpoint to the non-demand side of the room target so the inverter/compressor can stop naturally,
|
||||||
- use hysteresis and a minimum adjustment interval to avoid command chatter,
|
- use hysteresis and a minimum adjustment interval to avoid command chatter,
|
||||||
- optionally adjust fan speed based on room error and outdoor extremes.
|
- optionally adjust fan speed based on room error and outdoor extremes; with Smart fan enabled, a satisfied zone uses Low fan together with the standby setpoint and requests Quiet mode in the same frame when the unit supports it. Unsupported Quiet commands automatically fall back to Low fan without breaking thermostat control.
|
||||||
|
|
||||||
Each zone has separate seasonal profile temperatures:
|
Each zone has separate seasonal profile temperatures:
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"domain": "gree_controller",
|
"domain": "gree_controller",
|
||||||
"name": "GREE Controller",
|
"name": "GREE Controller",
|
||||||
"version": "0.5.0",
|
"version": "0.5.2",
|
||||||
"config_flow": true,
|
"config_flow": true,
|
||||||
"integration_type": "hub",
|
"integration_type": "hub",
|
||||||
"iot_class": "local_polling",
|
"iot_class": "local_polling",
|
||||||
|
|||||||
+44
-1
@@ -400,6 +400,49 @@
|
|||||||
"debug.apiOnly": "API logs",
|
"debug.apiOnly": "API logs",
|
||||||
"debug.empty": "No debug events yet.",
|
"debug.empty": "No debug events yet.",
|
||||||
"toast.exported": "Configuration exported",
|
"toast.exported": "Configuration exported",
|
||||||
"toast.imported": "Configuration imported"
|
"toast.imported": "Configuration imported",
|
||||||
|
"nav.simulation": "Simulator",
|
||||||
|
"simulation.eyebrow": "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.timelineEyebrow": "Timeline",
|
||||||
|
"simulation.timelineTitle": "What will happen next",
|
||||||
|
"simulation.zoneEyebrow": "Zones",
|
||||||
|
"simulation.zoneTitle": "Thermostat decision flow",
|
||||||
|
"simulation.rulesEyebrow": "Rules",
|
||||||
|
"simulation.rulesTitle": "Automation rules",
|
||||||
|
"simulation.houseMode": "House mode",
|
||||||
|
"simulation.strategy": "Strategy: {strategy}",
|
||||||
|
"simulation.outdoor": "Outdoor",
|
||||||
|
"simulation.generatedAt": "Generated: {time}",
|
||||||
|
"simulation.activeZones": "Active zones",
|
||||||
|
"simulation.requestingZones": "Requesting comfort: {count}",
|
||||||
|
"simulation.rulesLabel": "Active rules",
|
||||||
|
"simulation.eventsQueued": "Queued events: {count}",
|
||||||
|
"simulation.house": "House",
|
||||||
|
"simulation.stepRoom": "Room reading",
|
||||||
|
"simulation.stepTarget": "Target",
|
||||||
|
"simulation.stepDecision": "Decision",
|
||||||
|
"simulation.stepCommand": "Unit command",
|
||||||
|
"simulation.hysteresis": "Hysteresis {value}°C",
|
||||||
|
"simulation.deviceSetpoint": "Device setpoint",
|
||||||
|
"simulation.quietIfSupported": "If supported",
|
||||||
|
"simulation.manual": "Manual",
|
||||||
|
"simulation.noCommand": "No command preview",
|
||||||
|
"simulation.noActionPreview": "No direct action payload",
|
||||||
|
"simulation.ruleOnDevice": "Action on device: {device}",
|
||||||
|
"simulation.nextReady": "Ready again",
|
||||||
|
"simulation.noRules": "No active automation rules.",
|
||||||
|
"simulation.waitingForData": "Waiting for data",
|
||||||
|
"simulation.stateDemand": "Demand",
|
||||||
|
"simulation.stateSatisfied": "Satisfied",
|
||||||
|
"simulation.reasonDisabled": "Zone is disabled, so the thermostat does not send control commands.",
|
||||||
|
"simulation.reasonHouseOff": "The global house mode is off, so this zone stays inactive.",
|
||||||
|
"simulation.reasonWaiting": "The simulator is waiting for a valid room temperature and target.",
|
||||||
|
"simulation.reasonDemand": "Room needs {mode}. Thermostat compares the reading with the target {target}°C using hysteresis {hysteresis}°C.",
|
||||||
|
"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.standbyTargetLabel": "Standby target before device rounding: {value}°C",
|
||||||
|
"common.fan": "Fan"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+44
-1
@@ -400,6 +400,49 @@
|
|||||||
"debug.apiOnly": "Logi API",
|
"debug.apiOnly": "Logi API",
|
||||||
"debug.empty": "Brak zdarzeń debug.",
|
"debug.empty": "Brak zdarzeń debug.",
|
||||||
"toast.exported": "Konfiguracja wyeksportowana",
|
"toast.exported": "Konfiguracja wyeksportowana",
|
||||||
"toast.imported": "Konfiguracja zaimportowana"
|
"toast.imported": "Konfiguracja zaimportowana",
|
||||||
|
"nav.simulation": "Symulator",
|
||||||
|
"simulation.eyebrow": "Symulator",
|
||||||
|
"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.timelineEyebrow": "Oś czasu",
|
||||||
|
"simulation.timelineTitle": "Co wydarzy się dalej",
|
||||||
|
"simulation.zoneEyebrow": "Strefy",
|
||||||
|
"simulation.zoneTitle": "Przepływ decyzji termostatu",
|
||||||
|
"simulation.rulesEyebrow": "Reguły",
|
||||||
|
"simulation.rulesTitle": "Reguły automatyzacji",
|
||||||
|
"simulation.houseMode": "Tryb domu",
|
||||||
|
"simulation.strategy": "Strategia: {strategy}",
|
||||||
|
"simulation.outdoor": "Na zewnątrz",
|
||||||
|
"simulation.generatedAt": "Wygenerowano: {time}",
|
||||||
|
"simulation.activeZones": "Aktywne strefy",
|
||||||
|
"simulation.requestingZones": "Żądają komfortu: {count}",
|
||||||
|
"simulation.rulesLabel": "Aktywne reguły",
|
||||||
|
"simulation.eventsQueued": "Zdarzenia w kolejce: {count}",
|
||||||
|
"simulation.house": "Dom",
|
||||||
|
"simulation.stepRoom": "Pomiar w pokoju",
|
||||||
|
"simulation.stepTarget": "Cel",
|
||||||
|
"simulation.stepDecision": "Decyzja",
|
||||||
|
"simulation.stepCommand": "Komenda dla jednostki",
|
||||||
|
"simulation.hysteresis": "Histereza {value}°C",
|
||||||
|
"simulation.deviceSetpoint": "Nastawa jednostki",
|
||||||
|
"simulation.quietIfSupported": "Jeśli obsługiwane",
|
||||||
|
"simulation.manual": "Ręcznie",
|
||||||
|
"simulation.noCommand": "Brak podglądu komendy",
|
||||||
|
"simulation.noActionPreview": "Brak bezpośredniej akcji",
|
||||||
|
"simulation.ruleOnDevice": "Akcja na urządzeniu: {device}",
|
||||||
|
"simulation.nextReady": "Gotowa ponownie",
|
||||||
|
"simulation.noRules": "Brak aktywnych reguł automatyzacji.",
|
||||||
|
"simulation.waitingForData": "Oczekiwanie na dane",
|
||||||
|
"simulation.stateDemand": "Zapotrzebowanie",
|
||||||
|
"simulation.stateSatisfied": "Osiągnięto cel",
|
||||||
|
"simulation.reasonDisabled": "Strefa jest wyłączona, więc termostat nie wysyła komend sterujących.",
|
||||||
|
"simulation.reasonHouseOff": "Globalny tryb domu jest wyłączony, więc ta strefa pozostaje nieaktywna.",
|
||||||
|
"simulation.reasonWaiting": "Symulator czeka na poprawny odczyt temperatury pokojowej i cel.",
|
||||||
|
"simulation.reasonDemand": "Pomieszczenie potrzebuje trybu {mode}. Termostat porównuje odczyt z celem {target}°C i histerezą {hysteresis}°C.",
|
||||||
|
"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.standbyTargetLabel": "Cel podtrzymania przed zaokrągleniem: {value}°C",
|
||||||
|
"common.fan": "Wentylator"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+74
-20
@@ -102,8 +102,9 @@ pub async fn send_command(state: &AppState, device_id: &str, command: DeviceComm
|
|||||||
if command.is_empty() { return Ok(device); }
|
if command.is_empty() { return Ok(device); }
|
||||||
let suppress_beep = state.settings.read().await.suppress_device_beep;
|
let suppress_beep = state.settings.read().await.suppress_device_beep;
|
||||||
|
|
||||||
|
let mut applied_command = command.clone();
|
||||||
if device.simulated {
|
if device.simulated {
|
||||||
command.apply(&mut device);
|
applied_command.apply(&mut device);
|
||||||
device.online = true;
|
device.online = true;
|
||||||
device.last_seen = Some(Utc::now());
|
device.last_seen = Some(Utc::now());
|
||||||
device.last_error = None;
|
device.last_error = None;
|
||||||
@@ -124,24 +125,30 @@ pub async fn send_command(state: &AppState, device_id: &str, command: DeviceComm
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Err(first_err) = state.gree.command(&device, &command, suppress_beep).await {
|
match state.gree.command(&device, &command, suppress_beep).await {
|
||||||
// Retry once after a fresh bind. This covers stale keys and devices that
|
Ok(result) => applied_command = result,
|
||||||
// switched between ECB/GCM after a firmware update.
|
Err(first_err) => {
|
||||||
let retry_result = match state.gree.bind(&device).await {
|
// Retry once after a fresh bind. This covers stale keys and devices that
|
||||||
Ok(bound) => {
|
// switched between ECB/GCM after a firmware update.
|
||||||
device.key = Some(bound.key);
|
let retry_result = match state.gree.bind(&device).await {
|
||||||
device.protocol_version = bound.protocol_version;
|
Ok(bound) => {
|
||||||
state.db.save_device(&device)?;
|
device.key = Some(bound.key);
|
||||||
state.gree.command(&device, &command, suppress_beep).await
|
device.protocol_version = bound.protocol_version;
|
||||||
|
state.db.save_device(&device)?;
|
||||||
|
state.gree.command(&device, &command, suppress_beep).await
|
||||||
|
}
|
||||||
|
Err(_) => Err(first_err),
|
||||||
|
};
|
||||||
|
match retry_result {
|
||||||
|
Ok(result) => applied_command = result,
|
||||||
|
Err(err) => {
|
||||||
|
register_device_failure(state, &mut device, &err.to_string())?;
|
||||||
|
return Err(AppError::Device(err.to_string()));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Err(_) => Err(first_err),
|
|
||||||
};
|
|
||||||
if let Err(err) = retry_result {
|
|
||||||
register_device_failure(state, &mut device, &err.to_string())?;
|
|
||||||
return Err(AppError::Device(err.to_string()));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
command.apply(&mut device);
|
applied_command.apply(&mut device);
|
||||||
device.online = true;
|
device.online = true;
|
||||||
device.communication_failures = 0;
|
device.communication_failures = 0;
|
||||||
device.last_seen = Some(Utc::now());
|
device.last_seen = Some(Utc::now());
|
||||||
@@ -151,7 +158,7 @@ pub async fn send_command(state: &AppState, device_id: &str, command: DeviceComm
|
|||||||
|
|
||||||
state.log("info", "device.command", &format!("Updated {}", device.name), json!({
|
state.log("info", "device.command", &format!("Updated {}", device.name), json!({
|
||||||
"device_id": device.id,
|
"device_id": device.id,
|
||||||
"command": command,
|
"command": applied_command,
|
||||||
}));
|
}));
|
||||||
state.broadcast("device.updated", serde_json::to_value(&device).unwrap_or_default());
|
state.broadcast("device.updated", serde_json::to_value(&device).unwrap_or_default());
|
||||||
Ok(device)
|
Ok(device)
|
||||||
@@ -424,6 +431,7 @@ async fn control_zones(state: &AppState) -> Result<()> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let half = zone.hysteresis.max(0.1) / 2.0;
|
let half = zone.hysteresis.max(0.1) / 2.0;
|
||||||
|
let previous_demand = zone.demand;
|
||||||
zone.demand = match effective_mode {
|
zone.demand = match effective_mode {
|
||||||
"heat" => {
|
"heat" => {
|
||||||
if temp <= target - half { true }
|
if temp <= target - half { true }
|
||||||
@@ -456,11 +464,23 @@ async fn control_zones(state: &AppState) -> Result<()> {
|
|||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
// 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
|
||||||
|
// only on that transition so a user's manual Quiet choice is not constantly
|
||||||
|
// overwritten while the zone is actively heating/cooling.
|
||||||
|
let desired_quiet = smart_quiet_command(
|
||||||
|
zone.smart_fan,
|
||||||
|
state.gree.quiet_command_supported(&device.id),
|
||||||
|
previous_demand,
|
||||||
|
zone.demand,
|
||||||
|
device.quiet,
|
||||||
|
);
|
||||||
|
|
||||||
let needs_command = !device.power
|
let needs_command = !device.power
|
||||||
|| device.mode != effective_mode
|
|| device.mode != effective_mode
|
||||||
|| (device.target_temperature - desired_device_target).abs() >= 0.5
|
|| (device.target_temperature - desired_device_target).abs() >= 0.5
|
||||||
|| desired_fan.map(|fan| fan != device.fan_speed).unwrap_or(false);
|
|| desired_fan.map(|fan| fan != device.fan_speed).unwrap_or(false)
|
||||||
|
|| desired_quiet.map(|quiet| quiet != device.quiet).unwrap_or(false);
|
||||||
|
|
||||||
let urgent_mode_change = !device.power || device.mode != effective_mode;
|
let urgent_mode_change = !device.power || device.mode != effective_mode;
|
||||||
if needs_command && (urgent_mode_change || adjustment_allowed(&zone)) {
|
if needs_command && (urgent_mode_change || adjustment_allowed(&zone)) {
|
||||||
@@ -469,10 +489,11 @@ async fn control_zones(state: &AppState) -> Result<()> {
|
|||||||
mode: Some(effective_mode.to_string()),
|
mode: Some(effective_mode.to_string()),
|
||||||
target_temperature: Some(desired_device_target),
|
target_temperature: Some(desired_device_target),
|
||||||
fan_speed: desired_fan,
|
fan_speed: desired_fan,
|
||||||
|
quiet: desired_quiet,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
match send_command(state, &zone.device_id, command).await {
|
match send_command(state, &zone.device_id, command).await {
|
||||||
Ok(_) => {
|
Ok(updated_device) => {
|
||||||
zone.last_action_at = Some(Utc::now());
|
zone.last_action_at = Some(Utc::now());
|
||||||
state.log("info", "zone.setpoint_modulation", &format!("Zone {} -> {:.1} C ({})", zone.name, desired_device_target, if zone.demand { "demand" } else { "standby" }), json!({
|
state.log("info", "zone.setpoint_modulation", &format!("Zone {} -> {:.1} C ({})", zone.name, desired_device_target, if zone.demand { "demand" } else { "standby" }), json!({
|
||||||
"zone_id": zone.id,
|
"zone_id": zone.id,
|
||||||
@@ -482,6 +503,8 @@ async fn control_zones(state: &AppState) -> Result<()> {
|
|||||||
"mode": effective_mode,
|
"mode": effective_mode,
|
||||||
"preset": zone.active_preset,
|
"preset": zone.active_preset,
|
||||||
"outdoor_temperature": outdoor_temperature,
|
"outdoor_temperature": outdoor_temperature,
|
||||||
|
"fan_speed": updated_device.fan_speed,
|
||||||
|
"quiet": updated_device.quiet,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
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})),
|
||||||
@@ -640,8 +663,24 @@ fn outdoor_assist_offset(mode: &str, outdoor: Option<f64>, room: f64, target: f6
|
|||||||
(weather * room_error.clamp(0.0, 2.0) * 0.5).clamp(0.0, 1.0)
|
(weather * room_error.clamp(0.0, 2.0) * 0.5).clamp(0.0, 1.0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn smart_quiet_command(
|
||||||
|
smart_fan: bool,
|
||||||
|
quiet_supported: bool,
|
||||||
|
previous_demand: bool,
|
||||||
|
demand: bool,
|
||||||
|
device_quiet: bool,
|
||||||
|
) -> Option<bool> {
|
||||||
|
if !smart_fan || !quiet_supported { return None; }
|
||||||
|
if !demand { return Some(true); }
|
||||||
|
if !previous_demand && device_quiet { return Some(false); }
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
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 {
|
||||||
if !demand { return 0; }
|
// 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
|
||||||
|
// the same GREE command, so e.g. 21 C reached -> 19 C + Low fan for heating.
|
||||||
|
if !demand { return 1; }
|
||||||
let error = (room - target).abs();
|
let error = (room - target).abs();
|
||||||
let extreme_weather = match (mode, outdoor) {
|
let extreme_weather = match (mode, outdoor) {
|
||||||
("heat", Some(value)) => value <= 0.0,
|
("heat", Some(value)) => value <= 0.0,
|
||||||
@@ -1023,6 +1062,21 @@ mod tests {
|
|||||||
assert_eq!(round_device_setpoint("heat", false, 19.5), 19.0);
|
assert_eq!(round_device_setpoint("heat", false, 19.5), 19.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn smart_fan_uses_low_speed_when_zone_is_satisfied() {
|
||||||
|
assert_eq!(smart_fan_speed("heat", 21.0, 21.0, None, false), 1);
|
||||||
|
assert_eq!(smart_fan_speed("cool", 23.0, 23.0, None, false), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
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, false, true, true), Some(false));
|
||||||
|
assert_eq!(smart_quiet_command(true, true, true, true, true), None);
|
||||||
|
assert_eq!(smart_quiet_command(true, false, true, false, false), None);
|
||||||
|
assert_eq!(smart_quiet_command(false, true, true, false, false), None);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn outdoor_assist_is_bounded_and_direction_neutral() {
|
fn outdoor_assist_is_bounded_and_direction_neutral() {
|
||||||
let cool = outdoor_assist_offset("cool", Some(36.0), 27.0, 23.0);
|
let cool = outdoor_assist_offset("cool", Some(36.0), 27.0, 23.0);
|
||||||
|
|||||||
+65
-5
@@ -23,6 +23,7 @@ pub struct GreeClient {
|
|||||||
debug_events: Option<broadcast::Sender<ApiEvent>>,
|
debug_events: Option<broadcast::Sender<ApiEvent>>,
|
||||||
debug_gree_frames: Arc<AtomicBool>,
|
debug_gree_frames: Arc<AtomicBool>,
|
||||||
buzzer_unsupported: Arc<Mutex<HashSet<String>>>,
|
buzzer_unsupported: Arc<Mutex<HashSet<String>>>,
|
||||||
|
quiet_unsupported: Arc<Mutex<HashSet<String>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl GreeClient {
|
impl GreeClient {
|
||||||
@@ -38,6 +39,7 @@ impl GreeClient {
|
|||||||
debug_events,
|
debug_events,
|
||||||
debug_gree_frames,
|
debug_gree_frames,
|
||||||
buzzer_unsupported: Arc::new(Mutex::new(HashSet::new())),
|
buzzer_unsupported: Arc::new(Mutex::new(HashSet::new())),
|
||||||
|
quiet_unsupported: Arc::new(Mutex::new(HashSet::new())),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -414,17 +416,25 @@ impl GreeClient {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn command(&self, device: &Device, command: &DeviceCommand, suppress_beep: bool) -> Result<Value> {
|
pub fn quiet_command_supported(&self, device_id: &str) -> bool {
|
||||||
let key = device.key.as_deref().ok_or_else(|| anyhow!("device is not bound"))?;
|
self.quiet_unsupported.lock().map(|items| !items.contains(device_id)).unwrap_or(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn request_command_with_buzzer_fallback(
|
||||||
|
&self,
|
||||||
|
device: &Device,
|
||||||
|
key: &str,
|
||||||
|
command: &DeviceCommand,
|
||||||
|
suppress_beep: bool,
|
||||||
|
) -> Result<Value> {
|
||||||
let try_buzzer_suppression = suppress_beep
|
let try_buzzer_suppression = suppress_beep
|
||||||
&& self.buzzer_unsupported.lock().map(|items| !items.contains(&device.id)).unwrap_or(true);
|
&& self.buzzer_unsupported.lock().map(|items| !items.contains(&device.id)).unwrap_or(true);
|
||||||
let inner = Self::command_payload(command, try_buzzer_suppression)?;
|
let inner = Self::command_payload(command, try_buzzer_suppression)?;
|
||||||
match self.request(device, &inner, key, false, device.protocol_version).await {
|
match self.request(device, &inner, key, false, device.protocol_version).await {
|
||||||
Ok(value) => Ok(value),
|
Ok(value) => Ok(value),
|
||||||
Err(first_err) if try_buzzer_suppression => {
|
Err(first_err) if try_buzzer_suppression => {
|
||||||
// Some firmwares reject unknown command properties instead of ignoring them.
|
// Some firmwares reject unknown buzzer properties instead of ignoring them.
|
||||||
// Retry the exact state change without buzzer fields; only remember the device
|
// Retry the exact state change without buzzer fields and remember the fallback.
|
||||||
// as incompatible after that fallback succeeds.
|
|
||||||
let fallback = Self::command_payload(command, false)?;
|
let fallback = Self::command_payload(command, false)?;
|
||||||
match self.request(device, &fallback, key, false, device.protocol_version).await {
|
match self.request(device, &fallback, key, false, device.protocol_version).await {
|
||||||
Ok(value) => {
|
Ok(value) => {
|
||||||
@@ -439,6 +449,38 @@ impl GreeClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn command(&self, device: &Device, command: &DeviceCommand, suppress_beep: bool) -> Result<DeviceCommand> {
|
||||||
|
let key = device.key.as_deref().ok_or_else(|| anyhow!("device is not bound"))?;
|
||||||
|
let mut effective = command.clone();
|
||||||
|
if effective.quiet.is_some() && !self.quiet_command_supported(&device.id) {
|
||||||
|
effective.quiet = None;
|
||||||
|
}
|
||||||
|
if effective.is_empty() {
|
||||||
|
return Ok(effective);
|
||||||
|
}
|
||||||
|
|
||||||
|
match self.request_command_with_buzzer_fallback(device, key, &effective, suppress_beep).await {
|
||||||
|
Ok(_) => Ok(effective),
|
||||||
|
Err(first_err) if effective.quiet.is_some() => {
|
||||||
|
// Quiet is not implemented by every GREE indoor unit. If a combined thermostat
|
||||||
|
// command is rejected, retry the same setpoint/fan change without Quiet. Once
|
||||||
|
// that succeeds, remember the unit so future thermostat frames omit Quiet.
|
||||||
|
let mut fallback = effective.clone();
|
||||||
|
fallback.quiet = None;
|
||||||
|
if fallback.is_empty() { return Err(first_err); }
|
||||||
|
match self.request_command_with_buzzer_fallback(device, key, &fallback, suppress_beep).await {
|
||||||
|
Ok(_) => {
|
||||||
|
if let Ok(mut items) = self.quiet_unsupported.lock() { items.insert(device.id.clone()); }
|
||||||
|
tracing::warn!(device=%device.id, "GREE Quiet command is unsupported; thermostat will use Low fan without Quiet for this device");
|
||||||
|
Ok(fallback)
|
||||||
|
}
|
||||||
|
Err(_) => Err(first_err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(err) => Err(err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn command_payload(command: &DeviceCommand, suppress_beep: bool) -> Result<Value> {
|
fn command_payload(command: &DeviceCommand, suppress_beep: bool) -> Result<Value> {
|
||||||
let mut opt = Vec::<&str>::new();
|
let mut opt = Vec::<&str>::new();
|
||||||
let mut values = Vec::<Value>::new();
|
let mut values = Vec::<Value>::new();
|
||||||
@@ -683,3 +725,21 @@ pub fn merge_discovered(existing: Option<Device>, discovered: Device) -> Device
|
|||||||
new
|
new
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn thermostat_standby_setpoint_low_fan_and_quiet_share_one_frame() {
|
||||||
|
let payload = GreeClient::command_payload(&DeviceCommand {
|
||||||
|
target_temperature: Some(19.0),
|
||||||
|
fan_speed: Some(1),
|
||||||
|
quiet: Some(true),
|
||||||
|
..DeviceCommand::default()
|
||||||
|
}, false).expect("thermostat command payload");
|
||||||
|
|
||||||
|
assert_eq!(payload.get("opt").cloned(), Some(serde_json::json!(["SetTem", "WdSpd", "Quiet"])));
|
||||||
|
assert_eq!(payload.get("p").cloned(), Some(serde_json::json!([19, 1, 1])));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+178
-3
@@ -194,6 +194,7 @@ function renderAll() {
|
|||||||
renderSummary();
|
renderSummary();
|
||||||
renderHouseClimate();
|
renderHouseClimate();
|
||||||
renderControlPlan();
|
renderControlPlan();
|
||||||
|
renderSimulationPage();
|
||||||
renderDevices();
|
renderDevices();
|
||||||
renderZones();
|
renderZones();
|
||||||
renderSchedules();
|
renderSchedules();
|
||||||
@@ -267,10 +268,179 @@ function renderControlPlan() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadControlPlan() {
|
async function loadControlPlan() {
|
||||||
try { app.controlPlan = await api('/api/control-plan'); renderControlPlan(); }
|
try { app.controlPlan = await api('/api/control-plan'); renderControlPlan(); renderSimulationPage(); }
|
||||||
catch (error) { console.warn('Unable to load control plan:', error); }
|
catch (error) { console.warn('Unable to load control plan:', error); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function simulationTime(value, options = {}) {
|
||||||
|
if (!value) return '—';
|
||||||
|
const date = new Date(value);
|
||||||
|
return date.toLocaleString(locale(), {weekday: options.withDay === false ? undefined : 'short', hour:'2-digit', minute:'2-digit'}).replace(',', '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function simulationNumeric(value) {
|
||||||
|
const n = Number(value);
|
||||||
|
return Number.isFinite(n) ? n : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function simulationOutdoorAssist(mode, outdoor, room, target) {
|
||||||
|
if (outdoor == null || room == null || target == null) return 0;
|
||||||
|
const roomError = Math.abs(room - target);
|
||||||
|
const weather = mode === 'heat' ? clamp((5 - outdoor) / 15, 0, 1) : clamp((outdoor - 30) / 10, 0, 1);
|
||||||
|
return clamp(weather * clamp(roomError, 0, 2) * 0.5, 0, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function simulationRoundDeviceSetpoint(mode, demand, value) {
|
||||||
|
const safe = clamp(Number(value) || 0, 16, 30);
|
||||||
|
if (mode === 'heat' && demand) return Math.ceil(safe);
|
||||||
|
if (mode === 'heat' && !demand) return Math.floor(safe);
|
||||||
|
if (mode !== 'heat' && demand) return Math.floor(safe);
|
||||||
|
return Math.ceil(safe);
|
||||||
|
}
|
||||||
|
|
||||||
|
function simulationSmartFanSpeed(mode, room, target, outdoor, demand) {
|
||||||
|
if (!demand) return 1;
|
||||||
|
const error = Math.abs(room - target);
|
||||||
|
const extremeWeather = mode === 'heat' ? outdoor != null && outdoor <= 0 : outdoor != null && outdoor >= 32;
|
||||||
|
if (error >= 2 || extremeWeather) return 3;
|
||||||
|
if (error >= 1) return 2;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function simulationStatusInfo(status) {
|
||||||
|
const map = {
|
||||||
|
disabled: {label: tr('common.disabled'), badge: ''},
|
||||||
|
off: {label: tr('mode.off'), badge: ''},
|
||||||
|
waiting: {label: tr('simulation.waitingForData'), badge: ''},
|
||||||
|
demand: {label: tr('simulation.stateDemand'), badge: 'active'},
|
||||||
|
satisfied: {label: tr('simulation.stateSatisfied'), badge: ''},
|
||||||
|
};
|
||||||
|
return map[status] || {label: status, badge: ''};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildZoneSimulation(zone, planZone) {
|
||||||
|
const device = app.devices.find(item => item.id === (planZone?.device_id || zone?.device_id));
|
||||||
|
const mode = planZone?.mode || zone?.effective_mode || zone?.mode || app.settings?.house_mode || 'off';
|
||||||
|
const current = simulationNumeric(planZone?.current_temperature ?? zone?.current_temperature ?? zone?.device_temperature ?? device?.current_temperature);
|
||||||
|
const target = simulationNumeric(planZone?.target_temperature ?? zone?.effective_setpoint ?? zone?.manual_setpoint ?? zone?.setpoint);
|
||||||
|
const outdoor = simulationNumeric(app.outdoorTemperature ?? app.controlPlan?.outdoor_temperature);
|
||||||
|
const hysteresis = Math.max(simulationNumeric(zone?.hysteresis) || 0.4, 0.1);
|
||||||
|
const half = hysteresis / 2;
|
||||||
|
let status = 'waiting';
|
||||||
|
let demand = false;
|
||||||
|
if (!zone?.enabled || planZone?.enabled === false) status = 'disabled';
|
||||||
|
else if (mode === 'off') status = 'off';
|
||||||
|
else if (current == null || target == null) status = 'waiting';
|
||||||
|
else {
|
||||||
|
if (mode === 'heat') {
|
||||||
|
if (current <= target - half) demand = true;
|
||||||
|
else if (current >= target + half) demand = false;
|
||||||
|
else demand = !!zone?.demand;
|
||||||
|
} else {
|
||||||
|
if (current >= target + half) demand = true;
|
||||||
|
else if (current <= target - half) demand = false;
|
||||||
|
else demand = !!zone?.demand;
|
||||||
|
}
|
||||||
|
status = demand ? 'demand' : 'satisfied';
|
||||||
|
}
|
||||||
|
const standbyOffset = Math.max(simulationNumeric(zone?.standby_offset_c) || 0.5, 0.5);
|
||||||
|
const assist = current == null || target == null ? 0 : simulationOutdoorAssist(mode, outdoor, current, target);
|
||||||
|
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 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 quiet = zone?.smart_fan ? (!demand ? tr('simulation.quietIfSupported') : tr('common.off')) : tr('simulation.manual');
|
||||||
|
const reasoning = !zone?.enabled || planZone?.enabled === false
|
||||||
|
? tr('simulation.reasonDisabled')
|
||||||
|
: mode === 'off'
|
||||||
|
? tr('simulation.reasonHouseOff')
|
||||||
|
: current == null || target == null
|
||||||
|
? tr('simulation.reasonWaiting')
|
||||||
|
: demand
|
||||||
|
? tr('simulation.reasonDemand', {mode: modeLabel(mode), target: target.toFixed(1), hysteresis: hysteresis.toFixed(1)})
|
||||||
|
: tr('simulation.reasonSatisfied', {target: target.toFixed(1), standby: standbyTarget.toFixed(1)});
|
||||||
|
return {device, mode, current, target, demand, status, outdoor, hysteresis, assist, activeTarget, standbyTarget, desiredDeviceTarget, fanSpeed, quiet, reasoning};
|
||||||
|
}
|
||||||
|
|
||||||
|
function simulationRuleAction(rule) {
|
||||||
|
const bits = [];
|
||||||
|
if (rule.action?.power != null) bits.push(`${tr('common.power')}: ${rule.action.power ? tr('common.on') : tr('common.off')}`);
|
||||||
|
if (rule.action?.mode) bits.push(`${tr('common.mode')}: ${modeLabel(rule.action.mode)}`);
|
||||||
|
if (rule.action?.target_temperature != null) bits.push(`${tr('common.temperature')}: ${fmtTemp(rule.action.target_temperature)}`);
|
||||||
|
return bits.length ? bits.join(' · ') : tr('simulation.noActionPreview');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSimulationPage() {
|
||||||
|
const summaryHost = $('#simulationSummary');
|
||||||
|
const timelineHost = $('#simulationTimeline');
|
||||||
|
const zonesHost = $('#simulationZones');
|
||||||
|
const rulesHost = $('#simulationRules');
|
||||||
|
if (!summaryHost || !timelineHost || !zonesHost || !rulesHost) return;
|
||||||
|
const plan = app.controlPlan;
|
||||||
|
if (!plan) {
|
||||||
|
summaryHost.innerHTML = `<div class="panel plan-loading">${esc(tr('plan.loading'))}</div>`;
|
||||||
|
timelineHost.innerHTML = '';
|
||||||
|
zonesHost.innerHTML = '';
|
||||||
|
rulesHost.innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const enabledZones = (plan.zones || []).filter(zone => zone.enabled);
|
||||||
|
const demandingZones = enabledZones.filter(zone => zone.demand);
|
||||||
|
summaryHost.innerHTML = [
|
||||||
|
{label: tr('simulation.houseMode'), value: modeLabel(plan.house_mode || 'off'), note: tr('simulation.strategy', {strategy: plan.control_strategy || 'setpoint'})},
|
||||||
|
{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.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('');
|
||||||
|
|
||||||
|
const timelineItems = [];
|
||||||
|
(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})));
|
||||||
|
timelineItems.sort((a, b) => new Date(a.event.at) - new Date(b.event.at));
|
||||||
|
const unique = [];
|
||||||
|
const seen = new Set();
|
||||||
|
for (const item of timelineItems) {
|
||||||
|
const key = `${item.scope}|${item.event.at}|${item.event.label}`;
|
||||||
|
if (seen.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
unique.push(item);
|
||||||
|
if (unique.length >= 16) break;
|
||||||
|
}
|
||||||
|
timelineHost.innerHTML = unique.length ? unique.map(item => {
|
||||||
|
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>`;
|
||||||
|
}).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);
|
||||||
|
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>`;
|
||||||
|
}
|
||||||
|
|
||||||
function scheduleControlPlanLoad() {
|
function scheduleControlPlanLoad() {
|
||||||
clearTimeout(app.controlPlanTimer);
|
clearTimeout(app.controlPlanTimer);
|
||||||
app.controlPlanTimer = setTimeout(loadControlPlan, 180);
|
app.controlPlanTimer = setTimeout(loadControlPlan, 180);
|
||||||
@@ -485,7 +655,7 @@ function formatDuration(seconds) {
|
|||||||
return `${days ? `${days}d ` : ''}${hours}h ${minutes}m`;
|
return `${days ? `${days}d ` : ''}${hours}h ${minutes}m`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const VIEW_ROUTES = {dashboard:'/dashboard', devices:'/devices', zones:'/zones', schedules:'/schedules', automations:'/automations', settings:'/settings', logs:'/events'};
|
const VIEW_ROUTES = {dashboard:'/dashboard', devices:'/devices', zones:'/zones', schedules:'/schedules', automations:'/automations', simulation:'/simulation', settings:'/settings', logs:'/events'};
|
||||||
const HISTORY_TABS = ['overview','zones','devices','sensors','custom'];
|
const HISTORY_TABS = ['overview','zones','devices','sensors','custom'];
|
||||||
|
|
||||||
function currentHistoryPath() {
|
function currentHistoryPath() {
|
||||||
@@ -510,7 +680,7 @@ function updateBrowserUrl(path, replace=false) {
|
|||||||
function showView(name, {push=true, scroll=true}={}) {
|
function showView(name, {push=true, scroll=true}={}) {
|
||||||
app.currentView = name;
|
app.currentView = name;
|
||||||
$$('.view').forEach(view => view.classList.toggle('active', view.dataset.view === name));
|
$$('.view').forEach(view => view.classList.toggle('active', view.dataset.view === name));
|
||||||
$$('.bottom-nav button').forEach(button => button.classList.toggle('active', button.dataset.nav === name || (button.dataset.nav === 'more' && ['schedules','automations','settings','logs'].includes(name))));
|
$$('.bottom-nav button').forEach(button => button.classList.toggle('active', button.dataset.nav === name || (button.dataset.nav === 'more' && ['schedules','automations','simulation','settings','logs'].includes(name))));
|
||||||
if (push) updateBrowserUrl(name === 'history' ? currentHistoryPath() : (VIEW_ROUTES[name] || '/dashboard'));
|
if (push) updateBrowserUrl(name === 'history' ? currentHistoryPath() : (VIEW_ROUTES[name] || '/dashboard'));
|
||||||
if (scroll) window.scrollTo({top: 0, behavior: 'smooth'});
|
if (scroll) window.scrollTo({top: 0, behavior: 'smooth'});
|
||||||
if (name === 'history') { renderHistoryNavigation(); loadHistory(); }
|
if (name === 'history') { renderHistoryNavigation(); loadHistory(); }
|
||||||
@@ -1264,6 +1434,11 @@ $('#haTest').addEventListener('click', async () => {
|
|||||||
} catch(error){toast(error.message,true);}
|
} catch(error){toast(error.message,true);}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$('#simulationRefresh')?.addEventListener('click', async () => {
|
||||||
|
try { await loadControlPlan(); toast(tr('common.updated')); }
|
||||||
|
catch (error) { toast(error.message, true); }
|
||||||
|
});
|
||||||
|
|
||||||
$('#exportSettings').addEventListener('click', async () => {
|
$('#exportSettings').addEventListener('click', async () => {
|
||||||
try {
|
try {
|
||||||
const data = await api('/api/settings/export');
|
const data = await api('/api/settings/export');
|
||||||
|
|||||||
@@ -91,6 +91,20 @@
|
|||||||
<div class="list-grid" id="automationList"></div>
|
<div class="list-grid" id="automationList"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
<div class="simulation-summary-grid" id="simulationSummary"></div>
|
||||||
|
<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-timeline" id="simulationTimeline"></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-rules-grid" id="simulationRules"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="view" data-view="history">
|
<section class="view" data-view="history">
|
||||||
<div class="section-heading"><div><span class="eyebrow" data-i18n="history.measurements">Measurements</span><h1 data-i18n="history.title">Climate history</h1></div></div>
|
<div class="section-heading"><div><span class="eyebrow" data-i18n="history.measurements">Measurements</span><h1 data-i18n="history.title">Climate history</h1></div></div>
|
||||||
<div class="history-tabs" id="historyTabs" role="tablist">
|
<div class="history-tabs" id="historyTabs" role="tablist">
|
||||||
@@ -204,6 +218,7 @@
|
|||||||
<div class="menu-list">
|
<div class="menu-list">
|
||||||
<button data-go="schedules"><span data-i18n="nav.schedules">Schedules</span><span>›</span></button>
|
<button data-go="schedules"><span data-i18n="nav.schedules">Schedules</span><span>›</span></button>
|
||||||
<button data-go="automations"><span data-i18n="nav.automations">Automations</span><span>›</span></button>
|
<button data-go="automations"><span data-i18n="nav.automations">Automations</span><span>›</span></button>
|
||||||
|
<button data-go="simulation"><span data-i18n="nav.simulation">Simulator</span><span>›</span></button>
|
||||||
<button data-go="settings"><span data-i18n="nav.settings">Settings</span><span>›</span></button>
|
<button data-go="settings"><span data-i18n="nav.settings">Settings</span><span>›</span></button>
|
||||||
<button data-go="logs"><span data-i18n="nav.logsAndEvents">Events and logs</span><span>›</span></button>
|
<button data-go="logs"><span data-i18n="nav.logsAndEvents">Events and logs</span><span>›</span></button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -461,3 +461,48 @@ legend { padding: 0 5px; color: var(--muted); font-size: 11px; }
|
|||||||
}
|
}
|
||||||
|
|
||||||
.history-chart-card canvas { width: 100%; min-width: 720px; }
|
.history-chart-card canvas { width: 100%; min-width: 720px; }
|
||||||
|
|
||||||
|
.simulation-summary-grid { display:grid; grid-template-columns:repeat(auto-fit, minmax(min(100%, 210px), 1fr)); gap:12px; margin-bottom:16px; }
|
||||||
|
.simulation-summary-card { display:grid; gap:6px; padding:16px; }
|
||||||
|
.simulation-summary-card small { color:var(--muted); font-size:12px; }
|
||||||
|
.simulation-summary-card strong { font-size:28px; letter-spacing:-.04em; }
|
||||||
|
.simulation-summary-card span { color:var(--muted); font-size:12px; line-height:1.45; }
|
||||||
|
.simulation-section-head { display:flex; align-items:end; justify-content:space-between; margin:24px 0 12px; }
|
||||||
|
.simulation-section-head h2 { margin:4px 0 0; }
|
||||||
|
.simulation-timeline-panel { padding:16px; margin-bottom:22px; }
|
||||||
|
.simulation-timeline { display:grid; gap:10px; }
|
||||||
|
.simulation-timeline-item { display:grid; grid-template-columns:110px 1fr; gap:12px; align-items:start; padding:12px 0; border-bottom:1px solid var(--line); }
|
||||||
|
.simulation-timeline-item:last-child { border-bottom:0; padding-bottom:0; }
|
||||||
|
.simulation-timeline-item time { color:var(--muted); font-size:12px; }
|
||||||
|
.simulation-timeline-item strong { display:block; margin-bottom:4px; }
|
||||||
|
.simulation-timeline-item p { margin:0; color:var(--muted); line-height:1.45; }
|
||||||
|
.simulation-zone-grid { display:grid; grid-template-columns:repeat(auto-fit, minmax(min(100%, 380px), 1fr)); gap:14px; }
|
||||||
|
.simulation-zone-card { display:grid; gap:14px; padding:16px; }
|
||||||
|
.simulation-zone-card.disabled { opacity:.68; }
|
||||||
|
.simulation-zone-head, .simulation-rule-head { display:flex; align-items:flex-start; justify-content:space-between; gap:10px; }
|
||||||
|
.simulation-zone-head h3, .simulation-rule-head h3 { margin:3px 0 0; }
|
||||||
|
.simulation-zone-head p, .simulation-rule-head p { margin:6px 0 0; color:var(--muted); font-size:13px; line-height:1.45; }
|
||||||
|
.simulation-flow { display:grid; grid-template-columns:repeat(4, minmax(0, 1fr)); gap:10px; }
|
||||||
|
.simulation-step { display:grid; gap:6px; padding:12px; border:1px solid var(--line); border-radius:16px; background:rgba(255,255,255,.02); min-height:112px; }
|
||||||
|
html[data-theme='light'] .simulation-step { background:rgba(0,0,0,.02); }
|
||||||
|
.simulation-step.accent { box-shadow:inset 0 0 0 1px rgba(101,163,255,.28); }
|
||||||
|
.simulation-step small, .simulation-metrics small, .simulation-rule-meta small { color:var(--muted); font-size:11px; }
|
||||||
|
.simulation-step strong { font-size:15px; line-height:1.35; }
|
||||||
|
.simulation-step span { color:var(--muted); font-size:12px; line-height:1.4; }
|
||||||
|
.simulation-metrics { display:grid; grid-template-columns:repeat(4, minmax(0, 1fr)); gap:10px; }
|
||||||
|
.simulation-metrics > div, .simulation-rule-action { padding:12px; border-radius:14px; border:1px solid var(--line); background:rgba(255,255,255,.02); }
|
||||||
|
html[data-theme='light'] .simulation-metrics > div, html[data-theme='light'] .simulation-rule-action { background:rgba(0,0,0,.02); }
|
||||||
|
.simulation-metrics strong { display:block; margin-top:4px; }
|
||||||
|
.simulation-events { padding-top:0; border-top:0; }
|
||||||
|
.simulation-rules-grid { display:grid; grid-template-columns:repeat(auto-fit, minmax(min(100%, 320px), 1fr)); gap:14px; }
|
||||||
|
.simulation-rule-card { display:grid; gap:12px; padding:16px; }
|
||||||
|
.simulation-rule-card > p { margin:0; color:var(--muted); }
|
||||||
|
.simulation-rule-action { font-size:14px; line-height:1.5; }
|
||||||
|
.simulation-rule-meta { display:flex; flex-wrap:wrap; gap:12px; color:var(--muted); }
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.simulation-flow, .simulation-metrics { grid-template-columns:repeat(2, minmax(0, 1fr)); }
|
||||||
|
}
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.simulation-timeline-item { grid-template-columns:1fr; gap:6px; }
|
||||||
|
.simulation-flow, .simulation-metrics { grid-template-columns:1fr; }
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user