diff --git a/Cargo.lock b/Cargo.lock index 08b2245..52f2553 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -700,7 +700,7 @@ dependencies = [ [[package]] name = "gree-controller" -version = "0.12.1" +version = "0.13.0" dependencies = [ "aes", "aes-gcm", diff --git a/Cargo.toml b/Cargo.toml index 7c80a9c..df41f30 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "gree-controller" -version = "0.12.1" +version = "0.13.0" 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 23d8d3b..6f56c5c 100644 --- a/FILE_MANIFEST.sha256 +++ b/FILE_MANIFEST.sha256 @@ -1,24 +1,24 @@ -d56cfff4179a6511aa764a91c6ddebe21fcd35f36b7298e62d012e11f62bfeb9 ./build.rs -0696b5112a35943786aa4dbe0547b745d158309795a8e8967946eed07cb0d1e7 ./Cargo.lock -000dd8d1a535f357667cf812f817a2abbc72eb79fe062ae2dcf8fef65d6b7491 ./Cargo.toml -ceafb0ec649a7a7ea0c469501dab144743306150c15869f3a084bab3ce47f51d ./data/gree-controller.db -9596e50efb3ce09afd823d2507c814cfbffe812a622b45c9e0d53526e8cd996a ./docs/API.md -2e1e18fd8167dabfe2469c26e85cce62486c7cb6a502c63c6f6b0cd74d5885e0 ./docs/FLOW.md -933f560266bd309be0b1eaaa943e86874646e0b9ada5e805bf47a7a62f7063b8 ./docs/openapi.json -d67af429e4da9ce08e9d2f2a8472849ffbd70d135b1c5da535a076026794d04c ./.env d67af429e4da9ce08e9d2f2a8472849ffbd70d135b1c5da535a076026794d04c ./.env.example a4ec3874a2e3ab1bad28fb40bb620f7b01f64d01ad9b699306bf70ada31227db ./.gitignore -d48b8a8fbd32b483e5aa176111ec821450a338438780f73fde11e11ac2ac4271 ./gree-controller.zip -6910589f27960a28d4de9735884a7e5376e455cd885f19ce2b55947fcd135114 ./home-assistant/custom_components/gree_controller/api.py +e664a2f53234cb1e5d75bb9482c5477484d6d6ad878f26ea0bf84e23c4f35644 ./Cargo.lock +cbbebcf672b947987eaaa2122aa080d2c71737fcdd0736aef318fd3099ef5851 ./Cargo.toml +19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE +01243b698f466857db8755e857ac1da49d9ab1e78c7a914c237829c98242bbf7 ./README.md +d56cfff4179a6511aa764a91c6ddebe21fcd35f36b7298e62d012e11f62bfeb9 ./build.rs +f586babc2a9392035f3821c8b78226aec19589cf0a9f9178097fef9afaf1026f ./docs/API.md +2e1e18fd8167dabfe2469c26e85cce62486c7cb6a502c63c6f6b0cd74d5885e0 ./docs/FLOW.md +5dc6f579834a991f8c9366e7a13b24214993cba01db89ed637d305a71fdd59c8 ./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 71c8058f85fd9ce9f063b7a84dcf2956f23cdbbd02bd4bd99d2fbb9d177c72ad ./home-assistant/custom_components/gree_controller/climate.py 5e4aef2143e81bedb5a15dd4be5c71b64a3ab448ec6d33a20851edd098e5f529 ./home-assistant/custom_components/gree_controller/config_flow.py b7f0873109c52be9d7f09bea3dffc416103c50085e1f0680d11661a969479898 ./home-assistant/custom_components/gree_controller/const.py -ab08fbe40e9bb48ebcbfff98760aaa0c9434b61b705aaaadea0c09c255d99b0e ./home-assistant/custom_components/gree_controller/coordinator.py +41a8958a6fe6d10e5679c95ac34832583a2a15ecfd67d09b4e094a83d06f5c47 ./home-assistant/custom_components/gree_controller/coordinator.py 5a96fe8f5c035c34f1339370270cd078056202d09e236dec75735be11de92a7d ./home-assistant/custom_components/gree_controller/entity_map.py c4fb75c246db651087900ebfc2291ff41ac87652cd6194fc0b776b0005c1cbcf ./home-assistant/custom_components/gree_controller/icon.png -f8e8559fe10fe523ac5bc9aac25c6e26e862f679d502e8f3c39f38a0a8e40911 ./home-assistant/custom_components/gree_controller/__init__.py c4fb75c246db651087900ebfc2291ff41ac87652cd6194fc0b776b0005c1cbcf ./home-assistant/custom_components/gree_controller/logo.png -fec5ff50552f331bc0f994d9980059f1b1c309a57419e20f785c04d93dcd14f9 ./home-assistant/custom_components/gree_controller/manifest.json +f62e0122fcd812e42736af85233a4c53fdb34450f38a93d8ad57a18f8a937b91 ./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 @@ -26,11 +26,9 @@ cca65482e36d48035aca178121a378fe7d578d600acff267ae81a6399c0da653 ./home-assista 6bddb7b4620021ecd2099a86a77ef5c7f2c2dcd3d07d5db4e7b4c4ce6d3e8c03 ./home-assistant/custom_components/gree_controller/translations/en.json 13f30e2dcdcedbd1b6c3f99c2335e0487108fd72c8e86922368b84f2fa2038ae ./home-assistant/custom_components/gree_controller/translations/pl.json c5fc5c87273d82d2834a0a5a365c821a416413ed509613441e24e4dc3507c4d2 ./home-assistant/generated/gree_controller_entities.example.json -b11e581dd916676e24402112fe6896bc681abf3c94cafc85c4d3ded3f3a272a3 ./home-assistant/README.md +253a0bc912786e67ea7fc92a64e4a510ad973bec343a88ccfb1f28fca3e8cf01 ./lang/README.md 810bf9a9483277cbde14454b6b88dbfe79b873a6f34737031a7e10d23fda9299 ./lang/en.json ad48d11875aa041c0f593aa6e322f8d953aef137d4df4c369c5f82eec5aebd11 ./lang/pl.json -253a0bc912786e67ea7fc92a64e4a510ad973bec343a88ccfb1f28fca3e8cf01 ./lang/README.md -19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE d8459024f04ca514bd8e9d6bd3af872fb942fd85a7cdd5583f4a9d28aab6faba ./make_zip.py b14233a8987e53bbbdd6770386ba10fa166ec25c1e098275c173c544b37846fc ./presets/bedroom_window_night.json 1960841119c0b673fb2f03621b32237552fd828045304cd221401214b997c910 ./presets/device_resilience.json @@ -69,9 +67,9 @@ fffa18bd989a0dcc1c5458975522517d40af4af40cc1ca30640e79d3db711053 ./presets/unoc ae21459a261712bcb8d57594528b1648432e8d03a8a38502234829c0dbfef774 ./presets/weekend_comfort.json fdcd9a5055d08037278b842e7ab69265345c5811f0a06867136c511d140bb191 ./presets/window_available_guard.json 804f22123cd3e8db0fac791826c8dd9f758fb866c8e3b5655fb6d25d259dccf1 ./presets/workday_comfort.json -c353091e7695cd19f77bdcad80b41438710a7315c5f2696016dc482000023a21 ./README.md 01952aa92b217f8eae2493b88870e2dec595100cd15c4d561ff11ae2b936c46f ./regenerate-sha.sh -cd2eb8c851ab3cacd00efcb5cdbd404f03279e75126c46f025e132ba1e9d4ce1 ./scripts/api_dev_test.py +bb89bac237e750e9b1bf73761d7df97a6b81853091615878c03f13d7b6399aa7 ./scripts/README.md +52fa6906d2c80d7c8fe0b0d178c16b9b0a53408472a9ecdccbb9d8df1137860f ./scripts/api_dev_test.py 5bc736c7bc76ca80aaa406bb171d2aa91baf4c3aa8695dce0e09b888b6ab3146 ./scripts/common.sh 6403786610ee6d2f628193c25aee0dd058d62e904aa1a31d5f62fdaae0e94b4f ./scripts/configure-gree-network.sh 1d6e14e26e49aa9d3527f30a23668bf8d9c48b67e6628ef686c3155c012155de ./scripts/dev.sh @@ -79,12 +77,12 @@ cd2eb8c851ab3cacd00efcb5cdbd404f03279e75126c46f025e132ba1e9d4ce1 ./scripts/api_ e4849261fd9ed1f01df96c0637c439c0c4eff8fa317b2918026167bba343af79 ./scripts/install-lxc.sh bb7cd2c5b27c9dceec1d1d2846fbad9600df9c08e0ad07d13fcde97af533091c ./scripts/install.sh e00d211e3885e30d7fed1e43b44e6fdad40a67019060156c0641816a93e3365f ./scripts/network-debug.sh -bb89bac237e750e9b1bf73761d7df97a6b81853091615878c03f13d7b6399aa7 ./scripts/README.md 01952aa92b217f8eae2493b88870e2dec595100cd15c4d561ff11ae2b936c46f ./scripts/regenerate-sha.sh 81345b6a0b51736bdbc98fd23199b62e4c721b4e7437e02dab7ea79b97dff29a ./scripts/service.sh 7e8f558b284362f55f50bd7223a89754a2a4960e746a27035cc61180f4a2da6f ./scripts/smoke.sh b50782b3742dfbf8a319c60571c968e93fdf8547db747c759edcffae68cb98bf ./scripts/update.sh -04084aac0c2af07a9bb2d0d7d4499efaed47944aefff526f32c73a557a491224 ./src/api/assets.rs +4b795bcb2e6b73c0dc9687325f6ffb0e6f008c6694b2a9fb8190a45a563451b2 ./src/api.rs +5027797b9cefb9f0aa04c792e2ac9a38745c6bf1ee822f50eb70574fa6f997e2 ./src/api/assets.rs 05c255919f7b3d81188fc5f1410be6a0037471dfc44e93bffb009f29a1e6a3fc ./src/api/auth.rs 9e4fc675306e111ed3db7af9822e2925d88109c31b9e309851665a975c0bd83c ./src/api/automations.rs 368cd843dcac970f1aa6800ba132a4bbc3429e8379ed7abf9878dcb0c633af40 ./src/api/configuration.rs @@ -92,20 +90,20 @@ b50782b3742dfbf8a319c60571c968e93fdf8547db747c759edcffae68cb98bf ./scripts/upda 0b87a7f0caef2a9c9cdea34d560095a3367e3c9d4322d6bcabc5b8ade8d55d06 ./src/api/devices.rs 4b5b784670ae761a6848b9a33fe3fb391bebd39d65e87936907428711e96ca44 ./src/api/events.rs 44b74965f3256c2972d6b3cd384a63c68e3b422f414a0902c085bc92e17369bf ./src/api/flows.rs -5879d4fb0eda24298a00300bf2237110e13ee0067b513f332bbc7344a859911a ./src/api/groups.rs -641f17dc5b107e6f13949ada8d05e8fdf878d0e44666ddc72aea71a57c70df06 ./src/api/history.rs +b61b0b4f196c5e72e47cf06ca0c14df3cf8eedbdef0242a9d2af33623663489f ./src/api/groups.rs +853e80f5adf074b629bca1d7ee1483b6be0aaf397a46a281fc58fb96ebac8cb1 ./src/api/history.rs e8edfbb725d6de650442444b0a929ad2be0bd2c2d7d227d2b3fdd99d05c94f94 ./src/api/house.rs -6139e17a08e35d0fcd07ee5625983c602d758ae77452c924f4b4cc05580e9ee8 ./src/api/integrations.rs +21636a6beb83e7bda961ef3ee383f6da317ffaa90b89fbcefa1547beb888de2d ./src/api/integrations.rs 6bfbe5135d4f93bd8d19a707877b53ed2029fd8490b4b1954a7520888fb1d664 ./src/api/middleware.rs 45e34a447ff384d99a3c42c489ca9fa75849c74d4d246caa91c63d927de17396 ./src/api/openapi.rs 744ce18aa9e0227c6cdfb6980063d0d738d74c5865d45c20fd20026d4742bb3e ./src/api/public_settings.rs -e86df48db04868bd65bb7815fb9205fec8b29b5212cfb84af74990e0eb380120 ./src/api.rs 5b29f2e5aa2c422c5829ef89c7b59cf753883aa7f60661c95ab0f95cf676fa3f ./src/api/schedules.rs 9d157e1901081c077e32430d5d4d7f328ffb4933996579f584ce2959d5d10f47 ./src/api/settings.rs -20dddb2a07f4a647226783b66f6f3032c6a1ba2910134408d79cf362f909299c ./src/api/system.rs -63f02760d0e2cfd59788b5b1cdf4317c685aab1b5a0a5665d52b50f793ce5c74 ./src/api/websocket.rs +df3297f36f0d0fa071b872967c5c124ed3887805d4f0f1031761cfba25331e97 ./src/api/system.rs +badd52159fa5d19c236cf9022b623d15c6fbfc7f0b7fc59e269717eb5ee3d10b ./src/api/websocket.rs c742634b5c8001b8e31ce313a1114abb1f44f18186a86860de4b6b1c01a4832f ./src/api/zones.rs f06014454d5062b1be17ac50d875a42c02fc778b59702ec455611265ab7de934 ./src/config.rs +e0340b0e5945d5a38a1054e549c819566d5c1ef6034ab0e4d44c68a3b9a249cf ./src/db.rs 6c278fc5728124ab2f53ac7789a733942a0462f397144a231cc878c8cfcf7426 ./src/db/climate.rs be4082b142fe16ba40c5978b287b7ef808e67c13edbd8f2d6637ccad974b8847 ./src/db/configuration.rs bc10f1a66ddcaacbb08b096a02998482da547024dd0ba3144d00f993b4351cb0 ./src/db/core_devices.rs @@ -113,35 +111,35 @@ bc10f1a66ddcaacbb08b096a02998482da547024dd0ba3144d00f993b4351cb0 ./src/db/core_ eb07d94b58bcc8ea17b5b086cc88c165a35666f84c3d9a1d74f72dc30b979829 ./src/db/events_tokens.rs bb5048ee60053f39ce3c1203dc1f24c7047310538d2357f105a052c016d21ec4 ./src/db/flows.rs 5f4b567c6bc866d0674b3b0c66f4aeccf561774623f575b872ff25bf015b91c2 ./src/db/ha_history.rs -e0340b0e5945d5a38a1054e549c819566d5c1ef6034ab0e4d44c68a3b9a249cf ./src/db.rs c0a7fa3eac198737d7261ad00c542a614e6d7291f6581af8e7f7aef9dec1a049 ./src/db/schedules_automations.rs 1a209dc6d8df864778bb03c980f309a2ec86fc62df274880fbb95eff8470174e ./src/db/tests.rs f90fbb14f29a20b799b3856bdaa22812a9f0d9dfa46693f1341c160dccda8a97 ./src/db/zone_history.rs +4d2c6053586558afbca6b60fbbc9b1cd397f74f8284d7c01f3337eb42bf6d4f7 ./src/engine.rs ec6034df6667b3ac6ec39851443048a56e87fa861f5846950a5e79fa89ee5a30 ./src/engine/automations.rs 88f85acf692e12c7061e039dd7c54f101fe0b014c4a3d799150ad92eea9e2894 ./src/engine/commands.rs -10ab2d7abc0f149936393129459dc56e85d8fca51d96da71c742cb96e86a6f08 ./src/engine/control_plan.rs +8ee70ad21d8ee32914dd90952b04dfa8355ee868252ef4fc682ec40503590a7a ./src/engine/control_plan.rs 7b48a33343d519a2ff2d81e9d757eb56a0c1beacfc0b7b8ef61f49c2ad363f9a ./src/engine/deadlines.rs e350aa63f3bb14ca887eaaa7555edbeae00fb584cbcaf552cc972f0c8918f5d8 ./src/engine/groups.rs f0c8f3384b6174771aeb5640ee1eec87f3d6d9ff79a4fbd6a35554a4eec00cfa ./src/engine/history.rs 41b7045877771b7c352328998cb45ce2433e7f9631fad3eb1a882ab4897dc31f ./src/engine/local_thermostat.rs 6280a7f5059d9737987bf2d72b19a26b3a6709ce578af1108c4215f3f47d7763 ./src/engine/ownership.rs 9c37adf514c96840fd6a5c715c579350fa86d6dcc0655a2aa97d1fd7c7e04299 ./src/engine/polling.rs -35f7ed5fb42199afbb331dd6cfb992815a77a3fea346edc176cd4a2b0fcab58f ./src/engine.rs -b0e33539dc7ef52dfa4cad500a96b26bf124b2bc195c9f7965fe03eb71de6621 ./src/engine/runtime.rs -7c4ad4de24833504c2d2c692f5a154977f0d6ddba2bd82f16235806a85a8d64e ./src/engine/schedules.rs +f0bcd3a56b600944ccc62fa24ce20838033eeb4354f937cdb534630c1a1f5a78 ./src/engine/runtime.rs +c1e8de5392681ba1255e3e2d871a6ee860fb8ca987a0a8f973d9ded3977a68fb ./src/engine/schedules.rs de80d9ff69f1c843fdac23fdc3e3a350fc1082a186261b3a38617af189132356 ./src/engine/targets.rs 682c8b3956c657b655746cbd19ee97ee2f4cf953bc9de67d6d70a1b14f3dd746 ./src/engine/temperature.rs 833b72907375e75c3f42b3921ad70a8e298f5a93743bf0d0c19059aa272462d1 ./src/engine/temporary_thermostat.rs -9da37fb636e2c342c4e86b6e0ecf07a5a6850431b1227cf4b8f02b4d05b02eaa ./src/engine/tests.rs +35e8582fcdb13ff6d8850a5274893805a04cabe3b0c6c936b1ad4a9808bf16ee ./src/engine/tests.rs 786ae270afb543d03cf6586942394c6e29015d94c71526f4fb54010c220c90d8 ./src/engine/zone_actions.rs 56d9af59eb700ac6363d26db0ffbea777270cdbbe8e5b7eb982bff9f02539eee ./src/engine/zone_control.rs 2276aca42bca712a55de9b6c5dbe64639675c84befa85a446a7d34613596ed7e ./src/error.rs 2731f629f22a9d4fdb831ea3b283f43900b190f7e6a08fdab5b8fbb2c288547b ./src/home_assistant.rs +342dcb54caf22d2282a3f3f2297f54bd413b5242c17756276ea74994e88e28fb ./src/influxdb.rs da4068295f37c23222bb13bebac022d88ba7729604a49ad25438c55e45458a49 ./src/influxdb/codec.rs 1769e2a96e336bf39b28ae9de02a5d942350afa2bb9702df7603fabe9d7fcba7 ./src/influxdb/query.rs -342dcb54caf22d2282a3f3f2297f54bd413b5242c17756276ea74994e88e28fb ./src/influxdb.rs 29dc8ed02dfa1f6419bf4aedfb99c805b25c8a2e772efef20681c750570928cf ./src/influxdb/write.rs -df4b6550fea08e070488ca9abafb8c3794d13b21715a7160d255839e662768e2 ./src/main.rs +9ec1bc25ebd06cc925e4fddc8b49e189a21ae495c6709c8cb1bba5b4dfa35388 ./src/main.rs +23688a5cb68efded89471ce959328d27adff370d451bd9f378a283ab51437e76 ./src/models.rs 920e028d96a14cca9f74a964bb296e68a109cfead314d3164c180201e606d151 ./src/models/automation.rs 3c3f344fe42cdde980b13dbec6e92ed0e2f5946cba8ac19b1a668846b48e7c81 ./src/models/control_plan.rs 9cd1904a0f822c557f6ad4576cdb5f96d9668d04e79ea9783885c9f39519752e ./src/models/defaults.rs @@ -149,13 +147,13 @@ f935d35b4a409f12774209f3fa2ffe60f579a8bc1722badbb27d4766efd6368e ./src/models/d aeab5b5acf35d38a54fd1d82e964fe56635ade69c0149ad423df317095d01555 ./src/models/flow.rs cf63af00d98410a1452f205ce00b8a6c5f0f3525b994cba3e971d631db288396 ./src/models/history.rs ea216f24f0f385903324f15a1164a0fc508adbdcc3c30d16383ea2e835c53e6e ./src/models/integrations.rs -23688a5cb68efded89471ce959328d27adff370d451bd9f378a283ab51437e76 ./src/models.rs 8294749dd3af524925309c51ad641ffd32ff1fd9c164cfa71660aa88c2d13d6e ./src/models/runtime.rs 887eb80a58998e90023e952c1fde67435eb843493387ae64536613b74c084534 ./src/models/settings_api.rs c4de6101de85d9d7e6814b2504b87d9216561e3d9e274054f081f505fb152140 ./src/models/temporary_thermostat.rs 7d798228a8d4d4e60a112167c001535f4b8a5fa32ec7a33554c73125c42256c2 ./src/models/zone.rs 2ccac88080ef3ca8546facad5167cbaadc080bdee3f5d9100c8fabb0fc3ae49d ./src/notifications.rs cca742bb2b500eb9269f69385fcf88053cd08aabf6a98b7669b8a7a2fd67df13 ./src/protocol/crypto.rs +28969da06a454ddd1ebea5fa606e8446a83dfd21e21352767c189013c508cb90 ./src/protocol/gree.rs 7907c33e6396c7e0d61705b735b21c84d6ed784012fb61022b872c6745540f1d ./src/protocol/gree/binding.rs d4df52f0202eb32290e68dc945c4b0b44f3be8aa90bd57fd116eb20285960616 ./src/protocol/gree/commands.rs 4e419ba3b86ca9d154e8c04445cf005965175c54041093b2daad3666d4d852bd ./src/protocol/gree/core.rs @@ -163,26 +161,26 @@ d4df52f0202eb32290e68dc945c4b0b44f3be8aa90bd57fd116eb20285960616 ./src/protocol 0ab094f609edb99715402d8eeaa7e38cbfd2d271036aec787dcbb3be3e005720 ./src/protocol/gree/merge.rs a96061fbbcadac6c2df2e9708a48dcf819b951eb147f32c7d0d285ac5ae2491b ./src/protocol/gree/network.rs de37931303fc92a9399e6edc17dfabd6870a38cd2ddb27257427c76768a66ec2 ./src/protocol/gree/polling.rs -28969da06a454ddd1ebea5fa606e8446a83dfd21e21352767c189013c508cb90 ./src/protocol/gree.rs c868422c02ba457df21ae92acbd00698c243855ddd1d4e95ac2166796eba9a44 ./src/protocol/gree/tests.rs 3c93e5b254a2a40a07854532cb5fc07e493ad65d9ba2cf28093cff4a4a4e451c ./src/protocol/gree/transport.rs 923bb4b2d342d6c1870474e43f33b0aba16dc88590c561d765b874412e83df1f ./src/protocol/mod.rs +830621fa75265da7288b8135704c0ce27e0687f87d55abf3cdbe40a6e2f3d2b1 ./src/queries.rs 7fac65a9dcbcecee09816f5447c7792dc94f736b1efb66ac85629db75f6280be ./src/queries/device_history.rs 3fb50f57d30ac23ef122c45dae4bd96c7cb7184df02d99aef00d9890b66479e2 ./src/queries/entities.rs 2c5fba462b72158b04dbaaa53c8536263f8d72a1338a6c4ff06cb28a36f6ae27 ./src/queries/ha_history.rs c91e4b8b250b30cbe76f313b70c16244e9003bce92e02c82283a1831f91974c5 ./src/queries/maintenance.rs -830621fa75265da7288b8135704c0ce27e0687f87d55abf3cdbe40a6e2f3d2b1 ./src/queries.rs 3a49bad74a2f047893e726b5e00a6e833c228ad268bdf50cd60f786ba164ea54 ./src/queries/schema.rs f13f1be3d5789539eba3fc1b59c14835f8c681eca7634e118fa3763a53aeee2b ./src/queries/zone_history.rs -df2f13c98b9d7f84916dcbf9e01a479a6f392f888413fe22bae586526747f704 ./src/state.rs +74c1ecdb45e1f4c6275a16a14d157b3a67a18c3f3d80dfdae18aec5bf6aed849 ./src/state.rs b92a6cb158b494fe145b43c7641e65f6fafff47201d7d76edbec2cfd8b94835c ./systemd/gree-controller.service 544a31cb2e5374227026afd6abd0d910381f3fca1638154ebd74e3c3d96a35d1 ./web/404.html e98bdd7204349cce1ec6f57283509697af0bbc72280622a6c3efa6fed242db4f ./web/favicon.svg d0388bf22f34ae28fb63074b0bc15c087a3be298bb702d006d4814ebb786b36e ./web/index.html -8fb20fdba11df68feb0b096bb5b4c8f936db974aed5d345a0d01455054f31f95 ./web/js/bootstrap.js +9ba3f6fa05b72aa989139b2a909982571b2a02055052e4c40104f1e81a9aa7eb ./web/js/README.md +7a0baeaa8703ba8654aa402e320ced127ae12327403f339f8fefdc0cd792af0d ./web/js/bootstrap.js badde8a5214a0322b2c45803c02a1cb723d53b25e57c687369cd804f509d9744 ./web/js/charts.js -af92b2140f6248bc636d7c75e37a9460f4128ba59adf1b7842d25244a607555c ./web/js/core.js -361d68a9f7dda66a628ebde25d130a26024d809aef521d1b3ad8d8bdf5d39098 ./web/js/dashboard.js +13def8de3450b612bc669f441782237e992a7a27aa4784b0dda4d48dcf3482b2 ./web/js/core.js +d880ffdac6aa07cd10d79fbacf12f87eb4c69b48a80564bbfdd0248abe8884d5 ./web/js/dashboard.js d6fbd8ef4a67269afadb2f59f19fe9a359f7ec4d7bd1473ecff77df117b9558d ./web/js/entities.js e332b3f811958a938105272c1e8e28d6ca8ba69303001da165b4b0b85727695c ./web/js/events.js 1b21b3ae43a6848f9fc13fd4eacaa6a1a50d44c65aa37ca737be60aa4eca8a38 ./web/js/flows.js @@ -190,11 +188,10 @@ f06557c6d338259059b990f8f79c91d68ac8ec1f245c302468f08929b5506901 ./web/js/forms dbf36223863ba882c03eccea4516ba8db7acf0c2e72c6c64490cb994f1e20f96 ./web/js/history.js 7eaa04992ea828dc89f5eaebf674cbbfd160f53eb8d0d6ef0d4e47bd8c28c435 ./web/js/main.js 733fcc595ed9c03c9639acc6caba46f0588f29e0b95bf72891e34443e7aa9db8 ./web/js/navigation.js -9ba3f6fa05b72aa989139b2a909982571b2a02055052e4c40104f1e81a9aa7eb ./web/js/README.md -8505ff605de9bcb11f79a0d01a6c5c15f0482d3f65a5e94cc510308a36f558cc ./web/js/realtime.js +90dccce563b1cbd6119576d575ea72121a438107c4dd16ea44f8a591f4e6db90 ./web/js/realtime.js c8c82e88c3715b1bfabf155e36266a4c94f5e2fc03eb39dcdd9d08a1985a997c ./web/js/router.js -9dd9fbcc5f00584562bae6a840189495f830d1086d9679763dbad6e9c18c4fdb ./web/js/settings.js 427376a35832cb3644206c6e89e1654bd807eec78352f80bd0dd43f74b4ec810 ./web/js/settings-ui.js +9dd9fbcc5f00584562bae6a840189495f830d1086d9679763dbad6e9c18c4fdb ./web/js/settings.js 6b653e4fe2d4db4ffae6ff8f39b8ce57a10a990dc0000ee4aae51d949afadd52 ./web/manifest.webmanifest 5c939ab91aa28ba386aefef6ba5f41b0c73fae3936f6e915fbc39e2336859605 ./web/styles.css ae1b03f30b494f474a781a5d32072f1e73eba2b7c768109fc1cc8028cb6662a4 ./web/sw.js diff --git a/README.md b/README.md index 56a463d..aa541a9 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.12.1** +**Current release: 0.13.0** + +Release 0.13.0 keeps the existing control-plan HTTP contract while adding a materialized backend snapshot, live `control_plan.updated` WebSocket delivery with resynchronization, HTTP fallback in the Web UI, and a single restricted Home Assistant snapshot request for devices, groups and the control plan. > [Full API reference](docs/API.md) — authentication, every endpoint, request bodies, response models, WebSocket events and examples. diff --git a/docs/API.md b/docs/API.md index 89d027c..c8a1ae0 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.12.1**. +HTTP and WebSocket API for GREE Controller **0.13.0**. [← Main documentation](../README.md) @@ -206,6 +206,7 @@ Common statuses: | GET | `/api/access-tokens` | List generated HA tokens without secrets. | | POST | `/api/access-tokens` | Create restricted HA token. | | DELETE | `/api/access-tokens/{id}` | Revoke token. | +| GET | `/api/integrations/home-assistant/snapshot` | Restricted devices + groups + control-plan snapshot in one request. | | GET | `/api/integrations/home-assistant/devices` | Restricted device list. | | POST | `/api/integrations/home-assistant/devices/{id}/command` | Restricted direct device command. | | GET | `/api/integrations/home-assistant/control-plan` | Restricted control plan. | @@ -230,7 +231,7 @@ Response: { "status": "ok", "name": "gree-controller", - "version": "0.12.1", + "version": "0.13.0", "uptime_seconds": 1234, "control_ready": true, "time": "2026-08-30T06:54:00Z" @@ -253,8 +254,10 @@ Returns the initial Web UI snapshot: "access_tokens": [], "house": {"mode": "cool"}, "outdoor_temperature": null, + "control_plan": {"generated_at": "2026-09-04T08:00:00Z", "zones": [], "rules": []}, + "control_plan_revision": 42, "system": { - "version": "0.12.1", + "version": "0.13.0", "uptime_seconds": 1234, "auth_required": false, "control_ready": true, @@ -941,7 +944,8 @@ When InfluxDB is enabled, older history can be read from Influx and merged with ### `GET /api/control-plan` -Returns the resolved machine-readable thermostat plan: +Returns the latest materialized machine-readable thermostat plan. The response shape is unchanged; the bundled Web UI receives plan updates primarily through WebSocket and uses this endpoint for fallback/resynchronization. + Top-level fields: @@ -1330,6 +1334,10 @@ Returns `Device[]`. Accepts `DeviceCommand`. A direct command is rejected if the device belongs to a disabled thermostat zone; re-enable the zone for normal HA/controller ownership or use the administrator technical device endpoint deliberately. +### `GET /api/integrations/home-assistant/snapshot` + +Returns `devices`, `groups`, `control_plan` and `control_plan_revision` in one restricted request. The bundled Home Assistant coordinator uses this endpoint to reduce its normal refresh from three HTTP requests to one. The existing restricted devices/groups/control-plan endpoints remain available. + ### `GET /api/integrations/home-assistant/control-plan` Same payload as administrator `GET /api/control-plan`. @@ -1401,12 +1409,13 @@ Every server event uses: } ``` -The first frame is always `bootstrap` with the same payload as `GET /api/bootstrap`, unless bootstrap generation itself fails. +The first frame is always `bootstrap` with the same payload as `GET /api/bootstrap`, including `control_plan` and `control_plan_revision`, unless bootstrap generation itself fails. If the generic event queue is overrun, the server sends another full `bootstrap` to resynchronize the client instead of silently continuing with missed state. Common live events include: ```text bootstrap +control_plan.updated device.created device.updated device.deleted @@ -1439,6 +1448,8 @@ gree.frame log.created ``` +`control_plan.updated` uses `data.revision` plus `data.plan`. Revisions increase only when the materialized plan changes semantically; `generated_at` alone does not create a new revision. + 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 6876c7a..a3c6eea 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -2,9 +2,9 @@ "openapi": "3.1.0", "info": { "title": "GREE Controller API", - "version": "0.12.1", + "version": "0.13.0", "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.", + "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.", "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 and system diagnostics. Runtime settings are loaded from the functional `/api/settings/*` resources.", + "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.", "operationId": "bootstrap", "security": [ { @@ -2971,7 +2971,7 @@ "History" ], "summary": "Get resolved control plan", - "description": "Machine-readable desired-vs-actual thermostat plan with ownership, blocking reasons, schedules, next events and per-zone control decisions. This is the preferred endpoint for clients that need to explain why a zone is or is not running.", + "description": "Machine-readable desired-vs-actual thermostat plan with ownership, blocking reasons, schedules, next events and per-zone control decisions. The response shape is unchanged; the bundled Web UI receives plan changes through WebSocket and uses this endpoint as a fallback/resynchronization surface.", "operationId": "controlPlan", "security": [ { @@ -3511,7 +3511,7 @@ "Home Assistant" ], "summary": "Get control plan for Home Assistant", - "description": "This restricted Home Assistant surface always requires either a generated controller access token or the administrator app token. Generated tokens are intentionally not accepted by normal administrator endpoints. Returns the same machine-readable control plan as the administrator endpoint.", + "description": "This restricted Home Assistant surface always requires either a generated controller access token or the administrator app token. Generated tokens are intentionally not accepted by normal administrator endpoints. Returns the same latest materialized machine-readable control plan as the administrator endpoint.", "operationId": "haControlPlan", "security": [ { @@ -4799,6 +4799,43 @@ } } } + }, + "/api/integrations/home-assistant/snapshot": { + "get": { + "tags": [ + "Home Assistant" + ], + "summary": "Get Home Assistant integration snapshot", + "description": "Returns devices, derived groups, the latest materialized control plan and its revision in one restricted request. Existing per-resource Home Assistant endpoints remain available for compatibility.", + "operationId": "haSnapshot", + "security": [ + { + "BearerToken": [] + }, + { + "ApiTokenHeader": [] + } + ], + "responses": { + "200": { + "description": "Home Assistant integration snapshot", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } } }, "components": { @@ -4854,7 +4891,7 @@ }, "version": { "type": "string", - "example": "0.12.1" + "example": "0.13.0" }, "uptime_seconds": { "type": "integer", diff --git a/home-assistant/custom_components/gree_controller/api.py b/home-assistant/custom_components/gree_controller/api.py index 77e85e9..adbfa89 100644 --- a/home-assistant/custom_components/gree_controller/api.py +++ b/home-assistant/custom_components/gree_controller/api.py @@ -60,6 +60,19 @@ class GreeControllerClient: """Return the public controller health payload.""" return await self._request("GET", "/api/health") + async def snapshot(self) -> dict[str, Any]: + """Return devices, groups and the control plan in one restricted request.""" + data = await self._request("GET", "/api/integrations/home-assistant/snapshot") + if not isinstance(data, dict): + raise GreeControllerApiError("Controller returned an invalid snapshot payload") + if not isinstance(data.get("devices"), list): + raise GreeControllerApiError("Controller returned invalid snapshot devices") + if not isinstance(data.get("control_plan"), dict): + raise GreeControllerApiError("Controller returned an invalid snapshot control plan") + if not isinstance(data.get("groups"), list): + raise GreeControllerApiError("Controller returned invalid snapshot groups") + return data + async def devices(self) -> list[dict[str, Any]]: """Return all controller devices.""" data = await self._request("GET", "/api/integrations/home-assistant/devices") diff --git a/home-assistant/custom_components/gree_controller/coordinator.py b/home-assistant/custom_components/gree_controller/coordinator.py index 6066ff1..fc3634d 100644 --- a/home-assistant/custom_components/gree_controller/coordinator.py +++ b/home-assistant/custom_components/gree_controller/coordinator.py @@ -40,11 +40,10 @@ class GreeControllerCoordinator(DataUpdateCoordinator[dict[str, dict]]): async def _async_update_data(self) -> dict[str, dict]: try: - devices, plan, groups = await asyncio.gather( - self.client.devices(), - self.client.control_plan(), - self.client.groups(), - ) + snapshot = await self.client.snapshot() + devices = snapshot["devices"] + plan = snapshot["control_plan"] + groups = snapshot["groups"] except GreeControllerApiError as err: raise UpdateFailed(str(err)) from err self._overlay_pending_zone_controls(plan) diff --git a/home-assistant/custom_components/gree_controller/manifest.json b/home-assistant/custom_components/gree_controller/manifest.json index 2b01438..83deb7c 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.12.1", + "version": "0.13.0", "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 23b37d5..3d0073c 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.12.1. +"""Developer smoke/integration tests for GREE Controller API 0.13.0. 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.12.1" +DEFAULT_EXPECTED_VERSION = "0.13.0" SETTINGS_PATHS: dict[str, set[str]] = { "/api/settings/application": {"simulator_enabled"}, @@ -113,6 +113,7 @@ HA_SAFE_GET_PATHS = [ "/api/integrations/home-assistant/devices", "/api/integrations/home-assistant/control-plan", "/api/integrations/home-assistant/groups", + "/api/integrations/home-assistant/snapshot", ] DETAIL_COLLECTIONS = [ @@ -127,6 +128,7 @@ DETAIL_COLLECTIONS = [ EXPECTED_OPENAPI_PATHS = set(SETTINGS_PATHS) | { "/api/configuration/export", "/api/configuration/import", + "/api/integrations/home-assistant/snapshot", } @@ -381,6 +383,20 @@ def test_ha_safe_get(client: ApiClient, path: str) -> str: return "restricted HA auth enforced (no token supplied)" assert_status(resp, 200) data = resp.json() + if path == "/api/integrations/home-assistant/snapshot": + if not isinstance(data, dict): + raise TestFailure(f"snapshot is not an object: {type(data).__name__}") + required = {"devices", "control_plan", "control_plan_revision", "groups"} + missing = sorted(required - set(data)) + if missing: + raise TestFailure(f"snapshot missing keys: {missing}") + if not isinstance(data["devices"], list) or not isinstance(data["groups"], list): + raise TestFailure("snapshot devices/groups must be arrays") + if not isinstance(data["control_plan"], dict): + raise TestFailure("snapshot control_plan must be an object") + if not isinstance(data["control_plan_revision"], int): + raise TestFailure("snapshot control_plan_revision must be an integer") + return f"devices={len(data['devices'])}, groups={len(data['groups'])}, revision={data['control_plan_revision']}" if not isinstance(data, (list, dict)): raise TestFailure(f"unexpected JSON type {type(data).__name__}") if isinstance(data, list): @@ -503,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.12.1 contract", lambda: test_openapi(client, args.expected_version)) + runner.run("OpenAPI 0.13.0 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: @@ -571,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.12.1.", + description="Developer smoke/integration tests for GREE Controller API 0.13.0.", formatter_class=argparse.RawDescriptionHelpFormatter, epilog="""Examples: python3 scripts/api_dev_test.py diff --git a/src/api.rs b/src/api.rs index 9a47b00..2eccd86 100644 --- a/src/api.rs +++ b/src/api.rs @@ -204,6 +204,10 @@ pub fn router(state: AppState) -> Router { .route_layer(middleware::from_fn_with_state(state.clone(), auth)); let home_assistant_api = Router::new() + .route( + "/api/integrations/home-assistant/snapshot", + get(home_assistant_snapshot), + ) .route( "/api/integrations/home-assistant/devices", get(list_devices), diff --git a/src/api/groups.rs b/src/api/groups.rs index b3335c6..b1f9c5f 100644 --- a/src/api/groups.rs +++ b/src/api/groups.rs @@ -278,7 +278,7 @@ async fn list_home_assistant_groups( let groups = state.db.list_groups()?; let zones = state.db.list_zones()?; let devices = state.db.list_devices()?; - let plan = engine::build_control_plan(&state).await?; + let plan = engine::get_control_plan_snapshot(&state).await?; let settings = state.settings.read().await.clone(); let mut output = Vec::with_capacity(groups.len()); @@ -288,6 +288,7 @@ async fn list_home_assistant_groups( .filter(|zone| group.zone_ids.iter().any(|zone_id| zone_id == &zone.id)) .collect::>(); let planned_members = plan + .plan .zones .iter() .filter(|zone| { diff --git a/src/api/history.rs b/src/api/history.rs index 66a4e3b..f0922b1 100644 --- a/src/api/history.rs +++ b/src/api/history.rs @@ -485,7 +485,6 @@ async fn history( } async fn control_plan(State(state): State) -> Result, AppError> { - Ok(Json(serde_json::to_value( - engine::build_control_plan(&state).await?, - )?)) + let snapshot = engine::get_control_plan_snapshot(&state).await?; + Ok(Json(serde_json::to_value(snapshot.plan.as_ref())?)) } diff --git a/src/api/integrations.rs b/src/api/integrations.rs index 0091922..2d26d4b 100644 --- a/src/api/integrations.rs +++ b/src/api/integrations.rs @@ -1,3 +1,16 @@ +async fn home_assistant_snapshot( + State(state): State, +) -> Result, AppError> { + let control_plan = engine::get_control_plan_snapshot(&state).await?; + let groups = list_home_assistant_groups(State(state.clone())).await?.0; + Ok(Json(json!({ + "devices": state.db.list_devices()?, + "control_plan": control_plan.plan.as_ref(), + "control_plan_revision": control_plan.revision, + "groups": groups, + }))) +} + #[derive(Debug, Deserialize)] struct HaTestRequest { entity_id: Option, diff --git a/src/api/system.rs b/src/api/system.rs index 3b50504..a9fb43d 100644 --- a/src/api/system.rs +++ b/src/api/system.rs @@ -20,6 +20,7 @@ async fn build_bootstrap(state: &AppState) -> Result { 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 control_plan = engine::get_control_plan_snapshot(state).await?; Ok(json!({ "devices": devices, "zones": state.db.list_zones()?, @@ -30,6 +31,8 @@ async fn build_bootstrap(state: &AppState) -> Result { "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(), diff --git a/src/api/websocket.rs b/src/api/websocket.rs index 05a45a2..5729248 100644 --- a/src/api/websocket.rs +++ b/src/api/websocket.rs @@ -14,21 +14,60 @@ async fn websocket( Ok(ws.on_upgrade(move |socket| websocket_loop(state, socket))) } -async fn websocket_loop(state: AppState, mut socket: WebSocket) { - let initial = match build_bootstrap(&state).await { - Ok(data) => json!({"event":"bootstrap","timestamp":Utc::now(),"data":data}), - Err(err) => { - json!({"event":"error","timestamp":Utc::now(),"data":{"message":err.to_string()}}) +fn control_plan_ws_message(snapshot: &crate::state::ControlPlanSnapshot) -> Value { + json!({ + "event": "control_plan.updated", + "timestamp": Utc::now(), + "data": { + "revision": snapshot.revision, + "plan": snapshot.plan.as_ref(), } + }) +} + +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); + ( + json!({"event":"bootstrap","timestamp":Utc::now(),"data":data}), + revision, + ) + } + Err(err) => ( + json!({"event":"error","timestamp":Utc::now(),"data":{"message":err.to_string()}}), + None, + ), }; - if socket - .send(Message::Text(initial.to_string())) + socket + .send(Message::Text(message.to_string())) .await - .is_err() - { - return; - } + .map_err(|_| ())?; + Ok(revision) +} + +async fn websocket_loop(state: AppState, mut socket: WebSocket) { + // Subscribe before building the bootstrap so state changes during bootstrap generation are + // queued and can be applied immediately after the first frame. let mut receiver = state.events.subscribe(); + let mut control_plan = state.subscribe_control_plan(); + + let bootstrap_revision = match send_ws_bootstrap(&state, &mut socket).await { + Ok(revision) => revision, + Err(()) => return, + }; + let mut last_control_plan_revision = bootstrap_revision; + + // If the watch value is exactly the plan embedded in bootstrap, mark it seen to avoid a + // duplicate control_plan.updated frame. A newer revision remains pending and is sent below. + let current_revision = control_plan + .borrow() + .as_ref() + .map(|snapshot| snapshot.revision); + if current_revision == bootstrap_revision { + control_plan.borrow_and_update(); + } + loop { tokio::select! { event = receiver.recv() => { @@ -38,10 +77,31 @@ async fn websocket_loop(state: AppState, mut socket: WebSocket) { if socket.send(Message::Text(text)).await.is_err() { break; } } } - Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue, + Err(tokio::sync::broadcast::error::RecvError::Lagged(skipped)) => { + tracing::warn!(skipped, "websocket client lagged; sending full bootstrap resync"); + // Drop the retained stale backlog before taking the replacement snapshot. + // Events created while bootstrap is built are queued on this fresh receiver. + receiver = state.events.subscribe(); + match send_ws_bootstrap(&state, &mut socket).await { + Ok(revision) => last_control_plan_revision = revision, + Err(()) => break, + } + } Err(_) => break, } } + changed = control_plan.changed() => { + if changed.is_err() { break; } + let snapshot = control_plan.borrow_and_update().clone(); + if let Some(snapshot) = snapshot { + if last_control_plan_revision == Some(snapshot.revision) { + continue; + } + let text = control_plan_ws_message(snapshot.as_ref()).to_string(); + if socket.send(Message::Text(text)).await.is_err() { break; } + last_control_plan_revision = Some(snapshot.revision); + } + } message = socket.next() => { match message { Some(Ok(Message::Ping(value))) => { if socket.send(Message::Pong(value)).await.is_err() { break; } } diff --git a/src/engine.rs b/src/engine.rs index 3e719ca..99054ac 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -6,14 +6,14 @@ use crate::{ DeviceCommand, GroupControlPatch, HaReading, NightModeSettings, Reading, RuntimeSettings, Schedule, TemporaryQuickThermostat, Zone, ZoneControlPlan, ZoneReading, }, - state::{AppState, PendingControllerCommand}, + state::{AppState, ControlPlanSnapshot, PendingControllerCommand}, }; use anyhow::Result; use chrono::{DateTime, Datelike, Local, NaiveTime, Timelike, Utc, Weekday}; use serde_json::{json, Value}; use std::{ - collections::HashMap, - sync::atomic::Ordering, + collections::{HashMap, HashSet}, + sync::{atomic::Ordering, Arc}, time::{Duration, Instant}, }; use tokio::time::sleep; diff --git a/src/engine/control_plan.rs b/src/engine/control_plan.rs index 138b393..abdc728 100644 --- a/src/engine/control_plan.rs +++ b/src/engine/control_plan.rs @@ -1,3 +1,100 @@ +const CONTROL_PLAN_DEBOUNCE: Duration = Duration::from_millis(120); +const CONTROL_PLAN_PERIODIC_REFRESH: Duration = Duration::from_secs(10); + +pub async fn get_control_plan_snapshot( + state: &AppState, +) -> Result, AppError> { + refresh_control_plan_cache(state, false).await +} + +async fn refresh_control_plan_cache( + state: &AppState, + force: bool, +) -> Result, AppError> { + let _guard = state.control_plan_build_lock.lock().await; + if !force { + if let Some(snapshot) = state.control_plan_snapshot() { + if !state.control_plan_dirty.load(Ordering::Acquire) { + return Ok(snapshot); + } + } + } + + // Clear before calculating. Any relevant mutation that happens during the build sets the + // flag again and schedules another pass, so an update cannot be lost. + state.control_plan_dirty.store(false, Ordering::Release); + let plan = match build_control_plan(state).await { + Ok(plan) => plan, + Err(err) => { + state.control_plan_dirty.store(true, Ordering::Release); + state.control_plan_wakeup.notify_one(); + return Err(err); + } + }; + + if let Some(previous) = state.control_plan_snapshot() { + if control_plans_semantically_equal(previous.plan.as_ref(), &plan)? { + // Keep generated_at fresh for HTTP/bootstrap consumers without creating a new + // semantic revision. WebSocket clients suppress same-revision watch updates. + let refreshed = Arc::new(ControlPlanSnapshot { + revision: previous.revision, + plan: Arc::new(plan), + }); + state.control_plan.send_replace(Some(refreshed.clone())); + return Ok(refreshed); + } + } + + let revision = state + .control_plan_revision + .fetch_add(1, Ordering::AcqRel) + .saturating_add(1); + let snapshot = Arc::new(ControlPlanSnapshot { + revision, + plan: Arc::new(plan), + }); + state.control_plan.send_replace(Some(snapshot.clone())); + Ok(snapshot) +} + +fn control_plans_semantically_equal( + left: &ControlPlan, + right: &ControlPlan, +) -> Result { + fn semantic_value(plan: &ControlPlan) -> Result { + let mut value = serde_json::to_value(plan)?; + if let Some(object) = value.as_object_mut() { + object.remove("generated_at"); + } + Ok(value) + } + Ok(semantic_value(left)? == semantic_value(right)?) +} + +async fn control_plan_cache_loop(state: AppState) { + let mut periodic = tokio::time::interval(CONTROL_PLAN_PERIODIC_REFRESH); + periodic.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + // Tokio intervals tick immediately once; consume that tick because startup is refreshed below. + periodic.tick().await; + + if let Err(err) = refresh_control_plan_cache(&state, false).await { + tracing::warn!(error=?err, "cannot build initial control plan snapshot"); + } + + loop { + let force = tokio::select! { + _ = state.control_plan_wakeup.notified() => { + sleep(CONTROL_PLAN_DEBOUNCE).await; + false + } + _ = periodic.tick() => true, + }; + if let Err(err) = refresh_control_plan_cache(&state, force).await { + tracing::warn!(error=?err, "cannot refresh control plan snapshot"); + } + } +} + pub async fn build_control_plan(state: &AppState) -> Result { let settings = state.settings.read().await.clone(); let schedules = state.db.list_schedules()?; @@ -333,11 +430,22 @@ fn next_schedule_events( if mode == "off" { return Vec::new(); } + let boundary_minutes = schedule_boundary_minutes(&zone.id, schedules); + if boundary_minutes.is_empty() { + return Vec::new(); + } let mut events = Vec::new(); let mut current = active_schedule_for_zone(zone, schedules, now).map(|item| item.id.as_str()); let base = minute_floor(now); for minute in 1..=(8 * 24 * 60) { let candidate = base + chrono::Duration::minutes(minute); + let minute_of_day = candidate.hour() * 60 + candidate.minute(); + let previous_local = base + chrono::Duration::minutes(minute - 1); + let clock_discontinuity = candidate.naive_local() - previous_local.naive_local() + != chrono::Duration::minutes(1); + if !boundary_minutes.contains(&minute_of_day) && !clock_discontinuity { + continue; + } let next = active_schedule_for_zone(zone, schedules, candidate); let next_id = next.map(|item| item.id.as_str()); if next_id == current { diff --git a/src/engine/runtime.rs b/src/engine/runtime.rs index db58144..f8d27e4 100644 --- a/src/engine/runtime.rs +++ b/src/engine/runtime.rs @@ -24,6 +24,11 @@ pub fn start(state: AppState) { if let Err(err) = reset_temporary_condition_observations_after_restart(&state) { tracing::warn!(error=?err, "cannot reset temporary thermostat observation continuity after restart"); } + + let control_plan_state = state.clone(); + tokio::spawn(async move { + control_plan_cache_loop(control_plan_state).await; + }); let poll_state = state.clone(); tokio::spawn(async move { sleep(Duration::from_millis(500)).await; diff --git a/src/engine/schedules.rs b/src/engine/schedules.rs index 5c85aaa..699e49e 100644 --- a/src/engine/schedules.rs +++ b/src/engine/schedules.rs @@ -17,6 +17,21 @@ fn minute_floor(now: DateTime) -> DateTime { .unwrap_or(now) } +fn schedule_boundary_minutes(zone_id: &str, schedules: &[Schedule]) -> HashSet { + let mut result = HashSet::new(); + for item in schedules + .iter() + .filter(|item| item.enabled && item.zone_id == zone_id) + { + for raw in [&item.start_time, &item.end_time] { + if let Ok(time) = NaiveTime::parse_from_str(raw, "%H:%M") { + result.insert(time.hour() * 60 + time.minute()); + } + } + } + result +} + pub fn next_schedule_boundary_utc( zone_id: &str, schedules: &[Schedule], @@ -27,10 +42,22 @@ pub fn next_schedule_boundary_utc( .filter(|item| item.enabled && item.zone_id == zone_id && schedule_active(item, now)) .max_by_key(|item| item.updated_at) .map(|item| item.id.as_str()); + let boundary_minutes = schedule_boundary_minutes(zone_id, schedules); + if boundary_minutes.is_empty() { + return None; + } let base = minute_floor(now); - // Eight days cover a complete weekly schedule plus the next transition. + // State normally changes on a configured start/end minute. Also evaluate local-clock + // discontinuities so DST jumps/repeats preserve the original absolute-minute behavior. for minute in 1..=(8 * 24 * 60) { let candidate = base + chrono::Duration::minutes(minute); + let minute_of_day = candidate.hour() * 60 + candidate.minute(); + let previous_local = base + chrono::Duration::minutes(minute - 1); + let clock_discontinuity = candidate.naive_local() - previous_local.naive_local() + != chrono::Duration::minutes(1); + if !boundary_minutes.contains(&minute_of_day) && !clock_discontinuity { + continue; + } let next = schedules .iter() .filter(|item| { diff --git a/src/engine/tests.rs b/src/engine/tests.rs index 6ba5e0e..66c3f0b 100644 --- a/src/engine/tests.rs +++ b/src/engine/tests.rs @@ -11,6 +11,31 @@ mod tests { assert_eq!(effective_sensor_stale_after_seconds(120_000, 600), 86_400); } + #[test] + fn control_plan_semantic_equality_ignores_only_generation_timestamp() { + let generated_at = Utc.with_ymd_and_hms(2026, 9, 4, 8, 0, 0).unwrap(); + let left = ControlPlan { + generated_at: generated_at.clone(), + house_mode: "cool".into(), + house_preset: Some("comfort".into()), + house_power: true, + outdoor_temperature: Some(24.0), + control_strategy: "thermostat".into(), + night_mode_active: false, + night_mode_start: "22:00".into(), + night_mode_end: "06:00".into(), + night_mode_max_fan_speed: 2, + next_events: Vec::new(), + zones: Vec::new(), + rules: Vec::new(), + }; + let mut right = left.clone(); + right.generated_at = generated_at + chrono::Duration::seconds(30); + assert!(control_plans_semantically_equal(&left, &right).unwrap()); + right.house_mode = "heat".into(); + assert!(!control_plans_semantically_equal(&left, &right).unwrap()); + } + #[test] fn overnight_schedule_works() { let now = Utc.with_ymd_and_hms(2025, 1, 7, 1, 0, 0).unwrap().with_timezone(&Local); // Tuesday diff --git a/src/main.rs b/src/main.rs index d95c47e..837f08e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -18,13 +18,16 @@ use models::Device; use protocol::GreeClient; use state::AppState; use std::{ - sync::{atomic::AtomicBool, Arc}, + sync::{ + atomic::{AtomicBool, AtomicU64}, + Arc, + }, time::{Duration, Instant}, }; use tokio::{ net::TcpListener, signal, - sync::{broadcast, Notify, RwLock}, + sync::{broadcast, watch, Notify, RwLock}, }; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; @@ -56,6 +59,7 @@ async fn main() -> Result<()> { } let (events, _) = broadcast::channel(512); + let (control_plan, _) = watch::channel(None); let debug_gree_frames = Arc::new(AtomicBool::new(runtime_settings.debug.gree_frames)); let http = reqwest::Client::builder() .timeout(Duration::from_secs(10)) @@ -78,6 +82,11 @@ async fn main() -> Result<()> { debug_gree_frames, initial_device_sync_complete: Arc::new(AtomicBool::new(false)), zone_control_wakeup: Arc::new(Notify::new()), + control_plan, + control_plan_wakeup: Arc::new(Notify::new()), + control_plan_dirty: Arc::new(AtomicBool::new(true)), + control_plan_revision: Arc::new(AtomicU64::new(0)), + control_plan_build_lock: Arc::new(tokio::sync::Mutex::new(())), device_operation_locks: Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())), zone_operation_locks: Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())), group_operation_locks: Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())), diff --git a/src/state.rs b/src/state.rs index 6de300b..706e891 100644 --- a/src/state.rs +++ b/src/state.rs @@ -1,17 +1,20 @@ use crate::{ config::Config, db::Db, - models::{ApiEvent, DeviceCommand, RuntimeSettings}, + models::{ApiEvent, ControlPlan, DeviceCommand, RuntimeSettings}, protocol::GreeClient, }; use chrono::Utc; use serde_json::Value; use std::{ collections::HashMap, - sync::{atomic::AtomicBool, Arc}, + sync::{ + atomic::{AtomicBool, AtomicU64, Ordering}, + Arc, + }, time::Instant, }; -use tokio::sync::{broadcast, Mutex, Notify, OwnedMutexGuard, RwLock}; +use tokio::sync::{broadcast, watch, Mutex, Notify, OwnedMutexGuard, RwLock}; #[derive(Debug, Clone)] pub(crate) struct PendingControllerCommand { @@ -24,6 +27,12 @@ pub(crate) struct PendingControllerCommand { pub expires_at: Instant, } +#[derive(Debug, Clone)] +pub struct ControlPlanSnapshot { + pub revision: u64, + pub plan: Arc, +} + #[derive(Clone)] pub struct AppState { pub db: Db, @@ -39,6 +48,14 @@ pub struct AppState { pub initial_device_sync_complete: Arc, /// Explicit thermostat changes wake the regulator instead of waiting for the next fixed interval. pub zone_control_wakeup: Arc, + /// Latest materialized control plan. WebSocket clients subscribe to this state stream, + /// while HTTP uses the same snapshot as a compatibility/fallback surface. + pub control_plan: watch::Sender>>, + /// Coalesces changes that can affect the derived control plan. + pub control_plan_wakeup: Arc, + pub control_plan_dirty: Arc, + pub(crate) control_plan_revision: Arc, + pub(crate) control_plan_build_lock: Arc>, pub(crate) device_operation_locks: Arc>>>>, pub(crate) zone_operation_locks: Arc>>>>, pub(crate) group_operation_locks: Arc>>>>, @@ -112,15 +129,36 @@ impl AppState { self.zone_control_wakeup.notify_one(); } + pub fn control_plan_snapshot(&self) -> Option> { + self.control_plan.borrow().clone() + } + + pub fn subscribe_control_plan(&self) -> watch::Receiver>> { + self.control_plan.subscribe() + } + + pub fn invalidate_control_plan(&self) { + self.control_plan_dirty.store(true, Ordering::Release); + self.control_plan_wakeup.notify_one(); + } + pub fn broadcast(&self, event: impl Into, data: Value) { + let event = event.into(); + let invalidates_control_plan = control_plan_event_affects_plan(&event); let _ = self.events.send(ApiEvent { - event: event.into(), + event, timestamp: Utc::now(), data, }); + if invalidates_control_plan { + self.invalidate_control_plan(); + } } pub fn log(&self, level: &str, kind: &str, message: &str, metadata: Value) { + if control_plan_event_affects_plan(kind) { + self.invalidate_control_plan(); + } if let Err(err) = self.db.log_event(level, kind, message, &metadata) { tracing::warn!(error=?err, "cannot persist event log"); } @@ -144,3 +182,44 @@ impl AppState { } } } + +fn control_plan_event_affects_plan(event: &str) -> bool { + event.starts_with("device.") + || event == "devices.discovered" + || event.starts_with("zone.") + || event.starts_with("group.") + || event.starts_with("schedule.") + || event.starts_with("automation.") + || event.starts_with("flow.") + || event.starts_with("settings.") + || matches!( + event, + "house.mode_changed" | "outdoor.updated" | "configuration.imported" + ) +} + +#[cfg(test)] +mod tests { + use super::control_plan_event_affects_plan; + + #[test] + fn control_plan_invalidation_tracks_state_events_but_not_diagnostics() { + for event in [ + "device.updated", + "zone.updated", + "group.updated", + "schedule.updated", + "automation.updated", + "flow.updated", + "settings.night.updated", + "house.mode_changed", + "outdoor.updated", + "configuration.imported", + ] { + assert!(control_plan_event_affects_plan(event), "{event}"); + } + for event in ["log.created", "api.request", "gree.frame"] { + assert!(!control_plan_event_affects_plan(event), "{event}"); + } + } +} diff --git a/web/js/bootstrap.js b/web/js/bootstrap.js index 3801b30..5f36bd7 100644 --- a/web/js/bootstrap.js +++ b/web/js/bootstrap.js @@ -16,8 +16,10 @@ async function loadBootstrap(showMessage = false) { 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; renderAll(); - loadControlPlan(); + if (!data.control_plan) scheduleControlPlanLoad(0); if (app.settings?.debug?.overlay_enabled) loadDebugBacklog(); if (showMessage) toast(tr('common.updated')); if ($('#tokenDialog').open) $('#tokenDialog').close(); diff --git a/web/js/core.js b/web/js/core.js index 5d8b1c8..fdea205 100644 --- a/web/js/core.js +++ b/web/js/core.js @@ -32,7 +32,7 @@ const app = { historyTab: 'overview', historyData: { zones: [], devices: [], sensors: [] }, historyCounts: {}, historyZone: 'all', historyDevice: 'all', historySensor: 'all', historyLoading: false, customChartSeries: [], savedCharts: [], chartZooms: {}, chartHiddenSeries: {}, zoneControlSeq: {}, groupControlSeq: {}, zoneControlQueue: {}, groupControlQueue: {}, deviceControlQueue: {}, houseControlQueue: null, climateControlQueue: null, groupCustomDrafts: {}, zoneTemperatureTimers: {}, deviceTemperatureDrafts: {}, - controlPlan: null, controlPlanTimer: null, debugLines: [], debugBacklogLoaded: false, debugFilter: 'all', sensorAliases: {}, flowSharedInputs: [], flowSharedInputValueCache: {}, flowSharedInputValueRequests: {}, flowSharedInputValueTimer: null, + controlPlan: null, controlPlanRevision: null, controlPlanPushReady: false, controlPlanTimer: null, debugLines: [], debugBacklogLoaded: false, debugFilter: 'all', sensorAliases: {}, flowSharedInputs: [], flowSharedInputValueCache: {}, flowSharedInputValueRequests: {}, flowSharedInputValueTimer: null, simulationScope: 'units', simulationTarget: 'all', standaloneSimulation: false, dashboardTab: 'main', settingsTab: 'app', systemSnapshotAt: Date.now(), pingMonitor: { targetId: '', all: false, running: false, timer: null, inFlight: false, samples: {} }, flowDraft: null, flowSelectedNodeId: null, flowSelectedNodeIds: [], flowConnectFrom: null, flowDirty: false, flowZoom: 1, diff --git a/web/js/dashboard.js b/web/js/dashboard.js index 538c919..e0dd0f7 100644 --- a/web/js/dashboard.js +++ b/web/js/dashboard.js @@ -133,9 +133,38 @@ function renderControlPlan() { host.innerHTML = content; if (section) section.hidden = !content; } +function applyControlPlan(plan, revision = null) { + if (!plan || typeof plan !== 'object') return false; + const hasRevision = revision !== null && revision !== undefined && revision !== ''; + const hasCurrentRevision = app.controlPlanRevision !== null && app.controlPlanRevision !== undefined && app.controlPlanRevision !== ''; + const nextRevision = hasRevision ? Number(revision) : NaN; + const currentRevision = hasCurrentRevision ? Number(app.controlPlanRevision) : NaN; + if (Number.isFinite(nextRevision) && Number.isFinite(currentRevision) && nextRevision < currentRevision) return false; + app.controlPlan = plan; + if (Number.isFinite(nextRevision)) app.controlPlanRevision = nextRevision; + renderControlPlan(); + renderSimulationPage(); + return true; +} + +function controlPlanWebSocketReady() { + return !!app.ws && app.ws.readyState === WebSocket.OPEN && app.controlPlanPushReady; +} + async function loadControlPlan() { - try { app.controlPlan = await api('/api/control-plan'); renderControlPlan(); renderSimulationPage(); } + try { + const plan = await api('/api/control-plan'); + // A fallback request may have started while disconnected and finish after WS resync. + // Never let that older unversioned HTTP response overwrite a revisioned pushed plan. + if (!controlPlanWebSocketReady()) applyControlPlan(plan); + } catch (error) { console.warn('Unable to load control plan:', error); } + finally { + if (!controlPlanWebSocketReady()) { + clearTimeout(app.controlPlanTimer); + app.controlPlanTimer = setTimeout(loadControlPlan, 10000); + } + } } function simulationTime(value, options = {}) { @@ -409,8 +438,14 @@ function renderSimulationPage() { rulesHost.innerHTML = activeRules.length ? activeRules.map(rule => `
${esc(tr('common.automation'))}

${esc(rule.name)}

${esc(automationTriggerLabel(rule))}

${esc(tr('simulation.ruleOnDevice', { device: rule.action_device_name || tr('common.noDevice') }))}

${esc(simulationRuleAction(rule))}
${esc(tr('automations.last'))}: ${esc(dateTime(rule.last_fired_at))}${esc(tr('simulation.nextReady'))}: ${esc(dateTime(rule.next_ready_at))}
`).join('') : `
${esc(tr('simulation.noRules'))}
`; } -function scheduleControlPlanLoad() { +function scheduleControlPlanLoad(delay = 180) { + if (controlPlanWebSocketReady()) return; clearTimeout(app.controlPlanTimer); - app.controlPlanTimer = setTimeout(loadControlPlan, 180); + app.controlPlanTimer = setTimeout(loadControlPlan, delay); +} + +function stopControlPlanFallback() { + clearTimeout(app.controlPlanTimer); + app.controlPlanTimer = null; } diff --git a/web/js/realtime.js b/web/js/realtime.js index 8e32cf3..110d973 100644 --- a/web/js/realtime.js +++ b/web/js/realtime.js @@ -15,10 +15,20 @@ async function handleWebSocketMessage(event) { 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; renderAll(); scheduleControlPlanLoad(); if (app.settings?.debug?.overlay_enabled) loadDebugBacklog(); return; + 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 || {}; - if (['device.updated', 'device.created'].includes(message.event)) { updateDevice(data); renderSummary(); renderDevices(); renderGroups(); scheduleControlPlanLoad(); } + 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.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(); } @@ -63,7 +73,7 @@ function connectWebSocket() { const query = app.token ? `?token=${encodeURIComponent(app.token)}` : ''; const ws = new WebSocket(`${protocol}//${location.host}${withBase('/ws')}${query}`); app.ws = ws; ws.onopen = () => updateConnectionIndicator('connected'); - ws.onclose = () => { updateConnectionIndicator('disconnected'); app.wsTimer = setTimeout(connectWebSocket, 3000); }; + ws.onclose = () => { app.controlPlanPushReady = false; updateConnectionIndicator('disconnected'); scheduleControlPlanLoad(0); app.wsTimer = setTimeout(connectWebSocket, 3000); }; ws.onerror = () => updateConnectionIndicator('connectionError'); let messageQueue = Promise.resolve(); ws.onmessage = event => {