v0.9.11
This commit is contained in:
Generated
+1
-1
@@ -633,7 +633,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "gree-controller"
|
name = "gree-controller"
|
||||||
version = "0.9.9"
|
version = "0.9.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes",
|
"aes",
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "gree-controller"
|
name = "gree-controller"
|
||||||
version = "0.9.9"
|
version = "0.9.11"
|
||||||
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
|
||||||
b0a17a6d6445239d1e307f5e685a6b87dbf7e700a6a362f9a57e713de204a802 ./Cargo.lock
|
71d5043c5c5892bcf5ad33dd0ae73600bc0ff9bbc10d93e963c90c173963c39e ./Cargo.lock
|
||||||
3ec8130c8c6266652fae17f214c2e57c8faf161709c2cba4d9c7da95db281031 ./Cargo.toml
|
c446b9af75b3d07cdf2c42b7f1286f503b225d5dc2ed3592cce4df74de31db21 ./Cargo.toml
|
||||||
19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE
|
19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE
|
||||||
081853b6c3874cda25374f86ba30f1a3796aeefc498bfb486f752f2741e59fc0 ./README.md
|
21b3a4808f6d47b3cb283be03faf8ec1327f2a757b247277c0299468c8a0d74c ./README.md
|
||||||
41dfdc6d099b54f87d4dd51f696122b3c88d3bf3ddf420bea8f4bc621ba920b0 ./build.rs
|
41dfdc6d099b54f87d4dd51f696122b3c88d3bf3ddf420bea8f4bc621ba920b0 ./build.rs
|
||||||
6275b530ace5c60b43036665feb5e5fd29f5bda784ec48f56d3f9969caeb70d1 ./docs/API.md
|
05eafc8541a867aba3121bd0df1ba475305005841c8a2437a667484a232e80e4 ./docs/API.md
|
||||||
e0642446a28e7241d5e418896850b0ecd61d272c2df74fd0bf3cd3d9addde17a ./docs/FLOW.md
|
5322117989c854763d87a4ea6ec5e957863d370ca4ebdc332e2c8c11f23338d1 ./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
|
||||||
@@ -17,7 +17,7 @@ ab08fbe40e9bb48ebcbfff98760aaa0c9434b61b705aaaadea0c09c255d99b0e ./home-assista
|
|||||||
5a96fe8f5c035c34f1339370270cd078056202d09e236dec75735be11de92a7d ./home-assistant/custom_components/gree_controller/entity_map.py
|
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/icon.png
|
||||||
c4fb75c246db651087900ebfc2291ff41ac87652cd6194fc0b776b0005c1cbcf ./home-assistant/custom_components/gree_controller/logo.png
|
c4fb75c246db651087900ebfc2291ff41ac87652cd6194fc0b776b0005c1cbcf ./home-assistant/custom_components/gree_controller/logo.png
|
||||||
2302987becea57c561706fdd3f4571db4632225e11e8281b146a1fd59a3478a7 ./home-assistant/custom_components/gree_controller/manifest.json
|
b4a1da0fc4912485c419f53b07dc651910a5cffb23f19b9a3290be7ae1946478 ./home-assistant/custom_components/gree_controller/manifest.json
|
||||||
38d659d0273c0d1428679e44574a2666c605d1b3796d323b626baa40560788cf ./home-assistant/custom_components/gree_controller/number.py
|
38d659d0273c0d1428679e44574a2666c605d1b3796d323b626baa40560788cf ./home-assistant/custom_components/gree_controller/number.py
|
||||||
39c4309001b75abb56234f05662bc06e077054986876f1927937edbce528ec95 ./home-assistant/custom_components/gree_controller/select.py
|
39c4309001b75abb56234f05662bc06e077054986876f1927937edbce528ec95 ./home-assistant/custom_components/gree_controller/select.py
|
||||||
cca65482e36d48035aca178121a378fe7d578d600acff267ae81a6399c0da653 ./home-assistant/custom_components/gree_controller/sensor.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
|
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
|
||||||
c9f0244b7b908d57fa4b9cf32fc1a999527fcfbb469e302fd62b75743f887753 ./lang/en.json
|
608a846712e3bccfade1b799843096ea9dfcbe6628804ac1a23e9ccc2cfec794 ./lang/en.json
|
||||||
b7bcd0bfc806c4bf488ca9771635066ccea8503c9a41ab00e97fe07ee6aaf4e9 ./lang/pl.json
|
fece587ab929200753697419de702f3abf4661a419acfac9c89d6143b761c7fd ./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
|
||||||
@@ -80,7 +80,7 @@ e00d211e3885e30d7fed1e43b44e6fdad40a67019060156c0641816a93e3365f ./scripts/netw
|
|||||||
81345b6a0b51736bdbc98fd23199b62e4c721b4e7437e02dab7ea79b97dff29a ./scripts/service.sh
|
81345b6a0b51736bdbc98fd23199b62e4c721b4e7437e02dab7ea79b97dff29a ./scripts/service.sh
|
||||||
8ccca8ed49576cf0b8e3453436ac3ff9c2a222cd41b27db98ceb62bd5f87aeef ./scripts/smoke.sh
|
8ccca8ed49576cf0b8e3453436ac3ff9c2a222cd41b27db98ceb62bd5f87aeef ./scripts/smoke.sh
|
||||||
b50782b3742dfbf8a319c60571c968e93fdf8547db747c759edcffae68cb98bf ./scripts/update.sh
|
b50782b3742dfbf8a319c60571c968e93fdf8547db747c759edcffae68cb98bf ./scripts/update.sh
|
||||||
51b26ee9a02278f086f69d2ca5874feabcc90ebeb32149d1fa4b9f4fd55d91c2 ./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
|
||||||
5643425214a0ad228d6229398b715d9a6823c8c5823cdb4fbf73912d9b1eb74b ./src/api/automations.rs
|
5643425214a0ad228d6229398b715d9a6823c8c5823cdb4fbf73912d9b1eb74b ./src/api/automations.rs
|
||||||
@@ -91,7 +91,7 @@ b50782b3742dfbf8a319c60571c968e93fdf8547db747c759edcffae68cb98bf ./scripts/upda
|
|||||||
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
|
||||||
0d7d598f7ed24315ed0547a60a72a06f20ebcf926489e1c454986f42d22cc305 ./src/api/integrations.rs
|
88e04a59bfedd5e6c54e6a2938b1031964be3a1505db2ca67d6290aa042cbe3b ./src/api/integrations.rs
|
||||||
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
|
||||||
@@ -171,7 +171,7 @@ f13f1be3d5789539eba3fc1b59c14835f8c681eca7634e118fa3763a53aeee2b ./src/queries/
|
|||||||
b92a6cb158b494fe145b43c7641e65f6fafff47201d7d76edbec2cfd8b94835c ./systemd/gree-controller.service
|
b92a6cb158b494fe145b43c7641e65f6fafff47201d7d76edbec2cfd8b94835c ./systemd/gree-controller.service
|
||||||
544a31cb2e5374227026afd6abd0d910381f3fca1638154ebd74e3c3d96a35d1 ./web/404.html
|
544a31cb2e5374227026afd6abd0d910381f3fca1638154ebd74e3c3d96a35d1 ./web/404.html
|
||||||
e98bdd7204349cce1ec6f57283509697af0bbc72280622a6c3efa6fed242db4f ./web/favicon.svg
|
e98bdd7204349cce1ec6f57283509697af0bbc72280622a6c3efa6fed242db4f ./web/favicon.svg
|
||||||
142c46fa8e1b9b726c2c05f50b0b0d19ab5667683f63d8d50fde922771dce6af ./web/index.html
|
460c3f8599feaf87deb854766b1d815069934132140fd3c48b5c81fe62957415 ./web/index.html
|
||||||
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
|
||||||
@@ -179,16 +179,16 @@ e98bdd7204349cce1ec6f57283509697af0bbc72280622a6c3efa6fed242db4f ./web/favicon.
|
|||||||
d34953aec21ade81ca9770c9c99873b0b2a4862a1bf671a6e13b507a5dd5b7ed ./web/js/dashboard.js
|
d34953aec21ade81ca9770c9c99873b0b2a4862a1bf671a6e13b507a5dd5b7ed ./web/js/dashboard.js
|
||||||
2887856e35e086bb575e9fc0d8b0ed1160b6912297a2957535d5416bda68b4b7 ./web/js/entities.js
|
2887856e35e086bb575e9fc0d8b0ed1160b6912297a2957535d5416bda68b4b7 ./web/js/entities.js
|
||||||
9a0e87911f78cd8a969f4c57ce74774f5fa829fdecd79494d9a7f9d5804f9792 ./web/js/events.js
|
9a0e87911f78cd8a969f4c57ce74774f5fa829fdecd79494d9a7f9d5804f9792 ./web/js/events.js
|
||||||
b7d4d428f0f40ce84d358fa90086c3161d9cac604281211ea46a11f6406fbbf6 ./web/js/flows.js
|
7373214514dc553c6c793afc88aa62f4c21b02c32e002a81f27d7aeabca82fdf ./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
|
||||||
7f23507b2f1f632d69b466688f68f3f0bf661dc6856a1ed14a462e063114a980 ./web/js/navigation.js
|
7f23507b2f1f632d69b466688f68f3f0bf661dc6856a1ed14a462e063114a980 ./web/js/navigation.js
|
||||||
c56059693d09e35261dc5dd1940289ffc67c82ea7c547750d57e6c79fe75b05b ./web/js/realtime.js
|
c56059693d09e35261dc5dd1940289ffc67c82ea7c547750d57e6c79fe75b05b ./web/js/realtime.js
|
||||||
c8c82e88c3715b1bfabf155e36266a4c94f5e2fc03eb39dcdd9d08a1985a997c ./web/js/router.js
|
c8c82e88c3715b1bfabf155e36266a4c94f5e2fc03eb39dcdd9d08a1985a997c ./web/js/router.js
|
||||||
566702c0dafd304581c0438597578bccce1cf21c428171a36415728eb4867d04 ./web/js/settings-ui.js
|
3d44b5457200a71f66fbf7bde89c7b9adf416ca185ad0e3c61f2c4e551bceb1e ./web/js/settings-ui.js
|
||||||
d299216bb707f494be77e0d46f87b203cf1a2c020e0036af86d8f5818ba03c88 ./web/js/settings.js
|
a06d718217dc6ffb6d4fb5b002fedac02ba7eea3a5b00066c2241df7c1957e51 ./web/js/settings.js
|
||||||
6b653e4fe2d4db4ffae6ff8f39b8ce57a10a990dc0000ee4aae51d949afadd52 ./web/manifest.webmanifest
|
6b653e4fe2d4db4ffae6ff8f39b8ce57a10a990dc0000ee4aae51d949afadd52 ./web/manifest.webmanifest
|
||||||
a1590b58b6a0e3fc632489de55a41261787f1340f7d9bf778a44d6f8ab1a19d6 ./web/styles.css
|
d6a7ebd22557a1dc8c74b14562a3f541c77bc0afcae88508a8e667c4ddbb775e ./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.9.9**
|
**Current release: 0.9.11**
|
||||||
|
|
||||||
> [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.
|
||||||
|
|
||||||
@@ -207,7 +207,7 @@ Schedules select profiles or a custom target for selected ISO weekdays (`1=Monda
|
|||||||
|
|
||||||
**Flow** is the authoring layer for combined schedules and automations. The full-screen editor stores a block graph as the source of truth and compiles it automatically: a pure weekday + time-range + thermostat path becomes a native schedule, while richer logic becomes a generated automation. Generated schedule/automation rows carry their Flow provenance and are read-only in the legacy editors.
|
**Flow** is the authoring layer for combined schedules and automations. The full-screen editor stores a block graph as the source of truth and compiles it automatically: a pure weekday + time-range + thermostat path becomes a native schedule, while richer logic becomes a generated automation. Generated schedule/automation rows carry their Flow provenance and are read-only in the legacy editors.
|
||||||
|
|
||||||
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 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.
|
||||||
|
|
||||||
Available blocks include weekday/time/date ranges, the application Night mode, outdoor/device/zone temperature, house/device/zone/group state, arbitrary Home Assistant state/numeric/attribute/availability checks, a constant diagnostic source, AND/OR/NOT, thermostat actions, direct GREE actions and group actions. The GREE action uses the existing `DeviceCommand` fields (power, mode, target, fan, swing, quiet, turbo, light, air, xfan, health and sleep). Multiple branches can feed an action; the compiled runtime preserves the graph logic.
|
Available blocks include weekday/time/date ranges, the application Night mode, outdoor/device/zone temperature, house/device/zone/group state, arbitrary Home Assistant state/numeric/attribute/availability checks, a constant diagnostic source, AND/OR/NOT, thermostat actions, direct GREE actions and group actions. The GREE action uses the existing `DeviceCommand` fields (power, mode, target, fan, swing, quiet, turbo, light, air, xfan, health and sleep). Multiple branches can feed an action; the compiled runtime preserves the graph logic.
|
||||||
|
|
||||||
@@ -424,6 +424,6 @@ Flow reuses the existing thermostat, group and device-control domains instead of
|
|||||||
|
|
||||||
The editor supports optimistic revisions, so saving an older copy from another browser/tab returns HTTP 409 instead of overwriting a newer graph. Flow compilation/replacement is serialized with configuration, automation, schedule and thermostat-cycle locks, and generated schedules/automations plus the Flow source are replaced in one SQLite transaction. Runtime condition evaluation happens before the automation execution lock; after acquiring it the rule is reloaded and stale snapshots are discarded. Same-cycle actions claim their target devices deterministically, and Flow/device thermostat writes take the schedule -> thermostat-cycle -> zone -> device path. Home Assistant read failures fail closed instead of making NOT/neq logic accidentally true.
|
The editor supports optimistic revisions, so saving an older copy from another browser/tab returns HTTP 409 instead of overwriting a newer graph. Flow compilation/replacement is serialized with configuration, automation, schedule and thermostat-cycle locks, and generated schedules/automations plus the Flow source are replaced in one SQLite transaction. Runtime condition evaluation happens before the automation execution lock; after acquiring it the rule is reloaded and stale snapshots are discarded. Same-cycle actions claim their target devices deterministically, and Flow/device thermostat writes take the schedule -> thermostat-cycle -> zone -> device path. Home Assistant read failures fail closed instead of making NOT/neq logic accidentally true.
|
||||||
|
|
||||||
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. 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. The editor includes reusable templates for workday comfort, weather-aware control, combined AND/OR demand, device-health gating, night group control, a Home Assistant window guard, presence comfort/eco, frost protection, a nested OR+NOT guard and Night-mode-driven quiet control.
|
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.
|
||||||
|
|||||||
+29
-4
@@ -1,6 +1,6 @@
|
|||||||
# GREE Controller API reference
|
# GREE Controller API reference
|
||||||
|
|
||||||
HTTP and WebSocket API for GREE Controller **0.9.9**.
|
HTTP and WebSocket API for GREE Controller **0.9.11**.
|
||||||
|
|
||||||
[← Main documentation](../README.md)
|
[← Main documentation](../README.md)
|
||||||
|
|
||||||
@@ -179,6 +179,7 @@ Common statuses:
|
|||||||
| GET | `/api/debug` | Read debug overlay settings. |
|
| GET | `/api/debug` | Read debug overlay settings. |
|
||||||
| PUT | `/api/debug` | Update debug overlay settings. |
|
| PUT | `/api/debug` | Update debug overlay settings. |
|
||||||
| POST | `/api/integrations/home-assistant/test` | Test HA temperature read. |
|
| POST | `/api/integrations/home-assistant/test` | Test HA temperature read. |
|
||||||
|
| POST | `/api/integrations/home-assistant/entity` | Read raw HA entity state/attributes for shared Flow input diagnostics. |
|
||||||
| POST | `/api/integrations/notifications/test` | Send a test notification. |
|
| POST | `/api/integrations/notifications/test` | Send a test notification. |
|
||||||
|
|
||||||
### Access tokens and restricted Home Assistant API
|
### Access tokens and restricted Home Assistant API
|
||||||
@@ -212,7 +213,7 @@ Response:
|
|||||||
{
|
{
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"name": "gree-controller",
|
"name": "gree-controller",
|
||||||
"version": "0.9.9",
|
"version": "0.9.11",
|
||||||
"uptime_seconds": 1234,
|
"uptime_seconds": 1234,
|
||||||
"control_ready": true,
|
"control_ready": true,
|
||||||
"time": "2026-08-30T06:54:00Z"
|
"time": "2026-08-30T06:54:00Z"
|
||||||
@@ -236,7 +237,7 @@ Returns the initial Web UI snapshot:
|
|||||||
"settings": {},
|
"settings": {},
|
||||||
"outdoor_temperature": null,
|
"outdoor_temperature": null,
|
||||||
"system": {
|
"system": {
|
||||||
"version": "0.9.9",
|
"version": "0.9.11",
|
||||||
"uptime_seconds": 1234,
|
"uptime_seconds": 1234,
|
||||||
"auth_required": false,
|
"auth_required": false,
|
||||||
"control_ready": true,
|
"control_ready": true,
|
||||||
@@ -1201,6 +1202,30 @@ The Web UI can display **All**, **Requests** or **GREE** subsets.
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### `POST /api/integrations/home-assistant/entity`
|
||||||
|
|
||||||
|
Reads the current raw Home Assistant entity document used by the shared-Flow-input diagnostics UI.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"entity_id": "climate.gas_boiler"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Response includes the raw state, availability, attributes and timestamps:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ok": true,
|
||||||
|
"entity_id": "climate.gas_boiler",
|
||||||
|
"state": "heat",
|
||||||
|
"available": true,
|
||||||
|
"attributes": { "hvac_action": "heating" },
|
||||||
|
"last_changed": "2026-09-02T06:30:00Z",
|
||||||
|
"last_updated": "2026-09-02T06:30:05Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
### `POST /api/integrations/notifications/test`
|
### `POST /api/integrations/notifications/test`
|
||||||
|
|
||||||
Accepts a `NotificationSettings` object. Blank secret/webhook fields reuse saved secrets for the test.
|
Accepts a `NotificationSettings` object. Blank secret/webhook fields reuse saved secrets for the test.
|
||||||
@@ -1512,4 +1537,4 @@ Cancellation suppresses the same pending intent until a new explicit thermostat/
|
|||||||
|
|
||||||
Existing Flow updates require `expected_revision`. A mismatched revision returns HTTP 409 to prevent stale editor tabs from overwriting newer graphs. Source Flow plus generated outputs are replaced atomically in one database transaction while configuration/automation/schedule/thermostat-cycle operations are serialized.
|
Existing Flow updates require `expected_revision`. A mismatched revision returns HTTP 409 to prevent stale editor tabs from overwriting newer graphs. Source Flow plus generated outputs are replaced atomically in one database transaction while configuration/automation/schedule/thermostat-cycle operations are serialized.
|
||||||
|
|
||||||
Additional Flow condition blocks are `house_mode`, `device_state`, `zone_state`, `group_state`, `night_mode`, `ha_attribute`, `ha_available`, `constant` and `shared_input`, alongside weekday/time/date, temperature, Home Assistant state/numeric and AND/OR/NOT blocks. `device_state` can inspect enabled/online/power/mode/fan/swing/quiet/turbo/light/air/xfan/health/sleep state. The editor ships 37 categorized editable templates covering comfort, energy, safety, night, reliability, Home Assistant heat-source coordination and advanced multi-branch logic.
|
Additional Flow condition blocks are `house_mode`, `device_state`, `zone_state`, `group_state`, `night_mode`, `ha_attribute`, `ha_available`, `constant` and `shared_input`, alongside weekday/time/date, temperature, Home Assistant state/numeric and AND/OR/NOT blocks. `device_state` can inspect enabled/online/power/mode/fan/swing/quiet/turbo/light/air/xfan/health/sleep state. The editor ships 37 categorized editable templates covering comfort, energy, safety, night, reliability, Home Assistant heat-source coordination and advanced multi-branch logic. The template UI adds search, favorites/recent history, a graph preview and runtime requirement checks; shared inputs expose usage links and HA-backed inputs can be tested against live entity state.
|
||||||
|
|||||||
+3
-1
@@ -63,7 +63,9 @@ Actions include thermostat zone, GREE device and climate group. Direct GREE acti
|
|||||||
|
|
||||||
## Built-in presets
|
## Built-in presets
|
||||||
|
|
||||||
The editor ships 37 editable presets grouped into seven categories: Comfort, Energy & cost, Protection & safety, Night, Reliability, Home Assistant / heat sources and Advanced. The library includes workday/weekend comfort, weather-aware demand, morning boost, presence and energy-price eco modes, peak-power limiting, open-window/humidity/overheat/frost guards, night profiles, sensor/device resilience, dual heating/cooling thresholds, multi-room group control, nested AND/OR/NOT Home Assistant scenarios, and gas-boiler/external-heat-source coordination including off, reduced-target, boost and fallback heating patterns.
|
The editor ships 37 editable presets grouped into seven categories: Comfort, Energy & cost, Protection & safety, Night, Reliability, Home Assistant / heat sources and Advanced. The library UI also provides search, Favorites, Recent, a mini graph preview and requirement checks before applying a preset. The library includes workday/weekend comfort, weather-aware demand, morning boost, presence and energy-price eco modes, peak-power limiting, open-window/humidity/overheat/frost guards, night profiles, sensor/device resilience, dual heating/cooling thresholds, multi-room group control, nested AND/OR/NOT Home Assistant scenarios, and gas-boiler/external-heat-source coordination including off, reduced-target, boost and fallback heating patterns.
|
||||||
|
|
||||||
|
Shared Flow inputs show the Flows that reference them and link directly to those editors. Home Assistant-backed shared inputs can be tested live in the input modal, including the current raw value/attribute and the resulting TRUE/FALSE condition.
|
||||||
|
|
||||||
Presets are data files, not JavaScript definitions. Every preset lives in `presets/<id>.json` and contains `id`, `category`, localized `name`, localized `description`, and the complete `flow` graph (`nodes` + `edges`). `build.rs` validates and embeds the directory, exposes `/presets/index.json` plus `/presets/<file>.json`, and the Flow editor loads that library dynamically. Device/zone/group placeholders such as `$zone1`, `$zone2`, `$device1` and `$group1` are resolved when a preset is applied. Adding a preset therefore does not require adding graph-building code to `web/js/flows.js`.
|
Presets are data files, not JavaScript definitions. Every preset lives in `presets/<id>.json` and contains `id`, `category`, localized `name`, localized `description`, and the complete `flow` graph (`nodes` + `edges`). `build.rs` validates and embeds the directory, exposes `/presets/index.json` plus `/presets/<file>.json`, and the Flow editor loads that library dynamically. Device/zone/group placeholders such as `$zone1`, `$zone2`, `$device1` and `$group1` are resolved when a preset is applied. Adding a preset therefore does not require adding graph-building code to `web/js/flows.js`.
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"domain": "gree_controller",
|
"domain": "gree_controller",
|
||||||
"name": "GREE Controller",
|
"name": "GREE Controller",
|
||||||
"version": "0.9.9",
|
"version": "0.9.11",
|
||||||
"config_flow": true,
|
"config_flow": true,
|
||||||
"integration_type": "hub",
|
"integration_type": "hub",
|
||||||
"iot_class": "local_polling",
|
"iot_class": "local_polling",
|
||||||
|
|||||||
+80
-6
@@ -927,8 +927,6 @@
|
|||||||
"settings.haConnectionHint": "Server address and credentials used by all Home Assistant features.",
|
"settings.haConnectionHint": "Server address and credentials used by all Home Assistant features.",
|
||||||
"settings.haThermostatSources": "Thermostat temperature sources",
|
"settings.haThermostatSources": "Thermostat temperature sources",
|
||||||
"settings.haThermostatSourcesHint": "These settings belong to thermostat and temperature-source logic, not reusable Flow inputs.",
|
"settings.haThermostatSourcesHint": "These settings belong to thermostat and temperature-source logic, not reusable Flow inputs.",
|
||||||
"flow.listEyebrow": "Your automations",
|
|
||||||
"flow.listTitle": "Flow list",
|
|
||||||
"flow.listCount": "{count} Flows",
|
"flow.listCount": "{count} Flows",
|
||||||
"flow.quickToggleHint": "Enable or disable the Flow without opening the editor",
|
"flow.quickToggleHint": "Enable or disable the Flow without opening the editor",
|
||||||
"flow.quickEnabled": "Flow enabled",
|
"flow.quickEnabled": "Flow enabled",
|
||||||
@@ -965,8 +963,84 @@
|
|||||||
"flow.templateCategory.advanced.hint": "Nested AND/OR/NOT logic, multiple rooms and Home Assistant states.",
|
"flow.templateCategory.advanced.hint": "Nested AND/OR/NOT logic, multiple rooms and Home Assistant states.",
|
||||||
"flow.templateCategory.home_assistant": "Home Assistant / heat sources",
|
"flow.templateCategory.home_assistant": "Home Assistant / heat sources",
|
||||||
"flow.templateCategory.home_assistant.hint": "Coordinate GREE with a gas boiler, external heat source, heating demand and temperatures read from Home Assistant.",
|
"flow.templateCategory.home_assistant.hint": "Coordinate GREE with a gas boiler, external heat source, heating demand and temperatures read from Home Assistant.",
|
||||||
"flow.templatesLoadFailed": "Unable to load preset library."
|
"flow.templatesLoadFailed": "Unable to load preset library.",
|
||||||
},
|
"flow.sharedInputsSettingsLink": "Shared Flow inputs in Home Assistant →",
|
||||||
"flow.sharedInputsSettingsLink": "Shared Flow inputs in Home Assistant →",
|
"flow.templateCategories": "Preset categories",
|
||||||
"flow.templateCategories": "Preset categories"
|
"flow.sharedInputTest": "Test HA input",
|
||||||
|
"flow.sharedInputTesting": "Testing…",
|
||||||
|
"flow.sharedInputTestHint": "The test uses the saved Home Assistant connection and does not save this input.",
|
||||||
|
"flow.sharedInputTestEntityRequired": "Enter entity_id before testing.",
|
||||||
|
"flow.sharedInputTestCurrent": "Current value",
|
||||||
|
"flow.sharedInputTestExpected": "Condition",
|
||||||
|
"flow.sharedInputTestMatched": "TRUE — condition matched",
|
||||||
|
"flow.sharedInputTestNotMatched": "FALSE — condition did not match",
|
||||||
|
"flow.sharedInputTestAvailable": "Entity available",
|
||||||
|
"flow.sharedInputTestUnavailable": "Entity unavailable",
|
||||||
|
"flow.sharedInputUsedBy": "Used in {count} Flows",
|
||||||
|
"flow.sharedInputUnused": "Not used in any Flow",
|
||||||
|
"flow.openReferencedFlow": "Open Flow “{name}”",
|
||||||
|
"flow.templateSearch": "Search presets…",
|
||||||
|
"flow.templateSearchLabel": "Search presets",
|
||||||
|
"flow.templateFavorites": "Favorites",
|
||||||
|
"flow.templateRecent": "Recent",
|
||||||
|
"flow.templateNoResults": "No presets match the search.",
|
||||||
|
"flow.templateNoFavorites": "You have no favorite presets yet.",
|
||||||
|
"flow.templateNoRecent": "No preset has been used yet.",
|
||||||
|
"flow.templateFavoriteAdd": "Add to favorites",
|
||||||
|
"flow.templateFavoriteRemove": "Remove from favorites",
|
||||||
|
"flow.templatePreview": "Preset preview",
|
||||||
|
"flow.templatePreviewHint": "Review requirements and the block layout before replacing the current Flow.",
|
||||||
|
"flow.templateUse": "Use preset",
|
||||||
|
"flow.templateRequirements": "Requirements",
|
||||||
|
"flow.templateRequiresHa": "Home Assistant",
|
||||||
|
"flow.templateRequiresZone": "{count} zone(s)",
|
||||||
|
"flow.templateRequiresDevice": "{count} device(s)",
|
||||||
|
"flow.templateRequiresGroup": "{count} group(s)",
|
||||||
|
"flow.templateRequiresShared": "{count} shared input(s)",
|
||||||
|
"flow.templateRequiresEntity": "HA entities configured in the preset",
|
||||||
|
"flow.templateRequirementsReady": "Requirements available",
|
||||||
|
"flow.templateRequirementsMissing": "Missing: {items}",
|
||||||
|
"flow.templateNodesCount": "{count} blocks",
|
||||||
|
"flow.templateEdgesCount": "{count} connections",
|
||||||
|
"flow.templateSelectPreview": "Select a preset to preview it.",
|
||||||
|
"flow.templateCategory.favorites": "Favorites",
|
||||||
|
"flow.templateCategory.recent": "Recent",
|
||||||
|
"flow.enable": "Enable Flow",
|
||||||
|
"flow.disable": "Disable Flow",
|
||||||
|
"flow.logLevelInfo": "Info",
|
||||||
|
"flow.logLevelWarn": "Warning",
|
||||||
|
"flow.logLevelError": "Error",
|
||||||
|
"flow.logKindCreated": "Flow created",
|
||||||
|
"flow.logKindUpdated": "Flow updated",
|
||||||
|
"flow.logKindDeleted": "Flow deleted",
|
||||||
|
"flow.logKindImported": "Flow imported",
|
||||||
|
"flow.logKindDryRun": "Flow test",
|
||||||
|
"flow.logKindConditionError": "Condition error",
|
||||||
|
"flow.logKindActionSuppressed": "Action suppressed",
|
||||||
|
"flow.logKindActionFired": "Action executed",
|
||||||
|
"flow.logKindActionError": "Action error",
|
||||||
|
"flow.logKindBlockedZone": "Blocked by zone",
|
||||||
|
"flow.logKindBlockedManual": "Blocked by manual control",
|
||||||
|
"flow.logKindBlockedThermostat": "Blocked by thermostat",
|
||||||
|
"flow.logKindBlockedTemporary": "Blocked by temporary thermostat",
|
||||||
|
"flow.logKindBlockedThermostatOwner": "Device owned by thermostat",
|
||||||
|
"flow.logKindBlockedOwnership": "Control ownership changed",
|
||||||
|
"flow.logKindConflict": "Automation conflict",
|
||||||
|
"flow.logMessageCreated": "Flow “{name}” was created.",
|
||||||
|
"flow.logMessageUpdated": "Flow “{name}” was updated.",
|
||||||
|
"flow.logMessageDeleted": "Flow “{name}” was deleted.",
|
||||||
|
"flow.logMessageImported": "Flow “{name}” was imported.",
|
||||||
|
"flow.logMessageDryRun": "Flow “{name}” was tested without changing device state.",
|
||||||
|
"flow.logMessageConditionError": "One of the Flow conditions could not be evaluated correctly.",
|
||||||
|
"flow.logMessageActionSuppressed": "The Flow action was suppressed by the current control owner.",
|
||||||
|
"flow.logMessageActionFired": "The Flow action was executed.",
|
||||||
|
"flow.logMessageActionError": "The Flow action failed to execute.",
|
||||||
|
"flow.logMessageBlockedZone": "The Flow action was blocked because the related zone is disabled.",
|
||||||
|
"flow.logMessageBlockedManual": "The Flow action was blocked by manual device control.",
|
||||||
|
"flow.logMessageBlockedThermostat": "The Flow action was blocked by the local thermostat.",
|
||||||
|
"flow.logMessageBlockedTemporary": "The Flow action was blocked by the temporary thermostat.",
|
||||||
|
"flow.logMessageBlockedThermostatOwner": "The Flow action was suppressed because the device is controlled by an enabled thermostat zone.",
|
||||||
|
"flow.logMessageBlockedOwnership": "The Flow action was suppressed because control ownership changed during execution.",
|
||||||
|
"flow.logMessageConflict": "The Flow action was skipped because another automation already claimed the same target."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+80
-6
@@ -927,8 +927,6 @@
|
|||||||
"settings.haConnectionHint": "Adres serwera i dane dostępu używane przez wszystkie funkcje Home Assistant.",
|
"settings.haConnectionHint": "Adres serwera i dane dostępu używane przez wszystkie funkcje Home Assistant.",
|
||||||
"settings.haThermostatSources": "Źródła temperatury sterowania",
|
"settings.haThermostatSources": "Źródła temperatury sterowania",
|
||||||
"settings.haThermostatSourcesHint": "Te ustawienia należą do logiki termostatów i źródeł temperatury, nie do wspólnych wejść Flow.",
|
"settings.haThermostatSourcesHint": "Te ustawienia należą do logiki termostatów i źródeł temperatury, nie do wspólnych wejść Flow.",
|
||||||
"flow.listEyebrow": "Twoje automatyzacje",
|
|
||||||
"flow.listTitle": "Lista Flow",
|
|
||||||
"flow.listCount": "{count} Flow",
|
"flow.listCount": "{count} Flow",
|
||||||
"flow.quickToggleHint": "Szybko włącz lub wyłącz Flow bez otwierania edytora",
|
"flow.quickToggleHint": "Szybko włącz lub wyłącz Flow bez otwierania edytora",
|
||||||
"flow.quickEnabled": "Flow włączony",
|
"flow.quickEnabled": "Flow włączony",
|
||||||
@@ -965,8 +963,84 @@
|
|||||||
"flow.templateCategory.advanced.hint": "Wielopoziomowe AND/OR/NOT, wiele pomieszczeń i stany Home Assistant.",
|
"flow.templateCategory.advanced.hint": "Wielopoziomowe AND/OR/NOT, wiele pomieszczeń i stany Home Assistant.",
|
||||||
"flow.templateCategory.home_assistant": "Home Assistant / źródła ciepła",
|
"flow.templateCategory.home_assistant": "Home Assistant / źródła ciepła",
|
||||||
"flow.templateCategory.home_assistant.hint": "Koordynacja GREE z kotłem gazowym, zewnętrznym ogrzewaniem, zapotrzebowaniem i temperaturami pobieranymi z Home Assistant.",
|
"flow.templateCategory.home_assistant.hint": "Koordynacja GREE z kotłem gazowym, zewnętrznym ogrzewaniem, zapotrzebowaniem i temperaturami pobieranymi z Home Assistant.",
|
||||||
"flow.templatesLoadFailed": "Nie udało się wczytać biblioteki presetów."
|
"flow.templatesLoadFailed": "Nie udało się wczytać biblioteki presetów.",
|
||||||
},
|
"flow.sharedInputsSettingsLink": "Wspólne wejścia Flow w Home Assistant →",
|
||||||
"flow.sharedInputsSettingsLink": "Wspólne wejścia Flow w Home Assistant →",
|
"flow.templateCategories": "Kategorie presetów",
|
||||||
"flow.templateCategories": "Kategorie presetów"
|
"flow.sharedInputTest": "Testuj wejście HA",
|
||||||
|
"flow.sharedInputTesting": "Sprawdzanie…",
|
||||||
|
"flow.sharedInputTestHint": "Test używa zapisanej konfiguracji połączenia Home Assistant i nie zapisuje tego wejścia.",
|
||||||
|
"flow.sharedInputTestEntityRequired": "Podaj entity_id przed testem.",
|
||||||
|
"flow.sharedInputTestCurrent": "Aktualna wartość",
|
||||||
|
"flow.sharedInputTestExpected": "Warunek",
|
||||||
|
"flow.sharedInputTestMatched": "TRUE — warunek spełniony",
|
||||||
|
"flow.sharedInputTestNotMatched": "FALSE — warunek niespełniony",
|
||||||
|
"flow.sharedInputTestAvailable": "Encja dostępna",
|
||||||
|
"flow.sharedInputTestUnavailable": "Encja niedostępna",
|
||||||
|
"flow.sharedInputUsedBy": "Używane w {count} Flow",
|
||||||
|
"flow.sharedInputUnused": "Nieużywane w żadnym Flow",
|
||||||
|
"flow.openReferencedFlow": "Otwórz Flow „{name}”",
|
||||||
|
"flow.templateSearch": "Szukaj presetów…",
|
||||||
|
"flow.templateSearchLabel": "Szukaj presetów",
|
||||||
|
"flow.templateFavorites": "Ulubione",
|
||||||
|
"flow.templateRecent": "Ostatnie",
|
||||||
|
"flow.templateNoResults": "Brak presetów pasujących do wyszukiwania.",
|
||||||
|
"flow.templateNoFavorites": "Nie masz jeszcze ulubionych presetów.",
|
||||||
|
"flow.templateNoRecent": "Nie użyto jeszcze żadnego presetu.",
|
||||||
|
"flow.templateFavoriteAdd": "Dodaj do ulubionych",
|
||||||
|
"flow.templateFavoriteRemove": "Usuń z ulubionych",
|
||||||
|
"flow.templatePreview": "Podgląd presetu",
|
||||||
|
"flow.templatePreviewHint": "Sprawdź wymagania i układ bloków przed zastąpieniem bieżącego Flow.",
|
||||||
|
"flow.templateUse": "Użyj presetu",
|
||||||
|
"flow.templateRequirements": "Wymagania",
|
||||||
|
"flow.templateRequiresHa": "Home Assistant",
|
||||||
|
"flow.templateRequiresZone": "{count} strefa/strefy",
|
||||||
|
"flow.templateRequiresDevice": "{count} urządzenie/urządzenia",
|
||||||
|
"flow.templateRequiresGroup": "{count} grupa/grupy",
|
||||||
|
"flow.templateRequiresShared": "{count} wspólne wejście/wejścia",
|
||||||
|
"flow.templateRequiresEntity": "Encje HA skonfigurowane w presecie",
|
||||||
|
"flow.templateRequirementsReady": "Wymagania dostępne",
|
||||||
|
"flow.templateRequirementsMissing": "Brakuje: {items}",
|
||||||
|
"flow.templateNodesCount": "{count} bloków",
|
||||||
|
"flow.templateEdgesCount": "{count} połączeń",
|
||||||
|
"flow.templateSelectPreview": "Wybierz preset, aby zobaczyć podgląd.",
|
||||||
|
"flow.templateCategory.favorites": "Ulubione",
|
||||||
|
"flow.templateCategory.recent": "Ostatnie",
|
||||||
|
"flow.enable": "Włącz Flow",
|
||||||
|
"flow.disable": "Wyłącz Flow",
|
||||||
|
"flow.logLevelInfo": "Informacja",
|
||||||
|
"flow.logLevelWarn": "Ostrzeżenie",
|
||||||
|
"flow.logLevelError": "Błąd",
|
||||||
|
"flow.logKindCreated": "Utworzenie Flow",
|
||||||
|
"flow.logKindUpdated": "Aktualizacja Flow",
|
||||||
|
"flow.logKindDeleted": "Usunięcie Flow",
|
||||||
|
"flow.logKindImported": "Import Flow",
|
||||||
|
"flow.logKindDryRun": "Test Flow",
|
||||||
|
"flow.logKindConditionError": "Błąd warunku",
|
||||||
|
"flow.logKindActionSuppressed": "Akcja pominięta",
|
||||||
|
"flow.logKindActionFired": "Akcja wykonana",
|
||||||
|
"flow.logKindActionError": "Błąd akcji",
|
||||||
|
"flow.logKindBlockedZone": "Zablokowane przez strefę",
|
||||||
|
"flow.logKindBlockedManual": "Zablokowane przez sterowanie ręczne",
|
||||||
|
"flow.logKindBlockedThermostat": "Zablokowane przez termostat",
|
||||||
|
"flow.logKindBlockedTemporary": "Zablokowane przez termostat tymczasowy",
|
||||||
|
"flow.logKindBlockedThermostatOwner": "Urządzenie kontrolowane przez termostat",
|
||||||
|
"flow.logKindBlockedOwnership": "Zmiana właściciela sterowania",
|
||||||
|
"flow.logKindConflict": "Konflikt automatyzacji",
|
||||||
|
"flow.logMessageCreated": "Utworzono Flow „{name}”.",
|
||||||
|
"flow.logMessageUpdated": "Zaktualizowano Flow „{name}”.",
|
||||||
|
"flow.logMessageDeleted": "Usunięto Flow „{name}”.",
|
||||||
|
"flow.logMessageImported": "Zaimportowano Flow „{name}”.",
|
||||||
|
"flow.logMessageDryRun": "Wykonano test Flow „{name}” bez zmiany stanu urządzeń.",
|
||||||
|
"flow.logMessageConditionError": "Nie udało się poprawnie ocenić jednego z warunków Flow.",
|
||||||
|
"flow.logMessageActionSuppressed": "Akcja Flow została pominięta z powodu aktualnego właściciela sterowania.",
|
||||||
|
"flow.logMessageActionFired": "Akcja Flow została wykonana.",
|
||||||
|
"flow.logMessageActionError": "Wykonanie akcji Flow zakończyło się błędem.",
|
||||||
|
"flow.logMessageBlockedZone": "Akcja Flow została zablokowana, ponieważ powiązana strefa jest wyłączona.",
|
||||||
|
"flow.logMessageBlockedManual": "Akcja Flow została zablokowana przez ręczne sterowanie urządzeniem.",
|
||||||
|
"flow.logMessageBlockedThermostat": "Akcja Flow została zablokowana przez lokalny termostat.",
|
||||||
|
"flow.logMessageBlockedTemporary": "Akcja Flow została zablokowana przez tymczasowy termostat.",
|
||||||
|
"flow.logMessageBlockedThermostatOwner": "Akcja Flow została pominięta, ponieważ urządzeniem steruje aktywna strefa termostatu.",
|
||||||
|
"flow.logMessageBlockedOwnership": "Akcja Flow została pominięta, ponieważ w trakcie wykonania zmienił się właściciel sterowania.",
|
||||||
|
"flow.logMessageConflict": "Akcja Flow została pominięta z powodu konfliktu z inną automatyzacją dla tego samego celu."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,6 +103,7 @@ pub fn router(state: AppState) -> Router {
|
|||||||
.route("/api/access-tokens", get(list_access_tokens).post(create_access_token))
|
.route("/api/access-tokens", get(list_access_tokens).post(create_access_token))
|
||||||
.route("/api/access-tokens/:id", axum::routing::delete(delete_access_token))
|
.route("/api/access-tokens/:id", axum::routing::delete(delete_access_token))
|
||||||
.route("/api/integrations/home-assistant/test", post(test_home_assistant))
|
.route("/api/integrations/home-assistant/test", post(test_home_assistant))
|
||||||
|
.route("/api/integrations/home-assistant/entity", post(inspect_home_assistant_entity))
|
||||||
.route("/api/integrations/notifications/test", post(test_notifications))
|
.route("/api/integrations/notifications/test", post(test_notifications))
|
||||||
.route_layer(middleware::from_fn_with_state(state.clone(), auth));
|
.route_layer(middleware::from_fn_with_state(state.clone(), auth));
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,30 @@ async fn test_home_assistant(State(state): State<AppState>, Json(input): Json<Ha
|
|||||||
Ok(Json(json!({"ok": true, "temperature_c": temperature, "entity_id": resolved_entity_id})))
|
Ok(Json(json!({"ok": true, "temperature_c": temperature, "entity_id": resolved_entity_id})))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct HaEntityRequest { entity_id: String }
|
||||||
|
|
||||||
|
async fn inspect_home_assistant_entity(State(state): State<AppState>, Json(input): Json<HaEntityRequest>) -> Result<Json<Value>, AppError> {
|
||||||
|
let settings = state.settings.read().await.clone();
|
||||||
|
let entity_id = home_assistant::resolve_entity_id(&settings.home_assistant, Some(input.entity_id.as_str()))
|
||||||
|
.filter(|value| !value.trim().is_empty())
|
||||||
|
.ok_or_else(|| AppError::BadRequest("Home Assistant entity_id is required".into()))?;
|
||||||
|
let payload = home_assistant::read_entity(&state.http, &settings.home_assistant, Some(entity_id.as_str()))
|
||||||
|
.await.map_err(|e| AppError::Device(e.to_string()))?;
|
||||||
|
let raw_state = payload.get("state").and_then(Value::as_str).unwrap_or_default().to_string();
|
||||||
|
let available = !matches!(raw_state.as_str(), "unknown" | "unavailable" | "");
|
||||||
|
Ok(Json(json!({
|
||||||
|
"ok": true,
|
||||||
|
"entity_id": entity_id,
|
||||||
|
"state": raw_state,
|
||||||
|
"available": available,
|
||||||
|
"attributes": payload.get("attributes").cloned().unwrap_or_else(|| json!({})),
|
||||||
|
"last_changed": payload.get("last_changed").cloned().unwrap_or(Value::Null),
|
||||||
|
"last_updated": payload.get("last_updated").cloned().unwrap_or(Value::Null),
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
async fn test_notifications(State(state): State<AppState>, Json(mut input): Json<NotificationSettings>) -> Result<Json<Value>, AppError> {
|
async fn test_notifications(State(state): State<AppState>, Json(mut input): Json<NotificationSettings>) -> Result<Json<Value>, AppError> {
|
||||||
let old = state.settings.read().await.notifications.clone();
|
let old = state.settings.read().await.notifications.clone();
|
||||||
if input.pushover_app_token.trim().is_empty() { input.pushover_app_token = old.pushover_app_token; }
|
if input.pushover_app_token.trim().is_empty() { input.pushover_app_token = old.pushover_app_token; }
|
||||||
|
|||||||
+14
-3
@@ -189,7 +189,7 @@
|
|||||||
<span class="badge" data-i18n="flow.sourceOfTruth">Flow = source of truth</span>
|
<span class="badge" data-i18n="flow.sourceOfTruth">Flow = source of truth</span>
|
||||||
</div>
|
</div>
|
||||||
<section class="flow-list-section">
|
<section class="flow-list-section">
|
||||||
<div class="flow-list-heading"><div><span class="eyebrow" data-i18n="flow.listEyebrow">Twoje automatyzacje</span><h2 data-i18n="flow.listTitle">Lista Flow</h2></div><span id="flowListCount" class="badge"></span></div>
|
<div class="flow-list-heading flow-list-heading-count"><span id="flowListCount" class="badge"></span></div>
|
||||||
<div class="list-grid" id="flowList"></div>
|
<div class="list-grid" id="flowList"></div>
|
||||||
</section>
|
</section>
|
||||||
</section>
|
</section>
|
||||||
@@ -728,6 +728,11 @@
|
|||||||
<label><span data-i18n="common.name">Nazwa</span><input name="name" maxlength="100" required placeholder="Okno w salonie zamknięte"></label>
|
<label><span data-i18n="common.name">Nazwa</span><input name="name" maxlength="100" required placeholder="Okno w salonie zamknięte"></label>
|
||||||
<label><span data-i18n="flow.sharedInputType">Typ źródła</span><select name="kind" id="flowSharedInputKind"></select></label>
|
<label><span data-i18n="flow.sharedInputType">Typ źródła</span><select name="kind" id="flowSharedInputKind"></select></label>
|
||||||
<div id="flowSharedInputFields" class="flow-shared-input-fields"></div>
|
<div id="flowSharedInputFields" class="flow-shared-input-fields"></div>
|
||||||
|
<div id="flowSharedInputTestPanel" class="flow-shared-test-panel" hidden>
|
||||||
|
<div><strong data-i18n="flow.sharedInputTest">Testuj wejście HA</strong><p class="field-note" data-i18n="flow.sharedInputTestHint">Test używa zapisanej konfiguracji połączenia Home Assistant i nie zapisuje tego wejścia.</p></div>
|
||||||
|
<button type="button" class="secondary" id="flowSharedInputTest" data-i18n="flow.sharedInputTest">Testuj wejście HA</button>
|
||||||
|
<div id="flowSharedInputTestResult" class="flow-shared-test-result" hidden></div>
|
||||||
|
</div>
|
||||||
<div class="dialog-actions flow-shared-dialog-actions"><button type="button" class="secondary" data-close data-i18n="actions.cancel">Anuluj</button><button type="submit" class="primary" data-i18n="actions.save">Zapisz</button></div>
|
<div class="dialog-actions flow-shared-dialog-actions"><button type="button" class="secondary" data-close data-i18n="actions.cancel">Anuluj</button><button type="submit" class="primary" data-i18n="actions.save">Zapisz</button></div>
|
||||||
</form>
|
</form>
|
||||||
</dialog>
|
</dialog>
|
||||||
@@ -735,8 +740,14 @@
|
|||||||
<dialog id="flowTemplateDialog" class="flow-tool-dialog">
|
<dialog id="flowTemplateDialog" class="flow-tool-dialog">
|
||||||
<div class="dialog-head"><div><span class="eyebrow" data-i18n="flow.templateLibrary">Flow library</span><h2 data-i18n="flow.templates">Templates</h2></div><button type="button" data-close>×</button></div>
|
<div class="dialog-head"><div><span class="eyebrow" data-i18n="flow.templateLibrary">Flow library</span><h2 data-i18n="flow.templates">Templates</h2></div><button type="button" data-close>×</button></div>
|
||||||
<p class="field-note" data-i18n="flow.templatesHint">Ready-made layouts create an editable graph using existing zones, devices and groups.</p>
|
<p class="field-note" data-i18n="flow.templatesHint">Ready-made layouts create an editable graph using existing zones, devices and groups.</p>
|
||||||
<div id="flowTemplateTabs" class="flow-template-tabs" role="tablist" data-i18n-aria="flow.templateCategories" aria-label="Preset categories"></div>
|
<div class="flow-template-toolbar"><label><span data-i18n="flow.templateSearchLabel">Szukaj presetów</span><input id="flowTemplateSearch" type="search" autocomplete="off" data-i18n-placeholder="flow.templateSearch" placeholder="Szukaj presetów…"></label></div>
|
||||||
<div id="flowTemplateList" class="flow-template-list"></div>
|
<div class="flow-template-browser">
|
||||||
|
<div class="flow-template-catalog">
|
||||||
|
<div id="flowTemplateTabs" class="flow-template-tabs" role="tablist" data-i18n-aria="flow.templateCategories" aria-label="Preset categories"></div>
|
||||||
|
<div id="flowTemplateList" class="flow-template-list"></div>
|
||||||
|
</div>
|
||||||
|
<aside id="flowTemplatePreview" class="flow-template-preview"><div class="empty compact"><strong data-i18n="flow.templatePreview">Podgląd presetu</strong><span data-i18n="flow.templateSelectPreview">Wybierz preset, aby zobaczyć podgląd.</span></div></aside>
|
||||||
|
</div>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|
||||||
<dialog id="flowTestDialog" class="flow-tool-dialog">
|
<dialog id="flowTestDialog" class="flow-tool-dialog">
|
||||||
|
|||||||
+170
-12
@@ -57,7 +57,7 @@ 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 ? '' : 'is-disabled'}">
|
||||||
<div class="list-card-head"><div><h3>${esc(flow.name)}</h3><p>${esc(flow.description || tr('flow.defaultDescription'))}</p></div><label class="flow-quick-toggle" title="${esc(tr('flow.quickToggleHint'))}"><input type="checkbox" data-flow-toggle="${esc(flow.id)}" ${flow.enabled ? 'checked' : ''}><span>${esc(flow.enabled ? tr('common.enabled') : tr('common.disabled'))}</span></label></div>
|
<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="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(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>`;
|
||||||
@@ -303,9 +303,33 @@ async function importFlowFile(file) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const FLOW_PRESET_CATEGORY_ORDER = ['comfort','energy','safety','night','reliability','home_assistant','advanced'];
|
const FLOW_PRESET_CATEGORY_ORDER = ['comfort','energy','safety','night','reliability','home_assistant','advanced'];
|
||||||
|
const FLOW_PRESET_FAVORITES_KEY = 'gree_controller_flow_preset_favorites';
|
||||||
|
const FLOW_PRESET_RECENT_KEY = 'gree_controller_flow_preset_recent';
|
||||||
let flowPresetLoadPromise = null;
|
let flowPresetLoadPromise = null;
|
||||||
let flowPresetActiveCategory = '';
|
let flowPresetActiveCategory = '';
|
||||||
|
let flowPresetPreviewId = '';
|
||||||
|
let flowPresetSearch = '';
|
||||||
|
|
||||||
|
function flowPresetStoredIds(key) {
|
||||||
|
try {
|
||||||
|
const value = JSON.parse(localStorage.getItem(key) || '[]');
|
||||||
|
return Array.isArray(value) ? value.filter(item => typeof item === 'string') : [];
|
||||||
|
} catch (_) { return []; }
|
||||||
|
}
|
||||||
|
function flowPresetFavoriteIds() { return new Set(flowPresetStoredIds(FLOW_PRESET_FAVORITES_KEY)); }
|
||||||
|
function flowPresetRecentIds() { return flowPresetStoredIds(FLOW_PRESET_RECENT_KEY); }
|
||||||
|
function saveFlowPresetIds(key, ids) { try { localStorage.setItem(key, JSON.stringify(ids)); } catch (_) {} }
|
||||||
|
function toggleFlowPresetFavorite(id) {
|
||||||
|
const ids = flowPresetFavoriteIds();
|
||||||
|
if (ids.has(id)) ids.delete(id); else ids.add(id);
|
||||||
|
saveFlowPresetIds(FLOW_PRESET_FAVORITES_KEY, [...ids]);
|
||||||
|
renderFlowPresetBrowser(flowPresetActiveCategory);
|
||||||
|
}
|
||||||
|
function markFlowPresetRecent(id) {
|
||||||
|
const ids = flowPresetRecentIds().filter(value => value !== id);
|
||||||
|
ids.unshift(id);
|
||||||
|
saveFlowPresetIds(FLOW_PRESET_RECENT_KEY, ids.slice(0, 8));
|
||||||
|
}
|
||||||
|
|
||||||
function flowPresetText(value) {
|
function flowPresetText(value) {
|
||||||
if (typeof value === 'string') return value;
|
if (typeof value === 'string') return value;
|
||||||
@@ -381,6 +405,84 @@ function materializeFlowPreset(preset) {
|
|||||||
return { nodes, edges };
|
return { nodes, edges };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function flowPresetRequirements(preset) {
|
||||||
|
const nodes = preset?.flow?.nodes || [];
|
||||||
|
const placeholders = { zone:new Set(), device:new Set(), group:new Set(), shared:new Set() };
|
||||||
|
const visit = value => {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const match = /^\$(zone|device|group|shared)(\d+)$/.exec(value);
|
||||||
|
if (match) placeholders[match[1]].add(value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) value.forEach(visit);
|
||||||
|
else if (value && typeof value === 'object') Object.values(value).forEach(visit);
|
||||||
|
};
|
||||||
|
nodes.forEach(node => visit(node.config || {}));
|
||||||
|
const hasHa = nodes.some(node => ['ha_state','ha_numeric','ha_attribute','ha_available'].includes(node.kind));
|
||||||
|
const haEntities = [...new Set(nodes.map(node => node.config?.entity_id).filter(value => typeof value === 'string' && value && !value.startsWith('$')))];
|
||||||
|
const requirements = [];
|
||||||
|
const missing = [];
|
||||||
|
const addCount = (kind, count, available, key) => {
|
||||||
|
if (!count) return;
|
||||||
|
const label = tr(key, { count });
|
||||||
|
requirements.push({ label, ok:available >= count });
|
||||||
|
if (available < count) missing.push(label);
|
||||||
|
};
|
||||||
|
addCount('zone', placeholders.zone.size, (app.zones || []).length, 'flow.templateRequiresZone');
|
||||||
|
addCount('device', placeholders.device.size, (app.devices || []).length, 'flow.templateRequiresDevice');
|
||||||
|
addCount('group', placeholders.group.size, (app.groups || []).length, 'flow.templateRequiresGroup');
|
||||||
|
addCount('shared', placeholders.shared.size, (app.flowSharedInputs || []).length, 'flow.templateRequiresShared');
|
||||||
|
if (hasHa) {
|
||||||
|
const ready = Boolean(app.settings?.home_assistant?.url && app.settings?.home_assistant?.token_configured);
|
||||||
|
requirements.push({ label:tr('flow.templateRequiresHa'), ok:ready });
|
||||||
|
if (!ready) missing.push(tr('flow.templateRequiresHa'));
|
||||||
|
}
|
||||||
|
if (haEntities.length) requirements.push({ label:`${tr('flow.templateRequiresEntity')}: ${haEntities.join(', ')}`, ok:true, info:true });
|
||||||
|
return { requirements, missing };
|
||||||
|
}
|
||||||
|
|
||||||
|
function flowPresetPreviewGraph(preset) {
|
||||||
|
const nodes = preset?.flow?.nodes || [], edges = preset?.flow?.edges || [];
|
||||||
|
if (!nodes.length) return '';
|
||||||
|
const byId = new Map(nodes.map(node => [node.id, node]));
|
||||||
|
const xs = nodes.map(node => Number(node.x || 0)), ys = nodes.map(node => Number(node.y || 0));
|
||||||
|
const minX = Math.min(...xs), maxX = Math.max(...xs), minY = Math.min(...ys), maxY = Math.max(...ys);
|
||||||
|
const spanX = Math.max(1, maxX - minX), spanY = Math.max(1, maxY - minY);
|
||||||
|
const point = node => ({ x:8 + ((Number(node.x || 0) - minX) / spanX) * 84, y:12 + ((Number(node.y || 0) - minY) / spanY) * 76 });
|
||||||
|
const lines = edges.map(edge => {
|
||||||
|
const from = byId.get(edge.from), to = byId.get(edge.to); if (!from || !to) return '';
|
||||||
|
const a = point(from), b = point(to);
|
||||||
|
return `<line x1="${a.x.toFixed(2)}" y1="${a.y.toFixed(2)}" x2="${b.x.toFixed(2)}" y2="${b.y.toFixed(2)}"></line>`;
|
||||||
|
}).join('');
|
||||||
|
const blocks = nodes.map(node => {
|
||||||
|
const p = point(node), meta = FLOW_NODE_META[node.kind] || { title:node.kind, category:'logic' };
|
||||||
|
return `<div class="flow-template-preview-node flow-template-preview-node-${esc(meta.category)}" style="left:${p.x.toFixed(2)}%;top:${p.y.toFixed(2)}%" title="${esc(flowNodeTitle(meta))}">${esc(flowNodeTitle(meta))}</div>`;
|
||||||
|
}).join('');
|
||||||
|
return `<div class="flow-template-preview-canvas"><svg viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">${lines}</svg>${blocks}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFlowPresetPreview(preset) {
|
||||||
|
const host = $('#flowTemplatePreview'); if (!host) return;
|
||||||
|
if (!preset) {
|
||||||
|
host.innerHTML = `<div class="empty compact"><strong>${esc(tr('flow.templatePreview'))}</strong><span>${esc(tr('flow.templateSelectPreview'))}</span></div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const req = flowPresetRequirements(preset);
|
||||||
|
const favorite = flowPresetFavoriteIds().has(preset.id);
|
||||||
|
const reqMarkup = req.requirements.length
|
||||||
|
? req.requirements.map(item => `<span class="flow-template-requirement ${item.ok ? 'ok' : 'missing'} ${item.info ? 'info' : ''}">${item.ok ? '✓' : '!' } ${esc(item.label)}</span>`).join('')
|
||||||
|
: `<span class="flow-template-requirement ok">✓ ${esc(tr('flow.templateRequirementsReady'))}</span>`;
|
||||||
|
host.innerHTML = `<div class="flow-template-preview-head"><div><span class="eyebrow">${esc(tr('flow.templatePreview'))}</span><h3>${esc(flowPresetText(preset.name) || preset.id)}</h3></div><button type="button" class="flow-template-favorite ${favorite ? 'active' : ''}" data-flow-template-favorite="${esc(preset.id)}" title="${esc(favorite ? tr('flow.templateFavoriteRemove') : tr('flow.templateFavoriteAdd'))}" aria-label="${esc(favorite ? tr('flow.templateFavoriteRemove') : tr('flow.templateFavoriteAdd'))}">★</button></div>
|
||||||
|
<p>${esc(flowPresetText(preset.description))}</p>
|
||||||
|
<div class="flow-template-preview-stats"><span>${esc(tr('flow.templateNodesCount', { count:preset.flow.nodes.length }))}</span><span>${esc(tr('flow.templateEdgesCount', { count:preset.flow.edges.length }))}</span></div>
|
||||||
|
${flowPresetPreviewGraph(preset)}
|
||||||
|
<div class="flow-template-requirements"><strong>${esc(tr('flow.templateRequirements'))}</strong><div>${reqMarkup}</div>${req.missing.length ? `<p class="field-note warning-note">${esc(tr('flow.templateRequirementsMissing', { items:req.missing.join(', ') }))}</p>` : ''}</div>
|
||||||
|
<div class="dialog-actions"><button type="button" class="primary" data-flow-template-use="${esc(preset.id)}">${esc(tr('flow.templateUse'))}</button></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function flowPresetCategoryLabel(key) { return tr(`flow.templateCategory.${key}`) === `flow.templateCategory.${key}` ? key : tr(`flow.templateCategory.${key}`); }
|
||||||
|
function flowPresetCategoryHint(key) { const name = `flow.templateCategory.${key}.hint`, value = tr(name); return value === name ? '' : value; }
|
||||||
|
|
||||||
function renderFlowPresetBrowser(category = '') {
|
function renderFlowPresetBrowser(category = '') {
|
||||||
const tabs = $('#flowTemplateTabs'), host = $('#flowTemplateList');
|
const tabs = $('#flowTemplateTabs'), host = $('#flowTemplateList');
|
||||||
if (!tabs || !host) return;
|
if (!tabs || !host) return;
|
||||||
@@ -391,20 +493,36 @@ function renderFlowPresetBrowser(category = '') {
|
|||||||
if (!byCategory.has(key)) byCategory.set(key, []);
|
if (!byCategory.has(key)) byCategory.set(key, []);
|
||||||
byCategory.get(key).push(preset);
|
byCategory.get(key).push(preset);
|
||||||
});
|
});
|
||||||
const categories = [...new Set([...FLOW_PRESET_CATEGORY_ORDER, ...byCategory.keys()])].filter(key => byCategory.has(key));
|
const favorites = flowPresetFavoriteIds();
|
||||||
if (!categories.length) { tabs.innerHTML = ''; host.innerHTML = ''; return; }
|
const recent = flowPresetRecentIds();
|
||||||
flowPresetActiveCategory = categories.includes(category) ? category : (categories.includes(flowPresetActiveCategory) ? flowPresetActiveCategory : categories[0]);
|
byCategory.set('favorites', presets.filter(preset => favorites.has(preset.id)));
|
||||||
tabs.innerHTML = categories.map(key => `<button type="button" class="flow-template-tab ${key === flowPresetActiveCategory ? 'active' : ''}" role="tab" aria-selected="${key === flowPresetActiveCategory}" data-flow-template-category="${esc(key)}">${esc(tr(`flow.templateCategory.${key}`) || key)} <span class="badge">${byCategory.get(key).length}</span></button>`).join('');
|
byCategory.set('recent', recent.map(id => presets.find(preset => preset.id === id)).filter(Boolean));
|
||||||
const active = byCategory.get(flowPresetActiveCategory) || [];
|
const normalCategories = [...new Set([...FLOW_PRESET_CATEGORY_ORDER, ...presets.map(preset => preset.category || 'advanced')])].filter(key => (byCategory.get(key) || []).length);
|
||||||
host.innerHTML = `<section class="flow-template-category-single"><div class="flow-template-category-head"><strong>${esc(tr(`flow.templateCategory.${flowPresetActiveCategory}`) || flowPresetActiveCategory)}</strong><span>${esc(tr(`flow.templateCategory.${flowPresetActiveCategory}.hint`) || '')}</span></div><div class="flow-template-category-grid">${active.map(preset => `<button type="button" class="flow-template-card" data-flow-template="${esc(preset.id)}"><strong>${esc(flowPresetText(preset.name) || preset.id)}</strong><span>${esc(flowPresetText(preset.description))}</span></button>`).join('')}</div></section>`;
|
const categories = ['favorites','recent', ...normalCategories];
|
||||||
|
if (!presets.length) { tabs.innerHTML = ''; host.innerHTML = ''; renderFlowPresetPreview(null); return; }
|
||||||
|
flowPresetActiveCategory = categories.includes(category) ? category : (categories.includes(flowPresetActiveCategory) ? flowPresetActiveCategory : normalCategories[0]);
|
||||||
|
tabs.innerHTML = categories.map(key => `<button type="button" class="flow-template-tab ${key === flowPresetActiveCategory ? 'active' : ''}" role="tab" aria-selected="${key === flowPresetActiveCategory}" data-flow-template-category="${esc(key)}">${esc(flowPresetCategoryLabel(key))} <span class="badge">${(byCategory.get(key) || []).length}</span></button>`).join('');
|
||||||
|
const query = flowPresetSearch.trim().toLocaleLowerCase(locale());
|
||||||
|
const activeAll = byCategory.get(flowPresetActiveCategory) || [];
|
||||||
|
const active = query ? activeAll.filter(preset => `${flowPresetText(preset.name)} ${flowPresetText(preset.description)} ${preset.id}`.toLocaleLowerCase(locale()).includes(query)) : activeAll;
|
||||||
|
const emptyKey = query ? 'flow.templateNoResults' : flowPresetActiveCategory === 'favorites' ? 'flow.templateNoFavorites' : flowPresetActiveCategory === 'recent' ? 'flow.templateNoRecent' : 'flow.templateNoResults';
|
||||||
|
const cards = active.map(preset => {
|
||||||
|
const isFavorite = favorites.has(preset.id), selected = preset.id === flowPresetPreviewId;
|
||||||
|
return `<article class="flow-template-card ${selected ? 'selected' : ''}"><button type="button" class="flow-template-card-main" data-flow-template-preview="${esc(preset.id)}"><strong>${esc(flowPresetText(preset.name) || preset.id)}</strong><span>${esc(flowPresetText(preset.description))}</span></button><button type="button" class="flow-template-favorite ${isFavorite ? 'active' : ''}" data-flow-template-favorite="${esc(preset.id)}" title="${esc(isFavorite ? tr('flow.templateFavoriteRemove') : tr('flow.templateFavoriteAdd'))}" aria-label="${esc(isFavorite ? tr('flow.templateFavoriteRemove') : tr('flow.templateFavoriteAdd'))}">★</button></article>`;
|
||||||
|
}).join('');
|
||||||
|
host.innerHTML = `<section class="flow-template-category-single"><div class="flow-template-category-head"><strong>${esc(flowPresetCategoryLabel(flowPresetActiveCategory))}</strong><span>${esc(flowPresetCategoryHint(flowPresetActiveCategory))}</span></div>${cards ? `<div class="flow-template-category-grid">${cards}</div>` : `<div class="empty compact"><span>${esc(tr(emptyKey))}</span></div>`}</section>`;
|
||||||
|
if (!active.some(preset => preset.id === flowPresetPreviewId)) flowPresetPreviewId = active[0]?.id || '';
|
||||||
|
renderFlowPresetPreview(presets.find(preset => preset.id === flowPresetPreviewId) || null);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openFlowTemplates() {
|
async function openFlowTemplates() {
|
||||||
if (!app.flowDraft) return;
|
if (!app.flowDraft) return;
|
||||||
const host = $('#flowTemplateList'), tabs = $('#flowTemplateTabs');
|
const host = $('#flowTemplateList'), tabs = $('#flowTemplateTabs'), search = $('#flowTemplateSearch');
|
||||||
if (!host) return;
|
if (!host) return;
|
||||||
if (tabs) tabs.innerHTML = '';
|
if (tabs) tabs.innerHTML = '';
|
||||||
|
if (search) { search.value = ''; flowPresetSearch = ''; }
|
||||||
host.innerHTML = `<p class="field-note">${esc(tr('common.loading'))}</p>`;
|
host.innerHTML = `<p class="field-note">${esc(tr('common.loading'))}</p>`;
|
||||||
|
renderFlowPresetPreview(null);
|
||||||
$('#flowTemplateDialog')?.showModal();
|
$('#flowTemplateDialog')?.showModal();
|
||||||
try {
|
try {
|
||||||
await loadFlowPresets();
|
await loadFlowPresets();
|
||||||
@@ -415,6 +533,12 @@ async function openFlowTemplates() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function previewFlowTemplate(key) {
|
||||||
|
const preset = (app.flowPresets || []).find(item => item.id === key); if (!preset) return;
|
||||||
|
flowPresetPreviewId = preset.id;
|
||||||
|
renderFlowPresetBrowser(flowPresetActiveCategory);
|
||||||
|
}
|
||||||
|
|
||||||
function applyFlowTemplate(key) {
|
function applyFlowTemplate(key) {
|
||||||
if (!app.flowDraft) return;
|
if (!app.flowDraft) return;
|
||||||
const preset = (app.flowPresets || []).find(item => item.id === key);
|
const preset = (app.flowPresets || []).find(item => item.id === key);
|
||||||
@@ -424,9 +548,11 @@ function applyFlowTemplate(key) {
|
|||||||
app.flowDraft.nodes = graph.nodes; app.flowDraft.edges = graph.edges;
|
app.flowDraft.nodes = graph.nodes; app.flowDraft.edges = graph.edges;
|
||||||
app.flowDraft.description = flowPresetText(preset.description); app.flowSelectedNodeId = null; app.flowSelectedNodeIds = []; app.flowDirty = true;
|
app.flowDraft.description = flowPresetText(preset.description); app.flowSelectedNodeId = null; app.flowSelectedNodeIds = []; app.flowDirty = true;
|
||||||
if (!app.flowDraft.id || $('#flowName').value === tr('flow.newDefaultName')) $('#flowName').value = flowPresetText(preset.name) || preset.id;
|
if (!app.flowDraft.id || $('#flowName').value === tr('flow.newDefaultName')) $('#flowName').value = flowPresetText(preset.name) || preset.id;
|
||||||
|
markFlowPresetRecent(preset.id);
|
||||||
$('#flowTemplateDialog')?.close(); renderFlowEditor();
|
$('#flowTemplateDialog')?.close(); renderFlowEditor();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function localDateTimeInputValue(date = new Date()) {
|
function localDateTimeInputValue(date = new Date()) {
|
||||||
const pad = value => String(value).padStart(2, '0');
|
const pad = value => String(value).padStart(2, '0');
|
||||||
return `${date.getFullYear()}-${pad(date.getMonth()+1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
return `${date.getFullYear()}-${pad(date.getMonth()+1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
||||||
@@ -474,13 +600,42 @@ async function runFlowDryRun() {
|
|||||||
renderFlowDryRunResult(result);
|
renderFlowDryRunResult(result);
|
||||||
} catch (error) { $('#flowTestResults').innerHTML = `<div class="empty compact"><strong>${esc(tr('flow.simulationFailed'))}</strong><span>${esc(error.message)}</span></div>`; }
|
} catch (error) { $('#flowTestResults').innerHTML = `<div class="empty compact"><strong>${esc(tr('flow.simulationFailed'))}</strong><span>${esc(error.message)}</span></div>`; }
|
||||||
}
|
}
|
||||||
|
function flowLogLevelLabel(level = '') {
|
||||||
|
const key = { info:'flow.logLevelInfo', warn:'flow.logLevelWarn', error:'flow.logLevelError' }[String(level).toLowerCase()];
|
||||||
|
return key ? tr(key) : level;
|
||||||
|
}
|
||||||
|
function flowLogKindLabel(kind = '') {
|
||||||
|
const key = {
|
||||||
|
'flow.created':'flow.logKindCreated', 'flow.updated':'flow.logKindUpdated', 'flow.deleted':'flow.logKindDeleted',
|
||||||
|
'flow.imported':'flow.logKindImported', 'flow.dry_run':'flow.logKindDryRun', 'flow.condition_error':'flow.logKindConditionError',
|
||||||
|
'flow.action_suppressed':'flow.logKindActionSuppressed', 'automation.fired':'flow.logKindActionFired',
|
||||||
|
'automation.error':'flow.logKindActionError', 'automation.blocked_by_zone':'flow.logKindBlockedZone',
|
||||||
|
'automation.blocked_by_manual_override':'flow.logKindBlockedManual', 'automation.blocked_by_local_thermostat':'flow.logKindBlockedThermostat',
|
||||||
|
'automation.blocked_by_temporary_thermostat':'flow.logKindBlockedTemporary', 'automation.blocked_by_thermostat_owner':'flow.logKindBlockedThermostatOwner',
|
||||||
|
'automation.blocked_by_fresh_ownership':'flow.logKindBlockedOwnership', 'automation.conflict':'flow.logKindConflict',
|
||||||
|
}[kind];
|
||||||
|
return key ? tr(key) : kind;
|
||||||
|
}
|
||||||
|
function flowLogMessage(event = {}) {
|
||||||
|
const name = app.flowDraft?.name || tr('nav.flows');
|
||||||
|
const key = {
|
||||||
|
'flow.created':'flow.logMessageCreated', 'flow.updated':'flow.logMessageUpdated', 'flow.deleted':'flow.logMessageDeleted',
|
||||||
|
'flow.imported':'flow.logMessageImported', 'flow.dry_run':'flow.logMessageDryRun', 'flow.condition_error':'flow.logMessageConditionError',
|
||||||
|
'flow.action_suppressed':'flow.logMessageActionSuppressed', 'automation.fired':'flow.logMessageActionFired',
|
||||||
|
'automation.error':'flow.logMessageActionError', 'automation.blocked_by_zone':'flow.logMessageBlockedZone',
|
||||||
|
'automation.blocked_by_manual_override':'flow.logMessageBlockedManual', 'automation.blocked_by_local_thermostat':'flow.logMessageBlockedThermostat',
|
||||||
|
'automation.blocked_by_temporary_thermostat':'flow.logMessageBlockedTemporary', 'automation.blocked_by_thermostat_owner':'flow.logMessageBlockedThermostatOwner',
|
||||||
|
'automation.blocked_by_fresh_ownership':'flow.logMessageBlockedOwnership', 'automation.conflict':'flow.logMessageConflict',
|
||||||
|
}[event.kind];
|
||||||
|
return key ? tr(key, { name }) : (event.message || '—');
|
||||||
|
}
|
||||||
async function openFlowLogs() {
|
async function openFlowLogs() {
|
||||||
if (!app.flowDraft?.id) return toast(tr('flow.saveBeforeLogs'), true);
|
if (!app.flowDraft?.id) return toast(tr('flow.saveBeforeLogs'), true);
|
||||||
$('#flowTestTitle').textContent = tr('flow.logs'); $('#flowSimulationControls').hidden = true; $('#flowTestResults').innerHTML = `<p class="field-note">${esc(tr('common.loading'))}</p>`; $('#flowTestDialog')?.showModal();
|
$('#flowTestTitle').textContent = tr('flow.logs'); $('#flowSimulationControls').hidden = true; $('#flowTestResults').innerHTML = `<p class="field-note">${esc(tr('common.loading'))}</p>`; $('#flowTestDialog')?.showModal();
|
||||||
try {
|
try {
|
||||||
const result = await api(`/api/flows/${encodeURIComponent(app.flowDraft.id)}/logs?limit=150`);
|
const result = await api(`/api/flows/${encodeURIComponent(app.flowDraft.id)}/logs?limit=150`);
|
||||||
const events = result.events || [];
|
const events = result.events || [];
|
||||||
$('#flowTestResults').innerHTML = events.length ? events.map(event => `<article class="flow-log-row"><div><strong>${esc(event.kind)}</strong><span class="badge">${esc(event.level)}</span></div><p>${esc(event.message)}</p><small>${esc(new Date(event.timestamp).toLocaleString())}</small></article>`).join('') : `<div class="empty compact"><strong>${esc(tr('flow.noLogs'))}</strong></div>`;
|
$('#flowTestResults').innerHTML = events.length ? events.map(event => `<article class="flow-log-row"><div><strong>${esc(flowLogKindLabel(event.kind))}</strong><span class="badge">${esc(flowLogLevelLabel(event.level))}</span></div><p>${esc(flowLogMessage(event))}</p><small>${esc(new Date(event.timestamp).toLocaleString())}</small></article>`).join('') : `<div class="empty compact"><strong>${esc(tr('flow.noLogs'))}</strong></div>`;
|
||||||
} 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>`; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -504,7 +659,7 @@ 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;
|
||||||
const toggle = $(`[data-flow-toggle="${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, description:flow.description || '', nodes:flow.nodes || [], edges:flow.edges || [], expected_revision:Number(flow.revision || 0) };
|
||||||
try {
|
try {
|
||||||
@@ -597,7 +752,9 @@ document.addEventListener('click', event => {
|
|||||||
const edge = event.target.closest?.('[data-flow-edge]'); if (edge) { removeFlowEdge(edge.dataset.flowEdge); return; }
|
const edge = event.target.closest?.('[data-flow-edge]'); if (edge) { removeFlowEdge(edge.dataset.flowEdge); return; }
|
||||||
const add = event.target.closest?.('[data-flow-add]'); if (add) { addFlowNode(add.dataset.flowAdd); return; }
|
const add = event.target.closest?.('[data-flow-add]'); if (add) { addFlowNode(add.dataset.flowAdd); return; }
|
||||||
const templateCategory = event.target.closest?.('[data-flow-template-category]'); if (templateCategory) { renderFlowPresetBrowser(templateCategory.dataset.flowTemplateCategory); return; }
|
const templateCategory = event.target.closest?.('[data-flow-template-category]'); if (templateCategory) { renderFlowPresetBrowser(templateCategory.dataset.flowTemplateCategory); return; }
|
||||||
const template = event.target.closest?.('[data-flow-template]'); if (template) { applyFlowTemplate(template.dataset.flowTemplate); return; }
|
const templateFavorite = event.target.closest?.('[data-flow-template-favorite]'); if (templateFavorite) { toggleFlowPresetFavorite(templateFavorite.dataset.flowTemplateFavorite); return; }
|
||||||
|
const templatePreview = event.target.closest?.('[data-flow-template-preview]'); if (templatePreview) { previewFlowTemplate(templatePreview.dataset.flowTemplatePreview); return; }
|
||||||
|
const templateUse = event.target.closest?.('[data-flow-template-use]'); if (templateUse) { applyFlowTemplate(templateUse.dataset.flowTemplateUse); return; }
|
||||||
const remove = event.target.closest?.('[data-flow-remove]'); if (remove) { removeFlowNode(remove.dataset.flowRemove); return; }
|
const remove = event.target.closest?.('[data-flow-remove]'); if (remove) { removeFlowNode(remove.dataset.flowRemove); return; }
|
||||||
const output = event.target.closest?.('[data-flow-output]'); if (output) { app.flowConnectFrom = output.dataset.flowOutput; renderFlowEditor(); return; }
|
const output = event.target.closest?.('[data-flow-output]'); if (output) { app.flowConnectFrom = output.dataset.flowOutput; renderFlowEditor(); return; }
|
||||||
const input = event.target.closest?.('[data-flow-input]'); if (input) { if (app.flowConnectFrom) connectFlowNodes(app.flowConnectFrom, input.dataset.flowInput); return; }
|
const input = event.target.closest?.('[data-flow-input]'); if (input) { if (app.flowConnectFrom) connectFlowNodes(app.flowConnectFrom, input.dataset.flowInput); return; }
|
||||||
@@ -606,6 +763,7 @@ document.addEventListener('click', event => {
|
|||||||
if (action === 'new-flow') openFlowEditor();
|
if (action === 'new-flow') openFlowEditor();
|
||||||
else if (action === 'edit-flow') openFlowEditor(actionButton.dataset.id);
|
else if (action === 'edit-flow') openFlowEditor(actionButton.dataset.id);
|
||||||
else if (action === 'delete-flow') deleteFlow(actionButton.dataset.id);
|
else if (action === 'delete-flow') deleteFlow(actionButton.dataset.id);
|
||||||
|
else if (action === 'toggle-flow-enabled') toggleFlowEnabled(actionButton.dataset.id, actionButton.dataset.value === 'true');
|
||||||
else if (action === 'save-flow') saveFlow();
|
else if (action === 'save-flow') saveFlow();
|
||||||
else if (action === 'import-flow') $('#flowImportFile')?.click();
|
else if (action === 'import-flow') $('#flowImportFile')?.click();
|
||||||
else if (action === 'export-flow') exportFlow();
|
else if (action === 'export-flow') exportFlow();
|
||||||
@@ -629,9 +787,9 @@ document.addEventListener('click', event => {
|
|||||||
});
|
});
|
||||||
document.addEventListener('change', event => {
|
document.addEventListener('change', event => {
|
||||||
if (event.target.matches?.('[data-flow-config]')) updateFlowConfig(event.target);
|
if (event.target.matches?.('[data-flow-config]')) updateFlowConfig(event.target);
|
||||||
const toggle = event.target.closest?.('[data-flow-toggle]'); if (toggle) toggleFlowEnabled(toggle.dataset.flowToggle, toggle.checked);
|
|
||||||
});
|
});
|
||||||
$('#flowImportFile')?.addEventListener('change', event => importFlowFile(event.target.files?.[0]));
|
$('#flowImportFile')?.addEventListener('change', event => importFlowFile(event.target.files?.[0]));
|
||||||
|
$('#flowTemplateSearch')?.addEventListener('input', event => { flowPresetSearch = event.target.value || ''; renderFlowPresetBrowser(flowPresetActiveCategory); });
|
||||||
$('#flowName')?.addEventListener('input', () => { if (app.flowDraft) app.flowDirty = true; });
|
$('#flowName')?.addEventListener('input', () => { if (app.flowDraft) app.flowDirty = true; });
|
||||||
$('#flowEnabled')?.addEventListener('change', () => { if (app.flowDraft) app.flowDirty = true; });
|
$('#flowEnabled')?.addEventListener('change', () => { if (app.flowDraft) app.flowDirty = true; });
|
||||||
window.addEventListener('resize', () => { if (!$('#flowEditor')?.hidden) renderFlowEdges(); });
|
window.addEventListener('resize', () => { if (!$('#flowEditor')?.hidden) renderFlowEdges(); });
|
||||||
|
|||||||
+19
-4
@@ -41,13 +41,25 @@ function sharedFlowInputSummary(item) {
|
|||||||
try { return flowNodeSummary({ kind: item.kind, config: item.config || {} }); } catch (_) { return item.kind || '—'; }
|
try { return flowNodeSummary({ kind: item.kind, config: item.config || {} }); } catch (_) { return item.kind || '—'; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isHaSharedInputKind(kind) { return ['ha_state','ha_numeric','ha_attribute','ha_available'].includes(kind); }
|
||||||
|
|
||||||
|
function flowSharedInputUsages(id) {
|
||||||
|
return (app.flows || []).filter(flow => (flow.nodes || []).some(node => node.kind === 'shared_input' && node.config?.input_id === id));
|
||||||
|
}
|
||||||
|
|
||||||
function renderFlowSharedInputs() {
|
function renderFlowSharedInputs() {
|
||||||
const host = $('#flowSharedInputList'); if (!host) return;
|
const host = $('#flowSharedInputList'); if (!host) return;
|
||||||
const items = app.flowSharedInputs || [];
|
const items = app.flowSharedInputs || [];
|
||||||
host.innerHTML = items.length ? items.map(item => `<div class="flow-shared-input-row">
|
host.innerHTML = items.length ? items.map(item => {
|
||||||
<div><strong>${esc(item.name)}</strong><small>${esc(flowNodeTitle(FLOW_NODE_META[item.kind] || { title:item.kind }))} · ${esc(sharedFlowInputSummary(item))}</small><code>${esc(item.id)}</code></div>
|
const usages = flowSharedInputUsages(item.id);
|
||||||
<div class="flow-shared-input-actions"><button type="button" class="secondary" data-flow-shared-edit="${esc(item.id)}">${esc(tr('actions.edit'))}</button><button type="button" class="danger" data-flow-shared-delete="${esc(item.id)}">${esc(tr('actions.delete'))}</button></div>
|
const usageMarkup = usages.length
|
||||||
</div>`).join('') : `<div class="empty compact"><strong>${esc(tr('flow.sharedInputsEmpty'))}</strong><span>${esc(tr('flow.sharedInputsEmptyHint'))}</span></div>`;
|
? `<div class="flow-shared-usage"><small>${esc(tr('flow.sharedInputUsedBy', { count:usages.length }))}</small><div>${usages.slice(0, 4).map(flow => `<button type="button" class="link-button" data-open-shared-flow="${esc(flow.id)}" title="${esc(tr('flow.openReferencedFlow', { name:flow.name }))}">${esc(flow.name)}</button>`).join('')}${usages.length > 4 ? `<span class="muted">+${usages.length - 4}</span>` : ''}</div></div>`
|
||||||
|
: `<small class="flow-shared-unused">${esc(tr('flow.sharedInputUnused'))}</small>`;
|
||||||
|
return `<div class="flow-shared-input-row">
|
||||||
|
<div><strong>${esc(item.name)}</strong><small>${esc(flowNodeTitle(FLOW_NODE_META[item.kind] || { title:item.kind }))} · ${esc(sharedFlowInputSummary(item))}</small><code>${esc(item.id)}</code>${usageMarkup}</div>
|
||||||
|
<div class="flow-shared-input-actions"><button type="button" class="secondary" data-flow-shared-edit="${esc(item.id)}">${esc(tr('actions.edit'))}</button><button type="button" class="danger" data-flow-shared-delete="${esc(item.id)}">${esc(tr('actions.delete'))}</button></div>
|
||||||
|
</div>`;
|
||||||
|
}).join('') : `<div class="empty compact"><strong>${esc(tr('flow.sharedInputsEmpty'))}</strong><span>${esc(tr('flow.sharedInputsEmptyHint'))}</span></div>`;
|
||||||
const revision = $('#flowSharedInputsRevision');
|
const revision = $('#flowSharedInputsRevision');
|
||||||
if (revision) revision.value = JSON.stringify(items);
|
if (revision) revision.value = JSON.stringify(items);
|
||||||
}
|
}
|
||||||
@@ -80,6 +92,9 @@ function renderFlowSharedInputFields(kind, config = {}) {
|
|||||||
else if (kind === 'group_state') fields = `<label><span>${esc(tr('groups.group'))}</span><select data-shared-config="group_id">${sharedFlowOptions(app.groups, c.group_id)}</select></label><input type="hidden" data-shared-config="field" value="power_enabled">${textComparison()}`;
|
else if (kind === 'group_state') fields = `<label><span>${esc(tr('groups.group'))}</span><select data-shared-config="group_id">${sharedFlowOptions(app.groups, c.group_id)}</select></label><input type="hidden" data-shared-config="field" value="power_enabled">${textComparison()}`;
|
||||||
else if (kind === 'night_mode') fields = `<p class="field-note">${esc(tr('flow.nightModeHint'))}</p>`;
|
else if (kind === 'night_mode') fields = `<p class="field-note">${esc(tr('flow.nightModeHint'))}</p>`;
|
||||||
host.innerHTML = fields;
|
host.innerHTML = fields;
|
||||||
|
const testPanel = $('#flowSharedInputTestPanel'), testResult = $('#flowSharedInputTestResult');
|
||||||
|
if (testPanel) testPanel.hidden = !isHaSharedInputKind(kind);
|
||||||
|
if (testResult) { testResult.hidden = true; testResult.innerHTML = ''; }
|
||||||
}
|
}
|
||||||
|
|
||||||
function openFlowSharedInputEditor(id = '') {
|
function openFlowSharedInputEditor(id = '') {
|
||||||
|
|||||||
@@ -300,6 +300,50 @@ $('#addFlowSharedInput')?.addEventListener('click', () => openFlowSharedInputEdi
|
|||||||
$('#flowSharedInputKind')?.addEventListener('change', event => {
|
$('#flowSharedInputKind')?.addEventListener('change', event => {
|
||||||
renderFlowSharedInputFields(event.target.value, flowDefaultConfig(event.target.value));
|
renderFlowSharedInputFields(event.target.value, flowDefaultConfig(event.target.value));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function compareFlowSharedHaValue(actual, operator, expected) {
|
||||||
|
if (['lt','lte','gt','gte'].includes(operator)) {
|
||||||
|
const a = Number(actual), b = Number(expected);
|
||||||
|
if (!Number.isFinite(a) || !Number.isFinite(b)) return false;
|
||||||
|
if (operator === 'lt') return a < b;
|
||||||
|
if (operator === 'lte') return a <= b;
|
||||||
|
if (operator === 'gt') return a > b;
|
||||||
|
return a >= b;
|
||||||
|
}
|
||||||
|
const left = actual == null ? '' : String(actual);
|
||||||
|
const right = expected == null ? '' : String(expected);
|
||||||
|
return operator === 'neq' ? left !== right : left === right;
|
||||||
|
}
|
||||||
|
|
||||||
|
function evaluateFlowSharedHaTest(kind, config, result) {
|
||||||
|
if (kind === 'ha_available') return { actual:result.state, matched:result.available === true };
|
||||||
|
let actual = result.state;
|
||||||
|
if (kind === 'ha_attribute') actual = result.attributes?.[config.attribute];
|
||||||
|
return { actual, matched:compareFlowSharedHaValue(actual, config.operator || 'eq', config.value) };
|
||||||
|
}
|
||||||
|
|
||||||
|
$('#flowSharedInputTest')?.addEventListener('click', async event => {
|
||||||
|
const form = $('#flowSharedInputForm'), resultHost = $('#flowSharedInputTestResult');
|
||||||
|
if (!form || !resultHost) return;
|
||||||
|
const kind = form.kind.value, config = collectFlowSharedInputConfig(kind);
|
||||||
|
const entityId = String(config.entity_id || '').trim();
|
||||||
|
if (!entityId) return toast(tr('flow.sharedInputTestEntityRequired'), true);
|
||||||
|
const button = event.currentTarget, idle = button.textContent;
|
||||||
|
button.disabled = true; button.textContent = tr('flow.sharedInputTesting');
|
||||||
|
resultHost.hidden = false; resultHost.innerHTML = `<span>${esc(tr('flow.sharedInputTesting'))}</span>`;
|
||||||
|
try {
|
||||||
|
const result = await api('/api/integrations/home-assistant/entity', { method:'POST', body:{ entity_id:entityId } });
|
||||||
|
const evaluation = evaluateFlowSharedHaTest(kind, config, result);
|
||||||
|
const actual = evaluation.actual == null ? 'null' : (typeof evaluation.actual === 'object' ? JSON.stringify(evaluation.actual) : String(evaluation.actual));
|
||||||
|
const expectation = kind === 'ha_available' ? tr('flow.sharedInputTestAvailable') : `${config.operator || 'eq'} ${config.value ?? ''}`;
|
||||||
|
resultHost.classList.toggle('pass', evaluation.matched);
|
||||||
|
resultHost.classList.toggle('fail', !evaluation.matched);
|
||||||
|
resultHost.innerHTML = `<div><strong>${esc(evaluation.matched ? tr('flow.sharedInputTestMatched') : tr('flow.sharedInputTestNotMatched'))}</strong><span class="badge ${evaluation.matched ? 'active' : ''}">${esc(result.available ? tr('flow.sharedInputTestAvailable') : tr('flow.sharedInputTestUnavailable'))}</span></div><dl><div><dt>${esc(tr('flow.sharedInputTestCurrent'))}</dt><dd><code>${esc(actual)}</code></dd></div><div><dt>${esc(tr('flow.sharedInputTestExpected'))}</dt><dd><code>${esc(expectation)}</code></dd></div></dl><small>${esc(result.entity_id)}${result.last_updated ? ` · ${esc(dateTime(result.last_updated))}` : ''}</small>`;
|
||||||
|
} catch (error) {
|
||||||
|
resultHost.classList.remove('pass'); resultHost.classList.add('fail');
|
||||||
|
resultHost.innerHTML = `<strong>${esc(tr('flow.sharedInputTestUnavailable'))}</strong><span>${esc(error.message)}</span>`;
|
||||||
|
} finally { button.disabled = false; button.textContent = idle; }
|
||||||
|
});
|
||||||
$('#flowSharedInputForm')?.addEventListener('submit', event => {
|
$('#flowSharedInputForm')?.addEventListener('submit', event => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const form = event.currentTarget;
|
const form = event.currentTarget;
|
||||||
@@ -318,6 +362,11 @@ $('#flowSharedInputForm')?.addEventListener('submit', event => {
|
|||||||
document.addEventListener('click', event => {
|
document.addEventListener('click', event => {
|
||||||
const edit = event.target.closest?.('[data-flow-shared-edit]');
|
const edit = event.target.closest?.('[data-flow-shared-edit]');
|
||||||
if (edit) { openFlowSharedInputEditor(edit.dataset.flowSharedEdit); return; }
|
if (edit) { openFlowSharedInputEditor(edit.dataset.flowSharedEdit); return; }
|
||||||
|
const openFlow = event.target.closest?.('[data-open-shared-flow]');
|
||||||
|
if (openFlow) {
|
||||||
|
if (showView('flows')) requestAnimationFrame(() => openFlowEditor(openFlow.dataset.openSharedFlow));
|
||||||
|
return;
|
||||||
|
}
|
||||||
const remove = event.target.closest?.('[data-flow-shared-delete]');
|
const remove = event.target.closest?.('[data-flow-shared-delete]');
|
||||||
if (!remove) return;
|
if (!remove) return;
|
||||||
const id = remove.dataset.flowSharedDelete;
|
const id = remove.dataset.flowSharedDelete;
|
||||||
|
|||||||
+64
-2
@@ -6232,10 +6232,9 @@ body.flow-editor-open { overflow:hidden; }
|
|||||||
.flow-list-section { margin-top:22px; padding-top:20px; border-top:1px solid var(--line); }
|
.flow-list-section { margin-top:22px; padding-top:20px; border-top:1px solid var(--line); }
|
||||||
.flow-list-heading { display:flex; align-items:flex-end; justify-content:space-between; gap:16px; margin-bottom:14px; }
|
.flow-list-heading { display:flex; align-items:flex-end; justify-content:space-between; gap:16px; margin-bottom:14px; }
|
||||||
.flow-list-heading h2 { margin:3px 0 0; }
|
.flow-list-heading h2 { margin:3px 0 0; }
|
||||||
|
.flow-list-heading-count { justify-content:flex-end; }
|
||||||
.flow-card.is-disabled { opacity:.72; }
|
.flow-card.is-disabled { opacity:.72; }
|
||||||
.flow-card.is-disabled:hover { opacity:.9; }
|
.flow-card.is-disabled:hover { opacity:.9; }
|
||||||
.flow-quick-toggle { display:flex; align-items:center; gap:7px; padding:7px 9px; border:1px solid var(--line); border-radius:999px; background:var(--surface-2); color:var(--muted); font-size:.78rem; cursor:pointer; white-space:nowrap; }
|
|
||||||
.flow-quick-toggle input { margin:0; }
|
|
||||||
.flow-workspace-toolbar { min-height:44px; display:flex; align-items:center; gap:7px; padding:6px 10px; border-bottom:1px solid var(--line); background:var(--surface); }
|
.flow-workspace-toolbar { min-height:44px; display:flex; align-items:center; gap:7px; padding:6px 10px; border-bottom:1px solid var(--line); background:var(--surface); }
|
||||||
.flow-workspace-toolbar .secondary { padding:7px 9px; font-size:.78rem; }
|
.flow-workspace-toolbar .secondary { padding:7px 9px; font-size:.78rem; }
|
||||||
.flow-workspace-toolbar .badge { margin-left:2px; }
|
.flow-workspace-toolbar .badge { margin-left:2px; }
|
||||||
@@ -6279,3 +6278,66 @@ body.flow-editor-open { overflow:hidden; }
|
|||||||
.flow-shared-add-button { width:auto; }
|
.flow-shared-add-button { width:auto; }
|
||||||
.flow-shared-dialog-actions { gap:16px; }
|
.flow-shared-dialog-actions { gap:16px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* v0.9.10: Flow preset library preview, favorites/search and shared-input diagnostics */
|
||||||
|
.flow-shared-usage { display:grid; gap:5px; margin-top:5px; }
|
||||||
|
.flow-shared-usage > div { display:flex; flex-wrap:wrap; align-items:center; gap:6px; }
|
||||||
|
.flow-shared-usage .link-button { border:0; padding:0; background:transparent; color:var(--accent); text-align:left; text-decoration:underline; text-underline-offset:2px; cursor:pointer; font:inherit; font-size:.78rem; }
|
||||||
|
.flow-shared-unused { margin-top:4px; }
|
||||||
|
.flow-shared-test-panel { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:10px 14px; align-items:start; padding:12px; border:1px solid var(--line); border-radius:12px; background:var(--surface-2); }
|
||||||
|
.flow-shared-test-panel .field-note { margin:3px 0 0; }
|
||||||
|
.flow-shared-test-result { grid-column:1 / -1; display:grid; gap:8px; padding:10px 12px; border:1px solid var(--line); border-radius:10px; background:var(--surface); }
|
||||||
|
.flow-shared-test-result.pass { border-left:4px solid var(--green); }
|
||||||
|
.flow-shared-test-result.fail { border-left:4px solid var(--line-strong); }
|
||||||
|
.flow-shared-test-result > div:first-child { display:flex; justify-content:space-between; gap:10px; align-items:center; }
|
||||||
|
.flow-shared-test-result dl { display:grid; gap:5px; margin:0; }
|
||||||
|
.flow-shared-test-result dl > div { display:grid; grid-template-columns:120px minmax(0,1fr); gap:8px; }
|
||||||
|
.flow-shared-test-result dt { color:var(--muted); }
|
||||||
|
.flow-shared-test-result dd { margin:0; min-width:0; overflow-wrap:anywhere; }
|
||||||
|
.flow-shared-test-result > small { color:var(--muted); }
|
||||||
|
|
||||||
|
#flowTemplateDialog { width:min(1180px,calc(100vw - 32px)); }
|
||||||
|
.flow-template-toolbar { margin-top:14px; }
|
||||||
|
.flow-template-toolbar label { display:grid; gap:6px; }
|
||||||
|
.flow-template-toolbar input { width:100%; border:1px solid var(--line); border-radius:10px; background:var(--input-bg); color:var(--text); padding:10px 12px; }
|
||||||
|
.flow-template-browser { display:grid; grid-template-columns:minmax(0,1.15fr) minmax(320px,.85fr); gap:18px; align-items:start; }
|
||||||
|
.flow-template-catalog { min-width:0; }
|
||||||
|
.flow-template-preview { position:sticky; top:0; display:grid; gap:12px; margin-top:14px; padding:14px; border:1px solid var(--line); border-radius:14px; background:var(--surface-2); min-height:250px; }
|
||||||
|
.flow-template-preview-head { display:flex; justify-content:space-between; gap:12px; align-items:flex-start; }
|
||||||
|
.flow-template-preview-head h3 { margin:3px 0 0; }
|
||||||
|
.flow-template-preview > p { margin:0; color:var(--muted); line-height:1.45; }
|
||||||
|
.flow-template-preview-stats { display:flex; flex-wrap:wrap; gap:7px; }
|
||||||
|
.flow-template-preview-stats span { padding:5px 8px; border:1px solid var(--line); border-radius:999px; color:var(--muted); font-size:.78rem; }
|
||||||
|
.flow-template-preview-canvas { position:relative; height:250px; overflow:hidden; border:1px solid var(--line); border-radius:12px; background:var(--surface); }
|
||||||
|
.flow-template-preview-canvas svg { position:absolute; inset:0; width:100%; height:100%; pointer-events:none; }
|
||||||
|
.flow-template-preview-canvas line { stroke:var(--line-strong); stroke-width:.7; vector-effect:non-scaling-stroke; }
|
||||||
|
.flow-template-preview-node { position:absolute; transform:translate(-50%,-50%); width:104px; max-width:28%; padding:6px 7px; border:1px solid var(--line); border-radius:8px; background:var(--surface-2); box-shadow:0 3px 10px rgba(0,0,0,.12); font-size:.68rem; line-height:1.2; text-align:center; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||||
|
.flow-template-preview-node-sensor { border-top:3px solid var(--blue, var(--accent)); }
|
||||||
|
.flow-template-preview-node-logic { border-top:3px solid var(--accent); }
|
||||||
|
.flow-template-preview-node-action { border-top:3px solid var(--green); }
|
||||||
|
.flow-template-requirements { display:grid; gap:8px; }
|
||||||
|
.flow-template-requirements > div { display:flex; flex-wrap:wrap; gap:6px; }
|
||||||
|
.flow-template-requirement { padding:5px 8px; border:1px solid var(--line); border-radius:999px; font-size:.75rem; }
|
||||||
|
.flow-template-requirement.ok { background:var(--surface); }
|
||||||
|
.flow-template-requirement.missing { border-color:var(--danger, var(--line-strong)); }
|
||||||
|
.flow-template-requirement.info { color:var(--muted); }
|
||||||
|
.flow-template-card { position:relative; display:grid; min-height:0; padding:0; overflow:hidden; }
|
||||||
|
.flow-template-card.selected { border-color:var(--accent); box-shadow:0 0 0 1px var(--accent-border); }
|
||||||
|
.flow-template-card-main { display:grid; gap:7px; width:100%; min-height:118px; border:0; background:transparent; color:var(--text); padding:14px 42px 14px 14px; text-align:left; align-content:start; cursor:pointer; }
|
||||||
|
.flow-template-card-main span { color:var(--muted); font-size:.86rem; line-height:1.4; }
|
||||||
|
.flow-template-favorite { width:34px; height:34px; border:0; border-radius:50%; background:transparent; color:var(--muted); cursor:pointer; font-size:1.15rem; line-height:1; }
|
||||||
|
.flow-template-card > .flow-template-favorite { position:absolute; right:7px; top:7px; }
|
||||||
|
.flow-template-favorite.active { color:var(--accent); background:var(--accent-soft); }
|
||||||
|
.flow-template-preview .dialog-actions { margin-top:0; padding-top:10px; border-top:1px solid var(--line); justify-content:flex-end; }
|
||||||
|
|
||||||
|
@media (max-width:900px) {
|
||||||
|
.flow-template-browser { grid-template-columns:1fr; }
|
||||||
|
.flow-template-preview { position:static; }
|
||||||
|
}
|
||||||
|
@media (max-width:760px) {
|
||||||
|
.flow-shared-test-panel { grid-template-columns:1fr; }
|
||||||
|
.flow-shared-test-panel > button { width:100%; }
|
||||||
|
.flow-shared-test-result { grid-column:1; }
|
||||||
|
.flow-shared-test-result dl > div { grid-template-columns:1fr; gap:2px; }
|
||||||
|
.flow-template-preview-canvas { height:220px; }
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user