From ade221bb765627006ea090192fae015f6f2f8b1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Fri, 4 Sep 2026 11:43:37 +0200 Subject: [PATCH] v0.13.2 --- Cargo.lock | 2 +- Cargo.toml | 2 +- FILE_MANIFEST.sha256 | 29 +- README.md | 4 +- docs/API.md | 13 +- docs/openapi.json | 6 +- .../gree_controller/manifest.json | 2 +- scripts/README.md | 1 + scripts/api_dev_test.py | 8 +- scripts/live_realtime_test.js | 278 ++++++++++++++++++ src/api/devices.rs | 1 + src/engine/automations.rs | 8 +- src/engine/polling.rs | 5 +- web/js/bootstrap.js | 6 +- web/js/core.js | 2 +- web/js/realtime.js | 11 +- 16 files changed, 338 insertions(+), 40 deletions(-) create mode 100755 scripts/live_realtime_test.js diff --git a/Cargo.lock b/Cargo.lock index e7600e6..01dd9e7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -700,7 +700,7 @@ dependencies = [ [[package]] name = "gree-controller" -version = "0.13.1" +version = "0.13.2" dependencies = [ "aes", "aes-gcm", diff --git a/Cargo.toml b/Cargo.toml index 88e975f..f678dac 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "gree-controller" -version = "0.13.1" +version = "0.13.2" 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 cf51f3d..43e31a1 100644 --- a/FILE_MANIFEST.sha256 +++ b/FILE_MANIFEST.sha256 @@ -1,13 +1,13 @@ d67af429e4da9ce08e9d2f2a8472849ffbd70d135b1c5da535a076026794d04c ./.env.example a4ec3874a2e3ab1bad28fb40bb620f7b01f64d01ad9b699306bf70ada31227db ./.gitignore -66ab5e9fc9c23c506c2cde7810ead25b63ea9831dab56bda09194af9cea378c0 ./Cargo.lock -1bda71918037a7f726cab6d384f3eec3ee5e34ba7377ba21c7b8833c599682e3 ./Cargo.toml +3ddb42eef37a20f8ace4cfca42bcb0a96b8e6de94f82fcf7f7a0fffc2c31b34a ./Cargo.lock +71ee004b46883be6d12098c1a6cde9caf8076e8beb041e2cafd38c7108859ba1 ./Cargo.toml 19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE -31dd042f3566a9927d9cde6b603f77444403a1e8055539c5f4c1245f94704507 ./README.md +97d2ed6c330e71b243de9816b50c191361ede2968ce9c0ebd31efbee35eec003 ./README.md d56cfff4179a6511aa764a91c6ddebe21fcd35f36b7298e62d012e11f62bfeb9 ./build.rs -fc63a26f2c9541ffe1c8b21a5ce29a5585b5e1d9ab1fdc1e57db3cd019c78171 ./docs/API.md +ad559c9b776cb4dcc1febc06879d8c9154f7b8ff3e0e0b4b9b394f87ec571131 ./docs/API.md 2e1e18fd8167dabfe2469c26e85cce62486c7cb6a502c63c6f6b0cd74d5885e0 ./docs/FLOW.md -6ca0bf3639a3133f7a67a1744269d85752d324142cf49c93e387bc02b40a5639 ./docs/openapi.json +ceeef0504298dc11b5964aa898e2425fc0847e0d9c1c1436d113161431a75917 ./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 -412a887b4349c3cbdaededff76ec5ffe5a0c7dd4374b2de4ca60de02ee3f3d2e ./home-assistant/custom_components/gree_controller/manifest.json +2cc326a9a839908eca36bc3f0c51f38c53c05c6093c678b80ac7b2ad3b323e9a ./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 @@ -68,14 +68,15 @@ ae21459a261712bcb8d57594528b1648432e8d03a8a38502234829c0dbfef774 ./presets/week fdcd9a5055d08037278b842e7ab69265345c5811f0a06867136c511d140bb191 ./presets/window_available_guard.json 804f22123cd3e8db0fac791826c8dd9f758fb866c8e3b5655fb6d25d259dccf1 ./presets/workday_comfort.json 01952aa92b217f8eae2493b88870e2dec595100cd15c4d561ff11ae2b936c46f ./regenerate-sha.sh -bb89bac237e750e9b1bf73761d7df97a6b81853091615878c03f13d7b6399aa7 ./scripts/README.md -9c76cf597224817576be543088e7aa0af8e5961e7045645cbca4e878bbffae2d ./scripts/api_dev_test.py +c64b1c6deeb24f20af662bfce7fa3683703914987241107ce664fa2f6d1a9dcb ./scripts/README.md +93d28fcfb77b59548ac4f4f0e03372cae8fb286142e7eb3130b438fd180caf6e ./scripts/api_dev_test.py 5bc736c7bc76ca80aaa406bb171d2aa91baf4c3aa8695dce0e09b888b6ab3146 ./scripts/common.sh 6403786610ee6d2f628193c25aee0dd058d62e904aa1a31d5f62fdaae0e94b4f ./scripts/configure-gree-network.sh 1d6e14e26e49aa9d3527f30a23668bf8d9c48b67e6628ef686c3155c012155de ./scripts/dev.sh 14076104c042fba1284ebb07531a6c3ff972df1f9f5b18f70da18ab774efed27 ./scripts/generate_ha_migration.py e4849261fd9ed1f01df96c0637c439c0c4eff8fa317b2918026167bba343af79 ./scripts/install-lxc.sh bb7cd2c5b27c9dceec1d1d2846fbad9600df9c08e0ad07d13fcde97af533091c ./scripts/install.sh +2af0a1390e69330acb6d62bafefafd3f33088b712867f9b1fc2f8604e2f253cd ./scripts/live_realtime_test.js e00d211e3885e30d7fed1e43b44e6fdad40a67019060156c0641816a93e3365f ./scripts/network-debug.sh 01952aa92b217f8eae2493b88870e2dec595100cd15c4d561ff11ae2b936c46f ./scripts/regenerate-sha.sh 81345b6a0b51736bdbc98fd23199b62e4c721b4e7437e02dab7ea79b97dff29a ./scripts/service.sh @@ -87,7 +88,7 @@ b50782b3742dfbf8a319c60571c968e93fdf8547db747c759edcffae68cb98bf ./scripts/upda 9e4fc675306e111ed3db7af9822e2925d88109c31b9e309851665a975c0bd83c ./src/api/automations.rs 368cd843dcac970f1aa6800ba132a4bbc3429e8379ed7abf9878dcb0c633af40 ./src/api/configuration.rs 19bc141286a336ad67e99079496eb0ae5126f986ab28be62b5f97758e5f6969c ./src/api/debug_tokens.rs -0b87a7f0caef2a9c9cdea34d560095a3367e3c9d4322d6bcabc5b8ade8d55d06 ./src/api/devices.rs +a58fb7dcf3f37e3f9d90363623ec91edb667bca97f5faa6405c8b8f539c3d998 ./src/api/devices.rs 4b5b784670ae761a6848b9a33fe3fb391bebd39d65e87936907428711e96ca44 ./src/api/events.rs 44b74965f3256c2972d6b3cd384a63c68e3b422f414a0902c085bc92e17369bf ./src/api/flows.rs b61b0b4f196c5e72e47cf06ca0c14df3cf8eedbdef0242a9d2af33623663489f ./src/api/groups.rs @@ -115,7 +116,7 @@ c0a7fa3eac198737d7261ad00c542a614e6d7291f6581af8e7f7aef9dec1a049 ./src/db/sched 1a209dc6d8df864778bb03c980f309a2ec86fc62df274880fbb95eff8470174e ./src/db/tests.rs f90fbb14f29a20b799b3856bdaa22812a9f0d9dfa46693f1341c160dccda8a97 ./src/db/zone_history.rs 4d2c6053586558afbca6b60fbbc9b1cd397f74f8284d7c01f3337eb42bf6d4f7 ./src/engine.rs -d834dab18fb8970a9e44e3d2fb6e5cee4407eea15f2ccbd50dc6fceb0526db3f ./src/engine/automations.rs +31ed13a143a80f165982662667c1248213a4db7b7e60c4b4bc25c4a96163c184 ./src/engine/automations.rs 88f85acf692e12c7061e039dd7c54f101fe0b014c4a3d799150ad92eea9e2894 ./src/engine/commands.rs 8ee70ad21d8ee32914dd90952b04dfa8355ee868252ef4fc682ec40503590a7a ./src/engine/control_plan.rs 7b48a33343d519a2ff2d81e9d757eb56a0c1beacfc0b7b8ef61f49c2ad363f9a ./src/engine/deadlines.rs @@ -123,7 +124,7 @@ e350aa63f3bb14ca887eaaa7555edbeae00fb584cbcaf552cc972f0c8918f5d8 ./src/engine/g f0c8f3384b6174771aeb5640ee1eec87f3d6d9ff79a4fbd6a35554a4eec00cfa ./src/engine/history.rs 41b7045877771b7c352328998cb45ce2433e7f9631fad3eb1a882ab4897dc31f ./src/engine/local_thermostat.rs 6280a7f5059d9737987bf2d72b19a26b3a6709ce578af1108c4215f3f47d7763 ./src/engine/ownership.rs -a5250a4220deb0a9d5cdaee0cf38343a0e26ba37003f6d47e63402a9eaa1e4b2 ./src/engine/polling.rs +3d7e74a8180e14f398363af1a45b32a5e4c89f749d631afce556a1ecd5fe4e80 ./src/engine/polling.rs f0bcd3a56b600944ccc62fa24ce20838033eeb4354f937cdb534630c1a1f5a78 ./src/engine/runtime.rs c1e8de5392681ba1255e3e2d871a6ee860fb8ca987a0a8f973d9ded3977a68fb ./src/engine/schedules.rs de80d9ff69f1c843fdac23fdc3e3a350fc1082a186261b3a38617af189132356 ./src/engine/targets.rs @@ -177,9 +178,9 @@ b92a6cb158b494fe145b43c7641e65f6fafff47201d7d76edbec2cfd8b94835c ./systemd/gree e98bdd7204349cce1ec6f57283509697af0bbc72280622a6c3efa6fed242db4f ./web/favicon.svg d0388bf22f34ae28fb63074b0bc15c087a3be298bb702d006d4814ebb786b36e ./web/index.html 9ba3f6fa05b72aa989139b2a909982571b2a02055052e4c40104f1e81a9aa7eb ./web/js/README.md -7a0baeaa8703ba8654aa402e320ced127ae12327403f339f8fefdc0cd792af0d ./web/js/bootstrap.js +45fb54541a117a8ee0dc484cd263b6af4aafab75d94d269b126bb603b5f0392c ./web/js/bootstrap.js badde8a5214a0322b2c45803c02a1cb723d53b25e57c687369cd804f509d9744 ./web/js/charts.js -13def8de3450b612bc669f441782237e992a7a27aa4784b0dda4d48dcf3482b2 ./web/js/core.js +dd596b3362643b96be8537b9415e8c1be90d7cf458124b61121016a6742951c7 ./web/js/core.js d880ffdac6aa07cd10d79fbacf12f87eb4c69b48a80564bbfdd0248abe8884d5 ./web/js/dashboard.js d6fbd8ef4a67269afadb2f59f19fe9a359f7ec4d7bd1473ecff77df117b9558d ./web/js/entities.js e332b3f811958a938105272c1e8e28d6ca8ba69303001da165b4b0b85727695c ./web/js/events.js @@ -188,7 +189,7 @@ f06557c6d338259059b990f8f79c91d68ac8ec1f245c302468f08929b5506901 ./web/js/forms dbf36223863ba882c03eccea4516ba8db7acf0c2e72c6c64490cb994f1e20f96 ./web/js/history.js 7eaa04992ea828dc89f5eaebf674cbbfd160f53eb8d0d6ef0d4e47bd8c28c435 ./web/js/main.js 733fcc595ed9c03c9639acc6caba46f0588f29e0b95bf72891e34443e7aa9db8 ./web/js/navigation.js -90dccce563b1cbd6119576d575ea72121a438107c4dd16ea44f8a591f4e6db90 ./web/js/realtime.js +e3e01511a34175b88eb01dac2c557e8b112fcad5826f02a0bc3cd1e8dfb7ff91 ./web/js/realtime.js c8c82e88c3715b1bfabf155e36266a4c94f5e2fc03eb39dcdd9d08a1985a997c ./web/js/router.js 427376a35832cb3644206c6e89e1654bd807eec78352f80bd0dd43f74b4ec810 ./web/js/settings-ui.js 9dd9fbcc5f00584562bae6a840189495f830d1086d9679763dbad6e9c18c4fdb ./web/js/settings.js diff --git a/README.md b/README.md index 632d26b..bae25a2 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.1** +**Current release: 0.13.2** -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. +Release 0.13.2 keeps the 0.13.x HTTP/UI contracts, retains the reduced WebSocket/control-plan churn from 0.13.1, and completes live synchronization for zone creation, schedules, automations, configuration imports, device bind/communication-health changes and automation runtime metadata. Real state changes remain pushed immediately while no-op regulator heartbeats stay deduplicated. > [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 d86c030..ebab5f9 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.1**. +HTTP and WebSocket API for GREE Controller **0.13.2**. [← Main documentation](../README.md) @@ -231,7 +231,7 @@ Response: { "status": "ok", "name": "gree-controller", - "version": "0.13.1", + "version": "0.13.2", "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.1", + "version": "0.13.2", "uptime_seconds": 1234, "auth_required": false, "control_ready": true, @@ -1433,6 +1433,9 @@ schedule.template_applied automation.created automation.updated automation.deleted +flow.created +flow.updated +flow.deleted settings.application.updated settings.gree.updated settings.history.updated @@ -1442,8 +1445,10 @@ settings.night.updated settings.home_assistant.updated settings.debug.updated house.mode_changed +outdoor.updated configuration.imported api.request +gree.frame_received gree.frame log.created ``` @@ -1452,6 +1457,8 @@ log.created `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`. +In `0.13.2`, the bundled Web UI mirrors entity CRUD events directly into its local state. Schedule-template replacement performs a targeted schedule resync, configuration import performs a full bootstrap resync, and runtime automation/device-health changes publish their updated snapshots immediately. + 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 0f51222..f27fdb0 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.1", + "version": "0.13.2", "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.\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.", + "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.\n\n### 0.13.2\nCompletes live WebSocket synchronization for entity creation, schedules, automations, configuration imports and runtime device/automation state while preserving the existing HTTP API contracts and control-plan fallback behavior.", "license": { "name": "MIT" } @@ -4891,7 +4891,7 @@ }, "version": { "type": "string", - "example": "0.13.1" + "example": "0.13.2" }, "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 ff8cf09..55724fe 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.1", + "version": "0.13.2", "config_flow": true, "integration_type": "hub", "iot_class": "local_polling", diff --git a/scripts/README.md b/scripts/README.md index c9d22c6..bd52a6b 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -7,6 +7,7 @@ All operator-facing scripts live in this directory. - `service.sh` — start, stop, restart, status, logs and health helper. - `dev.sh` — development build/run/check workflow. - `smoke.sh` — HTTP/API smoke test used by `dev.sh --check`. +- `live_realtime_test.js` — Node-based regression test for WebSocket live-state updates, control-plan push/fallback and reconnect behavior. - `generate_ha_migration.py` — legacy/manual Home Assistant entity mapping helper. - `install-lxc.sh` — compatibility alias for `install.sh`. - `common.sh` — shared shell functions; normally not executed directly. diff --git a/scripts/api_dev_test.py b/scripts/api_dev_test.py index e73bd75..ca9531e 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.1. +"""Developer smoke/integration tests for GREE Controller API 0.13.2. 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.1" +DEFAULT_EXPECTED_VERSION = "0.13.2" 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.1 contract", lambda: test_openapi(client, args.expected_version)) + runner.run("OpenAPI 0.13.2 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.1.", + description="Developer smoke/integration tests for GREE Controller API 0.13.2.", formatter_class=argparse.RawDescriptionHelpFormatter, epilog="""Examples: python3 scripts/api_dev_test.py diff --git a/scripts/live_realtime_test.js b/scripts/live_realtime_test.js new file mode 100755 index 0000000..e7ba87a --- /dev/null +++ b/scripts/live_realtime_test.js @@ -0,0 +1,278 @@ +#!/usr/bin/env node +'use strict'; + +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const vm = require('vm'); + +const root = path.resolve(__dirname, '..'); +const timers = []; +let timerId = 0; +let apiCalls = []; +let apiResponder = async requestPath => requestPath === '/api/schedules' ? [] : {}; +const calls = {}; +const hit = name => () => { calls[name] = (calls[name] || 0) + 1; }; + +class FakeWebSocket { + static OPEN = 1; + static CONNECTING = 0; + static CLOSED = 3; + static instances = []; + constructor(url) { + this.url = url; + this.readyState = FakeWebSocket.CONNECTING; + FakeWebSocket.instances.push(this); + } +} + +const app = { + devices: [], zones: [], groups: [], schedules: [], automations: [], flows: [], + settings: { house_mode: 'cool', debug: { overlay_enabled: false }, home_assistant: {} }, + system: {}, outdoorTemperature: null, controlPlan: null, controlPlanRevision: null, + controlPlanPushReady: false, controlPlanTimer: null, currentView: 'dashboard', + sensorAliases: {}, flowSharedInputs: [], flowDraft: null, ws: null, wsTimer: null, + token: '', debugBacklogLoaded: false, +}; + +const context = { + console, JSON, Number, Date, Intl, Promise, app, WebSocket: FakeWebSocket, + location: { protocol: 'http:', host: 'controller.test' }, + setTimeout: (fn, delay) => { + const id = ++timerId; + timers.push({ id, fn, delay, cleared: false }); + return id; + }, + clearTimeout: id => { + const timer = timers.find(item => item.id === id); + if (timer) timer.cleared = true; + }, + api: async (...args) => { + apiCalls.push(args); + return apiResponder(...args); + }, + withBase: value => value, + $: () => null, + isFormDirty: () => false, + loadSettingsSections: async () => {}, + applySettingsSection: (name, data) => { app.settings[name] = data; }, + loadDebugBacklog: hit('loadDebugBacklog'), + loadBootstrap: async () => { calls.loadBootstrap = (calls.loadBootstrap || 0) + 1; }, + updateConnectionIndicator: status => { calls[`connection:${status}`] = (calls[`connection:${status}`] || 0) + 1; }, + updateDevice: device => { + const index = app.devices.findIndex(item => item.id === device.id); + if (index >= 0) app.devices[index] = device; else app.devices.push(device); + }, + toast: () => {}, tr: key => key, esc: value => String(value), locale: () => 'en-GB', + logCategory: () => 'test', debugLine: hit('debugLine'), +}; + +const renderNames = [ + 'renderAll', 'renderSummary', 'renderDevices', 'renderGroups', 'renderHouseClimate', + 'renderZones', 'renderFlows', 'renderSchedules', 'renderAutomations', 'renderSettings', + 'renderSimulationModeBanner', 'renderSystemInfo', 'renderLogRetention', 'renderNightSettings', + 'renderHomeAssistantSettings', 'renderDebugOverlay', 'renderFlowEditor', 'renderGreeFrameStats', + 'renderControlPlan', 'renderSimulationPage', 'fillSelects', +]; +for (const name of renderNames) context[name] = hit(name); + +vm.createContext(context); +vm.runInContext(fs.readFileSync(path.join(root, 'web/js/dashboard.js'), 'utf8'), context, { filename: 'dashboard.js' }); +vm.runInContext(fs.readFileSync(path.join(root, 'web/js/realtime.js'), 'utf8'), context, { filename: 'realtime.js' }); +// Source files define DOM-heavy renderers. Replace those implementations for this state-machine test. +for (const name of renderNames) context[name] = hit(name); +context.updateDevice = device => { + const index = app.devices.findIndex(item => item.id === device.id); + if (index >= 0) app.devices[index] = device; else app.devices.push(device); +}; +context.loadSettingsSections = async () => {}; +context.applySettingsSection = (name, data) => { app.settings[name] = data; }; +context.loadDebugBacklog = hit('loadDebugBacklog'); +context.loadBootstrap = async () => { calls.loadBootstrap = (calls.loadBootstrap || 0) + 1; }; +context.isFormDirty = () => false; +context.updateConnectionIndicator = status => { calls[`connection:${status}`] = (calls[`connection:${status}`] || 0) + 1; }; +context.withBase = value => value; + +async function send(event, data) { + await context.handleWebSocketMessage({ + data: JSON.stringify({ event, timestamp: new Date().toISOString(), data }), + }); +} + + +async function testBootstrapReloadQueue() { + const queuedTimers = []; + let firstResolve; + let bootstrapCalls = 0; + const firstResponse = new Promise(resolve => { firstResolve = resolve; }); + const c = { + console, Promise, Date, + app: { loading: false, bootstrapReloadPending: false, controlPlan: null, controlPlanRevision: null, settings: {}, sensorAliases: {}, flowSharedInputs: {}, system: {}, debugBacklogLoaded: false }, + api: async requestPath => { + assert.equal(requestPath, '/api/bootstrap'); + bootstrapCalls += 1; + if (bootstrapCalls === 1) return firstResponse; + return { devices: [], zones: [], groups: [], schedules: [], automations: [], flows: [], access_tokens: [], house: { mode: 'cool' }, system: {}, control_plan: { marker: 'second' }, control_plan_revision: 2 }; + }, + fetchSettingsSections: async () => ({ application: {}, gree: {}, history: {}, influxdb: {}, notifications: {}, night: {}, home_assistant: {}, debug: {} }), + aggregateSettingsSections: () => ({ home_assistant: {}, debug: { overlay_enabled: false } }), + renderAll: () => {}, scheduleControlPlanLoad: () => {}, loadDebugBacklog: () => {}, toast: () => {}, tr: key => key, + $: () => ({ open: false }), connectWebSocket: () => {}, + setTimeout: (fn, delay) => { queuedTimers.push({ fn, delay }); return queuedTimers.length; }, + }; + vm.createContext(c); + vm.runInContext(fs.readFileSync(path.join(root, 'web/js/bootstrap.js'), 'utf8'), c, { filename: 'bootstrap.js' }); + const first = c.loadBootstrap(); + assert.equal(c.app.loading, true); + await c.loadBootstrap(); + assert.equal(c.app.bootstrapReloadPending, true, 'a bootstrap request during loading must be queued'); + firstResolve({ devices: [], zones: [], groups: [], schedules: [], automations: [], flows: [], access_tokens: [], house: { mode: 'cool' }, system: {}, control_plan: { marker: 'first' }, control_plan_revision: 1 }); + await first; + assert.equal(c.app.bootstrapReloadPending, false); + assert.equal(queuedTimers.length, 1); + assert.equal(queuedTimers[0].delay, 0); + queuedTimers[0].fn(); + await new Promise(resolve => setImmediate(resolve)); + assert.equal(bootstrapCalls, 2, 'queued bootstrap reload must run after the first request finishes'); +} + +async function main() { + await testBootstrapReloadQueue(); + await send('bootstrap', { + devices: [{ id: 'd1', power: false }], + zones: [{ id: 'z1', demand: false }], + groups: [{ id: 'g1', name: 'G1' }], + schedules: [{ id: 's1', name: 'S1' }], + automations: [{ id: 'a1', name: 'A1' }], + flows: [{ id: 'f1', name: 'F1' }], + house: { mode: 'heat' }, system: { ok: true }, outdoor_temperature: 10, + control_plan: { marker: 'p1' }, control_plan_revision: 1, + }); + assert.equal(app.controlPlan.marker, 'p1'); + assert.equal(app.controlPlanRevision, 1); + assert.equal(app.controlPlanPushReady, true); + + await send('control_plan.updated', { revision: 2, plan: { marker: 'p2' } }); + await send('control_plan.updated', { revision: 1, plan: { marker: 'stale' } }); + assert.equal(app.controlPlan.marker, 'p2', 'stale control-plan revision must be ignored'); + + app.ws = { readyState: FakeWebSocket.OPEN }; + const timerCountWhilePushReady = timers.length; + + await send('device.updated', { id: 'd1', power: true, online: true }); + assert.equal(app.devices[0].power, true); + await send('device.created', { id: 'd2', power: false }); + assert(app.devices.some(item => item.id === 'd2')); + await send('devices.discovered', { devices: [{ id: 'd3', power: false }] }); + assert(app.devices.some(item => item.id === 'd3')); + await send('device.deleted', { id: 'd2' }); + assert(!app.devices.some(item => item.id === 'd2')); + + await send('zone.updated', { id: 'z1', demand: true, current_temperature: 22.1 }); + assert.equal(app.zones[0].demand, true); + await send('zone.created', { id: 'z2', demand: false }); + assert(app.zones.some(item => item.id === 'z2')); + await send('zone.deleted', { id: 'z2' }); + assert(!app.zones.some(item => item.id === 'z2')); + + await send('group.updated', { id: 'g1', name: 'G1 updated' }); + assert.equal(app.groups[0].name, 'G1 updated'); + await send('group.created', { id: 'g2', name: 'G2' }); + assert(app.groups.some(item => item.id === 'g2')); + await send('group.deleted', { id: 'g2' }); + assert(!app.groups.some(item => item.id === 'g2')); + + await send('schedule.updated', { id: 's1', name: 'S1 updated' }); + assert.equal(app.schedules[0].name, 'S1 updated'); + await send('schedule.created', { id: 's2', name: 'S2' }); + assert(app.schedules.some(item => item.id === 's2')); + await send('schedule.deleted', { id: 's2' }); + assert(!app.schedules.some(item => item.id === 's2')); + apiResponder = async requestPath => requestPath === '/api/schedules' ? [{ id: 's3', name: 'Template' }] : {}; + await send('schedule.template_applied', { zone_id: 'z1', template: 'family', count: 2 }); + assert.equal(app.schedules[0].id, 's3'); + + await send('automation.updated', { id: 'a1', name: 'A1 updated', last_fired_at: '2026-09-04T09:00:00Z' }); + assert.equal(app.automations[0].name, 'A1 updated'); + await send('automation.created', { id: 'a2', name: 'A2' }); + assert(app.automations.some(item => item.id === 'a2')); + await send('automation.deleted', { id: 'a2' }); + assert(!app.automations.some(item => item.id === 'a2')); + + await send('flow.updated', { id: 'f1', name: 'F1 updated' }); + assert.equal(app.flows[0].name, 'F1 updated'); + await send('flow.created', { id: 'f2', name: 'F2' }); + assert(app.flows.some(item => item.id === 'f2')); + await send('flow.deleted', { id: 'f2' }); + assert(!app.flows.some(item => item.id === 'f2')); + + await send('settings.application.updated', { poll_interval_seconds: 15 }); + await send('settings.gree.updated', { command_timeout_ms: 1000 }); + await send('settings.history.updated', { retention_days: 30 }); + await send('settings.influxdb.updated', { enabled: false }); + await send('settings.notifications.updated', { enabled: false }); + await send('settings.debug.updated', { overlay_enabled: false }); + await send('settings.night.updated', { enabled: true, start_time: '22:00', end_time: '06:00' }); + await send('settings.home_assistant.updated', { sensor_aliases: { 'sensor.room': 'Room' }, flow_inputs: [] }); + assert.equal(app.settings.night.enabled, true); + assert.equal(app.sensorAliases['sensor.room'], 'Room'); + + await send('house.mode_changed', { mode: 'off' }); + assert.equal(app.settings.house_mode, 'off'); + await send('outdoor.updated', { temperature: 12.3 }); + assert.equal(app.outdoorTemperature, 12.3); + + await send('gree.frame_received', { total: 5, device_id: 'd1', device_count: 3 }); + assert.equal(app.system.gree_received_frames, 5); + assert.equal(app.system.gree_received_frames_by_device.d1, 3); + app.settings.debug = { overlay_enabled: true }; + await send('gree.frame', { direction: 'rx', payload: {} }); + await send('api.request', { method: 'GET', status: 200, path: '/api/health', duration_ms: 1 }); + await send('log.created', { level: 'info', kind: 'test', message: 'ok', metadata: {} }); + + await send('configuration.imported', { at: new Date().toISOString() }); + assert.equal(calls.loadBootstrap, 1, 'configuration import must full-resync connected UIs'); + + // With a valid pushed control plan, ordinary state events must not re-enable HTTP control-plan polling. + assert.equal(timers.length, timerCountWhilePushReady, 'push-ready live events must not schedule control-plan HTTP fallback'); + + // When push is unavailable, state changes must schedule the existing HTTP fallback. + app.controlPlanPushReady = false; + app.ws = { readyState: FakeWebSocket.CLOSED }; + const fallbackTimerCount = timers.length; + await send('zone.updated', { id: 'z1', demand: false }); + assert(timers.length > fallbackTimerCount); + assert.equal(timers[timers.length - 1].delay, 180); + + // An HTTP fallback started while disconnected must never overwrite a newer pushed revision after reconnect. + let resolveApi; + apiResponder = () => new Promise(resolve => { resolveApi = resolve; }); + const pendingHttp = context.loadControlPlan(); + app.ws = { readyState: FakeWebSocket.OPEN }; + await send('control_plan.updated', { revision: 3, plan: { marker: 'p3' } }); + resolveApi({ marker: 'old-http' }); + await pendingHttp; + assert.equal(app.controlPlan.marker, 'p3'); + + // Connection lifecycle still switches to fallback and schedules reconnect. + app.ws = null; + app.controlPlanPushReady = true; + context.connectWebSocket(); + const socket = FakeWebSocket.instances[FakeWebSocket.instances.length - 1]; + assert.equal(socket.url, 'ws://controller.test/ws'); + socket.readyState = FakeWebSocket.OPEN; + socket.onopen(); + assert.equal(calls['connection:connected'], 1); + const beforeCloseTimers = timers.length; + socket.readyState = FakeWebSocket.CLOSED; + socket.onclose(); + assert.equal(app.controlPlanPushReady, false); + assert(timers.length >= beforeCloseTimers + 2, 'close must schedule fallback and reconnect'); + + console.log(`Live realtime test OK (${apiCalls.length} API fallback/resync calls observed)`); +} + +main().catch(error => { + console.error(error.stack || error); + process.exit(1); +}); diff --git a/src/api/devices.rs b/src/api/devices.rs index 32608d8..627bdd2 100644 --- a/src/api/devices.rs +++ b/src/api/devices.rs @@ -293,6 +293,7 @@ async fn bind_device( device.last_error = None; device.updated_at = Utc::now(); state.db.save_device(&device)?; + state.broadcast("device.updated", serde_json::to_value(&device)?); state.log( "info", "device.bound", diff --git a/src/engine/automations.rs b/src/engine/automations.rs index 0787d99..90c9813 100644 --- a/src/engine/automations.rs +++ b/src/engine/automations.rs @@ -161,9 +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(); + // Keep connected clients in sync with runtime metadata such as last_fired_at. + // automation.updated also invalidates the materialized control plan. + state.broadcast("automation.updated", serde_json::to_value(&item)?); 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 })); @@ -184,7 +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.broadcast("automation.updated", serde_json::to_value(&item)?); 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 bc200bd..64ad665 100644 --- a/src/engine/polling.rs +++ b/src/engine/polling.rs @@ -198,9 +198,8 @@ 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(); + // Command failures change live communication health; publish the updated snapshot immediately. + state.broadcast("device.updated", serde_json::to_value(&*device)?); state.log("warn", "device.communication_error", &format!("{}: {error}", device.name), json!({ "device_id": device.id, "consecutive_failures": device.communication_failures, diff --git a/web/js/bootstrap.js b/web/js/bootstrap.js index 5f36bd7..4f1848f 100644 --- a/web/js/bootstrap.js +++ b/web/js/bootstrap.js @@ -1,5 +1,5 @@ async function loadBootstrap(showMessage = false) { - if (app.loading) return; + if (app.loading) { app.bootstrapReloadPending = true; return; } app.loading = true; try { const [data, sections] = await Promise.all([api('/api/bootstrap'), fetchSettingsSections()]); @@ -28,6 +28,10 @@ async function loadBootstrap(showMessage = false) { if (!String(error.message).toLowerCase().includes('token')) toast(error.message, true); } finally { app.loading = false; + if (app.bootstrapReloadPending) { + app.bootstrapReloadPending = false; + setTimeout(() => loadBootstrap(), 0); + } } } diff --git a/web/js/core.js b/web/js/core.js index fdea205..3de703d 100644 --- a/web/js/core.js +++ b/web/js/core.js @@ -27,7 +27,7 @@ const preferredTheme = ['system', 'light', 'dark'].includes(getCookie('gree_cont const app = { devices: [], zones: [], groups: [], schedules: [], automations: [], flows: [], accessTokens: [], settings: null, system: {}, outdoorTemperature: null, token: localStorage.getItem('gree_controller_token') || '', ws: null, wsTimer: null, - currentView: 'dashboard', loading: false, language: preferredLanguage, theme: preferredTheme, connectionStatus: 'connecting', + currentView: 'dashboard', loading: false, bootstrapReloadPending: false, language: preferredLanguage, theme: preferredTheme, connectionStatus: 'connecting', languages: [], translations: {}, locales: {}, historyTab: 'overview', historyData: { zones: [], devices: [], sensors: [] }, historyCounts: {}, historyZone: 'all', historyDevice: 'all', historySensor: 'all', historyLoading: false, diff --git a/web/js/realtime.js b/web/js/realtime.js index 110d973..9b4bb27 100644 --- a/web/js/realtime.js +++ b/web/js/realtime.js @@ -28,13 +28,20 @@ async function handleWebSocketMessage(event) { } const data = message.data || {}; if (message.event === 'control_plan.updated') { app.controlPlanPushReady = true; stopControlPlanFallback(); applyControlPlan(data.plan, data.revision); } - else if (['device.updated', 'device.created'].includes(message.event)) { updateDevice(data); renderSummary(); renderDevices(); renderGroups(); scheduleControlPlanLoad(); } + else if (message.event === 'device.updated') { updateDevice(data); renderSummary(); renderDevices(); renderGroups(); scheduleControlPlanLoad(); } + else if (message.event === 'device.created') { updateDevice(data); renderSummary(); renderDevices(); renderGroups(); fillSelects(); scheduleControlPlanLoad(); } else if (message.event === 'device.deleted') { app.devices = app.devices.filter(v => v.id !== data.id); renderAll(); scheduleControlPlanLoad(); } else if (message.event === 'devices.discovered') { (data.devices || []).forEach(updateDevice); renderAll(); scheduleControlPlanLoad(); } else if (message.event === 'zone.updated') { const i = app.zones.findIndex(v => v.id === data.id); if (i >= 0) app.zones[i] = data; else app.zones.push(data); renderSummary(); renderHouseClimate(); renderZones(); renderDevices(); renderGroups(); scheduleControlPlanLoad(); } + else if (message.event === 'zone.created') { const i = app.zones.findIndex(v => v.id === data.id); if (i >= 0) app.zones[i] = data; else app.zones.push(data); renderSummary(); renderHouseClimate(); renderZones(); renderDevices(); renderGroups(); fillSelects(); scheduleControlPlanLoad(); } else if (message.event === 'zone.deleted') { app.zones = app.zones.filter(v => v.id !== data.id); renderAll(); scheduleControlPlanLoad(); } else if (['group.updated', 'group.created'].includes(message.event)) { const i = app.groups.findIndex(v => v.id === data.id); if (i >= 0) app.groups[i] = data; else app.groups.push(data); renderGroups(); fillSelects(); scheduleControlPlanLoad(); } else if (message.event === 'group.deleted') { app.groups = app.groups.filter(v => v.id !== data.id); renderGroups(); fillSelects(); scheduleControlPlanLoad(); } + else if (['schedule.updated', 'schedule.created'].includes(message.event)) { const i = app.schedules.findIndex(v => v.id === data.id); if (i >= 0) app.schedules[i] = data; else app.schedules.push(data); renderSchedules(); fillSelects(); scheduleControlPlanLoad(); } + else if (message.event === 'schedule.deleted') { app.schedules = app.schedules.filter(v => v.id !== data.id); renderSchedules(); fillSelects(); scheduleControlPlanLoad(); } + else if (message.event === 'schedule.template_applied') { try { app.schedules = await api('/api/schedules'); renderSchedules(); fillSelects(); } catch (_) { } scheduleControlPlanLoad(); } + else if (['automation.updated', 'automation.created'].includes(message.event)) { const i = app.automations.findIndex(v => v.id === data.id); if (i >= 0) app.automations[i] = data; else app.automations.push(data); renderAutomations(); fillSelects(); scheduleControlPlanLoad(); } + else if (message.event === 'automation.deleted') { app.automations = app.automations.filter(v => v.id !== data.id); renderAutomations(); fillSelects(); scheduleControlPlanLoad(); } else if (['flow.updated', 'flow.created'].includes(message.event)) { const i = app.flows.findIndex(v => v.id === data.id); if (i >= 0) app.flows[i] = data; else app.flows.push(data); renderFlows(); scheduleControlPlanLoad(); } else if (message.event === 'flow.deleted') { app.flows = app.flows.filter(v => v.id !== data.id); renderFlows(); scheduleControlPlanLoad(); } else if (message.event === 'settings.application.updated') { applySettingsSection('application', data); if (!isFormDirty($('#settingsForm'))) renderSettings(); renderSimulationModeBanner(); renderSystemInfo(); } @@ -51,6 +58,7 @@ async function handleWebSocketMessage(event) { } else if (message.event === 'house.mode_changed') { app.settings = app.settings || {}; app.settings.house_mode = data.mode || 'cool'; renderHouseClimate(); renderGroups(); scheduleControlPlanLoad(); } else if (message.event === 'outdoor.updated') { app.outdoorTemperature = Number.isFinite(Number(data.temperature)) ? Number(data.temperature) : null; renderHouseClimate(); scheduleControlPlanLoad(); } + else if (message.event === 'configuration.imported') { await loadBootstrap(); return; } else if (message.event === 'gree.frame_received') { app.system = app.system || {}; app.system.gree_received_frames = Number(data.total || 0); @@ -62,7 +70,6 @@ async function handleWebSocketMessage(event) { else if (message.event === 'gree.frame') { if (app.settings?.debug?.overlay_enabled) debugLine('GREE', `${data.direction || '?'} ${data.protocol_version || ''}`, data.device_name || data.device_id || data.target || '', message.timestamp, data.payload); } else if (message.event === 'api.request') { if (app.settings?.debug?.overlay_enabled) debugLine('HTTP', `${data.method || '?'} ${data.status || ''}`, `${data.path || ''} · ${data.duration_ms ?? '?'} ms`, message.timestamp); } else if (message.event === 'log.created') { if (app.settings?.debug?.overlay_enabled) debugLine('API', data.kind || data.level || 'log', data.message || '', message.timestamp, data.metadata); if (app.currentView === 'logs') loadLogs(); } - else if (message.event.startsWith('schedule.') || message.event.startsWith('automation.') || message.event.startsWith('flow.')) scheduleControlPlanLoad(); } catch (_) { } }