This commit is contained in:
Mateusz Gruszczyński
2026-09-04 11:30:48 +02:00
parent 236540e0f0
commit 9b4fafdfa6
14 changed files with 262 additions and 62 deletions
Generated
+1 -1
View File
@@ -700,7 +700,7 @@ dependencies = [
[[package]] [[package]]
name = "gree-controller" name = "gree-controller"
version = "0.13.0" version = "0.13.1"
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.13.0" version = "0.13.1"
edition = "2021" edition = "2021"
authors = ["GREE Controller contributors"] authors = ["GREE Controller contributors"]
description = "Standalone local GREE HVAC controller with Web UI, SQLite and Home Assistant sensor support" description = "Standalone local GREE HVAC controller with Web UI, SQLite and Home Assistant sensor support"
+13 -13
View File
@@ -1,13 +1,13 @@
d67af429e4da9ce08e9d2f2a8472849ffbd70d135b1c5da535a076026794d04c ./.env.example d67af429e4da9ce08e9d2f2a8472849ffbd70d135b1c5da535a076026794d04c ./.env.example
a4ec3874a2e3ab1bad28fb40bb620f7b01f64d01ad9b699306bf70ada31227db ./.gitignore a4ec3874a2e3ab1bad28fb40bb620f7b01f64d01ad9b699306bf70ada31227db ./.gitignore
e664a2f53234cb1e5d75bb9482c5477484d6d6ad878f26ea0bf84e23c4f35644 ./Cargo.lock 66ab5e9fc9c23c506c2cde7810ead25b63ea9831dab56bda09194af9cea378c0 ./Cargo.lock
cbbebcf672b947987eaaa2122aa080d2c71737fcdd0736aef318fd3099ef5851 ./Cargo.toml 1bda71918037a7f726cab6d384f3eec3ee5e34ba7377ba21c7b8833c599682e3 ./Cargo.toml
19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE 19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE
01243b698f466857db8755e857ac1da49d9ab1e78c7a914c237829c98242bbf7 ./README.md 31dd042f3566a9927d9cde6b603f77444403a1e8055539c5f4c1245f94704507 ./README.md
d56cfff4179a6511aa764a91c6ddebe21fcd35f36b7298e62d012e11f62bfeb9 ./build.rs d56cfff4179a6511aa764a91c6ddebe21fcd35f36b7298e62d012e11f62bfeb9 ./build.rs
f586babc2a9392035f3821c8b78226aec19589cf0a9f9178097fef9afaf1026f ./docs/API.md fc63a26f2c9541ffe1c8b21a5ce29a5585b5e1d9ab1fdc1e57db3cd019c78171 ./docs/API.md
2e1e18fd8167dabfe2469c26e85cce62486c7cb6a502c63c6f6b0cd74d5885e0 ./docs/FLOW.md 2e1e18fd8167dabfe2469c26e85cce62486c7cb6a502c63c6f6b0cd74d5885e0 ./docs/FLOW.md
5dc6f579834a991f8c9366e7a13b24214993cba01db89ed637d305a71fdd59c8 ./docs/openapi.json 6ca0bf3639a3133f7a67a1744269d85752d324142cf49c93e387bc02b40a5639 ./docs/openapi.json
b11e581dd916676e24402112fe6896bc681abf3c94cafc85c4d3ded3f3a272a3 ./home-assistant/README.md b11e581dd916676e24402112fe6896bc681abf3c94cafc85c4d3ded3f3a272a3 ./home-assistant/README.md
f8e8559fe10fe523ac5bc9aac25c6e26e862f679d502e8f3c39f38a0a8e40911 ./home-assistant/custom_components/gree_controller/__init__.py f8e8559fe10fe523ac5bc9aac25c6e26e862f679d502e8f3c39f38a0a8e40911 ./home-assistant/custom_components/gree_controller/__init__.py
3f6ef15ef58456376ac53fde7cace1ef359d6a6f7a64c5b575ad77ea6e55ccc4 ./home-assistant/custom_components/gree_controller/api.py 3f6ef15ef58456376ac53fde7cace1ef359d6a6f7a64c5b575ad77ea6e55ccc4 ./home-assistant/custom_components/gree_controller/api.py
@@ -18,7 +18,7 @@ b7f0873109c52be9d7f09bea3dffc416103c50085e1f0680d11661a969479898 ./home-assista
5a96fe8f5c035c34f1339370270cd078056202d09e236dec75735be11de92a7d ./home-assistant/custom_components/gree_controller/entity_map.py 5a96fe8f5c035c34f1339370270cd078056202d09e236dec75735be11de92a7d ./home-assistant/custom_components/gree_controller/entity_map.py
c4fb75c246db651087900ebfc2291ff41ac87652cd6194fc0b776b0005c1cbcf ./home-assistant/custom_components/gree_controller/icon.png c4fb75c246db651087900ebfc2291ff41ac87652cd6194fc0b776b0005c1cbcf ./home-assistant/custom_components/gree_controller/icon.png
c4fb75c246db651087900ebfc2291ff41ac87652cd6194fc0b776b0005c1cbcf ./home-assistant/custom_components/gree_controller/logo.png c4fb75c246db651087900ebfc2291ff41ac87652cd6194fc0b776b0005c1cbcf ./home-assistant/custom_components/gree_controller/logo.png
f62e0122fcd812e42736af85233a4c53fdb34450f38a93d8ad57a18f8a937b91 ./home-assistant/custom_components/gree_controller/manifest.json 412a887b4349c3cbdaededff76ec5ffe5a0c7dd4374b2de4ca60de02ee3f3d2e ./home-assistant/custom_components/gree_controller/manifest.json
38d659d0273c0d1428679e44574a2666c605d1b3796d323b626baa40560788cf ./home-assistant/custom_components/gree_controller/number.py 38d659d0273c0d1428679e44574a2666c605d1b3796d323b626baa40560788cf ./home-assistant/custom_components/gree_controller/number.py
39c4309001b75abb56234f05662bc06e077054986876f1927937edbce528ec95 ./home-assistant/custom_components/gree_controller/select.py 39c4309001b75abb56234f05662bc06e077054986876f1927937edbce528ec95 ./home-assistant/custom_components/gree_controller/select.py
cca65482e36d48035aca178121a378fe7d578d600acff267ae81a6399c0da653 ./home-assistant/custom_components/gree_controller/sensor.py cca65482e36d48035aca178121a378fe7d578d600acff267ae81a6399c0da653 ./home-assistant/custom_components/gree_controller/sensor.py
@@ -69,7 +69,7 @@ fdcd9a5055d08037278b842e7ab69265345c5811f0a06867136c511d140bb191 ./presets/wind
804f22123cd3e8db0fac791826c8dd9f758fb866c8e3b5655fb6d25d259dccf1 ./presets/workday_comfort.json 804f22123cd3e8db0fac791826c8dd9f758fb866c8e3b5655fb6d25d259dccf1 ./presets/workday_comfort.json
01952aa92b217f8eae2493b88870e2dec595100cd15c4d561ff11ae2b936c46f ./regenerate-sha.sh 01952aa92b217f8eae2493b88870e2dec595100cd15c4d561ff11ae2b936c46f ./regenerate-sha.sh
bb89bac237e750e9b1bf73761d7df97a6b81853091615878c03f13d7b6399aa7 ./scripts/README.md bb89bac237e750e9b1bf73761d7df97a6b81853091615878c03f13d7b6399aa7 ./scripts/README.md
52fa6906d2c80d7c8fe0b0d178c16b9b0a53408472a9ecdccbb9d8df1137860f ./scripts/api_dev_test.py 9c76cf597224817576be543088e7aa0af8e5961e7045645cbca4e878bbffae2d ./scripts/api_dev_test.py
5bc736c7bc76ca80aaa406bb171d2aa91baf4c3aa8695dce0e09b888b6ab3146 ./scripts/common.sh 5bc736c7bc76ca80aaa406bb171d2aa91baf4c3aa8695dce0e09b888b6ab3146 ./scripts/common.sh
6403786610ee6d2f628193c25aee0dd058d62e904aa1a31d5f62fdaae0e94b4f ./scripts/configure-gree-network.sh 6403786610ee6d2f628193c25aee0dd058d62e904aa1a31d5f62fdaae0e94b4f ./scripts/configure-gree-network.sh
1d6e14e26e49aa9d3527f30a23668bf8d9c48b67e6628ef686c3155c012155de ./scripts/dev.sh 1d6e14e26e49aa9d3527f30a23668bf8d9c48b67e6628ef686c3155c012155de ./scripts/dev.sh
@@ -115,7 +115,7 @@ c0a7fa3eac198737d7261ad00c542a614e6d7291f6581af8e7f7aef9dec1a049 ./src/db/sched
1a209dc6d8df864778bb03c980f309a2ec86fc62df274880fbb95eff8470174e ./src/db/tests.rs 1a209dc6d8df864778bb03c980f309a2ec86fc62df274880fbb95eff8470174e ./src/db/tests.rs
f90fbb14f29a20b799b3856bdaa22812a9f0d9dfa46693f1341c160dccda8a97 ./src/db/zone_history.rs f90fbb14f29a20b799b3856bdaa22812a9f0d9dfa46693f1341c160dccda8a97 ./src/db/zone_history.rs
4d2c6053586558afbca6b60fbbc9b1cd397f74f8284d7c01f3337eb42bf6d4f7 ./src/engine.rs 4d2c6053586558afbca6b60fbbc9b1cd397f74f8284d7c01f3337eb42bf6d4f7 ./src/engine.rs
ec6034df6667b3ac6ec39851443048a56e87fa861f5846950a5e79fa89ee5a30 ./src/engine/automations.rs d834dab18fb8970a9e44e3d2fb6e5cee4407eea15f2ccbd50dc6fceb0526db3f ./src/engine/automations.rs
88f85acf692e12c7061e039dd7c54f101fe0b014c4a3d799150ad92eea9e2894 ./src/engine/commands.rs 88f85acf692e12c7061e039dd7c54f101fe0b014c4a3d799150ad92eea9e2894 ./src/engine/commands.rs
8ee70ad21d8ee32914dd90952b04dfa8355ee868252ef4fc682ec40503590a7a ./src/engine/control_plan.rs 8ee70ad21d8ee32914dd90952b04dfa8355ee868252ef4fc682ec40503590a7a ./src/engine/control_plan.rs
7b48a33343d519a2ff2d81e9d757eb56a0c1beacfc0b7b8ef61f49c2ad363f9a ./src/engine/deadlines.rs 7b48a33343d519a2ff2d81e9d757eb56a0c1beacfc0b7b8ef61f49c2ad363f9a ./src/engine/deadlines.rs
@@ -123,15 +123,15 @@ e350aa63f3bb14ca887eaaa7555edbeae00fb584cbcaf552cc972f0c8918f5d8 ./src/engine/g
f0c8f3384b6174771aeb5640ee1eec87f3d6d9ff79a4fbd6a35554a4eec00cfa ./src/engine/history.rs f0c8f3384b6174771aeb5640ee1eec87f3d6d9ff79a4fbd6a35554a4eec00cfa ./src/engine/history.rs
41b7045877771b7c352328998cb45ce2433e7f9631fad3eb1a882ab4897dc31f ./src/engine/local_thermostat.rs 41b7045877771b7c352328998cb45ce2433e7f9631fad3eb1a882ab4897dc31f ./src/engine/local_thermostat.rs
6280a7f5059d9737987bf2d72b19a26b3a6709ce578af1108c4215f3f47d7763 ./src/engine/ownership.rs 6280a7f5059d9737987bf2d72b19a26b3a6709ce578af1108c4215f3f47d7763 ./src/engine/ownership.rs
9c37adf514c96840fd6a5c715c579350fa86d6dcc0655a2aa97d1fd7c7e04299 ./src/engine/polling.rs a5250a4220deb0a9d5cdaee0cf38343a0e26ba37003f6d47e63402a9eaa1e4b2 ./src/engine/polling.rs
f0bcd3a56b600944ccc62fa24ce20838033eeb4354f937cdb534630c1a1f5a78 ./src/engine/runtime.rs f0bcd3a56b600944ccc62fa24ce20838033eeb4354f937cdb534630c1a1f5a78 ./src/engine/runtime.rs
c1e8de5392681ba1255e3e2d871a6ee860fb8ca987a0a8f973d9ded3977a68fb ./src/engine/schedules.rs c1e8de5392681ba1255e3e2d871a6ee860fb8ca987a0a8f973d9ded3977a68fb ./src/engine/schedules.rs
de80d9ff69f1c843fdac23fdc3e3a350fc1082a186261b3a38617af189132356 ./src/engine/targets.rs de80d9ff69f1c843fdac23fdc3e3a350fc1082a186261b3a38617af189132356 ./src/engine/targets.rs
682c8b3956c657b655746cbd19ee97ee2f4cf953bc9de67d6d70a1b14f3dd746 ./src/engine/temperature.rs 682c8b3956c657b655746cbd19ee97ee2f4cf953bc9de67d6d70a1b14f3dd746 ./src/engine/temperature.rs
833b72907375e75c3f42b3921ad70a8e298f5a93743bf0d0c19059aa272462d1 ./src/engine/temporary_thermostat.rs 833b72907375e75c3f42b3921ad70a8e298f5a93743bf0d0c19059aa272462d1 ./src/engine/temporary_thermostat.rs
35e8582fcdb13ff6d8850a5274893805a04cabe3b0c6c936b1ad4a9808bf16ee ./src/engine/tests.rs f4a9dc023cca9224c75ff1e9d91feac069918de93b4a344e72fef5050d6ff44f ./src/engine/tests.rs
786ae270afb543d03cf6586942394c6e29015d94c71526f4fb54010c220c90d8 ./src/engine/zone_actions.rs e6f8cfd1e4044b2f833d0f74c85ca664dd08dfb3e57bcddbafe7581cf7c63081 ./src/engine/zone_actions.rs
56d9af59eb700ac6363d26db0ffbea777270cdbbe8e5b7eb982bff9f02539eee ./src/engine/zone_control.rs dd38a8b8f6e910e4475c42ae41eaad2c68237fb36b78a3d7dd5d147c08487568 ./src/engine/zone_control.rs
2276aca42bca712a55de9b6c5dbe64639675c84befa85a446a7d34613596ed7e ./src/error.rs 2276aca42bca712a55de9b6c5dbe64639675c84befa85a446a7d34613596ed7e ./src/error.rs
2731f629f22a9d4fdb831ea3b283f43900b190f7e6a08fdab5b8fbb2c288547b ./src/home_assistant.rs 2731f629f22a9d4fdb831ea3b283f43900b190f7e6a08fdab5b8fbb2c288547b ./src/home_assistant.rs
342dcb54caf22d2282a3f3f2297f54bd413b5242c17756276ea74994e88e28fb ./src/influxdb.rs 342dcb54caf22d2282a3f3f2297f54bd413b5242c17756276ea74994e88e28fb ./src/influxdb.rs
@@ -171,7 +171,7 @@ c868422c02ba457df21ae92acbd00698c243855ddd1d4e95ac2166796eba9a44 ./src/protocol
c91e4b8b250b30cbe76f313b70c16244e9003bce92e02c82283a1831f91974c5 ./src/queries/maintenance.rs c91e4b8b250b30cbe76f313b70c16244e9003bce92e02c82283a1831f91974c5 ./src/queries/maintenance.rs
3a49bad74a2f047893e726b5e00a6e833c228ad268bdf50cd60f786ba164ea54 ./src/queries/schema.rs 3a49bad74a2f047893e726b5e00a6e833c228ad268bdf50cd60f786ba164ea54 ./src/queries/schema.rs
f13f1be3d5789539eba3fc1b59c14835f8c681eca7634e118fa3763a53aeee2b ./src/queries/zone_history.rs f13f1be3d5789539eba3fc1b59c14835f8c681eca7634e118fa3763a53aeee2b ./src/queries/zone_history.rs
74c1ecdb45e1f4c6275a16a14d157b3a67a18c3f3d80dfdae18aec5bf6aed849 ./src/state.rs 008be170ed418f8701c512f521828b8e6c0071ff195cd6f7aa39fade6c7c3fa9 ./src/state.rs
b92a6cb158b494fe145b43c7641e65f6fafff47201d7d76edbec2cfd8b94835c ./systemd/gree-controller.service b92a6cb158b494fe145b43c7641e65f6fafff47201d7d76edbec2cfd8b94835c ./systemd/gree-controller.service
544a31cb2e5374227026afd6abd0d910381f3fca1638154ebd74e3c3d96a35d1 ./web/404.html 544a31cb2e5374227026afd6abd0d910381f3fca1638154ebd74e3c3d96a35d1 ./web/404.html
e98bdd7204349cce1ec6f57283509697af0bbc72280622a6c3efa6fed242db4f ./web/favicon.svg e98bdd7204349cce1ec6f57283509697af0bbc72280622a6c3efa6fed242db4f ./web/favicon.svg
+2 -2
View File
@@ -2,9 +2,9 @@
Self-hosted controller for GREE-compatible air conditioners with a local Web UI, thermostat zones, schedules, Home Assistant integration, history, notifications and a documented HTTP/WebSocket API. Self-hosted controller for GREE-compatible air conditioners with a local Web UI, thermostat zones, schedules, Home Assistant integration, history, notifications and a documented HTTP/WebSocket API.
**Current release: 0.13.0** **Current release: 0.13.1**
Release 0.13.0 keeps the existing control-plan HTTP contract while adding a materialized backend snapshot, live `control_plan.updated` WebSocket delivery with resynchronization, HTTP fallback in the Web UI, and a single restricted Home Assistant snapshot request for devices, groups and the control plan. Release 0.13.1 keeps all 0.13.0 API/UI contracts and reduces real-time churn: regulator-only `updated_at` heartbeats no longer emit full `zone.updated` frames, device heartbeat polls no longer invalidate the materialized control plan unless plan-relevant device state changed, and runtime log traffic no longer triggers control-plan rebuilds. Real zone/device/control changes remain live.
> [Full API reference](docs/API.md) — authentication, every endpoint, request bodies, response models, WebSocket events and examples. > [Full API reference](docs/API.md) — authentication, every endpoint, request bodies, response models, WebSocket events and examples.
+5 -3
View File
@@ -1,6 +1,6 @@
# GREE Controller API reference # GREE Controller API reference
HTTP and WebSocket API for GREE Controller **0.13.0**. HTTP and WebSocket API for GREE Controller **0.13.1**.
[← Main documentation](../README.md) [← Main documentation](../README.md)
@@ -231,7 +231,7 @@ Response:
{ {
"status": "ok", "status": "ok",
"name": "gree-controller", "name": "gree-controller",
"version": "0.13.0", "version": "0.13.1",
"uptime_seconds": 1234, "uptime_seconds": 1234,
"control_ready": true, "control_ready": true,
"time": "2026-08-30T06:54:00Z" "time": "2026-08-30T06:54:00Z"
@@ -257,7 +257,7 @@ Returns the initial Web UI snapshot:
"control_plan": {"generated_at": "2026-09-04T08:00:00Z", "zones": [], "rules": []}, "control_plan": {"generated_at": "2026-09-04T08:00:00Z", "zones": [], "rules": []},
"control_plan_revision": 42, "control_plan_revision": 42,
"system": { "system": {
"version": "0.13.0", "version": "0.13.1",
"uptime_seconds": 1234, "uptime_seconds": 1234,
"auth_required": false, "auth_required": false,
"control_ready": true, "control_ready": true,
@@ -1450,6 +1450,8 @@ log.created
`control_plan.updated` uses `data.revision` plus `data.plan`. Revisions increase only when the materialized plan changes semantically; `generated_at` alone does not create a new revision. `control_plan.updated` uses `data.revision` plus `data.plan`. Revisions increase only when the materialized plan changes semantically; `generated_at` alone does not create a new revision.
`zone.updated` keeps the existing full-zone payload. Regulator passes that only advance the internal `updated_at` heartbeat are deduplicated and do not emit a frame; any actual zone state/sensor/control change still emits the full snapshot. Device polling keeps `device.updated` heartbeats for live `last_seen` UI, but heartbeat-only fields do not invalidate `control-plan`.
Additional engine/integration events may be introduced without changing the envelope. Additional engine/integration events may be introduced without changing the envelope.
`api.request` data: `api.request` data:
+3 -3
View File
@@ -2,9 +2,9 @@
"openapi": "3.1.0", "openapi": "3.1.0",
"info": { "info": {
"title": "GREE Controller API", "title": "GREE Controller API",
"version": "0.13.0", "version": "0.13.1",
"summary": "Local HTTP/WebSocket API for GREE HVAC control", "summary": "Local HTTP/WebSocket API for GREE HVAC control",
"description": "Self-hosted API used by the GREE Controller Web UI and Home Assistant integration.\n\n### Authentication\nNormal administrator endpoints accept `Authorization: Bearer <APP_TOKEN>` or `x-api-token: <APP_TOKEN>` when `GREE_CONTROLLER_APP_TOKEN` is configured. If the app token is empty, those endpoints intentionally run in trusted-LAN mode without authentication.\n\nThe restricted `/api/integrations/home-assistant/*` control surface always requires either a generated access token or the administrator app token. Generated tokens do **not** grant normal administrator API access.\n\nIn Swagger UI use **Authorize** and enter the token in either the bearer field or `x-api-token` field.\n\n### Control ownership\nThermostat zones, groups, house rules, direct device control, schedules and visual Flows share an ownership/arbitration model. Prefer zone/group/house endpoints for thermostat intent; direct device commands are technical/manual control and can deliberately take ownership.\n\n### 0.12.0 breaking API\nRuntime settings are split into functional `/api/settings/*` resources. The former `/api/settings`, `/api/debug`, `/api/events/retention`, `/api/settings/export` and `/api/settings/import` endpoints were removed without compatibility aliases.\n\n### 0.13.0\n`control-plan` keeps its existing HTTP response shape but is materialized in memory and pushed to WebSocket clients as `control_plan.updated`; HTTP remains the fallback/resync path. A restricted Home Assistant snapshot endpoint combines devices, groups and the control plan.", "description": "Self-hosted API used by the GREE Controller Web UI and Home Assistant integration.\n\n### Authentication\nNormal administrator endpoints accept `Authorization: Bearer <APP_TOKEN>` or `x-api-token: <APP_TOKEN>` when `GREE_CONTROLLER_APP_TOKEN` is configured. If the app token is empty, those endpoints intentionally run in trusted-LAN mode without authentication.\n\nThe restricted `/api/integrations/home-assistant/*` control surface always requires either a generated access token or the administrator app token. Generated tokens do **not** grant normal administrator API access.\n\nIn Swagger UI use **Authorize** and enter the token in either the bearer field or `x-api-token` field.\n\n### Control ownership\nThermostat zones, groups, house rules, direct device control, schedules and visual Flows share an ownership/arbitration model. Prefer zone/group/house endpoints for thermostat intent; direct device commands are technical/manual control and can deliberately take ownership.\n\n### 0.12.0 breaking API\nRuntime settings are split into functional `/api/settings/*` resources. The former `/api/settings`, `/api/debug`, `/api/events/retention`, `/api/settings/export` and `/api/settings/import` endpoints were removed without compatibility aliases.\n\n### 0.13.0\n`control-plan` keeps its existing HTTP response shape but is materialized in memory and pushed to WebSocket clients as `control_plan.updated`; HTTP remains the fallback/resync path. A restricted Home Assistant snapshot endpoint combines devices, groups and the control plan.\n\n### 0.13.1\nRuntime WebSocket churn is reduced without changing contracts: no-op zone regulator heartbeats are deduplicated and device heartbeat polls only invalidate the materialized control plan when plan-relevant device state changes.",
"license": { "license": {
"name": "MIT" "name": "MIT"
} }
@@ -4891,7 +4891,7 @@
}, },
"version": { "version": {
"type": "string", "type": "string",
"example": "0.13.0" "example": "0.13.1"
}, },
"uptime_seconds": { "uptime_seconds": {
"type": "integer", "type": "integer",
@@ -1,7 +1,7 @@
{ {
"domain": "gree_controller", "domain": "gree_controller",
"name": "GREE Controller", "name": "GREE Controller",
"version": "0.13.0", "version": "0.13.1",
"config_flow": true, "config_flow": true,
"integration_type": "hub", "integration_type": "hub",
"iot_class": "local_polling", "iot_class": "local_polling",
+4 -4
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Developer smoke/integration tests for GREE Controller API 0.13.0. """Developer smoke/integration tests for GREE Controller API 0.13.1.
Default mode is read-only and safe to run against a real controller. Default mode is read-only and safe to run against a real controller.
Use --settings-write to additionally round-trip all split settings resources and Use --settings-write to additionally round-trip all split settings resources and
@@ -26,7 +26,7 @@ from typing import Any, Callable, Iterable, Optional
DEFAULT_BASE_URL = os.environ.get("GREE_API_URL", "http://127.0.0.1:8787") DEFAULT_BASE_URL = os.environ.get("GREE_API_URL", "http://127.0.0.1:8787")
DEFAULT_TOKEN = os.environ.get("GREE_API_TOKEN", "") DEFAULT_TOKEN = os.environ.get("GREE_API_TOKEN", "")
DEFAULT_EXPECTED_VERSION = "0.13.0" DEFAULT_EXPECTED_VERSION = "0.13.1"
SETTINGS_PATHS: dict[str, set[str]] = { SETTINGS_PATHS: dict[str, set[str]] = {
"/api/settings/application": {"simulator_enabled"}, "/api/settings/application": {"simulator_enabled"},
@@ -519,7 +519,7 @@ def run_suite(args: argparse.Namespace) -> int:
print(f"Settings write tests: {'ENABLED' if args.settings_write else 'disabled'}\n") print(f"Settings write tests: {'ENABLED' if args.settings_write else 'disabled'}\n")
runner.run("public health", lambda: test_health(client, args.expected_version)) runner.run("public health", lambda: test_health(client, args.expected_version))
runner.run("OpenAPI 0.13.0 contract", lambda: test_openapi(client, args.expected_version)) runner.run("OpenAPI 0.13.1 contract", lambda: test_openapi(client, args.expected_version))
runner.run("protected API requires auth", lambda: test_protected_auth(client)) runner.run("protected API requires auth", lambda: test_protected_auth(client))
for path in SAFE_GET_PATHS: for path in SAFE_GET_PATHS:
@@ -587,7 +587,7 @@ def run_suite(args: argparse.Namespace) -> int:
def parse_args(argv: Optional[Iterable[str]] = None) -> argparse.Namespace: def parse_args(argv: Optional[Iterable[str]] = None) -> argparse.Namespace:
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description="Developer smoke/integration tests for GREE Controller API 0.13.0.", description="Developer smoke/integration tests for GREE Controller API 0.13.1.",
formatter_class=argparse.RawDescriptionHelpFormatter, formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""Examples: epilog="""Examples:
python3 scripts/api_dev_test.py python3 scripts/api_dev_test.py
+4
View File
@@ -161,6 +161,9 @@ async fn run_automations(state: &AppState) -> Result<()> {
item.last_fired_at = Some(fired_at); item.last_fired_at = Some(fired_at);
item.updated_at = Utc::now(); item.updated_at = Utc::now();
state.db.save_automation(&item)?; state.db.save_automation(&item)?;
// last_fired_at/next_ready_at are part of ControlPlan.rules. Runtime execution
// does not emit automation.updated, so invalidate the plan directly.
state.invalidate_control_plan();
state.log("info", "automation.fired", &format!("Automation {} fired", item.name), json!({ state.log("info", "automation.fired", &format!("Automation {} fired", item.name), json!({
"automation_id": item.id, "flow_id": item.flow_id, "flow_node_id": item.flow_node_id, "group_id": item.action_group_id, "device_id": item.action_device_id "automation_id": item.id, "flow_id": item.flow_id, "flow_node_id": item.flow_node_id, "group_id": item.action_group_id, "device_id": item.action_device_id
})); }));
@@ -181,6 +184,7 @@ async fn run_automations(state: &AppState) -> Result<()> {
item.last_fired_at = Some(Utc::now()); item.last_fired_at = Some(Utc::now());
item.updated_at = Utc::now(); item.updated_at = Utc::now();
state.db.save_automation(&item)?; state.db.save_automation(&item)?;
state.invalidate_control_plan();
state.log("error", "automation.error", &err.to_string(), json!({"automation_id": item.id, "flow_id": item.flow_id, "flow_node_id": item.flow_node_id})); state.log("error", "automation.error", &err.to_string(), json!({"automation_id": item.id, "flow_id": item.flow_id, "flow_node_id": item.flow_node_id}));
} }
} }
+20 -1
View File
@@ -1,3 +1,15 @@
fn device_runtime_change_affects_control_plan(before: &Device, after: &Device) -> bool {
// build_control_plan() consumes only these runtime device fields. Poll heartbeat data such
// as last_seen/response_time_ms remains available through device.updated but no longer
// forces an expensive control-plan rebuild.
before.name != after.name
|| before.power != after.power
|| before.mode != after.mode
|| before.target_temperature != after.target_temperature
|| before.online != after.online
|| before.communication_failures != after.communication_failures
}
async fn lock_poll_zone_operations(state: &AppState, device_id: &str) -> Result<Vec<tokio::sync::OwnedMutexGuard<()>>, AppError> { async fn lock_poll_zone_operations(state: &AppState, device_id: &str) -> Result<Vec<tokio::sync::OwnedMutexGuard<()>>, AppError> {
let mut zone_ids: Vec<String> = state.db.list_zones()?.into_iter() let mut zone_ids: Vec<String> = state.db.list_zones()?.into_iter()
.filter(|zone| zone.device_id == device_id) .filter(|zone| zone.device_id == device_id)
@@ -35,7 +47,11 @@ async fn poll_one_locked(state: &AppState, device_id: &str) -> Result<Device, Ap
} }
state.db.save_device(&device)?; state.db.save_device(&device)?;
record_reading(state, &device)?; record_reading(state, &device)?;
state.broadcast("device.updated", serde_json::to_value(&device).unwrap_or_default()); state.broadcast_with_control_plan_invalidation(
"device.updated",
serde_json::to_value(&device).unwrap_or_default(),
device_runtime_change_affects_control_plan(&before, &device),
);
Ok(device) Ok(device)
} }
@@ -182,6 +198,9 @@ fn record_poll_failure(device: &mut Device, error: &str) {
fn register_device_failure(state: &AppState, device: &mut Device, error: &str) -> Result<(), AppError> { fn register_device_failure(state: &AppState, device: &mut Device, error: &str) -> Result<(), AppError> {
record_poll_failure(device, error); record_poll_failure(device, error);
state.db.save_device(device)?; state.db.save_device(device)?;
// Command failures can change online/communication health without a device.updated frame.
// Keep the materialized plan current explicitly instead of relying on log-event prefixes.
state.invalidate_control_plan();
state.log("warn", "device.communication_error", &format!("{}: {error}", device.name), json!({ state.log("warn", "device.communication_error", &format!("{}: {error}", device.name), json!({
"device_id": device.id, "device_id": device.id,
"consecutive_failures": device.communication_failures, "consecutive_failures": device.communication_failures,
+54
View File
@@ -233,6 +233,60 @@ mod tests {
} }
} }
#[test]
fn regulator_zone_event_dedup_ignores_only_updated_at_heartbeat() {
let zone = test_zone("device");
let mut heartbeat = zone.clone();
heartbeat.updated_at = zone.updated_at.clone() + chrono::Duration::seconds(5);
assert_eq!(
zone_event_semantic_value(&zone).unwrap(),
zone_event_semantic_value(&heartbeat).unwrap()
);
heartbeat.device_temperature = Some(24.5);
assert_ne!(
zone_event_semantic_value(&zone).unwrap(),
zone_event_semantic_value(&heartbeat).unwrap()
);
}
#[test]
fn regulator_zone_plan_invalidation_tracks_only_plan_inputs() {
let zone = test_zone("combined");
let mut diagnostics_only = zone.clone();
diagnostics_only.device_temperature = Some(24.0);
diagnostics_only.external_temperature = Some(23.0);
diagnostics_only.last_action_at = Some(Utc::now());
assert!(!zone_runtime_change_affects_control_plan(&zone, &diagnostics_only));
let mut temperature_changed = zone.clone();
temperature_changed.current_temperature = Some(23.5);
assert!(zone_runtime_change_affects_control_plan(&zone, &temperature_changed));
let mut demand_changed = zone.clone();
demand_changed.demand = !zone.demand;
assert!(zone_runtime_change_affects_control_plan(&zone, &demand_changed));
}
#[test]
fn device_poll_heartbeat_does_not_invalidate_control_plan() {
let device = Device::simulated_default();
let mut heartbeat = device.clone();
heartbeat.last_seen = Some(Utc::now() + chrono::Duration::seconds(15));
heartbeat.updated_at = Utc::now() + chrono::Duration::seconds(15);
heartbeat.response_time_ms = Some(42);
heartbeat.current_temperature = Some(25.1);
assert!(!device_runtime_change_affects_control_plan(&device, &heartbeat));
let mut power_changed = device.clone();
power_changed.power = !device.power;
assert!(device_runtime_change_affects_control_plan(&device, &power_changed));
let mut health_changed = device.clone();
health_changed.communication_failures = 1;
assert!(device_runtime_change_affects_control_plan(&device, &health_changed));
}
#[test] #[test]
fn zone_can_use_separate_heating_and_cooling_hysteresis() { fn zone_can_use_separate_heating_and_cooling_hysteresis() {
let mut zone = test_zone("device"); let mut zone = test_zone("device");
+100 -4
View File
@@ -1,14 +1,107 @@
#[derive(Debug)]
struct PersistedZoneCycle {
previous: Option<Zone>,
zone: Zone,
}
fn zone_event_semantic_value(zone: &Zone) -> Result<Value, AppError> {
let mut value = serde_json::to_value(zone)?;
if let Some(object) = value.as_object_mut() {
// updated_at is a persistence/concurrency and observation timestamp. It advances on
// every regulator pass even when no user-visible zone state changed, so it must not
// by itself create a full zone.updated WebSocket frame.
object.remove("updated_at");
}
Ok(value)
}
fn zone_runtime_change_affects_control_plan(before: &Zone, after: &Zone) -> bool {
// Keep this projection aligned with build_control_plan(). Fields omitted here are runtime
// diagnostics/history only and do not alter the materialized plan. Configuration/API
// mutations still use the normal broadcast() path, which conservatively invalidates it.
let plan_input = |zone: &Zone| {
json!({
"id": zone.id,
"name": zone.name,
"device_id": zone.device_id,
"enabled": zone.enabled,
"mode": zone.mode,
"inherit_house_mode": zone.inherit_house_mode,
"setpoint": zone.setpoint,
"profile_version": zone.profile_version,
"cool_comfort_setpoint": zone.cool_comfort_setpoint,
"cool_sleep_setpoint": zone.cool_sleep_setpoint,
"cool_away_setpoint": zone.cool_away_setpoint,
"heat_comfort_setpoint": zone.heat_comfort_setpoint,
"heat_sleep_setpoint": zone.heat_sleep_setpoint,
"heat_away_setpoint": zone.heat_away_setpoint,
"current_temperature": zone.current_temperature,
"control_temperature_source": zone.control_temperature_source,
"manual_preset": zone.manual_preset,
"manual_setpoint": zone.manual_setpoint,
"manual_override_until": zone.manual_override_until,
"local_thermostat_power": zone.local_thermostat_power,
"local_thermostat_resume_at": zone.local_thermostat_resume_at,
"temporary_quick_thermostat": zone.temporary_quick_thermostat,
"device_manual_override": zone.device_manual_override,
"device_manual_override_until": zone.device_manual_override_until,
"control_owner": zone.control_owner,
"control_source": zone.control_source,
"control_since": zone.control_since,
"control_resume_at": zone.control_resume_at,
"control_reason": zone.control_reason,
"lockout_until": zone.lockout_until,
"lockout_reason": zone.lockout_reason,
"effective_setpoint": zone.effective_setpoint,
"demand": zone.demand,
})
};
plan_input(before) != plan_input(after)
}
fn publish_persisted_zone_cycle(
state: &AppState,
persisted: PersistedZoneCycle,
) -> Result<Zone, AppError> {
let event_changed = match persisted.previous.as_ref() {
Some(previous) => {
zone_event_semantic_value(previous)? != zone_event_semantic_value(&persisted.zone)?
}
None => true,
};
if event_changed {
let invalidates_control_plan = persisted
.previous
.as_ref()
.map(|previous| zone_runtime_change_affects_control_plan(previous, &persisted.zone))
.unwrap_or(true);
state.broadcast_with_control_plan_invalidation(
"zone.updated",
serde_json::to_value(&persisted.zone)?,
invalidates_control_plan,
);
}
Ok(persisted.zone)
}
fn persist_zone_cycle( fn persist_zone_cycle(
state: &AppState, state: &AppState,
computed: &Zone, computed: &Zone,
cycle_started_at: DateTime<Utc>, cycle_started_at: DateTime<Utc>,
) -> Result<Zone, AppError> { ) -> Result<PersistedZoneCycle, AppError> {
let Some(mut latest) = state.db.get_zone(&computed.id)? else { let Some(mut latest) = state.db.get_zone(&computed.id)? else {
return Ok(computed.clone()); return Ok(PersistedZoneCycle {
previous: None,
zone: computed.clone(),
});
}; };
let previous = latest.clone();
if latest.updated_at <= cycle_started_at { if latest.updated_at <= cycle_started_at {
state.db.save_zone(computed)?; state.db.save_zone(computed)?;
return Ok(computed.clone()); return Ok(PersistedZoneCycle {
previous: Some(previous),
zone: computed.clone(),
});
} }
// Another actor changed this zone while the regulator was doing network I/O. Never // Another actor changed this zone while the regulator was doing network I/O. Never
// write the old controller snapshot over fresh configuration or manual takeover state. // write the old controller snapshot over fresh configuration or manual takeover state.
@@ -21,7 +114,10 @@ fn persist_zone_cycle(
latest.updated_at = Utc::now(); latest.updated_at = Utc::now();
state.db.save_zone(&latest)?; state.db.save_zone(&latest)?;
} }
Ok(latest) Ok(PersistedZoneCycle {
previous: Some(previous),
zone: latest,
})
} }
async fn thermostat_ownership_is_current( async fn thermostat_ownership_is_current(
+41 -22
View File
@@ -200,8 +200,7 @@ fn persist_zone_cycle_with_history(
) -> Result<Zone> { ) -> Result<Zone> {
record_zone_history(state, zone, outdoor_temperature, poll_interval_seconds); record_zone_history(state, zone, outdoor_temperature, poll_interval_seconds);
let persisted = persist_zone_cycle(state, zone, cycle_started_at)?; let persisted = persist_zone_cycle(state, zone, cycle_started_at)?;
state.broadcast("zone.updated", serde_json::to_value(&persisted)?); Ok(publish_persisted_zone_cycle(state, persisted)?)
Ok(persisted)
} }
async fn handle_zone_pre_control_state( async fn handle_zone_pre_control_state(
@@ -607,8 +606,10 @@ async fn control_zones(state: &AppState) -> Result<()> {
outdoor_temperature, outdoor_temperature,
settings.poll_interval_seconds, settings.poll_interval_seconds,
); );
let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?; publish_persisted_zone_cycle(
state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?); state,
persist_zone_cycle(state, &zone, cycle_started_at)?,
)?;
continue; continue;
} }
@@ -686,8 +687,10 @@ async fn control_zones(state: &AppState) -> Result<()> {
outdoor_temperature, outdoor_temperature,
settings.poll_interval_seconds, settings.poll_interval_seconds,
); );
let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?; publish_persisted_zone_cycle(
state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?); state,
persist_zone_cycle(state, &zone, cycle_started_at)?,
)?;
continue; continue;
} }
@@ -702,8 +705,10 @@ async fn control_zones(state: &AppState) -> Result<()> {
outdoor_temperature, outdoor_temperature,
settings.poll_interval_seconds, settings.poll_interval_seconds,
); );
let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?; publish_persisted_zone_cycle(
state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?); state,
persist_zone_cycle(state, &zone, cycle_started_at)?,
)?;
continue; continue;
}; };
@@ -866,8 +871,10 @@ async fn control_zones(state: &AppState) -> Result<()> {
outdoor_temperature, outdoor_temperature,
settings.poll_interval_seconds, settings.poll_interval_seconds,
); );
let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?; publish_persisted_zone_cycle(
state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?); state,
persist_zone_cycle(state, &zone, cycle_started_at)?,
)?;
continue; continue;
} }
if let Some(last_change) = zone.last_power_change_at { if let Some(last_change) = zone.last_power_change_at {
@@ -884,8 +891,10 @@ async fn control_zones(state: &AppState) -> Result<()> {
outdoor_temperature, outdoor_temperature,
settings.poll_interval_seconds, settings.poll_interval_seconds,
); );
let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?; publish_persisted_zone_cycle(
state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?); state,
persist_zone_cycle(state, &zone, cycle_started_at)?,
)?;
continue; continue;
} }
} }
@@ -924,8 +933,10 @@ async fn control_zones(state: &AppState) -> Result<()> {
outdoor_temperature, outdoor_temperature,
settings.poll_interval_seconds, settings.poll_interval_seconds,
); );
let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?; publish_persisted_zone_cycle(
state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?); state,
persist_zone_cycle(state, &zone, cycle_started_at)?,
)?;
continue; continue;
} }
if !device.power { if !device.power {
@@ -944,8 +955,10 @@ async fn control_zones(state: &AppState) -> Result<()> {
outdoor_temperature, outdoor_temperature,
settings.poll_interval_seconds, settings.poll_interval_seconds,
); );
let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?; publish_persisted_zone_cycle(
state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?); state,
persist_zone_cycle(state, &zone, cycle_started_at)?,
)?;
continue; continue;
} }
if settings.compressor_protection_enabled { if settings.compressor_protection_enabled {
@@ -963,8 +976,10 @@ async fn control_zones(state: &AppState) -> Result<()> {
outdoor_temperature, outdoor_temperature,
settings.poll_interval_seconds, settings.poll_interval_seconds,
); );
let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?; publish_persisted_zone_cycle(
state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?); state,
persist_zone_cycle(state, &zone, cycle_started_at)?,
)?;
continue; continue;
} }
} }
@@ -1052,8 +1067,10 @@ async fn control_zones(state: &AppState) -> Result<()> {
outdoor_temperature, outdoor_temperature,
settings.poll_interval_seconds, settings.poll_interval_seconds,
); );
let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?; publish_persisted_zone_cycle(
state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?); state,
persist_zone_cycle(state, &zone, cycle_started_at)?,
)?;
continue; continue;
} }
Err(err) => state.log( Err(err) => state.log(
@@ -1071,8 +1088,10 @@ async fn control_zones(state: &AppState) -> Result<()> {
outdoor_temperature, outdoor_temperature,
settings.poll_interval_seconds, settings.poll_interval_seconds,
); );
let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?; publish_persisted_zone_cycle(
state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?); state,
persist_zone_cycle(state, &zone, cycle_started_at)?,
)?;
} }
Ok(()) Ok(())
+13 -7
View File
@@ -142,11 +142,14 @@ impl AppState {
self.control_plan_wakeup.notify_one(); self.control_plan_wakeup.notify_one();
} }
pub fn broadcast(&self, event: impl Into<String>, data: Value) { pub fn broadcast_with_control_plan_invalidation(
let event = event.into(); &self,
let invalidates_control_plan = control_plan_event_affects_plan(&event); event: impl Into<String>,
data: Value,
invalidates_control_plan: bool,
) {
let _ = self.events.send(ApiEvent { let _ = self.events.send(ApiEvent {
event, event: event.into(),
timestamp: Utc::now(), timestamp: Utc::now(),
data, data,
}); });
@@ -155,10 +158,13 @@ impl AppState {
} }
} }
pub fn broadcast(&self, event: impl Into<String>, data: Value) {
let event = event.into();
let invalidates_control_plan = control_plan_event_affects_plan(&event);
self.broadcast_with_control_plan_invalidation(event, data, invalidates_control_plan);
}
pub fn log(&self, level: &str, kind: &str, message: &str, metadata: Value) { pub fn log(&self, level: &str, kind: &str, message: &str, metadata: Value) {
if control_plan_event_affects_plan(kind) {
self.invalidate_control_plan();
}
if let Err(err) = self.db.log_event(level, kind, message, &metadata) { if let Err(err) = self.db.log_event(level, kind, message, &metadata) {
tracing::warn!(error=?err, "cannot persist event log"); tracing::warn!(error=?err, "cannot persist event log");
} }