From 7772e1e339efcc32f485ad8e3a47bf41c018c9ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Mon, 7 Sep 2026 10:01:52 +0200 Subject: [PATCH] v0.13.7 --- Cargo.lock | 2 +- Cargo.toml | 2 +- FILE_MANIFEST.sha256 | 35 ++--- README.md | 4 +- docs/API.md | 47 +++++- docs/openapi.json | 8 +- .../gree_controller/manifest.json | 2 +- scripts/api_dev_test.py | 43 +++++- scripts/live_realtime_test.js | 25 ++- scripts/smoke.sh | 29 ++++ src/api.rs | 5 +- src/api/settings.rs | 13 ++ src/api/system.rs | 145 +++++++++++------- src/api/websocket.rs | 2 +- src/models/settings_api.rs | 13 ++ web/js/bootstrap.js | 77 +++++++--- web/js/realtime.js | 19 +-- web/js/settings.js | 46 ------ 18 files changed, 347 insertions(+), 170 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1ebdeac..98ce744 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -700,7 +700,7 @@ dependencies = [ [[package]] name = "gree-controller" -version = "0.13.6" +version = "0.13.7" dependencies = [ "aes", "aes-gcm", diff --git a/Cargo.toml b/Cargo.toml index 0afeacf..a435c90 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "gree-controller" -version = "0.13.6" +version = "0.13.7" 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 231308b..06c6c75 100644 --- a/FILE_MANIFEST.sha256 +++ b/FILE_MANIFEST.sha256 @@ -1,13 +1,13 @@ d67af429e4da9ce08e9d2f2a8472849ffbd70d135b1c5da535a076026794d04c ./.env.example a4ec3874a2e3ab1bad28fb40bb620f7b01f64d01ad9b699306bf70ada31227db ./.gitignore -174b5c57813e07375723f80f92772d965b1b434c02881d916c89efac0b61ee7e ./Cargo.lock -dc8d8412b74e19f2d32570ca0ef176c0036a140e47e41d020cb4d140ad9bfe39 ./Cargo.toml +d4330d1b80dd2bf948a5157c97581c2ca10dc69b7d6740fd55c48a699c2e3bc6 ./Cargo.lock +71d0ce719983e6d41bd89b592a6eaf66658479c0c30424b88d1133d80936d4aa ./Cargo.toml 19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE -079ddac7ba983a3d679b8c748bd43c731eb078e91a40c5d8a683bd76e14f06c5 ./README.md +a7acf66c0bbae0c6ca3faa2993b4b8ba80fec935902d0102f34693ffc96b7a19 ./README.md d56cfff4179a6511aa764a91c6ddebe21fcd35f36b7298e62d012e11f62bfeb9 ./build.rs -ed543caace807645561db525c19ae2c3745600af146e1c1ea9b89a39a7f597a3 ./docs/API.md +6d42d846df9e6115acedd9ac0ba84d05689046dc3f71fb2311c074638488d8f6 ./docs/API.md 2e1e18fd8167dabfe2469c26e85cce62486c7cb6a502c63c6f6b0cd74d5885e0 ./docs/FLOW.md -cb62e2ea377bdf4785c139ee169eec4914e04ceac6883c0afcb4c96fe60427b0 ./docs/openapi.json +6da82ee5007330b210a1645aed7df7951f8c858e04009631e89de51f32815ecf ./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 -5d8a5f7be7e11b385d7c22a10dedce418c809e5d737e3a402ee21c7e2a5ae316 ./home-assistant/custom_components/gree_controller/manifest.json +542d58cc7f47b55c786619c831f7bcdecaa3699940f29d9a7f26f01aaa0f0547 ./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,20 +69,20 @@ fdcd9a5055d08037278b842e7ab69265345c5811f0a06867136c511d140bb191 ./presets/wind 804f22123cd3e8db0fac791826c8dd9f758fb866c8e3b5655fb6d25d259dccf1 ./presets/workday_comfort.json 01952aa92b217f8eae2493b88870e2dec595100cd15c4d561ff11ae2b936c46f ./regenerate-sha.sh c64b1c6deeb24f20af662bfce7fa3683703914987241107ce664fa2f6d1a9dcb ./scripts/README.md -0e734dd7cdb8fa6226e31a96927861b44a41e75dea27f27ff4a539002d5bcf35 ./scripts/api_dev_test.py +b1e6a729a28eec03b38f013377d26b95669b58c035802c96ea9201cb633d87b2 ./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 +1a84c91a89bf7f60aa4816a7650640f315efadf39bf7b54dc20f1259b2e2d5ce ./scripts/live_realtime_test.js e00d211e3885e30d7fed1e43b44e6fdad40a67019060156c0641816a93e3365f ./scripts/network-debug.sh 01952aa92b217f8eae2493b88870e2dec595100cd15c4d561ff11ae2b936c46f ./scripts/regenerate-sha.sh 81345b6a0b51736bdbc98fd23199b62e4c721b4e7437e02dab7ea79b97dff29a ./scripts/service.sh -7e8f558b284362f55f50bd7223a89754a2a4960e746a27035cc61180f4a2da6f ./scripts/smoke.sh +472db31959be29ba99247f7e9cc07a44bc090c538cd654ad8d16e094726037a3 ./scripts/smoke.sh b50782b3742dfbf8a319c60571c968e93fdf8547db747c759edcffae68cb98bf ./scripts/update.sh -4b795bcb2e6b73c0dc9687325f6ffb0e6f008c6694b2a9fb8190a45a563451b2 ./src/api.rs +6b6cab48685c56893fbe31174460254347d73a354598d0bb1f22b195df91da3e ./src/api.rs 5027797b9cefb9f0aa04c792e2ac9a38745c6bf1ee822f50eb70574fa6f997e2 ./src/api/assets.rs 05c255919f7b3d81188fc5f1410be6a0037471dfc44e93bffb009f29a1e6a3fc ./src/api/auth.rs 9e4fc675306e111ed3db7af9822e2925d88109c31b9e309851665a975c0bd83c ./src/api/automations.rs @@ -97,11 +97,10 @@ e8edfbb725d6de650442444b0a929ad2be0bd2c2d7d227d2b3fdd99d05c94f94 ./src/api/hous 21636a6beb83e7bda961ef3ee383f6da317ffaa90b89fbcefa1547beb888de2d ./src/api/integrations.rs 6bfbe5135d4f93bd8d19a707877b53ed2029fd8490b4b1954a7520888fb1d664 ./src/api/middleware.rs 45e34a447ff384d99a3c42c489ca9fa75849c74d4d246caa91c63d927de17396 ./src/api/openapi.rs -744ce18aa9e0227c6cdfb6980063d0d738d74c5865d45c20fd20026d4742bb3e ./src/api/public_settings.rs 5b29f2e5aa2c422c5829ef89c7b59cf753883aa7f60661c95ab0f95cf676fa3f ./src/api/schedules.rs -9d157e1901081c077e32430d5d4d7f328ffb4933996579f584ce2959d5d10f47 ./src/api/settings.rs -df3297f36f0d0fa071b872967c5c124ed3887805d4f0f1031761cfba25331e97 ./src/api/system.rs -badd52159fa5d19c236cf9022b623d15c6fbfc7f0b7fc59e269717eb5ee3d10b ./src/api/websocket.rs +95db93b7bf6d3d93723f1ebadbf7cd9b922fbe3d98966963df1dc94001c0c0e2 ./src/api/settings.rs +45d6f9d62dddb677b382c40d529aab9b0a4f30cef98c161d76d0492b7a0531cb ./src/api/system.rs +9118a5a3a381b010e22d646498ff56d2e05f51656789682cc4371b09c384aa98 ./src/api/websocket.rs 455c7238c9788d378e909a4fbc94d5390b67edd92e127f48b4b913ddcbe82fb0 ./src/api/zones.rs f06014454d5062b1be17ac50d875a42c02fc778b59702ec455611265ab7de934 ./src/config.rs e0340b0e5945d5a38a1054e549c819566d5c1ef6034ab0e4d44c68a3b9a249cf ./src/db.rs @@ -149,7 +148,7 @@ aeab5b5acf35d38a54fd1d82e964fe56635ade69c0149ad423df317095d01555 ./src/models/f cf63af00d98410a1452f205ce00b8a6c5f0f3525b994cba3e971d631db288396 ./src/models/history.rs e1621255217ee6128151cc898373dd404123cb6d54017beec6c7e3e5c3030486 ./src/models/integrations.rs 8294749dd3af524925309c51ad641ffd32ff1fd9c164cfa71660aa88c2d13d6e ./src/models/runtime.rs -887eb80a58998e90023e952c1fde67435eb843493387ae64536613b74c084534 ./src/models/settings_api.rs +41eafa63499dd800b499167ddaedce7a6fc43751734aaca789fcbe2fb992c668 ./src/models/settings_api.rs c4de6101de85d9d7e6814b2504b87d9216561e3d9e274054f081f505fb152140 ./src/models/temporary_thermostat.rs 7d798228a8d4d4e60a112167c001535f4b8a5fa32ec7a33554c73125c42256c2 ./src/models/zone.rs 62e696df8b86835df7ad127f03c257a78580769dc82bc66daa5041f71e8440a5 ./src/notifications.rs @@ -178,7 +177,7 @@ b92a6cb158b494fe145b43c7641e65f6fafff47201d7d76edbec2cfd8b94835c ./systemd/gree e98bdd7204349cce1ec6f57283509697af0bbc72280622a6c3efa6fed242db4f ./web/favicon.svg 9e98df7a759bd76bc7682b23c3c4b7e29f7bde972f03ae316ba7f8159301e2c6 ./web/index.html 9ba3f6fa05b72aa989139b2a909982571b2a02055052e4c40104f1e81a9aa7eb ./web/js/README.md -45fb54541a117a8ee0dc484cd263b6af4aafab75d94d269b126bb603b5f0392c ./web/js/bootstrap.js +f272912f6e5d8b34786827d78ba592513e3ca1e85e07e4c073089c5bbb75f282 ./web/js/bootstrap.js badde8a5214a0322b2c45803c02a1cb723d53b25e57c687369cd804f509d9744 ./web/js/charts.js dd596b3362643b96be8537b9415e8c1be90d7cf458124b61121016a6742951c7 ./web/js/core.js d880ffdac6aa07cd10d79fbacf12f87eb4c69b48a80564bbfdd0248abe8884d5 ./web/js/dashboard.js @@ -189,10 +188,10 @@ cf1ebf3b9d666f309a1add070b4da86be86d990b02c8d6474f6d121c6d332746 ./web/js/forms dbf36223863ba882c03eccea4516ba8db7acf0c2e72c6c64490cb994f1e20f96 ./web/js/history.js 7eaa04992ea828dc89f5eaebf674cbbfd160f53eb8d0d6ef0d4e47bd8c28c435 ./web/js/main.js 733fcc595ed9c03c9639acc6caba46f0588f29e0b95bf72891e34443e7aa9db8 ./web/js/navigation.js -0f0ecb9fcc2b47aece715de4ededdc1cf512daf404c37efe33bf9069027221e3 ./web/js/realtime.js +e0a6b5d2900f5524dba395b97288f8d75976e31b27e26810d42febbfd723085c ./web/js/realtime.js c8c82e88c3715b1bfabf155e36266a4c94f5e2fc03eb39dcdd9d08a1985a997c ./web/js/router.js 25f5c9a0aa8622f419b05f64712c9eca4896f0e79818616904b83051fd77b07b ./web/js/settings-ui.js -7399a951ae585d7c9ee55cc57064c64a2806ec725e2456cfe6b09bc61f8f4166 ./web/js/settings.js +4aadfdf107593d32ab73514688f225bf559fd5ce6bbf9d9459a8098daf1fbe3d ./web/js/settings.js 6b653e4fe2d4db4ffae6ff8f39b8ce57a10a990dc0000ee4aae51d949afadd52 ./web/manifest.webmanifest 0b9774bb388158e23f8c90297d70659a015e0db58d25eadcae316a49e3c9fcb7 ./web/styles.css ae1b03f30b494f474a781a5d32072f1e73eba2b7c768109fc1cc8028cb6662a4 ./web/sw.js diff --git a/README.md b/README.md index 01751fb..2ebc013 100644 --- a/README.md +++ b/README.md @@ -2,7 +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.6** +**Current release: 0.13.7** + +Release 0.13.7 consolidates the Web UI startup snapshot: `/api/bootstrap` now includes the same redacted settings views exposed by the split settings endpoints, eliminating redundant startup/resync requests while keeping the functional settings API unchanged. Bootstrap/system response construction and WebSocket state application are also deduplicated. Release 0.13.6 adds a dedicated notification toggle for GREE vs Home Assistant room-temperature discrepancies. Disabling this alert suppresses `zone.sensor_discrepancy` notifications only; sensor fallback and thermostat safety behavior are unchanged. diff --git a/docs/API.md b/docs/API.md index c1e85e9..2472718 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.6**. +HTTP and WebSocket API for GREE Controller **0.13.7**. [← Main documentation](../README.md) @@ -231,7 +231,7 @@ Response: { "status": "ok", "name": "gree-controller", - "version": "0.13.6", + "version": "0.13.7", "uptime_seconds": 1234, "control_ready": true, "time": "2026-08-30T06:54:00Z" @@ -251,13 +251,50 @@ Returns the initial Web UI snapshot: "groups": [], "schedules": [], "automations": [], + "flows": [], "access_tokens": [], + "settings": { + "application": {"simulator_enabled": false}, + "gree": { + "controller_id": "gree-controller", + "poll_interval_seconds": 10, + "zone_interval_seconds": 10, + "discovery_timeout_ms": 3000, + "discovery_broadcast": "auto", + "suppress_device_beep": false, + "compressor_protection_enabled": true, + "compressor_protection_seconds": 180 + }, + "history": {"retention_days": 30, "compaction_enabled": true, "event_retention_days": 30}, + "influxdb": { + "enabled": false, "version": "2", "url": "", "database": "gree_controller", + "username": "", "password_configured": false, "org": "", "bucket": "", + "token_configured": false, "history_threshold_days": 30 + }, + "notifications": { + "enabled": false, "mode": "problems", "provider": "pushover", + "pushover_configured": false, "slack_configured": false, "discord_configured": false, + "cooldown_seconds": 300, "communication_failure_threshold": 3, + "target_timeout_minutes": 60, "alert_types": {} + }, + "night": { + "enabled": false, "start_time": "22:00", "end_time": "06:00", + "max_fan_speed": 1, "force_quiet": true, "use_native_sleep": true + }, + "home_assistant": { + "url": "", "token_configured": false, "default_entity_id": "", + "outdoor_entity_id": "", "sensor_stale_after_seconds": 300, + "allow_invalid_tls": false, "sensor_aliases": {}, "flow_inputs": [], + "outdoor_assist_enabled": true + }, + "debug": {"overlay_enabled": false, "gree_frames": false} + }, "house": {"mode": "cool"}, "outdoor_temperature": null, "control_plan": {"generated_at": "2026-09-04T08:00:00Z", "zones": [], "rules": []}, "control_plan_revision": 42, "system": { - "version": "0.13.6", + "version": "0.13.7", "uptime_seconds": 1234, "auth_required": false, "control_ready": true, @@ -274,6 +311,8 @@ Returns the initial Web UI snapshot: } ``` +`settings` is a single startup snapshot composed from the same response models as the eight `/api/settings/*` GET endpoints. Secret values are never included; only `*_configured` flags are exposed for stored credentials. The split settings endpoints remain the canonical resources for independent reads and updates. + ### `GET /api/system/info` Returns the `system` diagnostic object independently of the full bootstrap. Useful for monitoring and **Settings → System status**. @@ -1472,6 +1511,8 @@ In `0.13.5`, the same disabled-zone behavior is explained directly beside the zo In `0.13.6`, notification settings add `alert_types.sensor_discrepancy`, allowing GREE vs Home Assistant temperature-difference notifications to be disabled independently from thermostat/group control errors. The sensor fallback behavior itself is unchanged. +In `0.13.7`, the bootstrap payload also contains all eight redacted settings views. The bundled Web UI applies the same bootstrap mapper for initial HTTP load and WebSocket resynchronization, so a bootstrap frame no longer triggers eight additional settings GET requests. + 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 02d4563..7698984 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.6", + "version": "0.13.7", "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.\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.\n\n### 0.13.3\nLogical thermostat targets are normalized to 0.1 C consistently, including Temporary Quick Thermostat and thermostat-managed automation/Flow actions. The bundled Web UI accepts comma or dot decimal input for the temporary thermostat target; physical GREE setpoint rounding is unchanged.\n\n### 0.13.4\nDisabled-zone thermostat controls remain visually active in the bundled Web UI because explicit temporary/manual thermostat and direct manual control remain available while zone automation is disabled. HTTP and WebSocket contracts are unchanged.\n\n### 0.13.5\nThe zone settings dialog now explains directly beside the enabled/disabled option that disabling a zone stops automation but does not block manual thermostat, temporary thermostat or direct manual control. HTTP and WebSocket contracts remain unchanged.\n\n### 0.13.6\nNotification settings add a dedicated sensor_discrepancy alert type for GREE vs Home Assistant room-temperature divergence. Disabling it suppresses only that notification; the GREE safety fallback remains active.", + "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.\n\n### 0.13.3\nLogical thermostat targets are normalized to 0.1 C consistently, including Temporary Quick Thermostat and thermostat-managed automation/Flow actions. The bundled Web UI accepts comma or dot decimal input for the temporary thermostat target; physical GREE setpoint rounding is unchanged.\n\n### 0.13.4\nDisabled-zone thermostat controls remain visually active in the bundled Web UI because explicit temporary/manual thermostat and direct manual control remain available while zone automation is disabled. HTTP and WebSocket contracts are unchanged.\n\n### 0.13.5\nThe zone settings dialog now explains directly beside the enabled/disabled option that disabling a zone stops automation but does not block manual thermostat, temporary thermostat or direct manual control. HTTP and WebSocket contracts remain unchanged.\n\n### 0.13.6\nNotification settings add a dedicated sensor_discrepancy alert type for GREE vs Home Assistant room-temperature divergence. Disabling it suppresses only that notification; the GREE safety fallback remains active.\n\n### 0.13.7\nThe bootstrap snapshot now embeds all eight redacted runtime settings views. The bundled Web UI consumes those settings directly for HTTP startup and WebSocket resynchronization, removing the previous settings-request fan-out while preserving the split settings endpoints for direct reads and writes.", "license": { "name": "MIT" } @@ -122,7 +122,7 @@ "System" ], "summary": "Get application bootstrap", - "description": "Returns the initial Web UI entity snapshot: devices, zones, groups, schedules, automations, Flows, access-token metadata, house mode, outdoor temperature, the latest materialized control plan with its revision, and system diagnostics. Runtime settings are loaded from the functional `/api/settings/*` resources.", + "description": "Returns the initial Web UI snapshot: devices, zones, groups, schedules, automations, Flows, access-token metadata, all eight redacted runtime settings views, house mode, outdoor temperature, the latest materialized control plan with its revision, and system diagnostics. The split `/api/settings/*` resources remain available for independent reads and writes.", "operationId": "bootstrap", "security": [ { @@ -4891,7 +4891,7 @@ }, "version": { "type": "string", - "example": "0.13.6" + "example": "0.13.7" }, "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 6ff6ae4..d807461 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.6", + "version": "0.13.7", "config_flow": true, "integration_type": "hub", "iot_class": "local_polling", diff --git a/scripts/api_dev_test.py b/scripts/api_dev_test.py index cfa93e9..4cfce31 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.6. +"""Developer smoke/integration tests for GREE Controller API 0.13.7. 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.6" +DEFAULT_EXPECTED_VERSION = "0.13.7" SETTINGS_PATHS: dict[str, set[str]] = { "/api/settings/application": {"simulator_enabled"}, @@ -131,6 +131,10 @@ EXPECTED_OPENAPI_PATHS = set(SETTINGS_PATHS) | { "/api/integrations/home-assistant/snapshot", } +BOOTSTRAP_SETTINGS_SECTIONS = { + "application", "gree", "history", "influxdb", "notifications", "night", "home_assistant", "debug" +} + class TestFailure(AssertionError): pass @@ -343,6 +347,36 @@ def test_openapi(client: ApiClient, expected_version: str) -> str: return f"paths={len(paths)}, version={version}" +def test_bootstrap_contract(client: ApiClient) -> str: + resp = client.get("/api/bootstrap") + assert_status(resp, 200) + data = assert_json_object(resp) + required = { + "devices", "zones", "groups", "schedules", "automations", "flows", + "access_tokens", "settings", "house", "outdoor_temperature", + "control_plan", "control_plan_revision", "system", + } + missing = sorted(required - set(data)) + if missing: + raise TestFailure(f"bootstrap missing fields: {missing}") + settings = data.get("settings") + if not isinstance(settings, dict): + raise TestFailure("bootstrap.settings is not an object") + missing_sections = sorted(BOOTSTRAP_SETTINGS_SECTIONS - set(settings)) + if missing_sections: + raise TestFailure(f"bootstrap.settings missing sections: {missing_sections}") + forbidden = { + "influxdb": {"password", "token"}, + "home_assistant": {"token"}, + "notifications": {"pushover_app_token", "pushover_user_key", "slack_webhook_url", "discord_webhook_url"}, + } + for section, fields in forbidden.items(): + leaked = sorted(fields & set(settings.get(section, {}))) + if leaked: + raise TestFailure(f"bootstrap.settings.{section} leaks secret fields: {leaked}") + return f"settings_sections={len(BOOTSTRAP_SETTINGS_SECTIONS)}, control_plan_revision={data.get('control_plan_revision')}" + + def test_protected_auth(client: ApiClient) -> str: if not client.token: raise SkipTest("no token supplied; controller may be in trusted-LAN mode") @@ -519,7 +553,8 @@ 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.6 contract", lambda: test_openapi(client, args.expected_version)) + runner.run("OpenAPI 0.13.7 contract", lambda: test_openapi(client, args.expected_version)) + runner.run("bootstrap snapshot contract", lambda: test_bootstrap_contract(client)) runner.run("protected API requires auth", lambda: test_protected_auth(client)) for path in SAFE_GET_PATHS: @@ -587,7 +622,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.6.", + description="Developer smoke/integration tests for GREE Controller API 0.13.7.", 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 index e7ba87a..c098766 100755 --- a/scripts/live_realtime_test.js +++ b/scripts/live_realtime_test.js @@ -27,7 +27,7 @@ class FakeWebSocket { } const app = { - devices: [], zones: [], groups: [], schedules: [], automations: [], flows: [], + devices: [], zones: [], groups: [], schedules: [], automations: [], flows: [], accessTokens: [], settings: { house_mode: 'cool', debug: { overlay_enabled: false }, home_assistant: {} }, system: {}, outdoorTemperature: null, controlPlan: null, controlPlanRevision: null, controlPlanPushReady: false, controlPlanTimer: null, currentView: 'dashboard', @@ -54,7 +54,6 @@ const context = { 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; }, @@ -78,6 +77,7 @@ 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/bootstrap.js'), 'utf8'), context, { filename: 'bootstrap.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); @@ -85,7 +85,6 @@ 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; }; @@ -112,10 +111,8 @@ async function testBootstrapReloadQueue() { 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 }; + return { devices: [], zones: [], groups: [], schedules: [], automations: [], flows: [], access_tokens: [], settings: {}, 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; }, @@ -126,8 +123,9 @@ async function testBootstrapReloadQueue() { 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 }); + firstResolve({ devices: [], zones: [], groups: [], schedules: [], automations: [], flows: [], access_tokens: [], settings: {}, house: { mode: 'cool' }, system: {}, control_plan: { marker: 'first' }, control_plan_revision: 1 }); await first; + assert.equal(c.app.outdoorTemperature, null, 'null/missing outdoor temperature must stay unknown, not become 0 C'); assert.equal(c.app.bootstrapReloadPending, false); assert.equal(queuedTimers.length, 1); assert.equal(queuedTimers[0].delay, 0); @@ -138,6 +136,7 @@ async function testBootstrapReloadQueue() { async function main() { await testBootstrapReloadQueue(); + const apiCallsBeforeBootstrap = apiCalls.length; await send('bootstrap', { devices: [{ id: 'd1', power: false }], zones: [{ id: 'z1', demand: false }], @@ -145,9 +144,21 @@ async function main() { schedules: [{ id: 's1', name: 'S1' }], automations: [{ id: 'a1', name: 'A1' }], flows: [{ id: 'f1', name: 'F1' }], + access_tokens: [{ id: 't1', name: 'HA', token_prefix: 'abc', created_at: '2026-09-07T08:00:00Z' }], + settings: { + application: { simulator_enabled: true }, + gree: { controller_id: 'test-controller' }, + history: { retention_days: 30 }, influxdb: {}, notifications: {}, night: {}, + home_assistant: { sensor_aliases: { 'sensor.bootstrap': 'Bootstrap' }, flow_inputs: [], outdoor_assist_enabled: true }, + debug: { overlay_enabled: false }, + }, house: { mode: 'heat' }, system: { ok: true }, outdoor_temperature: 10, control_plan: { marker: 'p1' }, control_plan_revision: 1, }); + assert.equal(apiCalls.length, apiCallsBeforeBootstrap, 'WebSocket bootstrap must not fetch split settings endpoints'); + assert.equal(app.accessTokens[0].id, 't1', 'WebSocket bootstrap must refresh access-token metadata'); + assert.equal(app.settings.controller_id, 'test-controller'); + assert.equal(app.sensorAliases['sensor.bootstrap'], 'Bootstrap'); assert.equal(app.controlPlan.marker, 'p1'); assert.equal(app.controlPlanRevision, 1); assert.equal(app.controlPlanPushReady, true); diff --git a/scripts/smoke.sh b/scripts/smoke.sh index a24207a..a5f18c0 100755 --- a/scripts/smoke.sh +++ b/scripts/smoke.sh @@ -48,6 +48,35 @@ grep -qi 'swagger-ui' "$TMP/swagger.html" curl -fsS "http://127.0.0.1:$PORT/api/bootstrap" >"$TMP/bootstrap.json" grep -q 'sim-salon' "$TMP/bootstrap.json" +python3 - "$TMP/bootstrap.json" "$PORT" <<'PYBOOTSTRAP' +import json, sys, urllib.request + +bootstrap = json.load(open(sys.argv[1], encoding="utf-8")) +port = sys.argv[2] +settings = bootstrap.get("settings") +assert isinstance(settings, dict), "bootstrap.settings missing" +paths = { + "application": "/api/settings/application", + "gree": "/api/settings/gree", + "history": "/api/settings/history", + "influxdb": "/api/settings/influxdb", + "notifications": "/api/settings/notifications", + "night": "/api/settings/night", + "home_assistant": "/api/settings/home-assistant", + "debug": "/api/settings/debug", +} +assert set(paths) <= set(settings), f"bootstrap settings sections missing: {set(paths) - set(settings)}" +for section, path in paths.items(): + with urllib.request.urlopen(f"http://127.0.0.1:{port}{path}") as response: + standalone = json.load(response) + assert settings[section] == standalone, f"bootstrap settings mismatch for {section}" + +assert "password" not in settings["influxdb"] +assert "token" not in settings["influxdb"] +assert "token" not in settings["home_assistant"] +for secret in ("pushover_app_token", "pushover_user_key", "slack_webhook_url", "discord_webhook_url"): + assert secret not in settings["notifications"], f"secret field leaked: {secret}" +PYBOOTSTRAP curl -fsS -X POST -H 'Content-Type: application/json' \ -d '{"power":true,"mode":"cool","target_temperature":22}' \ diff --git a/src/api.rs b/src/api.rs index 2eccd86..4b42c83 100644 --- a/src/api.rs +++ b/src/api.rs @@ -4,12 +4,13 @@ use crate::{ home_assistant, influxdb, models::{ ApiTokenInfo, ApplicationSettings, Automation, ClimateGroup, ConfigurationExport, - DebugSettings, Device, DeviceCommand, DevicePatch, DiscoveryRequest, GreeSettings, + DebugSettings, Device, DeviceCommand, DevicePatch, DiscoveryRequest, Flow, GreeSettings, GroupControlPatch, HaReading, HistorySettings, HomeAssistantSettings, HomeAssistantSettingsUpdate, HomeAssistantSettingsView, InfluxDbSettings, InfluxDbSettingsUpdate, InfluxDbSettingsView, ManualDeviceRequest, NightModeSettings, NotificationSettings, NotificationSettingsUpdate, NotificationSettingsView, Reading, - RuntimeSettings, Schedule, TemporaryQuickThermostat, TemporaryQuickThermostatRequest, Zone, + RuntimeSettings, Schedule, SettingsSnapshot, TemporaryQuickThermostat, + TemporaryQuickThermostatRequest, Zone, ZoneControlPatch, ZoneReading, }, notifications, diff --git a/src/api/settings.rs b/src/api/settings.rs index 105db5a..8051d3d 100644 --- a/src/api/settings.rs +++ b/src/api/settings.rs @@ -70,6 +70,19 @@ fn home_assistant_settings(settings: &RuntimeSettings) -> HomeAssistantSettingsV } } +fn settings_snapshot(settings: &RuntimeSettings) -> SettingsSnapshot { + SettingsSnapshot { + application: application_settings(settings), + gree: gree_settings(settings), + history: history_settings(settings), + influxdb: influxdb_settings(settings), + notifications: notification_settings(settings), + night: settings.night_mode.clone(), + home_assistant: home_assistant_settings(settings), + debug: settings.debug.clone(), + } +} + async fn get_application_settings(State(state): State) -> Json { Json(application_settings(&*state.settings.read().await)) } diff --git a/src/api/system.rs b/src/api/system.rs index a9fb43d..81f0fb3 100644 --- a/src/api/system.rs +++ b/src/api/system.rs @@ -1,3 +1,42 @@ +#[derive(Debug, serde::Serialize)] +struct HouseSnapshot { + mode: String, +} + +#[derive(Debug, serde::Serialize)] +struct SystemInfoResponse { + version: &'static str, + uptime_seconds: u64, + auth_required: bool, + control_ready: bool, + database: String, + device_count: usize, + online_count: usize, + simulator_count: usize, + bind: String, + base_path: String, + gree_interface: String, + gree_received_frames: u64, + gree_received_frames_by_device: std::collections::HashMap, +} + +#[derive(Debug, serde::Serialize)] +struct BootstrapResponse { + devices: Vec, + zones: Vec, + groups: Vec, + schedules: Vec, + automations: Vec, + flows: Vec, + access_tokens: Vec, + settings: SettingsSnapshot, + house: HouseSnapshot, + outdoor_temperature: Option, + control_plan: crate::models::ControlPlan, + control_plan_revision: u64, + system: SystemInfoResponse, +} + async fn health(State(state): State) -> Json { Json(json!({ "status": "ok", @@ -9,64 +48,66 @@ async fn health(State(state): State) -> Json { })) } -async fn bootstrap(State(state): State) -> Result, AppError> { +async fn bootstrap(State(state): State) -> Result, AppError> { Ok(Json(build_bootstrap(&state).await?)) } -async fn build_bootstrap(state: &AppState) -> Result { - let settings = state.settings.read().await.clone(); +async fn build_bootstrap(state: &AppState) -> Result { + let (settings, house_mode) = { + let settings = state.settings.read().await; + (settings_snapshot(&settings), settings.house_mode.clone()) + }; let devices = state.db.list_devices()?; - let device_count = devices.len(); - let online_count = devices.iter().filter(|value| value.online).count(); - let simulator_count = devices.iter().filter(|value| value.simulated).count(); - let (received_frames_total, received_frames_by_device) = state.gree.received_frame_stats(); + let system = build_system_info(state, &devices); let control_plan = engine::get_control_plan_snapshot(state).await?; - Ok(json!({ - "devices": devices, - "zones": state.db.list_zones()?, - "groups": state.db.list_groups()?, - "schedules": state.db.list_schedules()?, - "automations": state.db.list_automations()?, - "flows": state.db.list_flows()?, - "access_tokens": state.db.list_api_tokens()?, - "house": {"mode": settings.house_mode}, - "outdoor_temperature": *state.outdoor_temperature.read().await, - "control_plan": control_plan.plan.as_ref(), - "control_plan_revision": control_plan.revision, - "system": { - "version": env!("CARGO_PKG_VERSION"), - "uptime_seconds": state.started.elapsed().as_secs(), - "auth_required": !state.config.app_token.trim().is_empty(), - "control_ready": state.initial_device_sync_complete.load(Ordering::Acquire), - "database": state.config.database.display().to_string(), - "device_count": device_count, - "online_count": online_count, - "simulator_count": simulator_count, - "bind": state.config.bind.to_string(), - "base_path": if state.config.base_path.is_empty() { "/" } else { state.config.base_path.as_str() }, - "gree_interface": if state.config.gree_interface.trim().is_empty() { "auto" } else { state.config.gree_interface.trim() }, - "gree_received_frames": received_frames_total, - "gree_received_frames_by_device": received_frames_by_device, - } - })) + + Ok(BootstrapResponse { + devices, + zones: state.db.list_zones()?, + groups: state.db.list_groups()?, + schedules: state.db.list_schedules()?, + automations: state.db.list_automations()?, + flows: state.db.list_flows()?, + access_tokens: state.db.list_api_tokens()?, + settings, + house: HouseSnapshot { mode: house_mode }, + outdoor_temperature: *state.outdoor_temperature.read().await, + control_plan: control_plan.plan.as_ref().clone(), + control_plan_revision: control_plan.revision, + system, + }) } -async fn system_info(State(state): State) -> Result, AppError> { - let devices = state.db.list_devices()?; +fn build_system_info(state: &AppState, devices: &[Device]) -> SystemInfoResponse { let (received_frames_total, received_frames_by_device) = state.gree.received_frame_stats(); - Ok(Json(json!({ - "version": env!("CARGO_PKG_VERSION"), - "uptime_seconds": state.started.elapsed().as_secs(), - "database": state.config.database.display().to_string(), - "device_count": devices.len(), - "online_count": devices.iter().filter(|v| v.online).count(), - "simulator_count": devices.iter().filter(|v| v.simulated).count(), - "control_ready": state.initial_device_sync_complete.load(Ordering::Acquire), - "auth_required": !state.config.app_token.trim().is_empty(), - "bind": state.config.bind.to_string(), - "base_path": if state.config.base_path.is_empty() { "/" } else { state.config.base_path.as_str() }, - "gree_interface": if state.config.gree_interface.trim().is_empty() { "auto" } else { state.config.gree_interface.trim() }, - "gree_received_frames": received_frames_total, - "gree_received_frames_by_device": received_frames_by_device, - }))) + SystemInfoResponse { + version: env!("CARGO_PKG_VERSION"), + uptime_seconds: state.started.elapsed().as_secs(), + auth_required: !state.config.app_token.trim().is_empty(), + control_ready: state.initial_device_sync_complete.load(Ordering::Acquire), + database: state.config.database.display().to_string(), + device_count: devices.len(), + online_count: devices.iter().filter(|device| device.online).count(), + simulator_count: devices.iter().filter(|device| device.simulated).count(), + bind: state.config.bind.to_string(), + base_path: if state.config.base_path.is_empty() { + "/".to_string() + } else { + state.config.base_path.clone() + }, + gree_interface: if state.config.gree_interface.trim().is_empty() { + "auto".to_string() + } else { + state.config.gree_interface.trim().to_string() + }, + gree_received_frames: received_frames_total, + gree_received_frames_by_device: received_frames_by_device, + } +} + +async fn system_info( + State(state): State, +) -> Result, AppError> { + let devices = state.db.list_devices()?; + Ok(Json(build_system_info(&state, &devices))) } diff --git a/src/api/websocket.rs b/src/api/websocket.rs index 5729248..fc71424 100644 --- a/src/api/websocket.rs +++ b/src/api/websocket.rs @@ -28,7 +28,7 @@ fn control_plan_ws_message(snapshot: &crate::state::ControlPlanSnapshot) -> Valu async fn send_ws_bootstrap(state: &AppState, socket: &mut WebSocket) -> Result, ()> { let (message, revision) = match build_bootstrap(state).await { Ok(data) => { - let revision = data.get("control_plan_revision").and_then(Value::as_u64); + let revision = Some(data.control_plan_revision); ( json!({"event":"bootstrap","timestamp":Utc::now(),"data":data}), revision, diff --git a/src/models/settings_api.rs b/src/models/settings_api.rs index 33b053b..85d12b7 100644 --- a/src/models/settings_api.rs +++ b/src/models/settings_api.rs @@ -111,3 +111,16 @@ pub struct HomeAssistantSettingsView { pub flow_inputs: Vec, pub outdoor_assist_enabled: bool, } + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SettingsSnapshot { + pub application: ApplicationSettings, + pub gree: GreeSettings, + pub history: HistorySettings, + pub influxdb: InfluxDbSettingsView, + pub notifications: NotificationSettingsView, + pub night: NightModeSettings, + pub home_assistant: HomeAssistantSettingsView, + pub debug: DebugSettings, +} + diff --git a/web/js/bootstrap.js b/web/js/bootstrap.js index 4f1848f..2d74db3 100644 --- a/web/js/bootstrap.js +++ b/web/js/bootstrap.js @@ -1,25 +1,67 @@ +function settingsFromSnapshot(sections, houseMode = app.settings?.house_mode || 'cool') { + const application = sections?.application || {}; + const gree = sections?.gree || {}; + const history = sections?.history || {}; + const influxdb = sections?.influxdb || {}; + const notifications = sections?.notifications || {}; + const night = sections?.night || {}; + const homeAssistant = sections?.home_assistant || {}; + const debug = sections?.debug || {}; + return { + simulator_enabled: !!application.simulator_enabled, + controller_id: gree.controller_id, + poll_interval_seconds: Number(gree.poll_interval_seconds), + zone_interval_seconds: Number(gree.zone_interval_seconds), + discovery_timeout_ms: Number(gree.discovery_timeout_ms), + discovery_broadcast: gree.discovery_broadcast, + suppress_device_beep: !!gree.suppress_device_beep, + compressor_protection_enabled: gree.compressor_protection_enabled !== false, + compressor_protection_seconds: Number(gree.compressor_protection_seconds), + history_retention_days: Number(history.retention_days), + history_compaction_enabled: history.compaction_enabled !== false, + event_log_retention_days: Number(history.event_retention_days), + influxdb, + notifications, + night_mode: night, + home_assistant: homeAssistant, + outdoor_assist_enabled: !!homeAssistant.outdoor_assist_enabled, + debug, + house_mode: houseMode || 'cool', + }; +} + +function applyBootstrapSnapshot(data) { + app.devices = data.devices || []; + app.zones = data.zones || []; + app.groups = data.groups || []; + app.schedules = data.schedules || []; + app.automations = data.automations || []; + app.flows = data.flows || []; + app.accessTokens = data.access_tokens || []; + app.settings = settingsFromSnapshot(data.settings, data.house?.mode || 'cool'); + app.sensorAliases = { ...(app.settings.home_assistant?.sensor_aliases || {}) }; + app.flowSharedInputs = JSON.parse(JSON.stringify(app.settings.home_assistant?.flow_inputs || [])); + app.system = data.system || {}; + app.systemSnapshotAt = Date.now(); + const outdoorTemperature = data.outdoor_temperature; + app.outdoorTemperature = outdoorTemperature === null || outdoorTemperature === undefined || outdoorTemperature === '' + ? null + : (Number.isFinite(Number(outdoorTemperature)) ? Number(outdoorTemperature) : null); + + const hasControlPlan = !!data.control_plan; + if (hasControlPlan) app.controlPlan = data.control_plan; + if (Number.isFinite(Number(data.control_plan_revision))) app.controlPlanRevision = Number(data.control_plan_revision); + return hasControlPlan; +} + async function loadBootstrap(showMessage = false) { if (app.loading) { app.bootstrapReloadPending = true; return; } app.loading = true; try { - const [data, sections] = await Promise.all([api('/api/bootstrap'), fetchSettingsSections()]); - app.devices = data.devices || []; - app.zones = data.zones || []; - app.groups = data.groups || []; - app.schedules = data.schedules || []; - app.automations = data.automations || []; - app.flows = data.flows || []; - app.accessTokens = data.access_tokens || []; - app.settings = aggregateSettingsSections(sections, data.house?.mode || 'cool'); - app.sensorAliases = { ...(app.settings.home_assistant?.sensor_aliases || {}) }; - app.flowSharedInputs = JSON.parse(JSON.stringify(app.settings.home_assistant?.flow_inputs || [])); - app.system = data.system || {}; - app.systemSnapshotAt = Date.now(); - app.outdoorTemperature = Number.isFinite(Number(data.outdoor_temperature)) ? Number(data.outdoor_temperature) : null; - app.controlPlan = data.control_plan || app.controlPlan; - app.controlPlanRevision = Number.isFinite(Number(data.control_plan_revision)) ? Number(data.control_plan_revision) : app.controlPlanRevision; + const data = await api('/api/bootstrap'); + const hasControlPlan = applyBootstrapSnapshot(data); renderAll(); - if (!data.control_plan) scheduleControlPlanLoad(0); + if (!hasControlPlan) scheduleControlPlanLoad(0); if (app.settings?.debug?.overlay_enabled) loadDebugBacklog(); if (showMessage) toast(tr('common.updated')); if ($('#tokenDialog').open) $('#tokenDialog').close(); @@ -34,4 +76,3 @@ async function loadBootstrap(showMessage = false) { } } } - diff --git a/web/js/realtime.js b/web/js/realtime.js index 712ae92..c213ead 100644 --- a/web/js/realtime.js +++ b/web/js/realtime.js @@ -26,17 +26,14 @@ async function handleWebSocketMessage(event) { try { const message = JSON.parse(event.data); if (message.event === 'bootstrap') { - const data = message.data; app.devices = data.devices || []; app.zones = data.zones || []; app.groups = data.groups || []; app.schedules = data.schedules || []; app.automations = data.automations || []; - app.flows = data.flows || []; await loadSettingsSections(data.house?.mode || app.settings?.house_mode || 'cool'); app.system = data.system || app.system; app.systemSnapshotAt = Date.now(); app.outdoorTemperature = Number.isFinite(Number(data.outdoor_temperature)) ? Number(data.outdoor_temperature) : app.outdoorTemperature; - if (data.control_plan) { - app.controlPlan = data.control_plan; - app.controlPlanRevision = Number.isFinite(Number(data.control_plan_revision)) ? Number(data.control_plan_revision) : app.controlPlanRevision; - app.controlPlanPushReady = true; - stopControlPlanFallback(); - } else { - app.controlPlanPushReady = false; - } - renderAll(); if (!data.control_plan) scheduleControlPlanLoad(0); if (app.settings?.debug?.overlay_enabled) loadDebugBacklog(); return; + const data = message.data || {}; + const hasControlPlan = applyBootstrapSnapshot(data); + app.controlPlanPushReady = hasControlPlan; + if (hasControlPlan) stopControlPlanFallback(); + renderAll(); + if (!hasControlPlan) scheduleControlPlanLoad(0); + if (app.settings?.debug?.overlay_enabled) loadDebugBacklog(); + return; } const data = message.data || {}; if (message.event === 'control_plan.updated') { app.controlPlanPushReady = true; stopControlPlanFallback(); applyControlPlan(data.plan, data.revision); } diff --git a/web/js/settings.js b/web/js/settings.js index 8d414da..ab9d882 100644 --- a/web/js/settings.js +++ b/web/js/settings.js @@ -9,52 +9,6 @@ const SETTINGS_ENDPOINTS = { debug: '/api/settings/debug', }; -async function fetchSettingsSections() { - const [application, gree, history, influxdb, notifications, night, homeAssistant, debug] = await Promise.all([ - api(SETTINGS_ENDPOINTS.application), - api(SETTINGS_ENDPOINTS.gree), - api(SETTINGS_ENDPOINTS.history), - api(SETTINGS_ENDPOINTS.influxdb), - api(SETTINGS_ENDPOINTS.notifications), - api(SETTINGS_ENDPOINTS.night), - api(SETTINGS_ENDPOINTS.homeAssistant), - api(SETTINGS_ENDPOINTS.debug), - ]); - return { application, gree, history, influxdb, notifications, night, homeAssistant, debug }; -} - -function aggregateSettingsSections(sections, houseMode = app.settings?.house_mode || 'cool') { - return { - simulator_enabled: !!sections.application.simulator_enabled, - controller_id: sections.gree.controller_id, - poll_interval_seconds: Number(sections.gree.poll_interval_seconds), - zone_interval_seconds: Number(sections.gree.zone_interval_seconds), - discovery_timeout_ms: Number(sections.gree.discovery_timeout_ms), - discovery_broadcast: sections.gree.discovery_broadcast, - suppress_device_beep: !!sections.gree.suppress_device_beep, - compressor_protection_enabled: sections.gree.compressor_protection_enabled !== false, - compressor_protection_seconds: Number(sections.gree.compressor_protection_seconds), - history_retention_days: Number(sections.history.retention_days), - history_compaction_enabled: sections.history.compaction_enabled !== false, - event_log_retention_days: Number(sections.history.event_retention_days), - influxdb: sections.influxdb, - notifications: sections.notifications, - night_mode: sections.night, - home_assistant: sections.homeAssistant, - outdoor_assist_enabled: !!sections.homeAssistant.outdoor_assist_enabled, - debug: sections.debug, - house_mode: houseMode || 'cool', - }; -} - -async function loadSettingsSections(houseMode = app.settings?.house_mode || 'cool') { - const sections = await fetchSettingsSections(); - app.settings = aggregateSettingsSections(sections, houseMode); - app.sensorAliases = { ...(app.settings.home_assistant?.sensor_aliases || {}) }; - app.flowSharedInputs = JSON.parse(JSON.stringify(app.settings.home_assistant?.flow_inputs || [])); - return app.settings; -} - function applySettingsSection(section, data) { app.settings = app.settings || {}; if (section === 'application') app.settings.simulator_enabled = !!data.simulator_enabled;