This commit is contained in:
Mateusz Gruszczyński committed 2026-09-23 13:41:34 +02:00
1 parent abfbf0dd68
commit aa1cb05b2f
24 files changed
+749 -70

No files matched your search

Generated
+1 -1
View File
@@ -719,7 +719,7 @@ dependencies = [
[[package]] [[package]]
name = "gree-controller" name = "gree-controller"
version = "0.15.19" version = "0.15.20"
dependencies = [ dependencies = [
"aes", "aes",
"aes-gcm", "aes-gcm",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "gree-controller" name = "gree-controller"
version = "0.15.19" version = "0.15.20"
edition = "2021" edition = "2021"
authors = ["GREE Controller contributors"] authors = ["GREE Controller contributors"]
description = "Standalone local GREE HVAC controller with Web UI, SQLite and Home Assistant sensor support" description = "Standalone local GREE HVAC controller with Web UI, SQLite and Home Assistant sensor support"
+13 -13
View File
@@ -4,10 +4,10 @@ a4ec3874a2e3ab1bad28fb40bb620f7b01f64d01ad9b699306bf70ada31227db ./.gitignore
ab2b0e10ea3a02b56d689f994bd89fa71a61eff49306995e725f7104c122b53d ./Cargo.toml ab2b0e10ea3a02b56d689f994bd89fa71a61eff49306995e725f7104c122b53d ./Cargo.toml
19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE 19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE
98217f563d7ed22f8e36a8092cfb3fdba7efe217fea543f7edae4a836a38baab ./README.md 98217f563d7ed22f8e36a8092cfb3fdba7efe217fea543f7edae4a836a38baab ./README.md
135b04a5acf661be25afe52c7a6a7826cf24a892398e3a28d9c77503272562d5 ./SOURCE_MANIFEST.sha256 9b99f30c22e96e328366a8b3f5ea88be0a6405e6675b27b54c66e5ba16a2c1c4 ./SOURCE_MANIFEST.sha256
29e9821e5ed216ea3565dd6d8bc0b174ed3cf9fa72c4ccebbfa99e98db3ad8f9 ./build.rs 29e9821e5ed216ea3565dd6d8bc0b174ed3cf9fa72c4ccebbfa99e98db3ad8f9 ./build.rs
41e1b38bb5378e1fbcf59e2a956008eea2b3141cfef102c72cf88d28f5f4cf65 ./docs/API.md 41e1b38bb5378e1fbcf59e2a956008eea2b3141cfef102c72cf88d28f5f4cf65 ./docs/API.md
94585aab43325a139f6c291f2293d93206d382c25a095b46930b924aaf336765 ./docs/FLOW.md eab8cbca46f87ffcba537aa882e671a6c0ac368856f3aa13896617d272e7ae02 ./docs/FLOW.md
a273744fab39afc79defa0957d8f4a404abb222f32f35316075521cb3a6cc4b0 ./docs/openapi.json a273744fab39afc79defa0957d8f4a404abb222f32f35316075521cb3a6cc4b0 ./docs/openapi.json
77b8becf7a2c16a384ea85721d878b76e2d520549cb4455d9aa806e57e5a2ecd ./ha-addon/.env.example 77b8becf7a2c16a384ea85721d878b76e2d520549cb4455d9aa806e57e5a2ecd ./ha-addon/.env.example
0ca6e58170c2b8925194431ebbdb55f0c8b377fec5cd422cd2bd257e2d9328e3 ./ha-addon/Dockerfile 0ca6e58170c2b8925194431ebbdb55f0c8b377fec5cd422cd2bd257e2d9328e3 ./ha-addon/Dockerfile
@@ -43,8 +43,8 @@ cea12bc74c066a588a5ef0ef7bad9d4906bdbcea99f2d275429b0eefc94b4b5f ./ha-addon/run
081e27b6e43070d44f865335c30a42a5a29668eaa6a5ea5ab6967ac4447b8e06 ./ha-addon/sync-repository.sh 081e27b6e43070d44f865335c30a42a5a29668eaa6a5ea5ab6967ac4447b8e06 ./ha-addon/sync-repository.sh
f9e31de3d109ead32bf531c074d46a087df398d62488caee0b46b5e0a1c027f7 ./ha_addon.md f9e31de3d109ead32bf531c074d46a087df398d62488caee0b46b5e0a1c027f7 ./ha_addon.md
253a0bc912786e67ea7fc92a64e4a510ad973bec343a88ccfb1f28fca3e8cf01 ./lang/README.md 253a0bc912786e67ea7fc92a64e4a510ad973bec343a88ccfb1f28fca3e8cf01 ./lang/README.md
c5c95c19ceac8969435c6f7742c86e7efa4db9bf723a0b30b098acf7fe6295fa ./lang/en.json ea015719d066cab86aff9daab33c3a83a29def711195eca416652be45a033ef8 ./lang/en.json
9ebf7bcfd49220e828336f551251ffc86986772864e75a5a74493cee27929749 ./lang/pl.json fefb2710c34195a2d50c62aa107e2f284875990fa63f7da42c8019b0398c3387 ./lang/pl.json
d8459024f04ca514bd8e9d6bd3af872fb942fd85a7cdd5583f4a9d28aab6faba ./make_zip.py d8459024f04ca514bd8e9d6bd3af872fb942fd85a7cdd5583f4a9d28aab6faba ./make_zip.py
b14233a8987e53bbbdd6770386ba10fa166ec25c1e098275c173c544b37846fc ./presets/bedroom_window_night.json b14233a8987e53bbbdd6770386ba10fa166ec25c1e098275c173c544b37846fc ./presets/bedroom_window_night.json
1960841119c0b673fb2f03621b32237552fd828045304cd221401214b997c910 ./presets/device_resilience.json 1960841119c0b673fb2f03621b32237552fd828045304cd221401214b997c910 ./presets/device_resilience.json
@@ -96,18 +96,18 @@ bb7cd2c5b27c9dceec1d1d2846fbad9600df9c08e0ad07d13fcde97af533091c ./scripts/inst
e00d211e3885e30d7fed1e43b44e6fdad40a67019060156c0641816a93e3365f ./scripts/network-debug.sh e00d211e3885e30d7fed1e43b44e6fdad40a67019060156c0641816a93e3365f ./scripts/network-debug.sh
01952aa92b217f8eae2493b88870e2dec595100cd15c4d561ff11ae2b936c46f ./scripts/regenerate-sha.sh 01952aa92b217f8eae2493b88870e2dec595100cd15c4d561ff11ae2b936c46f ./scripts/regenerate-sha.sh
81345b6a0b51736bdbc98fd23199b62e4c721b4e7437e02dab7ea79b97dff29a ./scripts/service.sh 81345b6a0b51736bdbc98fd23199b62e4c721b4e7437e02dab7ea79b97dff29a ./scripts/service.sh
bd703ef841f9763f13367aaa5764381b9c59ee87210e806c24617f3cc0739a9b ./scripts/smoke.sh 36396ecfcfd30f50016b17a83cd76cbccd8debaacef45a0d6b4e61946fef098d ./scripts/smoke.sh
b50782b3742dfbf8a319c60571c968e93fdf8547db747c759edcffae68cb98bf ./scripts/update.sh b50782b3742dfbf8a319c60571c968e93fdf8547db747c759edcffae68cb98bf ./scripts/update.sh
ea84f5230da97447c60dfb4f8dadd0747c92fd1f7a74d5a65d6fdb6f23cf4b6d ./src/api.rs ea84f5230da97447c60dfb4f8dadd0747c92fd1f7a74d5a65d6fdb6f23cf4b6d ./src/api.rs
0db0d81da4b8161004ddcbed2096812c285ecec9d944771670e6c8edf0775175 ./src/api/assets.rs 0db0d81da4b8161004ddcbed2096812c285ecec9d944771670e6c8edf0775175 ./src/api/assets.rs
1139dacbd3f6ff94bf721eb5a60d469016fb935ddcf484d71bfeb249b72c833a ./src/api/auth.rs 1139dacbd3f6ff94bf721eb5a60d469016fb935ddcf484d71bfeb249b72c833a ./src/api/auth.rs
a652409de6e2e2897d67d19dc16a7d4209c5c35afda39081fef627a098a739b4 ./src/api/automations.rs 1eb8c3cd86beb4f23aef21a23d31c144a9b31ec5bbaeaf24536723da5e78b768 ./src/api/automations.rs
fffad7874465739cd6b508e9d7791387b864fa88420ce0f7e2fc933d129a2751 ./src/api/configuration.rs fffad7874465739cd6b508e9d7791387b864fa88420ce0f7e2fc933d129a2751 ./src/api/configuration.rs
19bc141286a336ad67e99079496eb0ae5126f986ab28be62b5f97758e5f6969c ./src/api/debug_tokens.rs 19bc141286a336ad67e99079496eb0ae5126f986ab28be62b5f97758e5f6969c ./src/api/debug_tokens.rs
ca21f6d5a407e2e3532899d53ba4e8682c2fe961a46d8cd870ddc459721513ef ./src/api/device_groups.rs ca21f6d5a407e2e3532899d53ba4e8682c2fe961a46d8cd870ddc459721513ef ./src/api/device_groups.rs
8f92909dc575ea6238f4a003f8bd4056c2752fa3247b9ab226e47f875c80b916 ./src/api/devices.rs 8f92909dc575ea6238f4a003f8bd4056c2752fa3247b9ab226e47f875c80b916 ./src/api/devices.rs
4b5b784670ae761a6848b9a33fe3fb391bebd39d65e87936907428711e96ca44 ./src/api/events.rs 4b5b784670ae761a6848b9a33fe3fb391bebd39d65e87936907428711e96ca44 ./src/api/events.rs
b1e9567cae7036af51945ce1219f537567c944c0402c2ff03edb1249403670b5 ./src/api/flows.rs 1c5bc76fca312e6cd5fce988b3bfee785068322337b609405c12e18c1448665a ./src/api/flows.rs
82c02fa135132ea3ebdb4d88dd1210dc536d22f987151d7f82595ee3d3d03440 ./src/api/gree_cloud.rs 82c02fa135132ea3ebdb4d88dd1210dc536d22f987151d7f82595ee3d3d03440 ./src/api/gree_cloud.rs
b61b0b4f196c5e72e47cf06ca0c14df3cf8eedbdef0242a9d2af33623663489f ./src/api/groups.rs b61b0b4f196c5e72e47cf06ca0c14df3cf8eedbdef0242a9d2af33623663489f ./src/api/groups.rs
b6d586bc42b166ac05648f3da7d9aeb88efde2e9eaab5642685eb4d9cf15f95a ./src/api/history.rs b6d586bc42b166ac05648f3da7d9aeb88efde2e9eaab5642685eb4d9cf15f95a ./src/api/history.rs
@@ -136,7 +136,7 @@ d6b9b70a04c774173c97f7426487d5e2932d7713e12ae3fb7f64cf2b4e0b3c10 ./src/db/sched
28ee42d08147b0f154af818065d7d014d74ee8ddd59fdef98115f5c5368056cc ./src/db/tests.rs 28ee42d08147b0f154af818065d7d014d74ee8ddd59fdef98115f5c5368056cc ./src/db/tests.rs
f90fbb14f29a20b799b3856bdaa22812a9f0d9dfa46693f1341c160dccda8a97 ./src/db/zone_history.rs f90fbb14f29a20b799b3856bdaa22812a9f0d9dfa46693f1341c160dccda8a97 ./src/db/zone_history.rs
0e49ea4a52017b0a8501d5101b1906a7a1e8d5158acb0bb65caa43cde2836ec0 ./src/engine.rs 0e49ea4a52017b0a8501d5101b1906a7a1e8d5158acb0bb65caa43cde2836ec0 ./src/engine.rs
e39d0bbc4c1dda7c5538b2c2abdbad7923e56f40ad7a9788c8dbd338244f2c41 ./src/engine/automations.rs b187f88dd821940a7c44ab57e49d46423c6d039ba3ebb8ba2601fb08e268b7fa ./src/engine/automations.rs
1dccb8779f87dfdbd4ccda3f02ec51273e7be2e34e85166861668685528eca79 ./src/engine/commands.rs 1dccb8779f87dfdbd4ccda3f02ec51273e7be2e34e85166861668685528eca79 ./src/engine/commands.rs
357ef92c3cc42b243af4da31ac52bb360d7444ae423f58d7a13bdee79bf0a748 ./src/engine/connectivity.rs 357ef92c3cc42b243af4da31ac52bb360d7444ae423f58d7a13bdee79bf0a748 ./src/engine/connectivity.rs
828657b3c13b68792991b5ca2a26ae0b1d5c417191c83c4e5f650f62b6440096 ./src/engine/control_plan.rs 828657b3c13b68792991b5ca2a26ae0b1d5c417191c83c4e5f650f62b6440096 ./src/engine/control_plan.rs
@@ -152,7 +152,7 @@ c73691c3637465ab0f79aecf7f5428d0317799a8ab5329ed564cf57963bbe86e ./src/engine/r
b0189f28d0f337dfd3ce0934da7a565e4a5535afc1c242214c26ed584b3b7840 ./src/engine/targets.rs b0189f28d0f337dfd3ce0934da7a565e4a5535afc1c242214c26ed584b3b7840 ./src/engine/targets.rs
682c8b3956c657b655746cbd19ee97ee2f4cf953bc9de67d6d70a1b14f3dd746 ./src/engine/temperature.rs 682c8b3956c657b655746cbd19ee97ee2f4cf953bc9de67d6d70a1b14f3dd746 ./src/engine/temperature.rs
833b72907375e75c3f42b3921ad70a8e298f5a93743bf0d0c19059aa272462d1 ./src/engine/temporary_thermostat.rs 833b72907375e75c3f42b3921ad70a8e298f5a93743bf0d0c19059aa272462d1 ./src/engine/temporary_thermostat.rs
0d4f2df3c3881aceb41b9f8d78adde7ecbb5b39d2f8aea444b865166a0670dec ./src/engine/tests.rs cea4b27a419fd8eb92f755cd19c79c70bc164f2f44199a9253587df574f534f1 ./src/engine/tests.rs
c89ff36c6a67bf4fbf68c43e18aa0a556de2dc69263fd79db376363afc741a4f ./src/engine/zone_actions.rs c89ff36c6a67bf4fbf68c43e18aa0a556de2dc69263fd79db376363afc741a4f ./src/engine/zone_actions.rs
87ab87d434a6462a2c3f25249b42c59ecb2bca6ed700ec73e95e4613349cf16f ./src/engine/zone_control.rs 87ab87d434a6462a2c3f25249b42c59ecb2bca6ed700ec73e95e4613349cf16f ./src/engine/zone_control.rs
bfac95cec87f0df6ce562153cd3cfe6731359d703e6c592d2c2f7ba01ea62d99 ./src/error.rs bfac95cec87f0df6ce562153cd3cfe6731359d703e6c592d2c2f7ba01ea62d99 ./src/error.rs
@@ -163,7 +163,7 @@ da4068295f37c23222bb13bebac022d88ba7729604a49ad25438c55e45458a49 ./src/influxdb
67e42a92295480d2f1d364958f184654cfb5d4b77e7e1df47f588bdec18868a0 ./src/influxdb/write.rs 67e42a92295480d2f1d364958f184654cfb5d4b77e7e1df47f588bdec18868a0 ./src/influxdb/write.rs
62f9da6ef4f5fd92701d0857773f9f6236ef25356f21b9b42236abab6ba7da53 ./src/main.rs 62f9da6ef4f5fd92701d0857773f9f6236ef25356f21b9b42236abab6ba7da53 ./src/main.rs
23688a5cb68efded89471ce959328d27adff370d451bd9f378a283ab51437e76 ./src/models.rs 23688a5cb68efded89471ce959328d27adff370d451bd9f378a283ab51437e76 ./src/models.rs
a7d40ff2b00c6352223bd6335dc734925c42d70be2c70d1cb0b23051baa6f867 ./src/models/automation.rs 615fba5dc5f3ad75497d637b02cc83b7aacfe7baa6372c034dd5609bfff398a0 ./src/models/automation.rs
e4195a40135f408302a78cb200a54e6ef1c0e6031fb069a2431d068e5dbbdd8d ./src/models/control_plan.rs e4195a40135f408302a78cb200a54e6ef1c0e6031fb069a2431d068e5dbbdd8d ./src/models/control_plan.rs
06357b352ff6494d83e72796b869573a8b8f36b835985a5900ac571c922b8da9 ./src/models/defaults.rs 06357b352ff6494d83e72796b869573a8b8f36b835985a5900ac571c922b8da9 ./src/models/defaults.rs
698dcbeba72037a251c25d07c95833f121716d949f9217f1f9429e8cd40638e5 ./src/models/device.rs 698dcbeba72037a251c25d07c95833f121716d949f9217f1f9429e8cd40638e5 ./src/models/device.rs
@@ -199,10 +199,10 @@ f13f1be3d5789539eba3fc1b59c14835f8c681eca7634e118fa3763a53aeee2b ./src/queries/
d8a602ed8906d77593bd140315ce1bd56716038e789f53ff31762e9b3cb7220d ./src/state.rs d8a602ed8906d77593bd140315ce1bd56716038e789f53ff31762e9b3cb7220d ./src/state.rs
b92a6cb158b494fe145b43c7641e65f6fafff47201d7d76edbec2cfd8b94835c ./systemd/gree-controller.service b92a6cb158b494fe145b43c7641e65f6fafff47201d7d76edbec2cfd8b94835c ./systemd/gree-controller.service
544a31cb2e5374227026afd6abd0d910381f3fca1638154ebd74e3c3d96a35d1 ./web/404.html 544a31cb2e5374227026afd6abd0d910381f3fca1638154ebd74e3c3d96a35d1 ./web/404.html
da9cb50e003fa25989a693741c81ceb8b36c2bebbbb370c42184e2a89057c83c ./web/css/styles.css 6a30a5ecee99d2cf40381b9f770ed574e9e85b8b50e3425a77e6f64283faeee6 ./web/css/styles.css
183d122ba366303d9952ac0e68aff4bced4e0d9a468fbe51316c13f15227402b ./web/custom-chart.html 183d122ba366303d9952ac0e68aff4bced4e0d9a468fbe51316c13f15227402b ./web/custom-chart.html
e98bdd7204349cce1ec6f57283509697af0bbc72280622a6c3efa6fed242db4f ./web/favicon.svg e98bdd7204349cce1ec6f57283509697af0bbc72280622a6c3efa6fed242db4f ./web/favicon.svg
43f33fc21bb11df0ea149fe7fbca722a57f93890e4d9bb542ced703c1311207e ./web/index.html 78996e460243aa3043bd889891a99b9417d930beb7c9ea81b4a7f496e3d58844 ./web/index.html
9ba3f6fa05b72aa989139b2a909982571b2a02055052e4c40104f1e81a9aa7eb ./web/js-dynamic/README.md 9ba3f6fa05b72aa989139b2a909982571b2a02055052e4c40104f1e81a9aa7eb ./web/js-dynamic/README.md
e7318cd92b76e8a0109dc01adc2431474be17d8bb1488cdf6ffb39dc5450909d ./web/js-dynamic/bootstrap.js e7318cd92b76e8a0109dc01adc2431474be17d8bb1488cdf6ffb39dc5450909d ./web/js-dynamic/bootstrap.js
5d57253a013fd1cb79e4b8701d4be6e394e3f2acbce73dfcd726a86e5878f1a1 ./web/js-dynamic/charts.js 5d57253a013fd1cb79e4b8701d4be6e394e3f2acbce73dfcd726a86e5878f1a1 ./web/js-dynamic/charts.js
@@ -210,7 +210,7 @@ e7318cd92b76e8a0109dc01adc2431474be17d8bb1488cdf6ffb39dc5450909d ./web/js-dynam
80aaf5c56375345c67fd30c4fc1622319e4749b2859ec10613f77ac117f0441e ./web/js-dynamic/dashboard.js 80aaf5c56375345c67fd30c4fc1622319e4749b2859ec10613f77ac117f0441e ./web/js-dynamic/dashboard.js
930e67432f55e8476513e808edfa9a0809a8da7479307284fcc136082a4129a4 ./web/js-dynamic/entities.js 930e67432f55e8476513e808edfa9a0809a8da7479307284fcc136082a4129a4 ./web/js-dynamic/entities.js
4612c958d7b9ccf89d6d45a11c3bc7e864bbd2ceaccc16fd7d76811ba4c74196 ./web/js-dynamic/events.js 4612c958d7b9ccf89d6d45a11c3bc7e864bbd2ceaccc16fd7d76811ba4c74196 ./web/js-dynamic/events.js
43c78a3871cfdb0382081ec30b8d48650450c71b44d006414b161349ac1fa659 ./web/js-dynamic/flows.js 3e52db4f0ab5d721a047bf5a8f690c2d634c1cb24433d50d95bcbaa617115bcc ./web/js-dynamic/flows.js
3ae714de6b71c068ba665c81a7689f994b1716284e8d937db669ad500f12cf63 ./web/js-dynamic/forms.js 3ae714de6b71c068ba665c81a7689f994b1716284e8d937db669ad500f12cf63 ./web/js-dynamic/forms.js
92d013bb5d64f64aa8c55380704540a5f72732bde7ccd16554965e401daf50ea ./web/js-dynamic/history.js 92d013bb5d64f64aa8c55380704540a5f72732bde7ccd16554965e401daf50ea ./web/js-dynamic/history.js
511c4e7cc4c48fbbdab64558a9d1c27e6759c9b6dca255d11f301b9708e955c7 ./web/js-dynamic/main.js 511c4e7cc4c48fbbdab64558a9d1c27e6759c9b6dca255d11f301b9708e955c7 ./web/js-dynamic/main.js
+2 -2
View File
@@ -2,9 +2,9 @@
Self-hosted controller for GREE-compatible air conditioners with a local Web UI, thermostat zones, schedules, Home Assistant integration, history, notifications and a documented HTTP/WebSocket API. 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.19** **Version: 0.15.20**
Version 0.15.19 fixes Flow thermostat OFF semantics: `zone_thermostat power=false` powers the assigned unit off without disabling the Zone, so Flow and thermostat evaluation remain active. Version 0.15.20 adds the Visual Flow heating-curve block with graphical X/Y points, selectable temperature sources, linear interpolation and dynamic setpoint binding for thermostats, GREE targets and Home Assistant service actions.
> [Full API reference](docs/API.md) — authentication, every endpoint, request bodies, response models, WebSocket events and examples. > [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`. > Built-in API: `/api/*` and `/ws`; interactive Swagger docs: `/api-docs`; OpenAPI JSON: `/api-docs/openapi.json`.
+12 -12
View File
@@ -6,7 +6,7 @@ ab2b0e10ea3a02b56d689f994bd89fa71a61eff49306995e725f7104c122b53d ./Cargo.toml
98217f563d7ed22f8e36a8092cfb3fdba7efe217fea543f7edae4a836a38baab ./README.md 98217f563d7ed22f8e36a8092cfb3fdba7efe217fea543f7edae4a836a38baab ./README.md
29e9821e5ed216ea3565dd6d8bc0b174ed3cf9fa72c4ccebbfa99e98db3ad8f9 ./build.rs 29e9821e5ed216ea3565dd6d8bc0b174ed3cf9fa72c4ccebbfa99e98db3ad8f9 ./build.rs
41e1b38bb5378e1fbcf59e2a956008eea2b3141cfef102c72cf88d28f5f4cf65 ./docs/API.md 41e1b38bb5378e1fbcf59e2a956008eea2b3141cfef102c72cf88d28f5f4cf65 ./docs/API.md
94585aab43325a139f6c291f2293d93206d382c25a095b46930b924aaf336765 ./docs/FLOW.md eab8cbca46f87ffcba537aa882e671a6c0ac368856f3aa13896617d272e7ae02 ./docs/FLOW.md
a273744fab39afc79defa0957d8f4a404abb222f32f35316075521cb3a6cc4b0 ./docs/openapi.json a273744fab39afc79defa0957d8f4a404abb222f32f35316075521cb3a6cc4b0 ./docs/openapi.json
77b8becf7a2c16a384ea85721d878b76e2d520549cb4455d9aa806e57e5a2ecd ./ha-addon/.env.example 77b8becf7a2c16a384ea85721d878b76e2d520549cb4455d9aa806e57e5a2ecd ./ha-addon/.env.example
0ca6e58170c2b8925194431ebbdb55f0c8b377fec5cd422cd2bd257e2d9328e3 ./ha-addon/Dockerfile 0ca6e58170c2b8925194431ebbdb55f0c8b377fec5cd422cd2bd257e2d9328e3 ./ha-addon/Dockerfile
@@ -42,8 +42,8 @@ cea12bc74c066a588a5ef0ef7bad9d4906bdbcea99f2d275429b0eefc94b4b5f ./ha-addon/run
081e27b6e43070d44f865335c30a42a5a29668eaa6a5ea5ab6967ac4447b8e06 ./ha-addon/sync-repository.sh 081e27b6e43070d44f865335c30a42a5a29668eaa6a5ea5ab6967ac4447b8e06 ./ha-addon/sync-repository.sh
f9e31de3d109ead32bf531c074d46a087df398d62488caee0b46b5e0a1c027f7 ./ha_addon.md f9e31de3d109ead32bf531c074d46a087df398d62488caee0b46b5e0a1c027f7 ./ha_addon.md
253a0bc912786e67ea7fc92a64e4a510ad973bec343a88ccfb1f28fca3e8cf01 ./lang/README.md 253a0bc912786e67ea7fc92a64e4a510ad973bec343a88ccfb1f28fca3e8cf01 ./lang/README.md
c5c95c19ceac8969435c6f7742c86e7efa4db9bf723a0b30b098acf7fe6295fa ./lang/en.json ea015719d066cab86aff9daab33c3a83a29def711195eca416652be45a033ef8 ./lang/en.json
9ebf7bcfd49220e828336f551251ffc86986772864e75a5a74493cee27929749 ./lang/pl.json fefb2710c34195a2d50c62aa107e2f284875990fa63f7da42c8019b0398c3387 ./lang/pl.json
d8459024f04ca514bd8e9d6bd3af872fb942fd85a7cdd5583f4a9d28aab6faba ./make_zip.py d8459024f04ca514bd8e9d6bd3af872fb942fd85a7cdd5583f4a9d28aab6faba ./make_zip.py
b14233a8987e53bbbdd6770386ba10fa166ec25c1e098275c173c544b37846fc ./presets/bedroom_window_night.json b14233a8987e53bbbdd6770386ba10fa166ec25c1e098275c173c544b37846fc ./presets/bedroom_window_night.json
1960841119c0b673fb2f03621b32237552fd828045304cd221401214b997c910 ./presets/device_resilience.json 1960841119c0b673fb2f03621b32237552fd828045304cd221401214b997c910 ./presets/device_resilience.json
@@ -95,18 +95,18 @@ bb7cd2c5b27c9dceec1d1d2846fbad9600df9c08e0ad07d13fcde97af533091c ./scripts/inst
e00d211e3885e30d7fed1e43b44e6fdad40a67019060156c0641816a93e3365f ./scripts/network-debug.sh e00d211e3885e30d7fed1e43b44e6fdad40a67019060156c0641816a93e3365f ./scripts/network-debug.sh
01952aa92b217f8eae2493b88870e2dec595100cd15c4d561ff11ae2b936c46f ./scripts/regenerate-sha.sh 01952aa92b217f8eae2493b88870e2dec595100cd15c4d561ff11ae2b936c46f ./scripts/regenerate-sha.sh
81345b6a0b51736bdbc98fd23199b62e4c721b4e7437e02dab7ea79b97dff29a ./scripts/service.sh 81345b6a0b51736bdbc98fd23199b62e4c721b4e7437e02dab7ea79b97dff29a ./scripts/service.sh
bd703ef841f9763f13367aaa5764381b9c59ee87210e806c24617f3cc0739a9b ./scripts/smoke.sh 36396ecfcfd30f50016b17a83cd76cbccd8debaacef45a0d6b4e61946fef098d ./scripts/smoke.sh
b50782b3742dfbf8a319c60571c968e93fdf8547db747c759edcffae68cb98bf ./scripts/update.sh b50782b3742dfbf8a319c60571c968e93fdf8547db747c759edcffae68cb98bf ./scripts/update.sh
ea84f5230da97447c60dfb4f8dadd0747c92fd1f7a74d5a65d6fdb6f23cf4b6d ./src/api.rs ea84f5230da97447c60dfb4f8dadd0747c92fd1f7a74d5a65d6fdb6f23cf4b6d ./src/api.rs
0db0d81da4b8161004ddcbed2096812c285ecec9d944771670e6c8edf0775175 ./src/api/assets.rs 0db0d81da4b8161004ddcbed2096812c285ecec9d944771670e6c8edf0775175 ./src/api/assets.rs
1139dacbd3f6ff94bf721eb5a60d469016fb935ddcf484d71bfeb249b72c833a ./src/api/auth.rs 1139dacbd3f6ff94bf721eb5a60d469016fb935ddcf484d71bfeb249b72c833a ./src/api/auth.rs
a652409de6e2e2897d67d19dc16a7d4209c5c35afda39081fef627a098a739b4 ./src/api/automations.rs 1eb8c3cd86beb4f23aef21a23d31c144a9b31ec5bbaeaf24536723da5e78b768 ./src/api/automations.rs
fffad7874465739cd6b508e9d7791387b864fa88420ce0f7e2fc933d129a2751 ./src/api/configuration.rs fffad7874465739cd6b508e9d7791387b864fa88420ce0f7e2fc933d129a2751 ./src/api/configuration.rs
19bc141286a336ad67e99079496eb0ae5126f986ab28be62b5f97758e5f6969c ./src/api/debug_tokens.rs 19bc141286a336ad67e99079496eb0ae5126f986ab28be62b5f97758e5f6969c ./src/api/debug_tokens.rs
ca21f6d5a407e2e3532899d53ba4e8682c2fe961a46d8cd870ddc459721513ef ./src/api/device_groups.rs ca21f6d5a407e2e3532899d53ba4e8682c2fe961a46d8cd870ddc459721513ef ./src/api/device_groups.rs
8f92909dc575ea6238f4a003f8bd4056c2752fa3247b9ab226e47f875c80b916 ./src/api/devices.rs 8f92909dc575ea6238f4a003f8bd4056c2752fa3247b9ab226e47f875c80b916 ./src/api/devices.rs
4b5b784670ae761a6848b9a33fe3fb391bebd39d65e87936907428711e96ca44 ./src/api/events.rs 4b5b784670ae761a6848b9a33fe3fb391bebd39d65e87936907428711e96ca44 ./src/api/events.rs
b1e9567cae7036af51945ce1219f537567c944c0402c2ff03edb1249403670b5 ./src/api/flows.rs 1c5bc76fca312e6cd5fce988b3bfee785068322337b609405c12e18c1448665a ./src/api/flows.rs
82c02fa135132ea3ebdb4d88dd1210dc536d22f987151d7f82595ee3d3d03440 ./src/api/gree_cloud.rs 82c02fa135132ea3ebdb4d88dd1210dc536d22f987151d7f82595ee3d3d03440 ./src/api/gree_cloud.rs
b61b0b4f196c5e72e47cf06ca0c14df3cf8eedbdef0242a9d2af33623663489f ./src/api/groups.rs b61b0b4f196c5e72e47cf06ca0c14df3cf8eedbdef0242a9d2af33623663489f ./src/api/groups.rs
b6d586bc42b166ac05648f3da7d9aeb88efde2e9eaab5642685eb4d9cf15f95a ./src/api/history.rs b6d586bc42b166ac05648f3da7d9aeb88efde2e9eaab5642685eb4d9cf15f95a ./src/api/history.rs
@@ -135,7 +135,7 @@ d6b9b70a04c774173c97f7426487d5e2932d7713e12ae3fb7f64cf2b4e0b3c10 ./src/db/sched
28ee42d08147b0f154af818065d7d014d74ee8ddd59fdef98115f5c5368056cc ./src/db/tests.rs 28ee42d08147b0f154af818065d7d014d74ee8ddd59fdef98115f5c5368056cc ./src/db/tests.rs
f90fbb14f29a20b799b3856bdaa22812a9f0d9dfa46693f1341c160dccda8a97 ./src/db/zone_history.rs f90fbb14f29a20b799b3856bdaa22812a9f0d9dfa46693f1341c160dccda8a97 ./src/db/zone_history.rs
0e49ea4a52017b0a8501d5101b1906a7a1e8d5158acb0bb65caa43cde2836ec0 ./src/engine.rs 0e49ea4a52017b0a8501d5101b1906a7a1e8d5158acb0bb65caa43cde2836ec0 ./src/engine.rs
e39d0bbc4c1dda7c5538b2c2abdbad7923e56f40ad7a9788c8dbd338244f2c41 ./src/engine/automations.rs b187f88dd821940a7c44ab57e49d46423c6d039ba3ebb8ba2601fb08e268b7fa ./src/engine/automations.rs
1dccb8779f87dfdbd4ccda3f02ec51273e7be2e34e85166861668685528eca79 ./src/engine/commands.rs 1dccb8779f87dfdbd4ccda3f02ec51273e7be2e34e85166861668685528eca79 ./src/engine/commands.rs
357ef92c3cc42b243af4da31ac52bb360d7444ae423f58d7a13bdee79bf0a748 ./src/engine/connectivity.rs 357ef92c3cc42b243af4da31ac52bb360d7444ae423f58d7a13bdee79bf0a748 ./src/engine/connectivity.rs
828657b3c13b68792991b5ca2a26ae0b1d5c417191c83c4e5f650f62b6440096 ./src/engine/control_plan.rs 828657b3c13b68792991b5ca2a26ae0b1d5c417191c83c4e5f650f62b6440096 ./src/engine/control_plan.rs
@@ -151,7 +151,7 @@ c73691c3637465ab0f79aecf7f5428d0317799a8ab5329ed564cf57963bbe86e ./src/engine/r
b0189f28d0f337dfd3ce0934da7a565e4a5535afc1c242214c26ed584b3b7840 ./src/engine/targets.rs b0189f28d0f337dfd3ce0934da7a565e4a5535afc1c242214c26ed584b3b7840 ./src/engine/targets.rs
682c8b3956c657b655746cbd19ee97ee2f4cf953bc9de67d6d70a1b14f3dd746 ./src/engine/temperature.rs 682c8b3956c657b655746cbd19ee97ee2f4cf953bc9de67d6d70a1b14f3dd746 ./src/engine/temperature.rs
833b72907375e75c3f42b3921ad70a8e298f5a93743bf0d0c19059aa272462d1 ./src/engine/temporary_thermostat.rs 833b72907375e75c3f42b3921ad70a8e298f5a93743bf0d0c19059aa272462d1 ./src/engine/temporary_thermostat.rs
0d4f2df3c3881aceb41b9f8d78adde7ecbb5b39d2f8aea444b865166a0670dec ./src/engine/tests.rs cea4b27a419fd8eb92f755cd19c79c70bc164f2f44199a9253587df574f534f1 ./src/engine/tests.rs
c89ff36c6a67bf4fbf68c43e18aa0a556de2dc69263fd79db376363afc741a4f ./src/engine/zone_actions.rs c89ff36c6a67bf4fbf68c43e18aa0a556de2dc69263fd79db376363afc741a4f ./src/engine/zone_actions.rs
87ab87d434a6462a2c3f25249b42c59ecb2bca6ed700ec73e95e4613349cf16f ./src/engine/zone_control.rs 87ab87d434a6462a2c3f25249b42c59ecb2bca6ed700ec73e95e4613349cf16f ./src/engine/zone_control.rs
bfac95cec87f0df6ce562153cd3cfe6731359d703e6c592d2c2f7ba01ea62d99 ./src/error.rs bfac95cec87f0df6ce562153cd3cfe6731359d703e6c592d2c2f7ba01ea62d99 ./src/error.rs
@@ -162,7 +162,7 @@ da4068295f37c23222bb13bebac022d88ba7729604a49ad25438c55e45458a49 ./src/influxdb
67e42a92295480d2f1d364958f184654cfb5d4b77e7e1df47f588bdec18868a0 ./src/influxdb/write.rs 67e42a92295480d2f1d364958f184654cfb5d4b77e7e1df47f588bdec18868a0 ./src/influxdb/write.rs
62f9da6ef4f5fd92701d0857773f9f6236ef25356f21b9b42236abab6ba7da53 ./src/main.rs 62f9da6ef4f5fd92701d0857773f9f6236ef25356f21b9b42236abab6ba7da53 ./src/main.rs
23688a5cb68efded89471ce959328d27adff370d451bd9f378a283ab51437e76 ./src/models.rs 23688a5cb68efded89471ce959328d27adff370d451bd9f378a283ab51437e76 ./src/models.rs
a7d40ff2b00c6352223bd6335dc734925c42d70be2c70d1cb0b23051baa6f867 ./src/models/automation.rs 615fba5dc5f3ad75497d637b02cc83b7aacfe7baa6372c034dd5609bfff398a0 ./src/models/automation.rs
e4195a40135f408302a78cb200a54e6ef1c0e6031fb069a2431d068e5dbbdd8d ./src/models/control_plan.rs e4195a40135f408302a78cb200a54e6ef1c0e6031fb069a2431d068e5dbbdd8d ./src/models/control_plan.rs
06357b352ff6494d83e72796b869573a8b8f36b835985a5900ac571c922b8da9 ./src/models/defaults.rs 06357b352ff6494d83e72796b869573a8b8f36b835985a5900ac571c922b8da9 ./src/models/defaults.rs
698dcbeba72037a251c25d07c95833f121716d949f9217f1f9429e8cd40638e5 ./src/models/device.rs 698dcbeba72037a251c25d07c95833f121716d949f9217f1f9429e8cd40638e5 ./src/models/device.rs
@@ -198,10 +198,10 @@ f13f1be3d5789539eba3fc1b59c14835f8c681eca7634e118fa3763a53aeee2b ./src/queries/
d8a602ed8906d77593bd140315ce1bd56716038e789f53ff31762e9b3cb7220d ./src/state.rs d8a602ed8906d77593bd140315ce1bd56716038e789f53ff31762e9b3cb7220d ./src/state.rs
b92a6cb158b494fe145b43c7641e65f6fafff47201d7d76edbec2cfd8b94835c ./systemd/gree-controller.service b92a6cb158b494fe145b43c7641e65f6fafff47201d7d76edbec2cfd8b94835c ./systemd/gree-controller.service
544a31cb2e5374227026afd6abd0d910381f3fca1638154ebd74e3c3d96a35d1 ./web/404.html 544a31cb2e5374227026afd6abd0d910381f3fca1638154ebd74e3c3d96a35d1 ./web/404.html
da9cb50e003fa25989a693741c81ceb8b36c2bebbbb370c42184e2a89057c83c ./web/css/styles.css 6a30a5ecee99d2cf40381b9f770ed574e9e85b8b50e3425a77e6f64283faeee6 ./web/css/styles.css
183d122ba366303d9952ac0e68aff4bced4e0d9a468fbe51316c13f15227402b ./web/custom-chart.html 183d122ba366303d9952ac0e68aff4bced4e0d9a468fbe51316c13f15227402b ./web/custom-chart.html
e98bdd7204349cce1ec6f57283509697af0bbc72280622a6c3efa6fed242db4f ./web/favicon.svg e98bdd7204349cce1ec6f57283509697af0bbc72280622a6c3efa6fed242db4f ./web/favicon.svg
43f33fc21bb11df0ea149fe7fbca722a57f93890e4d9bb542ced703c1311207e ./web/index.html 78996e460243aa3043bd889891a99b9417d930beb7c9ea81b4a7f496e3d58844 ./web/index.html
9ba3f6fa05b72aa989139b2a909982571b2a02055052e4c40104f1e81a9aa7eb ./web/js-dynamic/README.md 9ba3f6fa05b72aa989139b2a909982571b2a02055052e4c40104f1e81a9aa7eb ./web/js-dynamic/README.md
e7318cd92b76e8a0109dc01adc2431474be17d8bb1488cdf6ffb39dc5450909d ./web/js-dynamic/bootstrap.js e7318cd92b76e8a0109dc01adc2431474be17d8bb1488cdf6ffb39dc5450909d ./web/js-dynamic/bootstrap.js
5d57253a013fd1cb79e4b8701d4be6e394e3f2acbce73dfcd726a86e5878f1a1 ./web/js-dynamic/charts.js 5d57253a013fd1cb79e4b8701d4be6e394e3f2acbce73dfcd726a86e5878f1a1 ./web/js-dynamic/charts.js
@@ -209,7 +209,7 @@ e7318cd92b76e8a0109dc01adc2431474be17d8bb1488cdf6ffb39dc5450909d ./web/js-dynam
80aaf5c56375345c67fd30c4fc1622319e4749b2859ec10613f77ac117f0441e ./web/js-dynamic/dashboard.js 80aaf5c56375345c67fd30c4fc1622319e4749b2859ec10613f77ac117f0441e ./web/js-dynamic/dashboard.js
930e67432f55e8476513e808edfa9a0809a8da7479307284fcc136082a4129a4 ./web/js-dynamic/entities.js 930e67432f55e8476513e808edfa9a0809a8da7479307284fcc136082a4129a4 ./web/js-dynamic/entities.js
4612c958d7b9ccf89d6d45a11c3bc7e864bbd2ceaccc16fd7d76811ba4c74196 ./web/js-dynamic/events.js 4612c958d7b9ccf89d6d45a11c3bc7e864bbd2ceaccc16fd7d76811ba4c74196 ./web/js-dynamic/events.js
43c78a3871cfdb0382081ec30b8d48650450c71b44d006414b161349ac1fa659 ./web/js-dynamic/flows.js 3e52db4f0ab5d721a047bf5a8f690c2d634c1cb24433d50d95bcbaa617115bcc ./web/js-dynamic/flows.js
3ae714de6b71c068ba665c81a7689f994b1716284e8d937db669ad500f12cf63 ./web/js-dynamic/forms.js 3ae714de6b71c068ba665c81a7689f994b1716284e8d937db669ad500f12cf63 ./web/js-dynamic/forms.js
92d013bb5d64f64aa8c55380704540a5f72732bde7ccd16554965e401daf50ea ./web/js-dynamic/history.js 92d013bb5d64f64aa8c55380704540a5f72732bde7ccd16554965e401daf50ea ./web/js-dynamic/history.js
511c4e7cc4c48fbbdab64558a9d1c27e6759c9b6dca255d11f301b9708e955c7 ./web/js-dynamic/main.js 511c4e7cc4c48fbbdab64558a9d1c27e6759c9b6dca255d11f301b9708e955c7 ./web/js-dynamic/main.js
+3 -3
View File
@@ -1,6 +1,6 @@
# GREE Controller API reference # GREE Controller API reference
HTTP and WebSocket API for GREE Controller **0.15.19**. HTTP and WebSocket API for GREE Controller **0.15.20**.
[← Main documentation](../README.md) [← Main documentation](../README.md)
@@ -243,7 +243,7 @@ Response:
{ {
"status": "ok", "status": "ok",
"name": "gree-controller", "name": "gree-controller",
"version": "0.15.19", "version": "0.15.20",
"uptime_seconds": 1234, "uptime_seconds": 1234,
"control_ready": true, "control_ready": true,
"time": "2026-08-30T06:54:00Z" "time": "2026-08-30T06:54:00Z"
@@ -306,7 +306,7 @@ Returns the initial Web UI snapshot:
"control_plan": {"generated_at": "2026-09-04T08:00:00Z", "zones": [], "rules": []}, "control_plan": {"generated_at": "2026-09-04T08:00:00Z", "zones": [], "rules": []},
"control_plan_revision": 42, "control_plan_revision": 42,
"system": { "system": {
"version": "0.15.19", "version": "0.15.20",
"uptime_seconds": 1234, "uptime_seconds": 1234,
"auth_required": false, "auth_required": false,
"control_ready": true, "control_ready": true,
+5 -1
View File
@@ -60,7 +60,11 @@ Dry-run never mutates thermostats, devices, groups, schedules or automations. Fo
## Blocks ## 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. `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. 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, `heating_curve`, 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.
`heating_curve` is a numeric transform block. Its X axis is the selected temperature source (`outdoor_temperature`, a device temperature, a zone temperature, or a numeric Home Assistant entity) and its Y axis is the requested target temperature. The editor renders the configured X/Y points as a chart; runtime evaluation linearly interpolates between adjacent points and clamps values outside the configured X range to the nearest endpoint. If the source is unavailable, the block fails closed and the downstream action does not execute.
Connecting `heating_curve` **directly** to an action also creates a runtime value binding. For `zone_thermostat` and `group_action` the curve output becomes the custom setpoint; for `device_action` and the `target_temperature` single-feature GREE action it becomes `target_temperature`. Native thermostat/GREE bindings require all configured Y values to stay within 8–30 °C. For `ha_service_action`, the curve output is inserted into the configured top-level service-data field (`value_key`, default `temperature`), which allows wider heating-water/boiler setpoint ranges such as `climate.set_temperature`. The source graph remains the authority: the numeric value is evaluated immediately before execution from the same Flow trace and is not baked into the generated automation as a constant.
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. 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.
+2 -2
View File
@@ -2,7 +2,7 @@
"openapi": "3.1.0", "openapi": "3.1.0",
"info": { "info": {
"title": "GREE Controller API", "title": "GREE Controller API",
"version": "0.15.19", "version": "0.15.20",
"summary": "Local HTTP/WebSocket API for GREE Controller", "summary": "Local HTTP/WebSocket API for GREE Controller",
"description": "Local API used by the GREE Controller Web UI and Home Assistant integration.", "description": "Local API used by the GREE Controller Web UI and Home Assistant integration.",
"license": { "license": {
@@ -6079,7 +6079,7 @@
}, },
"version": { "version": {
"type": "string", "type": "string",
"example": "0.15.19" "example": "0.15.20"
}, },
"uptime_seconds": { "uptime_seconds": {
"type": "integer", "type": "integer",
@@ -1,7 +1,7 @@
{ {
"domain": "gree_controller", "domain": "gree_controller",
"name": "GREE Controller", "name": "GREE Controller",
"version": "0.15.19", "version": "0.15.20",
"config_flow": true, "config_flow": true,
"integration_type": "hub", "integration_type": "hub",
"iot_class": "local_polling", "iot_class": "local_polling",
@@ -1,5 +1,14 @@
# Changelog # Changelog
## 0.15.20
- Adds a new Visual Flow **Heating curve** block to the Sensors / values palette.
- Provides a graphical X/Y curve editor with outdoor, device, zone, or numeric Home Assistant temperature as the input source.
- Linearly interpolates target values between configured points and clamps values outside the X range to the nearest endpoint.
- Allows the curve output to drive zone/group thermostat setpoints, GREE target temperature, or a configurable Home Assistant service-data field.
- Shows the calculated dynamic value in Flow simulation/dry-run and adds validation/tests for curve points, native 8–30 °C targets, and wider Home Assistant outputs.
- Adds a smoke check that keeps Flow block metadata and the user-visible block palette in sync.
## 0.15.19 ## 0.15.19
- Fixes Visual Flow `zone_thermostat` OFF semantics: `power=false` now powers the assigned unit off without setting the persistent Zone `enabled` flag to false. - Fixes Visual Flow `zone_thermostat` OFF semantics: `power=false` now powers the assigned unit off without setting the persistent Zone `enabled` flag to false.
@@ -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`. 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.19 fixes Flow thermostat OFF semantics: `zone_thermostat power=false` powers the assigned unit off without disabling the Zone, so Flow and thermostat evaluation remain active. Version 0.15.20 adds the Visual Flow heating-curve block with graphical X/Y points, selectable temperature sources, linear interpolation and dynamic setpoint binding for thermostats, GREE targets and Home Assistant service actions.
![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) ![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)
@@ -1,5 +1,5 @@
name: "GREE Controller" name: "GREE Controller"
version: "0.15.19" version: "0.15.20"
slug: "gree_controller" slug: "gree_controller"
description: "Local GREE HVAC controller with Web UI, API, and Home Assistant integration" description: "Local GREE HVAC controller with Web UI, API, and Home Assistant integration"
url: "https://git.linuxiarz.pl/gru/gree-controller-ha-addon/" url: "https://git.linuxiarz.pl/gru/gree-controller-ha-addon/"
+16 -1
View File
@@ -1226,6 +1226,7 @@
"flow.node.cronTrigger": "CRON schedule", "flow.node.cronTrigger": "CRON schedule",
"flow.node.stableFor": "Stable for…", "flow.node.stableFor": "Stable for…",
"flow.node.rollingStat": "Mean / median", "flow.node.rollingStat": "Mean / median",
"flow.node.heatingCurve": "Heating curve",
"flow.node.haServiceAction": "Home Assistant service", "flow.node.haServiceAction": "Home Assistant service",
"flow.cronHint": "5 fields: minute hour day month weekday. Supports *, */N, ranges and lists.", "flow.cronHint": "5 fields: minute hour day month weekday. Supports *, */N, ranges and lists.",
"flow.durationSeconds": "Duration [s]", "flow.durationSeconds": "Duration [s]",
@@ -1248,6 +1249,19 @@
"flow.timeOps": "Time operations", "flow.timeOps": "Time operations",
"flow.haActions": "Home Assistant", "flow.haActions": "Home Assistant",
"flow.node.oscillates": "Value oscillates", "flow.node.oscillates": "Value oscillates",
"flow.heatingCurveSource": "Outdoor temperature source",
"flow.heatingCurvePoints": "Curve points",
"flow.heatingCurveAddPoint": "Add point",
"flow.heatingCurveAxisX": "X · outdoor temperature [°C]",
"flow.heatingCurveAxisY": "Y · target temperature [°C]",
"flow.heatingCurveChartLabel": "Heating curve chart: outdoor temperature to target temperature",
"flow.heatingCurveHint": "The result is linearly interpolated between points. Outside the configured range the nearest endpoint is used. Connect the curve directly to an action to pass the calculated temperature.",
"flow.heatingCurveNeedsPoints": "The curve needs at least two points.",
"flow.heatingCurveDuplicateX": "Each curve point must use a unique X value.",
"flow.heatingCurveNativeBinding": "The connected heating curve replaces the fixed target temperature with the curve output.",
"flow.heatingCurveFeatureWarning": "A heating curve can drive a single GREE feature block only when the selected feature is target temperature.",
"flow.heatingCurveHaValueKey": "Service-data field for curve output",
"flow.heatingCurveHaBindingHint": "At execution time the curve output is written to this Home Assistant service-data field, for example temperature for climate.set_temperature.",
"flow.minSpan": "Minimum value span", "flow.minSpan": "Minimum value span",
"flow.minDirectionChanges": "Minimum direction changes", "flow.minDirectionChanges": "Minimum direction changes",
"flow.oscillatesHint": "Within the time window the value must move both up and down. The block requires the configured max−min span and number of direction changes.", "flow.oscillatesHint": "Within the time window the value must move both up and down. The block requires the configured max−min span and number of direction changes.",
@@ -1533,6 +1547,7 @@
"flow.contextDelete": "Delete selection ({count})", "flow.contextDelete": "Delete selection ({count})",
"flow.contextDeleteConnection": "Delete connection", "flow.contextDeleteConnection": "Delete connection",
"flow.rightClickKey": "Right click", "flow.rightClickKey": "Right click",
"flow.shortcutContextMenu": "Flow context menu" "flow.shortcutContextMenu": "Flow context menu",
"flow.heatingCurveDryRunValue": "Value passed from curve"
} }
} }
+16 -1
View File
@@ -1226,6 +1226,7 @@
"flow.node.cronTrigger": "CRON / harmonogram", "flow.node.cronTrigger": "CRON / harmonogram",
"flow.node.stableFor": "Utrzymuje się przez…", "flow.node.stableFor": "Utrzymuje się przez…",
"flow.node.rollingStat": "Średnia / mediana", "flow.node.rollingStat": "Średnia / mediana",
"flow.node.heatingCurve": "Krzywa grzewcza",
"flow.node.haServiceAction": "Home Assistant: usługa", "flow.node.haServiceAction": "Home Assistant: usługa",
"flow.cronHint": "5 pól: minuta godzina dzień miesiąc dzień_tygodnia. Obsługiwane: *, */N, zakresy i listy.", "flow.cronHint": "5 pól: minuta godzina dzień miesiąc dzień_tygodnia. Obsługiwane: *, */N, zakresy i listy.",
"flow.durationSeconds": "Czas [s]", "flow.durationSeconds": "Czas [s]",
@@ -1248,6 +1249,19 @@
"flow.timeOps": "Operacje czasu", "flow.timeOps": "Operacje czasu",
"flow.haActions": "Home Assistant", "flow.haActions": "Home Assistant",
"flow.node.oscillates": "Wartość oscyluje", "flow.node.oscillates": "Wartość oscyluje",
"flow.heatingCurveSource": "Źródło temperatury zewnętrznej",
"flow.heatingCurvePoints": "Punkty krzywej",
"flow.heatingCurveAddPoint": "Dodaj punkt",
"flow.heatingCurveAxisX": "X · temperatura zewn. [°C]",
"flow.heatingCurveAxisY": "Y · temperatura zadana [°C]",
"flow.heatingCurveChartLabel": "Wykres krzywej grzewczej: temperatura zewnętrzna do temperatury zadanej",
"flow.heatingCurveHint": "Wynik jest interpolowany liniowo między punktami. Poza zakresem używany jest skrajny punkt. Połącz krzywą bezpośrednio z akcją, aby przekazać jej wyliczoną temperaturę.",
"flow.heatingCurveNeedsPoints": "Krzywa wymaga co najmniej dwóch punktów.",
"flow.heatingCurveDuplicateX": "Każdy punkt krzywej musi mieć inną wartość X.",
"flow.heatingCurveNativeBinding": "Podłączona krzywa grzewcza zastępuje stałą temperaturę docelową wynikiem krzywej.",
"flow.heatingCurveFeatureWarning": "Krzywa grzewcza może sterować blokiem pojedynczej funkcji GREE tylko po wybraniu funkcji „temperatura docelowa”.",
"flow.heatingCurveHaValueKey": "Pole danych usługi dla wyniku krzywej",
"flow.heatingCurveHaBindingHint": "Przy wykonaniu wynik krzywej zostanie wpisany do tego pola w danych usługi Home Assistant, np. temperature dla climate.set_temperature.",
"flow.minSpan": "Minimalna rozpiętość wartości", "flow.minSpan": "Minimalna rozpiętość wartości",
"flow.minDirectionChanges": "Minimalna liczba zmian kierunku", "flow.minDirectionChanges": "Minimalna liczba zmian kierunku",
"flow.oscillatesHint": "W oknie czasu wartość musi poruszać się w górę i w dół. Blok wymaga podanej rozpiętości (max−min) oraz liczby zmian kierunku.", "flow.oscillatesHint": "W oknie czasu wartość musi poruszać się w górę i w dół. Blok wymaga podanej rozpiętości (max−min) oraz liczby zmian kierunku.",
@@ -1533,6 +1547,7 @@
"flow.contextDelete": "Usuń zaznaczenie ({count})", "flow.contextDelete": "Usuń zaznaczenie ({count})",
"flow.contextDeleteConnection": "Usuń połączenie", "flow.contextDeleteConnection": "Usuń połączenie",
"flow.rightClickKey": "Prawy przycisk", "flow.rightClickKey": "Prawy przycisk",
"flow.shortcutContextMenu": "Menu kontekstowe Flow" "flow.shortcutContextMenu": "Menu kontekstowe Flow",
"flow.heatingCurveDryRunValue": "Wartość przekazana z krzywej"
} }
} }
+3 -3
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Developer smoke/integration tests for GREE Controller API 0.15.19. """Developer smoke/integration tests for GREE Controller API 0.15.20.
Default mode is read-only and safe to run against a real controller. 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 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_BASE_URL = os.environ.get("GREE_API_URL", "http://127.0.0.1:8787")
DEFAULT_TOKEN = os.environ.get("GREE_API_TOKEN", "") DEFAULT_TOKEN = os.environ.get("GREE_API_TOKEN", "")
DEFAULT_EXPECTED_VERSION = "0.15.19" DEFAULT_EXPECTED_VERSION = "0.15.20"
SETTINGS_PATHS: dict[str, set[str]] = { SETTINGS_PATHS: dict[str, set[str]] = {
"/api/settings/application": {"simulator_enabled"}, "/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") 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("public health", lambda: test_health(client, args.expected_version))
runner.run("OpenAPI 0.15.19 contract", lambda: test_openapi(client, args.expected_version)) runner.run("OpenAPI 0.15.20 contract", lambda: test_openapi(client, args.expected_version))
runner.run("bootstrap snapshot contract", lambda: test_bootstrap_contract(client)) runner.run("bootstrap snapshot contract", lambda: test_bootstrap_contract(client))
runner.run("protected API requires auth", lambda: test_protected_auth(client)) runner.run("protected API requires auth", lambda: test_protected_auth(client))
+25
View File
@@ -3,6 +3,10 @@ set -Eeuo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT" cd "$ROOT"
# Keep Flow metadata and the user-visible block palette in sync.
grep -q 'heating_curve:.*flow.node.heatingCurve' web/js-dynamic/flows.js
grep -q 'data-flow-add="heating_curve".*data-i18n="flow.node.heatingCurve"' web/index.html
BINARY="${GREE_CONTROLLER_TEST_BINARY:-$ROOT/target/debug/gree-controller}" BINARY="${GREE_CONTROLLER_TEST_BINARY:-$ROOT/target/debug/gree-controller}"
[[ -x "$BINARY" ]] || cargo build >/dev/null [[ -x "$BINARY" ]] || cargo build >/dev/null
TMP="$(mktemp -d)" TMP="$(mktemp -d)"
@@ -128,6 +132,27 @@ json.dump({"flow":flow,"flow_id":sys.argv[3],"at":datetime.datetime.now(datetime
PYFLOW PYFLOW
curl -fsS -X POST -H 'Content-Type: application/json' --data-binary @"$TMP/flow-simulate.json" "http://127.0.0.1:$PORT/api/flows/simulate" >"$TMP/flow-dry-run.json" curl -fsS -X POST -H 'Content-Type: application/json' --data-binary @"$TMP/flow-simulate.json" "http://127.0.0.1:$PORT/api/flows/simulate" >"$TMP/flow-dry-run.json"
grep -q '"dry_run":true' "$TMP/flow-dry-run.json" grep -q '"dry_run":true' "$TMP/flow-dry-run.json"
# Heating curve is a numeric Flow source: it interpolates X/Y and binds the result to the
# downstream thermostat setpoint without turning the graph into a native fixed schedule.
cat >"$TMP/flow-heating-curve.json" <<EOF
{"flow":{"name":"Heating curve","enabled":true,"nodes":[{"id":"curve","kind":"heating_curve","x":20,"y":20,"config":{"source":"outdoor_temperature","points":[{"x":-20,"y":30},{"x":0,"y":25},{"x":20,"y":19}]}},{"id":"action","kind":"zone_thermostat","x":300,"y":20,"config":{"zone_id":"$ZONE_ID","preset":"comfort","mode":"auto","cooldown_seconds":60}}],"edges":[{"id":"e1","from":"curve","to":"action"}]},"overrides":{"curve":0},"log":false}
EOF
curl -fsS -X POST -H 'Content-Type: application/json' --data-binary @"$TMP/flow-heating-curve.json" \
"http://127.0.0.1:$PORT/api/flows/simulate" >"$TMP/flow-heating-curve-result.json"
python3 - "$TMP/flow-heating-curve-result.json" <<'PYFLOW'
import json,sys
result=json.load(open(sys.argv[1]))
assert result["compiled"] == {"schedules": 0, "automations": 1}, result
action=result["actions"][0]
assert action["matched"] is True, action
assert action["dynamic_value"] == 25.0, action
assert action["dynamic_value_target"] == "setpoint", action
trace={item["node_id"]:item for item in action["trace"]}
assert trace["curve"]["actual"]["input"] == 0.0, trace
assert trace["curve"]["actual"]["output"] == 25.0, trace
PYFLOW
curl -fsS "http://127.0.0.1:$PORT/api/flows/$FLOW_ID/export" >"$TMP/flow-export.json" curl -fsS "http://127.0.0.1:$PORT/api/flows/$FLOW_ID/export" >"$TMP/flow-export.json"
grep -q '"format":"gree-controller-flow"' "$TMP/flow-export.json" grep -q '"format":"gree-controller-flow"' "$TMP/flow-export.json"
curl -fsS -X POST -H 'Content-Type: application/json' --data-binary @"$TMP/flow-export.json" "http://127.0.0.1:$PORT/api/flows/import" >"$TMP/flow-import.json" curl -fsS -X POST -H 'Content-Type: application/json' --data-binary @"$TMP/flow-export.json" "http://127.0.0.1:$PORT/api/flows/import" >"$TMP/flow-import.json"
+2
View File
@@ -168,6 +168,8 @@ impl AutomationInput {
action_ha_service: None, action_ha_service: None,
action_ha_entity_id: None, action_ha_entity_id: None,
action_ha_data: Value::Null, action_ha_data: Value::Null,
flow_value_source_id: None,
flow_value_target: None,
flow_conditions: vec![], flow_conditions: vec![],
flow_id: None, flow_id: None,
flow_node_id: None, flow_node_id: None,
+266 -9
View File
@@ -245,6 +245,7 @@ fn flow_condition_kind(kind: &str) -> bool {
| "rate_limit" | "rate_limit"
| "rolling_stat" | "rolling_stat"
| "oscillates" | "oscillates"
| "heating_curve"
| "outdoor_temperature" | "outdoor_temperature"
| "device_temperature" | "device_temperature"
| "zone_temperature" | "zone_temperature"
@@ -275,6 +276,77 @@ fn flow_action_kind(kind: &str) -> bool {
) )
} }
fn heating_curve_points(config: &Value) -> Result<Vec<(f64, f64)>, AppError> {
let points = config
.get("points")
.and_then(Value::as_array)
.ok_or_else(|| AppError::BadRequest("heating curve needs points".into()))?;
if !(2..=32).contains(&points.len()) {
return Err(AppError::BadRequest(
"heating curve needs between 2 and 32 points".into(),
));
}
let mut parsed = Vec::with_capacity(points.len());
for point in points {
let x = point.get("x").and_then(Value::as_f64).ok_or_else(|| {
AppError::BadRequest("heating curve point needs numeric x".into())
})?;
let y = point.get("y").and_then(Value::as_f64).ok_or_else(|| {
AppError::BadRequest("heating curve point needs numeric y".into())
})?;
if !x.is_finite() || !y.is_finite() {
return Err(AppError::BadRequest(
"heating curve points must be finite numbers".into(),
));
}
parsed.push((x, y));
}
parsed.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
if parsed
.windows(2)
.any(|pair| (pair[1].0 - pair[0].0).abs() < f64::EPSILON)
{
return Err(AppError::BadRequest(
"heating curve x values must be unique".into(),
));
}
Ok(parsed)
}
fn validate_heating_curve_source(config: &Value, state: &AppState) -> Result<(), AppError> {
let source = flow_string(config, "source").unwrap_or_default();
if !matches!(
source.as_str(),
"outdoor_temperature" | "device_temperature" | "zone_temperature" | "ha_numeric"
) {
return Err(AppError::BadRequest(
"heating curve has an unsupported temperature source".into(),
));
}
if source == "device_temperature" {
let id = flow_string(config, "device_id").unwrap_or_default();
if state.db.get_device(&id)?.is_none() {
return Err(AppError::BadRequest(
"heating curve references a missing device".into(),
));
}
}
if source == "zone_temperature" {
let id = flow_string(config, "zone_id").unwrap_or_default();
if state.db.get_zone(&id)?.is_none() {
return Err(AppError::BadRequest(
"heating curve references a missing zone".into(),
));
}
}
if source == "ha_numeric" && flow_string(config, "entity_id").is_none() {
return Err(AppError::BadRequest(
"heating curve Home Assistant source needs entity_id".into(),
));
}
Ok(())
}
fn shared_input_comparison_kind(kind: &str) -> bool { fn shared_input_comparison_kind(kind: &str) -> bool {
matches!( matches!(
kind, kind,
@@ -691,6 +763,73 @@ fn compile_flow_program(
Ok(out) Ok(out)
} }
fn flow_action_value_binding(
action: &crate::models::FlowNode,
nodes: &[crate::models::FlowNode],
edges: &[crate::models::FlowEdge],
) -> Result<Option<(String, String)>, AppError> {
let curve_sources = edges
.iter()
.filter(|edge| edge.to == action.id)
.filter_map(|edge| nodes.iter().find(|node| node.id == edge.from))
.filter(|node| node.kind == "heating_curve")
.collect::<Vec<_>>();
if curve_sources.len() > 1 {
return Err(AppError::BadRequest(
"an action can receive a value from only one heating curve".into(),
));
}
let Some(curve) = curve_sources.first() else {
return Ok(None);
};
let target = match action.kind.as_str() {
"zone_thermostat" | "group_action" => "setpoint".to_string(),
"device_action" => "target_temperature".to_string(),
"device_feature_action" => {
if flow_string(&action.config, "feature").as_deref() != Some("target_temperature") {
return Err(AppError::BadRequest(
"a heating curve can feed a single GREE feature action only when the feature is target_temperature".into(),
));
}
"target_temperature".to_string()
}
"ha_service_action" => {
let key = flow_string(&action.config, "value_key")
.unwrap_or_else(|| "temperature".into());
if key.len() > 80
|| key
.chars()
.any(|ch| !(ch.is_ascii_alphanumeric() || ch == '_' || ch == '-'))
{
return Err(AppError::BadRequest(
"Home Assistant dynamic value key contains unsupported characters".into(),
));
}
key
}
_ => {
return Err(AppError::BadRequest(
"this action cannot receive a heating curve value".into(),
))
}
};
if matches!(
action.kind.as_str(),
"zone_thermostat" | "group_action" | "device_action" | "device_feature_action"
) {
let points = heating_curve_points(&curve.config)?;
if points.iter().any(|(_, y)| !(8.0..=30.0).contains(y)) {
return Err(AppError::BadRequest(
"a heating curve connected to a thermostat/GREE target must output between 8 and 30 C".into(),
));
}
}
Ok(Some((curve.id.clone(), target)))
}
fn validate_text_comparison(config: &Value) -> Result<(), AppError> { fn validate_text_comparison(config: &Value) -> Result<(), AppError> {
let op = flow_string(config, "operator").unwrap_or_else(|| "eq".into()); let op = flow_string(config, "operator").unwrap_or_else(|| "eq".into());
if !matches!(op.as_str(), "eq" | "neq") { if !matches!(op.as_str(), "eq" | "neq") {
@@ -970,6 +1109,10 @@ fn validate_condition(
)); ));
} }
} }
"heating_curve" => {
validate_heating_curve_source(&condition.config, state)?;
heating_curve_points(&condition.config)?;
}
"outdoor_temperature" => { "outdoor_temperature" => {
validate_flow_comparison(&condition.config)?; validate_flow_comparison(&condition.config)?;
} }
@@ -1243,6 +1386,7 @@ fn compile_flow(
.collect(); .collect();
for action_node in actions { for action_node in actions {
let conditions = compile_flow_program(&action_node.id, &flow.nodes, &flow.edges)?; let conditions = compile_flow_program(&action_node.id, &flow.nodes, &flow.edges)?;
let value_binding = flow_action_value_binding(&action_node, &flow.nodes, &flow.edges)?;
for condition in conditions for condition in conditions
.iter() .iter()
.filter(|condition| flow_condition_kind(&condition.kind)) .filter(|condition| flow_condition_kind(&condition.kind))
@@ -1271,6 +1415,7 @@ fn compile_flow(
.map(|(start, end)| start < end) .map(|(start, end)| start < end)
.unwrap_or(false); .unwrap_or(false);
let schedule_only = action_node.kind == "zone_thermostat" let schedule_only = action_node.kind == "zone_thermostat"
&& value_binding.is_none()
&& !conditions && !conditions
.iter() .iter()
.any(|condition| matches!(condition.kind.as_str(), "logic_or" | "logic_not")) .any(|condition| matches!(condition.kind.as_str(), "logic_or" | "logic_not"))
@@ -1422,6 +1567,8 @@ fn compile_flow(
action_ha_service: None, action_ha_service: None,
action_ha_entity_id: None, action_ha_entity_id: None,
action_ha_data: Value::Null, action_ha_data: Value::Null,
flow_value_source_id: value_binding.as_ref().map(|(id, _)| id.clone()),
flow_value_target: value_binding.as_ref().map(|(_, target)| target.clone()),
flow_conditions: conditions, flow_conditions: conditions,
flow_id: Some(flow.id.clone()), flow_id: Some(flow.id.clone()),
flow_node_id: Some(action_node.id.clone()), flow_node_id: Some(action_node.id.clone()),
@@ -1446,8 +1593,12 @@ fn compile_flow(
)); ));
} }
item.action_zone_id = Some(zone_id); item.action_zone_id = Some(zone_id);
let preset = let preset = if value_binding.is_some() {
flow_string(&action_node.config, "preset").unwrap_or_else(|| "comfort".into()); "custom".to_string()
} else {
flow_string(&action_node.config, "preset")
.unwrap_or_else(|| "comfort".into())
};
if !matches!( if !matches!(
preset.as_str(), preset.as_str(),
"comfort" | "sleep" | "away" | "custom" | "auto" "comfort" | "sleep" | "away" | "custom" | "auto"
@@ -1455,7 +1606,7 @@ fn compile_flow(
return Err(AppError::BadRequest("unsupported thermostat preset".into())); return Err(AppError::BadRequest("unsupported thermostat preset".into()));
} }
item.action_zone_preset = Some(preset.clone()); item.action_zone_preset = Some(preset.clone());
if preset == "custom" { if preset == "custom" && value_binding.is_none() {
let target = flow_f64(&action_node.config, "setpoint").ok_or_else(|| { let target = flow_f64(&action_node.config, "setpoint").ok_or_else(|| {
AppError::BadRequest("custom thermostat block needs a target".into()) AppError::BadRequest("custom thermostat block needs a target".into())
})?; })?;
@@ -1507,7 +1658,7 @@ fn compile_flow(
item.action.health = flow_bool(&action_node.config, "health"); item.action.health = flow_bool(&action_node.config, "health");
item.action.sleep = flow_bool(&action_node.config, "sleep"); item.action.sleep = flow_bool(&action_node.config, "sleep");
engine::validate_command(&item.action)?; engine::validate_command(&item.action)?;
if item.action.is_empty() { if item.action.is_empty() && value_binding.is_none() {
return Err(AppError::BadRequest("device action cannot be empty".into())); return Err(AppError::BadRequest("device action cannot be empty".into()));
} }
} }
@@ -1521,7 +1672,16 @@ fn compile_flow(
)); ));
} }
item.action_device_id = id; item.action_device_id = id;
item.action = flow_device_feature_command(&action_node.config)?; item.action = if value_binding.is_some()
&& flow_string(&action_node.config, "feature").as_deref()
== Some("target_temperature")
{
// The heating curve supplies the real value at execution time, so do not
// persist a misleading static temperature in the generated automation.
DeviceCommand::default()
} else {
flow_device_feature_command(&action_node.config)?
};
} }
"ha_service_action" => { "ha_service_action" => {
let domain = flow_string(&action_node.config, "domain").ok_or_else(|| { let domain = flow_string(&action_node.config, "domain").ok_or_else(|| {
@@ -1555,7 +1715,11 @@ fn compile_flow(
item.action_group_id = Some(id); item.action_group_id = Some(id);
item.action.power = flow_bool(&action_node.config, "power"); item.action.power = flow_bool(&action_node.config, "power");
item.action.mode = flow_string(&action_node.config, "mode"); item.action.mode = flow_string(&action_node.config, "mode");
item.action_preset = flow_string(&action_node.config, "preset"); item.action_preset = if value_binding.is_some() {
Some("custom".into())
} else {
flow_string(&action_node.config, "preset")
};
if let Some(mode) = item.action.mode.as_deref() { if let Some(mode) = item.action.mode.as_deref() {
if !matches!(mode, "auto" | "house" | "cool" | "heat") { if !matches!(mode, "auto" | "house" | "cool" | "heat") {
return Err(AppError::BadRequest("unsupported Flow group mode".into())); return Err(AppError::BadRequest("unsupported Flow group mode".into()));
@@ -1565,7 +1729,7 @@ fn compile_flow(
if !matches!(preset, "auto" | "comfort" | "sleep" | "away" | "custom") { if !matches!(preset, "auto" | "comfort" | "sleep" | "away" | "custom") {
return Err(AppError::BadRequest("unsupported Flow group preset".into())); return Err(AppError::BadRequest("unsupported Flow group preset".into()));
} }
if preset == "custom" { if preset == "custom" && value_binding.is_none() {
let target = let target =
flow_f64(&action_node.config, "setpoint").ok_or_else(|| { flow_f64(&action_node.config, "setpoint").ok_or_else(|| {
AppError::BadRequest( AppError::BadRequest(
@@ -1935,6 +2099,7 @@ async fn import_flow(
fn dry_run_block_reason( fn dry_run_block_reason(
state: &AppState, state: &AppState,
action: &crate::models::FlowNode, action: &crate::models::FlowNode,
value_binding: Option<&(String, String)>,
) -> Result<Option<String>, AppError> { ) -> Result<Option<String>, AppError> {
match action.kind.as_str() { match action.kind.as_str() {
"zone_thermostat" => { "zone_thermostat" => {
@@ -1991,7 +2156,14 @@ fn dry_run_block_reason(
return Ok(Some("temporary_quick_thermostat".into())); return Ok(Some("temporary_quick_thermostat".into()));
} }
let command = if action.kind == "device_feature_action" { let command = if action.kind == "device_feature_action" {
if value_binding.is_some()
&& flow_string(&action.config, "feature").as_deref()
== Some("target_temperature")
{
DeviceCommand::default()
} else {
flow_device_feature_command(&action.config)? flow_device_feature_command(&action.config)?
}
} else { } else {
let mut command = DeviceCommand::default(); let mut command = DeviceCommand::default();
command.power = flow_bool(&action.config, "power"); command.power = flow_bool(&action.config, "power");
@@ -2028,7 +2200,8 @@ fn dry_run_block_reason(
let Some(group) = state.db.get_group(&group_id)? else { let Some(group) = state.db.get_group(&group_id)? else {
return Ok(Some("missing_group".into())); return Ok(Some("missing_group".into()));
}; };
let climate_change = flow_string(&action.config, "mode").is_some() let climate_change = value_binding.is_some()
|| flow_string(&action.config, "mode").is_some()
|| flow_string(&action.config, "preset").is_some(); || flow_string(&action.config, "preset").is_some();
let resulting_enabled = let resulting_enabled =
flow_bool(&action.config, "power").unwrap_or(group.power_enabled); flow_bool(&action.config, "power").unwrap_or(group.power_enabled);
@@ -2083,6 +2256,7 @@ async fn simulate_flow(
.filter(|node| flow_action_kind(&node.kind)) .filter(|node| flow_action_kind(&node.kind))
{ {
let program = compile_flow_program(&action.id, &input.flow.nodes, &input.flow.edges)?; let program = compile_flow_program(&action.id, &input.flow.nodes, &input.flow.edges)?;
let value_binding = flow_action_value_binding(action, &input.flow.nodes, &input.flow.edges)?;
let automation_id = format!("flow:{preview_id}:automation:{}", action.id); let automation_id = format!("flow:{preview_id}:automation:{}", action.id);
let mut runtime = state let mut runtime = state
.db .db
@@ -2098,10 +2272,18 @@ async fn simulate_flow(
Some(&mut runtime), Some(&mut runtime),
) )
.await?; .await?;
let dynamic_value = value_binding.as_ref().and_then(|(source_id, _)| {
trace
.iter()
.find(|item| item.get("node_id").and_then(Value::as_str) == Some(source_id))
.and_then(|item| item.get("actual"))
.and_then(|actual| actual.get("output"))
.and_then(Value::as_f64)
});
let blocked_reason = if matched && !input.flow.enabled { let blocked_reason = if matched && !input.flow.enabled {
Some("flow_disabled".into()) Some("flow_disabled".into())
} else if matched { } else if matched {
dry_run_block_reason(&state, action)? dry_run_block_reason(&state, action, value_binding.as_ref())?
} else { } else {
None None
}; };
@@ -2111,6 +2293,8 @@ async fn simulate_flow(
"matched": matched, "matched": matched,
"would_execute": matched && blocked_reason.is_none(), "would_execute": matched && blocked_reason.is_none(),
"blocked_reason": blocked_reason, "blocked_reason": blocked_reason,
"dynamic_value": dynamic_value,
"dynamic_value_target": value_binding.as_ref().map(|(_, target)| target.clone()),
"config": action.config, "config": action.config,
"trace": trace "trace": trace
})); }));
@@ -2163,6 +2347,16 @@ async fn flow_logs(
mod flow_draft_tests { mod flow_draft_tests {
use super::*; use super::*;
fn test_node(id: &str, kind: &str, config: Value) -> crate::models::FlowNode {
crate::models::FlowNode {
id: id.into(),
kind: kind.into(),
x: 0.0,
y: 0.0,
config,
}
}
fn unfinished_input() -> FlowInput { fn unfinished_input() -> FlowInput {
FlowInput { FlowInput {
name: "Unfinished".into(), name: "Unfinished".into(),
@@ -2224,4 +2418,67 @@ mod flow_draft_tests {
let result = flow_device_feature_command(&json!({"feature": "swing_vertical", "value": 99})); let result = flow_device_feature_command(&json!({"feature": "swing_vertical", "value": 99}));
assert!(result.is_err()); assert!(result.is_err());
} }
#[test]
fn heating_curve_binds_to_native_setpoint() {
let nodes = vec![
test_node(
"curve",
"heating_curve",
json!({"points": [{"x": -20.0, "y": 30.0}, {"x": 20.0, "y": 19.0}]}),
),
test_node("action", "zone_thermostat", json!({"zone_id": "zone"})),
];
let edges = vec![crate::models::FlowEdge {
id: "edge".into(),
from: "curve".into(),
to: "action".into(),
}];
let binding = flow_action_value_binding(&nodes[1], &nodes, &edges).unwrap();
assert_eq!(binding, Some(("curve".into(), "setpoint".into())));
}
#[test]
fn heating_curve_rejects_native_output_above_device_range() {
let nodes = vec![
test_node(
"curve",
"heating_curve",
json!({"points": [{"x": -20.0, "y": 55.0}, {"x": 20.0, "y": 30.0}]}),
),
test_node("action", "device_action", json!({"device_id": "device"})),
];
let edges = vec![crate::models::FlowEdge {
id: "edge".into(),
from: "curve".into(),
to: "action".into(),
}];
assert!(flow_action_value_binding(&nodes[1], &nodes, &edges).is_err());
}
#[test]
fn heating_curve_allows_wider_home_assistant_output_range() {
let nodes = vec![
test_node(
"curve",
"heating_curve",
json!({"points": [{"x": -20.0, "y": 60.0}, {"x": 20.0, "y": 30.0}]}),
),
test_node(
"action",
"ha_service_action",
json!({"domain": "climate", "service": "set_temperature", "value_key": "temperature"}),
),
];
let edges = vec![crate::models::FlowEdge {
id: "edge".into(),
from: "curve".into(),
to: "action".into(),
}];
let binding = flow_action_value_binding(&nodes[1], &nodes, &edges).unwrap();
assert_eq!(binding, Some(("curve".into(), "temperature".into())));
}
} }
+110 -10
View File
@@ -66,6 +66,43 @@ async fn run_automations(state: &AppState) -> Result<()> {
let Some(latest_item) = state.db.get_automation(&item.id)? else { continue; }; let Some(latest_item) = state.db.get_automation(&item.id)? else { continue; };
if latest_item.updated_at != item.updated_at { continue; } if latest_item.updated_at != item.updated_at { continue; }
item = latest_item; item = latest_item;
let mut effective_action = item.action.clone();
let mut effective_ha_data = item.action_ha_data.clone();
let mut dynamic_value = None::<f64>;
if let Some(source_id) = item.flow_value_source_id.as_deref() {
let Some(value) = flow_trace_numeric_output(&trigger_trace, source_id) else {
state.log("warn", "flow.value_missing", &format!("Flow automation {} has no numeric value from {}", item.name, source_id), json!({
"automation_id": item.id, "flow_id": item.flow_id, "flow_node_id": item.flow_node_id, "source_node_id": source_id
}));
continue;
};
dynamic_value = Some(value);
let target = item.flow_value_target.as_deref().unwrap_or("target_temperature");
if item.action_ha_domain.is_some() {
let Some(map) = effective_ha_data.as_object_mut() else {
state.log("warn", "flow.value_target_error", &format!("Flow automation {} has invalid Home Assistant service data", item.name), json!({
"automation_id": item.id, "flow_id": item.flow_id, "flow_node_id": item.flow_node_id
}));
continue;
};
map.insert(target.to_string(), json!(value));
} else if matches!(target, "target_temperature" | "setpoint") {
effective_action.target_temperature = Some(value);
} else {
state.log("warn", "flow.value_target_error", &format!("Flow automation {} has unsupported dynamic target {}", item.name, target), json!({
"automation_id": item.id, "flow_id": item.flow_id, "flow_node_id": item.flow_node_id, "target": target
}));
continue;
}
if item.action_ha_domain.is_none() {
if let Err(err) = validate_command(&effective_action) {
state.log("warn", "flow.value_target_error", &format!("Flow automation {} produced an invalid action: {}", item.name, err), json!({
"automation_id": item.id, "flow_id": item.flow_id, "flow_node_id": item.flow_node_id, "value": value
}));
continue;
}
}
}
// Group membership and zone ownership may have changed after the cycle snapshot but // Group membership and zone ownership may have changed after the cycle snapshot but
// before we acquired the automation lock. Reload them inside this serialized window so // before we acquired the automation lock. Reload them inside this serialized window so
// same-cycle conflict arbitration claims the actual current target set. // same-cycle conflict arbitration claims the actual current target set.
@@ -74,7 +111,7 @@ async fn run_automations(state: &AppState) -> Result<()> {
if item.action_group_id.is_none() && item.action_zone_id.is_none() if item.action_group_id.is_none() && item.action_zone_id.is_none()
&& device_blocked_by_disabled_zone(&item.action_device_id, &zones) && device_blocked_by_disabled_zone(&item.action_device_id, &zones)
&& item.action.power != Some(true) && effective_action.power != Some(true)
{ {
state.log("info", "automation.blocked_by_zone", &format!("Automation {} suppressed by disabled zone", item.name), json!({ state.log("info", "automation.blocked_by_zone", &format!("Automation {} suppressed by disabled zone", item.name), json!({
"automation_id": item.id, "device_id": item.action_device_id, "flow_id": item.flow_id, "flow_node_id": item.flow_node_id "automation_id": item.id, "device_id": item.action_device_id, "flow_id": item.flow_id, "flow_node_id": item.flow_node_id
@@ -101,7 +138,7 @@ async fn run_automations(state: &AppState) -> Result<()> {
} }
if item.action_group_id.is_none() && item.action_zone_id.is_none() if item.action_group_id.is_none() && item.action_zone_id.is_none()
&& device_has_thermostat_zone(&item.action_device_id, &zones) && device_has_thermostat_zone(&item.action_device_id, &zones)
&& automation_action_conflicts_with_thermostat(&item.action) && automation_action_conflicts_with_thermostat(&effective_action)
{ {
// Fan/quiet/sleep are outputs continuously managed by the thermostat. Unlike // Fan/quiet/sleep are outputs continuously managed by the thermostat. Unlike
// power/mode/target they cannot be translated into durable zone state, so a direct // power/mode/target they cannot be translated into durable zone state, so a direct
@@ -139,22 +176,22 @@ async fn run_automations(state: &AppState) -> Result<()> {
let result: Result<bool, AppError> = if let (Some(domain), Some(service)) = (item.action_ha_domain.as_deref(), item.action_ha_service.as_deref()) { let result: Result<bool, AppError> = if let (Some(domain), Some(service)) = (item.action_ha_domain.as_deref(), item.action_ha_service.as_deref()) {
let settings = state.settings.read().await.clone(); let settings = state.settings.read().await.clone();
match home_assistant::call_service(&state.http, &settings.home_assistant, domain, service, item.action_ha_entity_id.as_deref(), &item.action_ha_data).await { match home_assistant::call_service(&state.http, &settings.home_assistant, domain, service, item.action_ha_entity_id.as_deref(), &effective_ha_data).await {
Ok(_) => Ok(true), Ok(_) => Ok(true),
Err(err) => Err(AppError::BadRequest(format!("Home Assistant service action failed: {err}"))), Err(err) => Err(AppError::BadRequest(format!("Home Assistant service action failed: {err}"))),
} }
} else if let Some(zone_id) = item.action_zone_id.as_deref() { } else if let Some(zone_id) = item.action_zone_id.as_deref() {
apply_flow_zone_action(state, zone_id, item.action_zone_preset.as_deref(), &item.action).await apply_flow_zone_action(state, zone_id, item.action_zone_preset.as_deref(), &effective_action).await
} else if let Some(group_id) = item.action_group_id.as_deref() { } else if let Some(group_id) = item.action_group_id.as_deref() {
let group_mode = item.action.mode.as_deref().map(|mode| if mode == "auto" { "house".to_string() } else { mode.to_string() }); let group_mode = effective_action.mode.as_deref().map(|mode| if mode == "auto" { "house".to_string() } else { mode.to_string() });
control_group(state, group_id, GroupControlPatch { control_group(state, group_id, GroupControlPatch {
power: item.action.power, power: effective_action.power,
mode: group_mode, mode: group_mode,
preset: item.action_preset.clone(), preset: item.action_preset.clone(),
setpoint: item.action.target_temperature, setpoint: effective_action.target_temperature,
}, "automation.group").await.map(|value| !value.get("suppressed").and_then(Value::as_bool).unwrap_or(false)) }, "automation.group").await.map(|value| !value.get("suppressed").and_then(Value::as_bool).unwrap_or(false))
} else { } else {
match apply_automatic_device_action(state, &item.action_device_id, item.action.clone()).await { match apply_automatic_device_action(state, &item.action_device_id, effective_action.clone()).await {
Ok(Some(_)) => Ok(true), Ok(Some(_)) => Ok(true),
Ok(None) => { Ok(None) => {
state.log("info", "automation.blocked_by_fresh_ownership", &format!("Automation {} was suppressed after ownership changed", item.name), json!({ state.log("info", "automation.blocked_by_fresh_ownership", &format!("Automation {} was suppressed after ownership changed", item.name), json!({
@@ -179,7 +216,7 @@ async fn run_automations(state: &AppState) -> Result<()> {
// automation.updated also invalidates the materialized control plan. // automation.updated also invalidates the materialized control plan.
state.broadcast("automation.updated", serde_json::to_value(&item)?); state.broadcast("automation.updated", serde_json::to_value(&item)?);
state.log("info", "automation.fired", &format!("Automation {} fired", item.name), json!({ state.log("info", "automation.fired", &format!("Automation {} fired", item.name), json!({
"automation_id": item.id, "flow_id": item.flow_id, "flow_node_id": item.flow_node_id, "group_id": item.action_group_id, "device_id": item.action_device_id "automation_id": item.id, "flow_id": item.flow_id, "flow_node_id": item.flow_node_id, "group_id": item.action_group_id, "device_id": item.action_device_id, "dynamic_value": dynamic_value
})); }));
} }
Ok(false) => { Ok(false) => {
@@ -494,6 +531,49 @@ fn flow_rolling_stat_update(
} }
} }
fn flow_heating_curve_output(config: &Value, input: f64) -> Option<f64> {
let mut points = config
.get("points")?
.as_array()?
.iter()
.filter_map(|point| {
Some((
point.get("x")?.as_f64()?,
point.get("y")?.as_f64()?,
))
})
.collect::<Vec<_>>();
if points.len() < 2 || !input.is_finite() {
return None;
}
points.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
if input <= points[0].0 {
return Some(points[0].1);
}
if input >= points[points.len() - 1].0 {
return Some(points[points.len() - 1].1);
}
for pair in points.windows(2) {
let (x1, y1) = pair[0];
let (x2, y2) = pair[1];
if input < x1 || input > x2 || (x2 - x1).abs() < f64::EPSILON {
continue;
}
let ratio = (input - x1) / (x2 - x1);
return Some(y1 + (y2 - y1) * ratio);
}
None
}
fn flow_trace_numeric_output(trace: &[Value], source_id: &str) -> Option<f64> {
trace
.iter()
.find(|item| item.get("node_id").and_then(Value::as_str) == Some(source_id))
.and_then(|item| item.get("actual"))
.and_then(|actual| actual.get("output"))
.and_then(Value::as_f64)
}
async fn flow_leaf_observation( async fn flow_leaf_observation(
state: &AppState, state: &AppState,
devices: &[Device], devices: &[Device],
@@ -789,6 +869,26 @@ pub async fn evaluate_flow_conditions_trace(
} else { false }; } else { false };
(input && available, json!({"input": input, "used": used, "max_count": max_count, "period_seconds": period_seconds, "remaining": max_count.saturating_sub(used)})) (input && available, json!({"input": input, "used": used, "max_count": max_count, "period_seconds": period_seconds, "remaining": max_count.saturating_sub(used)}))
} }
"heating_curve" => {
let predecessors_match = condition.inputs.iter().all(|id| values.get(id).copied().unwrap_or(false));
let source = condition.config.get("source").and_then(Value::as_str).unwrap_or("");
let sample = if let Some(value) = overrides.get(&condition.id).and_then(Value::as_f64) {
Some(value)
} else {
match source {
"outdoor_temperature" => outdoor_temperature,
"device_temperature" => condition.config.get("device_id").and_then(Value::as_str).and_then(|id| find_temperature(devices, Some(id))),
"zone_temperature" => condition.config.get("zone_id").and_then(Value::as_str).and_then(|id| zones.iter().find(|zone| zone.id == id).and_then(|zone| zone.current_temperature)),
"ha_numeric" => {
let entity = condition.config.get("entity_id").and_then(Value::as_str).unwrap_or("");
match home_assistant::read_state(&state.http, &settings.home_assistant, Some(entity)).await { Ok(raw) => raw.parse::<f64>().ok(), Err(_) => None }
}
_ => None,
}
};
let output = sample.and_then(|value| flow_heating_curve_output(&condition.config, value));
(predecessors_match && output.is_some(), json!({"input": sample, "output": output, "source": source}))
}
"rolling_stat" => { "rolling_stat" => {
let predecessors_match = condition.inputs.iter().all(|id| values.get(id).copied().unwrap_or(false)); let predecessors_match = condition.inputs.iter().all(|id| values.get(id).copied().unwrap_or(false));
let source = condition.config.get("source").and_then(Value::as_str).unwrap_or(""); let source = condition.config.get("source").and_then(Value::as_str).unwrap_or("");
@@ -905,7 +1005,7 @@ fn flow_condition_kind_runtime(kind: &str) -> bool {
matches!(kind, matches!(kind,
"weekday" | "time_range" | "date_range" | "cron_trigger" | "outdoor_temperature" | "device_temperature" | "weekday" | "time_range" | "date_range" | "cron_trigger" | "outdoor_temperature" | "device_temperature" |
"zone_temperature" | "ha_state" | "ha_numeric" | "ha_attribute" | "ha_available" | "house_mode" | "zone_temperature" | "ha_state" | "ha_numeric" | "ha_attribute" | "ha_available" | "house_mode" |
"device_state" | "zone_state" | "group_state" | "night_mode" | "constant" | "shared_input" "device_state" | "zone_state" | "group_state" | "night_mode" | "constant" | "shared_input" | "heating_curve"
) )
} }
+18
View File
@@ -408,6 +408,22 @@ mod tests {
assert_eq!(monotonic_changes, 0); assert_eq!(monotonic_changes, 0);
} }
#[test]
fn heating_curve_interpolates_and_clamps_to_endpoints() {
let config = json!({
"points": [
{"x": -20.0, "y": 30.0},
{"x": 0.0, "y": 25.0},
{"x": 20.0, "y": 19.0}
]
});
assert_eq!(flow_heating_curve_output(&config, -30.0), Some(30.0));
assert_eq!(flow_heating_curve_output(&config, 30.0), Some(19.0));
assert_eq!(flow_heating_curve_output(&config, -10.0), Some(27.5));
assert_eq!(flow_heating_curve_output(&config, 10.0), Some(22.0));
}
#[test] #[test]
fn time_automation_fires_only_once_in_the_same_minute() { fn time_automation_fires_only_once_in_the_same_minute() {
let now = Local let now = Local
@@ -438,6 +454,8 @@ mod tests {
action_ha_service: None, action_ha_service: None,
action_ha_entity_id: None, action_ha_entity_id: None,
action_ha_data: Value::Null, action_ha_data: Value::Null,
flow_value_source_id: None,
flow_value_target: None,
flow_conditions: vec![], flow_conditions: vec![],
flow_id: None, flow_id: None,
flow_node_id: None, flow_node_id: None,
+8
View File
@@ -91,6 +91,14 @@ pub struct Automation {
pub action_ha_entity_id: Option<String>, pub action_ha_entity_id: Option<String>,
#[serde(default)] #[serde(default)]
pub action_ha_data: Value, pub action_ha_data: Value,
/// Optional numeric value produced by a Flow value block (currently heating_curve).
/// The engine resolves this value from the Flow trace immediately before executing the action.
#[serde(default)]
pub flow_value_source_id: Option<String>,
/// Action field that receives the numeric Flow value. For Home Assistant actions this is
/// the top-level service-data key; native actions use "target_temperature"/"setpoint".
#[serde(default)]
pub flow_value_target: Option<String>,
/// Topologically ordered condition/logic program compiled from the visual Flow graph. /// Topologically ordered condition/logic program compiled from the visual Flow graph.
#[serde(default)] #[serde(default)]
pub flow_conditions: Vec<FlowCondition>, pub flow_conditions: Vec<FlowCondition>,
+89
View File
@@ -6632,6 +6632,95 @@ textarea[aria-invalid="true"] {
gap: 10px; gap: 10px;
} }
.flow-heating-curve-chart {
margin: 4px 0 14px;
padding: 8px;
border: 1px solid var(--line);
border-radius: 12px;
background: var(--surface-2);
}
.flow-heating-curve-chart svg {
display: block;
width: 100%;
height: auto;
overflow: visible;
}
.flow-heating-curve-chart .axis {
stroke: var(--muted-2);
stroke-width: 1.4;
}
.flow-heating-curve-chart .grid {
stroke: var(--line);
stroke-width: 1;
stroke-dasharray: 4 4;
}
.flow-heating-curve-chart .curve {
fill: none;
stroke: var(--accent);
stroke-width: 3;
stroke-linejoin: round;
stroke-linecap: round;
}
.flow-heating-curve-chart circle {
fill: var(--surface);
stroke: var(--accent);
stroke-width: 3;
}
.flow-heating-curve-chart text {
fill: var(--muted);
font-size: 11px;
}
.flow-heating-curve-chart .axis-title {
fill: var(--text);
font-size: 11px;
font-weight: 700;
}
.flow-heating-curve-points-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin: 2px 0 8px;
}
.flow-heating-curve-points {
display: grid;
gap: 7px;
margin-bottom: 10px;
}
.flow-heating-curve-point-row {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto;
gap: 7px;
align-items: end;
}
.flow-heating-curve-point-row label {
margin: 0;
}
.flow-heating-curve-point-row button {
min-width: 38px;
min-height: 38px;
padding: 7px;
}
.flow-value-binding-note {
padding: 9px 10px;
border: 1px solid var(--accent-border);
border-radius: 10px;
background: var(--accent-soft);
}
.flow-day-grid { .flow-day-grid {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
+2 -1
View File
@@ -959,7 +959,8 @@
data-i18n="flow.node.delay">Odczekaj…</button> data-i18n="flow.node.delay">Odczekaj…</button>
</div> </div>
<div class="flow-palette-group block-category-sensor"><span data-i18n="flow.sensors">Sensory / wartości</span> <div class="flow-palette-group block-category-sensor"><span data-i18n="flow.sensors">Sensory / wartości</span>
<button type="button" data-flow-add="outdoor_temperature" data-i18n="flow.node.outdoorTemperature">Temperatura <button type="button" data-flow-add="heating_curve" data-i18n="flow.node.heatingCurve">Krzywa
grzewcza</button><button type="button" data-flow-add="outdoor_temperature" data-i18n="flow.node.outdoorTemperature">Temperatura
zewn.</button><button type="button" data-flow-add="device_temperature" zewn.</button><button type="button" data-flow-add="device_temperature"
data-i18n="flow.node.deviceTemperature">Temperatura urządzenia</button><button type="button" data-i18n="flow.node.deviceTemperature">Temperatura urządzenia</button><button type="button"
data-flow-add="zone_temperature" data-i18n="flow.node.zoneTemperature">Temperatura strefy</button><button data-flow-add="zone_temperature" data-i18n="flow.node.zoneTemperature">Temperatura strefy</button><button
+143 -7
View File
@@ -10,6 +10,7 @@ const FLOW_NODE_META = Object.freeze({
delay: { titleKey: 'flow.node.delay', category: 'timeop' }, delay: { titleKey: 'flow.node.delay', category: 'timeop' },
rolling_stat: { titleKey: 'flow.node.rollingStat', category: 'sensor' }, rolling_stat: { titleKey: 'flow.node.rollingStat', category: 'sensor' },
oscillates: { titleKey: 'flow.node.oscillates', category: 'sensor' }, oscillates: { titleKey: 'flow.node.oscillates', category: 'sensor' },
heating_curve: { titleKey: 'flow.node.heatingCurve', category: 'sensor' },
outdoor_temperature: { titleKey: 'flow.node.outdoorTemperature', category: 'sensor' }, outdoor_temperature: { titleKey: 'flow.node.outdoorTemperature', category: 'sensor' },
device_temperature: { titleKey: 'flow.node.deviceTemperature', category: 'sensor' }, device_temperature: { titleKey: 'flow.node.deviceTemperature', category: 'sensor' },
zone_temperature: { titleKey: 'flow.node.zoneTemperature', category: 'sensor' }, zone_temperature: { titleKey: 'flow.node.zoneTemperature', category: 'sensor' },
@@ -82,6 +83,7 @@ function flowDefaultConfig(kind) {
if (kind === 'delay') return { seconds: 30 }; if (kind === 'delay') return { seconds: 30 };
if (kind === 'rolling_stat') return { source: 'outdoor_temperature', statistic: 'mean', window_seconds: 300, operator: 'lt', value: 20, device_id: app.devices[0]?.id || '', zone_id: app.zones[0]?.id || '', entity_id: '' }; if (kind === 'rolling_stat') return { source: 'outdoor_temperature', statistic: 'mean', window_seconds: 300, operator: 'lt', value: 20, device_id: app.devices[0]?.id || '', zone_id: app.zones[0]?.id || '', entity_id: '' };
if (kind === 'oscillates') return { source: 'outdoor_temperature', window_seconds: 300, min_span: 1.0, min_direction_changes: 2, device_id: app.devices[0]?.id || '', zone_id: app.zones[0]?.id || '', entity_id: '' }; if (kind === 'oscillates') return { source: 'outdoor_temperature', window_seconds: 300, min_span: 1.0, min_direction_changes: 2, device_id: app.devices[0]?.id || '', zone_id: app.zones[0]?.id || '', entity_id: '' };
if (kind === 'heating_curve') return { source: 'outdoor_temperature', device_id: app.devices[0]?.id || '', zone_id: app.zones[0]?.id || '', entity_id: '', points: [{ x: -20, y: 30 }, { x: -10, y: 28 }, { x: 0, y: 25 }, { x: 10, y: 22 }, { x: 20, y: 19 }] };
if (kind === 'outdoor_temperature') return { operator: 'lt', value: 5 }; if (kind === 'outdoor_temperature') return { operator: 'lt', value: 5 };
if (kind === 'device_temperature') return { device_id: app.devices[0]?.id || '', operator: 'lt', value: 20 }; if (kind === 'device_temperature') return { device_id: app.devices[0]?.id || '', operator: 'lt', value: 20 };
if (kind === 'zone_temperature') return { zone_id: app.zones[0]?.id || '', operator: 'lt', value: 20 }; if (kind === 'zone_temperature') return { zone_id: app.zones[0]?.id || '', operator: 'lt', value: 20 };
@@ -385,6 +387,11 @@ function flowNodeSummary(node) {
if (node.kind === 'delay') return flowDurationLabel(c.seconds); if (node.kind === 'delay') return flowDurationLabel(c.seconds);
if (node.kind === 'rolling_stat') return `${c.statistic === 'median' ? tr('flow.median') : tr('flow.mean')} · ${flowDurationLabel(c.window_seconds)} · ${flowOperatorLabel(c.operator)} ${c.value ?? '—'}`; if (node.kind === 'rolling_stat') return `${c.statistic === 'median' ? tr('flow.median') : tr('flow.mean')} · ${flowDurationLabel(c.window_seconds)} · ${flowOperatorLabel(c.operator)} ${c.value ?? '—'}`;
if (node.kind === 'oscillates') return `${flowDurationLabel(c.window_seconds)} · Δ≥${Number(c.min_span ?? 1)} · ↕≥${Number(c.min_direction_changes ?? 2)}`; if (node.kind === 'oscillates') return `${flowDurationLabel(c.window_seconds)} · Δ≥${Number(c.min_span ?? 1)} · ↕≥${Number(c.min_direction_changes ?? 2)}`;
if (node.kind === 'heating_curve') {
const points = flowHeatingCurvePoints(c); const first = points[0], last = points[points.length - 1];
const source = flowHeatingCurveSourceLabel(c);
return points.length >= 2 ? `${source} · ${first.x}°→${first.y}° / ${last.x}°→${last.y}°` : source;
}
if (node.kind === 'outdoor_temperature') return `${flowOperatorLabel(c.operator)} ${fmtTemp(c.value)}`; if (node.kind === 'outdoor_temperature') return `${flowOperatorLabel(c.operator)} ${fmtTemp(c.value)}`;
if (node.kind === 'device_temperature') return `${app.devices.find(d => d.id === c.device_id)?.name || tr('common.noDevice')} · ${flowOperatorLabel(c.operator)} ${fmtTemp(c.value)}`; if (node.kind === 'device_temperature') return `${app.devices.find(d => d.id === c.device_id)?.name || tr('common.noDevice')} · ${flowOperatorLabel(c.operator)} ${fmtTemp(c.value)}`;
if (node.kind === 'zone_temperature') return `${app.zones.find(z => z.id === c.zone_id)?.name || tr('common.noZone')} · ${flowOperatorLabel(c.operator)} ${fmtTemp(c.value)}`; if (node.kind === 'zone_temperature') return `${app.zones.find(z => z.id === c.zone_id)?.name || tr('common.noZone')} · ${flowOperatorLabel(c.operator)} ${fmtTemp(c.value)}`;
@@ -692,6 +699,80 @@ function sharedFlowReferenceComparisonFields(item, config) {
return `<p class="field-note">${esc(tr('flow.sharedInputFlowComparisonHint'))}</p><div class="two"><label><span>${esc(tr('flow.operator'))}</span><select data-flow-config="operator">${operatorOptions}</select></label><label><span>${esc(tr('flow.value'))}</span>${valueField}</label></div>`; return `<p class="field-note">${esc(tr('flow.sharedInputFlowComparisonHint'))}</p><div class="two"><label><span>${esc(tr('flow.operator'))}</span><select data-flow-config="operator">${operatorOptions}</select></label><label><span>${esc(tr('flow.value'))}</span>${valueField}</label></div>`;
} }
function flowHeatingCurvePoints(config) {
return (Array.isArray(config?.points) ? config.points : [])
.map(point => ({ x: Number(point?.x), y: Number(point?.y) }))
.filter(point => Number.isFinite(point.x) && Number.isFinite(point.y))
.sort((a, b) => a.x - b.x);
}
function flowHeatingCurveSourceLabel(config) {
const source = config?.source || 'outdoor_temperature';
if (source === 'device_temperature') return app.devices.find(item => item.id === config.device_id)?.name || tr('flow.node.deviceTemperature');
if (source === 'zone_temperature') return app.zones.find(item => item.id === config.zone_id)?.name || tr('flow.node.zoneTemperature');
if (source === 'ha_numeric') return config.entity_id ? haSensorLabel(config.entity_id) : tr('flow.node.haNumeric');
return tr('flow.node.outdoorTemperature');
}
function flowHeatingCurveInterpolate(points, x) {
if (!points.length) return 0;
if (x <= points[0].x) return points[0].y;
if (x >= points[points.length - 1].x) return points[points.length - 1].y;
for (let index = 0; index < points.length - 1; index += 1) {
const a = points[index], b = points[index + 1];
if (x < a.x || x > b.x || a.x === b.x) continue;
return a.y + ((x - a.x) / (b.x - a.x)) * (b.y - a.y);
}
return points[0].y;
}
function flowHeatingCurveChart(config) {
const points = flowHeatingCurvePoints(config);
if (points.length < 2) return `<div class="empty compact"><span>${esc(tr('flow.heatingCurveNeedsPoints'))}</span></div>`;
const width = 360, height = 220, left = 46, right = 18, top = 16, bottom = 38;
const plotW = width - left - right, plotH = height - top - bottom;
const xs = points.map(point => point.x), ys = points.map(point => point.y);
let minX = Math.min(...xs), maxX = Math.max(...xs), minY = Math.min(...ys), maxY = Math.max(...ys);
if (minX === maxX) { minX -= 1; maxX += 1; }
if (minY === maxY) { minY -= 1; maxY += 1; }
const xPad = Math.max(1, (maxX - minX) * .08), yPad = Math.max(1, (maxY - minY) * .12);
minX -= xPad; maxX += xPad; minY -= yPad; maxY += yPad;
const sx = value => left + ((value - minX) / (maxX - minX)) * plotW;
const sy = value => top + plotH - ((value - minY) / (maxY - minY)) * plotH;
const polyline = points.map(point => `${sx(point.x).toFixed(1)},${sy(point.y).toFixed(1)}`).join(' ');
const circles = points.map((point, index) => `<circle cx="${sx(point.x).toFixed(1)}" cy="${sy(point.y).toFixed(1)}" r="5" data-flow-curve-chart-point="${index}"><title>${esc(`${point.x}°C → ${point.y}°C`)}</title></circle>`).join('');
const xMid = (minX + maxX) / 2, yMid = (minY + maxY) / 2;
return `<div class="flow-heating-curve-chart"><svg viewBox="0 0 ${width} ${height}" role="img" aria-label="${esc(tr('flow.heatingCurveChartLabel'))}">
<line class="axis" x1="${left}" y1="${top}" x2="${left}" y2="${top + plotH}"/><line class="axis" x1="${left}" y1="${top + plotH}" x2="${left + plotW}" y2="${top + plotH}"/>
<line class="grid" x1="${sx(xMid)}" y1="${top}" x2="${sx(xMid)}" y2="${top + plotH}"/><line class="grid" x1="${left}" y1="${sy(yMid)}" x2="${left + plotW}" y2="${sy(yMid)}"/>
<polyline class="curve" points="${polyline}"/>${circles}
<text x="${left}" y="${height - 18}" text-anchor="middle">${Number(minX.toFixed(1))}</text><text x="${left + plotW}" y="${height - 18}" text-anchor="middle">${Number(maxX.toFixed(1))}</text>
<text x="${left - 8}" y="${top + 4}" text-anchor="end">${Number(maxY.toFixed(1))}</text><text x="${left - 8}" y="${top + plotH + 4}" text-anchor="end">${Number(minY.toFixed(1))}</text>
<text class="axis-title" x="${left + plotW / 2}" y="${height - 3}" text-anchor="middle">${esc(tr('flow.heatingCurveAxisX'))}</text>
<text class="axis-title" transform="translate(12 ${top + plotH / 2}) rotate(-90)" text-anchor="middle">${esc(tr('flow.heatingCurveAxisY'))}</text>
</svg></div>`;
}
function flowHeatingCurveEditor(config) {
const source = config.source || 'outdoor_temperature';
const points = flowHeatingCurvePoints(config);
const sourceFields = source === 'device_temperature'
? `<label><span>${esc(tr('common.device'))}</span><select data-flow-config="device_id">${flowSelectOptions(app.devices, config.device_id)}</select></label>`
: source === 'zone_temperature'
? `<label><span>${esc(tr('common.zone'))}</span><select data-flow-config="zone_id">${flowSelectOptions(app.zones, config.zone_id)}</select></label>`
: source === 'ha_numeric'
? `<label><span>entity_id</span><input data-flow-config="entity_id" list="haEntitySuggestions" value="${esc(config.entity_id || '')}" placeholder="sensor.outdoor_temperature"></label>`
: '';
const rows = points.map((point, index) => `<div class="flow-heating-curve-point-row"><label><span>X</span><input type="number" step="0.1" data-flow-curve-point="x" data-flow-curve-index="${index}" value="${point.x}"></label><label><span>Y</span><input type="number" step="0.1" data-flow-curve-point="y" data-flow-curve-index="${index}" value="${point.y}"></label><button type="button" class="secondary" data-flow-curve-remove="${index}" ${points.length <= 2 ? 'disabled' : ''} aria-label="${esc(tr('actions.delete'))}">${uiIcon('close')}</button></div>`).join('');
return `<label><span>${esc(tr('flow.heatingCurveSource'))}</span><select data-flow-config="source">${[['outdoor_temperature', tr('flow.node.outdoorTemperature')], ['device_temperature', tr('flow.node.deviceTemperature')], ['zone_temperature', tr('flow.node.zoneTemperature')], ['ha_numeric', tr('flow.node.haNumeric')]].map(([value, label]) => `<option value="${value}" ${source === value ? 'selected' : ''}>${esc(label)}</option>`).join('')}</select></label>${sourceFields}${flowHeatingCurveChart(config)}<div class="flow-heating-curve-points-head"><strong>${esc(tr('flow.heatingCurvePoints'))}</strong><button type="button" class="secondary" data-flow-curve-add ${points.length >= 32 ? 'disabled' : ''}>${esc(tr('flow.heatingCurveAddPoint'))}</button></div><div class="flow-heating-curve-points">${rows}</div><p class="field-note">${esc(tr('flow.heatingCurveHint'))}</p>`;
}
function flowIncomingHeatingCurve(nodeId) {
if (!app.flowDraft) return null;
const edge = app.flowDraft.edges.find(item => item.to === nodeId && flowNodeById(item.from)?.kind === 'heating_curve');
return edge ? flowNodeById(edge.from) : null;
}
function renderFlowInspector() { function renderFlowInspector() {
const host = $('#flowInspector'), node = flowNodeById(app.flowSelectedNodeId); if (!host) return; const host = $('#flowInspector'), node = flowNodeById(app.flowSelectedNodeId); if (!host) return;
host.classList.toggle('has-selection', Boolean(node)); host.classList.toggle('has-selection', Boolean(node));
@@ -709,6 +790,7 @@ function renderFlowInspector() {
else if (node.kind === 'delay') fields = `<label><span>${esc(tr('flow.durationSeconds'))}</span><input type="number" min="1" max="604800" data-flow-config="seconds" value="${Number(c.seconds || 30)}"></label><p class="field-note">${esc(tr('flow.delayHint'))}</p>`; else if (node.kind === 'delay') fields = `<label><span>${esc(tr('flow.durationSeconds'))}</span><input type="number" min="1" max="604800" data-flow-config="seconds" value="${Number(c.seconds || 30)}"></label><p class="field-note">${esc(tr('flow.delayHint'))}</p>`;
else if (node.kind === 'rolling_stat') { const source = c.source || 'outdoor_temperature'; fields = `<label><span>${esc(tr('flow.source'))}</span><select data-flow-config="source">${[['outdoor_temperature', tr('flow.node.outdoorTemperature')], ['device_temperature', tr('flow.node.deviceTemperature')], ['zone_temperature', tr('flow.node.zoneTemperature')], ['ha_numeric', tr('flow.node.haNumeric')]].map(([v, l]) => `<option value="${v}" ${source === v ? 'selected' : ''}>${esc(l)}</option>`).join('')}</select></label>${source === 'device_temperature' ? `<label><span>${esc(tr('common.device'))}</span><select data-flow-config="device_id">${flowSelectOptions(app.devices, c.device_id)}</select></label>` : ''}${source === 'zone_temperature' ? `<label><span>${esc(tr('common.zone'))}</span><select data-flow-config="zone_id">${flowSelectOptions(app.zones, c.zone_id)}</select></label>` : ''}${source === 'ha_numeric' ? `<label><span>entity_id</span><input data-flow-config="entity_id" list="haEntitySuggestions" value="${esc(c.entity_id || '')}" placeholder="sensor.temperature"></label>` : ''}<div class="two"><label><span>${esc(tr('flow.statistic'))}</span><select data-flow-config="statistic"><option value="mean" ${c.statistic !== 'median' ? 'selected' : ''}>${esc(tr('flow.mean'))}</option><option value="median" ${c.statistic === 'median' ? 'selected' : ''}>${esc(tr('flow.median'))}</option></select></label><label><span>${esc(tr('flow.windowSeconds'))}</span><input type="number" min="10" max="604800" data-flow-config="window_seconds" value="${Number(c.window_seconds || 300)}"></label></div>${flowComparisonFields(c, false)}`; } else if (node.kind === 'rolling_stat') { const source = c.source || 'outdoor_temperature'; fields = `<label><span>${esc(tr('flow.source'))}</span><select data-flow-config="source">${[['outdoor_temperature', tr('flow.node.outdoorTemperature')], ['device_temperature', tr('flow.node.deviceTemperature')], ['zone_temperature', tr('flow.node.zoneTemperature')], ['ha_numeric', tr('flow.node.haNumeric')]].map(([v, l]) => `<option value="${v}" ${source === v ? 'selected' : ''}>${esc(l)}</option>`).join('')}</select></label>${source === 'device_temperature' ? `<label><span>${esc(tr('common.device'))}</span><select data-flow-config="device_id">${flowSelectOptions(app.devices, c.device_id)}</select></label>` : ''}${source === 'zone_temperature' ? `<label><span>${esc(tr('common.zone'))}</span><select data-flow-config="zone_id">${flowSelectOptions(app.zones, c.zone_id)}</select></label>` : ''}${source === 'ha_numeric' ? `<label><span>entity_id</span><input data-flow-config="entity_id" list="haEntitySuggestions" value="${esc(c.entity_id || '')}" placeholder="sensor.temperature"></label>` : ''}<div class="two"><label><span>${esc(tr('flow.statistic'))}</span><select data-flow-config="statistic"><option value="mean" ${c.statistic !== 'median' ? 'selected' : ''}>${esc(tr('flow.mean'))}</option><option value="median" ${c.statistic === 'median' ? 'selected' : ''}>${esc(tr('flow.median'))}</option></select></label><label><span>${esc(tr('flow.windowSeconds'))}</span><input type="number" min="10" max="604800" data-flow-config="window_seconds" value="${Number(c.window_seconds || 300)}"></label></div>${flowComparisonFields(c, false)}`; }
else if (node.kind === 'oscillates') { const source = c.source || 'outdoor_temperature'; fields = `<label><span>${esc(tr('flow.source'))}</span><select data-flow-config="source">${[['outdoor_temperature', tr('flow.node.outdoorTemperature')], ['device_temperature', tr('flow.node.deviceTemperature')], ['zone_temperature', tr('flow.node.zoneTemperature')], ['ha_numeric', tr('flow.node.haNumeric')]].map(([v, l]) => `<option value="${v}" ${source === v ? 'selected' : ''}>${esc(l)}</option>`).join('')}</select></label>${source === 'device_temperature' ? `<label><span>${esc(tr('common.device'))}</span><select data-flow-config="device_id">${flowSelectOptions(app.devices, c.device_id)}</select></label>` : ''}${source === 'zone_temperature' ? `<label><span>${esc(tr('common.zone'))}</span><select data-flow-config="zone_id">${flowSelectOptions(app.zones, c.zone_id)}</select></label>` : ''}${source === 'ha_numeric' ? `<label><span>entity_id</span><input data-flow-config="entity_id" list="haEntitySuggestions" value="${esc(c.entity_id || '')}" placeholder="sensor.temperature"></label>` : ''}<div class="two"><label><span>${esc(tr('flow.windowSeconds'))}</span><input type="number" min="10" max="604800" data-flow-config="window_seconds" value="${Number(c.window_seconds || 300)}"></label><label><span>${esc(tr('flow.minSpan'))}</span><input type="number" min="0.001" step="0.1" data-flow-config="min_span" value="${Number(c.min_span ?? 1)}"></label></div><label><span>${esc(tr('flow.minDirectionChanges'))}</span><input type="number" min="1" max="1000" data-flow-config="min_direction_changes" value="${Number(c.min_direction_changes || 2)}"></label><p class="field-note">${esc(tr('flow.oscillatesHint'))}</p>`; } else if (node.kind === 'oscillates') { const source = c.source || 'outdoor_temperature'; fields = `<label><span>${esc(tr('flow.source'))}</span><select data-flow-config="source">${[['outdoor_temperature', tr('flow.node.outdoorTemperature')], ['device_temperature', tr('flow.node.deviceTemperature')], ['zone_temperature', tr('flow.node.zoneTemperature')], ['ha_numeric', tr('flow.node.haNumeric')]].map(([v, l]) => `<option value="${v}" ${source === v ? 'selected' : ''}>${esc(l)}</option>`).join('')}</select></label>${source === 'device_temperature' ? `<label><span>${esc(tr('common.device'))}</span><select data-flow-config="device_id">${flowSelectOptions(app.devices, c.device_id)}</select></label>` : ''}${source === 'zone_temperature' ? `<label><span>${esc(tr('common.zone'))}</span><select data-flow-config="zone_id">${flowSelectOptions(app.zones, c.zone_id)}</select></label>` : ''}${source === 'ha_numeric' ? `<label><span>entity_id</span><input data-flow-config="entity_id" list="haEntitySuggestions" value="${esc(c.entity_id || '')}" placeholder="sensor.temperature"></label>` : ''}<div class="two"><label><span>${esc(tr('flow.windowSeconds'))}</span><input type="number" min="10" max="604800" data-flow-config="window_seconds" value="${Number(c.window_seconds || 300)}"></label><label><span>${esc(tr('flow.minSpan'))}</span><input type="number" min="0.001" step="0.1" data-flow-config="min_span" value="${Number(c.min_span ?? 1)}"></label></div><label><span>${esc(tr('flow.minDirectionChanges'))}</span><input type="number" min="1" max="1000" data-flow-config="min_direction_changes" value="${Number(c.min_direction_changes || 2)}"></label><p class="field-note">${esc(tr('flow.oscillatesHint'))}</p>`; }
else if (node.kind === 'heating_curve') fields = flowHeatingCurveEditor(c);
else if (node.kind === 'outdoor_temperature') fields = flowComparisonFields(c); else if (node.kind === 'outdoor_temperature') fields = flowComparisonFields(c);
else if (node.kind === 'device_temperature') fields = `<label><span>${esc(tr('common.device'))}</span><select data-flow-config="device_id">${flowSelectOptions(app.devices, c.device_id)}</select></label>${flowComparisonFields(c)}`; else if (node.kind === 'device_temperature') fields = `<label><span>${esc(tr('common.device'))}</span><select data-flow-config="device_id">${flowSelectOptions(app.devices, c.device_id)}</select></label>${flowComparisonFields(c)}`;
else if (node.kind === 'zone_temperature') fields = `<label><span>${esc(tr('common.zone'))}</span><select data-flow-config="zone_id">${flowSelectOptions(app.zones, c.zone_id)}</select></label>${flowComparisonFields(c)}`; else if (node.kind === 'zone_temperature') fields = `<label><span>${esc(tr('common.zone'))}</span><select data-flow-config="zone_id">${flowSelectOptions(app.zones, c.zone_id)}</select></label>${flowComparisonFields(c)}`;
@@ -736,6 +818,14 @@ function renderFlowInspector() {
else if (node.kind === 'device_feature_action') fields = `<label><span>${esc(tr('common.device'))}</span><select data-flow-config="device_id">${flowSelectOptions(app.devices, c.device_id)}</select></label><label><span>${esc(tr('flow.deviceFeature'))}</span><select data-flow-config="feature">${flowDeviceFeatureOptions(c.feature)}</select></label>${flowDeviceFeatureValueField(c)}<p class="field-note">${esc(tr('flow.deviceFeatureHint'))}</p><label><span>${esc(tr('common.cooldownSeconds'))}</span><input type="number" min="30" data-flow-config="cooldown_seconds" value="${Number(c.cooldown_seconds || 60)}"></label>`; else if (node.kind === 'device_feature_action') fields = `<label><span>${esc(tr('common.device'))}</span><select data-flow-config="device_id">${flowSelectOptions(app.devices, c.device_id)}</select></label><label><span>${esc(tr('flow.deviceFeature'))}</span><select data-flow-config="feature">${flowDeviceFeatureOptions(c.feature)}</select></label>${flowDeviceFeatureValueField(c)}<p class="field-note">${esc(tr('flow.deviceFeatureHint'))}</p><label><span>${esc(tr('common.cooldownSeconds'))}</span><input type="number" min="30" data-flow-config="cooldown_seconds" value="${Number(c.cooldown_seconds || 60)}"></label>`;
else if (node.kind === 'group_action') fields = `<label><span>${esc(tr('groups.group'))}</span><select data-flow-config="group_id">${flowSelectOptions(app.groups, c.group_id)}</select></label>${flowActionFields(c, true)}`; else if (node.kind === 'group_action') fields = `<label><span>${esc(tr('groups.group'))}</span><select data-flow-config="group_id">${flowSelectOptions(app.groups, c.group_id)}</select></label>${flowActionFields(c, true)}`;
else if (node.kind === 'ha_service_action') fields = `<div class="two"><label><span>domain</span><input data-flow-config="domain" value="${esc(c.domain || 'switch')}" placeholder="switch"></label><label><span>service</span><input data-flow-config="service" value="${esc(c.service || 'turn_on')}" placeholder="turn_on"></label></div><p class="field-note">${esc(tr('flow.serviceExample'))}</p><label><span>entity_id</span><input data-flow-config="entity_id" list="haEntitySuggestions" value="${esc(c.entity_id || '')}" placeholder="switch.gas"></label><label><span>${esc(tr('flow.serviceData'))}</span><textarea rows="5" data-flow-config="data_json">${esc(JSON.stringify(c.data || {}, null, 2))}</textarea></label><label><span>${esc(tr('common.cooldownSeconds'))}</span><input type="number" min="30" data-flow-config="cooldown_seconds" value="${Number(c.cooldown_seconds || 60)}"></label>`; else if (node.kind === 'ha_service_action') fields = `<div class="two"><label><span>domain</span><input data-flow-config="domain" value="${esc(c.domain || 'switch')}" placeholder="switch"></label><label><span>service</span><input data-flow-config="service" value="${esc(c.service || 'turn_on')}" placeholder="turn_on"></label></div><p class="field-note">${esc(tr('flow.serviceExample'))}</p><label><span>entity_id</span><input data-flow-config="entity_id" list="haEntitySuggestions" value="${esc(c.entity_id || '')}" placeholder="switch.gas"></label><label><span>${esc(tr('flow.serviceData'))}</span><textarea rows="5" data-flow-config="data_json">${esc(JSON.stringify(c.data || {}, null, 2))}</textarea></label><label><span>${esc(tr('common.cooldownSeconds'))}</span><input type="number" min="30" data-flow-config="cooldown_seconds" value="${Number(c.cooldown_seconds || 60)}"></label>`;
const incomingCurve = flowIncomingHeatingCurve(node.id);
if (incomingCurve && ['zone_thermostat', 'device_action', 'group_action'].includes(node.kind)) {
fields += `<p class="field-note flow-value-binding-note">${esc(tr('flow.heatingCurveNativeBinding', { curve: flowNodeTitle(FLOW_NODE_META.heating_curve) }))}</p>`;
} else if (incomingCurve && node.kind === 'device_feature_action') {
fields += `<p class="field-note flow-value-binding-note">${esc(c.feature === 'target_temperature' ? tr('flow.heatingCurveNativeBinding', { curve: flowNodeTitle(FLOW_NODE_META.heating_curve) }) : tr('flow.heatingCurveFeatureWarning'))}</p>`;
} else if (incomingCurve && node.kind === 'ha_service_action') {
fields += `<label><span>${esc(tr('flow.heatingCurveHaValueKey'))}</span><input data-flow-config="value_key" value="${esc(c.value_key || 'temperature')}" placeholder="temperature"></label><p class="field-note flow-value-binding-note">${esc(tr('flow.heatingCurveHaBindingHint'))}</p>`;
}
host.innerHTML = `<div class="flow-inspector-head"><div><span class="eyebrow">${esc(tr('flow.blockSettings'))}</span><h3>${esc(flowNodeTitle(meta))}</h3></div><div class="flow-inspector-head-actions"><button type="button" class="secondary flow-inspector-expand" data-action="flow-toggle-inspector" aria-label="${esc(tr('flow.expandSettings'))}">${uiIcon('expand-vertical')}</button><button type="button" class="secondary flow-inspector-close" data-action="flow-clear-selection" aria-label="${esc(tr('flow.clearSelection'))}">${uiIcon('close')}</button><button type="button" class="danger flow-inspector-delete" data-flow-remove="${esc(node.id)}">${esc(tr('actions.delete'))}</button></div></div>${fields}<div class="flow-inspector-meta"><small>ID</small><code>${esc(node.id)}</code></div>`; host.innerHTML = `<div class="flow-inspector-head"><div><span class="eyebrow">${esc(tr('flow.blockSettings'))}</span><h3>${esc(flowNodeTitle(meta))}</h3></div><div class="flow-inspector-head-actions"><button type="button" class="secondary flow-inspector-expand" data-action="flow-toggle-inspector" aria-label="${esc(tr('flow.expandSettings'))}">${uiIcon('expand-vertical')}</button><button type="button" class="secondary flow-inspector-close" data-action="flow-clear-selection" aria-label="${esc(tr('flow.clearSelection'))}">${uiIcon('close')}</button><button type="button" class="danger flow-inspector-delete" data-flow-remove="${esc(node.id)}">${esc(tr('actions.delete'))}</button></div></div>${fields}<div class="flow-inspector-meta"><small>ID</small><code>${esc(node.id)}</code></div>`;
} }
@@ -1224,7 +1314,7 @@ function localDateTimeInputValue(date = new Date()) {
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`; return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
} }
function flowSimulationOverrideNodes(flow = app.flowDraft) { function flowSimulationOverrideNodes(flow = app.flowDraft) {
return (flow?.nodes || []).filter(node => ['outdoor_temperature', 'device_temperature', 'zone_temperature', 'ha_state', 'ha_numeric', 'ha_attribute', 'ha_available', 'house_mode', 'device_state', 'zone_state', 'group_state', 'night_mode', 'shared_input'].includes(node.kind)); return (flow?.nodes || []).filter(node => ['outdoor_temperature', 'device_temperature', 'zone_temperature', 'heating_curve', 'ha_state', 'ha_numeric', 'ha_attribute', 'ha_available', 'house_mode', 'device_state', 'zone_state', 'group_state', 'night_mode', 'shared_input'].includes(node.kind));
} }
function flowSimulationLouverAxis(node) { function flowSimulationLouverAxis(node) {
if (node?.kind === 'device_state') { if (node?.kind === 'device_state') {
@@ -1243,7 +1333,7 @@ function renderFlowSimulationOverrides() {
host.innerHTML = nodes.length ? nodes.map(node => { host.innerHTML = nodes.length ? nodes.map(node => {
const effectiveKind = node.kind === 'shared_input' ? (app.flowSharedInputs || []).find(item => item.id === node.config?.input_id)?.kind : node.kind; const effectiveKind = node.kind === 'shared_input' ? (app.flowSharedInputs || []).find(item => item.id === node.config?.input_id)?.kind : node.kind;
const louverAxis = flowSimulationLouverAxis(node); const louverAxis = flowSimulationLouverAxis(node);
const numeric = ['outdoor_temperature', 'device_temperature', 'zone_temperature', 'ha_numeric'].includes(effectiveKind); const numeric = ['outdoor_temperature', 'device_temperature', 'zone_temperature', 'ha_numeric', 'heating_curve'].includes(effectiveKind);
const control = louverAxis const control = louverAxis
? `<select data-flow-sim-node="${esc(node.id)}"><option value="">${esc(tr('flow.useLiveValue'))}</option>${louverOptions(louverAxis, null)}</select>` ? `<select data-flow-sim-node="${esc(node.id)}"><option value="">${esc(tr('flow.useLiveValue'))}</option>${louverOptions(louverAxis, null)}</select>`
: `<input ${numeric ? 'type="number" step="0.1"' : 'type="text"'} data-flow-sim-node="${esc(node.id)}" placeholder="${esc(tr('flow.useLiveValue'))}">`; : `<input ${numeric ? 'type="number" step="0.1"' : 'type="text"'} data-flow-sim-node="${esc(node.id)}" placeholder="${esc(tr('flow.useLiveValue'))}">`;
@@ -1256,7 +1346,7 @@ function collectFlowSimulationOverrides() {
if (input.value.trim() === '') return; if (input.value.trim() === '') return;
const node = flowNodeById(input.dataset.flowSimNode); let value = input.value.trim(); const node = flowNodeById(input.dataset.flowSimNode); let value = input.value.trim();
const effectiveKind = node?.kind === 'shared_input' ? (app.flowSharedInputs || []).find(item => item.id === node.config?.input_id)?.kind : node?.kind; const effectiveKind = node?.kind === 'shared_input' ? (app.flowSharedInputs || []).find(item => item.id === node.config?.input_id)?.kind : node?.kind;
if (node && (flowSimulationLouverAxis(node) || ['outdoor_temperature', 'device_temperature', 'zone_temperature', 'ha_numeric'].includes(effectiveKind))) value = Number(value); if (node && (flowSimulationLouverAxis(node) || ['outdoor_temperature', 'device_temperature', 'zone_temperature', 'ha_numeric', 'heating_curve'].includes(effectiveKind))) value = Number(value);
else if (/^(true|false)$/i.test(value)) value = value.toLowerCase() === 'true'; else if (/^(true|false)$/i.test(value)) value = value.toLowerCase() === 'true';
result[input.dataset.flowSimNode] = value; result[input.dataset.flowSimNode] = value;
}); });
@@ -1283,7 +1373,7 @@ function flowSimpleSimulationControl(node) {
const louverAxis = flowSimulationLouverAxis(node); const louverAxis = flowSimulationLouverAxis(node);
const live = `<option value="">${esc(tr('flow.useLiveValue'))}</option>`; const live = `<option value="">${esc(tr('flow.useLiveValue'))}</option>`;
if (louverAxis) return `<select data-flow-simple-sim-node="${esc(node.id)}">${live}${louverOptions(louverAxis, null)}</select>`; if (louverAxis) return `<select data-flow-simple-sim-node="${esc(node.id)}">${live}${louverOptions(louverAxis, null)}</select>`;
if (['outdoor_temperature', 'device_temperature', 'zone_temperature', 'ha_numeric'].includes(source.kind)) { if (['outdoor_temperature', 'device_temperature', 'zone_temperature', 'ha_numeric', 'heating_curve'].includes(source.kind)) {
return `<input type="number" step="0.1" data-flow-simple-sim-node="${esc(node.id)}" placeholder="${esc(tr('flow.useLiveValue'))}">`; return `<input type="number" step="0.1" data-flow-simple-sim-node="${esc(node.id)}" placeholder="${esc(tr('flow.useLiveValue'))}">`;
} }
if (source.kind === 'house_mode') { if (source.kind === 'house_mode') {
@@ -1309,7 +1399,7 @@ function collectFlowSimpleSimulationOverrides(flow) {
const node = byId.get(input.dataset.flowSimpleSimNode); if (!node) return; const node = byId.get(input.dataset.flowSimpleSimNode); if (!node) return;
const source = flowSimulationEffectiveSource(node); const source = flowSimulationEffectiveSource(node);
let value = raw; let value = raw;
if (flowSimulationLouverAxis(node) || ['outdoor_temperature', 'device_temperature', 'zone_temperature', 'ha_numeric'].includes(source.kind)) value = Number(raw); if (flowSimulationLouverAxis(node) || ['outdoor_temperature', 'device_temperature', 'zone_temperature', 'ha_numeric', 'heating_curve'].includes(source.kind)) value = Number(raw);
else if (/^(true|false)$/i.test(raw)) value = raw.toLowerCase() === 'true'; else if (/^(true|false)$/i.test(raw)) value = raw.toLowerCase() === 'true';
result[node.id] = value; result[node.id] = value;
}); });
@@ -1320,6 +1410,10 @@ function flowSimpleSimulationActualText(node, actual) {
if (actual === null || actual === undefined) return tr('flow.simpleNoData'); if (actual === null || actual === undefined) return tr('flow.simpleNoData');
if (typeof actual === 'object') { if (typeof actual === 'object') {
if (actual.error) return tr('flow.simpleNoData'); if (actual.error) return tr('flow.simpleNoData');
if (node?.kind === 'heating_curve') {
const input = Number(actual.input), output = Number(actual.output);
if (Number.isFinite(input) && Number.isFinite(output)) return `${input.toFixed(1)} °C → ${output.toFixed(1)} °C`;
}
return ''; return '';
} }
const source = flowSimulationEffectiveSource(node); const source = flowSimulationEffectiveSource(node);
@@ -1361,7 +1455,8 @@ function renderFlowSimpleSimulationResult(result) {
return `<div class="flow-simple-condition ${item.matched ? 'pass' : 'fail'}"><span class="flow-simple-condition-mark">${uiIcon(item.matched ? 'check' : 'close')}</span><span><strong>${esc(flowNodeTitle(FLOW_NODE_META[node.kind] || { title: node.kind }))}</strong><small>${esc(flowNodeSummary(node))}</small></span>${actual ? `<em>${esc(tr('flow.simpleCurrentValue', { value: actual }))}</em>` : ''}</div>`; return `<div class="flow-simple-condition ${item.matched ? 'pass' : 'fail'}"><span class="flow-simple-condition-mark">${uiIcon(item.matched ? 'check' : 'close')}</span><span><strong>${esc(flowNodeTitle(FLOW_NODE_META[node.kind] || { title: node.kind }))}</strong><small>${esc(flowNodeSummary(node))}</small></span>${actual ? `<em>${esc(tr('flow.simpleCurrentValue', { value: actual }))}</em>` : ''}</div>`;
}).join(''); }).join('');
const actionType = actionNode ? flowNodeTitle(FLOW_NODE_META[actionNode.kind] || { title: actionNode.kind }) : tr('flow.simpleEffect'); const actionType = actionNode ? flowNodeTitle(FLOW_NODE_META[actionNode.kind] || { title: actionNode.kind }) : tr('flow.simpleEffect');
return `<article class="flow-simple-result ${state}"><div class="flow-simple-result-head"><div><small>${esc(`${tr('flow.simpleEffect')} · ${actionType}`)}</small><strong>${esc(actionNode ? flowNodeSummary(actionNode) : action.node_id)}</strong></div><span class="badge ${action.would_execute ? 'active' : ''}">${esc(stateLabel)}</span></div><p>${esc(explanation)}</p>${trace ? `<details class="flow-simple-details" ${action.would_execute ? '' : 'open'}><summary>${esc(tr('flow.simpleConditions'))}</summary><div class="flow-simple-condition-list">${trace}</div></details>` : ''}</article>`; const dynamicValue = Number.isFinite(Number(action.dynamic_value)) ? `${Number(action.dynamic_value).toFixed(1)} °C → ${action.dynamic_value_target || 'target'}` : '';
return `<article class="flow-simple-result ${state}"><div class="flow-simple-result-head"><div><small>${esc(`${tr('flow.simpleEffect')} · ${actionType}`)}</small><strong>${esc(actionNode ? flowNodeSummary(actionNode) : action.node_id)}</strong></div><span class="badge ${action.would_execute ? 'active' : ''}">${esc(stateLabel)}</span></div><p>${esc(explanation)}</p>${dynamicValue ? `<p>${esc(tr('flow.heatingCurveDryRunValue'))}: <code>${esc(dynamicValue)}</code></p>` : ''}${trace ? `<details class="flow-simple-details" ${action.would_execute ? '' : 'open'}><summary>${esc(tr('flow.simpleConditions'))}</summary><div class="flow-simple-condition-list">${trace}</div></details>` : ''}</article>`;
}).join(''); }).join('');
} }
@@ -1398,7 +1493,7 @@ function openFlowDryRun() {
function renderFlowDryRunResult(result) { function renderFlowDryRunResult(result) {
const host = $('#flowTestResults'); const host = $('#flowTestResults');
const actionName = id => flowNodeById(id) ? `${flowNodeTitle(FLOW_NODE_META[flowNodeById(id).kind])}: ${flowNodeSummary(flowNodeById(id))}` : id; const actionName = id => flowNodeById(id) ? `${flowNodeTitle(FLOW_NODE_META[flowNodeById(id).kind])}: ${flowNodeSummary(flowNodeById(id))}` : id;
host.innerHTML = `<div class="flow-test-summary"><strong>${esc(result.summary || tr('flow.dryRun'))}</strong><span>${esc(tr('flow.compiledPreview', result.compiled || { schedules: 0, automations: 0 }))}</span></div>${(result.actions || []).map(action => `<article class="flow-test-action ${action.would_execute ? 'pass' : 'blocked'}"><div><strong>${esc(actionName(action.node_id))}</strong><span class="badge ${action.would_execute ? 'active' : ''}">${esc(action.would_execute ? tr('flow.wouldExecute') : action.matched ? tr('flow.blockedByOwnership') : tr('flow.conditionFalse'))}</span></div>${action.blocked_reason ? `<p>${esc(tr('flow.blockReason'))}: <code>${esc(action.blocked_reason)}</code></p>` : ''}<div class="flow-trace">${(action.trace || []).map(item => `<div><span>${uiIcon(item.matched ? 'check' : 'close')} ${esc(flowNodeTitle(FLOW_NODE_META[item.kind] || { title: item.kind }))}</span><code>${esc(JSON.stringify(item.actual))}</code></div>`).join('')}</div></article>`).join('')}`; host.innerHTML = `<div class="flow-test-summary"><strong>${esc(result.summary || tr('flow.dryRun'))}</strong><span>${esc(tr('flow.compiledPreview', result.compiled || { schedules: 0, automations: 0 }))}</span></div>${(result.actions || []).map(action => `<article class="flow-test-action ${action.would_execute ? 'pass' : 'blocked'}"><div><strong>${esc(actionName(action.node_id))}</strong><span class="badge ${action.would_execute ? 'active' : ''}">${esc(action.would_execute ? tr('flow.wouldExecute') : action.matched ? tr('flow.blockedByOwnership') : tr('flow.conditionFalse'))}</span></div>${action.blocked_reason ? `<p>${esc(tr('flow.blockReason'))}: <code>${esc(action.blocked_reason)}</code></p>` : ''}${Number.isFinite(Number(action.dynamic_value)) ? `<p>${esc(tr('flow.heatingCurveDryRunValue'))}: <code>${esc(`${Number(action.dynamic_value).toFixed(1)} °C → ${action.dynamic_value_target || 'target'}`)}</code></p>` : ''}<div class="flow-trace">${(action.trace || []).map(item => `<div><span>${uiIcon(item.matched ? 'check' : 'close')} ${esc(flowNodeTitle(FLOW_NODE_META[item.kind] || { title: item.kind }))}</span><code>${esc(JSON.stringify(item.actual))}</code></div>`).join('')}</div></article>`).join('')}`;
} }
async function runFlowDryRun() { async function runFlowDryRun() {
if (!app.flowDraft) return; if (!app.flowDraft) return;
@@ -1659,6 +1754,44 @@ function updateFlowConfig(input) {
app.flowDirty = true; renderFlowEditor(); flowHistoryCommit(); app.flowDirty = true; renderFlowEditor(); flowHistoryCommit();
} }
function updateFlowHeatingCurvePoint(input) {
const node = flowNodeById(app.flowSelectedNodeId); if (!node || node.kind !== 'heating_curve') return;
const index = Number(input.dataset.flowCurveIndex); const axis = input.dataset.flowCurvePoint;
const points = flowHeatingCurvePoints(node.config); const value = Number(input.value);
if (!Number.isInteger(index) || !points[index] || !['x', 'y'].includes(axis) || !Number.isFinite(value)) return;
points[index][axis] = value;
const duplicateX = axis === 'x' && points.some((point, pointIndex) => pointIndex !== index && Math.abs(point.x - value) < 0.000001);
if (duplicateX) { toast(tr('flow.heatingCurveDuplicateX'), true); renderFlowInspector(); return; }
node.config.points = points.sort((a, b) => a.x - b.x);
app.flowDirty = true; renderFlowEditor(); flowHistoryCommit();
}
function addFlowHeatingCurvePoint() {
const node = flowNodeById(app.flowSelectedNodeId); if (!node || node.kind !== 'heating_curve') return;
const points = flowHeatingCurvePoints(node.config); if (points.length >= 32) return;
if (points.length < 2) {
node.config.points = [{ x: -10, y: 28 }, { x: 10, y: 22 }];
} else {
let bestIndex = 0, bestGap = -Infinity;
for (let index = 0; index < points.length - 1; index += 1) {
const gap = points[index + 1].x - points[index].x;
if (gap > bestGap) { bestGap = gap; bestIndex = index; }
}
const a = points[bestIndex], b = points[bestIndex + 1];
const x = Number(((a.x + b.x) / 2).toFixed(1));
const y = Number(flowHeatingCurveInterpolate(points, x).toFixed(1));
points.push({ x, y }); node.config.points = points.sort((left, right) => left.x - right.x);
}
app.flowDirty = true; renderFlowEditor(); flowHistoryCommit();
}
function removeFlowHeatingCurvePoint(index) {
const node = flowNodeById(app.flowSelectedNodeId); if (!node || node.kind !== 'heating_curve') return;
const points = flowHeatingCurvePoints(node.config); if (points.length <= 2 || !points[index]) return;
points.splice(index, 1); node.config.points = points;
app.flowDirty = true; renderFlowEditor(); flowHistoryCommit();
}
let flowDrag = null; let flowDrag = null;
let flowMarquee = null; let flowMarquee = null;
@@ -1850,6 +1983,8 @@ document.addEventListener('keydown', event => {
}); });
document.addEventListener('click', event => { document.addEventListener('click', event => {
const curveAdd = event.target.closest?.('[data-flow-curve-add]'); if (curveAdd) { addFlowHeatingCurvePoint(); return; }
const curveRemove = event.target.closest?.('[data-flow-curve-remove]'); if (curveRemove) { removeFlowHeatingCurvePoint(Number(curveRemove.dataset.flowCurveRemove)); return; }
const contextAction = event.target.closest?.('[data-flow-context-action]'); const contextAction = event.target.closest?.('[data-flow-context-action]');
if (contextAction) { if (contextAction) {
const action = contextAction.dataset.flowContextAction; const action = contextAction.dataset.flowContextAction;
@@ -1926,6 +2061,7 @@ document.addEventListener('click', event => {
else if (action === 'close-flow-editor') closeFlowEditor(); else if (action === 'close-flow-editor') closeFlowEditor();
}); });
document.addEventListener('change', event => { document.addEventListener('change', event => {
if (event.target.matches?.('[data-flow-curve-point]')) { updateFlowHeatingCurvePoint(event.target); return; }
if (event.target.matches?.('[data-flow-config]')) updateFlowConfig(event.target); if (event.target.matches?.('[data-flow-config]')) updateFlowConfig(event.target);
}); });
$('#flowDescriptionForm')?.addEventListener('submit', event => { event.preventDefault(); saveFlowDescription(); }); $('#flowDescriptionForm')?.addEventListener('submit', event => { event.preventDefault(); saveFlowDescription(); });