v0.7.0
This commit is contained in:
+35
-1
@@ -1,4 +1,4 @@
|
|||||||
# GREE Controller v0.6.9 - build and validation report
|
# GREE Controller v0.7.0 - build and validation report
|
||||||
|
|
||||||
## Scope
|
## Scope
|
||||||
|
|
||||||
@@ -191,3 +191,37 @@ Validation performed in this packaging environment:
|
|||||||
- Added explicit climate-group cards to What happens next, combining member demand and upcoming member-zone events.
|
- Added explicit climate-group cards to What happens next, combining member demand and upcoming member-zone events.
|
||||||
- Zone plan cards identify their climate group membership, and automation plan rows preserve group targets.
|
- Zone plan cards identify their climate group membership, and automation plan rows preserve group targets.
|
||||||
- Advanced the PWA cache key to `gree-controller-v069-dashboard-fit`.
|
- Advanced the PWA cache key to `gree-controller-v069-dashboard-fit`.
|
||||||
|
|
||||||
|
|
||||||
|
## 0.7.0 UI cleanup and scheduler/automation integrity audit
|
||||||
|
|
||||||
|
- Removed directional arrow glyphs from Swing controls on dashboard and device cards.
|
||||||
|
- Removed the divider above the unit feature block and tightened desktop quick-mode/fan controls so all buttons fit without horizontal scrolling.
|
||||||
|
- Allowed the detailed zone quick-change hint to wrap naturally on desktop.
|
||||||
|
- Added an explicit Mode policy hint: keep Follow house mode when the zone should inherit the global GREE Controller mode; fixed Cooling/Heating are intentional overrides.
|
||||||
|
- Fixed the Always-comfort schedule template to represent a true 24-hour window without a one-minute gap.
|
||||||
|
- Added overlap detection for enabled schedules, including overnight windows, and deterministic handling for legacy overlapping rows.
|
||||||
|
- Fixed the Workday template Sunday-to-Monday handoff so Sunday Sleep ends at 06:30 before Monday Morning starts.
|
||||||
|
- Manual zone/profile overrides now expire at the actual next weekly schedule transition; if none exists, they remain active until explicitly cleared instead of expiring after an arbitrary fallback timeout.
|
||||||
|
- Editing/deleting/applying schedule templates refreshes the active manual-override boundary.
|
||||||
|
- Time automations are limited to one execution per local minute even with the minimum 30-second cooldown; failed actions now also observe cooldown to avoid retry/log storms.
|
||||||
|
- Temperature automations ignore stale readings from disabled/offline trigger devices.
|
||||||
|
- Direct-device automations cannot wake a unit blocked by a disabled climate group.
|
||||||
|
- Group power-on respects other disabled overlapping groups; group power-off remains authoritative.
|
||||||
|
- Automation creation/import validates target/trigger references, empty actions and unsupported group-action fields.
|
||||||
|
- Device/zone deletion now protects automation/group references and removes orphaned schedules. A physical device can be assigned to only one thermostat zone.
|
||||||
|
- Control-plan event timestamps are normalized to minute boundaries; time-event preview covers DST-length local days.
|
||||||
|
- Added Rust unit-test cases for overnight/full-day schedules, overlap detection, weekly boundary lookup, Workday weekend handoff and one-fire-per-minute time automations.
|
||||||
|
|
||||||
|
Rust compilation/tests still require a host with the Rust toolchain (`cargo`/`rustc`); this build environment does not provide it. Static frontend, JSON, Python/Home Assistant, archive and manifest validation are performed below.
|
||||||
|
|
||||||
|
Validation for the 0.7.0 package:
|
||||||
|
|
||||||
|
- `node --check` passed for `web/app.js` and `web/sw.js`.
|
||||||
|
- PL/EN JSON and Home Assistant manifest parsed successfully; PL/EN translation key sets are identical (578 keys each).
|
||||||
|
- Home Assistant Python modules passed `python3 -m py_compile`.
|
||||||
|
- `web/index.html` parsed with Python's HTML parser; CSS braces are balanced.
|
||||||
|
- No Swing arrow glyph remains in web controls, the device feature divider rule is removed, and the detailed-zone quick hint has an explicit wrapping rule.
|
||||||
|
- All built-in schedule templates were exercised against the weekly minute-mask model: no overlaps; Family/Child/Bedroom/Always cover the full week, and Workday's intentional uncovered period falls back to the normal Comfort profile.
|
||||||
|
- SQLite `INIT_SCHEMA` and device-delete orphan-schedule cleanup SQL were executed successfully against temporary in-memory databases.
|
||||||
|
- Rust source delimiter/static checks passed. Rust compile/test execution is unavailable here because no Rust toolchain is installed.
|
||||||
|
|||||||
Generated
+1
-1
@@ -633,7 +633,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "gree-controller"
|
name = "gree-controller"
|
||||||
version = "0.6.9"
|
version = "0.7.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes",
|
"aes",
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "gree-controller"
|
name = "gree-controller"
|
||||||
version = "0.6.9"
|
version = "0.7.0"
|
||||||
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"
|
||||||
|
|||||||
+17
-17
@@ -1,16 +1,16 @@
|
|||||||
d67af429e4da9ce08e9d2f2a8472849ffbd70d135b1c5da535a076026794d04c ./.env.example
|
d67af429e4da9ce08e9d2f2a8472849ffbd70d135b1c5da535a076026794d04c ./.env.example
|
||||||
a4ec3874a2e3ab1bad28fb40bb620f7b01f64d01ad9b699306bf70ada31227db ./.gitignore
|
a4ec3874a2e3ab1bad28fb40bb620f7b01f64d01ad9b699306bf70ada31227db ./.gitignore
|
||||||
e6eb0b2882aa446546fc5a4e63fb11973e79483c263fa053fc10cb2e9aaf53bb ./BUILD_REPORT.md
|
88a25226a5831a9ca8aa8cad30c6dea3d0df8138d0f2a5b1542218b5ac1f9864 ./BUILD_REPORT.md
|
||||||
9227483cd15d1d93b01b312d119179c8348cffda4bd4870571cdabfa8e972b14 ./Cargo.lock
|
180a882f534228ac685d1a97696f8d2874aa500b905d4dbbe42ee38eddbe57b8 ./Cargo.lock
|
||||||
b8fcf5528e25c4823ca90b2565dd025994aafadfa35360af475db66ef53d2359 ./Cargo.toml
|
72d6873d30d9d76f7e402c3370f0f0bf8e7515a1ab50a67fcda4ddd396bc8036 ./Cargo.toml
|
||||||
19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE
|
19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE
|
||||||
191d7fd1242937ba1c3df7f0b3014c56a6944f6445cd1453832b4dc8024d37bf ./README.md
|
f53e008970ef05699efca9f15e6de487af5c7f7bef6e226eb409e9f8c17e54b8 ./README.md
|
||||||
f2c8eb28fedb26bb8f5636e1805e7a782cc472b87d882f6752d520f16216a248 ./build.rs
|
f2c8eb28fedb26bb8f5636e1805e7a782cc472b87d882f6752d520f16216a248 ./build.rs
|
||||||
74fc74d2bb05541f2137250c6a73a9f39a6fbd838e5d95d8c8efd7ebefc384f0 ./docs/API.md
|
53b04025be8be67e6970e825f5c80a067715eea5ecd2d592aa26a9d37a0dcb51 ./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
|
||||||
41289af73916d22273a482d664a47e7207b64f8ac04ee9e427662ba7c9c7a24a ./docs/PROJECT_SPEC.md
|
f135c029c528f55918e394d98dae7f92794ff28e00d49cc7e9f519e409c0d3ee ./docs/PROJECT_SPEC.md
|
||||||
7748b1230029439a405139750b2e0607b8e463bfd56ad86982394105069697c5 ./docs/REVERSE_PROXY.md
|
7748b1230029439a405139750b2e0607b8e463bfd56ad86982394105069697c5 ./docs/REVERSE_PROXY.md
|
||||||
28f1403d84b01b37bebb72548aa39a75bb14bdbb611a43bfa761319957cd6f39 ./docs/SECURITY.md
|
28f1403d84b01b37bebb72548aa39a75bb14bdbb611a43bfa761319957cd6f39 ./docs/SECURITY.md
|
||||||
1192c5442cb0439723294bbed8b4610728c51bca23e1d8c0ef030270d1c697ba ./home-assistant/README.md
|
1192c5442cb0439723294bbed8b4610728c51bca23e1d8c0ef030270d1c697ba ./home-assistant/README.md
|
||||||
@@ -23,7 +23,7 @@ b7f0873109c52be9d7f09bea3dffc416103c50085e1f0680d11661a969479898 ./home-assista
|
|||||||
5a96fe8f5c035c34f1339370270cd078056202d09e236dec75735be11de92a7d ./home-assistant/custom_components/gree_controller/entity_map.py
|
5a96fe8f5c035c34f1339370270cd078056202d09e236dec75735be11de92a7d ./home-assistant/custom_components/gree_controller/entity_map.py
|
||||||
c4fb75c246db651087900ebfc2291ff41ac87652cd6194fc0b776b0005c1cbcf ./home-assistant/custom_components/gree_controller/icon.png
|
c4fb75c246db651087900ebfc2291ff41ac87652cd6194fc0b776b0005c1cbcf ./home-assistant/custom_components/gree_controller/icon.png
|
||||||
c4fb75c246db651087900ebfc2291ff41ac87652cd6194fc0b776b0005c1cbcf ./home-assistant/custom_components/gree_controller/logo.png
|
c4fb75c246db651087900ebfc2291ff41ac87652cd6194fc0b776b0005c1cbcf ./home-assistant/custom_components/gree_controller/logo.png
|
||||||
97117268892b7868ff4001017d422c30a94b68f4c656cbb8c5fa1d65c23fd65f ./home-assistant/custom_components/gree_controller/manifest.json
|
9032738bebf9c0e030f711f22853f6541ed6a4ab80a659b930756ba722052848 ./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
|
||||||
459233ccefa7827fc1c6887146d8a69e01149a497635fd5e1802c220f418eb28 ./home-assistant/custom_components/gree_controller/select.py
|
459233ccefa7827fc1c6887146d8a69e01149a497635fd5e1802c220f418eb28 ./home-assistant/custom_components/gree_controller/select.py
|
||||||
ec35191e27a018a72cb341914e2abd7fecd25f6b641ea25bebb9e809dd8ecaff ./home-assistant/custom_components/gree_controller/sensor.py
|
ec35191e27a018a72cb341914e2abd7fecd25f6b641ea25bebb9e809dd8ecaff ./home-assistant/custom_components/gree_controller/sensor.py
|
||||||
@@ -32,8 +32,8 @@ ec35191e27a018a72cb341914e2abd7fecd25f6b641ea25bebb9e809dd8ecaff ./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
|
||||||
6385512c4d23d53abca0c1e178420733baf30232a20d00e53688c7b8da91ac01 ./lang/en.json
|
54c6f8405d090d9be4fe8247636679603132662d1c3359fdcb272bf86a144609 ./lang/en.json
|
||||||
067888c4d220169604303f3236e4832e2935bf2a20633cbff7fa86ec8dc9785d ./lang/pl.json
|
caa87ffcace5e00a1cc116f1183481f2a191d5a377d04f870d1ac9f9751213ad ./lang/pl.json
|
||||||
028e1f16e9fbaed57cadb88eff04e65b4bd67722c50b4d6b1fb525f5a2f39abf ./make_zip.py
|
028e1f16e9fbaed57cadb88eff04e65b4bd67722c50b4d6b1fb525f5a2f39abf ./make_zip.py
|
||||||
bb89bac237e750e9b1bf73761d7df97a6b81853091615878c03f13d7b6399aa7 ./scripts/README.md
|
bb89bac237e750e9b1bf73761d7df97a6b81853091615878c03f13d7b6399aa7 ./scripts/README.md
|
||||||
5bc736c7bc76ca80aaa406bb171d2aa91baf4c3aa8695dce0e09b888b6ab3146 ./scripts/common.sh
|
5bc736c7bc76ca80aaa406bb171d2aa91baf4c3aa8695dce0e09b888b6ab3146 ./scripts/common.sh
|
||||||
@@ -46,10 +46,10 @@ 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
|
||||||
36020d4996270aca03d5621d1ce0eb5c8e87629b95c80eb3f53b6f7613331a4a ./src/api.rs
|
13ba625f9b0db3caed3a8c09f2a18d7485f2574e23daa5375a407ab63ece169e ./src/api.rs
|
||||||
32b1d0a7ac2060fd18fe7f1ca1d110ee45beaf67e81514d24d6bde3d36301387 ./src/config.rs
|
32b1d0a7ac2060fd18fe7f1ca1d110ee45beaf67e81514d24d6bde3d36301387 ./src/config.rs
|
||||||
0d734ffd0874c8794a87a75ff4d9ddde467d4c2b87b24c4c1531cb419561384d ./src/db.rs
|
5dfda2f4dc540c502885b0cd7017dc77768684588acf528f01d1fd88f1af4aec ./src/db.rs
|
||||||
ca3d30969d64ec08ce5c38c833f287a5ee8231f9beda3140e51b9bf4e0f721ab ./src/engine.rs
|
0f818e27983476ad41bf8f647749bc5a7dc7924731bb436ec996b0c8e595f93d ./src/engine.rs
|
||||||
ae3b496749a3fd723b243d9bea92e5d76249f52814c80359ad9bac53abacb074 ./src/error.rs
|
ae3b496749a3fd723b243d9bea92e5d76249f52814c80359ad9bac53abacb074 ./src/error.rs
|
||||||
081f6909e95839c7da8c40b866af9de237e0540f3e65e1ce739ce70a6fa94846 ./src/home_assistant.rs
|
081f6909e95839c7da8c40b866af9de237e0540f3e65e1ce739ce70a6fa94846 ./src/home_assistant.rs
|
||||||
190b0a33431539676e5dd7796698077f16c179d42eae4501ca96a91bf797cbf8 ./src/influxdb.rs
|
190b0a33431539676e5dd7796698077f16c179d42eae4501ca96a91bf797cbf8 ./src/influxdb.rs
|
||||||
@@ -59,13 +59,13 @@ ea4bd7f20759101c2525600a1f342d2fc0946e2f0b3208b03cc5a415b6947c10 ./src/notifica
|
|||||||
7fc31fbf8841a073a1544b8c7a6390f1a15b56087486ca0596a8418340fa232a ./src/protocol/crypto.rs
|
7fc31fbf8841a073a1544b8c7a6390f1a15b56087486ca0596a8418340fa232a ./src/protocol/crypto.rs
|
||||||
0008f13b1c921300aafb5b9f745c916a65fd06916e5de19f89599ebb618c6350 ./src/protocol/gree.rs
|
0008f13b1c921300aafb5b9f745c916a65fd06916e5de19f89599ebb618c6350 ./src/protocol/gree.rs
|
||||||
a910bd9432a393740c0f6fab52bfcb551f0ea756718d66d290fd2610767cf07c ./src/protocol/mod.rs
|
a910bd9432a393740c0f6fab52bfcb551f0ea756718d66d290fd2610767cf07c ./src/protocol/mod.rs
|
||||||
66d7a1a12843975506ffef2128d161627b96d665d39cedde2d791651e4598ee1 ./src/queries.rs
|
6a1c0cab3eab80ecd254c5e486b1fba068523edb8d699c64054c17f67f4a31d0 ./src/queries.rs
|
||||||
f74ac398c6f2e64ebb965900330324f3ec7403e70339638a408229f29fbd07ae ./src/state.rs
|
f74ac398c6f2e64ebb965900330324f3ec7403e70339638a408229f29fbd07ae ./src/state.rs
|
||||||
b92a6cb158b494fe145b43c7641e65f6fafff47201d7d76edbec2cfd8b94835c ./systemd/gree-controller.service
|
b92a6cb158b494fe145b43c7641e65f6fafff47201d7d76edbec2cfd8b94835c ./systemd/gree-controller.service
|
||||||
dfb89acd9b040c32b24146198d66ca98a0bc3dfbc852ac03b2ca3d252923ec3e ./web/app.js
|
a51b4bc14f9fbc4d5b936c1f56b4c53a5aaded69f5334991da3328cb7b3f3cb6 ./web/app.js
|
||||||
e98bdd7204349cce1ec6f57283509697af0bbc72280622a6c3efa6fed242db4f ./web/favicon.svg
|
e98bdd7204349cce1ec6f57283509697af0bbc72280622a6c3efa6fed242db4f ./web/favicon.svg
|
||||||
f3b864558d327d769a6d4a33e88b96a518ec900ce39d6af990bd9cabb3b075a5 ./web/index.html
|
954b5fdabef37d7788790fb15a5f95a96aeb810cba7ccf24763aa28615d4749c ./web/index.html
|
||||||
fd26156e9f1d6713d3def564ad000553d9a16a24376059701db2ee762c99ee6c ./web/manifest.webmanifest
|
fd26156e9f1d6713d3def564ad000553d9a16a24376059701db2ee762c99ee6c ./web/manifest.webmanifest
|
||||||
77c96fe9ca242edb44defe2f7df40b99e7ec2c546d8d13c1c31cc55beea6522c ./web/styles.css
|
9bc2a34daeb2b7083af44215e904aaadf1d4da9d003e6fcef2d3fc9f994de363 ./web/styles.css
|
||||||
17f83bd268aac0a48e0ad230b970ca868df330f8884cc03747d8bee9aa8aef5d ./web/sw.js
|
bf2db03bf27c567121a3ebe3bd11d84d13dce91f3c1f068aadff6e11c5120a26 ./web/sw.js
|
||||||
d505d793ce7cc9485b45b78bba1c0d51887adc7451ab59a42702946e5b991382 ./web/theme-init.js
|
d505d793ce7cc9485b45b78bba1c0d51887adc7451ab59a42702946e5b991382 ./web/theme-init.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.6.9**.
|
Current version: **0.7.0**.
|
||||||
|
|
||||||
## Highlights
|
## Highlights
|
||||||
|
|
||||||
@@ -47,7 +47,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.6.9.zip
|
unzip gree-controller-v0.7.0.zip
|
||||||
cd gree-controller
|
cd gree-controller
|
||||||
chmod +x scripts/*.sh
|
chmod +x scripts/*.sh
|
||||||
./scripts/dev.sh
|
./scripts/dev.sh
|
||||||
@@ -218,15 +218,15 @@ For combined control, `max_sensor_difference` protects against an obviously inco
|
|||||||
|
|
||||||
The zone API exposes `device_temperature`, `external_temperature`, `current_temperature` (the actual control temperature) and `control_temperature_source` for diagnostics. Existing SQLite zone records remain compatible because the new fields have defaults and are stored in the existing JSON payload.
|
The zone API exposes `device_temperature`, `external_temperature`, `current_temperature` (the actual control temperature) and `control_temperature_source` for diagnostics. Existing SQLite zone records remain compatible because the new fields have defaults and are stored in the existing JSON payload.
|
||||||
|
|
||||||
Zone cards are fast thermostats: `- / +`, **Auto schedule / Comfort / Sleep now / Away**, and **House / Heat / Cool** are available without opening the edit dialog. Temporary overrides automatically end at the next schedule boundary. The zone engine, not the browser, owns device commands so schedules, sensors and manual overrides cannot fight each other.
|
Zone cards are fast thermostats: `- / +`, **Auto schedule / Comfort / Sleep now / Away**, and **House / Heat / Cool** are available without opening the edit dialog. Temporary overrides automatically end at the next schedule boundary; if a zone has no future schedule transition, the override stays active until it is cleared. The zone engine, not the browser, owns device commands so schedules, sensors and manual overrides cannot fight each other.
|
||||||
|
|
||||||
## Smart thermostat and schedules
|
## Smart thermostat and schedules
|
||||||
|
|
||||||
Version 0.4.0 is designed around a simple rule: during normal heating/cooling, keep the indoor unit powered and modulate its target instead of repeatedly switching the unit OFF and ON. When a room is satisfied, cooling moves the device target above the comfort target and heating moves it below the comfort target. When hysteresis requests conditioning again, the working target is restored. **House Off** is now a no-control state for inherited zones, so manual device operation is not overwritten; whole-house master power on/off is available separately and can authoritatively keep every unit off without changing the selected thermostat mode.
|
Version 0.4.0 is designed around a simple rule: during normal heating/cooling, keep the indoor unit powered and modulate its target instead of repeatedly switching the unit OFF and ON. When a room is satisfied, cooling moves the device target above the comfort target and heating moves it below the comfort target. When hysteresis requests conditioning again, the working target is restored. **House Off** is now a no-control state for inherited zones, so manual device operation is not overwritten; whole-house master power on/off is available separately and can authoritatively keep every unit off without changing the selected thermostat mode.
|
||||||
|
|
||||||
Choose one seasonal **House mode** — Cooling in summer, Heating in winter, or Off. Zones follow the house mode by default, while a zone may explicitly override Heat/Cool when required. Each zone has separate Comfort/Sleep/Away temperatures for cooling and heating.
|
Choose one seasonal **House mode** — Cooling in summer, Heating in winter, or Off. Zones follow the house mode by default, while a zone may explicitly override Heat/Cool when required. In **Mode policy**, leave **Follow house mode** unchanged when the zone should inherit the mode selected globally in GREE Controller; **Cooling only** / **Heating only** are deliberate per-zone overrides. Each zone has separate Comfort/Sleep/Away temperatures for cooling and heating.
|
||||||
|
|
||||||
Schedules store profiles rather than duplicating temperatures. Ready-made **Family**, **Child room**, **Bedroom**, **Workday** and **Always comfort** templates generate ordinary editable schedule entries. For example, a child room template can enter Sleep earlier; if the child goes to bed even earlier, press **Sleep now** and that zone remains in Sleep until its next schedule boundary. Whole-house **Comfort all / Sleep all / Away all / Auto schedule** actions are also available.
|
Schedules store profiles rather than duplicating temperatures. Ready-made **Family**, **Child room**, **Bedroom**, **Workday** and **Always comfort** templates generate ordinary editable schedule entries. For example, a child room template can enter Sleep earlier; if the child goes to bed even earlier, press **Sleep now** and that zone remains in Sleep until its next schedule boundary (or until manually cleared when no future boundary exists). Whole-house **Comfort all / Sleep all / Away all / Auto schedule** actions are also available.
|
||||||
|
|
||||||
The optional HA outdoor sensor is an assist signal only. It never replaces the room-control temperature. In extreme weather it can make the active device setpoint slightly more assertive and let Smart Fan increase airflow.
|
The optional HA outdoor sensor is an assist signal only. It never replaces the room-control temperature. In extreme weather it can make the active device setpoint slightly more assertive and let Smart Fan increase airflow.
|
||||||
|
|
||||||
|
|||||||
+4
-4
@@ -79,7 +79,7 @@ curl -X POST "$BASE/api/house/preset" -H "$AUTH" -H 'Content-Type: application/j
|
|||||||
-d '{"preset":"sleep"}'
|
-d '{"preset":"sleep"}'
|
||||||
```
|
```
|
||||||
|
|
||||||
House modes: `cool`, `heat`, `off` (`off` = no house-level thermostat control). House presets: `auto`, `comfort`, `sleep`, `away`. A non-`auto` house preset creates temporary per-zone overrides that expire at each zone's next schedule boundary. `POST /api/house/power` controls a separate whole-house master power state. With master power off, zones and controller automations cannot restart units; the selected house thermostat mode is preserved. This is independent from house mode `off`, which only means no house-level thermostat control and allows direct/manual device operation. Choosing whole-house `cool`/`heat` or any whole-house preset is treated as an explicit whole-house activation: master power is set back to on and all enabled units are powered on. Choosing house mode `off` does not change master power.
|
House modes: `cool`, `heat`, `off` (`off` = no house-level thermostat control). House presets: `auto`, `comfort`, `sleep`, `away`. A non-`auto` house preset creates per-zone overrides that expire at each zone's next schedule boundary; a zone with no future transition keeps the override until it is cleared. `POST /api/house/power` controls a separate whole-house master power state. With master power off, zones and controller automations cannot restart units; the selected house thermostat mode is preserved. This is independent from house mode `off`, which only means no house-level thermostat control and allows direct/manual device operation. Choosing whole-house `cool`/`heat` or any whole-house preset is treated as an explicit whole-house activation: master power is set back to on and all enabled units are powered on. Choosing house mode `off` does not change master power.
|
||||||
|
|
||||||
A zone stores separate profile temperatures for both seasons:
|
A zone stores separate profile temperatures for both seasons:
|
||||||
|
|
||||||
@@ -111,7 +111,7 @@ A zone stores separate profile temperatures for both seasons:
|
|||||||
Quick control does not directly fight the schedule engine:
|
Quick control does not directly fight the schedule engine:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Sleep now until the next schedule boundary
|
# Sleep now until the next schedule boundary (or until cleared if there is no future transition)
|
||||||
curl -X POST "$BASE/api/zones/ZONE_ID/control" -H "$AUTH" -H 'Content-Type: application/json' \
|
curl -X POST "$BASE/api/zones/ZONE_ID/control" -H "$AUTH" -H 'Content-Type: application/json' \
|
||||||
-d '{"preset":"sleep"}'
|
-d '{"preset":"sleep"}'
|
||||||
|
|
||||||
@@ -124,7 +124,7 @@ curl -X POST "$BASE/api/zones/ZONE_ID/control" -H "$AUTH" -H 'Content-Type: appl
|
|||||||
-d '{"setpoint":22.5}'
|
-d '{"setpoint":22.5}'
|
||||||
```
|
```
|
||||||
|
|
||||||
`mode` on the quick zone endpoint accepts `house`, `cool`, or `heat`. Global `off` always wins over local zone mode overrides.
|
`mode` on the quick zone endpoint accepts `house`, `cool`, or `heat`. `house` means **Follow house mode** and should be left unchanged when the zone is meant to inherit the global GREE Controller mode. A fixed `cool`/`heat` is an explicit per-zone override; therefore house mode `off` stops inherited zones but does not disable a zone deliberately fixed to Cooling or Heating. Whole-house master power remains authoritative over all zones.
|
||||||
|
|
||||||
### Climate groups
|
### Climate groups
|
||||||
|
|
||||||
@@ -166,7 +166,7 @@ Schedules use a profile (`comfort`, `sleep`, `away`) or a custom temperature:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
For non-`custom` schedules, `setpoint` is only a compatibility value; the actual target comes from the zone's seasonal profile.
|
For non-`custom` schedules, `setpoint` is only a compatibility value; the actual target comes from the zone's seasonal profile. `start_time == end_time` means a 24-hour block for each selected weekday. Enabled schedules belonging to the same zone are rejected when their active windows overlap, including overnight overlaps.
|
||||||
|
|
||||||
### Home Assistant outdoor assist and local HTTPS
|
### Home Assistant outdoor assist and local HTTPS
|
||||||
|
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ Each device stores:
|
|||||||
|
|
||||||
## Smart thermostat, zones and schedules
|
## Smart thermostat, zones and schedules
|
||||||
|
|
||||||
The primary user model is a fast thermostat, not raw device automation. A global **House mode** selects the season (`cool`, `heat`, `off`). Zones follow it by default and can optionally override Heat/Cool. House `off` is a **do not control** state for inherited zones: manual device operation is left untouched, while a zone explicitly switched to Heat/Cool can operate independently. Whole-house power on/off is a separate master state. Master Off is authoritative for zones and controller automations but does not modify the selected house thermostat mode.
|
The primary user model is a fast thermostat, not raw device automation. A global **House mode** selects the season (`cool`, `heat`, `off`). Zones follow it by default and can optionally override Heat/Cool. The UI labels this as **Mode policy**: keep **Follow house mode** when the zone should inherit the mode selected globally in GREE Controller; the fixed Cooling/Heating policies are explicit overrides. House `off` is a **do not control** state for inherited zones: manual device operation is left untouched, while a zone explicitly switched to Heat/Cool can operate independently. Whole-house power on/off is a separate master state. Master Off is authoritative for zones and controller automations but does not modify the selected house thermostat mode.
|
||||||
|
|
||||||
During normal heating/cooling the controller minimizes unit power cycling. It keeps the indoor unit powered and uses **setpoint modulation**:
|
During normal heating/cooling the controller minimizes unit power cycling. It keeps the indoor unit powered and uses **setpoint modulation**:
|
||||||
|
|
||||||
@@ -84,7 +84,7 @@ Each zone has separate seasonal profile temperatures:
|
|||||||
|
|
||||||
The zone can use the GREE internal sensor, its own Home Assistant room sensor, or a weighted combination. External room sensors are configured per zone; loss/discrepancy falls back safely to the GREE sensor.
|
The zone can use the GREE internal sensor, its own Home Assistant room sensor, or a weighted combination. External room sensors are configured per zone; loss/discrepancy falls back safely to the GREE sensor.
|
||||||
|
|
||||||
Weekly schedules reference profiles instead of duplicating temperatures. Ready-made Family, Child room, Bedroom, Workday and Always-comfort templates create ordinary schedule rows that remain fully editable. A manual **Sleep now**, Comfort, Away or custom-temperature override ends automatically at the next schedule boundary. Whole-house preset actions apply the same temporary policy to every zone.
|
Weekly schedules reference profiles instead of duplicating temperatures. Ready-made Family, Child room, Bedroom, Workday and Always-comfort templates create ordinary schedule rows that remain fully editable. A manual **Sleep now**, Comfort, Away or custom-temperature override ends automatically at the next schedule boundary; if no future transition exists, it remains active until explicitly cleared. Whole-house preset actions apply the same temporary policy to every zone.
|
||||||
|
|
||||||
Automations remain available for advanced exceptions. Daily comfort should be implemented with zones/profiles/schedules so direct-device automation commands do not compete with the thermostat engine.
|
Automations remain available for advanced exceptions. Daily comfort should be implemented with zones/profiles/schedules so direct-device automation commands do not compete with the thermostat engine.
|
||||||
|
|
||||||
@@ -96,7 +96,7 @@ A configured Home Assistant outdoor-temperature entity is optional auxiliary con
|
|||||||
|
|
||||||
## Scheduler and automations
|
## Scheduler and automations
|
||||||
|
|
||||||
Schedules are weekly time windows and may cross midnight. Schedules can select Comfort/Sleep/Away or a custom setpoint. Automations support time or temperature triggers and device commands with cooldown protection; they are intended for exceptions rather than the normal daily thermostat cycle.
|
Schedules are weekly time windows and may cross midnight. Equal start/end times mean a 24-hour block for each selected weekday. Enabled schedules for one zone may not overlap. Schedules can select Comfort/Sleep/Away or a custom setpoint. Automations support time or temperature triggers and device commands with cooldown protection; a failed command also observes the cooldown instead of retrying every control cycle. They are intended for exceptions rather than the normal daily thermostat cycle.
|
||||||
|
|
||||||
The automation engine runs in Rust and does not require Home Assistant YAML automation logic.
|
The automation engine runs in Rust and does not require Home Assistant YAML automation logic.
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"domain": "gree_controller",
|
"domain": "gree_controller",
|
||||||
"name": "GREE Controller",
|
"name": "GREE Controller",
|
||||||
"version": "0.6.9",
|
"version": "0.7.0",
|
||||||
"config_flow": true,
|
"config_flow": true,
|
||||||
"integration_type": "hub",
|
"integration_type": "hub",
|
||||||
"iot_class": "local_polling",
|
"iot_class": "local_polling",
|
||||||
|
|||||||
+3
-1
@@ -248,6 +248,7 @@
|
|||||||
"house.outdoor": "Outdoor",
|
"house.outdoor": "Outdoor",
|
||||||
"house.modeUpdated": "Home climate mode updated",
|
"house.modeUpdated": "Home climate mode updated",
|
||||||
"zones.modePolicy": "Mode policy",
|
"zones.modePolicy": "Mode policy",
|
||||||
|
"zones.modePolicyHint": "If this zone should inherit the mode selected globally in the app, leave “Follow house mode” unchanged. “Cooling only” or “Heating only” forces a fixed mode only for this zone.",
|
||||||
"zones.followHouse": "Follow house mode",
|
"zones.followHouse": "Follow house mode",
|
||||||
"zones.followHouseShort": "House",
|
"zones.followHouseShort": "House",
|
||||||
"zones.profileTemperatures": "Comfort / sleep / away temperatures",
|
"zones.profileTemperatures": "Comfort / sleep / away temperatures",
|
||||||
@@ -257,9 +258,10 @@
|
|||||||
"zones.deviceTarget": "AC target",
|
"zones.deviceTarget": "AC target",
|
||||||
"zones.overrideUntil": "Manual until",
|
"zones.overrideUntil": "Manual until",
|
||||||
"zones.scheduleControl": "Schedule control",
|
"zones.scheduleControl": "Schedule control",
|
||||||
|
"zones.manualNoBoundary": "Manual · no next transition",
|
||||||
"zones.requesting": "Comfort demand",
|
"zones.requesting": "Comfort demand",
|
||||||
"zones.satisfied": "Satisfied / inverter standby",
|
"zones.satisfied": "Satisfied / inverter standby",
|
||||||
"zones.quickHintSmart": "Quick changes override the schedule until its next transition.",
|
"zones.quickHintSmart": "Quick changes last until the next schedule transition; without a schedule they stay active.",
|
||||||
"preset.auto": "Auto",
|
"preset.auto": "Auto",
|
||||||
"preset.comfort": "Comfort",
|
"preset.comfort": "Comfort",
|
||||||
"preset.sleep": "Sleep",
|
"preset.sleep": "Sleep",
|
||||||
|
|||||||
+3
-1
@@ -248,6 +248,7 @@
|
|||||||
"house.outdoor": "Na zewnątrz",
|
"house.outdoor": "Na zewnątrz",
|
||||||
"house.modeUpdated": "Zmieniono tryb klimatu domu",
|
"house.modeUpdated": "Zmieniono tryb klimatu domu",
|
||||||
"zones.modePolicy": "Polityka trybu",
|
"zones.modePolicy": "Polityka trybu",
|
||||||
|
"zones.modePolicyHint": "Jeśli strefa ma dziedziczyć tryb ustawiony globalnie w aplikacji, zostaw „Dziedzicz tryb domu” i nie zmieniaj tej opcji. „Tylko chłodzenie” lub „Tylko grzanie” wymusza stały tryb tylko dla tej strefy.",
|
||||||
"zones.followHouse": "Dziedzicz tryb domu",
|
"zones.followHouse": "Dziedzicz tryb domu",
|
||||||
"zones.followHouseShort": "Dom",
|
"zones.followHouseShort": "Dom",
|
||||||
"zones.profileTemperatures": "Temperatury komfort / sen / poza domem",
|
"zones.profileTemperatures": "Temperatury komfort / sen / poza domem",
|
||||||
@@ -257,9 +258,10 @@
|
|||||||
"zones.deviceTarget": "Cel jednostki",
|
"zones.deviceTarget": "Cel jednostki",
|
||||||
"zones.overrideUntil": "Ręcznie do",
|
"zones.overrideUntil": "Ręcznie do",
|
||||||
"zones.scheduleControl": "Sterowanie harmonogramem",
|
"zones.scheduleControl": "Sterowanie harmonogramem",
|
||||||
|
"zones.manualNoBoundary": "Ręcznie · brak kolejnego przejścia",
|
||||||
"zones.requesting": "Potrzeba komfortu",
|
"zones.requesting": "Potrzeba komfortu",
|
||||||
"zones.satisfied": "Komfort / standby inwertera",
|
"zones.satisfied": "Komfort / standby inwertera",
|
||||||
"zones.quickHintSmart": "Szybka zmiana obowiązuje do następnego przejścia harmonogramu.",
|
"zones.quickHintSmart": "Szybka zmiana działa do kolejnego przejścia harmonogramu; bez harmonogramu pozostaje aktywna.",
|
||||||
"preset.auto": "Auto",
|
"preset.auto": "Auto",
|
||||||
"preset.comfort": "Komfort",
|
"preset.comfort": "Komfort",
|
||||||
"preset.sleep": "Sen",
|
"preset.sleep": "Sen",
|
||||||
|
|||||||
+235
-33
@@ -371,10 +371,18 @@ async fn patch_device(State(state): State<AppState>, Path(id): Path<String>, Jso
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn delete_device(State(state): State<AppState>, Path(id): Path<String>) -> Result<StatusCode, AppError> {
|
async fn delete_device(State(state): State<AppState>, Path(id): Path<String>) -> Result<StatusCode, AppError> {
|
||||||
|
if state.db.get_device(&id)?.is_none() { return Err(AppError::NotFound(format!("device {id}"))); }
|
||||||
|
if state.db.list_automations()?.iter().any(|item| {
|
||||||
|
item.trigger_device_id.as_deref() == Some(id.as_str())
|
||||||
|
|| (item.action_group_id.is_none() && item.action_device_id == id)
|
||||||
|
}) {
|
||||||
|
return Err(AppError::BadRequest("device is used by an automation; remove or retarget that automation first".into()));
|
||||||
|
}
|
||||||
let removed_zone_ids: std::collections::HashSet<String> = state.db.list_zones()?.into_iter()
|
let removed_zone_ids: std::collections::HashSet<String> = state.db.list_zones()?.into_iter()
|
||||||
.filter(|zone| zone.device_id == id)
|
.filter(|zone| zone.device_id == id)
|
||||||
.map(|zone| zone.id)
|
.map(|zone| zone.id)
|
||||||
.collect();
|
.collect();
|
||||||
|
ensure_zone_removal_safe(&state, &removed_zone_ids)?;
|
||||||
if !state.db.delete_device(&id)? { return Err(AppError::NotFound(format!("device {id}"))); }
|
if !state.db.delete_device(&id)? { return Err(AppError::NotFound(format!("device {id}"))); }
|
||||||
remove_zone_ids_from_groups(&state, &removed_zone_ids)?;
|
remove_zone_ids_from_groups(&state, &removed_zone_ids)?;
|
||||||
state.log("info", "device.deleted", "Device deleted", json!({"device_id": id}));
|
state.log("info", "device.deleted", "Device deleted", json!({"device_id": id}));
|
||||||
@@ -506,6 +514,13 @@ impl ZoneInput {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn validate_zone_device_assignment(state: &AppState, device_id: &str, current_zone_id: Option<&str>) -> Result<(), AppError> {
|
||||||
|
if state.db.list_zones()?.iter().any(|zone| zone.device_id == device_id && current_zone_id != Some(zone.id.as_str())) {
|
||||||
|
return Err(AppError::BadRequest("a device can belong to only one thermostat zone".into()));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
async fn list_zones(State(state): State<AppState>) -> Result<Json<Vec<Zone>>, AppError> { Ok(Json(state.db.list_zones()?)) }
|
async fn list_zones(State(state): State<AppState>) -> Result<Json<Vec<Zone>>, AppError> { Ok(Json(state.db.list_zones()?)) }
|
||||||
async fn get_zone(State(state): State<AppState>, Path(id): Path<String>) -> Result<Json<Zone>, AppError> {
|
async fn get_zone(State(state): State<AppState>, Path(id): Path<String>) -> Result<Json<Zone>, AppError> {
|
||||||
state.db.get_zone(&id)?.map(Json).ok_or_else(|| AppError::NotFound(format!("zone {id}")))
|
state.db.get_zone(&id)?.map(Json).ok_or_else(|| AppError::NotFound(format!("zone {id}")))
|
||||||
@@ -513,6 +528,7 @@ async fn get_zone(State(state): State<AppState>, Path(id): Path<String>) -> Resu
|
|||||||
async fn create_zone(State(state): State<AppState>, Json(input): Json<ZoneInput>) -> Result<(StatusCode, Json<Zone>), AppError> {
|
async fn create_zone(State(state): State<AppState>, Json(input): Json<ZoneInput>) -> Result<(StatusCode, Json<Zone>), AppError> {
|
||||||
input.validate()?;
|
input.validate()?;
|
||||||
if state.db.get_device(&input.device_id)?.is_none() { return Err(AppError::BadRequest("zone device does not exist".into())); }
|
if state.db.get_device(&input.device_id)?.is_none() { return Err(AppError::BadRequest("zone device does not exist".into())); }
|
||||||
|
validate_zone_device_assignment(&state, &input.device_id, None)?;
|
||||||
let mut zone = input.into_zone(Uuid::new_v4().to_string(), Utc::now());
|
let mut zone = input.into_zone(Uuid::new_v4().to_string(), Utc::now());
|
||||||
let settings = state.settings.read().await.clone();
|
let settings = state.settings.read().await.clone();
|
||||||
canonicalize_zone_ha_entity(&mut zone, &settings);
|
canonicalize_zone_ha_entity(&mut zone, &settings);
|
||||||
@@ -524,6 +540,7 @@ async fn update_zone(State(state): State<AppState>, Path(id): Path<String>, Json
|
|||||||
input.validate()?;
|
input.validate()?;
|
||||||
let existing = state.db.get_zone(&id)?.ok_or_else(|| AppError::NotFound(format!("zone {id}")))?;
|
let existing = state.db.get_zone(&id)?.ok_or_else(|| AppError::NotFound(format!("zone {id}")))?;
|
||||||
if state.db.get_device(&input.device_id)?.is_none() { return Err(AppError::BadRequest("zone device does not exist".into())); }
|
if state.db.get_device(&input.device_id)?.is_none() { return Err(AppError::BadRequest("zone device does not exist".into())); }
|
||||||
|
validate_zone_device_assignment(&state, &input.device_id, Some(&id))?;
|
||||||
let mut zone = input.into_zone(id, existing.created_at);
|
let mut zone = input.into_zone(id, existing.created_at);
|
||||||
zone.device_temperature = existing.device_temperature;
|
zone.device_temperature = existing.device_temperature;
|
||||||
zone.external_temperature = existing.external_temperature;
|
zone.external_temperature = existing.external_temperature;
|
||||||
@@ -556,7 +573,7 @@ async fn update_zone_control(State(state): State<AppState>, Path(id): Path<Strin
|
|||||||
zone.setpoint = value;
|
zone.setpoint = value;
|
||||||
zone.manual_setpoint = Some(value);
|
zone.manual_setpoint = Some(value);
|
||||||
zone.effective_setpoint = Some(value);
|
zone.effective_setpoint = Some(value);
|
||||||
zone.manual_override_until = Some(engine::next_schedule_boundary_utc(&zone.id, &schedules, chrono::Local::now()));
|
zone.manual_override_until = engine::next_schedule_boundary_utc(&zone.id, &schedules, chrono::Local::now());
|
||||||
}
|
}
|
||||||
if let Some(value) = patch.mode.as_deref() {
|
if let Some(value) = patch.mode.as_deref() {
|
||||||
match value {
|
match value {
|
||||||
@@ -578,7 +595,7 @@ async fn update_zone_control(State(state): State<AppState>, Path(id): Path<Strin
|
|||||||
"comfort" | "sleep" | "away" | "custom" => {
|
"comfort" | "sleep" | "away" | "custom" => {
|
||||||
zone.manual_preset = Some(value.to_string());
|
zone.manual_preset = Some(value.to_string());
|
||||||
zone.manual_setpoint = None;
|
zone.manual_setpoint = None;
|
||||||
zone.manual_override_until = Some(engine::next_schedule_boundary_utc(&zone.id, &schedules, chrono::Local::now()));
|
zone.manual_override_until = engine::next_schedule_boundary_utc(&zone.id, &schedules, chrono::Local::now());
|
||||||
}
|
}
|
||||||
_ => return Err(AppError::BadRequest("unsupported zone preset".into())),
|
_ => return Err(AppError::BadRequest("unsupported zone preset".into())),
|
||||||
}
|
}
|
||||||
@@ -684,12 +701,31 @@ async fn delete_group(State(state): State<AppState>, Path(id): Path<String>) ->
|
|||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn ensure_zone_removal_safe(state: &AppState, zone_ids: &std::collections::HashSet<String>) -> Result<(), AppError> {
|
||||||
|
if zone_ids.is_empty() { return Ok(()); }
|
||||||
|
let automated_groups: std::collections::HashSet<String> = state.db.list_automations()?.into_iter()
|
||||||
|
.filter_map(|item| item.action_group_id)
|
||||||
|
.collect();
|
||||||
|
for group in state.db.list_groups()? {
|
||||||
|
let remaining = group.zone_ids.iter().filter(|zone_id| !zone_ids.contains(*zone_id)).count();
|
||||||
|
if remaining == 0 && group.zone_ids.iter().any(|zone_id| zone_ids.contains(zone_id)) && automated_groups.contains(&group.id) {
|
||||||
|
return Err(AppError::BadRequest(format!("cannot remove the last zone from group '{}' while an automation targets that group", group.name)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn remove_zone_ids_from_groups(state: &AppState, zone_ids: &std::collections::HashSet<String>) -> Result<(), AppError> {
|
fn remove_zone_ids_from_groups(state: &AppState, zone_ids: &std::collections::HashSet<String>) -> Result<(), AppError> {
|
||||||
if zone_ids.is_empty() { return Ok(()); }
|
if zone_ids.is_empty() { return Ok(()); }
|
||||||
for mut group in state.db.list_groups()? {
|
for mut group in state.db.list_groups()? {
|
||||||
let before = group.zone_ids.len();
|
let before = group.zone_ids.len();
|
||||||
group.zone_ids.retain(|zone_id| !zone_ids.contains(zone_id));
|
group.zone_ids.retain(|zone_id| !zone_ids.contains(zone_id));
|
||||||
if group.zone_ids.len() == before { continue; }
|
if group.zone_ids.len() == before { continue; }
|
||||||
|
if group.zone_ids.is_empty() {
|
||||||
|
state.db.delete_group(&group.id)?;
|
||||||
|
state.broadcast("group.deleted", json!({"id": group.id}));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
group.updated_at = Utc::now();
|
group.updated_at = Utc::now();
|
||||||
state.db.save_group(&group)?;
|
state.db.save_group(&group)?;
|
||||||
state.broadcast("group.updated", serde_json::to_value(&group)?);
|
state.broadcast("group.updated", serde_json::to_value(&group)?);
|
||||||
@@ -832,7 +868,7 @@ async fn update_house_preset(State(state): State<AppState>, Json(input): Json<Ho
|
|||||||
} else {
|
} else {
|
||||||
zone.manual_preset = Some(input.preset.clone());
|
zone.manual_preset = Some(input.preset.clone());
|
||||||
zone.manual_setpoint = None;
|
zone.manual_setpoint = None;
|
||||||
zone.manual_override_until = Some(engine::next_schedule_boundary_utc(&zone.id, &schedules, chrono::Local::now()));
|
zone.manual_override_until = engine::next_schedule_boundary_utc(&zone.id, &schedules, chrono::Local::now());
|
||||||
}
|
}
|
||||||
zone.updated_at = Utc::now();
|
zone.updated_at = Utc::now();
|
||||||
state.db.save_zone(zone)?;
|
state.db.save_zone(zone)?;
|
||||||
@@ -889,21 +925,28 @@ async fn apply_schedule_template(State(state): State<AppState>, Path(id): Path<S
|
|||||||
add("Away", weekdays.clone(), "08:00", "16:00", "away");
|
add("Away", weekdays.clone(), "08:00", "16:00", "away");
|
||||||
add("Evening", weekdays.clone(), "16:00", "22:30", "comfort");
|
add("Evening", weekdays.clone(), "16:00", "22:30", "comfort");
|
||||||
add("Sleep", weekdays, "22:30", "06:30", "sleep");
|
add("Sleep", weekdays, "22:30", "06:30", "sleep");
|
||||||
add("Weekend", weekend.clone(), "08:00", "23:00", "comfort");
|
add("Weekend", weekend, "08:00", "23:00", "comfort");
|
||||||
add("Weekend sleep", weekend, "23:00", "08:00", "sleep");
|
// Saturday can sleep until the Sunday weekend block starts at 08:00.
|
||||||
|
add("Saturday sleep", vec![6], "23:00", "08:00", "sleep");
|
||||||
|
// Sunday must hand over at 06:30 so it never overlaps Monday morning.
|
||||||
|
add("Sunday sleep", vec![7], "23:00", "06:30", "sleep");
|
||||||
}
|
}
|
||||||
"always" => add("Comfort", all, "00:00", "23:59", "comfort"),
|
"always" => add("Comfort", all, "00:00", "00:00", "comfort"),
|
||||||
_ => return Err(AppError::BadRequest("unknown schedule template".into())),
|
_ => return Err(AppError::BadRequest("unknown schedule template".into())),
|
||||||
}
|
}
|
||||||
|
validate_schedule_set(&items)?;
|
||||||
state.db.replace_schedules_for_zone(&id, &items)?;
|
state.db.replace_schedules_for_zone(&id, &items)?;
|
||||||
|
refresh_zone_override_boundary(&state, &id)?;
|
||||||
state.broadcast("schedule.template_applied", json!({"zone_id": id, "template": input.template, "count": items.len()}));
|
state.broadcast("schedule.template_applied", json!({"zone_id": id, "template": input.template, "count": items.len()}));
|
||||||
Ok(Json(json!({"zone": zone, "schedules": items})))
|
Ok(Json(json!({"zone": zone, "schedules": items})))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn delete_zone(State(state): State<AppState>, Path(id): Path<String>) -> Result<StatusCode, AppError> {
|
async fn delete_zone(State(state): State<AppState>, Path(id): Path<String>) -> Result<StatusCode, AppError> {
|
||||||
if !state.db.delete_zone(&id)? { return Err(AppError::NotFound(format!("zone {id}"))); }
|
if state.db.get_zone(&id)?.is_none() { return Err(AppError::NotFound(format!("zone {id}"))); }
|
||||||
let mut removed = std::collections::HashSet::new();
|
let mut removed = std::collections::HashSet::new();
|
||||||
removed.insert(id.clone());
|
removed.insert(id.clone());
|
||||||
|
ensure_zone_removal_safe(&state, &removed)?;
|
||||||
|
if !state.db.delete_zone(&id)? { return Err(AppError::NotFound(format!("zone {id}"))); }
|
||||||
remove_zone_ids_from_groups(&state, &removed)?;
|
remove_zone_ids_from_groups(&state, &removed)?;
|
||||||
state.broadcast("zone.deleted", json!({"id": id}));
|
state.broadcast("zone.deleted", json!({"id": id}));
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
@@ -939,6 +982,38 @@ impl ScheduleInput {
|
|||||||
preset: self.preset, setpoint: self.setpoint, created_at, updated_at: Utc::now() }
|
preset: self.preset, setpoint: self.setpoint, created_at, updated_at: Utc::now() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
fn validate_schedule_set(items: &[Schedule]) -> Result<(), AppError> {
|
||||||
|
for (index, item) in items.iter().enumerate() {
|
||||||
|
for other in items.iter().skip(index + 1) {
|
||||||
|
if engine::schedules_overlap(item, other) {
|
||||||
|
return Err(AppError::BadRequest(format!("schedule '{}' overlaps with '{}' for the same zone", item.name, other.name)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_schedule_conflicts(state: &AppState, item: &Schedule, exclude_id: Option<&str>) -> Result<(), AppError> {
|
||||||
|
for existing in state.db.list_schedules()? {
|
||||||
|
if exclude_id == Some(existing.id.as_str()) { continue; }
|
||||||
|
if engine::schedules_overlap(item, &existing) {
|
||||||
|
return Err(AppError::BadRequest(format!("schedule overlaps with '{}'", existing.name)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn refresh_zone_override_boundary(state: &AppState, zone_id: &str) -> Result<(), AppError> {
|
||||||
|
let Some(mut zone) = state.db.get_zone(zone_id)? else { return Ok(()); };
|
||||||
|
if zone.manual_preset.is_none() && zone.manual_setpoint.is_none() { return Ok(()); }
|
||||||
|
let schedules = state.db.list_schedules()?;
|
||||||
|
zone.manual_override_until = engine::next_schedule_boundary_utc(&zone.id, &schedules, chrono::Local::now());
|
||||||
|
zone.updated_at = Utc::now();
|
||||||
|
state.db.save_zone(&zone)?;
|
||||||
|
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
async fn list_schedules(State(state): State<AppState>) -> Result<Json<Vec<Schedule>>, AppError> { Ok(Json(state.db.list_schedules()?)) }
|
async fn list_schedules(State(state): State<AppState>) -> Result<Json<Vec<Schedule>>, AppError> { Ok(Json(state.db.list_schedules()?)) }
|
||||||
async fn get_schedule(State(state): State<AppState>, Path(id): Path<String>) -> Result<Json<Schedule>, AppError> {
|
async fn get_schedule(State(state): State<AppState>, Path(id): Path<String>) -> Result<Json<Schedule>, AppError> {
|
||||||
state.db.get_schedule(&id)?.map(Json).ok_or_else(|| AppError::NotFound(format!("schedule {id}")))
|
state.db.get_schedule(&id)?.map(Json).ok_or_else(|| AppError::NotFound(format!("schedule {id}")))
|
||||||
@@ -947,7 +1022,9 @@ async fn create_schedule(State(state): State<AppState>, Json(input): Json<Schedu
|
|||||||
input.validate()?;
|
input.validate()?;
|
||||||
if state.db.get_zone(&input.zone_id)?.is_none() { return Err(AppError::BadRequest("schedule zone does not exist".into())); }
|
if state.db.get_zone(&input.zone_id)?.is_none() { return Err(AppError::BadRequest("schedule zone does not exist".into())); }
|
||||||
let item = input.into_schedule(Uuid::new_v4().to_string(), Utc::now());
|
let item = input.into_schedule(Uuid::new_v4().to_string(), Utc::now());
|
||||||
|
validate_schedule_conflicts(&state, &item, None)?;
|
||||||
state.db.save_schedule(&item)?;
|
state.db.save_schedule(&item)?;
|
||||||
|
refresh_zone_override_boundary(&state, &item.zone_id)?;
|
||||||
state.broadcast("schedule.created", serde_json::to_value(&item)?);
|
state.broadcast("schedule.created", serde_json::to_value(&item)?);
|
||||||
Ok((StatusCode::CREATED, Json(item)))
|
Ok((StatusCode::CREATED, Json(item)))
|
||||||
}
|
}
|
||||||
@@ -955,13 +1032,19 @@ async fn update_schedule(State(state): State<AppState>, Path(id): Path<String>,
|
|||||||
input.validate()?;
|
input.validate()?;
|
||||||
let existing = state.db.get_schedule(&id)?.ok_or_else(|| AppError::NotFound(format!("schedule {id}")))?;
|
let existing = state.db.get_schedule(&id)?.ok_or_else(|| AppError::NotFound(format!("schedule {id}")))?;
|
||||||
if state.db.get_zone(&input.zone_id)?.is_none() { return Err(AppError::BadRequest("schedule zone does not exist".into())); }
|
if state.db.get_zone(&input.zone_id)?.is_none() { return Err(AppError::BadRequest("schedule zone does not exist".into())); }
|
||||||
let item = input.into_schedule(id, existing.created_at);
|
let old_zone_id = existing.zone_id.clone();
|
||||||
|
let item = input.into_schedule(id.clone(), existing.created_at);
|
||||||
|
validate_schedule_conflicts(&state, &item, Some(&id))?;
|
||||||
state.db.save_schedule(&item)?;
|
state.db.save_schedule(&item)?;
|
||||||
|
refresh_zone_override_boundary(&state, &old_zone_id)?;
|
||||||
|
if item.zone_id != old_zone_id { refresh_zone_override_boundary(&state, &item.zone_id)?; }
|
||||||
state.broadcast("schedule.updated", serde_json::to_value(&item)?);
|
state.broadcast("schedule.updated", serde_json::to_value(&item)?);
|
||||||
Ok(Json(item))
|
Ok(Json(item))
|
||||||
}
|
}
|
||||||
async fn delete_schedule(State(state): State<AppState>, Path(id): Path<String>) -> Result<StatusCode, AppError> {
|
async fn delete_schedule(State(state): State<AppState>, Path(id): Path<String>) -> Result<StatusCode, AppError> {
|
||||||
|
let existing = state.db.get_schedule(&id)?.ok_or_else(|| AppError::NotFound(format!("schedule {id}")))?;
|
||||||
if !state.db.delete_schedule(&id)? { return Err(AppError::NotFound(format!("schedule {id}"))); }
|
if !state.db.delete_schedule(&id)? { return Err(AppError::NotFound(format!("schedule {id}"))); }
|
||||||
|
refresh_zone_override_boundary(&state, &existing.zone_id)?;
|
||||||
state.broadcast("schedule.deleted", json!({"id": id}));
|
state.broadcast("schedule.deleted", json!({"id": id}));
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
@@ -1023,15 +1106,34 @@ impl AutomationInput {
|
|||||||
return Err(AppError::BadRequest("group automation mode must be house, cool or heat".into()));
|
return Err(AppError::BadRequest("group automation mode must be house, cool or heat".into()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if self.action.target_temperature.is_some() {
|
if self.action.target_temperature.is_some()
|
||||||
return Err(AppError::BadRequest("group automation target temperature is not supported; use a group preset".into()));
|
|| self.action.fan_speed.is_some()
|
||||||
|
|| self.action.swing_vertical.is_some()
|
||||||
|
|| self.action.swing_horizontal.is_some()
|
||||||
|
|| self.action.quiet.is_some()
|
||||||
|
|| self.action.turbo.is_some()
|
||||||
|
|| self.action.light.is_some()
|
||||||
|
|| self.action.air.is_some()
|
||||||
|
|| self.action.xfan.is_some()
|
||||||
|
|| self.action.health.is_some()
|
||||||
|
|| self.action.sleep.is_some()
|
||||||
|
{
|
||||||
|
return Err(AppError::BadRequest("group automations support only power, heat/cool/house mode and a group preset".into()));
|
||||||
|
}
|
||||||
|
if self.action.power.is_none() && self.action.mode.is_none() && self.action_preset.as_deref().map(str::trim).filter(|v| !v.is_empty()).is_none() {
|
||||||
|
return Err(AppError::BadRequest("group automation action cannot be empty".into()));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
engine::validate_command(&self.action)?;
|
||||||
|
if self.action.is_empty() {
|
||||||
|
return Err(AppError::BadRequest("automation action cannot be empty".into()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
fn into_automation(self, id: String, created_at: chrono::DateTime<Utc>, last_fired_at: Option<chrono::DateTime<Utc>>) -> Automation {
|
fn into_automation(self, id: String, created_at: chrono::DateTime<Utc>, last_fired_at: Option<chrono::DateTime<Utc>>) -> Automation {
|
||||||
Automation { id, name: self.name.trim().into(), enabled: self.enabled,
|
Automation { id, name: self.name.trim().into(), enabled: self.enabled,
|
||||||
trigger_kind: self.trigger_kind, trigger_device_id: self.trigger_device_id,
|
trigger_kind: self.trigger_kind, trigger_device_id: self.trigger_device_id.map(|value| value.trim().to_string()).filter(|value| !value.is_empty()),
|
||||||
threshold: self.threshold, at_time: self.at_time, action_device_id: self.action_device_id.trim().to_string(),
|
threshold: self.threshold, at_time: self.at_time, action_device_id: self.action_device_id.trim().to_string(),
|
||||||
action_group_id: self.action_group_id.map(|value| value.trim().to_string()).filter(|value| !value.is_empty()),
|
action_group_id: self.action_group_id.map(|value| value.trim().to_string()).filter(|value| !value.is_empty()),
|
||||||
action_preset: self.action_preset.map(|value| value.trim().to_string()).filter(|value| !value.is_empty()),
|
action_preset: self.action_preset.map(|value| value.trim().to_string()).filter(|value| !value.is_empty()),
|
||||||
@@ -1039,17 +1141,30 @@ impl AutomationInput {
|
|||||||
created_at, updated_at: Utc::now() }
|
created_at, updated_at: Utc::now() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
fn validate_automation_references(state: &AppState, input: &AutomationInput) -> Result<(), AppError> {
|
||||||
|
if matches!(input.trigger_kind.as_str(), "temperature_above" | "temperature_below") {
|
||||||
|
let trigger_id = input.trigger_device_id.as_deref().map(str::trim).unwrap_or_default();
|
||||||
|
if state.db.get_device(trigger_id)?.is_none() {
|
||||||
|
return Err(AppError::BadRequest("automation trigger device does not exist".into()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(group_id) = input.action_group_id.as_deref().map(str::trim).filter(|value| !value.is_empty()) {
|
||||||
|
if state.db.get_group(group_id)?.is_none() {
|
||||||
|
return Err(AppError::BadRequest("automation action group does not exist".into()));
|
||||||
|
}
|
||||||
|
} else if state.db.get_device(input.action_device_id.trim())?.is_none() {
|
||||||
|
return Err(AppError::BadRequest("automation action device does not exist".into()));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
async fn list_automations(State(state): State<AppState>) -> Result<Json<Vec<Automation>>, AppError> { Ok(Json(state.db.list_automations()?)) }
|
async fn list_automations(State(state): State<AppState>) -> Result<Json<Vec<Automation>>, AppError> { Ok(Json(state.db.list_automations()?)) }
|
||||||
async fn get_automation(State(state): State<AppState>, Path(id): Path<String>) -> Result<Json<Automation>, AppError> {
|
async fn get_automation(State(state): State<AppState>, Path(id): Path<String>) -> Result<Json<Automation>, AppError> {
|
||||||
state.db.get_automation(&id)?.map(Json).ok_or_else(|| AppError::NotFound(format!("automation {id}")))
|
state.db.get_automation(&id)?.map(Json).ok_or_else(|| AppError::NotFound(format!("automation {id}")))
|
||||||
}
|
}
|
||||||
async fn create_automation(State(state): State<AppState>, Json(input): Json<AutomationInput>) -> Result<(StatusCode, Json<Automation>), AppError> {
|
async fn create_automation(State(state): State<AppState>, Json(input): Json<AutomationInput>) -> Result<(StatusCode, Json<Automation>), AppError> {
|
||||||
input.validate()?;
|
input.validate()?;
|
||||||
if let Some(group_id) = input.action_group_id.as_deref().map(str::trim).filter(|value| !value.is_empty()) {
|
validate_automation_references(&state, &input)?;
|
||||||
if state.db.get_group(group_id)?.is_none() { return Err(AppError::BadRequest("automation action group does not exist".into())); }
|
|
||||||
} else if state.db.get_device(input.action_device_id.trim())?.is_none() {
|
|
||||||
return Err(AppError::BadRequest("automation action device does not exist".into()));
|
|
||||||
}
|
|
||||||
let item = input.into_automation(Uuid::new_v4().to_string(), Utc::now(), None);
|
let item = input.into_automation(Uuid::new_v4().to_string(), Utc::now(), None);
|
||||||
state.db.save_automation(&item)?;
|
state.db.save_automation(&item)?;
|
||||||
state.broadcast("automation.created", serde_json::to_value(&item)?);
|
state.broadcast("automation.created", serde_json::to_value(&item)?);
|
||||||
@@ -1058,11 +1173,7 @@ async fn create_automation(State(state): State<AppState>, Json(input): Json<Auto
|
|||||||
async fn update_automation(State(state): State<AppState>, Path(id): Path<String>, Json(input): Json<AutomationInput>) -> Result<Json<Automation>, AppError> {
|
async fn update_automation(State(state): State<AppState>, Path(id): Path<String>, Json(input): Json<AutomationInput>) -> Result<Json<Automation>, AppError> {
|
||||||
input.validate()?;
|
input.validate()?;
|
||||||
let existing = state.db.get_automation(&id)?.ok_or_else(|| AppError::NotFound(format!("automation {id}")))?;
|
let existing = state.db.get_automation(&id)?.ok_or_else(|| AppError::NotFound(format!("automation {id}")))?;
|
||||||
if let Some(group_id) = input.action_group_id.as_deref().map(str::trim).filter(|value| !value.is_empty()) {
|
validate_automation_references(&state, &input)?;
|
||||||
if state.db.get_group(group_id)?.is_none() { return Err(AppError::BadRequest("automation action group does not exist".into())); }
|
|
||||||
} else if state.db.get_device(input.action_device_id.trim())?.is_none() {
|
|
||||||
return Err(AppError::BadRequest("automation action device does not exist".into()));
|
|
||||||
}
|
|
||||||
let item = input.into_automation(id, existing.created_at, existing.last_fired_at);
|
let item = input.into_automation(id, existing.created_at, existing.last_fired_at);
|
||||||
state.db.save_automation(&item)?;
|
state.db.save_automation(&item)?;
|
||||||
state.broadcast("automation.updated", serde_json::to_value(&item)?);
|
state.broadcast("automation.updated", serde_json::to_value(&item)?);
|
||||||
@@ -1508,29 +1619,120 @@ async fn export_settings(State(state): State<AppState>) -> Result<Json<Configura
|
|||||||
fn validate_configuration_export(export: &ConfigurationExport) -> Result<(), AppError> {
|
fn validate_configuration_export(export: &ConfigurationExport) -> Result<(), AppError> {
|
||||||
if export.format_version != 1 { return Err(AppError::BadRequest("unsupported configuration export version".into())); }
|
if export.format_version != 1 { return Err(AppError::BadRequest("unsupported configuration export version".into())); }
|
||||||
influxdb::validate(&export.settings.influxdb).map_err(|err| AppError::BadRequest(err.to_string()))?;
|
influxdb::validate(&export.settings.influxdb).map_err(|err| AppError::BadRequest(err.to_string()))?;
|
||||||
|
if !matches!(export.settings.house_mode.as_str(), "cool" | "heat" | "off") {
|
||||||
|
return Err(AppError::BadRequest("import contains an invalid house mode".into()));
|
||||||
|
}
|
||||||
|
|
||||||
let devices: std::collections::HashSet<&str> = export.devices.iter().map(|item| item.id.as_str()).collect();
|
let devices: std::collections::HashSet<&str> = export.devices.iter().map(|item| item.id.as_str()).collect();
|
||||||
let zones: std::collections::HashSet<&str> = export.zones.iter().map(|item| item.id.as_str()).collect();
|
let zones: std::collections::HashSet<&str> = export.zones.iter().map(|item| item.id.as_str()).collect();
|
||||||
|
let schedules: std::collections::HashSet<&str> = export.schedules.iter().map(|item| item.id.as_str()).collect();
|
||||||
|
let automations: std::collections::HashSet<&str> = export.automations.iter().map(|item| item.id.as_str()).collect();
|
||||||
|
if devices.len() != export.devices.len() || zones.len() != export.zones.len()
|
||||||
|
|| schedules.len() != export.schedules.len() || automations.len() != export.automations.len()
|
||||||
|
|| devices.contains("") || zones.contains("") || schedules.contains("") || automations.contains("")
|
||||||
|
{
|
||||||
|
return Err(AppError::BadRequest("import contains duplicate or empty resource IDs".into()));
|
||||||
|
}
|
||||||
|
let device_macs: std::collections::HashSet<&str> = export.devices.iter().map(|item| item.mac.as_str()).collect();
|
||||||
|
if device_macs.len() != export.devices.len() {
|
||||||
|
return Err(AppError::BadRequest("import contains duplicate device MAC addresses".into()));
|
||||||
|
}
|
||||||
if export.zones.iter().any(|item| !devices.contains(item.device_id.as_str())) {
|
if export.zones.iter().any(|item| !devices.contains(item.device_id.as_str())) {
|
||||||
return Err(AppError::BadRequest("import contains a zone referencing a missing device".into()));
|
return Err(AppError::BadRequest("import contains a zone referencing a missing device".into()));
|
||||||
}
|
}
|
||||||
|
let mut zone_devices = std::collections::HashSet::new();
|
||||||
|
for zone in &export.zones {
|
||||||
|
if !zone_devices.insert(zone.device_id.as_str()) {
|
||||||
|
return Err(AppError::BadRequest("import assigns one device to more than one thermostat zone".into()));
|
||||||
|
}
|
||||||
|
if !matches!(zone.mode.as_str(), "cool" | "heat") {
|
||||||
|
return Err(AppError::BadRequest("import contains an invalid zone mode".into()));
|
||||||
|
}
|
||||||
|
if !matches!(zone.sensor_source.as_str(), "device" | "home_assistant" | "combined") {
|
||||||
|
return Err(AppError::BadRequest("import contains an invalid zone sensor source".into()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if export.schedules.iter().any(|item| !zones.contains(item.zone_id.as_str())) {
|
if export.schedules.iter().any(|item| !zones.contains(item.zone_id.as_str())) {
|
||||||
return Err(AppError::BadRequest("import contains a schedule referencing a missing zone".into()));
|
return Err(AppError::BadRequest("import contains a schedule referencing a missing zone".into()));
|
||||||
}
|
}
|
||||||
if export.groups.iter().any(|group| group.zone_ids.iter().any(|zone_id| !zones.contains(zone_id.as_str()))) {
|
for item in &export.schedules {
|
||||||
return Err(AppError::BadRequest("import contains a group referencing a missing zone".into()));
|
if item.weekdays.is_empty() || item.weekdays.iter().any(|day| !(1..=7).contains(day)) {
|
||||||
|
return Err(AppError::BadRequest("import contains invalid schedule weekdays".into()));
|
||||||
|
}
|
||||||
|
NaiveTime::parse_from_str(&item.start_time, "%H:%M").map_err(|_| AppError::BadRequest("import contains an invalid schedule start time".into()))?;
|
||||||
|
NaiveTime::parse_from_str(&item.end_time, "%H:%M").map_err(|_| AppError::BadRequest("import contains an invalid schedule end time".into()))?;
|
||||||
|
if !matches!(item.preset.as_str(), "comfort" | "sleep" | "away" | "custom") {
|
||||||
|
return Err(AppError::BadRequest("import contains an invalid schedule preset".into()));
|
||||||
|
}
|
||||||
|
if item.preset == "custom" && !(8.0..=30.0).contains(&item.setpoint) {
|
||||||
|
return Err(AppError::BadRequest("import contains an invalid schedule setpoint".into()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
validate_schedule_set(&export.schedules)?;
|
||||||
|
|
||||||
|
if export.groups.iter().any(|group| {
|
||||||
|
let members: std::collections::HashSet<&str> = group.zone_ids.iter().map(String::as_str).collect();
|
||||||
|
group.id.trim().is_empty() || group.zone_ids.is_empty() || members.len() != group.zone_ids.len()
|
||||||
|
|| group.zone_ids.iter().any(|zone_id| !zones.contains(zone_id.as_str()))
|
||||||
|
}) {
|
||||||
|
return Err(AppError::BadRequest("import contains an invalid group, duplicate members or a missing zone reference".into()));
|
||||||
}
|
}
|
||||||
let groups: std::collections::HashSet<&str> = export.groups.iter().map(|item| item.id.as_str()).collect();
|
let groups: std::collections::HashSet<&str> = export.groups.iter().map(|item| item.id.as_str()).collect();
|
||||||
if export.automations.iter().any(|item| {
|
if groups.len() != export.groups.len() {
|
||||||
if let Some(group_id) = item.action_group_id.as_deref().filter(|value| !value.is_empty()) {
|
return Err(AppError::BadRequest("import contains duplicate group IDs".into()));
|
||||||
!groups.contains(group_id)
|
|
||||||
} else {
|
|
||||||
!devices.contains(item.action_device_id.as_str())
|
|
||||||
}
|
|
||||||
}) {
|
|
||||||
return Err(AppError::BadRequest("import contains an automation referencing a missing device or group".into()));
|
|
||||||
}
|
}
|
||||||
if export.automations.iter().any(|item| item.trigger_device_id.as_deref().is_some_and(|id| !devices.contains(id))) {
|
|
||||||
return Err(AppError::BadRequest("import contains an automation trigger referencing a missing device".into()));
|
for item in &export.automations {
|
||||||
|
match item.trigger_kind.as_str() {
|
||||||
|
"temperature_above" | "temperature_below" => {
|
||||||
|
let Some(trigger_id) = item.trigger_device_id.as_deref() else {
|
||||||
|
return Err(AppError::BadRequest("import contains a temperature automation without a trigger device".into()));
|
||||||
|
};
|
||||||
|
if !devices.contains(trigger_id) || item.threshold.is_none() {
|
||||||
|
return Err(AppError::BadRequest("import contains an invalid temperature automation trigger".into()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"time" => {
|
||||||
|
let at = item.at_time.as_deref().ok_or_else(|| AppError::BadRequest("import contains a time automation without at_time".into()))?;
|
||||||
|
NaiveTime::parse_from_str(at, "%H:%M").map_err(|_| AppError::BadRequest("import contains an invalid automation time".into()))?;
|
||||||
|
}
|
||||||
|
_ => return Err(AppError::BadRequest("import contains an unsupported automation trigger".into())),
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(group_id) = item.action_group_id.as_deref().filter(|value| !value.is_empty()) {
|
||||||
|
if !groups.contains(group_id) {
|
||||||
|
return Err(AppError::BadRequest("import contains an automation referencing a missing group".into()));
|
||||||
|
}
|
||||||
|
if let Some(mode) = item.action.mode.as_deref() {
|
||||||
|
if !matches!(mode, "auto" | "house" | "cool" | "heat") {
|
||||||
|
return Err(AppError::BadRequest("import contains an invalid group automation mode".into()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(preset) = item.action_preset.as_deref() {
|
||||||
|
if !matches!(preset, "auto" | "comfort" | "sleep" | "away") {
|
||||||
|
return Err(AppError::BadRequest("import contains an invalid group automation preset".into()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if item.action.target_temperature.is_some() || item.action.fan_speed.is_some()
|
||||||
|
|| item.action.swing_vertical.is_some() || item.action.swing_horizontal.is_some()
|
||||||
|
|| item.action.quiet.is_some() || item.action.turbo.is_some() || item.action.light.is_some()
|
||||||
|
|| item.action.air.is_some() || item.action.xfan.is_some() || item.action.health.is_some() || item.action.sleep.is_some()
|
||||||
|
{
|
||||||
|
return Err(AppError::BadRequest("import contains unsupported fields in a group automation".into()));
|
||||||
|
}
|
||||||
|
if item.action.power.is_none() && item.action.mode.is_none() && item.action_preset.as_deref().filter(|v| !v.is_empty()).is_none() {
|
||||||
|
return Err(AppError::BadRequest("import contains an empty group automation action".into()));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if !devices.contains(item.action_device_id.as_str()) {
|
||||||
|
return Err(AppError::BadRequest("import contains an automation referencing a missing device".into()));
|
||||||
|
}
|
||||||
|
engine::validate_command(&item.action)?;
|
||||||
|
if item.action.is_empty() {
|
||||||
|
return Err(AppError::BadRequest("import contains an empty automation action".into()));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ impl Db {
|
|||||||
let tx = conn.transaction()?;
|
let tx = conn.transaction()?;
|
||||||
tx.execute(queries::DELETE_DEVICE_READINGS, [id])?;
|
tx.execute(queries::DELETE_DEVICE_READINGS, [id])?;
|
||||||
tx.execute(queries::DELETE_ZONE_READINGS_BY_DEVICE_ID, [id])?;
|
tx.execute(queries::DELETE_ZONE_READINGS_BY_DEVICE_ID, [id])?;
|
||||||
|
tx.execute(queries::DELETE_SCHEDULES_BY_DEVICE_ID, [id])?;
|
||||||
tx.execute(queries::DELETE_ZONES_BY_DEVICE_ID, [id])?;
|
tx.execute(queries::DELETE_ZONES_BY_DEVICE_ID, [id])?;
|
||||||
let changed = tx.execute(queries::DELETE_DEVICE, [id])? > 0;
|
let changed = tx.execute(queries::DELETE_DEVICE, [id])? > 0;
|
||||||
tx.commit()?;
|
tx.commit()?;
|
||||||
|
|||||||
+210
-21
@@ -316,7 +316,7 @@ fn register_device_failure(state: &AppState, device: &mut Device, error: &str) -
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn validate_command(command: &DeviceCommand) -> Result<(), AppError> {
|
pub(crate) fn validate_command(command: &DeviceCommand) -> Result<(), AppError> {
|
||||||
if let Some(value) = command.target_temperature {
|
if let Some(value) = command.target_temperature {
|
||||||
if !(8.0..=30.0).contains(&value) { return Err(AppError::BadRequest("target temperature must be between 8 and 30 C".into())); }
|
if !(8.0..=30.0).contains(&value) { return Err(AppError::BadRequest("target temperature must be between 8 and 30 C".into())); }
|
||||||
}
|
}
|
||||||
@@ -377,7 +377,7 @@ pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControl
|
|||||||
} else {
|
} else {
|
||||||
zone.manual_preset = Some(preset.to_string());
|
zone.manual_preset = Some(preset.to_string());
|
||||||
zone.manual_setpoint = None;
|
zone.manual_setpoint = None;
|
||||||
zone.manual_override_until = Some(next_schedule_boundary_utc(&zone.id, &schedules, Local::now()));
|
zone.manual_override_until = next_schedule_boundary_utc(&zone.id, &schedules, Local::now());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
zone.updated_at = Utc::now();
|
zone.updated_at = Utc::now();
|
||||||
@@ -390,6 +390,9 @@ pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControl
|
|||||||
let master_power_enabled = runtime.house_power_enabled;
|
let master_power_enabled = runtime.house_power_enabled;
|
||||||
let should_command_power = patch.power.is_some() || activates_group;
|
let should_command_power = patch.power.is_some() || activates_group;
|
||||||
let desired_power = group.power_enabled;
|
let desired_power = group.power_enabled;
|
||||||
|
// A zone may intentionally belong to more than one group. Power-off is authoritative:
|
||||||
|
// turning one group on must never briefly wake a member that is still blocked by another group.
|
||||||
|
let group_snapshot = state.db.list_groups()?;
|
||||||
let mut failed = Vec::new();
|
let mut failed = Vec::new();
|
||||||
if should_command_power && (!desired_power || master_power_enabled) {
|
if should_command_power && (!desired_power || master_power_enabled) {
|
||||||
let mut seen = std::collections::HashSet::new();
|
let mut seen = std::collections::HashSet::new();
|
||||||
@@ -399,6 +402,10 @@ pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControl
|
|||||||
if !device.enabled || device.power == desired_power { continue; }
|
if !device.enabled || device.power == desired_power { continue; }
|
||||||
if desired_power {
|
if desired_power {
|
||||||
if !zone.enabled { continue; }
|
if !zone.enabled { continue; }
|
||||||
|
let blocked_by_other_group = group_snapshot.iter().any(|other| {
|
||||||
|
other.id != group.id && !other.power_enabled && other.zone_ids.iter().any(|zone_id| zone_id == &zone.id)
|
||||||
|
});
|
||||||
|
if blocked_by_other_group { continue; }
|
||||||
let zone_mode = if zone.inherit_house_mode { runtime.house_mode.as_str() } else { zone.mode.as_str() };
|
let zone_mode = if zone.inherit_house_mode { runtime.house_mode.as_str() } else { zone.mode.as_str() };
|
||||||
if zone_mode == "off" { continue; }
|
if zone_mode == "off" { continue; }
|
||||||
}
|
}
|
||||||
@@ -993,25 +1000,34 @@ fn resolve_zone_target(zone: &Zone, schedule: Option<&Schedule>, mode: &str) ->
|
|||||||
fn active_schedule_for_zone<'a>(zone: &Zone, schedules: &'a [Schedule], now: DateTime<Local>) -> Option<&'a Schedule> {
|
fn active_schedule_for_zone<'a>(zone: &Zone, schedules: &'a [Schedule], now: DateTime<Local>) -> Option<&'a Schedule> {
|
||||||
schedules.iter()
|
schedules.iter()
|
||||||
.filter(|item| item.enabled && item.zone_id == zone.id && schedule_active(item, now))
|
.filter(|item| item.enabled && item.zone_id == zone.id && schedule_active(item, now))
|
||||||
.last()
|
// Overlaps are rejected by the API, but imported/legacy data may still contain one.
|
||||||
|
// Prefer the most recently edited entry instead of depending on database/name order.
|
||||||
|
.max_by_key(|item| item.updated_at)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn next_schedule_boundary_utc(zone_id: &str, schedules: &[Schedule], now: DateTime<Local>) -> DateTime<Utc> {
|
fn minute_floor(now: DateTime<Local>) -> DateTime<Local> {
|
||||||
|
now.with_second(0).and_then(|value| value.with_nanosecond(0)).unwrap_or(now)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn next_schedule_boundary_utc(zone_id: &str, schedules: &[Schedule], now: DateTime<Local>) -> Option<DateTime<Utc>> {
|
||||||
let current = schedules.iter()
|
let current = schedules.iter()
|
||||||
.filter(|item| item.enabled && item.zone_id == zone_id && schedule_active(item, now))
|
.filter(|item| item.enabled && item.zone_id == zone_id && schedule_active(item, now))
|
||||||
.last()
|
.max_by_key(|item| item.updated_at)
|
||||||
.map(|item| item.id.as_str());
|
.map(|item| item.id.as_str());
|
||||||
for minute in 1..=(48 * 60) {
|
let base = minute_floor(now);
|
||||||
let candidate = now + chrono::Duration::minutes(minute);
|
// Eight days cover a complete weekly schedule plus the next transition.
|
||||||
|
for minute in 1..=(8 * 24 * 60) {
|
||||||
|
let candidate = base + chrono::Duration::minutes(minute);
|
||||||
let next = schedules.iter()
|
let next = schedules.iter()
|
||||||
.filter(|item| item.enabled && item.zone_id == zone_id && schedule_active(item, candidate))
|
.filter(|item| item.enabled && item.zone_id == zone_id && schedule_active(item, candidate))
|
||||||
.last()
|
.max_by_key(|item| item.updated_at)
|
||||||
.map(|item| item.id.as_str());
|
.map(|item| item.id.as_str());
|
||||||
if next != current {
|
if next != current {
|
||||||
return candidate.with_timezone(&Utc);
|
return Some(candidate.with_timezone(&Utc));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
(now + chrono::Duration::hours(8)).with_timezone(&Utc)
|
// No schedule transition exists: keep a manual override until the user clears it.
|
||||||
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
fn schedule_active(item: &Schedule, now: DateTime<Local>) -> bool {
|
fn schedule_active(item: &Schedule, now: DateTime<Local>) -> bool {
|
||||||
@@ -1019,7 +1035,15 @@ fn schedule_active(item: &Schedule, now: DateTime<Local>) -> bool {
|
|||||||
let Ok(end) = NaiveTime::parse_from_str(&item.end_time, "%H:%M") else { return false; };
|
let Ok(end) = NaiveTime::parse_from_str(&item.end_time, "%H:%M") else { return false; };
|
||||||
let time = now.time();
|
let time = now.time();
|
||||||
let today = now.weekday().number_from_monday();
|
let today = now.weekday().number_from_monday();
|
||||||
if start <= end {
|
if start == end {
|
||||||
|
// Equal times mean a 24-hour block starting on each selected weekday.
|
||||||
|
if time >= start {
|
||||||
|
item.weekdays.contains(&today)
|
||||||
|
} else {
|
||||||
|
let previous = previous_weekday(now.weekday()).number_from_monday();
|
||||||
|
item.weekdays.contains(&previous)
|
||||||
|
}
|
||||||
|
} else if start < end {
|
||||||
item.weekdays.contains(&today) && time >= start && time < end
|
item.weekdays.contains(&today) && time >= start && time < end
|
||||||
} else if time >= start {
|
} else if time >= start {
|
||||||
item.weekdays.contains(&today)
|
item.weekdays.contains(&today)
|
||||||
@@ -1031,6 +1055,38 @@ fn schedule_active(item: &Schedule, now: DateTime<Local>) -> bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn schedule_week_mask(item: &Schedule) -> Option<Vec<bool>> {
|
||||||
|
let start = NaiveTime::parse_from_str(&item.start_time, "%H:%M").ok()?;
|
||||||
|
let end = NaiveTime::parse_from_str(&item.end_time, "%H:%M").ok()?;
|
||||||
|
let start_minute = (start.hour() * 60 + start.minute()) as usize;
|
||||||
|
let end_minute = (end.hour() * 60 + end.minute()) as usize;
|
||||||
|
let mut mask = vec![false; 7 * 24 * 60];
|
||||||
|
for weekday in &item.weekdays {
|
||||||
|
if !(1..=7).contains(weekday) { return None; }
|
||||||
|
let day = (*weekday as usize) - 1;
|
||||||
|
let mark = |mask: &mut [bool], day: usize, from: usize, to: usize| {
|
||||||
|
let base = (day % 7) * 24 * 60;
|
||||||
|
for minute in from..to { mask[base + minute] = true; }
|
||||||
|
};
|
||||||
|
if start_minute == end_minute {
|
||||||
|
mark(&mut mask, day, start_minute, 24 * 60);
|
||||||
|
mark(&mut mask, day + 1, 0, end_minute);
|
||||||
|
} else if start_minute < end_minute {
|
||||||
|
mark(&mut mask, day, start_minute, end_minute);
|
||||||
|
} else {
|
||||||
|
mark(&mut mask, day, start_minute, 24 * 60);
|
||||||
|
mark(&mut mask, day + 1, 0, end_minute);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(mask)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn schedules_overlap(a: &Schedule, b: &Schedule) -> bool {
|
||||||
|
if !a.enabled || !b.enabled || a.zone_id != b.zone_id { return false; }
|
||||||
|
let (Some(left), Some(right)) = (schedule_week_mask(a), schedule_week_mask(b)) else { return false; };
|
||||||
|
left.iter().zip(right.iter()).any(|(a, b)| *a && *b)
|
||||||
|
}
|
||||||
|
|
||||||
fn previous_weekday(day: Weekday) -> Weekday {
|
fn previous_weekday(day: Weekday) -> Weekday {
|
||||||
match day {
|
match day {
|
||||||
Weekday::Mon => Weekday::Sun, Weekday::Tue => Weekday::Mon, Weekday::Wed => Weekday::Tue,
|
Weekday::Mon => Weekday::Sun, Weekday::Tue => Weekday::Mon, Weekday::Wed => Weekday::Tue,
|
||||||
@@ -1170,8 +1226,9 @@ fn next_night_mode_events(settings: &NightModeSettings, now: DateTime<Local>, li
|
|||||||
let Ok(start) = NaiveTime::parse_from_str(&settings.start_time, "%H:%M") else { 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 Ok(end) = NaiveTime::parse_from_str(&settings.end_time, "%H:%M") else { return Vec::new(); };
|
||||||
let mut events = Vec::new();
|
let mut events = Vec::new();
|
||||||
|
let base = minute_floor(now);
|
||||||
for minute in 1..=(48 * 60) {
|
for minute in 1..=(48 * 60) {
|
||||||
let candidate = now + chrono::Duration::minutes(minute);
|
let candidate = base + chrono::Duration::minutes(minute);
|
||||||
let time = candidate.time();
|
let time = candidate.time();
|
||||||
let (kind, label) = if time.hour() == start.hour() && time.minute() == start.minute() {
|
let (kind, label) = if time.hour() == start.hour() && time.minute() == start.minute() {
|
||||||
let quiet = if settings.force_quiet { " + Quiet" } else { "" };
|
let quiet = if settings.force_quiet { " + Quiet" } else { "" };
|
||||||
@@ -1195,8 +1252,19 @@ fn next_night_mode_events(settings: &NightModeSettings, now: DateTime<Local>, li
|
|||||||
|
|
||||||
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) {
|
let base = minute_floor(now.clone());
|
||||||
let candidate = now + chrono::Duration::minutes(minute);
|
if time_automation_due(item, now) {
|
||||||
|
return Some(ControlPlanEvent {
|
||||||
|
at: base.with_timezone(&Utc),
|
||||||
|
kind: "automation".into(),
|
||||||
|
label: format!("{} -> {}", item.name, action_name),
|
||||||
|
preset: None,
|
||||||
|
target_temperature: item.action.target_temperature,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// A local day can last 25 hours at the end of daylight saving time.
|
||||||
|
for minute in 1..=(26 * 60) {
|
||||||
|
let candidate = base + chrono::Duration::minutes(minute);
|
||||||
if candidate.hour() == expected.hour() && candidate.minute() == expected.minute() {
|
if candidate.hour() == expected.hour() && candidate.minute() == expected.minute() {
|
||||||
return Some(ControlPlanEvent {
|
return Some(ControlPlanEvent {
|
||||||
at: candidate.with_timezone(&Utc),
|
at: candidate.with_timezone(&Utc),
|
||||||
@@ -1214,8 +1282,9 @@ fn next_schedule_events(zone: &Zone, schedules: &[Schedule], mode: &str, now: Da
|
|||||||
if mode == "off" { return Vec::new(); }
|
if mode == "off" { return Vec::new(); }
|
||||||
let mut events = Vec::new();
|
let mut events = Vec::new();
|
||||||
let mut current = active_schedule_for_zone(zone, schedules, now).map(|item| item.id.as_str());
|
let mut current = active_schedule_for_zone(zone, schedules, now).map(|item| item.id.as_str());
|
||||||
|
let base = minute_floor(now);
|
||||||
for minute in 1..=(8 * 24 * 60) {
|
for minute in 1..=(8 * 24 * 60) {
|
||||||
let candidate = now + chrono::Duration::minutes(minute);
|
let candidate = base + chrono::Duration::minutes(minute);
|
||||||
let next = active_schedule_for_zone(zone, schedules, candidate);
|
let next = active_schedule_for_zone(zone, schedules, candidate);
|
||||||
let next_id = next.map(|item| item.id.as_str());
|
let next_id = next.map(|item| item.id.as_str());
|
||||||
if next_id == current { continue; }
|
if next_id == current { continue; }
|
||||||
@@ -1242,6 +1311,8 @@ fn next_schedule_events(zone: &Zone, schedules: &[Schedule], mode: &str, now: Da
|
|||||||
async fn run_automations(state: &AppState) -> Result<()> {
|
async fn run_automations(state: &AppState) -> Result<()> {
|
||||||
if !state.settings.read().await.house_power_enabled { return Ok(()); }
|
if !state.settings.read().await.house_power_enabled { return Ok(()); }
|
||||||
let devices = state.db.list_devices()?;
|
let devices = state.db.list_devices()?;
|
||||||
|
let zones = state.db.list_zones()?;
|
||||||
|
let groups = state.db.list_groups()?;
|
||||||
for mut item in state.db.list_automations()? {
|
for mut item in state.db.list_automations()? {
|
||||||
if !item.enabled || !automation_ready(&item) { continue; }
|
if !item.enabled || !automation_ready(&item) { continue; }
|
||||||
let should_fire = match item.trigger_kind.as_str() {
|
let should_fire = match item.trigger_kind.as_str() {
|
||||||
@@ -1249,10 +1320,21 @@ async fn run_automations(state: &AppState) -> Result<()> {
|
|||||||
.zip(item.threshold).map(|(t, threshold)| t > threshold).unwrap_or(false),
|
.zip(item.threshold).map(|(t, threshold)| t > threshold).unwrap_or(false),
|
||||||
"temperature_below" => find_temperature(&devices, item.trigger_device_id.as_deref())
|
"temperature_below" => find_temperature(&devices, item.trigger_device_id.as_deref())
|
||||||
.zip(item.threshold).map(|(t, threshold)| t < threshold).unwrap_or(false),
|
.zip(item.threshold).map(|(t, threshold)| t < threshold).unwrap_or(false),
|
||||||
"time" => item.at_time.as_deref().map(time_matches).unwrap_or(false),
|
"time" => time_automation_due(&item, Local::now()),
|
||||||
_ => false,
|
_ => false,
|
||||||
};
|
};
|
||||||
if !should_fire { continue; }
|
if !should_fire { continue; }
|
||||||
|
if item.action_group_id.is_none() && device_blocked_by_disabled_group(&item.action_device_id, &zones, &groups) {
|
||||||
|
// Group power-off is authoritative. Suppress a raw-device automation instead of
|
||||||
|
// waking the unit for one control cycle and immediately switching it off again.
|
||||||
|
item.last_fired_at = Some(Utc::now());
|
||||||
|
item.updated_at = Utc::now();
|
||||||
|
state.db.save_automation(&item)?;
|
||||||
|
state.log("info", "automation.blocked_by_group", &format!("Automation {} suppressed by disabled group", item.name), json!({
|
||||||
|
"automation_id": item.id, "device_id": item.action_device_id
|
||||||
|
}));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let result = if let Some(group_id) = item.action_group_id.as_deref() {
|
let result = if let Some(group_id) = item.action_group_id.as_deref() {
|
||||||
let group_mode = item.action.mode.as_deref().map(|mode| if mode == "auto" { "house".to_string() } else { mode.to_string() });
|
let group_mode = item.action.mode.as_deref().map(|mode| if mode == "auto" { "house".to_string() } else { mode.to_string() });
|
||||||
control_group(state, group_id, GroupControlPatch {
|
control_group(state, group_id, GroupControlPatch {
|
||||||
@@ -1272,25 +1354,53 @@ async fn run_automations(state: &AppState) -> Result<()> {
|
|||||||
"automation_id": item.id, "group_id": item.action_group_id, "device_id": item.action_device_id
|
"automation_id": item.id, "group_id": item.action_group_id, "device_id": item.action_device_id
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
Err(err) => state.log("error", "automation.error", &err.to_string(), json!({"automation_id": item.id})),
|
Err(err) => {
|
||||||
|
// A failed action is still an execution attempt. Apply the configured cooldown
|
||||||
|
// so an offline/disabled target cannot be hammered on every automation cycle.
|
||||||
|
item.last_fired_at = Some(Utc::now());
|
||||||
|
item.updated_at = Utc::now();
|
||||||
|
state.db.save_automation(&item)?;
|
||||||
|
state.log("error", "automation.error", &err.to_string(), json!({"automation_id": item.id}));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
fn device_blocked_by_disabled_group(device_id: &str, zones: &[Zone], groups: &[crate::models::ClimateGroup]) -> bool {
|
||||||
|
let zone_ids: std::collections::HashSet<&str> = zones.iter()
|
||||||
|
.filter(|zone| zone.device_id == device_id)
|
||||||
|
.map(|zone| zone.id.as_str())
|
||||||
|
.collect();
|
||||||
|
if zone_ids.is_empty() { return false; }
|
||||||
|
groups.iter().any(|group| !group.power_enabled && group.zone_ids.iter().any(|zone_id| zone_ids.contains(zone_id.as_str())))
|
||||||
|
}
|
||||||
|
|
||||||
fn find_temperature(devices: &[Device], device_id: Option<&str>) -> Option<f64> {
|
fn find_temperature(devices: &[Device], device_id: Option<&str>) -> Option<f64> {
|
||||||
let id = device_id?;
|
let id = device_id?;
|
||||||
devices.iter().find(|d| d.id == id)?.current_temperature
|
// Never fire a temperature automation from stale cached data of an offline/disabled unit.
|
||||||
|
devices.iter().find(|d| d.id == id && d.enabled && d.online)?.current_temperature
|
||||||
}
|
}
|
||||||
|
|
||||||
fn automation_ready(item: &Automation) -> bool {
|
fn automation_ready(item: &Automation) -> bool {
|
||||||
item.last_fired_at.map(|last| (Utc::now() - last).num_seconds().max(0) as u64 >= item.cooldown_seconds).unwrap_or(true)
|
item.last_fired_at.map(|last| (Utc::now() - last).num_seconds().max(0) as u64 >= item.cooldown_seconds).unwrap_or(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn time_matches(expected: &str) -> bool {
|
fn time_automation_due(item: &Automation, now: DateTime<Local>) -> bool {
|
||||||
|
let Some(expected) = item.at_time.as_deref() else { return false; };
|
||||||
let Ok(value) = NaiveTime::parse_from_str(expected, "%H:%M") else { return false; };
|
let Ok(value) = NaiveTime::parse_from_str(expected, "%H:%M") else { return false; };
|
||||||
let now = Local::now().time();
|
if now.hour() != value.hour() || now.minute() != value.minute() { return false; }
|
||||||
now.hour() == value.hour() && now.minute() == value.minute()
|
if let Some(last) = item.last_fired_at {
|
||||||
|
let local_last = last.with_timezone(&Local);
|
||||||
|
if local_last.date_naive() == now.date_naive()
|
||||||
|
&& local_last.hour() == now.hour()
|
||||||
|
&& local_last.minute() == now.minute()
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -1309,6 +1419,85 @@ mod tests {
|
|||||||
assert!(schedule_active(&item, now));
|
assert!(schedule_active(&item, now));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn test_schedule(id: &str, weekdays: Vec<u32>, start: &str, end: &str) -> Schedule {
|
||||||
|
Schedule {
|
||||||
|
id: id.into(), zone_id: "z".into(), name: id.into(), enabled: true,
|
||||||
|
weekdays, start_time: start.into(), end_time: end.into(), preset: "comfort".into(), setpoint: 21.0,
|
||||||
|
created_at: Utc::now(), updated_at: Utc::now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn equal_schedule_times_mean_a_full_day() {
|
||||||
|
let item = test_schedule("full", vec![1], "06:00", "06:00");
|
||||||
|
let monday_noon = Local.with_ymd_and_hms(2025, 1, 6, 12, 0, 0).single().unwrap();
|
||||||
|
let tuesday_early = Local.with_ymd_and_hms(2025, 1, 7, 5, 59, 0).single().unwrap();
|
||||||
|
let tuesday_after = Local.with_ymd_and_hms(2025, 1, 7, 6, 1, 0).single().unwrap();
|
||||||
|
assert!(schedule_active(&item, monday_noon));
|
||||||
|
assert!(schedule_active(&item, tuesday_early));
|
||||||
|
assert!(!schedule_active(&item, tuesday_after));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn schedule_overlap_detection_handles_overnight_ranges() {
|
||||||
|
let daytime = test_schedule("day", vec![1,2,3,4,5,6,7], "06:30", "22:30");
|
||||||
|
let night = test_schedule("night", vec![1,2,3,4,5,6,7], "22:30", "06:30");
|
||||||
|
let conflict = test_schedule("conflict", vec![1], "22:00", "23:00");
|
||||||
|
assert!(!schedules_overlap(&daytime, &night));
|
||||||
|
assert!(schedules_overlap(&night, &conflict));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn next_schedule_boundary_scans_the_whole_week() {
|
||||||
|
let friday = test_schedule("friday", vec![5], "12:00", "13:00");
|
||||||
|
let monday = Local.with_ymd_and_hms(2025, 1, 6, 10, 0, 30).single().unwrap();
|
||||||
|
let boundary = next_schedule_boundary_utc("z", &[friday], monday).unwrap().with_timezone(&Local);
|
||||||
|
assert_eq!(boundary.weekday(), Weekday::Fri);
|
||||||
|
assert_eq!(boundary.hour(), 12);
|
||||||
|
assert_eq!(boundary.minute(), 0);
|
||||||
|
assert_eq!(boundary.second(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn full_week_schedule_has_no_manual_override_boundary() {
|
||||||
|
let always = test_schedule("always", vec![1,2,3,4,5,6,7], "00:00", "00:00");
|
||||||
|
let now = Local.with_ymd_and_hms(2025, 1, 6, 10, 0, 30).single().unwrap();
|
||||||
|
assert!(next_schedule_boundary_utc("z", &[always], now).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn workday_weekend_handoff_has_no_overlaps() {
|
||||||
|
let schedules = vec![
|
||||||
|
test_schedule("morning", vec![1,2,3,4,5], "06:30", "08:00"),
|
||||||
|
test_schedule("away", vec![1,2,3,4,5], "08:00", "16:00"),
|
||||||
|
test_schedule("evening", vec![1,2,3,4,5], "16:00", "22:30"),
|
||||||
|
test_schedule("sleep", vec![1,2,3,4,5], "22:30", "06:30"),
|
||||||
|
test_schedule("weekend", vec![6,7], "08:00", "23:00"),
|
||||||
|
test_schedule("saturday-sleep", vec![6], "23:00", "08:00"),
|
||||||
|
test_schedule("sunday-sleep", vec![7], "23:00", "06:30"),
|
||||||
|
];
|
||||||
|
for (index, item) in schedules.iter().enumerate() {
|
||||||
|
for other in schedules.iter().skip(index + 1) {
|
||||||
|
assert!(!schedules_overlap(item, other), "{} overlaps {}", item.name, other.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn time_automation_fires_only_once_in_the_same_minute() {
|
||||||
|
let now = Local.with_ymd_and_hms(2025, 1, 6, 10, 15, 40).single().unwrap();
|
||||||
|
let mut item = Automation {
|
||||||
|
id: "a".into(), name: "at time".into(), enabled: true, trigger_kind: "time".into(),
|
||||||
|
trigger_device_id: None, threshold: None, at_time: Some("10:15".into()),
|
||||||
|
action_device_id: "d".into(), action_group_id: None, action_preset: None,
|
||||||
|
action: DeviceCommand { power: Some(true), ..Default::default() }, cooldown_seconds: 30,
|
||||||
|
last_fired_at: None, created_at: Utc::now(), updated_at: Utc::now(),
|
||||||
|
};
|
||||||
|
assert!(time_automation_due(&item, now.clone()));
|
||||||
|
item.last_fired_at = Some((now.clone() - chrono::Duration::seconds(35)).with_timezone(&Utc));
|
||||||
|
assert!(!time_automation_due(&item, now));
|
||||||
|
}
|
||||||
|
|
||||||
fn test_zone(source: &str) -> Zone {
|
fn test_zone(source: &str) -> Zone {
|
||||||
Zone {
|
Zone {
|
||||||
id: "z".into(), name: "Room".into(), device_id: "d".into(), enabled: true,
|
id: "z".into(), name: "Room".into(), device_id: "d".into(), enabled: true,
|
||||||
|
|||||||
@@ -153,6 +153,8 @@ pub const LIST_DEVICES: &str = "SELECT payload FROM devices ORDER BY name COLLAT
|
|||||||
pub const GET_DEVICE_BY_ID: &str = "SELECT payload FROM devices WHERE id=?1";
|
pub const GET_DEVICE_BY_ID: &str = "SELECT payload FROM devices WHERE id=?1";
|
||||||
pub const GET_DEVICE_BY_MAC: &str = "SELECT payload FROM devices WHERE lower(mac)=lower(?1)";
|
pub const GET_DEVICE_BY_MAC: &str = "SELECT payload FROM devices WHERE lower(mac)=lower(?1)";
|
||||||
pub const DELETE_DEVICE_READINGS: &str = "DELETE FROM readings WHERE device_id=?1";
|
pub const DELETE_DEVICE_READINGS: &str = "DELETE FROM readings WHERE device_id=?1";
|
||||||
|
pub const DELETE_SCHEDULES_BY_DEVICE_ID: &str =
|
||||||
|
"DELETE FROM schedules WHERE zone_id IN (SELECT id FROM zones WHERE json_extract(payload, '$.device_id')=?1)";
|
||||||
pub const DELETE_ZONES_BY_DEVICE_ID: &str =
|
pub const DELETE_ZONES_BY_DEVICE_ID: &str =
|
||||||
"DELETE FROM zones WHERE json_extract(payload, '$.device_id')=?1";
|
"DELETE FROM zones WHERE json_extract(payload, '$.device_id')=?1";
|
||||||
pub const DELETE_DEVICE: &str = "DELETE FROM devices WHERE id=?1";
|
pub const DELETE_DEVICE: &str = "DELETE FROM devices WHERE id=?1";
|
||||||
|
|||||||
+5
-2
@@ -635,7 +635,7 @@ function deviceCard(device, detailed = false) {
|
|||||||
<div class="mode-row">${modes.map(mode => `<button class="${device.mode === mode ? 'active' : ''}" data-action="mode" data-value="${mode}" data-device="${esc(device.id)}">${esc(modeLabel(mode))}</button>`).join('')}</div>
|
<div class="mode-row">${modes.map(mode => `<button class="${device.mode === mode ? 'active' : ''}" data-action="mode" data-value="${mode}" data-device="${esc(device.id)}">${esc(modeLabel(mode))}</button>`).join('')}</div>
|
||||||
<div class="fan-row">${fans.map(fan => `<button class="${Number(device.fan_speed) === fan ? 'active' : ''}" data-action="fan" data-value="${fan}" data-device="${esc(device.id)}">${esc(fanLabel(fan))}</button>`).join('')}</div>
|
<div class="fan-row">${fans.map(fan => `<button class="${Number(device.fan_speed) === fan ? 'active' : ''}" data-action="fan" data-value="${fan}" data-device="${esc(device.id)}">${esc(fanLabel(fan))}</button>`).join('')}</div>
|
||||||
<div class="device-toggles">
|
<div class="device-toggles">
|
||||||
<button class="${device.swing_vertical ? 'active' : ''}" data-action="toggle" data-field="swing_vertical" data-device="${esc(device.id)}">↕ ${esc(tr('devices.swing'))}</button>
|
<button class="${device.swing_vertical ? 'active' : ''}" data-action="toggle" data-field="swing_vertical" data-device="${esc(device.id)}">${esc(tr('devices.swing'))}</button>
|
||||||
${device.supports_quiet === false ? '' : `<button class="${device.quiet ? 'active' : ''}" data-action="toggle" data-field="quiet" data-device="${esc(device.id)}">${esc(tr('devices.quiet'))}</button>`}
|
${device.supports_quiet === false ? '' : `<button class="${device.quiet ? 'active' : ''}" data-action="toggle" data-field="quiet" data-device="${esc(device.id)}">${esc(tr('devices.quiet'))}</button>`}
|
||||||
${device.supports_turbo === false ? '' : `<button class="${device.turbo ? 'active' : ''}" data-action="toggle" data-field="turbo" data-device="${esc(device.id)}">${esc(tr('devices.turbo'))}</button>`}
|
${device.supports_turbo === false ? '' : `<button class="${device.turbo ? 'active' : ''}" data-action="toggle" data-field="turbo" data-device="${esc(device.id)}">${esc(tr('devices.turbo'))}</button>`}
|
||||||
</div>
|
</div>
|
||||||
@@ -684,7 +684,10 @@ function zoneCard(zone, detailed = true) {
|
|||||||
const target = Number(zone.effective_setpoint ?? zone.setpoint);
|
const target = Number(zone.effective_setpoint ?? zone.setpoint);
|
||||||
const manual = zone.manual_preset || 'auto';
|
const manual = zone.manual_preset || 'auto';
|
||||||
const mode = zone.inherit_house_mode ? 'house' : zone.mode;
|
const mode = zone.inherit_house_mode ? 'house' : zone.mode;
|
||||||
const override = zone.manual_override_until ? `${tr('zones.overrideUntil')} ${new Date(zone.manual_override_until).toLocaleTimeString(locale(), {hour:'2-digit',minute:'2-digit'})}` : tr('zones.scheduleControl');
|
const hasManualOverride = zone.manual_preset != null || zone.manual_setpoint != null;
|
||||||
|
const override = zone.manual_override_until
|
||||||
|
? `${tr('zones.overrideUntil')} ${new Date(zone.manual_override_until).toLocaleTimeString(locale(), {hour:'2-digit',minute:'2-digit'})}`
|
||||||
|
: (hasManualOverride ? tr('zones.manualNoBoundary') : tr('zones.scheduleControl'));
|
||||||
const enabledControl = detailed
|
const enabledControl = detailed
|
||||||
? `<span class="badge ${zone.enabled ? 'active' : ''}">${esc(state)}</span>`
|
? `<span class="badge ${zone.enabled ? 'active' : ''}">${esc(state)}</span>`
|
||||||
: `<button type="button" class="zone-enable-toggle ${zone.enabled ? 'active' : ''}" data-action="zone-enabled" data-id="${esc(zone.id)}" data-value="${zone.enabled ? 'false' : 'true'}" aria-label="${esc(tr(zone.enabled ? 'zones.disable' : 'zones.enable'))}"><span>${zone.enabled ? '✓' : '○'}</span>${esc(state)}</button>`;
|
: `<button type="button" class="zone-enable-toggle ${zone.enabled ? 'active' : ''}" data-action="zone-enabled" data-id="${esc(zone.id)}" data-value="${zone.enabled ? 'false' : 'true'}" aria-label="${esc(tr(zone.enabled ? 'zones.disable' : 'zones.enable'))}"><span>${zone.enabled ? '✓' : '○'}</span>${esc(state)}</button>`;
|
||||||
|
|||||||
@@ -376,6 +376,7 @@
|
|||||||
<label><span data-i18n="common.device">Device</span><select name="device_id" required></select></label>
|
<label><span data-i18n="common.device">Device</span><select name="device_id" required></select></label>
|
||||||
<div class="copy-settings-row"><select id="copyZoneSource"><option value="" data-i18n="zones.copyFrom">Copy thermostat settings from…</option></select><button type="button" class="secondary" id="copyZoneSettings" data-i18n="zones.copySettings">Copy settings</button></div>
|
<div class="copy-settings-row"><select id="copyZoneSource"><option value="" data-i18n="zones.copyFrom">Copy thermostat settings from…</option></select><button type="button" class="secondary" id="copyZoneSettings" data-i18n="zones.copySettings">Copy settings</button></div>
|
||||||
<div class="two"><label><span data-i18n="zones.modePolicy">Mode policy</span><select name="mode_policy"><option value="house" data-i18n="zones.followHouse">Follow house mode</option><option value="cool" data-i18n="mode.cool">Cooling only</option><option value="heat" data-i18n="mode.heat">Heating only</option></select></label><label><span data-i18n="common.targetC">Manual target °C</span><input type="text" inputmode="decimal" step="0.5" min="8" max="30" name="setpoint" value="23"></label></div>
|
<div class="two"><label><span data-i18n="zones.modePolicy">Mode policy</span><select name="mode_policy"><option value="house" data-i18n="zones.followHouse">Follow house mode</option><option value="cool" data-i18n="mode.cool">Cooling only</option><option value="heat" data-i18n="mode.heat">Heating only</option></select></label><label><span data-i18n="common.targetC">Manual target °C</span><input type="text" inputmode="decimal" step="0.5" min="8" max="30" name="setpoint" value="23"></label></div>
|
||||||
|
<p class="field-note" data-i18n="zones.modePolicyHint">If this zone should inherit the mode selected globally in the app, leave “Follow house mode” unchanged. Cooling only / Heating only forces a fixed mode for this zone.</p>
|
||||||
<details class="profile-editor"><summary data-i18n="zones.profileTemperatures">Comfort / sleep / away temperatures</summary>
|
<details class="profile-editor"><summary data-i18n="zones.profileTemperatures">Comfort / sleep / away temperatures</summary>
|
||||||
<div class="profile-grid"><strong data-i18n="mode.cool">Cooling</strong><label><span data-i18n="preset.comfort">Comfort</span><input type="text" inputmode="decimal" step="0.5" name="cool_comfort_setpoint" value="23"></label><label><span data-i18n="preset.sleep">Sleep</span><input type="text" inputmode="decimal" step="0.5" name="cool_sleep_setpoint" value="24.5"></label><label><span data-i18n="preset.away">Away</span><input type="text" inputmode="decimal" step="0.5" name="cool_away_setpoint" value="27"></label></div>
|
<div class="profile-grid"><strong data-i18n="mode.cool">Cooling</strong><label><span data-i18n="preset.comfort">Comfort</span><input type="text" inputmode="decimal" step="0.5" name="cool_comfort_setpoint" value="23"></label><label><span data-i18n="preset.sleep">Sleep</span><input type="text" inputmode="decimal" step="0.5" name="cool_sleep_setpoint" value="24.5"></label><label><span data-i18n="preset.away">Away</span><input type="text" inputmode="decimal" step="0.5" name="cool_away_setpoint" value="27"></label></div>
|
||||||
<div class="profile-grid"><strong data-i18n="mode.heat">Heating</strong><label><span data-i18n="preset.comfort">Comfort</span><input type="text" inputmode="decimal" step="0.5" name="heat_comfort_setpoint" value="21"></label><label><span data-i18n="preset.sleep">Sleep</span><input type="text" inputmode="decimal" step="0.5" name="heat_sleep_setpoint" value="19"></label><label><span data-i18n="preset.away">Away</span><input type="text" inputmode="decimal" step="0.5" name="heat_away_setpoint" value="17"></label></div>
|
<div class="profile-grid"><strong data-i18n="mode.heat">Heating</strong><label><span data-i18n="preset.comfort">Comfort</span><input type="text" inputmode="decimal" step="0.5" name="heat_comfort_setpoint" value="21"></label><label><span data-i18n="preset.sleep">Sleep</span><input type="text" inputmode="decimal" step="0.5" name="heat_sleep_setpoint" value="19"></label><label><span data-i18n="preset.away">Away</span><input type="text" inputmode="decimal" step="0.5" name="heat_away_setpoint" value="17"></label></div>
|
||||||
|
|||||||
+32
-1
@@ -616,7 +616,7 @@ html[data-theme='light'] .simulation-metrics > div, html[data-theme='light'] .si
|
|||||||
|
|
||||||
/* v0.5.4: settings headers no longer use decorative icons. */
|
/* v0.5.4: settings headers no longer use decorative icons. */
|
||||||
.settings-block-head { gap:0; }
|
.settings-block-head { gap:0; }
|
||||||
.device-capability-panel { display:grid; gap:8px; padding:12px 0 2px; border-top:1px solid var(--line); }
|
.device-capability-panel { display:grid; gap:8px; padding:12px 0 2px; }
|
||||||
.device-capability-panel > small { color:var(--muted); font-size:11px; text-transform:uppercase; letter-spacing:.08em; }
|
.device-capability-panel > small { color:var(--muted); font-size:11px; text-transform:uppercase; letter-spacing:.08em; }
|
||||||
.device-capability-buttons { display:flex; flex-wrap:wrap; gap:7px; }
|
.device-capability-buttons { display:flex; flex-wrap:wrap; gap:7px; }
|
||||||
.device-capability-buttons button { min-height:34px; padding:7px 11px; border:1px solid var(--line); border-radius:11px; background:var(--surface-muted); color:var(--text); }
|
.device-capability-buttons button { min-height:34px; padding:7px 11px; border:1px solid var(--line); border-radius:11px; background:var(--surface-muted); color:var(--text); }
|
||||||
@@ -841,3 +841,34 @@ body.simulation-standalone [data-view="simulation"] { min-height:calc(100vh - 36
|
|||||||
#controlPlan.automation-plan-grid { grid-template-columns:repeat(auto-fit,minmax(min(100%,180px),1fr)); }
|
#controlPlan.automation-plan-grid { grid-template-columns:repeat(auto-fit,minmax(min(100%,180px),1fr)); }
|
||||||
}
|
}
|
||||||
[data-view="dashboard"] .dashboard-summary-card { margin-bottom:10px; }
|
[data-view="dashboard"] .dashboard-summary-card { margin-bottom:10px; }
|
||||||
|
|
||||||
|
/* 0.7.0: desktop fit and cleaner unit controls. */
|
||||||
|
#zoneList .zone-thermostat .card-footer { align-items:flex-start; }
|
||||||
|
#zoneList .zone-thermostat .card-footer > small {
|
||||||
|
flex:1 1 auto;
|
||||||
|
min-width:0;
|
||||||
|
overflow:visible;
|
||||||
|
text-overflow:clip;
|
||||||
|
white-space:normal;
|
||||||
|
line-height:1.35;
|
||||||
|
}
|
||||||
|
#zoneList .zone-thermostat .card-menu { flex:0 0 auto; }
|
||||||
|
|
||||||
|
@media (min-width: 701px) {
|
||||||
|
#dashboardDevices .mode-row {
|
||||||
|
grid-template-columns:repeat(5,minmax(0,1fr));
|
||||||
|
gap:4px;
|
||||||
|
}
|
||||||
|
#dashboardDevices .fan-row {
|
||||||
|
grid-template-columns:repeat(4,minmax(0,1fr));
|
||||||
|
gap:4px;
|
||||||
|
}
|
||||||
|
#dashboardDevices .mode-row button,
|
||||||
|
#dashboardDevices .fan-row button {
|
||||||
|
min-height:26px;
|
||||||
|
padding:4px 3px;
|
||||||
|
font-size:9px;
|
||||||
|
line-height:1.05;
|
||||||
|
white-space:nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
const CACHE = 'gree-controller-v069-dashboard-fit';
|
const CACHE = 'gree-controller-v070-schedule-audit-ui';
|
||||||
const SCOPE = new URL(self.registration.scope).pathname.replace(/\/$/, '');
|
const SCOPE = new URL(self.registration.scope).pathname.replace(/\/$/, '');
|
||||||
const path = value => `${SCOPE}${value.startsWith('/') ? value : `/${value}`}` || '/';
|
const path = value => `${SCOPE}${value.startsWith('/') ? value : `/${value}`}` || '/';
|
||||||
const ASSETS = [path('/'), path('/styles.css'), path('/app.js'), path('/theme-init.js'), path('/favicon.svg'), path('/manifest.webmanifest'), path('/lang/index.json'), path('/lang/en.json')];
|
const ASSETS = [path('/'), path('/styles.css'), path('/app.js'), path('/theme-init.js'), path('/favicon.svg'), path('/manifest.webmanifest'), path('/lang/index.json'), path('/lang/en.json')];
|
||||||
|
|||||||
Reference in New Issue
Block a user