From 9b4fafdfa6eacae545e3d9356cfd69bf1cf1d4f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Fri, 4 Sep 2026 11:30:48 +0200 Subject: [PATCH] v0.13.1 --- Cargo.lock | 2 +- Cargo.toml | 2 +- FILE_MANIFEST.sha256 | 26 ++--- README.md | 4 +- docs/API.md | 8 +- docs/openapi.json | 6 +- .../gree_controller/manifest.json | 2 +- scripts/api_dev_test.py | 8 +- src/engine/automations.rs | 4 + src/engine/polling.rs | 21 +++- src/engine/tests.rs | 54 +++++++++ src/engine/zone_actions.rs | 104 +++++++++++++++++- src/engine/zone_control.rs | 63 +++++++---- src/state.rs | 20 ++-- 14 files changed, 262 insertions(+), 62 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 52f2553..e7600e6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -700,7 +700,7 @@ dependencies = [ [[package]] name = "gree-controller" -version = "0.13.0" +version = "0.13.1" dependencies = [ "aes", "aes-gcm", diff --git a/Cargo.toml b/Cargo.toml index df41f30..88e975f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "gree-controller" -version = "0.13.0" +version = "0.13.1" edition = "2021" authors = ["GREE Controller contributors"] description = "Standalone local GREE HVAC controller with Web UI, SQLite and Home Assistant sensor support" diff --git a/FILE_MANIFEST.sha256 b/FILE_MANIFEST.sha256 index 6f56c5c..cf51f3d 100644 --- a/FILE_MANIFEST.sha256 +++ b/FILE_MANIFEST.sha256 @@ -1,13 +1,13 @@ d67af429e4da9ce08e9d2f2a8472849ffbd70d135b1c5da535a076026794d04c ./.env.example a4ec3874a2e3ab1bad28fb40bb620f7b01f64d01ad9b699306bf70ada31227db ./.gitignore -e664a2f53234cb1e5d75bb9482c5477484d6d6ad878f26ea0bf84e23c4f35644 ./Cargo.lock -cbbebcf672b947987eaaa2122aa080d2c71737fcdd0736aef318fd3099ef5851 ./Cargo.toml +66ab5e9fc9c23c506c2cde7810ead25b63ea9831dab56bda09194af9cea378c0 ./Cargo.lock +1bda71918037a7f726cab6d384f3eec3ee5e34ba7377ba21c7b8833c599682e3 ./Cargo.toml 19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE -01243b698f466857db8755e857ac1da49d9ab1e78c7a914c237829c98242bbf7 ./README.md +31dd042f3566a9927d9cde6b603f77444403a1e8055539c5f4c1245f94704507 ./README.md d56cfff4179a6511aa764a91c6ddebe21fcd35f36b7298e62d012e11f62bfeb9 ./build.rs -f586babc2a9392035f3821c8b78226aec19589cf0a9f9178097fef9afaf1026f ./docs/API.md +fc63a26f2c9541ffe1c8b21a5ce29a5585b5e1d9ab1fdc1e57db3cd019c78171 ./docs/API.md 2e1e18fd8167dabfe2469c26e85cce62486c7cb6a502c63c6f6b0cd74d5885e0 ./docs/FLOW.md -5dc6f579834a991f8c9366e7a13b24214993cba01db89ed637d305a71fdd59c8 ./docs/openapi.json +6ca0bf3639a3133f7a67a1744269d85752d324142cf49c93e387bc02b40a5639 ./docs/openapi.json b11e581dd916676e24402112fe6896bc681abf3c94cafc85c4d3ded3f3a272a3 ./home-assistant/README.md f8e8559fe10fe523ac5bc9aac25c6e26e862f679d502e8f3c39f38a0a8e40911 ./home-assistant/custom_components/gree_controller/__init__.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 c4fb75c246db651087900ebfc2291ff41ac87652cd6194fc0b776b0005c1cbcf ./home-assistant/custom_components/gree_controller/icon.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 39c4309001b75abb56234f05662bc06e077054986876f1927937edbce528ec95 ./home-assistant/custom_components/gree_controller/select.py cca65482e36d48035aca178121a378fe7d578d600acff267ae81a6399c0da653 ./home-assistant/custom_components/gree_controller/sensor.py @@ -69,7 +69,7 @@ fdcd9a5055d08037278b842e7ab69265345c5811f0a06867136c511d140bb191 ./presets/wind 804f22123cd3e8db0fac791826c8dd9f758fb866c8e3b5655fb6d25d259dccf1 ./presets/workday_comfort.json 01952aa92b217f8eae2493b88870e2dec595100cd15c4d561ff11ae2b936c46f ./regenerate-sha.sh bb89bac237e750e9b1bf73761d7df97a6b81853091615878c03f13d7b6399aa7 ./scripts/README.md -52fa6906d2c80d7c8fe0b0d178c16b9b0a53408472a9ecdccbb9d8df1137860f ./scripts/api_dev_test.py +9c76cf597224817576be543088e7aa0af8e5961e7045645cbca4e878bbffae2d ./scripts/api_dev_test.py 5bc736c7bc76ca80aaa406bb171d2aa91baf4c3aa8695dce0e09b888b6ab3146 ./scripts/common.sh 6403786610ee6d2f628193c25aee0dd058d62e904aa1a31d5f62fdaae0e94b4f ./scripts/configure-gree-network.sh 1d6e14e26e49aa9d3527f30a23668bf8d9c48b67e6628ef686c3155c012155de ./scripts/dev.sh @@ -115,7 +115,7 @@ c0a7fa3eac198737d7261ad00c542a614e6d7291f6581af8e7f7aef9dec1a049 ./src/db/sched 1a209dc6d8df864778bb03c980f309a2ec86fc62df274880fbb95eff8470174e ./src/db/tests.rs f90fbb14f29a20b799b3856bdaa22812a9f0d9dfa46693f1341c160dccda8a97 ./src/db/zone_history.rs 4d2c6053586558afbca6b60fbbc9b1cd397f74f8284d7c01f3337eb42bf6d4f7 ./src/engine.rs -ec6034df6667b3ac6ec39851443048a56e87fa861f5846950a5e79fa89ee5a30 ./src/engine/automations.rs +d834dab18fb8970a9e44e3d2fb6e5cee4407eea15f2ccbd50dc6fceb0526db3f ./src/engine/automations.rs 88f85acf692e12c7061e039dd7c54f101fe0b014c4a3d799150ad92eea9e2894 ./src/engine/commands.rs 8ee70ad21d8ee32914dd90952b04dfa8355ee868252ef4fc682ec40503590a7a ./src/engine/control_plan.rs 7b48a33343d519a2ff2d81e9d757eb56a0c1beacfc0b7b8ef61f49c2ad363f9a ./src/engine/deadlines.rs @@ -123,15 +123,15 @@ e350aa63f3bb14ca887eaaa7555edbeae00fb584cbcaf552cc972f0c8918f5d8 ./src/engine/g f0c8f3384b6174771aeb5640ee1eec87f3d6d9ff79a4fbd6a35554a4eec00cfa ./src/engine/history.rs 41b7045877771b7c352328998cb45ce2433e7f9631fad3eb1a882ab4897dc31f ./src/engine/local_thermostat.rs 6280a7f5059d9737987bf2d72b19a26b3a6709ce578af1108c4215f3f47d7763 ./src/engine/ownership.rs -9c37adf514c96840fd6a5c715c579350fa86d6dcc0655a2aa97d1fd7c7e04299 ./src/engine/polling.rs +a5250a4220deb0a9d5cdaee0cf38343a0e26ba37003f6d47e63402a9eaa1e4b2 ./src/engine/polling.rs f0bcd3a56b600944ccc62fa24ce20838033eeb4354f937cdb534630c1a1f5a78 ./src/engine/runtime.rs c1e8de5392681ba1255e3e2d871a6ee860fb8ca987a0a8f973d9ded3977a68fb ./src/engine/schedules.rs de80d9ff69f1c843fdac23fdc3e3a350fc1082a186261b3a38617af189132356 ./src/engine/targets.rs 682c8b3956c657b655746cbd19ee97ee2f4cf953bc9de67d6d70a1b14f3dd746 ./src/engine/temperature.rs 833b72907375e75c3f42b3921ad70a8e298f5a93743bf0d0c19059aa272462d1 ./src/engine/temporary_thermostat.rs -35e8582fcdb13ff6d8850a5274893805a04cabe3b0c6c936b1ad4a9808bf16ee ./src/engine/tests.rs -786ae270afb543d03cf6586942394c6e29015d94c71526f4fb54010c220c90d8 ./src/engine/zone_actions.rs -56d9af59eb700ac6363d26db0ffbea777270cdbbe8e5b7eb982bff9f02539eee ./src/engine/zone_control.rs +f4a9dc023cca9224c75ff1e9d91feac069918de93b4a344e72fef5050d6ff44f ./src/engine/tests.rs +e6f8cfd1e4044b2f833d0f74c85ca664dd08dfb3e57bcddbafe7581cf7c63081 ./src/engine/zone_actions.rs +dd38a8b8f6e910e4475c42ae41eaad2c68237fb36b78a3d7dd5d147c08487568 ./src/engine/zone_control.rs 2276aca42bca712a55de9b6c5dbe64639675c84befa85a446a7d34613596ed7e ./src/error.rs 2731f629f22a9d4fdb831ea3b283f43900b190f7e6a08fdab5b8fbb2c288547b ./src/home_assistant.rs 342dcb54caf22d2282a3f3f2297f54bd413b5242c17756276ea74994e88e28fb ./src/influxdb.rs @@ -171,7 +171,7 @@ c868422c02ba457df21ae92acbd00698c243855ddd1d4e95ac2166796eba9a44 ./src/protocol c91e4b8b250b30cbe76f313b70c16244e9003bce92e02c82283a1831f91974c5 ./src/queries/maintenance.rs 3a49bad74a2f047893e726b5e00a6e833c228ad268bdf50cd60f786ba164ea54 ./src/queries/schema.rs f13f1be3d5789539eba3fc1b59c14835f8c681eca7634e118fa3763a53aeee2b ./src/queries/zone_history.rs -74c1ecdb45e1f4c6275a16a14d157b3a67a18c3f3d80dfdae18aec5bf6aed849 ./src/state.rs +008be170ed418f8701c512f521828b8e6c0071ff195cd6f7aa39fade6c7c3fa9 ./src/state.rs b92a6cb158b494fe145b43c7641e65f6fafff47201d7d76edbec2cfd8b94835c ./systemd/gree-controller.service 544a31cb2e5374227026afd6abd0d910381f3fca1638154ebd74e3c3d96a35d1 ./web/404.html e98bdd7204349cce1ec6f57283509697af0bbc72280622a6c3efa6fed242db4f ./web/favicon.svg diff --git a/README.md b/README.md index aa541a9..632d26b 100644 --- a/README.md +++ b/README.md @@ -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. -**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. diff --git a/docs/API.md b/docs/API.md index c8a1ae0..d86c030 100644 --- a/docs/API.md +++ b/docs/API.md @@ -1,6 +1,6 @@ # 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) @@ -231,7 +231,7 @@ Response: { "status": "ok", "name": "gree-controller", - "version": "0.13.0", + "version": "0.13.1", "uptime_seconds": 1234, "control_ready": true, "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_revision": 42, "system": { - "version": "0.13.0", + "version": "0.13.1", "uptime_seconds": 1234, "auth_required": false, "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. +`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. `api.request` data: diff --git a/docs/openapi.json b/docs/openapi.json index a3c6eea..0f51222 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -2,9 +2,9 @@ "openapi": "3.1.0", "info": { "title": "GREE Controller API", - "version": "0.13.0", + "version": "0.13.1", "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 ` or `x-api-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 ` or `x-api-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": { "name": "MIT" } @@ -4891,7 +4891,7 @@ }, "version": { "type": "string", - "example": "0.13.0" + "example": "0.13.1" }, "uptime_seconds": { "type": "integer", diff --git a/home-assistant/custom_components/gree_controller/manifest.json b/home-assistant/custom_components/gree_controller/manifest.json index 83deb7c..ff8cf09 100644 --- a/home-assistant/custom_components/gree_controller/manifest.json +++ b/home-assistant/custom_components/gree_controller/manifest.json @@ -1,7 +1,7 @@ { "domain": "gree_controller", "name": "GREE Controller", - "version": "0.13.0", + "version": "0.13.1", "config_flow": true, "integration_type": "hub", "iot_class": "local_polling", diff --git a/scripts/api_dev_test.py b/scripts/api_dev_test.py index 3d0073c..e73bd75 100644 --- a/scripts/api_dev_test.py +++ b/scripts/api_dev_test.py @@ -1,5 +1,5 @@ #!/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. 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_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]] = { "/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") 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)) 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: 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, epilog="""Examples: python3 scripts/api_dev_test.py diff --git a/src/engine/automations.rs b/src/engine/automations.rs index 34c6574..0787d99 100644 --- a/src/engine/automations.rs +++ b/src/engine/automations.rs @@ -161,6 +161,9 @@ async fn run_automations(state: &AppState) -> Result<()> { item.last_fired_at = Some(fired_at); item.updated_at = Utc::now(); 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!({ "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.updated_at = Utc::now(); 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})); } } diff --git a/src/engine/polling.rs b/src/engine/polling.rs index 4f4ea57..bc200bd 100644 --- a/src/engine/polling.rs +++ b/src/engine/polling.rs @@ -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>, AppError> { let mut zone_ids: Vec = state.db.list_zones()?.into_iter() .filter(|zone| zone.device_id == device_id) @@ -35,7 +47,11 @@ async fn poll_one_locked(state: &AppState, device_id: &str) -> Result Result<(), AppError> { record_poll_failure(device, error); 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!({ "device_id": device.id, "consecutive_failures": device.communication_failures, diff --git a/src/engine/tests.rs b/src/engine/tests.rs index 66c3f0b..03ed4d1 100644 --- a/src/engine/tests.rs +++ b/src/engine/tests.rs @@ -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] fn zone_can_use_separate_heating_and_cooling_hysteresis() { let mut zone = test_zone("device"); diff --git a/src/engine/zone_actions.rs b/src/engine/zone_actions.rs index 77325ac..c3e8517 100644 --- a/src/engine/zone_actions.rs +++ b/src/engine/zone_actions.rs @@ -1,14 +1,107 @@ +#[derive(Debug)] +struct PersistedZoneCycle { + previous: Option, + zone: Zone, +} + +fn zone_event_semantic_value(zone: &Zone) -> Result { + 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 { + 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( state: &AppState, computed: &Zone, cycle_started_at: DateTime, -) -> Result { +) -> Result { 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 { 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 // 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(); state.db.save_zone(&latest)?; } - Ok(latest) + Ok(PersistedZoneCycle { + previous: Some(previous), + zone: latest, + }) } async fn thermostat_ownership_is_current( diff --git a/src/engine/zone_control.rs b/src/engine/zone_control.rs index 237db71..066037b 100644 --- a/src/engine/zone_control.rs +++ b/src/engine/zone_control.rs @@ -200,8 +200,7 @@ fn persist_zone_cycle_with_history( ) -> Result { record_zone_history(state, zone, outdoor_temperature, poll_interval_seconds); let persisted = persist_zone_cycle(state, zone, cycle_started_at)?; - state.broadcast("zone.updated", serde_json::to_value(&persisted)?); - Ok(persisted) + Ok(publish_persisted_zone_cycle(state, persisted)?) } async fn handle_zone_pre_control_state( @@ -607,8 +606,10 @@ async fn control_zones(state: &AppState) -> Result<()> { outdoor_temperature, settings.poll_interval_seconds, ); - let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?; - state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?); + publish_persisted_zone_cycle( + state, + persist_zone_cycle(state, &zone, cycle_started_at)?, + )?; continue; } @@ -686,8 +687,10 @@ async fn control_zones(state: &AppState) -> Result<()> { outdoor_temperature, settings.poll_interval_seconds, ); - let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?; - state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?); + publish_persisted_zone_cycle( + state, + persist_zone_cycle(state, &zone, cycle_started_at)?, + )?; continue; } @@ -702,8 +705,10 @@ async fn control_zones(state: &AppState) -> Result<()> { outdoor_temperature, settings.poll_interval_seconds, ); - let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?; - state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?); + publish_persisted_zone_cycle( + state, + persist_zone_cycle(state, &zone, cycle_started_at)?, + )?; continue; }; @@ -866,8 +871,10 @@ async fn control_zones(state: &AppState) -> Result<()> { outdoor_temperature, settings.poll_interval_seconds, ); - let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?; - state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?); + publish_persisted_zone_cycle( + state, + persist_zone_cycle(state, &zone, cycle_started_at)?, + )?; continue; } if let Some(last_change) = zone.last_power_change_at { @@ -884,8 +891,10 @@ async fn control_zones(state: &AppState) -> Result<()> { outdoor_temperature, settings.poll_interval_seconds, ); - let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?; - state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?); + publish_persisted_zone_cycle( + state, + persist_zone_cycle(state, &zone, cycle_started_at)?, + )?; continue; } } @@ -924,8 +933,10 @@ async fn control_zones(state: &AppState) -> Result<()> { outdoor_temperature, settings.poll_interval_seconds, ); - let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?; - state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?); + publish_persisted_zone_cycle( + state, + persist_zone_cycle(state, &zone, cycle_started_at)?, + )?; continue; } if !device.power { @@ -944,8 +955,10 @@ async fn control_zones(state: &AppState) -> Result<()> { outdoor_temperature, settings.poll_interval_seconds, ); - let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?; - state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?); + publish_persisted_zone_cycle( + state, + persist_zone_cycle(state, &zone, cycle_started_at)?, + )?; continue; } if settings.compressor_protection_enabled { @@ -963,8 +976,10 @@ async fn control_zones(state: &AppState) -> Result<()> { outdoor_temperature, settings.poll_interval_seconds, ); - let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?; - state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?); + publish_persisted_zone_cycle( + state, + persist_zone_cycle(state, &zone, cycle_started_at)?, + )?; continue; } } @@ -1052,8 +1067,10 @@ async fn control_zones(state: &AppState) -> Result<()> { outdoor_temperature, settings.poll_interval_seconds, ); - let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?; - state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?); + publish_persisted_zone_cycle( + state, + persist_zone_cycle(state, &zone, cycle_started_at)?, + )?; continue; } Err(err) => state.log( @@ -1071,8 +1088,10 @@ async fn control_zones(state: &AppState) -> Result<()> { outdoor_temperature, settings.poll_interval_seconds, ); - let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?; - state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?); + publish_persisted_zone_cycle( + state, + persist_zone_cycle(state, &zone, cycle_started_at)?, + )?; } Ok(()) diff --git a/src/state.rs b/src/state.rs index 706e891..eda7aa1 100644 --- a/src/state.rs +++ b/src/state.rs @@ -142,11 +142,14 @@ impl AppState { self.control_plan_wakeup.notify_one(); } - pub fn broadcast(&self, event: impl Into, data: Value) { - let event = event.into(); - let invalidates_control_plan = control_plan_event_affects_plan(&event); + pub fn broadcast_with_control_plan_invalidation( + &self, + event: impl Into, + data: Value, + invalidates_control_plan: bool, + ) { let _ = self.events.send(ApiEvent { - event, + event: event.into(), timestamp: Utc::now(), data, }); @@ -155,10 +158,13 @@ impl AppState { } } + pub fn broadcast(&self, event: impl Into, 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) { - if control_plan_event_affects_plan(kind) { - self.invalidate_control_plan(); - } if let Err(err) = self.db.log_event(level, kind, message, &metadata) { tracing::warn!(error=?err, "cannot persist event log"); }