diff --git a/Cargo.lock b/Cargo.lock index d5ed69e..0e63068 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -719,7 +719,7 @@ dependencies = [ [[package]] name = "gree-controller" -version = "0.15.8" +version = "0.15.9" dependencies = [ "aes", "aes-gcm", diff --git a/Cargo.toml b/Cargo.toml index a45e964..a61218c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "gree-controller" -version = "0.15.8" +version = "0.15.9" 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 3092a84..969a886 100644 --- a/FILE_MANIFEST.sha256 +++ b/FILE_MANIFEST.sha256 @@ -1,14 +1,14 @@ de0793949cf01d27d903653226ecc9b5f72523d8711f20bcab022cf42c4c4ed0 ./.env.example a4ec3874a2e3ab1bad28fb40bb620f7b01f64d01ad9b699306bf70ada31227db ./.gitignore -ddda584a029e67d057e3afbe93246795e6579d7efd2f3e9ebe4cfd2bb164e907 ./Cargo.lock -d09e3c8438adcfac6aa9d16de9aade898a1fa6f64f7edf17bcd70afb714db12f ./Cargo.toml +0c5cd71516b15e7b51a39fd694d8532a38485d8a8d358caab8ae850274a41127 ./Cargo.lock +e3b77008eebcb6db5b188102f788cd06f5159c1a66577bb212d96d125aa72510 ./Cargo.toml 19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE -5b5586d2e3e7b80c0ffe2d241517f8f7a8c4d7e80169a6cb3cf46221024f54e9 ./README.md -54e2716acb43f0813c6b979df802b8a0f7e3f8f03b1e42b45c746945ed21d098 ./SOURCE_MANIFEST.sha256 +d5e5fdafaf8d777e3848b5c8f3aacfd0cf4c80735fbbb6bda14e86468329de88 ./README.md +f4806d66acea0baaf5b1457afb708771b2765f7c08b4c4824c5ff1eace42c83d ./SOURCE_MANIFEST.sha256 29e9821e5ed216ea3565dd6d8bc0b174ed3cf9fa72c4ccebbfa99e98db3ad8f9 ./build.rs -70a8217b46a12ae47e6b461f6f619fa19facb0b422fd8d850208b55237abc752 ./docs/API.md -c6b1aded8ef6f4c840a2b1f2ffa3f0b22eddc080a4750adb34ee950f12f64382 ./docs/FLOW.md -9debc0d7d3f663c2a46ab17fd385a37aa732757e6bb7c75d6d60281ea89092b5 ./docs/openapi.json +96144a5c5f3688854ab19dcd97794bd74da8ed26133c1468d91f6659a54faa4b ./docs/API.md +0884be80021ff93826525f0b5bd5c81418f0c9c4eadec9583386ec61ae8e82ba ./docs/FLOW.md +d883d4a58b19b10b5b33dacd4a58b0a4eb69dcc133a478524407027956ed9f70 ./docs/openapi.json 77b8becf7a2c16a384ea85721d878b76e2d520549cb4455d9aa806e57e5a2ecd ./ha-addon/.env.example 0ca6e58170c2b8925194431ebbdb55f0c8b377fec5cd422cd2bd257e2d9328e3 ./ha-addon/Dockerfile 0951696a2c2e66d4d969f8e7f8641184e3012f261583b31074bc38a7a1c0cc1a ./ha-addon/README.md @@ -22,7 +22,7 @@ b7f0873109c52be9d7f09bea3dffc416103c50085e1f0680d11661a969479898 ./ha-addon/hom 6509286c7b7de638dcc5d40ce53181e65d1eddc5dc36a13306dc6d87220fedbb ./ha-addon/home-assistant/custom_components/gree_controller/coordinator.py 5a96fe8f5c035c34f1339370270cd078056202d09e236dec75735be11de92a7d ./ha-addon/home-assistant/custom_components/gree_controller/entity_map.py c4fb75c246db651087900ebfc2291ff41ac87652cd6194fc0b776b0005c1cbcf ./ha-addon/home-assistant/custom_components/gree_controller/icon.png -00fc79f5195f43cd8d076b3d9a7a782263f0bde2a6b58b310c2d7a8b307a9f88 ./ha-addon/home-assistant/custom_components/gree_controller/manifest.json +62e7bb884b342349bb175ce874d11ccb845e8219512ff6cf4d6c9ed03cb12294 ./ha-addon/home-assistant/custom_components/gree_controller/manifest.json c52a484b671ce738ecc00228a1db19a5d703f69ab27530c2896a5c2ae3b4a96f ./ha-addon/home-assistant/custom_components/gree_controller/number.py 39c4309001b75abb56234f05662bc06e077054986876f1927937edbce528ec95 ./ha-addon/home-assistant/custom_components/gree_controller/select.py cca65482e36d48035aca178121a378fe7d578d600acff267ae81a6399c0da653 ./ha-addon/home-assistant/custom_components/gree_controller/sensor.py @@ -30,10 +30,10 @@ cca65482e36d48035aca178121a378fe7d578d600acff267ae81a6399c0da653 ./ha-addon/hom a745cc458c235158d5d70b6a33d8000fd8b40ff93bfe2f2796af0f4b3a9eef9b ./ha-addon/home-assistant/custom_components/gree_controller/translations/en.json 50b7d6fe6329ad4ea6bee9612ab141c2f73f8382fcc2af0bb076a8e8d4dd4465 ./ha-addon/home-assistant/custom_components/gree_controller/translations/pl.json c5fc5c87273d82d2834a0a5a365c821a416413ed509613441e24e4dc3507c4d2 ./ha-addon/home-assistant/generated/gree_controller_entities.example.json -2976822c9663e505d22297e2749192b4ca119bb4e46ccc1c29c813522c84b3c5 ./ha-addon/repository/gree-controller/CHANGELOG.md +9cb2e827567e8cb06050e24fd3811dda6f51350d8bec9b605189fa8ab85b331a ./ha-addon/repository/gree-controller/CHANGELOG.md 50b8ba050c6b4919e4d057f7b38c1e23d3913b5b7c87552626452e9695d37251 ./ha-addon/repository/gree-controller/DOCS.md -5f0bd0d1ea4aca47844f9e870e4ba1676ad8b90369577db75a1bb0d00228a9da ./ha-addon/repository/gree-controller/README.md -f43723e7e356c7cc637b36b5ade0e34cfb69c50c4cf653cca7fc013e926cd7c9 ./ha-addon/repository/gree-controller/config.yaml +0bd253135a67d295dfca355b735b350dbf36ed396a6c8e30387d5218d4e5afcf ./ha-addon/repository/gree-controller/README.md +cb348473b59a76dfd29adc04d281128a9b8848b92107b928073aac4e047fdc4b ./ha-addon/repository/gree-controller/config.yaml 3aae6cdae4c3aaab7786a9575e7093b7e2c5b69288167075f4b7475611691a6b ./ha-addon/repository/gree-controller/topologia-ha-vlan-gree-pl.png 0d0b42a7639b128946eec379175436fa1f150294f39d2dfd169077bfa87fd14d ./ha-addon/repository/gree-controller/topology-ha-vlan-gree-en.png 2272740b185e9520e948f74b8f45cf98b8b5fd28c93bdee760da626f91fe5c2a ./ha-addon/repository/gree-controller/translations/en.yaml @@ -43,8 +43,8 @@ cea12bc74c066a588a5ef0ef7bad9d4906bdbcea99f2d275429b0eefc94b4b5f ./ha-addon/run 081e27b6e43070d44f865335c30a42a5a29668eaa6a5ea5ab6967ac4447b8e06 ./ha-addon/sync-repository.sh f9e31de3d109ead32bf531c074d46a087df398d62488caee0b46b5e0a1c027f7 ./ha_addon.md 253a0bc912786e67ea7fc92a64e4a510ad973bec343a88ccfb1f28fca3e8cf01 ./lang/README.md -37d24f8de343b4796a0a8cfff3a839e55bdb779add0c8119799787d87ad8b953 ./lang/en.json -1610da0b2bbb9424006647adfb95e1318b7670aaed5a2798db1251db82635317 ./lang/pl.json +e9f8da620e017a3c06363f9b3d8709d9abaee9bbac4e53e60f27db7c072bb647 ./lang/en.json +efc35994018e526ed829ee26b3cb1c74df2ac3655433e3b9bd0bf12f087e3223 ./lang/pl.json d8459024f04ca514bd8e9d6bd3af872fb942fd85a7cdd5583f4a9d28aab6faba ./make_zip.py b14233a8987e53bbbdd6770386ba10fa166ec25c1e098275c173c544b37846fc ./presets/bedroom_window_night.json 1960841119c0b673fb2f03621b32237552fd828045304cd221401214b997c910 ./presets/device_resilience.json @@ -85,7 +85,7 @@ fdcd9a5055d08037278b842e7ab69265345c5811f0a06867136c511d140bb191 ./presets/wind 804f22123cd3e8db0fac791826c8dd9f758fb866c8e3b5655fb6d25d259dccf1 ./presets/workday_comfort.json fd7390d64a2378352c09382accf6efff43903d952d111b22a982f06c5b2a3b4b ./regenerate-sha.sh c64b1c6deeb24f20af662bfce7fa3683703914987241107ce664fa2f6d1a9dcb ./scripts/README.md -3a3287a4ca516f91a1b1bf14257aeef7b1b3027d592200c0a6da1cc70afa9c32 ./scripts/api_dev_test.py +863f087740abee259d51dc5935c6b60dfe25b31a6a9fe64657d25a05d200a6fa ./scripts/api_dev_test.py 5bc736c7bc76ca80aaa406bb171d2aa91baf4c3aa8695dce0e09b888b6ab3146 ./scripts/common.sh 6403786610ee6d2f628193c25aee0dd058d62e904aa1a31d5f62fdaae0e94b4f ./scripts/configure-gree-network.sh 1d6e14e26e49aa9d3527f30a23668bf8d9c48b67e6628ef686c3155c012155de ./scripts/dev.sh @@ -136,7 +136,7 @@ d6b9b70a04c774173c97f7426487d5e2932d7713e12ae3fb7f64cf2b4e0b3c10 ./src/db/sched 28ee42d08147b0f154af818065d7d014d74ee8ddd59fdef98115f5c5368056cc ./src/db/tests.rs f90fbb14f29a20b799b3856bdaa22812a9f0d9dfa46693f1341c160dccda8a97 ./src/db/zone_history.rs 0e49ea4a52017b0a8501d5101b1906a7a1e8d5158acb0bb65caa43cde2836ec0 ./src/engine.rs -76dbf72600a48a6ba5b2273777bd8f2c8a6bd212aa236d419cc59eedcb8ab22b ./src/engine/automations.rs +a0d60ad81d024488dfec586b0d9e9f447e9a3485985604415465cacba59d645f ./src/engine/automations.rs 1dccb8779f87dfdbd4ccda3f02ec51273e7be2e34e85166861668685528eca79 ./src/engine/commands.rs 357ef92c3cc42b243af4da31ac52bb360d7444ae423f58d7a13bdee79bf0a748 ./src/engine/connectivity.rs ad53cd7be24deb759e03cbc6472522b69ebd36d8fac35398a9b19c899d3b17f2 ./src/engine/control_plan.rs @@ -152,7 +152,7 @@ c73691c3637465ab0f79aecf7f5428d0317799a8ab5329ed564cf57963bbe86e ./src/engine/r b0189f28d0f337dfd3ce0934da7a565e4a5535afc1c242214c26ed584b3b7840 ./src/engine/targets.rs 682c8b3956c657b655746cbd19ee97ee2f4cf953bc9de67d6d70a1b14f3dd746 ./src/engine/temperature.rs 833b72907375e75c3f42b3921ad70a8e298f5a93743bf0d0c19059aa272462d1 ./src/engine/temporary_thermostat.rs -af4edaf12faf294f44a8992d120a99b09edd1cbdd66981390e027d13fd6b6790 ./src/engine/tests.rs +0404e040c5d6f388682edb3d103a658e0b963a402e4e847fc92fd0aea1c0e773 ./src/engine/tests.rs c89ff36c6a67bf4fbf68c43e18aa0a556de2dc69263fd79db376363afc741a4f ./src/engine/zone_actions.rs fb169bcc20f95abe9892367df6dd3a683584bdbc721792d6ffb9311b81ca30d9 ./src/engine/zone_control.rs bfac95cec87f0df6ce562153cd3cfe6731359d703e6c592d2c2f7ba01ea62d99 ./src/error.rs @@ -163,7 +163,7 @@ da4068295f37c23222bb13bebac022d88ba7729604a49ad25438c55e45458a49 ./src/influxdb 67e42a92295480d2f1d364958f184654cfb5d4b77e7e1df47f588bdec18868a0 ./src/influxdb/write.rs 62f9da6ef4f5fd92701d0857773f9f6236ef25356f21b9b42236abab6ba7da53 ./src/main.rs 23688a5cb68efded89471ce959328d27adff370d451bd9f378a283ab51437e76 ./src/models.rs -920e028d96a14cca9f74a964bb296e68a109cfead314d3164c180201e606d151 ./src/models/automation.rs +e7b6e49d369aaf665a8aea2464950fd795957de0ad7528a002e11c55c7983f35 ./src/models/automation.rs fc5f2802a073a175a8985e56bcb87eb1ff481226d5b4eeabad73dbba1839fc45 ./src/models/control_plan.rs 06357b352ff6494d83e72796b869573a8b8f36b835985a5900ac571c922b8da9 ./src/models/defaults.rs 698dcbeba72037a251c25d07c95833f121716d949f9217f1f9429e8cd40638e5 ./src/models/device.rs @@ -199,10 +199,10 @@ f13f1be3d5789539eba3fc1b59c14835f8c681eca7634e118fa3763a53aeee2b ./src/queries/ 8c34b70fb5f80e3249c2e0c63686e60ce1697cf44a7e6f11b3ad6f689ce17792 ./src/state.rs b92a6cb158b494fe145b43c7641e65f6fafff47201d7d76edbec2cfd8b94835c ./systemd/gree-controller.service 544a31cb2e5374227026afd6abd0d910381f3fca1638154ebd74e3c3d96a35d1 ./web/404.html -1043dbcfb24a474ce088acb6aa86479e84db74688fb4802febeb3f3f564ca65f ./web/css/styles.css +c60667f270dc66ee463f425ca5eda676363fcd2390e5d652220f6b4af9ded6c3 ./web/css/styles.css 183d122ba366303d9952ac0e68aff4bced4e0d9a468fbe51316c13f15227402b ./web/custom-chart.html e98bdd7204349cce1ec6f57283509697af0bbc72280622a6c3efa6fed242db4f ./web/favicon.svg -cc1509fd76f0b0bd4c069db63e1e104210c2bed05f32a3eebac7642f94ef8a99 ./web/index.html +ca887343ec089cbbb83c4ce5685edbade1266c89a29b5570c02f75c9c50b1c10 ./web/index.html 9ba3f6fa05b72aa989139b2a909982571b2a02055052e4c40104f1e81a9aa7eb ./web/js-dynamic/README.md e74bb06dba646f298a4d118b5c2dca6ba24f1179ce8ea8d53c1acd6000ca332c ./web/js-dynamic/bootstrap.js 5d57253a013fd1cb79e4b8701d4be6e394e3f2acbce73dfcd726a86e5878f1a1 ./web/js-dynamic/charts.js @@ -210,7 +210,7 @@ e74bb06dba646f298a4d118b5c2dca6ba24f1179ce8ea8d53c1acd6000ca332c ./web/js-dynam 752b3c3b59558b42d867d4a15329238f0213f354b76f7011c10e31b4e6dbb311 ./web/js-dynamic/dashboard.js 6b166a9311e30fe948bd3236189436eea769c238f963ba00acec5cdb51ff1dd7 ./web/js-dynamic/entities.js b4204241eecf387256364f4230b23ed50d947a362f4ceb1e528d69494e93d7cf ./web/js-dynamic/events.js -879a92e95575ace4775b2f9a7f189e78ad0082ca5c12d012ef456e6477010886 ./web/js-dynamic/flows.js +e0f604f54eafb9a6d6ac1c6c24eb27f3e3c1e31ed80ead57ac0340c7228318b8 ./web/js-dynamic/flows.js 3ae714de6b71c068ba665c81a7689f994b1716284e8d937db669ad500f12cf63 ./web/js-dynamic/forms.js 92d013bb5d64f64aa8c55380704540a5f72732bde7ccd16554965e401daf50ea ./web/js-dynamic/history.js 511c4e7cc4c48fbbdab64558a9d1c27e6759c9b6dca255d11f301b9708e955c7 ./web/js-dynamic/main.js diff --git a/README.md b/README.md index 609f1c3..079d6fd 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,9 @@ Self-hosted controller for GREE-compatible air conditioners with a local Web UI, thermostat zones, schedules, Home Assistant integration, history, notifications and a documented HTTP/WebSocket API. -**Version: 0.15.8** +**Version: 0.15.9** -Version 0.15.8 improves Visual Flow portability and editor ergonomics: exported Flows carry the Shared Input definitions they use, imports can create or safely remap missing Shared Inputs, and the Interpretation / Flow cycle panel is collapsed by default on every viewport. +Version 0.15.9 strengthens Visual Flow execution and editing: change events remain pending until their action succeeds, rolling mean/median blocks wait for a complete configured window, thermostat action summaries show power/mode explicitly, and the canvas adds box selection plus copy/paste/duplicate for groups of blocks. > [Full API reference](docs/API.md) — authentication, every endpoint, request bodies, response models, WebSocket events and examples. > Built-in API: `/api/*` and `/ws`; interactive Swagger docs: `/api-docs`; OpenAPI JSON: `/api-docs/openapi.json`. diff --git a/SOURCE_MANIFEST.sha256 b/SOURCE_MANIFEST.sha256 index a7bd486..335c7ef 100644 --- a/SOURCE_MANIFEST.sha256 +++ b/SOURCE_MANIFEST.sha256 @@ -1,13 +1,13 @@ de0793949cf01d27d903653226ecc9b5f72523d8711f20bcab022cf42c4c4ed0 ./.env.example a4ec3874a2e3ab1bad28fb40bb620f7b01f64d01ad9b699306bf70ada31227db ./.gitignore -ddda584a029e67d057e3afbe93246795e6579d7efd2f3e9ebe4cfd2bb164e907 ./Cargo.lock -d09e3c8438adcfac6aa9d16de9aade898a1fa6f64f7edf17bcd70afb714db12f ./Cargo.toml +0c5cd71516b15e7b51a39fd694d8532a38485d8a8d358caab8ae850274a41127 ./Cargo.lock +e3b77008eebcb6db5b188102f788cd06f5159c1a66577bb212d96d125aa72510 ./Cargo.toml 19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE -5b5586d2e3e7b80c0ffe2d241517f8f7a8c4d7e80169a6cb3cf46221024f54e9 ./README.md +d5e5fdafaf8d777e3848b5c8f3aacfd0cf4c80735fbbb6bda14e86468329de88 ./README.md 29e9821e5ed216ea3565dd6d8bc0b174ed3cf9fa72c4ccebbfa99e98db3ad8f9 ./build.rs -70a8217b46a12ae47e6b461f6f619fa19facb0b422fd8d850208b55237abc752 ./docs/API.md -c6b1aded8ef6f4c840a2b1f2ffa3f0b22eddc080a4750adb34ee950f12f64382 ./docs/FLOW.md -9debc0d7d3f663c2a46ab17fd385a37aa732757e6bb7c75d6d60281ea89092b5 ./docs/openapi.json +96144a5c5f3688854ab19dcd97794bd74da8ed26133c1468d91f6659a54faa4b ./docs/API.md +0884be80021ff93826525f0b5bd5c81418f0c9c4eadec9583386ec61ae8e82ba ./docs/FLOW.md +d883d4a58b19b10b5b33dacd4a58b0a4eb69dcc133a478524407027956ed9f70 ./docs/openapi.json 77b8becf7a2c16a384ea85721d878b76e2d520549cb4455d9aa806e57e5a2ecd ./ha-addon/.env.example 0ca6e58170c2b8925194431ebbdb55f0c8b377fec5cd422cd2bd257e2d9328e3 ./ha-addon/Dockerfile 0951696a2c2e66d4d969f8e7f8641184e3012f261583b31074bc38a7a1c0cc1a ./ha-addon/README.md @@ -21,7 +21,7 @@ b7f0873109c52be9d7f09bea3dffc416103c50085e1f0680d11661a969479898 ./ha-addon/hom 6509286c7b7de638dcc5d40ce53181e65d1eddc5dc36a13306dc6d87220fedbb ./ha-addon/home-assistant/custom_components/gree_controller/coordinator.py 5a96fe8f5c035c34f1339370270cd078056202d09e236dec75735be11de92a7d ./ha-addon/home-assistant/custom_components/gree_controller/entity_map.py c4fb75c246db651087900ebfc2291ff41ac87652cd6194fc0b776b0005c1cbcf ./ha-addon/home-assistant/custom_components/gree_controller/icon.png -00fc79f5195f43cd8d076b3d9a7a782263f0bde2a6b58b310c2d7a8b307a9f88 ./ha-addon/home-assistant/custom_components/gree_controller/manifest.json +62e7bb884b342349bb175ce874d11ccb845e8219512ff6cf4d6c9ed03cb12294 ./ha-addon/home-assistant/custom_components/gree_controller/manifest.json c52a484b671ce738ecc00228a1db19a5d703f69ab27530c2896a5c2ae3b4a96f ./ha-addon/home-assistant/custom_components/gree_controller/number.py 39c4309001b75abb56234f05662bc06e077054986876f1927937edbce528ec95 ./ha-addon/home-assistant/custom_components/gree_controller/select.py cca65482e36d48035aca178121a378fe7d578d600acff267ae81a6399c0da653 ./ha-addon/home-assistant/custom_components/gree_controller/sensor.py @@ -29,10 +29,10 @@ cca65482e36d48035aca178121a378fe7d578d600acff267ae81a6399c0da653 ./ha-addon/hom a745cc458c235158d5d70b6a33d8000fd8b40ff93bfe2f2796af0f4b3a9eef9b ./ha-addon/home-assistant/custom_components/gree_controller/translations/en.json 50b7d6fe6329ad4ea6bee9612ab141c2f73f8382fcc2af0bb076a8e8d4dd4465 ./ha-addon/home-assistant/custom_components/gree_controller/translations/pl.json c5fc5c87273d82d2834a0a5a365c821a416413ed509613441e24e4dc3507c4d2 ./ha-addon/home-assistant/generated/gree_controller_entities.example.json -2976822c9663e505d22297e2749192b4ca119bb4e46ccc1c29c813522c84b3c5 ./ha-addon/repository/gree-controller/CHANGELOG.md +9cb2e827567e8cb06050e24fd3811dda6f51350d8bec9b605189fa8ab85b331a ./ha-addon/repository/gree-controller/CHANGELOG.md 50b8ba050c6b4919e4d057f7b38c1e23d3913b5b7c87552626452e9695d37251 ./ha-addon/repository/gree-controller/DOCS.md -5f0bd0d1ea4aca47844f9e870e4ba1676ad8b90369577db75a1bb0d00228a9da ./ha-addon/repository/gree-controller/README.md -f43723e7e356c7cc637b36b5ade0e34cfb69c50c4cf653cca7fc013e926cd7c9 ./ha-addon/repository/gree-controller/config.yaml +0bd253135a67d295dfca355b735b350dbf36ed396a6c8e30387d5218d4e5afcf ./ha-addon/repository/gree-controller/README.md +cb348473b59a76dfd29adc04d281128a9b8848b92107b928073aac4e047fdc4b ./ha-addon/repository/gree-controller/config.yaml 3aae6cdae4c3aaab7786a9575e7093b7e2c5b69288167075f4b7475611691a6b ./ha-addon/repository/gree-controller/topologia-ha-vlan-gree-pl.png 0d0b42a7639b128946eec379175436fa1f150294f39d2dfd169077bfa87fd14d ./ha-addon/repository/gree-controller/topology-ha-vlan-gree-en.png 2272740b185e9520e948f74b8f45cf98b8b5fd28c93bdee760da626f91fe5c2a ./ha-addon/repository/gree-controller/translations/en.yaml @@ -42,8 +42,8 @@ cea12bc74c066a588a5ef0ef7bad9d4906bdbcea99f2d275429b0eefc94b4b5f ./ha-addon/run 081e27b6e43070d44f865335c30a42a5a29668eaa6a5ea5ab6967ac4447b8e06 ./ha-addon/sync-repository.sh f9e31de3d109ead32bf531c074d46a087df398d62488caee0b46b5e0a1c027f7 ./ha_addon.md 253a0bc912786e67ea7fc92a64e4a510ad973bec343a88ccfb1f28fca3e8cf01 ./lang/README.md -37d24f8de343b4796a0a8cfff3a839e55bdb779add0c8119799787d87ad8b953 ./lang/en.json -1610da0b2bbb9424006647adfb95e1318b7670aaed5a2798db1251db82635317 ./lang/pl.json +e9f8da620e017a3c06363f9b3d8709d9abaee9bbac4e53e60f27db7c072bb647 ./lang/en.json +efc35994018e526ed829ee26b3cb1c74df2ac3655433e3b9bd0bf12f087e3223 ./lang/pl.json d8459024f04ca514bd8e9d6bd3af872fb942fd85a7cdd5583f4a9d28aab6faba ./make_zip.py b14233a8987e53bbbdd6770386ba10fa166ec25c1e098275c173c544b37846fc ./presets/bedroom_window_night.json 1960841119c0b673fb2f03621b32237552fd828045304cd221401214b997c910 ./presets/device_resilience.json @@ -84,7 +84,7 @@ fdcd9a5055d08037278b842e7ab69265345c5811f0a06867136c511d140bb191 ./presets/wind 804f22123cd3e8db0fac791826c8dd9f758fb866c8e3b5655fb6d25d259dccf1 ./presets/workday_comfort.json fd7390d64a2378352c09382accf6efff43903d952d111b22a982f06c5b2a3b4b ./regenerate-sha.sh c64b1c6deeb24f20af662bfce7fa3683703914987241107ce664fa2f6d1a9dcb ./scripts/README.md -3a3287a4ca516f91a1b1bf14257aeef7b1b3027d592200c0a6da1cc70afa9c32 ./scripts/api_dev_test.py +863f087740abee259d51dc5935c6b60dfe25b31a6a9fe64657d25a05d200a6fa ./scripts/api_dev_test.py 5bc736c7bc76ca80aaa406bb171d2aa91baf4c3aa8695dce0e09b888b6ab3146 ./scripts/common.sh 6403786610ee6d2f628193c25aee0dd058d62e904aa1a31d5f62fdaae0e94b4f ./scripts/configure-gree-network.sh 1d6e14e26e49aa9d3527f30a23668bf8d9c48b67e6628ef686c3155c012155de ./scripts/dev.sh @@ -135,7 +135,7 @@ d6b9b70a04c774173c97f7426487d5e2932d7713e12ae3fb7f64cf2b4e0b3c10 ./src/db/sched 28ee42d08147b0f154af818065d7d014d74ee8ddd59fdef98115f5c5368056cc ./src/db/tests.rs f90fbb14f29a20b799b3856bdaa22812a9f0d9dfa46693f1341c160dccda8a97 ./src/db/zone_history.rs 0e49ea4a52017b0a8501d5101b1906a7a1e8d5158acb0bb65caa43cde2836ec0 ./src/engine.rs -76dbf72600a48a6ba5b2273777bd8f2c8a6bd212aa236d419cc59eedcb8ab22b ./src/engine/automations.rs +a0d60ad81d024488dfec586b0d9e9f447e9a3485985604415465cacba59d645f ./src/engine/automations.rs 1dccb8779f87dfdbd4ccda3f02ec51273e7be2e34e85166861668685528eca79 ./src/engine/commands.rs 357ef92c3cc42b243af4da31ac52bb360d7444ae423f58d7a13bdee79bf0a748 ./src/engine/connectivity.rs ad53cd7be24deb759e03cbc6472522b69ebd36d8fac35398a9b19c899d3b17f2 ./src/engine/control_plan.rs @@ -151,7 +151,7 @@ c73691c3637465ab0f79aecf7f5428d0317799a8ab5329ed564cf57963bbe86e ./src/engine/r b0189f28d0f337dfd3ce0934da7a565e4a5535afc1c242214c26ed584b3b7840 ./src/engine/targets.rs 682c8b3956c657b655746cbd19ee97ee2f4cf953bc9de67d6d70a1b14f3dd746 ./src/engine/temperature.rs 833b72907375e75c3f42b3921ad70a8e298f5a93743bf0d0c19059aa272462d1 ./src/engine/temporary_thermostat.rs -af4edaf12faf294f44a8992d120a99b09edd1cbdd66981390e027d13fd6b6790 ./src/engine/tests.rs +0404e040c5d6f388682edb3d103a658e0b963a402e4e847fc92fd0aea1c0e773 ./src/engine/tests.rs c89ff36c6a67bf4fbf68c43e18aa0a556de2dc69263fd79db376363afc741a4f ./src/engine/zone_actions.rs fb169bcc20f95abe9892367df6dd3a683584bdbc721792d6ffb9311b81ca30d9 ./src/engine/zone_control.rs bfac95cec87f0df6ce562153cd3cfe6731359d703e6c592d2c2f7ba01ea62d99 ./src/error.rs @@ -162,7 +162,7 @@ da4068295f37c23222bb13bebac022d88ba7729604a49ad25438c55e45458a49 ./src/influxdb 67e42a92295480d2f1d364958f184654cfb5d4b77e7e1df47f588bdec18868a0 ./src/influxdb/write.rs 62f9da6ef4f5fd92701d0857773f9f6236ef25356f21b9b42236abab6ba7da53 ./src/main.rs 23688a5cb68efded89471ce959328d27adff370d451bd9f378a283ab51437e76 ./src/models.rs -920e028d96a14cca9f74a964bb296e68a109cfead314d3164c180201e606d151 ./src/models/automation.rs +e7b6e49d369aaf665a8aea2464950fd795957de0ad7528a002e11c55c7983f35 ./src/models/automation.rs fc5f2802a073a175a8985e56bcb87eb1ff481226d5b4eeabad73dbba1839fc45 ./src/models/control_plan.rs 06357b352ff6494d83e72796b869573a8b8f36b835985a5900ac571c922b8da9 ./src/models/defaults.rs 698dcbeba72037a251c25d07c95833f121716d949f9217f1f9429e8cd40638e5 ./src/models/device.rs @@ -198,10 +198,10 @@ f13f1be3d5789539eba3fc1b59c14835f8c681eca7634e118fa3763a53aeee2b ./src/queries/ 8c34b70fb5f80e3249c2e0c63686e60ce1697cf44a7e6f11b3ad6f689ce17792 ./src/state.rs b92a6cb158b494fe145b43c7641e65f6fafff47201d7d76edbec2cfd8b94835c ./systemd/gree-controller.service 544a31cb2e5374227026afd6abd0d910381f3fca1638154ebd74e3c3d96a35d1 ./web/404.html -1043dbcfb24a474ce088acb6aa86479e84db74688fb4802febeb3f3f564ca65f ./web/css/styles.css +c60667f270dc66ee463f425ca5eda676363fcd2390e5d652220f6b4af9ded6c3 ./web/css/styles.css 183d122ba366303d9952ac0e68aff4bced4e0d9a468fbe51316c13f15227402b ./web/custom-chart.html e98bdd7204349cce1ec6f57283509697af0bbc72280622a6c3efa6fed242db4f ./web/favicon.svg -cc1509fd76f0b0bd4c069db63e1e104210c2bed05f32a3eebac7642f94ef8a99 ./web/index.html +ca887343ec089cbbb83c4ce5685edbade1266c89a29b5570c02f75c9c50b1c10 ./web/index.html 9ba3f6fa05b72aa989139b2a909982571b2a02055052e4c40104f1e81a9aa7eb ./web/js-dynamic/README.md e74bb06dba646f298a4d118b5c2dca6ba24f1179ce8ea8d53c1acd6000ca332c ./web/js-dynamic/bootstrap.js 5d57253a013fd1cb79e4b8701d4be6e394e3f2acbce73dfcd726a86e5878f1a1 ./web/js-dynamic/charts.js @@ -209,7 +209,7 @@ e74bb06dba646f298a4d118b5c2dca6ba24f1179ce8ea8d53c1acd6000ca332c ./web/js-dynam 752b3c3b59558b42d867d4a15329238f0213f354b76f7011c10e31b4e6dbb311 ./web/js-dynamic/dashboard.js 6b166a9311e30fe948bd3236189436eea769c238f963ba00acec5cdb51ff1dd7 ./web/js-dynamic/entities.js b4204241eecf387256364f4230b23ed50d947a362f4ceb1e528d69494e93d7cf ./web/js-dynamic/events.js -879a92e95575ace4775b2f9a7f189e78ad0082ca5c12d012ef456e6477010886 ./web/js-dynamic/flows.js +e0f604f54eafb9a6d6ac1c6c24eb27f3e3c1e31ed80ead57ac0340c7228318b8 ./web/js-dynamic/flows.js 3ae714de6b71c068ba665c81a7689f994b1716284e8d937db669ad500f12cf63 ./web/js-dynamic/forms.js 92d013bb5d64f64aa8c55380704540a5f72732bde7ccd16554965e401daf50ea ./web/js-dynamic/history.js 511c4e7cc4c48fbbdab64558a9d1c27e6759c9b6dca255d11f301b9708e955c7 ./web/js-dynamic/main.js diff --git a/docs/API.md b/docs/API.md index 1c6905b..4dc9a68 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.15.8**. +HTTP and WebSocket API for GREE Controller **0.15.9**. [← Main documentation](../README.md) @@ -241,7 +241,7 @@ Response: { "status": "ok", "name": "gree-controller", - "version": "0.15.8", + "version": "0.15.9", "uptime_seconds": 1234, "control_ready": true, "time": "2026-08-30T06:54:00Z" @@ -304,7 +304,7 @@ Returns the initial Web UI snapshot: "control_plan": {"generated_at": "2026-09-04T08:00:00Z", "zones": [], "rules": []}, "control_plan_revision": 42, "system": { - "version": "0.15.8", + "version": "0.15.9", "uptime_seconds": 1234, "auth_required": false, "control_ready": true, diff --git a/docs/FLOW.md b/docs/FLOW.md index f9b0941..9874ee5 100644 --- a/docs/FLOW.md +++ b/docs/FLOW.md @@ -60,7 +60,7 @@ Dry-run never mutates thermostats, devices, groups, schedules or automations. Fo ## Blocks -Condition/source blocks include weekday, time range, date range, optional 5-field CRON triggers, application Night mode, outdoor/device/zone temperature, house mode, device state, thermostat/zone state, group state, Home Assistant state/numeric/attribute/availability, rolling mean/median windows, numeric oscillation detection, a diagnostic constant, and `shared_input`. Stateful gates include `stable_for`, bounded `state_duration`, `on_change` (condition-result or raw-value edge), `rate_limit` (maximum successful executions in a rolling period), and `delay`. Their runtime state, together with rolling statistics and oscillation samples, is persisted in the generated automation payload so restarts do not silently reset timing/history. Editing a stateful block resets only that block's incompatible runtime state. Flow state continues to be observed while the action itself is in cooldown, preventing stale continuity/change state. A `rate_limit` block must be placed directly before an action and consumes quota only after a successful execution; suppressed or failed actions do not consume it. Shared inputs are configured centrally in Home Assistant / Sensors and referenced by ID. Shared inputs store reusable value sources only; operators and comparison values are configured exclusively in each Flow reference. Logic blocks are AND, OR and NOT. +Condition/source blocks include weekday, time range, date range, optional 5-field CRON triggers, application Night mode, outdoor/device/zone temperature, house mode, device state, thermostat/zone state, group state, Home Assistant state/numeric/attribute/availability, rolling mean/median windows, numeric oscillation detection, a diagnostic constant, and `shared_input`. Stateful gates include `stable_for`, bounded `state_duration`, `on_change` (condition-result or raw-value edge), `rate_limit` (maximum successful executions in a rolling period), and `delay`. Their runtime state, together with rolling statistics and oscillation samples, is persisted in the generated automation payload so restarts do not silently reset timing/history. Editing a stateful block resets only that block's incompatible runtime state. Flow state continues to be observed while the action itself is in cooldown. `on_change` latches a detected event while its upstream condition remains true and clears it only after a successful downstream action, so cooldown, ownership suppression or same-cycle target arbitration cannot silently consume the edge. Rolling mean/median blocks wait for one complete configured time window before matching and restart that warm-up after their source becomes unavailable. A `rate_limit` block must be placed directly before an action and consumes quota only after a successful execution; suppressed or failed actions do not consume it. Shared inputs are configured centrally in Home Assistant / Sensors and referenced by ID. Shared inputs store reusable value sources only; operators and comparison values are configured exclusively in each Flow reference. Logic blocks are AND, OR and NOT. Actions include thermostat zone, full GREE device command, single GREE function, climate group, and generic Home Assistant service calls (`domain`, `service`, optional `entity_id`, JSON service data). Direct GREE actions expose power, HVAC mode, target temperature, fan speed, vertical/horizontal louver position, quiet, turbo, light, air, xfan, health and sleep. The single-function block writes exactly one of those fields. @@ -72,7 +72,7 @@ Shared Flow inputs show the Flows that reference them and link directly to those Presets are data files, not JavaScript definitions. Every preset lives in `presets/.json` and contains `id`, `category`, localized `name`, localized `description`, and the complete `flow` graph (`nodes` + `edges`). `build.rs` validates and embeds the directory, exposes `/presets/index.json` plus `/presets/.json`, and the Flow editor loads that library dynamically. Device/zone/group placeholders such as `$zone1`, `$zone2`, `$device1` and `$group1` are resolved when a preset is applied. Adding a preset therefore does not require adding graph-building code to `web/js-dynamic/flows.js`. -The canvas supports multi-selection (Shift/Ctrl/Cmd), `Ctrl/Cmd+A`, Select all / Clear selection toolbar actions, and dragging the whole selected group while preserving relative positions. +The canvas supports drag-box multi-selection, additive Shift/Ctrl/Cmd selection, `Ctrl/Cmd+A`, Select all / Clear selection toolbar actions, and dragging the whole selected group while preserving relative positions. Selected blocks can be duplicated from the toolbar or with `Ctrl/Cmd+D`; `Ctrl/Cmd+C` and `Ctrl/Cmd+V` copy/paste the selected subgraph and preserve connections whose endpoints are both selected. ## Good next stateful blocks diff --git a/docs/openapi.json b/docs/openapi.json index df7c4a7..78b74d3 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -2,7 +2,7 @@ "openapi": "3.1.0", "info": { "title": "GREE Controller API", - "version": "0.15.8", + "version": "0.15.9", "summary": "Local HTTP/WebSocket API for GREE Controller", "description": "Local API used by the GREE Controller Web UI and Home Assistant integration.", "license": { @@ -5967,7 +5967,7 @@ }, "version": { "type": "string", - "example": "0.15.8" + "example": "0.15.9" }, "uptime_seconds": { "type": "integer", diff --git a/ha-addon/home-assistant/custom_components/gree_controller/manifest.json b/ha-addon/home-assistant/custom_components/gree_controller/manifest.json index 2deec23..d28e5e3 100644 --- a/ha-addon/home-assistant/custom_components/gree_controller/manifest.json +++ b/ha-addon/home-assistant/custom_components/gree_controller/manifest.json @@ -1,7 +1,7 @@ { "domain": "gree_controller", "name": "GREE Controller", - "version": "0.15.8", + "version": "0.15.9", "config_flow": true, "integration_type": "hub", "iot_class": "local_polling", diff --git a/ha-addon/repository/gree-controller/CHANGELOG.md b/ha-addon/repository/gree-controller/CHANGELOG.md index c35ccfd..d11fb24 100644 --- a/ha-addon/repository/gree-controller/CHANGELOG.md +++ b/ha-addon/repository/gree-controller/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 0.15.9 + +- Visual Flow `on_change` events are now latched while their upstream condition remains true and are acknowledged only after the downstream action executes successfully, so cooldowns, ownership suppression and same-cycle target conflicts no longer silently consume a change event. +- Rolling mean/median blocks now require one complete configured time window after their runtime window starts or restarts before they can match; unavailable sources reset the rolling window instead of reusing partial history. +- Thermostat action summaries now show explicit power and HVAC mode alongside the preset/target, so `power=false` is displayed as OFF instead of looking like a preset-only Auto action. +- The Flow canvas adds drag-box multi-selection, keeps Shift/Ctrl/Cmd additive selection and moves the whole selection together. +- Selected Flow blocks can be copied/pasted or duplicated with internal connections preserved; Ctrl/Cmd+C, Ctrl/Cmd+V and Ctrl/Cmd+D are supported. + ## 0.15.8 - Visual Flow exports now include the Shared Input definitions referenced by the graph. diff --git a/ha-addon/repository/gree-controller/README.md b/ha-addon/repository/gree-controller/README.md index fbd5548..071fc9b 100644 --- a/ha-addon/repository/gree-controller/README.md +++ b/ha-addon/repository/gree-controller/README.md @@ -2,7 +2,7 @@ Local GREE HVAC controller packaged as a Home Assistant add-on, with Web UI, HTTP/WebSocket API, built-in Swagger API documentation, UDP discovery/control, `amd64` and `aarch64` support, ingress, and database backup from `/data/gree-controller.db`. -Version 0.15.8 makes Visual Flow exports portable with referenced Shared Inputs and keeps the Interpretation / Flow cycle details collapsed until opened. +Version 0.15.9 strengthens Visual Flow execution and editing with latched change events, full rolling-stat windows, clearer thermostat action summaries, box selection and multi-block copy/paste/duplicate. ![Home Assistant OS + GREE + VLAN topology](https://git.linuxiarz.pl/gru/gree-controller-ha-addon/raw/branch/master/gree-controller/topology-ha-vlan-gree-en.png) diff --git a/ha-addon/repository/gree-controller/config.yaml b/ha-addon/repository/gree-controller/config.yaml index c0fe536..54a3c29 100644 --- a/ha-addon/repository/gree-controller/config.yaml +++ b/ha-addon/repository/gree-controller/config.yaml @@ -1,5 +1,5 @@ name: "GREE Controller" -version: "0.15.8" +version: "0.15.9" slug: "gree_controller" description: "Local GREE HVAC controller with Web UI, API, and Home Assistant integration" url: "https://git.linuxiarz.pl/gru/gree-controller-ha-addon/" diff --git a/lang/en.json b/lang/en.json index c737a64..6d52dee 100644 --- a/lang/en.json +++ b/lang/en.json @@ -1019,6 +1019,10 @@ "flow.selectAll": "Select all", "flow.clearSelection": "Clear selection", "flow.selectedCount": "Selected: {count}", + "flow.duplicateSelection": "Duplicate", + "flow.blocksCopied": "Copied blocks: {count}", + "flow.blocksPasted": "Pasted blocks: {count}", + "flow.blocksDuplicated": "Duplicated blocks: {count}", "flow.node.sharedInput": "Shared input", "flow.sharedInputMissing": "Shared input missing", "flow.sharedInputTitle": "Shared Flow input", diff --git a/lang/pl.json b/lang/pl.json index 0087068..61fc91b 100644 --- a/lang/pl.json +++ b/lang/pl.json @@ -1019,6 +1019,10 @@ "flow.selectAll": "Zaznacz wszystko", "flow.clearSelection": "Wyczyść zaznaczenie", "flow.selectedCount": "Zaznaczono: {count}", + "flow.duplicateSelection": "Duplikuj", + "flow.blocksCopied": "Skopiowano bloki: {count}", + "flow.blocksPasted": "Wklejono bloki: {count}", + "flow.blocksDuplicated": "Zduplikowano bloki: {count}", "flow.node.sharedInput": "Wspólne wejście", "flow.sharedInputMissing": "Brak wspólnego wejścia", "flow.sharedInputTitle": "Wspólne wejście Flow", diff --git a/scripts/api_dev_test.py b/scripts/api_dev_test.py index 9967f27..8cecbce 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.15.8. +"""Developer smoke/integration tests for GREE Controller API 0.15.9. 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.15.8" +DEFAULT_EXPECTED_VERSION = "0.15.9" SETTINGS_PATHS: dict[str, set[str]] = { "/api/settings/application": {"simulator_enabled"}, @@ -599,7 +599,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.15.8 contract", lambda: test_openapi(client, args.expected_version)) + runner.run("OpenAPI 0.15.9 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)) diff --git a/src/engine/automations.rs b/src/engine/automations.rs index 48ee876..25965a9 100644 --- a/src/engine/automations.rs +++ b/src/engine/automations.rs @@ -157,6 +157,7 @@ async fn run_automations(state: &AppState) -> Result<()> { Ok(true) => { for device_id in target_devices { claimed_devices.insert(device_id); } let fired_at = Utc::now(); + flow_acknowledge_change_gates(&mut item); flow_record_rate_limited_execution(&mut item, fired_at.clone()); item.last_fired_at = Some(fired_at); item.updated_at = Utc::now(); @@ -405,10 +406,27 @@ fn flow_state_duration_update( (within_min && within_max, elapsed) } -fn flow_change_gate_update(state: &mut crate::models::FlowRuntimeNodeState, current: Value) -> bool { +fn flow_change_gate_update( + state: &mut crate::models::FlowRuntimeNodeState, + current: Value, + input: bool, +) -> (bool, bool) { let changed = state.last_value.as_ref().is_some_and(|previous| previous != ¤t); state.last_value = Some(current); - changed + if !input { + state.pending = false; + } else if changed { + state.pending = true; + } + (changed, state.pending) +} + +fn flow_acknowledge_change_gates(item: &mut Automation) { + for condition in item.flow_conditions.iter().filter(|condition| condition.kind == "on_change") { + if let Some(state) = item.flow_runtime.get_mut(&condition.id) { + state.pending = false; + } + } } fn flow_rate_limit_status( @@ -444,9 +462,13 @@ fn flow_rolling_stat_update( statistic: &str, now: DateTime, ) -> Option { + let started_at = state.since.get_or_insert_with(|| now.clone()).clone(); let cutoff = now.clone() - chrono::Duration::seconds(window_seconds as i64); state.samples.retain(|item| item.at >= cutoff); - state.samples.push(crate::models::FlowRuntimeSample { at: now, value: sample }); + state.samples.push(crate::models::FlowRuntimeSample { at: now.clone(), value: sample }); + if now.signed_duration_since(started_at).num_seconds().max(0) < window_seconds as i64 { + return None; + } let mut values = state.samples.iter().map(|item| item.value).collect::>(); if values.is_empty() { return None; } if statistic == "median" { @@ -730,13 +752,14 @@ pub async fn evaluate_flow_conditions_trace( let mode = condition.config.get("mode").and_then(Value::as_str).unwrap_or("result"); let observed = if mode == "value" { actual_values.get(&input_id).cloned() } else { Some(json!(input)) }; let mut changed = false; + let mut pending = false; let mut previous = None; if let (Some(current), Some(map)) = (observed.clone(), runtime.as_deref_mut()) { let state = map.entry(condition.id.clone()).or_default(); previous = state.last_value.clone(); - changed = flow_change_gate_update(state, current); + (changed, pending) = flow_change_gate_update(state, current, input); } - (input && changed, json!({"input": input, "mode": mode, "previous": previous, "current": observed, "changed": changed})) + (input && pending, json!({"input": input, "mode": mode, "previous": previous, "current": observed, "changed": changed, "pending": pending})) } "rate_limit" => { let input = condition.inputs.len() == 1 && values.get(&condition.inputs[0]).copied().unwrap_or(false); @@ -772,12 +795,17 @@ pub async fn evaluate_flow_conditions_trace( let window = condition.config.get("window_seconds").and_then(Value::as_u64).unwrap_or(60); let mut aggregate = None; let mut count = 0usize; - if let (Some(sample), Some(map)) = (sample, runtime.as_deref_mut()) { + if let Some(map) = runtime.as_deref_mut() { let node_state = map.entry(condition.id.clone()).or_default(); - aggregate = flow_rolling_stat_update( - node_state, sample, window, condition.config.get("statistic").and_then(Value::as_str).unwrap_or("mean"), now.with_timezone(&Utc), - ); - count = node_state.samples.len(); + if let Some(sample) = sample { + aggregate = flow_rolling_stat_update( + node_state, sample, window, condition.config.get("statistic").and_then(Value::as_str).unwrap_or("mean"), now.with_timezone(&Utc), + ); + count = node_state.samples.len(); + } else { + node_state.since = None; + node_state.samples.clear(); + } } let expected = condition.config.get("value").and_then(Value::as_f64); let matched = predecessors_match && aggregate.zip(expected).map(|(a,e)| flow_compare(a, condition.config.get("operator").and_then(Value::as_str).unwrap_or("lt"), e)).unwrap_or(false); diff --git a/src/engine/tests.rs b/src/engine/tests.rs index 2352448..aca4a31 100644 --- a/src/engine/tests.rs +++ b/src/engine/tests.rs @@ -306,12 +306,15 @@ mod tests { } #[test] - fn flow_change_gate_ignores_first_observation_and_detects_later_change() { + fn flow_change_gate_latches_until_action_ack_or_input_reset() { let mut state = crate::models::FlowRuntimeNodeState::default(); - assert!(!flow_change_gate_update(&mut state, json!("off"))); - assert!(!flow_change_gate_update(&mut state, json!("off"))); - assert!(flow_change_gate_update(&mut state, json!("on"))); - assert!(!flow_change_gate_update(&mut state, json!("on"))); + assert_eq!(flow_change_gate_update(&mut state, json!(false), false), (false, false)); + assert_eq!(flow_change_gate_update(&mut state, json!(true), true), (true, true)); + assert_eq!(flow_change_gate_update(&mut state, json!(true), true), (false, true)); + state.pending = false; + assert_eq!(flow_change_gate_update(&mut state, json!(true), true), (false, false)); + state.pending = true; + assert_eq!(flow_change_gate_update(&mut state, json!(false), false), (true, false)); } #[test] @@ -337,30 +340,27 @@ mod tests { } #[test] - fn flow_rolling_statistics_compute_mean_median_and_prune_window() { + fn flow_rolling_statistics_require_a_full_window_and_prune_samples() { let mut state = crate::models::FlowRuntimeNodeState::default(); let start = Utc.with_ymd_and_hms(2026, 9, 2, 12, 0, 0).unwrap(); - assert_eq!( - flow_rolling_stat_update(&mut state, 10.0, 60, "mean", start), - Some(10.0) - ); + assert_eq!(flow_rolling_stat_update(&mut state, 10.0, 60, "mean", start), None); assert_eq!( flow_rolling_stat_update( &mut state, 20.0, 60, "mean", - start + chrono::Duration::seconds(10) + start + chrono::Duration::seconds(30) ), - Some(15.0) + None ); assert_eq!( flow_rolling_stat_update( &mut state, 30.0, 60, - "median", - start + chrono::Duration::seconds(20) + "mean", + start + chrono::Duration::seconds(60) ), Some(20.0) ); @@ -370,21 +370,11 @@ mod tests { 40.0, 60, "median", - start + chrono::Duration::seconds(30) + start + chrono::Duration::seconds(90) ), - Some(25.0) + Some(30.0) ); - assert_eq!( - flow_rolling_stat_update( - &mut state, - 100.0, - 60, - "mean", - start + chrono::Duration::seconds(100) - ), - Some(100.0) - ); - assert_eq!(state.samples.len(), 1); + assert_eq!(state.samples.len(), 3); } #[test] diff --git a/src/models/automation.rs b/src/models/automation.rs index 05c225d..bc3f356 100644 --- a/src/models/automation.rs +++ b/src/models/automation.rs @@ -34,6 +34,9 @@ pub struct FlowRuntimeNodeState { /// Last observed raw/result value for edge/change detection blocks. #[serde(default)] pub last_value: Option, + /// Latched change event waiting for its downstream action to execute successfully. + #[serde(default)] + pub pending: bool, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] diff --git a/web/css/styles.css b/web/css/styles.css index 84eaef3..ff5465d 100644 --- a/web/css/styles.css +++ b/web/css/styles.css @@ -6270,6 +6270,15 @@ textarea[aria-invalid="true"] { box-shadow: 0 0 0 2px var(--accent-soft), 0 12px 30px rgba(0, 0, 0, .22); } +.flow-selection-marquee { + position: absolute; + z-index: 4; + pointer-events: none; + border: 1px solid var(--accent); + border-radius: 4px; + background: var(--accent-soft); +} + .flow-editor .flow-node-time { border-top: 3px solid var(--blue); } diff --git a/web/index.html b/web/index.html index b0dbefd..f24522f 100644 --- a/web/index.html +++ b/web/index.html @@ -998,6 +998,8 @@ data-i18n="flow.addBlock">Add block @@ -1015,6 +1017,7 @@
+
Start by adding blocksConditions go on the left, actions on the diff --git a/web/js-dynamic/flows.js b/web/js-dynamic/flows.js index 0075500..9211bb0 100644 --- a/web/js-dynamic/flows.js +++ b/web/js-dynamic/flows.js @@ -357,7 +357,13 @@ function flowNodeSummary(node) { if (node.kind === 'logic_and') return tr('flow.allConditions'); if (node.kind === 'logic_or') return tr('flow.anyCondition'); if (node.kind === 'logic_not') return tr('flow.invertCondition'); - if (node.kind === 'zone_thermostat') { const zone = app.zones.find(z => z.id === c.zone_id); return `${zone?.name || tr('common.noZone')} · ${c.preset === 'custom' ? fmtTemp(c.setpoint) : zonePresetLabel(c.preset || 'comfort')}`; } + if (node.kind === 'zone_thermostat') { + const zone = app.zones.find(z => z.id === c.zone_id); + const power = c.power == null ? null : (c.power ? tr('common.on') : tr('common.off')); + const mode = c.mode ? (c.mode === 'auto' ? 'Auto' : modeLabel(c.mode)) : null; + const target = c.preset === 'custom' ? fmtTemp(c.setpoint) : zonePresetLabel(c.preset || 'comfort'); + return [zone?.name || tr('common.noZone'), power, mode, target].filter(Boolean).join(' · '); + } if (node.kind === 'device_action') { const d = app.devices.find(v => v.id === c.device_id); return `${d?.name || tr('common.noDevice')} · ${c.power == null ? tr('actions.noChange') : c.power ? tr('common.on') : tr('common.off')}`; } if (node.kind === 'device_feature_action') { const d = app.devices.find(v => v.id === c.device_id); return `${d?.name || tr('common.noDevice')} · ${deviceCommandFieldLabel(c.feature || 'light')} → ${flowDeviceFeatureValueLabel(c.feature, c.value)}`; } if (node.kind === 'group_action') { const g = app.groups.find(v => v.id === c.group_id); return `${g?.name || tr('groups.group')} · ${c.power == null ? tr('actions.noChange') : c.power ? tr('common.on') : tr('common.off')}`; } @@ -470,7 +476,9 @@ function renderFlowEditor() { `; }).join(''); $('#flowEmptyHint').hidden = draft.nodes.length > 0; - const selectionCount = $('#flowSelectionCount'); if (selectionCount) selectionCount.textContent = (app.flowSelectedNodeIds || []).length ? tr('flow.selectedCount', { count: (app.flowSelectedNodeIds || []).length }) : ''; + const selectedCount = (app.flowSelectedNodeIds || []).length; + const selectionCount = $('#flowSelectionCount'); if (selectionCount) selectionCount.textContent = selectedCount ? tr('flow.selectedCount', { count: selectedCount }) : ''; + const duplicateButton = $('[data-action="flow-duplicate-selection"]'); if (duplicateButton) duplicateButton.disabled = !selectedCount; renderFlowNameMode(); renderFlowEnabledLabel(); setFlowZoom(app.flowZoom || 1, { render: false }); renderFlowEdges(); renderFlowInspector(); renderFlowInterpretation(); renderFlowRuntimeInfo(); renderFlowSaveStatus(); refreshFlowSharedInputCurrentValues(); const status = $('#flowCompileStatus'); status.textContent = draft.draft ? tr('flow.draftStatus') : tr('flow.compileCount', { schedules: draft.compiled_schedule_ids?.length || 0, automations: draft.compiled_automation_ids?.length || 0 }); @@ -1189,6 +1197,59 @@ function selectAllFlowNodes() { function clearFlowSelection() { setFlowSelection([], null); } +let flowClipboard = null; +let flowClipboardPasteCount = 0; + +function copySelectedFlowNodes({ notify = true } = {}) { + if (!app.flowDraft || !(app.flowSelectedNodeIds || []).length) return false; + const selected = new Set(app.flowSelectedNodeIds); + const nodes = app.flowDraft.nodes + .filter(node => selected.has(node.id)) + .map(node => ({ ...node, config: JSON.parse(JSON.stringify(node.config || {})) })); + const edges = app.flowDraft.edges + .filter(edge => selected.has(edge.from) && selected.has(edge.to)) + .map(edge => ({ ...edge })); + if (!nodes.length) return false; + flowClipboard = { nodes, edges }; + flowClipboardPasteCount = 0; + if (notify) toast(tr('flow.blocksCopied', { count: nodes.length })); + return true; +} + +function pasteFlowNodes({ notify = true } = {}) { + if (!app.flowDraft || !flowClipboard?.nodes?.length) return false; + flowClipboardPasteCount += 1; + const offset = 36 * flowClipboardPasteCount; + const idMap = new Map(flowClipboard.nodes.map(node => [node.id, newFlowId('node')])); + const copies = flowClipboard.nodes.map(node => ({ + ...node, + id: idMap.get(node.id), + config: JSON.parse(JSON.stringify(node.config || {})), + x: Math.max(12, Number(node.x || 0) + offset), + y: Math.max(12, Number(node.y || 0) + offset), + })); + const copiedEdges = flowClipboard.edges.map(edge => ({ + ...edge, + id: newFlowId('edge'), + from: idMap.get(edge.from), + to: idMap.get(edge.to), + })); + app.flowDraft.nodes.push(...copies); + app.flowDraft.edges.push(...copiedEdges); + app.flowSelectedNodeIds = copies.map(node => node.id); + app.flowSelectedNodeId = copies[copies.length - 1]?.id || null; + app.flowDirty = true; + renderFlowEditor(); + if (notify) toast(tr('flow.blocksPasted', { count: copies.length })); + return true; +} + +function duplicateSelectedFlowNodes() { + const count = (app.flowSelectedNodeIds || []).length; + if (!count || !copySelectedFlowNodes({ notify: false })) return; + if (pasteFlowNodes({ notify: false })) toast(tr('flow.blocksDuplicated', { count })); +} + function removeSelectedFlowNodes() { if (!app.flowDraft || !(app.flowSelectedNodeIds || []).length) return; const selected = new Set(app.flowSelectedNodeIds); @@ -1250,11 +1311,68 @@ function updateFlowConfig(input) { } let flowDrag = null; +let flowMarquee = null; + +function flowCanvasPoint(event) { + const workspace = $('#flowWorkspace'); + if (!workspace) return { x: 0, y: 0 }; + const rect = workspace.getBoundingClientRect(); + const zoom = app.flowZoom || 1; + return { + x: (event.clientX - rect.left + workspace.scrollLeft) / zoom, + y: (event.clientY - rect.top + workspace.scrollTop) / zoom, + }; +} + +function updateFlowMarqueeSelection(event) { + if (!flowMarquee || !app.flowDraft) return; + const current = flowCanvasPoint(event); + const left = Math.min(flowMarquee.start.x, current.x); + const top = Math.min(flowMarquee.start.y, current.y); + const right = Math.max(flowMarquee.start.x, current.x); + const bottom = Math.max(flowMarquee.start.y, current.y); + const marquee = $('#flowSelectionMarquee'); + if (marquee) { + marquee.hidden = false; + marquee.style.left = `${left}px`; + marquee.style.top = `${top}px`; + marquee.style.width = `${right - left}px`; + marquee.style.height = `${bottom - top}px`; + } + const hits = app.flowDraft.nodes.filter(node => { + const el = $(`[data-flow-node="${CSS.escape(node.id)}"]`); + const nodeLeft = Number(node.x || 0), nodeTop = Number(node.y || 0); + const nodeRight = nodeLeft + Number(el?.offsetWidth || 170); + const nodeBottom = nodeTop + Number(el?.offsetHeight || 96); + return nodeRight >= left && nodeLeft <= right && nodeBottom >= top && nodeTop <= bottom; + }).map(node => node.id); + const selected = [...new Set([...flowMarquee.baseSelection, ...hits])]; + app.flowSelectedNodeIds = selected; + app.flowSelectedNodeId = selected[selected.length - 1] || null; + $$('[data-flow-node]').forEach(el => el.classList.toggle('selected', selected.includes(el.dataset.flowNode))); + const count = $('#flowSelectionCount'); if (count) count.textContent = selected.length ? tr('flow.selectedCount', { count: selected.length }) : ''; +} + document.addEventListener('pointerdown', event => { if ($('#flowEditor')?.hidden) return; + if (event.button !== 0) return; const nodeEl = event.target.closest?.('[data-flow-node]'); if (!nodeEl) { - if (event.target.closest?.('#flowWorkspace') && !event.target.closest('button,input,select,label')) clearFlowSelection(); + const workspace = event.target.closest?.('#flowWorkspace'); + if (!workspace || event.target.closest?.('button,input,select,label,[data-flow-edge]')) return; + const additive = event.shiftKey || event.ctrlKey || event.metaKey; + flowMarquee = { + start: flowCanvasPoint(event), + clientX: event.clientX, + clientY: event.clientY, + baseSelection: additive ? [...(app.flowSelectedNodeIds || [])] : [], + additive, + moved: false, + pointerId: event.pointerId, + }; + workspace.setPointerCapture?.(event.pointerId); + const marquee = $('#flowSelectionMarquee'); if (marquee) marquee.hidden = true; + event.preventDefault(); return; } if (event.target.closest('button,input,select,label')) return; @@ -1271,6 +1389,12 @@ document.addEventListener('pointerdown', event => { nodeEl.setPointerCapture?.(event.pointerId); event.preventDefault(); }); document.addEventListener('pointermove', event => { + if (flowMarquee && app.flowDraft) { + flowMarquee.moved = flowMarquee.moved || Math.abs(event.clientX - flowMarquee.clientX) > 3 || Math.abs(event.clientY - flowMarquee.clientY) > 3; + if (flowMarquee.moved) updateFlowMarqueeSelection(event); + event.preventDefault(); + return; + } if (!flowDrag || !app.flowDraft) return; let dx = (event.clientX - flowDrag.x) / (app.flowZoom || 1), dy = (event.clientY - flowDrag.y) / (app.flowZoom || 1); const minLeft = Math.min(...flowDrag.starts.map(item => item.left)); @@ -1283,7 +1407,21 @@ document.addEventListener('pointermove', event => { }); app.flowDirty = true; renderFlowSaveStatus(); renderFlowEdges(); }); -document.addEventListener('pointerup', () => { flowDrag = null; }); +document.addEventListener('pointerup', event => { + if (flowMarquee) { + if (!flowMarquee.moved) { + app.flowSelectedNodeIds = flowMarquee.additive ? [...flowMarquee.baseSelection] : []; + app.flowSelectedNodeId = app.flowSelectedNodeIds[app.flowSelectedNodeIds.length - 1] || null; + } + const workspace = $('#flowWorkspace'); workspace?.releasePointerCapture?.(flowMarquee.pointerId); + const marquee = $('#flowSelectionMarquee'); if (marquee) marquee.hidden = true; + flowMarquee = null; + renderFlowEditor(); + event.preventDefault(); + return; + } + flowDrag = null; +}); document.addEventListener('keydown', event => { const flowCard = event.target.closest?.('[data-flow-card-id]'); @@ -1291,6 +1429,9 @@ document.addEventListener('keydown', event => { if ($('#flowEditor')?.hidden || !app.flowDraft) return; if (event.target.closest?.('input,select,textarea,[contenteditable="true"]')) return; if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'a') { event.preventDefault(); selectAllFlowNodes(); return; } + if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'c') { event.preventDefault(); copySelectedFlowNodes(); return; } + if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'v') { event.preventDefault(); pasteFlowNodes(); return; } + if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'd') { event.preventDefault(); duplicateSelectedFlowNodes(); return; } if ((event.key === 'Delete' || event.key === 'Backspace') && (app.flowSelectedNodeIds || []).length) { event.preventDefault(); removeSelectedFlowNodes(); } }); @@ -1333,6 +1474,7 @@ document.addEventListener('click', event => { else if (action === 'run-flow-dry-run') runFlowDryRun(); else if (action === 'flow-logs') openFlowLogs(); else if (action === 'flow-select-all') selectAllFlowNodes(); + else if (action === 'flow-duplicate-selection') duplicateSelectedFlowNodes(); else if (action === 'flow-clear-selection') clearFlowSelection(); else if (action === 'edit-flow-name') editFlowName(); else if (action === 'flow-shared-input-settings') {