This commit is contained in:
Mateusz Gruszczyński
2026-09-02 22:58:55 +02:00
parent db6d2f09db
commit 563523d2e2
18 changed files with 420 additions and 62 deletions
Generated
+1 -1
View File
@@ -633,7 +633,7 @@ dependencies = [
[[package]]
name = "gree-controller"
version = "0.11.0"
version = "0.11.1"
dependencies = [
"aes",
"aes-gcm",
+1 -1
View File
@@ -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"
+17 -17
View File
@@ -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
+2 -2
View File
@@ -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.
+4 -4
View File
@@ -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-<stable-unique-id>`, 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
+7 -8
View File
@@ -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,
@@ -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",
+26 -1
View File
@@ -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 maxmin 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 maxmin 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}"
}
}
+26 -1
View File
@@ -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 (maxmin) 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 (maxmin) 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}"
}
}
+1 -1
View File
@@ -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
+73 -9
View File
@@ -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}')
+46 -2
View File
@@ -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::<Vec<_>>();
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::<Vec<_>>();
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::<String, Vec<String>>::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
+105 -8
View File
@@ -21,13 +21,11 @@ async fn run_automations(state: &AppState) -> Result<()> {
let mut claimed_devices = std::collections::HashSet::<String>::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<u64>,
now: DateTime<Utc>,
) -> (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 != &current);
state.last_value = Some(current);
changed
}
fn flow_rate_limit_status(
state: &mut crate::models::FlowRuntimeNodeState,
max_count: usize,
period_seconds: u64,
now: DateTime<Utc>,
) -> (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<Utc>) {
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::<Vec<_>>();
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::<String, bool>::new();
let mut actual_values = HashMap::<String, Value>::new();
let mut final_id = None::<String>;
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!({
+31
View File
@@ -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();
+4 -1
View File
@@ -32,6 +32,9 @@ pub struct FlowRuntimeNodeState {
pub since: Option<DateTime<Utc>>,
#[serde(default)]
pub samples: Vec<FlowRuntimeSample>,
/// Last observed raw/result value for edge/change detection blocks.
#[serde(default)]
pub last_value: Option<Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
@@ -89,7 +92,7 @@ pub struct Automation {
pub flow_id: Option<String>,
#[serde(default)]
pub flow_node_id: Option<String>,
/// 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<String, FlowRuntimeNodeState>,
pub created_at: DateTime<Utc>,
+5 -2
View File
@@ -704,7 +704,7 @@
<div class="flow-palette-group flow-palette-time"><span data-i18n="flow.time">Czas</span>
<button type="button" data-flow-add="weekday" data-i18n="flow.node.weekday">Dni tygodnia</button><button type="button" data-flow-add="time_range" data-i18n="flow.node.timeRange">Przedział godzin</button><button type="button" data-flow-add="date_range" data-i18n="flow.node.dateRange">Zakres dat</button><button type="button" data-flow-add="night_mode" data-i18n="flow.node.nightMode">Tryb nocny</button></div>
<div class="flow-palette-group flow-palette-timeop"><span data-i18n="flow.timeOps">Operacje czasu</span>
<button type="button" data-flow-add="stable_for" data-i18n="flow.node.stableFor">Utrzymuje się przez…</button><button type="button" data-flow-add="delay" data-i18n="flow.node.delay">Odczekaj…</button></div>
<button type="button" data-flow-add="stable_for" data-i18n="flow.node.stableFor">Utrzymuje się przez…</button><button type="button" data-flow-add="state_duration" data-i18n="flow.node.stateDuration">Czas trwania stanu</button><button type="button" data-flow-add="on_change" data-i18n="flow.node.onChange">Tylko przy zmianie</button><button type="button" data-flow-add="rate_limit" data-i18n="flow.node.rateLimit">Maks. X razy / okres</button><button type="button" data-flow-add="delay" data-i18n="flow.node.delay">Odczekaj…</button></div>
<div class="flow-palette-group flow-palette-sensor"><span data-i18n="flow.sensors">Sensory / wartości</span>
<button type="button" data-flow-add="outdoor_temperature" data-i18n="flow.node.outdoorTemperature">Temperatura zewn.</button><button type="button" data-flow-add="device_temperature" data-i18n="flow.node.deviceTemperature">Temperatura urządzenia</button><button type="button" data-flow-add="zone_temperature" data-i18n="flow.node.zoneTemperature">Temperatura strefy</button><button type="button" data-flow-add="house_mode" data-i18n="flow.node.houseMode">Tryb domu</button><button type="button" data-flow-add="device_state" data-i18n="flow.node.deviceState">Stan urządzenia</button><button type="button" data-flow-add="zone_state" data-i18n="flow.node.zoneState">Stan termostatu</button><button type="button" data-flow-add="group_state" data-i18n="flow.node.groupState">Stan grupy</button><button type="button" data-flow-add="ha_state" data-i18n="flow.node.haState">HA: stan</button><button type="button" data-flow-add="ha_numeric" data-i18n="flow.node.haNumeric">HA: wartość</button><button type="button" data-flow-add="ha_attribute" data-i18n="flow.node.haAttribute">HA: atrybut</button><button type="button" data-flow-add="ha_available" data-i18n="flow.node.haAvailable">HA: dostępność</button><button type="button" data-flow-add="constant" data-i18n="flow.node.constant">Stała testowa</button><button type="button" data-flow-add="shared_input" data-i18n="flow.node.sharedInput">Wspólne wejście</button><button type="button" data-flow-add="rolling_stat" data-i18n="flow.node.rollingStat">Średnia / mediana</button><button type="button" data-flow-add="oscillates" data-i18n="flow.node.oscillates">Wartość oscyluje</button></div>
<div class="flow-palette-group flow-palette-logic"><span data-i18n="flow.logic">Logika</span><button type="button" data-flow-add="logic_and" data-i18n="flow.node.and">AND</button><button type="button" data-flow-add="logic_or" data-i18n="flow.node.or">OR</button><button type="button" data-flow-add="logic_not" data-i18n="flow.node.not">NOT</button></div>
@@ -719,7 +719,10 @@
<svg id="flowEdges" class="flow-edges" aria-hidden="true"></svg><div id="flowNodes" class="flow-nodes"></div>
<div id="flowEmptyHint" class="flow-empty-hint"><strong data-i18n="flow.emptyCanvas">Start by adding blocks</strong><span data-i18n="flow.emptyCanvasHint">Conditions go on the left, actions on the right.</span></div>
</div>
<div class="flow-natural-preview"><strong data-i18n="flow.interpretation">Interpretation</strong><span id="flowInterpretation"></span></div>
<div class="flow-natural-preview">
<div class="flow-preview-item flow-preview-interpretation"><strong data-i18n="flow.interpretation">Interpretation</strong><span id="flowInterpretation"></span></div>
<div class="flow-preview-item flow-preview-runtime" id="flowRuntimeInfo"><strong data-i18n="flow.runtimeInfo">Flow cycle</strong><span id="flowRuntimeSummary"></span></div>
</div>
</div>
<aside id="flowInspector" class="flow-inspector"><div class="empty compact"><strong data-i18n="flow.selectBlock">Select a block</strong><span data-i18n="flow.selectBlockHint">Its settings will appear here.</span></div></aside>
</div>
+61 -1
View File
@@ -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 = `<div class="two"><label><span>${esc(tr('common.from'))}</span><input type="date" data-flow-config="start" value="${esc(c.start || '')}"></label><label><span>${esc(tr('common.to'))}</span><input type="date" data-flow-config="end" value="${esc(c.end || '')}"></label></div>`;
else if (node.kind === 'cron_trigger') fields = `<label><span>CRON</span><input data-flow-config="expression" value="${esc(c.expression || '*/5 * * * *')}" placeholder="*/5 * * * *"></label><p class="field-note">${esc(tr('flow.cronHint'))}</p>`;
else if (node.kind === 'stable_for') fields = `<label><span>${esc(tr('flow.durationSeconds'))}</span><input type="number" min="1" max="604800" data-flow-config="seconds" value="${Number(c.seconds || 180)}"></label><p class="field-note">${esc(tr('flow.stableForHint'))}</p>`;
else if (node.kind === 'state_duration') fields = `<div class="two"><label><span>${esc(tr('flow.minDurationSeconds'))}</span><input type="number" min="0" max="604800" data-flow-config="min_seconds" value="${Number(c.min_seconds || 0)}"></label><label><span>${esc(tr('flow.maxDurationSeconds'))}</span><input type="number" min="0" max="604800" data-flow-config="max_seconds" value="${c.max_seconds == null ? '' : Number(c.max_seconds)}" placeholder="∞"></label></div><p class="field-note">${esc(tr('flow.stateDurationHint'))}</p>`;
else if (node.kind === 'on_change') fields = `<label><span>${esc(tr('flow.changeMode'))}</span><select data-flow-config="mode"><option value="result" ${c.mode !== 'value' ? 'selected' : ''}>${esc(tr('flow.changeModeResult'))}</option><option value="value" ${c.mode === 'value' ? 'selected' : ''}>${esc(tr('flow.changeModeValue'))}</option></select></label><p class="field-note">${esc(tr('flow.onChangeHint'))}</p>`;
else if (node.kind === 'rate_limit') fields = `<div class="two"><label><span>${esc(tr('flow.maxExecutions'))}</span><input type="number" min="1" max="1000" data-flow-config="max_count" value="${Number(c.max_count || 1)}"></label><label><span>${esc(tr('flow.periodSeconds'))}</span><input type="number" min="1" max="2678400" data-flow-config="period_seconds" value="${Number(c.period_seconds || 3600)}"></label></div><p class="field-note">${esc(tr('flow.rateLimitHint'))}</p>`;
else if (node.kind === 'delay') fields = `<label><span>${esc(tr('flow.durationSeconds'))}</span><input type="number" min="1" max="604800" data-flow-config="seconds" value="${Number(c.seconds || 30)}"></label><p class="field-note">${esc(tr('flow.delayHint'))}</p>`;
else if (node.kind === 'rolling_stat') { const source=c.source || 'outdoor_temperature'; fields = `<label><span>${esc(tr('flow.source'))}</span><select data-flow-config="source">${[['outdoor_temperature',tr('flow.node.outdoorTemperature')],['device_temperature',tr('flow.node.deviceTemperature')],['zone_temperature',tr('flow.node.zoneTemperature')],['ha_numeric',tr('flow.node.haNumeric')]].map(([v,l])=>`<option value="${v}" ${source===v?'selected':''}>${esc(l)}</option>`).join('')}</select></label>${source==='device_temperature'?`<label><span>${esc(tr('common.device'))}</span><select data-flow-config="device_id">${flowSelectOptions(app.devices,c.device_id)}</select></label>`:''}${source==='zone_temperature'?`<label><span>${esc(tr('common.zone'))}</span><select data-flow-config="zone_id">${flowSelectOptions(app.zones,c.zone_id)}</select></label>`:''}${source==='ha_numeric'?`<label><span>entity_id</span><input data-flow-config="entity_id" value="${esc(c.entity_id||'')}" placeholder="sensor.temperature"></label>`:''}<div class="two"><label><span>${esc(tr('flow.statistic'))}</span><select data-flow-config="statistic"><option value="mean" ${c.statistic!=='median'?'selected':''}>${esc(tr('flow.mean'))}</option><option value="median" ${c.statistic==='median'?'selected':''}>${esc(tr('flow.median'))}</option></select></label><label><span>${esc(tr('flow.windowSeconds'))}</span><input type="number" min="10" max="604800" data-flow-config="window_seconds" value="${Number(c.window_seconds||300)}"></label></div>${flowComparisonFields(c,false)}`; }
else if (node.kind === 'oscillates') { const source=c.source || 'outdoor_temperature'; fields = `<label><span>${esc(tr('flow.source'))}</span><select data-flow-config="source">${[['outdoor_temperature',tr('flow.node.outdoorTemperature')],['device_temperature',tr('flow.node.deviceTemperature')],['zone_temperature',tr('flow.node.zoneTemperature')],['ha_numeric',tr('flow.node.haNumeric')]].map(([v,l])=>`<option value="${v}" ${source===v?'selected':''}>${esc(l)}</option>`).join('')}</select></label>${source==='device_temperature'?`<label><span>${esc(tr('common.device'))}</span><select data-flow-config="device_id">${flowSelectOptions(app.devices,c.device_id)}</select></label>`:''}${source==='zone_temperature'?`<label><span>${esc(tr('common.zone'))}</span><select data-flow-config="zone_id">${flowSelectOptions(app.zones,c.zone_id)}</select></label>`:''}${source==='ha_numeric'?`<label><span>entity_id</span><input data-flow-config="entity_id" value="${esc(c.entity_id||'')}" placeholder="sensor.temperature"></label>`:''}<div class="two"><label><span>${esc(tr('flow.windowSeconds'))}</span><input type="number" min="10" max="604800" data-flow-config="window_seconds" value="${Number(c.window_seconds||300)}"></label><label><span>${esc(tr('flow.minSpan'))}</span><input type="number" min="0.001" step="0.1" data-flow-config="min_span" value="${Number(c.min_span??1)}"></label></div><label><span>${esc(tr('flow.minDirectionChanges'))}</span><input type="number" min="1" max="1000" data-flow-config="min_direction_changes" value="${Number(c.min_direction_changes||2)}"></label><p class="field-note">${esc(tr('flow.oscillatesHint'))}</p>`; }
@@ -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));
+9 -2
View File
@@ -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 */