From 563523d2e286549f509b96d00318ef7716bc6231 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Wed, 2 Sep 2026 22:58:55 +0200 Subject: [PATCH] v0.11.1 --- Cargo.lock | 2 +- Cargo.toml | 2 +- FILE_MANIFEST.sha256 | 34 +++--- README.md | 4 +- docs/API.md | 8 +- docs/FLOW.md | 15 ++- .../gree_controller/manifest.json | 2 +- lang/en.json | 27 ++++- lang/pl.json | 27 ++++- scripts/FILE_MANIFEST.sha256 | 2 +- scripts/verify_flow_logic.py | 82 +++++++++++-- src/api/flows.rs | 48 +++++++- src/engine/automations.rs | 113 ++++++++++++++++-- src/engine/tests.rs | 31 +++++ src/models/automation.rs | 5 +- web/index.html | 7 +- web/js/flows.js | 62 +++++++++- web/styles.css | 11 +- 18 files changed, 420 insertions(+), 62 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 09b1ce4..50fb436 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -633,7 +633,7 @@ dependencies = [ [[package]] name = "gree-controller" -version = "0.11.0" +version = "0.11.1" dependencies = [ "aes", "aes-gcm", diff --git a/Cargo.toml b/Cargo.toml index 5aed59b..b983276 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "gree-controller" -version = "0.11.0" +version = "0.11.1" 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 45c9453..ed5924e 100644 --- a/FILE_MANIFEST.sha256 +++ b/FILE_MANIFEST.sha256 @@ -1,12 +1,12 @@ d67af429e4da9ce08e9d2f2a8472849ffbd70d135b1c5da535a076026794d04c ./.env.example a4ec3874a2e3ab1bad28fb40bb620f7b01f64d01ad9b699306bf70ada31227db ./.gitignore -9aed69e37c083f1248c4c5ed23decc3ef796ac062f8e4fd591131887a15c4cbc ./Cargo.lock -e0142c9260ad30454663b1f1e9fa161bc5c9e06e5c7f491d15639d8cb23e282a ./Cargo.toml +0908ab9051cecea10d593240f22cab54ea1c5b7111f9090037fa03fe4a009df9 ./Cargo.lock +81dfad1866f4e431282fb7e7b67367fbd01f983db593358c27ffbd2100b7cf84 ./Cargo.toml 19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE -c6021f6df33986f6365c34a4e1892c7c7a0e0403ec6a7ef777cf64ee834bce1f ./README.md +dc9711f3c80bde54d4db52960dc1fc6583893bb52b76727ac261d9bbb7c3c3f9 ./README.md 41dfdc6d099b54f87d4dd51f696122b3c88d3bf3ddf420bea8f4bc621ba920b0 ./build.rs -ba0c6ac59b5143daa5ac89f9006e516777f4258509bf40b03cc576beb75d80b7 ./docs/API.md -b612dee4ff09398ec593fa55349be15080ba7b1947743b2a41c1d36ca5635ecd ./docs/FLOW.md +7c096c62872e570a3fce80253497a42c18248570e785dfad9acefe89bf597e25 ./docs/API.md +2de6eea12eb0ab5ef25eb220bc3c161e31419531b64e8eb761191e9ab3c4d3ea ./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 -779d49e14506ee2435e7e05f3c0fc5fffa01536a182b559dba2a0a73cc32e724 ./home-assistant/custom_components/gree_controller/manifest.json +629c10dd8b65cfde3a1db168cd6b5b642a87c4a4cd1149cc77121a121e525bbb ./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 -83b367cbb81b2fddc7f1c5c0712c20d38b8ba8d17f50ed26a13690624067e4bc ./lang/en.json -853df747bcba915f8fe837584a096583725d12d43935ff3b21363abc33dc032f ./lang/pl.json +12882bf4351aa5e0f6adac4ccc61205a3f95c4f1ec9853d322ee7b9a4e93d651 ./lang/en.json +a4f5b11e058b267f9ba60acdc447d401f4d95d1180af786c02c3f794a83a6146 ./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 -9aff6913a90df35c3c7cedf4c4752bfa84360602cb7711309b0dc909b7522a60 ./scripts/FILE_MANIFEST.sha256 +809cad38164d2775d8eea4c72f308b4448642863bec30b531e0e9dcb8f9da418 ./scripts/FILE_MANIFEST.sha256 bb89bac237e750e9b1bf73761d7df97a6b81853091615878c03f13d7b6399aa7 ./scripts/README.md 5bc736c7bc76ca80aaa406bb171d2aa91baf4c3aa8695dce0e09b888b6ab3146 ./scripts/common.sh 6403786610ee6d2f628193c25aee0dd058d62e904aa1a31d5f62fdaae0e94b4f ./scripts/configure-gree-network.sh @@ -80,7 +80,7 @@ e00d211e3885e30d7fed1e43b44e6fdad40a67019060156c0641816a93e3365f ./scripts/netw 81345b6a0b51736bdbc98fd23199b62e4c721b4e7437e02dab7ea79b97dff29a ./scripts/service.sh fec8b0362e763bbc115b5cc5c56f9ebe815730cee161632d81c5dc0ee1475f80 ./scripts/smoke.sh b50782b3742dfbf8a319c60571c968e93fdf8547db747c759edcffae68cb98bf ./scripts/update.sh -9abc30f32476ffbab8d8073c7f2167a58732a1de9aeb30ceffb6708857a7f1f2 ./scripts/verify_flow_logic.py +b70f2a66f39ed050f9030ed499f74e8e899e224d6d7c47ed2a1389ce1be11421 ./scripts/verify_flow_logic.py da16e16dfb330d528d54d2138b07950da701edc46af1ff174a9f6a54090122b3 ./src/api.rs 825370f409124a719043200742c01c3441bc65f483108106140d13fe7bbcba91 ./src/api/assets.rs 6c34b1294d76b0eba4c56bfd0bf69bdd502de0e40838104f6ddf2db4e922683d ./src/api/auth.rs @@ -88,7 +88,7 @@ da16e16dfb330d528d54d2138b07950da701edc46af1ff174a9f6a54090122b3 ./src/api.rs 2317b7cd0c2437c128ac4982d64412c8184ecb5e774825761d61aab6e45fae4a ./src/api/debug_tokens.rs 4f82206fb48a291bdc643c96d4d6c917d669c7bdf2a35312c0a056dddecc7062 ./src/api/devices.rs 417940b467c6f00bbf01cc47eb4479880dee9b2f284a80e3662a6d433dc11b92 ./src/api/events.rs -381b468544fa3c7e192d9ed9a99db774db6bed9b1588a415dfaa5347b39c3053 ./src/api/flows.rs +40a1d010371eb7c5c90ddd881c86f8023bb61ff77eeec83fabd0759d38e241cb ./src/api/flows.rs d096000e94997cf9aaf9d3cc567214a78b8b2da7da57a76e2b3519247af6e592 ./src/api/groups.rs a13d4e217fe3ddaa73873ba6e0d1bc93750cf61764d867ff7372274ed21d9599 ./src/api/history.rs b3ed82de9d885a6325647bff3d0ead6ac46fc74d0250d5e14d75319555ad88d7 ./src/api/house.rs @@ -113,7 +113,7 @@ f3bd91d0bdb7699f319b9341d928d3cc3b25f759653fcd2095a5c5f695d08fac ./src/db/event 35cbb521a8ec456756496b8aee71f9dfebad528a2cf2653ba8b48f6035be0c01 ./src/db/tests.rs a384ea4042d1d11692b5f8693df1f54850dcc4e80726780b44b2b096bf667b78 ./src/db/zone_history.rs 952ea5906d39ef917ad71edc345f76b29c07c63079644fd5699b12aa254cb35f ./src/engine.rs -657c9ac9c11ac8b81246366d7e7616f7d0f896c0aad99bf51fabb351eb15a228 ./src/engine/automations.rs +c803180a341d27f4e5165add623d6548cb43ffc23fa167f7235ce9cf2423ea68 ./src/engine/automations.rs 77931af524e9402fc826050b5fcec422d01b02b625c8ce7f2004f1f6520c9b4d ./src/engine/commands.rs ce7e809765c8ad7dc4819b7f775ba7a632031d4d0e68780cc6ea953f2fd890e7 ./src/engine/control_plan.rs 38f42c86c31048780ca414f2c328f363ef803a0edbc1e1ed85503909a10b69ae ./src/engine/deadlines.rs @@ -127,7 +127,7 @@ ffa72b93502eece187a96adb4ec94aec6048d4a6dc7af66e7c7af0931318787f ./src/engine/s 7742046c0067c7cd5a74e2b81f8a8bdef9b76fcacf9b4af0b7eede1b050b9036 ./src/engine/targets.rs a9188b588b2617ce1547a45d46141f7e5f1bb0027a48675dd86b274f807d0875 ./src/engine/temperature.rs c2fe371f9245ce299015f0b4e43f6625a207016abf73d2419acb6bd7437f045a ./src/engine/temporary_thermostat.rs -d2f54341f835155f0941db287abb3b382026261b4ef5058924fcedafddae4547 ./src/engine/tests.rs +cdf6f444b959ef1dcafe06e4cf48bdff35ddc4f4b34bd95169660fa4a535a1da ./src/engine/tests.rs 986c5ce1d7d5e79975b0089807408109afdef5c5a173b427b58e34f534faf411 ./src/engine/zone_actions.rs 900947200973b8e4490600f33738424a89aadf0b59c0316f4cd46deb997730b6 ./src/engine/zone_control.rs 4b271b6fc365b1078c01d6178eb563841b2ecaed5d8639196f58e1312d2236fe ./src/error.rs @@ -138,7 +138,7 @@ feb50ecd60b9a9fe9de1f7d8e1e26caab5435d59347468f6266a318f431bc3d3 ./src/influxdb 5e4d94f8a010df05bae9acab72c518080d2dba94d054df95b87e492305357222 ./src/influxdb/write.rs ef8ad176eb2ee6ceb02bbf3d63b65460013475908fced05388b29727af67ef7e ./src/main.rs c63bd6257386969a763f44d0e4fd7b6b19078ec7a7f46e7ec2d73580d2274cab ./src/models.rs -1806b82a89121548ed0385cd22143cea13f52f0e06df1e75a8518dc946f58cba ./src/models/automation.rs +67f189c195426c39669178dd92d8602c519806acca7d325665076720f9fee8a9 ./src/models/automation.rs 377fc9697a70a03ad35fc3701adf4dbe8687760dc90de9f5c532a4f3b612c762 ./src/models/control_plan.rs 8228b9b6d9e8b1d13de29ce88500beacd88c8877893b7a3e41a4bd1e237ca36c ./src/models/defaults.rs 7cdc99a6f0920baf4ac88fbe7e6f1859e8598a00f00b8c3d84f20bc3a4de35f9 ./src/models/device.rs @@ -172,7 +172,7 @@ f13f1be3d5789539eba3fc1b59c14835f8c681eca7634e118fa3763a53aeee2b ./src/queries/ b92a6cb158b494fe145b43c7641e65f6fafff47201d7d76edbec2cfd8b94835c ./systemd/gree-controller.service 544a31cb2e5374227026afd6abd0d910381f3fca1638154ebd74e3c3d96a35d1 ./web/404.html e98bdd7204349cce1ec6f57283509697af0bbc72280622a6c3efa6fed242db4f ./web/favicon.svg -041f9c80d11e1b9ba6924fa0fbd7b86b05a3bf53b2501e0431afed43ffc9d7f7 ./web/index.html +9cfdddeb88418a01936c66611327c566cb3d3ab6816d4ffd1189c53e6cf7df8d ./web/index.html 9ba3f6fa05b72aa989139b2a909982571b2a02055052e4c40104f1e81a9aa7eb ./web/js/README.md 21b662914590e720dafefe4dc3dccc91dbeb963cce9ae91f007c835d2d0c1fe5 ./web/js/bootstrap.js 72fa280996ba331a05bdb29a1f49b4e61bdd8964953c8ce1db952156620f08fe ./web/js/charts.js @@ -180,7 +180,7 @@ f07600277c60c771c04b1d43c5ed08de6bf518a72660011767b71cde8ec5bde8 ./web/js/core. dfc123885a9b6b5c1d07c12d2d2625ff15aa0844e5cd3736092ccd0a360da39c ./web/js/dashboard.js f53e2da5c48b55cf0191ffeb970a2c053c66e62d29099e3c5b5fccc8d89440cc ./web/js/entities.js 9a0e87911f78cd8a969f4c57ce74774f5fa829fdecd79494d9a7f9d5804f9792 ./web/js/events.js -00af9e3302a25f6d0d21b877c92227a795d59ec11b57494544c0521984ea2134 ./web/js/flows.js +e83d7646ea4f37b746ef550ded3de326ae22a5ae9539cc3cedb71f43d5e122db ./web/js/flows.js f06557c6d338259059b990f8f79c91d68ac8ec1f245c302468f08929b5506901 ./web/js/forms.js dbf36223863ba882c03eccea4516ba8db7acf0c2e72c6c64490cb994f1e20f96 ./web/js/history.js 7eaa04992ea828dc89f5eaebf674cbbfd160f53eb8d0d6ef0d4e47bd8c28c435 ./web/js/main.js @@ -190,6 +190,6 @@ c8c82e88c3715b1bfabf155e36266a4c94f5e2fc03eb39dcdd9d08a1985a997c ./web/js/route 34513d9ec2e45c60443dffe028eea74f415b8a29016df6af3377a80a4ac0df5b ./web/js/settings-ui.js aa6a2ba22648de44547efd09f41c4335ebe38ed564f4684e942d7827df40f1a2 ./web/js/settings.js 6b653e4fe2d4db4ffae6ff8f39b8ce57a10a990dc0000ee4aae51d949afadd52 ./web/manifest.webmanifest -4845c01e7f6e1158f7b1a93853b267c6d98349d3843de32a031438d6ebfa1939 ./web/styles.css +ca526ef93c5b31a212ca71efdadeac4d80659b9a28f281011f461ae772c0272a ./web/styles.css ae1b03f30b494f474a781a5d32072f1e73eba2b7c768109fc1cc8028cb6662a4 ./web/sw.js d505d793ce7cc9485b45b78bba1c0d51887adc7451ab59a42702946e5b991382 ./web/theme-init.js diff --git a/README.md b/README.md index a180689..933acd8 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.11.0** +**Current release: 0.11.1** > [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. 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. +**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`, bounded state duration, change/edge detection, rolling execution rate limits, 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 b894112..79a2c7f 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.11.0**. +HTTP and WebSocket API for GREE Controller **0.11.1**. [← Main documentation](../README.md) @@ -213,7 +213,7 @@ Response: { "status": "ok", "name": "gree-controller", - "version": "0.11.0", + "version": "0.11.1", "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.11.0", + "version": "0.11.1", "uptime_seconds": 1234, "auth_required": false, "control_ready": true, @@ -1516,7 +1516,7 @@ Flow is the source-of-truth representation for visual schedule/automation logic. Saving a Flow validates the DAG and compiles it atomically. A thermostat action driven only by one weekday block and one time-range block is emitted as a native schedule when its settings are schedule-compatible. More complex graphs are emitted as Flow-triggered automations. Generated schedules and automations expose `flow_id` / `flow_node_id`, use stable names in the form `flow-`, and cannot be edited or deleted through their legacy endpoints; edit the owning Flow instead. -Condition blocks support weekday, time/date ranges, application Night mode, outdoor/device/zone temperatures, house/device/zone/group state, arbitrary Home Assistant state/numeric/attribute/availability sources, and `shared_input` references. Shared inputs are stored in `home_assistant.flow_inputs` and resolve dynamically at evaluation time. Shared inputs store reusable value sources only. They never store a comparison operator or threshold. For comparison-capable source kinds, each `shared_input` Flow node defines its own `operator` and `value`. `PUT /api/settings` rejects shared-input configs that contain an `operator`, and rejects comparison `value` fields for comparison-capable source kinds. Logic blocks support AND, OR and NOT. Action blocks target thermostat zones, GREE devices or climate groups. Direct GREE actions map to the existing `DeviceCommand` fields including fan, swing, quiet, turbo, light, air, xfan, health and sleep; ownership rules may suppress fields that would fight an active thermostat. Flow group actions use the existing group controller and support power, house/heat/cool mode, auto/comfort/sleep/away and the existing custom group target. +Condition blocks support weekday, time/date ranges, optional 5-field CRON, application Night mode, outdoor/device/zone temperatures, house/device/zone/group state, arbitrary Home Assistant state/numeric/attribute/availability sources, rolling mean/median, oscillation detection, and `shared_input` references. Stateful gate blocks include `stable_for`, `delay`, `state_duration` (`min_seconds`, optional `max_seconds`), `on_change` (`mode: result|value`) and `rate_limit` (`max_count`, `period_seconds`). `on_change` establishes a baseline on its first observation and does not fire immediately. `rate_limit` must feed an action directly and consumes quota only after a successful action execution. Shared inputs are stored in `home_assistant.flow_inputs` and resolve dynamically at evaluation time. Shared inputs store reusable value sources only. They never store a comparison operator or threshold. For comparison-capable source kinds, each `shared_input` Flow node defines its own `operator` and `value`. `PUT /api/settings` rejects shared-input configs that contain an `operator`, and rejects comparison `value` fields for comparison-capable source kinds. Logic blocks support AND, OR and NOT. Action blocks target thermostat zones, GREE devices, climate groups or a generic Home Assistant service. Direct GREE actions map to the existing `DeviceCommand` fields including fan, swing, quiet, turbo, light, air, xfan, health and sleep; ownership rules may suppress fields that would fight an active thermostat. Flow group actions use the existing group controller and support power, house/heat/cool mode, auto/comfort/sleep/away and the existing custom group target. ## Compressor protection queue diff --git a/docs/FLOW.md b/docs/FLOW.md index aa8967d..87d630e 100644 --- a/docs/FLOW.md +++ b/docs/FLOW.md @@ -53,11 +53,11 @@ 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. 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. +Dry-run never mutates thermostats, devices, groups, schedules or automations. For an existing Flow it clones persisted stateful runtime data, so timers, last-observed values, rate-limit history and rolling/oscillation windows are evaluated against the same saved state without writing it back. ## Blocks -Condition/source blocks include weekday, time range, date range, optional 5-field CRON triggers, application Night mode, outdoor/device/zone temperature, house mode, device state, thermostat/zone state, group state, Home Assistant state/numeric/attribute/availability, rolling mean/median windows, numeric oscillation detection, a diagnostic constant, and `shared_input`. Stateful `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. +Condition/source blocks include weekday, time range, date range, optional 5-field CRON triggers, application Night mode, outdoor/device/zone temperature, house mode, device state, thermostat/zone state, group state, Home Assistant state/numeric/attribute/availability, rolling mean/median windows, numeric oscillation detection, a diagnostic constant, and `shared_input`. Stateful gates include `stable_for`, bounded `state_duration`, `on_change` (condition-result or raw-value edge), `rate_limit` (maximum successful executions in a rolling period), and `delay`. Their runtime state, together with rolling statistics and oscillation samples, is persisted in the generated automation payload so restarts do not silently reset timing/history. Editing a stateful block resets only that block's incompatible runtime state. Flow state continues to be observed while the action itself is in cooldown, preventing stale continuity/change state. A `rate_limit` block must be placed directly before an action and consumes quota only after a successful execution; suppressed or failed actions do not consume it. Shared inputs are configured centrally in Home Assistant / Sensors and referenced by ID. Shared inputs store reusable value sources only; operators and comparison values are configured exclusively in each Flow reference. Logic blocks are AND, OR and NOT. 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. @@ -73,13 +73,12 @@ The canvas supports multi-selection (Shift/Ctrl/Cmd), `Ctrl/Cmd+A`, Select all / ## Good next stateful blocks -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: +Stateful timing now has persisted runtime support for `stable_for`, bounded state duration, change detection, rolling execution limits, delay-before-action, rolling mean/median samples and oscillation detection. Useful next additions on the same runtime-state foundation are: -- debounce, -- hysteresis block, -- rising/falling edge, -- cooldown/rate limit independent of the action, -- retry/backoff, +- debounce with configurable dead time, +- a dedicated external-entity thermostat/hysteresis action (rather than duplicating the built-in HVAC thermostat), +- explicit rising/falling edge modes, +- retry/backoff for external service failures, - variables/counters, - reusable subflows/macros, - explicit priority/mutex groups for actions, diff --git a/home-assistant/custom_components/gree_controller/manifest.json b/home-assistant/custom_components/gree_controller/manifest.json index cfd2214..a9d0339 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.11.0", + "version": "0.11.1", "config_flow": true, "integration_type": "hub", "iot_class": "local_polling", diff --git a/lang/en.json b/lang/en.json index 67963cb..6f5ab4d 100644 --- a/lang/en.json +++ b/lang/en.json @@ -1068,6 +1068,31 @@ "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." + "flow.oscillatesHint": "Within the time window the value must move both up and down. The block requires the configured max−min span and number of direction changes.", + "flow.node.stateDuration": "State duration", + "flow.node.onChange": "Only on change", + "flow.node.rateLimit": "Max X times / period", + "flow.minDurationSeconds": "Min duration [s]", + "flow.maxDurationSeconds": "Max duration [s] (optional)", + "flow.stateDurationHint": "Passes only while the input state has lasted at least the minimum and no longer than the optional maximum. A false input resets the timer.", + "flow.changeMode": "Detect change of", + "flow.changeModeResult": "condition result", + "flow.changeModeValue": "source value", + "flow.onChangeHint": "The first observation only establishes the baseline. The block passes a later change only while the input condition is true.", + "flow.maxExecutions": "Max executions", + "flow.periodSeconds": "Period [s]", + "flow.rateLimitHint": "Limits the action to X successful executions in a rolling period. Place it directly before the action; suppressed or failed actions do not consume the limit.", + "flow.runtimeInfo": "Flow cycle", + "flow.runtimeHint": "The backend evaluates the Flow on every cycle. CRON limits when an action may execute, but stateful conditions continue to be observed.", + "flow.runtimeNoActions": "evaluate every {seconds} s · no actions", + "flow.runtimeWithoutCron": "evaluate every {seconds} s · execute without CRON", + "flow.runtimeWithCron": "evaluate every {seconds} s · execute: {cron}", + "flow.runtimeWithMultipleCrons": "evaluate every {seconds} s · execute via {count} CRON schedules", + "flow.runtimeMixed": "evaluate every {seconds} s · mixed: no CRON + {count} CRON", + "flow.cronEveryMinute": "CRON every minute", + "flow.cronEveryMinutes": "CRON every {minutes} min", + "flow.cronHourlyAt": "CRON hourly at :{minute}", + "flow.cronDailyAt": "CRON daily at {time}", + "flow.cronCustom": "CRON {expression}" } } diff --git a/lang/pl.json b/lang/pl.json index 6d1119d..9376a38 100644 --- a/lang/pl.json +++ b/lang/pl.json @@ -1068,6 +1068,31 @@ "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." + "flow.oscillatesHint": "W oknie czasu wartość musi poruszać się w górę i w dół. Blok wymaga podanej rozpiętości (max−min) oraz liczby zmian kierunku.", + "flow.node.stateDuration": "Czas trwania stanu", + "flow.node.onChange": "Tylko przy zmianie", + "flow.node.rateLimit": "Maks. X razy / okres", + "flow.minDurationSeconds": "Min. czas [s]", + "flow.maxDurationSeconds": "Maks. czas [s] (opcjonalnie)", + "flow.stateDurationHint": "Przepuszcza warunek tylko wtedy, gdy stan trwa co najmniej podany czas i nie dłużej niż opcjonalne maksimum. Zmiana na false zeruje licznik.", + "flow.changeMode": "Wykrywaj zmianę", + "flow.changeModeResult": "wyniku warunku", + "flow.changeModeValue": "wartości źródłowej", + "flow.onChangeHint": "Pierwszy odczyt tylko zapamiętuje stan. Blok przepuszcza dopiero kolejną zmianę, gdy warunek wejściowy jest spełniony.", + "flow.maxExecutions": "Maks. wykonań", + "flow.periodSeconds": "Okres [s]", + "flow.rateLimitHint": "Ogranicza akcję do X faktycznych wykonań w ruchomym okresie. Umieść blok bezpośrednio przed akcją; wykonanie zablokowane lub nieudane nie zużywa limitu.", + "flow.runtimeInfo": "Cykl Flow", + "flow.runtimeHint": "Backend ocenia Flow w każdym cyklu. CRON ogranicza moment wykonania akcji, ale nie zatrzymuje obserwowania warunków stanowych.", + "flow.runtimeNoActions": "ocena co {seconds} s · brak akcji", + "flow.runtimeWithoutCron": "ocena co {seconds} s · wykonanie bez CRON", + "flow.runtimeWithCron": "ocena co {seconds} s · wykonanie: {cron}", + "flow.runtimeWithMultipleCrons": "ocena co {seconds} s · wykonanie wg {count} harmonogramów CRON", + "flow.runtimeMixed": "ocena co {seconds} s · mieszane: bez CRON + {count} CRON", + "flow.cronEveryMinute": "CRON co minutę", + "flow.cronEveryMinutes": "CRON co {minutes} min", + "flow.cronHourlyAt": "CRON co godzinę o :{minute}", + "flow.cronDailyAt": "CRON codziennie o {time}", + "flow.cronCustom": "CRON {expression}" } } diff --git a/scripts/FILE_MANIFEST.sha256 b/scripts/FILE_MANIFEST.sha256 index 9c3c18b..df259b3 100644 --- a/scripts/FILE_MANIFEST.sha256 +++ b/scripts/FILE_MANIFEST.sha256 @@ -10,4 +10,4 @@ e00d211e3885e30d7fed1e43b44e6fdad40a67019060156c0641816a93e3365f ./network-debu 81345b6a0b51736bdbc98fd23199b62e4c721b4e7437e02dab7ea79b97dff29a ./service.sh fec8b0362e763bbc115b5cc5c56f9ebe815730cee161632d81c5dc0ee1475f80 ./smoke.sh b50782b3742dfbf8a319c60571c968e93fdf8547db747c759edcffae68cb98bf ./update.sh -9abc30f32476ffbab8d8073c7f2167a58732a1de9aeb30ceffb6708857a7f1f2 ./verify_flow_logic.py +b70f2a66f39ed050f9030ed499f74e8e899e224d6d7c47ed2a1389ce1be11421 ./verify_flow_logic.py diff --git a/scripts/verify_flow_logic.py b/scripts/verify_flow_logic.py index 693be0f..6a83d76 100755 --- a/scripts/verify_flow_logic.py +++ b/scripts/verify_flow_logic.py @@ -10,7 +10,7 @@ from pathlib import Path ROOT = Path(__file__).resolve().parents[1] CONDITIONS = { - 'weekday','time_range','date_range','cron_trigger','stable_for','delay','rolling_stat','oscillates', + 'weekday','time_range','date_range','cron_trigger','stable_for','delay','state_duration','on_change','rate_limit','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' } @@ -79,6 +79,7 @@ def cron_matches(expr: str, dt: datetime): class Runtime: since: datetime|None=None samples: list[tuple[datetime,float]]=field(default_factory=list) + last_value: object|None=None def timed_gate(rt: Runtime, input_value: bool, seconds: int, now: datetime): if not input_value: @@ -86,6 +87,28 @@ def timed_gate(rt: Runtime, input_value: bool, seconds: int, now: datetime): if rt.since is None: rt.since=now return (now-rt.since).total_seconds() >= seconds +def state_duration(rt: Runtime, input_value: bool, minimum: int, maximum: int|None, now: datetime): + if not input_value: + rt.since=None; return False,0 + if rt.since is None: rt.since=now + elapsed=max(0,int((now-rt.since).total_seconds())) + return elapsed >= minimum and (maximum is None or elapsed <= maximum), elapsed + +def changed(rt: Runtime, current): + result=rt.last_value is not None and rt.last_value != current + rt.last_value=current + return result + +def rate_limit_status(rt: Runtime, maximum: int, period: int, now: datetime): + cutoff=now-timedelta(seconds=period) + rt.samples=[x for x in rt.samples if x[0] >= cutoff] + return len(rt.samples) < maximum, len(rt.samples) + +def rate_limit_record(rt: Runtime, period: int, now: datetime): + cutoff=now-timedelta(seconds=period) + rt.samples=[x for x in rt.samples if x[0] >= cutoff] + rt.samples.append((now,1.0)) + 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] @@ -172,6 +195,17 @@ def validate_node_config(node, name): 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 == 'state_duration': + minimum=c.get('min_seconds',0); maximum=c.get('max_seconds') + check(isinstance(minimum,int) and not isinstance(minimum,bool) and 0 <= minimum <= 604800, f'{tag}: invalid min duration') + check(maximum is None or (isinstance(maximum,int) and not isinstance(maximum,bool) and minimum <= maximum <= 604800), f'{tag}: invalid max duration') + check(minimum > 0 or maximum is not None, f'{tag}: empty duration range') + elif kind == 'on_change': + check(c.get('mode','result') in {'result','value'}, f'{tag}: invalid change mode') + elif kind == 'rate_limit': + count=c.get('max_count'); period=c.get('period_seconds') + check(isinstance(count,int) and not isinstance(count,bool) and 1 <= count <= 1000, f'{tag}: invalid max count') + check(isinstance(period,int) and not isinstance(period,bool) and 1 <= period <= 2678400, f'{tag}: invalid rate period') 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') @@ -248,7 +282,7 @@ def flow_action_truth_table(flow, action_id): 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'}] + 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','state_duration','on_change','rate_limit'}] check(len(primitive) <= 16, f'action {action_id}: truth table unexpectedly large') def eval_node(node_id, assigned, memo): @@ -257,7 +291,7 @@ def flow_action_truth_table(flow, action_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) + elif kind in {'stable_for','delay','state_duration','on_change','rate_limit'}: 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) @@ -303,8 +337,15 @@ def validate_graph(flow, 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 {'stable_for','delay','state_duration','on_change','rate_limit'}: check(len(ins)==1, f'{name}: stateful gate arity') if n['kind'] in ACTIONS: check(len(ins)>=1, f'{name}: action without condition') + if n['kind'] == 'rate_limit': + targets=[by[target] for target in outgoing.get(n['id'],[]) if target in by] + check(bool(targets) and all(target['kind'] in ACTIONS for target in targets), f'{name}: rate limit must be directly before action') + if n['kind'] == 'on_change' and n.get('config',{}).get('mode','result') == 'value': + sources=[by[source] for source in incoming.get(n['id'],[]) if source in by] + blocked=LOGIC | {'stable_for','delay','state_duration','on_change','rate_limit','rolling_stat','oscillates'} + check(len(sources)==1 and sources[0]['kind'] not in blocked, f'{name}: value-change mode needs direct source input') 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') @@ -328,6 +369,25 @@ def main(): 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(state_duration(rt,True,10,30,base)==(False,0),'state duration immediate'); check(state_duration(rt,True,10,30,base+timedelta(seconds=10))==(True,10),'state duration minimum'); check(state_duration(rt,True,10,30,base+timedelta(seconds=30))==(True,30),'state duration maximum boundary'); check(state_duration(rt,True,10,30,base+timedelta(seconds=31))==(False,31),'state duration exceeded maximum'); check(state_duration(rt,False,10,30,base+timedelta(seconds=32))==(False,0) and rt.since is None,'state duration reset') + rt=Runtime(); check(not changed(rt,'off'),'change fired on first observation'); check(not changed(rt,'off'),'change fired without change'); check(changed(rt,'on'),'change not detected'); check(not changed(rt,'on'),'change repeated without new edge') + rt=Runtime(); rate_limit_record(rt,60,base); rate_limit_record(rt,60,base+timedelta(seconds=10)); check(rate_limit_status(rt,2,60,base+timedelta(seconds=20))==(False,2),'rate limit did not block'); check(rate_limit_status(rt,2,60,base+timedelta(seconds=61))==(True,1),'rate limit did not prune rolling window') + + synthetic={ + 'nodes':[ + {'id':'source','kind':'constant','config':{'value':True}}, + {'id':'duration','kind':'state_duration','config':{'min_seconds':10,'max_seconds':60}}, + {'id':'change','kind':'on_change','config':{'mode':'result'}}, + {'id':'limit','kind':'rate_limit','config':{'max_count':2,'period_seconds':3600}}, + {'id':'action','kind':'ha_service_action','config':{'domain':'switch','service':'turn_on','entity_id':'switch.test','data':{}}}, + ], + 'edges':[ + {'id':'e1','from':'source','to':'duration'}, {'id':'e2','from':'duration','to':'change'}, + {'id':'e3','from':'change','to':'limit'}, {'id':'e4','from':'limit','to':'action'}, + ] + } + check(validate_graph(synthetic,'synthetic-new-stateful-flow')==(5,4),'new stateful flow graph validation failed') + 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)): @@ -343,7 +403,7 @@ def main(): 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'): + for kind in ('cron_trigger','stable_for','delay','state_duration','on_change','rate_limit','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] @@ -357,11 +417,15 @@ def main(): 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') + check('flow_record_rate_limited_execution' in engine and 'Ok(true)' in engine, 'rate limit is not recorded after successful execution') + check('if !item.enabled { continue; }' in engine and 'if !ready || !should_fire { continue; }' in engine, 'Flow runtime may be skipped during action cooldown') + check('rate-limit block must be placed directly before an action' in api, 'rate-limit placement guard missing') + check('on-change value mode needs one direct source/condition input' in api, 'on-change value-source guard 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'}: + for kind in CONDITIONS - {'stable_for','delay','state_duration','on_change','rate_limit','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'): + for kind in ('stable_for','delay','state_duration','on_change','rate_limit','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') @@ -373,12 +437,12 @@ def main(): 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'): + for key in ('flow.triggers','flow.timeOps','flow.haActions','flow.node.cronTrigger','flow.node.stableFor','flow.node.stateDuration','flow.node.onChange','flow.node.rateLimit','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)) + referenced_flow_keys=set(re.findall(r'''(?:data-i18n(?:-[a-z]+)?=|tr\()\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}') diff --git a/src/api/flows.rs b/src/api/flows.rs index c19c8f7..10d378d 100644 --- a/src/api/flows.rs +++ b/src/api/flows.rs @@ -43,7 +43,7 @@ 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" | "cron_trigger" | "stable_for" | "delay" | "rolling_stat" | "oscillates" | "outdoor_temperature" | "device_temperature" | + "weekday" | "time_range" | "date_range" | "cron_trigger" | "stable_for" | "delay" | "state_duration" | "on_change" | "rate_limit" | "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" ) @@ -136,6 +136,24 @@ fn validate_flow_graph(input: &FlowInput) -> Result<(), AppError> { return Err(AppError::BadRequest("Flow connection has an unsupported target".into())); } } + for node in input.nodes.iter().filter(|node| node.kind == "rate_limit") { + let targets = input.edges.iter().filter(|edge| edge.from == node.id) + .filter_map(|edge| input.nodes.iter().find(|target| target.id == edge.to)) + .collect::>(); + if targets.is_empty() || targets.iter().any(|target| !flow_action_kind(&target.kind)) { + return Err(AppError::BadRequest("rate-limit block must be placed directly before an action".into())); + } + } + + for node in input.nodes.iter().filter(|node| node.kind == "on_change" && flow_string(&node.config, "mode").as_deref() == Some("value")) { + let sources = input.edges.iter().filter(|edge| edge.to == node.id) + .filter_map(|edge| input.nodes.iter().find(|source| source.id == edge.from)) + .collect::>(); + if sources.len() != 1 || sources.iter().any(|source| flow_logic_kind(&source.kind) || matches!(source.kind.as_str(), "stable_for" | "delay" | "state_duration" | "on_change" | "rate_limit" | "rolling_stat" | "oscillates")) { + return Err(AppError::BadRequest("on-change value mode needs one direct source/condition input".into())); + } + } + // Reject cycles. Flow is deliberately a DAG: finite evaluation, deterministic topological order, // and no hidden state machine semantics unless a dedicated stateful block is introduced later. let mut outgoing = std::collections::HashMap::>::new(); @@ -230,6 +248,32 @@ fn validate_condition(condition: &crate::models::FlowCondition, state: &AppState 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())); } } + "state_duration" => { + let min_seconds = condition.config.get("min_seconds").and_then(Value::as_u64).unwrap_or(0); + let max_seconds = condition.config.get("max_seconds").and_then(Value::as_u64); + if min_seconds > 604800 || max_seconds.is_some_and(|value| value > 604800) { + return Err(AppError::BadRequest("state duration must be between 0 seconds and 7 days".into())); + } + if max_seconds.is_some_and(|value| value < min_seconds) { + return Err(AppError::BadRequest("state duration maximum must be greater than or equal to minimum".into())); + } + if min_seconds == 0 && max_seconds.is_none() { + return Err(AppError::BadRequest("state duration needs a minimum or maximum duration".into())); + } + if condition.inputs.len() != 1 { return Err(AppError::BadRequest("state duration block needs exactly one input".into())); } + } + "on_change" => { + let mode = flow_string(&condition.config, "mode").unwrap_or_else(|| "result".into()); + if !matches!(mode.as_str(), "result" | "value") { return Err(AppError::BadRequest("on-change mode must be result or value".into())); } + if condition.inputs.len() != 1 { return Err(AppError::BadRequest("on-change block needs exactly one input".into())); } + } + "rate_limit" => { + let max_count = condition.config.get("max_count").and_then(Value::as_u64).unwrap_or(0); + let period_seconds = condition.config.get("period_seconds").and_then(Value::as_u64).unwrap_or(0); + if !(1..=1000).contains(&max_count) { return Err(AppError::BadRequest("rate limit max_count must be between 1 and 1000".into())); } + if !(1..=2678400).contains(&period_seconds) { return Err(AppError::BadRequest("rate limit period must be between 1 second and 31 days".into())); } + if condition.inputs.len() != 1 { return Err(AppError::BadRequest("rate-limit 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())); } @@ -409,7 +453,7 @@ fn compile_flow(state: &AppState, mut flow: crate::models::Flow) -> Result<(crat 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") + matches!(current.kind.as_str(), "stable_for" | "delay" | "state_duration" | "on_change" | "rate_limit" | "rolling_stat" | "oscillates") && previous.kind == current.kind && previous.config == current.config && previous.inputs == current.inputs diff --git a/src/engine/automations.rs b/src/engine/automations.rs index f4ea8b3..42c58b1 100644 --- a/src/engine/automations.rs +++ b/src/engine/automations.rs @@ -21,13 +21,11 @@ async fn run_automations(state: &AppState) -> Result<()> { let mut claimed_devices = std::collections::HashSet::::new(); for mut item in automations { - if !item.enabled || !automation_ready(&item) { continue; } + if !item.enabled { continue; } + let ready = automation_ready(&item); let should_fire = match item.trigger_kind.as_str() { - "temperature_above" => find_temperature(&devices, item.trigger_device_id.as_deref()) - .zip(item.threshold).map(|(t, threshold)| t > threshold).unwrap_or(false), - "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()), + // Stateful Flow blocks must keep observing while an action is in cooldown. Otherwise + // stable/change-duration state could silently span an unobserved interval. "flow" => { let before_runtime = item.flow_runtime.clone(); let result = flow_conditions_match(state, &devices, &mut item).await; @@ -40,9 +38,14 @@ async fn run_automations(state: &AppState) -> Result<()> { } } }, + "temperature_above" if ready => find_temperature(&devices, item.trigger_device_id.as_deref()) + .zip(item.threshold).map(|(t, threshold)| t > threshold).unwrap_or(false), + "temperature_below" if ready => find_temperature(&devices, item.trigger_device_id.as_deref()) + .zip(item.threshold).map(|(t, threshold)| t < threshold).unwrap_or(false), + "time" if ready => time_automation_due(&item, Local::now()), _ => false, }; - if !should_fire { continue; } + if !ready || !should_fire { continue; } // API edits/deletes and execution share one short ownership window. If the rule // changed since this cycle snapshot was taken, skip it now and evaluate the new @@ -153,7 +156,9 @@ async fn run_automations(state: &AppState) -> Result<()> { match result { Ok(true) => { for device_id in target_devices { claimed_devices.insert(device_id); } - item.last_fired_at = Some(Utc::now()); + let fired_at = Utc::now(); + flow_record_rate_limited_execution(&mut item, fired_at.clone()); + item.last_fired_at = Some(fired_at); item.updated_at = Utc::now(); state.db.save_automation(&item)?; state.log("info", "automation.fired", &format!("Automation {} fired", item.name), json!({ @@ -369,6 +374,53 @@ fn flow_timed_gate_update( now.signed_duration_since(since.clone()).num_seconds() >= seconds as i64 } +fn flow_state_duration_update( + state: &mut crate::models::FlowRuntimeNodeState, + input: bool, + min_seconds: u64, + max_seconds: Option, + now: DateTime, +) -> (bool, u64) { + if !input { state.since = None; return (false, 0); } + let since = state.since.get_or_insert_with(|| now.clone()); + let elapsed = now.signed_duration_since(since.clone()).num_seconds().max(0) as u64; + let within_min = elapsed >= min_seconds; + let within_max = max_seconds.map(|max| elapsed <= max).unwrap_or(true); + (within_min && within_max, elapsed) +} + +fn flow_change_gate_update(state: &mut crate::models::FlowRuntimeNodeState, current: Value) -> bool { + let changed = state.last_value.as_ref().is_some_and(|previous| previous != ¤t); + state.last_value = Some(current); + changed +} + +fn flow_rate_limit_status( + state: &mut crate::models::FlowRuntimeNodeState, + max_count: usize, + period_seconds: u64, + now: DateTime, +) -> (bool, usize) { + let cutoff = now - chrono::Duration::seconds(period_seconds as i64); + state.samples.retain(|item| item.at >= cutoff); + let used = state.samples.len(); + (used < max_count, used) +} + +fn flow_record_rate_limited_execution(item: &mut Automation, now: DateTime) { + let rate_limits = item.flow_conditions.iter() + .filter(|condition| condition.kind == "rate_limit") + .filter(|condition| item.flow_runtime.get(&condition.id).and_then(|state| state.last_value.as_ref()).and_then(Value::as_bool) == Some(true)) + .map(|condition| (condition.id.clone(), condition.config.get("period_seconds").and_then(Value::as_u64).unwrap_or(3600))) + .collect::>(); + for (node_id, period_seconds) in rate_limits { + let state = item.flow_runtime.entry(node_id).or_default(); + let cutoff = now.clone() - chrono::Duration::seconds(period_seconds as i64); + state.samples.retain(|sample| sample.at >= cutoff); + state.samples.push(crate::models::FlowRuntimeSample { at: now.clone(), value: 1.0 }); + } +} + fn flow_rolling_stat_update( state: &mut crate::models::FlowRuntimeNodeState, sample: f64, @@ -606,6 +658,7 @@ pub async fn evaluate_flow_conditions_trace( } let mut values = HashMap::::new(); + let mut actual_values = HashMap::::new(); let mut final_id = None::; for condition in conditions { if condition.id.is_empty() { return Ok((false, trace)); } @@ -634,6 +687,49 @@ pub async fn evaluate_flow_conditions_trace( } else { false }; (value, json!({"input": input, "since": since_value, "seconds": seconds})) } + "state_duration" => { + let input = condition.inputs.len() == 1 && values.get(&condition.inputs[0]).copied().unwrap_or(false); + let min_seconds = condition.config.get("min_seconds").and_then(Value::as_u64).unwrap_or(0); + let max_seconds = condition.config.get("max_seconds").and_then(Value::as_u64); + let mut since_value = None; + let mut elapsed_seconds = 0u64; + let value = if let Some(map) = runtime.as_deref_mut() { + let state = map.entry(condition.id.clone()).or_default(); + let (value, elapsed) = flow_state_duration_update(state, input, min_seconds, max_seconds, now.with_timezone(&Utc)); + since_value = state.since.clone(); + elapsed_seconds = elapsed; + value + } else { false }; + (value, json!({"input": input, "since": since_value, "elapsed_seconds": elapsed_seconds, "min_seconds": min_seconds, "max_seconds": max_seconds})) + } + "on_change" => { + let input_id = condition.inputs.first().cloned().unwrap_or_default(); + let input = condition.inputs.len() == 1 && values.get(&input_id).copied().unwrap_or(false); + let mode = condition.config.get("mode").and_then(Value::as_str).unwrap_or("result"); + let observed = if mode == "value" { actual_values.get(&input_id).cloned() } else { Some(json!(input)) }; + let mut changed = false; + let mut previous = None; + if let (Some(current), Some(map)) = (observed.clone(), runtime.as_deref_mut()) { + let state = map.entry(condition.id.clone()).or_default(); + previous = state.last_value.clone(); + changed = flow_change_gate_update(state, current); + } + (input && changed, json!({"input": input, "mode": mode, "previous": previous, "current": observed, "changed": changed})) + } + "rate_limit" => { + let input = condition.inputs.len() == 1 && values.get(&condition.inputs[0]).copied().unwrap_or(false); + let max_count = condition.config.get("max_count").and_then(Value::as_u64).unwrap_or(1) as usize; + let period_seconds = condition.config.get("period_seconds").and_then(Value::as_u64).unwrap_or(3600); + let mut used = 0usize; + let available = if let Some(map) = runtime.as_deref_mut() { + let state = map.entry(condition.id.clone()).or_default(); + let (available, count) = flow_rate_limit_status(state, max_count, period_seconds, now.with_timezone(&Utc)); + used = count; + state.last_value = Some(json!(input && available)); + available + } else { false }; + (input && available, json!({"input": input, "used": used, "max_count": max_count, "period_seconds": period_seconds, "remaining": max_count.saturating_sub(used)})) + } "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(""); @@ -712,6 +808,7 @@ pub async fn evaluate_flow_conditions_trace( _ => (false, Value::Null), }; values.insert(condition.id.clone(), matched); + actual_values.insert(condition.id.clone(), actual.clone()); final_id = Some(condition.id.clone()); let expected = condition.config.get("value").cloned(); trace.push(json!({ diff --git a/src/engine/tests.rs b/src/engine/tests.rs index e1a9264..f114020 100644 --- a/src/engine/tests.rs +++ b/src/engine/tests.rs @@ -115,6 +115,37 @@ mod tests { assert!(!flow_timed_gate_update(&mut state, true, 30, start + chrono::Duration::seconds(32))); } + #[test] + fn flow_state_duration_respects_minimum_maximum_and_reset() { + let mut state = crate::models::FlowRuntimeNodeState::default(); + let start = Utc.with_ymd_and_hms(2026, 9, 2, 12, 0, 0).unwrap(); + assert_eq!(flow_state_duration_update(&mut state, true, 10, Some(30), start), (false, 0)); + assert_eq!(flow_state_duration_update(&mut state, true, 10, Some(30), start + chrono::Duration::seconds(10)), (true, 10)); + assert_eq!(flow_state_duration_update(&mut state, true, 10, Some(30), start + chrono::Duration::seconds(30)), (true, 30)); + assert_eq!(flow_state_duration_update(&mut state, true, 10, Some(30), start + chrono::Duration::seconds(31)), (false, 31)); + assert_eq!(flow_state_duration_update(&mut state, false, 10, Some(30), start + chrono::Duration::seconds(32)), (false, 0)); + assert!(state.since.is_none()); + } + + #[test] + fn flow_change_gate_ignores_first_observation_and_detects_later_change() { + let mut state = crate::models::FlowRuntimeNodeState::default(); + assert!(!flow_change_gate_update(&mut state, json!("off"))); + assert!(!flow_change_gate_update(&mut state, json!("off"))); + assert!(flow_change_gate_update(&mut state, json!("on"))); + assert!(!flow_change_gate_update(&mut state, json!("on"))); + } + + #[test] + fn flow_rate_limit_prunes_rolling_window_and_blocks_at_limit() { + let mut state = crate::models::FlowRuntimeNodeState::default(); + let start = Utc.with_ymd_and_hms(2026, 9, 2, 12, 0, 0).unwrap(); + state.samples.push(crate::models::FlowRuntimeSample { at: start, value: 1.0 }); + state.samples.push(crate::models::FlowRuntimeSample { at: start + chrono::Duration::seconds(10), value: 1.0 }); + assert_eq!(flow_rate_limit_status(&mut state, 2, 60, start + chrono::Duration::seconds(20)), (false, 2)); + assert_eq!(flow_rate_limit_status(&mut state, 2, 60, start + chrono::Duration::seconds(61)), (true, 1)); + } + #[test] fn flow_rolling_statistics_compute_mean_median_and_prune_window() { let mut state = crate::models::FlowRuntimeNodeState::default(); diff --git a/src/models/automation.rs b/src/models/automation.rs index 3ec6a81..4a41b75 100644 --- a/src/models/automation.rs +++ b/src/models/automation.rs @@ -32,6 +32,9 @@ pub struct FlowRuntimeNodeState { pub since: Option>, #[serde(default)] pub samples: Vec, + /// Last observed raw/result value for edge/change detection blocks. + #[serde(default)] + pub last_value: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] @@ -89,7 +92,7 @@ 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). + /// Durable state for stateful Flow blocks (timers, change detection, rate limits and rolling samples). #[serde(default)] pub flow_runtime: BTreeMap, pub created_at: DateTime, diff --git a/web/index.html b/web/index.html index b40a3a5..fd0d2cc 100644 --- a/web/index.html +++ b/web/index.html @@ -704,7 +704,7 @@
Czas
Operacje czasu -
+
Sensory / wartości
Logika
@@ -719,7 +719,10 @@
Start by adding blocksConditions go on the left, actions on the right.
-
Interpretation
+
+
Interpretation
+
Flow cycle
+
diff --git a/web/js/flows.js b/web/js/flows.js index d5988d3..9c33e59 100644 --- a/web/js/flows.js +++ b/web/js/flows.js @@ -4,6 +4,9 @@ const FLOW_NODE_META = Object.freeze({ date_range: { titleKey: 'flow.node.dateRange', category: 'time' }, cron_trigger: { titleKey: 'flow.node.cronTrigger', category: 'trigger' }, stable_for: { titleKey: 'flow.node.stableFor', category: 'timeop' }, + state_duration: { titleKey: 'flow.node.stateDuration', category: 'timeop' }, + on_change: { titleKey: 'flow.node.onChange', category: 'timeop' }, + rate_limit: { titleKey: 'flow.node.rateLimit', category: 'timeop' }, delay: { titleKey: 'flow.node.delay', category: 'timeop' }, rolling_stat: { titleKey: 'flow.node.rollingStat', category: 'sensor' }, oscillates: { titleKey: 'flow.node.oscillates', category: 'sensor' }, @@ -64,6 +67,9 @@ function flowDefaultConfig(kind) { 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 === 'state_duration') return { min_seconds: 60, max_seconds: 600 }; + if (kind === 'on_change') return { mode: 'result' }; + if (kind === 'rate_limit') return { max_count: 3, period_seconds: 3600 }; 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: '' }; @@ -260,6 +266,9 @@ function flowNodeSummary(node) { 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 === 'state_duration') return `${flowDurationLabel(c.min_seconds || 0)}–${c.max_seconds == null ? '∞' : flowDurationLabel(c.max_seconds)}`; + if (node.kind === 'on_change') return c.mode === 'value' ? tr('flow.changeModeValue') : tr('flow.changeModeResult'); + if (node.kind === 'rate_limit') return `${Number(c.max_count || 1)} × / ${flowDurationLabel(c.period_seconds || 3600)}`; 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)}`; @@ -315,7 +324,7 @@ function renderFlowEditor() { }).join(''); $('#flowEmptyHint').hidden = draft.nodes.length > 0; const selectionCount = $('#flowSelectionCount'); if (selectionCount) selectionCount.textContent = (app.flowSelectedNodeIds || []).length ? tr('flow.selectedCount', { count:(app.flowSelectedNodeIds || []).length }) : ''; - renderFlowEdges(); renderFlowInspector(); renderFlowInterpretation(); refreshFlowSharedInputCurrentValues(); + renderFlowEdges(); renderFlowInspector(); renderFlowInterpretation(); renderFlowRuntimeInfo(); refreshFlowSharedInputCurrentValues(); const status = $('#flowCompileStatus'); status.textContent = tr('flow.compileCount', { schedules: draft.compiled_schedule_ids?.length || 0, automations: draft.compiled_automation_ids?.length || 0 }); } @@ -363,6 +372,9 @@ function renderFlowInspector() { 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 === 'state_duration') fields = `

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

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

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

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

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

`; 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'))}

`; } @@ -437,6 +449,50 @@ function renderFlowInterpretation() { target.textContent = actions.map(action => `${flowExpressionSummary(action.id)} → ${flowNodeTitle(FLOW_NODE_META[action.kind])}: ${flowNodeSummary(action)}`).join(' · '); } +function flowCronHumanSummary(expression) { + const raw = String(expression || '').trim(); + const parts = raw.split(/\s+/); + if (parts.length !== 5) return tr('flow.cronCustom', { expression: raw || '—' }); + const [minute, hour, day, month, weekday] = parts; + const allDays = day === '*' && month === '*' && weekday === '*'; + if (minute === '*' && hour === '*' && allDays) return tr('flow.cronEveryMinute'); + const everyMinutes = minute.match(/^\*\/(\d+)$/); + if (everyMinutes && hour === '*' && allDays) return tr('flow.cronEveryMinutes', { minutes: everyMinutes[1] }); + if (/^\d{1,2}$/.test(minute) && hour === '*' && allDays) return tr('flow.cronHourlyAt', { minute: String(Number(minute)).padStart(2, '0') }); + if (/^\d{1,2}$/.test(minute) && /^\d{1,2}$/.test(hour) && allDays) return tr('flow.cronDailyAt', { time: `${String(Number(hour)).padStart(2, '0')}:${String(Number(minute)).padStart(2, '0')}` }); + return tr('flow.cronCustom', { expression: raw }); +} + +function renderFlowRuntimeInfo() { + const target = $('#flowRuntimeSummary'); + const container = $('#flowRuntimeInfo'); + if (!target || !app.flowDraft) return; + const seconds = Math.max(2, Number(app.settings?.zone_interval_seconds || 5)); + const actions = app.flowDraft.nodes.filter(node => ['action','haaction'].includes(FLOW_NODE_META[node.kind]?.category)); + if (!actions.length) { + target.textContent = tr('flow.runtimeNoActions', { seconds }); + if (container) container.title = tr('flow.runtimeHint'); + return; + } + const cronExpressions = new Set(); + let hasActionWithoutCron = false; + actions.forEach(action => { + const crons = flowUpstreamNodes(action.id).filter(node => node.kind === 'cron_trigger'); + if (!crons.length) hasActionWithoutCron = true; + crons.forEach(node => cronExpressions.add(String(node.config?.expression || '').trim())); + }); + if (!cronExpressions.size) { + target.textContent = tr('flow.runtimeWithoutCron', { seconds }); + } else if (!hasActionWithoutCron && cronExpressions.size === 1) { + target.textContent = tr('flow.runtimeWithCron', { seconds, cron: flowCronHumanSummary([...cronExpressions][0]) }); + } else if (!hasActionWithoutCron) { + target.textContent = tr('flow.runtimeWithMultipleCrons', { seconds, count: cronExpressions.size }); + } else { + target.textContent = tr('flow.runtimeMixed', { seconds, count: cronExpressions.size }); + } + if (container) container.title = tr('flow.runtimeHint'); +} + function flowUpstreamNodes(id) { if (!app.flowDraft) return []; const byId = new Map(app.flowDraft.nodes.map(node => [node.id, node])); const seen = new Set(), stack = [id], out = []; @@ -921,6 +977,10 @@ function updateFlowConfig(input) { else if (key === 'value' && node.kind === 'constant') node.config[key] = input.value === 'true'; 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 === 'min_seconds' && node.kind === 'state_duration') node.config.min_seconds = Math.max(0, Number(input.value || 0)); + else if (key === 'max_seconds' && node.kind === 'state_duration') node.config.max_seconds = input.value === '' ? null : Math.max(0, Number(input.value)); + else if (key === 'max_count' && node.kind === 'rate_limit') node.config.max_count = Math.max(1, Math.floor(Number(input.value || 1))); + else if (key === 'period_seconds' && node.kind === 'rate_limit') node.config.period_seconds = Math.max(1, Math.floor(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)); diff --git a/web/styles.css b/web/styles.css index 9fd3c8b..eae5e99 100644 --- a/web/styles.css +++ b/web/styles.css @@ -6213,8 +6213,12 @@ body.flow-editor-open { overflow:hidden; } .flow-editor .flow-port-out { right:-8px; } .flow-editor .flow-port:hover,.flow-editor .flow-port.armed { background:var(--accent); transform:scale(1.15); } .flow-empty-hint { position:absolute; z-index:0; left:50%; top:42%; transform:translate(-50%,-50%); text-align:center; display:grid; gap:5px; color:var(--muted); pointer-events:none; } -.flow-natural-preview { display:grid; grid-template-columns:auto 1fr; gap:14px; align-items:center; min-height:58px; padding:10px 18px; border-top:1px solid var(--line); background:var(--surface); } +.flow-natural-preview { display:flex; justify-content:space-between; gap:24px; align-items:center; min-height:58px; padding:10px 18px; border-top:1px solid var(--line); background:var(--surface); } +.flow-preview-item { display:grid; grid-template-columns:auto minmax(0,1fr); gap:10px; align-items:baseline; min-width:0; } +.flow-preview-interpretation { flex:1 1 auto; } +.flow-preview-runtime { flex:0 0 auto; justify-content:end; text-align:right; } .flow-natural-preview span { color:var(--muted); font-size:.86rem; line-height:1.4; } +.flow-preview-runtime span { white-space:nowrap; } .flow-inspector-head { display:flex; align-items:flex-start; justify-content:space-between; gap:10px; margin-bottom:18px; } .flow-inspector-head h3 { margin:2px 0 0; } .flow-inspector label { display:grid; gap:6px; margin-bottom:13px; color:var(--muted); font-size:.82rem; } @@ -6237,7 +6241,10 @@ body.flow-editor-open { overflow:hidden; } .flow-inspector { position:absolute; right:0; top:138px; bottom:0; width:min(310px,86vw); z-index:8; box-shadow:-12px 0 30px rgba(0,0,0,.28); } .flow-palette { padding:10px; } .flow-palette-group button { font-size:.76rem; padding:8px; } - .flow-natural-preview { grid-template-columns:1fr; gap:4px; } + .flow-natural-preview { flex-direction:column; align-items:stretch; gap:6px; } + .flow-preview-item { grid-template-columns:1fr; gap:2px; } + .flow-preview-runtime { justify-content:start; text-align:left; } + .flow-preview-runtime span { white-space:normal; } } /* Flow diagnostics, templates and portable Flow files */