From db6d2f09dbfc53d29c8fb80d4817c400632e11d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Wed, 2 Sep 2026 22:41:27 +0200 Subject: [PATCH] v0.11.0 --- Cargo.lock | 2 +- Cargo.toml | 2 +- FILE_MANIFEST.sha256 | 37 +- README.md | 4 +- docs/API.md | 6 +- docs/FLOW.md | 12 +- .../gree_controller/manifest.json | 2 +- lang/en.json | 25 +- lang/pl.json | 25 +- scripts/FILE_MANIFEST.sha256 | 1 + scripts/verify_flow_logic.py | 394 ++++++++++++++++++ src/api/automations.rs | 2 +- src/api/flows.rs | 74 +++- src/engine/automations.rs | 222 +++++++++- src/engine/tests.rs | 59 ++- src/home_assistant.rs | 35 ++ src/models/automation.rs | 26 ++ web/index.html | 16 +- web/js/flows.js | 37 +- web/styles.css | 23 +- 20 files changed, 945 insertions(+), 59 deletions(-) create mode 100755 scripts/verify_flow_logic.py diff --git a/Cargo.lock b/Cargo.lock index 0688d64..09b1ce4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -633,7 +633,7 @@ dependencies = [ [[package]] name = "gree-controller" -version = "0.10.0" +version = "0.11.0" dependencies = [ "aes", "aes-gcm", diff --git a/Cargo.toml b/Cargo.toml index 7bc0832..5aed59b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "gree-controller" -version = "0.10.0" +version = "0.11.0" edition = "2021" authors = ["GREE Controller contributors"] description = "Standalone local GREE HVAC controller with Web UI, SQLite and Home Assistant sensor support" diff --git a/FILE_MANIFEST.sha256 b/FILE_MANIFEST.sha256 index fa5127d..45c9453 100644 --- a/FILE_MANIFEST.sha256 +++ b/FILE_MANIFEST.sha256 @@ -1,12 +1,12 @@ d67af429e4da9ce08e9d2f2a8472849ffbd70d135b1c5da535a076026794d04c ./.env.example a4ec3874a2e3ab1bad28fb40bb620f7b01f64d01ad9b699306bf70ada31227db ./.gitignore -bf9a8e40ddb34e4ad07c8af2fb2701d80694ae735b2cbca1a9d227a3f1450ca1 ./Cargo.lock -7142adffb9b6a110ea8aa6337aeb7b43b52a2a35024ef3bc9e9ff3c3b0437590 ./Cargo.toml +9aed69e37c083f1248c4c5ed23decc3ef796ac062f8e4fd591131887a15c4cbc ./Cargo.lock +e0142c9260ad30454663b1f1e9fa161bc5c9e06e5c7f491d15639d8cb23e282a ./Cargo.toml 19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE -77904d7a2d5bf7206bd1d350f2a15e827b0f821e31da511a4afdd0ba403542c9 ./README.md +c6021f6df33986f6365c34a4e1892c7c7a0e0403ec6a7ef777cf64ee834bce1f ./README.md 41dfdc6d099b54f87d4dd51f696122b3c88d3bf3ddf420bea8f4bc621ba920b0 ./build.rs -f2d6d4177a3628e0939ee691a3071c876e95c56d564fcd6e34aedbc3f25d1b0c ./docs/API.md -bdbb1bf4f46fe4e4e99ef3a484a091442c082f57304880b4a1535da167a1d830 ./docs/FLOW.md +ba0c6ac59b5143daa5ac89f9006e516777f4258509bf40b03cc576beb75d80b7 ./docs/API.md +b612dee4ff09398ec593fa55349be15080ba7b1947743b2a41c1d36ca5635ecd ./docs/FLOW.md a0893b2a56eb1523f1a72871842e9be2139a5fafba1f51ae942fc407a6e4ca34 ./home-assistant/README.md f8e8559fe10fe523ac5bc9aac25c6e26e862f679d502e8f3c39f38a0a8e40911 ./home-assistant/custom_components/gree_controller/__init__.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 c4fb75c246db651087900ebfc2291ff41ac87652cd6194fc0b776b0005c1cbcf ./home-assistant/custom_components/gree_controller/icon.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 39c4309001b75abb56234f05662bc06e077054986876f1927937edbce528ec95 ./home-assistant/custom_components/gree_controller/select.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 4513070521d3dda0efb0d974a86ba674494cfb2b66fe9e5cac5b1b0430dede97 ./home-assistant/generated/gree_controller_entities.example.json 253a0bc912786e67ea7fc92a64e4a510ad973bec343a88ccfb1f28fca3e8cf01 ./lang/README.md -981cbf3b3c24a32480a2ceb611985d62bdd9eecc6db68f105447d68a02c3492c ./lang/en.json -9e0088d79f5863239591d902a874ae81f1735721ef9f861d9ee26a6414beea47 ./lang/pl.json +83b367cbb81b2fddc7f1c5c0712c20d38b8ba8d17f50ed26a13690624067e4bc ./lang/en.json +853df747bcba915f8fe837584a096583725d12d43935ff3b21363abc33dc032f ./lang/pl.json d8459024f04ca514bd8e9d6bd3af872fb942fd85a7cdd5583f4a9d28aab6faba ./make_zip.py b14233a8987e53bbbdd6770386ba10fa166ec25c1e098275c173c544b37846fc ./presets/bedroom_window_night.json 1960841119c0b673fb2f03621b32237552fd828045304cd221401214b997c910 ./presets/device_resilience.json @@ -67,7 +67,7 @@ ae21459a261712bcb8d57594528b1648432e8d03a8a38502234829c0dbfef774 ./presets/week fdcd9a5055d08037278b842e7ab69265345c5811f0a06867136c511d140bb191 ./presets/window_available_guard.json 804f22123cd3e8db0fac791826c8dd9f758fb866c8e3b5655fb6d25d259dccf1 ./presets/workday_comfort.json 01952aa92b217f8eae2493b88870e2dec595100cd15c4d561ff11ae2b936c46f ./regenerate-sha.sh -feb34a6d60da49a5135eae8de5028988cca1ea1dd0971c76aec848f965fdea67 ./scripts/FILE_MANIFEST.sha256 +9aff6913a90df35c3c7cedf4c4752bfa84360602cb7711309b0dc909b7522a60 ./scripts/FILE_MANIFEST.sha256 bb89bac237e750e9b1bf73761d7df97a6b81853091615878c03f13d7b6399aa7 ./scripts/README.md 5bc736c7bc76ca80aaa406bb171d2aa91baf4c3aa8695dce0e09b888b6ab3146 ./scripts/common.sh 6403786610ee6d2f628193c25aee0dd058d62e904aa1a31d5f62fdaae0e94b4f ./scripts/configure-gree-network.sh @@ -80,14 +80,15 @@ e00d211e3885e30d7fed1e43b44e6fdad40a67019060156c0641816a93e3365f ./scripts/netw 81345b6a0b51736bdbc98fd23199b62e4c721b4e7437e02dab7ea79b97dff29a ./scripts/service.sh fec8b0362e763bbc115b5cc5c56f9ebe815730cee161632d81c5dc0ee1475f80 ./scripts/smoke.sh b50782b3742dfbf8a319c60571c968e93fdf8547db747c759edcffae68cb98bf ./scripts/update.sh +9abc30f32476ffbab8d8073c7f2167a58732a1de9aeb30ceffb6708857a7f1f2 ./scripts/verify_flow_logic.py da16e16dfb330d528d54d2138b07950da701edc46af1ff174a9f6a54090122b3 ./src/api.rs 825370f409124a719043200742c01c3441bc65f483108106140d13fe7bbcba91 ./src/api/assets.rs 6c34b1294d76b0eba4c56bfd0bf69bdd502de0e40838104f6ddf2db4e922683d ./src/api/auth.rs -5643425214a0ad228d6229398b715d9a6823c8c5823cdb4fbf73912d9b1eb74b ./src/api/automations.rs +63cb241c4536abed402f2a0ad37e7f299343df264eab6a33bb5354c15421e045 ./src/api/automations.rs 2317b7cd0c2437c128ac4982d64412c8184ecb5e774825761d61aab6e45fae4a ./src/api/debug_tokens.rs 4f82206fb48a291bdc643c96d4d6c917d669c7bdf2a35312c0a056dddecc7062 ./src/api/devices.rs 417940b467c6f00bbf01cc47eb4479880dee9b2f284a80e3662a6d433dc11b92 ./src/api/events.rs -10dcc9330f4d0b1b88b4fd3e80dd85b67cb45c5205c3fe03bbadecf3003e9e99 ./src/api/flows.rs +381b468544fa3c7e192d9ed9a99db774db6bed9b1588a415dfaa5347b39c3053 ./src/api/flows.rs d096000e94997cf9aaf9d3cc567214a78b8b2da7da57a76e2b3519247af6e592 ./src/api/groups.rs a13d4e217fe3ddaa73873ba6e0d1bc93750cf61764d867ff7372274ed21d9599 ./src/api/history.rs b3ed82de9d885a6325647bff3d0ead6ac46fc74d0250d5e14d75319555ad88d7 ./src/api/house.rs @@ -112,7 +113,7 @@ f3bd91d0bdb7699f319b9341d928d3cc3b25f759653fcd2095a5c5f695d08fac ./src/db/event 35cbb521a8ec456756496b8aee71f9dfebad528a2cf2653ba8b48f6035be0c01 ./src/db/tests.rs a384ea4042d1d11692b5f8693df1f54850dcc4e80726780b44b2b096bf667b78 ./src/db/zone_history.rs 952ea5906d39ef917ad71edc345f76b29c07c63079644fd5699b12aa254cb35f ./src/engine.rs -db8a94f464e0e92f9b80ec4e9a1f2c46cabbf69e6540c3d3b2155f96ba6b55cb ./src/engine/automations.rs +657c9ac9c11ac8b81246366d7e7616f7d0f896c0aad99bf51fabb351eb15a228 ./src/engine/automations.rs 77931af524e9402fc826050b5fcec422d01b02b625c8ce7f2004f1f6520c9b4d ./src/engine/commands.rs ce7e809765c8ad7dc4819b7f775ba7a632031d4d0e68780cc6ea953f2fd890e7 ./src/engine/control_plan.rs 38f42c86c31048780ca414f2c328f363ef803a0edbc1e1ed85503909a10b69ae ./src/engine/deadlines.rs @@ -126,18 +127,18 @@ ffa72b93502eece187a96adb4ec94aec6048d4a6dc7af66e7c7af0931318787f ./src/engine/s 7742046c0067c7cd5a74e2b81f8a8bdef9b76fcacf9b4af0b7eede1b050b9036 ./src/engine/targets.rs a9188b588b2617ce1547a45d46141f7e5f1bb0027a48675dd86b274f807d0875 ./src/engine/temperature.rs c2fe371f9245ce299015f0b4e43f6625a207016abf73d2419acb6bd7437f045a ./src/engine/temporary_thermostat.rs -ea3b533752b7abab113541f47ab7f49da27cc5696142796b78cd16889471371b ./src/engine/tests.rs +d2f54341f835155f0941db287abb3b382026261b4ef5058924fcedafddae4547 ./src/engine/tests.rs 986c5ce1d7d5e79975b0089807408109afdef5c5a173b427b58e34f534faf411 ./src/engine/zone_actions.rs 900947200973b8e4490600f33738424a89aadf0b59c0316f4cd46deb997730b6 ./src/engine/zone_control.rs 4b271b6fc365b1078c01d6178eb563841b2ecaed5d8639196f58e1312d2236fe ./src/error.rs -3be3961c2adefb599db54382f204233fd4e5584b6c9ad26935c4e029a63d746d ./src/home_assistant.rs +9eef7820fcf0c98b08fb1bf5e0a4a244b1dbdfa700d8b930d3ff40379babdada ./src/home_assistant.rs 6f9ef85cd53ba030b477bc3c186f0f7e762674124210eabcb0686f2806eaada1 ./src/influxdb.rs feb50ecd60b9a9fe9de1f7d8e1e26caab5435d59347468f6266a318f431bc3d3 ./src/influxdb/codec.rs 553b44ec0321e62480059bc2451fd34ec7aabe9fc87fc717daba0c4b55679d53 ./src/influxdb/query.rs 5e4d94f8a010df05bae9acab72c518080d2dba94d054df95b87e492305357222 ./src/influxdb/write.rs ef8ad176eb2ee6ceb02bbf3d63b65460013475908fced05388b29727af67ef7e ./src/main.rs c63bd6257386969a763f44d0e4fd7b6b19078ec7a7f46e7ec2d73580d2274cab ./src/models.rs -a4f2d6d5a8d1ef58e45721e4640a63d7dd2e80b24accd22f32c74ce4a541ae21 ./src/models/automation.rs +1806b82a89121548ed0385cd22143cea13f52f0e06df1e75a8518dc946f58cba ./src/models/automation.rs 377fc9697a70a03ad35fc3701adf4dbe8687760dc90de9f5c532a4f3b612c762 ./src/models/control_plan.rs 8228b9b6d9e8b1d13de29ce88500beacd88c8877893b7a3e41a4bd1e237ca36c ./src/models/defaults.rs 7cdc99a6f0920baf4ac88fbe7e6f1859e8598a00f00b8c3d84f20bc3a4de35f9 ./src/models/device.rs @@ -171,7 +172,7 @@ f13f1be3d5789539eba3fc1b59c14835f8c681eca7634e118fa3763a53aeee2b ./src/queries/ b92a6cb158b494fe145b43c7641e65f6fafff47201d7d76edbec2cfd8b94835c ./systemd/gree-controller.service 544a31cb2e5374227026afd6abd0d910381f3fca1638154ebd74e3c3d96a35d1 ./web/404.html e98bdd7204349cce1ec6f57283509697af0bbc72280622a6c3efa6fed242db4f ./web/favicon.svg -d6df434f3fbd33ba38dd244830c980e54433c6dff94dd39c716b377255bd2ebf ./web/index.html +041f9c80d11e1b9ba6924fa0fbd7b86b05a3bf53b2501e0431afed43ffc9d7f7 ./web/index.html 9ba3f6fa05b72aa989139b2a909982571b2a02055052e4c40104f1e81a9aa7eb ./web/js/README.md 21b662914590e720dafefe4dc3dccc91dbeb963cce9ae91f007c835d2d0c1fe5 ./web/js/bootstrap.js 72fa280996ba331a05bdb29a1f49b4e61bdd8964953c8ce1db952156620f08fe ./web/js/charts.js @@ -179,7 +180,7 @@ f07600277c60c771c04b1d43c5ed08de6bf518a72660011767b71cde8ec5bde8 ./web/js/core. dfc123885a9b6b5c1d07c12d2d2625ff15aa0844e5cd3736092ccd0a360da39c ./web/js/dashboard.js f53e2da5c48b55cf0191ffeb970a2c053c66e62d29099e3c5b5fccc8d89440cc ./web/js/entities.js 9a0e87911f78cd8a969f4c57ce74774f5fa829fdecd79494d9a7f9d5804f9792 ./web/js/events.js -133165112ee1a37264be87d251a6eae07db6af4e14d7ac6115f4e3589940f0e3 ./web/js/flows.js +00af9e3302a25f6d0d21b877c92227a795d59ec11b57494544c0521984ea2134 ./web/js/flows.js f06557c6d338259059b990f8f79c91d68ac8ec1f245c302468f08929b5506901 ./web/js/forms.js dbf36223863ba882c03eccea4516ba8db7acf0c2e72c6c64490cb994f1e20f96 ./web/js/history.js 7eaa04992ea828dc89f5eaebf674cbbfd160f53eb8d0d6ef0d4e47bd8c28c435 ./web/js/main.js @@ -189,6 +190,6 @@ c8c82e88c3715b1bfabf155e36266a4c94f5e2fc03eb39dcdd9d08a1985a997c ./web/js/route 34513d9ec2e45c60443dffe028eea74f415b8a29016df6af3377a80a4ac0df5b ./web/js/settings-ui.js aa6a2ba22648de44547efd09f41c4335ebe38ed564f4684e942d7827df40f1a2 ./web/js/settings.js 6b653e4fe2d4db4ffae6ff8f39b8ce57a10a990dc0000ee4aae51d949afadd52 ./web/manifest.webmanifest -a0f7cfe940ba27e549a9aafd42e9fd726aa0767312c4914f389291ba39ecdf47 ./web/styles.css +4845c01e7f6e1158f7b1a93853b267c6d98349d3843de32a031438d6ebfa1939 ./web/styles.css ae1b03f30b494f474a781a5d32072f1e73eba2b7c768109fc1cc8028cb6662a4 ./web/sw.js d505d793ce7cc9485b45b78bba1c0d51887adc7451ab59a42702946e5b991382 ./web/theme-init.js diff --git a/README.md b/README.md index cb0bd87..a180689 100644 --- a/README.md +++ b/README.md @@ -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. -**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. @@ -205,7 +205,7 @@ Schedules select profiles or a custom target for selected ISO weekdays (`1=Monda ### 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. diff --git a/docs/API.md b/docs/API.md index 80f2684..b894112 100644 --- a/docs/API.md +++ b/docs/API.md @@ -1,6 +1,6 @@ # GREE Controller API reference -HTTP and WebSocket API for GREE Controller **0.10.0**. +HTTP and WebSocket API for GREE Controller **0.11.0**. [← Main documentation](../README.md) @@ -213,7 +213,7 @@ Response: { "status": "ok", "name": "gree-controller", - "version": "0.10.0", + "version": "0.11.0", "uptime_seconds": 1234, "control_ready": true, "time": "2026-08-30T06:54:00Z" @@ -237,7 +237,7 @@ Returns the initial Web UI snapshot: "settings": {}, "outdoor_temperature": null, "system": { - "version": "0.10.0", + "version": "0.11.0", "uptime_seconds": 1234, "auth_required": false, "control_ready": true, diff --git a/docs/FLOW.md b/docs/FLOW.md index b8c0360..aa8967d 100644 --- a/docs/FLOW.md +++ b/docs/FLOW.md @@ -32,7 +32,7 @@ Group actions use the existing group order: `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. @@ -53,13 +53,13 @@ The Flow editor exposes: - Flow-scoped execution/dry-run logs, - 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 -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 @@ -73,14 +73,12 @@ The canvas supports multi-selection (Shift/Ctrl/Cmd), `Ctrl/Cmd+A`, Select all / ## 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, - hysteresis block, - rising/falling edge, - cooldown/rate limit independent of the action, -- delay/timer, - retry/backoff, - variables/counters, - reusable subflows/macros, diff --git a/home-assistant/custom_components/gree_controller/manifest.json b/home-assistant/custom_components/gree_controller/manifest.json index 75c2be9..cfd2214 100644 --- a/home-assistant/custom_components/gree_controller/manifest.json +++ b/home-assistant/custom_components/gree_controller/manifest.json @@ -1,7 +1,7 @@ { "domain": "gree_controller", "name": "GREE Controller", - "version": "0.10.0", + "version": "0.11.0", "config_flow": true, "integration_type": "hub", "iot_class": "local_polling", diff --git a/lang/en.json b/lang/en.json index d11947b..67963cb 100644 --- a/lang/en.json +++ b/lang/en.json @@ -1045,6 +1045,29 @@ "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.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." } } diff --git a/lang/pl.json b/lang/pl.json index f440ba9..6d1119d 100644 --- a/lang/pl.json +++ b/lang/pl.json @@ -1045,6 +1045,29 @@ "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.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." } } diff --git a/scripts/FILE_MANIFEST.sha256 b/scripts/FILE_MANIFEST.sha256 index d70a6df..9c3c18b 100644 --- a/scripts/FILE_MANIFEST.sha256 +++ b/scripts/FILE_MANIFEST.sha256 @@ -10,3 +10,4 @@ e00d211e3885e30d7fed1e43b44e6fdad40a67019060156c0641816a93e3365f ./network-debu 81345b6a0b51736bdbc98fd23199b62e4c721b4e7437e02dab7ea79b97dff29a ./service.sh fec8b0362e763bbc115b5cc5c56f9ebe815730cee161632d81c5dc0ee1475f80 ./smoke.sh b50782b3742dfbf8a319c60571c968e93fdf8547db747c759edcffae68cb98bf ./update.sh +9abc30f32476ffbab8d8073c7f2167a58732a1de9aeb30ceffb6708857a7f1f2 ./verify_flow_logic.py diff --git a/scripts/verify_flow_logic.py b/scripts/verify_flow_logic.py new file mode 100755 index 0000000..693be0f --- /dev/null +++ b/scripts/verify_flow_logic.py @@ -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() diff --git a/src/api/automations.rs b/src/api/automations.rs index 8adc59e..766f499 100644 --- a/src/api/automations.rs +++ b/src/api/automations.rs @@ -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_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_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() } } } diff --git a/src/api/flows.rs b/src/api/flows.rs index 275ff6b..c19c8f7 100644 --- a/src/api/flows.rs +++ b/src/api/flows.rs @@ -43,13 +43,13 @@ fn generated_flow_name(flow_id: &str, action_node_id: &str) -> String { fn flow_condition_kind(kind: &str) -> bool { 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" | "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_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 { 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()))?; 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)?; } "device_temperature" => { 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 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 flow_runtime = previous_automations.get(&id).map(|item| { + let previous_conditions = item.flow_conditions.iter().map(|condition| (condition.id.as_str(), condition)).collect::>(); + let current_conditions = conditions.iter().map(|condition| (condition.id.as_str(), condition)).collect::>(); + 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 { 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, 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, - flow_id: Some(flow.id.clone()), flow_node_id: Some(action_node.id.clone()), created_at, updated_at: now.clone(), + 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()), 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); } 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"); 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" => { 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())); } @@ -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())); } Ok(None) } + "ha_service_action" => Ok(None), _ => Ok(Some("unsupported_action".into())), } } @@ -602,7 +664,9 @@ async fn simulate_flow(State(state): State, Json(input): Json Result<()> { "temperature_below" => find_temperature(&devices, item.trigger_device_id.as_deref()) .zip(item.threshold).map(|(t, threshold)| t < threshold).unwrap_or(false), "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, 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})); false } + } }, _ => false, }; @@ -92,7 +97,9 @@ async fn run_automations(state: &AppState) -> Result<()> { continue; } - let target_devices: Vec = if let Some(zone_id) = item.action_zone_id.as_deref() { + let target_devices: Vec = 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() } else if let Some(group_id) = item.action_group_id.as_deref() { groups.iter().find(|group| group.id == group_id) @@ -115,7 +122,13 @@ async fn run_automations(state: &AppState) -> Result<()> { continue; } - let result: Result = if let Some(zone_id) = item.action_zone_id.as_deref() { + let result: Result = if let (Some(domain), Some(service)) = (item.action_ha_domain.as_deref(), item.action_ha_service.as_deref()) { + let settings = state.settings.read().await.clone(); + match home_assistant::call_service(&state.http, &settings.home_assistant, domain, service, item.action_ha_entity_id.as_deref(), &item.action_ha_data).await { + 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 } 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() }); @@ -275,6 +288,108 @@ fn flow_zone_state_value(zone: &Zone, field: &str) -> Option { }) } + +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::().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::(), b.parse::()) { return a <= value && value <= b && a >= min && b <= max; } + return false; + } + part.parse::().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::().ok().is_some_and(|step| step > 0 && step <= (max - min + 1)); + } + if let Some((a, b)) = part.split_once('-') { + return a.parse::().ok().zip(b.parse::().ok()) + .is_some_and(|(a, b)| a >= min && b <= upper && a <= b); + } + part.parse::().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) -> 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, +) -> 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, +) -> Option { + 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::>(); + 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::() / values.len() as f64) + } +} + async fn flow_leaf_observation( state: &AppState, 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()); (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" => { let actual = if let Some(v) = override_value { v.as_f64() } else { outdoor_temperature }; 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], now: DateTime, overrides: &HashMap, + mut runtime: Option<&mut std::collections::BTreeMap>, ) -> Result<(bool, Vec), AppError> { if conditions.is_empty() { return Ok((false, Vec::new())); } 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); (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::().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::().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) => { 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?; @@ -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)) } -async fn flow_conditions_match(state: &AppState, devices: &[Device], conditions: &[crate::models::FlowCondition]) -> Result { +async fn flow_conditions_match(state: &AppState, devices: &[Device], item: &mut Automation) -> Result { 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 { 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" | "device_state" | "zone_state" | "group_state" | "night_mode" | "constant" | "shared_input" ) diff --git a/src/engine/tests.rs b/src/engine/tests.rs index a821b3e..e1a9264 100644 --- a/src/engine/tests.rs +++ b/src/engine/tests.rs @@ -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::>(); + 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::>(); + 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] 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(); @@ -94,7 +151,7 @@ mod tests { 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: 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())); item.last_fired_at = Some((now.clone() - chrono::Duration::seconds(35)).with_timezone(&Utc)); diff --git a/src/home_assistant.rs b/src/home_assistant.rs index f1ce5ff..acc2ba0 100644 --- a/src/home_assistant.rs +++ b/src/home_assistant.rs @@ -145,3 +145,38 @@ pub async fn read_state( 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")) } + + +/// 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 { + 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::()) + } + match response.json::().await { + Ok(value) => Ok(value), + Err(_) => Ok(Value::Null), + } +} diff --git a/src/models/automation.rs b/src/models/automation.rs index 2d4c520..3ec6a81 100644 --- a/src/models/automation.rs +++ b/src/models/automation.rs @@ -25,6 +25,21 @@ pub struct Schedule { pub flow_node_id: Option, } + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)] +pub struct FlowRuntimeNodeState { + #[serde(default)] + pub since: Option>, + #[serde(default)] + pub samples: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct FlowRuntimeSample { + pub at: DateTime, + pub value: f64, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Automation { pub id: String, @@ -59,6 +74,14 @@ pub struct Automation { pub action_zone_id: Option, #[serde(default)] pub action_zone_preset: Option, + #[serde(default)] + pub action_ha_domain: Option, + #[serde(default)] + pub action_ha_service: Option, + #[serde(default)] + pub action_ha_entity_id: Option, + #[serde(default)] + pub action_ha_data: Value, /// Topologically ordered condition/logic program compiled from the visual Flow graph. #[serde(default)] pub flow_conditions: Vec, @@ -66,6 +89,9 @@ pub struct Automation { pub flow_id: Option, #[serde(default)] pub flow_node_id: Option, + /// Durable state for stateful Flow blocks (stable-for and rolling statistics). + #[serde(default)] + pub flow_runtime: BTreeMap, pub created_at: DateTime, pub updated_at: DateTime, } diff --git a/web/index.html b/web/index.html index d5e7fe4..b40a3a5 100644 --- a/web/index.html +++ b/web/index.html @@ -699,13 +699,19 @@
diff --git a/web/js/flows.js b/web/js/flows.js index d21dae6..d5988d3 100644 --- a/web/js/flows.js +++ b/web/js/flows.js @@ -2,6 +2,11 @@ const FLOW_NODE_META = Object.freeze({ weekday: { titleKey: 'flow.node.weekday', category: 'time' }, time_range: { titleKey: 'flow.node.timeRange', 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' }, device_temperature: { titleKey: 'flow.node.deviceTemperature', 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' }, device_action: { titleKey: 'flow.node.greeDevice', category: 'action' }, group_action: { titleKey: 'flow.node.group', category: 'action' }, + ha_service_action: { titleKey: 'flow.node.haServiceAction', category: 'haaction' }, }); function newFlowId(prefix = 'node') { @@ -56,6 +62,11 @@ function flowDefaultConfig(kind) { if (kind === 'weekday') return { days: [1, 2, 3, 4, 5] }; 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 === '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 === '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 }; @@ -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 === '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 === 'ha_service_action') return { domain: 'switch', service: 'turn_on', entity_id: '', data: {}, cooldown_seconds: 60 }; return {}; } @@ -246,6 +258,11 @@ function flowNodeSummary(node) { 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 === '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 === '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)}`; @@ -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 === '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 === 'ha_service_action') return `${c.domain || 'domain'}.${c.service || 'service'} · ${c.entity_id || 'entity_id'}`; return ''; } function flowNodeTitle(meta) { return meta?.titleKey ? tr(meta.titleKey) : (meta?.title || ''); } 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 renderFlowEditor() { @@ -342,6 +361,11 @@ function renderFlowInspector() { if (node.kind === 'weekday') fields = `
${[1,2,3,4,5,6,7].map(day => ``).join('')}
`; else if (node.kind === 'time_range') fields = `
`; else if (node.kind === 'date_range') fields = `
`; + else if (node.kind === 'cron_trigger') fields = `

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

`; + else if (node.kind === 'stable_for') fields = `

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

`; + else if (node.kind === 'delay') fields = `

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

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

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

`; } else if (node.kind === 'outdoor_temperature') fields = flowComparisonFields(c); else if (node.kind === 'device_temperature') fields = `${flowComparisonFields(c)}`; else if (node.kind === 'zone_temperature') fields = `${flowComparisonFields(c)}`; @@ -367,6 +391,7 @@ function renderFlowInspector() { else if (node.kind === 'zone_thermostat') fields = `
`; else if (node.kind === 'device_action') fields = `${flowActionFields(c, false)}`; else if (node.kind === 'group_action') fields = `${flowActionFields(c, true)}`; + else if (node.kind === 'ha_service_action') fields = `
`; host.innerHTML = `
${esc(tr('flow.blockSettings'))}

${esc(flowNodeTitle(meta))}

${fields}
ID${esc(node.id)}
`; } @@ -407,7 +432,7 @@ function flowExpressionSummary(actionId) { function renderFlowInterpretation() { 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; } 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); }; 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 requirements = []; 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 (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; app.flowDirty = true; renderFlowEditor(); } diff --git a/web/styles.css b/web/styles.css index a41c9ff..9fd3c8b 100644 --- a/web/styles.css +++ b/web/styles.css @@ -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 { 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.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-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; } @@ -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 .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.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 > 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; } @@ -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-sensor { border-top:3px solid var(--blue, 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 > 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; } @@ -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 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; } + +/* 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); }