v0.13.1
This commit is contained in:
Generated
+1
-1
@@ -700,7 +700,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gree-controller"
|
||||
version = "0.13.0"
|
||||
version = "0.13.1"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
+13
-13
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
+5
-3
@@ -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:
|
||||
|
||||
+3
-3
@@ -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 <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": {
|
||||
"name": "MIT"
|
||||
}
|
||||
@@ -4891,7 +4891,7 @@
|
||||
},
|
||||
"version": {
|
||||
"type": "string",
|
||||
"example": "0.13.0"
|
||||
"example": "0.13.1"
|
||||
},
|
||||
"uptime_seconds": {
|
||||
"type": "integer",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}));
|
||||
}
|
||||
}
|
||||
|
||||
+20
-1
@@ -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> {
|
||||
let mut zone_ids: Vec<String> = 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<Device, Ap
|
||||
}
|
||||
state.db.save_device(&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)
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
+100
-4
@@ -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(
|
||||
state: &AppState,
|
||||
computed: &Zone,
|
||||
cycle_started_at: DateTime<Utc>,
|
||||
) -> Result<Zone, AppError> {
|
||||
) -> Result<PersistedZoneCycle, AppError> {
|
||||
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(
|
||||
|
||||
+41
-22
@@ -200,8 +200,7 @@ fn persist_zone_cycle_with_history(
|
||||
) -> Result<Zone> {
|
||||
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(())
|
||||
|
||||
+13
-7
@@ -142,11 +142,14 @@ impl AppState {
|
||||
self.control_plan_wakeup.notify_one();
|
||||
}
|
||||
|
||||
pub fn broadcast(&self, event: impl Into<String>, 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<String>,
|
||||
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<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) {
|
||||
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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user