v0.15.20
This commit is contained in:
1 parent
abfbf0dd68
commit
aa1cb05b2f
24 files changed
+750
-71
No files matched your search
Generated
+1
-1
@@ -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
@@ -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
@@ -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,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
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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))
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
+267
-10
@@ -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" {
|
||||||
flow_device_feature_command(&action.config)?
|
if value_binding.is_some()
|
||||||
|
&& flow_string(&action.config, "feature").as_deref()
|
||||||
|
== Some("target_temperature")
|
||||||
|
{
|
||||||
|
DeviceCommand::default()
|
||||||
|
} else {
|
||||||
|
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
@@ -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"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>,
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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(); });
|
||||||
|
|||||||
Reference in new issue
Block a user