v0.11.1
This commit is contained in:
Generated
+1
-1
@@ -633,7 +633,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gree-controller"
|
||||
version = "0.11.0"
|
||||
version = "0.11.1"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
|
||||
+1
-1
@@ -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
@@ -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,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
@@ -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
@@ -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
@@ -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}"
|
||||
}
|
||||
}
|
||||
|
||||
+26
-1
@@ -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}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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 != ¤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<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!({
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 */
|
||||
|
||||
Reference in New Issue
Block a user