diff --git a/Cargo.lock b/Cargo.lock index e2b9700..67dfa87 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -719,7 +719,7 @@ dependencies = [ [[package]] name = "gree-controller" -version = "0.15.19" +version = "0.15.20" dependencies = [ "aes", "aes-gcm", diff --git a/Cargo.toml b/Cargo.toml index b9d6432..7427570 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "gree-controller" -version = "0.15.19" +version = "0.15.20" edition = "2021" authors = ["GREE Controller contributors"] description = "Standalone local GREE HVAC controller with Web UI, SQLite and Home Assistant sensor support" diff --git a/FILE_MANIFEST.sha256 b/FILE_MANIFEST.sha256 index a8e785e..781d86c 100644 --- a/FILE_MANIFEST.sha256 +++ b/FILE_MANIFEST.sha256 @@ -4,10 +4,10 @@ a4ec3874a2e3ab1bad28fb40bb620f7b01f64d01ad9b699306bf70ada31227db ./.gitignore ab2b0e10ea3a02b56d689f994bd89fa71a61eff49306995e725f7104c122b53d ./Cargo.toml 19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE 98217f563d7ed22f8e36a8092cfb3fdba7efe217fea543f7edae4a836a38baab ./README.md -135b04a5acf661be25afe52c7a6a7826cf24a892398e3a28d9c77503272562d5 ./SOURCE_MANIFEST.sha256 +9b99f30c22e96e328366a8b3f5ea88be0a6405e6675b27b54c66e5ba16a2c1c4 ./SOURCE_MANIFEST.sha256 29e9821e5ed216ea3565dd6d8bc0b174ed3cf9fa72c4ccebbfa99e98db3ad8f9 ./build.rs 41e1b38bb5378e1fbcf59e2a956008eea2b3141cfef102c72cf88d28f5f4cf65 ./docs/API.md -94585aab43325a139f6c291f2293d93206d382c25a095b46930b924aaf336765 ./docs/FLOW.md +eab8cbca46f87ffcba537aa882e671a6c0ac368856f3aa13896617d272e7ae02 ./docs/FLOW.md a273744fab39afc79defa0957d8f4a404abb222f32f35316075521cb3a6cc4b0 ./docs/openapi.json 77b8becf7a2c16a384ea85721d878b76e2d520549cb4455d9aa806e57e5a2ecd ./ha-addon/.env.example 0ca6e58170c2b8925194431ebbdb55f0c8b377fec5cd422cd2bd257e2d9328e3 ./ha-addon/Dockerfile @@ -43,8 +43,8 @@ cea12bc74c066a588a5ef0ef7bad9d4906bdbcea99f2d275429b0eefc94b4b5f ./ha-addon/run 081e27b6e43070d44f865335c30a42a5a29668eaa6a5ea5ab6967ac4447b8e06 ./ha-addon/sync-repository.sh f9e31de3d109ead32bf531c074d46a087df398d62488caee0b46b5e0a1c027f7 ./ha_addon.md 253a0bc912786e67ea7fc92a64e4a510ad973bec343a88ccfb1f28fca3e8cf01 ./lang/README.md -c5c95c19ceac8969435c6f7742c86e7efa4db9bf723a0b30b098acf7fe6295fa ./lang/en.json -9ebf7bcfd49220e828336f551251ffc86986772864e75a5a74493cee27929749 ./lang/pl.json +ea015719d066cab86aff9daab33c3a83a29def711195eca416652be45a033ef8 ./lang/en.json +fefb2710c34195a2d50c62aa107e2f284875990fa63f7da42c8019b0398c3387 ./lang/pl.json d8459024f04ca514bd8e9d6bd3af872fb942fd85a7cdd5583f4a9d28aab6faba ./make_zip.py b14233a8987e53bbbdd6770386ba10fa166ec25c1e098275c173c544b37846fc ./presets/bedroom_window_night.json 1960841119c0b673fb2f03621b32237552fd828045304cd221401214b997c910 ./presets/device_resilience.json @@ -96,18 +96,18 @@ bb7cd2c5b27c9dceec1d1d2846fbad9600df9c08e0ad07d13fcde97af533091c ./scripts/inst e00d211e3885e30d7fed1e43b44e6fdad40a67019060156c0641816a93e3365f ./scripts/network-debug.sh 01952aa92b217f8eae2493b88870e2dec595100cd15c4d561ff11ae2b936c46f ./scripts/regenerate-sha.sh 81345b6a0b51736bdbc98fd23199b62e4c721b4e7437e02dab7ea79b97dff29a ./scripts/service.sh -bd703ef841f9763f13367aaa5764381b9c59ee87210e806c24617f3cc0739a9b ./scripts/smoke.sh +36396ecfcfd30f50016b17a83cd76cbccd8debaacef45a0d6b4e61946fef098d ./scripts/smoke.sh b50782b3742dfbf8a319c60571c968e93fdf8547db747c759edcffae68cb98bf ./scripts/update.sh ea84f5230da97447c60dfb4f8dadd0747c92fd1f7a74d5a65d6fdb6f23cf4b6d ./src/api.rs 0db0d81da4b8161004ddcbed2096812c285ecec9d944771670e6c8edf0775175 ./src/api/assets.rs 1139dacbd3f6ff94bf721eb5a60d469016fb935ddcf484d71bfeb249b72c833a ./src/api/auth.rs -a652409de6e2e2897d67d19dc16a7d4209c5c35afda39081fef627a098a739b4 ./src/api/automations.rs +1eb8c3cd86beb4f23aef21a23d31c144a9b31ec5bbaeaf24536723da5e78b768 ./src/api/automations.rs fffad7874465739cd6b508e9d7791387b864fa88420ce0f7e2fc933d129a2751 ./src/api/configuration.rs 19bc141286a336ad67e99079496eb0ae5126f986ab28be62b5f97758e5f6969c ./src/api/debug_tokens.rs ca21f6d5a407e2e3532899d53ba4e8682c2fe961a46d8cd870ddc459721513ef ./src/api/device_groups.rs 8f92909dc575ea6238f4a003f8bd4056c2752fa3247b9ab226e47f875c80b916 ./src/api/devices.rs 4b5b784670ae761a6848b9a33fe3fb391bebd39d65e87936907428711e96ca44 ./src/api/events.rs -b1e9567cae7036af51945ce1219f537567c944c0402c2ff03edb1249403670b5 ./src/api/flows.rs +1c5bc76fca312e6cd5fce988b3bfee785068322337b609405c12e18c1448665a ./src/api/flows.rs 82c02fa135132ea3ebdb4d88dd1210dc536d22f987151d7f82595ee3d3d03440 ./src/api/gree_cloud.rs b61b0b4f196c5e72e47cf06ca0c14df3cf8eedbdef0242a9d2af33623663489f ./src/api/groups.rs b6d586bc42b166ac05648f3da7d9aeb88efde2e9eaab5642685eb4d9cf15f95a ./src/api/history.rs @@ -136,7 +136,7 @@ d6b9b70a04c774173c97f7426487d5e2932d7713e12ae3fb7f64cf2b4e0b3c10 ./src/db/sched 28ee42d08147b0f154af818065d7d014d74ee8ddd59fdef98115f5c5368056cc ./src/db/tests.rs f90fbb14f29a20b799b3856bdaa22812a9f0d9dfa46693f1341c160dccda8a97 ./src/db/zone_history.rs 0e49ea4a52017b0a8501d5101b1906a7a1e8d5158acb0bb65caa43cde2836ec0 ./src/engine.rs -e39d0bbc4c1dda7c5538b2c2abdbad7923e56f40ad7a9788c8dbd338244f2c41 ./src/engine/automations.rs +b187f88dd821940a7c44ab57e49d46423c6d039ba3ebb8ba2601fb08e268b7fa ./src/engine/automations.rs 1dccb8779f87dfdbd4ccda3f02ec51273e7be2e34e85166861668685528eca79 ./src/engine/commands.rs 357ef92c3cc42b243af4da31ac52bb360d7444ae423f58d7a13bdee79bf0a748 ./src/engine/connectivity.rs 828657b3c13b68792991b5ca2a26ae0b1d5c417191c83c4e5f650f62b6440096 ./src/engine/control_plan.rs @@ -152,7 +152,7 @@ c73691c3637465ab0f79aecf7f5428d0317799a8ab5329ed564cf57963bbe86e ./src/engine/r b0189f28d0f337dfd3ce0934da7a565e4a5535afc1c242214c26ed584b3b7840 ./src/engine/targets.rs 682c8b3956c657b655746cbd19ee97ee2f4cf953bc9de67d6d70a1b14f3dd746 ./src/engine/temperature.rs 833b72907375e75c3f42b3921ad70a8e298f5a93743bf0d0c19059aa272462d1 ./src/engine/temporary_thermostat.rs -0d4f2df3c3881aceb41b9f8d78adde7ecbb5b39d2f8aea444b865166a0670dec ./src/engine/tests.rs +cea4b27a419fd8eb92f755cd19c79c70bc164f2f44199a9253587df574f534f1 ./src/engine/tests.rs c89ff36c6a67bf4fbf68c43e18aa0a556de2dc69263fd79db376363afc741a4f ./src/engine/zone_actions.rs 87ab87d434a6462a2c3f25249b42c59ecb2bca6ed700ec73e95e4613349cf16f ./src/engine/zone_control.rs bfac95cec87f0df6ce562153cd3cfe6731359d703e6c592d2c2f7ba01ea62d99 ./src/error.rs @@ -163,7 +163,7 @@ da4068295f37c23222bb13bebac022d88ba7729604a49ad25438c55e45458a49 ./src/influxdb 67e42a92295480d2f1d364958f184654cfb5d4b77e7e1df47f588bdec18868a0 ./src/influxdb/write.rs 62f9da6ef4f5fd92701d0857773f9f6236ef25356f21b9b42236abab6ba7da53 ./src/main.rs 23688a5cb68efded89471ce959328d27adff370d451bd9f378a283ab51437e76 ./src/models.rs -a7d40ff2b00c6352223bd6335dc734925c42d70be2c70d1cb0b23051baa6f867 ./src/models/automation.rs +615fba5dc5f3ad75497d637b02cc83b7aacfe7baa6372c034dd5609bfff398a0 ./src/models/automation.rs e4195a40135f408302a78cb200a54e6ef1c0e6031fb069a2431d068e5dbbdd8d ./src/models/control_plan.rs 06357b352ff6494d83e72796b869573a8b8f36b835985a5900ac571c922b8da9 ./src/models/defaults.rs 698dcbeba72037a251c25d07c95833f121716d949f9217f1f9429e8cd40638e5 ./src/models/device.rs @@ -199,10 +199,10 @@ f13f1be3d5789539eba3fc1b59c14835f8c681eca7634e118fa3763a53aeee2b ./src/queries/ d8a602ed8906d77593bd140315ce1bd56716038e789f53ff31762e9b3cb7220d ./src/state.rs b92a6cb158b494fe145b43c7641e65f6fafff47201d7d76edbec2cfd8b94835c ./systemd/gree-controller.service 544a31cb2e5374227026afd6abd0d910381f3fca1638154ebd74e3c3d96a35d1 ./web/404.html -da9cb50e003fa25989a693741c81ceb8b36c2bebbbb370c42184e2a89057c83c ./web/css/styles.css +6a30a5ecee99d2cf40381b9f770ed574e9e85b8b50e3425a77e6f64283faeee6 ./web/css/styles.css 183d122ba366303d9952ac0e68aff4bced4e0d9a468fbe51316c13f15227402b ./web/custom-chart.html e98bdd7204349cce1ec6f57283509697af0bbc72280622a6c3efa6fed242db4f ./web/favicon.svg -43f33fc21bb11df0ea149fe7fbca722a57f93890e4d9bb542ced703c1311207e ./web/index.html +78996e460243aa3043bd889891a99b9417d930beb7c9ea81b4a7f496e3d58844 ./web/index.html 9ba3f6fa05b72aa989139b2a909982571b2a02055052e4c40104f1e81a9aa7eb ./web/js-dynamic/README.md e7318cd92b76e8a0109dc01adc2431474be17d8bb1488cdf6ffb39dc5450909d ./web/js-dynamic/bootstrap.js 5d57253a013fd1cb79e4b8701d4be6e394e3f2acbce73dfcd726a86e5878f1a1 ./web/js-dynamic/charts.js @@ -210,7 +210,7 @@ e7318cd92b76e8a0109dc01adc2431474be17d8bb1488cdf6ffb39dc5450909d ./web/js-dynam 80aaf5c56375345c67fd30c4fc1622319e4749b2859ec10613f77ac117f0441e ./web/js-dynamic/dashboard.js 930e67432f55e8476513e808edfa9a0809a8da7479307284fcc136082a4129a4 ./web/js-dynamic/entities.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 92d013bb5d64f64aa8c55380704540a5f72732bde7ccd16554965e401daf50ea ./web/js-dynamic/history.js 511c4e7cc4c48fbbdab64558a9d1c27e6759c9b6dca255d11f301b9708e955c7 ./web/js-dynamic/main.js diff --git a/README.md b/README.md index 8cddce6..c922d4a 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,9 @@ Self-hosted controller for GREE-compatible air conditioners with a local Web UI, thermostat zones, schedules, Home Assistant integration, history, notifications and a documented HTTP/WebSocket API. -**Version: 0.15.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. > Built-in API: `/api/*` and `/ws`; interactive Swagger docs: `/api-docs`; OpenAPI JSON: `/api-docs/openapi.json`. diff --git a/SOURCE_MANIFEST.sha256 b/SOURCE_MANIFEST.sha256 index 9d1b0e1..ce0b9f5 100644 --- a/SOURCE_MANIFEST.sha256 +++ b/SOURCE_MANIFEST.sha256 @@ -6,7 +6,7 @@ ab2b0e10ea3a02b56d689f994bd89fa71a61eff49306995e725f7104c122b53d ./Cargo.toml 98217f563d7ed22f8e36a8092cfb3fdba7efe217fea543f7edae4a836a38baab ./README.md 29e9821e5ed216ea3565dd6d8bc0b174ed3cf9fa72c4ccebbfa99e98db3ad8f9 ./build.rs 41e1b38bb5378e1fbcf59e2a956008eea2b3141cfef102c72cf88d28f5f4cf65 ./docs/API.md -94585aab43325a139f6c291f2293d93206d382c25a095b46930b924aaf336765 ./docs/FLOW.md +eab8cbca46f87ffcba537aa882e671a6c0ac368856f3aa13896617d272e7ae02 ./docs/FLOW.md a273744fab39afc79defa0957d8f4a404abb222f32f35316075521cb3a6cc4b0 ./docs/openapi.json 77b8becf7a2c16a384ea85721d878b76e2d520549cb4455d9aa806e57e5a2ecd ./ha-addon/.env.example 0ca6e58170c2b8925194431ebbdb55f0c8b377fec5cd422cd2bd257e2d9328e3 ./ha-addon/Dockerfile @@ -42,8 +42,8 @@ cea12bc74c066a588a5ef0ef7bad9d4906bdbcea99f2d275429b0eefc94b4b5f ./ha-addon/run 081e27b6e43070d44f865335c30a42a5a29668eaa6a5ea5ab6967ac4447b8e06 ./ha-addon/sync-repository.sh f9e31de3d109ead32bf531c074d46a087df398d62488caee0b46b5e0a1c027f7 ./ha_addon.md 253a0bc912786e67ea7fc92a64e4a510ad973bec343a88ccfb1f28fca3e8cf01 ./lang/README.md -c5c95c19ceac8969435c6f7742c86e7efa4db9bf723a0b30b098acf7fe6295fa ./lang/en.json -9ebf7bcfd49220e828336f551251ffc86986772864e75a5a74493cee27929749 ./lang/pl.json +ea015719d066cab86aff9daab33c3a83a29def711195eca416652be45a033ef8 ./lang/en.json +fefb2710c34195a2d50c62aa107e2f284875990fa63f7da42c8019b0398c3387 ./lang/pl.json d8459024f04ca514bd8e9d6bd3af872fb942fd85a7cdd5583f4a9d28aab6faba ./make_zip.py b14233a8987e53bbbdd6770386ba10fa166ec25c1e098275c173c544b37846fc ./presets/bedroom_window_night.json 1960841119c0b673fb2f03621b32237552fd828045304cd221401214b997c910 ./presets/device_resilience.json @@ -95,18 +95,18 @@ bb7cd2c5b27c9dceec1d1d2846fbad9600df9c08e0ad07d13fcde97af533091c ./scripts/inst e00d211e3885e30d7fed1e43b44e6fdad40a67019060156c0641816a93e3365f ./scripts/network-debug.sh 01952aa92b217f8eae2493b88870e2dec595100cd15c4d561ff11ae2b936c46f ./scripts/regenerate-sha.sh 81345b6a0b51736bdbc98fd23199b62e4c721b4e7437e02dab7ea79b97dff29a ./scripts/service.sh -bd703ef841f9763f13367aaa5764381b9c59ee87210e806c24617f3cc0739a9b ./scripts/smoke.sh +36396ecfcfd30f50016b17a83cd76cbccd8debaacef45a0d6b4e61946fef098d ./scripts/smoke.sh b50782b3742dfbf8a319c60571c968e93fdf8547db747c759edcffae68cb98bf ./scripts/update.sh ea84f5230da97447c60dfb4f8dadd0747c92fd1f7a74d5a65d6fdb6f23cf4b6d ./src/api.rs 0db0d81da4b8161004ddcbed2096812c285ecec9d944771670e6c8edf0775175 ./src/api/assets.rs 1139dacbd3f6ff94bf721eb5a60d469016fb935ddcf484d71bfeb249b72c833a ./src/api/auth.rs -a652409de6e2e2897d67d19dc16a7d4209c5c35afda39081fef627a098a739b4 ./src/api/automations.rs +1eb8c3cd86beb4f23aef21a23d31c144a9b31ec5bbaeaf24536723da5e78b768 ./src/api/automations.rs fffad7874465739cd6b508e9d7791387b864fa88420ce0f7e2fc933d129a2751 ./src/api/configuration.rs 19bc141286a336ad67e99079496eb0ae5126f986ab28be62b5f97758e5f6969c ./src/api/debug_tokens.rs ca21f6d5a407e2e3532899d53ba4e8682c2fe961a46d8cd870ddc459721513ef ./src/api/device_groups.rs 8f92909dc575ea6238f4a003f8bd4056c2752fa3247b9ab226e47f875c80b916 ./src/api/devices.rs 4b5b784670ae761a6848b9a33fe3fb391bebd39d65e87936907428711e96ca44 ./src/api/events.rs -b1e9567cae7036af51945ce1219f537567c944c0402c2ff03edb1249403670b5 ./src/api/flows.rs +1c5bc76fca312e6cd5fce988b3bfee785068322337b609405c12e18c1448665a ./src/api/flows.rs 82c02fa135132ea3ebdb4d88dd1210dc536d22f987151d7f82595ee3d3d03440 ./src/api/gree_cloud.rs b61b0b4f196c5e72e47cf06ca0c14df3cf8eedbdef0242a9d2af33623663489f ./src/api/groups.rs b6d586bc42b166ac05648f3da7d9aeb88efde2e9eaab5642685eb4d9cf15f95a ./src/api/history.rs @@ -135,7 +135,7 @@ d6b9b70a04c774173c97f7426487d5e2932d7713e12ae3fb7f64cf2b4e0b3c10 ./src/db/sched 28ee42d08147b0f154af818065d7d014d74ee8ddd59fdef98115f5c5368056cc ./src/db/tests.rs f90fbb14f29a20b799b3856bdaa22812a9f0d9dfa46693f1341c160dccda8a97 ./src/db/zone_history.rs 0e49ea4a52017b0a8501d5101b1906a7a1e8d5158acb0bb65caa43cde2836ec0 ./src/engine.rs -e39d0bbc4c1dda7c5538b2c2abdbad7923e56f40ad7a9788c8dbd338244f2c41 ./src/engine/automations.rs +b187f88dd821940a7c44ab57e49d46423c6d039ba3ebb8ba2601fb08e268b7fa ./src/engine/automations.rs 1dccb8779f87dfdbd4ccda3f02ec51273e7be2e34e85166861668685528eca79 ./src/engine/commands.rs 357ef92c3cc42b243af4da31ac52bb360d7444ae423f58d7a13bdee79bf0a748 ./src/engine/connectivity.rs 828657b3c13b68792991b5ca2a26ae0b1d5c417191c83c4e5f650f62b6440096 ./src/engine/control_plan.rs @@ -151,7 +151,7 @@ c73691c3637465ab0f79aecf7f5428d0317799a8ab5329ed564cf57963bbe86e ./src/engine/r b0189f28d0f337dfd3ce0934da7a565e4a5535afc1c242214c26ed584b3b7840 ./src/engine/targets.rs 682c8b3956c657b655746cbd19ee97ee2f4cf953bc9de67d6d70a1b14f3dd746 ./src/engine/temperature.rs 833b72907375e75c3f42b3921ad70a8e298f5a93743bf0d0c19059aa272462d1 ./src/engine/temporary_thermostat.rs -0d4f2df3c3881aceb41b9f8d78adde7ecbb5b39d2f8aea444b865166a0670dec ./src/engine/tests.rs +cea4b27a419fd8eb92f755cd19c79c70bc164f2f44199a9253587df574f534f1 ./src/engine/tests.rs c89ff36c6a67bf4fbf68c43e18aa0a556de2dc69263fd79db376363afc741a4f ./src/engine/zone_actions.rs 87ab87d434a6462a2c3f25249b42c59ecb2bca6ed700ec73e95e4613349cf16f ./src/engine/zone_control.rs bfac95cec87f0df6ce562153cd3cfe6731359d703e6c592d2c2f7ba01ea62d99 ./src/error.rs @@ -162,7 +162,7 @@ da4068295f37c23222bb13bebac022d88ba7729604a49ad25438c55e45458a49 ./src/influxdb 67e42a92295480d2f1d364958f184654cfb5d4b77e7e1df47f588bdec18868a0 ./src/influxdb/write.rs 62f9da6ef4f5fd92701d0857773f9f6236ef25356f21b9b42236abab6ba7da53 ./src/main.rs 23688a5cb68efded89471ce959328d27adff370d451bd9f378a283ab51437e76 ./src/models.rs -a7d40ff2b00c6352223bd6335dc734925c42d70be2c70d1cb0b23051baa6f867 ./src/models/automation.rs +615fba5dc5f3ad75497d637b02cc83b7aacfe7baa6372c034dd5609bfff398a0 ./src/models/automation.rs e4195a40135f408302a78cb200a54e6ef1c0e6031fb069a2431d068e5dbbdd8d ./src/models/control_plan.rs 06357b352ff6494d83e72796b869573a8b8f36b835985a5900ac571c922b8da9 ./src/models/defaults.rs 698dcbeba72037a251c25d07c95833f121716d949f9217f1f9429e8cd40638e5 ./src/models/device.rs @@ -198,10 +198,10 @@ f13f1be3d5789539eba3fc1b59c14835f8c681eca7634e118fa3763a53aeee2b ./src/queries/ d8a602ed8906d77593bd140315ce1bd56716038e789f53ff31762e9b3cb7220d ./src/state.rs b92a6cb158b494fe145b43c7641e65f6fafff47201d7d76edbec2cfd8b94835c ./systemd/gree-controller.service 544a31cb2e5374227026afd6abd0d910381f3fca1638154ebd74e3c3d96a35d1 ./web/404.html -da9cb50e003fa25989a693741c81ceb8b36c2bebbbb370c42184e2a89057c83c ./web/css/styles.css +6a30a5ecee99d2cf40381b9f770ed574e9e85b8b50e3425a77e6f64283faeee6 ./web/css/styles.css 183d122ba366303d9952ac0e68aff4bced4e0d9a468fbe51316c13f15227402b ./web/custom-chart.html e98bdd7204349cce1ec6f57283509697af0bbc72280622a6c3efa6fed242db4f ./web/favicon.svg -43f33fc21bb11df0ea149fe7fbca722a57f93890e4d9bb542ced703c1311207e ./web/index.html +78996e460243aa3043bd889891a99b9417d930beb7c9ea81b4a7f496e3d58844 ./web/index.html 9ba3f6fa05b72aa989139b2a909982571b2a02055052e4c40104f1e81a9aa7eb ./web/js-dynamic/README.md e7318cd92b76e8a0109dc01adc2431474be17d8bb1488cdf6ffb39dc5450909d ./web/js-dynamic/bootstrap.js 5d57253a013fd1cb79e4b8701d4be6e394e3f2acbce73dfcd726a86e5878f1a1 ./web/js-dynamic/charts.js @@ -209,7 +209,7 @@ e7318cd92b76e8a0109dc01adc2431474be17d8bb1488cdf6ffb39dc5450909d ./web/js-dynam 80aaf5c56375345c67fd30c4fc1622319e4749b2859ec10613f77ac117f0441e ./web/js-dynamic/dashboard.js 930e67432f55e8476513e808edfa9a0809a8da7479307284fcc136082a4129a4 ./web/js-dynamic/entities.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 92d013bb5d64f64aa8c55380704540a5f72732bde7ccd16554965e401daf50ea ./web/js-dynamic/history.js 511c4e7cc4c48fbbdab64558a9d1c27e6759c9b6dca255d11f301b9708e955c7 ./web/js-dynamic/main.js diff --git a/docs/API.md b/docs/API.md index 3034424..fa8b602 100644 --- a/docs/API.md +++ b/docs/API.md @@ -1,6 +1,6 @@ # GREE Controller API reference -HTTP and WebSocket API for GREE Controller **0.15.19**. +HTTP and WebSocket API for GREE Controller **0.15.20**. [← Main documentation](../README.md) @@ -243,7 +243,7 @@ Response: { "status": "ok", "name": "gree-controller", - "version": "0.15.19", + "version": "0.15.20", "uptime_seconds": 1234, "control_ready": true, "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_revision": 42, "system": { - "version": "0.15.19", + "version": "0.15.20", "uptime_seconds": 1234, "auth_required": false, "control_ready": true, diff --git a/docs/FLOW.md b/docs/FLOW.md index 024bc46..dc5e87f 100644 --- a/docs/FLOW.md +++ b/docs/FLOW.md @@ -60,7 +60,11 @@ Dry-run never mutates thermostats, devices, groups, schedules or automations. Fo ## Blocks -Condition/source blocks include weekday, time range, date range, optional 5-field CRON triggers, application Night mode, outdoor/device/zone temperature, house mode, device state, thermostat/zone state, group state, Home Assistant state/numeric/attribute/availability, rolling mean/median windows, numeric oscillation detection, a diagnostic constant, and `shared_input`. Stateful gates include `stable_for`, bounded `state_duration`, `on_change` (condition-result or raw-value edge), `rate_limit` (maximum successful executions in a rolling period), and `delay`. Their runtime state, together with rolling statistics and oscillation samples, is persisted in the generated automation payload so restarts do not silently reset timing/history. Editing a stateful block resets only that block's incompatible runtime state. Flow state continues to be observed while the action itself is in cooldown. `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. diff --git a/docs/openapi.json b/docs/openapi.json index 71613c2..e88f426 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -2,7 +2,7 @@ "openapi": "3.1.0", "info": { "title": "GREE Controller API", - "version": "0.15.19", + "version": "0.15.20", "summary": "Local HTTP/WebSocket API for GREE Controller", "description": "Local API used by the GREE Controller Web UI and Home Assistant integration.", "license": { @@ -6079,7 +6079,7 @@ }, "version": { "type": "string", - "example": "0.15.19" + "example": "0.15.20" }, "uptime_seconds": { "type": "integer", diff --git a/ha-addon/home-assistant/custom_components/gree_controller/manifest.json b/ha-addon/home-assistant/custom_components/gree_controller/manifest.json index de35e07..553521e 100644 --- a/ha-addon/home-assistant/custom_components/gree_controller/manifest.json +++ b/ha-addon/home-assistant/custom_components/gree_controller/manifest.json @@ -1,7 +1,7 @@ { "domain": "gree_controller", "name": "GREE Controller", - "version": "0.15.19", + "version": "0.15.20", "config_flow": true, "integration_type": "hub", "iot_class": "local_polling", diff --git a/ha-addon/repository/gree-controller/CHANGELOG.md b/ha-addon/repository/gree-controller/CHANGELOG.md index 045f661..b235c31 100644 --- a/ha-addon/repository/gree-controller/CHANGELOG.md +++ b/ha-addon/repository/gree-controller/CHANGELOG.md @@ -1,5 +1,14 @@ # 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 - Fixes Visual Flow `zone_thermostat` OFF semantics: `power=false` now powers the assigned unit off without setting the persistent Zone `enabled` flag to false. diff --git a/ha-addon/repository/gree-controller/README.md b/ha-addon/repository/gree-controller/README.md index 8d48340..30ed1e9 100644 --- a/ha-addon/repository/gree-controller/README.md +++ b/ha-addon/repository/gree-controller/README.md @@ -2,7 +2,7 @@ Local GREE HVAC controller packaged as a Home Assistant add-on, with Web UI, HTTP/WebSocket API, built-in Swagger API documentation, UDP discovery/control, `amd64` and `aarch64` support, ingress, and database backup from `/data/gree-controller.db`. -Version 0.15.19 fixes Flow thermostat OFF semantics: `zone_thermostat power=false` powers the assigned unit off without disabling the Zone, so Flow and thermostat evaluation remain active. +Version 0.15.20 adds the Visual Flow heating-curve block with graphical X/Y points, selectable temperature sources, linear interpolation and dynamic setpoint binding for thermostats, GREE targets and Home Assistant service actions. ![Home Assistant OS + GREE + VLAN topology](https://git.linuxiarz.pl/gru/gree-controller-ha-addon/raw/branch/master/gree-controller/topology-ha-vlan-gree-en.png) diff --git a/ha-addon/repository/gree-controller/config.yaml b/ha-addon/repository/gree-controller/config.yaml index 6e3742d..2257506 100644 --- a/ha-addon/repository/gree-controller/config.yaml +++ b/ha-addon/repository/gree-controller/config.yaml @@ -1,5 +1,5 @@ name: "GREE Controller" -version: "0.15.19" +version: "0.15.20" slug: "gree_controller" description: "Local GREE HVAC controller with Web UI, API, and Home Assistant integration" url: "https://git.linuxiarz.pl/gru/gree-controller-ha-addon/" diff --git a/lang/en.json b/lang/en.json index 9dbbf7e..38c154a 100644 --- a/lang/en.json +++ b/lang/en.json @@ -1226,6 +1226,7 @@ "flow.node.cronTrigger": "CRON schedule", "flow.node.stableFor": "Stable for…", "flow.node.rollingStat": "Mean / median", + "flow.node.heatingCurve": "Heating curve", "flow.node.haServiceAction": "Home Assistant service", "flow.cronHint": "5 fields: minute hour day month weekday. Supports *, */N, ranges and lists.", "flow.durationSeconds": "Duration [s]", @@ -1248,6 +1249,19 @@ "flow.timeOps": "Time operations", "flow.haActions": "Home Assistant", "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.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.", @@ -1533,6 +1547,7 @@ "flow.contextDelete": "Delete selection ({count})", "flow.contextDeleteConnection": "Delete connection", "flow.rightClickKey": "Right click", - "flow.shortcutContextMenu": "Flow context menu" + "flow.shortcutContextMenu": "Flow context menu", + "flow.heatingCurveDryRunValue": "Value passed from curve" } } diff --git a/lang/pl.json b/lang/pl.json index ee522ad..bcd868f 100644 --- a/lang/pl.json +++ b/lang/pl.json @@ -1226,6 +1226,7 @@ "flow.node.cronTrigger": "CRON / harmonogram", "flow.node.stableFor": "Utrzymuje się przez…", "flow.node.rollingStat": "Średnia / mediana", + "flow.node.heatingCurve": "Krzywa grzewcza", "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.durationSeconds": "Czas [s]", @@ -1248,6 +1249,19 @@ "flow.timeOps": "Operacje czasu", "flow.haActions": "Home Assistant", "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.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.", @@ -1533,6 +1547,7 @@ "flow.contextDelete": "Usuń zaznaczenie ({count})", "flow.contextDeleteConnection": "Usuń połączenie", "flow.rightClickKey": "Prawy przycisk", - "flow.shortcutContextMenu": "Menu kontekstowe Flow" + "flow.shortcutContextMenu": "Menu kontekstowe Flow", + "flow.heatingCurveDryRunValue": "Wartość przekazana z krzywej" } } diff --git a/scripts/api_dev_test.py b/scripts/api_dev_test.py index 4c03127..07e3eef 100644 --- a/scripts/api_dev_test.py +++ b/scripts/api_dev_test.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Developer smoke/integration tests for GREE Controller API 0.15.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. Use --settings-write to additionally round-trip all split settings resources and @@ -26,7 +26,7 @@ from typing import Any, Callable, Iterable, Optional DEFAULT_BASE_URL = os.environ.get("GREE_API_URL", "http://127.0.0.1:8787") DEFAULT_TOKEN = os.environ.get("GREE_API_TOKEN", "") -DEFAULT_EXPECTED_VERSION = "0.15.19" +DEFAULT_EXPECTED_VERSION = "0.15.20" SETTINGS_PATHS: dict[str, set[str]] = { "/api/settings/application": {"simulator_enabled"}, @@ -599,7 +599,7 @@ def run_suite(args: argparse.Namespace) -> int: print(f"Settings write tests: {'ENABLED' if args.settings_write else 'disabled'}\n") runner.run("public health", lambda: test_health(client, args.expected_version)) - runner.run("OpenAPI 0.15.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("protected API requires auth", lambda: test_protected_auth(client)) diff --git a/scripts/smoke.sh b/scripts/smoke.sh index e7af04f..4faf3a1 100755 --- a/scripts/smoke.sh +++ b/scripts/smoke.sh @@ -3,6 +3,10 @@ set -Eeuo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" 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}" [[ -x "$BINARY" ]] || cargo build >/dev/null TMP="$(mktemp -d)" @@ -128,6 +132,27 @@ json.dump({"flow":flow,"flow_id":sys.argv[3],"at":datetime.datetime.now(datetime 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" 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" <"$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" 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" diff --git a/src/api/automations.rs b/src/api/automations.rs index 3af2a6d..66cc610 100644 --- a/src/api/automations.rs +++ b/src/api/automations.rs @@ -168,6 +168,8 @@ impl AutomationInput { action_ha_service: None, action_ha_entity_id: None, action_ha_data: Value::Null, + flow_value_source_id: None, + flow_value_target: None, flow_conditions: vec![], flow_id: None, flow_node_id: None, diff --git a/src/api/flows.rs b/src/api/flows.rs index c466ecd..02c4a77 100644 --- a/src/api/flows.rs +++ b/src/api/flows.rs @@ -245,6 +245,7 @@ fn flow_condition_kind(kind: &str) -> bool { | "rate_limit" | "rolling_stat" | "oscillates" + | "heating_curve" | "outdoor_temperature" | "device_temperature" | "zone_temperature" @@ -275,6 +276,77 @@ fn flow_action_kind(kind: &str) -> bool { ) } +fn heating_curve_points(config: &Value) -> Result, 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 { matches!( kind, @@ -691,6 +763,73 @@ fn compile_flow_program( Ok(out) } +fn flow_action_value_binding( + action: &crate::models::FlowNode, + nodes: &[crate::models::FlowNode], + edges: &[crate::models::FlowEdge], +) -> Result, 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::>(); + 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> { let op = flow_string(config, "operator").unwrap_or_else(|| "eq".into()); 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" => { validate_flow_comparison(&condition.config)?; } @@ -1243,6 +1386,7 @@ fn compile_flow( .collect(); for action_node in actions { 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 .iter() .filter(|condition| flow_condition_kind(&condition.kind)) @@ -1271,6 +1415,7 @@ fn compile_flow( .map(|(start, end)| start < end) .unwrap_or(false); let schedule_only = action_node.kind == "zone_thermostat" + && value_binding.is_none() && !conditions .iter() .any(|condition| matches!(condition.kind.as_str(), "logic_or" | "logic_not")) @@ -1422,6 +1567,8 @@ fn compile_flow( action_ha_service: None, action_ha_entity_id: None, 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_id: Some(flow.id.clone()), flow_node_id: Some(action_node.id.clone()), @@ -1446,8 +1593,12 @@ fn compile_flow( )); } item.action_zone_id = Some(zone_id); - let preset = - flow_string(&action_node.config, "preset").unwrap_or_else(|| "comfort".into()); + let preset = if value_binding.is_some() { + "custom".to_string() + } else { + flow_string(&action_node.config, "preset") + .unwrap_or_else(|| "comfort".into()) + }; if !matches!( preset.as_str(), "comfort" | "sleep" | "away" | "custom" | "auto" @@ -1455,7 +1606,7 @@ fn compile_flow( return Err(AppError::BadRequest("unsupported thermostat preset".into())); } 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(|| { 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.sleep = flow_bool(&action_node.config, "sleep"); 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())); } } @@ -1521,7 +1672,16 @@ fn compile_flow( )); } 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" => { 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.power = flow_bool(&action_node.config, "power"); 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 !matches!(mode, "auto" | "house" | "cool" | "heat") { return Err(AppError::BadRequest("unsupported Flow group mode".into())); @@ -1565,7 +1729,7 @@ fn compile_flow( if !matches!(preset, "auto" | "comfort" | "sleep" | "away" | "custom") { return Err(AppError::BadRequest("unsupported Flow group preset".into())); } - if preset == "custom" { + if preset == "custom" && value_binding.is_none() { let target = flow_f64(&action_node.config, "setpoint").ok_or_else(|| { AppError::BadRequest( @@ -1935,6 +2099,7 @@ async fn import_flow( fn dry_run_block_reason( state: &AppState, action: &crate::models::FlowNode, + value_binding: Option<&(String, String)>, ) -> Result, AppError> { match action.kind.as_str() { "zone_thermostat" => { @@ -1991,7 +2156,14 @@ fn dry_run_block_reason( return Ok(Some("temporary_quick_thermostat".into())); } 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 { let mut command = DeviceCommand::default(); 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 { 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(); let resulting_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)) { 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 mut runtime = state .db @@ -2098,10 +2272,18 @@ async fn simulate_flow( Some(&mut runtime), ) .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 { Some("flow_disabled".into()) } else if matched { - dry_run_block_reason(&state, action)? + dry_run_block_reason(&state, action, value_binding.as_ref())? } else { None }; @@ -2111,6 +2293,8 @@ async fn simulate_flow( "matched": matched, "would_execute": matched && blocked_reason.is_none(), "blocked_reason": blocked_reason, + "dynamic_value": dynamic_value, + "dynamic_value_target": value_binding.as_ref().map(|(_, target)| target.clone()), "config": action.config, "trace": trace })); @@ -2163,6 +2347,16 @@ async fn flow_logs( mod flow_draft_tests { 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 { FlowInput { name: "Unfinished".into(), @@ -2224,4 +2418,67 @@ mod flow_draft_tests { let result = flow_device_feature_command(&json!({"feature": "swing_vertical", "value": 99})); 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()))); + } } diff --git a/src/engine/automations.rs b/src/engine/automations.rs index 1f539cf..96bcac0 100644 --- a/src/engine/automations.rs +++ b/src/engine/automations.rs @@ -66,6 +66,43 @@ async fn run_automations(state: &AppState) -> Result<()> { let Some(latest_item) = state.db.get_automation(&item.id)? else { continue; }; if latest_item.updated_at != item.updated_at { continue; } 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::; + 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 // before we acquired the automation lock. Reload them inside this serialized window so // 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() && 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!({ "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() && 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 // 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 = 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(); - 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), Err(err) => Err(AppError::BadRequest(format!("Home Assistant service action failed: {err}"))), } } 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() { - 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 { - power: item.action.power, + power: effective_action.power, mode: group_mode, 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)) } 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(None) => { 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. state.broadcast("automation.updated", serde_json::to_value(&item)?); 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) => { @@ -494,6 +531,49 @@ fn flow_rolling_stat_update( } } +fn flow_heating_curve_output(config: &Value, input: f64) -> Option { + let mut points = config + .get("points")? + .as_array()? + .iter() + .filter_map(|point| { + Some(( + point.get("x")?.as_f64()?, + point.get("y")?.as_f64()?, + )) + }) + .collect::>(); + 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 { + 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( state: &AppState, devices: &[Device], @@ -789,6 +869,26 @@ pub async fn evaluate_flow_conditions_trace( } else { false }; (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::().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" => { 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(""); @@ -905,7 +1005,7 @@ fn flow_condition_kind_runtime(kind: &str) -> bool { matches!(kind, "weekday" | "time_range" | "date_range" | "cron_trigger" | "outdoor_temperature" | "device_temperature" | "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" ) } diff --git a/src/engine/tests.rs b/src/engine/tests.rs index 6d59cae..f29b949 100644 --- a/src/engine/tests.rs +++ b/src/engine/tests.rs @@ -408,6 +408,22 @@ mod tests { 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] fn time_automation_fires_only_once_in_the_same_minute() { let now = Local @@ -438,6 +454,8 @@ mod tests { action_ha_service: None, action_ha_entity_id: None, action_ha_data: Value::Null, + flow_value_source_id: None, + flow_value_target: None, flow_conditions: vec![], flow_id: None, flow_node_id: None, diff --git a/src/models/automation.rs b/src/models/automation.rs index f1ae29d..0407ed9 100644 --- a/src/models/automation.rs +++ b/src/models/automation.rs @@ -91,6 +91,14 @@ pub struct Automation { pub action_ha_entity_id: Option, #[serde(default)] 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, + /// 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, /// Topologically ordered condition/logic program compiled from the visual Flow graph. #[serde(default)] pub flow_conditions: Vec, diff --git a/web/css/styles.css b/web/css/styles.css index d6e9c13..57c7fa4 100644 --- a/web/css/styles.css +++ b/web/css/styles.css @@ -6632,6 +6632,95 @@ textarea[aria-invalid="true"] { 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 { display: grid; grid-template-columns: 1fr 1fr; diff --git a/web/index.html b/web/index.html index fa831ce..bd093c0 100644 --- a/web/index.html +++ b/web/index.html @@ -959,7 +959,8 @@ data-i18n="flow.node.delay">Odczekaj…
Sensory / wartości -
`).join(''); + return `${sourceFields}${flowHeatingCurveChart(config)}
${esc(tr('flow.heatingCurvePoints'))}
${rows}

${esc(tr('flow.heatingCurveHint'))}

`; +} + +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() { const host = $('#flowInspector'), node = flowNodeById(app.flowSelectedNodeId); if (!host) return; host.classList.toggle('has-selection', Boolean(node)); @@ -709,6 +790,7 @@ function renderFlowInspector() { else if (node.kind === 'delay') fields = `

${esc(tr('flow.delayHint'))}

`; else if (node.kind === 'rolling_stat') { const source = c.source || 'outdoor_temperature'; fields = `${source === 'device_temperature' ? `` : ''}${source === 'zone_temperature' ? `` : ''}${source === 'ha_numeric' ? `` : ''}
${flowComparisonFields(c, false)}`; } else if (node.kind === 'oscillates') { const source = c.source || 'outdoor_temperature'; fields = `${source === 'device_temperature' ? `` : ''}${source === 'zone_temperature' ? `` : ''}${source === 'ha_numeric' ? `` : ''}

${esc(tr('flow.oscillatesHint'))}

`; } + else if (node.kind === 'heating_curve') fields = flowHeatingCurveEditor(c); else if (node.kind === 'outdoor_temperature') fields = flowComparisonFields(c); else if (node.kind === 'device_temperature') fields = `${flowComparisonFields(c)}`; else if (node.kind === 'zone_temperature') fields = `${flowComparisonFields(c)}`; @@ -736,6 +818,14 @@ function renderFlowInspector() { else if (node.kind === 'device_feature_action') fields = `${flowDeviceFeatureValueField(c)}

${esc(tr('flow.deviceFeatureHint'))}

`; else if (node.kind === 'group_action') fields = `${flowActionFields(c, true)}`; else if (node.kind === 'ha_service_action') fields = `

${esc(tr('flow.serviceExample'))}

`; + const incomingCurve = flowIncomingHeatingCurve(node.id); + if (incomingCurve && ['zone_thermostat', 'device_action', 'group_action'].includes(node.kind)) { + fields += `

${esc(tr('flow.heatingCurveNativeBinding', { curve: flowNodeTitle(FLOW_NODE_META.heating_curve) }))}

`; + } else if (incomingCurve && node.kind === 'device_feature_action') { + fields += `

${esc(c.feature === 'target_temperature' ? tr('flow.heatingCurveNativeBinding', { curve: flowNodeTitle(FLOW_NODE_META.heating_curve) }) : tr('flow.heatingCurveFeatureWarning'))}

`; + } else if (incomingCurve && node.kind === 'ha_service_action') { + fields += `

${esc(tr('flow.heatingCurveHaBindingHint'))}

`; + } host.innerHTML = `
${esc(tr('flow.blockSettings'))}

${esc(flowNodeTitle(meta))}

${fields}
ID${esc(node.id)}
`; } @@ -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())}`; } 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) { if (node?.kind === 'device_state') { @@ -1243,7 +1333,7 @@ function renderFlowSimulationOverrides() { 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 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 ? `` : ``; @@ -1256,7 +1346,7 @@ function collectFlowSimulationOverrides() { if (input.value.trim() === '') return; 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; - 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'; result[input.dataset.flowSimNode] = value; }); @@ -1283,7 +1373,7 @@ function flowSimpleSimulationControl(node) { const louverAxis = flowSimulationLouverAxis(node); const live = ``; if (louverAxis) return ``; - 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 ``; } if (source.kind === 'house_mode') { @@ -1309,7 +1399,7 @@ function collectFlowSimpleSimulationOverrides(flow) { const node = byId.get(input.dataset.flowSimpleSimNode); if (!node) return; const source = flowSimulationEffectiveSource(node); 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'; result[node.id] = value; }); @@ -1320,6 +1410,10 @@ function flowSimpleSimulationActualText(node, actual) { if (actual === null || actual === undefined) return tr('flow.simpleNoData'); if (typeof actual === 'object') { 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 ''; } const source = flowSimulationEffectiveSource(node); @@ -1361,7 +1455,8 @@ function renderFlowSimpleSimulationResult(result) { return `
${uiIcon(item.matched ? 'check' : 'close')}${esc(flowNodeTitle(FLOW_NODE_META[node.kind] || { title: node.kind }))}${esc(flowNodeSummary(node))}${actual ? `${esc(tr('flow.simpleCurrentValue', { value: actual }))}` : ''}
`; }).join(''); const actionType = actionNode ? flowNodeTitle(FLOW_NODE_META[actionNode.kind] || { title: actionNode.kind }) : tr('flow.simpleEffect'); - return `
${esc(`${tr('flow.simpleEffect')} · ${actionType}`)}${esc(actionNode ? flowNodeSummary(actionNode) : action.node_id)}
${esc(stateLabel)}

${esc(explanation)}

${trace ? `
${esc(tr('flow.simpleConditions'))}
${trace}
` : ''}
`; + const dynamicValue = Number.isFinite(Number(action.dynamic_value)) ? `${Number(action.dynamic_value).toFixed(1)} °C → ${action.dynamic_value_target || 'target'}` : ''; + return `
${esc(`${tr('flow.simpleEffect')} · ${actionType}`)}${esc(actionNode ? flowNodeSummary(actionNode) : action.node_id)}
${esc(stateLabel)}

${esc(explanation)}

${dynamicValue ? `

${esc(tr('flow.heatingCurveDryRunValue'))}: ${esc(dynamicValue)}

` : ''}${trace ? `
${esc(tr('flow.simpleConditions'))}
${trace}
` : ''}
`; }).join(''); } @@ -1398,7 +1493,7 @@ function openFlowDryRun() { function renderFlowDryRunResult(result) { const host = $('#flowTestResults'); const actionName = id => flowNodeById(id) ? `${flowNodeTitle(FLOW_NODE_META[flowNodeById(id).kind])}: ${flowNodeSummary(flowNodeById(id))}` : id; - host.innerHTML = `
${esc(result.summary || tr('flow.dryRun'))}${esc(tr('flow.compiledPreview', result.compiled || { schedules: 0, automations: 0 }))}
${(result.actions || []).map(action => `
${esc(actionName(action.node_id))}${esc(action.would_execute ? tr('flow.wouldExecute') : action.matched ? tr('flow.blockedByOwnership') : tr('flow.conditionFalse'))}
${action.blocked_reason ? `

${esc(tr('flow.blockReason'))}: ${esc(action.blocked_reason)}

` : ''}
${(action.trace || []).map(item => `
${uiIcon(item.matched ? 'check' : 'close')} ${esc(flowNodeTitle(FLOW_NODE_META[item.kind] || { title: item.kind }))}${esc(JSON.stringify(item.actual))}
`).join('')}
`).join('')}`; + host.innerHTML = `
${esc(result.summary || tr('flow.dryRun'))}${esc(tr('flow.compiledPreview', result.compiled || { schedules: 0, automations: 0 }))}
${(result.actions || []).map(action => `
${esc(actionName(action.node_id))}${esc(action.would_execute ? tr('flow.wouldExecute') : action.matched ? tr('flow.blockedByOwnership') : tr('flow.conditionFalse'))}
${action.blocked_reason ? `

${esc(tr('flow.blockReason'))}: ${esc(action.blocked_reason)}

` : ''}${Number.isFinite(Number(action.dynamic_value)) ? `

${esc(tr('flow.heatingCurveDryRunValue'))}: ${esc(`${Number(action.dynamic_value).toFixed(1)} °C → ${action.dynamic_value_target || 'target'}`)}

` : ''}
${(action.trace || []).map(item => `
${uiIcon(item.matched ? 'check' : 'close')} ${esc(flowNodeTitle(FLOW_NODE_META[item.kind] || { title: item.kind }))}${esc(JSON.stringify(item.actual))}
`).join('')}
`).join('')}`; } async function runFlowDryRun() { if (!app.flowDraft) return; @@ -1659,6 +1754,44 @@ function updateFlowConfig(input) { 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 flowMarquee = null; @@ -1850,6 +1983,8 @@ document.addEventListener('keydown', 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]'); if (contextAction) { const action = contextAction.dataset.flowContextAction; @@ -1926,6 +2061,7 @@ document.addEventListener('click', event => { else if (action === 'close-flow-editor') closeFlowEditor(); }); 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); }); $('#flowDescriptionForm')?.addEventListener('submit', event => { event.preventDefault(); saveFlowDescription(); });