This commit is contained in:
Mateusz Gruszczyński
2026-09-04 11:43:37 +02:00
parent 9b4fafdfa6
commit ade221bb76
16 changed files with 338 additions and 40 deletions
Generated
+1 -1
View File
@@ -700,7 +700,7 @@ dependencies = [
[[package]]
name = "gree-controller"
version = "0.13.1"
version = "0.13.2"
dependencies = [
"aes",
"aes-gcm",
+1 -1
View File
@@ -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"
+15 -14
View File
@@ -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
+2 -2
View File
@@ -2,9 +2,9 @@
Self-hosted controller for GREE-compatible air conditioners with a local Web UI, thermostat zones, schedules, Home Assistant integration, history, notifications and a documented HTTP/WebSocket API.
**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.
+10 -3
View File
@@ -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:
+3 -3
View File
@@ -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 <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.",
"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.\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",
@@ -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",
+1
View File
@@ -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.
+4 -4
View File
@@ -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
+278
View File
@@ -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);
});
+1
View File
@@ -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",
+4 -4
View File
@@ -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}));
}
}
+2 -3
View File
@@ -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,
+5 -1
View File
@@ -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);
}
}
}
+1 -1
View File
@@ -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,
+9 -2
View File
@@ -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 (_) { }
}