This commit is contained in:
Mateusz Gruszczyński
2026-08-24 14:05:43 +02:00
parent eb02f66056
commit 04fc91b9f4
30 changed files with 2257 additions and 175 deletions
+31 -53
View File
@@ -1,67 +1,45 @@
# GREE Controller v0.4.5 - build and validation report # GREE Controller v0.5.0 - build and validation report
## v0.4.5 compile fix
- Fixed Rust borrow-checker error `E0502` in `GreeProtocol::poll`.
- The bound device key is now cloned into an owned `String` before mutating the `Device` with `apply_status`.
- Subsequent full/core/`OutEnvTem` status requests borrow that owned key, so no immutable borrow of `device.key` survives across the mutable device update.
## Scope ## Scope
Version 0.4.4 fixes empty/under-populated history views and expands climate telemetry across GREE devices, zones and Home Assistant sensors. It also adds URL-addressable UI pages, History sub-pages, a saved/shareable custom chart composer, redesigned toasts and optimistic Quick Thermostat controls that no longer clear the active preset. Version 0.5.0 focuses on dashboard thermostat control, metric retention/archiving, quieter GREE commands, diagnostics, configuration backup and a machine-readable automation plan shared by the Web UI and Home Assistant.
## History and chart changes ## Implemented changes
- `readings` remains the independent history source for every GREE device, whether or not the device belongs to a zone. - Quick Thermostat cards on the dashboard can enable/disable a zone directly; the edit dialog is no longer required for this action.
- Added bucketed device-history queries for indoor temperature, available GREE `OutEnvTem`, target and power. - SQLite history housekeeping is tiered to chart resolution: full recent data, 10-minute buckets after 24 hours and 30-minute buckets after 7 days.
- Added `ha_readings` for configured HA room/outdoor sensors. - Optional long-term InfluxDB archive supports both InfluxDB 1.x (database/basic auth) and 2.x (org/bucket/token), from persisted settings or environment variables.
- Existing `zone_readings` remains the rich thermostat timeline with GREE temperature, HA room temperature, control temperature, target, actual device setpoint, outdoor temperature, power, mode, fan, demand, source and preset. - New samples are written to SQLite and InfluxDB in parallel when the archive is enabled.
- `/api/history?scope=overview` returns device, zone and HA streams together plus row counts. - Maintenance backfills compacted legacy SQLite samples older than the configured Influx handoff age. A local row is deleted only after the Influx batch succeeds; archive failures keep the SQLite copy.
- If a zone has no `zone_readings` yet, the API reconstructs a compatibility timeline from its existing device `readings` instead of returning an empty chart. - History requests crossing the handoff age merge old InfluxDB data with recent SQLite data and fall back to SQLite when the archive query fails.
- Older rich zone samples can be exposed as compatibility HA history when the dedicated HA table has not accumulated samples yet. - Device commands are reduced to fields that actually changed. Optional GREE buzzer suppression uses `Buzzer_ON_OFF`/`BuzzerCtrl`; incompatible devices automatically fall back to normal frames and are remembered for the running process.
- GREE polling now probes `OutEnvTem` separately after a core-property fallback so models that expose outdoor temperature can contribute that series without making the main poll fail. - Toast progress indicators have horizontal inset so they stay inside rounded toast corners.
- History UI is split into Overview, Zones, GREE devices, HA sensors and Custom chart sub-pages. - Settings can be exported/imported as JSON. Metrics, event logs and generated API-token records are preserved during import. Exported configuration contains configured secrets and must be protected.
- Overview includes common charts for all GREE indoor temperatures, available outdoor temperatures and all zone control temperatures. - Optional on-screen debug overlay is available on every Web UI route, can be changed through `/api/debug`, shows controller events plus live HTTP method/path/status timing and can include sanitized decrypted GREE request/response payloads.
- The custom chart composer can mix GREE device, zone and HA sensor series, save chart definitions in browser storage and serialize them into a shareable URL. - `/api/control-plan` exposes house state, current zone decisions, schedules, upcoming transitions and automation rules/time events.
- The dashboard renders the control plan as blocks with current targets/demand and upcoming actions.
## Browser navigation and feedback - Home Assistant integration now also exposes whole-house/per-zone plan sensors, writable per-zone target-temperature `number` entities and per-zone enable `switch` entities via the restricted integration API.
- Main application views use stable paths such as `/dashboard`, `/devices`, `/zones`, `/schedules`, `/automations`, `/settings` and `/events`.
- History uses `/history/overview`, `/history/zones`, `/history/devices`, `/history/sensors` and `/history/custom`.
- Browser Back/Forward and direct page refresh are handled by History API routing plus the server-side index fallback.
- Toasts are now stacked, theme-aware success/error cards with close controls and progress indicators.
## Quick Thermostat fix
- `+/-` updates the displayed target immediately and batches rapid taps with a short debounce.
- Stale HTTP responses cannot overwrite a newer tap.
- Added `manual_setpoint` separately from `manual_preset`.
- A temperature nudge no longer changes Auto/Comfort/Sleep/Away to `custom` or clears the active preset.
- Manual temperature correction still expires at the next schedule boundary.
## SQLite / compatibility
- Added `ha_readings` and indexes through idempotent `CREATE TABLE IF NOT EXISTS` schema initialization.
- All SQL remains centralized in `src/queries.rs`.
- Existing `readings`, devices, zones, schedules, automations and settings are preserved.
- No database reset is required.
## Validation performed in packaging environment ## Validation performed in packaging environment
- JavaScript syntax: `node --check web/app.js`. - `node --check web/app.js`.
- JSON validation for EN/PL language packs, PWA manifest and HA manifest. - Python syntax validation for the Home Assistant integration and helper scripts.
- Shell syntax validation for every `scripts/*.sh` file. - Shell syntax validation for all `scripts/*.sh` files.
- Python syntax validation for helper scripts. - JSON validation for EN/PL language packs, PWA manifest, Home Assistant manifest and HA translations.
- SQLite execution tests for the schema, device bucket queries, zone bucket queries, HA bucket queries and history counts. - EN/PL UI translation-key parity and literal UI translation-reference validation.
- EN/PL translation-key parity check. - SQLite schema execution plus tiered compaction tests.
- Verified project SQL statements remain centralized in `src/queries.rs`. - SQLite archive-selection query tests for device, zone and Home Assistant histories.
- Verified no Node/npm/Tailwind runtime or build dependency exists. - Configuration-clear behavior verified to preserve metric history, event logs and API tokens.
- ZIP integrity and SHA-256 manifest verification. - Version consistency checked for Cargo package, Cargo lock root package, README and Home Assistant manifest.
- Archive ZIP integrity and SHA-256 manifest are verified during final packaging.
A Rust toolchain is not installed in the packaging environment. The authoritative Rust compile/tests are therefore intentionally performed on the target LXC by `scripts/update.sh` before it replaces the running binary: ## Rust compiler note
The packaging environment does not contain `rustc`, `cargo` or `rustfmt`, so a Rust compile cannot be executed here. Regression tests were added for unchanged-command filtering and history compaction; the target LXC update/install scripts remain the authoritative compile gate and run:
```bash ```bash
cargo test --all-targets cargo test --all-targets
cargo build --release cargo build --release
``` ```
The update script must complete those commands before replacing the running binary.
Generated
+1 -1
View File
@@ -633,7 +633,7 @@ dependencies = [
[[package]] [[package]]
name = "gree-controller" name = "gree-controller"
version = "0.4.5" version = "0.5.0"
dependencies = [ dependencies = [
"aes", "aes",
"aes-gcm", "aes-gcm",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "gree-controller" name = "gree-controller"
version = "0.4.5" version = "0.5.0"
edition = "2021" edition = "2021"
authors = ["GREE Controller contributors"] authors = ["GREE Controller contributors"]
description = "Standalone local GREE HVAC controller with Web UI, SQLite and Home Assistant sensor support" description = "Standalone local GREE HVAC controller with Web UI, SQLite and Home Assistant sensor support"
+31 -26
View File
@@ -1,32 +1,36 @@
da945a594f47b2b0c326cc0275b10a23cb509f545a1841ba9c57f00086e553ff ./.env.example 4989a37261ebad2b981c62ee9b5be41cc298f982e101782f27151774293f7a9b ./.env.example
17780b6416a709efae772c37fb1151a35e51e6e8e6f428566d2f3eb308a4c58d ./.gitignore a4ec3874a2e3ab1bad28fb40bb620f7b01f64d01ad9b699306bf70ada31227db ./.gitignore
efddeff2e9cff44538b518bd76f32fbbccd56f5bc38190f21a9a8ec3e8ca66f7 ./BUILD_REPORT.md 2f6060e367a0289ee8029bc8d0486c2c28e54f990ae69816af7987ef5a5969ca ./BUILD_REPORT.md
9ec04c293b2dc7f61417d70cfe7b405aa119e9a8fbd9be7d05bc9886bc510f5a ./Cargo.toml 6273e6e09d41e084d7bf6da83a8eeee17c89da9fa05ad248898c14558dfe098a ./Cargo.lock
d1bf838b0634706fdd4a533dfed712a45eb5167f9f675fbdcab2781baf2a1611 ./Cargo.toml
19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE 19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE
7ef22d04c07942284f5e5c000f2626341de2032a1e3c95fbb3a9aac5e1b5dcde ./README.md a0d2b6a9efc28f3359ca2bbbc72c562d18a8fceca2b7d90994f60f95366f833f ./README.md
a4fa9bfee9735ed8ed95ea31456e0cce503d82502ae3f550108ffca51b0f0c3d ./build.rs a4fa9bfee9735ed8ed95ea31456e0cce503d82502ae3f550108ffca51b0f0c3d ./build.rs
177916e2d5476f643e2f8aae31d2ac8a67eee3b0f010bf7898c0d6d232685f74 ./docs/API.md 6577e178c03cdb5f9e5b55eba4c25e2733bd5ca48daa76cf7910cda776066517 ./docs/API.md
234dd200e380a13ecd3e61b4ea455f6f08d64ce89382077dee80684acadb9703 ./docs/HOME_ASSISTANT_MIGRATION.md 234dd200e380a13ecd3e61b4ea455f6f08d64ce89382077dee80684acadb9703 ./docs/HOME_ASSISTANT_MIGRATION.md
7a88d6e76fda21e5d34ab351e26bc10dc1f8f7b3055505aefad1df7c56d65ae4 ./docs/LOCALIZATION.md 7a88d6e76fda21e5d34ab351e26bc10dc1f8f7b3055505aefad1df7c56d65ae4 ./docs/LOCALIZATION.md
10a0722e1100fb4a05e3067daeb67dc47b0c0a096b43b1cbf2bf002967ce7d98 ./docs/LXC.md 10a0722e1100fb4a05e3067daeb67dc47b0c0a096b43b1cbf2bf002967ce7d98 ./docs/LXC.md
6309591814958f059f18ad7677f925a6f6e4a0eab4b780519fc7ed2067e98d56 ./docs/PROJECT_SPEC.md 6309591814958f059f18ad7677f925a6f6e4a0eab4b780519fc7ed2067e98d56 ./docs/PROJECT_SPEC.md
33214270b96ac4c64e3db41c11e54158792b4a87ee5668c651bad571766b591a ./home-assistant/README.md 3996549f159bb1bb07139cd5afa218d948326b192123a4e7a8ace15fe1e18be9 ./home-assistant/README.md
f8e8559fe10fe523ac5bc9aac25c6e26e862f679d502e8f3c39f38a0a8e40911 ./home-assistant/custom_components/gree_controller/__init__.py f8e8559fe10fe523ac5bc9aac25c6e26e862f679d502e8f3c39f38a0a8e40911 ./home-assistant/custom_components/gree_controller/__init__.py
e0cf725c9f84be51cdbd83a5ab12b2b8288f90b36623cb4671cefcbf04378504 ./home-assistant/custom_components/gree_controller/api.py b8e7c0dd722bfac1d1d8d5c2a4b5ff6b2d6fa89525b9ff5c121ee1d09623a7fd ./home-assistant/custom_components/gree_controller/api.py
fde31b8e020fd36be2d9d9b1e554254f5da8a1cc593240ebb8d78820154dd790 ./home-assistant/custom_components/gree_controller/climate.py fde31b8e020fd36be2d9d9b1e554254f5da8a1cc593240ebb8d78820154dd790 ./home-assistant/custom_components/gree_controller/climate.py
5e4aef2143e81bedb5a15dd4be5c71b64a3ab448ec6d33a20851edd098e5f529 ./home-assistant/custom_components/gree_controller/config_flow.py 5e4aef2143e81bedb5a15dd4be5c71b64a3ab448ec6d33a20851edd098e5f529 ./home-assistant/custom_components/gree_controller/config_flow.py
e1821b74859bc40773a6ee39e6ccc9650980b62af50d6426b46cdb3e3a90d200 ./home-assistant/custom_components/gree_controller/const.py 071f721b75af2ed68812c9be3a62b7ae63771e56e567a569f59ca976f8ff42f1 ./home-assistant/custom_components/gree_controller/const.py
2d27d7cb67c53e819b99a0302cd0bd339c27e3e2dfc85e07d30c31f505f740b5 ./home-assistant/custom_components/gree_controller/coordinator.py 8dd51322798cad28d4d19938fe459aa2a4d3b64b075532149f3c8b62049a6c8b ./home-assistant/custom_components/gree_controller/coordinator.py
5a96fe8f5c035c34f1339370270cd078056202d09e236dec75735be11de92a7d ./home-assistant/custom_components/gree_controller/entity_map.py 5a96fe8f5c035c34f1339370270cd078056202d09e236dec75735be11de92a7d ./home-assistant/custom_components/gree_controller/entity_map.py
429a2729c4b5dd4eb76e789108615484358762e801a82eba88c314be98beed4b ./home-assistant/custom_components/gree_controller/manifest.json 0e9288c70c0e3d3b9944ac79f6fe227fdef1d18ed844c04ede2bbe2e1bc16962 ./home-assistant/custom_components/gree_controller/manifest.json
d0206766d76f70ea1c75a618e41bf1e8e8c0429f42ee97f40daac1ea12f16658 ./home-assistant/custom_components/gree_controller/number.py
5c914c2faa18a9cb9eb442dc111c9416f89a630578f820beb021d5381aa1b2d0 ./home-assistant/custom_components/gree_controller/sensor.py
59632b92ed799234c632324b00e9a48e89ee06a5c49b8c27d29e495ee42e40e9 ./home-assistant/custom_components/gree_controller/switch.py
6bddb7b4620021ecd2099a86a77ef5c7f2c2dcd3d07d5db4e7b4c4ce6d3e8c03 ./home-assistant/custom_components/gree_controller/translations/en.json 6bddb7b4620021ecd2099a86a77ef5c7f2c2dcd3d07d5db4e7b4c4ce6d3e8c03 ./home-assistant/custom_components/gree_controller/translations/en.json
13f30e2dcdcedbd1b6c3f99c2335e0487108fd72c8e86922368b84f2fa2038ae ./home-assistant/custom_components/gree_controller/translations/pl.json 13f30e2dcdcedbd1b6c3f99c2335e0487108fd72c8e86922368b84f2fa2038ae ./home-assistant/custom_components/gree_controller/translations/pl.json
4513070521d3dda0efb0d974a86ba674494cfb2b66fe9e5cac5b1b0430dede97 ./home-assistant/generated/gree_controller_entities.example.json 4513070521d3dda0efb0d974a86ba674494cfb2b66fe9e5cac5b1b0430dede97 ./home-assistant/generated/gree_controller_entities.example.json
253a0bc912786e67ea7fc92a64e4a510ad973bec343a88ccfb1f28fca3e8cf01 ./lang/README.md 253a0bc912786e67ea7fc92a64e4a510ad973bec343a88ccfb1f28fca3e8cf01 ./lang/README.md
74fd82612a159dbbb8f0eca023f90407240c65e766b9642974ea4dc5a3d0ce47 ./lang/en.json 6e3489bedfea68bd0040686616cbfaab944182a1cebd0adf756b12469dcd925c ./lang/en.json
6a8edb6fb158865dfe04b502f848f1672444e0e3af573bcc351484a16538ac15 ./lang/pl.json 95039d760d81ec46208762e0cc34570a060e4f52a43fe58b5f95eafe826d4335 ./lang/pl.json
028e1f16e9fbaed57cadb88eff04e65b4bd67722c50b4d6b1fb525f5a2f39abf ./make_zip.py
bb89bac237e750e9b1bf73761d7df97a6b81853091615878c03f13d7b6399aa7 ./scripts/README.md bb89bac237e750e9b1bf73761d7df97a6b81853091615878c03f13d7b6399aa7 ./scripts/README.md
896caae04468f743d85d011638a0683253d8271c0db8365aea9bcdc646795cf1 ./scripts/__pycache__/generate_ha_migration.cpython-313.pyc
3640e34a1ed5a93be3e96610848b16b8e748d5ca95975ac7e95d38cbf32695a8 ./scripts/common.sh 3640e34a1ed5a93be3e96610848b16b8e748d5ca95975ac7e95d38cbf32695a8 ./scripts/common.sh
6403786610ee6d2f628193c25aee0dd058d62e904aa1a31d5f62fdaae0e94b4f ./scripts/configure-gree-network.sh 6403786610ee6d2f628193c25aee0dd058d62e904aa1a31d5f62fdaae0e94b4f ./scripts/configure-gree-network.sh
dbb92ddc27b8724faf709983e4feecd3f188052b2cba39daba96d4bc16914df5 ./scripts/dev.sh dbb92ddc27b8724faf709983e4feecd3f188052b2cba39daba96d4bc16914df5 ./scripts/dev.sh
@@ -37,23 +41,24 @@ e00d211e3885e30d7fed1e43b44e6fdad40a67019060156c0641816a93e3365f ./scripts/netw
81345b6a0b51736bdbc98fd23199b62e4c721b4e7437e02dab7ea79b97dff29a ./scripts/service.sh 81345b6a0b51736bdbc98fd23199b62e4c721b4e7437e02dab7ea79b97dff29a ./scripts/service.sh
b48fc84d79aab381226363ac8473f981bcba5e4911c4cc0011261182debf4250 ./scripts/smoke.sh b48fc84d79aab381226363ac8473f981bcba5e4911c4cc0011261182debf4250 ./scripts/smoke.sh
b50782b3742dfbf8a319c60571c968e93fdf8547db747c759edcffae68cb98bf ./scripts/update.sh b50782b3742dfbf8a319c60571c968e93fdf8547db747c759edcffae68cb98bf ./scripts/update.sh
b7a82ec69207464149e8777691da639ae12cb621cf0e41106d326a791d3e9b12 ./src/api.rs 48abf32b9eb26dc980044d138732129c6e35e79c225c2231782e8d69338061a0 ./src/api.rs
5138f9762540da497360667bd93e5e7274515a37baf3ca3c19c38b23a57c9e2b ./src/config.rs 4bbbe739219730d7369965277e689b5a557202e2674869705009ca4c3239ad38 ./src/config.rs
2e3effc5d6a716d0bbfcd64ee7bc9a44ba6751778b963ad63e02bbd56a96e24b ./src/db.rs 1ec49d30ff14fc341bb15177ff195db41fb927d67114d1c1cb8ca671c7ea73ec ./src/db.rs
ff93dba5c02d7e3bbd07b43d4b591b89a4d58305f05abb55544231636b121cd9 ./src/engine.rs 6359748b86a420334f5a090640773abb18ef4057f74342af32d5a250be5e0d4a ./src/engine.rs
ae3b496749a3fd723b243d9bea92e5d76249f52814c80359ad9bac53abacb074 ./src/error.rs ae3b496749a3fd723b243d9bea92e5d76249f52814c80359ad9bac53abacb074 ./src/error.rs
584a5dad5d52cafa0ab9d0883c501c7bc5d3126f515ace98a261a78a6d714c3a ./src/home_assistant.rs 584a5dad5d52cafa0ab9d0883c501c7bc5d3126f515ace98a261a78a6d714c3a ./src/home_assistant.rs
8f4fa47395451b2d9ee6270aae63dd8e7df9fa74182a3429b10aadac1029b090 ./src/main.rs 190b0a33431539676e5dd7796698077f16c179d42eae4501ca96a91bf797cbf8 ./src/influxdb.rs
6b3b70c23e0ffb6a22070be8050a10be34e105a44bc7a7b417f5ce73186a7956 ./src/models.rs 1936f06b4d9982024a676f97ce7d6ca225d5a8b13fd883d65ec100d8372b7bca ./src/main.rs
eae77ed4aed700c72a0c1e6d672ffb0dd0fd6f50d0e7f6e1f8b8ab41cbf5df42 ./src/models.rs
7fc31fbf8841a073a1544b8c7a6390f1a15b56087486ca0596a8418340fa232a ./src/protocol/crypto.rs 7fc31fbf8841a073a1544b8c7a6390f1a15b56087486ca0596a8418340fa232a ./src/protocol/crypto.rs
d49dd5a35dc1d59ab440a622517ccb63e93582658a276a9eaccf53f04060f35e ./src/protocol/gree.rs a34005e60b1048d1fe7ed1457f4cd28055b486dbcc5d9da2886fdef5cf3f8c78 ./src/protocol/gree.rs
a910bd9432a393740c0f6fab52bfcb551f0ea756718d66d290fd2610767cf07c ./src/protocol/mod.rs a910bd9432a393740c0f6fab52bfcb551f0ea756718d66d290fd2610767cf07c ./src/protocol/mod.rs
f8e5daeff84a773455ce7ef96bd217f420b46b938d3f695459ced89b8f7c8914 ./src/queries.rs b6061a3325228fc5f70adb819d1b443e560d36964f98b8edb7c5bd079241348e ./src/queries.rs
3bd56018114a7199082e9e9d973107b73cb8259693a80743f4022364cda62095 ./src/state.rs e3c02b232bd9d632ca1d3b6ad69a7cc61dc2e51954765d9e963d713d135f7dca ./src/state.rs
b92a6cb158b494fe145b43c7641e65f6fafff47201d7d76edbec2cfd8b94835c ./systemd/gree-controller.service b92a6cb158b494fe145b43c7641e65f6fafff47201d7d76edbec2cfd8b94835c ./systemd/gree-controller.service
2ab5573f1fa4a773fcf446c48861c51bf519ee482934a1eb7d2f5e039f0d4e3a ./web/app.js 21b0a8e6f9852d5156d60268691134ef6e395814fa3a345b0ad6181471afd8bc ./web/app.js
e98bdd7204349cce1ec6f57283509697af0bbc72280622a6c3efa6fed242db4f ./web/favicon.svg e98bdd7204349cce1ec6f57283509697af0bbc72280622a6c3efa6fed242db4f ./web/favicon.svg
f0e231f6c8a8223e0e00620c9cc4765c344b24e61e3aa3d26bf207692f2912fc ./web/index.html 7452a04bd4bcb7178f998c4f042976da6a532fcf14f0f90b43fccf4fbe3a4b43 ./web/index.html
c0e628423a82c5037b6d650aad5e8befe30945492c8cb0a5d94433db7b58c77b ./web/manifest.webmanifest c0e628423a82c5037b6d650aad5e8befe30945492c8cb0a5d94433db7b58c77b ./web/manifest.webmanifest
44d61dff54a348ed3e8a9606f57c52a1ddcc764f128531c14b015b4da4f024ab ./web/styles.css 081dd439c49eee1f325a58c286a7b77a89642df9706a182b0bfa927702b9a876 ./web/styles.css
98bf333d3cb273ff7569c8985b459985ce956cbd5274ca4d48a634b578edf23b ./web/sw.js 98bf333d3cb273ff7569c8985b459985ce956cbd5274ca4d48a634b578edf23b ./web/sw.js
+30 -7
View File
@@ -4,7 +4,7 @@
Standalone local GREE air-conditioner controller written in Rust. It runs on a regular Linux host or an LXC container and provides a mobile-first web interface without depending on the vendor cloud. Standalone local GREE air-conditioner controller written in Rust. It runs on a regular Linux host or an LXC container and provides a mobile-first web interface without depending on the vendor cloud.
Current version: **0.4.5**. Current version: **0.5.0**.
## Highlights ## Highlights
@@ -13,6 +13,7 @@ Current version: **0.4.5**.
- automatic ECB/GCM detection from discovery responses and bind fallback, - automatic ECB/GCM detection from discovery responses and bind fallback,
- power, HVAC mode, target temperature, fan, vertical/horizontal swing, quiet, turbo and display light, - power, HVAC mode, target temperature, fan, vertical/horizontal swing, quiet, turbo and display light,
- SQLite state/history/event storage, - SQLite state/history/event storage,
- automatic SQLite history compaction plus optional InfluxDB 1.x/2.x long-term archive,
- smart thermostat zones with global house Heat/Cool/Off mode and per-zone overrides, - 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, - 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**, - Comfort/Sleep/Away profiles, temporary overrides and one-tap **Sleep now**,
@@ -24,12 +25,14 @@ Current version: **0.4.5**.
- combined zone temperature using configurable GREE/external sensor weighting and discrepancy protection, - combined zone temperature using configurable GREE/external sensor weighting and discrepancy protection,
- REST API and WebSocket updates, - REST API and WebSocket updates,
- responsive PWA optimized for phones, - responsive PWA optimized for phones,
- dashboard automation-plan blocks showing current zone decisions and upcoming schedule changes,
- settings import/export and an optional live debug overlay with API logs and decrypted GREE frame payloads,
- JSON-based UI localization loaded from embedded `lang/*.json` language packs, - JSON-based UI localization loaded from embedded `lang/*.json` language packs,
- light, dark and system appearance modes stored in a browser cookie, - light, dark and system appearance modes stored in a browser cookie,
- optional Bearer-token authentication, - optional Bearer-token authentication,
- simulator mode for development without physical hardware, - simulator mode for development without physical hardware,
- Debian/Ubuntu LXC systemd installer, - Debian/Ubuntu LXC systemd installer,
- Home Assistant custom integration that proxies `climate` commands through this controller, - Home Assistant custom integration that proxies `climate` commands and exposes zone plan sensors, writable zone target-temperature numbers and zone enable switches,
- migration mapping generator for retaining existing HA entity IDs such as `climate.klima_salon`. - migration mapping generator for retaining existing HA entity IDs such as `climate.klima_salon`.
See [`BUILD_REPORT.md`](BUILD_REPORT.md) for package validation details and [`docs/LXC.md`](docs/LXC.md) for the LXC deployment/update workflow. See [`BUILD_REPORT.md`](BUILD_REPORT.md) for package validation details and [`docs/LXC.md`](docs/LXC.md) for the LXC deployment/update workflow.
@@ -39,7 +42,7 @@ See [`BUILD_REPORT.md`](BUILD_REPORT.md) for package validation details and [`do
On Debian, Ubuntu or an LXC container: On Debian, Ubuntu or an LXC container:
```bash ```bash
unzip gree-controller-v0.4.5.zip unzip gree-controller-v0.5.0.zip
cd gree-controller cd gree-controller
chmod +x scripts/*.sh chmod +x scripts/*.sh
./scripts/dev.sh ./scripts/dev.sh
@@ -85,7 +88,7 @@ Appearance selector:
- Light, - Light,
- Dark. - Dark.
The selected appearance is stored in the `gree_controller_theme` cookie. Version 0.4.4 keeps the classic GREE Controller interface: large rounded cards, circular thermostat controls, compact desktop navigation and mobile bottom navigation. It keeps the newer neutral dark/light palette and green accent (`#3ecf8e` in dark mode and `#24b47e` in light mode). The selected appearance is stored in the `gree_controller_theme` cookie. The current UI keeps the classic GREE Controller interface: large rounded cards, circular thermostat controls, compact desktop navigation and mobile bottom navigation. It keeps the newer neutral dark/light palette and green accent (`#3ecf8e` in dark mode and `#24b47e` in light mode).
### Static offline CSS ### Static offline CSS
@@ -150,13 +153,25 @@ Use `--skip-tests` with `install.sh` or `update.sh` only when you explicitly wan
| `GREE_CONTROLLER_ID` | `gree-controller` | Controller instance identifier used for logs/metadata; the GREE wire protocol uses the standard `cid=app` | | `GREE_CONTROLLER_ID` | `gree-controller` | Controller instance identifier used for logs/metadata; the GREE wire protocol uses the standard `cid=app` |
| `GREE_CONTROLLER_HOUSE_MODE` | `cool` | Initial seasonal house mode: `cool`, `heat` or `off` | | `GREE_CONTROLLER_HOUSE_MODE` | `cool` | Initial seasonal house mode: `cool`, `heat` or `off` |
| `GREE_CONTROLLER_OUTDOOR_ASSIST_ENABLED` | `true` | Initial outdoor-temperature assist state | | `GREE_CONTROLLER_OUTDOOR_ASSIST_ENABLED` | `true` | Initial outdoor-temperature assist state |
| `GREE_CONTROLLER_HISTORY_RETENTION_DAYS` | `30` | Local SQLite retention before pruning |
| `GREE_CONTROLLER_HISTORY_COMPACTION_ENABLED` | `true` | Compact old local samples to chart-oriented resolution |
| `GREE_CONTROLLER_SUPPRESS_DEVICE_BEEP` | `false` | Send only changed fields and request GREE buzzer suppression where supported |
| `GREE_CONTROLLER_INFLUX_ENABLED` | inferred from URL | Enable optional long-term InfluxDB archive |
| `GREE_CONTROLLER_INFLUX_VERSION` | `2` | `1` for InfluxDB 1.x or `2` for InfluxDB 2.x |
| `GREE_CONTROLLER_INFLUX_URL` / `INFLUXDB_URL` | empty | InfluxDB base URL |
| `GREE_CONTROLLER_INFLUX_THRESHOLD_DAYS` | `30` | Read history older than this age from InfluxDB |
| `GREE_CONTROLLER_INFLUX_DATABASE` | `gree_controller` | InfluxDB 1.x database |
| `GREE_CONTROLLER_INFLUX_USERNAME` / `GREE_CONTROLLER_INFLUX_PASSWORD` | empty | Optional InfluxDB 1.x credentials |
| `GREE_CONTROLLER_INFLUX_ORG` / `GREE_CONTROLLER_INFLUX_BUCKET` / `GREE_CONTROLLER_INFLUX_TOKEN` | empty | InfluxDB 2.x organization, bucket and token |
| `GREE_CONTROLLER_DEBUG_OVERLAY` | `false` | Show the live debug window on every web view |
| `GREE_CONTROLLER_DEBUG_GREE_FRAMES` | `false` | Stream decrypted GREE request/response payloads into debug |
| `HA_URL` | empty | Optional Home Assistant URL | | `HA_URL` | empty | Optional Home Assistant URL |
| `HA_TOKEN` | empty | Optional Home Assistant Long-Lived Access Token | | `HA_TOKEN` | empty | Optional Home Assistant Long-Lived Access Token |
| `HA_ENTITY_ID` | empty | Optional default HA room-temperature sensor | | `HA_ENTITY_ID` | empty | Optional default HA room-temperature sensor |
| `HA_OUTDOOR_ENTITY_ID` | empty | Optional HA outdoor-temperature sensor | | `HA_OUTDOOR_ENTITY_ID` | empty | Optional HA outdoor-temperature sensor |
| `HA_ALLOW_INVALID_TLS` | `false` | Opt in to invalid/self-signed HA HTTPS certificates | | `HA_ALLOW_INVALID_TLS` | `false` | Opt in to invalid/self-signed HA HTTPS certificates |
Settings changed from the web panel are stored in SQLite. `GREE_CONTROLLER_APP_TOKEN` is loaded at process startup. When `GREE_CONTROLLER_DISCOVERY_BROADCAST` is explicitly present in the service environment, it overrides the persisted discovery target. Use `auto` together with `GREE_CONTROLLER_GREE_INTERFACE` to derive the subnet broadcast automatically. Settings changed from the web panel are stored in SQLite. Explicit history/debug/Influx environment variables override their persisted values at process startup. Supplying an Influx URL enables the archive unless `GREE_CONTROLLER_INFLUX_ENABLED=false` is explicitly set. `GREE_CONTROLLER_APP_TOKEN` is loaded at process startup.
## Climate history ## Climate history
@@ -170,7 +185,7 @@ The History area has linkable sub-pages:
- `/history/sensors` — configured Home Assistant room/outdoor temperature sensors, - `/history/sensors` — configured Home Assistant room/outdoor temperature sensors,
- `/history/custom` — compose arbitrary series, save chart definitions in the browser and copy a URL that recreates the chart. - `/history/custom` — compose arbitrary series, save chart definitions in the browser and copy a URL that recreates the chart.
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). Longer ranges are bucketed in SQLite to keep the UI responsive. 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`, `/schedules`, `/automations`, `/settings`, `/events`) so refresh, browser Back/Forward, bookmarks and direct links work normally.
@@ -215,7 +230,7 @@ There are two independent HA directions:
1. **HA as an optional sensor source**: the Rust controller can read a selected HA sensor and use it for a zone. 1. **HA as an optional sensor source**: the Rust controller can read a selected HA sensor and use it for a zone.
2. **HA as a client of GREE Controller**: the included custom integration creates `climate` entities whose commands are sent to this Rust application. 2. **HA as a client of GREE Controller**: the included custom integration creates `climate` entities whose commands are sent to this Rust application.
For the HA client connection, open **Settings -> Home Assistant integration access** in GREE Controller and press **Create new token**. The secret is shown once. Paste that token into the Home Assistant `GREE Controller` integration together with the controller URL. Managed HA tokens are stored as SHA-256 hashes and are scoped to the dedicated HA device/command API; they cannot change controller settings or manage other tokens. For the HA client connection, open **Settings -> Home Assistant integration access** in GREE Controller and press **Create new token**. The secret is shown once. Paste that token into the Home Assistant `GREE Controller` integration together with the controller URL. Managed HA tokens are stored as SHA-256 hashes and are scoped to the dedicated HA device/control-plan/zone-control API; they cannot change controller settings or manage other tokens.
The outbound HA sensor client also supports local HTTPS endpoints such as `https://10.87.65.2`. If the server uses a self-signed, expired or hostname-mismatched certificate, enable **Settings -> Allow invalid/self-signed HTTPS certificate**. This disables certificate/hostname validation only for the controller's outbound Home Assistant sensor client and should only be used on a trusted LAN. The outbound HA sensor client also supports local HTTPS endpoints such as `https://10.87.65.2`. If the server uses a self-signed, expired or hostname-mismatched certificate, enable **Settings -> Allow invalid/self-signed HTTPS certificate**. This disables certificate/hostname validation only for the controller's outbound Home Assistant sensor client and should only be used on a trusted LAN.
@@ -279,15 +294,23 @@ POST /api/schedules
GET /api/automations GET /api/automations
POST /api/automations POST /api/automations
GET /api/readings GET /api/readings
GET /api/history
GET /api/control-plan
GET /api/events GET /api/events
GET /api/settings GET /api/settings
PUT /api/settings PUT /api/settings
GET /api/settings/export
POST /api/settings/import
GET /api/debug
PUT /api/debug
GET /api/access-tokens GET /api/access-tokens
POST /api/access-tokens POST /api/access-tokens
DELETE /api/access-tokens/{id} DELETE /api/access-tokens/{id}
POST /api/integrations/home-assistant/test POST /api/integrations/home-assistant/test
GET /api/integrations/home-assistant/devices GET /api/integrations/home-assistant/devices
POST /api/integrations/home-assistant/devices/{id}/command POST /api/integrations/home-assistant/devices/{id}/command
GET /api/integrations/home-assistant/control-plan
POST /api/integrations/home-assistant/zones/{id}/control
WS /ws WS /ws
``` ```
+43 -19
View File
@@ -25,9 +25,11 @@ The Home Assistant custom integration uses a restricted API surface:
```text ```text
GET /api/integrations/home-assistant/devices GET /api/integrations/home-assistant/devices
POST /api/integrations/home-assistant/devices/{id}/command POST /api/integrations/home-assistant/devices/{id}/command
GET /api/integrations/home-assistant/control-plan
POST /api/integrations/home-assistant/zones/{id}/control
``` ```
These two endpoints always require `Authorization: Bearer <generated-token>` (or the administrator `GREE_CONTROLLER_APP_TOKEN`). A generated HA token cannot update settings, run discovery, delete devices, manage tokens, or use the controller WebSocket. These endpoints always require `Authorization: Bearer <generated-token>` (or the administrator `GREE_CONTROLLER_APP_TOKEN`). A generated HA token cannot update settings, run discovery, delete devices, manage tokens, or use the controller WebSocket.
## Discovery ## Discovery
@@ -159,9 +161,40 @@ For trusted local Home Assistant servers with self-signed/invalid HTTPS certific
This setting is opt-in and applies only to the controller's outbound Home Assistant sensor client. This setting is opt-in and applies only to the controller's outbound Home Assistant sensor client.
## Current control plan
`GET /api/control-plan` returns a machine-readable view of what the controller is doing now and what is expected next. It includes the house mode/strategy, each zone's active mode/preset/current and target temperatures, current schedule, manual override expiry, upcoming schedule transitions, enabled automation rules and predictable time-triggered automation events.
```bash
curl "$BASE/api/control-plan" -H "$AUTH"
```
The restricted Home Assistant equivalent is `GET /api/integrations/home-assistant/control-plan`. HA may change a zone target/preset/mode/enabled state through:
```bash
curl -X POST "$BASE/api/integrations/home-assistant/zones/ZONE_ID/control" \
-H 'Authorization: Bearer HA_TOKEN' -H 'Content-Type: application/json' \
-d '{"setpoint":22.5}'
```
## 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.
`POST /api/settings/import` accepts that JSON format and replaces application configuration while preserving metric history, events and generated API tokens.
Debug overlay state can be read or changed independently of the full settings document:
```text
GET /api/debug
PUT /api/debug
```
Example body: `{"overlay_enabled":true,"gree_frames":true}`. With the overlay enabled, live `api.request` WebSocket events contain only HTTP method, path, status and duration. When GREE frame debug is enabled, sanitized `gree.frame` events are also emitted; the bound encryption key is not exposed.
## WebSocket ## WebSocket
Connect to `ws://HOST:8787/ws?token=TOKEN`. The first message uses event type `bootstrap`; later events include `device.updated`, `zone.updated`, `settings.updated` and `log.created`. Connect to `ws://HOST:8787/ws?token=TOKEN`. The first message uses event type `bootstrap`; later events include `device.updated`, `zone.updated`, `settings.updated`, `debug.settings`, `api.request`, `gree.frame` and `log.created`.
## Localization assets ## Localization assets
@@ -176,30 +209,21 @@ curl "$BASE/lang/en.json"
### `GET /api/history` ### `GET /api/history`
The history API exposes three independent data families and a combined overview. Common parameters are `hours=6|24|168|720` and `limit` up to 20,000 rows. Downsampling is performed in SQLite: 30-second buckets up to 6 hours, 2-minute buckets up to 24 hours, 10-minute buckets up to 7 days, and 30-minute buckets for longer ranges. The history API exposes three independent data families and a combined overview. `hours` may cover up to 10 years and `limit` is capped at 20,000 rows. Query buckets become progressively wider: 30 s (<=6 h), 2 min (<=24 h), 10 min (<=7 d), 30 min (<=30 d), 2 h (<=90 d), 6 h (<=1 y), then 24 h.
```text ```text
GET /api/history?scope=overview&hours=24 GET /api/history?scope=overview&hours=24
GET /api/history?scope=devices&device_id=DEVICE_ID&hours=24 GET /api/history?scope=devices&device_id=DEVICE_ID&hours=2160
GET /api/history?scope=zones&zone_id=ZONE_ID&hours=24 GET /api/history?scope=zones&zone_id=ZONE_ID&hours=8760
GET /api/history?scope=sensors&entity_id=sensor.room_temperature&hours=24 GET /api/history?scope=sensors&entity_id=sensor.room_temperature&hours=24
``` ```
`scope=overview` returns: Local SQLite is the hot store. When compaction is enabled, full-resolution samples are kept for 24 h, then one sample per 10-minute bucket through day 7 and one per 30-minute bucket afterwards. `history_retention_days` controls final local pruning.
```json When InfluxDB is enabled, samples are written in parallel. Maintenance also backfills compacted legacy SQLite samples older than `influxdb.history_threshold_days`; those local rows are deleted only after the archive batch is accepted. Requests that cross the threshold read the older portion from InfluxDB 1.x or 2.x and merge it with recent SQLite data. If the Influx query fails, the endpoint falls back to the available SQLite history and returns `storage_warning`.
{
"scope": "overview",
"bucket_seconds": 120,
"zones": [],
"devices": [],
"sensors": [],
"counts": {"devices": 0, "zones": 0, "ha": 0}
}
```
Device rows come from the existing `readings` table and contain `indoor_temperature`, optional GREE `outdoor_temperature`, `target_temperature`, `power`, and `source`. This makes old device history immediately available after an upgrade. `scope=overview` returns `zones`, `devices`, `sensors`, row `counts`, `bucket_seconds`, plus `storage`/optional `storage_warning`. Device rows contain indoor/outdoor/target/power; zone rows contain GREE/external/control/target/device-setpoint/outdoor/power/mode/fan/demand/source/preset; HA rows contain entity, optional zone, kind and temperature.
Zone rows contain `gree_temperature`, `external_temperature` (HA room sensor), `control_temperature`, `target_temperature`, `device_setpoint`, `outdoor_temperature`, `power`, `mode`, `fan_speed`, `demand`, `control_source`, and `active_preset`. If a zone has no rich samples yet, the API falls back to that zone's existing GREE device readings instead of returning an empty timeline. ## InfluxDB long-term storage
Sensor rows contain `entity_id`, optional `zone_id`, `kind` (`room` or `outdoor`), `timestamp`, and `temperature`. New HA samples start being collected after the upgrade; where older rich zone samples contain the same HA values, the API can expose them as compatibility history. Runtime settings support either InfluxDB 1.x (`version=1`, URL, database and optional username/password) or InfluxDB 2.x (`version=2`, URL, org, bucket and token). The same values can be supplied with `GREE_CONTROLLER_INFLUX_*` environment variables; see `.env.example`. An explicitly configured environment value overrides the persisted setting at startup.
+10 -1
View File
@@ -10,6 +10,15 @@ It creates HA `climate` entities but sends every command to the standalone Rust
The climate proxy supports power/turn on/off, HVAC modes, target temperature, fan mode, vertical swing and horizontal swing. The climate proxy supports power/turn on/off, HVAC modes, target temperature, fan mode, vertical swing and horizontal swing.
Version 0.5.0 additionally exposes the controller's automation plan:
- `sensor` for the whole-house plan (house mode, upcoming events and active rules),
- one `sensor` per thermostat zone with current demand, active preset/schedule and upcoming transitions,
- one writable `number` per zone for the controller target temperature (8-30°C, 0.5°C step),
- one `switch` per zone for enabling/disabling the thermostat directly from Home Assistant.
The plan entities can be placed on a normal Home Assistant dashboard; their `next_events` attributes contain the same schedule timeline shown as graphical blocks in the controller Web UI. Changing a zone target number calls the restricted controller zone API and creates the same temporary override as the Web UI. The zone switch uses the same API to enable or disable that thermostat.
## Install the custom integration ## Install the custom integration
Copy the directory into your HA configuration: Copy the directory into your HA configuration:
@@ -25,7 +34,7 @@ Restart Home Assistant, then open **Settings -> Devices & services -> Add integr
- controller URL, for example `http://192.168.1.20:8787`, - controller URL, for example `http://192.168.1.20:8787`,
- the generated controller access token. - the generated controller access token.
The integration token is required even when the controller Web UI itself is left open on a trusted LAN. It is restricted to reading controller devices and sending climate commands. The integration token is required even when the controller Web UI itself is left open on a trusted LAN. It is restricted to reading controller devices/control plans and sending climate, zone target or zone enable/disable commands.
## Preserve an existing entity ID ## Preserve an existing entity ID
@@ -74,3 +74,21 @@ class GreeControllerClient:
f"/api/integrations/home-assistant/devices/{device_id}/command", f"/api/integrations/home-assistant/devices/{device_id}/command",
json=payload, json=payload,
) )
async def control_plan(self) -> dict[str, Any]:
"""Return the current whole-house and zone automation plan."""
data = await self._request("GET", "/api/integrations/home-assistant/control-plan")
if not isinstance(data, dict):
raise GreeControllerApiError("Controller returned an invalid control plan payload")
return data
async def zone_control(self, zone_id: str, payload: dict[str, Any]) -> dict[str, Any]:
"""Change a zone thermostat override through the controller."""
data = await self._request(
"POST",
f"/api/integrations/home-assistant/zones/{zone_id}/control",
json=payload,
)
if not isinstance(data, dict):
raise GreeControllerApiError("Controller returned an invalid zone payload")
return data
@@ -3,7 +3,7 @@
from homeassistant.const import Platform from homeassistant.const import Platform
DOMAIN = "gree_controller" DOMAIN = "gree_controller"
PLATFORMS = [Platform.CLIMATE] PLATFORMS = [Platform.CLIMATE, Platform.SENSOR, Platform.NUMBER, Platform.SWITCH]
CONF_URL = "url" CONF_URL = "url"
CONF_TOKEN = "token" CONF_TOKEN = "token"
@@ -2,8 +2,10 @@
from __future__ import annotations from __future__ import annotations
import asyncio
from datetime import timedelta from datetime import timedelta
import logging import logging
from typing import Any
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
@@ -27,10 +29,15 @@ class GreeControllerCoordinator(DataUpdateCoordinator[dict[str, dict]]):
update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL_SECONDS), update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL_SECONDS),
) )
self.client = client self.client = client
self.plan: dict[str, Any] = {}
async def _async_update_data(self) -> dict[str, dict]: async def _async_update_data(self) -> dict[str, dict]:
try: try:
devices = await self.client.devices() devices, plan = await asyncio.gather(
self.client.devices(),
self.client.control_plan(),
)
except GreeControllerApiError as err: except GreeControllerApiError as err:
raise UpdateFailed(str(err)) from err raise UpdateFailed(str(err)) from err
self.plan = plan
return {str(device["id"]): device for device in devices if device.get("id")} return {str(device["id"]): device for device in devices if device.get("id")}
@@ -1,7 +1,7 @@
{ {
"domain": "gree_controller", "domain": "gree_controller",
"name": "GREE Controller", "name": "GREE Controller",
"version": "0.4.5", "version": "0.5.0",
"config_flow": true, "config_flow": true,
"integration_type": "hub", "integration_type": "hub",
"iot_class": "local_polling", "iot_class": "local_polling",
@@ -0,0 +1,80 @@
"""Writable zone setpoint numbers for GREE Controller."""
from __future__ import annotations
from typing import Any
from homeassistant.components.number import NumberDeviceClass, NumberEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import GreeControllerRuntimeData
from .const import DOMAIN
from .coordinator import GreeControllerCoordinator
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Create one writable target-temperature number per zone."""
runtime: GreeControllerRuntimeData = entry.runtime_data
entities = [
GreeControllerZoneTargetNumber(runtime.coordinator, str(zone["zone_id"]))
for zone in runtime.coordinator.plan.get("zones", [])
if zone.get("zone_id")
]
async_add_entities(entities)
class GreeControllerZoneTargetNumber(CoordinatorEntity[GreeControllerCoordinator], NumberEntity):
"""Zone target override controlled through the standalone service."""
_attr_has_entity_name = True
_attr_name = "Target temperature"
_attr_device_class = NumberDeviceClass.TEMPERATURE
_attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS
_attr_native_min_value = 8.0
_attr_native_max_value = 30.0
_attr_native_step = 0.5
def __init__(self, coordinator: GreeControllerCoordinator, zone_id: str) -> None:
super().__init__(coordinator)
self._zone_id = zone_id
self._attr_unique_id = f"{zone_id}-target-temperature"
@property
def _zone(self) -> dict[str, Any]:
for zone in self.coordinator.plan.get("zones", []):
if str(zone.get("zone_id")) == self._zone_id:
return zone
return {}
@property
def available(self) -> bool:
return super().available and bool(self._zone)
@property
def native_value(self) -> float | None:
value = self._zone.get("target_temperature")
return float(value) if value is not None else None
@property
def device_info(self) -> DeviceInfo:
zone = self._zone
return DeviceInfo(
identifiers={(DOMAIN, f"zone:{self._zone_id}")},
name=str(zone.get("zone_name") or self._zone_id),
manufacturer="GREE Controller",
model="Zone thermostat",
via_device=(DOMAIN, "controller"),
)
async def async_set_native_value(self, value: float) -> None:
await self.coordinator.client.zone_control(self._zone_id, {"setpoint": float(value)})
await self.coordinator.async_request_refresh()
@@ -0,0 +1,126 @@
"""Automation-plan sensors exposed by GREE Controller."""
from __future__ import annotations
from typing import Any
from homeassistant.components.sensor import SensorEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import GreeControllerRuntimeData
from .const import DOMAIN
from .coordinator import GreeControllerCoordinator
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Create whole-house and per-zone automation-plan sensors."""
runtime: GreeControllerRuntimeData = entry.runtime_data
entities: list[SensorEntity] = [GreeControllerHousePlanSensor(runtime.coordinator)]
for zone in runtime.coordinator.plan.get("zones", []):
if zone.get("zone_id"):
entities.append(GreeControllerZonePlanSensor(runtime.coordinator, str(zone["zone_id"])))
async_add_entities(entities)
class GreeControllerHousePlanSensor(CoordinatorEntity[GreeControllerCoordinator], SensorEntity):
"""Summary of the current whole-house control plan."""
_attr_has_entity_name = True
_attr_name = "Automation plan"
_attr_unique_id = "house-control-plan"
_unrecorded_attributes = frozenset({"next_events", "rules"})
@property
def native_value(self) -> str:
return str(self.coordinator.plan.get("house_mode") or "unknown")
@property
def device_info(self) -> DeviceInfo:
return DeviceInfo(
identifiers={(DOMAIN, "controller")},
name="GREE Controller",
manufacturer="GREE Controller",
model="Local controller",
)
@property
def extra_state_attributes(self) -> dict[str, Any]:
plan = self.coordinator.plan
zones = plan.get("zones", [])
return {
"generated_at": plan.get("generated_at"),
"outdoor_temperature": plan.get("outdoor_temperature"),
"control_strategy": plan.get("control_strategy"),
"enabled_zones": sum(bool(zone.get("enabled")) for zone in zones),
"demanding_zones": sum(bool(zone.get("enabled")) and bool(zone.get("demand")) for zone in zones),
"next_events": plan.get("next_events", []),
"rules": plan.get("rules", []),
}
class GreeControllerZonePlanSensor(CoordinatorEntity[GreeControllerCoordinator], SensorEntity):
"""Readable automation plan for one controller zone."""
_attr_has_entity_name = True
_attr_name = "Control plan"
_unrecorded_attributes = frozenset({"next_events"})
def __init__(self, coordinator: GreeControllerCoordinator, zone_id: str) -> None:
super().__init__(coordinator)
self._zone_id = zone_id
self._attr_unique_id = f"{zone_id}-control-plan"
@property
def _zone(self) -> dict[str, Any]:
for zone in self.coordinator.plan.get("zones", []):
if str(zone.get("zone_id")) == self._zone_id:
return zone
return {}
@property
def available(self) -> bool:
return super().available and bool(self._zone)
@property
def native_value(self) -> str:
zone = self._zone
if not zone.get("enabled", False):
return "disabled"
return "requesting" if zone.get("demand", False) else "satisfied"
@property
def device_info(self) -> DeviceInfo:
zone = self._zone
return DeviceInfo(
identifiers={(DOMAIN, f"zone:{self._zone_id}")},
name=str(zone.get("zone_name") or self._zone_id),
manufacturer="GREE Controller",
model="Zone thermostat",
via_device=(DOMAIN, "controller"),
)
@property
def extra_state_attributes(self) -> dict[str, Any]:
zone = self._zone
return {
"zone_id": self._zone_id,
"device_id": zone.get("device_id"),
"device_name": zone.get("device_name"),
"mode": zone.get("mode"),
"preset": zone.get("preset"),
"current_temperature": zone.get("current_temperature"),
"target_temperature": zone.get("target_temperature"),
"device_setpoint": zone.get("device_setpoint"),
"control_source": zone.get("control_source"),
"manual_override_until": zone.get("manual_override_until"),
"current_schedule": zone.get("current_schedule_name"),
"next_events": zone.get("next_events", []),
}
@@ -0,0 +1,77 @@
"""Writable zone enabled switches for GREE Controller."""
from __future__ import annotations
from typing import Any
from homeassistant.components.switch import SwitchEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import GreeControllerRuntimeData
from .const import DOMAIN
from .coordinator import GreeControllerCoordinator
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Create one enabled switch per controller zone."""
runtime: GreeControllerRuntimeData = entry.runtime_data
entities = [
GreeControllerZoneEnabledSwitch(runtime.coordinator, str(zone["zone_id"]))
for zone in runtime.coordinator.plan.get("zones", [])
if zone.get("zone_id")
]
async_add_entities(entities)
class GreeControllerZoneEnabledSwitch(CoordinatorEntity[GreeControllerCoordinator], SwitchEntity):
"""Enable or disable a controller thermostat zone."""
_attr_has_entity_name = True
_attr_name = "Enabled"
def __init__(self, coordinator: GreeControllerCoordinator, zone_id: str) -> None:
super().__init__(coordinator)
self._zone_id = zone_id
self._attr_unique_id = f"{zone_id}-enabled"
@property
def _zone(self) -> dict[str, Any]:
for zone in self.coordinator.plan.get("zones", []):
if str(zone.get("zone_id")) == self._zone_id:
return zone
return {}
@property
def available(self) -> bool:
return super().available and bool(self._zone)
@property
def is_on(self) -> bool:
return bool(self._zone.get("enabled", False))
@property
def device_info(self) -> DeviceInfo:
zone = self._zone
return DeviceInfo(
identifiers={(DOMAIN, f"zone:{self._zone_id}")},
name=str(zone.get("zone_name") or self._zone_id),
manufacturer="GREE Controller",
model="Zone thermostat",
via_device=(DOMAIN, "controller"),
)
async def async_turn_on(self, **kwargs: Any) -> None:
await self.coordinator.client.zone_control(self._zone_id, {"enabled": True})
await self.coordinator.async_request_refresh()
async def async_turn_off(self, **kwargs: Any) -> None:
await self.coordinator.client.zone_control(self._zone_id, {"enabled": False})
await self.coordinator.async_request_refresh()
+49 -1
View File
@@ -352,6 +352,54 @@
"history.chartSaved": "Custom chart saved", "history.chartSaved": "Custom chart saved",
"history.linkCopied": "Chart link copied", "history.linkCopied": "Chart link copied",
"toast.successTitle": "Done", "toast.successTitle": "Done",
"toast.errorTitle": "Something went wrong" "toast.errorTitle": "Something went wrong",
"plan.eyebrow": "Control plan",
"plan.title": "What happens next",
"plan.house": "Whole house",
"plan.houseSummary": "{zones} enabled zones · {demand} requesting action",
"plan.noEvents": "No scheduled changes in the next days",
"plan.loading": "Loading current automation plan…",
"plan.rules": "Extra automations",
"plan.ruleCount": "{count} active rules",
"plan.event": "Change",
"zones.enable": "Enable zone",
"zones.disable": "Disable zone",
"history.90d": "90 days",
"history.1y": "1 year",
"settings.metrics": "Metrics storage",
"settings.retentionDays": "Local retention (days)",
"settings.compaction": "Compact old metrics",
"settings.compactionHint": "SQLite keeps recent data locally and compacts older samples to the resolution used by charts.",
"settings.greeCommands": "GREE commands",
"settings.suppressBeep": "Try to suppress command beeps",
"settings.suppressBeepHint": "Only changed properties are sent. Buzzer suppression is also requested when supported by the unit firmware.",
"settings.influx": "Long-term InfluxDB history",
"settings.influxHint": "Optional archive for older history. InfluxDB 1.x and 2.x are supported. Samples older than the handoff age are removed from SQLite only after a successful archive write.",
"settings.influxEnabled": "Enable InfluxDB archive",
"settings.influxVersion": "InfluxDB version",
"settings.influxThreshold": "Use archive for history older than (days)",
"settings.influxDatabase": "Database",
"settings.influxUsername": "Username",
"settings.influxPassword": "Password",
"settings.influxOrg": "Organization",
"settings.influxBucket": "Bucket",
"settings.influxToken": "Token",
"settings.secretKeep": "Leave empty to keep saved secret",
"settings.secretSaved": "Secret already saved — leave empty to keep it",
"settings.debug": "On-screen debug",
"settings.debugOverlay": "Show debug window on every page",
"settings.debugGreeFrames": "Include GREE protocol frames",
"settings.backup": "Configuration backup",
"settings.backupHint": "Export/import application configuration. Exported files can contain GREE device keys plus Home Assistant and InfluxDB secrets; metrics and API access tokens are not included.",
"settings.export": "Export settings",
"settings.import": "Import settings",
"settings.importConfirm": "Replace the current application configuration with this file? Existing metrics and API access tokens will be kept.",
"debug.title": "Live debug",
"debug.clear": "Clear",
"debug.apiAndGree": "API logs + GREE frames",
"debug.apiOnly": "API logs",
"debug.empty": "No debug events yet.",
"toast.exported": "Configuration exported",
"toast.imported": "Configuration imported"
} }
} }
+49 -1
View File
@@ -352,6 +352,54 @@
"history.chartSaved": "Własny wykres zapisany", "history.chartSaved": "Własny wykres zapisany",
"history.linkCopied": "Link do wykresu skopiowany", "history.linkCopied": "Link do wykresu skopiowany",
"toast.successTitle": "Gotowe", "toast.successTitle": "Gotowe",
"toast.errorTitle": "Wystąpił błąd" "toast.errorTitle": "Wystąpił błąd",
"plan.eyebrow": "Plan sterowania",
"plan.title": "Co wydarzy się dalej",
"plan.house": "Cały dom",
"plan.houseSummary": "Aktywne strefy: {zones} · żądające działania: {demand}",
"plan.noEvents": "Brak zaplanowanych zmian w najbliższych dniach",
"plan.loading": "Wczytywanie aktualnego planu automatyki…",
"plan.rules": "Dodatkowe automatyzacje",
"plan.ruleCount": "Aktywne reguły: {count}",
"plan.event": "Zmiana",
"zones.enable": "Włącz strefę",
"zones.disable": "Wyłącz strefę",
"history.90d": "90 dni",
"history.1y": "1 rok",
"settings.metrics": "Przechowywanie metryk",
"settings.retentionDays": "Lokalna retencja (dni)",
"settings.compaction": "Kompaktuj starsze metryki",
"settings.compactionHint": "SQLite przechowuje świeże dane lokalnie, a starsze próbki redukuje do rozdzielczości używanej przez wykresy.",
"settings.greeCommands": "Polecenia GREE",
"settings.suppressBeep": "Próbuj wyciszać dźwięk poleceń",
"settings.suppressBeepHint": "Wysyłane są tylko zmienione właściwości. Jeśli firmware jednostki to obsługuje, wysyłane jest też polecenie wyciszenia buzzera.",
"settings.influx": "Długoterminowa historia InfluxDB",
"settings.influxHint": "Opcjonalne archiwum starszej historii. Obsługiwane są InfluxDB 1.x i 2.x. Próbki starsze niż próg są usuwane z SQLite dopiero po udanym zapisie do archiwum.",
"settings.influxEnabled": "Włącz archiwum InfluxDB",
"settings.influxVersion": "Wersja InfluxDB",
"settings.influxThreshold": "Używaj archiwum dla historii starszej niż (dni)",
"settings.influxDatabase": "Baza danych",
"settings.influxUsername": "Użytkownik",
"settings.influxPassword": "Hasło",
"settings.influxOrg": "Organizacja",
"settings.influxBucket": "Bucket",
"settings.influxToken": "Token",
"settings.secretKeep": "Pozostaw puste, aby zachować zapisany sekret",
"settings.secretSaved": "Sekret jest zapisany — pozostaw puste, aby go zachować",
"settings.debug": "Debug na ekranie",
"settings.debugOverlay": "Pokazuj okno debug na każdej podstronie",
"settings.debugGreeFrames": "Dołącz ramki protokołu GREE",
"settings.backup": "Kopia konfiguracji",
"settings.backupHint": "Eksport/import konfiguracji aplikacji. Plik eksportu może zawierać klucze urządzeń GREE oraz sekrety Home Assistant i InfluxDB; metryki i tokeny dostępu API nie są eksportowane.",
"settings.export": "Eksportuj ustawienia",
"settings.import": "Importuj ustawienia",
"settings.importConfirm": "Zastąpić bieżącą konfigurację aplikacji tym plikiem? Istniejące metryki i tokeny dostępu API zostaną zachowane.",
"debug.title": "Debug na żywo",
"debug.clear": "Wyczyść",
"debug.apiAndGree": "Logi API + ramki GREE",
"debug.apiOnly": "Logi API",
"debug.empty": "Brak zdarzeń debug.",
"toast.exported": "Konfiguracja wyeksportowana",
"toast.imported": "Konfiguracja zaimportowana"
} }
} }
+70
View File
@@ -0,0 +1,70 @@
#!/usr/bin/env python3
import os
import sys
import zipfile
import subprocess
from pathlib import Path
def run_git_command(args, repo_path: Path) -> bytes:
result = subprocess.run(
["git", *args],
cwd=repo_path,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=True,
)
return result.stdout
def get_files_to_archive(repo_path: Path) -> list[str]:
output = run_git_command(
["ls-files", "--cached", "--others", "--exclude-standard", "-z"],
repo_path,
)
files = output.decode("utf-8", errors="surrogateescape").split("\0")
return [f for f in files if f]
def make_zip(repo_path: Path, output_zip: Path) -> None:
files = get_files_to_archive(repo_path)
output_zip = output_zip.resolve()
if output_zip.exists():
output_zip.unlink()
with zipfile.ZipFile(output_zip, "w", compression=zipfile.ZIP_DEFLATED) as zf:
for rel_path in files:
abs_path = repo_path / rel_path
if not abs_path.exists():
continue
if abs_path.resolve() == output_zip:
continue
zf.write(abs_path, arcname=rel_path)
print(f"Created: {output_zip}")
print(f"Added files: {len(files)}")
def main():
repo_path = Path.cwd()
if len(sys.argv) > 1:
output_zip = Path(sys.argv[1])
else:
output_zip = repo_path / f"{repo_path.name}.zip"
try:
run_git_command(["rev-parse", "--show-toplevel"], repo_path)
except subprocess.CalledProcessError:
print("Error: this directory is not a Git repository.", file=sys.stderr)
sys.exit(1)
make_zip(repo_path, output_zip)
if __name__ == "__main__":
main()
+224 -11
View File
@@ -1,4 +1,4 @@
use std::{net::IpAddr, time::Duration}; use std::{net::IpAddr, sync::atomic::Ordering, time::{Duration, Instant}};
use axum::{ use axum::{
body::Body, body::Body,
extract::{Path, Query, Request, State, WebSocketUpgrade, ws::{Message, WebSocket}}, extract::{Path, Query, Request, State, WebSocketUpgrade, ws::{Message, WebSocket}},
@@ -21,7 +21,8 @@ use crate::{
engine, engine,
error::AppError, error::AppError,
home_assistant, home_assistant,
models::{ApiTokenInfo, Automation, Device, DeviceCommand, DevicePatch, DiscoveryRequest, ManualDeviceRequest, HaReading, Reading, RuntimeSettings, Schedule, Zone, ZoneControlPatch, ZoneReading}, influxdb,
models::{ApiTokenInfo, Automation, ConfigurationExport, DebugSettings, Device, DeviceCommand, DevicePatch, DiscoveryRequest, ManualDeviceRequest, HaReading, Reading, RuntimeSettings, Schedule, Zone, ZoneControlPatch, ZoneReading},
protocol::merge_discovered, protocol::merge_discovered,
state::AppState, state::AppState,
}; };
@@ -56,8 +57,12 @@ pub fn router(state: AppState) -> Router {
.route("/api/automations/:id", get(get_automation).put(update_automation).delete(delete_automation)) .route("/api/automations/:id", get(get_automation).put(update_automation).delete(delete_automation))
.route("/api/readings", get(readings)) .route("/api/readings", get(readings))
.route("/api/history", get(history)) .route("/api/history", get(history))
.route("/api/control-plan", get(control_plan))
.route("/api/events", get(events)) .route("/api/events", get(events))
.route("/api/settings", get(get_settings).put(update_settings)) .route("/api/settings", get(get_settings).put(update_settings))
.route("/api/settings/export", get(export_settings))
.route("/api/settings/import", post(import_settings))
.route("/api/debug", get(get_debug).put(update_debug))
.route("/api/access-tokens", get(list_access_tokens).post(create_access_token)) .route("/api/access-tokens", get(list_access_tokens).post(create_access_token))
.route("/api/access-tokens/:id", axum::routing::delete(delete_access_token)) .route("/api/access-tokens/:id", axum::routing::delete(delete_access_token))
.route("/api/integrations/home-assistant/test", post(test_home_assistant)) .route("/api/integrations/home-assistant/test", post(test_home_assistant))
@@ -66,6 +71,8 @@ pub fn router(state: AppState) -> Router {
let home_assistant_api = Router::new() let home_assistant_api = Router::new()
.route("/api/integrations/home-assistant/devices", get(list_devices)) .route("/api/integrations/home-assistant/devices", get(list_devices))
.route("/api/integrations/home-assistant/devices/:id/command", post(command_device)) .route("/api/integrations/home-assistant/devices/:id/command", post(command_device))
.route("/api/integrations/home-assistant/control-plan", get(control_plan))
.route("/api/integrations/home-assistant/zones/:id/control", post(update_zone_control))
.route_layer(middleware::from_fn_with_state(state.clone(), home_assistant_auth)); .route_layer(middleware::from_fn_with_state(state.clone(), home_assistant_auth));
Router::new() Router::new()
@@ -86,9 +93,27 @@ pub fn router(state: AppState) -> Router {
.layer(CompressionLayer::new()) .layer(CompressionLayer::new())
.layer(CorsLayer::permissive()) .layer(CorsLayer::permissive())
.layer(TraceLayer::new_for_http()) .layer(TraceLayer::new_for_http())
.layer(middleware::from_fn_with_state(state.clone(), debug_api_requests))
.with_state(state) .with_state(state)
} }
async fn debug_api_requests(State(state): State<AppState>, request: Request, next: Next) -> Response {
if !state.settings.read().await.debug.overlay_enabled {
return next.run(request).await;
}
let method = request.method().clone();
let path = request.uri().path().to_string();
let started = Instant::now();
let response = next.run(request).await;
state.broadcast("api.request", json!({
"method": method.as_str(),
"path": path,
"status": response.status().as_u16(),
"duration_ms": started.elapsed().as_millis(),
}));
response
}
async fn auth(State(state): State<AppState>, request: Request, next: Next) -> Result<Response, AppError> { async fn auth(State(state): State<AppState>, request: Request, next: Next) -> Result<Response, AppError> {
let expected = state.config.app_token.trim(); let expected = state.config.app_token.trim();
if expected.is_empty() { if expected.is_empty() {
@@ -735,7 +760,7 @@ async fn delete_automation(State(state): State<AppState>, Path(id): Path<String>
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
struct ReadingsQuery { device_id: Option<String>, hours: Option<i64>, limit: Option<u32> } struct ReadingsQuery { device_id: Option<String>, hours: Option<i64>, limit: Option<u32> }
async fn readings(State(state): State<AppState>, Query(query): Query<ReadingsQuery>) -> Result<Json<Value>, AppError> { async fn readings(State(state): State<AppState>, Query(query): Query<ReadingsQuery>) -> Result<Json<Value>, AppError> {
let hours = query.hours.unwrap_or(24).clamp(1, 24 * 31); let hours = query.hours.unwrap_or(24).clamp(1, 24 * 3650);
let values = state.db.list_readings(query.device_id.as_deref(), Utc::now() - ChronoDuration::hours(hours), query.limit.unwrap_or(1500))?; let values = state.db.list_readings(query.device_id.as_deref(), Utc::now() - ChronoDuration::hours(hours), query.limit.unwrap_or(1500))?;
Ok(Json(json!({"readings": values}))) Ok(Json(json!({"readings": values})))
} }
@@ -755,7 +780,10 @@ fn history_bucket_seconds(hours: i64) -> i64 {
1..=6 => 30, 1..=6 => 30,
7..=24 => 120, 7..=24 => 120,
25..=168 => 600, 25..=168 => 600,
_ => 1800, 169..=720 => 1800,
721..=2160 => 7200,
2161..=8760 => 21600,
_ => 86400,
} }
} }
@@ -858,8 +886,110 @@ fn sensor_history_with_fallback(
Ok(values) Ok(values)
} }
async fn combined_device_history(
state: &AppState,
device_id: Option<&str>,
since: chrono::DateTime<Utc>,
bucket_seconds: i64,
limit: u32,
) -> Result<(Vec<Reading>, String, Option<String>), AppError> {
let influx = state.settings.read().await.influxdb.clone();
let cutoff = Utc::now() - ChronoDuration::days(influx.history_threshold_days.max(1) as i64);
if !influx.enabled || since >= cutoff {
return Ok((state.db.list_device_history(device_id, since, bucket_seconds, limit)?, "sqlite".into(), None));
}
let mut warning = None;
let mut values = match influxdb::query_devices(&state.http, &influx, device_id, since, cutoff, bucket_seconds, limit).await {
Ok(rows) => rows,
Err(err) => {
warning = Some(err.to_string());
state.log("warn", "influx.query_error", "InfluxDB device history query failed", json!({"error": err.to_string()}));
state.db.list_device_history(device_id, since, bucket_seconds, limit)?
}
};
if warning.is_none() {
values.extend(state.db.list_device_history(device_id, cutoff, bucket_seconds, limit)?);
}
values.sort_by_key(|row| row.timestamp);
trim_history(&mut values, limit);
let source = if warning.is_some() { "sqlite_fallback" } else { "influx+sqlite" };
Ok((values, source.into(), warning))
}
async fn combined_zone_history(
state: &AppState,
zone_id: Option<&str>,
since: chrono::DateTime<Utc>,
bucket_seconds: i64,
limit: u32,
) -> Result<(Vec<ZoneReading>, String, Option<String>), AppError> {
let influx = state.settings.read().await.influxdb.clone();
let cutoff = Utc::now() - ChronoDuration::days(influx.history_threshold_days.max(1) as i64);
if !influx.enabled || since >= cutoff {
return Ok((zone_history_with_fallback(state, zone_id, since, bucket_seconds, limit)?, "sqlite".into(), None));
}
let mut warning = None;
let mut values = match influxdb::query_zones(&state.http, &influx, zone_id, since, cutoff, bucket_seconds, limit).await {
Ok(rows) => rows,
Err(err) => {
warning = Some(err.to_string());
state.log("warn", "influx.query_error", "InfluxDB zone history query failed", json!({"error": err.to_string()}));
zone_history_with_fallback(state, zone_id, since, bucket_seconds, limit)?
}
};
if warning.is_none() {
values.extend(zone_history_with_fallback(state, zone_id, cutoff, bucket_seconds, limit)?);
}
values.sort_by_key(|row| row.timestamp);
trim_history(&mut values, limit);
let source = if warning.is_some() { "sqlite_fallback" } else { "influx+sqlite" };
Ok((values, source.into(), warning))
}
async fn combined_sensor_history(
state: &AppState,
entity_id: Option<&str>,
since: chrono::DateTime<Utc>,
bucket_seconds: i64,
limit: u32,
outdoor_entity: &str,
) -> Result<(Vec<HaReading>, String, Option<String>), AppError> {
let influx = state.settings.read().await.influxdb.clone();
let cutoff = Utc::now() - ChronoDuration::days(influx.history_threshold_days.max(1) as i64);
let local = |start| -> Result<Vec<HaReading>, AppError> {
if entity_id.is_some() { Ok(state.db.list_ha_history(entity_id, start, bucket_seconds, limit)?) }
else { sensor_history_with_fallback(state, start, bucket_seconds, limit, outdoor_entity) }
};
if !influx.enabled || since >= cutoff {
return Ok((local(since)?, "sqlite".into(), None));
}
let mut warning = None;
let mut values = match influxdb::query_ha(&state.http, &influx, entity_id, since, cutoff, bucket_seconds, limit).await {
Ok(rows) => rows,
Err(err) => {
warning = Some(err.to_string());
state.log("warn", "influx.query_error", "InfluxDB HA history query failed", json!({"error": err.to_string()}));
local(since)?
}
};
if warning.is_none() {
values.extend(local(cutoff)?);
}
values.sort_by_key(|row| row.timestamp);
trim_history(&mut values, limit);
let source = if warning.is_some() { "sqlite_fallback" } else { "influx+sqlite" };
Ok((values, source.into(), warning))
}
fn trim_history<T>(values: &mut Vec<T>, limit: u32) {
if values.len() > limit as usize {
let keep_from = values.len() - limit as usize;
values.drain(0..keep_from);
}
}
async fn history(State(state): State<AppState>, Query(query): Query<HistoryQuery>) -> Result<Json<Value>, AppError> { async fn history(State(state): State<AppState>, Query(query): Query<HistoryQuery>) -> Result<Json<Value>, AppError> {
let hours = query.hours.unwrap_or(24).clamp(1, 24 * 31); let hours = query.hours.unwrap_or(24).clamp(1, 24 * 3650);
let since = Utc::now() - ChronoDuration::hours(hours); let since = Utc::now() - ChronoDuration::hours(hours);
let bucket_seconds = history_bucket_seconds(hours); let bucket_seconds = history_bucket_seconds(hours);
let limit = query.limit.unwrap_or(12_000).clamp(1, 20_000); let limit = query.limit.unwrap_or(12_000).clamp(1, 20_000);
@@ -870,27 +1000,31 @@ async fn history(State(state): State<AppState>, Query(query): Query<HistoryQuery
match scope { match scope {
"devices" => { "devices" => {
let device_id = query.device_id.as_deref().filter(|value| !value.is_empty() && *value != "all"); let device_id = query.device_id.as_deref().filter(|value| !value.is_empty() && *value != "all");
let readings = state.db.list_device_history(device_id, since.clone(), bucket_seconds, limit)?; let (readings, storage, warning) = combined_device_history(&state, device_id, since, bucket_seconds, limit).await?;
Ok(Json(json!({ Ok(Json(json!({
"scope": "devices", "readings": readings, "bucket_seconds": bucket_seconds, "scope": "devices", "readings": readings, "bucket_seconds": bucket_seconds,
"storage": storage, "storage_warning": warning,
"counts": {"devices": device_count, "zones": zone_count, "ha": ha_count} "counts": {"devices": device_count, "zones": zone_count, "ha": ha_count}
}))) })))
} }
"sensors" => { "sensors" => {
let entity_id = query.entity_id.as_deref().filter(|value| !value.is_empty() && *value != "all"); let entity_id = query.entity_id.as_deref().filter(|value| !value.is_empty() && *value != "all");
let readings = if entity_id.is_some() { state.db.list_ha_history(entity_id, since.clone(), bucket_seconds, limit)? } else { sensor_history_with_fallback(&state, since.clone(), bucket_seconds, limit, &outdoor_entity)? }; let (readings, storage, warning) = combined_sensor_history(&state, entity_id, since, bucket_seconds, limit, &outdoor_entity).await?;
Ok(Json(json!({ Ok(Json(json!({
"scope": "sensors", "readings": readings, "bucket_seconds": bucket_seconds, "scope": "sensors", "readings": readings, "bucket_seconds": bucket_seconds,
"storage": storage, "storage_warning": warning,
"counts": {"devices": device_count, "zones": zone_count, "ha": ha_count} "counts": {"devices": device_count, "zones": zone_count, "ha": ha_count}
}))) })))
} }
"overview" => { "overview" => {
let zones = zone_history_with_fallback(&state, None, since.clone(), bucket_seconds, limit)?; let (zones, zone_storage, zone_warning) = combined_zone_history(&state, None, since, bucket_seconds, limit).await?;
let devices = state.db.list_device_history(None, since.clone(), bucket_seconds, limit)?; let (devices, device_storage, device_warning) = combined_device_history(&state, None, since, bucket_seconds, limit).await?;
let sensors = sensor_history_with_fallback(&state, since.clone(), bucket_seconds, limit, &outdoor_entity)?; let (sensors, sensor_storage, sensor_warning) = combined_sensor_history(&state, None, since, bucket_seconds, limit, &outdoor_entity).await?;
Ok(Json(json!({ Ok(Json(json!({
"scope": "overview", "bucket_seconds": bucket_seconds, "scope": "overview", "bucket_seconds": bucket_seconds,
"zones": zones, "devices": devices, "sensors": sensors, "zones": zones, "devices": devices, "sensors": sensors,
"storage": {"zones": zone_storage, "devices": device_storage, "sensors": sensor_storage},
"storage_warning": [zone_warning, device_warning, sensor_warning].into_iter().flatten().collect::<Vec<_>>(),
"counts": {"devices": device_count, "zones": zone_count, "ha": ha_count} "counts": {"devices": device_count, "zones": zone_count, "ha": ha_count}
}))) })))
} }
@@ -901,9 +1035,10 @@ async fn history(State(state): State<AppState>, Query(query): Query<HistoryQuery
return Err(AppError::NotFound(format!("zone {zone_id}"))); return Err(AppError::NotFound(format!("zone {zone_id}")));
} }
} }
let readings = zone_history_with_fallback(&state, zone_id, since.clone(), bucket_seconds, limit)?; let (readings, storage, warning) = combined_zone_history(&state, zone_id, since, bucket_seconds, limit).await?;
Ok(Json(json!({ Ok(Json(json!({
"scope": "zones", "readings": readings, "bucket_seconds": bucket_seconds, "scope": "zones", "readings": readings, "bucket_seconds": bucket_seconds,
"storage": storage, "storage_warning": warning,
"counts": {"devices": device_count, "zones": zone_count, "ha": ha_count} "counts": {"devices": device_count, "zones": zone_count, "ha": ha_count}
}))) })))
} }
@@ -911,6 +1046,10 @@ async fn history(State(state): State<AppState>, Query(query): Query<HistoryQuery
} }
} }
async fn control_plan(State(state): State<AppState>) -> Result<Json<Value>, AppError> {
Ok(Json(serde_json::to_value(engine::build_control_plan(&state).await?)?))
}
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
struct EventsQuery { limit: Option<u32> } struct EventsQuery { limit: Option<u32> }
async fn events(State(state): State<AppState>, Query(query): Query<EventsQuery>) -> Result<Json<Value>, AppError> { async fn events(State(state): State<AppState>, Query(query): Query<EventsQuery>) -> Result<Json<Value>, AppError> {
@@ -936,17 +1075,73 @@ async fn update_settings(State(state): State<AppState>, Json(mut input): Json<Ru
} }
if input.controller_id.trim().is_empty() { input.controller_id = old.controller_id; } if input.controller_id.trim().is_empty() { input.controller_id = old.controller_id; }
if input.home_assistant.token.trim().is_empty() { input.home_assistant.token = old.home_assistant.token; } if input.home_assistant.token.trim().is_empty() { input.home_assistant.token = old.home_assistant.token; }
input.history_retention_days = input.history_retention_days.clamp(1, 3650);
input.influxdb.history_threshold_days = input.influxdb.history_threshold_days.clamp(1, 3650);
if input.influxdb.token.trim().is_empty() { input.influxdb.token = old.influxdb.token; }
if input.influxdb.password.trim().is_empty() { input.influxdb.password = old.influxdb.password; }
influxdb::validate(&input.influxdb).map_err(|err| AppError::BadRequest(err.to_string()))?;
if !input.home_assistant.url.trim().is_empty() { if !input.home_assistant.url.trim().is_empty() {
let parsed = url::Url::parse(&input.home_assistant.url).map_err(|_| AppError::BadRequest("invalid Home Assistant URL".into()))?; let parsed = url::Url::parse(&input.home_assistant.url).map_err(|_| AppError::BadRequest("invalid Home Assistant URL".into()))?;
if !matches!(parsed.scheme(), "http" | "https") { return Err(AppError::BadRequest("Home Assistant URL must use http or https".into())); } if !matches!(parsed.scheme(), "http" | "https") { return Err(AppError::BadRequest("Home Assistant URL must use http or https".into())); }
} }
state.db.save_runtime_settings(&input)?; state.db.save_runtime_settings(&input)?;
state.debug_gree_frames.store(input.debug.gree_frames, Ordering::Relaxed);
*state.settings.write().await = input.clone(); *state.settings.write().await = input.clone();
state.log("info", "settings.updated", "Settings updated", json!({})); state.log("info", "settings.updated", "Settings updated", json!({}));
state.broadcast("settings.updated", public_settings(&input)); state.broadcast("settings.updated", public_settings(&input));
Ok(Json(public_settings(&input))) Ok(Json(public_settings(&input)))
} }
async fn export_settings(State(state): State<AppState>) -> Result<Json<ConfigurationExport>, AppError> {
let settings = state.settings.read().await.clone();
Ok(Json(state.db.export_configuration(settings)?))
}
fn validate_configuration_export(export: &ConfigurationExport) -> Result<(), AppError> {
if export.format_version != 1 { return Err(AppError::BadRequest("unsupported configuration export version".into())); }
influxdb::validate(&export.settings.influxdb).map_err(|err| AppError::BadRequest(err.to_string()))?;
let devices: std::collections::HashSet<&str> = export.devices.iter().map(|item| item.id.as_str()).collect();
let zones: std::collections::HashSet<&str> = export.zones.iter().map(|item| item.id.as_str()).collect();
if export.zones.iter().any(|item| !devices.contains(item.device_id.as_str())) {
return Err(AppError::BadRequest("import contains a zone referencing a missing device".into()));
}
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.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()));
}
Ok(())
}
async fn import_settings(State(state): State<AppState>, Json(mut export): Json<ConfigurationExport>) -> Result<Json<Value>, AppError> {
validate_configuration_export(&export)?;
export.settings.history_retention_days = export.settings.history_retention_days.clamp(1, 3650);
export.settings.influxdb.history_threshold_days = export.settings.influxdb.history_threshold_days.clamp(1, 3650);
state.db.replace_configuration(&export)?;
state.debug_gree_frames.store(export.settings.debug.gree_frames, Ordering::Relaxed);
*state.settings.write().await = export.settings.clone();
state.log("info", "settings.imported", "Application configuration imported", json!({"format_version": export.format_version}));
state.broadcast("configuration.imported", json!({"at": Utc::now()}));
Ok(Json(json!({"ok": true})))
}
async fn get_debug(State(state): State<AppState>) -> Json<DebugSettings> {
Json(state.settings.read().await.debug.clone())
}
async fn update_debug(State(state): State<AppState>, Json(input): Json<DebugSettings>) -> Result<Json<DebugSettings>, AppError> {
let mut settings = state.settings.write().await;
settings.debug = input.clone();
state.db.save_runtime_settings(&settings)?;
state.debug_gree_frames.store(input.gree_frames, Ordering::Relaxed);
state.broadcast("debug.settings", serde_json::to_value(&input)?);
Ok(Json(input))
}
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
struct CreateAccessTokenRequest { struct CreateAccessTokenRequest {
name: Option<String>, name: Option<String>,
@@ -1015,6 +1210,24 @@ fn public_settings(settings: &RuntimeSettings) -> Value {
"house_mode": settings.house_mode, "house_mode": settings.house_mode,
"control_strategy": settings.control_strategy, "control_strategy": settings.control_strategy,
"outdoor_assist_enabled": settings.outdoor_assist_enabled, "outdoor_assist_enabled": settings.outdoor_assist_enabled,
"history_retention_days": settings.history_retention_days,
"history_compaction_enabled": settings.history_compaction_enabled,
"suppress_device_beep": settings.suppress_device_beep,
"debug": settings.debug,
"influxdb": {
"enabled": settings.influxdb.enabled,
"version": settings.influxdb.version,
"url": settings.influxdb.url,
"database": settings.influxdb.database,
"username": settings.influxdb.username,
"password": "",
"password_configured": !settings.influxdb.password.trim().is_empty(),
"org": settings.influxdb.org,
"bucket": settings.influxdb.bucket,
"token": "",
"token_configured": !settings.influxdb.token.trim().is_empty(),
"history_threshold_days": settings.influxdb.history_threshold_days,
},
"home_assistant": { "home_assistant": {
"url": settings.home_assistant.url, "url": settings.home_assistant.url,
"token": "", "token": "",
+74 -4
View File
@@ -1,7 +1,7 @@
use std::{env, net::SocketAddr, path::PathBuf}; use std::{env, net::SocketAddr, path::PathBuf};
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use clap::Parser; use clap::Parser;
use crate::models::{HomeAssistantSettings, RuntimeSettings}; use crate::models::{DebugSettings, HomeAssistantSettings, InfluxDbSettings, RuntimeSettings};
#[derive(Debug, Clone, Parser)] #[derive(Debug, Clone, Parser)]
#[command(author, version, about)] #[command(author, version, about)]
@@ -51,9 +51,15 @@ impl Config {
discovery_broadcast: self.discovery_broadcast.clone(), discovery_broadcast: self.discovery_broadcast.clone(),
house_mode: env::var("GREE_CONTROLLER_HOUSE_MODE").unwrap_or_else(|_| "cool".into()), house_mode: env::var("GREE_CONTROLLER_HOUSE_MODE").unwrap_or_else(|_| "cool".into()),
control_strategy: "setpoint".into(), control_strategy: "setpoint".into(),
outdoor_assist_enabled: env::var("GREE_CONTROLLER_OUTDOOR_ASSIST_ENABLED") outdoor_assist_enabled: env_bool("GREE_CONTROLLER_OUTDOOR_ASSIST_ENABLED").unwrap_or(true),
.map(|v| matches!(v.to_ascii_lowercase().as_str(), "1" | "true" | "yes" | "on")) history_retention_days: env_u32("GREE_CONTROLLER_HISTORY_RETENTION_DAYS").unwrap_or(30).clamp(1, 3650),
.unwrap_or(true), history_compaction_enabled: env_bool("GREE_CONTROLLER_HISTORY_COMPACTION_ENABLED").unwrap_or(true),
suppress_device_beep: env_bool("GREE_CONTROLLER_SUPPRESS_DEVICE_BEEP").unwrap_or(false),
influxdb: influx_settings_from_env(),
debug: DebugSettings {
overlay_enabled: env_bool("GREE_CONTROLLER_DEBUG_OVERLAY").unwrap_or(false),
gree_frames: env_bool("GREE_CONTROLLER_DEBUG_GREE_FRAMES").unwrap_or(false),
},
home_assistant: HomeAssistantSettings { home_assistant: HomeAssistantSettings {
url: env::var("HA_URL").unwrap_or_default(), url: env::var("HA_URL").unwrap_or_default(),
token: env::var("HA_TOKEN").unwrap_or_default(), token: env::var("HA_TOKEN").unwrap_or_default(),
@@ -65,4 +71,68 @@ impl Config {
}, },
} }
} }
/// Environment values explicitly supplied by the service override persisted runtime values.
pub fn apply_runtime_env_overrides(&self, settings: &mut RuntimeSettings) {
if env::var_os("GREE_CONTROLLER_HISTORY_RETENTION_DAYS").is_some() {
settings.history_retention_days = env_u32("GREE_CONTROLLER_HISTORY_RETENTION_DAYS").unwrap_or(settings.history_retention_days).clamp(1, 3650);
}
if let Some(value) = env_bool("GREE_CONTROLLER_HISTORY_COMPACTION_ENABLED") { settings.history_compaction_enabled = value; }
if let Some(value) = env_bool("GREE_CONTROLLER_SUPPRESS_DEVICE_BEEP") { settings.suppress_device_beep = value; }
if let Some(value) = env_bool("GREE_CONTROLLER_DEBUG_OVERLAY") { settings.debug.overlay_enabled = value; }
if let Some(value) = env_bool("GREE_CONTROLLER_DEBUG_GREE_FRAMES") { settings.debug.gree_frames = value; }
let influx_env_present = [
"GREE_CONTROLLER_INFLUX_ENABLED", "GREE_CONTROLLER_INFLUX_VERSION", "GREE_CONTROLLER_INFLUX_URL",
"GREE_CONTROLLER_INFLUX_DATABASE", "GREE_CONTROLLER_INFLUX_USERNAME", "GREE_CONTROLLER_INFLUX_PASSWORD",
"GREE_CONTROLLER_INFLUX_ORG", "GREE_CONTROLLER_INFLUX_BUCKET", "GREE_CONTROLLER_INFLUX_TOKEN",
"GREE_CONTROLLER_INFLUX_THRESHOLD_DAYS", "INFLUXDB_URL", "INFLUXDB_DATABASE", "INFLUXDB_USERNAME",
"INFLUXDB_PASSWORD", "INFLUXDB_TOKEN", "INFLUXDB_ORG", "INFLUXDB_BUCKET",
].iter().any(|name| env::var_os(name).is_some());
if influx_env_present {
let env_settings = influx_settings_from_env();
if env::var_os("GREE_CONTROLLER_INFLUX_ENABLED").is_some() {
settings.influxdb.enabled = env_settings.enabled;
} else if first_env(&["GREE_CONTROLLER_INFLUX_URL", "INFLUXDB_URL"]).is_some() {
settings.influxdb.enabled = true;
}
if first_env(&["GREE_CONTROLLER_INFLUX_VERSION"]).is_some() { settings.influxdb.version = env_settings.version; }
if first_env(&["GREE_CONTROLLER_INFLUX_URL", "INFLUXDB_URL"]).is_some() { settings.influxdb.url = env_settings.url; }
if first_env(&["GREE_CONTROLLER_INFLUX_DATABASE", "INFLUXDB_DATABASE"]).is_some() { settings.influxdb.database = env_settings.database; }
if first_env(&["GREE_CONTROLLER_INFLUX_USERNAME", "INFLUXDB_USERNAME"]).is_some() { settings.influxdb.username = env_settings.username; }
if first_env(&["GREE_CONTROLLER_INFLUX_PASSWORD", "INFLUXDB_PASSWORD"]).is_some() { settings.influxdb.password = env_settings.password; }
if first_env(&["GREE_CONTROLLER_INFLUX_ORG", "INFLUXDB_ORG"]).is_some() { settings.influxdb.org = env_settings.org; }
if first_env(&["GREE_CONTROLLER_INFLUX_BUCKET", "INFLUXDB_BUCKET"]).is_some() { settings.influxdb.bucket = env_settings.bucket; }
if first_env(&["GREE_CONTROLLER_INFLUX_TOKEN", "INFLUXDB_TOKEN"]).is_some() { settings.influxdb.token = env_settings.token; }
if env::var_os("GREE_CONTROLLER_INFLUX_THRESHOLD_DAYS").is_some() { settings.influxdb.history_threshold_days = env_settings.history_threshold_days; }
}
}
}
fn env_bool(name: &str) -> Option<bool> {
env::var(name).ok().map(|v| matches!(v.to_ascii_lowercase().as_str(), "1" | "true" | "yes" | "on"))
}
fn env_u32(name: &str) -> Option<u32> { env::var(name).ok()?.parse().ok() }
fn first_env(names: &[&str]) -> Option<String> {
names.iter().find_map(|name| {
let value = env::var(name).ok()?;
(!value.trim().is_empty()).then_some(value)
})
}
fn influx_settings_from_env() -> InfluxDbSettings {
let mut settings = InfluxDbSettings::default();
settings.version = first_env(&["GREE_CONTROLLER_INFLUX_VERSION"]).unwrap_or_else(|| "2".into());
settings.url = first_env(&["GREE_CONTROLLER_INFLUX_URL", "INFLUXDB_URL"]).unwrap_or_default();
settings.enabled = env_bool("GREE_CONTROLLER_INFLUX_ENABLED").unwrap_or(!settings.url.is_empty());
settings.database = first_env(&["GREE_CONTROLLER_INFLUX_DATABASE", "INFLUXDB_DATABASE"]).unwrap_or_else(|| "gree_controller".into());
settings.username = first_env(&["GREE_CONTROLLER_INFLUX_USERNAME", "INFLUXDB_USERNAME"]).unwrap_or_default();
settings.password = first_env(&["GREE_CONTROLLER_INFLUX_PASSWORD", "INFLUXDB_PASSWORD"]).unwrap_or_default();
settings.org = first_env(&["GREE_CONTROLLER_INFLUX_ORG", "INFLUXDB_ORG"]).unwrap_or_default();
settings.bucket = first_env(&["GREE_CONTROLLER_INFLUX_BUCKET", "INFLUXDB_BUCKET"]).unwrap_or_else(|| "gree_controller".into());
settings.token = first_env(&["GREE_CONTROLLER_INFLUX_TOKEN", "INFLUXDB_TOKEN"]).unwrap_or_default();
settings.history_threshold_days = env_u32("GREE_CONTROLLER_INFLUX_THRESHOLD_DAYS").unwrap_or(30).clamp(1, 3650);
settings
} }
+118 -1
View File
@@ -5,7 +5,7 @@ use rusqlite::{params, Connection, OptionalExtension};
use serde::{de::DeserializeOwned, Serialize}; use serde::{de::DeserializeOwned, Serialize};
use serde_json::Value; use serde_json::Value;
use crate::{ use crate::{
models::{ApiTokenInfo, Automation, Device, EventLog, HaReading, Reading, RuntimeSettings, Schedule, Zone, ZoneReading}, models::{ApiTokenInfo, Automation, ConfigurationExport, Device, EventLog, HaReading, Reading, RuntimeSettings, Schedule, Zone, ZoneReading},
queries, queries,
}; };
@@ -252,6 +252,40 @@ impl Db {
}) })
} }
pub fn history_before(&self, before: DateTime<Utc>, limit_per_family: u32) -> Result<(Vec<Reading>, Vec<ZoneReading>, Vec<HaReading>)> {
let conn = self.lock()?;
let limit = limit_per_family.clamp(1, 5_000) as i64;
let before = before.to_rfc3339();
let devices = {
let mut stmt = conn.prepare(queries::LIST_DEVICE_HISTORY_BEFORE)?;
let rows = stmt.query_map(params![before.clone(), limit], Self::map_reading)?;
rows.collect::<rusqlite::Result<Vec<_>>>()?
};
let zones = {
let mut stmt = conn.prepare(queries::LIST_ZONE_HISTORY_BEFORE)?;
let rows = stmt.query_map(params![before.clone(), limit], Self::map_zone_reading)?;
rows.collect::<rusqlite::Result<Vec<_>>>()?
};
let ha = {
let mut stmt = conn.prepare(queries::LIST_HA_HISTORY_BEFORE)?;
let rows = stmt.query_map(params![before, limit], Self::map_ha_reading)?;
rows.collect::<rusqlite::Result<Vec<_>>>()?
};
Ok((devices, zones, ha))
}
pub fn delete_history_batch(&self, devices: &[Reading], zones: &[ZoneReading], ha: &[HaReading]) -> Result<u64> {
let mut conn = self.lock()?;
let tx = conn.transaction()?;
let mut changed = 0_u64;
for row in devices { changed += tx.execute(queries::DELETE_READING_BY_ID, [row.id])? as u64; }
for row in zones { changed += tx.execute(queries::DELETE_ZONE_READING_BY_ID, [row.id])? as u64; }
for row in ha { changed += tx.execute(queries::DELETE_HA_READING_BY_ID, [row.id])? as u64; }
tx.commit()?;
Ok(changed)
}
pub fn prune_readings(&self, retention_days: i64) -> Result<u64> { pub fn prune_readings(&self, retention_days: i64) -> Result<u64> {
let before = Utc::now() - Duration::days(retention_days.max(1)); let before = Utc::now() - Duration::days(retention_days.max(1));
let conn = self.lock()?; let conn = self.lock()?;
@@ -261,6 +295,31 @@ impl Db {
Ok(device + zone + ha) Ok(device + zone + ha)
} }
/// Compact history to the same practical resolution used by charts.
/// 1-7 days: one sample / 10 minutes, 7+ days: one sample / 30 minutes.
pub fn compact_history(&self, retention_days: i64) -> Result<u64> {
let now = Utc::now();
let one_day = now - Duration::days(1);
let seven_days = now - Duration::days(7);
let retention = now - Duration::days(retention_days.max(1));
let conn = self.lock()?;
let mut changed = 0_u64;
for (bucket, older_than, newer_than) in [
(600_i64, one_day, seven_days),
(1800_i64, seven_days, retention),
] {
if older_than <= newer_than { continue; }
let args = params![bucket, older_than.to_rfc3339(), newer_than.to_rfc3339()];
changed += conn.execute(queries::COMPACT_DEVICE_HISTORY, args)? as u64;
let args = params![bucket, older_than.to_rfc3339(), newer_than.to_rfc3339()];
changed += conn.execute(queries::COMPACT_ZONE_HISTORY, args)? as u64;
let args = params![bucket, older_than.to_rfc3339(), newer_than.to_rfc3339()];
changed += conn.execute(queries::COMPACT_HA_HISTORY, args)? as u64;
}
conn.execute_batch("PRAGMA optimize;")?;
Ok(changed)
}
pub fn add_zone_reading_if_due(&self, reading: &ZoneReading, min_interval_seconds: i64) -> Result<bool> { pub fn add_zone_reading_if_due(&self, reading: &ZoneReading, min_interval_seconds: i64) -> Result<bool> {
let cutoff = reading.timestamp.clone() - Duration::seconds(min_interval_seconds.max(1)); let cutoff = reading.timestamp.clone() - Duration::seconds(min_interval_seconds.max(1));
let conn = self.lock()?; let conn = self.lock()?;
@@ -451,6 +510,44 @@ impl Db {
Ok(conn.execute(queries::DELETE_API_TOKEN, [id])? > 0) Ok(conn.execute(queries::DELETE_API_TOKEN, [id])? > 0)
} }
pub fn export_configuration(&self, settings: RuntimeSettings) -> Result<ConfigurationExport> {
Ok(ConfigurationExport {
format_version: 1,
exported_at: Utc::now(),
settings,
devices: self.list_devices()?,
zones: self.list_zones()?,
schedules: self.list_schedules()?,
automations: self.list_automations()?,
})
}
pub fn replace_configuration(&self, export: &ConfigurationExport) -> Result<()> {
let mut conn = self.lock()?;
let tx = conn.transaction()?;
tx.execute_batch(queries::CLEAR_CONFIGURATION)?;
for device in &export.devices {
let payload = Self::to_json(device)?;
tx.execute(queries::UPSERT_DEVICE, params![device.id, device.mac, device.name, device.ip, device.simulated as i64, payload, device.updated_at.to_rfc3339()])?;
}
for zone in &export.zones {
let payload = Self::to_json(zone)?;
tx.execute(queries::UPSERT_ZONE, params![zone.id, payload, zone.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()])?;
}
for item in &export.automations {
let payload = Self::to_json(item)?;
tx.execute(queries::UPSERT_AUTOMATION, params![item.id, payload, item.updated_at.to_rfc3339()])?;
}
let settings_json = Self::to_json(&export.settings)?;
tx.execute(queries::UPSERT_RUNTIME_SETTINGS, params![settings_json, Utc::now().to_rfc3339()])?;
tx.commit()?;
Ok(())
}
pub fn load_runtime_settings(&self) -> Result<Option<RuntimeSettings>> { pub fn load_runtime_settings(&self) -> Result<Option<RuntimeSettings>> {
let conn = self.lock()?; let conn = self.lock()?;
let value: Option<String> = conn.query_row(queries::LOAD_RUNTIME_SETTINGS, [], |row| row.get(0)).optional()?; let value: Option<String> = conn.query_row(queries::LOAD_RUNTIME_SETTINGS, [], |row| row.get(0)).optional()?;
@@ -473,6 +570,26 @@ mod tests {
use super::*; use super::*;
use crate::models::{ApiTokenInfo, Device, HaReading, Reading}; use crate::models::{ApiTokenInfo, Device, HaReading, Reading};
#[test]
fn history_compaction_keeps_one_sample_per_old_bucket() {
let dir = tempfile::tempdir().unwrap();
let db = Db::open(&dir.path().join("compact.db")).unwrap();
let device = Device::simulated_default();
db.save_device(&device).unwrap();
let seconds = (Utc::now().timestamp() - 2 * 86_400) / 600 * 600;
let base = DateTime::<Utc>::from_timestamp(seconds, 0).unwrap();
for offset in [10_i64, 20_i64] {
db.add_reading(&Reading {
id: 0, device_id: device.id.clone(), timestamp: base + Duration::seconds(offset),
indoor_temperature: Some(22.0), outdoor_temperature: None, target_temperature: 23.0,
power: true, source: "gree".into(),
}).unwrap();
}
assert_eq!(db.history_counts().unwrap().0, 2);
assert_eq!(db.compact_history(30).unwrap(), 1);
assert_eq!(db.history_counts().unwrap().0, 1);
}
#[test] #[test]
fn sqlite_round_trip() { fn sqlite_round_trip() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
+254 -14
View File
@@ -6,7 +6,8 @@ use tokio::time::sleep;
use crate::{ use crate::{
error::AppError, error::AppError,
home_assistant, home_assistant,
models::{Automation, Device, DeviceCommand, HaReading, Reading, Schedule, Zone, ZoneReading}, influxdb,
models::{Automation, AutomationPlanRule, ControlPlan, ControlPlanEvent, Device, DeviceCommand, HaReading, Reading, Schedule, Zone, ZoneControlPlan, ZoneReading},
state::AppState, state::AppState,
}; };
@@ -40,23 +41,67 @@ pub fn start(state: AppState) {
let maintenance_state = state; let maintenance_state = state;
tokio::spawn(async move { tokio::spawn(async move {
sleep(Duration::from_secs(60)).await;
loop { loop {
sleep(Duration::from_secs(6 * 60 * 60)).await; let settings = maintenance_state.settings.read().await.clone();
match maintenance_state.db.prune_readings(30) { // When InfluxDB is enabled, compact all locally retained legacy history before
Ok(count) if count > 0 => tracing::info!(count, "old readings pruned"), // transferring old buckets. Without Influx, compact only the configured retention window.
Ok(_) => {} let compaction_days = if settings.influxdb.enabled { 3650 } else { settings.history_retention_days.max(1) } as i64;
Err(err) => tracing::warn!(error=?err, "cannot prune readings"), if settings.history_compaction_enabled {
match maintenance_state.db.compact_history(compaction_days) {
Ok(count) if count > 0 => tracing::info!(count, "history samples compacted"),
Ok(_) => {}
Err(err) => tracing::warn!(error=?err, "cannot compact history"),
}
} }
if settings.influxdb.enabled {
match archive_old_history(&maintenance_state, settings.influxdb.history_threshold_days.max(1)).await {
Ok(count) if count > 0 => tracing::info!(count, "old local readings archived to InfluxDB and removed from SQLite"),
Ok(_) => {}
Err(err) => tracing::warn!(error=?err, "cannot archive old history to InfluxDB; SQLite copies were kept"),
}
} else {
let retention_days = settings.history_retention_days.max(1) as i64;
match maintenance_state.db.prune_readings(retention_days) {
Ok(count) if count > 0 => tracing::info!(count, retention_days, "old local readings pruned"),
Ok(_) => {}
Err(err) => tracing::warn!(error=?err, "cannot prune readings"),
}
}
sleep(Duration::from_secs(6 * 60 * 60)).await;
} }
}); });
} }
async fn archive_old_history(state: &AppState, threshold_days: u32) -> Result<u64> {
let cutoff = Utc::now() - chrono::Duration::days(threshold_days.max(1) as i64);
let settings = state.settings.read().await.influxdb.clone();
let mut moved = 0_u64;
// Bound one maintenance pass so a very large legacy database never monopolizes the runtime.
// Successful batches are deleted from SQLite, so the next pass naturally continues forward.
for _ in 0..50 {
let (devices, zones, ha) = state.db.history_before(cutoff, 1_000)?;
if devices.is_empty() && zones.is_empty() && ha.is_empty() { break; }
influxdb::write_batch(&state.http, &settings, &devices, &zones, &ha).await?;
let deleted = state.db.delete_history_batch(&devices, &zones, &ha)?;
moved += deleted;
if deleted == 0 { break; }
}
Ok(moved)
}
pub async fn send_command(state: &AppState, device_id: &str, command: DeviceCommand) -> Result<Device, AppError> { pub async fn send_command(state: &AppState, device_id: &str, command: DeviceCommand) -> Result<Device, AppError> {
validate_command(&command)?; validate_command(&command)?;
let mut device = state.db.get_device(device_id)? let mut device = state.db.get_device(device_id)?
.ok_or_else(|| AppError::NotFound(format!("device {device_id}")))?; .ok_or_else(|| AppError::NotFound(format!("device {device_id}")))?;
if !device.enabled { return Err(AppError::BadRequest("device is disabled".into())); } if !device.enabled { return Err(AppError::BadRequest("device is disabled".into())); }
// Do not wake/beep a unit for fields that already match the last known state.
// Offline devices still receive the full request because their cached state may be stale.
let command = if device.online { command.changed_from(&device) } else { command };
if command.is_empty() { return Ok(device); }
let suppress_beep = state.settings.read().await.suppress_device_beep;
if device.simulated { if device.simulated {
command.apply(&mut device); command.apply(&mut device);
device.online = true; device.online = true;
@@ -79,7 +124,7 @@ pub async fn send_command(state: &AppState, device_id: &str, command: DeviceComm
} }
} }
} }
if let Err(first_err) = state.gree.command(&device, &command).await { if let Err(first_err) = state.gree.command(&device, &command, suppress_beep).await {
// Retry once after a fresh bind. This covers stale keys and devices that // Retry once after a fresh bind. This covers stale keys and devices that
// switched between ECB/GCM after a firmware update. // switched between ECB/GCM after a firmware update.
let retry_result = match state.gree.bind(&device).await { let retry_result = match state.gree.bind(&device).await {
@@ -87,7 +132,7 @@ pub async fn send_command(state: &AppState, device_id: &str, command: DeviceComm
device.key = Some(bound.key); device.key = Some(bound.key);
device.protocol_version = bound.protocol_version; device.protocol_version = bound.protocol_version;
state.db.save_device(&device)?; state.db.save_device(&device)?;
state.gree.command(&device, &command).await state.gree.command(&device, &command, suppress_beep).await
} }
Err(_) => Err(first_err), Err(_) => Err(first_err),
}; };
@@ -198,7 +243,7 @@ fn simulate_tick(device: &mut Device) {
} }
fn record_reading(state: &AppState, device: &Device) -> Result<()> { fn record_reading(state: &AppState, device: &Device) -> Result<()> {
state.db.add_reading(&Reading { let reading = Reading {
id: 0, id: 0,
device_id: device.id.clone(), device_id: device.id.clone(),
timestamp: Utc::now(), timestamp: Utc::now(),
@@ -207,7 +252,9 @@ fn record_reading(state: &AppState, device: &Device) -> Result<()> {
target_temperature: device.target_temperature, target_temperature: device.target_temperature,
power: device.power, power: device.power,
source: if device.simulated { "simulator".into() } else { "gree".into() }, source: if device.simulated { "simulator".into() } else { "gree".into() },
})?; };
state.db.add_reading(&reading)?;
queue_influx_device(state, reading);
Ok(()) Ok(())
} }
@@ -476,8 +523,10 @@ fn record_zone_history(state: &AppState, zone: &Zone, outdoor_temperature: Optio
active_preset: zone.active_preset.clone(), active_preset: zone.active_preset.clone(),
}; };
let interval = poll_interval_seconds.max(15) as i64; let interval = poll_interval_seconds.max(15) as i64;
if let Err(err) = state.db.add_zone_reading_if_due(&reading, interval) { match state.db.add_zone_reading_if_due(&reading, interval) {
tracing::warn!(error=?err, zone_id=%zone.id, "cannot save zone history sample"); Ok(true) => queue_influx_zone(state, reading),
Ok(false) => {}
Err(err) => tracing::warn!(error=?err, zone_id=%zone.id, "cannot save zone history sample"),
} }
} }
@@ -498,11 +547,46 @@ fn record_ha_history(
temperature, temperature,
}; };
let interval = poll_interval_seconds.max(15) as i64; let interval = poll_interval_seconds.max(15) as i64;
if let Err(err) = state.db.add_ha_reading_if_due(&reading, interval) { match state.db.add_ha_reading_if_due(&reading, interval) {
tracing::warn!(error=?err, entity_id=%entity_id, "cannot save Home Assistant history sample"); Ok(true) => queue_influx_ha(state, reading),
Ok(false) => {}
Err(err) => tracing::warn!(error=?err, entity_id=%entity_id, "cannot save Home Assistant history sample"),
} }
} }
fn queue_influx_device(state: &AppState, reading: Reading) {
let state = state.clone();
tokio::spawn(async move {
let settings = state.settings.read().await.influxdb.clone();
if !settings.enabled { return; }
if let Err(err) = influxdb::write_device(&state.http, &settings, &reading).await {
tracing::warn!(error=?err, device_id=%reading.device_id, "cannot write device metric to InfluxDB");
}
});
}
fn queue_influx_zone(state: &AppState, reading: ZoneReading) {
let state = state.clone();
tokio::spawn(async move {
let settings = state.settings.read().await.influxdb.clone();
if !settings.enabled { return; }
if let Err(err) = influxdb::write_zone(&state.http, &settings, &reading).await {
tracing::warn!(error=?err, zone_id=%reading.zone_id, "cannot write zone metric to InfluxDB");
}
});
}
fn queue_influx_ha(state: &AppState, reading: HaReading) {
let state = state.clone();
tokio::spawn(async move {
let settings = state.settings.read().await.influxdb.clone();
if !settings.enabled { return; }
if let Err(err) = influxdb::write_ha(&state.http, &settings, &reading).await {
tracing::warn!(error=?err, entity_id=%reading.entity_id, "cannot write HA metric to InfluxDB");
}
});
}
fn select_zone_temperature(zone: &Zone, device_temperature: Option<f64>, external_temperature: Option<f64>) -> (Option<f64>, String, bool) { fn select_zone_temperature(zone: &Zone, device_temperature: Option<f64>, external_temperature: Option<f64>) -> (Option<f64>, String, bool) {
match zone.sensor_source.as_str() { match zone.sensor_source.as_str() {
"home_assistant" => match (external_temperature, device_temperature) { "home_assistant" => match (external_temperature, device_temperature) {
@@ -649,6 +733,143 @@ fn previous_weekday(day: Weekday) -> Weekday {
} }
} }
pub async fn build_control_plan(state: &AppState) -> Result<ControlPlan, AppError> {
let settings = state.settings.read().await.clone();
let schedules = state.db.list_schedules()?;
let devices = state.db.list_devices()?;
let now = Local::now();
let mut zones_out = Vec::new();
let mut house_events = Vec::new();
for zone in state.db.list_zones()? {
let device = devices.iter().find(|item| item.id == zone.device_id);
let effective_mode = if settings.house_mode == "off" {
"off"
} else if zone.inherit_house_mode {
settings.house_mode.as_str()
} else {
zone.mode.as_str()
};
let active = active_schedule_for_zone(&zone, &schedules, now);
let (preset, target) = if effective_mode == "off" {
("off".to_string(), None)
} else {
let (preset, target) = resolve_zone_target(&zone, active, effective_mode);
(preset, Some(target))
};
let next_events = next_schedule_events(&zone, &schedules, effective_mode, now, 8);
for event in next_events.iter().take(2) {
let mut event = event.clone();
event.label = format!("{}: {}", zone.name, event.label);
house_events.push(event);
}
zones_out.push(ZoneControlPlan {
zone_id: zone.id.clone(),
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,
mode: effective_mode.to_string(),
preset: if effective_mode == "off" { "off".into() } else if zone.active_preset.is_empty() { preset } else { zone.active_preset.clone() },
current_temperature: zone.current_temperature,
target_temperature: if effective_mode == "off" { None } else { zone.effective_setpoint.or(target) },
device_setpoint: zone.device_setpoint.or_else(|| device.map(|item| item.target_temperature)),
demand: zone.enabled && effective_mode != "off" && zone.demand,
control_source: zone.control_temperature_source.clone(),
manual_override_until: zone.manual_override_until,
current_schedule_id: active.map(|item| item.id.clone()),
current_schedule_name: active.map(|item| item.name.clone()),
next_events,
});
}
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 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" {
if let Some(event) = next_time_automation_event(&item, &action_name, now) {
house_events.push(event);
}
}
rules.push(AutomationPlanRule {
id: item.id,
name: item.name,
enabled: item.enabled,
trigger_kind: item.trigger_kind,
trigger_device_id: item.trigger_device_id,
trigger_device_name: trigger_name,
threshold: item.threshold,
at_time: item.at_time,
action_device_id: item.action_device_id,
action_device_name: action_name,
action: item.action,
last_fired_at: item.last_fired_at,
next_ready_at,
});
}
house_events.sort_by_key(|event| event.at);
house_events.truncate(12);
Ok(ControlPlan {
generated_at: Utc::now(),
house_mode: settings.house_mode,
outdoor_temperature: *state.outdoor_temperature.read().await,
control_strategy: settings.control_strategy,
next_events: house_events,
zones: zones_out,
rules,
})
}
fn next_time_automation_event(item: &Automation, action_name: &str, now: DateTime<Local>) -> Option<ControlPlanEvent> {
let expected = NaiveTime::parse_from_str(item.at_time.as_deref()?, "%H:%M").ok()?;
for minute in 1..=(24 * 60) {
let candidate = now + chrono::Duration::minutes(minute);
if candidate.hour() == expected.hour() && candidate.minute() == expected.minute() {
return Some(ControlPlanEvent {
at: candidate.with_timezone(&Utc),
kind: "automation".into(),
label: format!("{} -> {}", item.name, action_name),
preset: None,
target_temperature: item.action.target_temperature,
});
}
}
None
}
fn next_schedule_events(zone: &Zone, schedules: &[Schedule], mode: &str, now: DateTime<Local>, limit: usize) -> Vec<ControlPlanEvent> {
if mode == "off" { return Vec::new(); }
let mut events = Vec::new();
let mut current = active_schedule_for_zone(zone, schedules, now).map(|item| item.id.as_str());
for minute in 1..=(8 * 24 * 60) {
let candidate = now + chrono::Duration::minutes(minute);
let next = active_schedule_for_zone(zone, schedules, candidate);
let next_id = next.map(|item| item.id.as_str());
if next_id == current { continue; }
current = next_id;
let (preset, target, label) = if let Some(item) = next {
let target = if item.preset == "custom" { item.setpoint } else { profile_setpoint(zone, &item.preset, mode) };
(Some(item.preset.clone()), Some(target), format!("{} -> {} {:.1} C", item.name, item.preset, target))
} else {
let target = profile_setpoint(zone, "comfort", mode);
(Some("comfort".into()), Some(target), format!("comfort {:.1} C", target))
};
events.push(ControlPlanEvent {
at: candidate.with_timezone(&Utc),
kind: "schedule_transition".into(),
label,
preset,
target_temperature: target,
});
if events.len() >= limit { break; }
}
events
}
async fn run_automations(state: &AppState) -> Result<()> { async fn run_automations(state: &AppState) -> Result<()> {
let devices = state.db.list_devices()?; let devices = state.db.list_devices()?;
for mut item in state.db.list_automations()? { for mut item in state.db.list_automations()? {
@@ -721,6 +942,25 @@ mod tests {
} }
} }
#[test]
fn device_command_drops_unchanged_fields() {
let device = Device::simulated_default();
let command = DeviceCommand {
power: Some(false),
mode: Some("cool".into()),
target_temperature: Some(23.4),
fan_speed: Some(3),
light: Some(false),
..DeviceCommand::default()
};
let changed = command.changed_from(&device);
assert_eq!(changed.power, None);
assert_eq!(changed.mode, None);
assert_eq!(changed.target_temperature, None);
assert_eq!(changed.fan_speed, Some(3));
assert_eq!(changed.light, Some(false));
}
#[test] #[test]
fn combined_temperature_prefers_room_sensor_weight() { fn combined_temperature_prefers_room_sensor_weight() {
let zone = test_zone("combined"); let zone = test_zone("combined");
+403
View File
@@ -0,0 +1,403 @@
use anyhow::{anyhow, bail, Context, Result};
use chrono::{DateTime, Utc};
use reqwest::Client;
use serde_json::Value;
use std::collections::HashMap;
use crate::models::{HaReading, InfluxDbSettings, Reading, ZoneReading};
const DEVICE_MEASUREMENT: &str = "gree_device";
const ZONE_MEASUREMENT: &str = "gree_zone";
const HA_MEASUREMENT: &str = "gree_ha";
pub fn validate(settings: &InfluxDbSettings) -> Result<()> {
if !settings.enabled { return Ok(()); }
if !matches!(settings.version.as_str(), "1" | "2") { bail!("InfluxDB version must be 1 or 2"); }
let parsed = url::Url::parse(settings.url.trim()).context("invalid InfluxDB URL")?;
if !matches!(parsed.scheme(), "http" | "https") { bail!("InfluxDB URL must use http or https"); }
if settings.version == "1" && settings.database.trim().is_empty() { bail!("InfluxDB 1.x database is required"); }
if settings.version == "2" {
if settings.org.trim().is_empty() { bail!("InfluxDB 2.x organization is required"); }
if settings.bucket.trim().is_empty() { bail!("InfluxDB 2.x bucket is required"); }
if settings.token.trim().is_empty() { bail!("InfluxDB 2.x token is required"); }
}
Ok(())
}
pub async fn write_device(client: &Client, settings: &InfluxDbSettings, reading: &Reading) -> Result<()> {
if !settings.enabled { return Ok(()); }
let mut fields = Vec::new();
push_float(&mut fields, "indoor_temperature", reading.indoor_temperature);
push_float(&mut fields, "outdoor_temperature", reading.outdoor_temperature);
push_float(&mut fields, "target_temperature", Some(reading.target_temperature));
push_int(&mut fields, "power", reading.power as i64);
let line = line_protocol(DEVICE_MEASUREMENT, &[("device_id", &reading.device_id)], fields, reading.timestamp)?;
write_line(client, settings, line).await
}
pub async fn write_zone(client: &Client, settings: &InfluxDbSettings, reading: &ZoneReading) -> Result<()> {
if !settings.enabled { return Ok(()); }
let mut fields = Vec::new();
push_float(&mut fields, "gree_temperature", reading.gree_temperature);
push_float(&mut fields, "external_temperature", reading.external_temperature);
push_float(&mut fields, "control_temperature", reading.control_temperature);
push_float(&mut fields, "target_temperature", reading.target_temperature);
push_float(&mut fields, "device_setpoint", reading.device_setpoint);
push_float(&mut fields, "outdoor_temperature", reading.outdoor_temperature);
push_int(&mut fields, "power", reading.power as i64);
push_int(&mut fields, "fan_speed", reading.fan_speed as i64);
push_int(&mut fields, "demand", reading.demand as i64);
let line = line_protocol(
ZONE_MEASUREMENT,
&[("zone_id", &reading.zone_id), ("device_id", &reading.device_id)],
fields,
reading.timestamp,
)?;
write_line(client, settings, line).await
}
pub async fn write_ha(client: &Client, settings: &InfluxDbSettings, reading: &HaReading) -> Result<()> {
if !settings.enabled { return Ok(()); }
let zone = reading.zone_id.as_deref().unwrap_or("");
let mut fields = Vec::new();
push_float(&mut fields, "temperature", Some(reading.temperature));
let line = line_protocol(
HA_MEASUREMENT,
&[("entity_id", &reading.entity_id), ("zone_id", zone), ("kind", &reading.kind)],
fields,
reading.timestamp,
)?;
write_line(client, settings, line).await
}
pub async fn write_batch(
client: &Client,
settings: &InfluxDbSettings,
devices: &[Reading],
zones: &[ZoneReading],
ha: &[HaReading],
) -> Result<()> {
if !settings.enabled { return Ok(()); }
let mut lines = Vec::with_capacity(devices.len() + zones.len() + ha.len());
for reading in devices {
let mut fields = Vec::new();
push_float(&mut fields, "indoor_temperature", reading.indoor_temperature);
push_float(&mut fields, "outdoor_temperature", reading.outdoor_temperature);
push_float(&mut fields, "target_temperature", Some(reading.target_temperature));
push_int(&mut fields, "power", reading.power as i64);
lines.push(line_protocol(DEVICE_MEASUREMENT, &[("device_id", &reading.device_id)], fields, reading.timestamp)?);
}
for reading in zones {
let mut fields = Vec::new();
push_float(&mut fields, "gree_temperature", reading.gree_temperature);
push_float(&mut fields, "external_temperature", reading.external_temperature);
push_float(&mut fields, "control_temperature", reading.control_temperature);
push_float(&mut fields, "target_temperature", reading.target_temperature);
push_float(&mut fields, "device_setpoint", reading.device_setpoint);
push_float(&mut fields, "outdoor_temperature", reading.outdoor_temperature);
push_int(&mut fields, "power", reading.power as i64);
push_int(&mut fields, "fan_speed", reading.fan_speed as i64);
push_int(&mut fields, "demand", reading.demand as i64);
lines.push(line_protocol(ZONE_MEASUREMENT, &[("zone_id", &reading.zone_id), ("device_id", &reading.device_id)], fields, reading.timestamp)?);
}
for reading in ha {
let mut fields = Vec::new();
push_float(&mut fields, "temperature", Some(reading.temperature));
lines.push(line_protocol(HA_MEASUREMENT, &[("entity_id", &reading.entity_id), ("zone_id", reading.zone_id.as_deref().unwrap_or("")), ("kind", &reading.kind)], fields, reading.timestamp)?);
}
if lines.is_empty() { return Ok(()); }
write_lines(client, settings, lines.join("\n")).await
}
async fn write_line(client: &Client, settings: &InfluxDbSettings, line: String) -> Result<()> {
write_lines(client, settings, line).await
}
async fn write_lines(client: &Client, settings: &InfluxDbSettings, body: String) -> Result<()> {
validate(settings)?;
let base = settings.url.trim_end_matches('/');
let request = if settings.version == "1" {
let request = client.post(format!("{base}/write"))
.query(&[("db", settings.database.as_str()), ("precision", "ns")])
.header(reqwest::header::CONTENT_TYPE, "text/plain; charset=utf-8")
.body(body.clone());
if settings.username.trim().is_empty() { request } else { request.basic_auth(&settings.username, Some(&settings.password)) }
} else {
client.post(format!("{base}/api/v2/write"))
.query(&[("org", settings.org.as_str()), ("bucket", settings.bucket.as_str()), ("precision", "ns")])
.bearer_auth(settings.token.trim())
.header(reqwest::header::CONTENT_TYPE, "text/plain; charset=utf-8")
.body(body)
};
let response = request.send().await.context("InfluxDB write request failed")?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
bail!("InfluxDB write failed ({status}): {}", truncate(&body, 300));
}
Ok(())
}
pub async fn query_devices(
client: &Client,
settings: &InfluxDbSettings,
device_id: Option<&str>,
start: DateTime<Utc>,
stop: DateTime<Utc>,
bucket_seconds: i64,
limit: u32,
) -> Result<Vec<Reading>> {
if settings.version == "1" {
query_devices_v1(client, settings, device_id, start, stop, bucket_seconds, limit).await
} else {
let tags = if let Some(value) = device_id { format!(" |> filter(fn: (r) => r.device_id == {})", flux_string(value)) } else { String::new() };
let query = flux_query(settings, DEVICE_MEASUREMENT, &tags, &["device_id"], start, stop, bucket_seconds);
let rows = query_v2(client, settings, &query).await?;
let mut out = Vec::new();
for row in rows.into_iter().take(limit as usize) {
let Some(timestamp) = parse_flux_time(&row) else { continue; };
let Some(id) = row.get("device_id").filter(|v| !v.is_empty()) else { continue; };
out.push(Reading {
id: 0,
device_id: id.clone(),
timestamp,
indoor_temperature: row_f64(&row, "indoor_temperature"),
outdoor_temperature: row_f64(&row, "outdoor_temperature"),
target_temperature: row_f64(&row, "target_temperature").unwrap_or(0.0),
power: row_f64(&row, "power").unwrap_or(0.0) >= 0.5,
source: "influx".into(),
});
}
out.sort_by_key(|row| row.timestamp);
Ok(out)
}
}
pub async fn query_zones(
client: &Client,
settings: &InfluxDbSettings,
zone_id: Option<&str>,
start: DateTime<Utc>,
stop: DateTime<Utc>,
bucket_seconds: i64,
limit: u32,
) -> Result<Vec<ZoneReading>> {
if settings.version == "1" {
query_zones_v1(client, settings, zone_id, start, stop, bucket_seconds, limit).await
} else {
let tags = if let Some(value) = zone_id { format!(" |> filter(fn: (r) => r.zone_id == {})", flux_string(value)) } else { String::new() };
let query = flux_query(settings, ZONE_MEASUREMENT, &tags, &["zone_id", "device_id"], start, stop, bucket_seconds);
let rows = query_v2(client, settings, &query).await?;
let mut out = Vec::new();
for row in rows.into_iter().take(limit as usize) {
let Some(timestamp) = parse_flux_time(&row) else { continue; };
let Some(zone) = row.get("zone_id").filter(|v| !v.is_empty()) else { continue; };
out.push(ZoneReading {
id: 0,
zone_id: zone.clone(),
device_id: row.get("device_id").cloned().unwrap_or_default(),
timestamp,
gree_temperature: row_f64(&row, "gree_temperature"),
external_temperature: row_f64(&row, "external_temperature"),
control_temperature: row_f64(&row, "control_temperature"),
target_temperature: row_f64(&row, "target_temperature"),
device_setpoint: row_f64(&row, "device_setpoint"),
outdoor_temperature: row_f64(&row, "outdoor_temperature"),
power: row_f64(&row, "power").unwrap_or(0.0) >= 0.5,
mode: "history".into(),
fan_speed: row_f64(&row, "fan_speed").unwrap_or(0.0).round().clamp(0.0, 5.0) as u8,
demand: row_f64(&row, "demand").unwrap_or(0.0) >= 0.5,
control_source: "influx".into(),
active_preset: "history".into(),
});
}
out.sort_by_key(|row| row.timestamp);
Ok(out)
}
}
pub async fn query_ha(
client: &Client,
settings: &InfluxDbSettings,
entity_id: Option<&str>,
start: DateTime<Utc>,
stop: DateTime<Utc>,
bucket_seconds: i64,
limit: u32,
) -> Result<Vec<HaReading>> {
if settings.version == "1" {
query_ha_v1(client, settings, entity_id, start, stop, bucket_seconds, limit).await
} else {
let tags = if let Some(value) = entity_id { format!(" |> filter(fn: (r) => r.entity_id == {})", flux_string(value)) } else { String::new() };
let query = flux_query(settings, HA_MEASUREMENT, &tags, &["entity_id", "zone_id", "kind"], start, stop, bucket_seconds);
let rows = query_v2(client, settings, &query).await?;
let mut out = Vec::new();
for row in rows.into_iter().take(limit as usize) {
let Some(timestamp) = parse_flux_time(&row) else { continue; };
let Some(entity) = row.get("entity_id").filter(|v| !v.is_empty()) else { continue; };
let Some(temperature) = row_f64(&row, "temperature") else { continue; };
out.push(HaReading {
id: 0,
entity_id: entity.clone(),
zone_id: row.get("zone_id").filter(|v| !v.is_empty()).cloned(),
kind: row.get("kind").cloned().unwrap_or_else(|| "room".into()),
timestamp,
temperature,
});
}
out.sort_by_key(|row| row.timestamp);
Ok(out)
}
}
async fn query_devices_v1(client: &Client, settings: &InfluxDbSettings, device_id: Option<&str>, start: DateTime<Utc>, stop: DateTime<Utc>, bucket: i64, limit: u32) -> Result<Vec<Reading>> {
let filter = device_id.map(|id| format!(" AND \"device_id\"='{}'", influxql_string(id))).unwrap_or_default();
let q = format!("SELECT mean(\"indoor_temperature\") AS \"indoor_temperature\",mean(\"outdoor_temperature\") AS \"outdoor_temperature\",mean(\"target_temperature\") AS \"target_temperature\",max(\"power\") AS \"power\" FROM \"{DEVICE_MEASUREMENT}\" WHERE time >= '{}' AND time < '{}'{} GROUP BY time({}s),\"device_id\" fill(none) LIMIT {}", start.to_rfc3339(), stop.to_rfc3339(), filter, bucket.max(1), limit);
let series = query_v1(client, settings, &q).await?;
let mut out = Vec::new();
for item in series {
let device = item.tags.get("device_id").cloned().unwrap_or_default();
for row in item.rows {
let Some(timestamp) = row_time(&row) else { continue; };
out.push(Reading { id:0, device_id:device.clone(), timestamp, indoor_temperature:row_num(&row,"indoor_temperature"), outdoor_temperature:row_num(&row,"outdoor_temperature"), target_temperature:row_num(&row,"target_temperature").unwrap_or(0.0), power:row_num(&row,"power").unwrap_or(0.0)>=0.5, source:"influx".into() });
}
}
out.sort_by_key(|row| row.timestamp); out.truncate(limit as usize); Ok(out)
}
async fn query_zones_v1(client: &Client, settings: &InfluxDbSettings, zone_id: Option<&str>, start: DateTime<Utc>, stop: DateTime<Utc>, bucket: i64, limit: u32) -> Result<Vec<ZoneReading>> {
let filter = zone_id.map(|id| format!(" AND \"zone_id\"='{}'", influxql_string(id))).unwrap_or_default();
let q = format!("SELECT mean(\"gree_temperature\") AS \"gree_temperature\",mean(\"external_temperature\") AS \"external_temperature\",mean(\"control_temperature\") AS \"control_temperature\",mean(\"target_temperature\") AS \"target_temperature\",mean(\"device_setpoint\") AS \"device_setpoint\",mean(\"outdoor_temperature\") AS \"outdoor_temperature\",max(\"power\") AS \"power\",mean(\"fan_speed\") AS \"fan_speed\",max(\"demand\") AS \"demand\" FROM \"{ZONE_MEASUREMENT}\" WHERE time >= '{}' AND time < '{}'{} GROUP BY time({}s),\"zone_id\",\"device_id\" fill(none) LIMIT {}", start.to_rfc3339(), stop.to_rfc3339(), filter, bucket.max(1), limit);
let series = query_v1(client, settings, &q).await?;
let mut out = Vec::new();
for item in series {
let zone = item.tags.get("zone_id").cloned().unwrap_or_default();
let device = item.tags.get("device_id").cloned().unwrap_or_default();
for row in item.rows {
let Some(timestamp) = row_time(&row) else { continue; };
out.push(ZoneReading { id:0, zone_id:zone.clone(), device_id:device.clone(), timestamp, gree_temperature:row_num(&row,"gree_temperature"), external_temperature:row_num(&row,"external_temperature"), control_temperature:row_num(&row,"control_temperature"), target_temperature:row_num(&row,"target_temperature"), device_setpoint:row_num(&row,"device_setpoint"), outdoor_temperature:row_num(&row,"outdoor_temperature"), power:row_num(&row,"power").unwrap_or(0.0)>=0.5, mode:"history".into(), fan_speed:row_num(&row,"fan_speed").unwrap_or(0.0).round().clamp(0.0,5.0) as u8, demand:row_num(&row,"demand").unwrap_or(0.0)>=0.5, control_source:"influx".into(), active_preset:"history".into() });
}
}
out.sort_by_key(|row| row.timestamp); out.truncate(limit as usize); Ok(out)
}
async fn query_ha_v1(client: &Client, settings: &InfluxDbSettings, entity_id: Option<&str>, start: DateTime<Utc>, stop: DateTime<Utc>, bucket: i64, limit: u32) -> Result<Vec<HaReading>> {
let filter = entity_id.map(|id| format!(" AND \"entity_id\"='{}'", influxql_string(id))).unwrap_or_default();
let q = format!("SELECT mean(\"temperature\") AS \"temperature\" FROM \"{HA_MEASUREMENT}\" WHERE time >= '{}' AND time < '{}'{} GROUP BY time({}s),\"entity_id\",\"zone_id\",\"kind\" fill(none) LIMIT {}", start.to_rfc3339(), stop.to_rfc3339(), filter, bucket.max(1), limit);
let series = query_v1(client, settings, &q).await?;
let mut out = Vec::new();
for item in series {
let entity = item.tags.get("entity_id").cloned().unwrap_or_default();
let zone = item.tags.get("zone_id").filter(|v| !v.is_empty()).cloned();
let kind = item.tags.get("kind").cloned().unwrap_or_else(|| "room".into());
for row in item.rows {
let Some(timestamp) = row_time(&row) else { continue; };
let Some(temperature) = row_num(&row,"temperature") else { continue; };
out.push(HaReading { id:0, entity_id:entity.clone(), zone_id:zone.clone(), kind:kind.clone(), timestamp, temperature });
}
}
out.sort_by_key(|row| row.timestamp); out.truncate(limit as usize); Ok(out)
}
struct V1Series { tags: HashMap<String,String>, rows: Vec<HashMap<String,Value>> }
async fn query_v1(client: &Client, settings: &InfluxDbSettings, q: &str) -> Result<Vec<V1Series>> {
validate(settings)?;
let base = settings.url.trim_end_matches('/');
let request = client.get(format!("{base}/query")).query(&[("db", settings.database.as_str()), ("q", q)]);
let request = if settings.username.trim().is_empty() { request } else { request.basic_auth(&settings.username, Some(&settings.password)) };
let response = request.send().await.context("InfluxDB 1.x query failed")?;
let status = response.status();
let body: Value = response.json().await.context("invalid InfluxDB 1.x JSON response")?;
if !status.is_success() { bail!("InfluxDB 1.x query failed ({status}): {body}"); }
if let Some(error) = body.pointer("/results/0/error").and_then(Value::as_str) { bail!("InfluxDB 1.x query error: {error}"); }
let mut out = Vec::new();
for series in body.pointer("/results/0/series").and_then(Value::as_array).into_iter().flatten() {
let columns: Vec<String> = series.get("columns").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str).map(str::to_owned).collect();
let tags = series.get("tags").and_then(Value::as_object).map(|map| map.iter().map(|(k,v)|(k.clone(),v.as_str().unwrap_or_default().to_string())).collect()).unwrap_or_default();
let mut rows = Vec::new();
for values in series.get("values").and_then(Value::as_array).into_iter().flatten() {
let Some(values) = values.as_array() else { continue; };
rows.push(columns.iter().cloned().zip(values.iter().cloned()).collect());
}
out.push(V1Series { tags, rows });
}
Ok(out)
}
async fn query_v2(client: &Client, settings: &InfluxDbSettings, query: &str) -> Result<Vec<HashMap<String,String>>> {
validate(settings)?;
let base = settings.url.trim_end_matches('/');
let response = client.post(format!("{base}/api/v2/query"))
.query(&[("org", settings.org.as_str())])
.bearer_auth(settings.token.trim())
.header(reqwest::header::ACCEPT, "application/csv")
.header(reqwest::header::CONTENT_TYPE, "application/vnd.flux")
.body(query.to_string())
.send().await.context("InfluxDB 2.x query failed")?;
let status = response.status();
let body = response.text().await.context("cannot read InfluxDB 2.x response")?;
if !status.is_success() { bail!("InfluxDB 2.x query failed ({status}): {}", truncate(&body, 500)); }
let mut headers: Option<Vec<String>> = None;
let mut rows = Vec::new();
for line in body.lines().filter(|line| !line.starts_with('#') && !line.trim().is_empty()) {
let record = parse_csv_line(line);
if headers.is_none() {
headers = Some(record);
continue;
}
let row: HashMap<String,String> = headers.as_ref().unwrap().iter().cloned().zip(record.into_iter()).collect();
if row.get("_time").map(|value| !value.is_empty()).unwrap_or(false) { rows.push(row); }
}
Ok(rows)
}
fn parse_csv_line(line: &str) -> Vec<String> {
let mut out = Vec::new();
let mut field = String::new();
let mut chars = line.chars().peekable();
let mut quoted = false;
while let Some(ch) = chars.next() {
match ch {
'"' if quoted && chars.peek() == Some(&'"') => {
field.push('"');
chars.next();
}
'"' => quoted = !quoted,
',' if !quoted => {
out.push(std::mem::take(&mut field));
}
_ => field.push(ch),
}
}
out.push(field);
out
}
fn flux_query(settings: &InfluxDbSettings, measurement: &str, extra_filters: &str, group_tags: &[&str], start: DateTime<Utc>, stop: DateTime<Utc>, bucket_seconds: i64) -> String {
let tags = group_tags.iter().map(|tag| format!("\"{tag}\"")).collect::<Vec<_>>().join(",");
format!(
"from(bucket: {}) |> range(start: time(v: {}), stop: time(v: {})) |> filter(fn: (r) => r._measurement == {}){} |> aggregateWindow(every: {}s, fn: mean, createEmpty: false) |> group(columns: [{}]) |> pivot(rowKey:[\"_time\"], columnKey:[\"_field\"], valueColumn:\"_value\") |> sort(columns:[\"_time\"])",
flux_string(&settings.bucket), flux_string(&start.to_rfc3339()), flux_string(&stop.to_rfc3339()), flux_string(measurement), extra_filters, bucket_seconds.max(1), tags
)
}
fn line_protocol(measurement: &str, tags: &[(&str, &str)], fields: Vec<String>, timestamp: DateTime<Utc>) -> Result<String> {
if fields.is_empty() { bail!("InfluxDB measurement has no fields"); }
let tags = tags.iter().filter(|(_, value)| !value.is_empty()).map(|(key,value)| format!(",{}={}", escape_tag(key), escape_tag(value))).collect::<String>();
let nanos = timestamp.timestamp_nanos_opt().ok_or_else(|| anyhow!("timestamp outside nanosecond range"))?;
Ok(format!("{}{} {} {}", escape_measurement(measurement), tags, fields.join(","), nanos))
}
fn push_float(fields: &mut Vec<String>, key: &str, value: Option<f64>) { if let Some(value) = value.filter(|v| v.is_finite()) { fields.push(format!("{}={value}", escape_field_key(key))); } }
fn push_int(fields: &mut Vec<String>, key: &str, value: i64) { fields.push(format!("{}={value}i", escape_field_key(key))); }
fn escape_measurement(value: &str) -> String { value.replace('\\', "\\\\").replace(',', "\\,").replace(' ', "\\ ") }
fn escape_tag(value: &str) -> String { value.replace('\\', "\\\\").replace(',', "\\,").replace('=', "\\=").replace(' ', "\\ ") }
fn escape_field_key(value: &str) -> String { escape_tag(value) }
fn influxql_string(value: &str) -> String { value.replace('\\', "\\\\").replace('\'', "\\'") }
fn flux_string(value: &str) -> String { format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\"")) }
fn truncate(value: &str, max: usize) -> String { value.chars().take(max).collect() }
fn row_f64(row: &HashMap<String,String>, key: &str) -> Option<f64> { row.get(key)?.parse().ok() }
fn parse_flux_time(row: &HashMap<String,String>) -> Option<DateTime<Utc>> { DateTime::parse_from_rfc3339(row.get("_time")?).ok().map(|v| v.with_timezone(&Utc)) }
fn row_num(row: &HashMap<String,Value>, key: &str) -> Option<f64> { row.get(key)?.as_f64().or_else(|| row.get(key)?.as_i64().map(|v|v as f64)) }
fn row_time(row: &HashMap<String,Value>) -> Option<DateTime<Utc>> { DateTime::parse_from_rfc3339(row.get("time")?.as_str()?).ok().map(|v|v.with_timezone(&Utc)) }
+8 -2
View File
@@ -4,12 +4,13 @@ mod db;
mod engine; mod engine;
mod error; mod error;
mod home_assistant; mod home_assistant;
mod influxdb;
mod models; mod models;
mod protocol; mod protocol;
mod queries; mod queries;
mod state; mod state;
use std::{sync::Arc, time::{Duration, Instant}}; use std::{sync::{Arc, atomic::AtomicBool}, time::{Duration, Instant}};
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use config::Config; use config::Config;
use db::Db; use db::Db;
@@ -31,6 +32,7 @@ async fn main() -> Result<()> {
if std::env::var_os("GREE_CONTROLLER_DISCOVERY_BROADCAST").is_some() { if std::env::var_os("GREE_CONTROLLER_DISCOVERY_BROADCAST").is_some() {
runtime_settings.discovery_broadcast = config.discovery_broadcast.clone(); runtime_settings.discovery_broadcast = config.discovery_broadcast.clone();
} }
config.apply_runtime_env_overrides(&mut runtime_settings);
db.save_runtime_settings(&runtime_settings)?; db.save_runtime_settings(&runtime_settings)?;
if config.simulate && config.auto_seed && db.count_devices()? == 0 { if config.simulate && config.auto_seed && db.count_devices()? == 0 {
@@ -43,7 +45,8 @@ async fn main() -> Result<()> {
)?; )?;
} }
let (events, _) = broadcast::channel(256); let (events, _) = broadcast::channel(512);
let debug_gree_frames = Arc::new(AtomicBool::new(runtime_settings.debug.gree_frames));
let http = reqwest::Client::builder() let http = reqwest::Client::builder()
.timeout(Duration::from_secs(10)) .timeout(Duration::from_secs(10))
.user_agent(concat!("gree-controller/", env!("CARGO_PKG_VERSION"))) .user_agent(concat!("gree-controller/", env!("CARGO_PKG_VERSION")))
@@ -55,10 +58,13 @@ async fn main() -> Result<()> {
gree: GreeClient::new( gree: GreeClient::new(
runtime_settings.controller_id.clone(), runtime_settings.controller_id.clone(),
(!config.gree_interface.trim().is_empty()).then(|| config.gree_interface.trim().to_string()), (!config.gree_interface.trim().is_empty()).then(|| config.gree_interface.trim().to_string()),
Some(events.clone()),
debug_gree_frames.clone(),
), ),
events, events,
http, http,
outdoor_temperature: Arc::new(RwLock::new(None)), outdoor_temperature: Arc::new(RwLock::new(None)),
debug_gree_frames,
started: Instant::now(), started: Instant::now(),
}; };
+159
View File
@@ -26,6 +26,10 @@ fn default_cool_away() -> f64 { 27.0 }
fn default_heat_comfort() -> f64 { 21.0 } fn default_heat_comfort() -> f64 { 21.0 }
fn default_heat_sleep() -> f64 { 19.0 } fn default_heat_sleep() -> f64 { 19.0 }
fn default_heat_away() -> f64 { 17.0 } fn default_heat_away() -> f64 { 17.0 }
fn default_history_retention_days() -> u32 { 30 }
fn default_influx_version() -> String { "2".into() }
fn default_influx_database() -> String { "gree_controller".into() }
fn default_influx_threshold_days() -> u32 { 30 }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Device { pub struct Device {
@@ -148,6 +152,27 @@ pub struct DeviceCommand {
} }
impl DeviceCommand { impl DeviceCommand {
pub fn is_empty(&self) -> bool {
self.power.is_none() && self.mode.is_none() && self.target_temperature.is_none()
&& self.fan_speed.is_none() && self.swing_vertical.is_none() && self.swing_horizontal.is_none()
&& self.quiet.is_none() && self.turbo.is_none() && self.light.is_none()
}
/// Return only fields that differ from the last known device state.
pub fn changed_from(&self, device: &Device) -> Self {
Self {
power: self.power.filter(|value| *value != device.power),
mode: self.mode.as_ref().filter(|value| value.as_str() != device.mode.as_str()).cloned(),
target_temperature: self.target_temperature.filter(|value| value.clamp(8.0, 30.0).round() != device.target_temperature.clamp(8.0, 30.0).round()),
fan_speed: self.fan_speed.filter(|value| (*value).min(5) != device.fan_speed),
swing_vertical: self.swing_vertical.filter(|value| *value != device.swing_vertical),
swing_horizontal: self.swing_horizontal.filter(|value| *value != device.swing_horizontal),
quiet: self.quiet.filter(|value| *value != device.quiet),
turbo: self.turbo.filter(|value| *value != device.turbo),
light: self.light.filter(|value| *value != device.light),
}
}
pub fn apply(&self, device: &mut Device) { pub fn apply(&self, device: &mut Device) {
if let Some(v) = self.power { device.power = v; } if let Some(v) = self.power { device.power = v; }
if let Some(v) = &self.mode { device.mode = v.clone(); } if let Some(v) = &self.mode { device.mode = v.clone(); }
@@ -386,6 +411,128 @@ pub struct HomeAssistantSettings {
pub allow_invalid_tls: bool, pub allow_invalid_tls: bool,
} }
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InfluxDbSettings {
#[serde(default)]
pub enabled: bool,
/// InfluxDB API generation: `1` or `2`.
#[serde(default = "default_influx_version")]
pub version: String,
#[serde(default)]
pub url: String,
/// InfluxDB 1.x database name.
#[serde(default = "default_influx_database")]
pub database: String,
#[serde(default)]
pub username: String,
#[serde(default)]
pub password: String,
/// InfluxDB 2.x organization.
#[serde(default)]
pub org: String,
/// InfluxDB 2.x bucket.
#[serde(default = "default_influx_database")]
pub bucket: String,
#[serde(default)]
pub token: String,
/// Queries older than this age are read from InfluxDB when it is enabled.
#[serde(default = "default_influx_threshold_days")]
pub history_threshold_days: u32,
}
impl Default for InfluxDbSettings {
fn default() -> Self {
Self {
enabled: false,
version: default_influx_version(),
url: String::new(),
database: default_influx_database(),
username: String::new(),
password: String::new(),
org: String::new(),
bucket: default_influx_database(),
token: String::new(),
history_threshold_days: default_influx_threshold_days(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct DebugSettings {
#[serde(default)]
pub overlay_enabled: bool,
#[serde(default)]
pub gree_frames: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConfigurationExport {
pub format_version: u32,
pub exported_at: DateTime<Utc>,
pub settings: RuntimeSettings,
pub devices: Vec<Device>,
pub zones: Vec<Zone>,
pub schedules: Vec<Schedule>,
pub automations: Vec<Automation>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ControlPlanEvent {
pub at: DateTime<Utc>,
pub kind: String,
pub label: String,
pub preset: Option<String>,
pub target_temperature: Option<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ZoneControlPlan {
pub zone_id: String,
pub zone_name: String,
pub device_id: String,
pub device_name: String,
pub enabled: bool,
pub mode: String,
pub preset: String,
pub current_temperature: Option<f64>,
pub target_temperature: Option<f64>,
pub device_setpoint: Option<f64>,
pub demand: bool,
pub control_source: String,
pub manual_override_until: Option<DateTime<Utc>>,
pub current_schedule_id: Option<String>,
pub current_schedule_name: Option<String>,
pub next_events: Vec<ControlPlanEvent>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AutomationPlanRule {
pub id: String,
pub name: String,
pub enabled: bool,
pub trigger_kind: String,
pub trigger_device_id: Option<String>,
pub trigger_device_name: Option<String>,
pub threshold: Option<f64>,
pub at_time: Option<String>,
pub action_device_id: String,
pub action_device_name: String,
pub action: DeviceCommand,
pub last_fired_at: Option<DateTime<Utc>>,
pub next_ready_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ControlPlan {
pub generated_at: DateTime<Utc>,
pub house_mode: String,
pub outdoor_temperature: Option<f64>,
pub control_strategy: String,
pub next_events: Vec<ControlPlanEvent>,
pub zones: Vec<ZoneControlPlan>,
pub rules: Vec<AutomationPlanRule>,
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RuntimeSettings { pub struct RuntimeSettings {
pub controller_id: String, pub controller_id: String,
@@ -402,6 +549,18 @@ pub struct RuntimeSettings {
pub control_strategy: String, pub control_strategy: String,
#[serde(default = "default_true")] #[serde(default = "default_true")]
pub outdoor_assist_enabled: bool, pub outdoor_assist_enabled: bool,
/// Keep recent metrics locally; older history may live in InfluxDB.
#[serde(default = "default_history_retention_days")]
pub history_retention_days: u32,
#[serde(default = "default_true")]
pub history_compaction_enabled: bool,
/// Add protocol-specific buzzer suppression fields to command frames.
#[serde(default)]
pub suppress_device_beep: bool,
#[serde(default)]
pub influxdb: InfluxDbSettings,
#[serde(default)]
pub debug: DebugSettings,
pub home_assistant: HomeAssistantSettings, pub home_assistant: HomeAssistantSettings,
} }
+73 -8
View File
@@ -1,10 +1,10 @@
use std::{collections::HashSet, net::{Ipv4Addr, SocketAddr, SocketAddrV4}, time::Duration}; use std::{collections::HashSet, net::{Ipv4Addr, SocketAddr, SocketAddrV4}, sync::{Arc, Mutex, atomic::{AtomicBool, Ordering}}, time::Duration};
use anyhow::{anyhow, bail, Context, Result}; use anyhow::{anyhow, bail, Context, Result};
use chrono::Utc; use chrono::Utc;
use serde_json::{json, Value}; use serde_json::{json, Value};
use tokio::{net::UdpSocket, time::{timeout, Instant}}; use tokio::{net::UdpSocket, sync::broadcast, time::{timeout, Instant}};
use uuid::Uuid; use uuid::Uuid;
use crate::models::{Device, DeviceCommand}; use crate::models::{ApiEvent, Device, DeviceCommand};
use super::crypto::{ use super::crypto::{
decrypt_v1, decrypt_v2, encrypt_v1, encrypt_v2, decrypt_v1, decrypt_v2, encrypt_v1, encrypt_v2,
GENERIC_GREE_V1_KEY, GENERIC_GREE_V2_KEY, GENERIC_GREE_V1_KEY, GENERIC_GREE_V2_KEY,
@@ -20,11 +20,46 @@ pub struct BindResult {
pub struct GreeClient { pub struct GreeClient {
controller_id: String, controller_id: String,
interface: Option<String>, interface: Option<String>,
debug_events: Option<broadcast::Sender<ApiEvent>>,
debug_gree_frames: Arc<AtomicBool>,
buzzer_unsupported: Arc<Mutex<HashSet<String>>>,
} }
impl GreeClient { impl GreeClient {
pub fn new(controller_id: String, interface: Option<String>) -> Self { pub fn new(
Self { controller_id, interface } controller_id: String,
interface: Option<String>,
debug_events: Option<broadcast::Sender<ApiEvent>>,
debug_gree_frames: Arc<AtomicBool>,
) -> Self {
Self {
controller_id,
interface,
debug_events,
debug_gree_frames,
buzzer_unsupported: Arc::new(Mutex::new(HashSet::new())),
}
}
fn debug_frame(&self, direction: &str, device: &Device, target: SocketAddr, protocol: u8, payload: &Value) {
if !self.debug_gree_frames.load(Ordering::Relaxed) { return; }
let Some(events) = &self.debug_events else { return; };
let mut safe = payload.clone();
if let Some(object) = safe.as_object_mut() {
if object.contains_key("key") { object.insert("key".into(), json!("***")); }
}
let _ = events.send(ApiEvent {
event: "gree.frame".into(),
timestamp: Utc::now(),
data: json!({
"direction": direction,
"device_id": device.id,
"device_name": device.name,
"target": target.to_string(),
"protocol_version": protocol,
"payload": safe,
}),
});
} }
async fn udp_socket(&self, broadcast: bool, target_hint: Option<Ipv4Addr>) -> Result<UdpSocket> { async fn udp_socket(&self, broadcast: bool, target_hint: Option<Ipv4Addr>) -> Result<UdpSocket> {
@@ -379,8 +414,32 @@ impl GreeClient {
Ok(()) Ok(())
} }
pub async fn command(&self, device: &Device, command: &DeviceCommand) -> Result<Value> { pub async fn command(&self, device: &Device, command: &DeviceCommand, suppress_beep: bool) -> Result<Value> {
let key = device.key.as_deref().ok_or_else(|| anyhow!("device is not bound"))?; let key = device.key.as_deref().ok_or_else(|| anyhow!("device is not bound"))?;
let try_buzzer_suppression = suppress_beep
&& self.buzzer_unsupported.lock().map(|items| !items.contains(&device.id)).unwrap_or(true);
let inner = Self::command_payload(command, try_buzzer_suppression)?;
match self.request(device, &inner, key, false, device.protocol_version).await {
Ok(value) => Ok(value),
Err(first_err) if try_buzzer_suppression => {
// Some firmwares reject unknown command properties instead of ignoring them.
// Retry the exact state change without buzzer fields; only remember the device
// as incompatible after that fallback succeeds.
let fallback = Self::command_payload(command, false)?;
match self.request(device, &fallback, key, false, device.protocol_version).await {
Ok(value) => {
if let Ok(mut items) = self.buzzer_unsupported.lock() { items.insert(device.id.clone()); }
tracing::warn!(device=%device.id, "GREE buzzer suppression is unsupported; using normal command frames for this device");
Ok(value)
}
Err(_) => Err(first_err),
}
}
Err(err) => Err(err),
}
}
fn command_payload(command: &DeviceCommand, suppress_beep: bool) -> Result<Value> {
let mut opt = Vec::<&str>::new(); let mut opt = Vec::<&str>::new();
let mut values = Vec::<Value>::new(); let mut values = Vec::<Value>::new();
if let Some(v) = command.power { opt.push("Pow"); values.push(json!(if v { 1 } else { 0 })); } if let Some(v) = command.power { opt.push("Pow"); values.push(json!(if v { 1 } else { 0 })); }
@@ -398,8 +457,11 @@ impl GreeClient {
if let Some(v) = command.turbo { opt.push("Tur"); values.push(json!(if v { 1 } else { 0 })); } if let Some(v) = command.turbo { opt.push("Tur"); values.push(json!(if v { 1 } else { 0 })); }
if let Some(v) = command.light { opt.push("Lig"); values.push(json!(if v { 1 } else { 0 })); } if let Some(v) = command.light { opt.push("Lig"); values.push(json!(if v { 1 } else { 0 })); }
if opt.is_empty() { bail!("empty device command") } if opt.is_empty() { bail!("empty device command") }
let inner = json!({"opt": opt, "p": values, "t": "cmd"}); if suppress_beep {
self.request(device, &inner, key, false, device.protocol_version).await opt.push("Buzzer_ON_OFF"); values.push(json!(1));
opt.push("BuzzerCtrl"); values.push(json!(0));
}
Ok(json!({"opt": opt, "p": values, "t": "cmd"}))
} }
async fn request(&self, device: &Device, inner: &Value, key: &str, binding: bool, protocol_version: u8) -> Result<Value> { async fn request(&self, device: &Device, inner: &Value, key: &str, binding: bool, protocol_version: u8) -> Result<Value> {
@@ -430,6 +492,7 @@ impl GreeClient {
} }
let payload = serde_json::to_vec(&outer)?; let payload = serde_json::to_vec(&outer)?;
tracing::debug!(target=%target, local=%socket.local_addr()?, protocol=version, wire_mac=%wire_mac, interface=%self.interface.as_deref().unwrap_or("auto"), binding, "Sending GREE request"); tracing::debug!(target=%target, local=%socket.local_addr()?, protocol=version, wire_mac=%wire_mac, interface=%self.interface.as_deref().unwrap_or("auto"), binding, "Sending GREE request");
self.debug_frame("tx", device, target, version, inner);
socket.send_to(&payload, target).await?; socket.send_to(&payload, target).await?;
let deadline = Instant::now() + Duration::from_secs(4); let deadline = Instant::now() + Duration::from_secs(4);
@@ -458,6 +521,7 @@ impl GreeClient {
} }
} }
if let Some(err) = decoded.get("err").filter(|v| !v.is_null()) { bail!("GREE device error: {err}") } if let Some(err) = decoded.get("err").filter(|v| !v.is_null()) { bail!("GREE device error: {err}") }
self.debug_frame("rx", device, target, version, &decoded);
return Ok(decoded); return Ok(decoded);
} }
let Some(pack) = response.get("pack").and_then(Value::as_str) else { continue; }; let Some(pack) = response.get("pack").and_then(Value::as_str) else { continue; };
@@ -485,6 +549,7 @@ impl GreeClient {
if !response_type.eq_ignore_ascii_case("bindok") { continue; } if !response_type.eq_ignore_ascii_case("bindok") { continue; }
} }
if let Some(err) = decoded.get("err").filter(|v| !v.is_null()) { bail!("GREE device error: {err}") } if let Some(err) = decoded.get("err").filter(|v| !v.is_null()) { bail!("GREE device error: {err}") }
self.debug_frame("rx", device, target, version, &decoded);
return Ok(decoded); return Ok(decoded);
} }
if let Some(err) = last_decode_error { return Err(err); } if let Some(err) = last_decode_error { return Err(err); }
+63
View File
@@ -233,6 +233,12 @@ ORDER BY MIN(timestamp) ASC
LIMIT ?3 LIMIT ?3
"#; "#;
pub const LIST_DEVICE_HISTORY_BEFORE: &str = r#"
SELECT id,device_id,timestamp,indoor_temperature,outdoor_temperature,target_temperature,power,source
FROM readings WHERE timestamp < ?1 ORDER BY timestamp ASC LIMIT ?2
"#;
pub const DELETE_READING_BY_ID: &str = "DELETE FROM readings WHERE id=?1";
pub const PRUNE_READINGS: &str = "DELETE FROM readings WHERE timestamp < ?1"; pub const PRUNE_READINGS: &str = "DELETE FROM readings WHERE timestamp < ?1";
pub const INSERT_ZONE_READING_IF_DUE: &str = r#" pub const INSERT_ZONE_READING_IF_DUE: &str = r#"
@@ -276,6 +282,13 @@ LIMIT ?3
pub const DELETE_ZONE_READINGS_BY_ZONE_ID: &str = "DELETE FROM zone_readings WHERE zone_id=?1"; pub const DELETE_ZONE_READINGS_BY_ZONE_ID: &str = "DELETE FROM zone_readings WHERE zone_id=?1";
pub const DELETE_ZONE_READINGS_BY_DEVICE_ID: &str = "DELETE FROM zone_readings WHERE device_id=?1"; pub const DELETE_ZONE_READINGS_BY_DEVICE_ID: &str = "DELETE FROM zone_readings WHERE device_id=?1";
pub const LIST_ZONE_HISTORY_BEFORE: &str = r#"
SELECT id,zone_id,device_id,timestamp,gree_temperature,external_temperature,control_temperature,
target_temperature,device_setpoint,outdoor_temperature,power,mode,fan_speed,demand,control_source,active_preset
FROM zone_readings WHERE timestamp < ?1 ORDER BY timestamp ASC LIMIT ?2
"#;
pub const DELETE_ZONE_READING_BY_ID: &str = "DELETE FROM zone_readings WHERE id=?1";
pub const PRUNE_ZONE_READINGS: &str = "DELETE FROM zone_readings WHERE timestamp < ?1"; pub const PRUNE_ZONE_READINGS: &str = "DELETE FROM zone_readings WHERE timestamp < ?1";
pub const INSERT_HA_READING_IF_DUE: &str = r#" pub const INSERT_HA_READING_IF_DUE: &str = r#"
@@ -306,8 +319,58 @@ ORDER BY MIN(timestamp) ASC
LIMIT ?3 LIMIT ?3
"#; "#;
pub const LIST_HA_HISTORY_BEFORE: &str = r#"
SELECT id,entity_id,zone_id,kind,timestamp,temperature
FROM ha_readings WHERE timestamp < ?1 ORDER BY timestamp ASC LIMIT ?2
"#;
pub const DELETE_HA_READING_BY_ID: &str = "DELETE FROM ha_readings WHERE id=?1";
pub const PRUNE_HA_READINGS: &str = "DELETE FROM ha_readings WHERE timestamp < ?1"; pub const PRUNE_HA_READINGS: &str = "DELETE FROM ha_readings WHERE timestamp < ?1";
// Tiered history compaction keeps only the resolution the charts can actually display.
pub const COMPACT_DEVICE_HISTORY: &str = r#"
DELETE FROM readings WHERE id IN (
SELECT id FROM (
SELECT id, ROW_NUMBER() OVER (
PARTITION BY device_id, CAST(unixepoch(timestamp)/?1 AS INTEGER)
ORDER BY timestamp DESC, id DESC
) AS rn
FROM readings WHERE timestamp < ?2 AND timestamp >= ?3
) WHERE rn > 1
)
"#;
pub const COMPACT_ZONE_HISTORY: &str = r#"
DELETE FROM zone_readings WHERE id IN (
SELECT id FROM (
SELECT id, ROW_NUMBER() OVER (
PARTITION BY zone_id, CAST(unixepoch(timestamp)/?1 AS INTEGER)
ORDER BY timestamp DESC, id DESC
) AS rn
FROM zone_readings WHERE timestamp < ?2 AND timestamp >= ?3
) WHERE rn > 1
)
"#;
pub const COMPACT_HA_HISTORY: &str = r#"
DELETE FROM ha_readings WHERE id IN (
SELECT id FROM (
SELECT id, ROW_NUMBER() OVER (
PARTITION BY entity_id, COALESCE(zone_id,''), kind, CAST(unixepoch(timestamp)/?1 AS INTEGER)
ORDER BY timestamp DESC, id DESC
) AS rn
FROM ha_readings WHERE timestamp < ?2 AND timestamp >= ?3
) WHERE rn > 1
)
"#;
pub const CLEAR_CONFIGURATION: &str = r#"
DELETE FROM schedules;
DELETE FROM automations;
DELETE FROM zones;
DELETE FROM devices;
"#;
pub const HISTORY_COUNTS: &str = r#" pub const HISTORY_COUNTS: &str = r#"
SELECT SELECT
(SELECT COUNT(*) FROM readings), (SELECT COUNT(*) FROM readings),
+2 -1
View File
@@ -1,4 +1,4 @@
use std::{sync::Arc, time::Instant}; use std::{sync::{Arc, atomic::AtomicBool}, time::Instant};
use chrono::Utc; use chrono::Utc;
use serde_json::Value; use serde_json::Value;
use tokio::sync::{broadcast, RwLock}; use tokio::sync::{broadcast, RwLock};
@@ -13,6 +13,7 @@ pub struct AppState {
pub events: broadcast::Sender<ApiEvent>, pub events: broadcast::Sender<ApiEvent>,
pub http: reqwest::Client, pub http: reqwest::Client,
pub outdoor_temperature: Arc<RwLock<Option<f64>>>, pub outdoor_temperature: Arc<RwLock<Option<f64>>>,
pub debug_gree_frames: Arc<AtomicBool>,
pub started: Instant, pub started: Instant,
} }
+168 -20
View File
@@ -20,6 +20,7 @@ const app = {
historyTab: 'overview', historyData: {zones:[], devices:[], sensors:[]}, historyCounts: {}, historyTab: 'overview', historyData: {zones:[], devices:[], sensors:[]}, historyCounts: {},
historyZone: 'all', historyDevice: 'all', historySensor: 'all', historyLoading: false, historyZone: 'all', historyDevice: 'all', historySensor: 'all', historyLoading: false,
customChartSeries: [], savedCharts: [], zoneControlSeq: {}, zoneTemperatureTimers: {}, customChartSeries: [], savedCharts: [], zoneControlSeq: {}, zoneTemperatureTimers: {},
controlPlan: null, controlPlanTimer: null, debugLines: [], debugBacklogLoaded: false,
}; };
try { app.savedCharts = JSON.parse(localStorage.getItem('gree_controller_saved_charts') || '[]'); } catch (_) { app.savedCharts = []; } try { app.savedCharts = JSON.parse(localStorage.getItem('gree_controller_saved_charts') || '[]'); } catch (_) { app.savedCharts = []; }
@@ -177,6 +178,8 @@ async function loadBootstrap(showMessage = false) {
app.system = data.system || {}; app.system = data.system || {};
app.outdoorTemperature = Number.isFinite(Number(data.outdoor_temperature)) ? Number(data.outdoor_temperature) : null; app.outdoorTemperature = Number.isFinite(Number(data.outdoor_temperature)) ? Number(data.outdoor_temperature) : null;
renderAll(); renderAll();
loadControlPlan();
if (app.settings?.debug?.overlay_enabled) loadDebugBacklog();
if (showMessage) toast(tr('common.updated')); if (showMessage) toast(tr('common.updated'));
if ($('#tokenDialog').open) $('#tokenDialog').close(); if ($('#tokenDialog').open) $('#tokenDialog').close();
connectWebSocket(); connectWebSocket();
@@ -190,6 +193,7 @@ async function loadBootstrap(showMessage = false) {
function renderAll() { function renderAll() {
renderSummary(); renderSummary();
renderHouseClimate(); renderHouseClimate();
renderControlPlan();
renderDevices(); renderDevices();
renderZones(); renderZones();
renderSchedules(); renderSchedules();
@@ -197,6 +201,7 @@ function renderAll() {
renderAccessTokens(); renderAccessTokens();
fillSelects(); fillSelects();
renderSettings(); renderSettings();
renderDebugOverlay();
} }
function renderSummary() { function renderSummary() {
@@ -230,6 +235,47 @@ function renderHouseClimate() {
<div class="preset-row house-preset-row">${['auto','comfort','sleep','away'].map(p=>`<button data-action="house-preset" data-value="${p}">${esc(p==='sleep'?tr('house.sleepAll'):p==='comfort'?tr('house.comfortAll'):p==='away'?tr('house.awayAll'):tr('house.autoAll'))}</button>`).join('')}</div>`; <div class="preset-row house-preset-row">${['auto','comfort','sleep','away'].map(p=>`<button data-action="house-preset" data-value="${p}">${esc(p==='sleep'?tr('house.sleepAll'):p==='comfort'?tr('house.comfortAll'):p==='away'?tr('house.awayAll'):tr('house.autoAll'))}</button>`).join('')}</div>`;
} }
function planEventMarkup(event) {
const when = event?.at ? new Date(event.at).toLocaleString(locale(), {weekday:'short', hour:'2-digit', minute:'2-digit'}) : '—';
const target = event?.target_temperature == null ? '' : ` · ${fmtTemp(event.target_temperature)}`;
return `<li><time>${esc(when)}</time><span>${esc(event?.label || event?.kind || tr('plan.event'))}${esc(target)}</span></li>`;
}
function automationTriggerLabel(item) {
if (item.trigger_kind === 'time') return tr('automations.triggerAt', {time: item.at_time || '—'});
const key = item.trigger_kind === 'temperature_above' ? 'automations.triggerAbove' : 'automations.triggerBelow';
return tr(key, {temperature: fmtTemp(item.threshold)});
}
function renderControlPlan() {
const host = $('#controlPlan'); if (!host) return;
const plan = app.controlPlan;
if (!plan) {
host.innerHTML = `<div class="panel plan-loading">${esc(tr('plan.loading'))}</div>`;
return;
}
const houseEvents = (plan.next_events || []).slice(0, 5);
const house = `<article class="panel plan-card plan-house"><div class="plan-card-head"><div><span class="eyebrow">${esc(tr('plan.house'))}</span><h3>${esc(modeLabel(plan.house_mode || 'off'))}</h3></div><span class="badge active">${esc(plan.control_strategy || 'setpoint')}</span></div><p>${esc(tr('plan.houseSummary', {zones:(plan.zones || []).filter(zone=>zone.enabled).length, demand:(plan.zones || []).filter(zone=>zone.enabled && zone.demand).length}))}</p><ul class="plan-events">${houseEvents.length ? houseEvents.map(planEventMarkup).join('') : `<li class="muted">${esc(tr('plan.noEvents'))}</li>`}</ul></article>`;
const zones = (plan.zones || []).map(zone => {
const events = (zone.next_events || []).slice(0, 3);
const target = zone.target_temperature == null ? '--' : Number(zone.target_temperature).toFixed(1);
return `<article class="panel plan-card ${zone.enabled ? '' : 'disabled'}"><div class="plan-card-head"><div><span class="eyebrow">${esc(zone.device_name || tr('common.noDevice'))}</span><h3>${esc(zone.zone_name)}</h3></div><span class="badge ${zone.enabled && zone.demand ? 'active' : ''}">${esc(zone.enabled ? (zone.demand ? tr('zones.requesting') : tr('zones.satisfied')) : tr('common.disabled'))}</span></div><div class="plan-temp"><span>${fmtTemp(zone.current_temperature)}</span><b>→</b><strong>${esc(target)}<small>°C</small></strong></div><p>${esc(modeLabel(zone.mode || 'off'))} · ${esc(zonePresetLabel(zone.preset))}${zone.current_schedule_name ? ` · ${esc(zone.current_schedule_name)}` : ''}</p><ul class="plan-events">${events.length ? events.map(planEventMarkup).join('') : `<li class="muted">${esc(tr('plan.noEvents'))}</li>`}</ul></article>`;
}).join('');
const rules = (plan.rules || []).filter(rule => rule.enabled);
const ruleCard = rules.length ? `<article class="panel plan-card"><div class="plan-card-head"><div><span class="eyebrow">${esc(tr('plan.rules'))}</span><h3>${esc(tr('plan.ruleCount', {count:rules.length}))}</h3></div></div><ul class="plan-events">${rules.slice(0,5).map(rule=>`<li><time>${esc(automationTriggerLabel(rule))}</time><span>${esc(rule.name)}${esc(rule.action_device_name || '')}</span></li>`).join('')}</ul></article>` : '';
host.innerHTML = house + zones + ruleCard;
}
async function loadControlPlan() {
try { app.controlPlan = await api('/api/control-plan'); renderControlPlan(); }
catch (error) { console.warn('Unable to load control plan:', error); }
}
function scheduleControlPlanLoad() {
clearTimeout(app.controlPlanTimer);
app.controlPlanTimer = setTimeout(loadControlPlan, 180);
}
function deviceCard(device, detailed = false) { function deviceCard(device, detailed = false) {
const modes = ['auto','cool','dry','fan','heat']; const modes = ['auto','cool','dry','fan','heat'];
const fans = [0,1,3,5]; const fans = [0,1,3,5];
@@ -296,8 +342,11 @@ function zoneCard(zone, detailed = true) {
const manual = zone.manual_preset || 'auto'; const manual = zone.manual_preset || 'auto';
const mode = zone.inherit_house_mode ? 'house' : zone.mode; const mode = zone.inherit_house_mode ? 'house' : zone.mode;
const override = zone.manual_override_until ? `${tr('zones.overrideUntil')} ${new Date(zone.manual_override_until).toLocaleTimeString(locale(), {hour:'2-digit',minute:'2-digit'})}` : tr('zones.scheduleControl'); const override = zone.manual_override_until ? `${tr('zones.overrideUntil')} ${new Date(zone.manual_override_until).toLocaleTimeString(locale(), {hour:'2-digit',minute:'2-digit'})}` : tr('zones.scheduleControl');
return `<article class="list-card zone-thermostat ${zone.demand ? 'demanding' : ''}"> const enabledControl = detailed
<div class="list-card-head"><div><h3>${esc(zone.name)}</h3><p>${esc(device?.name || tr('common.noDevice'))} · ${esc(zonePresetLabel(zone.active_preset))}</p></div><span class="badge ${zone.enabled ? 'active' : ''}">${esc(state)}</span></div> ? `<span class="badge ${zone.enabled ? 'active' : ''}">${esc(state)}</span>`
: `<button type="button" class="zone-enable-toggle ${zone.enabled ? 'active' : ''}" data-action="zone-enabled" data-id="${esc(zone.id)}" data-value="${zone.enabled ? 'false' : 'true'}" aria-label="${esc(tr(zone.enabled ? 'zones.disable' : 'zones.enable'))}"><span>${zone.enabled ? '✓' : '○'}</span>${esc(state)}</button>`;
return `<article class="list-card zone-thermostat ${zone.demand ? 'demanding' : ''} ${zone.enabled ? '' : 'zone-disabled'}">
<div class="list-card-head"><div><h3>${esc(zone.name)}</h3><p>${esc(device?.name || tr('common.noDevice'))} · ${esc(zonePresetLabel(zone.active_preset))}</p></div>${enabledControl}</div>
<div class="thermostat-main"><div><small>${esc(tr('zones.measurement'))}</small><strong>${fmtTemp(zone.current_temperature)}</strong></div><div class="temperature-control compact"><button data-action="zone-temperature" data-id="${esc(zone.id)}" data-delta="-0.5"></button><div class="target-temp compact">${Number.isFinite(target)?target.toFixed(1):'--'}<small>°C</small></div><button data-action="zone-temperature" data-id="${esc(zone.id)}" data-delta="0.5">+</button></div><div><small>${esc(tr('zones.deviceTarget'))}</small><strong>${fmtTemp(zone.device_setpoint)}</strong></div></div> <div class="thermostat-main"><div><small>${esc(tr('zones.measurement'))}</small><strong>${fmtTemp(zone.current_temperature)}</strong></div><div class="temperature-control compact"><button data-action="zone-temperature" data-id="${esc(zone.id)}" data-delta="-0.5"></button><div class="target-temp compact">${Number.isFinite(target)?target.toFixed(1):'--'}<small>°C</small></div><button data-action="zone-temperature" data-id="${esc(zone.id)}" data-delta="0.5">+</button></div><div><small>${esc(tr('zones.deviceTarget'))}</small><strong>${fmtTemp(zone.device_setpoint)}</strong></div></div>
<div class="preset-row"> <div class="preset-row">
${['auto','comfort','sleep','away'].map(preset=>`<button class="${manual===preset?'active':''}" data-action="zone-preset" data-id="${esc(zone.id)}" data-value="${preset}">${esc(preset==='sleep'?tr('zones.sleepNow'):zonePresetLabel(preset))}</button>`).join('')} ${['auto','comfort','sleep','away'].map(preset=>`<button class="${manual===preset?'active':''}" data-action="zone-preset" data-id="${esc(zone.id)}" data-value="${preset}">${esc(preset==='sleep'?tr('zones.sleepNow'):zonePresetLabel(preset))}</button>`).join('')}
@@ -327,12 +376,9 @@ function renderSchedules() {
} }
function renderAutomations() { function renderAutomations() {
const triggerLabel = item => item.trigger_kind === 'time'
? tr('automations.triggerAt', {time: item.at_time})
: tr(item.trigger_kind === 'temperature_above' ? 'automations.triggerAbove' : 'automations.triggerBelow', {temperature: fmtTemp(item.threshold)});
$('#automationList').innerHTML = app.automations.length ? app.automations.map(item => { $('#automationList').innerHTML = app.automations.length ? app.automations.map(item => {
const actionDevice = app.devices.find(d => d.id === item.action_device_id); 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: triggerLabel(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> 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-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>`; <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>`;
}).join('') : `<div class="empty"><strong>${esc(tr('automations.emptyTitle'))}</strong>${esc(tr('automations.emptyText'))}</div>`; }).join('') : `<div class="empty"><strong>${esc(tr('automations.emptyTitle'))}</strong>${esc(tr('automations.emptyText'))}</div>`;
@@ -370,6 +416,23 @@ function renderSettings() {
form.discovery_broadcast.value = app.settings.discovery_broadcast || '255.255.255.255:7000'; form.discovery_broadcast.value = app.settings.discovery_broadcast || '255.255.255.255:7000';
form.discovery_timeout_ms.value = app.settings.discovery_timeout_ms || 3000; form.discovery_timeout_ms.value = app.settings.discovery_timeout_ms || 3000;
form.simulator_enabled.checked = !!app.settings.simulator_enabled; form.simulator_enabled.checked = !!app.settings.simulator_enabled;
form.history_retention_days.value = app.settings.history_retention_days || 30;
form.history_compaction_enabled.checked = app.settings.history_compaction_enabled !== false;
form.suppress_device_beep.checked = !!app.settings.suppress_device_beep;
form.influx_enabled.checked = !!app.settings.influxdb?.enabled;
form.influx_version.value = String(app.settings.influxdb?.version || '2');
form.influx_threshold_days.value = app.settings.influxdb?.history_threshold_days || 30;
form.influx_url.value = app.settings.influxdb?.url || '';
form.influx_database.value = app.settings.influxdb?.database || 'gree_controller';
form.influx_username.value = app.settings.influxdb?.username || '';
form.influx_password.value = '';
form.influx_password.placeholder = app.settings.influxdb?.password_configured ? tr('settings.secretSaved') : tr('settings.secretKeep');
form.influx_org.value = app.settings.influxdb?.org || '';
form.influx_bucket.value = app.settings.influxdb?.bucket || 'gree_controller';
form.influx_token.value = '';
form.influx_token.placeholder = app.settings.influxdb?.token_configured ? tr('settings.secretSaved') : tr('settings.secretKeep');
form.debug_overlay_enabled.checked = !!app.settings.debug?.overlay_enabled;
form.debug_gree_frames.checked = !!app.settings.debug?.gree_frames;
form.ha_url.value = app.settings.home_assistant?.url || ''; form.ha_url.value = app.settings.home_assistant?.url || '';
form.ha_token.value = ''; form.ha_token.value = '';
form.ha_token.placeholder = app.settings.home_assistant?.token_configured ? tr('settings.haTokenSaved') : tr('settings.haLongLivedToken'); form.ha_token.placeholder = app.settings.home_assistant?.token_configured ? tr('settings.haTokenSaved') : tr('settings.haLongLivedToken');
@@ -377,9 +440,46 @@ function renderSettings() {
form.ha_outdoor_entity_id.value = app.settings.home_assistant?.outdoor_entity_id || ''; form.ha_outdoor_entity_id.value = app.settings.home_assistant?.outdoor_entity_id || '';
form.ha_allow_invalid_tls.checked = !!app.settings.home_assistant?.allow_invalid_tls; form.ha_allow_invalid_tls.checked = !!app.settings.home_assistant?.allow_invalid_tls;
form.outdoor_assist_enabled.checked = !!app.settings.outdoor_assist_enabled; form.outdoor_assist_enabled.checked = !!app.settings.outdoor_assist_enabled;
updateInfluxFields();
$('#systemInfo').innerHTML = `<h3>${esc(tr('settings.systemState'))}</h3><div>${esc(tr('settings.version'))}: <strong>${esc(app.system.version || '—')}</strong></div><div>${esc(tr('settings.uptime'))}: <strong>${esc(formatDuration(app.system.uptime_seconds || 0))}</strong></div><div>${esc(tr('settings.apiAuth'))}: <strong>${esc(app.system.auth_required ? tr('settings.enabled') : tr('settings.disabled'))}</strong></div>`; $('#systemInfo').innerHTML = `<h3>${esc(tr('settings.systemState'))}</h3><div>${esc(tr('settings.version'))}: <strong>${esc(app.system.version || '—')}</strong></div><div>${esc(tr('settings.uptime'))}: <strong>${esc(formatDuration(app.system.uptime_seconds || 0))}</strong></div><div>${esc(tr('settings.apiAuth'))}: <strong>${esc(app.system.auth_required ? tr('settings.enabled') : tr('settings.disabled'))}</strong></div>`;
} }
function updateInfluxFields() {
const version = $('#settingsForm [name=influx_version]')?.value || '2';
$$('[data-influx-fields]').forEach(node => { node.hidden = node.dataset.influxFields !== version; });
}
function debugLine(source, kind, message, timestamp = new Date().toISOString(), data = null) {
app.debugLines.push({source, kind, message, timestamp, data});
if (app.debugLines.length > 160) app.debugLines.splice(0, app.debugLines.length - 160);
renderDebugOverlay();
}
function renderDebugOverlay() {
const overlay = $('#debugOverlay'); if (!overlay) return;
const enabled = !!app.settings?.debug?.overlay_enabled;
overlay.hidden = !enabled;
if (!enabled) return;
const status = $('#debugOverlayStatus');
if (status) status.textContent = app.settings?.debug?.gree_frames ? tr('debug.apiAndGree') : tr('debug.apiOnly');
const host = $('#debugOverlayLines'); if (!host) return;
host.innerHTML = app.debugLines.length ? app.debugLines.slice(-120).map(line => {
const details = line.data == null ? '' : ` ${typeof line.data === 'string' ? line.data : JSON.stringify(line.data)}`;
return `<div class="debug-line"><time>${esc(new Date(line.timestamp).toLocaleTimeString(locale()))}</time><b>${esc(line.source)}</b><span>${esc(line.kind)}: ${esc(line.message || '')}${esc(details)}</span></div>`;
}).join('') : `<div class="debug-empty">${esc(tr('debug.empty'))}</div>`;
host.scrollTop = host.scrollHeight;
}
async function loadDebugBacklog() {
if (app.debugBacklogLoaded || !app.settings?.debug?.overlay_enabled) return;
try {
const data = await api('/api/events?limit=60');
app.debugLines = (data.events || []).reverse().map(item => ({source:'API', kind:item.kind, message:item.message, timestamp:item.timestamp, data:item.metadata})).slice(-120);
app.debugBacklogLoaded = true;
renderDebugOverlay();
} catch (_) {}
}
function formatDuration(seconds) { function formatDuration(seconds) {
const days = Math.floor(seconds / 86400), hours = Math.floor((seconds % 86400) / 3600), minutes = Math.floor((seconds % 3600) / 60); const days = Math.floor(seconds / 86400), hours = Math.floor((seconds % 86400) / 3600), minutes = Math.floor((seconds % 3600) / 60);
return `${days ? `${days}d ` : ''}${hours}h ${minutes}m`; return `${days ? `${days}d ` : ''}${hours}h ${minutes}m`;
@@ -434,7 +534,7 @@ function applyRouteFromLocation() {
app.historyDevice = params.get('device') || 'all'; app.historyDevice = params.get('device') || 'all';
app.historySensor = params.get('sensor') || 'all'; app.historySensor = params.get('sensor') || 'all';
const hours = params.get('hours'); const hours = params.get('hours');
if (hours && ['6','24','168','720'].includes(hours) && $('#historyHours')) $('#historyHours').value = hours; if (hours && ['6','24','168','720','2160','8760'].includes(hours) && $('#historyHours')) $('#historyHours').value = hours;
if (app.historyTab === 'custom' && params.get('chart')) app.customChartSeries = decodeChartSpec(params.get('chart')); if (app.historyTab === 'custom' && params.get('chart')) app.customChartSeries = decodeChartSpec(params.get('chart'));
showView('history', {push:false, scroll:false}); showView('history', {push:false, scroll:false});
return; return;
@@ -463,7 +563,7 @@ async function sendZoneControl(id, patch) {
if (app.zoneControlSeq[id] !== sequence) return; if (app.zoneControlSeq[id] !== sequence) return;
const index = app.zones.findIndex(item => item.id === zone.id); const index = app.zones.findIndex(item => item.id === zone.id);
if (index >= 0) app.zones[index] = zone; else app.zones.push(zone); if (index >= 0) app.zones[index] = zone; else app.zones.push(zone);
renderSummary(); renderZones(); renderSummary(); renderZones(); scheduleControlPlanLoad();
} catch (error) { } catch (error) {
if (app.zoneControlSeq[id] === sequence) { await loadBootstrap(); toast(error.message, true); } if (app.zoneControlSeq[id] === sequence) { await loadBootstrap(); toast(error.message, true); }
} }
@@ -932,16 +1032,20 @@ function connectWebSocket() {
try { try {
const message = JSON.parse(event.data); const message = JSON.parse(event.data);
if (message.event === 'bootstrap') { if (message.event === 'bootstrap') {
const data=message.data; app.devices=data.devices||[]; app.zones=data.zones||[]; app.schedules=data.schedules||[]; app.automations=data.automations||[]; app.settings=data.settings||app.settings; app.system=data.system||app.system; app.outdoorTemperature=Number.isFinite(Number(data.outdoor_temperature))?Number(data.outdoor_temperature):app.outdoorTemperature; renderAll(); return; const data=message.data; app.devices=data.devices||[]; app.zones=data.zones||[]; app.schedules=data.schedules||[]; app.automations=data.automations||[]; app.settings=data.settings||app.settings; app.system=data.system||app.system; app.outdoorTemperature=Number.isFinite(Number(data.outdoor_temperature))?Number(data.outdoor_temperature):app.outdoorTemperature; renderAll(); scheduleControlPlanLoad(); if(app.settings?.debug?.overlay_enabled) loadDebugBacklog(); return;
} }
const data = message.data || {}; const data = message.data || {};
if (['device.updated','device.created'].includes(message.event)) { updateDevice(data); renderSummary(); renderDevices(); } if (['device.updated','device.created'].includes(message.event)) { updateDevice(data); renderSummary(); renderDevices(); scheduleControlPlanLoad(); }
else if (message.event === 'device.deleted') { app.devices=app.devices.filter(v=>v.id!==data.id); renderAll(); } 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(); } 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(); } else if (message.event === 'zone.updated') { const i=app.zones.findIndex(v=>v.id===data.id); if(i>=0) app.zones[i]=data; else app.zones.push(data); renderSummary(); renderHouseClimate(); renderZones(); scheduleControlPlanLoad(); }
else if (message.event === 'settings.updated') { app.settings=data; renderSettings(); renderHouseClimate(); } else if (message.event === 'settings.updated') { app.settings=data; renderSettings(); renderHouseClimate(); renderDebugOverlay(); if(app.settings?.debug?.overlay_enabled) loadDebugBacklog(); scheduleControlPlanLoad(); }
else if (message.event === 'outdoor.updated') { app.outdoorTemperature=Number.isFinite(Number(data.temperature))?Number(data.temperature):null; renderHouseClimate(); } 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 === 'log.created' && app.currentView === 'logs') loadLogs(); else if (message.event === 'outdoor.updated') { app.outdoorTemperature=Number.isFinite(Number(data.temperature))?Number(data.temperature):null; renderHouseClimate(); scheduleControlPlanLoad(); }
else if (message.event === 'gree.frame') { if(app.settings?.debug?.overlay_enabled) debugLine('GREE', `${data.direction || '?'} ${data.protocol_version || ''}`, data.device_name || data.device_id || data.target || '', message.timestamp, data.payload); }
else if (message.event === 'api.request') { if(app.settings?.debug?.overlay_enabled) debugLine('HTTP', `${data.method || '?'} ${data.status || ''}`, `${data.path || ''} · ${data.duration_ms ?? '?'} ms`, message.timestamp); }
else if (message.event === 'log.created') { if(app.settings?.debug?.overlay_enabled) debugLine('API', data.kind || data.level || 'log', data.message || '', message.timestamp, data.metadata); if(app.currentView === 'logs') loadLogs(); }
else if (message.event.startsWith('schedule.') || message.event.startsWith('automation.')) scheduleControlPlanLoad();
} catch (_) {} } catch (_) {}
}; };
} }
@@ -979,16 +1083,18 @@ document.addEventListener('click', async event => {
if (action === 'rename-device' && device) return populateDeviceRename(device.id); if (action === 'rename-device' && device) return populateDeviceRename(device.id);
if (action === 'delete-device') return deleteEntity('devices', button.dataset.device, 'label.device'); if (action === 'delete-device') return deleteEntity('devices', button.dataset.device, 'label.device');
if (action === 'house-mode') { if (action === 'house-mode') {
try { app.settings = await api('/api/house/control',{method:'POST',body:{mode:button.dataset.value}}); renderHouseClimate(); toast(tr('house.modeUpdated')); } try { app.settings = await api('/api/house/control',{method:'POST',body:{mode:button.dataset.value}}); renderHouseClimate(); scheduleControlPlanLoad(); toast(tr('house.modeUpdated')); }
catch(error){ toast(error.message,true); } return; catch(error){ toast(error.message,true); } return;
} }
if (action === 'house-preset') { if (action === 'house-preset') {
try { const result=await api('/api/house/preset',{method:'POST',body:{preset:button.dataset.value}}); app.zones=result.zones||app.zones; renderZones(); toast(tr('house.presetUpdated')); } try { const result=await api('/api/house/preset',{method:'POST',body:{preset:button.dataset.value}}); app.zones=result.zones||app.zones; renderZones(); scheduleControlPlanLoad(); toast(tr('house.presetUpdated')); }
catch(error){ toast(error.message,true); } return; catch(error){ toast(error.message,true); } return;
} }
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-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-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-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-zone') return populateZone(button.dataset.id); if (action === 'edit-zone') return populateZone(button.dataset.id);
if (action === 'delete-zone') return deleteEntity('zones', button.dataset.id, 'label.zone'); if (action === 'delete-zone') return deleteEntity('zones', button.dataset.id, 'label.zone');
if (action === 'edit-schedule') return populateSchedule(button.dataset.id); if (action === 'edit-schedule') return populateSchedule(button.dataset.id);
@@ -1096,11 +1202,26 @@ $('#automationForm').addEventListener('submit', async event => {
function settingsBodyFromForm(form) { function settingsBodyFromForm(form) {
const raw=Object.fromEntries(new FormData(form)); const raw=Object.fromEntries(new FormData(form));
return {controller_id:raw.controller_id,simulator_enabled:form.simulator_enabled.checked,poll_interval_seconds:Number(raw.poll_interval_seconds),zone_interval_seconds:Number(raw.zone_interval_seconds),discovery_timeout_ms:Number(raw.discovery_timeout_ms),discovery_broadcast:raw.discovery_broadcast,house_mode:app.settings?.house_mode||'cool',control_strategy:'setpoint',outdoor_assist_enabled:form.outdoor_assist_enabled.checked,home_assistant:{url:raw.ha_url,token:raw.ha_token,default_entity_id:raw.ha_entity_id,outdoor_entity_id:raw.ha_outdoor_entity_id,allow_invalid_tls:form.ha_allow_invalid_tls.checked}}; return {
controller_id:raw.controller_id, simulator_enabled:form.simulator_enabled.checked,
poll_interval_seconds:Number(raw.poll_interval_seconds), zone_interval_seconds:Number(raw.zone_interval_seconds),
discovery_timeout_ms:Number(raw.discovery_timeout_ms), discovery_broadcast:raw.discovery_broadcast,
house_mode:app.settings?.house_mode||'cool', control_strategy:'setpoint', outdoor_assist_enabled:form.outdoor_assist_enabled.checked,
history_retention_days:Number(raw.history_retention_days), history_compaction_enabled:form.history_compaction_enabled.checked,
suppress_device_beep:form.suppress_device_beep.checked,
influxdb:{
enabled:form.influx_enabled.checked, version:raw.influx_version, url:raw.influx_url,
database:raw.influx_database, username:raw.influx_username, password:raw.influx_password,
org:raw.influx_org, bucket:raw.influx_bucket, token:raw.influx_token,
history_threshold_days:Number(raw.influx_threshold_days),
},
debug:{overlay_enabled:form.debug_overlay_enabled.checked, gree_frames:form.debug_gree_frames.checked},
home_assistant:{url:raw.ha_url,token:raw.ha_token,default_entity_id:raw.ha_entity_id,outdoor_entity_id:raw.ha_outdoor_entity_id,allow_invalid_tls:form.ha_allow_invalid_tls.checked},
};
} }
async function saveSettingsForm(form, notify=true) { async function saveSettingsForm(form, notify=true) {
app.settings=await api('/api/settings',{method:'PUT',body:settingsBodyFromForm(form)}); renderSettings(); renderHouseClimate(); if(notify) toast(tr('common.saved')); return app.settings; app.settings=await api('/api/settings',{method:'PUT',body:settingsBodyFromForm(form)}); renderSettings(); renderHouseClimate(); renderDebugOverlay(); scheduleControlPlanLoad(); if(app.settings?.debug?.overlay_enabled) loadDebugBacklog(); if(notify) toast(tr('common.saved')); return app.settings;
} }
$('#settingsForm').addEventListener('submit', async event => { $('#settingsForm').addEventListener('submit', async event => {
@@ -1143,11 +1264,38 @@ $('#haTest').addEventListener('click', async () => {
} catch(error){toast(error.message,true);} } catch(error){toast(error.message,true);}
}); });
$('#exportSettings').addEventListener('click', async () => {
try {
const data = await api('/api/settings/export');
const blob = new Blob([JSON.stringify(data, null, 2)], {type:'application/json'});
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = `gree-controller-settings-${new Date().toISOString().slice(0,10)}.json`;
document.body.appendChild(link); link.click(); link.remove(); URL.revokeObjectURL(link.href);
toast(tr('toast.exported'));
} catch (error) { toast(error.message, true); }
});
$('#importSettings').addEventListener('click', () => $('#importSettingsFile').click());
$('#importSettingsFile').addEventListener('change', async event => {
const file = event.target.files?.[0]; if (!file) return;
try {
if (!confirm(tr('settings.importConfirm'))) return;
const body = JSON.parse(await file.text());
await api('/api/settings/import', {method:'POST', body});
app.debugBacklogLoaded = false;
await loadBootstrap();
toast(tr('toast.imported'));
} catch (error) { toast(error.message, true); }
finally { event.target.value = ''; }
});
document.addEventListener('change', event => { document.addEventListener('change', event => {
const target=event.target; const target=event.target;
if(target.id==='historyZoneSelect'){app.historyZone=target.value;updateBrowserUrl(currentHistoryPath());renderHistoryPage();} if(target.id==='historyZoneSelect'){app.historyZone=target.value;updateBrowserUrl(currentHistoryPath());renderHistoryPage();}
else if(target.id==='historyDeviceSelect'){app.historyDevice=target.value;updateBrowserUrl(currentHistoryPath());renderHistoryPage();} else if(target.id==='historyDeviceSelect'){app.historyDevice=target.value;updateBrowserUrl(currentHistoryPath());renderHistoryPage();}
else if(target.id==='historySensorSelect'){app.historySensor=target.value;updateBrowserUrl(currentHistoryPath());renderHistoryPage();} else if(target.id==='historySensorSelect'){app.historySensor=target.value;updateBrowserUrl(currentHistoryPath());renderHistoryPage();}
else if(target.name==='influx_version') updateInfluxFields();
}); });
window.addEventListener('popstate', applyRouteFromLocation); window.addEventListener('popstate', applyRouteFromLocation);
+42
View File
@@ -47,6 +47,8 @@
</div> </div>
<div class="metrics" id="metrics"></div> <div class="metrics" id="metrics"></div>
<div class="panel house-climate" id="houseClimate"></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="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="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="section-heading"><div><span class="eyebrow" data-i18n="nav.devices">Devices</span><h2 data-i18n="dashboard.quickControl">Direct device control</h2></div></div>
@@ -106,6 +108,8 @@
<option value="24" selected data-i18n="history.24h">24 hours</option> <option value="24" selected data-i18n="history.24h">24 hours</option>
<option value="168" data-i18n="history.7d">7 days</option> <option value="168" data-i18n="history.7d">7 days</option>
<option value="720" data-i18n="history.30d">30 days</option> <option value="720" data-i18n="history.30d">30 days</option>
<option value="2160" data-i18n="history.90d">90 days</option>
<option value="8760" data-i18n="history.1y">1 year</option>
</select> </select>
<button class="secondary" id="historyRefresh" data-i18n="actions.refresh">Refresh</button> <button class="secondary" id="historyRefresh" data-i18n="actions.refresh">Refresh</button>
</div> </div>
@@ -126,6 +130,36 @@
<label><span data-i18n="settings.discoveryTimeout">Discovery timeout (ms)</span><input type="number" name="discovery_timeout_ms" min="300" max="30000" required></label> <label><span data-i18n="settings.discoveryTimeout">Discovery timeout (ms)</span><input type="number" name="discovery_timeout_ms" min="300" max="30000" required></label>
<label class="check"><input type="checkbox" name="simulator_enabled"> <span data-i18n="settings.simulationMode">Simulation mode</span></label> <label class="check"><input type="checkbox" name="simulator_enabled"> <span data-i18n="settings.simulationMode">Simulation mode</span></label>
<hr> <hr>
<h3 data-i18n="settings.metrics">Metrics storage</h3>
<p class="field-note wide" data-i18n="settings.compactionHint">SQLite keeps recent data locally and compacts older samples to the resolution used by charts.</p>
<label><span data-i18n="settings.retentionDays">Local retention (days)</span><input type="number" name="history_retention_days" min="1" max="3650" required></label>
<label class="check"><input type="checkbox" name="history_compaction_enabled"> <span data-i18n="settings.compaction">Compact old metrics</span></label>
<hr>
<h3 data-i18n="settings.greeCommands">GREE commands</h3>
<label class="check wide"><input type="checkbox" name="suppress_device_beep"> <span data-i18n="settings.suppressBeep">Try to suppress command beeps</span></label>
<p class="field-note wide" data-i18n="settings.suppressBeepHint">Only changed properties are sent. Buzzer suppression is also requested when supported by the unit firmware.</p>
<hr>
<h3 data-i18n="settings.influx">Long-term InfluxDB history</h3>
<p class="field-note wide" data-i18n="settings.influxHint">Optional archive for older history. InfluxDB 1.x and 2.x are supported.</p>
<label class="check wide"><input type="checkbox" name="influx_enabled"> <span data-i18n="settings.influxEnabled">Enable InfluxDB archive</span></label>
<label><span data-i18n="settings.influxVersion">InfluxDB version</span><select name="influx_version"><option value="1">1.x</option><option value="2">2.x</option></select></label>
<label><span data-i18n="settings.influxThreshold">Use archive for history older than (days)</span><input type="number" name="influx_threshold_days" min="1" max="3650" value="30"></label>
<label class="wide"><span>URL</span><input type="url" name="influx_url" placeholder="http://influxdb:8086"></label>
<div class="wide influx-fields" data-influx-fields="1">
<label><span data-i18n="settings.influxDatabase">Database</span><input name="influx_database" placeholder="gree_controller"></label>
<label><span data-i18n="settings.influxUsername">Username</span><input name="influx_username"></label>
<label><span data-i18n="settings.influxPassword">Password</span><input type="password" name="influx_password" autocomplete="new-password" data-i18n-placeholder="settings.secretKeep" placeholder="Leave empty to keep saved secret"></label>
</div>
<div class="wide influx-fields" data-influx-fields="2">
<label><span data-i18n="settings.influxOrg">Organization</span><input name="influx_org"></label>
<label><span data-i18n="settings.influxBucket">Bucket</span><input name="influx_bucket" placeholder="gree_controller"></label>
<label><span data-i18n="settings.influxToken">Token</span><input type="password" name="influx_token" autocomplete="new-password" data-i18n-placeholder="settings.secretKeep" placeholder="Leave empty to keep saved secret"></label>
</div>
<hr>
<h3 data-i18n="settings.debug">On-screen debug</h3>
<label class="check"><input type="checkbox" name="debug_overlay_enabled"> <span data-i18n="settings.debugOverlay">Show debug window on every page</span></label>
<label class="check"><input type="checkbox" name="debug_gree_frames"> <span data-i18n="settings.debugGreeFrames">Include GREE protocol frames</span></label>
<hr>
<h3 data-i18n="settings.haSensorInput">Home Assistant sensor input</h3> <h3 data-i18n="settings.haSensorInput">Home Assistant sensor input</h3>
<p class="field-note wide" data-i18n="settings.haSensorInputHint">Optional. Used only when a room zone reads an external Home Assistant temperature sensor.</p> <p class="field-note wide" data-i18n="settings.haSensorInputHint">Optional. Used only when a room zone reads an external Home Assistant temperature sensor.</p>
<label class="wide"><span>URL</span><input type="url" name="ha_url" placeholder="http://homeassistant.local:8123"></label> <label class="wide"><span>URL</span><input type="url" name="ha_url" placeholder="http://homeassistant.local:8123"></label>
@@ -143,6 +177,10 @@
<div id="accessTokenList" class="token-list"></div> <div id="accessTokenList" class="token-list"></div>
<div class="form-actions"><button type="button" class="primary" id="createAccessToken" data-i18n="settings.newToken">Create new token</button></div> <div class="form-actions"><button type="button" class="primary" id="createAccessToken" data-i18n="settings.newToken">Create new token</button></div>
</div> </div>
<hr>
<h3 data-i18n="settings.backup">Configuration backup</h3>
<p class="field-note wide warning-note" data-i18n="settings.backupHint">Export/import application configuration. Exported files can contain GREE device keys plus Home Assistant and InfluxDB secrets; metrics and API access tokens are not included.</p>
<div class="form-actions wide backup-actions"><button type="button" class="secondary" id="exportSettings" data-i18n="settings.export">Export settings</button><button type="button" class="secondary" id="importSettings" data-i18n="settings.import">Import settings</button><input type="file" id="importSettingsFile" accept="application/json,.json" hidden></div>
</form> </form>
<div class="panel system-panel" id="systemInfo"></div> <div class="panel system-panel" id="systemInfo"></div>
</section> </section>
@@ -289,6 +327,10 @@
</form> </form>
</dialog> </dialog>
<aside id="debugOverlay" class="debug-overlay" hidden>
<div class="debug-overlay-head"><div><strong data-i18n="debug.title">Live debug</strong><small id="debugOverlayStatus"></small></div><button type="button" data-action="debug-clear" data-i18n="debug.clear">Clear</button></div>
<div id="debugOverlayLines" class="debug-overlay-lines"></div>
</aside>
<div id="toastStack" class="toast-stack" role="status" aria-live="polite" aria-atomic="false"></div> <div id="toastStack" class="toast-stack" role="status" aria-live="polite" aria-atomic="false"></div>
<script src="/app.js" defer></script> <script src="/app.js" defer></script>
</body> </body>
+43 -1
View File
@@ -158,6 +158,10 @@ h3 { margin-bottom: 10px; }
.list-card p { margin: 0; color: var(--muted); font-size: 13px; line-height: 1.45; } .list-card p { margin: 0; color: var(--muted); font-size: 13px; line-height: 1.45; }
.badge { display: inline-flex; align-items: center; padding: 5px 9px; border-radius: 99px; color: var(--muted); background: var(--surface-muted); font-size: 11px; } .badge { display: inline-flex; align-items: center; padding: 5px 9px; border-radius: 99px; color: var(--muted); background: var(--surface-muted); font-size: 11px; }
.badge.active { color: var(--accent); background: color-mix(in srgb, var(--accent) 12%, var(--surface)); } .badge.active { color: var(--accent); background: color-mix(in srgb, var(--accent) 12%, var(--surface)); }
.zone-enable-toggle { display: inline-flex; align-items: center; gap: 6px; min-height: 30px; padding: 5px 9px; border: 1px solid var(--line); border-radius: 99px; color: var(--muted); background: var(--surface-muted); font-size: 11px; }
.zone-enable-toggle.active { border-color: color-mix(in srgb, var(--accent) 34%, var(--line)); color: var(--accent); background: color-mix(in srgb, var(--accent) 12%, var(--surface)); }
.zone-enable-toggle span { font-size: 12px; font-weight: 900; }
.zone-thermostat.zone-disabled .thermostat-main, .zone-thermostat.zone-disabled .preset-row, .zone-thermostat.zone-disabled .zone-mode-row, .zone-thermostat.zone-disabled .zone-state-line { opacity: .5; }
.card-stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 7px; margin-top: 16px; } .card-stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 7px; margin-top: 16px; }
.card-stat { padding: 10px; border-radius: 12px; background: var(--surface-muted); text-align: center; } .card-stat { padding: 10px; border-radius: 12px; background: var(--surface-muted); text-align: center; }
.card-stat small, .card-stat strong { display: block; } .card-stat small, .card-stat strong { display: block; }
@@ -184,6 +188,10 @@ input:focus, select:focus { border-color: var(--accent); outline: 2px solid colo
.check { display: flex; grid-auto-flow: column; justify-content: start; align-items: center; gap: 9px; min-height: 44px; } .check { display: flex; grid-auto-flow: column; justify-content: start; align-items: center; gap: 9px; min-height: 44px; }
.check input { width: 19px; min-height: 19px; accent-color: var(--accent); } .check input { width: 19px; min-height: 19px; accent-color: var(--accent); }
.form-actions { display: flex; justify-content: flex-end; gap: 9px; margin-top: 6px; } .form-actions { display: flex; justify-content: flex-end; gap: 9px; margin-top: 6px; }
.influx-fields { display: grid; grid-template-columns: repeat(2, 1fr); gap: 14px; padding: 14px; border: 1px solid var(--line); border-radius: 14px; background: var(--surface-muted); }
.influx-fields[hidden] { display: none; }
.influx-fields label:last-child:nth-child(odd) { grid-column: 1/-1; }
.backup-actions { align-items: center; }
.system-panel { margin-top: 14px; color: var(--muted); line-height: 1.7; } .system-panel { margin-top: 14px; color: var(--muted); line-height: 1.7; }
.system-panel strong { color: var(--text); } .system-panel strong { color: var(--text); }
.log-list { display: grid; gap: 2px; padding: 8px; } .log-list { display: grid; gap: 2px; padding: 8px; }
@@ -296,6 +304,23 @@ legend { padding: 0 5px; color: var(--muted); font-size: 11px; }
.outside-pill small, .outside-pill strong { display: block; } .outside-pill small, .outside-pill strong { display: block; }
.outside-pill small { color: var(--muted); font-size: 10px; } .outside-pill small { color: var(--muted); font-size: 10px; }
.outside-pill strong { margin-top: 3px; font-size: 18px; } .outside-pill strong { margin-top: 3px; font-size: 18px; }
.automation-plan-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 290px), 1fr)); gap: 12px; margin-bottom: 34px; }
.plan-card { display: grid; align-content: start; gap: 11px; min-height: 190px; padding: 17px; }
.plan-card.disabled { opacity: .65; }
.plan-card-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; }
.plan-card-head h3 { margin: 2px 0 0; font-size: 19px; }
.plan-card > p { margin: 0; color: var(--muted); font-size: 12px; line-height: 1.45; }
.plan-temp { display: flex; align-items: baseline; gap: 9px; }
.plan-temp > span { color: var(--muted); font-size: 20px; }
.plan-temp > b { color: var(--muted); font-weight: 500; }
.plan-temp > strong { font-size: 28px; letter-spacing: -.04em; }
.plan-temp small { margin-left: 2px; color: var(--muted); font-size: 11px; }
.plan-events { display: grid; gap: 6px; margin: 0; padding: 10px 0 0; border-top: 1px solid var(--line); list-style: none; }
.plan-events li { display: grid; grid-template-columns: 82px 1fr; gap: 8px; color: var(--text-soft); font-size: 11px; line-height: 1.35; }
.plan-events time { color: var(--muted); font-variant-numeric: tabular-nums; }
.plan-events .muted { display: block; color: var(--muted); }
.plan-house { border-color: color-mix(in srgb, var(--accent) 30%, var(--line)); }
.plan-loading { grid-column: 1/-1; padding: 18px; color: var(--muted); }
.house-mode-row { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; } .house-mode-row { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
.house-mode-row button { min-height: 44px; color: var(--muted); background: var(--surface-muted); } .house-mode-row button { min-height: 44px; color: var(--muted); background: var(--surface-muted); }
.house-mode-row button.active { color: var(--accent-text); background: var(--accent); font-weight: 800; } .house-mode-row button.active { color: var(--accent-text); background: var(--accent); font-weight: 800; }
@@ -409,13 +434,30 @@ legend { padding: 0 5px; color: var(--muted); font-size: 11px; }
.toast-copy strong { margin-bottom: 2px; font-size: 12px; } .toast-copy strong { margin-bottom: 2px; font-size: 12px; }
.toast-copy span { overflow-wrap: anywhere; color: var(--muted); font-size: 11px; line-height: 1.35; } .toast-copy span { overflow-wrap: anywhere; color: var(--muted); font-size: 11px; line-height: 1.35; }
.toast-close { width: 28px; height: 28px; padding: 0; border-radius: 50%; color: var(--muted); background: transparent; font-size: 19px; } .toast-close { width: 28px; height: 28px; padding: 0; border-radius: 50%; color: var(--muted); background: transparent; font-size: 19px; }
.toast-progress { position: absolute; right: 0; bottom: 0; left: 0; height: 2px; background: var(--accent); transform-origin: left; animation: toast-progress 3.6s linear forwards; } .toast-progress { position: absolute; right: 11px; bottom: 3px; left: 11px; height: 2px; border-radius: 99px; background: var(--accent); transform-origin: left; animation: toast-progress 3.6s linear forwards; }
.toast-item.error .toast-progress { background: var(--danger); animation-duration: 5.2s; } .toast-item.error .toast-progress { background: var(--danger); animation-duration: 5.2s; }
@keyframes toast-progress { from { transform: scaleX(1); } to { transform: scaleX(0); } } @keyframes toast-progress { from { transform: scaleX(1); } to { transform: scaleX(0); } }
.debug-overlay { position: fixed; z-index: 110; right: 18px; bottom: 92px; width: min(680px, calc(100vw - 36px)); max-height: min(46vh, 440px); overflow: hidden; border: 1px solid color-mix(in srgb, var(--accent) 28%, var(--line)); border-radius: 18px; background: color-mix(in srgb, var(--surface) 96%, transparent); box-shadow: 0 18px 60px rgba(0,0,0,.22); backdrop-filter: blur(18px); }
.debug-overlay[hidden] { display: none; }
.debug-overlay-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 12px; border-bottom: 1px solid var(--line); }
.debug-overlay-head > div { display: grid; gap: 2px; }
.debug-overlay-head strong { font-size: 12px; }
.debug-overlay-head small { color: var(--muted); font-size: 10px; }
.debug-overlay-head button { padding: 6px 9px; color: var(--muted); background: var(--surface-muted); font-size: 10px; }
.debug-overlay-lines { max-height: min(38vh, 360px); overflow: auto; padding: 6px; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; }
.debug-line { display: grid; grid-template-columns: 70px 46px 1fr; gap: 8px; padding: 5px 6px; border-bottom: 1px solid color-mix(in srgb, var(--line) 70%, transparent); font-size: 10px; line-height: 1.35; }
.debug-line time, .debug-line b { color: var(--muted); font-weight: 600; }
.debug-line span { overflow-wrap: anywhere; white-space: pre-wrap; }
.debug-empty { padding: 18px; color: var(--muted); text-align: center; font-size: 11px; }
@media (max-width: 620px) { @media (max-width: 620px) {
.custom-chart-add, .custom-chart-save { align-items: stretch; flex-direction: column; } .custom-chart-add, .custom-chart-save { align-items: stretch; flex-direction: column; }
.toast-stack { right: 12px; bottom: 88px; width: calc(100vw - 24px); } .toast-stack { right: 12px; bottom: 88px; width: calc(100vw - 24px); }
.debug-overlay { right: 12px; bottom: 88px; width: calc(100vw - 24px); }
.debug-line { grid-template-columns: 58px 38px 1fr; }
.influx-fields { grid-template-columns: 1fr; }
.influx-fields label:last-child:nth-child(odd) { grid-column: auto; }
} }
.history-chart-card canvas { width: 100%; min-width: 720px; } .history-chart-card canvas { width: 100%; min-width: 720px; }