v0.6.8
This commit is contained in:
+43
-2
@@ -1,8 +1,8 @@
|
||||
# GREE Controller v0.6.6 - build and validation report
|
||||
# GREE Controller v0.6.8 - build and validation report
|
||||
|
||||
## Scope
|
||||
|
||||
Version 0.6.6 includes the 0.6.5 changes described below plus synchronized whole-house activation: selecting a global operating mode or work profile now re-enables master power and powers on all enabled units. The global power panel is also placed above the house climate controls.
|
||||
Version 0.6.7 includes the previous climate-group and whole-house changes plus a reorganized main dashboard. Global controls are consolidated, group control stays immediately below them, and lower-priority dashboard sections are collapsed on demand.
|
||||
|
||||
## Implemented changes
|
||||
|
||||
@@ -139,3 +139,44 @@ Validation performed in the build environment:
|
||||
- Selecting Auto, Comfort, Sleep or Away as a whole-house profile does the same.
|
||||
- House mode `off` / **Do not control** remains non-invasive and does not alter master power.
|
||||
- The behavior is implemented in the shared backend endpoints, so Home Assistant house-mode/profile controls follow the same rules.
|
||||
|
||||
## 0.6.6 climate groups, HA off-target and standalone simulation
|
||||
|
||||
- Added persistent named climate groups based on thermostat zones, with independent group power, Heat/Cool/whole-house-follow mode and Auto/Comfort/Sleep/Away profile control.
|
||||
- Added group controls directly below the global **All air conditioners** panel plus a dedicated **Groups** view for membership management.
|
||||
- Whole-house **Turn all on**, Cooling/Heating and whole-house profile activation now re-enable all groups as part of their existing "all units" semantics; global power-off remains the authoritative master block.
|
||||
- Automation actions can target either a direct device or a climate group and can apply group mode/power/profile changes.
|
||||
- The control plan keeps a resolved thermostat target temperature while a zone, group or whole-house inherited mode is off, so Home Assistant zone climate entities no longer expose the configured target as `unknown` solely because control is disabled.
|
||||
- Home Assistant documentation and zone attributes clarify that zone thermostat **Auto** means follow the controller's whole-house Heating/Cooling mode; the direct physical-device climate entity still uses native GREE Auto.
|
||||
- The simulator can be opened as a standalone new-tab view, switched to browser full-screen mode, and filtered by individual unit or climate group.
|
||||
- Configuration backup/import, bootstrap/WebSocket state, SQLite schema and API documentation include groups.
|
||||
|
||||
Validation performed in this packaging environment:
|
||||
|
||||
- `node --check web/app.js` and `node --check web/sw.js`.
|
||||
- Home Assistant Python modules compiled with `python3 -m py_compile`.
|
||||
- English/Polish translation packs parsed and checked for key parity and static UI references.
|
||||
- HTML IDs and translation references checked for duplicates/missing keys.
|
||||
- `INIT_SCHEMA` executed against an empty SQLite database and the new `climate_groups` table verified.
|
||||
- Rust compilation cannot be executed here because the environment does not contain `cargo`, `rustc` or `rustfmt`; the backend changes were statically reviewed and still require the normal LXC/CI `cargo test --all-targets` and `cargo build --release` gate before deployment.
|
||||
|
||||
|
||||
## 0.6.7 dashboard hierarchy
|
||||
|
||||
- Reorganized the main dashboard into three levels: whole-house controls, climate-group controls, and secondary details.
|
||||
- Global master power and whole-house climate mode/profile now share one panel instead of competing for separate dashboard space.
|
||||
- Group controls remain directly below global control, use a compact horizontal strip, and omit duplicate mode/profile/member summary boxes on the dashboard while retaining full details on the Groups page.
|
||||
- Control plan, quick thermostats and direct device controls remain available from the dashboard but are collapsed by default and can be expanded independently.
|
||||
- Dashboard section badges show current group, zone and device counts.
|
||||
- Mobile layout keeps global actions and expandable sections compact without removing any controls.
|
||||
- PWA cache key advanced to `gree-controller-v067-dashboard` so the reorganized web assets replace cached v0.6.6 files after update.
|
||||
|
||||
## 0.6.8 compact dashboard without horizontal scrolling
|
||||
|
||||
- Removed the horizontally scrolling group strip from the main dashboard.
|
||||
- Dashboard group cards now use a responsive wrapped grid and flow into additional rows.
|
||||
- Group mode/profile controls use fixed grids on the dashboard, so they never require horizontal scrolling.
|
||||
- Whole-house profile controls also wrap into a grid on narrow screens.
|
||||
- Kept Control plan, Zones and Devices as collapsed disclosure panels.
|
||||
- Reduced dashboard hero, metric, global-control, group-card and disclosure spacing for a denser layout without removing functionality.
|
||||
- Advanced PWA cache key to `gree-controller-v068-dashboard-wrap`.
|
||||
|
||||
Generated
+1
-1
@@ -633,7 +633,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gree-controller"
|
||||
version = "0.6.6"
|
||||
version = "0.6.8"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "gree-controller"
|
||||
version = "0.6.6"
|
||||
version = "0.6.8"
|
||||
edition = "2021"
|
||||
authors = ["GREE Controller contributors"]
|
||||
description = "Standalone local GREE HVAC controller with Web UI, SQLite and Home Assistant sensor support"
|
||||
|
||||
+20
-20
@@ -1,29 +1,29 @@
|
||||
d67af429e4da9ce08e9d2f2a8472849ffbd70d135b1c5da535a076026794d04c ./.env.example
|
||||
a4ec3874a2e3ab1bad28fb40bb620f7b01f64d01ad9b699306bf70ada31227db ./.gitignore
|
||||
c5c9d9dad288bd9ec4695db7e4c1003ddd39a6cdf70073362dd84c7a87da57f8 ./BUILD_REPORT.md
|
||||
dae36dbeeb9f26dce98eefc232c47b2fcbd5b75c9b896943990fa65b642c3638 ./Cargo.lock
|
||||
2a20c8523878f420c0f3d4df542b60220d7b645da8ed2acdc71d7328d721013e ./Cargo.toml
|
||||
4c31bf357041b674d29a7ab5751b6060ad8650178e227aa3137d9195a49a2c03 ./BUILD_REPORT.md
|
||||
006a65924eb99c5c911edeea96f6f1498f44734eaf638af81d9562f7e41faaf2 ./Cargo.lock
|
||||
b0dfc13a0710b9f4cc0d2417129335ef277509ce951bcf85781bc38cf8aa892e ./Cargo.toml
|
||||
19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE
|
||||
0e702e55efa6445d959c03c54dff71b1e56317b6fa9de28c52ef34a38c65e9fc ./README.md
|
||||
1dbfc1eab7344a021cdce4859b5ae03faf30974d3b9c7c46d49e933bb34cdfdd ./README.md
|
||||
f2c8eb28fedb26bb8f5636e1805e7a782cc472b87d882f6752d520f16216a248 ./build.rs
|
||||
1aeca9e23d3a4fcbe3214acafdc2a33cdffa0ad46efb5ead4477a2ebc3a4286c ./docs/API.md
|
||||
74fc74d2bb05541f2137250c6a73a9f39a6fbd838e5d95d8c8efd7ebefc384f0 ./docs/API.md
|
||||
234dd200e380a13ecd3e61b4ea455f6f08d64ce89382077dee80684acadb9703 ./docs/HOME_ASSISTANT_MIGRATION.md
|
||||
7a88d6e76fda21e5d34ab351e26bc10dc1f8f7b3055505aefad1df7c56d65ae4 ./docs/LOCALIZATION.md
|
||||
10a0722e1100fb4a05e3067daeb67dc47b0c0a096b43b1cbf2bf002967ce7d98 ./docs/LXC.md
|
||||
19e6532f21c59b93c77863e8a8ac3a7d9abaf869f18e30401b7d0975b404b06e ./docs/PROJECT_SPEC.md
|
||||
41289af73916d22273a482d664a47e7207b64f8ac04ee9e427662ba7c9c7a24a ./docs/PROJECT_SPEC.md
|
||||
7748b1230029439a405139750b2e0607b8e463bfd56ad86982394105069697c5 ./docs/REVERSE_PROXY.md
|
||||
28f1403d84b01b37bebb72548aa39a75bb14bdbb611a43bfa761319957cd6f39 ./docs/SECURITY.md
|
||||
8942e83eabe4edc9728594236b61e3c2be39b8bb46b66c19c4eff7ec7fe80716 ./home-assistant/README.md
|
||||
1192c5442cb0439723294bbed8b4610728c51bca23e1d8c0ef030270d1c697ba ./home-assistant/README.md
|
||||
f8e8559fe10fe523ac5bc9aac25c6e26e862f679d502e8f3c39f38a0a8e40911 ./home-assistant/custom_components/gree_controller/__init__.py
|
||||
001215412d77abd393f34c414ff6d0ebbaf419f140d76037b7f114a7955df6c3 ./home-assistant/custom_components/gree_controller/api.py
|
||||
abcca32b090e5a5d02574a3a340e97321f9879ae6c94ef12eb1f42d967eae2d5 ./home-assistant/custom_components/gree_controller/climate.py
|
||||
62547d17114dd2ae47881b0789279f2d595285b630af33830244c31074801961 ./home-assistant/custom_components/gree_controller/climate.py
|
||||
5e4aef2143e81bedb5a15dd4be5c71b64a3ab448ec6d33a20851edd098e5f529 ./home-assistant/custom_components/gree_controller/config_flow.py
|
||||
b7f0873109c52be9d7f09bea3dffc416103c50085e1f0680d11661a969479898 ./home-assistant/custom_components/gree_controller/const.py
|
||||
8dd51322798cad28d4d19938fe459aa2a4d3b64b075532149f3c8b62049a6c8b ./home-assistant/custom_components/gree_controller/coordinator.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/logo.png
|
||||
52c67fb50b8e9fec5c60af20df6db3385cc40afe08e57b9ea1776ab5fefdaf70 ./home-assistant/custom_components/gree_controller/manifest.json
|
||||
3019758a902fbd38b1faf7d25ed2bea8e4ab124dca34cc6fe0d05a9d4e5f3714 ./home-assistant/custom_components/gree_controller/manifest.json
|
||||
d0206766d76f70ea1c75a618e41bf1e8e8c0429f42ee97f40daac1ea12f16658 ./home-assistant/custom_components/gree_controller/number.py
|
||||
459233ccefa7827fc1c6887146d8a69e01149a497635fd5e1802c220f418eb28 ./home-assistant/custom_components/gree_controller/select.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
|
||||
4513070521d3dda0efb0d974a86ba674494cfb2b66fe9e5cac5b1b0430dede97 ./home-assistant/generated/gree_controller_entities.example.json
|
||||
253a0bc912786e67ea7fc92a64e4a510ad973bec343a88ccfb1f28fca3e8cf01 ./lang/README.md
|
||||
3b147813b7ad9d44c99ec4b2c062de77f914ae04538abc6cfaa9e5451fc6a83c ./lang/en.json
|
||||
c301de561c5999031b7a09101a67ab5c7ee49118fe181ad152741c6c2ea01272 ./lang/pl.json
|
||||
3de7cd88c5c879ba797e805962afa7d449fbd1a016ca8ecf4740d2348ebd39f7 ./lang/en.json
|
||||
1a23e91ed6bec4850cd1304dad9669041b08ea75114f3de3ececd5d5eaeee258 ./lang/pl.json
|
||||
028e1f16e9fbaed57cadb88eff04e65b4bd67722c50b4d6b1fb525f5a2f39abf ./make_zip.py
|
||||
bb89bac237e750e9b1bf73761d7df97a6b81853091615878c03f13d7b6399aa7 ./scripts/README.md
|
||||
5bc736c7bc76ca80aaa406bb171d2aa91baf4c3aa8695dce0e09b888b6ab3146 ./scripts/common.sh
|
||||
@@ -46,26 +46,26 @@ e00d211e3885e30d7fed1e43b44e6fdad40a67019060156c0641816a93e3365f ./scripts/netw
|
||||
81345b6a0b51736bdbc98fd23199b62e4c721b4e7437e02dab7ea79b97dff29a ./scripts/service.sh
|
||||
b48fc84d79aab381226363ac8473f981bcba5e4911c4cc0011261182debf4250 ./scripts/smoke.sh
|
||||
b50782b3742dfbf8a319c60571c968e93fdf8547db747c759edcffae68cb98bf ./scripts/update.sh
|
||||
f3d38b84a241cf0dd14ae8f8db7f1ef972734c1f2acaeb8d0bd7997c6e8f05bd ./src/api.rs
|
||||
36020d4996270aca03d5621d1ce0eb5c8e87629b95c80eb3f53b6f7613331a4a ./src/api.rs
|
||||
32b1d0a7ac2060fd18fe7f1ca1d110ee45beaf67e81514d24d6bde3d36301387 ./src/config.rs
|
||||
a89bc49e359715109149130a10ac60abd26bfd2bc18c5ee5142e01696d9f9cdd ./src/db.rs
|
||||
8ec14c01036c73f28cdb330d696f0d3573198e7ea4d03ba6bf9cc1d34ec6c7a8 ./src/engine.rs
|
||||
0d734ffd0874c8794a87a75ff4d9ddde467d4c2b87b24c4c1531cb419561384d ./src/db.rs
|
||||
ca3d30969d64ec08ce5c38c833f287a5ee8231f9beda3140e51b9bf4e0f721ab ./src/engine.rs
|
||||
ae3b496749a3fd723b243d9bea92e5d76249f52814c80359ad9bac53abacb074 ./src/error.rs
|
||||
081f6909e95839c7da8c40b866af9de237e0540f3e65e1ce739ce70a6fa94846 ./src/home_assistant.rs
|
||||
190b0a33431539676e5dd7796698077f16c179d42eae4501ca96a91bf797cbf8 ./src/influxdb.rs
|
||||
31d56301834ffe4afa7d8092feb033643c39c65040c62b73d4bd38ddd34407f0 ./src/main.rs
|
||||
69cd06183d7492112ed74096cf629b8f4dc073a4b7f15f94b223b299b6244582 ./src/models.rs
|
||||
13fb67f279f97410f9632401a887547c7b5c7f43c40f3220253bfa3e86b600a4 ./src/models.rs
|
||||
ea4bd7f20759101c2525600a1f342d2fc0946e2f0b3208b03cc5a415b6947c10 ./src/notifications.rs
|
||||
7fc31fbf8841a073a1544b8c7a6390f1a15b56087486ca0596a8418340fa232a ./src/protocol/crypto.rs
|
||||
0008f13b1c921300aafb5b9f745c916a65fd06916e5de19f89599ebb618c6350 ./src/protocol/gree.rs
|
||||
a910bd9432a393740c0f6fab52bfcb551f0ea756718d66d290fd2610767cf07c ./src/protocol/mod.rs
|
||||
d1cba5e55c77d44c75a9c9897ae76c327fd96bdf9c01ad27e34322b689348487 ./src/queries.rs
|
||||
66d7a1a12843975506ffef2128d161627b96d665d39cedde2d791651e4598ee1 ./src/queries.rs
|
||||
f74ac398c6f2e64ebb965900330324f3ec7403e70339638a408229f29fbd07ae ./src/state.rs
|
||||
b92a6cb158b494fe145b43c7641e65f6fafff47201d7d76edbec2cfd8b94835c ./systemd/gree-controller.service
|
||||
8a5471859605115780e1a1cc3d07345698c846c642a9a604977ae2ca73cec0ec ./web/app.js
|
||||
d1971287c12f5d022e0607dca6ec065d11903432fa6ec0df5584cb5ad0a524e2 ./web/app.js
|
||||
e98bdd7204349cce1ec6f57283509697af0bbc72280622a6c3efa6fed242db4f ./web/favicon.svg
|
||||
3d47ede16e8652f2b36223ca26271ca302d8a1c62030f18a47b461da9c1bf47f ./web/index.html
|
||||
e665efcc1fac4138d6ce83309e5c6766e80901292bd25983d35613ff3c754b9d ./web/index.html
|
||||
fd26156e9f1d6713d3def564ad000553d9a16a24376059701db2ee762c99ee6c ./web/manifest.webmanifest
|
||||
3796167c55146cf549a6df8b7057072ba4731ed667f9691a35a21832139169cd ./web/styles.css
|
||||
aba62a4b8535e59fd78650142e96c7234ef744e02f28178923b40b5188cb0265 ./web/sw.js
|
||||
0e9e57efd2ba706c315c34d43c95930455fa3ce720bfac6a506b89a755fc4349 ./web/styles.css
|
||||
9099a3d1d4b2775241728b22fa81cd07ecebc34cb160a729feeca7af3efcdf03 ./web/sw.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.
|
||||
|
||||
Current version: **0.6.6**.
|
||||
Current version: **0.6.8**.
|
||||
|
||||
## Highlights
|
||||
|
||||
@@ -18,6 +18,7 @@ Current version: **0.6.6**.
|
||||
- smart thermostat zones with global house Heat/Cool/Off mode and per-zone overrides,
|
||||
- setpoint modulation that keeps indoor units powered during normal operation instead of repeatedly cycling power,
|
||||
- Comfort/Sleep/Away profiles, temporary overrides and one-tap **Sleep now**,
|
||||
- named climate groups (for example Upstairs/Downstairs) with independent power, Heat/Cool/house-follow mode and profile control, including automation targets,
|
||||
- editable weekly schedules plus Family/Child room/Bedroom/Workday/Always-comfort templates,
|
||||
- temperature/time automations for advanced exceptions,
|
||||
- per-zone optional Home Assistant room-temperature sensors with GREE fallback,
|
||||
@@ -27,7 +28,7 @@ Current version: **0.6.6**.
|
||||
- REST API and WebSocket updates,
|
||||
- responsive PWA optimized for phones,
|
||||
- dashboard automation-plan blocks showing current zone decisions and upcoming schedule changes,
|
||||
- Node-RED-style live automation flow board with sensor → thermostat → decision → unit command nodes,
|
||||
- Node-RED-style live automation flow board with sensor → thermostat → decision → unit command nodes, group/unit filtering, standalone-tab view and browser full-screen mode,
|
||||
- configurable night mode with quiet hours, maximum fan speed and optional GREE Quiet/native Sleep requests,
|
||||
- configurable event-log retention and presentation-only Home Assistant sensor aliases used by history charts/selectors,
|
||||
- settings import/export and an optional live debug overlay with API logs and decrypted GREE frame payloads,
|
||||
@@ -46,7 +47,7 @@ See [`BUILD_REPORT.md`](BUILD_REPORT.md) for package validation details and [`do
|
||||
On Debian, Ubuntu or an LXC container:
|
||||
|
||||
```bash
|
||||
unzip gree-controller-v0.6.6.zip
|
||||
unzip gree-controller-v0.6.8.zip
|
||||
cd gree-controller
|
||||
chmod +x scripts/*.sh
|
||||
./scripts/dev.sh
|
||||
@@ -193,7 +194,7 @@ The History area has linkable sub-pages:
|
||||
|
||||
A zone timeline includes GREE indoor temperature, optional HA room temperature, calculated control temperature, active profile target, actual setpoint sent to the AC, available outdoor temperature, demand, power, mode and fan speed. HA sensor samples and zone samples are throttled to at least 15 seconds (or the configured device poll interval, whichever is longer). Local samples older than 24 hours are compacted to 10-minute resolution and samples older than 7 days to 30-minute resolution. When InfluxDB is configured, older queries are merged from InfluxDB with recent SQLite history. The maintenance task backfills compacted legacy samples into InfluxDB and deletes an old SQLite sample only after the archive write succeeds, so an unavailable archive does not cause local data loss.
|
||||
|
||||
All main UI views also use browser URLs (`/dashboard`, `/devices`, `/zones`, `/schedules`, `/automations`, `/settings`, `/events`) so refresh, browser Back/Forward, bookmarks and direct links work normally.
|
||||
All main UI views also use browser URLs (`/dashboard`, `/devices`, `/zones`, `/groups`, `/schedules`, `/automations`, `/simulation`, `/settings`, `/events`) so refresh, browser Back/Forward, bookmarks and direct links work normally.
|
||||
|
||||
## Per-zone room temperature sensors
|
||||
|
||||
@@ -293,6 +294,12 @@ GET /api/zones
|
||||
POST /api/zones
|
||||
POST /api/zones/:id/control
|
||||
POST /api/zones/:id/schedule-template
|
||||
GET /api/groups
|
||||
POST /api/groups
|
||||
GET /api/groups/:id
|
||||
PUT /api/groups/:id
|
||||
DELETE /api/groups/:id
|
||||
POST /api/groups/:id/control
|
||||
POST /api/house/control
|
||||
POST /api/house/power
|
||||
POST /api/house/preset
|
||||
|
||||
+15
-1
@@ -126,6 +126,20 @@ curl -X POST "$BASE/api/zones/ZONE_ID/control" -H "$AUTH" -H 'Content-Type: appl
|
||||
|
||||
`mode` on the quick zone endpoint accepts `house`, `cool`, or `heat`. Global `off` always wins over local zone mode overrides.
|
||||
|
||||
### Climate groups
|
||||
|
||||
Groups persist a set of thermostat zone IDs and an independent group power gate. Group control reuses the normal zone engine, so profiles, schedules, hysteresis and sensor selection remain unchanged.
|
||||
|
||||
```bash
|
||||
curl -X POST "$BASE/api/groups" -H "$AUTH" -H 'Content-Type: application/json' \
|
||||
-d '{"name":"Upstairs","zone_ids":["ZONE_A","ZONE_B"]}'
|
||||
|
||||
curl -X POST "$BASE/api/groups/GROUP_ID/control" -H "$AUTH" -H 'Content-Type: application/json' \
|
||||
-d '{"mode":"cool","preset":"sleep","power":true}'
|
||||
```
|
||||
|
||||
Group `mode` accepts `house`/`auto` (follow whole-house Heating/Cooling), `cool`, or `heat`. Group `preset` accepts `auto`, `comfort`, `sleep`, or `away`. Automations may set `action_group_id` and optional `action_preset`; direct-device `action_device_id` remains backward compatible.
|
||||
|
||||
### Ready-made schedule templates
|
||||
|
||||
Templates generate normal schedule records and replace the current schedules for the selected zone. Every generated entry remains editable.
|
||||
@@ -189,7 +203,7 @@ curl -X POST "$BASE/api/integrations/home-assistant/zones/ZONE_ID/control" \
|
||||
|
||||
## Settings backup and debug
|
||||
|
||||
`GET /api/settings/export` downloads configuration JSON (settings, devices, zones, schedules and automations). It intentionally excludes metric history, event logs and generated API-token records. The export includes GREE device binding keys plus configured Home Assistant and InfluxDB credentials, so treat it as a secret.
|
||||
`GET /api/settings/export` downloads configuration JSON (settings, devices, zones, groups, schedules and automations). It intentionally excludes metric history, event logs and generated API-token records. The export includes GREE device binding keys plus configured Home Assistant and InfluxDB credentials, so treat it as a secret.
|
||||
|
||||
`POST /api/settings/import` accepts that JSON format and replaces application configuration while preserving metric history, events and generated API tokens.
|
||||
|
||||
|
||||
@@ -88,6 +88,8 @@ Weekly schedules reference profiles instead of duplicating temperatures. Ready-m
|
||||
|
||||
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.
|
||||
|
||||
Climate groups are named collections of thermostat zones (for example Upstairs/Downstairs). Group commands can independently set power, Heat/Cool/house-follow mode and Auto/Comfort/Sleep/Away profile for only their member zones. Automation actions may target either a direct device or a climate group.
|
||||
|
||||
### Outdoor temperature assist
|
||||
|
||||
A configured Home Assistant outdoor-temperature entity is optional auxiliary context. It never replaces the room-control temperature. Under strong heat/cold the engine may slightly bias the active AC target and increase Smart Fan airflow. If HA is unavailable, the smart thermostat continues without outdoor assistance.
|
||||
|
||||
@@ -120,7 +120,7 @@ Bedroom -> GREE Bedroom + sensor.bedroom_temperature
|
||||
The recommended `combined` strategy keeps the GREE sensor as the primary input and uses the room sensor as a configurable supporting measurement (40% weight by default). A zone may also select the room sensor as its preferred source. If HA or that entity becomes unavailable, the controller falls back to the corresponding GREE unit, so local control and schedules continue to run.
|
||||
## Zone climate and optional unit features (0.5.4)
|
||||
|
||||
Each controller zone is exposed as a full Home Assistant `climate` entity with current/target temperature and HVAC modes: Off, Auto (follow the controller house mode), Cool and Heat. The existing zone target `number` and enabled `switch` remain available for compatibility.
|
||||
Each controller zone is exposed as a full Home Assistant `climate` entity with current/target temperature and HVAC modes: Off, Auto (follow the controller house mode), Cool and Heat. For a **zone thermostat**, Auto does not mean the GREE unit's native automatic heat/cool algorithm: it means **inherit the whole-house Heating/Cooling selection from GREE Controller**. Direct physical-device climate entities still use the native GREE Auto mode. The target temperature remains published while a zone is Off, so Home Assistant can display the configured setpoint instead of `unknown`. The existing zone target `number` and enabled `switch` remain available for compatibility.
|
||||
|
||||
From version 0.6.4, zone climate entities use `climate.<zone_name>_thermostat`, for example `climate.igor_thermostat`. On integration reload, existing zone climate registry entries are migrated to this scheme using the zone's current name. If the target entity ID is already occupied, the old ID is retained and Home Assistant logs a warning.
|
||||
|
||||
|
||||
@@ -325,6 +325,7 @@ class GreeControllerZoneClimate(CoordinatorEntity[GreeControllerCoordinator], Cl
|
||||
"controller_zone_id": self._zone_id,
|
||||
"effective_mode": zone.get("mode"),
|
||||
"follows_house_mode": bool(zone.get("inherit_house_mode", True)),
|
||||
"auto_mode_meaning": "Follow the GREE Controller whole-house heating/cooling mode",
|
||||
"preset": zone.get("preset"),
|
||||
"demand": bool(zone.get("demand", False)),
|
||||
"device_setpoint": zone.get("device_setpoint"),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"domain": "gree_controller",
|
||||
"name": "GREE Controller",
|
||||
"version": "0.6.6",
|
||||
"version": "0.6.8",
|
||||
"config_flow": true,
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
|
||||
+37
-2
@@ -430,7 +430,7 @@
|
||||
"simulation.manual": "Manual",
|
||||
"simulation.noCommand": "No command preview",
|
||||
"simulation.noActionPreview": "No direct action payload",
|
||||
"simulation.ruleOnDevice": "Action on device: {device}",
|
||||
"simulation.ruleOnDevice": "Action target: {device}",
|
||||
"simulation.nextReady": "Ready again",
|
||||
"simulation.noRules": "No active automation rules.",
|
||||
"simulation.waitingForData": "Waiting for data",
|
||||
@@ -540,6 +540,41 @@
|
||||
"house.powerOnDone": "All air conditioners turned on",
|
||||
"house.powerOffDone": "All air conditioners turned off",
|
||||
"house.powerPartial": "Could not change power on {count} devices",
|
||||
"preset.manual": "Manual"
|
||||
"preset.manual": "Manual",
|
||||
"nav.groups": "Groups",
|
||||
"groups.eyebrow": "Group control",
|
||||
"groups.dashboardTitle": "Selected air conditioners",
|
||||
"groups.dashboardHint": "Control each group independently with its own heating/cooling mode and Auto/Comfort/Sleep/Away profile.",
|
||||
"groups.manage": "Manage groups",
|
||||
"groups.new": "New group",
|
||||
"groups.description": "Combine thermostat zones, for example upstairs and downstairs, and control only that set. One group can use Sleep + Cooling while another uses Comfort + Heating.",
|
||||
"groups.group": "Group",
|
||||
"groups.namePlaceholder": "Upstairs",
|
||||
"groups.members": "Group members",
|
||||
"groups.membersHint": "Groups are built from thermostat zones so their temperature profiles and schedules keep working.",
|
||||
"groups.noMembers": "No members",
|
||||
"groups.mixed": "Mixed",
|
||||
"groups.followHouse": "House / Auto",
|
||||
"groups.mode": "Mode",
|
||||
"groups.profile": "Profile",
|
||||
"groups.masterOff": "Global power is off. Group settings are saved, but units will stay off until global power is enabled.",
|
||||
"groups.memberCount": "{count} zones",
|
||||
"groups.emptyTitle": "No groups yet",
|
||||
"groups.emptyText": "Create a group to control selected air conditioners together.",
|
||||
"groups.partial": "Group updated, but {count} device commands failed.",
|
||||
"groups.controlUpdated": "Group control updated.",
|
||||
"groups.noZones": "Create thermostat zones before creating a group.",
|
||||
"automations.targetType": "Target type",
|
||||
"automations.groupHint": "For a group, Auto means follow the whole-house heating/cooling mode. Group profiles are applied to all member zones.",
|
||||
"simulation.openTab": "Open simulator only in new tab",
|
||||
"simulation.fullscreen": "Full screen",
|
||||
"simulation.exitFullscreen": "Exit full screen",
|
||||
"simulation.viewBy": "View by",
|
||||
"simulation.unitsView": "Individual units",
|
||||
"simulation.groupsView": "Groups",
|
||||
"simulation.show": "Show",
|
||||
"simulation.allUnits": "All units",
|
||||
"simulation.allGroups": "All groups",
|
||||
"groups.chooseMember": "Select at least one thermostat zone."
|
||||
}
|
||||
}
|
||||
|
||||
+37
-2
@@ -430,7 +430,7 @@
|
||||
"simulation.manual": "Ręcznie",
|
||||
"simulation.noCommand": "Brak podglądu komendy",
|
||||
"simulation.noActionPreview": "Brak bezpośredniej akcji",
|
||||
"simulation.ruleOnDevice": "Akcja na urządzeniu: {device}",
|
||||
"simulation.ruleOnDevice": "Cel akcji: {device}",
|
||||
"simulation.nextReady": "Gotowa ponownie",
|
||||
"simulation.noRules": "Brak aktywnych reguł automatyzacji.",
|
||||
"simulation.waitingForData": "Oczekiwanie na dane",
|
||||
@@ -540,6 +540,41 @@
|
||||
"house.powerOnDone": "Włączono wszystkie klimatyzatory",
|
||||
"house.powerOffDone": "Wyłączono wszystkie klimatyzatory",
|
||||
"house.powerPartial": "Nie udało się zmienić zasilania {count} urządzeń",
|
||||
"preset.manual": "Ręcznie"
|
||||
"preset.manual": "Ręcznie",
|
||||
"nav.groups": "Grupy",
|
||||
"groups.eyebrow": "Sterowanie grupami",
|
||||
"groups.dashboardTitle": "Wybrane klimatyzatory",
|
||||
"groups.dashboardHint": "Steruj każdą grupą niezależnie: własny tryb grzania/chłodzenia oraz profil Auto/Komfort/Sen/Poza domem.",
|
||||
"groups.manage": "Zarządzaj grupami",
|
||||
"groups.new": "Nowa grupa",
|
||||
"groups.description": "Połącz strefy termostatów, np. góra i dół, i steruj tylko wybranym zestawem. Jedna grupa może mieć Sen + Chłodzenie, a druga Komfort + Grzanie.",
|
||||
"groups.group": "Grupa",
|
||||
"groups.namePlaceholder": "Góra",
|
||||
"groups.members": "Członkowie grupy",
|
||||
"groups.membersHint": "Grupy bazują na strefach termostatów, więc nadal działają ich profile temperatur i harmonogramy.",
|
||||
"groups.noMembers": "Brak członków",
|
||||
"groups.mixed": "Różne",
|
||||
"groups.followHouse": "Dom / Auto",
|
||||
"groups.mode": "Tryb",
|
||||
"groups.profile": "Profil",
|
||||
"groups.masterOff": "Zasilanie globalne jest wyłączone. Ustawienia grupy zostaną zapisane, ale jednostki pozostaną wyłączone do włączenia zasilania globalnego.",
|
||||
"groups.memberCount": "Strefy: {count}",
|
||||
"groups.emptyTitle": "Brak grup",
|
||||
"groups.emptyText": "Utwórz grupę, aby wspólnie sterować wybranymi klimatyzatorami.",
|
||||
"groups.partial": "Grupa zaktualizowana, ale nie udało się wykonać {count} poleceń urządzeń.",
|
||||
"groups.controlUpdated": "Sterowanie grupą zaktualizowane.",
|
||||
"groups.noZones": "Najpierw utwórz strefy termostatów.",
|
||||
"automations.targetType": "Typ celu",
|
||||
"automations.groupHint": "Dla grupy Auto oznacza dziedziczenie globalnego trybu grzania/chłodzenia. Profil grupy jest stosowany do wszystkich stref w grupie.",
|
||||
"simulation.openTab": "Otwórz sam symulator w nowej karcie",
|
||||
"simulation.fullscreen": "Pełny ekran",
|
||||
"simulation.exitFullscreen": "Wyjdź z pełnego ekranu",
|
||||
"simulation.viewBy": "Widok według",
|
||||
"simulation.unitsView": "Pojedyncze jednostki",
|
||||
"simulation.groupsView": "Grupy",
|
||||
"simulation.show": "Pokaż",
|
||||
"simulation.allUnits": "Wszystkie jednostki",
|
||||
"simulation.allGroups": "Wszystkie grupy",
|
||||
"groups.chooseMember": "Wybierz co najmniej jedną strefę termostatu."
|
||||
}
|
||||
}
|
||||
|
||||
+180
-6
@@ -23,7 +23,7 @@ use crate::{
|
||||
home_assistant,
|
||||
influxdb,
|
||||
notifications,
|
||||
models::{ApiTokenInfo, Automation, ConfigurationExport, DebugSettings, Device, DeviceCommand, DevicePatch, DiscoveryRequest, ManualDeviceRequest, HaReading, NotificationSettings, Reading, RuntimeSettings, Schedule, Zone, ZoneControlPatch, ZoneReading},
|
||||
models::{ApiTokenInfo, Automation, ClimateGroup, ConfigurationExport, DebugSettings, Device, DeviceCommand, DevicePatch, DiscoveryRequest, GroupControlPatch, ManualDeviceRequest, HaReading, NotificationSettings, Reading, RuntimeSettings, Schedule, Zone, ZoneControlPatch, ZoneReading},
|
||||
protocol::merge_discovered,
|
||||
state::AppState,
|
||||
};
|
||||
@@ -51,6 +51,9 @@ pub fn router(state: AppState) -> Router {
|
||||
.route("/api/zones/:id", get(get_zone).put(update_zone).delete(delete_zone))
|
||||
.route("/api/zones/:id/control", post(update_zone_control))
|
||||
.route("/api/zones/:id/schedule-template", post(apply_schedule_template))
|
||||
.route("/api/groups", get(list_groups).post(create_group))
|
||||
.route("/api/groups/:id", get(get_group).put(update_group).delete(delete_group))
|
||||
.route("/api/groups/:id/control", post(update_group_control))
|
||||
.route("/api/house/control", post(update_house_control))
|
||||
.route("/api/house/power", post(update_house_power))
|
||||
.route("/api/house/preset", post(update_house_preset))
|
||||
@@ -205,6 +208,7 @@ async fn build_bootstrap(state: &AppState) -> Result<Value, AppError> {
|
||||
Ok(json!({
|
||||
"devices": state.db.list_devices()?,
|
||||
"zones": state.db.list_zones()?,
|
||||
"groups": state.db.list_groups()?,
|
||||
"schedules": state.db.list_schedules()?,
|
||||
"automations": state.db.list_automations()?,
|
||||
"access_tokens": state.db.list_api_tokens()?,
|
||||
@@ -367,7 +371,12 @@ 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> {
|
||||
let removed_zone_ids: std::collections::HashSet<String> = state.db.list_zones()?.into_iter()
|
||||
.filter(|zone| zone.device_id == id)
|
||||
.map(|zone| zone.id)
|
||||
.collect();
|
||||
if !state.db.delete_device(&id)? { return Err(AppError::NotFound(format!("device {id}"))); }
|
||||
remove_zone_ids_from_groups(&state, &removed_zone_ids)?;
|
||||
state.log("info", "device.deleted", "Device deleted", json!({"device_id": id}));
|
||||
state.broadcast("device.deleted", json!({"id": id}));
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
@@ -591,9 +600,121 @@ async fn update_zone_control(State(state): State<AppState>, Path(id): Path<Strin
|
||||
Ok(Json(zone))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct GroupInput {
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
zone_ids: Vec<String>,
|
||||
#[serde(default)]
|
||||
power_enabled: Option<bool>,
|
||||
}
|
||||
|
||||
fn normalize_group_zone_ids(zone_ids: Vec<String>) -> Vec<String> {
|
||||
let mut values: Vec<String> = zone_ids.into_iter()
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
.collect();
|
||||
values.sort();
|
||||
values.dedup();
|
||||
values
|
||||
}
|
||||
|
||||
fn validate_group_input(state: &AppState, input: &GroupInput) -> Result<Vec<String>, AppError> {
|
||||
if input.name.trim().is_empty() {
|
||||
return Err(AppError::BadRequest("group name is required".into()));
|
||||
}
|
||||
let zone_ids = normalize_group_zone_ids(input.zone_ids.clone());
|
||||
if zone_ids.is_empty() {
|
||||
return Err(AppError::BadRequest("group must contain at least one zone".into()));
|
||||
}
|
||||
for zone_id in &zone_ids {
|
||||
if state.db.get_zone(zone_id)?.is_none() {
|
||||
return Err(AppError::BadRequest(format!("group references missing zone {zone_id}")));
|
||||
}
|
||||
}
|
||||
Ok(zone_ids)
|
||||
}
|
||||
|
||||
async fn list_groups(State(state): State<AppState>) -> Result<Json<Vec<ClimateGroup>>, AppError> {
|
||||
Ok(Json(state.db.list_groups()?))
|
||||
}
|
||||
|
||||
async fn get_group(State(state): State<AppState>, Path(id): Path<String>) -> Result<Json<ClimateGroup>, AppError> {
|
||||
state.db.get_group(&id)?.map(Json).ok_or_else(|| AppError::NotFound(format!("group {id}")))
|
||||
}
|
||||
|
||||
async fn create_group(State(state): State<AppState>, Json(input): Json<GroupInput>) -> Result<(StatusCode, Json<ClimateGroup>), AppError> {
|
||||
let zone_ids = validate_group_input(&state, &input)?;
|
||||
let now = Utc::now();
|
||||
let group = ClimateGroup {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
name: input.name.trim().to_string(),
|
||||
zone_ids,
|
||||
power_enabled: input.power_enabled.unwrap_or(true),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
state.db.save_group(&group)?;
|
||||
state.broadcast("group.created", serde_json::to_value(&group)?);
|
||||
Ok((StatusCode::CREATED, Json(group)))
|
||||
}
|
||||
|
||||
async fn update_group(State(state): State<AppState>, Path(id): Path<String>, Json(input): Json<GroupInput>) -> Result<Json<ClimateGroup>, AppError> {
|
||||
let existing = state.db.get_group(&id)?.ok_or_else(|| AppError::NotFound(format!("group {id}")))?;
|
||||
let zone_ids = validate_group_input(&state, &input)?;
|
||||
let group = ClimateGroup {
|
||||
id,
|
||||
name: input.name.trim().to_string(),
|
||||
zone_ids,
|
||||
power_enabled: input.power_enabled.unwrap_or(existing.power_enabled),
|
||||
created_at: existing.created_at,
|
||||
updated_at: Utc::now(),
|
||||
};
|
||||
state.db.save_group(&group)?;
|
||||
state.broadcast("group.updated", serde_json::to_value(&group)?);
|
||||
Ok(Json(group))
|
||||
}
|
||||
|
||||
async fn delete_group(State(state): State<AppState>, Path(id): Path<String>) -> Result<StatusCode, AppError> {
|
||||
if state.db.list_automations()?.iter().any(|item| item.action_group_id.as_deref() == Some(id.as_str())) {
|
||||
return Err(AppError::BadRequest("group is used by an automation; remove or retarget that automation first".into()));
|
||||
}
|
||||
if !state.db.delete_group(&id)? { return Err(AppError::NotFound(format!("group {id}"))); }
|
||||
state.broadcast("group.deleted", json!({"id": id}));
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
fn remove_zone_ids_from_groups(state: &AppState, zone_ids: &std::collections::HashSet<String>) -> Result<(), AppError> {
|
||||
if zone_ids.is_empty() { return Ok(()); }
|
||||
for mut group in state.db.list_groups()? {
|
||||
let before = group.zone_ids.len();
|
||||
group.zone_ids.retain(|zone_id| !zone_ids.contains(zone_id));
|
||||
if group.zone_ids.len() == before { continue; }
|
||||
group.updated_at = Utc::now();
|
||||
state.db.save_group(&group)?;
|
||||
state.broadcast("group.updated", serde_json::to_value(&group)?);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn update_group_control(State(state): State<AppState>, Path(id): Path<String>, Json(patch): Json<GroupControlPatch>) -> Result<Json<Value>, AppError> {
|
||||
Ok(Json(engine::control_group(&state, &id, patch, "group.quick_control").await?))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct HouseControlPatch { mode: String }
|
||||
|
||||
fn enable_all_groups(state: &AppState) -> Result<(), AppError> {
|
||||
for mut group in state.db.list_groups()? {
|
||||
if group.power_enabled { continue; }
|
||||
group.power_enabled = true;
|
||||
group.updated_at = Utc::now();
|
||||
state.db.save_group(&group)?;
|
||||
state.broadcast("group.updated", serde_json::to_value(&group)?);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn command_all_enabled_devices_power(state: &AppState, power: bool, source: &str) -> Result<Vec<Value>, AppError> {
|
||||
let mut failed = Vec::new();
|
||||
for device in state.db.list_devices()? {
|
||||
@@ -634,6 +755,7 @@ async fn update_house_control(State(state): State<AppState>, Json(input): Json<H
|
||||
};
|
||||
state.broadcast("settings.updated", payload.clone());
|
||||
if activate_all {
|
||||
enable_all_groups(&state)?;
|
||||
let failed = command_all_enabled_devices_power(&state, true, "house_mode").await?;
|
||||
if !failed.is_empty() {
|
||||
state.log("warn", "house.mode_power_partial", "House mode enabled master power, but some devices could not be powered on", json!({
|
||||
@@ -662,6 +784,7 @@ async fn update_house_power(State(state): State<AppState>, Json(input): Json<Hou
|
||||
}
|
||||
}
|
||||
|
||||
if input.power { enable_all_groups(&state)?; }
|
||||
let failed = command_all_enabled_devices_power(&state, input.power, "house_power").await?;
|
||||
|
||||
let devices = state.db.list_devices()?;
|
||||
@@ -697,6 +820,7 @@ async fn update_house_preset(State(state): State<AppState>, Json(input): Json<Ho
|
||||
public_settings(&settings)
|
||||
};
|
||||
state.broadcast("settings.updated", settings_payload.clone());
|
||||
enable_all_groups(&state)?;
|
||||
|
||||
let schedules = state.db.list_schedules()?;
|
||||
let mut zones = state.db.list_zones()?;
|
||||
@@ -778,6 +902,9 @@ async fn apply_schedule_template(State(state): State<AppState>, Path(id): Path<S
|
||||
|
||||
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}"))); }
|
||||
let mut removed = std::collections::HashSet::new();
|
||||
removed.insert(id.clone());
|
||||
remove_zone_ids_from_groups(&state, &removed)?;
|
||||
state.broadcast("zone.deleted", json!({"id": id}));
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
@@ -851,8 +978,13 @@ struct AutomationInput {
|
||||
threshold: Option<f64>,
|
||||
#[serde(default)]
|
||||
at_time: Option<String>,
|
||||
#[serde(default)]
|
||||
action_device_id: String,
|
||||
#[serde(default)]
|
||||
action_group_id: Option<String>,
|
||||
#[serde(default)]
|
||||
action_preset: Option<String>,
|
||||
#[serde(default)]
|
||||
action: DeviceCommand,
|
||||
#[serde(default = "automation_cooldown")]
|
||||
cooldown_seconds: u64,
|
||||
@@ -873,12 +1005,36 @@ impl AutomationInput {
|
||||
}
|
||||
_ => return Err(AppError::BadRequest("unsupported automation trigger".into())),
|
||||
}
|
||||
let action_group_id = self.action_group_id.as_deref().map(str::trim).filter(|value| !value.is_empty());
|
||||
if action_group_id.is_none() && self.action_device_id.trim().is_empty() {
|
||||
return Err(AppError::BadRequest("automation action needs a device or group".into()));
|
||||
}
|
||||
if let Some(preset) = self.action_preset.as_deref().map(str::trim).filter(|value| !value.is_empty()) {
|
||||
if action_group_id.is_none() {
|
||||
return Err(AppError::BadRequest("automation preset actions require a group target".into()));
|
||||
}
|
||||
if !matches!(preset, "auto" | "comfort" | "sleep" | "away") {
|
||||
return Err(AppError::BadRequest("unsupported group automation preset".into()));
|
||||
}
|
||||
}
|
||||
if action_group_id.is_some() {
|
||||
if let Some(mode) = self.action.mode.as_deref() {
|
||||
if !matches!(mode, "auto" | "house" | "cool" | "heat") {
|
||||
return Err(AppError::BadRequest("group automation mode must be house, cool or heat".into()));
|
||||
}
|
||||
}
|
||||
if self.action.target_temperature.is_some() {
|
||||
return Err(AppError::BadRequest("group automation target temperature is not supported; use a group preset".into()));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
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,
|
||||
trigger_kind: self.trigger_kind, trigger_device_id: self.trigger_device_id,
|
||||
threshold: self.threshold, at_time: self.at_time, action_device_id: self.action_device_id,
|
||||
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_preset: self.action_preset.map(|value| value.trim().to_string()).filter(|value| !value.is_empty()),
|
||||
action: self.action, cooldown_seconds: self.cooldown_seconds.max(30), last_fired_at,
|
||||
created_at, updated_at: Utc::now() }
|
||||
}
|
||||
@@ -889,7 +1045,11 @@ async fn get_automation(State(state): State<AppState>, Path(id): Path<String>) -
|
||||
}
|
||||
async fn create_automation(State(state): State<AppState>, Json(input): Json<AutomationInput>) -> Result<(StatusCode, Json<Automation>), AppError> {
|
||||
input.validate()?;
|
||||
if state.db.get_device(&input.action_device_id)?.is_none() { return Err(AppError::BadRequest("automation action 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()));
|
||||
}
|
||||
let item = input.into_automation(Uuid::new_v4().to_string(), Utc::now(), None);
|
||||
state.db.save_automation(&item)?;
|
||||
state.broadcast("automation.created", serde_json::to_value(&item)?);
|
||||
@@ -898,7 +1058,11 @@ 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> {
|
||||
input.validate()?;
|
||||
let existing = state.db.get_automation(&id)?.ok_or_else(|| AppError::NotFound(format!("automation {id}")))?;
|
||||
if state.db.get_device(&input.action_device_id)?.is_none() { return Err(AppError::BadRequest("automation action 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()));
|
||||
}
|
||||
let item = input.into_automation(id, existing.created_at, existing.last_fired_at);
|
||||
state.db.save_automation(&item)?;
|
||||
state.broadcast("automation.updated", serde_json::to_value(&item)?);
|
||||
@@ -1352,8 +1516,18 @@ fn validate_configuration_export(export: &ConfigurationExport) -> Result<(), App
|
||||
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()));
|
||||
}
|
||||
if export.automations.iter().any(|item| !devices.contains(item.action_device_id.as_str())) {
|
||||
return Err(AppError::BadRequest("import contains an automation referencing a missing device".into()));
|
||||
if export.groups.iter().any(|group| group.zone_ids.iter().any(|zone_id| !zones.contains(zone_id.as_str()))) {
|
||||
return Err(AppError::BadRequest("import contains a group referencing a missing zone".into()));
|
||||
}
|
||||
let groups: std::collections::HashSet<&str> = export.groups.iter().map(|item| item.id.as_str()).collect();
|
||||
if export.automations.iter().any(|item| {
|
||||
if let Some(group_id) = item.action_group_id.as_deref().filter(|value| !value.is_empty()) {
|
||||
!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()));
|
||||
|
||||
@@ -5,7 +5,7 @@ use rusqlite::{params, Connection, OptionalExtension};
|
||||
use serde::{de::DeserializeOwned, Serialize};
|
||||
use serde_json::Value;
|
||||
use crate::{
|
||||
models::{ApiTokenInfo, Automation, ConfigurationExport, Device, EventLog, HaReading, Reading, RuntimeSettings, Schedule, Zone, ZoneReading},
|
||||
models::{ApiTokenInfo, Automation, ClimateGroup, ConfigurationExport, Device, EventLog, HaReading, Reading, RuntimeSettings, Schedule, Zone, ZoneReading},
|
||||
queries,
|
||||
};
|
||||
|
||||
@@ -110,6 +110,28 @@ impl Db {
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
pub fn save_group(&self, group: &ClimateGroup) -> Result<()> {
|
||||
let payload = Self::to_json(group)?;
|
||||
let conn = self.lock()?;
|
||||
conn.execute(
|
||||
queries::UPSERT_GROUP,
|
||||
params![group.id, payload, group.updated_at.to_rfc3339()],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn list_groups(&self) -> Result<Vec<ClimateGroup>> {
|
||||
self.list_payloads(queries::LIST_GROUPS)
|
||||
}
|
||||
|
||||
pub fn get_group(&self, id: &str) -> Result<Option<ClimateGroup>> {
|
||||
self.get_payload(queries::GET_GROUP, id)
|
||||
}
|
||||
|
||||
pub fn delete_group(&self, id: &str) -> Result<bool> {
|
||||
self.delete_by_id("groups", id)
|
||||
}
|
||||
|
||||
pub fn save_schedule(&self, schedule: &Schedule) -> Result<()> {
|
||||
let payload = Self::to_json(schedule)?;
|
||||
let conn = self.lock()?;
|
||||
@@ -187,6 +209,7 @@ impl Db {
|
||||
let sql = match table {
|
||||
"schedules" => queries::DELETE_SCHEDULE,
|
||||
"automations" => queries::DELETE_AUTOMATION,
|
||||
"groups" => queries::DELETE_GROUP,
|
||||
_ => anyhow::bail!("unsupported table"),
|
||||
};
|
||||
let conn = self.lock()?;
|
||||
@@ -523,6 +546,7 @@ impl Db {
|
||||
settings,
|
||||
devices: self.list_devices()?,
|
||||
zones: self.list_zones()?,
|
||||
groups: self.list_groups()?,
|
||||
schedules: self.list_schedules()?,
|
||||
automations: self.list_automations()?,
|
||||
})
|
||||
@@ -540,6 +564,10 @@ impl Db {
|
||||
let payload = Self::to_json(zone)?;
|
||||
tx.execute(queries::UPSERT_ZONE, params![zone.id, payload, zone.updated_at.to_rfc3339()])?;
|
||||
}
|
||||
for group in &export.groups {
|
||||
let payload = Self::to_json(group)?;
|
||||
tx.execute(queries::UPSERT_GROUP, params![group.id, payload, group.updated_at.to_rfc3339()])?;
|
||||
}
|
||||
for schedule in &export.schedules {
|
||||
let payload = Self::to_json(schedule)?;
|
||||
tx.execute(queries::UPSERT_SCHEDULE, params![schedule.id, schedule.zone_id, payload, schedule.updated_at.to_rfc3339()])?;
|
||||
|
||||
+158
-21
@@ -1,13 +1,13 @@
|
||||
use std::time::{Duration, Instant};
|
||||
use anyhow::Result;
|
||||
use chrono::{DateTime, Datelike, Local, NaiveTime, Timelike, Utc, Weekday};
|
||||
use serde_json::json;
|
||||
use serde_json::{json, Value};
|
||||
use tokio::time::sleep;
|
||||
use crate::{
|
||||
error::AppError,
|
||||
home_assistant,
|
||||
influxdb,
|
||||
models::{Automation, AutomationPlanRule, ControlPlan, ControlPlanEvent, Device, DeviceCommand, HaReading, NightModeSettings, Reading, Schedule, Zone, ZoneControlPlan, ZoneReading},
|
||||
models::{Automation, AutomationPlanRule, ControlPlan, ControlPlanEvent, Device, DeviceCommand, GroupControlPatch, HaReading, NightModeSettings, Reading, Schedule, Zone, ZoneControlPlan, ZoneReading},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
@@ -331,8 +331,103 @@ fn validate_command(command: &DeviceCommand) -> Result<(), AppError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControlPatch, source: &str) -> Result<Value, AppError> {
|
||||
if let Some(mode) = patch.mode.as_deref() {
|
||||
if !matches!(mode, "house" | "auto" | "cool" | "heat") {
|
||||
return Err(AppError::BadRequest("group mode must be house, cool or heat".into()));
|
||||
}
|
||||
}
|
||||
if let Some(preset) = patch.preset.as_deref() {
|
||||
if !matches!(preset, "auto" | "comfort" | "sleep" | "away") {
|
||||
return Err(AppError::BadRequest("group preset must be auto, comfort, sleep or away".into()));
|
||||
}
|
||||
}
|
||||
|
||||
let mut group = state.db.get_group(group_id)?
|
||||
.ok_or_else(|| AppError::NotFound(format!("group {group_id}")))?;
|
||||
let schedules = state.db.list_schedules()?;
|
||||
let activates_group = patch.mode.is_some() || patch.preset.is_some();
|
||||
if let Some(power) = patch.power {
|
||||
group.power_enabled = power;
|
||||
} else if activates_group {
|
||||
group.power_enabled = true;
|
||||
}
|
||||
group.updated_at = Utc::now();
|
||||
state.db.save_group(&group)?;
|
||||
state.broadcast("group.updated", serde_json::to_value(&group)?);
|
||||
|
||||
let mut zones = Vec::new();
|
||||
for zone_id in &group.zone_ids {
|
||||
let Some(mut zone) = state.db.get_zone(zone_id)? else { continue; };
|
||||
if let Some(mode) = patch.mode.as_deref() {
|
||||
match mode {
|
||||
"house" | "auto" => zone.inherit_house_mode = true,
|
||||
"cool" | "heat" => {
|
||||
zone.inherit_house_mode = false;
|
||||
zone.mode = mode.to_string();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if let Some(preset) = patch.preset.as_deref() {
|
||||
if preset == "auto" {
|
||||
zone.manual_preset = None;
|
||||
zone.manual_setpoint = None;
|
||||
zone.manual_override_until = None;
|
||||
} else {
|
||||
zone.manual_preset = Some(preset.to_string());
|
||||
zone.manual_setpoint = None;
|
||||
zone.manual_override_until = Some(next_schedule_boundary_utc(&zone.id, &schedules, Local::now()));
|
||||
}
|
||||
}
|
||||
zone.updated_at = Utc::now();
|
||||
state.db.save_zone(&zone)?;
|
||||
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
|
||||
zones.push(zone);
|
||||
}
|
||||
|
||||
let runtime = state.settings.read().await.clone();
|
||||
let master_power_enabled = runtime.house_power_enabled;
|
||||
let should_command_power = patch.power.is_some() || activates_group;
|
||||
let desired_power = group.power_enabled;
|
||||
let mut failed = Vec::new();
|
||||
if should_command_power && (!desired_power || master_power_enabled) {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for zone in &zones {
|
||||
if !seen.insert(zone.device_id.clone()) { continue; }
|
||||
let Some(device) = state.db.get_device(&zone.device_id)? else { continue; };
|
||||
if !device.enabled || device.power == desired_power { continue; }
|
||||
if desired_power {
|
||||
if !zone.enabled { continue; }
|
||||
let zone_mode = if zone.inherit_house_mode { runtime.house_mode.as_str() } else { zone.mode.as_str() };
|
||||
if zone_mode == "off" { continue; }
|
||||
}
|
||||
if let Err(err) = send_command(state, &device.id, DeviceCommand { power: Some(desired_power), ..Default::default() }).await {
|
||||
state.log("error", "group.power_error", &err.to_string(), json!({
|
||||
"group_id": group.id, "device_id": device.id, "device_name": device.name,
|
||||
"power": desired_power, "source": source,
|
||||
}));
|
||||
failed.push(json!({"device_id": device.id, "device_name": device.name, "error": err.to_string()}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
state.log("info", source, &format!("Updated group {}", group.name), json!({
|
||||
"group_id": group.id, "power_enabled": group.power_enabled, "mode": patch.mode, "preset": patch.preset,
|
||||
"zones": zones.len(), "failed": failed.len(), "master_power_enabled": master_power_enabled,
|
||||
}));
|
||||
Ok(json!({
|
||||
"group": group,
|
||||
"zones": zones,
|
||||
"devices": state.db.list_devices()?,
|
||||
"failed": failed,
|
||||
"master_power_enabled": master_power_enabled,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn control_zones(state: &AppState) -> Result<()> {
|
||||
let schedules = state.db.list_schedules()?;
|
||||
let groups = state.db.list_groups()?;
|
||||
let settings = state.settings.read().await.clone();
|
||||
|
||||
// Outdoor temperature is deliberately optional. Prefer the configured Home
|
||||
@@ -441,6 +536,23 @@ async fn control_zones(state: &AppState) -> Result<()> {
|
||||
zone.control_temperature_source = control_source;
|
||||
zone.updated_at = Utc::now();
|
||||
|
||||
let blocked_by_group = groups.iter().any(|group| !group.power_enabled && group.zone_ids.iter().any(|zone_id| zone_id == &zone.id));
|
||||
if blocked_by_group {
|
||||
zone.effective_mode = "off".into();
|
||||
zone.demand = false;
|
||||
zone.demand_since = None;
|
||||
zone.device_setpoint = None;
|
||||
if device.power {
|
||||
if let Err(err) = send_command(state, &zone.device_id, DeviceCommand { power: Some(false), ..Default::default() }).await {
|
||||
state.log("error", "group.power_error", &err.to_string(), json!({"zone_id": zone.id, "device_id": zone.device_id}));
|
||||
}
|
||||
}
|
||||
record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds);
|
||||
state.db.save_zone(&zone)?;
|
||||
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
|
||||
continue;
|
||||
}
|
||||
|
||||
if discrepancy && previous_source != "device_discrepancy_fallback" {
|
||||
state.log("warn", "zone.sensor_discrepancy", &format!("Zone {} sensors differ by more than {:.1} C; using GREE sensor", zone.name, zone.max_sensor_difference), json!({
|
||||
"zone_id": zone.id,
|
||||
@@ -932,6 +1044,7 @@ pub async fn build_control_plan(state: &AppState) -> Result<ControlPlan, AppErro
|
||||
let schedules = state.db.list_schedules()?;
|
||||
let devices = state.db.list_devices()?;
|
||||
let zones = state.db.list_zones()?;
|
||||
let groups = state.db.list_groups()?;
|
||||
let house_preset = zones.first().and_then(|first| {
|
||||
let first_preset = first.manual_preset.as_deref().unwrap_or("auto");
|
||||
zones.iter().all(|zone| zone.manual_preset.as_deref().unwrap_or("auto") == first_preset)
|
||||
@@ -945,22 +1058,20 @@ pub async fn build_control_plan(state: &AppState) -> Result<ControlPlan, AppErro
|
||||
|
||||
for zone in zones {
|
||||
let device = devices.iter().find(|item| item.id == zone.device_id);
|
||||
let effective_mode = if zone.inherit_house_mode {
|
||||
let configured_effective_mode = if zone.inherit_house_mode {
|
||||
settings.house_mode.as_str()
|
||||
} else {
|
||||
zone.mode.as_str()
|
||||
};
|
||||
let active = if effective_mode == "off" {
|
||||
None
|
||||
} else {
|
||||
active_schedule_for_zone(&zone, &schedules, now)
|
||||
};
|
||||
let (preset, target) = if effective_mode == "off" {
|
||||
("manual".to_string(), None)
|
||||
} else {
|
||||
let (preset, target) = resolve_zone_target(&zone, active, effective_mode);
|
||||
(preset, Some(target))
|
||||
};
|
||||
let blocked_by_group = groups.iter().any(|group| !group.power_enabled && group.zone_ids.iter().any(|zone_id| zone_id == &zone.id));
|
||||
let effective_mode = if blocked_by_group { "off" } else { configured_effective_mode };
|
||||
|
||||
// Keep the thermostat target readable even while the zone/group/house control is off.
|
||||
// Home Assistant climate entities otherwise expose target_temperature as unknown.
|
||||
let target_mode = if configured_effective_mode == "off" { zone.mode.as_str() } else { configured_effective_mode };
|
||||
let active_for_target = active_schedule_for_zone(&zone, &schedules, now);
|
||||
let (resolved_preset, resolved_target) = resolve_zone_target(&zone, active_for_target, target_mode);
|
||||
let active = if effective_mode == "off" { None } else { active_for_target };
|
||||
let next_events = if effective_mode == "off" {
|
||||
Vec::new()
|
||||
} else {
|
||||
@@ -976,13 +1087,17 @@ pub async fn build_control_plan(state: &AppState) -> Result<ControlPlan, AppErro
|
||||
zone_name: zone.name.clone(),
|
||||
device_id: zone.device_id.clone(),
|
||||
device_name: device.map(|item| item.name.clone()).unwrap_or_else(|| zone.device_id.clone()),
|
||||
enabled: zone.enabled,
|
||||
enabled: zone.enabled && !blocked_by_group,
|
||||
mode: effective_mode.to_string(),
|
||||
configured_mode: zone.mode.clone(),
|
||||
inherit_house_mode: zone.inherit_house_mode,
|
||||
preset: if effective_mode == "off" { "manual".into() } else if zone.active_preset.is_empty() { preset } else { zone.active_preset.clone() },
|
||||
preset: if zone.active_preset.is_empty() { resolved_preset } else { zone.active_preset.clone() },
|
||||
current_temperature: zone.current_temperature,
|
||||
target_temperature: if effective_mode == "off" { None } else { zone.effective_setpoint.or(target) },
|
||||
target_temperature: if !zone.enabled || effective_mode == "off" {
|
||||
Some(resolved_target)
|
||||
} else {
|
||||
zone.effective_setpoint.or(Some(resolved_target))
|
||||
},
|
||||
device_setpoint: zone.device_setpoint.or_else(|| device.map(|item| item.target_temperature)),
|
||||
demand: settings.house_power_enabled && zone.enabled && effective_mode != "off" && zone.demand,
|
||||
control_source: zone.control_temperature_source.clone(),
|
||||
@@ -994,7 +1109,14 @@ pub async fn build_control_plan(state: &AppState) -> Result<ControlPlan, AppErro
|
||||
}
|
||||
let mut rules = Vec::new();
|
||||
for item in state.db.list_automations()? {
|
||||
let action_name = devices.iter().find(|device| device.id == item.action_device_id).map(|device| device.name.clone()).unwrap_or_else(|| item.action_device_id.clone());
|
||||
let action_group_name = item.action_group_id.as_deref()
|
||||
.and_then(|id| groups.iter().find(|group| group.id == id))
|
||||
.map(|group| group.name.clone());
|
||||
let action_name = action_group_name.clone().unwrap_or_else(|| {
|
||||
devices.iter().find(|device| device.id == item.action_device_id)
|
||||
.map(|device| device.name.clone())
|
||||
.unwrap_or_else(|| item.action_device_id.clone())
|
||||
});
|
||||
let trigger_name = item.trigger_device_id.as_deref().and_then(|id| devices.iter().find(|device| device.id == id)).map(|device| device.name.clone());
|
||||
let next_ready_at = item.last_fired_at.map(|last| last + chrono::Duration::seconds(item.cooldown_seconds as i64));
|
||||
if item.enabled && item.trigger_kind == "time" {
|
||||
@@ -1013,6 +1135,9 @@ pub async fn build_control_plan(state: &AppState) -> Result<ControlPlan, AppErro
|
||||
at_time: item.at_time,
|
||||
action_device_id: item.action_device_id,
|
||||
action_device_name: action_name,
|
||||
action_group_id: item.action_group_id,
|
||||
action_group_name,
|
||||
action_preset: item.action_preset,
|
||||
action: item.action,
|
||||
last_fired_at: item.last_fired_at,
|
||||
next_ready_at,
|
||||
@@ -1128,12 +1253,24 @@ async fn run_automations(state: &AppState) -> Result<()> {
|
||||
_ => false,
|
||||
};
|
||||
if !should_fire { continue; }
|
||||
match send_command(state, &item.action_device_id, item.action.clone()).await {
|
||||
Ok(_) => {
|
||||
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() });
|
||||
control_group(state, group_id, GroupControlPatch {
|
||||
power: item.action.power,
|
||||
mode: group_mode,
|
||||
preset: item.action_preset.clone(),
|
||||
}, "automation.group").await.map(|_| ())
|
||||
} else {
|
||||
send_command(state, &item.action_device_id, item.action.clone()).await.map(|_| ())
|
||||
};
|
||||
match result {
|
||||
Ok(()) => {
|
||||
item.last_fired_at = Some(Utc::now());
|
||||
item.updated_at = Utc::now();
|
||||
state.db.save_automation(&item)?;
|
||||
state.log("info", "automation.fired", &format!("Automation {} fired", item.name), json!({"automation_id": item.id}));
|
||||
state.log("info", "automation.fired", &format!("Automation {} fired", item.name), json!({
|
||||
"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})),
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ fn default_influx_threshold_days() -> u32 { 30 }
|
||||
fn default_night_start() -> String { "22:00".into() }
|
||||
fn default_night_end() -> String { "06:00".into() }
|
||||
fn default_night_max_fan_speed() -> u8 { 1 }
|
||||
fn default_group_power_enabled() -> bool { true }
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Device {
|
||||
@@ -342,6 +343,30 @@ pub struct Zone {
|
||||
fn default_sensor_source() -> String { "device".into() }
|
||||
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ClimateGroup {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub zone_ids: Vec<String>,
|
||||
#[serde(default = "default_group_power_enabled")]
|
||||
pub power_enabled: bool,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct GroupControlPatch {
|
||||
#[serde(default)]
|
||||
pub power: Option<bool>,
|
||||
/// house follows the global house mode; cool/heat set an explicit mode on every member zone.
|
||||
#[serde(default)]
|
||||
pub mode: Option<String>,
|
||||
/// auto clears temporary overrides; comfort/sleep/away apply a temporary preset to every member zone.
|
||||
#[serde(default)]
|
||||
pub preset: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct ZoneControlPatch {
|
||||
#[serde(default)]
|
||||
@@ -392,7 +417,15 @@ pub struct Automation {
|
||||
pub threshold: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub at_time: Option<String>,
|
||||
/// Legacy/direct-device target. Empty when this automation targets a group.
|
||||
#[serde(default)]
|
||||
pub action_device_id: String,
|
||||
/// Optional climate group target. When set, the action is applied to every member zone/device.
|
||||
#[serde(default)]
|
||||
pub action_group_id: Option<String>,
|
||||
/// Optional thermostat preset used only for group actions.
|
||||
#[serde(default)]
|
||||
pub action_preset: Option<String>,
|
||||
#[serde(default)]
|
||||
pub action: DeviceCommand,
|
||||
#[serde(default = "default_cooldown")]
|
||||
@@ -613,6 +646,8 @@ pub struct ConfigurationExport {
|
||||
pub settings: RuntimeSettings,
|
||||
pub devices: Vec<Device>,
|
||||
pub zones: Vec<Zone>,
|
||||
#[serde(default)]
|
||||
pub groups: Vec<ClimateGroup>,
|
||||
pub schedules: Vec<Schedule>,
|
||||
pub automations: Vec<Automation>,
|
||||
}
|
||||
@@ -662,6 +697,12 @@ pub struct AutomationPlanRule {
|
||||
pub at_time: Option<String>,
|
||||
pub action_device_id: String,
|
||||
pub action_device_name: String,
|
||||
#[serde(default)]
|
||||
pub action_group_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub action_group_name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub action_preset: Option<String>,
|
||||
pub action: DeviceCommand,
|
||||
pub last_fired_at: Option<DateTime<Utc>>,
|
||||
pub next_ready_at: Option<DateTime<Utc>>,
|
||||
|
||||
@@ -36,6 +36,12 @@ CREATE TABLE IF NOT EXISTS zones (
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS climate_groups (
|
||||
id TEXT PRIMARY KEY,
|
||||
payload TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS schedules (
|
||||
id TEXT PRIMARY KEY,
|
||||
zone_id TEXT NOT NULL,
|
||||
@@ -125,6 +131,8 @@ INSERT OR IGNORE INTO schema_migrations(version, applied_at)
|
||||
VALUES (3, strftime('%Y-%m-%dT%H:%M:%fZ','now'));
|
||||
INSERT OR IGNORE INTO schema_migrations(version, applied_at)
|
||||
VALUES (4, strftime('%Y-%m-%dT%H:%M:%fZ','now'));
|
||||
INSERT OR IGNORE INTO schema_migrations(version, applied_at)
|
||||
VALUES (5, strftime('%Y-%m-%dT%H:%M:%fZ','now'));
|
||||
"#;
|
||||
|
||||
pub const COUNT_DEVICES: &str = "SELECT COUNT(*) FROM devices";
|
||||
@@ -161,6 +169,17 @@ pub const GET_ZONE: &str = "SELECT payload FROM zones WHERE id=?1";
|
||||
pub const DELETE_SCHEDULES_BY_ZONE_ID: &str = "DELETE FROM schedules WHERE zone_id=?1";
|
||||
pub const DELETE_ZONE: &str = "DELETE FROM zones WHERE id=?1";
|
||||
|
||||
pub const UPSERT_GROUP: &str = r#"
|
||||
INSERT INTO climate_groups(id,payload,updated_at) VALUES(?1,?2,?3)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
payload=excluded.payload,
|
||||
updated_at=excluded.updated_at
|
||||
"#;
|
||||
pub const LIST_GROUPS: &str =
|
||||
"SELECT payload FROM climate_groups ORDER BY json_extract(payload, '$.name') COLLATE NOCASE";
|
||||
pub const GET_GROUP: &str = "SELECT payload FROM climate_groups WHERE id=?1";
|
||||
pub const DELETE_GROUP: &str = "DELETE FROM climate_groups WHERE id=?1";
|
||||
|
||||
pub const UPSERT_SCHEDULE: &str = r#"
|
||||
INSERT INTO schedules(id,zone_id,payload,updated_at) VALUES(?1,?2,?3,?4)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
@@ -368,6 +387,7 @@ DELETE FROM ha_readings WHERE id IN (
|
||||
pub const CLEAR_CONFIGURATION: &str = r#"
|
||||
DELETE FROM schedules;
|
||||
DELETE FROM automations;
|
||||
DELETE FROM climate_groups;
|
||||
DELETE FROM zones;
|
||||
DELETE FROM devices;
|
||||
"#;
|
||||
|
||||
+246
-21
@@ -20,7 +20,7 @@ const preferredLanguage = getCookie('gree_controller_language') || DEFAULT_LANGU
|
||||
const preferredTheme = ['system', 'light', 'dark'].includes(getCookie('gree_controller_theme')) ? getCookie('gree_controller_theme') : 'system';
|
||||
|
||||
const app = {
|
||||
devices: [], zones: [], schedules: [], automations: [], accessTokens: [], settings: null, system: {}, outdoorTemperature: null,
|
||||
devices: [], zones: [], groups: [], schedules: [], automations: [], accessTokens: [], settings: null, system: {}, outdoorTemperature: null,
|
||||
token: localStorage.getItem('gree_controller_token') || '', ws: null, wsTimer: null,
|
||||
currentView: 'dashboard', loading: false, language: preferredLanguage, theme: preferredTheme, connectionStatus: 'connecting',
|
||||
languages: [], translations: {}, locales: {},
|
||||
@@ -28,6 +28,7 @@ const app = {
|
||||
historyZone: 'all', historyDevice: 'all', historySensor: 'all', historyLoading: false,
|
||||
customChartSeries: [], savedCharts: [], zoneControlSeq: {}, zoneTemperatureTimers: {},
|
||||
controlPlan: null, controlPlanTimer: null, debugLines: [], debugBacklogLoaded: false, sensorAliases: {},
|
||||
simulationScope: 'units', simulationTarget: 'all', standaloneSimulation: false,
|
||||
};
|
||||
try { app.savedCharts = JSON.parse(localStorage.getItem('gree_controller_saved_charts') || '[]'); } catch (_) { app.savedCharts = []; }
|
||||
|
||||
@@ -189,6 +190,7 @@ async function loadBootstrap(showMessage = false) {
|
||||
const data = await api('/api/bootstrap');
|
||||
app.devices = data.devices || [];
|
||||
app.zones = data.zones || [];
|
||||
app.groups = data.groups || [];
|
||||
app.schedules = data.schedules || [];
|
||||
app.automations = data.automations || [];
|
||||
app.accessTokens = data.access_tokens || [];
|
||||
@@ -212,6 +214,7 @@ async function loadBootstrap(showMessage = false) {
|
||||
function renderAll() {
|
||||
renderSummary();
|
||||
renderHouseClimate();
|
||||
renderGroups();
|
||||
renderControlPlan();
|
||||
renderSimulationPage();
|
||||
renderDevices();
|
||||
@@ -406,11 +409,74 @@ function buildZoneSimulation(zone, planZone) {
|
||||
function simulationRuleAction(rule) {
|
||||
const bits = [];
|
||||
if (rule.action?.power != null) bits.push(`${tr('common.power')}: ${rule.action.power ? tr('common.on') : tr('common.off')}`);
|
||||
if (rule.action?.mode) bits.push(`${tr('common.mode')}: ${modeLabel(rule.action.mode)}`);
|
||||
if (rule.action?.mode) bits.push(`${tr('common.mode')}: ${rule.action_group_id && rule.action.mode === 'auto' ? tr('groups.followHouse') : modeLabel(rule.action.mode)}`);
|
||||
if (rule.action?.target_temperature != null) bits.push(`${tr('common.temperature')}: ${fmtTemp(rule.action.target_temperature)}`);
|
||||
if (rule.action_preset) bits.push(`${tr('groups.profile')}: ${zonePresetLabel(rule.action_preset)}`);
|
||||
return bits.length ? bits.join(' · ') : tr('simulation.noActionPreview');
|
||||
}
|
||||
|
||||
function simulationGroupsForZone(zoneId) {
|
||||
return app.groups.filter(group => (group.zone_ids || []).includes(zoneId));
|
||||
}
|
||||
|
||||
function renderSimulationScopeControls(plan) {
|
||||
const scope = $('#simulationScope');
|
||||
const target = $('#simulationTarget');
|
||||
if (!scope || !target) return;
|
||||
if (!['units','groups'].includes(app.simulationScope)) app.simulationScope = 'units';
|
||||
scope.value = app.simulationScope;
|
||||
const zones = plan?.zones || [];
|
||||
const options = app.simulationScope === 'groups'
|
||||
? [{id:'all', name:tr('simulation.allGroups')}, ...app.groups.map(group => ({id:group.id, name:group.name}))]
|
||||
: [{id:'all', name:tr('simulation.allUnits')}, ...zones.map(zone => ({id:zone.zone_id, name:`${zone.zone_name} · ${zone.device_name || tr('common.device')}`}))];
|
||||
if (!options.some(option => option.id === app.simulationTarget)) app.simulationTarget = 'all';
|
||||
target.innerHTML = options.map(option => `<option value="${esc(option.id)}">${esc(option.name)}</option>`).join('');
|
||||
target.value = app.simulationTarget;
|
||||
}
|
||||
|
||||
function simulationFilteredZones(plan) {
|
||||
const zones = plan?.zones || [];
|
||||
if (app.simulationScope === 'groups') {
|
||||
if (app.simulationTarget === 'all') {
|
||||
const grouped = new Set(app.groups.flatMap(group => group.zone_ids || []));
|
||||
return zones.filter(zone => grouped.has(zone.zone_id));
|
||||
}
|
||||
const group = app.groups.find(item => item.id === app.simulationTarget);
|
||||
const wanted = new Set(group?.zone_ids || []);
|
||||
return zones.filter(zone => wanted.has(zone.zone_id));
|
||||
}
|
||||
return app.simulationTarget === 'all' ? zones : zones.filter(zone => zone.zone_id === app.simulationTarget);
|
||||
}
|
||||
|
||||
function simulationRuleVisible(rule, zones) {
|
||||
if (app.simulationTarget === 'all') return true;
|
||||
const zoneIds = new Set(zones.map(zone => zone.zone_id));
|
||||
const deviceIds = new Set(zones.map(zone => zone.device_id).filter(Boolean));
|
||||
if (app.simulationScope === 'groups') {
|
||||
if (rule.action_group_id === app.simulationTarget) return true;
|
||||
return deviceIds.has(rule.trigger_device_id) || deviceIds.has(rule.action_device_id);
|
||||
}
|
||||
const directZone = [...zoneIds][0];
|
||||
if (simulationGroupsForZone(directZone).some(group => group.id === rule.action_group_id)) return true;
|
||||
return deviceIds.has(rule.trigger_device_id) || deviceIds.has(rule.action_device_id);
|
||||
}
|
||||
|
||||
function simulationLaneContext(planZone) {
|
||||
if (app.simulationScope !== 'groups') return planZone.device_name || '';
|
||||
const groups = simulationGroupsForZone(planZone.zone_id);
|
||||
return groups.length ? groups.map(group => group.name).join(' · ') : (planZone.device_name || '');
|
||||
}
|
||||
|
||||
function updateSimulationUrl() {
|
||||
if (app.currentView !== 'simulation') return;
|
||||
const params = new URLSearchParams();
|
||||
if (app.standaloneSimulation) params.set('standalone', '1');
|
||||
if (app.simulationScope !== 'units') params.set('scope', app.simulationScope);
|
||||
if (app.simulationTarget !== 'all') params.set('target', app.simulationTarget);
|
||||
const query = params.toString();
|
||||
updateBrowserUrl(`/simulation${query ? `?${query}` : ''}`, true);
|
||||
}
|
||||
|
||||
function renderSimulationPage() {
|
||||
const summaryHost = $('#simulationSummary');
|
||||
const timelineHost = $('#simulationTimeline');
|
||||
@@ -424,17 +490,18 @@ function renderSimulationPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
const enabledZones = (plan.zones || []).filter(zone => zone.enabled);
|
||||
renderSimulationScopeControls(plan);
|
||||
const zones = simulationFilteredZones(plan);
|
||||
const enabledZones = zones.filter(zone => zone.enabled);
|
||||
const demandingZones = enabledZones.filter(zone => zone.demand);
|
||||
const nightOn = !!plan.night_mode_active;
|
||||
summaryHost.innerHTML = [
|
||||
{label: tr('simulation.houseMode'), value: houseModeLabel(plan.house_mode || 'off'), note: tr('simulation.strategy', {strategy: plan.control_strategy || 'setpoint'})},
|
||||
{label: tr('settings.nightMode'), value: nightOn ? tr('common.active') : (app.settings?.night_mode?.enabled ? tr('simulation.scheduled') : tr('common.disabled')), note: `${plan.night_mode_start || '22:00'} → ${plan.night_mode_end || '06:00'} · ${tr('simulation.fanMax')} ${fanLabel(plan.night_mode_max_fan_speed || 1)}`},
|
||||
{label: tr('simulation.outdoor'), value: plan.outdoor_temperature == null ? '—' : fmtTemp(plan.outdoor_temperature), note: tr('simulation.generatedAt', {time: dateTime(plan.generated_at)})},
|
||||
{label: tr('simulation.activeZones'), value: String(enabledZones.length), note: `${tr('simulation.requestingZones', {count: demandingZones.length})} · ${tr('simulation.rulesLabel')}: ${(plan.rules || []).filter(rule => rule.enabled).length}`},
|
||||
{label: tr('simulation.activeZones'), value: String(enabledZones.length), note: `${tr('simulation.requestingZones', {count: demandingZones.length})} · ${tr('simulation.rulesLabel')}: ${(plan.rules || []).filter(rule => rule.enabled && simulationRuleVisible(rule, zones)).length}`},
|
||||
].map(card => `<article class="panel simulation-summary-card"><small>${esc(card.label)}</small><strong>${esc(card.value)}</strong><span>${esc(card.note)}</span></article>`).join('');
|
||||
|
||||
const zones = plan.zones || [];
|
||||
const nodeW = 190, nodeH = 106, rowH = 190, topY = 155;
|
||||
const x = {sensor:45, thermostat:305, decision:565, unit:825, event:1085};
|
||||
const boardW = 1325, boardH = Math.max(390, topY + zones.length * rowH + 35);
|
||||
@@ -468,7 +535,7 @@ function renderSimulationPage() {
|
||||
const eventValue = next ? simulationTime(next.at) : tr('simulation.noEventShort');
|
||||
const eventMeta = next ? `${next.label}${next.target_temperature == null ? '' : ` · ${fmtTemp(next.target_temperature)}`}` : tr('plan.noEvents');
|
||||
|
||||
nodes.push(`<div class="flow-lane-label" style="top:${y-27}px"><strong>${esc(planZone.zone_name)}</strong><span>${esc(planZone.device_name || '')}</span></div>`);
|
||||
nodes.push(`<div class="flow-lane-label" style="top:${y-27}px"><strong>${esc(planZone.zone_name)}</strong><span>${esc(simulationLaneContext(planZone))}</span></div>`);
|
||||
nodes.push(node({left:x.sensor,top:y,kind:'input',eyebrow:tr('simulation.stepRoom'),title:sensorName,value:fmtTemp(sim.current),meta:source,badge:'●',badgeClass:sim.current == null?'':'active'}));
|
||||
nodes.push(node({left:x.thermostat,top:y,kind:'logic',eyebrow:tr('simulation.thermostat'),title:planZone.zone_name,value:sim.target == null?'—':fmtTemp(sim.target),meta:`${zonePresetLabel(planZone.preset)} · ${tr('simulation.hysteresis',{value:sim.hysteresis.toFixed(1)})}`,badge:houseModeLabel(sim.mode)}));
|
||||
nodes.push(node({left:x.decision,top:y,kind:`logic decision ${sim.demand?'demand':'satisfied'}`,eyebrow:tr('simulation.stepDecision'),title:status.label,value:sim.demand?tr('simulation.callForComfort'):tr('simulation.standby'),meta:sim.reasoning,badge:sim.demand?'▶':'✓',badgeClass:status.badge}));
|
||||
@@ -489,7 +556,7 @@ function renderSimulationPage() {
|
||||
boardHost.innerHTML = `<svg class="flow-links" viewBox="0 0 ${boardW} ${boardH}" width="${boardW}" height="${boardH}" aria-hidden="true">${links.join('')}</svg>${nodes.join('')}`;
|
||||
|
||||
const timelineItems = [];
|
||||
(plan.next_events || []).forEach(event => timelineItems.push({scope: tr('simulation.house'), event}));
|
||||
if (app.simulationTarget === 'all') (plan.next_events || []).forEach(event => timelineItems.push({scope: tr('simulation.house'), event}));
|
||||
zones.forEach(zone => (zone.next_events || []).forEach(event => timelineItems.push({scope: zone.zone_name, event, device: zone.device_name})));
|
||||
timelineItems.sort((a, b) => new Date(a.event.at) - new Date(b.event.at));
|
||||
const unique = [], seen = new Set();
|
||||
@@ -502,7 +569,7 @@ function renderSimulationPage() {
|
||||
return `<article class="simulation-timeline-item"><time>${esc(simulationTime(item.event.at))}</time><div><strong>${esc(item.scope)}</strong><p>${esc(item.event.label)}${esc(target)}${item.device ? ` · ${esc(item.device)}` : ''}</p></div></article>`;
|
||||
}).join('') : `<div class="empty">${esc(tr('plan.noEvents'))}</div>`;
|
||||
|
||||
const activeRules = (plan.rules || []).filter(rule => rule.enabled);
|
||||
const activeRules = (plan.rules || []).filter(rule => rule.enabled && simulationRuleVisible(rule, zones));
|
||||
rulesHost.innerHTML = activeRules.length ? activeRules.map(rule => `<article class="panel simulation-rule-card"><div class="simulation-rule-head"><div><span class="eyebrow">${esc(tr('common.automation'))}</span><h3>${esc(rule.name)}</h3></div><span class="badge active">${esc(automationTriggerLabel(rule))}</span></div><p>${esc(tr('simulation.ruleOnDevice', {device: rule.action_device_name || tr('common.noDevice')}))}</p><div class="simulation-rule-action">${esc(simulationRuleAction(rule))}</div><div class="simulation-rule-meta"><small>${esc(tr('automations.last'))}: ${esc(dateTime(rule.last_fired_at))}</small><small>${esc(tr('simulation.nextReady'))}: ${esc(dateTime(rule.next_ready_at))}</small></div></article>`).join('') : `<div class="empty">${esc(tr('simulation.noRules'))}</div>`;
|
||||
}
|
||||
|
||||
@@ -557,6 +624,8 @@ function renderDevices() {
|
||||
const empty = `<div class="empty"><strong>${esc(tr('devices.emptyTitle'))}</strong>${esc(tr('devices.emptyText'))}</div>`;
|
||||
$('#dashboardDevices').innerHTML = app.devices.length ? app.devices.map(d => deviceCard(d, false)).join('') : empty;
|
||||
$('#deviceList').innerHTML = app.devices.length ? app.devices.map(d => deviceCard(d, true)).join('') : empty;
|
||||
const dashboardCount = $('#dashboardDeviceCount');
|
||||
if (dashboardCount) dashboardCount.textContent = String(app.devices.length);
|
||||
}
|
||||
|
||||
function zoneStrategyLabel(zone) {
|
||||
@@ -612,6 +681,90 @@ function renderZones() {
|
||||
$('#zoneList').innerHTML = app.zones.length ? app.zones.map(zone => zoneCard(zone, true)).join('') : empty;
|
||||
const dashboard = $('#dashboardZones');
|
||||
if (dashboard) dashboard.innerHTML = app.zones.length ? app.zones.map(zone => zoneCard(zone, false)).join('') : empty;
|
||||
const dashboardCount = $('#dashboardZoneCount');
|
||||
if (dashboardCount) dashboardCount.textContent = String(app.zones.length);
|
||||
}
|
||||
|
||||
function groupZones(group) {
|
||||
const wanted = new Set(group?.zone_ids || []);
|
||||
return app.zones.filter(zone => wanted.has(zone.id));
|
||||
}
|
||||
|
||||
function groupState(group) {
|
||||
const zones = groupZones(group);
|
||||
const modes = [...new Set(zones.map(zone => zone.inherit_house_mode ? 'house' : (zone.mode || 'cool')))];
|
||||
const presets = [...new Set(zones.map(zone => zone.manual_preset || 'auto'))];
|
||||
return {
|
||||
zones,
|
||||
mode: modes.length === 1 ? modes[0] : (modes.length ? 'mixed' : 'house'),
|
||||
preset: presets.length === 1 ? presets[0] : (presets.length ? 'mixed' : 'auto'),
|
||||
};
|
||||
}
|
||||
|
||||
function groupCard(group, detailed = false) {
|
||||
const state = groupState(group);
|
||||
const memberNames = state.zones.map(zone => zone.name);
|
||||
const memberText = memberNames.length ? memberNames.join(' · ') : tr('groups.noMembers');
|
||||
const powerEnabled = group.power_enabled !== false;
|
||||
const masterOff = app.settings?.house_power_enabled === false;
|
||||
const modeLabelForGroup = value => value === 'house' ? tr('groups.followHouse') : (value === 'mixed' ? tr('groups.mixed') : modeLabel(value));
|
||||
const presetLabelForGroup = value => value === 'mixed' ? tr('groups.mixed') : zonePresetLabel(value);
|
||||
return `<article class="list-card group-card ${powerEnabled ? '' : 'group-off'}">
|
||||
<div class="list-card-head"><div><span class="eyebrow">${esc(tr('groups.group'))}</span><h3>${esc(group.name)}</h3><p>${esc(memberText)}</p></div><span class="badge ${powerEnabled ? 'active' : ''}">${esc(powerEnabled ? tr('common.on') : tr('common.off'))}</span></div>
|
||||
${detailed ? `<div class="group-state-summary"><div><small>${esc(tr('groups.mode'))}</small><strong>${esc(modeLabelForGroup(state.mode))}</strong></div><div><small>${esc(tr('groups.profile'))}</small><strong>${esc(presetLabelForGroup(state.preset))}</strong></div><div><small>${esc(tr('groups.members'))}</small><strong>${state.zones.length}</strong></div></div>` : ''}
|
||||
<div class="group-control-block"><small>${esc(tr('common.power'))}</small><div class="group-button-row"><button class="${powerEnabled ? 'active' : ''}" data-action="group-power" data-id="${esc(group.id)}" data-value="true">${esc(tr('common.on'))}</button><button class="${!powerEnabled ? 'active' : ''}" data-action="group-power" data-id="${esc(group.id)}" data-value="false">${esc(tr('common.off'))}</button></div></div>
|
||||
<div class="group-control-block"><small>${esc(tr('groups.mode'))}</small><div class="mode-row group-mode-row">${['house','heat','cool'].map(mode => `<button class="${state.mode === mode ? 'active' : ''}" data-action="group-mode" data-id="${esc(group.id)}" data-value="${mode}">${esc(mode === 'house' ? tr('groups.followHouse') : modeLabel(mode))}</button>`).join('')}</div></div>
|
||||
<div class="group-control-block"><small>${esc(tr('groups.profile'))}</small><div class="preset-row group-preset-row">${['auto','comfort','sleep','away'].map(preset => `<button class="${state.preset === preset ? 'active' : ''}" data-action="group-preset" data-id="${esc(group.id)}" data-value="${preset}">${esc(zonePresetLabel(preset))}</button>`).join('')}</div></div>
|
||||
${masterOff ? `<p class="field-note warning-note">${esc(tr('groups.masterOff'))}</p>` : ''}
|
||||
${detailed ? `<div class="card-footer"><small>${esc(tr('groups.memberCount', {count: state.zones.length}))}</small><div class="card-menu"><button data-action="edit-group" data-id="${esc(group.id)}">${esc(tr('actions.edit'))}</button><button class="danger" data-action="delete-group" data-id="${esc(group.id)}">${esc(tr('actions.delete'))}</button></div></div>` : ''}
|
||||
</article>`;
|
||||
}
|
||||
|
||||
function renderGroups() {
|
||||
const empty = `<div class="empty"><strong>${esc(tr('groups.emptyTitle'))}</strong>${esc(tr('groups.emptyText'))}</div>`;
|
||||
const dashboard = $('#dashboardGroups');
|
||||
if (dashboard) dashboard.innerHTML = app.groups.length ? app.groups.map(group => groupCard(group, false)).join('') : empty;
|
||||
const dashboardCount = $('#dashboardGroupCount');
|
||||
if (dashboardCount) dashboardCount.textContent = String(app.groups.length);
|
||||
const list = $('#groupList');
|
||||
if (list) list.innerHTML = app.groups.length ? app.groups.map(group => groupCard(group, true)).join('') : empty;
|
||||
}
|
||||
|
||||
async function sendGroupControl(id, patch) {
|
||||
try {
|
||||
const result = await api(`/api/groups/${encodeURIComponent(id)}/control`, {method:'POST', body:patch});
|
||||
if (result.group) {
|
||||
const index = app.groups.findIndex(group => group.id === result.group.id);
|
||||
if (index >= 0) app.groups[index] = result.group; else app.groups.push(result.group);
|
||||
}
|
||||
(result.zones || []).forEach(zone => {
|
||||
const index = app.zones.findIndex(item => item.id === zone.id);
|
||||
if (index >= 0) app.zones[index] = zone; else app.zones.push(zone);
|
||||
});
|
||||
(result.devices || []).forEach(updateDevice);
|
||||
renderAll(); scheduleControlPlanLoad();
|
||||
const failed = Array.isArray(result.failed) ? result.failed.length : 0;
|
||||
if (failed) toast(tr('groups.partial', {count: failed}), true);
|
||||
else toast(tr('groups.controlUpdated'));
|
||||
} catch (error) { toast(error.message, true); }
|
||||
}
|
||||
|
||||
function renderGroupZoneChoices(selectedIds = null) {
|
||||
const host = $('#groupZoneChoices'); if (!host) return;
|
||||
const selected = new Set(selectedIds || []);
|
||||
host.innerHTML = app.zones.length ? app.zones.map(zone => {
|
||||
const device = app.devices.find(item => item.id === zone.device_id);
|
||||
return `<label class="group-zone-choice"><input type="checkbox" name="zone_ids" value="${esc(zone.id)}" ${selected.has(zone.id) ? 'checked' : ''}><span><strong>${esc(zone.name)}</strong><small>${esc(device?.name || tr('common.noDevice'))}</small></span></label>`;
|
||||
}).join('') : `<div class="empty compact">${esc(tr('groups.noZones'))}</div>`;
|
||||
}
|
||||
|
||||
function populateGroup(id) {
|
||||
const item = app.groups.find(group => group.id === id); if (!item) return;
|
||||
const form = $('#groupForm'); form.reset();
|
||||
form.elements.id.value = item.id;
|
||||
form.elements.name.value = item.name;
|
||||
renderGroupZoneChoices(item.zone_ids || []);
|
||||
openDialog('groupDialog');
|
||||
}
|
||||
|
||||
function renderSchedules() {
|
||||
@@ -627,20 +780,27 @@ function renderSchedules() {
|
||||
|
||||
function renderAutomations() {
|
||||
$('#automationList').innerHTML = app.automations.length ? app.automations.map(item => {
|
||||
const actionDevice = app.devices.find(d => d.id === item.action_device_id);
|
||||
return `<article class="list-card"><div class="list-card-head"><div><h3>${esc(item.name)}</h3><p>${esc(tr('automations.triggerSummary', {trigger: automationTriggerLabel(item), device: actionDevice?.name || tr('common.noDevice')}))}</p></div><span class="badge ${item.enabled ? 'active' : ''}">${esc(item.enabled ? tr('common.active') : tr('common.disabled'))}</span></div>
|
||||
<div class="card-stats"><div class="card-stat"><small>${esc(tr('common.power'))}</small><strong>${item.action.power == null ? '—' : item.action.power ? tr('common.on') : tr('common.off')}</strong></div><div class="card-stat"><small>${esc(tr('common.mode'))}</small><strong>${item.action.mode ? esc(modeLabel(item.action.mode)) : '—'}</strong></div><div class="card-stat"><small>${esc(tr('automations.last'))}</small><strong>${item.last_fired_at ? new Date(item.last_fired_at).toLocaleTimeString(locale(),{hour:'2-digit',minute:'2-digit'}) : '—'}</strong></div></div>
|
||||
<div class="card-footer"><small>${esc(tr('common.cooldown'))} ${item.cooldown_seconds}s</small><div class="card-menu"><button data-action="edit-automation" data-id="${esc(item.id)}">${esc(tr('actions.edit'))}</button><button class="danger" data-action="delete-automation" data-id="${esc(item.id)}">${esc(tr('actions.delete'))}</button></div></div></article>`;
|
||||
const actionGroup = item.action_group_id ? app.groups.find(group => group.id === item.action_group_id) : null;
|
||||
const actionDevice = app.devices.find(device => device.id === item.action_device_id);
|
||||
const targetName = actionGroup?.name || actionDevice?.name || tr('common.noDevice');
|
||||
const mode = item.action.mode === 'auto' && actionGroup ? tr('groups.followHouse') : (item.action.mode ? modeLabel(item.action.mode) : '—');
|
||||
const preset = item.action_preset ? zonePresetLabel(item.action_preset) : '—';
|
||||
return `<article class="list-card"><div class="list-card-head"><div><h3>${esc(item.name)}</h3><p>${esc(tr('automations.triggerSummary', {trigger: automationTriggerLabel(item), device: targetName}))}</p></div><span class="badge ${item.enabled ? 'active' : ''}">${esc(item.enabled ? tr('common.active') : tr('common.disabled'))}</span></div>
|
||||
<div class="card-stats"><div class="card-stat"><small>${esc(tr('common.power'))}</small><strong>${item.action.power == null ? '—' : item.action.power ? tr('common.on') : tr('common.off')}</strong></div><div class="card-stat"><small>${esc(tr('common.mode'))}</small><strong>${esc(mode)}</strong></div><div class="card-stat"><small>${esc(tr('groups.profile'))}</small><strong>${esc(preset)}</strong></div><div class="card-stat"><small>${esc(tr('automations.last'))}</small><strong>${item.last_fired_at ? new Date(item.last_fired_at).toLocaleTimeString(locale(),{hour:'2-digit',minute:'2-digit'}) : '—'}</strong></div></div>
|
||||
<div class="card-footer"><small>${esc(actionGroup ? `${tr('groups.group')}: ${targetName}` : `${tr('common.device')}: ${targetName}`)} · ${esc(tr('common.cooldown'))} ${item.cooldown_seconds}s</small><div class="card-menu"><button data-action="edit-automation" data-id="${esc(item.id)}">${esc(tr('actions.edit'))}</button><button class="danger" data-action="delete-automation" data-id="${esc(item.id)}">${esc(tr('actions.delete'))}</button></div></div></article>`;
|
||||
}).join('') : `<div class="empty"><strong>${esc(tr('automations.emptyTitle'))}</strong>${esc(tr('automations.emptyText'))}</div>`;
|
||||
}
|
||||
|
||||
function fillSelects() {
|
||||
const deviceOptions = app.devices.map(d => `<option value="${esc(d.id)}">${esc(d.name)}</option>`).join('');
|
||||
const zoneOptions = app.zones.map(z => `<option value="${esc(z.id)}">${esc(z.name)}</option>`).join('');
|
||||
const groupOptions = app.groups.map(g => `<option value="${esc(g.id)}">${esc(g.name)}</option>`).join('');
|
||||
['#zoneForm [name=device_id]', '#automationForm [name=trigger_device_id]', '#automationForm [name=action_device_id]'].forEach(selector => {
|
||||
const select = $(selector); if (!select) return;
|
||||
const current = select.value; select.innerHTML = deviceOptions; if ([...select.options].some(o => o.value === current)) select.value = current;
|
||||
});
|
||||
const automationGroupSelect = $('#automationForm [name=action_group_id]');
|
||||
if (automationGroupSelect) { const currentGroup = automationGroupSelect.value; automationGroupSelect.innerHTML = groupOptions; if ([...automationGroupSelect.options].some(o => o.value === currentGroup)) automationGroupSelect.value = currentGroup; }
|
||||
const zoneSelect = $('#scheduleForm [name=zone_id]');
|
||||
if (zoneSelect) { const currentZone = zoneSelect.value; zoneSelect.innerHTML = zoneOptions; if ([...zoneSelect.options].some(o => o.value === currentZone)) zoneSelect.value = currentZone; }
|
||||
const presetZone = $('#schedulePresetZone');
|
||||
@@ -804,7 +964,7 @@ function formatDuration(seconds) {
|
||||
return `${days ? `${days}d ` : ''}${hours}h ${minutes}m`;
|
||||
}
|
||||
|
||||
const VIEW_ROUTES = {dashboard:'/dashboard', devices:'/devices', zones:'/zones', schedules:'/schedules', automations:'/automations', simulation:'/simulation', night:'/night-mode', homeassistant:'/home-assistant', settings:'/settings', logs:'/events'};
|
||||
const VIEW_ROUTES = {dashboard:'/dashboard', devices:'/devices', zones:'/zones', groups:'/groups', schedules:'/schedules', automations:'/automations', simulation:'/simulation', night:'/night-mode', homeassistant:'/home-assistant', settings:'/settings', logs:'/events'};
|
||||
const HISTORY_TABS = ['overview','zones','devices','sensors','custom'];
|
||||
|
||||
function currentHistoryPath() {
|
||||
@@ -830,7 +990,7 @@ function updateBrowserUrl(path, replace=false) {
|
||||
function showView(name, {push=true, scroll=true}={}) {
|
||||
app.currentView = name;
|
||||
$$('.view').forEach(view => view.classList.toggle('active', view.dataset.view === name));
|
||||
$$('.bottom-nav button').forEach(button => button.classList.toggle('active', button.dataset.nav === name || (button.dataset.nav === 'more' && ['schedules','automations','simulation','night','homeassistant','settings','logs'].includes(name))));
|
||||
$$('.bottom-nav button').forEach(button => button.classList.toggle('active', button.dataset.nav === name || (button.dataset.nav === 'more' && ['groups','schedules','automations','simulation','night','homeassistant','settings','logs'].includes(name))));
|
||||
if (push) updateBrowserUrl(name === 'history' ? currentHistoryPath() : (VIEW_ROUTES[name] || '/dashboard'));
|
||||
if (scroll) window.scrollTo({top: 0, behavior: 'smooth'});
|
||||
if (name === 'history') { renderHistoryNavigation(); loadHistory(); }
|
||||
@@ -849,6 +1009,12 @@ function applyRouteFromLocation() {
|
||||
const parts = routePath.split('/').filter(Boolean);
|
||||
const first = parts[0] || 'dashboard';
|
||||
const params = new URLSearchParams(location.search);
|
||||
app.standaloneSimulation = first === 'simulation' && params.get('standalone') === '1';
|
||||
document.body.classList.toggle('simulation-standalone', app.standaloneSimulation);
|
||||
if (first === 'simulation') {
|
||||
app.simulationScope = ['units','groups'].includes(params.get('scope')) ? params.get('scope') : 'units';
|
||||
app.simulationTarget = params.get('target') || 'all';
|
||||
}
|
||||
if (first === 'history') {
|
||||
app.historyTab = HISTORY_TABS.includes(parts[1]) ? parts[1] : 'overview';
|
||||
app.historyZone = params.get('zone') || 'all';
|
||||
@@ -946,6 +1112,26 @@ function updateSchedulePresetField() {
|
||||
form.setpoint.required = form.preset.value === 'custom';
|
||||
}
|
||||
|
||||
function updateAutomationTargetFields() {
|
||||
const form = $('#automationForm'); if (!form) return;
|
||||
const groupTarget = form.action_target_kind.value === 'group';
|
||||
$('#automationDeviceTarget').hidden = groupTarget;
|
||||
$('#automationGroupTarget').hidden = !groupTarget;
|
||||
$('#automationGroupPreset').hidden = !groupTarget;
|
||||
$('#automationTargetTemperature').hidden = groupTarget;
|
||||
$('#automationGroupHint').hidden = !groupTarget;
|
||||
form.action_device_id.required = !groupTarget;
|
||||
form.action_group_id.required = groupTarget;
|
||||
form.action_target_temperature.disabled = groupTarget;
|
||||
if (groupTarget) form.action_target_temperature.value = '';
|
||||
[...form.action_mode.options].forEach(option => {
|
||||
if (!['dry','fan'].includes(option.value)) return;
|
||||
option.disabled = groupTarget;
|
||||
option.hidden = groupTarget;
|
||||
});
|
||||
if (groupTarget && ['dry','fan'].includes(form.action_mode.value)) form.action_mode.value = '';
|
||||
}
|
||||
|
||||
function populateZone(id) {
|
||||
const item = app.zones.find(v => v.id === id); if (!item) return;
|
||||
const form = $('#zoneForm');
|
||||
@@ -980,10 +1166,15 @@ function populateAutomation(id) {
|
||||
const item = app.automations.find(v => v.id === id); if (!item) return;
|
||||
const form = $('#automationForm'); form.reset(); fillSelects();
|
||||
['id','name','trigger_kind','trigger_device_id','threshold','at_time','action_device_id','cooldown_seconds'].forEach(key => { if (form.elements[key] && item[key] != null) form.elements[key].value = item[key]; });
|
||||
const groupTarget = !!item.action_group_id;
|
||||
form.action_target_kind.value = groupTarget ? 'group' : 'device';
|
||||
if (groupTarget) form.action_group_id.value = item.action_group_id;
|
||||
form.action_power.value = item.action.power == null ? '' : String(item.action.power);
|
||||
form.action_mode.value = item.action.mode || '';
|
||||
form.action_preset.value = item.action_preset || '';
|
||||
form.action_target_temperature.value = item.action.target_temperature ?? '';
|
||||
form.enabled.checked = item.enabled;
|
||||
updateAutomationTargetFields();
|
||||
openDialog('automationDialog');
|
||||
}
|
||||
|
||||
@@ -1359,13 +1550,16 @@ function connectWebSocket() {
|
||||
try {
|
||||
const message = JSON.parse(event.data);
|
||||
if (message.event === 'bootstrap') {
|
||||
const data=message.data; app.devices=data.devices||[]; app.zones=data.zones||[]; app.schedules=data.schedules||[]; app.automations=data.automations||[]; app.settings=data.settings||app.settings; app.sensorAliases={...(app.settings?.home_assistant?.sensor_aliases||{})}; app.system=data.system||app.system; app.outdoorTemperature=Number.isFinite(Number(data.outdoor_temperature))?Number(data.outdoor_temperature):app.outdoorTemperature; renderAll(); scheduleControlPlanLoad(); if(app.settings?.debug?.overlay_enabled) loadDebugBacklog(); return;
|
||||
const data=message.data; app.devices=data.devices||[]; app.zones=data.zones||[]; app.groups=data.groups||[]; app.schedules=data.schedules||[]; app.automations=data.automations||[]; app.settings=data.settings||app.settings; app.sensorAliases={...(app.settings?.home_assistant?.sensor_aliases||{})}; app.system=data.system||app.system; app.outdoorTemperature=Number.isFinite(Number(data.outdoor_temperature))?Number(data.outdoor_temperature):app.outdoorTemperature; renderAll(); scheduleControlPlanLoad(); if(app.settings?.debug?.overlay_enabled) loadDebugBacklog(); return;
|
||||
}
|
||||
const data = message.data || {};
|
||||
if (['device.updated','device.created'].includes(message.event)) { updateDevice(data); renderSummary(); renderDevices(); scheduleControlPlanLoad(); }
|
||||
if (['device.updated','device.created'].includes(message.event)) { updateDevice(data); renderSummary(); renderDevices(); renderGroups(); scheduleControlPlanLoad(); }
|
||||
else if (message.event === 'device.deleted') { app.devices=app.devices.filter(v=>v.id!==data.id); renderAll(); scheduleControlPlanLoad(); }
|
||||
else if (message.event === 'devices.discovered') { (data.devices||[]).forEach(updateDevice); renderAll(); scheduleControlPlanLoad(); }
|
||||
else if (message.event === 'zone.updated') { const i=app.zones.findIndex(v=>v.id===data.id); if(i>=0) app.zones[i]=data; else app.zones.push(data); renderSummary(); renderHouseClimate(); renderZones(); scheduleControlPlanLoad(); }
|
||||
else if (message.event === 'zone.updated') { const i=app.zones.findIndex(v=>v.id===data.id); if(i>=0) app.zones[i]=data; else app.zones.push(data); renderSummary(); renderHouseClimate(); renderZones(); renderGroups(); scheduleControlPlanLoad(); }
|
||||
else if (message.event === 'zone.deleted') { app.zones=app.zones.filter(v=>v.id!==data.id); renderAll(); scheduleControlPlanLoad(); }
|
||||
else if (['group.updated','group.created'].includes(message.event)) { const i=app.groups.findIndex(v=>v.id===data.id); if(i>=0) app.groups[i]=data; else app.groups.push(data); renderGroups(); fillSelects(); scheduleControlPlanLoad(); }
|
||||
else if (message.event === 'group.deleted') { app.groups=app.groups.filter(v=>v.id!==data.id); renderGroups(); fillSelects(); scheduleControlPlanLoad(); }
|
||||
else if (message.event === 'settings.updated') { app.settings=data; app.sensorAliases={...(app.settings?.home_assistant?.sensor_aliases||{})}; renderSettings(); renderNightSettings(); renderHomeAssistantSettings(); renderHouseClimate(); renderDebugOverlay(); if(app.settings?.debug?.overlay_enabled) loadDebugBacklog(); scheduleControlPlanLoad(); }
|
||||
else if (message.event === 'debug.settings') { app.settings = app.settings || {}; app.settings.debug = data; renderSettings(); renderDebugOverlay(); if(data.overlay_enabled) loadDebugBacklog(); }
|
||||
else if (message.event === 'outdoor.updated') { app.outdoorTemperature=Number.isFinite(Number(data.temperature))?Number(data.temperature):null; renderHouseClimate(); scheduleControlPlanLoad(); }
|
||||
@@ -1383,7 +1577,7 @@ document.addEventListener('click', async event => {
|
||||
if (button.dataset.nav === 'more') openDialog('moreDialog'); else showView(button.dataset.nav);
|
||||
return;
|
||||
}
|
||||
if (button.dataset.go) { $('#moreDialog').close(); showView(button.dataset.go); return; }
|
||||
if (button.dataset.go) { const more=$('#moreDialog'); if(more?.open) more.close(); showView(button.dataset.go); return; }
|
||||
if (button.dataset.open) {
|
||||
const form = document.getElementById(button.dataset.open.replace('Dialog','Form'));
|
||||
if (form) form.reset();
|
||||
@@ -1391,6 +1585,11 @@ document.addEventListener('click', async event => {
|
||||
if (form) { form.dataset.editingZoneId = ''; if (form.elements.id) form.elements.id.value = ''; }
|
||||
updateZoneSensorFields();
|
||||
}
|
||||
if (button.dataset.open === 'groupDialog') {
|
||||
if (form?.elements.id) form.elements.id.value = '';
|
||||
renderGroupZoneChoices([]);
|
||||
}
|
||||
if (button.dataset.open === 'automationDialog') updateAutomationTargetFields();
|
||||
if (button.dataset.open === 'scheduleDialog') updateSchedulePresetField();
|
||||
openDialog(button.dataset.open);
|
||||
return;
|
||||
@@ -1448,11 +1647,16 @@ document.addEventListener('click', async event => {
|
||||
}
|
||||
catch(error){ toast(error.message,true); } return;
|
||||
}
|
||||
if (action === 'group-power') return sendGroupControl(button.dataset.id,{power:button.dataset.value==='true'});
|
||||
if (action === 'group-mode') return sendGroupControl(button.dataset.id,{mode:button.dataset.value});
|
||||
if (action === 'group-preset') return sendGroupControl(button.dataset.id,{preset:button.dataset.value});
|
||||
if (action === 'zone-temperature') { const zone=app.zones.find(v=>v.id===button.dataset.id); if(zone) { const base=Number(zone.manual_setpoint ?? zone.effective_setpoint ?? zone.setpoint); queueZoneTemperature(zone, base+Number(button.dataset.delta)); } return; }
|
||||
if (action === 'zone-mode') return sendZoneControl(button.dataset.id,{mode:button.dataset.value});
|
||||
if (action === 'zone-preset') return sendZoneControl(button.dataset.id,{preset:button.dataset.value});
|
||||
if (action === 'zone-enabled') return sendZoneControl(button.dataset.id,{enabled:button.dataset.value==='true'});
|
||||
if (action === 'debug-clear') { app.debugLines = []; renderDebugOverlay(); return; }
|
||||
if (action === 'edit-group') return populateGroup(button.dataset.id);
|
||||
if (action === 'delete-group') return deleteEntity('groups', button.dataset.id, 'groups.group');
|
||||
if (action === 'edit-zone') return populateZone(button.dataset.id);
|
||||
if (action === 'delete-zone') return deleteEntity('zones', button.dataset.id, 'label.zone');
|
||||
if (action === 'edit-schedule') return populateSchedule(button.dataset.id);
|
||||
@@ -1486,6 +1690,12 @@ $('#logLevelFilter')?.addEventListener('change', loadLogs); $('#logCategoryFilte
|
||||
$('#settingsForm [name=notifications_provider]')?.addEventListener('change', updateNotificationFields);
|
||||
$('#zoneForm [name=sensor_source]').addEventListener('change', updateZoneSensorFields);
|
||||
$('#scheduleForm [name=preset]').addEventListener('change', updateSchedulePresetField);
|
||||
$('#automationForm [name=action_target_kind]')?.addEventListener('change', updateAutomationTargetFields);
|
||||
$('#simulationScope')?.addEventListener('change', event => { app.simulationScope=event.target.value; app.simulationTarget='all'; renderSimulationPage(); updateSimulationUrl(); });
|
||||
$('#simulationTarget')?.addEventListener('change', event => { app.simulationTarget=event.target.value || 'all'; renderSimulationPage(); updateSimulationUrl(); });
|
||||
$('#simulationOpenTab')?.addEventListener('click', () => { const params=new URLSearchParams({standalone:'1'}); if(app.simulationScope!=='units')params.set('scope',app.simulationScope); if(app.simulationTarget!=='all')params.set('target',app.simulationTarget); window.open(`${withBase('/simulation')}?${params.toString()}`, '_blank', 'noopener'); });
|
||||
$('#simulationFullscreen')?.addEventListener('click', async () => { try { if(document.fullscreenElement) await document.exitFullscreen(); else await document.documentElement.requestFullscreen(); } catch(error) { toast(error.message, true); } });
|
||||
document.addEventListener('fullscreenchange', () => { const button=$('#simulationFullscreen'); if(button) button.textContent=tr(document.fullscreenElement?'simulation.exitFullscreen':'simulation.fullscreen'); });
|
||||
|
||||
$('#testNotifications')?.addEventListener('click', async () => { const f=$('#settingsForm'); const saved=app.settings?.notifications||{}; const body={enabled:true,mode:f.notifications_mode.value,provider:f.notifications_provider.value,pushover_app_token:f.pushover_app_token.value.trim(),pushover_user_key:f.pushover_user_key.value.trim(),slack_webhook_url:f.slack_webhook_url.value.trim(),discord_webhook_url:f.discord_webhook_url.value.trim(),cooldown_seconds:Number(f.notification_cooldown_seconds.value||300),communication_failure_threshold:Number(f.notification_failure_threshold.value||3),target_timeout_minutes:Number(f.notification_target_timeout.value||60)}; try{await api('/api/integrations/notifications/test',{method:'POST',body});toast('Test notification sent');}catch(e){toast(e.message,true);} });
|
||||
|
||||
@@ -1527,7 +1737,7 @@ $('#copyZoneSettings')?.addEventListener('click',()=>{
|
||||
toast(tr('zones.copiedFrom',{name:source.name}));
|
||||
});
|
||||
|
||||
$('#applyAutomationPreset')?.addEventListener('click',()=>{ const f=$('#automationForm'), p=$('#automationPreset')?.value; if(!p)return; const first=app.devices[0]?.id||''; const presets={hot:{name:'High temperature cooling',trigger_kind:'temperature_above',threshold:'28',action_power:'true',action_mode:'cool',action_target_temperature:'23',cooldown_seconds:'900'},cold:{name:'Low temperature heating',trigger_kind:'temperature_below',threshold:'17',action_power:'true',action_mode:'heat',action_target_temperature:'21',cooldown_seconds:'900'},morning:{name:'Morning comfort',trigger_kind:'time',at_time:'07:00',action_power:'true',action_mode:'auto',action_target_temperature:'22',cooldown_seconds:'3600'},nightoff:{name:'Night power off',trigger_kind:'time',at_time:'23:30',action_power:'false',action_mode:'',action_target_temperature:'',cooldown_seconds:'3600'}}; const x=presets[p]; Object.entries(x).forEach(([k,v])=>{if(f[k])f[k].value=v}); if(!f.trigger_device_id.value)f.trigger_device_id.value=first; if(!f.action_device_id.value)f.action_device_id.value=first; });
|
||||
$('#applyAutomationPreset')?.addEventListener('click',()=>{ const f=$('#automationForm'), p=$('#automationPreset')?.value; if(!p)return; const first=app.devices[0]?.id||''; const presets={hot:{name:'High temperature cooling',trigger_kind:'temperature_above',threshold:'28',action_power:'true',action_mode:'cool',action_target_temperature:'23',cooldown_seconds:'900'},cold:{name:'Low temperature heating',trigger_kind:'temperature_below',threshold:'17',action_power:'true',action_mode:'heat',action_target_temperature:'21',cooldown_seconds:'900'},morning:{name:'Morning comfort',trigger_kind:'time',at_time:'07:00',action_power:'true',action_mode:'auto',action_target_temperature:'22',cooldown_seconds:'3600'},nightoff:{name:'Night power off',trigger_kind:'time',at_time:'23:30',action_power:'false',action_mode:'',action_target_temperature:'',cooldown_seconds:'3600'}}; const x=presets[p]; f.action_target_kind.value='device'; updateAutomationTargetFields(); Object.entries(x).forEach(([k,v])=>{if(f[k])f[k].value=v}); if(!f.trigger_device_id.value)f.trigger_device_id.value=first; if(!f.action_device_id.value)f.action_device_id.value=first; });
|
||||
|
||||
$('#tokenForm').addEventListener('submit', event => {
|
||||
event.preventDefault(); app.token = new FormData(event.currentTarget).get('token').trim();
|
||||
@@ -1586,6 +1796,20 @@ $('#zoneForm').addEventListener('submit', async event => {
|
||||
catch(error){toast(error.message,true);}
|
||||
});
|
||||
|
||||
$('#groupForm')?.addEventListener('submit', async event => {
|
||||
event.preventDefault();
|
||||
const form=event.currentTarget, raw=Object.fromEntries(new FormData(form));
|
||||
const id=raw.id || '';
|
||||
const zone_ids=$$('input[name=zone_ids]:checked', form).map(input => input.value);
|
||||
if (!zone_ids.length) return toast(tr('groups.chooseMember'), true);
|
||||
const existing=id ? app.groups.find(group => group.id===id) : null;
|
||||
const body={name:raw.name.trim(),zone_ids,power_enabled:existing?.power_enabled !== false};
|
||||
try {
|
||||
await api(id?`/api/groups/${encodeURIComponent(id)}`:'/api/groups',{method:id?'PUT':'POST',body});
|
||||
form.closest('dialog').close(); form.reset(); await loadBootstrap(); toast(tr('common.saved'));
|
||||
} catch(error){toast(error.message,true);}
|
||||
});
|
||||
|
||||
$('#scheduleForm').addEventListener('submit', async event => {
|
||||
event.preventDefault(); const form=event.currentTarget, raw=Object.fromEntries(new FormData(form));
|
||||
const id=raw.id, weekdays=$$('[name=weekday]:checked',form).map(v=>Number(v.value));
|
||||
@@ -1596,8 +1820,9 @@ $('#scheduleForm').addEventListener('submit', async event => {
|
||||
|
||||
$('#automationForm').addEventListener('submit', async event => {
|
||||
event.preventDefault(); const form=event.currentTarget, raw=Object.fromEntries(new FormData(form)); const id=raw.id;
|
||||
const action={}; if(raw.action_power!=='') action.power=raw.action_power==='true'; if(raw.action_mode) action.mode=raw.action_mode; if(raw.action_target_temperature!=='') action.target_temperature=parseDecimal(raw.action_target_temperature);
|
||||
const body={name:raw.name,enabled:form.enabled.checked,trigger_kind:raw.trigger_kind,trigger_device_id:raw.trigger_device_id||null,threshold:raw.threshold===''?null:parseDecimal(raw.threshold),at_time:raw.at_time||null,action_device_id:raw.action_device_id,action,cooldown_seconds:Number(raw.cooldown_seconds)};
|
||||
const groupTarget=raw.action_target_kind==='group';
|
||||
const action={}; if(raw.action_power!=='') action.power=raw.action_power==='true'; if(raw.action_mode) action.mode=raw.action_mode; if(!groupTarget && raw.action_target_temperature!=='') action.target_temperature=parseDecimal(raw.action_target_temperature);
|
||||
const body={name:raw.name,enabled:form.enabled.checked,trigger_kind:raw.trigger_kind,trigger_device_id:raw.trigger_device_id||null,threshold:raw.threshold===''?null:parseDecimal(raw.threshold),at_time:raw.at_time||null,action_device_id:groupTarget?'':raw.action_device_id,action_group_id:groupTarget?(raw.action_group_id||null):null,action_preset:groupTarget?(raw.action_preset||null):null,action,cooldown_seconds:Number(raw.cooldown_seconds)};
|
||||
try { await api(id?`/api/automations/${encodeURIComponent(id)}`:'/api/automations',{method:id?'PUT':'POST',body}); form.closest('dialog').close(); form.reset(); await loadBootstrap(); toast(tr('common.saved')); }
|
||||
catch(error){toast(error.message,true);}
|
||||
});
|
||||
|
||||
+56
-13
@@ -37,17 +37,37 @@
|
||||
<div class="hero-temp" id="heroTemperature">--<small>°C</small></div>
|
||||
</div>
|
||||
<div class="metrics" id="metrics"></div>
|
||||
<div class="panel house-power-panel">
|
||||
<div><span class="eyebrow" data-i18n="house.powerAllEyebrow">Global power</span><h3 data-i18n="house.powerAllTitle">All air conditioners</h3><p data-i18n="house.powerAllHint">Master power for all enabled units. Whole-house mode or profile selection automatically switches it back on.</p></div>
|
||||
<div class="house-power-actions"><button id="housePowerOn" type="button" class="secondary" data-action="house-power" data-value="true" data-i18n="house.powerOnAll">Turn all on</button><button id="housePowerOff" type="button" class="secondary" data-action="house-power" data-value="false" data-i18n="house.powerOffAll">Turn all off</button></div>
|
||||
<div class="panel dashboard-global-panel">
|
||||
<div class="dashboard-global-power">
|
||||
<div><span class="eyebrow" data-i18n="house.powerAllEyebrow">Global power</span><h3 data-i18n="house.powerAllTitle">All air conditioners</h3><p data-i18n="house.powerAllHint">Master power for all enabled units. Whole-house mode or profile selection automatically switches it back on.</p></div>
|
||||
<div class="house-power-actions"><button id="housePowerOn" type="button" class="secondary" data-action="house-power" data-value="true" data-i18n="house.powerOnAll">Turn all on</button><button id="housePowerOff" type="button" class="secondary" data-action="house-power" data-value="false" data-i18n="house.powerOffAll">Turn all off</button></div>
|
||||
</div>
|
||||
<div class="dashboard-global-divider"></div>
|
||||
<div class="house-climate dashboard-house-climate" id="houseClimate"></div>
|
||||
</div>
|
||||
|
||||
<section class="dashboard-primary-section">
|
||||
<div class="dashboard-section-head">
|
||||
<div><span class="eyebrow" data-i18n="groups.eyebrow">Group control</span><h2 data-i18n="groups.dashboardTitle">Control selected air conditioners</h2><p data-i18n="groups.dashboardHint">Each group can use its own cooling/heating mode and Auto/Comfort/Sleep/Away profile.</p></div>
|
||||
<div class="dashboard-section-actions"><span class="badge" id="dashboardGroupCount">0</span><button type="button" class="secondary" data-go="groups" data-i18n="groups.manage">Manage groups</button></div>
|
||||
</div>
|
||||
<div class="group-quick-grid dashboard-group-strip" id="dashboardGroups"></div>
|
||||
</section>
|
||||
|
||||
<div class="dashboard-secondary-stack">
|
||||
<details class="panel dashboard-disclosure">
|
||||
<summary><div><span class="eyebrow" data-i18n="plan.eyebrow">Control plan</span><h3 data-i18n="plan.title">What happens next</h3></div><span class="dashboard-disclosure-chevron" aria-hidden="true">⌄</span></summary>
|
||||
<div class="dashboard-disclosure-body"><div class="automation-plan-grid" id="controlPlan"></div></div>
|
||||
</details>
|
||||
<details class="panel dashboard-disclosure">
|
||||
<summary><div><span class="eyebrow" data-i18n="nav.zones">Zones</span><h3 data-i18n="dashboard.quickThermostats">Quick thermostats</h3></div><div class="dashboard-summary-side"><span class="badge" id="dashboardZoneCount">0</span><span class="dashboard-disclosure-chevron" aria-hidden="true">⌄</span></div></summary>
|
||||
<div class="dashboard-disclosure-body"><div class="list-grid dashboard-zones" id="dashboardZones"></div></div>
|
||||
</details>
|
||||
<details class="panel dashboard-disclosure">
|
||||
<summary><div><span class="eyebrow" data-i18n="nav.devices">Devices</span><h3 data-i18n="dashboard.quickControl">Direct device control</h3></div><div class="dashboard-summary-side"><span class="badge" id="dashboardDeviceCount">0</span><span class="dashboard-disclosure-chevron" aria-hidden="true">⌄</span></div></summary>
|
||||
<div class="dashboard-disclosure-body"><div class="device-grid" id="dashboardDevices"></div></div>
|
||||
</details>
|
||||
</div>
|
||||
<div class="panel house-climate" id="houseClimate"></div>
|
||||
<div class="section-heading"><div><span class="eyebrow" data-i18n="plan.eyebrow">Control plan</span><h2 data-i18n="plan.title">What happens next</h2></div></div>
|
||||
<div class="automation-plan-grid" id="controlPlan"></div>
|
||||
<div class="section-heading"><div><span class="eyebrow" data-i18n="nav.zones">Zones</span><h2 data-i18n="dashboard.quickThermostats">Quick thermostats</h2></div></div>
|
||||
<div class="list-grid dashboard-zones" id="dashboardZones"></div>
|
||||
<div class="section-heading"><div><span class="eyebrow" data-i18n="nav.devices">Devices</span><h2 data-i18n="dashboard.quickControl">Direct device control</h2></div></div>
|
||||
<div class="device-grid" id="dashboardDevices"></div>
|
||||
</section>
|
||||
|
||||
<section class="view" data-view="devices">
|
||||
@@ -64,6 +84,12 @@
|
||||
<div class="list-grid" id="zoneList"></div>
|
||||
</section>
|
||||
|
||||
<section class="view" data-view="groups">
|
||||
<div class="section-heading"><div><span class="eyebrow" data-i18n="groups.eyebrow">Group control</span><h1 data-i18n="nav.groups">Groups</h1></div><button class="secondary" data-open="groupDialog" data-i18n="groups.new">New group</button></div>
|
||||
<p class="lead" data-i18n="groups.description">Combine zones such as upstairs or downstairs and control only that set. A group can run Sleep + Cooling while another uses Comfort + Heating.</p>
|
||||
<div class="list-grid" id="groupList"></div>
|
||||
</section>
|
||||
|
||||
<section class="view" data-view="schedules">
|
||||
<div class="section-heading"><div><span class="eyebrow" data-i18n="schedules.calendar">Calendar</span><h1 data-i18n="nav.schedules">Schedules</h1></div><button class="secondary" data-open="scheduleDialog" data-i18n="actions.add">Add</button></div>
|
||||
<div class="panel schedule-template-panel">
|
||||
@@ -87,8 +113,9 @@
|
||||
</section>
|
||||
|
||||
<section class="view" data-view="simulation">
|
||||
<div class="section-heading"><div><span class="eyebrow" data-i18n="simulation.eyebrow">Simulator</span><h1 data-i18n="simulation.title">Automation simulator</h1></div><button class="secondary" id="simulationRefresh" data-i18n="actions.refresh">Refresh</button></div>
|
||||
<div class="section-heading simulation-page-head"><div><span class="eyebrow" data-i18n="simulation.eyebrow">Simulator</span><h1 data-i18n="simulation.title">Automation simulator</h1></div><div class="simulation-head-actions"><button class="secondary" id="simulationOpenTab" data-i18n="simulation.openTab">Open only simulator in new tab</button><button class="secondary" id="simulationFullscreen" data-i18n="simulation.fullscreen">Full screen</button><button class="secondary" id="simulationRefresh" data-i18n="actions.refresh">Refresh</button></div></div>
|
||||
<p class="lead" data-i18n="simulation.description">Live preview of the thermostat and automation logic. The flow board shows how sensor readings, schedules, night mode and thermostat decisions become commands sent to each GREE unit.</p>
|
||||
<div class="panel simulation-scope-toolbar"><label><span data-i18n="simulation.viewBy">View by</span><select id="simulationScope"><option value="units" data-i18n="simulation.unitsView">Individual units</option><option value="groups" data-i18n="simulation.groupsView">Groups</option></select></label><label><span data-i18n="simulation.show">Show</span><select id="simulationTarget"></select></label></div>
|
||||
<div class="simulation-summary-grid" id="simulationSummary"></div>
|
||||
<div class="panel simulation-flow-shell">
|
||||
<div class="simulation-flow-head"><div><span class="eyebrow" data-i18n="simulation.flowEyebrow">Live flow</span><h2 data-i18n="simulation.flowTitle">Automation flow board</h2></div><div class="flow-legend"><span><i class="flow-legend-dot input"></i><b data-i18n="simulation.legendInput">Input</b></span><span><i class="flow-legend-dot logic"></i><b data-i18n="simulation.legendLogic">Logic</b></span><span><i class="flow-legend-dot action"></i><b data-i18n="simulation.legendAction">Action</b></span></div></div>
|
||||
@@ -287,6 +314,7 @@
|
||||
<dialog id="moreDialog" class="sheet">
|
||||
<div class="dialog-head"><h2 data-i18n="nav.more">More</h2><button data-close>×</button></div>
|
||||
<div class="menu-list">
|
||||
<button data-go="groups"><span data-i18n="nav.groups">Groups</span><span>›</span></button>
|
||||
<button data-go="schedules"><span data-i18n="nav.schedules">Schedules</span><span>›</span></button>
|
||||
<button data-go="automations"><span data-i18n="nav.automations">Automations</span><span>›</span></button>
|
||||
<button data-go="simulation"><span data-i18n="nav.simulation">Simulator</span><span>›</span></button>
|
||||
@@ -367,6 +395,17 @@
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<dialog id="groupDialog">
|
||||
<form method="dialog" id="groupForm" class="dialog-form">
|
||||
<input type="hidden" name="id">
|
||||
<div class="dialog-head"><h2 data-i18n="groups.group">Group</h2><button type="button" data-close>×</button></div>
|
||||
<label><span data-i18n="common.name">Name</span><input name="name" required data-i18n-placeholder="groups.namePlaceholder" placeholder="Upstairs"></label>
|
||||
<fieldset><legend data-i18n="groups.members">Group members</legend><div class="group-zone-choices" id="groupZoneChoices"></div></fieldset>
|
||||
<p class="field-note" data-i18n="groups.membersHint">Groups are built from thermostat zones so Comfort/Sleep profiles and schedules keep working.</p>
|
||||
<div class="form-actions"><button type="button" class="secondary" data-close data-i18n="actions.cancel">Cancel</button><button class="primary" type="submit" data-i18n="actions.save">Save</button></div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<dialog id="scheduleDialog">
|
||||
<form method="dialog" id="scheduleForm" class="dialog-form">
|
||||
<input type="hidden" name="id">
|
||||
@@ -392,9 +431,13 @@
|
||||
<label><span data-i18n="automations.measurementDevice">Measurement device</span><select name="trigger_device_id"></select></label>
|
||||
<div class="two"><label><span data-i18n="automations.thresholdC">Threshold °C</span><input type="text" inputmode="decimal" step="0.1" name="threshold" value="27"></label><label><span data-i18n="automations.time">Time</span><input type="time" name="at_time" value="08:00"></label></div>
|
||||
<hr><h3 data-i18n="automations.action">Action</h3>
|
||||
<label><span data-i18n="common.device">Device</span><select name="action_device_id" required></select></label>
|
||||
<label><span data-i18n="automations.targetType">Target type</span><select name="action_target_kind"><option value="device" data-i18n="common.device">Device</option><option value="group" data-i18n="groups.group">Group</option></select></label>
|
||||
<label id="automationDeviceTarget"><span data-i18n="common.device">Device</span><select name="action_device_id"></select></label>
|
||||
<label id="automationGroupTarget" hidden><span data-i18n="groups.group">Group</span><select name="action_group_id"></select></label>
|
||||
<div class="two"><label><span data-i18n="common.power">Power</span><select name="action_power"><option value="" data-i18n="actions.noChange">No change</option><option value="true" data-i18n="actions.turnOn">Turn on</option><option value="false" data-i18n="actions.turnOff">Turn off</option></select></label><label><span data-i18n="common.mode">Mode</span><select name="action_mode"><option value="" data-i18n="actions.noChange">No change</option><option value="cool" data-i18n="mode.cool">Cooling</option><option value="heat" data-i18n="mode.heat">Heating</option><option value="dry" data-i18n="mode.dry">Dry</option><option value="fan" data-i18n="mode.fan">Fan</option><option value="auto" data-i18n="mode.auto">Auto</option></select></label></div>
|
||||
<div class="two"><label><span data-i18n="common.targetC">Target °C</span><input type="text" inputmode="decimal" step="0.1" min="8" max="30" name="action_target_temperature"></label><label><span data-i18n="common.cooldownSeconds">Cooldown (s)</span><input type="number" min="30" name="cooldown_seconds" value="300"></label></div>
|
||||
<label id="automationGroupPreset" hidden><span data-i18n="groups.profile">Group profile</span><select name="action_preset"><option value="" data-i18n="actions.noChange">No change</option><option value="auto" data-i18n="preset.auto">Auto</option><option value="comfort" data-i18n="preset.comfort">Comfort</option><option value="sleep" data-i18n="preset.sleep">Sleep</option><option value="away" data-i18n="preset.away">Away</option></select></label>
|
||||
<div class="two"><label id="automationTargetTemperature"><span data-i18n="common.targetC">Target °C</span><input type="text" inputmode="decimal" step="0.1" min="8" max="30" name="action_target_temperature"></label><label><span data-i18n="common.cooldownSeconds">Cooldown (s)</span><input type="number" min="30" name="cooldown_seconds" value="300"></label></div>
|
||||
<p class="field-note" id="automationGroupHint" hidden data-i18n="automations.groupHint">For a group, Auto mode means follow the whole-house heating/cooling mode. Group presets are applied to all member zones.</p>
|
||||
<label class="check"><input type="checkbox" name="enabled" checked> <span data-i18n="common.enabled">Enabled</span></label>
|
||||
<div class="form-actions"><button type="button" class="secondary" data-close data-i18n="actions.cancel">Cancel</button><button class="primary" type="submit" data-i18n="actions.save">Save</button></div>
|
||||
</form>
|
||||
|
||||
+130
@@ -632,3 +632,133 @@ html[data-theme='light'] .simulation-metrics > div, html[data-theme='light'] .si
|
||||
.standalone-settings-form { max-width:1100px; }
|
||||
|
||||
.copy-settings-row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:.6rem;align-items:end}.notification-provider{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:.75rem}.log-row{display:grid;grid-template-columns:5.5rem 7rem minmax(9rem,auto) 1fr;gap:.75rem;align-items:center;border-left:4px solid transparent}.log-row.error{border-left-color:var(--danger,#d9544d)}.log-row.warn{border-left-color:#c98a21}.log-row.info{border-left-color:var(--accent,#4d9c7d)}.log-category{font-size:.72rem;text-transform:uppercase;letter-spacing:.08em;opacity:.7}.category-device .log-category:before{content:'▣ '}.category-zone .log-category:before{content:'◎ '}.category-automation .log-category:before{content:'⚙ '}.category-integration .log-category:before{content:'↔ '}.category-settings .log-category:before{content:'☷ '}.category-system .log-category:before{content:'• '}@media(max-width:720px){.copy-settings-row,.notification-provider{grid-template-columns:1fr}.log-row{grid-template-columns:4.5rem 1fr}.log-row .kind,.log-row .message{grid-column:2}.logs-toolbar{align-items:stretch}}
|
||||
|
||||
/* Group controls and standalone simulator */
|
||||
[hidden] { display:none !important; }
|
||||
.dashboard-group-panel { display:grid; gap:14px; margin-bottom:18px; }
|
||||
.dashboard-group-head { display:flex; align-items:flex-start; justify-content:space-between; gap:16px; }
|
||||
.dashboard-group-head h3 { margin:3px 0 5px; }
|
||||
.dashboard-group-head p { margin:0; color:var(--muted); line-height:1.45; }
|
||||
.group-quick-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(min(100%,330px),1fr)); gap:12px; }
|
||||
.group-card { display:grid; gap:13px; }
|
||||
.group-card.group-off { opacity:.78; }
|
||||
.group-state-summary { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:8px; }
|
||||
.group-state-summary > div { padding:10px; border:1px solid var(--line); border-radius:12px; background:var(--surface-muted); }
|
||||
.group-state-summary small,.group-control-block > small { display:block; margin-bottom:5px; color:var(--muted); font-size:10px; text-transform:uppercase; letter-spacing:.06em; }
|
||||
.group-state-summary strong { display:block; overflow:hidden; font-size:13px; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.group-control-block { display:grid; gap:6px; }
|
||||
.group-button-row { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:7px; }
|
||||
.group-button-row button,.group-mode-row button,.group-preset-row button { min-height:36px; }
|
||||
.group-zone-choices { display:grid; gap:7px; max-height:300px; overflow:auto; }
|
||||
.group-zone-choice { display:grid; grid-template-columns:auto 1fr; align-items:center; gap:10px; padding:10px; border:1px solid var(--line); border-radius:12px; background:var(--surface-muted); }
|
||||
.group-zone-choice input { width:18px; min-height:18px; accent-color:var(--accent); }
|
||||
.group-zone-choice strong,.group-zone-choice small { display:block; }
|
||||
.group-zone-choice small { margin-top:3px; color:var(--muted); font-size:10px; }
|
||||
.simulation-page-head { align-items:flex-start; }
|
||||
.simulation-head-actions { display:flex; flex-wrap:wrap; justify-content:flex-end; gap:8px; }
|
||||
.simulation-scope-toolbar { display:flex; align-items:end; gap:12px; margin-bottom:16px; padding:14px; }
|
||||
.simulation-scope-toolbar label { min-width:min(260px,100%); }
|
||||
body.simulation-standalone .topbar,body.simulation-standalone .bottom-nav { display:none !important; }
|
||||
body.simulation-standalone main { width:100%; max-width:none; margin:0; padding:18px; }
|
||||
body.simulation-standalone [data-view="simulation"] { min-height:calc(100vh - 36px); }
|
||||
:fullscreen main { width:100%; max-width:none; padding:18px; }
|
||||
:fullscreen .topbar,:fullscreen .bottom-nav { display:none !important; }
|
||||
@media (max-width:720px) {
|
||||
.dashboard-group-head,.simulation-page-head { align-items:stretch; flex-direction:column; }
|
||||
.simulation-head-actions { justify-content:stretch; }
|
||||
.simulation-head-actions button { flex:1 1 180px; }
|
||||
.simulation-scope-toolbar { align-items:stretch; flex-direction:column; }
|
||||
.group-state-summary { grid-template-columns:1fr; }
|
||||
}
|
||||
|
||||
/* Dashboard hierarchy: compact global controls -> wrapped groups -> on-demand details. */
|
||||
[data-view="dashboard"] .hero { min-height:164px; padding:24px 28px; border-radius:24px; }
|
||||
[data-view="dashboard"] .hero h1 { margin-bottom:6px; }
|
||||
[data-view="dashboard"] .hero-temp { font-size:clamp(44px,8vw,68px); }
|
||||
[data-view="dashboard"] .metrics { gap:10px; margin:10px 0 20px; }
|
||||
[data-view="dashboard"] .metric { padding:14px 16px; border-radius:15px; }
|
||||
[data-view="dashboard"] .metric span { margin-bottom:5px; }
|
||||
[data-view="dashboard"] .metric strong { font-size:21px; }
|
||||
.dashboard-global-panel { display:grid; gap:14px; padding:16px 18px; margin:0 0 20px; }
|
||||
.dashboard-global-power { display:flex; align-items:center; justify-content:space-between; gap:16px; }
|
||||
.dashboard-global-power h3 { margin:2px 0 3px; font-size:18px; }
|
||||
.dashboard-global-power p { margin:0; max-width:680px; color:var(--muted); font-size:11px; line-height:1.4; }
|
||||
.dashboard-global-divider { height:1px; background:var(--line); }
|
||||
.dashboard-house-climate { margin:0; gap:12px; }
|
||||
.dashboard-house-climate .house-climate-head h3 { font-size:18px; }
|
||||
.dashboard-house-climate .house-climate-head p { font-size:11px; }
|
||||
.dashboard-house-climate .outside-pill { padding:8px 11px; }
|
||||
.dashboard-house-climate .house-mode-row button { min-height:38px; padding:8px 10px; }
|
||||
.dashboard-house-climate .house-preset-row { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:6px; overflow:visible; padding:0; }
|
||||
.dashboard-house-climate .house-preset-row button { min-width:0; min-height:36px; padding:7px 8px; font-size:11px; }
|
||||
.dashboard-primary-section { margin-bottom:18px; }
|
||||
.dashboard-section-head { display:flex; align-items:flex-end; justify-content:space-between; gap:16px; margin:0 0 10px; }
|
||||
.dashboard-section-head h2 { margin:3px 0 4px; font-size:21px; }
|
||||
.dashboard-section-head p { margin:0; max-width:720px; color:var(--muted); font-size:11px; line-height:1.4; }
|
||||
.dashboard-section-actions { display:flex; align-items:center; flex:0 0 auto; gap:7px; }
|
||||
.dashboard-section-actions .badge { min-width:28px; justify-content:center; }
|
||||
.dashboard-section-actions button { min-height:36px; padding:8px 11px; }
|
||||
/* Groups wrap into rows. Horizontal scrolling is intentionally disabled on the dashboard. */
|
||||
.dashboard-group-strip { display:grid; grid-template-columns:repeat(auto-fit,minmax(min(100%,280px),1fr)); gap:10px; overflow:visible; padding:0; }
|
||||
.dashboard-group-strip .group-card { min-width:0; padding:13px; gap:8px; }
|
||||
.dashboard-group-strip .list-card-head { gap:9px; }
|
||||
.dashboard-group-strip .list-card-head h3 { font-size:16px; }
|
||||
.dashboard-group-strip .list-card-head p { display:-webkit-box; overflow:hidden; max-width:none; font-size:11px; line-height:1.35; -webkit-box-orient:vertical; -webkit-line-clamp:2; white-space:normal; }
|
||||
.dashboard-group-strip .badge { padding:4px 7px; font-size:10px; }
|
||||
.dashboard-group-strip .group-control-block { gap:4px; }
|
||||
.dashboard-group-strip .group-control-block > small { margin-bottom:1px; font-size:9px; }
|
||||
.dashboard-group-strip .group-button-row { gap:5px; }
|
||||
.dashboard-group-strip .group-mode-row,
|
||||
.dashboard-group-strip .group-preset-row { display:grid; gap:5px; overflow:visible; padding:0; }
|
||||
.dashboard-group-strip .group-mode-row { grid-template-columns:repeat(3,minmax(0,1fr)); }
|
||||
.dashboard-group-strip .group-preset-row { grid-template-columns:repeat(4,minmax(0,1fr)); }
|
||||
.dashboard-group-strip .group-button-row button,
|
||||
.dashboard-group-strip .group-mode-row button,
|
||||
.dashboard-group-strip .group-preset-row button { min-width:0; min-height:32px; padding:6px 5px; font-size:10px; line-height:1.15; }
|
||||
.dashboard-secondary-stack { display:grid; gap:8px; margin-top:4px; }
|
||||
.dashboard-disclosure { overflow:hidden; padding:0; }
|
||||
.dashboard-disclosure > summary { display:flex; align-items:center; justify-content:space-between; gap:14px; padding:13px 15px; cursor:pointer; list-style:none; user-select:none; }
|
||||
.dashboard-disclosure > summary::-webkit-details-marker { display:none; }
|
||||
.dashboard-disclosure > summary h3 { margin:2px 0 0; font-size:16px; }
|
||||
.dashboard-disclosure > summary .eyebrow { font-size:9px; }
|
||||
.dashboard-summary-side { display:flex; align-items:center; gap:10px; }
|
||||
.dashboard-summary-side .badge { min-width:28px; justify-content:center; }
|
||||
.dashboard-disclosure-chevron { display:inline-block; color:var(--muted); font-size:18px; line-height:1; transition:transform .18s ease; }
|
||||
.dashboard-disclosure[open] > summary { border-bottom:1px solid var(--line); }
|
||||
.dashboard-disclosure[open] .dashboard-disclosure-chevron { transform:rotate(180deg); }
|
||||
.dashboard-disclosure-body { padding:12px; }
|
||||
.dashboard-disclosure-body .automation-plan-grid,
|
||||
.dashboard-disclosure-body .dashboard-zones { margin-bottom:0; }
|
||||
.dashboard-disclosure-body .device-grid,
|
||||
.dashboard-disclosure-body .list-grid,
|
||||
.dashboard-disclosure-body .automation-plan-grid { gap:10px; grid-template-columns:repeat(auto-fit,minmax(min(100%,300px),1fr)); }
|
||||
#dashboardZones .list-card, #dashboardDevices .device-card { padding:15px; }
|
||||
#dashboardZones .preset-row,
|
||||
#dashboardZones .mode-row,
|
||||
#dashboardDevices .mode-row,
|
||||
#dashboardDevices .fan-row { display:grid; grid-template-columns:repeat(auto-fit,minmax(70px,1fr)); gap:5px; overflow:visible; padding:2px 0 6px; }
|
||||
#dashboardZones .preset-row button,
|
||||
#dashboardZones .mode-row button,
|
||||
#dashboardDevices .mode-row button,
|
||||
#dashboardDevices .fan-row button { min-width:0; min-height:32px; padding:6px 7px; font-size:10px; }
|
||||
#dashboardDevices .temperature-control { gap:16px; padding:18px 0 14px; }
|
||||
#dashboardDevices .target-temp { min-width:92px; font-size:40px; }
|
||||
#dashboardDevices .current-line { margin:-6px 0 13px; }
|
||||
#dashboardZones .thermostat-main { margin:12px 0 8px; }
|
||||
|
||||
@media (max-width: 700px) {
|
||||
[data-view="dashboard"] .hero { min-height:145px; padding:20px; }
|
||||
[data-view="dashboard"] .metrics { margin-bottom:16px; }
|
||||
[data-view="dashboard"] .metric { padding:12px; }
|
||||
.dashboard-global-panel { padding:14px; margin-bottom:18px; }
|
||||
.dashboard-global-power { align-items:stretch; flex-direction:column; }
|
||||
.dashboard-global-power .house-power-actions { display:grid; grid-template-columns:1fr 1fr; }
|
||||
.dashboard-house-climate .house-preset-row { grid-template-columns:repeat(2,minmax(0,1fr)); }
|
||||
.dashboard-section-head { align-items:stretch; flex-direction:column; gap:10px; }
|
||||
.dashboard-section-actions { justify-content:space-between; }
|
||||
.dashboard-section-actions button { flex:1; }
|
||||
.dashboard-group-strip { grid-template-columns:1fr; }
|
||||
.dashboard-group-strip .group-preset-row { grid-template-columns:repeat(2,minmax(0,1fr)); }
|
||||
.dashboard-disclosure > summary { padding:12px 13px; }
|
||||
.dashboard-disclosure-body { padding:10px; }
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const CACHE = 'gree-controller-v066';
|
||||
const CACHE = 'gree-controller-v068-dashboard-wrap';
|
||||
const SCOPE = new URL(self.registration.scope).pathname.replace(/\/$/, '');
|
||||
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')];
|
||||
|
||||
Reference in New Issue
Block a user