v0.11.0
This commit is contained in:
Generated
+1
-1
@@ -633,7 +633,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "gree-controller"
|
name = "gree-controller"
|
||||||
version = "0.10.0"
|
version = "0.11.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes",
|
"aes",
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "gree-controller"
|
name = "gree-controller"
|
||||||
version = "0.10.0"
|
version = "0.11.0"
|
||||||
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"
|
||||||
|
|||||||
+19
-18
@@ -1,12 +1,12 @@
|
|||||||
d67af429e4da9ce08e9d2f2a8472849ffbd70d135b1c5da535a076026794d04c ./.env.example
|
d67af429e4da9ce08e9d2f2a8472849ffbd70d135b1c5da535a076026794d04c ./.env.example
|
||||||
a4ec3874a2e3ab1bad28fb40bb620f7b01f64d01ad9b699306bf70ada31227db ./.gitignore
|
a4ec3874a2e3ab1bad28fb40bb620f7b01f64d01ad9b699306bf70ada31227db ./.gitignore
|
||||||
bf9a8e40ddb34e4ad07c8af2fb2701d80694ae735b2cbca1a9d227a3f1450ca1 ./Cargo.lock
|
9aed69e37c083f1248c4c5ed23decc3ef796ac062f8e4fd591131887a15c4cbc ./Cargo.lock
|
||||||
7142adffb9b6a110ea8aa6337aeb7b43b52a2a35024ef3bc9e9ff3c3b0437590 ./Cargo.toml
|
e0142c9260ad30454663b1f1e9fa161bc5c9e06e5c7f491d15639d8cb23e282a ./Cargo.toml
|
||||||
19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE
|
19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE
|
||||||
77904d7a2d5bf7206bd1d350f2a15e827b0f821e31da511a4afdd0ba403542c9 ./README.md
|
c6021f6df33986f6365c34a4e1892c7c7a0e0403ec6a7ef777cf64ee834bce1f ./README.md
|
||||||
41dfdc6d099b54f87d4dd51f696122b3c88d3bf3ddf420bea8f4bc621ba920b0 ./build.rs
|
41dfdc6d099b54f87d4dd51f696122b3c88d3bf3ddf420bea8f4bc621ba920b0 ./build.rs
|
||||||
f2d6d4177a3628e0939ee691a3071c876e95c56d564fcd6e34aedbc3f25d1b0c ./docs/API.md
|
ba0c6ac59b5143daa5ac89f9006e516777f4258509bf40b03cc576beb75d80b7 ./docs/API.md
|
||||||
bdbb1bf4f46fe4e4e99ef3a484a091442c082f57304880b4a1535da167a1d830 ./docs/FLOW.md
|
b612dee4ff09398ec593fa55349be15080ba7b1947743b2a41c1d36ca5635ecd ./docs/FLOW.md
|
||||||
a0893b2a56eb1523f1a72871842e9be2139a5fafba1f51ae942fc407a6e4ca34 ./home-assistant/README.md
|
a0893b2a56eb1523f1a72871842e9be2139a5fafba1f51ae942fc407a6e4ca34 ./home-assistant/README.md
|
||||||
f8e8559fe10fe523ac5bc9aac25c6e26e862f679d502e8f3c39f38a0a8e40911 ./home-assistant/custom_components/gree_controller/__init__.py
|
f8e8559fe10fe523ac5bc9aac25c6e26e862f679d502e8f3c39f38a0a8e40911 ./home-assistant/custom_components/gree_controller/__init__.py
|
||||||
6910589f27960a28d4de9735884a7e5376e455cd885f19ce2b55947fcd135114 ./home-assistant/custom_components/gree_controller/api.py
|
6910589f27960a28d4de9735884a7e5376e455cd885f19ce2b55947fcd135114 ./home-assistant/custom_components/gree_controller/api.py
|
||||||
@@ -17,7 +17,7 @@ ab08fbe40e9bb48ebcbfff98760aaa0c9434b61b705aaaadea0c09c255d99b0e ./home-assista
|
|||||||
5a96fe8f5c035c34f1339370270cd078056202d09e236dec75735be11de92a7d ./home-assistant/custom_components/gree_controller/entity_map.py
|
5a96fe8f5c035c34f1339370270cd078056202d09e236dec75735be11de92a7d ./home-assistant/custom_components/gree_controller/entity_map.py
|
||||||
c4fb75c246db651087900ebfc2291ff41ac87652cd6194fc0b776b0005c1cbcf ./home-assistant/custom_components/gree_controller/icon.png
|
c4fb75c246db651087900ebfc2291ff41ac87652cd6194fc0b776b0005c1cbcf ./home-assistant/custom_components/gree_controller/icon.png
|
||||||
c4fb75c246db651087900ebfc2291ff41ac87652cd6194fc0b776b0005c1cbcf ./home-assistant/custom_components/gree_controller/logo.png
|
c4fb75c246db651087900ebfc2291ff41ac87652cd6194fc0b776b0005c1cbcf ./home-assistant/custom_components/gree_controller/logo.png
|
||||||
ac0edf99b99e4764a6feed506795c8fb709d3ae73cf054e2a68bf7c8685ac115 ./home-assistant/custom_components/gree_controller/manifest.json
|
779d49e14506ee2435e7e05f3c0fc5fffa01536a182b559dba2a0a73cc32e724 ./home-assistant/custom_components/gree_controller/manifest.json
|
||||||
38d659d0273c0d1428679e44574a2666c605d1b3796d323b626baa40560788cf ./home-assistant/custom_components/gree_controller/number.py
|
38d659d0273c0d1428679e44574a2666c605d1b3796d323b626baa40560788cf ./home-assistant/custom_components/gree_controller/number.py
|
||||||
39c4309001b75abb56234f05662bc06e077054986876f1927937edbce528ec95 ./home-assistant/custom_components/gree_controller/select.py
|
39c4309001b75abb56234f05662bc06e077054986876f1927937edbce528ec95 ./home-assistant/custom_components/gree_controller/select.py
|
||||||
cca65482e36d48035aca178121a378fe7d578d600acff267ae81a6399c0da653 ./home-assistant/custom_components/gree_controller/sensor.py
|
cca65482e36d48035aca178121a378fe7d578d600acff267ae81a6399c0da653 ./home-assistant/custom_components/gree_controller/sensor.py
|
||||||
@@ -26,8 +26,8 @@ cca65482e36d48035aca178121a378fe7d578d600acff267ae81a6399c0da653 ./home-assista
|
|||||||
13f30e2dcdcedbd1b6c3f99c2335e0487108fd72c8e86922368b84f2fa2038ae ./home-assistant/custom_components/gree_controller/translations/pl.json
|
13f30e2dcdcedbd1b6c3f99c2335e0487108fd72c8e86922368b84f2fa2038ae ./home-assistant/custom_components/gree_controller/translations/pl.json
|
||||||
4513070521d3dda0efb0d974a86ba674494cfb2b66fe9e5cac5b1b0430dede97 ./home-assistant/generated/gree_controller_entities.example.json
|
4513070521d3dda0efb0d974a86ba674494cfb2b66fe9e5cac5b1b0430dede97 ./home-assistant/generated/gree_controller_entities.example.json
|
||||||
253a0bc912786e67ea7fc92a64e4a510ad973bec343a88ccfb1f28fca3e8cf01 ./lang/README.md
|
253a0bc912786e67ea7fc92a64e4a510ad973bec343a88ccfb1f28fca3e8cf01 ./lang/README.md
|
||||||
981cbf3b3c24a32480a2ceb611985d62bdd9eecc6db68f105447d68a02c3492c ./lang/en.json
|
83b367cbb81b2fddc7f1c5c0712c20d38b8ba8d17f50ed26a13690624067e4bc ./lang/en.json
|
||||||
9e0088d79f5863239591d902a874ae81f1735721ef9f861d9ee26a6414beea47 ./lang/pl.json
|
853df747bcba915f8fe837584a096583725d12d43935ff3b21363abc33dc032f ./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
|
||||||
@@ -67,7 +67,7 @@ ae21459a261712bcb8d57594528b1648432e8d03a8a38502234829c0dbfef774 ./presets/week
|
|||||||
fdcd9a5055d08037278b842e7ab69265345c5811f0a06867136c511d140bb191 ./presets/window_available_guard.json
|
fdcd9a5055d08037278b842e7ab69265345c5811f0a06867136c511d140bb191 ./presets/window_available_guard.json
|
||||||
804f22123cd3e8db0fac791826c8dd9f758fb866c8e3b5655fb6d25d259dccf1 ./presets/workday_comfort.json
|
804f22123cd3e8db0fac791826c8dd9f758fb866c8e3b5655fb6d25d259dccf1 ./presets/workday_comfort.json
|
||||||
01952aa92b217f8eae2493b88870e2dec595100cd15c4d561ff11ae2b936c46f ./regenerate-sha.sh
|
01952aa92b217f8eae2493b88870e2dec595100cd15c4d561ff11ae2b936c46f ./regenerate-sha.sh
|
||||||
feb34a6d60da49a5135eae8de5028988cca1ea1dd0971c76aec848f965fdea67 ./scripts/FILE_MANIFEST.sha256
|
9aff6913a90df35c3c7cedf4c4752bfa84360602cb7711309b0dc909b7522a60 ./scripts/FILE_MANIFEST.sha256
|
||||||
bb89bac237e750e9b1bf73761d7df97a6b81853091615878c03f13d7b6399aa7 ./scripts/README.md
|
bb89bac237e750e9b1bf73761d7df97a6b81853091615878c03f13d7b6399aa7 ./scripts/README.md
|
||||||
5bc736c7bc76ca80aaa406bb171d2aa91baf4c3aa8695dce0e09b888b6ab3146 ./scripts/common.sh
|
5bc736c7bc76ca80aaa406bb171d2aa91baf4c3aa8695dce0e09b888b6ab3146 ./scripts/common.sh
|
||||||
6403786610ee6d2f628193c25aee0dd058d62e904aa1a31d5f62fdaae0e94b4f ./scripts/configure-gree-network.sh
|
6403786610ee6d2f628193c25aee0dd058d62e904aa1a31d5f62fdaae0e94b4f ./scripts/configure-gree-network.sh
|
||||||
@@ -80,14 +80,15 @@ e00d211e3885e30d7fed1e43b44e6fdad40a67019060156c0641816a93e3365f ./scripts/netw
|
|||||||
81345b6a0b51736bdbc98fd23199b62e4c721b4e7437e02dab7ea79b97dff29a ./scripts/service.sh
|
81345b6a0b51736bdbc98fd23199b62e4c721b4e7437e02dab7ea79b97dff29a ./scripts/service.sh
|
||||||
fec8b0362e763bbc115b5cc5c56f9ebe815730cee161632d81c5dc0ee1475f80 ./scripts/smoke.sh
|
fec8b0362e763bbc115b5cc5c56f9ebe815730cee161632d81c5dc0ee1475f80 ./scripts/smoke.sh
|
||||||
b50782b3742dfbf8a319c60571c968e93fdf8547db747c759edcffae68cb98bf ./scripts/update.sh
|
b50782b3742dfbf8a319c60571c968e93fdf8547db747c759edcffae68cb98bf ./scripts/update.sh
|
||||||
|
9abc30f32476ffbab8d8073c7f2167a58732a1de9aeb30ceffb6708857a7f1f2 ./scripts/verify_flow_logic.py
|
||||||
da16e16dfb330d528d54d2138b07950da701edc46af1ff174a9f6a54090122b3 ./src/api.rs
|
da16e16dfb330d528d54d2138b07950da701edc46af1ff174a9f6a54090122b3 ./src/api.rs
|
||||||
825370f409124a719043200742c01c3441bc65f483108106140d13fe7bbcba91 ./src/api/assets.rs
|
825370f409124a719043200742c01c3441bc65f483108106140d13fe7bbcba91 ./src/api/assets.rs
|
||||||
6c34b1294d76b0eba4c56bfd0bf69bdd502de0e40838104f6ddf2db4e922683d ./src/api/auth.rs
|
6c34b1294d76b0eba4c56bfd0bf69bdd502de0e40838104f6ddf2db4e922683d ./src/api/auth.rs
|
||||||
5643425214a0ad228d6229398b715d9a6823c8c5823cdb4fbf73912d9b1eb74b ./src/api/automations.rs
|
63cb241c4536abed402f2a0ad37e7f299343df264eab6a33bb5354c15421e045 ./src/api/automations.rs
|
||||||
2317b7cd0c2437c128ac4982d64412c8184ecb5e774825761d61aab6e45fae4a ./src/api/debug_tokens.rs
|
2317b7cd0c2437c128ac4982d64412c8184ecb5e774825761d61aab6e45fae4a ./src/api/debug_tokens.rs
|
||||||
4f82206fb48a291bdc643c96d4d6c917d669c7bdf2a35312c0a056dddecc7062 ./src/api/devices.rs
|
4f82206fb48a291bdc643c96d4d6c917d669c7bdf2a35312c0a056dddecc7062 ./src/api/devices.rs
|
||||||
417940b467c6f00bbf01cc47eb4479880dee9b2f284a80e3662a6d433dc11b92 ./src/api/events.rs
|
417940b467c6f00bbf01cc47eb4479880dee9b2f284a80e3662a6d433dc11b92 ./src/api/events.rs
|
||||||
10dcc9330f4d0b1b88b4fd3e80dd85b67cb45c5205c3fe03bbadecf3003e9e99 ./src/api/flows.rs
|
381b468544fa3c7e192d9ed9a99db774db6bed9b1588a415dfaa5347b39c3053 ./src/api/flows.rs
|
||||||
d096000e94997cf9aaf9d3cc567214a78b8b2da7da57a76e2b3519247af6e592 ./src/api/groups.rs
|
d096000e94997cf9aaf9d3cc567214a78b8b2da7da57a76e2b3519247af6e592 ./src/api/groups.rs
|
||||||
a13d4e217fe3ddaa73873ba6e0d1bc93750cf61764d867ff7372274ed21d9599 ./src/api/history.rs
|
a13d4e217fe3ddaa73873ba6e0d1bc93750cf61764d867ff7372274ed21d9599 ./src/api/history.rs
|
||||||
b3ed82de9d885a6325647bff3d0ead6ac46fc74d0250d5e14d75319555ad88d7 ./src/api/house.rs
|
b3ed82de9d885a6325647bff3d0ead6ac46fc74d0250d5e14d75319555ad88d7 ./src/api/house.rs
|
||||||
@@ -112,7 +113,7 @@ f3bd91d0bdb7699f319b9341d928d3cc3b25f759653fcd2095a5c5f695d08fac ./src/db/event
|
|||||||
35cbb521a8ec456756496b8aee71f9dfebad528a2cf2653ba8b48f6035be0c01 ./src/db/tests.rs
|
35cbb521a8ec456756496b8aee71f9dfebad528a2cf2653ba8b48f6035be0c01 ./src/db/tests.rs
|
||||||
a384ea4042d1d11692b5f8693df1f54850dcc4e80726780b44b2b096bf667b78 ./src/db/zone_history.rs
|
a384ea4042d1d11692b5f8693df1f54850dcc4e80726780b44b2b096bf667b78 ./src/db/zone_history.rs
|
||||||
952ea5906d39ef917ad71edc345f76b29c07c63079644fd5699b12aa254cb35f ./src/engine.rs
|
952ea5906d39ef917ad71edc345f76b29c07c63079644fd5699b12aa254cb35f ./src/engine.rs
|
||||||
db8a94f464e0e92f9b80ec4e9a1f2c46cabbf69e6540c3d3b2155f96ba6b55cb ./src/engine/automations.rs
|
657c9ac9c11ac8b81246366d7e7616f7d0f896c0aad99bf51fabb351eb15a228 ./src/engine/automations.rs
|
||||||
77931af524e9402fc826050b5fcec422d01b02b625c8ce7f2004f1f6520c9b4d ./src/engine/commands.rs
|
77931af524e9402fc826050b5fcec422d01b02b625c8ce7f2004f1f6520c9b4d ./src/engine/commands.rs
|
||||||
ce7e809765c8ad7dc4819b7f775ba7a632031d4d0e68780cc6ea953f2fd890e7 ./src/engine/control_plan.rs
|
ce7e809765c8ad7dc4819b7f775ba7a632031d4d0e68780cc6ea953f2fd890e7 ./src/engine/control_plan.rs
|
||||||
38f42c86c31048780ca414f2c328f363ef803a0edbc1e1ed85503909a10b69ae ./src/engine/deadlines.rs
|
38f42c86c31048780ca414f2c328f363ef803a0edbc1e1ed85503909a10b69ae ./src/engine/deadlines.rs
|
||||||
@@ -126,18 +127,18 @@ ffa72b93502eece187a96adb4ec94aec6048d4a6dc7af66e7c7af0931318787f ./src/engine/s
|
|||||||
7742046c0067c7cd5a74e2b81f8a8bdef9b76fcacf9b4af0b7eede1b050b9036 ./src/engine/targets.rs
|
7742046c0067c7cd5a74e2b81f8a8bdef9b76fcacf9b4af0b7eede1b050b9036 ./src/engine/targets.rs
|
||||||
a9188b588b2617ce1547a45d46141f7e5f1bb0027a48675dd86b274f807d0875 ./src/engine/temperature.rs
|
a9188b588b2617ce1547a45d46141f7e5f1bb0027a48675dd86b274f807d0875 ./src/engine/temperature.rs
|
||||||
c2fe371f9245ce299015f0b4e43f6625a207016abf73d2419acb6bd7437f045a ./src/engine/temporary_thermostat.rs
|
c2fe371f9245ce299015f0b4e43f6625a207016abf73d2419acb6bd7437f045a ./src/engine/temporary_thermostat.rs
|
||||||
ea3b533752b7abab113541f47ab7f49da27cc5696142796b78cd16889471371b ./src/engine/tests.rs
|
d2f54341f835155f0941db287abb3b382026261b4ef5058924fcedafddae4547 ./src/engine/tests.rs
|
||||||
986c5ce1d7d5e79975b0089807408109afdef5c5a173b427b58e34f534faf411 ./src/engine/zone_actions.rs
|
986c5ce1d7d5e79975b0089807408109afdef5c5a173b427b58e34f534faf411 ./src/engine/zone_actions.rs
|
||||||
900947200973b8e4490600f33738424a89aadf0b59c0316f4cd46deb997730b6 ./src/engine/zone_control.rs
|
900947200973b8e4490600f33738424a89aadf0b59c0316f4cd46deb997730b6 ./src/engine/zone_control.rs
|
||||||
4b271b6fc365b1078c01d6178eb563841b2ecaed5d8639196f58e1312d2236fe ./src/error.rs
|
4b271b6fc365b1078c01d6178eb563841b2ecaed5d8639196f58e1312d2236fe ./src/error.rs
|
||||||
3be3961c2adefb599db54382f204233fd4e5584b6c9ad26935c4e029a63d746d ./src/home_assistant.rs
|
9eef7820fcf0c98b08fb1bf5e0a4a244b1dbdfa700d8b930d3ff40379babdada ./src/home_assistant.rs
|
||||||
6f9ef85cd53ba030b477bc3c186f0f7e762674124210eabcb0686f2806eaada1 ./src/influxdb.rs
|
6f9ef85cd53ba030b477bc3c186f0f7e762674124210eabcb0686f2806eaada1 ./src/influxdb.rs
|
||||||
feb50ecd60b9a9fe9de1f7d8e1e26caab5435d59347468f6266a318f431bc3d3 ./src/influxdb/codec.rs
|
feb50ecd60b9a9fe9de1f7d8e1e26caab5435d59347468f6266a318f431bc3d3 ./src/influxdb/codec.rs
|
||||||
553b44ec0321e62480059bc2451fd34ec7aabe9fc87fc717daba0c4b55679d53 ./src/influxdb/query.rs
|
553b44ec0321e62480059bc2451fd34ec7aabe9fc87fc717daba0c4b55679d53 ./src/influxdb/query.rs
|
||||||
5e4d94f8a010df05bae9acab72c518080d2dba94d054df95b87e492305357222 ./src/influxdb/write.rs
|
5e4d94f8a010df05bae9acab72c518080d2dba94d054df95b87e492305357222 ./src/influxdb/write.rs
|
||||||
ef8ad176eb2ee6ceb02bbf3d63b65460013475908fced05388b29727af67ef7e ./src/main.rs
|
ef8ad176eb2ee6ceb02bbf3d63b65460013475908fced05388b29727af67ef7e ./src/main.rs
|
||||||
c63bd6257386969a763f44d0e4fd7b6b19078ec7a7f46e7ec2d73580d2274cab ./src/models.rs
|
c63bd6257386969a763f44d0e4fd7b6b19078ec7a7f46e7ec2d73580d2274cab ./src/models.rs
|
||||||
a4f2d6d5a8d1ef58e45721e4640a63d7dd2e80b24accd22f32c74ce4a541ae21 ./src/models/automation.rs
|
1806b82a89121548ed0385cd22143cea13f52f0e06df1e75a8518dc946f58cba ./src/models/automation.rs
|
||||||
377fc9697a70a03ad35fc3701adf4dbe8687760dc90de9f5c532a4f3b612c762 ./src/models/control_plan.rs
|
377fc9697a70a03ad35fc3701adf4dbe8687760dc90de9f5c532a4f3b612c762 ./src/models/control_plan.rs
|
||||||
8228b9b6d9e8b1d13de29ce88500beacd88c8877893b7a3e41a4bd1e237ca36c ./src/models/defaults.rs
|
8228b9b6d9e8b1d13de29ce88500beacd88c8877893b7a3e41a4bd1e237ca36c ./src/models/defaults.rs
|
||||||
7cdc99a6f0920baf4ac88fbe7e6f1859e8598a00f00b8c3d84f20bc3a4de35f9 ./src/models/device.rs
|
7cdc99a6f0920baf4ac88fbe7e6f1859e8598a00f00b8c3d84f20bc3a4de35f9 ./src/models/device.rs
|
||||||
@@ -171,7 +172,7 @@ f13f1be3d5789539eba3fc1b59c14835f8c681eca7634e118fa3763a53aeee2b ./src/queries/
|
|||||||
b92a6cb158b494fe145b43c7641e65f6fafff47201d7d76edbec2cfd8b94835c ./systemd/gree-controller.service
|
b92a6cb158b494fe145b43c7641e65f6fafff47201d7d76edbec2cfd8b94835c ./systemd/gree-controller.service
|
||||||
544a31cb2e5374227026afd6abd0d910381f3fca1638154ebd74e3c3d96a35d1 ./web/404.html
|
544a31cb2e5374227026afd6abd0d910381f3fca1638154ebd74e3c3d96a35d1 ./web/404.html
|
||||||
e98bdd7204349cce1ec6f57283509697af0bbc72280622a6c3efa6fed242db4f ./web/favicon.svg
|
e98bdd7204349cce1ec6f57283509697af0bbc72280622a6c3efa6fed242db4f ./web/favicon.svg
|
||||||
d6df434f3fbd33ba38dd244830c980e54433c6dff94dd39c716b377255bd2ebf ./web/index.html
|
041f9c80d11e1b9ba6924fa0fbd7b86b05a3bf53b2501e0431afed43ffc9d7f7 ./web/index.html
|
||||||
9ba3f6fa05b72aa989139b2a909982571b2a02055052e4c40104f1e81a9aa7eb ./web/js/README.md
|
9ba3f6fa05b72aa989139b2a909982571b2a02055052e4c40104f1e81a9aa7eb ./web/js/README.md
|
||||||
21b662914590e720dafefe4dc3dccc91dbeb963cce9ae91f007c835d2d0c1fe5 ./web/js/bootstrap.js
|
21b662914590e720dafefe4dc3dccc91dbeb963cce9ae91f007c835d2d0c1fe5 ./web/js/bootstrap.js
|
||||||
72fa280996ba331a05bdb29a1f49b4e61bdd8964953c8ce1db952156620f08fe ./web/js/charts.js
|
72fa280996ba331a05bdb29a1f49b4e61bdd8964953c8ce1db952156620f08fe ./web/js/charts.js
|
||||||
@@ -179,7 +180,7 @@ f07600277c60c771c04b1d43c5ed08de6bf518a72660011767b71cde8ec5bde8 ./web/js/core.
|
|||||||
dfc123885a9b6b5c1d07c12d2d2625ff15aa0844e5cd3736092ccd0a360da39c ./web/js/dashboard.js
|
dfc123885a9b6b5c1d07c12d2d2625ff15aa0844e5cd3736092ccd0a360da39c ./web/js/dashboard.js
|
||||||
f53e2da5c48b55cf0191ffeb970a2c053c66e62d29099e3c5b5fccc8d89440cc ./web/js/entities.js
|
f53e2da5c48b55cf0191ffeb970a2c053c66e62d29099e3c5b5fccc8d89440cc ./web/js/entities.js
|
||||||
9a0e87911f78cd8a969f4c57ce74774f5fa829fdecd79494d9a7f9d5804f9792 ./web/js/events.js
|
9a0e87911f78cd8a969f4c57ce74774f5fa829fdecd79494d9a7f9d5804f9792 ./web/js/events.js
|
||||||
133165112ee1a37264be87d251a6eae07db6af4e14d7ac6115f4e3589940f0e3 ./web/js/flows.js
|
00af9e3302a25f6d0d21b877c92227a795d59ec11b57494544c0521984ea2134 ./web/js/flows.js
|
||||||
f06557c6d338259059b990f8f79c91d68ac8ec1f245c302468f08929b5506901 ./web/js/forms.js
|
f06557c6d338259059b990f8f79c91d68ac8ec1f245c302468f08929b5506901 ./web/js/forms.js
|
||||||
dbf36223863ba882c03eccea4516ba8db7acf0c2e72c6c64490cb994f1e20f96 ./web/js/history.js
|
dbf36223863ba882c03eccea4516ba8db7acf0c2e72c6c64490cb994f1e20f96 ./web/js/history.js
|
||||||
7eaa04992ea828dc89f5eaebf674cbbfd160f53eb8d0d6ef0d4e47bd8c28c435 ./web/js/main.js
|
7eaa04992ea828dc89f5eaebf674cbbfd160f53eb8d0d6ef0d4e47bd8c28c435 ./web/js/main.js
|
||||||
@@ -189,6 +190,6 @@ c8c82e88c3715b1bfabf155e36266a4c94f5e2fc03eb39dcdd9d08a1985a997c ./web/js/route
|
|||||||
34513d9ec2e45c60443dffe028eea74f415b8a29016df6af3377a80a4ac0df5b ./web/js/settings-ui.js
|
34513d9ec2e45c60443dffe028eea74f415b8a29016df6af3377a80a4ac0df5b ./web/js/settings-ui.js
|
||||||
aa6a2ba22648de44547efd09f41c4335ebe38ed564f4684e942d7827df40f1a2 ./web/js/settings.js
|
aa6a2ba22648de44547efd09f41c4335ebe38ed564f4684e942d7827df40f1a2 ./web/js/settings.js
|
||||||
6b653e4fe2d4db4ffae6ff8f39b8ce57a10a990dc0000ee4aae51d949afadd52 ./web/manifest.webmanifest
|
6b653e4fe2d4db4ffae6ff8f39b8ce57a10a990dc0000ee4aae51d949afadd52 ./web/manifest.webmanifest
|
||||||
a0f7cfe940ba27e549a9aafd42e9fd726aa0767312c4914f389291ba39ecdf47 ./web/styles.css
|
4845c01e7f6e1158f7b1a93853b267c6d98349d3843de32a031438d6ebfa1939 ./web/styles.css
|
||||||
ae1b03f30b494f474a781a5d32072f1e73eba2b7c768109fc1cc8028cb6662a4 ./web/sw.js
|
ae1b03f30b494f474a781a5d32072f1e73eba2b7c768109fc1cc8028cb6662a4 ./web/sw.js
|
||||||
d505d793ce7cc9485b45b78bba1c0d51887adc7451ab59a42702946e5b991382 ./web/theme-init.js
|
d505d793ce7cc9485b45b78bba1c0d51887adc7451ab59a42702946e5b991382 ./web/theme-init.js
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
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.
|
||||||
|
|
||||||
**Current release: 0.10.0**
|
**Current release: 0.11.0**
|
||||||
|
|
||||||
> [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.
|
||||||
|
|
||||||
@@ -205,7 +205,7 @@ Schedules select profiles or a custom target for selected ISO weekdays (`1=Monda
|
|||||||
|
|
||||||
### Visual Flow
|
### Visual Flow
|
||||||
|
|
||||||
**Flow** is the authoring layer for combined schedules and automations. The full-screen editor stores a block graph as the source of truth and compiles it automatically: a pure weekday + time-range + thermostat path becomes a native schedule, while richer logic becomes a generated automation. Generated schedule/automation rows carry their Flow provenance and are read-only in the legacy editors.
|
**Flow** is the authoring layer for combined schedules and automations. The full-screen editor stores a block graph as the source of truth and compiles it automatically: a pure weekday + time-range + thermostat path becomes a native schedule, while richer logic becomes a generated automation. Generated schedule/automation rows carry their Flow provenance and are read-only in the legacy editors. Stateful blocks include optional 5-field CRON triggers, continuous `stable_for`, delay-before-action, rolling mean/median windows and oscillation detection; their runtime state survives restarts. Generic Home Assistant service actions can call any configured `domain.service` with an optional entity and JSON service data.
|
||||||
|
|
||||||
The reusable Flow preset library is stored as standalone JSON files in `presets/`. Each file contains its name, description and complete Flow graph, while the UI loads the embedded preset catalog dynamically instead of hard-coding graphs in JavaScript. The library has category tabs, search, local favorites/recent presets, a graph preview and requirement checks before applying a preset.
|
The reusable Flow preset library is stored as standalone JSON files in `presets/`. Each file contains its name, description and complete Flow graph, while the UI loads the embedded preset catalog dynamically instead of hard-coding graphs in JavaScript. The library has category tabs, search, local favorites/recent presets, a graph preview and requirement checks before applying a preset.
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -1,6 +1,6 @@
|
|||||||
# GREE Controller API reference
|
# GREE Controller API reference
|
||||||
|
|
||||||
HTTP and WebSocket API for GREE Controller **0.10.0**.
|
HTTP and WebSocket API for GREE Controller **0.11.0**.
|
||||||
|
|
||||||
[← Main documentation](../README.md)
|
[← Main documentation](../README.md)
|
||||||
|
|
||||||
@@ -213,7 +213,7 @@ Response:
|
|||||||
{
|
{
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"name": "gree-controller",
|
"name": "gree-controller",
|
||||||
"version": "0.10.0",
|
"version": "0.11.0",
|
||||||
"uptime_seconds": 1234,
|
"uptime_seconds": 1234,
|
||||||
"control_ready": true,
|
"control_ready": true,
|
||||||
"time": "2026-08-30T06:54:00Z"
|
"time": "2026-08-30T06:54:00Z"
|
||||||
@@ -237,7 +237,7 @@ Returns the initial Web UI snapshot:
|
|||||||
"settings": {},
|
"settings": {},
|
||||||
"outdoor_temperature": null,
|
"outdoor_temperature": null,
|
||||||
"system": {
|
"system": {
|
||||||
"version": "0.10.0",
|
"version": "0.11.0",
|
||||||
"uptime_seconds": 1234,
|
"uptime_seconds": 1234,
|
||||||
"auth_required": false,
|
"auth_required": false,
|
||||||
"control_ready": true,
|
"control_ready": true,
|
||||||
|
|||||||
+5
-7
@@ -32,7 +32,7 @@ Group actions use the existing group order:
|
|||||||
|
|
||||||
`automation -> house -> thermostat-cycle -> group -> zone(s) -> device`
|
`automation -> house -> thermostat-cycle -> group -> zone(s) -> device`
|
||||||
|
|
||||||
The control loop runs thermostat arbitration and automation arbitration sequentially. Automation execution reloads the row after acquiring the automation lock and skips it when `updated_at` changed. Same-cycle due automations deterministically claim target devices so two rules cannot issue conflicting commands to the same unit in one pass. Polling and physical commands share per-zone/per-device locks.
|
The control loop runs thermostat arbitration and automation arbitration sequentially. Automation execution reloads the row after acquiring the automation lock and skips it when `updated_at` changed. Same-cycle due automations deterministically claim target devices and Home Assistant entity IDs so two rules cannot issue conflicting commands to the same target in one pass. Polling and physical commands share per-zone/per-device locks.
|
||||||
|
|
||||||
Holding the automation lock across the physical action is deliberate: Flow cannot be deleted/recompiled while an already-selected action is in flight. Do not shorten this ownership window without adding an explicit execution-generation/token mechanism.
|
Holding the automation lock across the physical action is deliberate: Flow cannot be deleted/recompiled while an already-selected action is in flight. Do not shorten this ownership window without adding an explicit execution-generation/token mechanism.
|
||||||
|
|
||||||
@@ -53,13 +53,13 @@ The Flow editor exposes:
|
|||||||
- Flow-scoped execution/dry-run logs,
|
- Flow-scoped execution/dry-run logs,
|
||||||
- import/export of versioned `.flow.json` source graphs.
|
- import/export of versioned `.flow.json` source graphs.
|
||||||
|
|
||||||
Dry-run never mutates thermostats, devices, groups, schedules or automations.
|
Dry-run never mutates thermostats, devices, groups, schedules or automations. For an existing Flow it clones persisted stateful runtime data, so timers and rolling/oscillation windows are evaluated against the same saved state without writing it back.
|
||||||
|
|
||||||
## Blocks
|
## Blocks
|
||||||
|
|
||||||
Condition/source blocks currently include weekday, time range, date range, application Night mode, outdoor/device/zone temperature, house mode, device state, thermostat/zone state, group state, Home Assistant state/numeric/attribute/availability, a diagnostic constant, and `shared_input`. Shared inputs are configured centrally in Home Assistant / Sensors and referenced by ID. Shared inputs store reusable value sources only; operators and comparison values are configured exclusively in each Flow reference. Logic blocks are AND, OR and NOT.
|
Condition/source blocks include weekday, time range, date range, optional 5-field CRON triggers, application Night mode, outdoor/device/zone temperature, house mode, device state, thermostat/zone state, group state, Home Assistant state/numeric/attribute/availability, rolling mean/median windows, numeric oscillation detection, a diagnostic constant, and `shared_input`. Stateful `stable_for`, `delay`, rolling statistics and oscillation blocks persist runtime state in the generated automation payload so restarts do not silently reset timing or history. Editing a stateful block resets only that block's incompatible runtime state. Shared inputs are configured centrally in Home Assistant / Sensors and referenced by ID. Shared inputs store reusable value sources only; operators and comparison values are configured exclusively in each Flow reference. Logic blocks are AND, OR and NOT.
|
||||||
|
|
||||||
Actions include thermostat zone, GREE device and climate group. Direct GREE actions expose power, HVAC mode, target temperature, fan speed, vertical/horizontal swing, quiet, turbo, light, air, xfan, health and sleep.
|
Actions include thermostat zone, GREE device, 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 swing, quiet, turbo, light, air, xfan, health and sleep.
|
||||||
|
|
||||||
## Built-in presets
|
## Built-in presets
|
||||||
|
|
||||||
@@ -73,14 +73,12 @@ The canvas supports multi-selection (Shift/Ctrl/Cmd), `Ctrl/Cmd+A`, Select all /
|
|||||||
|
|
||||||
## Good next stateful blocks
|
## Good next stateful blocks
|
||||||
|
|
||||||
The next useful additions should be implemented with persisted runtime state rather than pretending they are stateless predicates:
|
Stateful timing now has persisted runtime support for `stable_for`, delay-before-action, rolling mean/median samples and oscillation detection. Useful next additions on the same runtime-state foundation are:
|
||||||
|
|
||||||
- `for X minutes` / stable-for,
|
|
||||||
- debounce,
|
- debounce,
|
||||||
- hysteresis block,
|
- hysteresis block,
|
||||||
- rising/falling edge,
|
- rising/falling edge,
|
||||||
- cooldown/rate limit independent of the action,
|
- cooldown/rate limit independent of the action,
|
||||||
- delay/timer,
|
|
||||||
- retry/backoff,
|
- retry/backoff,
|
||||||
- variables/counters,
|
- variables/counters,
|
||||||
- reusable subflows/macros,
|
- reusable subflows/macros,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"domain": "gree_controller",
|
"domain": "gree_controller",
|
||||||
"name": "GREE Controller",
|
"name": "GREE Controller",
|
||||||
"version": "0.10.0",
|
"version": "0.11.0",
|
||||||
"config_flow": true,
|
"config_flow": true,
|
||||||
"integration_type": "hub",
|
"integration_type": "hub",
|
||||||
"iot_class": "local_polling",
|
"iot_class": "local_polling",
|
||||||
|
|||||||
+24
-1
@@ -1045,6 +1045,29 @@
|
|||||||
"flow.logMessageBlockedOwnership": "The Flow action was suppressed because control ownership changed during execution.",
|
"flow.logMessageBlockedOwnership": "The Flow action was suppressed because control ownership changed during execution.",
|
||||||
"flow.logMessageConflict": "The Flow action was skipped because another automation already claimed the same target.",
|
"flow.logMessageConflict": "The Flow action was skipped because another automation already claimed the same target.",
|
||||||
"flow.nameAliasHint": "This is only the user-facing display name. The technical Flow ID and generated task names remain unchanged.",
|
"flow.nameAliasHint": "This is only the user-facing display name. The technical Flow ID and generated task names remain unchanged.",
|
||||||
"flow.sharedInputSourceOnlyHint": "This shared input stores only the value source. Configure the comparison in the Flow block."
|
"flow.sharedInputSourceOnlyHint": "This shared input stores only the value source. Configure the comparison in the Flow block.",
|
||||||
|
"flow.triggers": "Triggers",
|
||||||
|
"flow.node.cronTrigger": "CRON schedule",
|
||||||
|
"flow.node.stableFor": "Stable for…",
|
||||||
|
"flow.node.rollingStat": "Mean / median",
|
||||||
|
"flow.node.haServiceAction": "Home Assistant service",
|
||||||
|
"flow.cronHint": "5 fields: minute hour day month weekday. Supports *, */N, ranges and lists.",
|
||||||
|
"flow.durationSeconds": "Duration [s]",
|
||||||
|
"flow.stableForHint": "The input condition must remain true continuously for the configured duration. State survives restarts.",
|
||||||
|
"flow.serviceData": "Service data (JSON)",
|
||||||
|
"flow.invalidJson": "Invalid JSON",
|
||||||
|
"flow.mean": "Mean",
|
||||||
|
"flow.median": "Median",
|
||||||
|
"flow.source": "Source",
|
||||||
|
"flow.statistic": "Statistic",
|
||||||
|
"flow.windowSeconds": "Window [s]",
|
||||||
|
"flow.node.delay": "Wait…",
|
||||||
|
"flow.delayHint": "Passes the signal onward only after the configured time. Place it before an action to delay execution.",
|
||||||
|
"flow.timeOps": "Time operations",
|
||||||
|
"flow.haActions": "Home Assistant",
|
||||||
|
"flow.node.oscillates": "Value oscillates",
|
||||||
|
"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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+24
-1
@@ -1045,6 +1045,29 @@
|
|||||||
"flow.logMessageBlockedOwnership": "Akcja Flow została pominięta, ponieważ w trakcie wykonania zmienił się właściciel sterowania.",
|
"flow.logMessageBlockedOwnership": "Akcja Flow została pominięta, ponieważ w trakcie wykonania zmienił się właściciel sterowania.",
|
||||||
"flow.logMessageConflict": "Akcja Flow została pominięta z powodu konfliktu z inną automatyzacją dla tego samego celu.",
|
"flow.logMessageConflict": "Akcja Flow została pominięta z powodu konfliktu z inną automatyzacją dla tego samego celu.",
|
||||||
"flow.nameAliasHint": "To tylko nazwa wyświetlana użytkownikowi. Techniczne ID Flow i nazwy wygenerowanych zadań pozostają bez zmian.",
|
"flow.nameAliasHint": "To tylko nazwa wyświetlana użytkownikowi. Techniczne ID Flow i nazwy wygenerowanych zadań pozostają bez zmian.",
|
||||||
"flow.sharedInputSourceOnlyHint": "Wspólne wejście zapisuje tylko źródło wartości. Porównanie ustawiasz w bloku Flow."
|
"flow.sharedInputSourceOnlyHint": "Wspólne wejście zapisuje tylko źródło wartości. Porównanie ustawiasz w bloku Flow.",
|
||||||
|
"flow.triggers": "Wyzwalacze",
|
||||||
|
"flow.node.cronTrigger": "CRON / harmonogram",
|
||||||
|
"flow.node.stableFor": "Utrzymuje się przez…",
|
||||||
|
"flow.node.rollingStat": "Średnia / mediana",
|
||||||
|
"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]",
|
||||||
|
"flow.stableForHint": "Warunek na wejściu musi pozostać prawdziwy bez przerwy przez podany czas. Stan jest zachowywany po restarcie.",
|
||||||
|
"flow.serviceData": "Dane usługi (JSON)",
|
||||||
|
"flow.invalidJson": "Nieprawidłowy JSON",
|
||||||
|
"flow.mean": "Średnia",
|
||||||
|
"flow.median": "Mediana",
|
||||||
|
"flow.source": "Źródło",
|
||||||
|
"flow.statistic": "Funkcja",
|
||||||
|
"flow.windowSeconds": "Okno [s]",
|
||||||
|
"flow.node.delay": "Odczekaj…",
|
||||||
|
"flow.delayHint": "Przepuszcza sygnał dalej dopiero po upływie podanego czasu. Użyj przed akcją jako opóźnienie wykonania.",
|
||||||
|
"flow.timeOps": "Operacje czasu",
|
||||||
|
"flow.haActions": "Home Assistant",
|
||||||
|
"flow.node.oscillates": "Wartość oscyluje",
|
||||||
|
"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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,3 +10,4 @@ e00d211e3885e30d7fed1e43b44e6fdad40a67019060156c0641816a93e3365f ./network-debu
|
|||||||
81345b6a0b51736bdbc98fd23199b62e4c721b4e7437e02dab7ea79b97dff29a ./service.sh
|
81345b6a0b51736bdbc98fd23199b62e4c721b4e7437e02dab7ea79b97dff29a ./service.sh
|
||||||
fec8b0362e763bbc115b5cc5c56f9ebe815730cee161632d81c5dc0ee1475f80 ./smoke.sh
|
fec8b0362e763bbc115b5cc5c56f9ebe815730cee161632d81c5dc0ee1475f80 ./smoke.sh
|
||||||
b50782b3742dfbf8a319c60571c968e93fdf8547db747c759edcffae68cb98bf ./update.sh
|
b50782b3742dfbf8a319c60571c968e93fdf8547db747c759edcffae68cb98bf ./update.sh
|
||||||
|
9abc30f32476ffbab8d8073c7f2167a58732a1de9aeb30ceffb6708857a7f1f2 ./verify_flow_logic.py
|
||||||
|
|||||||
Executable
+394
@@ -0,0 +1,394 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
from __future__ import annotations
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
CONDITIONS = {
|
||||||
|
'weekday','time_range','date_range','cron_trigger','stable_for','delay','rolling_stat','oscillates',
|
||||||
|
'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'
|
||||||
|
}
|
||||||
|
LOGIC = {'logic_and','logic_or','logic_not'}
|
||||||
|
ACTIONS = {'zone_thermostat','device_action','group_action','ha_service_action'}
|
||||||
|
ALL = CONDITIONS | LOGIC | ACTIONS
|
||||||
|
|
||||||
|
passed = 0
|
||||||
|
|
||||||
|
def check(value, message):
|
||||||
|
global passed
|
||||||
|
if not value:
|
||||||
|
raise AssertionError(message)
|
||||||
|
passed += 1
|
||||||
|
|
||||||
|
|
||||||
|
def cron_field_valid(field: str, lo: int, hi: int, allow_seven=False):
|
||||||
|
upper = 7 if allow_seven else hi
|
||||||
|
if not field.strip(): return False
|
||||||
|
for part in field.split(','):
|
||||||
|
part = part.strip()
|
||||||
|
if part == '*': continue
|
||||||
|
if part.startswith('*/'):
|
||||||
|
try: step = int(part[2:])
|
||||||
|
except ValueError: return False
|
||||||
|
if not (0 < step <= hi - lo + 1): return False
|
||||||
|
continue
|
||||||
|
if '-' in part:
|
||||||
|
try: a,b = map(int, part.split('-',1))
|
||||||
|
except ValueError: return False
|
||||||
|
if not (lo <= a <= b <= upper): return False
|
||||||
|
continue
|
||||||
|
try: v = int(part)
|
||||||
|
except ValueError: return False
|
||||||
|
if not (lo <= v <= upper): return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def cron_valid(expr: str):
|
||||||
|
f=expr.split()
|
||||||
|
return len(f)==5 and cron_field_valid(f[0],0,59) and cron_field_valid(f[1],0,23) and cron_field_valid(f[2],1,31) and cron_field_valid(f[3],1,12) and cron_field_valid(f[4],0,6,True)
|
||||||
|
|
||||||
|
|
||||||
|
def cron_part_match(part, value, lo, hi):
|
||||||
|
if part == '*': return True
|
||||||
|
if part.startswith('*/'):
|
||||||
|
step=int(part[2:]); return lo <= value <= hi and (value-lo)%step==0
|
||||||
|
if '-' in part:
|
||||||
|
a,b=map(int,part.split('-',1)); return a <= value <= b and a >= lo and b <= hi
|
||||||
|
v=int(part); return v==value and lo <= v <= hi
|
||||||
|
|
||||||
|
|
||||||
|
def cron_field_match(field,value,lo,hi):
|
||||||
|
return any(cron_part_match(p.strip(),value,lo,hi) for p in field.split(','))
|
||||||
|
|
||||||
|
|
||||||
|
def cron_matches(expr: str, dt: datetime):
|
||||||
|
if not cron_valid(expr): return False
|
||||||
|
f=expr.split(); # Python Monday=0; backend Sunday=0
|
||||||
|
weekday=(dt.weekday()+1)%7
|
||||||
|
return (cron_field_match(f[0],dt.minute,0,59) and cron_field_match(f[1],dt.hour,0,23)
|
||||||
|
and cron_field_match(f[2],dt.day,1,31) and cron_field_match(f[3],dt.month,1,12)
|
||||||
|
and (cron_field_match(f[4],weekday,0,7) or (weekday==0 and cron_field_match(f[4],7,0,7))))
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Runtime:
|
||||||
|
since: datetime|None=None
|
||||||
|
samples: list[tuple[datetime,float]]=field(default_factory=list)
|
||||||
|
|
||||||
|
def timed_gate(rt: Runtime, input_value: bool, seconds: int, now: datetime):
|
||||||
|
if not input_value:
|
||||||
|
rt.since=None; return False
|
||||||
|
if rt.since is None: rt.since=now
|
||||||
|
return (now-rt.since).total_seconds() >= seconds
|
||||||
|
|
||||||
|
def rolling(rt: Runtime, sample: float, window: int, statistic: str, now: datetime):
|
||||||
|
cutoff=now-timedelta(seconds=window)
|
||||||
|
rt.samples=[x for x in rt.samples if x[0] >= cutoff]
|
||||||
|
rt.samples.append((now,sample))
|
||||||
|
vals=[v for _,v in rt.samples]
|
||||||
|
if statistic == 'median':
|
||||||
|
vals=sorted(vals); mid=len(vals)//2
|
||||||
|
return (vals[mid-1]+vals[mid])/2 if len(vals)%2==0 else vals[mid]
|
||||||
|
return sum(vals)/len(vals)
|
||||||
|
|
||||||
|
def oscillation_metrics(values):
|
||||||
|
if len(values)<3: return None
|
||||||
|
span=max(values)-min(values)
|
||||||
|
prev=0; changes=0
|
||||||
|
for a,b in zip(values,values[1:]):
|
||||||
|
d=b-a; sign=1 if d>1e-6 else -1 if d<-1e-6 else 0
|
||||||
|
if not sign: continue
|
||||||
|
if prev and sign != prev: changes += 1
|
||||||
|
prev=sign
|
||||||
|
return span,changes
|
||||||
|
|
||||||
|
def oscillates(rt: Runtime, sample: float, window: int, min_span: float, min_changes: int, now: datetime):
|
||||||
|
cutoff=now-timedelta(seconds=window)
|
||||||
|
rt.samples=[x for x in rt.samples if x[0] >= cutoff]
|
||||||
|
rt.samples.append((now,sample))
|
||||||
|
metrics=oscillation_metrics([v for _,v in rt.samples])
|
||||||
|
return bool(metrics and metrics[0] >= min_span and metrics[1] >= min_changes)
|
||||||
|
|
||||||
|
|
||||||
|
NUMERIC_OPS = {'lt','lte','gt','gte','eq','neq'}
|
||||||
|
TEXT_OPS = {'eq','neq'}
|
||||||
|
DEVICE_STATE_FIELDS = {'enabled','online','power','mode','fan_speed','swing_vertical','swing_horizontal','quiet','turbo','light','air','xfan','health','sleep'}
|
||||||
|
ZONE_STATE_FIELDS = {'enabled','mode','active_preset','demand','control_owner','device_manual_override','local_thermostat_power'}
|
||||||
|
THERMOSTAT_PRESETS = {'auto','comfort','sleep','away','custom'}
|
||||||
|
THERMOSTAT_MODES = {'auto','heat','cool'}
|
||||||
|
GROUP_PRESETS = {'auto','comfort','sleep','away','custom'}
|
||||||
|
GROUP_MODES = {'auto','house','cool','heat'}
|
||||||
|
ROLLING_SOURCES = {'outdoor_temperature','device_temperature','zone_temperature','ha_numeric'}
|
||||||
|
|
||||||
|
def nonempty(value):
|
||||||
|
return isinstance(value,str) and bool(value.strip())
|
||||||
|
|
||||||
|
def finite_number(value):
|
||||||
|
return isinstance(value,(int,float)) and not isinstance(value,bool) and math.isfinite(float(value))
|
||||||
|
|
||||||
|
def optional_text(value):
|
||||||
|
return value.strip() if isinstance(value,str) and value.strip() else None
|
||||||
|
|
||||||
|
def validate_numeric_comparison(config, name):
|
||||||
|
check(config.get('operator','lt') in NUMERIC_OPS, f'{name}: unsupported numeric operator')
|
||||||
|
check(finite_number(config.get('value')), f'{name}: numeric comparison needs finite value')
|
||||||
|
|
||||||
|
def validate_text_comparison_config(config, name):
|
||||||
|
check(config.get('operator','eq') in TEXT_OPS, f'{name}: unsupported text operator')
|
||||||
|
check('value' in config, f'{name}: state comparison needs value')
|
||||||
|
|
||||||
|
def validate_source_config(config, name):
|
||||||
|
source=config.get('source')
|
||||||
|
check(source in ROLLING_SOURCES, f'{name}: unsupported statistic source')
|
||||||
|
if source == 'device_temperature': check(nonempty(config.get('device_id')), f'{name}: device source needs device_id')
|
||||||
|
if source == 'zone_temperature': check(nonempty(config.get('zone_id')), f'{name}: zone source needs zone_id')
|
||||||
|
if source == 'ha_numeric': check(nonempty(config.get('entity_id')), f'{name}: HA source needs entity_id')
|
||||||
|
|
||||||
|
def validate_node_config(node, name):
|
||||||
|
kind=node['kind']; c=node.get('config',{})
|
||||||
|
check(isinstance(c,dict), f'{name}/{node.get("id")}: config is not an object')
|
||||||
|
tag=f'{name}/{node.get("id")}:{kind}'
|
||||||
|
if kind == 'weekday':
|
||||||
|
days=c.get('days'); check(isinstance(days,list) and bool(days), f'{tag}: weekdays missing')
|
||||||
|
check(all(isinstance(d,int) and not isinstance(d,bool) and 1 <= d <= 7 for d in days), f'{tag}: invalid weekday')
|
||||||
|
elif kind == 'time_range':
|
||||||
|
for key in ('start','end'):
|
||||||
|
try: datetime.strptime(c.get(key,''),'%H:%M')
|
||||||
|
except (TypeError,ValueError): check(False, f'{tag}: invalid {key} time')
|
||||||
|
else: check(True, f'{tag}: valid {key} time')
|
||||||
|
elif kind == 'date_range':
|
||||||
|
try:
|
||||||
|
start=datetime.strptime(c.get('start',''),'%Y-%m-%d').date(); end=datetime.strptime(c.get('end',''),'%Y-%m-%d').date()
|
||||||
|
except (TypeError,ValueError):
|
||||||
|
check(False, f'{tag}: invalid date range')
|
||||||
|
else:
|
||||||
|
check(start <= end, f'{tag}: reversed date range')
|
||||||
|
elif kind == 'cron_trigger':
|
||||||
|
check(nonempty(c.get('expression')) and cron_valid(c['expression']), f'{tag}: invalid cron')
|
||||||
|
elif kind in {'stable_for','delay'}:
|
||||||
|
seconds=c.get('seconds'); check(isinstance(seconds,int) and not isinstance(seconds,bool) and 1 <= seconds <= 604800, f'{tag}: invalid duration')
|
||||||
|
elif kind == 'rolling_stat':
|
||||||
|
validate_source_config(c,tag)
|
||||||
|
window=c.get('window_seconds'); check(isinstance(window,int) and not isinstance(window,bool) and 10 <= window <= 604800, f'{tag}: invalid window')
|
||||||
|
check(c.get('statistic') in {'mean','median'}, f'{tag}: invalid statistic')
|
||||||
|
validate_numeric_comparison(c,tag)
|
||||||
|
elif kind == 'oscillates':
|
||||||
|
validate_source_config(c,tag)
|
||||||
|
window=c.get('window_seconds'); check(isinstance(window,int) and not isinstance(window,bool) and 10 <= window <= 604800, f'{tag}: invalid window')
|
||||||
|
check(finite_number(c.get('min_span')) and float(c['min_span']) > 0, f'{tag}: min_span must be positive')
|
||||||
|
changes=c.get('min_direction_changes'); check(isinstance(changes,int) and not isinstance(changes,bool) and 1 <= changes <= 1000, f'{tag}: invalid direction-change count')
|
||||||
|
elif kind == 'outdoor_temperature':
|
||||||
|
validate_numeric_comparison(c,tag)
|
||||||
|
elif kind == 'device_temperature':
|
||||||
|
check(nonempty(c.get('device_id')), f'{tag}: device_id missing'); validate_numeric_comparison(c,tag)
|
||||||
|
elif kind == 'zone_temperature':
|
||||||
|
check(nonempty(c.get('zone_id')), f'{tag}: zone_id missing'); validate_numeric_comparison(c,tag)
|
||||||
|
elif kind == 'ha_state':
|
||||||
|
check(nonempty(c.get('entity_id')), f'{tag}: entity_id missing'); validate_text_comparison_config(c,tag)
|
||||||
|
elif kind == 'ha_numeric':
|
||||||
|
check(nonempty(c.get('entity_id')), f'{tag}: entity_id missing'); validate_numeric_comparison(c,tag)
|
||||||
|
elif kind == 'ha_attribute':
|
||||||
|
check(nonempty(c.get('entity_id')), f'{tag}: entity_id missing')
|
||||||
|
check(nonempty(c.get('attribute')), f'{tag}: attribute missing')
|
||||||
|
check(c.get('operator','eq') in NUMERIC_OPS, f'{tag}: invalid attribute operator')
|
||||||
|
check('value' in c, f'{tag}: attribute value missing')
|
||||||
|
elif kind == 'ha_available':
|
||||||
|
check(nonempty(c.get('entity_id')), f'{tag}: entity_id missing')
|
||||||
|
elif kind == 'house_mode':
|
||||||
|
check(c.get('value') in {'cool','heat','off'}, f'{tag}: invalid house mode'); validate_text_comparison_config(c,tag)
|
||||||
|
elif kind == 'device_state':
|
||||||
|
check(nonempty(c.get('device_id')), f'{tag}: device_id missing')
|
||||||
|
check(c.get('field') in DEVICE_STATE_FIELDS, f'{tag}: invalid device field'); validate_text_comparison_config(c,tag)
|
||||||
|
elif kind == 'zone_state':
|
||||||
|
check(nonempty(c.get('zone_id')), f'{tag}: zone_id missing')
|
||||||
|
check(c.get('field') in ZONE_STATE_FIELDS, f'{tag}: invalid zone field'); validate_text_comparison_config(c,tag)
|
||||||
|
elif kind == 'group_state':
|
||||||
|
check(nonempty(c.get('group_id')), f'{tag}: group_id missing')
|
||||||
|
check(c.get('field') == 'power_enabled', f'{tag}: invalid group field'); validate_text_comparison_config(c,tag)
|
||||||
|
elif kind == 'constant':
|
||||||
|
check(isinstance(c.get('value'),bool), f'{tag}: constant needs boolean')
|
||||||
|
elif kind == 'shared_input':
|
||||||
|
check(nonempty(c.get('input_id')), f'{tag}: input_id missing')
|
||||||
|
elif kind == 'zone_thermostat':
|
||||||
|
check(nonempty(c.get('zone_id')), f'{tag}: zone_id missing')
|
||||||
|
preset=optional_text(c.get('preset')) or 'comfort'; check(preset in THERMOSTAT_PRESETS, f'{tag}: invalid preset')
|
||||||
|
mode=optional_text(c.get('mode')); check(mode is None or mode in THERMOSTAT_MODES, f'{tag}: invalid mode')
|
||||||
|
if preset == 'custom': check(finite_number(c.get('setpoint')) and 8 <= float(c['setpoint']) <= 30, f'{tag}: invalid custom target')
|
||||||
|
if 'power' in c and c['power'] is not None: check(isinstance(c['power'],bool), f'{tag}: power is not boolean')
|
||||||
|
if 'cooldown_seconds' in c: check(isinstance(c['cooldown_seconds'],int) and c['cooldown_seconds'] >= 0, f'{tag}: invalid cooldown')
|
||||||
|
elif kind == 'device_action':
|
||||||
|
check(nonempty(c.get('device_id')), f'{tag}: device_id missing')
|
||||||
|
fields=('power','mode','target_temperature','fan_speed','swing_vertical','swing_horizontal','quiet','turbo','light','air','xfan','health','sleep')
|
||||||
|
check(any(c.get(field) is not None for field in fields), f'{tag}: empty device action')
|
||||||
|
elif kind == 'group_action':
|
||||||
|
check(nonempty(c.get('group_id')), f'{tag}: group_id missing')
|
||||||
|
mode=optional_text(c.get('mode')); preset=optional_text(c.get('preset'))
|
||||||
|
check(mode is None or mode in GROUP_MODES, f'{tag}: invalid group mode')
|
||||||
|
check(preset is None or preset in GROUP_PRESETS, f'{tag}: invalid group preset')
|
||||||
|
check(c.get('power') is not None or mode is not None or preset is not None, f'{tag}: empty group action')
|
||||||
|
if preset == 'custom': check(finite_number(c.get('setpoint')) and 8 <= float(c['setpoint']) <= 30, f'{tag}: invalid custom target')
|
||||||
|
elif kind == 'ha_service_action':
|
||||||
|
check(nonempty(c.get('domain')), f'{tag}: domain missing'); check(nonempty(c.get('service')), f'{tag}: service missing')
|
||||||
|
# Empty entity_id is intentionally treated as omitted by backend flow_string().
|
||||||
|
check(isinstance(c.get('data',{}),dict), f'{tag}: HA service data must be object')
|
||||||
|
elif kind in LOGIC or kind == 'night_mode':
|
||||||
|
pass
|
||||||
|
|
||||||
|
def flow_action_truth_table(flow, action_id):
|
||||||
|
by={n['id']:n for n in flow.get('nodes',[])}
|
||||||
|
incoming={}
|
||||||
|
for edge in flow.get('edges',[]): incoming.setdefault(edge['to'],[]).append(edge['from'])
|
||||||
|
ancestors=set(); stack=list(incoming.get(action_id,[]))
|
||||||
|
while stack:
|
||||||
|
node_id=stack.pop()
|
||||||
|
if node_id in ancestors: continue
|
||||||
|
ancestors.add(node_id); stack.extend(incoming.get(node_id,[]))
|
||||||
|
primitive=[node_id for node_id in ancestors if by[node_id]['kind'] not in LOGIC and by[node_id]['kind'] not in {'stable_for','delay'}]
|
||||||
|
check(len(primitive) <= 16, f'action {action_id}: truth table unexpectedly large')
|
||||||
|
|
||||||
|
def eval_node(node_id, assigned, memo):
|
||||||
|
if node_id in memo: return memo[node_id]
|
||||||
|
node=by[node_id]; kind=node['kind']; ins=incoming.get(node_id,[])
|
||||||
|
if kind == 'logic_and': value=bool(ins) and all(eval_node(x,assigned,memo) for x in ins)
|
||||||
|
elif kind == 'logic_or': value=bool(ins) and any(eval_node(x,assigned,memo) for x in ins)
|
||||||
|
elif kind == 'logic_not': value=len(ins)==1 and not eval_node(ins[0],assigned,memo)
|
||||||
|
elif kind in {'stable_for','delay'}: value=len(ins)==1 and eval_node(ins[0],assigned,memo)
|
||||||
|
else:
|
||||||
|
predecessors=all(eval_node(x,assigned,memo) for x in ins)
|
||||||
|
value=predecessors and assigned.get(node_id,False)
|
||||||
|
memo[node_id]=value; return value
|
||||||
|
|
||||||
|
outcomes=[]
|
||||||
|
for mask in range(1 << len(primitive)):
|
||||||
|
assigned={node_id: bool(mask & (1 << index)) for index,node_id in enumerate(primitive)}
|
||||||
|
memo={}
|
||||||
|
outcomes.append(bool(incoming.get(action_id)) and all(eval_node(x,assigned,memo) for x in incoming.get(action_id,[])))
|
||||||
|
return outcomes
|
||||||
|
|
||||||
|
def validate_graph(flow, name):
|
||||||
|
nodes=flow.get('nodes',[]); edges=flow.get('edges',[])
|
||||||
|
check(bool(nodes), f'{name}: no nodes')
|
||||||
|
ids=[n.get('id') for n in nodes]
|
||||||
|
check(all(ids) and len(ids)==len(set(ids)), f'{name}: duplicate/empty node ids')
|
||||||
|
by={n['id']:n for n in nodes}
|
||||||
|
check(all(n.get('kind') in ALL for n in nodes), f'{name}: unsupported node kind')
|
||||||
|
check(any(n['kind'] in ACTIONS for n in nodes), f'{name}: no action')
|
||||||
|
edge_ids=[e.get('id') for e in edges]
|
||||||
|
check(all(edge_ids) and len(edge_ids)==len(set(edge_ids)), f'{name}: duplicate/empty edge ids')
|
||||||
|
pairs=[]
|
||||||
|
incoming={}
|
||||||
|
outgoing={}
|
||||||
|
for e in edges:
|
||||||
|
a,b=e.get('from'),e.get('to')
|
||||||
|
check(a in by and b in by and a != b, f'{name}: invalid edge endpoint')
|
||||||
|
check(by[a]['kind'] not in ACTIONS, f'{name}: action has outgoing edge')
|
||||||
|
pairs.append((a,b)); incoming.setdefault(b,[]).append(a); outgoing.setdefault(a,[]).append(b)
|
||||||
|
check(len(pairs)==len(set(pairs)), f'{name}: duplicate connections')
|
||||||
|
# DAG
|
||||||
|
temp=set(); done=set()
|
||||||
|
def visit(x):
|
||||||
|
if x in done: return
|
||||||
|
check(x not in temp, f'{name}: cycle')
|
||||||
|
temp.add(x)
|
||||||
|
for y in outgoing.get(x,[]): visit(y)
|
||||||
|
temp.remove(x); done.add(x)
|
||||||
|
for x in ids: visit(x)
|
||||||
|
for n in nodes:
|
||||||
|
validate_node_config(n, name)
|
||||||
|
ins=incoming.get(n['id'],[])
|
||||||
|
if n['kind']=='logic_not': check(len(ins)==1, f'{name}: NOT arity')
|
||||||
|
if n['kind'] in {'logic_and','logic_or'}: check(len(ins)>=1, f'{name}: logic arity')
|
||||||
|
if n['kind'] in {'stable_for','delay'}: check(len(ins)==1, f'{name}: timed block arity')
|
||||||
|
if n['kind'] in ACTIONS: check(len(ins)>=1, f'{name}: action without condition')
|
||||||
|
for action in (n for n in nodes if n['kind'] in ACTIONS):
|
||||||
|
outcomes=flow_action_truth_table(flow, action['id'])
|
||||||
|
check(any(outcomes), f'{name}/{action["id"]}: action condition graph can never become true')
|
||||||
|
return len(nodes),len(edges)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
# Deterministic backend semantics.
|
||||||
|
check(cron_valid('*/5 * * * *'), 'cron */5 invalid')
|
||||||
|
check(cron_valid('0,15,30,45 6-18 * * 1-5'), 'cron list/range invalid')
|
||||||
|
for bad in ('* * * *','*/0 * * * *','61 * * * *','* 24 * * *','* * 0 * *','* * * 13 *','* * * * 8','*/100 * * * *'):
|
||||||
|
check(not cron_valid(bad), f'cron accepted invalid: {bad}')
|
||||||
|
monday=datetime(2026,9,7,10,15)
|
||||||
|
check(cron_matches('15 10 * * 1', monday), 'cron exact weekday mismatch')
|
||||||
|
check(cron_matches('15 10 * * 1-7', monday), 'cron weekday range containing 7 mismatch')
|
||||||
|
check(not cron_matches('16 10 * * 1', monday), 'cron false positive')
|
||||||
|
sunday=datetime(2026,9,6,8,0)
|
||||||
|
check(cron_matches('0 8 * * 0', sunday) and cron_matches('0 8 * * 7', sunday) and cron_matches('0 8 * * 1-7', sunday), 'cron Sunday 0/7/range mismatch')
|
||||||
|
|
||||||
|
base=datetime(2026,9,2,12,0,0)
|
||||||
|
rt=Runtime(); check(not timed_gate(rt,True,30,base),'stable fired immediately'); check(not timed_gate(rt,True,30,base+timedelta(seconds=29)),'stable fired early'); check(timed_gate(rt,True,30,base+timedelta(seconds=30)),'stable did not fire'); check(not timed_gate(rt,False,30,base+timedelta(seconds=31)),'stable did not reset'); check(not timed_gate(rt,True,30,base+timedelta(seconds=32)),'stable restart should wait')
|
||||||
|
rt=Runtime(); check(not timed_gate(rt,True,3,base),'delay immediate'); check(timed_gate(rt,True,3,base+timedelta(seconds=3)),'delay did not pass after wait')
|
||||||
|
|
||||||
|
rt=Runtime(); check(math.isclose(rolling(rt,10,60,'mean',base),10),'mean one'); check(math.isclose(rolling(rt,20,60,'mean',base+timedelta(seconds=10)),15),'mean two'); check(math.isclose(rolling(rt,30,60,'median',base+timedelta(seconds=20)),20),'median odd'); check(math.isclose(rolling(rt,40,60,'median',base+timedelta(seconds=30)),25),'median even'); check(math.isclose(rolling(rt,100,60,'mean',base+timedelta(seconds=100)),100),'window prune')
|
||||||
|
rt=Runtime();
|
||||||
|
for i,v in enumerate((20,22,19,23,20)):
|
||||||
|
result=oscillates(rt,v,300,3,2,base+timedelta(seconds=i*10))
|
||||||
|
check(result,'oscillation not detected')
|
||||||
|
rt=Runtime();
|
||||||
|
for i,v in enumerate((20,21,22,23,24)):
|
||||||
|
result=oscillates(rt,v,300,3,2,base+timedelta(seconds=i*10))
|
||||||
|
check(not result,'monotonic trend detected as oscillation')
|
||||||
|
|
||||||
|
# Static backend/frontend wiring checks.
|
||||||
|
engine=(ROOT/'src/engine/automations.rs').read_text()
|
||||||
|
api=(ROOT/'src/api/flows.rs').read_text()
|
||||||
|
js=(ROOT/'web/js/flows.js').read_text()
|
||||||
|
html=(ROOT/'web/index.html').read_text()
|
||||||
|
for kind in ('cron_trigger','stable_for','delay','rolling_stat','oscillates'):
|
||||||
|
check(f'"{kind}"' in api and f'"{kind}"' in engine, f'{kind}: backend wiring missing')
|
||||||
|
check(kind in js and f'data-flow-add="{kind}"' in html, f'{kind}: UI wiring missing')
|
||||||
|
meta_block=js.split('const FLOW_NODE_META = Object.freeze({',1)[1].split('});',1)[0]
|
||||||
|
meta_kinds=set(re.findall(r'^\s*([a-z_]+):\s*\{', meta_block, re.M))
|
||||||
|
palette_kinds=set(re.findall(r'data-flow-add="([a-z_]+)"', html))
|
||||||
|
check(meta_kinds == ALL, f'UI metadata/backend kind mismatch: missing={ALL-meta_kinds}, extra={meta_kinds-ALL}')
|
||||||
|
check(palette_kinds == ALL, f'palette/backend kind mismatch: missing={ALL-palette_kinds}, extra={palette_kinds-ALL}')
|
||||||
|
check('"ha_service_action" => Ok(None)' in api, 'HA action missing dry-run support')
|
||||||
|
check('home_assistant::call_service' in engine and 'action_ha_domain' in engine, 'HA action runtime missing')
|
||||||
|
check('format!("ha:{entity_id}")' in engine, 'HA target conflict claim missing')
|
||||||
|
check('Some(&mut runtime)' in api, 'dry-run does not use stateful runtime')
|
||||||
|
check('conditions.iter().any(|condition| condition.kind == "cron_trigger")' in engine and 'last.minute() == now.minute()' in engine, 'CRON same-minute duplicate guard missing')
|
||||||
|
check('previous.config == current.config' in api and 'runtime.retain' in api, 'runtime reset-on-edit protection missing')
|
||||||
|
runtime_leaf=engine.split('async fn flow_leaf_observation',1)[1].split('pub async fn evaluate_flow_conditions_trace',1)[0]
|
||||||
|
for kind in CONDITIONS - {'stable_for','delay','rolling_stat','oscillates'}:
|
||||||
|
check(f'\"{kind}\" =>' in runtime_leaf or (kind == 'shared_input' and 'condition.kind == \"shared_input\"' in runtime_leaf), f'{kind}: runtime leaf implementation missing')
|
||||||
|
evaluator=engine.split('pub async fn evaluate_flow_conditions_trace',1)[1].split('async fn flow_conditions_match',1)[0]
|
||||||
|
for kind in ('stable_for','delay','rolling_stat','oscillates','logic_and','logic_or','logic_not'):
|
||||||
|
check(f'\"{kind}\"' in evaluator, f'{kind}: evaluator implementation missing')
|
||||||
|
for category in ('trigger','time','timeop','sensor','logic','action','haaction'):
|
||||||
|
check(f'flow-palette-{category}' in html, f'palette category {category} missing')
|
||||||
|
css=(ROOT/'web/styles.css').read_text()
|
||||||
|
for marker in ('.flow-palette-group button', 'min-height:30px', 'padding:6px 8px', '.flow-palette-timeop', '.flow-palette-haaction'):
|
||||||
|
check(marker in css, f'compact/color Flow CSS missing: {marker}')
|
||||||
|
|
||||||
|
# Translation pack correctness: all new keys must be inside translations, not root.
|
||||||
|
for lang in ('pl','en'):
|
||||||
|
pack=json.loads((ROOT/f'lang/{lang}.json').read_text())
|
||||||
|
check(not any(k.startswith('flow.') for k in pack), f'{lang}: flow translations leaked to root')
|
||||||
|
for key in ('flow.triggers','flow.timeOps','flow.haActions','flow.node.cronTrigger','flow.node.stableFor','flow.node.delay','flow.node.rollingStat','flow.node.oscillates','flow.node.haServiceAction'):
|
||||||
|
check(bool(pack.get('translations',{}).get(key)), f'{lang}: missing {key}')
|
||||||
|
title_keys=set(re.findall(r"titleKey: '([^']+)'", meta_block))
|
||||||
|
missing_titles=sorted(key for key in title_keys if not pack.get('translations',{}).get(key))
|
||||||
|
check(not missing_titles, f'{lang}: missing Flow node title translations: {missing_titles}')
|
||||||
|
referenced_flow_keys=set(re.findall(r'''(?:data-i18n(?:-[a-z]+)?=|t\()\s*[\"'](flow\.[A-Za-z0-9_.]+)[\"']''', js + '\n' + html))
|
||||||
|
missing_referenced=sorted(key for key in referenced_flow_keys if key not in pack.get('translations',{}))
|
||||||
|
check(not missing_referenced, f'{lang}: missing referenced Flow translations: {missing_referenced}')
|
||||||
|
|
||||||
|
# Regression pass over every bundled Flow preset.
|
||||||
|
preset_files=sorted((ROOT/'presets').glob('*.json'))
|
||||||
|
check(len(preset_files) >= 30, 'unexpectedly small preset library')
|
||||||
|
nodes=edges=0
|
||||||
|
for f in preset_files:
|
||||||
|
data=json.loads(f.read_text())
|
||||||
|
n,e=validate_graph(data['flow'],f.name); nodes+=n; edges+=e
|
||||||
|
print(f'PASS: {passed} logical assertions; {len(preset_files)} presets; {nodes} nodes; {edges} edges')
|
||||||
|
|
||||||
|
if __name__=='__main__': main()
|
||||||
@@ -87,7 +87,7 @@ impl AutomationInput {
|
|||||||
action_group_id: self.action_group_id.map(|value| value.trim().to_string()).filter(|value| !value.is_empty()),
|
action_group_id: self.action_group_id.map(|value| value.trim().to_string()).filter(|value| !value.is_empty()),
|
||||||
action_preset: self.action_preset.map(|value| value.trim().to_string()).filter(|value| !value.is_empty()),
|
action_preset: self.action_preset.map(|value| value.trim().to_string()).filter(|value| !value.is_empty()),
|
||||||
action: self.action, cooldown_seconds: self.cooldown_seconds.max(30), last_fired_at,
|
action: self.action, cooldown_seconds: self.cooldown_seconds.max(30), last_fired_at,
|
||||||
action_zone_id: None, action_zone_preset: None, flow_conditions: vec![], flow_id: None, flow_node_id: None,
|
action_zone_id: None, action_zone_preset: None, action_ha_domain: None, action_ha_service: None, action_ha_entity_id: None, action_ha_data: Value::Null, flow_conditions: vec![], flow_id: None, flow_node_id: None, flow_runtime: Default::default(),
|
||||||
created_at, updated_at: Utc::now() }
|
created_at, updated_at: Utc::now() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+69
-5
@@ -43,13 +43,13 @@ fn generated_flow_name(flow_id: &str, action_node_id: &str) -> String {
|
|||||||
|
|
||||||
fn flow_condition_kind(kind: &str) -> bool {
|
fn flow_condition_kind(kind: &str) -> bool {
|
||||||
matches!(kind,
|
matches!(kind,
|
||||||
"weekday" | "time_range" | "date_range" | "outdoor_temperature" | "device_temperature" |
|
"weekday" | "time_range" | "date_range" | "cron_trigger" | "stable_for" | "delay" | "rolling_stat" | "oscillates" | "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"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
fn flow_logic_kind(kind: &str) -> bool { matches!(kind, "logic_and" | "logic_or" | "logic_not") }
|
fn flow_logic_kind(kind: &str) -> bool { matches!(kind, "logic_and" | "logic_or" | "logic_not") }
|
||||||
fn flow_action_kind(kind: &str) -> bool { matches!(kind, "zone_thermostat" | "device_action" | "group_action") }
|
fn flow_action_kind(kind: &str) -> bool { matches!(kind, "zone_thermostat" | "device_action" | "group_action" | "ha_service_action") }
|
||||||
|
|
||||||
fn shared_input_comparison_kind(kind: &str) -> bool {
|
fn shared_input_comparison_kind(kind: &str) -> bool {
|
||||||
matches!(kind,
|
matches!(kind,
|
||||||
@@ -216,6 +216,44 @@ fn validate_condition(condition: &crate::models::FlowCondition, state: &AppState
|
|||||||
let end = chrono::NaiveDate::parse_from_str(&end, "%Y-%m-%d").map_err(|_| AppError::BadRequest("invalid date range".into()))?;
|
let end = chrono::NaiveDate::parse_from_str(&end, "%Y-%m-%d").map_err(|_| AppError::BadRequest("invalid date range".into()))?;
|
||||||
if start > end { return Err(AppError::BadRequest("date range start must not be after end".into())); }
|
if start > end { return Err(AppError::BadRequest("date range start must not be after end".into())); }
|
||||||
}
|
}
|
||||||
|
"cron_trigger" => {
|
||||||
|
let expr = flow_string(&condition.config, "expression").ok_or_else(|| AppError::BadRequest("cron block needs an expression".into()))?;
|
||||||
|
if !engine::cron_expression_valid(&expr) { return Err(AppError::BadRequest("invalid cron expression; expected 5 fields with *, */N, ranges or lists".into())); }
|
||||||
|
}
|
||||||
|
"stable_for" => {
|
||||||
|
let seconds = condition.config.get("seconds").and_then(Value::as_u64).unwrap_or(0);
|
||||||
|
if seconds == 0 || seconds > 604800 { return Err(AppError::BadRequest("stable-for duration must be between 1 second and 7 days".into())); }
|
||||||
|
if condition.inputs.len() != 1 { return Err(AppError::BadRequest("stable-for block needs exactly one input".into())); }
|
||||||
|
}
|
||||||
|
"delay" => {
|
||||||
|
let seconds = condition.config.get("seconds").and_then(Value::as_u64).unwrap_or(0);
|
||||||
|
if seconds == 0 || seconds > 604800 { return Err(AppError::BadRequest("delay must be between 1 second and 7 days".into())); }
|
||||||
|
if condition.inputs.len() != 1 { return Err(AppError::BadRequest("delay block needs exactly one input".into())); }
|
||||||
|
}
|
||||||
|
"rolling_stat" => {
|
||||||
|
let source = flow_string(&condition.config, "source").unwrap_or_default();
|
||||||
|
if !matches!(source.as_str(), "outdoor_temperature" | "device_temperature" | "zone_temperature" | "ha_numeric") { return Err(AppError::BadRequest("rolling statistic has an unsupported source".into())); }
|
||||||
|
if source == "device_temperature" { let id=flow_string(&condition.config,"device_id").unwrap_or_default(); if state.db.get_device(&id)?.is_none(){return Err(AppError::BadRequest("rolling statistic references a missing device".into()));} }
|
||||||
|
if source == "zone_temperature" { let id=flow_string(&condition.config,"zone_id").unwrap_or_default(); if state.db.get_zone(&id)?.is_none(){return Err(AppError::BadRequest("rolling statistic references a missing zone".into()));} }
|
||||||
|
if source == "ha_numeric" && flow_string(&condition.config,"entity_id").is_none() { return Err(AppError::BadRequest("rolling HA statistic needs entity_id".into())); }
|
||||||
|
let window = condition.config.get("window_seconds").and_then(Value::as_u64).unwrap_or(0);
|
||||||
|
if window < 10 || window > 604800 { return Err(AppError::BadRequest("rolling statistic window must be between 10 seconds and 7 days".into())); }
|
||||||
|
if !matches!(flow_string(&condition.config,"statistic").as_deref(), Some("mean") | Some("median")) { return Err(AppError::BadRequest("rolling statistic must be mean or median".into())); }
|
||||||
|
validate_flow_comparison(&condition.config)?;
|
||||||
|
}
|
||||||
|
"oscillates" => {
|
||||||
|
let source = flow_string(&condition.config, "source").unwrap_or_default();
|
||||||
|
if !matches!(source.as_str(), "outdoor_temperature" | "device_temperature" | "zone_temperature" | "ha_numeric") { return Err(AppError::BadRequest("oscillation block has an unsupported source".into())); }
|
||||||
|
if source == "device_temperature" { let id=flow_string(&condition.config,"device_id").unwrap_or_default(); if state.db.get_device(&id)?.is_none(){return Err(AppError::BadRequest("oscillation block references a missing device".into()));} }
|
||||||
|
if source == "zone_temperature" { let id=flow_string(&condition.config,"zone_id").unwrap_or_default(); if state.db.get_zone(&id)?.is_none(){return Err(AppError::BadRequest("oscillation block references a missing zone".into()));} }
|
||||||
|
if source == "ha_numeric" && flow_string(&condition.config,"entity_id").is_none() { return Err(AppError::BadRequest("oscillation HA source needs entity_id".into())); }
|
||||||
|
let window = condition.config.get("window_seconds").and_then(Value::as_u64).unwrap_or(0);
|
||||||
|
if window < 10 || window > 604800 { return Err(AppError::BadRequest("oscillation window must be between 10 seconds and 7 days".into())); }
|
||||||
|
let min_span = flow_f64(&condition.config, "min_span").unwrap_or(0.0);
|
||||||
|
if !min_span.is_finite() || min_span <= 0.0 { return Err(AppError::BadRequest("oscillation minimum span must be greater than zero".into())); }
|
||||||
|
let min_changes = condition.config.get("min_direction_changes").and_then(Value::as_u64).unwrap_or(0);
|
||||||
|
if min_changes == 0 || min_changes > 1000 { return Err(AppError::BadRequest("oscillation direction changes must be between 1 and 1000".into())); }
|
||||||
|
}
|
||||||
"outdoor_temperature" => { validate_flow_comparison(&condition.config)?; }
|
"outdoor_temperature" => { validate_flow_comparison(&condition.config)?; }
|
||||||
"device_temperature" => {
|
"device_temperature" => {
|
||||||
validate_flow_comparison(&condition.config)?;
|
validate_flow_comparison(&condition.config)?;
|
||||||
@@ -364,12 +402,26 @@ fn compile_flow(state: &AppState, mut flow: crate::models::Flow) -> Result<(crat
|
|||||||
let id = format!("flow:{}:automation:{}", flow.id, action_node.id);
|
let id = format!("flow:{}:automation:{}", flow.id, action_node.id);
|
||||||
let created_at = previous_automations.get(&id).map(|item| item.created_at.clone()).unwrap_or_else(|| now.clone());
|
let created_at = previous_automations.get(&id).map(|item| item.created_at.clone()).unwrap_or_else(|| now.clone());
|
||||||
let last_fired_at = previous_automations.get(&id).and_then(|item| item.last_fired_at.clone());
|
let last_fired_at = previous_automations.get(&id).and_then(|item| item.last_fired_at.clone());
|
||||||
|
let flow_runtime = previous_automations.get(&id).map(|item| {
|
||||||
|
let previous_conditions = item.flow_conditions.iter().map(|condition| (condition.id.as_str(), condition)).collect::<std::collections::HashMap<_, _>>();
|
||||||
|
let current_conditions = conditions.iter().map(|condition| (condition.id.as_str(), condition)).collect::<std::collections::HashMap<_, _>>();
|
||||||
|
let mut runtime = item.flow_runtime.clone();
|
||||||
|
runtime.retain(|node_id, _| {
|
||||||
|
let Some(previous) = previous_conditions.get(node_id.as_str()) else { return false; };
|
||||||
|
let Some(current) = current_conditions.get(node_id.as_str()) else { return false; };
|
||||||
|
matches!(current.kind.as_str(), "stable_for" | "delay" | "rolling_stat" | "oscillates")
|
||||||
|
&& previous.kind == current.kind
|
||||||
|
&& previous.config == current.config
|
||||||
|
&& previous.inputs == current.inputs
|
||||||
|
});
|
||||||
|
runtime
|
||||||
|
}).unwrap_or_default();
|
||||||
let mut item = Automation {
|
let mut item = Automation {
|
||||||
id, name: generated_flow_name(&flow.id, &action_node.id), enabled: flow.enabled,
|
id, name: generated_flow_name(&flow.id, &action_node.id), enabled: flow.enabled,
|
||||||
trigger_kind: "flow".into(), trigger_device_id: None, threshold: None, at_time: None,
|
trigger_kind: "flow".into(), trigger_device_id: None, threshold: None, at_time: None,
|
||||||
action_device_id: String::new(), action_group_id: None, action_preset: None, action: DeviceCommand::default(), cooldown_seconds: 60,
|
action_device_id: String::new(), action_group_id: None, action_preset: None, action: DeviceCommand::default(), cooldown_seconds: 60,
|
||||||
last_fired_at, action_zone_id: None, action_zone_preset: None, flow_conditions: conditions,
|
last_fired_at, action_zone_id: None, action_zone_preset: None, action_ha_domain: None, action_ha_service: None, action_ha_entity_id: None, action_ha_data: Value::Null, flow_conditions: conditions,
|
||||||
flow_id: Some(flow.id.clone()), flow_node_id: Some(action_node.id.clone()), created_at, updated_at: now.clone(),
|
flow_id: Some(flow.id.clone()), flow_node_id: Some(action_node.id.clone()), flow_runtime, created_at, updated_at: now.clone(),
|
||||||
};
|
};
|
||||||
if let Some(cooldown) = action_node.config.get("cooldown_seconds").and_then(Value::as_u64) { item.cooldown_seconds = cooldown.max(30); }
|
if let Some(cooldown) = action_node.config.get("cooldown_seconds").and_then(Value::as_u64) { item.cooldown_seconds = cooldown.max(30); }
|
||||||
match action_node.kind.as_str() {
|
match action_node.kind.as_str() {
|
||||||
@@ -403,6 +455,15 @@ fn compile_flow(state: &AppState, mut flow: crate::models::Flow) -> Result<(crat
|
|||||||
item.action.sleep = flow_bool(&action_node.config, "sleep");
|
item.action.sleep = flow_bool(&action_node.config, "sleep");
|
||||||
engine::validate_command(&item.action)?; if item.action.is_empty() { return Err(AppError::BadRequest("device action cannot be empty".into())); }
|
engine::validate_command(&item.action)?; if item.action.is_empty() { return Err(AppError::BadRequest("device action cannot be empty".into())); }
|
||||||
}
|
}
|
||||||
|
"ha_service_action" => {
|
||||||
|
let domain = flow_string(&action_node.config, "domain").ok_or_else(|| AppError::BadRequest("Home Assistant action needs a domain".into()))?;
|
||||||
|
let service = flow_string(&action_node.config, "service").ok_or_else(|| AppError::BadRequest("Home Assistant action needs a service".into()))?;
|
||||||
|
item.action_ha_domain = Some(domain);
|
||||||
|
item.action_ha_service = Some(service);
|
||||||
|
item.action_ha_entity_id = flow_string(&action_node.config, "entity_id");
|
||||||
|
item.action_ha_data = action_node.config.get("data").cloned().unwrap_or_else(|| json!({}));
|
||||||
|
if !item.action_ha_data.is_object() { return Err(AppError::BadRequest("Home Assistant service data must be a JSON object".into())); }
|
||||||
|
}
|
||||||
"group_action" => {
|
"group_action" => {
|
||||||
let id = flow_string(&action_node.config, "group_id").ok_or_else(|| AppError::BadRequest("group action needs a group".into()))?;
|
let id = flow_string(&action_node.config, "group_id").ok_or_else(|| AppError::BadRequest("group action needs a group".into()))?;
|
||||||
if !groups.iter().any(|g| g.id == id) { return Err(AppError::BadRequest("group action references a missing group".into())); }
|
if !groups.iter().any(|g| g.id == id) { return Err(AppError::BadRequest("group action references a missing group".into())); }
|
||||||
@@ -581,6 +642,7 @@ fn dry_run_block_reason(state: &AppState, action: &crate::models::FlowNode) -> R
|
|||||||
if climate_change && !resulting_enabled { return Ok(Some("group_control_disabled".into())); }
|
if climate_change && !resulting_enabled { return Ok(Some("group_control_disabled".into())); }
|
||||||
Ok(None)
|
Ok(None)
|
||||||
}
|
}
|
||||||
|
"ha_service_action" => Ok(None),
|
||||||
_ => Ok(Some("unsupported_action".into())),
|
_ => Ok(Some("unsupported_action".into())),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -602,7 +664,9 @@ async fn simulate_flow(State(state): State<AppState>, Json(input): Json<FlowSimu
|
|||||||
let mut actions = Vec::new();
|
let mut actions = Vec::new();
|
||||||
for action in input.flow.nodes.iter().filter(|node| flow_action_kind(&node.kind)) {
|
for action in input.flow.nodes.iter().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 (matched, trace) = engine::evaluate_flow_conditions_trace(&state, &devices, &program, at.clone(), &input.overrides).await?;
|
let automation_id = format!("flow:{preview_id}:automation:{}", action.id);
|
||||||
|
let mut runtime = state.db.get_automation(&automation_id)?.map(|item| item.flow_runtime).unwrap_or_default();
|
||||||
|
let (matched, trace) = engine::evaluate_flow_conditions_trace(&state, &devices, &program, at.clone(), &input.overrides, Some(&mut runtime)).await?;
|
||||||
let blocked_reason = if matched && !input.flow.enabled { Some("flow_disabled".into()) }
|
let blocked_reason = if matched && !input.flow.enabled { Some("flow_disabled".into()) }
|
||||||
else if matched { dry_run_block_reason(&state, action)? } else { None };
|
else if matched { dry_run_block_reason(&state, action)? } else { None };
|
||||||
actions.push(json!({
|
actions.push(json!({
|
||||||
|
|||||||
+216
-6
@@ -28,12 +28,17 @@ async fn run_automations(state: &AppState) -> Result<()> {
|
|||||||
"temperature_below" => find_temperature(&devices, item.trigger_device_id.as_deref())
|
"temperature_below" => find_temperature(&devices, item.trigger_device_id.as_deref())
|
||||||
.zip(item.threshold).map(|(t, threshold)| t < threshold).unwrap_or(false),
|
.zip(item.threshold).map(|(t, threshold)| t < threshold).unwrap_or(false),
|
||||||
"time" => time_automation_due(&item, Local::now()),
|
"time" => time_automation_due(&item, Local::now()),
|
||||||
"flow" => match flow_conditions_match(state, &devices, &item.flow_conditions).await {
|
"flow" => {
|
||||||
|
let before_runtime = item.flow_runtime.clone();
|
||||||
|
let result = flow_conditions_match(state, &devices, &mut item).await;
|
||||||
|
if item.flow_runtime != before_runtime { state.db.save_automation(&item)?; }
|
||||||
|
match result {
|
||||||
Ok(value) => value,
|
Ok(value) => value,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
state.log("warn", "flow.condition_error", &format!("Flow automation {} condition evaluation failed: {}", item.name, err), json!({"automation_id": item.id, "flow_id": item.flow_id, "flow_node_id": item.flow_node_id}));
|
state.log("warn", "flow.condition_error", &format!("Flow automation {} condition evaluation failed: {}", item.name, err), json!({"automation_id": item.id, "flow_id": item.flow_id, "flow_node_id": item.flow_node_id}));
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
_ => false,
|
_ => false,
|
||||||
};
|
};
|
||||||
@@ -92,7 +97,9 @@ async fn run_automations(state: &AppState) -> Result<()> {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let target_devices: Vec<String> = if let Some(zone_id) = item.action_zone_id.as_deref() {
|
let target_devices: Vec<String> = if item.action_ha_domain.is_some() {
|
||||||
|
item.action_ha_entity_id.as_ref().map(|entity_id| vec![format!("ha:{entity_id}")]).unwrap_or_default()
|
||||||
|
} else if let Some(zone_id) = item.action_zone_id.as_deref() {
|
||||||
zones.iter().find(|zone| zone.id == zone_id).map(|zone| vec![zone.device_id.clone()]).unwrap_or_default()
|
zones.iter().find(|zone| zone.id == zone_id).map(|zone| vec![zone.device_id.clone()]).unwrap_or_default()
|
||||||
} else if let Some(group_id) = item.action_group_id.as_deref() {
|
} else if let Some(group_id) = item.action_group_id.as_deref() {
|
||||||
groups.iter().find(|group| group.id == group_id)
|
groups.iter().find(|group| group.id == group_id)
|
||||||
@@ -115,7 +122,13 @@ async fn run_automations(state: &AppState) -> Result<()> {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let result: Result<bool, AppError> = if let Some(zone_id) = item.action_zone_id.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();
|
||||||
|
match home_assistant::call_service(&state.http, &settings.home_assistant, domain, service, item.action_ha_entity_id.as_deref(), &item.action_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(), &item.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 = item.action.mode.as_deref().map(|mode| if mode == "auto" { "house".to_string() } else { mode.to_string() });
|
||||||
@@ -275,6 +288,108 @@ fn flow_zone_state_value(zone: &Zone, field: &str) -> Option<Value> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
fn cron_field_matches(field: &str, value: u32, min: u32, max: u32) -> bool {
|
||||||
|
fn part_matches(part: &str, value: u32, min: u32, max: u32) -> bool {
|
||||||
|
if part == "*" { return true; }
|
||||||
|
if let Some(step) = part.strip_prefix("*/").and_then(|v| v.parse::<u32>().ok()).filter(|v| *v > 0) {
|
||||||
|
return value >= min && value <= max && (value - min) % step == 0;
|
||||||
|
}
|
||||||
|
if let Some((a,b)) = part.split_once('-') {
|
||||||
|
if let (Ok(a), Ok(b)) = (a.parse::<u32>(), b.parse::<u32>()) { return a <= value && value <= b && a >= min && b <= max; }
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
part.parse::<u32>().ok().is_some_and(|v| v == value && v >= min && v <= max)
|
||||||
|
}
|
||||||
|
field.split(',').any(|part| part_matches(part.trim(), value, min, max))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn cron_expression_valid(expression: &str) -> bool {
|
||||||
|
fn valid_field(field: &str, min: u32, max: u32, allow_seven: bool) -> bool {
|
||||||
|
let upper = if allow_seven { 7 } else { max };
|
||||||
|
if field.trim().is_empty() { return false; }
|
||||||
|
field.split(',').all(|part| {
|
||||||
|
let part = part.trim();
|
||||||
|
if part == "*" { return true; }
|
||||||
|
if let Some(raw) = part.strip_prefix("*/") {
|
||||||
|
return raw.parse::<u32>().ok().is_some_and(|step| step > 0 && step <= (max - min + 1));
|
||||||
|
}
|
||||||
|
if let Some((a, b)) = part.split_once('-') {
|
||||||
|
return a.parse::<u32>().ok().zip(b.parse::<u32>().ok())
|
||||||
|
.is_some_and(|(a, b)| a >= min && b <= upper && a <= b);
|
||||||
|
}
|
||||||
|
part.parse::<u32>().ok().is_some_and(|value| value >= min && value <= upper)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
let fields: Vec<_> = expression.split_whitespace().collect();
|
||||||
|
fields.len() == 5
|
||||||
|
&& valid_field(fields[0], 0, 59, false)
|
||||||
|
&& valid_field(fields[1], 0, 23, false)
|
||||||
|
&& valid_field(fields[2], 1, 31, false)
|
||||||
|
&& valid_field(fields[3], 1, 12, false)
|
||||||
|
&& valid_field(fields[4], 0, 6, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cron_matches(expression: &str, now: &DateTime<Local>) -> bool {
|
||||||
|
if !cron_expression_valid(expression) { return false; }
|
||||||
|
let fields: Vec<_> = expression.split_whitespace().collect();
|
||||||
|
let weekday = now.weekday().num_days_from_sunday();
|
||||||
|
cron_field_matches(fields[0], now.minute(), 0, 59)
|
||||||
|
&& cron_field_matches(fields[1], now.hour(), 0, 23)
|
||||||
|
&& cron_field_matches(fields[2], now.day(), 1, 31)
|
||||||
|
&& cron_field_matches(fields[3], now.month(), 1, 12)
|
||||||
|
&& (cron_field_matches(fields[4], weekday, 0, 7) || (weekday == 0 && cron_field_matches(fields[4], 7, 0, 7)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn flow_oscillation_metrics(samples: &[crate::models::FlowRuntimeSample]) -> Option<(f64, usize)> {
|
||||||
|
if samples.len() < 3 { return None; }
|
||||||
|
let min = samples.iter().map(|item| item.value).fold(f64::INFINITY, f64::min);
|
||||||
|
let max = samples.iter().map(|item| item.value).fold(f64::NEG_INFINITY, f64::max);
|
||||||
|
if !min.is_finite() || !max.is_finite() { return None; }
|
||||||
|
let mut previous_sign = 0i8;
|
||||||
|
let mut direction_changes = 0usize;
|
||||||
|
for pair in samples.windows(2) {
|
||||||
|
let delta = pair[1].value - pair[0].value;
|
||||||
|
let sign = if delta > 0.000001 { 1 } else if delta < -0.000001 { -1 } else { 0 };
|
||||||
|
if sign == 0 { continue; }
|
||||||
|
if previous_sign != 0 && sign != previous_sign { direction_changes += 1; }
|
||||||
|
previous_sign = sign;
|
||||||
|
}
|
||||||
|
Some((max - min, direction_changes))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn flow_timed_gate_update(
|
||||||
|
state: &mut crate::models::FlowRuntimeNodeState,
|
||||||
|
input: bool,
|
||||||
|
seconds: u64,
|
||||||
|
now: DateTime<Utc>,
|
||||||
|
) -> bool {
|
||||||
|
if !input { state.since = None; return false; }
|
||||||
|
let since = state.since.get_or_insert_with(|| now.clone());
|
||||||
|
now.signed_duration_since(since.clone()).num_seconds() >= seconds as i64
|
||||||
|
}
|
||||||
|
|
||||||
|
fn flow_rolling_stat_update(
|
||||||
|
state: &mut crate::models::FlowRuntimeNodeState,
|
||||||
|
sample: f64,
|
||||||
|
window_seconds: u64,
|
||||||
|
statistic: &str,
|
||||||
|
now: DateTime<Utc>,
|
||||||
|
) -> Option<f64> {
|
||||||
|
let cutoff = now.clone() - chrono::Duration::seconds(window_seconds as i64);
|
||||||
|
state.samples.retain(|item| item.at >= cutoff);
|
||||||
|
state.samples.push(crate::models::FlowRuntimeSample { at: now, value: sample });
|
||||||
|
let mut values = state.samples.iter().map(|item| item.value).collect::<Vec<_>>();
|
||||||
|
if values.is_empty() { return None; }
|
||||||
|
if statistic == "median" {
|
||||||
|
values.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
|
||||||
|
let mid = values.len() / 2;
|
||||||
|
Some(if values.len() % 2 == 0 { (values[mid - 1] + values[mid]) / 2.0 } else { values[mid] })
|
||||||
|
} else {
|
||||||
|
Some(values.iter().sum::<f64>() / values.len() as f64)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn flow_leaf_observation(
|
async fn flow_leaf_observation(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
devices: &[Device],
|
devices: &[Device],
|
||||||
@@ -339,6 +454,10 @@ async fn flow_leaf_observation(
|
|||||||
let end = c.get("end").and_then(Value::as_str).and_then(|v| chrono::NaiveDate::parse_from_str(v, "%Y-%m-%d").ok());
|
let end = c.get("end").and_then(Value::as_str).and_then(|v| chrono::NaiveDate::parse_from_str(v, "%Y-%m-%d").ok());
|
||||||
(matches!((start, end), (Some(a), Some(b)) if current >= a && current <= b), json!(actual))
|
(matches!((start, end), (Some(a), Some(b)) if current >= a && current <= b), json!(actual))
|
||||||
}
|
}
|
||||||
|
"cron_trigger" => {
|
||||||
|
let expression = c.get("expression").and_then(Value::as_str).unwrap_or("");
|
||||||
|
(cron_matches(expression, now), json!(now.format("%Y-%m-%d %H:%M").to_string()))
|
||||||
|
}
|
||||||
"outdoor_temperature" => {
|
"outdoor_temperature" => {
|
||||||
let actual = if let Some(v) = override_value { v.as_f64() } else { outdoor_temperature };
|
let actual = if let Some(v) = override_value { v.as_f64() } else { outdoor_temperature };
|
||||||
let expected = c.get("value").and_then(Value::as_f64);
|
let expected = c.get("value").and_then(Value::as_f64);
|
||||||
@@ -468,6 +587,7 @@ pub async fn evaluate_flow_conditions_trace(
|
|||||||
conditions: &[crate::models::FlowCondition],
|
conditions: &[crate::models::FlowCondition],
|
||||||
now: DateTime<Local>,
|
now: DateTime<Local>,
|
||||||
overrides: &HashMap<String, Value>,
|
overrides: &HashMap<String, Value>,
|
||||||
|
mut runtime: Option<&mut std::collections::BTreeMap<String, crate::models::FlowRuntimeNodeState>>,
|
||||||
) -> Result<(bool, Vec<Value>), AppError> {
|
) -> Result<(bool, Vec<Value>), AppError> {
|
||||||
if conditions.is_empty() { return Ok((false, Vec::new())); }
|
if conditions.is_empty() { return Ok((false, Vec::new())); }
|
||||||
let settings = state.settings.read().await.clone();
|
let settings = state.settings.read().await.clone();
|
||||||
@@ -502,6 +622,88 @@ pub async fn evaluate_flow_conditions_trace(
|
|||||||
let value = condition.inputs.len() == 1 && !values.get(&condition.inputs[0]).copied().unwrap_or(false);
|
let value = condition.inputs.len() == 1 && !values.get(&condition.inputs[0]).copied().unwrap_or(false);
|
||||||
(value, json!(condition.inputs.first().and_then(|id| values.get(id)).copied()))
|
(value, json!(condition.inputs.first().and_then(|id| values.get(id)).copied()))
|
||||||
}
|
}
|
||||||
|
"stable_for" | "delay" => {
|
||||||
|
let input = condition.inputs.len() == 1 && values.get(&condition.inputs[0]).copied().unwrap_or(false);
|
||||||
|
let seconds = condition.config.get("seconds").and_then(Value::as_u64).unwrap_or(1);
|
||||||
|
let mut since_value = None;
|
||||||
|
let value = if let Some(map) = runtime.as_deref_mut() {
|
||||||
|
let state = map.entry(condition.id.clone()).or_default();
|
||||||
|
let value = flow_timed_gate_update(state, input, seconds, now.with_timezone(&Utc));
|
||||||
|
since_value = state.since.clone();
|
||||||
|
value
|
||||||
|
} else { false };
|
||||||
|
(value, json!({"input": input, "since": since_value, "seconds": seconds}))
|
||||||
|
}
|
||||||
|
"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("");
|
||||||
|
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 window = condition.config.get("window_seconds").and_then(Value::as_u64).unwrap_or(60);
|
||||||
|
let mut aggregate = None;
|
||||||
|
let mut count = 0usize;
|
||||||
|
if let (Some(sample), Some(map)) = (sample, runtime.as_deref_mut()) {
|
||||||
|
let node_state = map.entry(condition.id.clone()).or_default();
|
||||||
|
aggregate = flow_rolling_stat_update(
|
||||||
|
node_state, sample, window, condition.config.get("statistic").and_then(Value::as_str).unwrap_or("mean"), now.with_timezone(&Utc),
|
||||||
|
);
|
||||||
|
count = node_state.samples.len();
|
||||||
|
}
|
||||||
|
let expected = condition.config.get("value").and_then(Value::as_f64);
|
||||||
|
let matched = predecessors_match && aggregate.zip(expected).map(|(a,e)| flow_compare(a, condition.config.get("operator").and_then(Value::as_str).unwrap_or("lt"), e)).unwrap_or(false);
|
||||||
|
(matched, json!({"sample": sample, "aggregate": aggregate, "samples": count, "window_seconds": window, "statistic": condition.config.get("statistic")}))
|
||||||
|
}
|
||||||
|
"oscillates" => {
|
||||||
|
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 window = condition.config.get("window_seconds").and_then(Value::as_u64).unwrap_or(300);
|
||||||
|
let min_span = condition.config.get("min_span").and_then(Value::as_f64).unwrap_or(1.0);
|
||||||
|
let min_changes = condition.config.get("min_direction_changes").and_then(Value::as_u64).unwrap_or(2) as usize;
|
||||||
|
let cutoff = now.with_timezone(&Utc) - chrono::Duration::seconds(window as i64);
|
||||||
|
let mut count = 0usize;
|
||||||
|
let mut span = None;
|
||||||
|
let mut direction_changes = 0usize;
|
||||||
|
if let (Some(sample), Some(map)) = (sample, runtime.as_deref_mut()) {
|
||||||
|
let node_state = map.entry(condition.id.clone()).or_default();
|
||||||
|
node_state.samples.retain(|item| item.at >= cutoff);
|
||||||
|
node_state.samples.push(crate::models::FlowRuntimeSample { at: now.with_timezone(&Utc), value: sample });
|
||||||
|
count = node_state.samples.len();
|
||||||
|
if let Some((value_span, changes)) = flow_oscillation_metrics(&node_state.samples) {
|
||||||
|
span = Some(value_span);
|
||||||
|
direction_changes = changes;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let matched = predecessors_match
|
||||||
|
&& span.is_some_and(|value| value >= min_span)
|
||||||
|
&& direction_changes >= min_changes;
|
||||||
|
(matched, json!({"sample": sample, "samples": count, "window_seconds": window, "span": span, "min_span": min_span, "direction_changes": direction_changes, "min_direction_changes": min_changes}))
|
||||||
|
}
|
||||||
_ if flow_condition_kind_runtime(&condition.kind) => {
|
_ if flow_condition_kind_runtime(&condition.kind) => {
|
||||||
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 (leaf, actual) = flow_leaf_observation(state, devices, &zones, outdoor_temperature, condition, &now, &settings, overrides).await?;
|
let (leaf, actual) = flow_leaf_observation(state, devices, &zones, outdoor_temperature, condition, &now, &settings, overrides).await?;
|
||||||
@@ -524,14 +726,22 @@ pub async fn evaluate_flow_conditions_trace(
|
|||||||
Ok((final_id.and_then(|id| values.get(&id).copied()).unwrap_or(false), trace))
|
Ok((final_id.and_then(|id| values.get(&id).copied()).unwrap_or(false), trace))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn flow_conditions_match(state: &AppState, devices: &[Device], conditions: &[crate::models::FlowCondition]) -> Result<bool, AppError> {
|
async fn flow_conditions_match(state: &AppState, devices: &[Device], item: &mut Automation) -> Result<bool, AppError> {
|
||||||
let overrides = HashMap::new();
|
let overrides = HashMap::new();
|
||||||
evaluate_flow_conditions_trace(state, devices, conditions, Local::now(), &overrides).await.map(|(matched, _)| matched)
|
let conditions = item.flow_conditions.clone();
|
||||||
|
let now = Local::now();
|
||||||
|
let (matched, _) = evaluate_flow_conditions_trace(state, devices, &conditions, now.clone(), &overrides, Some(&mut item.flow_runtime)).await?;
|
||||||
|
if matched && conditions.iter().any(|condition| condition.kind == "cron_trigger") {
|
||||||
|
if let Some(last) = item.last_fired_at.as_ref().map(|value| value.with_timezone(&Local)) {
|
||||||
|
if last.year() == now.year() && last.ordinal() == now.ordinal() && last.hour() == now.hour() && last.minute() == now.minute() { return Ok(false); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(matched)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn flow_condition_kind_runtime(kind: &str) -> bool {
|
fn flow_condition_kind_runtime(kind: &str) -> bool {
|
||||||
matches!(kind,
|
matches!(kind,
|
||||||
"weekday" | "time_range" | "date_range" | "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"
|
||||||
)
|
)
|
||||||
|
|||||||
+58
-1
@@ -86,6 +86,63 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn flow_cron_validation_and_matching_cover_ranges_steps_and_sunday_alias() {
|
||||||
|
assert!(cron_expression_valid("*/5 * * * *"));
|
||||||
|
assert!(cron_expression_valid("0,15,30,45 6-18 * * 1-5"));
|
||||||
|
for invalid in ["* * * *", "*/0 * * * *", "61 * * * *", "* 24 * * *", "* * 0 * *", "* * * 13 *", "* * * * 8", "*/100 * * * *"] {
|
||||||
|
assert!(!cron_expression_valid(invalid), "accepted invalid cron: {invalid}");
|
||||||
|
}
|
||||||
|
let monday = Local.with_ymd_and_hms(2026, 9, 7, 10, 15, 0).single().unwrap();
|
||||||
|
assert!(cron_matches("15 10 * * 1", &monday));
|
||||||
|
assert!(cron_matches("15 10 * * 1-7", &monday));
|
||||||
|
assert!(!cron_matches("16 10 * * 1", &monday));
|
||||||
|
let sunday = Local.with_ymd_and_hms(2026, 9, 6, 8, 0, 0).single().unwrap();
|
||||||
|
assert!(cron_matches("0 8 * * 0", &sunday));
|
||||||
|
assert!(cron_matches("0 8 * * 7", &sunday));
|
||||||
|
assert!(cron_matches("0 8 * * 1-7", &sunday));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn flow_timed_gate_requires_continuity_and_resets_on_false() {
|
||||||
|
let mut state = crate::models::FlowRuntimeNodeState::default();
|
||||||
|
let start = Utc.with_ymd_and_hms(2026, 9, 2, 12, 0, 0).unwrap();
|
||||||
|
assert!(!flow_timed_gate_update(&mut state, true, 30, start));
|
||||||
|
assert!(!flow_timed_gate_update(&mut state, true, 30, start + chrono::Duration::seconds(29)));
|
||||||
|
assert!(flow_timed_gate_update(&mut state, true, 30, start + chrono::Duration::seconds(30)));
|
||||||
|
assert!(!flow_timed_gate_update(&mut state, false, 30, start + chrono::Duration::seconds(31)));
|
||||||
|
assert!(state.since.is_none());
|
||||||
|
assert!(!flow_timed_gate_update(&mut state, true, 30, start + chrono::Duration::seconds(32)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn flow_rolling_statistics_compute_mean_median_and_prune_window() {
|
||||||
|
let mut state = crate::models::FlowRuntimeNodeState::default();
|
||||||
|
let start = Utc.with_ymd_and_hms(2026, 9, 2, 12, 0, 0).unwrap();
|
||||||
|
assert_eq!(flow_rolling_stat_update(&mut state, 10.0, 60, "mean", start), Some(10.0));
|
||||||
|
assert_eq!(flow_rolling_stat_update(&mut state, 20.0, 60, "mean", start + chrono::Duration::seconds(10)), Some(15.0));
|
||||||
|
assert_eq!(flow_rolling_stat_update(&mut state, 30.0, 60, "median", start + chrono::Duration::seconds(20)), Some(20.0));
|
||||||
|
assert_eq!(flow_rolling_stat_update(&mut state, 40.0, 60, "median", start + chrono::Duration::seconds(30)), Some(25.0));
|
||||||
|
assert_eq!(flow_rolling_stat_update(&mut state, 100.0, 60, "mean", start + chrono::Duration::seconds(100)), Some(100.0));
|
||||||
|
assert_eq!(state.samples.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn flow_oscillation_requires_direction_changes_not_only_large_span() {
|
||||||
|
let start = Utc.with_ymd_and_hms(2026, 9, 2, 12, 0, 0).unwrap();
|
||||||
|
let bouncing = [20.0, 22.0, 19.0, 23.0, 20.0].into_iter().enumerate().map(|(index, value)| crate::models::FlowRuntimeSample {
|
||||||
|
at: start + chrono::Duration::seconds(index as i64 * 10), value,
|
||||||
|
}).collect::<Vec<_>>();
|
||||||
|
let monotonic = [20.0, 21.0, 22.0, 23.0, 24.0].into_iter().enumerate().map(|(index, value)| crate::models::FlowRuntimeSample {
|
||||||
|
at: start + chrono::Duration::seconds(index as i64 * 10), value,
|
||||||
|
}).collect::<Vec<_>>();
|
||||||
|
let (span, changes) = flow_oscillation_metrics(&bouncing).unwrap();
|
||||||
|
assert!(span >= 4.0);
|
||||||
|
assert!(changes >= 2);
|
||||||
|
let (_, monotonic_changes) = flow_oscillation_metrics(&monotonic).unwrap();
|
||||||
|
assert_eq!(monotonic_changes, 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.with_ymd_and_hms(2025, 1, 6, 10, 15, 40).single().unwrap();
|
let now = Local.with_ymd_and_hms(2025, 1, 6, 10, 15, 40).single().unwrap();
|
||||||
@@ -94,7 +151,7 @@ mod tests {
|
|||||||
trigger_device_id: None, threshold: None, at_time: Some("10:15".into()),
|
trigger_device_id: None, threshold: None, at_time: Some("10:15".into()),
|
||||||
action_device_id: "d".into(), action_group_id: None, action_preset: None,
|
action_device_id: "d".into(), action_group_id: None, action_preset: None,
|
||||||
action: DeviceCommand { power: Some(true), ..Default::default() }, cooldown_seconds: 30,
|
action: DeviceCommand { power: Some(true), ..Default::default() }, cooldown_seconds: 30,
|
||||||
last_fired_at: None, action_zone_id: None, action_zone_preset: None, flow_conditions: vec![], flow_id: None, flow_node_id: None, created_at: Utc::now(), updated_at: Utc::now(),
|
last_fired_at: None, action_zone_id: None, action_zone_preset: None, action_ha_domain: None, action_ha_service: None, action_ha_entity_id: None, action_ha_data: Value::Null, flow_conditions: vec![], flow_id: None, flow_node_id: None, flow_runtime: Default::default(), created_at: Utc::now(), updated_at: Utc::now(),
|
||||||
};
|
};
|
||||||
assert!(time_automation_due(&item, now.clone()));
|
assert!(time_automation_due(&item, now.clone()));
|
||||||
item.last_fired_at = Some((now.clone() - chrono::Duration::seconds(35)).with_timezone(&Utc));
|
item.last_fired_at = Some((now.clone() - chrono::Duration::seconds(35)).with_timezone(&Utc));
|
||||||
|
|||||||
@@ -145,3 +145,38 @@ pub async fn read_state(
|
|||||||
let payload = read_entity(default_client, settings, entity_override).await?;
|
let payload = read_entity(default_client, settings, entity_override).await?;
|
||||||
payload.get("state").and_then(Value::as_str).map(str::to_string).ok_or_else(|| anyhow!("Home Assistant state is missing"))
|
payload.get("state").and_then(Value::as_str).map(str::to_string).ok_or_else(|| anyhow!("Home Assistant state is missing"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// Call a Home Assistant service from a Flow action. The domain/service pair is explicit
|
||||||
|
/// and the payload is sent as JSON. Entity targeting is normalized through entity_id.
|
||||||
|
pub async fn call_service(
|
||||||
|
default_client: &reqwest::Client,
|
||||||
|
settings: &HomeAssistantSettings,
|
||||||
|
domain: &str,
|
||||||
|
service: &str,
|
||||||
|
entity_id: Option<&str>,
|
||||||
|
data: &Value,
|
||||||
|
) -> Result<Value> {
|
||||||
|
if settings.url.trim().is_empty() { bail!("Home Assistant URL is not configured") }
|
||||||
|
if settings.token.trim().is_empty() { bail!("Home Assistant token is not configured") }
|
||||||
|
if domain.trim().is_empty() || service.trim().is_empty() { bail!("Home Assistant domain/service is required") }
|
||||||
|
let mut base = Url::parse(settings.url.trim()).context("invalid Home Assistant URL")?;
|
||||||
|
if !matches!(base.scheme(), "http" | "https") { bail!("Home Assistant URL must use http or https") }
|
||||||
|
base = base.join(&format!("api/services/{}/{}", domain.trim(), service.trim())).context("cannot build Home Assistant service URL")?;
|
||||||
|
let mut payload = data.as_object().cloned().unwrap_or_default();
|
||||||
|
if let Some(entity) = entity_id.map(str::trim).filter(|v| !v.is_empty()) {
|
||||||
|
payload.insert("entity_id".into(), Value::String(entity.to_string()));
|
||||||
|
}
|
||||||
|
let client = request_client(default_client, settings)?;
|
||||||
|
let response = client.post(base).bearer_auth(settings.token.trim()).header("Accept", "application/json")
|
||||||
|
.json(&Value::Object(payload)).send().await.context("Home Assistant service request failed")?;
|
||||||
|
if !response.status().is_success() {
|
||||||
|
let status = response.status();
|
||||||
|
let body = response.text().await.unwrap_or_default();
|
||||||
|
bail!("Home Assistant returned {status}: {}", body.chars().take(200).collect::<String>())
|
||||||
|
}
|
||||||
|
match response.json::<Value>().await {
|
||||||
|
Ok(value) => Ok(value),
|
||||||
|
Err(_) => Ok(Value::Null),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -25,6 +25,21 @@ pub struct Schedule {
|
|||||||
pub flow_node_id: Option<String>,
|
pub flow_node_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
|
||||||
|
pub struct FlowRuntimeNodeState {
|
||||||
|
#[serde(default)]
|
||||||
|
pub since: Option<DateTime<Utc>>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub samples: Vec<FlowRuntimeSample>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct FlowRuntimeSample {
|
||||||
|
pub at: DateTime<Utc>,
|
||||||
|
pub value: f64,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct Automation {
|
pub struct Automation {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
@@ -59,6 +74,14 @@ pub struct Automation {
|
|||||||
pub action_zone_id: Option<String>,
|
pub action_zone_id: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub action_zone_preset: Option<String>,
|
pub action_zone_preset: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub action_ha_domain: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub action_ha_service: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub action_ha_entity_id: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub action_ha_data: Value,
|
||||||
/// 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>,
|
||||||
@@ -66,6 +89,9 @@ pub struct Automation {
|
|||||||
pub flow_id: Option<String>,
|
pub flow_id: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub flow_node_id: Option<String>,
|
pub flow_node_id: Option<String>,
|
||||||
|
/// Durable state for stateful Flow blocks (stable-for and rolling statistics).
|
||||||
|
#[serde(default)]
|
||||||
|
pub flow_runtime: BTreeMap<String, FlowRuntimeNodeState>,
|
||||||
pub created_at: DateTime<Utc>,
|
pub created_at: DateTime<Utc>,
|
||||||
pub updated_at: DateTime<Utc>,
|
pub updated_at: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-5
@@ -699,13 +699,19 @@
|
|||||||
<div class="flow-editor-body">
|
<div class="flow-editor-body">
|
||||||
<aside class="flow-palette">
|
<aside class="flow-palette">
|
||||||
<div class="flow-palette-head"><strong data-i18n="flow.blocks">Blocks</strong><small data-i18n="flow.paletteHint">Add a block, then connect its output to the next block.</small></div>
|
<div class="flow-palette-head"><strong data-i18n="flow.blocks">Blocks</strong><small data-i18n="flow.paletteHint">Add a block, then connect its output to the next block.</small></div>
|
||||||
<div class="flow-palette-group"><span data-i18n="flow.time">Time</span>
|
<div class="flow-palette-group flow-palette-trigger"><span data-i18n="flow.triggers">Wyzwalacze</span>
|
||||||
|
<button type="button" data-flow-add="cron_trigger" data-i18n="flow.node.cronTrigger">CRON</button></div>
|
||||||
|
<div class="flow-palette-group flow-palette-time"><span data-i18n="flow.time">Czas</span>
|
||||||
<button type="button" data-flow-add="weekday" data-i18n="flow.node.weekday">Dni tygodnia</button><button type="button" data-flow-add="time_range" data-i18n="flow.node.timeRange">Przedział godzin</button><button type="button" data-flow-add="date_range" data-i18n="flow.node.dateRange">Zakres dat</button><button type="button" data-flow-add="night_mode" data-i18n="flow.node.nightMode">Tryb nocny</button></div>
|
<button type="button" data-flow-add="weekday" data-i18n="flow.node.weekday">Dni tygodnia</button><button type="button" data-flow-add="time_range" data-i18n="flow.node.timeRange">Przedział godzin</button><button type="button" data-flow-add="date_range" data-i18n="flow.node.dateRange">Zakres dat</button><button type="button" data-flow-add="night_mode" data-i18n="flow.node.nightMode">Tryb nocny</button></div>
|
||||||
<div class="flow-palette-group"><span data-i18n="flow.sensors">Sensors</span>
|
<div class="flow-palette-group flow-palette-timeop"><span data-i18n="flow.timeOps">Operacje czasu</span>
|
||||||
<button type="button" data-flow-add="outdoor_temperature" data-i18n="flow.node.outdoorTemperature">Temperatura zewn.</button><button type="button" data-flow-add="device_temperature" 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 type="button" data-flow-add="house_mode" data-i18n="flow.node.houseMode">Tryb domu</button><button type="button" data-flow-add="device_state" data-i18n="flow.node.deviceState">Stan urządzenia</button><button type="button" data-flow-add="zone_state" data-i18n="flow.node.zoneState">Stan termostatu</button><button type="button" data-flow-add="group_state" data-i18n="flow.node.groupState">Stan grupy</button><button type="button" data-flow-add="ha_state" data-i18n="flow.node.haState">HA: stan</button><button type="button" data-flow-add="ha_numeric" data-i18n="flow.node.haNumeric">HA: wartość</button><button type="button" data-flow-add="ha_attribute" data-i18n="flow.node.haAttribute">HA: atrybut</button><button type="button" data-flow-add="ha_available" data-i18n="flow.node.haAvailable">HA: dostępność</button><button type="button" data-flow-add="constant" data-i18n="flow.node.constant">Stała testowa</button><button type="button" data-flow-add="shared_input" data-i18n="flow.node.sharedInput">Wspólne wejście</button></div>
|
<button type="button" data-flow-add="stable_for" data-i18n="flow.node.stableFor">Utrzymuje się przez…</button><button type="button" data-flow-add="delay" data-i18n="flow.node.delay">Odczekaj…</button></div>
|
||||||
<div class="flow-palette-group"><span data-i18n="flow.logic">Logic</span><button type="button" data-flow-add="logic_and" data-i18n="flow.node.and">AND</button><button type="button" data-flow-add="logic_or" data-i18n="flow.node.or">OR</button><button type="button" data-flow-add="logic_not" data-i18n="flow.node.not">NOT</button></div>
|
<div class="flow-palette-group flow-palette-sensor"><span data-i18n="flow.sensors">Sensory / wartości</span>
|
||||||
<div class="flow-palette-group"><span data-i18n="flow.actions">Actions</span>
|
<button type="button" data-flow-add="outdoor_temperature" data-i18n="flow.node.outdoorTemperature">Temperatura zewn.</button><button type="button" data-flow-add="device_temperature" 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 type="button" data-flow-add="house_mode" data-i18n="flow.node.houseMode">Tryb domu</button><button type="button" data-flow-add="device_state" data-i18n="flow.node.deviceState">Stan urządzenia</button><button type="button" data-flow-add="zone_state" data-i18n="flow.node.zoneState">Stan termostatu</button><button type="button" data-flow-add="group_state" data-i18n="flow.node.groupState">Stan grupy</button><button type="button" data-flow-add="ha_state" data-i18n="flow.node.haState">HA: stan</button><button type="button" data-flow-add="ha_numeric" data-i18n="flow.node.haNumeric">HA: wartość</button><button type="button" data-flow-add="ha_attribute" data-i18n="flow.node.haAttribute">HA: atrybut</button><button type="button" data-flow-add="ha_available" data-i18n="flow.node.haAvailable">HA: dostępność</button><button type="button" data-flow-add="constant" data-i18n="flow.node.constant">Stała testowa</button><button type="button" data-flow-add="shared_input" data-i18n="flow.node.sharedInput">Wspólne wejście</button><button type="button" data-flow-add="rolling_stat" data-i18n="flow.node.rollingStat">Średnia / mediana</button><button type="button" data-flow-add="oscillates" data-i18n="flow.node.oscillates">Wartość oscyluje</button></div>
|
||||||
|
<div class="flow-palette-group flow-palette-logic"><span data-i18n="flow.logic">Logika</span><button type="button" data-flow-add="logic_and" data-i18n="flow.node.and">AND</button><button type="button" data-flow-add="logic_or" data-i18n="flow.node.or">OR</button><button type="button" data-flow-add="logic_not" data-i18n="flow.node.not">NOT</button></div>
|
||||||
|
<div class="flow-palette-group flow-palette-action"><span data-i18n="flow.actions">Akcje</span>
|
||||||
<button type="button" data-flow-add="zone_thermostat" data-i18n="flow.node.thermostat">Termostat</button><button type="button" data-flow-add="device_action" data-i18n="flow.node.greeDevice">Urządzenie GREE</button><button type="button" data-flow-add="group_action" data-i18n="flow.node.group">Grupa</button></div>
|
<button type="button" data-flow-add="zone_thermostat" data-i18n="flow.node.thermostat">Termostat</button><button type="button" data-flow-add="device_action" data-i18n="flow.node.greeDevice">Urządzenie GREE</button><button type="button" data-flow-add="group_action" data-i18n="flow.node.group">Grupa</button></div>
|
||||||
|
<div class="flow-palette-group flow-palette-haaction"><span data-i18n="flow.haActions">Home Assistant</span>
|
||||||
|
<button type="button" data-flow-add="ha_service_action" data-i18n="flow.node.haServiceAction">Home Assistant: usługa</button></div>
|
||||||
</aside>
|
</aside>
|
||||||
<div class="flow-workspace-wrap">
|
<div class="flow-workspace-wrap">
|
||||||
<div class="flow-workspace-toolbar"><button type="button" class="secondary" data-action="flow-select-all" data-i18n="flow.selectAll">Zaznacz wszystko</button><button type="button" class="secondary" data-action="flow-clear-selection" data-i18n="flow.clearSelection">Wyczyść zaznaczenie</button><span id="flowSelectionCount" class="badge"></span></div>
|
<div class="flow-workspace-toolbar"><button type="button" class="secondary" data-action="flow-select-all" data-i18n="flow.selectAll">Zaznacz wszystko</button><button type="button" class="secondary" data-action="flow-clear-selection" data-i18n="flow.clearSelection">Wyczyść zaznaczenie</button><span id="flowSelectionCount" class="badge"></span></div>
|
||||||
|
|||||||
+34
-3
@@ -2,6 +2,11 @@ const FLOW_NODE_META = Object.freeze({
|
|||||||
weekday: { titleKey: 'flow.node.weekday', category: 'time' },
|
weekday: { titleKey: 'flow.node.weekday', category: 'time' },
|
||||||
time_range: { titleKey: 'flow.node.timeRange', category: 'time' },
|
time_range: { titleKey: 'flow.node.timeRange', category: 'time' },
|
||||||
date_range: { titleKey: 'flow.node.dateRange', category: 'time' },
|
date_range: { titleKey: 'flow.node.dateRange', category: 'time' },
|
||||||
|
cron_trigger: { titleKey: 'flow.node.cronTrigger', category: 'trigger' },
|
||||||
|
stable_for: { titleKey: 'flow.node.stableFor', category: 'timeop' },
|
||||||
|
delay: { titleKey: 'flow.node.delay', category: 'timeop' },
|
||||||
|
rolling_stat: { titleKey: 'flow.node.rollingStat', category: 'sensor' },
|
||||||
|
oscillates: { titleKey: 'flow.node.oscillates', 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' },
|
||||||
@@ -22,6 +27,7 @@ const FLOW_NODE_META = Object.freeze({
|
|||||||
zone_thermostat: { titleKey: 'flow.node.thermostat', category: 'action' },
|
zone_thermostat: { titleKey: 'flow.node.thermostat', category: 'action' },
|
||||||
device_action: { titleKey: 'flow.node.greeDevice', category: 'action' },
|
device_action: { titleKey: 'flow.node.greeDevice', category: 'action' },
|
||||||
group_action: { titleKey: 'flow.node.group', category: 'action' },
|
group_action: { titleKey: 'flow.node.group', category: 'action' },
|
||||||
|
ha_service_action: { titleKey: 'flow.node.haServiceAction', category: 'haaction' },
|
||||||
});
|
});
|
||||||
|
|
||||||
function newFlowId(prefix = 'node') {
|
function newFlowId(prefix = 'node') {
|
||||||
@@ -56,6 +62,11 @@ function flowDefaultConfig(kind) {
|
|||||||
if (kind === 'weekday') return { days: [1, 2, 3, 4, 5] };
|
if (kind === 'weekday') return { days: [1, 2, 3, 4, 5] };
|
||||||
if (kind === 'time_range') return { start: '06:00', end: '08:00' };
|
if (kind === 'time_range') return { start: '06:00', end: '08:00' };
|
||||||
if (kind === 'date_range') { const today = new Date().toISOString().slice(0, 10); return { start: today, end: today }; }
|
if (kind === 'date_range') { const today = new Date().toISOString().slice(0, 10); return { start: today, end: today }; }
|
||||||
|
if (kind === 'cron_trigger') return { expression: '*/5 * * * *' };
|
||||||
|
if (kind === 'stable_for') return { seconds: 180 };
|
||||||
|
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 === '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 === '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 };
|
||||||
@@ -73,6 +84,7 @@ function flowDefaultConfig(kind) {
|
|||||||
if (kind === 'zone_thermostat') return { zone_id: app.zones[0]?.id || '', preset: 'comfort', setpoint: 21, mode: 'auto', cooldown_seconds: 60 };
|
if (kind === 'zone_thermostat') return { zone_id: app.zones[0]?.id || '', preset: 'comfort', setpoint: 21, mode: 'auto', cooldown_seconds: 60 };
|
||||||
if (kind === 'device_action') return { device_id: app.devices[0]?.id || '', power: true, mode: '', target_temperature: null, fan_speed: null, swing_vertical: null, swing_horizontal: null, quiet: null, turbo: null, light: null, air: null, xfan: null, health: null, sleep: null, cooldown_seconds: 60 };
|
if (kind === 'device_action') return { device_id: app.devices[0]?.id || '', power: true, mode: '', target_temperature: null, fan_speed: null, swing_vertical: null, swing_horizontal: null, quiet: null, turbo: null, light: null, air: null, xfan: null, health: null, sleep: null, cooldown_seconds: 60 };
|
||||||
if (kind === 'group_action') return { group_id: app.groups[0]?.id || '', power: true, mode: '', preset: '', setpoint: 21, cooldown_seconds: 60 };
|
if (kind === 'group_action') return { group_id: app.groups[0]?.id || '', power: true, mode: '', preset: '', setpoint: 21, cooldown_seconds: 60 };
|
||||||
|
if (kind === 'ha_service_action') return { domain: 'switch', service: 'turn_on', entity_id: '', data: {}, cooldown_seconds: 60 };
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -246,6 +258,11 @@ function flowNodeSummary(node) {
|
|||||||
if (node.kind === 'weekday') return (c.days || []).map(day => tr(`day.${day}`)).join(', ') || '—';
|
if (node.kind === 'weekday') return (c.days || []).map(day => tr(`day.${day}`)).join(', ') || '—';
|
||||||
if (node.kind === 'time_range') return `${c.start || '—'}–${c.end || '—'}`;
|
if (node.kind === 'time_range') return `${c.start || '—'}–${c.end || '—'}`;
|
||||||
if (node.kind === 'date_range') return `${c.start || '—'} → ${c.end || '—'}`;
|
if (node.kind === 'date_range') return `${c.start || '—'} → ${c.end || '—'}`;
|
||||||
|
if (node.kind === 'cron_trigger') return c.expression || '—';
|
||||||
|
if (node.kind === 'stable_for') 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 === 'oscillates') return `${flowDurationLabel(c.window_seconds)} · Δ≥${Number(c.min_span ?? 1)} · ↕≥${Number(c.min_direction_changes ?? 2)}`;
|
||||||
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)}`;
|
||||||
@@ -275,11 +292,13 @@ function flowNodeSummary(node) {
|
|||||||
if (node.kind === 'zone_thermostat') { const zone = app.zones.find(z => z.id === c.zone_id); return `${zone?.name || tr('common.noZone')} · ${c.preset === 'custom' ? fmtTemp(c.setpoint) : zonePresetLabel(c.preset || 'comfort')}`; }
|
if (node.kind === 'zone_thermostat') { const zone = app.zones.find(z => z.id === c.zone_id); return `${zone?.name || tr('common.noZone')} · ${c.preset === 'custom' ? fmtTemp(c.setpoint) : zonePresetLabel(c.preset || 'comfort')}`; }
|
||||||
if (node.kind === 'device_action') { const d = app.devices.find(v => v.id === c.device_id); return `${d?.name || tr('common.noDevice')} · ${c.power == null ? tr('actions.noChange') : c.power ? tr('common.on') : tr('common.off')}`; }
|
if (node.kind === 'device_action') { const d = app.devices.find(v => v.id === c.device_id); return `${d?.name || tr('common.noDevice')} · ${c.power == null ? tr('actions.noChange') : c.power ? tr('common.on') : tr('common.off')}`; }
|
||||||
if (node.kind === 'group_action') { const g = app.groups.find(v => v.id === c.group_id); return `${g?.name || tr('groups.group')} · ${c.power == null ? tr('actions.noChange') : c.power ? tr('common.on') : tr('common.off')}`; }
|
if (node.kind === 'group_action') { const g = app.groups.find(v => v.id === c.group_id); return `${g?.name || tr('groups.group')} · ${c.power == null ? tr('actions.noChange') : c.power ? tr('common.on') : tr('common.off')}`; }
|
||||||
|
if (node.kind === 'ha_service_action') return `${c.domain || 'domain'}.${c.service || 'service'} · ${c.entity_id || 'entity_id'}`;
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function flowNodeTitle(meta) { return meta?.titleKey ? tr(meta.titleKey) : (meta?.title || ''); }
|
function flowNodeTitle(meta) { return meta?.titleKey ? tr(meta.titleKey) : (meta?.title || ''); }
|
||||||
function flowOperatorLabel(op) { return ({ lt: '<', lte: '≤', gt: '>', gte: '≥', eq: '=', neq: '≠' })[op] || '<'; }
|
function flowOperatorLabel(op) { return ({ lt: '<', lte: '≤', gt: '>', gte: '≥', eq: '=', neq: '≠' })[op] || '<'; }
|
||||||
|
function flowDurationLabel(seconds) { const value = Number(seconds || 0); return value >= 60 && value % 60 === 0 ? `${value / 60} min` : `${value} s`; }
|
||||||
function flowNodeById(id) { return app.flowDraft?.nodes?.find(node => node.id === id); }
|
function flowNodeById(id) { return app.flowDraft?.nodes?.find(node => node.id === id); }
|
||||||
|
|
||||||
function renderFlowEditor() {
|
function renderFlowEditor() {
|
||||||
@@ -342,6 +361,11 @@ function renderFlowInspector() {
|
|||||||
if (node.kind === 'weekday') fields = `<div class="flow-day-grid">${[1,2,3,4,5,6,7].map(day => `<label><input type="checkbox" data-flow-config="days" value="${day}" ${(c.days || []).includes(day) ? 'checked' : ''}><span>${esc(tr(`day.${day}`))}</span></label>`).join('')}</div>`;
|
if (node.kind === 'weekday') fields = `<div class="flow-day-grid">${[1,2,3,4,5,6,7].map(day => `<label><input type="checkbox" data-flow-config="days" value="${day}" ${(c.days || []).includes(day) ? 'checked' : ''}><span>${esc(tr(`day.${day}`))}</span></label>`).join('')}</div>`;
|
||||||
else if (node.kind === 'time_range') fields = `<div class="two"><label><span>${esc(tr('common.from'))}</span><input type="time" data-flow-config="start" value="${esc(c.start || '06:00')}"></label><label><span>${esc(tr('common.to'))}</span><input type="time" data-flow-config="end" value="${esc(c.end || '08:00')}"></label></div>`;
|
else if (node.kind === 'time_range') fields = `<div class="two"><label><span>${esc(tr('common.from'))}</span><input type="time" data-flow-config="start" value="${esc(c.start || '06:00')}"></label><label><span>${esc(tr('common.to'))}</span><input type="time" data-flow-config="end" value="${esc(c.end || '08:00')}"></label></div>`;
|
||||||
else if (node.kind === 'date_range') fields = `<div class="two"><label><span>${esc(tr('common.from'))}</span><input type="date" data-flow-config="start" value="${esc(c.start || '')}"></label><label><span>${esc(tr('common.to'))}</span><input type="date" data-flow-config="end" value="${esc(c.end || '')}"></label></div>`;
|
else if (node.kind === 'date_range') fields = `<div class="two"><label><span>${esc(tr('common.from'))}</span><input type="date" data-flow-config="start" value="${esc(c.start || '')}"></label><label><span>${esc(tr('common.to'))}</span><input type="date" data-flow-config="end" value="${esc(c.end || '')}"></label></div>`;
|
||||||
|
else if (node.kind === 'cron_trigger') fields = `<label><span>CRON</span><input data-flow-config="expression" value="${esc(c.expression || '*/5 * * * *')}" placeholder="*/5 * * * *"></label><p class="field-note">${esc(tr('flow.cronHint'))}</p>`;
|
||||||
|
else if (node.kind === 'stable_for') fields = `<label><span>${esc(tr('flow.durationSeconds'))}</span><input type="number" min="1" max="604800" data-flow-config="seconds" value="${Number(c.seconds || 180)}"></label><p class="field-note">${esc(tr('flow.stableForHint'))}</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" 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" 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 === '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)}`;
|
||||||
@@ -367,6 +391,7 @@ function renderFlowInspector() {
|
|||||||
else if (node.kind === 'zone_thermostat') fields = `<label><span>${esc(tr('common.zone'))}</span><select data-flow-config="zone_id">${flowSelectOptions(app.zones, c.zone_id)}</select></label><div class="two"><label><span>${esc(tr('schedules.profile'))}</span><select data-flow-config="preset">${['auto','comfort','sleep','away','custom'].map(v => `<option value="${v}" ${c.preset === v ? 'selected' : ''}>${esc(zonePresetLabel(v))}</option>`).join('')}</select></label><label><span>${esc(tr('common.targetC'))}</span><input type="number" min="8" max="30" step="0.5" data-flow-config="setpoint" value="${Number(c.setpoint ?? 21)}"></label></div><div class="two"><label><span>${esc(tr('common.mode'))}</span><select data-flow-config="mode"><option value="auto" ${c.mode === 'auto' ? 'selected' : ''}>Auto</option><option value="heat" ${c.mode === 'heat' ? 'selected' : ''}>${esc(tr('mode.heat'))}</option><option value="cool" ${c.mode === 'cool' ? 'selected' : ''}>${esc(tr('mode.cool'))}</option></select></label><label><span>${esc(tr('common.power'))}</span><select data-flow-config="power"><option value="" ${c.power == null ? 'selected' : ''}>${esc(tr('actions.noChange'))}</option><option value="true" ${c.power === true ? 'selected' : ''}>${esc(tr('common.on'))}</option><option value="false" ${c.power === false ? 'selected' : ''}>${esc(tr('common.off'))}</option></select></label></div><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 === 'zone_thermostat') fields = `<label><span>${esc(tr('common.zone'))}</span><select data-flow-config="zone_id">${flowSelectOptions(app.zones, c.zone_id)}</select></label><div class="two"><label><span>${esc(tr('schedules.profile'))}</span><select data-flow-config="preset">${['auto','comfort','sleep','away','custom'].map(v => `<option value="${v}" ${c.preset === v ? 'selected' : ''}>${esc(zonePresetLabel(v))}</option>`).join('')}</select></label><label><span>${esc(tr('common.targetC'))}</span><input type="number" min="8" max="30" step="0.5" data-flow-config="setpoint" value="${Number(c.setpoint ?? 21)}"></label></div><div class="two"><label><span>${esc(tr('common.mode'))}</span><select data-flow-config="mode"><option value="auto" ${c.mode === 'auto' ? 'selected' : ''}>Auto</option><option value="heat" ${c.mode === 'heat' ? 'selected' : ''}>${esc(tr('mode.heat'))}</option><option value="cool" ${c.mode === 'cool' ? 'selected' : ''}>${esc(tr('mode.cool'))}</option></select></label><label><span>${esc(tr('common.power'))}</span><select data-flow-config="power"><option value="" ${c.power == null ? 'selected' : ''}>${esc(tr('actions.noChange'))}</option><option value="true" ${c.power === true ? 'selected' : ''}>${esc(tr('common.on'))}</option><option value="false" ${c.power === false ? 'selected' : ''}>${esc(tr('common.off'))}</option></select></label></div><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_action') fields = `<label><span>${esc(tr('common.device'))}</span><select data-flow-config="device_id">${flowSelectOptions(app.devices, c.device_id)}</select></label>${flowActionFields(c, false)}`;
|
else if (node.kind === 'device_action') fields = `<label><span>${esc(tr('common.device'))}</span><select data-flow-config="device_id">${flowSelectOptions(app.devices, c.device_id)}</select></label>${flowActionFields(c, false)}`;
|
||||||
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><label><span>entity_id</span><input data-flow-config="entity_id" 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>`;
|
||||||
host.innerHTML = `<div class="flow-inspector-head"><div><span class="eyebrow">${esc(tr('flow.blockSettings'))}</span><h3>${esc(flowNodeTitle(meta))}</h3></div><button type="button" class="danger" data-flow-remove="${esc(node.id)}">${esc(tr('actions.delete'))}</button></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><button type="button" class="danger" data-flow-remove="${esc(node.id)}">${esc(tr('actions.delete'))}</button></div>${fields}<div class="flow-inspector-meta"><small>ID</small><code>${esc(node.id)}</code></div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -407,7 +432,7 @@ function flowExpressionSummary(actionId) {
|
|||||||
|
|
||||||
function renderFlowInterpretation() {
|
function renderFlowInterpretation() {
|
||||||
const target = $('#flowInterpretation'); if (!target || !app.flowDraft) return;
|
const target = $('#flowInterpretation'); if (!target || !app.flowDraft) return;
|
||||||
const actions = app.flowDraft.nodes.filter(node => FLOW_NODE_META[node.kind]?.category === 'action');
|
const actions = app.flowDraft.nodes.filter(node => ['action','haaction'].includes(FLOW_NODE_META[node.kind]?.category));
|
||||||
if (!actions.length) { target.textContent = tr('flow.noActionsYet'); return; }
|
if (!actions.length) { target.textContent = tr('flow.noActionsYet'); return; }
|
||||||
target.textContent = actions.map(action => `${flowExpressionSummary(action.id)} → ${flowNodeTitle(FLOW_NODE_META[action.kind])}: ${flowNodeSummary(action)}`).join(' · ');
|
target.textContent = actions.map(action => `${flowExpressionSummary(action.id)} → ${flowNodeTitle(FLOW_NODE_META[action.kind])}: ${flowNodeSummary(action)}`).join(' · ');
|
||||||
}
|
}
|
||||||
@@ -596,7 +621,7 @@ function flowPresetRequirements(preset) {
|
|||||||
else if (value && typeof value === 'object') Object.values(value).forEach(visit);
|
else if (value && typeof value === 'object') Object.values(value).forEach(visit);
|
||||||
};
|
};
|
||||||
nodes.forEach(node => visit(node.config || {}));
|
nodes.forEach(node => visit(node.config || {}));
|
||||||
const hasHa = nodes.some(node => ['ha_state','ha_numeric','ha_attribute','ha_available'].includes(node.kind));
|
const hasHa = nodes.some(node => ['ha_state','ha_numeric','ha_attribute','ha_available','ha_service_action'].includes(node.kind));
|
||||||
const haEntities = [...new Set(nodes.map(node => node.config?.entity_id).filter(value => typeof value === 'string' && value && !value.startsWith('$')))];
|
const haEntities = [...new Set(nodes.map(node => node.config?.entity_id).filter(value => typeof value === 'string' && value && !value.startsWith('$')))];
|
||||||
const requirements = [];
|
const requirements = [];
|
||||||
const missing = [];
|
const missing = [];
|
||||||
@@ -894,7 +919,13 @@ function updateFlowConfig(input) {
|
|||||||
}
|
}
|
||||||
else if (['power','swing_vertical','swing_horizontal','quiet','turbo','light','air','xfan','health','sleep'].includes(key)) node.config[key] = input.value === '' ? null : input.value === 'true';
|
else if (['power','swing_vertical','swing_horizontal','quiet','turbo','light','air','xfan','health','sleep'].includes(key)) node.config[key] = input.value === '' ? null : input.value === 'true';
|
||||||
else if (key === 'value' && node.kind === 'constant') node.config[key] = input.value === 'true';
|
else if (key === 'value' && node.kind === 'constant') node.config[key] = input.value === 'true';
|
||||||
else if (['value','setpoint','target_temperature','cooldown_seconds','fan_speed'].includes(key) && ['outdoor_temperature','device_temperature','zone_temperature','ha_numeric','zone_thermostat','device_action','group_action'].includes(node.kind)) node.config[key] = input.value === '' ? null : Number(input.value);
|
else if (key === 'data_json' && node.kind === 'ha_service_action') { try { node.config.data = JSON.parse(input.value || '{}'); } catch { toast(tr('flow.invalidJson'), true); return; } }
|
||||||
|
else if (key === 'seconds' && ['stable_for','delay'].includes(node.kind)) node.config.seconds = Math.max(1, Number(input.value || 1));
|
||||||
|
else if (key === 'window_seconds' && ['rolling_stat','oscillates'].includes(node.kind)) node.config.window_seconds = Math.max(10, Number(input.value || 10));
|
||||||
|
else if (key === 'value' && node.kind === 'rolling_stat') node.config.value = Number(input.value || 0);
|
||||||
|
else if (key === 'min_span' && node.kind === 'oscillates') node.config.min_span = Math.max(0.001, Number(input.value || 0.001));
|
||||||
|
else if (key === 'min_direction_changes' && node.kind === 'oscillates') node.config.min_direction_changes = Math.max(1, Math.floor(Number(input.value || 1)));
|
||||||
|
else if (['value','setpoint','target_temperature','cooldown_seconds','fan_speed'].includes(key) && ['outdoor_temperature','device_temperature','zone_temperature','ha_numeric','zone_thermostat','device_action','group_action','ha_service_action'].includes(node.kind)) node.config[key] = input.value === '' ? null : Number(input.value);
|
||||||
else node.config[key] = input.value;
|
else node.config[key] = input.value;
|
||||||
app.flowDirty = true; renderFlowEditor();
|
app.flowDirty = true; renderFlowEditor();
|
||||||
}
|
}
|
||||||
|
|||||||
+20
-3
@@ -6250,7 +6250,7 @@ body.flow-editor-open { overflow:hidden; }
|
|||||||
.flow-test-summary,.flow-test-action,.flow-log-row { border:1px solid var(--line); border-radius:12px; background:var(--surface-2); padding:12px; }
|
.flow-test-summary,.flow-test-action,.flow-log-row { border:1px solid var(--line); border-radius:12px; background:var(--surface-2); padding:12px; }
|
||||||
.flow-test-summary { display:flex; justify-content:space-between; gap:12px; align-items:center; }
|
.flow-test-summary { display:flex; justify-content:space-between; gap:12px; align-items:center; }
|
||||||
.flow-test-action > div:first-child,.flow-log-row > div:first-child { display:flex; justify-content:space-between; gap:10px; align-items:center; }
|
.flow-test-action > div:first-child,.flow-log-row > div:first-child { display:flex; justify-content:space-between; gap:10px; align-items:center; }
|
||||||
.flow-test-action.pass { border-left:4px solid var(--green); }
|
.flow-test-action.pass { border-left:4px solid var(--teal); }
|
||||||
.flow-test-action.blocked { border-left:4px solid var(--line-strong); }
|
.flow-test-action.blocked { border-left:4px solid var(--line-strong); }
|
||||||
.flow-trace { display:grid; gap:5px; margin-top:10px; }
|
.flow-trace { display:grid; gap:5px; margin-top:10px; }
|
||||||
.flow-trace > div { display:flex; justify-content:space-between; gap:10px; padding-top:5px; border-top:1px solid var(--line); font-size:.82rem; }
|
.flow-trace > div { display:flex; justify-content:space-between; gap:10px; padding-top:5px; border-top:1px solid var(--line); font-size:.82rem; }
|
||||||
@@ -6326,7 +6326,7 @@ body.flow-editor-open { overflow:hidden; }
|
|||||||
.flow-shared-test-panel { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:10px 14px; align-items:start; padding:12px; border:1px solid var(--line); border-radius:12px; background:var(--surface-2); }
|
.flow-shared-test-panel { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:10px 14px; align-items:start; padding:12px; border:1px solid var(--line); border-radius:12px; background:var(--surface-2); }
|
||||||
.flow-shared-test-panel .field-note { margin:3px 0 0; }
|
.flow-shared-test-panel .field-note { margin:3px 0 0; }
|
||||||
.flow-shared-test-result { grid-column:1 / -1; display:grid; gap:8px; padding:10px 12px; border:1px solid var(--line); border-radius:10px; background:var(--surface); }
|
.flow-shared-test-result { grid-column:1 / -1; display:grid; gap:8px; padding:10px 12px; border:1px solid var(--line); border-radius:10px; background:var(--surface); }
|
||||||
.flow-shared-test-result.pass { border-left:4px solid var(--green); }
|
.flow-shared-test-result.pass { border-left:4px solid var(--teal); }
|
||||||
.flow-shared-test-result.fail { border-left:4px solid var(--line-strong); }
|
.flow-shared-test-result.fail { border-left:4px solid var(--line-strong); }
|
||||||
.flow-shared-test-result > div:first-child { display:flex; justify-content:space-between; gap:10px; align-items:center; }
|
.flow-shared-test-result > div:first-child { display:flex; justify-content:space-between; gap:10px; align-items:center; }
|
||||||
.flow-shared-test-result dl { display:grid; gap:5px; margin:0; }
|
.flow-shared-test-result dl { display:grid; gap:5px; margin:0; }
|
||||||
@@ -6353,7 +6353,7 @@ body.flow-editor-open { overflow:hidden; }
|
|||||||
.flow-template-preview-node { position:absolute; transform:translate(-50%,-50%); width:104px; max-width:28%; padding:6px 7px; border:1px solid var(--line); border-radius:8px; background:var(--surface-2); box-shadow:0 3px 10px rgba(0,0,0,.12); font-size:.68rem; line-height:1.2; text-align:center; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
.flow-template-preview-node { position:absolute; transform:translate(-50%,-50%); width:104px; max-width:28%; padding:6px 7px; border:1px solid var(--line); border-radius:8px; background:var(--surface-2); box-shadow:0 3px 10px rgba(0,0,0,.12); font-size:.68rem; line-height:1.2; text-align:center; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||||
.flow-template-preview-node-sensor { border-top:3px solid var(--blue, var(--accent)); }
|
.flow-template-preview-node-sensor { border-top:3px solid var(--blue, var(--accent)); }
|
||||||
.flow-template-preview-node-logic { border-top:3px solid var(--accent); }
|
.flow-template-preview-node-logic { border-top:3px solid var(--accent); }
|
||||||
.flow-template-preview-node-action { border-top:3px solid var(--green); }
|
.flow-template-preview-node-action { border-top:3px solid var(--teal); }
|
||||||
.flow-template-requirements { display:grid; gap:8px; }
|
.flow-template-requirements { display:grid; gap:8px; }
|
||||||
.flow-template-requirements > div { display:flex; flex-wrap:wrap; gap:6px; }
|
.flow-template-requirements > div { display:flex; flex-wrap:wrap; gap:6px; }
|
||||||
.flow-template-requirement { padding:5px 8px; border:1px solid var(--line); border-radius:999px; font-size:.75rem; }
|
.flow-template-requirement { padding:5px 8px; border:1px solid var(--line); border-radius:999px; font-size:.75rem; }
|
||||||
@@ -6390,3 +6390,20 @@ body.flow-editor-open { overflow:hidden; }
|
|||||||
.flow-editor .flow-node-current { margin-top:6px; padding-top:6px; border-top:1px solid color-mix(in srgb, var(--border) 72%, transparent); display:flex; align-items:baseline; justify-content:space-between; gap:8px; }
|
.flow-editor .flow-node-current { margin-top:6px; padding-top:6px; border-top:1px solid color-mix(in srgb, var(--border) 72%, transparent); display:flex; align-items:baseline; justify-content:space-between; gap:8px; }
|
||||||
.flow-editor .flow-node-current span { color:var(--muted); font-size:.68rem; white-space:nowrap; }
|
.flow-editor .flow-node-current span { color:var(--muted); font-size:.68rem; white-space:nowrap; }
|
||||||
.flow-editor .flow-node-current strong { min-width:0; max-width:72px; color:var(--text); font-size:.76rem; font-weight:700; text-align:right; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
.flow-editor .flow-node-current strong { min-width:0; max-width:72px; color:var(--text); font-size:.76rem; font-weight:700; text-align:right; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||||
|
|
||||||
|
/* Compact, category-coded Flow palette */
|
||||||
|
.flow-palette-group { padding-left:8px; border-left:3px solid var(--line-strong); }
|
||||||
|
.flow-palette-group button { min-height:30px; padding:6px 8px; border-radius:8px; font-size:.76rem; line-height:1.15; }
|
||||||
|
.flow-palette-trigger { border-left-color:var(--warning); }
|
||||||
|
.flow-palette-time { border-left-color:var(--blue); }
|
||||||
|
.flow-palette-timeop { border-left-color:var(--info); }
|
||||||
|
.flow-palette-sensor { border-left-color:var(--teal); }
|
||||||
|
.flow-palette-logic { border-left-color:var(--purple); }
|
||||||
|
.flow-palette-action { border-left-color:var(--orange); }
|
||||||
|
.flow-palette-haaction { border-left-color:var(--accent); }
|
||||||
|
.flow-editor .flow-node-trigger { border-top:3px solid var(--warning); }
|
||||||
|
.flow-editor .flow-node-timeop { border-top:3px solid var(--info); }
|
||||||
|
.flow-editor .flow-node-haaction { border-top:3px solid var(--accent); }
|
||||||
|
.flow-template-preview-node-trigger { border-top:3px solid var(--warning); }
|
||||||
|
.flow-template-preview-node-time, .flow-template-preview-node-timeop { border-top:3px solid var(--blue); }
|
||||||
|
.flow-template-preview-node-haaction { border-top:3px solid var(--accent); }
|
||||||
|
|||||||
Reference in New Issue
Block a user