v0.11.2
This commit is contained in:
Generated
+1
-1
@@ -633,7 +633,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "gree-controller"
|
name = "gree-controller"
|
||||||
version = "0.11.1"
|
version = "0.11.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes",
|
"aes",
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "gree-controller"
|
name = "gree-controller"
|
||||||
version = "0.11.1"
|
version = "0.11.2"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
authors = ["GREE Controller contributors"]
|
authors = ["GREE Controller contributors"]
|
||||||
description = "Standalone local GREE HVAC controller with Web UI, SQLite and Home Assistant sensor support"
|
description = "Standalone local GREE HVAC controller with Web UI, SQLite and Home Assistant sensor support"
|
||||||
|
|||||||
+15
-15
@@ -1,12 +1,12 @@
|
|||||||
d67af429e4da9ce08e9d2f2a8472849ffbd70d135b1c5da535a076026794d04c ./.env.example
|
d67af429e4da9ce08e9d2f2a8472849ffbd70d135b1c5da535a076026794d04c ./.env.example
|
||||||
a4ec3874a2e3ab1bad28fb40bb620f7b01f64d01ad9b699306bf70ada31227db ./.gitignore
|
a4ec3874a2e3ab1bad28fb40bb620f7b01f64d01ad9b699306bf70ada31227db ./.gitignore
|
||||||
0908ab9051cecea10d593240f22cab54ea1c5b7111f9090037fa03fe4a009df9 ./Cargo.lock
|
a40c2764fb504ebe70c8cd1cf7f1f1c9f5f870a5e51e5a4711eac6721d6cc09b ./Cargo.lock
|
||||||
81dfad1866f4e431282fb7e7b67367fbd01f983db593358c27ffbd2100b7cf84 ./Cargo.toml
|
aa1d1bc6b03f0c0f4719a0c6dcb0725980da2ce9dc37f3476b14372da3468f6a ./Cargo.toml
|
||||||
19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE
|
19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE
|
||||||
dc9711f3c80bde54d4db52960dc1fc6583893bb52b76727ac261d9bbb7c3c3f9 ./README.md
|
f2ff4364706d1906e9961c56f311fb056c166f63a1b0ec184e844bc02be9c33f ./README.md
|
||||||
41dfdc6d099b54f87d4dd51f696122b3c88d3bf3ddf420bea8f4bc621ba920b0 ./build.rs
|
41dfdc6d099b54f87d4dd51f696122b3c88d3bf3ddf420bea8f4bc621ba920b0 ./build.rs
|
||||||
7c096c62872e570a3fce80253497a42c18248570e785dfad9acefe89bf597e25 ./docs/API.md
|
a3708b5708784936ff2509ccf094fc9926071560452cb0a07e5d3d283e265d7e ./docs/API.md
|
||||||
2de6eea12eb0ab5ef25eb220bc3c161e31419531b64e8eb761191e9ab3c4d3ea ./docs/FLOW.md
|
2e1e18fd8167dabfe2469c26e85cce62486c7cb6a502c63c6f6b0cd74d5885e0 ./docs/FLOW.md
|
||||||
a0893b2a56eb1523f1a72871842e9be2139a5fafba1f51ae942fc407a6e4ca34 ./home-assistant/README.md
|
a0893b2a56eb1523f1a72871842e9be2139a5fafba1f51ae942fc407a6e4ca34 ./home-assistant/README.md
|
||||||
f8e8559fe10fe523ac5bc9aac25c6e26e862f679d502e8f3c39f38a0a8e40911 ./home-assistant/custom_components/gree_controller/__init__.py
|
f8e8559fe10fe523ac5bc9aac25c6e26e862f679d502e8f3c39f38a0a8e40911 ./home-assistant/custom_components/gree_controller/__init__.py
|
||||||
6910589f27960a28d4de9735884a7e5376e455cd885f19ce2b55947fcd135114 ./home-assistant/custom_components/gree_controller/api.py
|
6910589f27960a28d4de9735884a7e5376e455cd885f19ce2b55947fcd135114 ./home-assistant/custom_components/gree_controller/api.py
|
||||||
@@ -26,8 +26,8 @@ cca65482e36d48035aca178121a378fe7d578d600acff267ae81a6399c0da653 ./home-assista
|
|||||||
13f30e2dcdcedbd1b6c3f99c2335e0487108fd72c8e86922368b84f2fa2038ae ./home-assistant/custom_components/gree_controller/translations/pl.json
|
13f30e2dcdcedbd1b6c3f99c2335e0487108fd72c8e86922368b84f2fa2038ae ./home-assistant/custom_components/gree_controller/translations/pl.json
|
||||||
4513070521d3dda0efb0d974a86ba674494cfb2b66fe9e5cac5b1b0430dede97 ./home-assistant/generated/gree_controller_entities.example.json
|
4513070521d3dda0efb0d974a86ba674494cfb2b66fe9e5cac5b1b0430dede97 ./home-assistant/generated/gree_controller_entities.example.json
|
||||||
253a0bc912786e67ea7fc92a64e4a510ad973bec343a88ccfb1f28fca3e8cf01 ./lang/README.md
|
253a0bc912786e67ea7fc92a64e4a510ad973bec343a88ccfb1f28fca3e8cf01 ./lang/README.md
|
||||||
12882bf4351aa5e0f6adac4ccc61205a3f95c4f1ec9853d322ee7b9a4e93d651 ./lang/en.json
|
2cd6bca7f5aa2a4e59f789c5c55229aab7ba52c1f2b9d5742c8f17162b0c4f8f ./lang/en.json
|
||||||
a4f5b11e058b267f9ba60acdc447d401f4d95d1180af786c02c3f794a83a6146 ./lang/pl.json
|
13f056aa147c3adb301b7d935adf498bbbf6d6906ec3eb96b3e39cd4c0e90fce ./lang/pl.json
|
||||||
d8459024f04ca514bd8e9d6bd3af872fb942fd85a7cdd5583f4a9d28aab6faba ./make_zip.py
|
d8459024f04ca514bd8e9d6bd3af872fb942fd85a7cdd5583f4a9d28aab6faba ./make_zip.py
|
||||||
b14233a8987e53bbbdd6770386ba10fa166ec25c1e098275c173c544b37846fc ./presets/bedroom_window_night.json
|
b14233a8987e53bbbdd6770386ba10fa166ec25c1e098275c173c544b37846fc ./presets/bedroom_window_night.json
|
||||||
1960841119c0b673fb2f03621b32237552fd828045304cd221401214b997c910 ./presets/device_resilience.json
|
1960841119c0b673fb2f03621b32237552fd828045304cd221401214b997c910 ./presets/device_resilience.json
|
||||||
@@ -67,7 +67,7 @@ ae21459a261712bcb8d57594528b1648432e8d03a8a38502234829c0dbfef774 ./presets/week
|
|||||||
fdcd9a5055d08037278b842e7ab69265345c5811f0a06867136c511d140bb191 ./presets/window_available_guard.json
|
fdcd9a5055d08037278b842e7ab69265345c5811f0a06867136c511d140bb191 ./presets/window_available_guard.json
|
||||||
804f22123cd3e8db0fac791826c8dd9f758fb866c8e3b5655fb6d25d259dccf1 ./presets/workday_comfort.json
|
804f22123cd3e8db0fac791826c8dd9f758fb866c8e3b5655fb6d25d259dccf1 ./presets/workday_comfort.json
|
||||||
01952aa92b217f8eae2493b88870e2dec595100cd15c4d561ff11ae2b936c46f ./regenerate-sha.sh
|
01952aa92b217f8eae2493b88870e2dec595100cd15c4d561ff11ae2b936c46f ./regenerate-sha.sh
|
||||||
809cad38164d2775d8eea4c72f308b4448642863bec30b531e0e9dcb8f9da418 ./scripts/FILE_MANIFEST.sha256
|
5a8125836ed0c6e0fd98ea7e24867760771210da9f80c98d8d6ec3bcc6c5b150 ./scripts/FILE_MANIFEST.sha256
|
||||||
bb89bac237e750e9b1bf73761d7df97a6b81853091615878c03f13d7b6399aa7 ./scripts/README.md
|
bb89bac237e750e9b1bf73761d7df97a6b81853091615878c03f13d7b6399aa7 ./scripts/README.md
|
||||||
5bc736c7bc76ca80aaa406bb171d2aa91baf4c3aa8695dce0e09b888b6ab3146 ./scripts/common.sh
|
5bc736c7bc76ca80aaa406bb171d2aa91baf4c3aa8695dce0e09b888b6ab3146 ./scripts/common.sh
|
||||||
6403786610ee6d2f628193c25aee0dd058d62e904aa1a31d5f62fdaae0e94b4f ./scripts/configure-gree-network.sh
|
6403786610ee6d2f628193c25aee0dd058d62e904aa1a31d5f62fdaae0e94b4f ./scripts/configure-gree-network.sh
|
||||||
@@ -80,7 +80,7 @@ e00d211e3885e30d7fed1e43b44e6fdad40a67019060156c0641816a93e3365f ./scripts/netw
|
|||||||
81345b6a0b51736bdbc98fd23199b62e4c721b4e7437e02dab7ea79b97dff29a ./scripts/service.sh
|
81345b6a0b51736bdbc98fd23199b62e4c721b4e7437e02dab7ea79b97dff29a ./scripts/service.sh
|
||||||
fec8b0362e763bbc115b5cc5c56f9ebe815730cee161632d81c5dc0ee1475f80 ./scripts/smoke.sh
|
fec8b0362e763bbc115b5cc5c56f9ebe815730cee161632d81c5dc0ee1475f80 ./scripts/smoke.sh
|
||||||
b50782b3742dfbf8a319c60571c968e93fdf8547db747c759edcffae68cb98bf ./scripts/update.sh
|
b50782b3742dfbf8a319c60571c968e93fdf8547db747c759edcffae68cb98bf ./scripts/update.sh
|
||||||
b70f2a66f39ed050f9030ed499f74e8e899e224d6d7c47ed2a1389ce1be11421 ./scripts/verify_flow_logic.py
|
4877f9e8217b6a77fb416722c3778373edac874ed3a583bb016ea76d4ffee7d4 ./scripts/verify_flow_logic.py
|
||||||
da16e16dfb330d528d54d2138b07950da701edc46af1ff174a9f6a54090122b3 ./src/api.rs
|
da16e16dfb330d528d54d2138b07950da701edc46af1ff174a9f6a54090122b3 ./src/api.rs
|
||||||
825370f409124a719043200742c01c3441bc65f483108106140d13fe7bbcba91 ./src/api/assets.rs
|
825370f409124a719043200742c01c3441bc65f483108106140d13fe7bbcba91 ./src/api/assets.rs
|
||||||
6c34b1294d76b0eba4c56bfd0bf69bdd502de0e40838104f6ddf2db4e922683d ./src/api/auth.rs
|
6c34b1294d76b0eba4c56bfd0bf69bdd502de0e40838104f6ddf2db4e922683d ./src/api/auth.rs
|
||||||
@@ -88,7 +88,7 @@ da16e16dfb330d528d54d2138b07950da701edc46af1ff174a9f6a54090122b3 ./src/api.rs
|
|||||||
2317b7cd0c2437c128ac4982d64412c8184ecb5e774825761d61aab6e45fae4a ./src/api/debug_tokens.rs
|
2317b7cd0c2437c128ac4982d64412c8184ecb5e774825761d61aab6e45fae4a ./src/api/debug_tokens.rs
|
||||||
4f82206fb48a291bdc643c96d4d6c917d669c7bdf2a35312c0a056dddecc7062 ./src/api/devices.rs
|
4f82206fb48a291bdc643c96d4d6c917d669c7bdf2a35312c0a056dddecc7062 ./src/api/devices.rs
|
||||||
417940b467c6f00bbf01cc47eb4479880dee9b2f284a80e3662a6d433dc11b92 ./src/api/events.rs
|
417940b467c6f00bbf01cc47eb4479880dee9b2f284a80e3662a6d433dc11b92 ./src/api/events.rs
|
||||||
40a1d010371eb7c5c90ddd881c86f8023bb61ff77eeec83fabd0759d38e241cb ./src/api/flows.rs
|
1bbe45c11b176d1595007942ef7723c7954c60fe20cc5aa18c4dbab00b6f09ff ./src/api/flows.rs
|
||||||
d096000e94997cf9aaf9d3cc567214a78b8b2da7da57a76e2b3519247af6e592 ./src/api/groups.rs
|
d096000e94997cf9aaf9d3cc567214a78b8b2da7da57a76e2b3519247af6e592 ./src/api/groups.rs
|
||||||
a13d4e217fe3ddaa73873ba6e0d1bc93750cf61764d867ff7372274ed21d9599 ./src/api/history.rs
|
a13d4e217fe3ddaa73873ba6e0d1bc93750cf61764d867ff7372274ed21d9599 ./src/api/history.rs
|
||||||
b3ed82de9d885a6325647bff3d0ead6ac46fc74d0250d5e14d75319555ad88d7 ./src/api/house.rs
|
b3ed82de9d885a6325647bff3d0ead6ac46fc74d0250d5e14d75319555ad88d7 ./src/api/house.rs
|
||||||
@@ -96,7 +96,7 @@ b3ed82de9d885a6325647bff3d0ead6ac46fc74d0250d5e14d75319555ad88d7 ./src/api/hous
|
|||||||
bb2a746ecdcc2da5fe54e18b455c7bd19453486dd5c008e71951a2c81d0e7d64 ./src/api/middleware.rs
|
bb2a746ecdcc2da5fe54e18b455c7bd19453486dd5c008e71951a2c81d0e7d64 ./src/api/middleware.rs
|
||||||
50cfd47e44e22cc802f22f97267c157fa5a6d19b51be921a6ca6b1ea7c9799d1 ./src/api/public_settings.rs
|
50cfd47e44e22cc802f22f97267c157fa5a6d19b51be921a6ca6b1ea7c9799d1 ./src/api/public_settings.rs
|
||||||
a1d1a4ac071493b052e6bf20b03be67cf75726de95649e9a73aeba8d22395a15 ./src/api/schedules.rs
|
a1d1a4ac071493b052e6bf20b03be67cf75726de95649e9a73aeba8d22395a15 ./src/api/schedules.rs
|
||||||
09ac8aae6e51f36379908e48b3b18d845d3c0b246277ed945c29e0c648197c4a ./src/api/settings.rs
|
430f446371489a2b2f1532d589c71f60c98c1a92765ccb5e486b08e2da8f31c3 ./src/api/settings.rs
|
||||||
bc1614b9948b8904d0f678c79cc9fffecb75977fbec951aca1ba087527808836 ./src/api/system.rs
|
bc1614b9948b8904d0f678c79cc9fffecb75977fbec951aca1ba087527808836 ./src/api/system.rs
|
||||||
681cccb8d09f4ad9c2e6467a4dbe2f9d991125775a5d2bfc9043daf940fecd29 ./src/api/websocket.rs
|
681cccb8d09f4ad9c2e6467a4dbe2f9d991125775a5d2bfc9043daf940fecd29 ./src/api/websocket.rs
|
||||||
923e7434e8f5789150eb34ffc4d18d25fa701f4a9040bf856d7926016f1936aa ./src/api/zones.rs
|
923e7434e8f5789150eb34ffc4d18d25fa701f4a9040bf856d7926016f1936aa ./src/api/zones.rs
|
||||||
@@ -142,7 +142,7 @@ c63bd6257386969a763f44d0e4fd7b6b19078ec7a7f46e7ec2d73580d2274cab ./src/models.r
|
|||||||
377fc9697a70a03ad35fc3701adf4dbe8687760dc90de9f5c532a4f3b612c762 ./src/models/control_plan.rs
|
377fc9697a70a03ad35fc3701adf4dbe8687760dc90de9f5c532a4f3b612c762 ./src/models/control_plan.rs
|
||||||
8228b9b6d9e8b1d13de29ce88500beacd88c8877893b7a3e41a4bd1e237ca36c ./src/models/defaults.rs
|
8228b9b6d9e8b1d13de29ce88500beacd88c8877893b7a3e41a4bd1e237ca36c ./src/models/defaults.rs
|
||||||
7cdc99a6f0920baf4ac88fbe7e6f1859e8598a00f00b8c3d84f20bc3a4de35f9 ./src/models/device.rs
|
7cdc99a6f0920baf4ac88fbe7e6f1859e8598a00f00b8c3d84f20bc3a4de35f9 ./src/models/device.rs
|
||||||
42f968d9a99ac154b0bd83e5baa498659b1378be4d47d2a05665be641a2fb938 ./src/models/flow.rs
|
aeab5b5acf35d38a54fd1d82e964fe56635ade69c0149ad423df317095d01555 ./src/models/flow.rs
|
||||||
cfbaa853a5185c4be572588577b313a2d618837f7c07a3860d0e8d2cca04ee60 ./src/models/history.rs
|
cfbaa853a5185c4be572588577b313a2d618837f7c07a3860d0e8d2cca04ee60 ./src/models/history.rs
|
||||||
552dfc1717ea88f3a23b1fb7705f1dbb0df7792ca9a6ae7ad7bd275eacef27af ./src/models/integrations.rs
|
552dfc1717ea88f3a23b1fb7705f1dbb0df7792ca9a6ae7ad7bd275eacef27af ./src/models/integrations.rs
|
||||||
268b4211df93f08423e39374ab9ffe93a7eba647c173168821736af299b36549 ./src/models/runtime.rs
|
268b4211df93f08423e39374ab9ffe93a7eba647c173168821736af299b36549 ./src/models/runtime.rs
|
||||||
@@ -176,11 +176,11 @@ e98bdd7204349cce1ec6f57283509697af0bbc72280622a6c3efa6fed242db4f ./web/favicon.
|
|||||||
9ba3f6fa05b72aa989139b2a909982571b2a02055052e4c40104f1e81a9aa7eb ./web/js/README.md
|
9ba3f6fa05b72aa989139b2a909982571b2a02055052e4c40104f1e81a9aa7eb ./web/js/README.md
|
||||||
21b662914590e720dafefe4dc3dccc91dbeb963cce9ae91f007c835d2d0c1fe5 ./web/js/bootstrap.js
|
21b662914590e720dafefe4dc3dccc91dbeb963cce9ae91f007c835d2d0c1fe5 ./web/js/bootstrap.js
|
||||||
72fa280996ba331a05bdb29a1f49b4e61bdd8964953c8ce1db952156620f08fe ./web/js/charts.js
|
72fa280996ba331a05bdb29a1f49b4e61bdd8964953c8ce1db952156620f08fe ./web/js/charts.js
|
||||||
f07600277c60c771c04b1d43c5ed08de6bf518a72660011767b71cde8ec5bde8 ./web/js/core.js
|
782ce96f3ec006c447e8c44c60ce4142e0fd830704a6ef0511a66505c12cb6bf ./web/js/core.js
|
||||||
dfc123885a9b6b5c1d07c12d2d2625ff15aa0844e5cd3736092ccd0a360da39c ./web/js/dashboard.js
|
dfc123885a9b6b5c1d07c12d2d2625ff15aa0844e5cd3736092ccd0a360da39c ./web/js/dashboard.js
|
||||||
f53e2da5c48b55cf0191ffeb970a2c053c66e62d29099e3c5b5fccc8d89440cc ./web/js/entities.js
|
f53e2da5c48b55cf0191ffeb970a2c053c66e62d29099e3c5b5fccc8d89440cc ./web/js/entities.js
|
||||||
9a0e87911f78cd8a969f4c57ce74774f5fa829fdecd79494d9a7f9d5804f9792 ./web/js/events.js
|
9a0e87911f78cd8a969f4c57ce74774f5fa829fdecd79494d9a7f9d5804f9792 ./web/js/events.js
|
||||||
e83d7646ea4f37b746ef550ded3de326ae22a5ae9539cc3cedb71f43d5e122db ./web/js/flows.js
|
8cbc2bc0f31721fbc441bd4be1008b430cae43d4a4035bf72b88eaf32a054c74 ./web/js/flows.js
|
||||||
f06557c6d338259059b990f8f79c91d68ac8ec1f245c302468f08929b5506901 ./web/js/forms.js
|
f06557c6d338259059b990f8f79c91d68ac8ec1f245c302468f08929b5506901 ./web/js/forms.js
|
||||||
dbf36223863ba882c03eccea4516ba8db7acf0c2e72c6c64490cb994f1e20f96 ./web/js/history.js
|
dbf36223863ba882c03eccea4516ba8db7acf0c2e72c6c64490cb994f1e20f96 ./web/js/history.js
|
||||||
7eaa04992ea828dc89f5eaebf674cbbfd160f53eb8d0d6ef0d4e47bd8c28c435 ./web/js/main.js
|
7eaa04992ea828dc89f5eaebf674cbbfd160f53eb8d0d6ef0d4e47bd8c28c435 ./web/js/main.js
|
||||||
@@ -190,6 +190,6 @@ c8c82e88c3715b1bfabf155e36266a4c94f5e2fc03eb39dcdd9d08a1985a997c ./web/js/route
|
|||||||
34513d9ec2e45c60443dffe028eea74f415b8a29016df6af3377a80a4ac0df5b ./web/js/settings-ui.js
|
34513d9ec2e45c60443dffe028eea74f415b8a29016df6af3377a80a4ac0df5b ./web/js/settings-ui.js
|
||||||
aa6a2ba22648de44547efd09f41c4335ebe38ed564f4684e942d7827df40f1a2 ./web/js/settings.js
|
aa6a2ba22648de44547efd09f41c4335ebe38ed564f4684e942d7827df40f1a2 ./web/js/settings.js
|
||||||
6b653e4fe2d4db4ffae6ff8f39b8ce57a10a990dc0000ee4aae51d949afadd52 ./web/manifest.webmanifest
|
6b653e4fe2d4db4ffae6ff8f39b8ce57a10a990dc0000ee4aae51d949afadd52 ./web/manifest.webmanifest
|
||||||
ca526ef93c5b31a212ca71efdadeac4d80659b9a28f281011f461ae772c0272a ./web/styles.css
|
5f8c321809677a12f20bf270c17c7472f6dfc289c03d40c879b92ba10e3f6d6d ./web/styles.css
|
||||||
ae1b03f30b494f474a781a5d32072f1e73eba2b7c768109fc1cc8028cb6662a4 ./web/sw.js
|
ae1b03f30b494f474a781a5d32072f1e73eba2b7c768109fc1cc8028cb6662a4 ./web/sw.js
|
||||||
d505d793ce7cc9485b45b78bba1c0d51887adc7451ab59a42702946e5b991382 ./web/theme-init.js
|
d505d793ce7cc9485b45b78bba1c0d51887adc7451ab59a42702946e5b991382 ./web/theme-init.js
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Self-hosted controller for GREE-compatible air conditioners with a local Web UI, thermostat zones, schedules, Home Assistant integration, history, notifications and a documented HTTP/WebSocket API.
|
Self-hosted controller for GREE-compatible air conditioners with a local Web UI, thermostat zones, schedules, Home Assistant integration, history, notifications and a documented HTTP/WebSocket API.
|
||||||
|
|
||||||
**Current release: 0.11.1**
|
**Current release: 0.11.2**
|
||||||
|
|
||||||
> [Full API reference](docs/API.md) — authentication, every endpoint, request bodies, response models, WebSocket events and examples.
|
> [Full API reference](docs/API.md) — authentication, every endpoint, request bodies, response models, WebSocket events and examples.
|
||||||
|
|
||||||
@@ -426,4 +426,4 @@ The editor supports optimistic revisions, so saving an older copy from another b
|
|||||||
|
|
||||||
Diagnostics include a non-mutating dry-run endpoint/UI with a selectable simulation time, optional per-block sensor/state overrides, a per-node condition trace and an ownership/block reason. Shared Home Assistant Flow inputs can also be tested directly in their editor against the current HA entity state. Flow-scoped execution and dry-run events can be viewed from the editor.
|
Diagnostics include a non-mutating dry-run endpoint/UI with a selectable simulation time, optional per-block sensor/state overrides, a per-node condition trace and an ownership/block reason. Shared Home Assistant Flow inputs can also be tested directly in their editor against the current HA entity state. Flow-scoped execution and dry-run events can be viewed from the editor.
|
||||||
|
|
||||||
Individual Flows can be exported/imported as versioned `gree-controller-flow` JSON. Generated schedules and automations are intentionally excluded from the portable document and are regenerated from the source graph on import. Shared inputs show which Flows reference them and link directly to those editors. The preset library includes 37 categorized scenarios for comfort, energy, safety, night, reliability, Home Assistant heat-source coordination and advanced multi-branch logic.
|
Individual Flows can be exported/imported as versioned `gree-controller-flow` JSON. Generated schedules and automations are intentionally excluded from the portable document and are regenerated from the source graph on import. Incomplete Flows can also be saved as disabled drafts; drafts preserve the editor graph but intentionally generate no schedules or automations until completed and saved normally. Shared inputs show which Flows reference them and link directly to those editors. The preset library includes 37 categorized scenarios for comfort, energy, safety, night, reliability, Home Assistant heat-source coordination and advanced multi-branch logic.
|
||||||
|
|||||||
+6
-6
@@ -1,6 +1,6 @@
|
|||||||
# GREE Controller API reference
|
# GREE Controller API reference
|
||||||
|
|
||||||
HTTP and WebSocket API for GREE Controller **0.11.1**.
|
HTTP and WebSocket API for GREE Controller **0.11.2**.
|
||||||
|
|
||||||
[← Main documentation](../README.md)
|
[← Main documentation](../README.md)
|
||||||
|
|
||||||
@@ -213,7 +213,7 @@ Response:
|
|||||||
{
|
{
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"name": "gree-controller",
|
"name": "gree-controller",
|
||||||
"version": "0.11.1",
|
"version": "0.11.2",
|
||||||
"uptime_seconds": 1234,
|
"uptime_seconds": 1234,
|
||||||
"control_ready": true,
|
"control_ready": true,
|
||||||
"time": "2026-08-30T06:54:00Z"
|
"time": "2026-08-30T06:54:00Z"
|
||||||
@@ -237,7 +237,7 @@ Returns the initial Web UI snapshot:
|
|||||||
"settings": {},
|
"settings": {},
|
||||||
"outdoor_temperature": null,
|
"outdoor_temperature": null,
|
||||||
"system": {
|
"system": {
|
||||||
"version": "0.11.1",
|
"version": "0.11.2",
|
||||||
"uptime_seconds": 1234,
|
"uptime_seconds": 1234,
|
||||||
"auth_required": false,
|
"auth_required": false,
|
||||||
"control_ready": true,
|
"control_ready": true,
|
||||||
@@ -1512,9 +1512,9 @@ curl -H 'Authorization: Bearer gree_controller_RESTRICTED_TOKEN' \
|
|||||||
|
|
||||||
## Visual Flow API
|
## Visual Flow API
|
||||||
|
|
||||||
Flow is the source-of-truth representation for visual schedule/automation logic. `GET /api/flows` lists Flows; `GET /api/flows/:id`, `POST /api/flows`, `PUT /api/flows/:id` and `DELETE /api/flows/:id` manage them. A Flow payload contains `name`, `enabled`, optional `description`, `nodes` and `edges`.
|
Flow is the source-of-truth representation for visual schedule/automation logic. `GET /api/flows` lists Flows; `GET /api/flows/:id`, `POST /api/flows`, `PUT /api/flows/:id` and `DELETE /api/flows/:id` manage them. A Flow payload contains `name`, `enabled`, optional `draft`, optional `description`, `nodes` and `edges`. `draft=true` is reserved for work-in-progress graphs: the backend forces them disabled and stores no generated schedules or automations.
|
||||||
|
|
||||||
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.
|
Saving an executable Flow validates the DAG and compiles it atomically. If the editor receives HTTP 400 during normal save, it can offer to retry with `draft=true`; draft validation preserves the editable graph while allowing missing actions or unfinished semantic wiring. Drafts never execute, and converting an existing Flow to a draft atomically removes its previously generated outputs. 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, 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.
|
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.
|
||||||
|
|
||||||
@@ -1531,7 +1531,7 @@ Cancellation suppresses the same pending intent until a new explicit thermostat/
|
|||||||
|
|
||||||
`GET /api/flows/:id/export` returns a versioned `gree-controller-flow` JSON document containing only the source graph. Generated schedules/automations are not exported.
|
`GET /api/flows/:id/export` returns a versioned `gree-controller-flow` JSON document containing only the source graph. Generated schedules/automations are not exported.
|
||||||
|
|
||||||
`POST /api/flows/import` accepts either that envelope or a direct Flow source payload, creates a new Flow ID/revision, validates it and atomically recompiles outputs.
|
`POST /api/flows/import` accepts either that envelope or a direct Flow source payload, creates a new Flow ID/revision, and preserves draft state. Executable imports are validated and recompiled; draft imports remain disabled with no generated outputs.
|
||||||
|
|
||||||
`POST /api/flows/simulate` accepts `{ flow, flow_id?, at?, overrides?, log? }`. `at` is RFC3339. `overrides` maps Flow node IDs to simulated values. The endpoint validates and compiles the graph, evaluates every action and returns per-node traces plus `matched`, `would_execute` and `blocked_reason` (for example disabled zone/device, manual/local/temporary thermostat ownership, thermostat-output conflict or disabled Flow). It never mutates thermostat/device/group/schedule/automation state. Home Assistant read/attribute/parse failures evaluate safely as false and are visible in the trace instead of accidentally satisfying `NOT`/`neq` logic.
|
`POST /api/flows/simulate` accepts `{ flow, flow_id?, at?, overrides?, log? }`. `at` is RFC3339. `overrides` maps Flow node IDs to simulated values. The endpoint validates and compiles the graph, evaluates every action and returns per-node traces plus `matched`, `would_execute` and `blocked_reason` (for example disabled zone/device, manual/local/temporary thermostat ownership, thermostat-output conflict or disabled Flow). It never mutates thermostat/device/group/schedule/automation state. Home Assistant read/attribute/parse failures evaluate safely as false and are visible in the trace instead of accidentally satisfying `NOT`/`neq` logic.
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ Flow is an authoring/orchestration layer. It does not replace the thermostat, sc
|
|||||||
- Generated rows keep `flow_id` and `flow_node_id`, use stable `flow-<stable-unique-id>` names, and are read-only in the legacy editors.
|
- Generated rows keep `flow_id` and `flow_node_id`, use stable `flow-<stable-unique-id>` names, and are read-only in the legacy editors.
|
||||||
- The Flow source graph plus all of its generated schedule/automation rows are replaced in one SQLite transaction.
|
- The Flow source graph plus all of its generated schedule/automation rows are replaced in one SQLite transaction.
|
||||||
- Flow updates require `expected_revision`; stale editor tabs receive HTTP 409.
|
- Flow updates require `expected_revision`; stale editor tabs receive HTTP 409.
|
||||||
|
- Work-in-progress graphs may be persisted with `draft=true`. Drafts are forced disabled, compile to zero schedules/automations, and can contain unfinished semantic wiring such as a graph without an action. Missing node references and duplicate/empty IDs are still rejected so the editor document remains structurally safe.
|
||||||
|
- Saving a previously executable Flow as a draft atomically deletes its generated outputs, so no stale task can keep running. Completing and saving it normally clears draft status and recompiles it.
|
||||||
|
|
||||||
## Existing control domains remain authoritative
|
## Existing control domains remain authoritative
|
||||||
|
|
||||||
|
|||||||
+8
-1
@@ -1093,6 +1093,13 @@
|
|||||||
"flow.cronEveryMinutes": "CRON every {minutes} min",
|
"flow.cronEveryMinutes": "CRON every {minutes} min",
|
||||||
"flow.cronHourlyAt": "CRON hourly at :{minute}",
|
"flow.cronHourlyAt": "CRON hourly at :{minute}",
|
||||||
"flow.cronDailyAt": "CRON daily at {time}",
|
"flow.cronDailyAt": "CRON daily at {time}",
|
||||||
"flow.cronCustom": "CRON {expression}"
|
"flow.cronCustom": "CRON {expression}",
|
||||||
|
"flow.draft": "Draft",
|
||||||
|
"flow.draftStatus": "Draft · not executable",
|
||||||
|
"flow.draftNoExecution": "Draft — disabled, with no executable tasks",
|
||||||
|
"flow.draftDisabledHint": "A draft is always disabled. Finish the Flow and save it again to create executable tasks.",
|
||||||
|
"flow.draftCannotEnable": "Finish and save the Flow first. A draft cannot be enabled.",
|
||||||
|
"flow.saveAsDraftConfirm": "The Flow cannot be saved as executable:\n\n{reason}\n\nSave it as a draft instead? The draft will be disabled and will not create any tasks or schedules.",
|
||||||
|
"flow.savedAsDraft": "Flow saved as a disabled draft. You can return to it later."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-1
@@ -1093,6 +1093,13 @@
|
|||||||
"flow.cronEveryMinutes": "CRON co {minutes} min",
|
"flow.cronEveryMinutes": "CRON co {minutes} min",
|
||||||
"flow.cronHourlyAt": "CRON co godzinę o :{minute}",
|
"flow.cronHourlyAt": "CRON co godzinę o :{minute}",
|
||||||
"flow.cronDailyAt": "CRON codziennie o {time}",
|
"flow.cronDailyAt": "CRON codziennie o {time}",
|
||||||
"flow.cronCustom": "CRON {expression}"
|
"flow.cronCustom": "CRON {expression}",
|
||||||
|
"flow.draft": "Szkic",
|
||||||
|
"flow.draftStatus": "Szkic · niewykonywany",
|
||||||
|
"flow.draftNoExecution": "Szkic — wyłączony, bez zadań wykonawczych",
|
||||||
|
"flow.draftDisabledHint": "Szkic jest zawsze wyłączony. Dokończ Flow i zapisz go ponownie, aby utworzyć zadania wykonawcze.",
|
||||||
|
"flow.draftCannotEnable": "Najpierw dokończ i zapisz Flow. Szkicu nie można włączyć.",
|
||||||
|
"flow.saveAsDraftConfirm": "Flow nie może zostać zapisany jako wykonywalny:\n\n{reason}\n\nZapisać go jako szkic? Szkic będzie wyłączony i nie utworzy żadnych zadań ani harmonogramów.",
|
||||||
|
"flow.savedAsDraft": "Flow zapisany jako wyłączony szkic. Możesz wrócić do niego później."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,4 +10,4 @@ e00d211e3885e30d7fed1e43b44e6fdad40a67019060156c0641816a93e3365f ./network-debu
|
|||||||
81345b6a0b51736bdbc98fd23199b62e4c721b4e7437e02dab7ea79b97dff29a ./service.sh
|
81345b6a0b51736bdbc98fd23199b62e4c721b4e7437e02dab7ea79b97dff29a ./service.sh
|
||||||
fec8b0362e763bbc115b5cc5c56f9ebe815730cee161632d81c5dc0ee1475f80 ./smoke.sh
|
fec8b0362e763bbc115b5cc5c56f9ebe815730cee161632d81c5dc0ee1475f80 ./smoke.sh
|
||||||
b50782b3742dfbf8a319c60571c968e93fdf8547db747c759edcffae68cb98bf ./update.sh
|
b50782b3742dfbf8a319c60571c968e93fdf8547db747c759edcffae68cb98bf ./update.sh
|
||||||
b70f2a66f39ed050f9030ed499f74e8e899e224d6d7c47ed2a1389ce1be11421 ./verify_flow_logic.py
|
4877f9e8217b6a77fb416722c3778373edac874ed3a583bb016ea76d4ffee7d4 ./verify_flow_logic.py
|
||||||
|
|||||||
@@ -421,6 +421,21 @@ def main():
|
|||||||
check('if !item.enabled { continue; }' in engine and 'if !ready || !should_fire { continue; }' in engine, 'Flow runtime may be skipped during action cooldown')
|
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('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')
|
check('on-change value mode needs one direct source/condition input' in api, 'on-change value-source guard missing')
|
||||||
|
models=(ROOT/'src/models/flow.rs').read_text()
|
||||||
|
core=(ROOT/'web/js/core.js').read_text()
|
||||||
|
settings_api=(ROOT/'src/api/settings.rs').read_text()
|
||||||
|
check('pub draft: bool' in models and '#[serde(default)]' in models, 'Flow draft persistence field missing')
|
||||||
|
check('fn validate_flow_draft_graph' in api, 'draft structural validator missing')
|
||||||
|
check(api.count('if input.draft { validate_flow_draft_graph(&input)?; } else { validate_flow_graph(&input)?; }') >= 3, 'draft create/update/import validation split missing')
|
||||||
|
check('enabled: if draft { false } else { input.enabled }' in api, 'draft does not force Flow disabled')
|
||||||
|
check(api.count('(prepare_draft_flow(flow), vec![], vec![])') >= 3, 'draft save/import may compile executable outputs')
|
||||||
|
check('flow.compiled_schedule_ids.clear()' in api and 'flow.compiled_automation_ids.clear()' in api, 'draft output clearing missing')
|
||||||
|
check('"draft": flow.draft' in api, 'draft state missing from Flow export')
|
||||||
|
check('import contains an executable Flow draft' in settings_api, 'configuration import does not enforce draft safety invariant')
|
||||||
|
check('error.status = response.status' in core, 'frontend API errors do not expose HTTP validation status')
|
||||||
|
check("error.status !== 400" in js and "draft:true" in js and "enabled:false" in js, 'save-as-draft retry flow missing')
|
||||||
|
check("if (flow.draft) return toast(tr('flow.draftCannotEnable')" in js, 'draft quick-enable guard missing')
|
||||||
|
check("draft: app.flowDraft.draft === true" in js, 'draft portability state missing from source payload')
|
||||||
runtime_leaf=engine.split('async fn flow_leaf_observation',1)[1].split('pub async fn evaluate_flow_conditions_trace',1)[0]
|
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','state_duration','on_change','rate_limit','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')
|
check(f'\"{kind}\" =>' in runtime_leaf or (kind == 'shared_input' and 'condition.kind == \"shared_input\"' in runtime_leaf), f'{kind}: runtime leaf implementation missing')
|
||||||
@@ -430,14 +445,14 @@ def main():
|
|||||||
for category in ('trigger','time','timeop','sensor','logic','action','haaction'):
|
for category in ('trigger','time','timeop','sensor','logic','action','haaction'):
|
||||||
check(f'flow-palette-{category}' in html, f'palette category {category} missing')
|
check(f'flow-palette-{category}' in html, f'palette category {category} missing')
|
||||||
css=(ROOT/'web/styles.css').read_text()
|
css=(ROOT/'web/styles.css').read_text()
|
||||||
for marker in ('.flow-palette-group button', 'min-height:30px', 'padding:6px 8px', '.flow-palette-timeop', '.flow-palette-haaction'):
|
for marker in ('.flow-palette-group button', 'min-height:30px', 'padding:6px 8px', '.flow-palette-timeop', '.flow-palette-haaction', '.flow-editor-title input:hover', '.flow-editor-title input:focus', '.flow-draft-badge'):
|
||||||
check(marker in css, f'compact/color Flow CSS missing: {marker}')
|
check(marker in css, f'compact/color Flow CSS missing: {marker}')
|
||||||
|
|
||||||
# Translation pack correctness: all new keys must be inside translations, not root.
|
# Translation pack correctness: all new keys must be inside translations, not root.
|
||||||
for lang in ('pl','en'):
|
for lang in ('pl','en'):
|
||||||
pack=json.loads((ROOT/f'lang/{lang}.json').read_text())
|
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')
|
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.stateDuration','flow.node.onChange','flow.node.rateLimit','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','flow.draft','flow.draftStatus','flow.draftNoExecution','flow.draftDisabledHint','flow.draftCannotEnable','flow.saveAsDraftConfirm','flow.savedAsDraft'):
|
||||||
check(bool(pack.get('translations',{}).get(key)), f'{lang}: missing {key}')
|
check(bool(pack.get('translations',{}).get(key)), f'{lang}: missing {key}')
|
||||||
title_keys=set(re.findall(r"titleKey: '([^']+)'", meta_block))
|
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))
|
missing_titles=sorted(key for key in title_keys if not pack.get('translations',{}).get(key))
|
||||||
|
|||||||
+85
-14
@@ -4,6 +4,8 @@ struct FlowInput {
|
|||||||
#[serde(default = "yes")]
|
#[serde(default = "yes")]
|
||||||
enabled: bool,
|
enabled: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
draft: bool,
|
||||||
|
#[serde(default)]
|
||||||
description: String,
|
description: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
nodes: Vec<crate::models::FlowNode>,
|
nodes: Vec<crate::models::FlowNode>,
|
||||||
@@ -107,9 +109,8 @@ fn validate_shared_input_source(kind: &str, config: &Value, state: &AppState) ->
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn validate_flow_graph(input: &FlowInput) -> Result<(), AppError> {
|
fn validate_flow_draft_graph(input: &FlowInput) -> Result<(), AppError> {
|
||||||
if input.name.trim().is_empty() { return Err(AppError::BadRequest("flow name is required".into())); }
|
if input.name.trim().is_empty() { return Err(AppError::BadRequest("flow name is required".into())); }
|
||||||
if input.nodes.is_empty() { return Err(AppError::BadRequest("flow needs at least one block".into())); }
|
|
||||||
let mut ids = std::collections::HashSet::<String>::new();
|
let mut ids = std::collections::HashSet::<String>::new();
|
||||||
for node in &input.nodes {
|
for node in &input.nodes {
|
||||||
if node.id.trim().is_empty() || !ids.insert(node.id.clone()) { return Err(AppError::BadRequest("flow contains duplicate or empty block IDs".into())); }
|
if node.id.trim().is_empty() || !ids.insert(node.id.clone()) { return Err(AppError::BadRequest("flow contains duplicate or empty block IDs".into())); }
|
||||||
@@ -117,14 +118,26 @@ fn validate_flow_graph(input: &FlowInput) -> Result<(), AppError> {
|
|||||||
return Err(AppError::BadRequest(format!("unsupported flow block: {}", node.kind)));
|
return Err(AppError::BadRequest(format!("unsupported flow block: {}", node.kind)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !input.nodes.iter().any(|node| flow_action_kind(&node.kind)) { return Err(AppError::BadRequest("flow needs at least one action block".into())); }
|
|
||||||
let mut edge_ids = std::collections::HashSet::<String>::new();
|
let mut edge_ids = std::collections::HashSet::<String>::new();
|
||||||
let mut connections = std::collections::HashSet::<(String, String)>::new();
|
let mut connections = std::collections::HashSet::<(String, String)>::new();
|
||||||
for edge in &input.edges {
|
for edge in &input.edges {
|
||||||
if edge.id.trim().is_empty() || !edge_ids.insert(edge.id.clone()) || !connections.insert((edge.from.clone(), edge.to.clone())) {
|
if edge.id.trim().is_empty() || !edge_ids.insert(edge.id.clone()) || !connections.insert((edge.from.clone(), edge.to.clone())) {
|
||||||
return Err(AppError::BadRequest("flow contains duplicate or empty connection IDs".into()));
|
return Err(AppError::BadRequest("flow contains duplicate or empty connection IDs".into()));
|
||||||
}
|
}
|
||||||
if edge.from == edge.to || !ids.contains(edge.from.as_str()) || !ids.contains(edge.to.as_str()) {
|
if !ids.contains(edge.from.as_str()) || !ids.contains(edge.to.as_str()) {
|
||||||
|
return Err(AppError::BadRequest("flow contains a connection to a missing block".into()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_flow_graph(input: &FlowInput) -> Result<(), AppError> {
|
||||||
|
validate_flow_draft_graph(input)?;
|
||||||
|
if input.nodes.is_empty() { return Err(AppError::BadRequest("flow needs at least one block".into())); }
|
||||||
|
let ids = input.nodes.iter().map(|node| node.id.clone()).collect::<std::collections::HashSet<_>>();
|
||||||
|
if !input.nodes.iter().any(|node| flow_action_kind(&node.kind)) { return Err(AppError::BadRequest("flow needs at least one action block".into())); }
|
||||||
|
for edge in &input.edges {
|
||||||
|
if edge.from == edge.to {
|
||||||
return Err(AppError::BadRequest("flow contains an invalid connection".into()));
|
return Err(AppError::BadRequest("flow contains an invalid connection".into()));
|
||||||
}
|
}
|
||||||
let from = input.nodes.iter().find(|node| node.id == edge.from).expect("validated Flow source");
|
let from = input.nodes.iter().find(|node| node.id == edge.from).expect("validated Flow source");
|
||||||
@@ -154,8 +167,7 @@ fn validate_flow_graph(input: &FlowInput) -> Result<(), AppError> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reject cycles. Flow is deliberately a DAG: finite evaluation, deterministic topological order,
|
// Reject cycles for executable Flows. Drafts intentionally allow unfinished wiring and are never compiled.
|
||||||
// and no hidden state machine semantics unless a dedicated stateful block is introduced later.
|
|
||||||
let mut outgoing = std::collections::HashMap::<String, Vec<String>>::new();
|
let mut outgoing = std::collections::HashMap::<String, Vec<String>>::new();
|
||||||
for edge in &input.edges { outgoing.entry(edge.from.clone()).or_default().push(edge.to.clone()); }
|
for edge in &input.edges { outgoing.entry(edge.from.clone()).or_default().push(edge.to.clone()); }
|
||||||
fn visit(id: &str, outgoing: &std::collections::HashMap<String, Vec<String>>, temp: &mut std::collections::HashSet<String>, done: &mut std::collections::HashSet<String>) -> bool {
|
fn visit(id: &str, outgoing: &std::collections::HashMap<String, Vec<String>>, temp: &mut std::collections::HashSet<String>, done: &mut std::collections::HashSet<String>) -> bool {
|
||||||
@@ -542,23 +554,34 @@ fn compile_flow(state: &AppState, mut flow: crate::models::Flow) -> Result<(crat
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn flow_from_input(id: String, input: FlowInput, created_at: chrono::DateTime<Utc>, revision: u64) -> crate::models::Flow {
|
fn flow_from_input(id: String, input: FlowInput, created_at: chrono::DateTime<Utc>, revision: u64) -> crate::models::Flow {
|
||||||
|
let draft = input.draft;
|
||||||
crate::models::Flow {
|
crate::models::Flow {
|
||||||
id, name: input.name.trim().into(), enabled: input.enabled, description: input.description.trim().into(), nodes: input.nodes, edges: input.edges,
|
id, name: input.name.trim().into(), enabled: if draft { false } else { input.enabled }, draft,
|
||||||
|
description: input.description.trim().into(), nodes: input.nodes, edges: input.edges,
|
||||||
summary: String::new(), compiled_schedule_ids: vec![], compiled_automation_ids: vec![], revision, created_at, updated_at: Utc::now(),
|
summary: String::new(), compiled_schedule_ids: vec![], compiled_automation_ids: vec![], revision, created_at, updated_at: Utc::now(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn prepare_draft_flow(mut flow: crate::models::Flow) -> crate::models::Flow {
|
||||||
|
flow.enabled = false;
|
||||||
|
flow.draft = true;
|
||||||
|
flow.compiled_schedule_ids.clear();
|
||||||
|
flow.compiled_automation_ids.clear();
|
||||||
|
flow.summary = format!("Draft · {} blocks", flow.nodes.len());
|
||||||
|
flow
|
||||||
|
}
|
||||||
|
|
||||||
async fn list_flows(State(state): State<AppState>) -> Result<Json<Vec<crate::models::Flow>>, AppError> { Ok(Json(state.db.list_flows()?)) }
|
async fn list_flows(State(state): State<AppState>) -> Result<Json<Vec<crate::models::Flow>>, AppError> { Ok(Json(state.db.list_flows()?)) }
|
||||||
async fn get_flow(State(state): State<AppState>, Path(id): Path<String>) -> Result<Json<crate::models::Flow>, AppError> { state.db.get_flow(&id)?.map(Json).ok_or_else(|| AppError::NotFound(format!("flow {id}"))) }
|
async fn get_flow(State(state): State<AppState>, Path(id): Path<String>) -> Result<Json<crate::models::Flow>, AppError> { state.db.get_flow(&id)?.map(Json).ok_or_else(|| AppError::NotFound(format!("flow {id}"))) }
|
||||||
|
|
||||||
async fn create_flow(State(state): State<AppState>, Json(input): Json<FlowInput>) -> Result<(StatusCode, Json<crate::models::Flow>), AppError> {
|
async fn create_flow(State(state): State<AppState>, Json(input): Json<FlowInput>) -> Result<(StatusCode, Json<crate::models::Flow>), AppError> {
|
||||||
validate_flow_graph(&input)?;
|
if input.draft { validate_flow_draft_graph(&input)?; } else { validate_flow_graph(&input)?; }
|
||||||
let _configuration_guard = state.lock_configuration_operation().await;
|
let _configuration_guard = state.lock_configuration_operation().await;
|
||||||
let _automation_guard = state.lock_automation_operation().await;
|
let _automation_guard = state.lock_automation_operation().await;
|
||||||
let _schedule_guard = state.lock_schedule_operation().await;
|
let _schedule_guard = state.lock_schedule_operation().await;
|
||||||
let _cycle_guard = state.lock_zone_control_cycle().await;
|
let _cycle_guard = state.lock_zone_control_cycle().await;
|
||||||
let flow = flow_from_input(Uuid::new_v4().to_string(), input, Utc::now(), 1);
|
let flow = flow_from_input(Uuid::new_v4().to_string(), input, Utc::now(), 1);
|
||||||
let (flow, schedules, automations) = compile_flow(&state, flow)?;
|
let (flow, schedules, automations) = if flow.draft { (prepare_draft_flow(flow), vec![], vec![]) } else { compile_flow(&state, flow)? };
|
||||||
state.db.replace_flow_outputs(&flow, &schedules, &automations)?;
|
state.db.replace_flow_outputs(&flow, &schedules, &automations)?;
|
||||||
for zone_id in schedules.iter().map(|s| s.zone_id.as_str()).collect::<std::collections::HashSet<_>>() { refresh_zone_override_boundary(&state, zone_id).await?; }
|
for zone_id in schedules.iter().map(|s| s.zone_id.as_str()).collect::<std::collections::HashSet<_>>() { refresh_zone_override_boundary(&state, zone_id).await?; }
|
||||||
state.log("info", "flow.created", &format!("Created Flow {}", flow.name), json!({"flow_id": flow.id, "revision": flow.revision, "schedules": schedules.len(), "automations": automations.len()}));
|
state.log("info", "flow.created", &format!("Created Flow {}", flow.name), json!({"flow_id": flow.id, "revision": flow.revision, "schedules": schedules.len(), "automations": automations.len()}));
|
||||||
@@ -567,7 +590,7 @@ async fn create_flow(State(state): State<AppState>, Json(input): Json<FlowInput>
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn update_flow(State(state): State<AppState>, Path(id): Path<String>, Json(input): Json<FlowInput>) -> Result<Json<crate::models::Flow>, AppError> {
|
async fn update_flow(State(state): State<AppState>, Path(id): Path<String>, Json(input): Json<FlowInput>) -> Result<Json<crate::models::Flow>, AppError> {
|
||||||
validate_flow_graph(&input)?;
|
if input.draft { validate_flow_draft_graph(&input)?; } else { validate_flow_graph(&input)?; }
|
||||||
let _configuration_guard = state.lock_configuration_operation().await;
|
let _configuration_guard = state.lock_configuration_operation().await;
|
||||||
let _automation_guard = state.lock_automation_operation().await;
|
let _automation_guard = state.lock_automation_operation().await;
|
||||||
let _schedule_guard = state.lock_schedule_operation().await;
|
let _schedule_guard = state.lock_schedule_operation().await;
|
||||||
@@ -578,7 +601,7 @@ async fn update_flow(State(state): State<AppState>, Path(id): Path<String>, Json
|
|||||||
let old_zone_ids: std::collections::HashSet<String> = state.db.list_schedules()?.into_iter().filter(|s| s.flow_id.as_deref() == Some(id.as_str())).map(|s| s.zone_id).collect();
|
let old_zone_ids: std::collections::HashSet<String> = state.db.list_schedules()?.into_iter().filter(|s| s.flow_id.as_deref() == Some(id.as_str())).map(|s| s.zone_id).collect();
|
||||||
let next_revision = existing.revision.saturating_add(1).max(1);
|
let next_revision = existing.revision.saturating_add(1).max(1);
|
||||||
let flow = flow_from_input(id, input, existing.created_at, next_revision);
|
let flow = flow_from_input(id, input, existing.created_at, next_revision);
|
||||||
let (flow, schedules, automations) = compile_flow(&state, flow)?;
|
let (flow, schedules, automations) = if flow.draft { (prepare_draft_flow(flow), vec![], vec![]) } else { compile_flow(&state, flow)? };
|
||||||
state.db.replace_flow_outputs(&flow, &schedules, &automations)?;
|
state.db.replace_flow_outputs(&flow, &schedules, &automations)?;
|
||||||
let mut zone_ids = old_zone_ids; zone_ids.extend(schedules.iter().map(|s| s.zone_id.clone()));
|
let mut zone_ids = old_zone_ids; zone_ids.extend(schedules.iter().map(|s| s.zone_id.clone()));
|
||||||
for zone_id in zone_ids { refresh_zone_override_boundary(&state, &zone_id).await?; }
|
for zone_id in zone_ids { refresh_zone_override_boundary(&state, &zone_id).await?; }
|
||||||
@@ -610,6 +633,7 @@ async fn export_flow(State(state): State<AppState>, Path(id): Path<String>) -> R
|
|||||||
"flow": {
|
"flow": {
|
||||||
"name": flow.name,
|
"name": flow.name,
|
||||||
"enabled": flow.enabled,
|
"enabled": flow.enabled,
|
||||||
|
"draft": flow.draft,
|
||||||
"description": flow.description,
|
"description": flow.description,
|
||||||
"nodes": flow.nodes,
|
"nodes": flow.nodes,
|
||||||
"edges": flow.edges
|
"edges": flow.edges
|
||||||
@@ -624,13 +648,13 @@ async fn import_flow(State(state): State<AppState>, Json(document): Json<Value>)
|
|||||||
let payload = document.get("flow").cloned().unwrap_or(document);
|
let payload = document.get("flow").cloned().unwrap_or(document);
|
||||||
let mut input: FlowInput = serde_json::from_value(payload).map_err(|err| AppError::BadRequest(format!("invalid Flow import: {err}")))?;
|
let mut input: FlowInput = serde_json::from_value(payload).map_err(|err| AppError::BadRequest(format!("invalid Flow import: {err}")))?;
|
||||||
input.expected_revision = None;
|
input.expected_revision = None;
|
||||||
validate_flow_graph(&input)?;
|
if input.draft { validate_flow_draft_graph(&input)?; } else { validate_flow_graph(&input)?; }
|
||||||
let _configuration_guard = state.lock_configuration_operation().await;
|
let _configuration_guard = state.lock_configuration_operation().await;
|
||||||
let _automation_guard = state.lock_automation_operation().await;
|
let _automation_guard = state.lock_automation_operation().await;
|
||||||
let _schedule_guard = state.lock_schedule_operation().await;
|
let _schedule_guard = state.lock_schedule_operation().await;
|
||||||
let _cycle_guard = state.lock_zone_control_cycle().await;
|
let _cycle_guard = state.lock_zone_control_cycle().await;
|
||||||
let flow = flow_from_input(Uuid::new_v4().to_string(), input, Utc::now(), 1);
|
let flow = flow_from_input(Uuid::new_v4().to_string(), input, Utc::now(), 1);
|
||||||
let (flow, schedules, automations) = compile_flow(&state, flow)?;
|
let (flow, schedules, automations) = if flow.draft { (prepare_draft_flow(flow), vec![], vec![]) } else { compile_flow(&state, flow)? };
|
||||||
state.db.replace_flow_outputs(&flow, &schedules, &automations)?;
|
state.db.replace_flow_outputs(&flow, &schedules, &automations)?;
|
||||||
for zone_id in schedules.iter().map(|s| s.zone_id.as_str()).collect::<std::collections::HashSet<_>>() { refresh_zone_override_boundary(&state, zone_id).await?; }
|
for zone_id in schedules.iter().map(|s| s.zone_id.as_str()).collect::<std::collections::HashSet<_>>() { refresh_zone_override_boundary(&state, zone_id).await?; }
|
||||||
state.log("info", "flow.imported", &format!("Imported Flow {}", flow.name), json!({"flow_id": flow.id, "revision": flow.revision}));
|
state.log("info", "flow.imported", &format!("Imported Flow {}", flow.name), json!({"flow_id": flow.id, "revision": flow.revision}));
|
||||||
@@ -699,7 +723,7 @@ async fn simulate_flow(State(state): State<AppState>, Json(input): Json<FlowSimu
|
|||||||
};
|
};
|
||||||
let preview_id = input.flow_id.clone().filter(|v| !v.trim().is_empty()).unwrap_or_else(|| format!("dry-run-{}", Uuid::new_v4()));
|
let preview_id = input.flow_id.clone().filter(|v| !v.trim().is_empty()).unwrap_or_else(|| format!("dry-run-{}", Uuid::new_v4()));
|
||||||
let preview = crate::models::Flow {
|
let preview = crate::models::Flow {
|
||||||
id: preview_id.clone(), name: input.flow.name.trim().into(), enabled: input.flow.enabled, description: input.flow.description.trim().into(),
|
id: preview_id.clone(), name: input.flow.name.trim().into(), enabled: input.flow.enabled, draft: false, description: input.flow.description.trim().into(),
|
||||||
nodes: input.flow.nodes.clone(), edges: input.flow.edges.clone(), summary: String::new(), compiled_schedule_ids: vec![], compiled_automation_ids: vec![], revision: 0,
|
nodes: input.flow.nodes.clone(), edges: input.flow.edges.clone(), summary: String::new(), compiled_schedule_ids: vec![], compiled_automation_ids: vec![], revision: 0,
|
||||||
created_at: Utc::now(), updated_at: Utc::now(),
|
created_at: Utc::now(), updated_at: Utc::now(),
|
||||||
};
|
};
|
||||||
@@ -749,3 +773,50 @@ async fn flow_logs(State(state): State<AppState>, Path(id): Path<String>, Query(
|
|||||||
}).take(limit).collect::<Vec<_>>();
|
}).take(limit).collect::<Vec<_>>();
|
||||||
Ok(Json(json!({"events": events})))
|
Ok(Json(json!({"events": events})))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod flow_draft_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn unfinished_input() -> FlowInput {
|
||||||
|
FlowInput {
|
||||||
|
name: "Unfinished".into(),
|
||||||
|
enabled: true,
|
||||||
|
draft: true,
|
||||||
|
description: String::new(),
|
||||||
|
nodes: vec![crate::models::FlowNode {
|
||||||
|
id: "condition".into(),
|
||||||
|
kind: "constant".into(),
|
||||||
|
x: 0.0,
|
||||||
|
y: 0.0,
|
||||||
|
config: json!({"value": true}),
|
||||||
|
}],
|
||||||
|
edges: vec![],
|
||||||
|
expected_revision: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn draft_graph_accepts_flow_without_action() {
|
||||||
|
let input = unfinished_input();
|
||||||
|
assert!(validate_flow_draft_graph(&input).is_ok());
|
||||||
|
assert!(validate_flow_graph(&input).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn draft_graph_still_rejects_missing_edge_endpoints() {
|
||||||
|
let mut input = unfinished_input();
|
||||||
|
input.edges.push(crate::models::FlowEdge { id: "edge".into(), from: "condition".into(), to: "missing".into() });
|
||||||
|
assert!(validate_flow_draft_graph(&input).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn draft_flow_is_forced_disabled_and_has_no_compiled_outputs() {
|
||||||
|
let flow = flow_from_input("draft-id".into(), unfinished_input(), chrono::Utc::now(), 1);
|
||||||
|
let flow = prepare_draft_flow(flow);
|
||||||
|
assert!(flow.draft);
|
||||||
|
assert!(!flow.enabled);
|
||||||
|
assert!(flow.compiled_schedule_ids.is_empty());
|
||||||
|
assert!(flow.compiled_automation_ids.is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -202,6 +202,13 @@ fn validate_configuration_export(export: &ConfigurationExport) -> Result<(), App
|
|||||||
let schedules: std::collections::HashSet<&str> = export.schedules.iter().map(|item| item.id.as_str()).collect();
|
let schedules: std::collections::HashSet<&str> = export.schedules.iter().map(|item| item.id.as_str()).collect();
|
||||||
let automations: std::collections::HashSet<&str> = export.automations.iter().map(|item| item.id.as_str()).collect();
|
let automations: std::collections::HashSet<&str> = export.automations.iter().map(|item| item.id.as_str()).collect();
|
||||||
let flows: std::collections::HashSet<&str> = export.flows.iter().map(|item| item.id.as_str()).collect();
|
let flows: std::collections::HashSet<&str> = export.flows.iter().map(|item| item.id.as_str()).collect();
|
||||||
|
let draft_flows: std::collections::HashSet<&str> = export.flows.iter().filter(|item| item.draft).map(|item| item.id.as_str()).collect();
|
||||||
|
if export.flows.iter().any(|item| item.draft && (item.enabled || !item.compiled_schedule_ids.is_empty() || !item.compiled_automation_ids.is_empty()))
|
||||||
|
|| export.schedules.iter().any(|item| item.flow_id.as_deref().is_some_and(|id| draft_flows.contains(id)))
|
||||||
|
|| export.automations.iter().any(|item| item.flow_id.as_deref().is_some_and(|id| draft_flows.contains(id)))
|
||||||
|
{
|
||||||
|
return Err(AppError::BadRequest("import contains an executable Flow draft".into()));
|
||||||
|
}
|
||||||
if devices.len() != export.devices.len() || zones.len() != export.zones.len()
|
if devices.len() != export.devices.len() || zones.len() != export.zones.len()
|
||||||
|| schedules.len() != export.schedules.len() || automations.len() != export.automations.len() || flows.len() != export.flows.len()
|
|| schedules.len() != export.schedules.len() || automations.len() != export.automations.len() || flows.len() != export.flows.len()
|
||||||
|| devices.contains("") || zones.contains("") || schedules.contains("") || automations.contains("") || flows.contains("")
|
|| devices.contains("") || zones.contains("") || schedules.contains("") || automations.contains("") || flows.contains("")
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ pub struct Flow {
|
|||||||
pub name: String,
|
pub name: String,
|
||||||
#[serde(default = "default_true")]
|
#[serde(default = "default_true")]
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
|
/// Work-in-progress Flow saved without executable outputs. Drafts are always disabled.
|
||||||
|
#[serde(default)]
|
||||||
|
pub draft: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub description: String,
|
pub description: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
|||||||
+3
-1
@@ -329,7 +329,9 @@ async function api(path, options = {}) {
|
|||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
let message = tr('error.http', { status: response.status });
|
let message = tr('error.http', { status: response.status });
|
||||||
try { message = (await response.json()).error || message; } catch (_) { }
|
try { message = (await response.json()).error || message; } catch (_) { }
|
||||||
throw new Error(message);
|
const error = new Error(message);
|
||||||
|
error.status = response.status;
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
if (response.status === 204) return null;
|
if (response.status === 204) return null;
|
||||||
return response.json();
|
return response.json();
|
||||||
|
|||||||
+32
-11
@@ -97,17 +97,17 @@ function flowDefaultConfig(kind) {
|
|||||||
function renderFlows() {
|
function renderFlows() {
|
||||||
const host = $('#flowList'); if (!host) return;
|
const host = $('#flowList'); if (!host) return;
|
||||||
const count = $('#flowListCount'); if (count) count.textContent = tr('flow.listCount', { count: app.flows.length });
|
const count = $('#flowListCount'); if (count) count.textContent = tr('flow.listCount', { count: app.flows.length });
|
||||||
host.innerHTML = app.flows.length ? app.flows.map(flow => `<article class="list-card flow-card ${flow.enabled ? '' : 'is-disabled'}">
|
host.innerHTML = app.flows.length ? app.flows.map(flow => `<article class="list-card flow-card ${flow.enabled && !flow.draft ? '' : 'is-disabled'} ${flow.draft ? 'is-draft' : ''}">
|
||||||
<div class="list-card-head"><div><h3>${esc(flow.name)}</h3><p>${esc(flow.description || tr('flow.defaultDescription'))}</p></div><button type="button" class="zone-enable-toggle ${flow.enabled ? 'active' : ''}" data-action="toggle-flow-enabled" data-id="${esc(flow.id)}" data-value="${flow.enabled ? 'false' : 'true'}" aria-label="${esc(tr(flow.enabled ? 'flow.disable' : 'flow.enable'))}" title="${esc(tr('flow.quickToggleHint'))}"><span>${flow.enabled ? '✓' : '○'}</span>${esc(flow.enabled ? tr('common.enabled') : tr('common.disabled'))}</button></div>
|
<div class="list-card-head"><div><h3>${esc(flow.name)}${flow.draft ? ` <span class="badge flow-draft-badge">${esc(tr('flow.draft'))}</span>` : ''}</h3><p>${esc(flow.description || tr('flow.defaultDescription'))}</p></div>${flow.draft ? `<button type="button" class="zone-enable-toggle" disabled title="${esc(tr('flow.draftDisabledHint'))}"><span>✎</span>${esc(tr('common.disabled'))}</button>` : `<button type="button" class="zone-enable-toggle ${flow.enabled ? 'active' : ''}" data-action="toggle-flow-enabled" data-id="${esc(flow.id)}" data-value="${flow.enabled ? 'false' : 'true'}" aria-label="${esc(tr(flow.enabled ? 'flow.disable' : 'flow.enable'))}" title="${esc(tr('flow.quickToggleHint'))}"><span>${flow.enabled ? '✓' : '○'}</span>${esc(flow.enabled ? tr('common.enabled') : tr('common.disabled'))}</button>`}</div>
|
||||||
<div class="card-stats"><div class="card-stat"><small>${esc(tr('flow.blocks'))}</small><strong>${flow.nodes?.length || 0}</strong></div><div class="card-stat"><small>${esc(tr('nav.schedules'))}</small><strong>${flow.compiled_schedule_ids?.length || 0}</strong></div><div class="card-stat"><small>${esc(tr('nav.automations'))}</small><strong>${flow.compiled_automation_ids?.length || 0}</strong></div></div>
|
<div class="card-stats"><div class="card-stat"><small>${esc(tr('flow.blocks'))}</small><strong>${flow.nodes?.length || 0}</strong></div><div class="card-stat"><small>${esc(tr('nav.schedules'))}</small><strong>${flow.compiled_schedule_ids?.length || 0}</strong></div><div class="card-stat"><small>${esc(tr('nav.automations'))}</small><strong>${flow.compiled_automation_ids?.length || 0}</strong></div></div>
|
||||||
<div class="card-footer"><small>${esc(tr('flow.compileCount', { schedules: flow.compiled_schedule_ids?.length || 0, automations: flow.compiled_automation_ids?.length || 0 }))}</small><div class="card-menu"><button data-action="edit-flow" data-id="${esc(flow.id)}">${esc(tr('flow.openEditor'))}</button><button class="danger" data-action="delete-flow" data-id="${esc(flow.id)}">${esc(tr('actions.delete'))}</button></div></div>
|
<div class="card-footer"><small>${esc(flow.draft ? tr('flow.draftNoExecution') : tr('flow.compileCount', { schedules: flow.compiled_schedule_ids?.length || 0, automations: flow.compiled_automation_ids?.length || 0 }))}</small><div class="card-menu"><button data-action="edit-flow" data-id="${esc(flow.id)}">${esc(tr('flow.openEditor'))}</button><button class="danger" data-action="delete-flow" data-id="${esc(flow.id)}">${esc(tr('actions.delete'))}</button></div></div>
|
||||||
</article>`).join('') : `<div class="empty"><strong>${esc(tr('flow.emptyTitle'))}</strong>${esc(tr('flow.emptyText'))}</div>`;
|
</article>`).join('') : `<div class="empty"><strong>${esc(tr('flow.emptyTitle'))}</strong>${esc(tr('flow.emptyText'))}</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function flowDraftFrom(flow) {
|
function flowDraftFrom(flow) {
|
||||||
return flow ? JSON.parse(JSON.stringify(flow)) : {
|
return flow ? JSON.parse(JSON.stringify(flow)) : {
|
||||||
id: '', revision: 0, name: tr('flow.newDefaultName'), enabled: true, description: '', nodes: [], edges: [], summary: '', compiled_schedule_ids: [], compiled_automation_ids: [],
|
id: '', revision: 0, name: tr('flow.newDefaultName'), enabled: true, draft: false, description: '', nodes: [], edges: [], summary: '', compiled_schedule_ids: [], compiled_automation_ids: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -326,7 +326,8 @@ function renderFlowEditor() {
|
|||||||
const selectionCount = $('#flowSelectionCount'); if (selectionCount) selectionCount.textContent = (app.flowSelectedNodeIds || []).length ? tr('flow.selectedCount', { count:(app.flowSelectedNodeIds || []).length }) : '';
|
const selectionCount = $('#flowSelectionCount'); if (selectionCount) selectionCount.textContent = (app.flowSelectedNodeIds || []).length ? tr('flow.selectedCount', { count:(app.flowSelectedNodeIds || []).length }) : '';
|
||||||
renderFlowEdges(); renderFlowInspector(); renderFlowInterpretation(); renderFlowRuntimeInfo(); refreshFlowSharedInputCurrentValues();
|
renderFlowEdges(); renderFlowInspector(); renderFlowInterpretation(); renderFlowRuntimeInfo(); refreshFlowSharedInputCurrentValues();
|
||||||
const status = $('#flowCompileStatus');
|
const status = $('#flowCompileStatus');
|
||||||
status.textContent = tr('flow.compileCount', { schedules: draft.compiled_schedule_ids?.length || 0, automations: draft.compiled_automation_ids?.length || 0 });
|
status.textContent = draft.draft ? tr('flow.draftStatus') : tr('flow.compileCount', { schedules: draft.compiled_schedule_ids?.length || 0, automations: draft.compiled_automation_ids?.length || 0 });
|
||||||
|
status.classList.toggle('flow-draft-badge', draft.draft === true);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderFlowEdges() {
|
function renderFlowEdges() {
|
||||||
@@ -527,6 +528,7 @@ function flowSourcePayload() {
|
|||||||
return {
|
return {
|
||||||
name: ($('#flowName')?.value || app.flowDraft.name || '').trim(),
|
name: ($('#flowName')?.value || app.flowDraft.name || '').trim(),
|
||||||
enabled: $('#flowEnabled')?.checked !== false,
|
enabled: $('#flowEnabled')?.checked !== false,
|
||||||
|
draft: app.flowDraft.draft === true,
|
||||||
description: app.flowDraft.description || '',
|
description: app.flowDraft.description || '',
|
||||||
nodes: app.flowDraft.nodes || [], edges: app.flowDraft.edges || [],
|
nodes: app.flowDraft.nodes || [], edges: app.flowDraft.edges || [],
|
||||||
};
|
};
|
||||||
@@ -898,16 +900,34 @@ async function openFlowLogs() {
|
|||||||
} catch (error) { $('#flowTestResults').innerHTML = `<div class="empty compact"><strong>${esc(tr('flow.logsFailed'))}</strong><span>${esc(error.message)}</span></div>`; }
|
} catch (error) { $('#flowTestResults').innerHTML = `<div class="empty compact"><strong>${esc(tr('flow.logsFailed'))}</strong><span>${esc(error.message)}</span></div>`; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function persistFlowDraft(body) {
|
||||||
|
return api(app.flowDraft.id ? `/api/flows/${encodeURIComponent(app.flowDraft.id)}` : '/api/flows', { method: app.flowDraft.id ? 'PUT' : 'POST', body });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applySavedFlow(saved, messageKey) {
|
||||||
|
const index = app.flows.findIndex(flow => flow.id === saved.id); if (index >= 0) app.flows[index] = saved; else app.flows.push(saved);
|
||||||
|
app.flowDraft = flowDraftFrom(saved); app.flowDirty = false;
|
||||||
|
$('#flowEnabled').checked = saved.enabled === true;
|
||||||
|
renderFlows(); renderFlowEditor(); updateBrowserUrl(`/flows/${saved.id}`, true); await loadBootstrap(); toast(tr(messageKey));
|
||||||
|
}
|
||||||
|
|
||||||
async function saveFlow() {
|
async function saveFlow() {
|
||||||
if (!app.flowDraft) return;
|
if (!app.flowDraft) return;
|
||||||
const name = $('#flowName').value.trim(); if (!name) return toast(tr('flow.nameRequired'), true);
|
const name = $('#flowName').value.trim(); if (!name) return toast(tr('flow.nameRequired'), true);
|
||||||
const body = { name, enabled: $('#flowEnabled').checked, description: app.flowDraft.description || '', nodes: app.flowDraft.nodes, edges: app.flowDraft.edges };
|
const body = { name, enabled: $('#flowEnabled').checked, draft: false, description: app.flowDraft.description || '', nodes: app.flowDraft.nodes, edges: app.flowDraft.edges };
|
||||||
if (app.flowDraft.id) body.expected_revision = Number(app.flowDraft.revision || 0);
|
if (app.flowDraft.id) body.expected_revision = Number(app.flowDraft.revision || 0);
|
||||||
try {
|
try {
|
||||||
const saved = await api(app.flowDraft.id ? `/api/flows/${encodeURIComponent(app.flowDraft.id)}` : '/api/flows', { method: app.flowDraft.id ? 'PUT' : 'POST', body });
|
const saved = await persistFlowDraft(body);
|
||||||
const index = app.flows.findIndex(flow => flow.id === saved.id); if (index >= 0) app.flows[index] = saved; else app.flows.push(saved);
|
await applySavedFlow(saved, 'flow.saved');
|
||||||
app.flowDraft = flowDraftFrom(saved); app.flowDirty = false; renderFlows(); renderFlowEditor(); updateBrowserUrl(`/flows/${saved.id}`, true); await loadBootstrap(); toast(tr('flow.saved'));
|
} catch (error) {
|
||||||
} catch (error) { toast(error.message, true); }
|
if (error.status !== 400) return toast(error.message, true);
|
||||||
|
const saveDraft = confirm(tr('flow.saveAsDraftConfirm', { reason:error.message }));
|
||||||
|
if (!saveDraft) return toast(error.message, true);
|
||||||
|
try {
|
||||||
|
const saved = await persistFlowDraft({ ...body, enabled:false, draft:true });
|
||||||
|
await applySavedFlow(saved, 'flow.savedAsDraft');
|
||||||
|
} catch (draftError) { toast(draftError.message, true); }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteFlow(id) {
|
async function deleteFlow(id) {
|
||||||
@@ -918,9 +938,10 @@ async function deleteFlow(id) {
|
|||||||
|
|
||||||
async function toggleFlowEnabled(id, enabled) {
|
async function toggleFlowEnabled(id, enabled) {
|
||||||
const flow = app.flows.find(item => item.id === id); if (!flow) return;
|
const flow = app.flows.find(item => item.id === id); if (!flow) return;
|
||||||
|
if (flow.draft) return toast(tr('flow.draftCannotEnable'), true);
|
||||||
const toggle = $(`[data-action="toggle-flow-enabled"][data-id="${CSS.escape(id)}"]`); if (toggle?.disabled) return;
|
const toggle = $(`[data-action="toggle-flow-enabled"][data-id="${CSS.escape(id)}"]`); if (toggle?.disabled) return;
|
||||||
if (toggle) toggle.disabled = true;
|
if (toggle) toggle.disabled = true;
|
||||||
const body = { name:flow.name, enabled, description:flow.description || '', nodes:flow.nodes || [], edges:flow.edges || [], expected_revision:Number(flow.revision || 0) };
|
const body = { name:flow.name, enabled, draft:false, description:flow.description || '', nodes:flow.nodes || [], edges:flow.edges || [], expected_revision:Number(flow.revision || 0) };
|
||||||
try {
|
try {
|
||||||
const saved = await api(`/api/flows/${encodeURIComponent(id)}`, { method:'PUT', body });
|
const saved = await api(`/api/flows/${encodeURIComponent(id)}`, { method:'PUT', body });
|
||||||
const index = app.flows.findIndex(item => item.id === id); if (index >= 0) app.flows[index] = saved;
|
const index = app.flows.findIndex(item => item.id === id); if (index >= 0) app.flows[index] = saved;
|
||||||
|
|||||||
+5
-1
@@ -6180,7 +6180,11 @@ body.flow-editor-open { overflow:hidden; }
|
|||||||
.flow-editor-bar { min-height:76px; display:flex; align-items:center; justify-content:space-between; gap:20px; padding:10px 18px; border-bottom:1px solid var(--line); background:var(--surface); }
|
.flow-editor-bar { min-height:76px; display:flex; align-items:center; justify-content:space-between; gap:20px; padding:10px 18px; border-bottom:1px solid var(--line); background:var(--surface); }
|
||||||
.flow-editor-title { display:flex; align-items:center; gap:12px; min-width:0; }
|
.flow-editor-title { display:flex; align-items:center; gap:12px; min-width:0; }
|
||||||
.flow-editor-title > div { display:grid; gap:2px; min-width:0; }
|
.flow-editor-title > div { display:grid; gap:2px; min-width:0; }
|
||||||
.flow-editor-title input { border:0; background:transparent; color:var(--text); font-weight:750; font-size:1.12rem; padding:2px 0; min-width:280px; outline:none; }
|
.flow-editor-title input { border:1px solid var(--line-strong); background:var(--input-bg); color:var(--text); font-weight:750; font-size:1.12rem; padding:7px 10px; min-width:280px; outline:none; border-radius:9px; box-shadow:inset 0 0 0 1px transparent; transition:border-color .15s ease,box-shadow .15s ease,background .15s ease; }
|
||||||
|
.flow-editor-title input:hover { border-color:var(--accent-border); background:var(--surface-2); }
|
||||||
|
.flow-editor-title input:focus { border-color:var(--accent); box-shadow:0 0 0 3px var(--accent-soft); background:var(--input-bg); }
|
||||||
|
.flow-draft-badge { border-color:var(--orange); background:color-mix(in srgb,var(--orange) 14%,var(--surface)); color:var(--text); }
|
||||||
|
.flow-card.is-draft { border-style:dashed; }
|
||||||
.flow-editor-actions { display:flex; align-items:center; gap:10px; }
|
.flow-editor-actions { display:flex; align-items:center; gap:10px; }
|
||||||
.flow-enabled { display:flex; align-items:center; gap:8px; color:var(--muted); }
|
.flow-enabled { display:flex; align-items:center; gap:8px; color:var(--muted); }
|
||||||
.flow-editor-body { flex:1; min-height:0; display:grid; grid-template-columns:220px minmax(420px,1fr) 300px; }
|
.flow-editor-body { flex:1; min-height:0; display:grid; grid-template-columns:220px minmax(420px,1fr) 300px; }
|
||||||
|
|||||||
Reference in New Issue
Block a user