v0.9.6
This commit is contained in:
Generated
+1
-1
@@ -633,7 +633,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "gree-controller"
|
name = "gree-controller"
|
||||||
version = "0.9.5"
|
version = "0.9.6"
|
||||||
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.5"
|
version = "0.9.6"
|
||||||
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"
|
||||||
|
|||||||
+56
-54
@@ -1,12 +1,14 @@
|
|||||||
5330f2eb75251cee43aa853448290bccd0e0d95f89aa1d23bde498eb86e99699 ./build.rs
|
|
||||||
37ba4d5222b221d9a3d560246d968092fa4402288410eaba7e6bab87ebcc5aea ./Cargo.lock
|
|
||||||
a0976abed3b84a5ba0c726410c87d31017d42098fb7e053af986869ea0c9db74 ./Cargo.toml
|
|
||||||
bfa6891d9abc7ac013773e5c7533325981c6887a87af725001a80611bc02e3ea ./data/gree-controller.db
|
|
||||||
e7f085a9b4bd75dd62609e3c9f62bdde532f5cb0675fda2cf5109e57fafef2d2 ./docs/API.md
|
|
||||||
d67af429e4da9ce08e9d2f2a8472849ffbd70d135b1c5da535a076026794d04c ./.env
|
|
||||||
d67af429e4da9ce08e9d2f2a8472849ffbd70d135b1c5da535a076026794d04c ./.env.example
|
d67af429e4da9ce08e9d2f2a8472849ffbd70d135b1c5da535a076026794d04c ./.env.example
|
||||||
a4ec3874a2e3ab1bad28fb40bb620f7b01f64d01ad9b699306bf70ada31227db ./.gitignore
|
a4ec3874a2e3ab1bad28fb40bb620f7b01f64d01ad9b699306bf70ada31227db ./.gitignore
|
||||||
ad08bba75bbc38d4c48cecef9ee05a19320d9a6a20b6cc950fdbb8115da573f2 ./gree-controller.zip
|
c1efa0e8c20ed2afc60487f36ebc86a63d664d846c8f811e4c43e2a2144ff776 ./Cargo.lock
|
||||||
|
50027832964d58224369e9ce0be561592118011dc427ea84da3fe934ea5385c3 ./Cargo.toml
|
||||||
|
19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE
|
||||||
|
5f094d78999d6e28de702a21380cafa2b9552a70c40f61a288f71210806eac2f ./README.md
|
||||||
|
07acba9eab602e8d5b7387b16476308799ad629565fe501b18f3c1ea024a3ccf ./build.rs
|
||||||
|
5b2b1cb7c209863891f247e14df2cbcd4c64f42392386b288b53dff0fe63fe72 ./docs/API.md
|
||||||
|
0fd82f68e66f3fb10e622ecf2f7699316c88f3a66b148deadd08f367d1dd0682 ./docs/FLOW.md
|
||||||
|
a0893b2a56eb1523f1a72871842e9be2139a5fafba1f51ae942fc407a6e4ca34 ./home-assistant/README.md
|
||||||
|
f8e8559fe10fe523ac5bc9aac25c6e26e862f679d502e8f3c39f38a0a8e40911 ./home-assistant/custom_components/gree_controller/__init__.py
|
||||||
6910589f27960a28d4de9735884a7e5376e455cd885f19ce2b55947fcd135114 ./home-assistant/custom_components/gree_controller/api.py
|
6910589f27960a28d4de9735884a7e5376e455cd885f19ce2b55947fcd135114 ./home-assistant/custom_components/gree_controller/api.py
|
||||||
71c8058f85fd9ce9f063b7a84dcf2956f23cdbbd02bd4bd99d2fbb9d177c72ad ./home-assistant/custom_components/gree_controller/climate.py
|
71c8058f85fd9ce9f063b7a84dcf2956f23cdbbd02bd4bd99d2fbb9d177c72ad ./home-assistant/custom_components/gree_controller/climate.py
|
||||||
5e4aef2143e81bedb5a15dd4be5c71b64a3ab448ec6d33a20851edd098e5f529 ./home-assistant/custom_components/gree_controller/config_flow.py
|
5e4aef2143e81bedb5a15dd4be5c71b64a3ab448ec6d33a20851edd098e5f529 ./home-assistant/custom_components/gree_controller/config_flow.py
|
||||||
@@ -14,9 +16,8 @@ b7f0873109c52be9d7f09bea3dffc416103c50085e1f0680d11661a969479898 ./home-assista
|
|||||||
ab08fbe40e9bb48ebcbfff98760aaa0c9434b61b705aaaadea0c09c255d99b0e ./home-assistant/custom_components/gree_controller/coordinator.py
|
ab08fbe40e9bb48ebcbfff98760aaa0c9434b61b705aaaadea0c09c255d99b0e ./home-assistant/custom_components/gree_controller/coordinator.py
|
||||||
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
|
||||||
f8e8559fe10fe523ac5bc9aac25c6e26e862f679d502e8f3c39f38a0a8e40911 ./home-assistant/custom_components/gree_controller/__init__.py
|
|
||||||
c4fb75c246db651087900ebfc2291ff41ac87652cd6194fc0b776b0005c1cbcf ./home-assistant/custom_components/gree_controller/logo.png
|
c4fb75c246db651087900ebfc2291ff41ac87652cd6194fc0b776b0005c1cbcf ./home-assistant/custom_components/gree_controller/logo.png
|
||||||
f30f751b4e561e51a3ff9bf9c8384bc2d160dd157b954dd7061d5844ee410b77 ./home-assistant/custom_components/gree_controller/manifest.json
|
4098899fbb12ce3364e0bf44414097461f3efdfffddfe18649a267723f18bf75 ./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
|
||||||
@@ -24,92 +25,93 @@ cca65482e36d48035aca178121a378fe7d578d600acff267ae81a6399c0da653 ./home-assista
|
|||||||
6bddb7b4620021ecd2099a86a77ef5c7f2c2dcd3d07d5db4e7b4c4ce6d3e8c03 ./home-assistant/custom_components/gree_controller/translations/en.json
|
6bddb7b4620021ecd2099a86a77ef5c7f2c2dcd3d07d5db4e7b4c4ce6d3e8c03 ./home-assistant/custom_components/gree_controller/translations/en.json
|
||||||
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
|
||||||
a0893b2a56eb1523f1a72871842e9be2139a5fafba1f51ae942fc407a6e4ca34 ./home-assistant/README.md
|
|
||||||
a3e5842856e956044aefe840eb14fa36f98ae31d5613a7ac9f546df96c6c2366 ./lang/en.json
|
|
||||||
1baff114849223295ecf91251f525ec7e88f8261adcfa0462fa69ec01fe54517 ./lang/pl.json
|
|
||||||
253a0bc912786e67ea7fc92a64e4a510ad973bec343a88ccfb1f28fca3e8cf01 ./lang/README.md
|
253a0bc912786e67ea7fc92a64e4a510ad973bec343a88ccfb1f28fca3e8cf01 ./lang/README.md
|
||||||
19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE
|
379ea62648744409db229dd9f58ce874b6ec65a86c6edd734978422d3aa6a5bd ./lang/en.json
|
||||||
|
1e453b8ef6c80208d9f88454ba5d78deef56ea7f74a0a7be44c51fabf007e495 ./lang/pl.json
|
||||||
d8459024f04ca514bd8e9d6bd3af872fb942fd85a7cdd5583f4a9d28aab6faba ./make_zip.py
|
d8459024f04ca514bd8e9d6bd3af872fb942fd85a7cdd5583f4a9d28aab6faba ./make_zip.py
|
||||||
a6391cc7fc014d06205c3fac708e0316e7239eaca47e0a2cb05a18275c58f192 ./README.md
|
01952aa92b217f8eae2493b88870e2dec595100cd15c4d561ff11ae2b936c46f ./regenerate-sha.sh
|
||||||
|
02d8c640c31fbc3fcb772f44608cdad20e0462762d8d81bd04cd176239499c4e ./scripts/FILE_MANIFEST.sha256
|
||||||
|
bb89bac237e750e9b1bf73761d7df97a6b81853091615878c03f13d7b6399aa7 ./scripts/README.md
|
||||||
5bc736c7bc76ca80aaa406bb171d2aa91baf4c3aa8695dce0e09b888b6ab3146 ./scripts/common.sh
|
5bc736c7bc76ca80aaa406bb171d2aa91baf4c3aa8695dce0e09b888b6ab3146 ./scripts/common.sh
|
||||||
6403786610ee6d2f628193c25aee0dd058d62e904aa1a31d5f62fdaae0e94b4f ./scripts/configure-gree-network.sh
|
6403786610ee6d2f628193c25aee0dd058d62e904aa1a31d5f62fdaae0e94b4f ./scripts/configure-gree-network.sh
|
||||||
dbb92ddc27b8724faf709983e4feecd3f188052b2cba39daba96d4bc16914df5 ./scripts/dev.sh
|
|
||||||
a0cc9f6c6fa7322c48b227b1e28521ac3abd3fde91d9931a2607f18d6adcd783 ./scripts/FILE_MANIFEST.sha256
|
|
||||||
14076104c042fba1284ebb07531a6c3ff972df1f9f5b18f70da18ab774efed27 ./scripts/generate_ha_migration.py
|
14076104c042fba1284ebb07531a6c3ff972df1f9f5b18f70da18ab774efed27 ./scripts/generate_ha_migration.py
|
||||||
e4849261fd9ed1f01df96c0637c439c0c4eff8fa317b2918026167bba343af79 ./scripts/install-lxc.sh
|
e4849261fd9ed1f01df96c0637c439c0c4eff8fa317b2918026167bba343af79 ./scripts/install-lxc.sh
|
||||||
bb7cd2c5b27c9dceec1d1d2846fbad9600df9c08e0ad07d13fcde97af533091c ./scripts/install.sh
|
bb7cd2c5b27c9dceec1d1d2846fbad9600df9c08e0ad07d13fcde97af533091c ./scripts/install.sh
|
||||||
e00d211e3885e30d7fed1e43b44e6fdad40a67019060156c0641816a93e3365f ./scripts/network-debug.sh
|
e00d211e3885e30d7fed1e43b44e6fdad40a67019060156c0641816a93e3365f ./scripts/network-debug.sh
|
||||||
bb89bac237e750e9b1bf73761d7df97a6b81853091615878c03f13d7b6399aa7 ./scripts/README.md
|
|
||||||
01952aa92b217f8eae2493b88870e2dec595100cd15c4d561ff11ae2b936c46f ./scripts/regenerate-sha.sh
|
01952aa92b217f8eae2493b88870e2dec595100cd15c4d561ff11ae2b936c46f ./scripts/regenerate-sha.sh
|
||||||
81345b6a0b51736bdbc98fd23199b62e4c721b4e7437e02dab7ea79b97dff29a ./scripts/service.sh
|
81345b6a0b51736bdbc98fd23199b62e4c721b4e7437e02dab7ea79b97dff29a ./scripts/service.sh
|
||||||
b48fc84d79aab381226363ac8473f981bcba5e4911c4cc0011261182debf4250 ./scripts/smoke.sh
|
8ccca8ed49576cf0b8e3453436ac3ff9c2a222cd41b27db98ceb62bd5f87aeef ./scripts/smoke.sh
|
||||||
b50782b3742dfbf8a319c60571c968e93fdf8547db747c759edcffae68cb98bf ./scripts/update.sh
|
b50782b3742dfbf8a319c60571c968e93fdf8547db747c759edcffae68cb98bf ./scripts/update.sh
|
||||||
|
037cc98e65772dade518aea33e13270c9c91afd639126a8a95be97495aecc447 ./src/api.rs
|
||||||
50b653f5ae1c9d24eb2e158fb4af713f5ccabe259c83053fa3cd2a310c7780da ./src/api/assets.rs
|
50b653f5ae1c9d24eb2e158fb4af713f5ccabe259c83053fa3cd2a310c7780da ./src/api/assets.rs
|
||||||
6c34b1294d76b0eba4c56bfd0bf69bdd502de0e40838104f6ddf2db4e922683d ./src/api/auth.rs
|
6c34b1294d76b0eba4c56bfd0bf69bdd502de0e40838104f6ddf2db4e922683d ./src/api/auth.rs
|
||||||
08a732a445f44d34c644109762ac389b58c96d0fe47b309ce34f4479d67c3712 ./src/api/automations.rs
|
5643425214a0ad228d6229398b715d9a6823c8c5823cdb4fbf73912d9b1eb74b ./src/api/automations.rs
|
||||||
2317b7cd0c2437c128ac4982d64412c8184ecb5e774825761d61aab6e45fae4a ./src/api/debug_tokens.rs
|
2317b7cd0c2437c128ac4982d64412c8184ecb5e774825761d61aab6e45fae4a ./src/api/debug_tokens.rs
|
||||||
4f82206fb48a291bdc643c96d4d6c917d669c7bdf2a35312c0a056dddecc7062 ./src/api/devices.rs
|
4f82206fb48a291bdc643c96d4d6c917d669c7bdf2a35312c0a056dddecc7062 ./src/api/devices.rs
|
||||||
417940b467c6f00bbf01cc47eb4479880dee9b2f284a80e3662a6d433dc11b92 ./src/api/events.rs
|
417940b467c6f00bbf01cc47eb4479880dee9b2f284a80e3662a6d433dc11b92 ./src/api/events.rs
|
||||||
|
ba3661eeb136ba9f63697dfcab1f3b1f86e173893ab5412b8363dd2606d3dbe8 ./src/api/flows.rs
|
||||||
d096000e94997cf9aaf9d3cc567214a78b8b2da7da57a76e2b3519247af6e592 ./src/api/groups.rs
|
d096000e94997cf9aaf9d3cc567214a78b8b2da7da57a76e2b3519247af6e592 ./src/api/groups.rs
|
||||||
a13d4e217fe3ddaa73873ba6e0d1bc93750cf61764d867ff7372274ed21d9599 ./src/api/history.rs
|
a13d4e217fe3ddaa73873ba6e0d1bc93750cf61764d867ff7372274ed21d9599 ./src/api/history.rs
|
||||||
7b5de0bb8b6559a59a98ba56c853b712ffcc7211900f02a4a9a089027878dde1 ./src/api/house.rs
|
b3ed82de9d885a6325647bff3d0ead6ac46fc74d0250d5e14d75319555ad88d7 ./src/api/house.rs
|
||||||
0d7d598f7ed24315ed0547a60a72a06f20ebcf926489e1c454986f42d22cc305 ./src/api/integrations.rs
|
0d7d598f7ed24315ed0547a60a72a06f20ebcf926489e1c454986f42d22cc305 ./src/api/integrations.rs
|
||||||
bb2a746ecdcc2da5fe54e18b455c7bd19453486dd5c008e71951a2c81d0e7d64 ./src/api/middleware.rs
|
bb2a746ecdcc2da5fe54e18b455c7bd19453486dd5c008e71951a2c81d0e7d64 ./src/api/middleware.rs
|
||||||
2b3feefbafc1f813c00dfe20db147aa1f5067ec5b8455f041ac3793802911be3 ./src/api/public_settings.rs
|
2b3feefbafc1f813c00dfe20db147aa1f5067ec5b8455f041ac3793802911be3 ./src/api/public_settings.rs
|
||||||
4c8b0d10d83d6130ed6e2703c233ac380b25a6c57fc23a29821f823df90331f9 ./src/api.rs
|
a1d1a4ac071493b052e6bf20b03be67cf75726de95649e9a73aeba8d22395a15 ./src/api/schedules.rs
|
||||||
8f7f0b8c157e9c97d7dc305b79923646ef47d76497abfcf09e9c67bb12af5dd1 ./src/api/schedules.rs
|
07e61483d10dba134055ab540c3222f1a2e2c51a8bffbcd5e29e632895a29f41 ./src/api/settings.rs
|
||||||
4ffab50d931ca46dc5708914b6b54256d639cc11ad1b4281f649c310db5d41a0 ./src/api/settings.rs
|
bc1614b9948b8904d0f678c79cc9fffecb75977fbec951aca1ba087527808836 ./src/api/system.rs
|
||||||
74c156717b37e83d329f7c52ac622e02f690621544c4eb07c4502194db55e6e9 ./src/api/system.rs
|
|
||||||
681cccb8d09f4ad9c2e6467a4dbe2f9d991125775a5d2bfc9043daf940fecd29 ./src/api/websocket.rs
|
681cccb8d09f4ad9c2e6467a4dbe2f9d991125775a5d2bfc9043daf940fecd29 ./src/api/websocket.rs
|
||||||
923e7434e8f5789150eb34ffc4d18d25fa701f4a9040bf856d7926016f1936aa ./src/api/zones.rs
|
923e7434e8f5789150eb34ffc4d18d25fa701f4a9040bf856d7926016f1936aa ./src/api/zones.rs
|
||||||
2b6eecead75cd4cdb98a9f74dd1c374dbf11ebfa716abbc3476aa12398af9acd ./src/config.rs
|
2b6eecead75cd4cdb98a9f74dd1c374dbf11ebfa716abbc3476aa12398af9acd ./src/config.rs
|
||||||
|
c8b8b4a04c94b9d91f5931057d69ad38344730bffbf68e2f6541b59013441b57 ./src/db.rs
|
||||||
24580b279cfcd5388ba584eaba2c9bb729b8aae81e77b781c23fb804c5b5fa5d ./src/db/climate.rs
|
24580b279cfcd5388ba584eaba2c9bb729b8aae81e77b781c23fb804c5b5fa5d ./src/db/climate.rs
|
||||||
3de3fa4262be00029dde2344c4cfa41acd4acc0adec176f344761ff35ea730b6 ./src/db/configuration.rs
|
3eded80d8dbf13729b2ae1f31baa47aa59c01bcfa550c90874031d25c74d2eac ./src/db/configuration.rs
|
||||||
56b0efe84dbf6bd3ee2397b099220a68966311c187f4d5b53007a1693a5ef327 ./src/db/core_devices.rs
|
56b0efe84dbf6bd3ee2397b099220a68966311c187f4d5b53007a1693a5ef327 ./src/db/core_devices.rs
|
||||||
d8a2b323e864f2ec38ee15e3023be37a6aa05387f1ddbeedafe1f51556ad8c7d ./src/db/device_history.rs
|
d8a2b323e864f2ec38ee15e3023be37a6aa05387f1ddbeedafe1f51556ad8c7d ./src/db/device_history.rs
|
||||||
f3bd91d0bdb7699f319b9341d928d3cc3b25f759653fcd2095a5c5f695d08fac ./src/db/events_tokens.rs
|
f3bd91d0bdb7699f319b9341d928d3cc3b25f759653fcd2095a5c5f695d08fac ./src/db/events_tokens.rs
|
||||||
|
03d689fb03c4f61629fff8404b6cea7101ba3a5358247b16ad714e0be93e9150 ./src/db/flows.rs
|
||||||
59912e70b688daf5c4d22679d643ed3b724c262d62466aeaa9ef74db87808178 ./src/db/ha_history.rs
|
59912e70b688daf5c4d22679d643ed3b724c262d62466aeaa9ef74db87808178 ./src/db/ha_history.rs
|
||||||
369455b5ffb125f1a80b767f62fd8c84a953adb69548dbd8adb653a6fbaaa45b ./src/db.rs
|
|
||||||
7e2fbce70f40aef360534ba38c6700163db77cb7288b3bf58728fce72a126435 ./src/db/schedules_automations.rs
|
7e2fbce70f40aef360534ba38c6700163db77cb7288b3bf58728fce72a126435 ./src/db/schedules_automations.rs
|
||||||
35cbb521a8ec456756496b8aee71f9dfebad528a2cf2653ba8b48f6035be0c01 ./src/db/tests.rs
|
35cbb521a8ec456756496b8aee71f9dfebad528a2cf2653ba8b48f6035be0c01 ./src/db/tests.rs
|
||||||
a384ea4042d1d11692b5f8693df1f54850dcc4e80726780b44b2b096bf667b78 ./src/db/zone_history.rs
|
a384ea4042d1d11692b5f8693df1f54850dcc4e80726780b44b2b096bf667b78 ./src/db/zone_history.rs
|
||||||
384f7027ca48bd4c4048320bc6d4cacd631bee40e8630ea22e0d6e5f22ddc3a6 ./src/engine/automations.rs
|
952ea5906d39ef917ad71edc345f76b29c07c63079644fd5699b12aa254cb35f ./src/engine.rs
|
||||||
|
69a18227b295c4b20644db0dc67061440260c4e89042936f0cc46d650b143a09 ./src/engine/automations.rs
|
||||||
77931af524e9402fc826050b5fcec422d01b02b625c8ce7f2004f1f6520c9b4d ./src/engine/commands.rs
|
77931af524e9402fc826050b5fcec422d01b02b625c8ce7f2004f1f6520c9b4d ./src/engine/commands.rs
|
||||||
e16c1a65057f6fbf11bb6f27493ed975f5c89309b31dec0fa3248b9237494ed3 ./src/engine/control_plan.rs
|
ce7e809765c8ad7dc4819b7f775ba7a632031d4d0e68780cc6ea953f2fd890e7 ./src/engine/control_plan.rs
|
||||||
38f42c86c31048780ca414f2c328f363ef803a0edbc1e1ed85503909a10b69ae ./src/engine/deadlines.rs
|
38f42c86c31048780ca414f2c328f363ef803a0edbc1e1ed85503909a10b69ae ./src/engine/deadlines.rs
|
||||||
d15148382f0c84bdef36750a10b463378e2de6a0fb70eecadeee2b07b8648681 ./src/engine/groups.rs
|
d15148382f0c84bdef36750a10b463378e2de6a0fb70eecadeee2b07b8648681 ./src/engine/groups.rs
|
||||||
18a7669318949e533f206d25cf7b63771d7f0f2c0b375a72ff78a2d921baaa61 ./src/engine/history.rs
|
18a7669318949e533f206d25cf7b63771d7f0f2c0b375a72ff78a2d921baaa61 ./src/engine/history.rs
|
||||||
4972c199274647bff47122c367e51bae8dab24d7ec4d652288e227a5a64c2310 ./src/engine/local_thermostat.rs
|
4972c199274647bff47122c367e51bae8dab24d7ec4d652288e227a5a64c2310 ./src/engine/local_thermostat.rs
|
||||||
ecab67072e756f5a371864282eb470a17de53016593e2e96540674066262c865 ./src/engine/ownership.rs
|
ecab67072e756f5a371864282eb470a17de53016593e2e96540674066262c865 ./src/engine/ownership.rs
|
||||||
9c37adf514c96840fd6a5c715c579350fa86d6dcc0655a2aa97d1fd7c7e04299 ./src/engine/polling.rs
|
9c37adf514c96840fd6a5c715c579350fa86d6dcc0655a2aa97d1fd7c7e04299 ./src/engine/polling.rs
|
||||||
952ea5906d39ef917ad71edc345f76b29c07c63079644fd5699b12aa254cb35f ./src/engine.rs
|
|
||||||
3d48608a065aa52a1fd8b9c59356a74d8216ecee3d47f8ff8ef7d3d47170c617 ./src/engine/runtime.rs
|
3d48608a065aa52a1fd8b9c59356a74d8216ecee3d47f8ff8ef7d3d47170c617 ./src/engine/runtime.rs
|
||||||
ffa72b93502eece187a96adb4ec94aec6048d4a6dc7af66e7c7af0931318787f ./src/engine/schedules.rs
|
ffa72b93502eece187a96adb4ec94aec6048d4a6dc7af66e7c7af0931318787f ./src/engine/schedules.rs
|
||||||
7742046c0067c7cd5a74e2b81f8a8bdef9b76fcacf9b4af0b7eede1b050b9036 ./src/engine/targets.rs
|
7742046c0067c7cd5a74e2b81f8a8bdef9b76fcacf9b4af0b7eede1b050b9036 ./src/engine/targets.rs
|
||||||
a9188b588b2617ce1547a45d46141f7e5f1bb0027a48675dd86b274f807d0875 ./src/engine/temperature.rs
|
a9188b588b2617ce1547a45d46141f7e5f1bb0027a48675dd86b274f807d0875 ./src/engine/temperature.rs
|
||||||
c2fe371f9245ce299015f0b4e43f6625a207016abf73d2419acb6bd7437f045a ./src/engine/temporary_thermostat.rs
|
c2fe371f9245ce299015f0b4e43f6625a207016abf73d2419acb6bd7437f045a ./src/engine/temporary_thermostat.rs
|
||||||
5631fd8420c4137f4586d98939d24a83701400ac07a3f2fe9da8b8d486df3e64 ./src/engine/tests.rs
|
ea3b533752b7abab113541f47ab7f49da27cc5696142796b78cd16889471371b ./src/engine/tests.rs
|
||||||
ebf8b9ec5c5a84850d8cad4c4faea090e52625b5a0be56625dc84060ecb35186 ./src/engine/zone_actions.rs
|
986c5ce1d7d5e79975b0089807408109afdef5c5a173b427b58e34f534faf411 ./src/engine/zone_actions.rs
|
||||||
d2c2f3678bd13ec2d92404993173a691803da215dbdb1edafcd96e1963c7004e ./src/engine/zone_control.rs
|
900947200973b8e4490600f33738424a89aadf0b59c0316f4cd46deb997730b6 ./src/engine/zone_control.rs
|
||||||
4b271b6fc365b1078c01d6178eb563841b2ecaed5d8639196f58e1312d2236fe ./src/error.rs
|
4b271b6fc365b1078c01d6178eb563841b2ecaed5d8639196f58e1312d2236fe ./src/error.rs
|
||||||
c6ff66da9ad08506f839ec56a869ead3abc824b5311e0ea9244517d11f7f4207 ./src/home_assistant.rs
|
5639bef8cb54d2c44fb67503d107b58b4decc9413e1739079e6b8e8d125247ba ./src/home_assistant.rs
|
||||||
|
6f9ef85cd53ba030b477bc3c186f0f7e762674124210eabcb0686f2806eaada1 ./src/influxdb.rs
|
||||||
feb50ecd60b9a9fe9de1f7d8e1e26caab5435d59347468f6266a318f431bc3d3 ./src/influxdb/codec.rs
|
feb50ecd60b9a9fe9de1f7d8e1e26caab5435d59347468f6266a318f431bc3d3 ./src/influxdb/codec.rs
|
||||||
553b44ec0321e62480059bc2451fd34ec7aabe9fc87fc717daba0c4b55679d53 ./src/influxdb/query.rs
|
553b44ec0321e62480059bc2451fd34ec7aabe9fc87fc717daba0c4b55679d53 ./src/influxdb/query.rs
|
||||||
6f9ef85cd53ba030b477bc3c186f0f7e762674124210eabcb0686f2806eaada1 ./src/influxdb.rs
|
|
||||||
5e4d94f8a010df05bae9acab72c518080d2dba94d054df95b87e492305357222 ./src/influxdb/write.rs
|
5e4d94f8a010df05bae9acab72c518080d2dba94d054df95b87e492305357222 ./src/influxdb/write.rs
|
||||||
ef8ad176eb2ee6ceb02bbf3d63b65460013475908fced05388b29727af67ef7e ./src/main.rs
|
ef8ad176eb2ee6ceb02bbf3d63b65460013475908fced05388b29727af67ef7e ./src/main.rs
|
||||||
fca124ec30b5c9286f0f44cc18e8953bb2809f5009d4d521deaebfdeb00c1bd1 ./src/models/automation.rs
|
c63bd6257386969a763f44d0e4fd7b6b19078ec7a7f46e7ec2d73580d2274cab ./src/models.rs
|
||||||
da672b06295370a36c69136e15684ab10ccb8fbdc814d76527a05e01213c2d2b ./src/models/control_plan.rs
|
a4f2d6d5a8d1ef58e45721e4640a63d7dd2e80b24accd22f32c74ce4a541ae21 ./src/models/automation.rs
|
||||||
|
377fc9697a70a03ad35fc3701adf4dbe8687760dc90de9f5c532a4f3b612c762 ./src/models/control_plan.rs
|
||||||
8228b9b6d9e8b1d13de29ce88500beacd88c8877893b7a3e41a4bd1e237ca36c ./src/models/defaults.rs
|
8228b9b6d9e8b1d13de29ce88500beacd88c8877893b7a3e41a4bd1e237ca36c ./src/models/defaults.rs
|
||||||
7cdc99a6f0920baf4ac88fbe7e6f1859e8598a00f00b8c3d84f20bc3a4de35f9 ./src/models/device.rs
|
7cdc99a6f0920baf4ac88fbe7e6f1859e8598a00f00b8c3d84f20bc3a4de35f9 ./src/models/device.rs
|
||||||
|
42f968d9a99ac154b0bd83e5baa498659b1378be4d47d2a05665be641a2fb938 ./src/models/flow.rs
|
||||||
cfbaa853a5185c4be572588577b313a2d618837f7c07a3860d0e8d2cca04ee60 ./src/models/history.rs
|
cfbaa853a5185c4be572588577b313a2d618837f7c07a3860d0e8d2cca04ee60 ./src/models/history.rs
|
||||||
668c9cf157679f8c9fe72846f743629c79e263b14827110834b4addbdf9d13ec ./src/models/integrations.rs
|
668c9cf157679f8c9fe72846f743629c79e263b14827110834b4addbdf9d13ec ./src/models/integrations.rs
|
||||||
e935e1fcf0d379b627df5d5055dbdad3dd55786fde7191376918b05afdde1727 ./src/models.rs
|
|
||||||
268b4211df93f08423e39374ab9ffe93a7eba647c173168821736af299b36549 ./src/models/runtime.rs
|
268b4211df93f08423e39374ab9ffe93a7eba647c173168821736af299b36549 ./src/models/runtime.rs
|
||||||
e77f8cdcb5fc0e51cdbf2527a69585a2f6f9089e159d1e45ec4a48735ab0e304 ./src/models/temporary_thermostat.rs
|
e77f8cdcb5fc0e51cdbf2527a69585a2f6f9089e159d1e45ec4a48735ab0e304 ./src/models/temporary_thermostat.rs
|
||||||
5b5c80e25ae2aad1b511bd3f0a82b8fbae2440abf8c656ecb90814587d0be137 ./src/models/zone.rs
|
5b5c80e25ae2aad1b511bd3f0a82b8fbae2440abf8c656ecb90814587d0be137 ./src/models/zone.rs
|
||||||
9c735d7c475f21f2f388c21be784376f7184f0bf18af1886544ef51585a3b290 ./src/notifications.rs
|
9c735d7c475f21f2f388c21be784376f7184f0bf18af1886544ef51585a3b290 ./src/notifications.rs
|
||||||
7fc31fbf8841a073a1544b8c7a6390f1a15b56087486ca0596a8418340fa232a ./src/protocol/crypto.rs
|
7fc31fbf8841a073a1544b8c7a6390f1a15b56087486ca0596a8418340fa232a ./src/protocol/crypto.rs
|
||||||
|
f1f765331469f4219a551414833c19b6de413114c22fdd0468e1f417659553c9 ./src/protocol/gree.rs
|
||||||
c935e441933ba8709432cc3ac33ed868043a89188d0f48ea6fbfe34b1f8936a3 ./src/protocol/gree/binding.rs
|
c935e441933ba8709432cc3ac33ed868043a89188d0f48ea6fbfe34b1f8936a3 ./src/protocol/gree/binding.rs
|
||||||
563ded1758b135eb523f1fa0b1bf55fe5430ef55935b301fd6d79cbb980e1820 ./src/protocol/gree/commands.rs
|
563ded1758b135eb523f1fa0b1bf55fe5430ef55935b301fd6d79cbb980e1820 ./src/protocol/gree/commands.rs
|
||||||
5d4328199e2285016fd1941e909e5c09f7cdf9fd21ab6538c1bf8bc2f4af3ba3 ./src/protocol/gree/core.rs
|
5d4328199e2285016fd1941e909e5c09f7cdf9fd21ab6538c1bf8bc2f4af3ba3 ./src/protocol/gree/core.rs
|
||||||
@@ -117,38 +119,38 @@ c935e441933ba8709432cc3ac33ed868043a89188d0f48ea6fbfe34b1f8936a3 ./src/protocol
|
|||||||
97284ad37767625cfc2b82a3de5c40f6eecddabe185bd0bcea7cf9b165e017f0 ./src/protocol/gree/merge.rs
|
97284ad37767625cfc2b82a3de5c40f6eecddabe185bd0bcea7cf9b165e017f0 ./src/protocol/gree/merge.rs
|
||||||
a96061fbbcadac6c2df2e9708a48dcf819b951eb147f32c7d0d285ac5ae2491b ./src/protocol/gree/network.rs
|
a96061fbbcadac6c2df2e9708a48dcf819b951eb147f32c7d0d285ac5ae2491b ./src/protocol/gree/network.rs
|
||||||
45d0cd0ea40b9255bd811362547f5a667dac6bc7324074726130baf60aa41d2b ./src/protocol/gree/polling.rs
|
45d0cd0ea40b9255bd811362547f5a667dac6bc7324074726130baf60aa41d2b ./src/protocol/gree/polling.rs
|
||||||
f1f765331469f4219a551414833c19b6de413114c22fdd0468e1f417659553c9 ./src/protocol/gree.rs
|
|
||||||
35fc903404045129e3046b7997e941b88f98ceb42485cccc872c2e4aa530c75b ./src/protocol/gree/tests.rs
|
35fc903404045129e3046b7997e941b88f98ceb42485cccc872c2e4aa530c75b ./src/protocol/gree/tests.rs
|
||||||
55c2d615e76c9e4b08c4067ea734e81d81915144e637f3b51c19bea4fc879137 ./src/protocol/gree/transport.rs
|
55c2d615e76c9e4b08c4067ea734e81d81915144e637f3b51c19bea4fc879137 ./src/protocol/gree/transport.rs
|
||||||
a910bd9432a393740c0f6fab52bfcb551f0ea756718d66d290fd2610767cf07c ./src/protocol/mod.rs
|
a910bd9432a393740c0f6fab52bfcb551f0ea756718d66d290fd2610767cf07c ./src/protocol/mod.rs
|
||||||
7fac65a9dcbcecee09816f5447c7792dc94f736b1efb66ac85629db75f6280be ./src/queries/device_history.rs
|
|
||||||
cd5d0befe9f51a7067201a6eaa8d8ed72182d359e42cbdc67c80aa12d722654b ./src/queries/entities.rs
|
|
||||||
2c5fba462b72158b04dbaaa53c8536263f8d72a1338a6c4ff06cb28a36f6ae27 ./src/queries/ha_history.rs
|
|
||||||
2b46a8cdc5efb5f930dd8ff7b9f6c0e720cbca096a51d9ae047711dd280fa3b8 ./src/queries/maintenance.rs
|
|
||||||
830621fa75265da7288b8135704c0ce27e0687f87d55abf3cdbe40a6e2f3d2b1 ./src/queries.rs
|
830621fa75265da7288b8135704c0ce27e0687f87d55abf3cdbe40a6e2f3d2b1 ./src/queries.rs
|
||||||
f688db6f440ee7b0129ccc22838de0ee0e0cf2b6376173b05f853ca2555c2c1e ./src/queries/schema.rs
|
7fac65a9dcbcecee09816f5447c7792dc94f736b1efb66ac85629db75f6280be ./src/queries/device_history.rs
|
||||||
|
3fb50f57d30ac23ef122c45dae4bd96c7cb7184df02d99aef00d9890b66479e2 ./src/queries/entities.rs
|
||||||
|
2c5fba462b72158b04dbaaa53c8536263f8d72a1338a6c4ff06cb28a36f6ae27 ./src/queries/ha_history.rs
|
||||||
|
c91e4b8b250b30cbe76f313b70c16244e9003bce92e02c82283a1831f91974c5 ./src/queries/maintenance.rs
|
||||||
|
3a49bad74a2f047893e726b5e00a6e833c228ad268bdf50cd60f786ba164ea54 ./src/queries/schema.rs
|
||||||
f13f1be3d5789539eba3fc1b59c14835f8c681eca7634e118fa3763a53aeee2b ./src/queries/zone_history.rs
|
f13f1be3d5789539eba3fc1b59c14835f8c681eca7634e118fa3763a53aeee2b ./src/queries/zone_history.rs
|
||||||
6ee23fcaa62231d15006db078d4f763d1b67d2365fcc2d736d8a7da8df5ea382 ./src/state.rs
|
6ee23fcaa62231d15006db078d4f763d1b67d2365fcc2d736d8a7da8df5ea382 ./src/state.rs
|
||||||
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
|
||||||
525c7e83930cde6ea9d248220d595f52da4ade6e0f511e8b1075509635f20b32 ./web/index.html
|
0e76a93e59621704879928e5f49cfa57203a9c3c6b609d054f3a5050ac739c9f ./web/index.html
|
||||||
fbe1ff0e28708f3b4acdbd361ba28457fe127e6474f4def15125e9d195c929c0 ./web/js/bootstrap.js
|
9ba3f6fa05b72aa989139b2a909982571b2a02055052e4c40104f1e81a9aa7eb ./web/js/README.md
|
||||||
|
835e606b2e238efe98f750d1e234b51a7a965e048315e785a13792125e8208f5 ./web/js/bootstrap.js
|
||||||
72fa280996ba331a05bdb29a1f49b4e61bdd8964953c8ce1db952156620f08fe ./web/js/charts.js
|
72fa280996ba331a05bdb29a1f49b4e61bdd8964953c8ce1db952156620f08fe ./web/js/charts.js
|
||||||
80bcc8d2214c55c13574e146eb099cb371565e55c7c4b337b71ca57ad29f90d2 ./web/js/core.js
|
4280d096af17ec011e0332492300c9e22713651ef0438ae2dd0559ee0f7844e9 ./web/js/core.js
|
||||||
83f6a5602da72ed6a307c1b4cc8cd9e4d9a8bf73e58be43af30ba19025a82ea3 ./web/js/dashboard.js
|
d34953aec21ade81ca9770c9c99873b0b2a4862a1bf671a6e13b507a5dd5b7ed ./web/js/dashboard.js
|
||||||
40f2363e79e12446ec8750b56c6df063035cd9655ee89e7c536e3b1f6e71746c ./web/js/entities.js
|
2887856e35e086bb575e9fc0d8b0ed1160b6912297a2957535d5416bda68b4b7 ./web/js/entities.js
|
||||||
9a0e87911f78cd8a969f4c57ce74774f5fa829fdecd79494d9a7f9d5804f9792 ./web/js/events.js
|
9a0e87911f78cd8a969f4c57ce74774f5fa829fdecd79494d9a7f9d5804f9792 ./web/js/events.js
|
||||||
|
65c6adb34a7d18bb401eb8c8d1a11d4d8302e8dfd17dbe2eb617a9f47835bc6a ./web/js/flows.js
|
||||||
cac7615888fd38be3cf714dcf02380c4a41a1802ee1724f91496e7c19cbdffba ./web/js/forms.js
|
cac7615888fd38be3cf714dcf02380c4a41a1802ee1724f91496e7c19cbdffba ./web/js/forms.js
|
||||||
dbf36223863ba882c03eccea4516ba8db7acf0c2e72c6c64490cb994f1e20f96 ./web/js/history.js
|
dbf36223863ba882c03eccea4516ba8db7acf0c2e72c6c64490cb994f1e20f96 ./web/js/history.js
|
||||||
7eaa04992ea828dc89f5eaebf674cbbfd160f53eb8d0d6ef0d4e47bd8c28c435 ./web/js/main.js
|
7eaa04992ea828dc89f5eaebf674cbbfd160f53eb8d0d6ef0d4e47bd8c28c435 ./web/js/main.js
|
||||||
72f2b4ef1e7de342b1d14a4b2757eb23d5a8acd6e837a0fe9fcc661dc35eeb63 ./web/js/navigation.js
|
7f23507b2f1f632d69b466688f68f3f0bf661dc6856a1ed14a462e063114a980 ./web/js/navigation.js
|
||||||
9ba3f6fa05b72aa989139b2a909982571b2a02055052e4c40104f1e81a9aa7eb ./web/js/README.md
|
d4aa5575fe0202acab36afd10a2c78f421af27ba4142ea20fb0cf6f182d6b59e ./web/js/realtime.js
|
||||||
262208c2487399ebbc94747c4d71267b01b5c4ac5bb20c682fc926840e048828 ./web/js/realtime.js
|
c8c82e88c3715b1bfabf155e36266a4c94f5e2fc03eb39dcdd9d08a1985a997c ./web/js/router.js
|
||||||
415348a93ac3423c491d5b222035a5ccd073eae9dc82e9c0560829afb6e02767 ./web/js/router.js
|
|
||||||
4a05126dc95c291ede08452b179d77791e270ed0c9ab9806036934ccf7e0f704 ./web/js/settings.js
|
|
||||||
b3e796778d6eee59d663f198115d0c2a45497fdfc23a88ff4b85893ba079d586 ./web/js/settings-ui.js
|
b3e796778d6eee59d663f198115d0c2a45497fdfc23a88ff4b85893ba079d586 ./web/js/settings-ui.js
|
||||||
|
4a05126dc95c291ede08452b179d77791e270ed0c9ab9806036934ccf7e0f704 ./web/js/settings.js
|
||||||
6b653e4fe2d4db4ffae6ff8f39b8ce57a10a990dc0000ee4aae51d949afadd52 ./web/manifest.webmanifest
|
6b653e4fe2d4db4ffae6ff8f39b8ce57a10a990dc0000ee4aae51d949afadd52 ./web/manifest.webmanifest
|
||||||
d2e533ca1a445036327095647d15eef89eb810295f4222cf7284ac31ee6b5e10 ./web/styles.css
|
c86801c5460f8342599affb0237e6e62fc39193277faf1bdfcbef58d8dabf2f0 ./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.5**
|
**Current release: 0.9.6**
|
||||||
|
|
||||||
> [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.
|
||||||
|
|
||||||
@@ -29,8 +29,9 @@ The interface is intentionally split by responsibility:
|
|||||||
| Devices | Discovery, binding and direct/technical GREE device control. |
|
| Devices | Discovery, binding and direct/technical GREE device control. |
|
||||||
| Zones | Thermostat configuration, profiles, sensors and control ownership. |
|
| Zones | Thermostat configuration, profiles, sensors and control ownership. |
|
||||||
| Groups | Climate group membership plus optional group-level mode/preset/custom-temperature control. Group ON activates member thermostat control; Group OFF powers member units down and releases group ownership so individual thermostats are not left group-blocked. |
|
| Groups | Climate group membership plus optional group-level mode/preset/custom-temperature control. Group ON activates member thermostat control; Group OFF powers member units down and releases group ownership so individual thermostats are not left group-blocked. |
|
||||||
| Schedules | Weekly thermostat schedules and ready-made templates. |
|
| Flow | Full-screen visual logic editor. Flow is the source of truth and is automatically compiled into generated schedules and automations. |
|
||||||
| Automations | Time/temperature triggered device or group actions. |
|
| Schedules | Weekly thermostat schedules and ready-made templates, including read-only entries generated by Flow. |
|
||||||
|
| Automations | Time/temperature/Flow-triggered device, group or thermostat actions. |
|
||||||
| History | Overview, zones, devices and HA sensors with precise chart tooltips and zoom. |
|
| History | Overview, zones, devices and HA sensors with precise chart tooltips and zoom. |
|
||||||
| Simulation | Simulated units for safe testing. |
|
| Simulation | Simulated units for safe testing. |
|
||||||
| Night mode | Quiet/Sleep/fan limits during an overnight window. |
|
| Night mode | Quiet/Sleep/fan limits during an overnight window. |
|
||||||
@@ -202,8 +203,16 @@ Each zone stores separate cooling and heating temperatures for:
|
|||||||
|
|
||||||
Schedules select profiles or a custom target for selected ISO weekdays (`1=Monday`, `7=Sunday`). Overnight windows are supported. Overlapping enabled schedules for the same zone are rejected.
|
Schedules select profiles or a custom target for selected ISO weekdays (`1=Monday`, `7=Sunday`). Overnight windows are supported. Overlapping enabled schedules for the same zone are rejected.
|
||||||
|
|
||||||
|
### Visual Flow
|
||||||
|
|
||||||
|
**Flow** is the authoring layer for combined schedules and automations. The full-screen editor stores a block graph as the source of truth and compiles it automatically: a pure weekday + time-range + thermostat path becomes a native schedule, while richer logic becomes a generated automation. Generated schedule/automation rows carry their Flow provenance and are read-only in the legacy editors.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
Quick preset/setpoint overrides normally hand control back at the next schedule boundary. Temporary Quick Thermostat adds explicit start/finish rules such as duration, exact time, temperature reached/stable or next schedule boundary.
|
Quick preset/setpoint overrides normally hand control back at the next schedule boundary. Temporary Quick Thermostat adds explicit start/finish rules such as duration, exact time, temperature reached/stable or next schedule boundary.
|
||||||
|
|
||||||
|
Implementation and concurrency invariants are documented in [`docs/FLOW.md`](docs/FLOW.md).
|
||||||
|
|
||||||
## Home Assistant
|
## Home Assistant
|
||||||
|
|
||||||
There are two independent Home Assistant directions.
|
There are two independent Home Assistant directions.
|
||||||
@@ -406,3 +415,13 @@ This runs formatting checks, Rust tests, a build and an isolated simulated API s
|
|||||||
## License
|
## License
|
||||||
|
|
||||||
See [LICENSE](LICENSE).
|
See [LICENSE](LICENSE).
|
||||||
|
|
||||||
|
#### Flow diagnostics, portability and concurrency
|
||||||
|
|
||||||
|
Flow reuses the existing thermostat, group and device-control domains instead of maintaining a second HVAC controller. Thermostat actions update the persistent zone intent and wake the normal zone-control engine, so hysteresis, compressor protection, schedule hand-back, Temporary Quick Thermostat and ownership rules remain authoritative. Flow group actions call the existing group controller; direct device actions use the existing automatic-device action path and `DeviceCommand`. A Flow thermostat OFF also performs the existing forced physical OFF transition. Durable thermostat/device transitions are serialized with the thermostat cycle and then use the canonical zone -> device lock order.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ const APP_JS_MODULES: &[&str] = &[
|
|||||||
"bootstrap.js",
|
"bootstrap.js",
|
||||||
"dashboard.js",
|
"dashboard.js",
|
||||||
"entities.js",
|
"entities.js",
|
||||||
|
"flows.js",
|
||||||
"settings-ui.js",
|
"settings-ui.js",
|
||||||
"router.js",
|
"router.js",
|
||||||
"navigation.js",
|
"navigation.js",
|
||||||
|
|||||||
+26
-3
@@ -1,6 +1,6 @@
|
|||||||
# GREE Controller API reference
|
# GREE Controller API reference
|
||||||
|
|
||||||
HTTP and WebSocket API for GREE Controller **0.9.5**.
|
HTTP and WebSocket API for GREE Controller **0.9.6**.
|
||||||
|
|
||||||
[← Main documentation](../README.md)
|
[← Main documentation](../README.md)
|
||||||
|
|
||||||
@@ -212,7 +212,7 @@ Response:
|
|||||||
{
|
{
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"name": "gree-controller",
|
"name": "gree-controller",
|
||||||
"version": "0.9.5",
|
"version": "0.9.6",
|
||||||
"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 +236,7 @@ Returns the initial Web UI snapshot:
|
|||||||
"settings": {},
|
"settings": {},
|
||||||
"outdoor_temperature": null,
|
"outdoor_temperature": null,
|
||||||
"system": {
|
"system": {
|
||||||
"version": "0.9.5",
|
"version": "0.9.6",
|
||||||
"uptime_seconds": 1234,
|
"uptime_seconds": 1234,
|
||||||
"auth_required": false,
|
"auth_required": false,
|
||||||
"control_ready": true,
|
"control_ready": true,
|
||||||
@@ -1482,6 +1482,15 @@ curl -H 'Authorization: Bearer gree_controller_RESTRICTED_TOKEN' \
|
|||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## Visual Flow API
|
||||||
|
|
||||||
|
Flow is the source-of-truth representation for visual schedule/automation logic. `GET /api/flows` lists Flows; `GET /api/flows/:id`, `POST /api/flows`, `PUT /api/flows/:id` and `DELETE /api/flows/:id` manage them. A Flow payload contains `name`, `enabled`, optional `description`, `nodes` and `edges`.
|
||||||
|
|
||||||
|
Saving a Flow validates the DAG and compiles it atomically. A thermostat action driven only by one weekday block and one time-range block is emitted as a native schedule when its settings are schedule-compatible. More complex graphs are emitted as Flow-triggered automations. Generated schedules and automations expose `flow_id` / `flow_node_id` and cannot be edited or deleted through their legacy endpoints; edit the owning Flow instead.
|
||||||
|
|
||||||
|
Condition blocks support weekday, time/date ranges, application Night mode, outdoor/device/zone temperatures, house/device/zone/group state, and arbitrary Home Assistant state/numeric/attribute/availability sources. Logic blocks support AND, OR and NOT. Action blocks target thermostat zones, GREE devices or climate groups. Direct GREE actions map to the existing `DeviceCommand` fields including fan, swing, quiet, turbo, light, air, xfan, health and sleep; ownership rules may suppress fields that would fight an active thermostat. Flow group actions use the existing group controller and support power, house/heat/cool mode, auto/comfort/sleep/away and the existing custom group target.
|
||||||
|
|
||||||
## Compressor protection queue
|
## Compressor protection queue
|
||||||
|
|
||||||
Runtime settings expose `compressor_protection_enabled` and `compressor_protection_seconds` (30–1800; default 180). While enabled, thermostat starts and Heat/Cool reversals that fall inside the protection window are represented on the owning zone by `compressor_pending_action`, `compressor_pending_since`, and `compressor_pending_until`.
|
Runtime settings expose `compressor_protection_enabled` and `compressor_protection_seconds` (30–1800; default 180). While enabled, thermostat starts and Heat/Cool reversals that fall inside the protection window are represented on the owning zone by `compressor_pending_action`, `compressor_pending_since`, and `compressor_pending_until`.
|
||||||
@@ -1490,3 +1499,17 @@ Runtime settings expose `compressor_protection_enabled` and `compressor_protecti
|
|||||||
- `POST /api/compressor-queue/cancel-all` cancels all currently pending compressor-protection tasks.
|
- `POST /api/compressor-queue/cancel-all` cancels all currently pending compressor-protection tasks.
|
||||||
|
|
||||||
Cancellation suppresses the same pending intent until a new explicit thermostat/group/house command re-arms it, a scheduled Temporary Quick Thermostat session takes ownership, or a different mode/target creates a new intent. Safety OFF commands are not delayed by compressor protection. Direct technical device commands remain immediate manual-control operations.
|
Cancellation suppresses the same pending intent until a new explicit thermostat/group/house command re-arms it, a scheduled Temporary Quick Thermostat session takes ownership, or a different mode/target creates a new intent. Safety OFF commands are not delayed by compressor protection. Direct technical device commands remain immediate manual-control operations.
|
||||||
|
|
||||||
|
### Flow portability and diagnostics
|
||||||
|
|
||||||
|
`GET /api/flows/:id/export` returns a versioned `gree-controller-flow` JSON document containing only the source graph. Generated schedules/automations are not exported.
|
||||||
|
|
||||||
|
`POST /api/flows/import` accepts either that envelope or a direct Flow source payload, creates a new Flow ID/revision, validates it and atomically recompiles outputs.
|
||||||
|
|
||||||
|
`POST /api/flows/simulate` accepts `{ flow, flow_id?, at?, overrides?, log? }`. `at` is RFC3339. `overrides` maps Flow node IDs to simulated values. The endpoint validates and compiles the graph, evaluates every action and returns per-node traces plus `matched`, `would_execute` and `blocked_reason` (for example disabled zone/device, manual/local/temporary thermostat ownership, thermostat-output conflict or disabled Flow). It never mutates thermostat/device/group/schedule/automation state. Home Assistant read/attribute/parse failures evaluate safely as false and are visible in the trace instead of accidentally satisfying `NOT`/`neq` logic.
|
||||||
|
|
||||||
|
`GET /api/flows/:id/logs?limit=100` returns events associated with the Flow or its generated automations.
|
||||||
|
|
||||||
|
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` and `constant`, 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 ten editable templates: workday comfort, weather comfort, smart demand, device resilience, night group, HA window guard, presence eco, frost guard, nested guard and night quiet.
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
# Visual Flow architecture
|
||||||
|
|
||||||
|
Flow is an authoring/orchestration layer. It does not replace the thermostat, schedule, group or device engines.
|
||||||
|
|
||||||
|
## Compilation
|
||||||
|
|
||||||
|
- `weekday + time_range -> zone_thermostat` with schedule-compatible settings compiles to a native `Schedule`.
|
||||||
|
- Richer DAGs compile to native `Automation` rows with `trigger_kind = "flow"`.
|
||||||
|
- Generated rows keep `flow_id` and `flow_node_id` and are read-only in the legacy editors.
|
||||||
|
- The Flow source graph plus all of its generated schedule/automation rows are replaced in one SQLite transaction.
|
||||||
|
- Flow updates require `expected_revision`; stale editor tabs receive HTTP 409.
|
||||||
|
|
||||||
|
## Existing control domains remain authoritative
|
||||||
|
|
||||||
|
- `zone_thermostat` writes the existing persistent Zone intent and wakes the normal thermostat cycle.
|
||||||
|
- `group_action` calls the existing `control_group` path, including custom group targets.
|
||||||
|
- `device_action` uses the existing `DeviceCommand` / automatic-device path.
|
||||||
|
- Thermostat hysteresis, compressor protection, schedule hand-back, manual/local ownership and Temporary Quick Thermostat keep their existing priority.
|
||||||
|
- GREE fan/quiet/sleep and dry/fan HVAC modes are suppressed on thermostat-assigned devices when they would fight the regulator. Device-only features such as light/turbo/swing/air/xfan/health remain available through the normal command path.
|
||||||
|
|
||||||
|
## Concurrency invariants
|
||||||
|
|
||||||
|
Structural Flow writes use the global order:
|
||||||
|
|
||||||
|
`configuration -> automation -> schedule -> thermostat-cycle`
|
||||||
|
|
||||||
|
Thermostat/device actions then use:
|
||||||
|
|
||||||
|
`automation -> schedule -> thermostat-cycle -> zone -> device`
|
||||||
|
|
||||||
|
Group actions use the existing group order:
|
||||||
|
|
||||||
|
`automation -> house -> thermostat-cycle -> group -> zone(s) -> device`
|
||||||
|
|
||||||
|
The control loop runs thermostat arbitration and automation arbitration sequentially. Automation execution reloads the row after acquiring the automation lock and skips it when `updated_at` changed. Same-cycle due automations deterministically claim target devices so two rules cannot issue conflicting commands to the same unit in one pass. Polling and physical commands share per-zone/per-device locks.
|
||||||
|
|
||||||
|
Holding the automation lock across the physical action is deliberate: Flow cannot be deleted/recompiled while an already-selected action is in flight. Do not shorten this ownership window without adding an explicit execution-generation/token mechanism.
|
||||||
|
|
||||||
|
External sensor values can naturally change between condition sampling and action execution. This is normal sampled-control semantics, not a persistent-state race; safety/ownership is rechecked at the action boundary.
|
||||||
|
|
||||||
|
## Home Assistant safety
|
||||||
|
|
||||||
|
`ha_state`, `ha_numeric`, `ha_attribute` and `ha_available` fail closed. Connection failures, missing attributes and non-numeric numeric states evaluate to `false` and appear as an error object in dry-run traces. This prevents `NOT` / `neq` branches from becoming true merely because Home Assistant is unavailable.
|
||||||
|
|
||||||
|
## Diagnostics
|
||||||
|
|
||||||
|
The Flow editor exposes:
|
||||||
|
|
||||||
|
- dry-run at an arbitrary date/time,
|
||||||
|
- per-source overrides,
|
||||||
|
- per-node trace,
|
||||||
|
- `would_execute` and ownership/block reason,
|
||||||
|
- Flow-scoped execution/dry-run logs,
|
||||||
|
- import/export of versioned `.flow.json` source graphs.
|
||||||
|
|
||||||
|
Dry-run never mutates thermostats, devices, groups, schedules or automations.
|
||||||
|
|
||||||
|
## Blocks
|
||||||
|
|
||||||
|
Condition/source blocks currently include weekday, time range, date range, application Night mode, outdoor/device/zone temperature, house mode, device state, thermostat/zone state, group state, Home Assistant state/numeric/attribute/availability and a diagnostic constant. Logic blocks are AND, OR and NOT.
|
||||||
|
|
||||||
|
Actions include thermostat zone, GREE device and climate group. Direct GREE actions expose power, HVAC mode, target temperature, fan speed, vertical/horizontal swing, quiet, turbo, light, air, xfan, health and sleep.
|
||||||
|
|
||||||
|
## Built-in templates
|
||||||
|
|
||||||
|
The editor ships editable templates for:
|
||||||
|
|
||||||
|
- workday comfort,
|
||||||
|
- weather-aware comfort,
|
||||||
|
- smart AND/OR demand,
|
||||||
|
- device resilience,
|
||||||
|
- night group control,
|
||||||
|
- Home Assistant window guard,
|
||||||
|
- presence comfort/eco,
|
||||||
|
- frost protection,
|
||||||
|
- nested OR + NOT guard,
|
||||||
|
- application Night-mode quiet profile.
|
||||||
|
|
||||||
|
## Good next stateful blocks
|
||||||
|
|
||||||
|
The next useful additions should be implemented with persisted runtime state rather than pretending they are stateless predicates:
|
||||||
|
|
||||||
|
- `for X minutes` / stable-for,
|
||||||
|
- debounce,
|
||||||
|
- hysteresis block,
|
||||||
|
- rising/falling edge,
|
||||||
|
- cooldown/rate limit independent of the action,
|
||||||
|
- delay/timer,
|
||||||
|
- retry/backoff,
|
||||||
|
- variables/counters,
|
||||||
|
- reusable subflows/macros,
|
||||||
|
- explicit priority/mutex groups for actions,
|
||||||
|
- trace replay from historical sensor samples.
|
||||||
|
|
||||||
|
These need durable state and restart semantics before they should be exposed in the editor.
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"domain": "gree_controller",
|
"domain": "gree_controller",
|
||||||
"name": "GREE Controller",
|
"name": "GREE Controller",
|
||||||
"version": "0.9.5",
|
"version": "0.9.6",
|
||||||
"config_flow": true,
|
"config_flow": true,
|
||||||
"integration_type": "hub",
|
"integration_type": "hub",
|
||||||
"iot_class": "local_polling",
|
"iot_class": "local_polling",
|
||||||
|
|||||||
+134
-1
@@ -809,6 +809,139 @@
|
|||||||
"zones.queueSourceGlobal": "Global power",
|
"zones.queueSourceGlobal": "Global power",
|
||||||
"zones.queueRemaining": "Remaining {time}",
|
"zones.queueRemaining": "Remaining {time}",
|
||||||
"zones.queueReady": "Ready to execute",
|
"zones.queueReady": "Ready to execute",
|
||||||
"zones.queueCancelAllConfirm": "Cancel all tasks ({count}) waiting for compressor protection?"
|
"zones.queueCancelAllConfirm": "Cancel all tasks ({count}) waiting for compressor protection?",
|
||||||
|
"nav.flows": "Flow",
|
||||||
|
"flow.eyebrow": "Visual logic",
|
||||||
|
"flow.new": "New Flow",
|
||||||
|
"flow.infoTitle": "One place for schedules and automations",
|
||||||
|
"flow.infoText": "Build logic from blocks. GREE Controller translates Flow into generated schedules and automations. Generated entries are read-only outside this editor.",
|
||||||
|
"flow.sourceOfTruth": "Flow = source of truth",
|
||||||
|
"flow.blocks": "Blocks",
|
||||||
|
"flow.defaultDescription": "Visual control logic",
|
||||||
|
"flow.compilesAutomatically": "Automatically translated into schedules and automations",
|
||||||
|
"flow.openEditor": "Open editor",
|
||||||
|
"flow.emptyTitle": "No Flows",
|
||||||
|
"flow.emptyText": "Create your first Flow and build the logic from blocks.",
|
||||||
|
"flow.editorEyebrow": "Flow editor",
|
||||||
|
"flow.namePlaceholder": "Logic name",
|
||||||
|
"flow.paletteHint": "Add a block, then connect its output to the next block.",
|
||||||
|
"flow.time": "Time",
|
||||||
|
"flow.sensors": "Sensors",
|
||||||
|
"flow.logic": "Logic",
|
||||||
|
"flow.actions": "Actions",
|
||||||
|
"flow.emptyCanvas": "Start by adding blocks",
|
||||||
|
"flow.emptyCanvasHint": "Put conditions on the left and actions on the right.",
|
||||||
|
"flow.interpretation": "Interpretation",
|
||||||
|
"flow.selectBlock": "Select a block",
|
||||||
|
"flow.selectBlockHint": "Its settings will appear here.",
|
||||||
|
"flow.newDefaultName": "New logic",
|
||||||
|
"flow.notFound": "Flow not found",
|
||||||
|
"flow.connectHere": "Connect here",
|
||||||
|
"flow.startConnection": "Start connection",
|
||||||
|
"flow.allConditions": "All conditions must match",
|
||||||
|
"flow.operator": "Operator",
|
||||||
|
"flow.state": "State",
|
||||||
|
"flow.value": "Value",
|
||||||
|
"flow.andHint": "AND joins multiple branches. All conditions connected upstream of an action must match.",
|
||||||
|
"flow.blockSettings": "Block settings",
|
||||||
|
"flow.noActionsYet": "Add an action to see the interpreted logic.",
|
||||||
|
"flow.always": "Always",
|
||||||
|
"flow.compileCount": "{schedules} schedules · {automations} automations",
|
||||||
|
"flow.nameRequired": "Enter a Flow name",
|
||||||
|
"flow.saved": "Flow saved and translated into executable logic.",
|
||||||
|
"flow.deleteConfirm": "Delete Flow “{name}” together with its generated schedules and automations?",
|
||||||
|
"flow.deleted": "Flow deleted.",
|
||||||
|
"flow.generated": "Generated by Flow",
|
||||||
|
"flow.generatedReadOnly": "Generated by Flow — edit only in the Flow editor",
|
||||||
|
"flow.anyCondition": "Any condition may match",
|
||||||
|
"flow.invertCondition": "Invert condition",
|
||||||
|
"flow.orHint": "OR is true when at least one connected branch matches.",
|
||||||
|
"flow.notHint": "NOT inverts exactly one connected branch.",
|
||||||
|
"flow.and": "and",
|
||||||
|
"flow.or": "or",
|
||||||
|
"flow.not": "not",
|
||||||
|
"flow.node.weekday": "Weekdays",
|
||||||
|
"flow.node.timeRange": "Time range",
|
||||||
|
"flow.node.dateRange": "Date range",
|
||||||
|
"flow.node.outdoorTemperature": "Outdoor temperature",
|
||||||
|
"flow.node.deviceTemperature": "Device temperature",
|
||||||
|
"flow.node.zoneTemperature": "Zone temperature",
|
||||||
|
"flow.node.haState": "HA: state",
|
||||||
|
"flow.node.haNumeric": "HA: numeric value",
|
||||||
|
"flow.node.and": "AND",
|
||||||
|
"flow.node.or": "OR",
|
||||||
|
"flow.node.not": "NOT",
|
||||||
|
"flow.node.thermostat": "Thermostat",
|
||||||
|
"flow.node.greeDevice": "GREE device",
|
||||||
|
"flow.node.group": "Group",
|
||||||
|
"common.yes": "Yes",
|
||||||
|
"common.no": "No",
|
||||||
|
"common.loading": "Loading…",
|
||||||
|
"flow.import": "Import Flow",
|
||||||
|
"flow.export": "Export",
|
||||||
|
"flow.exported": "Flow exported.",
|
||||||
|
"flow.imported": "Flow imported and compiled.",
|
||||||
|
"flow.importFailed": "Flow import failed",
|
||||||
|
"flow.templates": "Templates",
|
||||||
|
"flow.templateLibrary": "Flow library",
|
||||||
|
"flow.templatesHint": "Ready-made layouts create an editable graph using existing zones, devices and groups.",
|
||||||
|
"flow.templateReplaceConfirm": "Replace the current blocks with this template?",
|
||||||
|
"flow.dryRun": "Dry-run",
|
||||||
|
"flow.logs": "Logs",
|
||||||
|
"flow.testMode": "Flow diagnostics",
|
||||||
|
"flow.simulationTime": "Simulation date and time",
|
||||||
|
"flow.runSimulation": "Run simulation",
|
||||||
|
"flow.dryRunHint": "Dry-run does not change thermostat, device, group, schedule or automation state.",
|
||||||
|
"flow.simulationOverrides": "Sensor overrides",
|
||||||
|
"flow.simulationOverridesHint": "Leave blank to use the current application or Home Assistant state.",
|
||||||
|
"flow.useLiveValue": "live value",
|
||||||
|
"flow.noSimulationOverrides": "This Flow has no sources that need value overrides.",
|
||||||
|
"flow.runningSimulation": "Simulating…",
|
||||||
|
"flow.simulationFailed": "Simulation failed",
|
||||||
|
"flow.compiledPreview": "Compilation: {schedules} schedules · {automations} automations",
|
||||||
|
"flow.wouldExecute": "Action would execute",
|
||||||
|
"flow.blockedByOwnership": "Blocked by ownership",
|
||||||
|
"flow.conditionFalse": "Condition false",
|
||||||
|
"flow.blockReason": "Block reason",
|
||||||
|
"flow.saveBeforeLogs": "Save the Flow first to view its logs.",
|
||||||
|
"flow.noLogs": "No logs for this Flow.",
|
||||||
|
"flow.logsFailed": "Could not load Flow logs.",
|
||||||
|
"flow.attribute": "Attribute",
|
||||||
|
"flow.houseMode": "House mode",
|
||||||
|
"flow.field": "Field",
|
||||||
|
"flow.node.haAttribute": "HA: attribute",
|
||||||
|
"flow.node.houseMode": "House mode",
|
||||||
|
"flow.node.deviceState": "Device state",
|
||||||
|
"flow.node.zoneState": "Thermostat state",
|
||||||
|
"flow.node.constant": "Test constant",
|
||||||
|
"flow.template.workday_comfort.title": "Workday comfort",
|
||||||
|
"flow.template.workday_comfort.description": "Weekday morning comfort preset for the selected thermostat zone.",
|
||||||
|
"flow.template.weather_comfort.title": "Weather-aware comfort",
|
||||||
|
"flow.template.weather_comfort.description": "Combines weekday, time and outdoor temperature with AND before thermostat control.",
|
||||||
|
"flow.template.smart_demand.title": "Smart demand",
|
||||||
|
"flow.template.smart_demand.description": "Complex time, house-mode and outdoor-or-room temperature logic.",
|
||||||
|
"flow.template.device_resilience.title": "Only when device is healthy",
|
||||||
|
"flow.template.device_resilience.description": "Runs the thermostat only when the device is enabled, online and house mode allows operation.",
|
||||||
|
"flow.template.night_group.title": "Night group",
|
||||||
|
"flow.template.night_group.description": "Night time + house mode applies sleep preset to a group or zone.",
|
||||||
|
"flow.template.ha_window_guard.title": "Open window — stop HVAC",
|
||||||
|
"flow.template.ha_window_guard.description": "Home Assistant binary_sensor example: an open window turns the thermostat off. Set the correct entity_id.",
|
||||||
|
"flow.node.groupState": "Group state",
|
||||||
|
"flow.node.nightMode": "Night mode",
|
||||||
|
"flow.nightModeActive": "active according to app settings",
|
||||||
|
"flow.nightModeHint": "This condition is true while the Night mode configured in the app is currently active. Use NOT to match time outside Night mode.",
|
||||||
|
"flow.template.presence_eco.title": "Presence: comfort / eco",
|
||||||
|
"flow.template.presence_eco.description": "Two explicit Home Assistant branches: home enables comfort while not_home selects the away preset. Set the correct person or presence entity_id.",
|
||||||
|
"flow.template.frost_guard.title": "Frost protection",
|
||||||
|
"flow.template.frost_guard.description": "In heating mode, low outdoor and room temperatures select a safe low thermostat target.",
|
||||||
|
"flow.template.nested_guard.title": "Nested OR and NOT guard",
|
||||||
|
"flow.template.nested_guard.description": "Weekdays and time + outdoor OR room temperature + NOT open window. An example of a multi-level graph.",
|
||||||
|
"flow.template.night_quiet.title": "Night group profile",
|
||||||
|
"flow.template.night_quiet.description": "Uses the app Night mode and group state directly to apply the sleep preset.",
|
||||||
|
"flow.node.haAvailable": "HA: availability",
|
||||||
|
"flow.available": "available",
|
||||||
|
"flow.haAvailableHint": "This condition is true only when the Home Assistant entity returns a state other than unknown/unavailable. Connection errors fail closed as false. Useful as a guard before NOT and safety logic.",
|
||||||
|
"flow.deviceOptions": "Advanced GREE options",
|
||||||
|
"flow.deviceOptionsHint": "These fields use the existing DeviceCommand. Fan/quiet/sleep and dry/fan HVAC modes are blocked on a thermostat-assigned device when they would fight the regulator; use the Thermostat action there instead."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+134
-1
@@ -809,6 +809,139 @@
|
|||||||
"zones.queueSourceGlobal": "Zasilanie globalne",
|
"zones.queueSourceGlobal": "Zasilanie globalne",
|
||||||
"zones.queueRemaining": "Pozostało {time}",
|
"zones.queueRemaining": "Pozostało {time}",
|
||||||
"zones.queueReady": "Gotowe do wykonania",
|
"zones.queueReady": "Gotowe do wykonania",
|
||||||
"zones.queueCancelAllConfirm": "Anulować wszystkie oczekujące zadania ({count}) ochrony sprężarki?"
|
"zones.queueCancelAllConfirm": "Anulować wszystkie oczekujące zadania ({count}) ochrony sprężarki?",
|
||||||
|
"nav.flows": "Flow",
|
||||||
|
"flow.eyebrow": "Logika wizualna",
|
||||||
|
"flow.new": "Nowy Flow",
|
||||||
|
"flow.infoTitle": "Jedno miejsce dla harmonogramów i automatyzacji",
|
||||||
|
"flow.infoText": "Układasz logikę z bloków. GREE Controller tłumaczy Flow na generowane harmonogramy i automatyzacje. Wygenerowane wpisy są tylko do odczytu poza tym edytorem.",
|
||||||
|
"flow.sourceOfTruth": "Flow = źródło logiki",
|
||||||
|
"flow.blocks": "Bloki",
|
||||||
|
"flow.defaultDescription": "Wizualna logika sterowania",
|
||||||
|
"flow.compilesAutomatically": "Automatycznie tłumaczone na harmonogramy i automatyzacje",
|
||||||
|
"flow.openEditor": "Otwórz edytor",
|
||||||
|
"flow.emptyTitle": "Brak Flow",
|
||||||
|
"flow.emptyText": "Utwórz pierwszy przepływ i ułóż logikę z bloków.",
|
||||||
|
"flow.editorEyebrow": "Edytor Flow",
|
||||||
|
"flow.namePlaceholder": "Nazwa logiki",
|
||||||
|
"flow.paletteHint": "Dodaj blok, a potem połącz jego wyjście z kolejnym blokiem.",
|
||||||
|
"flow.time": "Czas",
|
||||||
|
"flow.sensors": "Czujniki",
|
||||||
|
"flow.logic": "Logika",
|
||||||
|
"flow.actions": "Akcje",
|
||||||
|
"flow.emptyCanvas": "Zacznij od dodania bloków",
|
||||||
|
"flow.emptyCanvasHint": "Warunki układaj po lewej, akcje po prawej.",
|
||||||
|
"flow.interpretation": "Interpretacja",
|
||||||
|
"flow.selectBlock": "Wybierz blok",
|
||||||
|
"flow.selectBlockHint": "Tutaj pojawią się jego ustawienia.",
|
||||||
|
"flow.newDefaultName": "Nowa logika",
|
||||||
|
"flow.notFound": "Nie znaleziono Flow",
|
||||||
|
"flow.connectHere": "Połącz tutaj",
|
||||||
|
"flow.startConnection": "Rozpocznij połączenie",
|
||||||
|
"flow.allConditions": "Wszystkie warunki muszą być spełnione",
|
||||||
|
"flow.operator": "Operator",
|
||||||
|
"flow.state": "Stan",
|
||||||
|
"flow.value": "Wartość",
|
||||||
|
"flow.andHint": "AND łączy kilka gałęzi. Wszystkie warunki doprowadzone do akcji muszą być spełnione.",
|
||||||
|
"flow.blockSettings": "Ustawienia bloku",
|
||||||
|
"flow.noActionsYet": "Dodaj akcję, aby zobaczyć interpretację logiki.",
|
||||||
|
"flow.always": "Zawsze",
|
||||||
|
"flow.compileCount": "{schedules} harm. · {automations} autom.",
|
||||||
|
"flow.nameRequired": "Podaj nazwę Flow",
|
||||||
|
"flow.saved": "Flow zapisany i przetłumaczony na logikę wykonawczą.",
|
||||||
|
"flow.deleteConfirm": "Usunąć Flow „{name}” razem z wygenerowanymi harmonogramami i automatyzacjami?",
|
||||||
|
"flow.deleted": "Flow usunięty.",
|
||||||
|
"flow.generated": "Wygenerowane przez Flow",
|
||||||
|
"flow.generatedReadOnly": "Wygenerowane przez Flow — edycja tylko w edytorze Flow",
|
||||||
|
"flow.anyCondition": "Wystarczy jeden warunek",
|
||||||
|
"flow.invertCondition": "Odwrócenie warunku",
|
||||||
|
"flow.orHint": "OR zwraca prawdę, gdy spełniona jest co najmniej jedna podłączona gałąź.",
|
||||||
|
"flow.notHint": "NOT odwraca wynik dokładnie jednej podłączonej gałęzi.",
|
||||||
|
"flow.and": "i",
|
||||||
|
"flow.or": "lub",
|
||||||
|
"flow.not": "nie",
|
||||||
|
"flow.node.weekday": "Dni tygodnia",
|
||||||
|
"flow.node.timeRange": "Przedział godzin",
|
||||||
|
"flow.node.dateRange": "Zakres dat",
|
||||||
|
"flow.node.outdoorTemperature": "Temperatura zewn.",
|
||||||
|
"flow.node.deviceTemperature": "Temperatura urządzenia",
|
||||||
|
"flow.node.zoneTemperature": "Temperatura strefy",
|
||||||
|
"flow.node.haState": "HA: stan",
|
||||||
|
"flow.node.haNumeric": "HA: wartość",
|
||||||
|
"flow.node.and": "AND",
|
||||||
|
"flow.node.or": "OR",
|
||||||
|
"flow.node.not": "NOT",
|
||||||
|
"flow.node.thermostat": "Termostat",
|
||||||
|
"flow.node.greeDevice": "Urządzenie GREE",
|
||||||
|
"flow.node.group": "Grupa",
|
||||||
|
"common.yes": "Tak",
|
||||||
|
"common.no": "Nie",
|
||||||
|
"common.loading": "Ładowanie…",
|
||||||
|
"flow.import": "Importuj Flow",
|
||||||
|
"flow.export": "Eksportuj",
|
||||||
|
"flow.exported": "Flow wyeksportowany.",
|
||||||
|
"flow.imported": "Flow zaimportowany i skompilowany.",
|
||||||
|
"flow.importFailed": "Nie udało się zaimportować Flow",
|
||||||
|
"flow.templates": "Presety",
|
||||||
|
"flow.templateLibrary": "Biblioteka Flow",
|
||||||
|
"flow.templatesHint": "Gotowe układy tworzą edytowalny graf z użyciem istniejących stref, urządzeń i grup.",
|
||||||
|
"flow.templateReplaceConfirm": "Zastąpić obecne bloki wybranym presetem?",
|
||||||
|
"flow.dryRun": "Dry-run",
|
||||||
|
"flow.logs": "Logi",
|
||||||
|
"flow.testMode": "Diagnostyka Flow",
|
||||||
|
"flow.simulationTime": "Data i godzina symulacji",
|
||||||
|
"flow.runSimulation": "Uruchom symulację",
|
||||||
|
"flow.dryRunHint": "Dry-run nie zmienia stanu termostatów, urządzeń, grup, harmonogramów ani automatyzacji.",
|
||||||
|
"flow.simulationOverrides": "Nadpisania czujników",
|
||||||
|
"flow.simulationOverridesHint": "Zostaw puste, aby użyć aktualnego stanu aplikacji lub Home Assistant.",
|
||||||
|
"flow.useLiveValue": "aktualna wartość",
|
||||||
|
"flow.noSimulationOverrides": "Ten Flow nie ma źródeł, które wymagają nadpisania wartości.",
|
||||||
|
"flow.runningSimulation": "Symulacja…",
|
||||||
|
"flow.simulationFailed": "Symulacja nie powiodła się",
|
||||||
|
"flow.compiledPreview": "Kompilacja: {schedules} harm. · {automations} autom.",
|
||||||
|
"flow.wouldExecute": "Akcja wykonałaby się",
|
||||||
|
"flow.blockedByOwnership": "Zablokowane przez ownership",
|
||||||
|
"flow.conditionFalse": "Warunek niespełniony",
|
||||||
|
"flow.blockReason": "Powód blokady",
|
||||||
|
"flow.saveBeforeLogs": "Najpierw zapisz Flow, aby zobaczyć jego logi.",
|
||||||
|
"flow.noLogs": "Brak logów dla tego Flow.",
|
||||||
|
"flow.logsFailed": "Nie udało się pobrać logów.",
|
||||||
|
"flow.attribute": "Atrybut",
|
||||||
|
"flow.houseMode": "Tryb domu",
|
||||||
|
"flow.field": "Pole",
|
||||||
|
"flow.node.haAttribute": "HA: atrybut",
|
||||||
|
"flow.node.houseMode": "Tryb domu",
|
||||||
|
"flow.node.deviceState": "Stan urządzenia",
|
||||||
|
"flow.node.zoneState": "Stan termostatu",
|
||||||
|
"flow.node.constant": "Stała testowa",
|
||||||
|
"flow.template.workday_comfort.title": "Komfort w dni robocze",
|
||||||
|
"flow.template.workday_comfort.description": "Pon.–pt. rano uruchamia preset komfortu w wybranej strefie.",
|
||||||
|
"flow.template.weather_comfort.title": "Komfort zależny od pogody",
|
||||||
|
"flow.template.weather_comfort.description": "Łączy dzień, godzinę i temperaturę zewnętrzną przez AND przed sterowaniem termostatem.",
|
||||||
|
"flow.template.smart_demand.title": "Inteligentne zapotrzebowanie",
|
||||||
|
"flow.template.smart_demand.description": "Złożona logika czasu, trybu domu i alternatywy temperatury zewnętrznej lub pokojowej.",
|
||||||
|
"flow.template.device_resilience.title": "Sterowanie tylko przy sprawnym urządzeniu",
|
||||||
|
"flow.template.device_resilience.description": "Uruchamia termostat tylko gdy urządzenie jest włączone, online i tryb domu pozwala na pracę.",
|
||||||
|
"flow.template.night_group.title": "Noc dla grupy",
|
||||||
|
"flow.template.night_group.description": "Nocny przedział czasu + tryb domu sterują presetem sleep dla grupy lub strefy.",
|
||||||
|
"flow.template.ha_window_guard.title": "Otwarte okno — wyłącz HVAC",
|
||||||
|
"flow.template.ha_window_guard.description": "Przykład integracji z binary_sensor Home Assistant: otwarte okno wyłącza termostat. Ustaw właściwy entity_id.",
|
||||||
|
"flow.node.groupState": "Stan grupy",
|
||||||
|
"flow.node.nightMode": "Tryb nocny",
|
||||||
|
"flow.nightModeActive": "aktywny zgodnie z ustawieniami aplikacji",
|
||||||
|
"flow.nightModeHint": "Warunek jest prawdziwy, gdy skonfigurowany w aplikacji Tryb nocny jest aktualnie aktywny. Użyj NOT, aby sprawdzić czas poza trybem nocnym.",
|
||||||
|
"flow.template.presence_eco.title": "Obecność: komfort / eco",
|
||||||
|
"flow.template.presence_eco.description": "Dwie jawne gałęzie Home Assistant: stan home włącza komfort, a not_home ustawia preset away. Ustaw właściwy entity_id osoby lub czujnika obecności.",
|
||||||
|
"flow.template.frost_guard.title": "Ochrona przeciwzamrożeniowa",
|
||||||
|
"flow.template.frost_guard.description": "W trybie grzania, przy niskiej temperaturze zewnętrznej i pokojowej, ustawia bezpieczny niski cel termostatu.",
|
||||||
|
"flow.template.nested_guard.title": "Złożony warunek z OR i NOT",
|
||||||
|
"flow.template.nested_guard.description": "Dni i godziny + temperatura zewnętrzna LUB pokojowa + NOT dla otwartego okna. Przykład wielopoziomowego grafu.",
|
||||||
|
"flow.template.night_quiet.title": "Nocny profil grupy",
|
||||||
|
"flow.template.night_quiet.description": "Korzysta bezpośrednio z istniejącego Trybu nocnego aplikacji i stanu grupy, aby zastosować preset sleep.",
|
||||||
|
"flow.node.haAvailable": "HA: dostępność",
|
||||||
|
"flow.available": "dostępna",
|
||||||
|
"flow.haAvailableHint": "Warunek jest prawdziwy tylko wtedy, gdy encja Home Assistant odpowiada stanem innym niż unknown/unavailable. Błąd połączenia jest traktowany bezpiecznie jako false. Przydatne jako bramka przed NOT i logiką bezpieczeństwa.",
|
||||||
|
"flow.deviceOptions": "Zaawansowane opcje GREE",
|
||||||
|
"flow.deviceOptionsHint": "Te pola używają istniejącego DeviceCommand. Fan/quiet/sleep oraz tryby dry/fan są blokowane dla urządzenia przypisanego do termostatu, gdy mogłyby walczyć z regulatorem; użyj wtedy akcji Termostat."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
scripts/regenerate-sha.sh
|
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
MANIFEST="FILE_MANIFEST.sha256"
|
||||||
|
TMP="${MANIFEST}.tmp"
|
||||||
|
|
||||||
|
find . -type f \
|
||||||
|
! -path "./${MANIFEST}" \
|
||||||
|
! -path "./${TMP}" \
|
||||||
|
! -path "./.git/*" \
|
||||||
|
! -path "./target/*" \
|
||||||
|
-print0 \
|
||||||
|
| sort -z \
|
||||||
|
| xargs -0 sha256sum > "$TMP"
|
||||||
|
|
||||||
|
mv "$TMP" "$MANIFEST"
|
||||||
|
|
||||||
|
sha256sum -c "$MANIFEST"
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
bb89bac237e750e9b1bf73761d7df97a6b81853091615878c03f13d7b6399aa7 ./README.md
|
||||||
|
5bc736c7bc76ca80aaa406bb171d2aa91baf4c3aa8695dce0e09b888b6ab3146 ./common.sh
|
||||||
|
6403786610ee6d2f628193c25aee0dd058d62e904aa1a31d5f62fdaae0e94b4f ./configure-gree-network.sh
|
||||||
|
14076104c042fba1284ebb07531a6c3ff972df1f9f5b18f70da18ab774efed27 ./generate_ha_migration.py
|
||||||
|
e4849261fd9ed1f01df96c0637c439c0c4eff8fa317b2918026167bba343af79 ./install-lxc.sh
|
||||||
|
bb7cd2c5b27c9dceec1d1d2846fbad9600df9c08e0ad07d13fcde97af533091c ./install.sh
|
||||||
|
e00d211e3885e30d7fed1e43b44e6fdad40a67019060156c0641816a93e3365f ./network-debug.sh
|
||||||
|
01952aa92b217f8eae2493b88870e2dec595100cd15c4d561ff11ae2b936c46f ./regenerate-sha.sh
|
||||||
|
81345b6a0b51736bdbc98fd23199b62e4c721b4e7437e02dab7ea79b97dff29a ./service.sh
|
||||||
|
d84315307bcda654ca5d23f86ae50723f6d26c0b8046711076d503983c05eaf6 ./smoke.sh
|
||||||
|
b50782b3742dfbf8a319c60571c968e93fdf8547db747c759edcffae68cb98bf ./update.sh
|
||||||
+103
@@ -0,0 +1,103 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -Eeuo pipefail
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
# shellcheck source=common.sh
|
||||||
|
source "$SCRIPT_DIR/common.sh"
|
||||||
|
cd "$PROJECT_ROOT"
|
||||||
|
|
||||||
|
MODE="debug"
|
||||||
|
ACTION="run"
|
||||||
|
INSTALL_DEPS=1
|
||||||
|
RESET_DB=0
|
||||||
|
HOST=""
|
||||||
|
PORT=""
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'TXT'
|
||||||
|
GREE Controller - development environment
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
./scripts/dev.sh install missing tools, build and run
|
||||||
|
./scripts/dev.sh --release run an optimized build
|
||||||
|
./scripts/dev.sh --check formatting, tests, build and API smoke test
|
||||||
|
./scripts/dev.sh --reset remove the local database before startup
|
||||||
|
./scripts/dev.sh --no-install do not install system packages or Rust
|
||||||
|
./scripts/dev.sh --host 0.0.0.0 --port 8787
|
||||||
|
TXT
|
||||||
|
}
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--release) MODE="release"; shift ;;
|
||||||
|
--check) ACTION="check"; shift ;;
|
||||||
|
--reset) RESET_DB=1; shift ;;
|
||||||
|
--no-install) INSTALL_DEPS=0; shift ;;
|
||||||
|
--host) HOST="${2:?missing value for --host}"; shift 2 ;;
|
||||||
|
--port) PORT="${2:?missing value for --port}"; shift 2 ;;
|
||||||
|
-h|--help) usage; exit 0 ;;
|
||||||
|
*) echo "Unknown argument: $1" >&2; usage; exit 2 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ "$INSTALL_DEPS" -eq 1 ]]; then
|
||||||
|
install_build_dependencies
|
||||||
|
ensure_rust
|
||||||
|
else
|
||||||
|
command -v cargo >/dev/null 2>&1 || fail "Cargo is not installed and --no-install was requested."
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
if [[ ! -f .env ]]; then
|
||||||
|
cp .env.example .env
|
||||||
|
say "Created .env from the example configuration"
|
||||||
|
fi
|
||||||
|
mkdir -p data
|
||||||
|
if [[ "$RESET_DB" -eq 1 ]]; then
|
||||||
|
rm -f data/gree-controller.db data/gree-controller.db-shm data/gree-controller.db-wal
|
||||||
|
say "Removed the local database"
|
||||||
|
fi
|
||||||
|
|
||||||
|
set -a
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
source ./.env
|
||||||
|
set +a
|
||||||
|
|
||||||
|
if [[ -n "$HOST" || -n "$PORT" ]]; then
|
||||||
|
current="${GREE_CONTROLLER_BIND:-0.0.0.0:8787}"
|
||||||
|
current_host="${current%:*}"
|
||||||
|
current_port="${current##*:}"
|
||||||
|
export GREE_CONTROLLER_BIND="${HOST:-$current_host}:${PORT:-$current_port}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$ACTION" == "check" ]]; then
|
||||||
|
say "Checking formatting"
|
||||||
|
cargo fmt --all -- --check
|
||||||
|
say "Running Rust tests"
|
||||||
|
cargo test --all-targets
|
||||||
|
say "Building the application"
|
||||||
|
cargo build
|
||||||
|
say "Running HTTP/API smoke test"
|
||||||
|
"$SCRIPT_DIR/smoke.sh"
|
||||||
|
say "All checks passed"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$MODE" == "release" ]]; then
|
||||||
|
say "Building release version"
|
||||||
|
cargo build --release
|
||||||
|
BINARY="$PROJECT_ROOT/target/release/gree-controller"
|
||||||
|
else
|
||||||
|
say "Building debug version"
|
||||||
|
cargo build
|
||||||
|
BINARY="$PROJECT_ROOT/target/debug/gree-controller"
|
||||||
|
fi
|
||||||
|
|
||||||
|
bind="${GREE_CONTROLLER_BIND:-0.0.0.0:8787}"
|
||||||
|
display_host="${bind%:*}"
|
||||||
|
[[ "$display_host" == "0.0.0.0" ]] && display_host="127.0.0.1"
|
||||||
|
say "Panel: http://${display_host}:${bind##*:}"
|
||||||
|
say "Stop: Ctrl+C"
|
||||||
|
exec "$BINARY"
|
||||||
|
Wspierane przez Gitea
|
||||||
|
Wersja: 1.27.2
|
||||||
|
Strona: 30ms Szablon: 4ms
|
||||||
@@ -54,6 +54,70 @@ grep -q '"control_temperature"' "$TMP/history.json"
|
|||||||
curl -fsS "http://127.0.0.1:$PORT/api/readings?device_id=sim-salon&hours=1" >"$TMP/readings.json"
|
curl -fsS "http://127.0.0.1:$PORT/api/readings?device_id=sim-salon&hours=1" >"$TMP/readings.json"
|
||||||
grep -q '"readings"' "$TMP/readings.json"
|
grep -q '"readings"' "$TMP/readings.json"
|
||||||
|
|
||||||
|
# Visual Flow: source graph, dry-run, export/import and optimistic concurrency.
|
||||||
|
cat >"$TMP/flow-create.json" <<EOF
|
||||||
|
{"name":"Smoke Flow","enabled":true,"description":"smoke","nodes":[{"id":"cond","kind":"constant","x":20,"y":20,"config":{"value":true}},{"id":"action","kind":"zone_thermostat","x":240,"y":20,"config":{"zone_id":"$ZONE_ID","preset":"comfort","mode":"auto","cooldown_seconds":60}}],"edges":[{"id":"edge","from":"cond","to":"action"}]}
|
||||||
|
EOF
|
||||||
|
curl -fsS -X POST -H 'Content-Type: application/json' --data-binary @"$TMP/flow-create.json" "http://127.0.0.1:$PORT/api/flows" >"$TMP/flow.json"
|
||||||
|
grep -q '"revision":1' "$TMP/flow.json"
|
||||||
|
FLOW_ID="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["id"])' "$TMP/flow.json")"
|
||||||
|
FLOW_REVISION="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["revision"])' "$TMP/flow.json")"
|
||||||
|
python3 - "$TMP/flow-create.json" "$TMP/flow-simulate.json" "$FLOW_ID" <<'PYFLOW'
|
||||||
|
import json,sys,datetime
|
||||||
|
flow=json.load(open(sys.argv[1]))
|
||||||
|
json.dump({"flow":flow,"flow_id":sys.argv[3],"at":datetime.datetime.now(datetime.timezone.utc).isoformat(),"overrides":{"cond":True},"log":True},open(sys.argv[2],"w"))
|
||||||
|
PYFLOW
|
||||||
|
curl -fsS -X POST -H 'Content-Type: application/json' --data-binary @"$TMP/flow-simulate.json" "http://127.0.0.1:$PORT/api/flows/simulate" >"$TMP/flow-dry-run.json"
|
||||||
|
grep -q '"dry_run":true' "$TMP/flow-dry-run.json"
|
||||||
|
curl -fsS "http://127.0.0.1:$PORT/api/flows/$FLOW_ID/export" >"$TMP/flow-export.json"
|
||||||
|
grep -q '"format":"gree-controller-flow"' "$TMP/flow-export.json"
|
||||||
|
curl -fsS -X POST -H 'Content-Type: application/json' --data-binary @"$TMP/flow-export.json" "http://127.0.0.1:$PORT/api/flows/import" >"$TMP/flow-import.json"
|
||||||
|
grep -q '"revision":1' "$TMP/flow-import.json"
|
||||||
|
python3 - "$TMP/flow-create.json" "$TMP/flow-update.json" "$FLOW_REVISION" <<'PYFLOW'
|
||||||
|
import json,sys
|
||||||
|
flow=json.load(open(sys.argv[1])); flow["expected_revision"]=int(sys.argv[3]); flow["description"]="updated"
|
||||||
|
json.dump(flow,open(sys.argv[2],"w"))
|
||||||
|
PYFLOW
|
||||||
|
curl -fsS -X PUT -H 'Content-Type: application/json' --data-binary @"$TMP/flow-update.json" "http://127.0.0.1:$PORT/api/flows/$FLOW_ID" >"$TMP/flow-updated.json"
|
||||||
|
grep -q '"revision":2' "$TMP/flow-updated.json"
|
||||||
|
STALE_STATUS="$(curl -sS -o /dev/null -w '%{http_code}' -X PUT -H 'Content-Type: application/json' --data-binary @"$TMP/flow-update.json" "http://127.0.0.1:$PORT/api/flows/$FLOW_ID")"
|
||||||
|
[[ "$STALE_STATUS" == "409" ]]
|
||||||
|
|
||||||
|
# A schedule-compatible Flow must keep using the native schedule engine.
|
||||||
|
cat >"$TMP/flow-schedule.json" <<EOF
|
||||||
|
{"name":"Native schedule Flow","enabled":true,"nodes":[{"id":"days","kind":"weekday","x":20,"y":20,"config":{"days":[1,2,3,4,5,6,7]}},{"id":"time","kind":"time_range","x":180,"y":20,"config":{"start":"06:00","end":"08:00"}},{"id":"action","kind":"zone_thermostat","x":360,"y":20,"config":{"zone_id":"$ZONE_ID","preset":"comfort","mode":"auto","cooldown_seconds":60}}],"edges":[{"id":"e1","from":"days","to":"time"},{"id":"e2","from":"time","to":"action"}]}
|
||||||
|
EOF
|
||||||
|
curl -fsS -X POST -H 'Content-Type: application/json' --data-binary @"$TMP/flow-schedule.json" \
|
||||||
|
"http://127.0.0.1:$PORT/api/flows" >"$TMP/flow-schedule-result.json"
|
||||||
|
python3 - "$TMP/flow-schedule-result.json" <<'PYFLOW'
|
||||||
|
import json,sys
|
||||||
|
flow=json.load(open(sys.argv[1]))
|
||||||
|
assert len(flow["compiled_schedule_ids"]) == 1, flow
|
||||||
|
assert len(flow["compiled_automation_ids"]) == 0, flow
|
||||||
|
PYFLOW
|
||||||
|
|
||||||
|
# HA errors must fail closed: even a != condition cannot become true just because HA is unavailable.
|
||||||
|
cat >"$TMP/flow-ha-safe.json" <<EOF
|
||||||
|
{"flow":{"name":"HA fail closed","enabled":true,"nodes":[{"id":"ha","kind":"ha_state","x":20,"y":20,"config":{"entity_id":"binary_sensor.missing","operator":"neq","value":"on"}},{"id":"action","kind":"zone_thermostat","x":260,"y":20,"config":{"zone_id":"$ZONE_ID","preset":"comfort","mode":"auto","cooldown_seconds":60}}],"edges":[{"id":"e1","from":"ha","to":"action"}]},"log":false}
|
||||||
|
EOF
|
||||||
|
curl -fsS -X POST -H 'Content-Type: application/json' --data-binary @"$TMP/flow-ha-safe.json" \
|
||||||
|
"http://127.0.0.1:$PORT/api/flows/simulate" >"$TMP/flow-ha-safe-result.json"
|
||||||
|
python3 - "$TMP/flow-ha-safe-result.json" <<'PYFLOW'
|
||||||
|
import json,sys
|
||||||
|
result=json.load(open(sys.argv[1]))
|
||||||
|
action=result["actions"][0]
|
||||||
|
assert action["matched"] is False, result
|
||||||
|
assert action["would_execute"] is False, result
|
||||||
|
PYFLOW
|
||||||
|
|
||||||
|
# Direct GREE Flow actions use the existing DeviceCommand surface beyond power/mode/target.
|
||||||
|
cat >"$TMP/flow-device-options.json" <<EOF
|
||||||
|
{"flow":{"name":"GREE options","enabled":true,"nodes":[{"id":"yes","kind":"constant","x":20,"y":20,"config":{"value":true}},{"id":"action","kind":"device_action","x":260,"y":20,"config":{"device_id":"sim-salon","light":false,"turbo":true,"cooldown_seconds":60}}],"edges":[{"id":"e1","from":"yes","to":"action"}]},"log":false}
|
||||||
|
EOF
|
||||||
|
curl -fsS -X POST -H 'Content-Type: application/json' --data-binary @"$TMP/flow-device-options.json" \
|
||||||
|
"http://127.0.0.1:$PORT/api/flows/simulate" >"$TMP/flow-device-options-result.json"
|
||||||
|
grep -q '"would_execute":true' "$TMP/flow-device-options-result.json"
|
||||||
|
|
||||||
# Home Assistant gets its own generated, restricted controller token.
|
# Home Assistant gets its own generated, restricted controller token.
|
||||||
curl -fsS -X POST -H 'Content-Type: application/json' \
|
curl -fsS -X POST -H 'Content-Type: application/json' \
|
||||||
-d '{"name":"Smoke Home Assistant"}' \
|
-d '{"name":"Smoke Home Assistant"}' \
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ const SPA_ROUTES: &[&str] = &[
|
|||||||
"/groups",
|
"/groups",
|
||||||
"/schedules",
|
"/schedules",
|
||||||
"/automations",
|
"/automations",
|
||||||
|
"/flows",
|
||||||
"/simulation",
|
"/simulation",
|
||||||
"/night-mode",
|
"/night-mode",
|
||||||
"/home-assistant",
|
"/home-assistant",
|
||||||
@@ -84,6 +85,12 @@ pub fn router(state: AppState) -> Router {
|
|||||||
.route("/api/schedules/:id", get(get_schedule).put(update_schedule).delete(delete_schedule))
|
.route("/api/schedules/:id", get(get_schedule).put(update_schedule).delete(delete_schedule))
|
||||||
.route("/api/automations", get(list_automations).post(create_automation))
|
.route("/api/automations", get(list_automations).post(create_automation))
|
||||||
.route("/api/automations/:id", get(get_automation).put(update_automation).delete(delete_automation))
|
.route("/api/automations/:id", get(get_automation).put(update_automation).delete(delete_automation))
|
||||||
|
.route("/api/flows", get(list_flows).post(create_flow))
|
||||||
|
.route("/api/flows/import", post(import_flow))
|
||||||
|
.route("/api/flows/simulate", post(simulate_flow))
|
||||||
|
.route("/api/flows/:id/export", get(export_flow))
|
||||||
|
.route("/api/flows/:id/logs", get(flow_logs))
|
||||||
|
.route("/api/flows/:id", get(get_flow).put(update_flow).delete(delete_flow))
|
||||||
.route("/api/readings", get(readings))
|
.route("/api/readings", get(readings))
|
||||||
.route("/api/history", get(history))
|
.route("/api/history", get(history))
|
||||||
.route("/api/control-plan", get(control_plan))
|
.route("/api/control-plan", get(control_plan))
|
||||||
@@ -125,6 +132,7 @@ pub fn router(state: AppState) -> Router {
|
|||||||
.route("/manifest.webmanifest", get(manifest))
|
.route("/manifest.webmanifest", get(manifest))
|
||||||
.route("/sw.js", get(service_worker))
|
.route("/sw.js", get(service_worker))
|
||||||
.route("/favicon.svg", get(favicon))
|
.route("/favicon.svg", get(favicon))
|
||||||
|
.route("/flows/:id", get(index))
|
||||||
.route("/lang/index.json", get(language_index))
|
.route("/lang/index.json", get(language_index))
|
||||||
.route("/lang/:file", get(language_file))
|
.route("/lang/:file", get(language_file))
|
||||||
.merge(protected)
|
.merge(protected)
|
||||||
@@ -167,6 +175,7 @@ include!("api/groups.rs");
|
|||||||
include!("api/house.rs");
|
include!("api/house.rs");
|
||||||
include!("api/schedules.rs");
|
include!("api/schedules.rs");
|
||||||
include!("api/automations.rs");
|
include!("api/automations.rs");
|
||||||
|
include!("api/flows.rs");
|
||||||
include!("api/history.rs");
|
include!("api/history.rs");
|
||||||
include!("api/events.rs");
|
include!("api/events.rs");
|
||||||
include!("api/settings.rs");
|
include!("api/settings.rs");
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ impl AutomationInput {
|
|||||||
action_group_id: self.action_group_id.map(|value| value.trim().to_string()).filter(|value| !value.is_empty()),
|
action_group_id: self.action_group_id.map(|value| value.trim().to_string()).filter(|value| !value.is_empty()),
|
||||||
action_preset: self.action_preset.map(|value| value.trim().to_string()).filter(|value| !value.is_empty()),
|
action_preset: self.action_preset.map(|value| value.trim().to_string()).filter(|value| !value.is_empty()),
|
||||||
action: self.action, cooldown_seconds: self.cooldown_seconds.max(30), last_fired_at,
|
action: self.action, cooldown_seconds: self.cooldown_seconds.max(30), last_fired_at,
|
||||||
|
action_zone_id: None, action_zone_preset: None, flow_conditions: vec![], flow_id: None, flow_node_id: None,
|
||||||
created_at, updated_at: Utc::now() }
|
created_at, updated_at: Utc::now() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -140,6 +141,7 @@ async fn update_automation(State(state): State<AppState>, Path(id): Path<String>
|
|||||||
let _automation_guard = state.lock_automation_operation().await;
|
let _automation_guard = state.lock_automation_operation().await;
|
||||||
input.validate()?;
|
input.validate()?;
|
||||||
let existing = state.db.get_automation(&id)?.ok_or_else(|| AppError::NotFound(format!("automation {id}")))?;
|
let existing = state.db.get_automation(&id)?.ok_or_else(|| AppError::NotFound(format!("automation {id}")))?;
|
||||||
|
if existing.flow_id.is_some() { return Err(AppError::BadRequest("this automation is generated by Flow; edit it in the Flow editor".into())); }
|
||||||
let action_group_id = input.action_group_id.as_deref().map(str::trim).filter(|value| !value.is_empty()).map(str::to_string);
|
let action_group_id = input.action_group_id.as_deref().map(str::trim).filter(|value| !value.is_empty()).map(str::to_string);
|
||||||
let _group_guard = if let Some(group_id) = action_group_id.as_deref() {
|
let _group_guard = if let Some(group_id) = action_group_id.as_deref() {
|
||||||
Some(state.lock_group_operation(group_id).await)
|
Some(state.lock_group_operation(group_id).await)
|
||||||
@@ -153,6 +155,8 @@ async fn update_automation(State(state): State<AppState>, Path(id): Path<String>
|
|||||||
async fn delete_automation(State(state): State<AppState>, Path(id): Path<String>) -> Result<StatusCode, AppError> {
|
async fn delete_automation(State(state): State<AppState>, Path(id): Path<String>) -> Result<StatusCode, AppError> {
|
||||||
let _configuration_guard = state.lock_configuration_operation().await;
|
let _configuration_guard = state.lock_configuration_operation().await;
|
||||||
let _automation_guard = state.lock_automation_operation().await;
|
let _automation_guard = state.lock_automation_operation().await;
|
||||||
|
let existing = state.db.get_automation(&id)?.ok_or_else(|| AppError::NotFound(format!("automation {id}")))?;
|
||||||
|
if existing.flow_id.is_some() { return Err(AppError::BadRequest("this automation is generated by Flow; delete it from the Flow editor".into())); }
|
||||||
if !state.db.delete_automation(&id)? { return Err(AppError::NotFound(format!("automation {id}"))); }
|
if !state.db.delete_automation(&id)? { return Err(AppError::NotFound(format!("automation {id}"))); }
|
||||||
state.broadcast("automation.deleted", json!({"id": id}));
|
state.broadcast("automation.deleted", json!({"id": id}));
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
|||||||
@@ -0,0 +1,561 @@
|
|||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct FlowInput {
|
||||||
|
name: String,
|
||||||
|
#[serde(default = "yes")]
|
||||||
|
enabled: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
description: String,
|
||||||
|
#[serde(default)]
|
||||||
|
nodes: Vec<crate::models::FlowNode>,
|
||||||
|
#[serde(default)]
|
||||||
|
edges: Vec<crate::models::FlowEdge>,
|
||||||
|
#[serde(default)]
|
||||||
|
expected_revision: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct FlowSimulationInput {
|
||||||
|
flow: FlowInput,
|
||||||
|
#[serde(default)]
|
||||||
|
flow_id: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
at: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
overrides: std::collections::HashMap<String, Value>,
|
||||||
|
#[serde(default = "yes")]
|
||||||
|
log: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct FlowLogsQuery { limit: Option<u32> }
|
||||||
|
|
||||||
|
fn flow_string(config: &Value, key: &str) -> Option<String> {
|
||||||
|
config.get(key).and_then(Value::as_str).map(str::trim).filter(|v| !v.is_empty()).map(str::to_string)
|
||||||
|
}
|
||||||
|
fn flow_f64(config: &Value, key: &str) -> Option<f64> { config.get(key).and_then(Value::as_f64) }
|
||||||
|
fn flow_u8(config: &Value, key: &str) -> Option<u8> { config.get(key).and_then(Value::as_u64).and_then(|value| u8::try_from(value).ok()) }
|
||||||
|
fn flow_bool(config: &Value, key: &str) -> Option<bool> { config.get(key).and_then(Value::as_bool) }
|
||||||
|
|
||||||
|
fn flow_condition_kind(kind: &str) -> bool {
|
||||||
|
matches!(kind,
|
||||||
|
"weekday" | "time_range" | "date_range" | "outdoor_temperature" | "device_temperature" |
|
||||||
|
"zone_temperature" | "ha_state" | "ha_numeric" | "ha_attribute" | "ha_available" | "house_mode" |
|
||||||
|
"device_state" | "zone_state" | "group_state" | "night_mode" | "constant"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
fn flow_logic_kind(kind: &str) -> bool { matches!(kind, "logic_and" | "logic_or" | "logic_not") }
|
||||||
|
fn flow_action_kind(kind: &str) -> bool { matches!(kind, "zone_thermostat" | "device_action" | "group_action") }
|
||||||
|
|
||||||
|
fn validate_flow_graph(input: &FlowInput) -> Result<(), AppError> {
|
||||||
|
if input.name.trim().is_empty() { return Err(AppError::BadRequest("flow name is required".into())); }
|
||||||
|
if input.nodes.is_empty() { return Err(AppError::BadRequest("flow needs at least one block".into())); }
|
||||||
|
let mut ids = std::collections::HashSet::<String>::new();
|
||||||
|
for node in &input.nodes {
|
||||||
|
if node.id.trim().is_empty() || !ids.insert(node.id.clone()) { return Err(AppError::BadRequest("flow contains duplicate or empty block IDs".into())); }
|
||||||
|
if !(flow_condition_kind(&node.kind) || flow_action_kind(&node.kind) || flow_logic_kind(&node.kind)) {
|
||||||
|
return Err(AppError::BadRequest(format!("unsupported flow block: {}", node.kind)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !input.nodes.iter().any(|node| flow_action_kind(&node.kind)) { return Err(AppError::BadRequest("flow needs at least one action block".into())); }
|
||||||
|
let mut edge_ids = std::collections::HashSet::<String>::new();
|
||||||
|
let mut connections = std::collections::HashSet::<(String, String)>::new();
|
||||||
|
for edge in &input.edges {
|
||||||
|
if edge.id.trim().is_empty() || !edge_ids.insert(edge.id.clone()) || !connections.insert((edge.from.clone(), edge.to.clone())) {
|
||||||
|
return Err(AppError::BadRequest("flow contains duplicate or empty connection IDs".into()));
|
||||||
|
}
|
||||||
|
if edge.from == edge.to || !ids.contains(edge.from.as_str()) || !ids.contains(edge.to.as_str()) {
|
||||||
|
return Err(AppError::BadRequest("flow contains an invalid connection".into()));
|
||||||
|
}
|
||||||
|
let from = input.nodes.iter().find(|node| node.id == edge.from).expect("validated Flow source");
|
||||||
|
let to = input.nodes.iter().find(|node| node.id == edge.to).expect("validated Flow target");
|
||||||
|
if flow_action_kind(&from.kind) {
|
||||||
|
return Err(AppError::BadRequest("Flow action blocks must be terminal and cannot feed another block".into()));
|
||||||
|
}
|
||||||
|
if !(flow_condition_kind(&to.kind) || flow_logic_kind(&to.kind) || flow_action_kind(&to.kind)) {
|
||||||
|
return Err(AppError::BadRequest("Flow connection has an unsupported target".into()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Reject cycles. Flow is deliberately a DAG: finite evaluation, deterministic topological order,
|
||||||
|
// and no hidden state machine semantics unless a dedicated stateful block is introduced later.
|
||||||
|
let mut outgoing = std::collections::HashMap::<String, Vec<String>>::new();
|
||||||
|
for edge in &input.edges { outgoing.entry(edge.from.clone()).or_default().push(edge.to.clone()); }
|
||||||
|
fn visit(id: &str, outgoing: &std::collections::HashMap<String, Vec<String>>, temp: &mut std::collections::HashSet<String>, done: &mut std::collections::HashSet<String>) -> bool {
|
||||||
|
if done.contains(id) { return false; }
|
||||||
|
if !temp.insert(id.to_string()) { return true; }
|
||||||
|
if outgoing.get(id).into_iter().flatten().any(|next| visit(next, outgoing, temp, done)) { return true; }
|
||||||
|
temp.remove(id); done.insert(id.to_string()); false
|
||||||
|
}
|
||||||
|
let mut temp = std::collections::HashSet::new(); let mut done = std::collections::HashSet::new();
|
||||||
|
for id in &ids { if visit(id, &outgoing, &mut temp, &mut done) { return Err(AppError::BadRequest("flow connections cannot contain a cycle".into())); } }
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compile_flow_program(action_id: &str, nodes: &[crate::models::FlowNode], edges: &[crate::models::FlowEdge]) -> Result<Vec<crate::models::FlowCondition>, AppError> {
|
||||||
|
let by_id: std::collections::HashMap<String, crate::models::FlowNode> = nodes.iter().cloned().map(|node| (node.id.clone(), node)).collect();
|
||||||
|
let mut incoming = std::collections::HashMap::<String, Vec<String>>::new();
|
||||||
|
for edge in edges { incoming.entry(edge.to.clone()).or_default().push(edge.from.clone()); }
|
||||||
|
let action_inputs = incoming.get(action_id).cloned().unwrap_or_default();
|
||||||
|
if action_inputs.is_empty() { return Err(AppError::BadRequest(format!("action '{action_id}' needs at least one connected condition"))); }
|
||||||
|
|
||||||
|
fn visit(
|
||||||
|
id: &str,
|
||||||
|
by_id: &std::collections::HashMap<String, crate::models::FlowNode>,
|
||||||
|
incoming: &std::collections::HashMap<String, Vec<String>>,
|
||||||
|
seen: &mut std::collections::HashSet<String>,
|
||||||
|
out: &mut Vec<crate::models::FlowCondition>,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
if !seen.insert(id.to_string()) { return Ok(()); }
|
||||||
|
let node = by_id.get(id).ok_or_else(|| AppError::BadRequest("Flow references a missing block".into()))?;
|
||||||
|
if !(flow_condition_kind(&node.kind) || flow_logic_kind(&node.kind)) {
|
||||||
|
return Err(AppError::BadRequest("only condition or logic blocks can feed a Flow action".into()));
|
||||||
|
}
|
||||||
|
let inputs = incoming.get(id).cloned().unwrap_or_default();
|
||||||
|
for input in &inputs { visit(input, by_id, incoming, seen, out)?; }
|
||||||
|
match node.kind.as_str() {
|
||||||
|
"logic_not" if inputs.len() != 1 => return Err(AppError::BadRequest("NOT block needs exactly one input".into())),
|
||||||
|
"logic_and" | "logic_or" if inputs.is_empty() => return Err(AppError::BadRequest(format!("{} block needs at least one input", node.kind))),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
out.push(crate::models::FlowCondition { id: node.id.clone(), kind: node.kind.clone(), config: node.config.clone(), inputs });
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let mut seen = std::collections::HashSet::new();
|
||||||
|
for input in &action_inputs { visit(input, &by_id, &incoming, &mut seen, &mut out)?; }
|
||||||
|
out.push(crate::models::FlowCondition {
|
||||||
|
id: format!("__flow_action__:{action_id}"),
|
||||||
|
kind: "logic_and".into(),
|
||||||
|
config: json!({}),
|
||||||
|
inputs: action_inputs,
|
||||||
|
});
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_text_comparison(config: &Value) -> Result<(), AppError> {
|
||||||
|
let op = flow_string(config, "operator").unwrap_or_else(|| "eq".into());
|
||||||
|
if !matches!(op.as_str(), "eq" | "neq") { return Err(AppError::BadRequest("state operator must be eq or neq".into())); }
|
||||||
|
if config.get("value").is_none() { return Err(AppError::BadRequest("state block needs a value".into())); }
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_condition(condition: &crate::models::FlowCondition, state: &AppState) -> Result<(), AppError> {
|
||||||
|
match condition.kind.as_str() {
|
||||||
|
"weekday" => {
|
||||||
|
let days = condition.config.get("days").and_then(Value::as_array).ok_or_else(|| AppError::BadRequest("weekday block needs days".into()))?;
|
||||||
|
if days.is_empty() || days.iter().any(|v| v.as_u64().map(|d| !(1..=7).contains(&d)).unwrap_or(true)) { return Err(AppError::BadRequest("weekday block contains invalid days".into())); }
|
||||||
|
}
|
||||||
|
"time_range" => {
|
||||||
|
for key in ["start", "end"] { let value = flow_string(&condition.config, key).ok_or_else(|| AppError::BadRequest(format!("time range needs {key}")))?; chrono::NaiveTime::parse_from_str(&value, "%H:%M").map_err(|_| AppError::BadRequest("invalid time range".into()))?; }
|
||||||
|
}
|
||||||
|
"date_range" => {
|
||||||
|
let start = flow_string(&condition.config, "start").ok_or_else(|| AppError::BadRequest("date range needs start".into()))?;
|
||||||
|
let end = flow_string(&condition.config, "end").ok_or_else(|| AppError::BadRequest("date range needs end".into()))?;
|
||||||
|
let start = chrono::NaiveDate::parse_from_str(&start, "%Y-%m-%d").map_err(|_| AppError::BadRequest("invalid date range".into()))?;
|
||||||
|
let end = chrono::NaiveDate::parse_from_str(&end, "%Y-%m-%d").map_err(|_| AppError::BadRequest("invalid date range".into()))?;
|
||||||
|
if start > end { return Err(AppError::BadRequest("date range start must not be after end".into())); }
|
||||||
|
}
|
||||||
|
"outdoor_temperature" => { validate_flow_comparison(&condition.config)?; }
|
||||||
|
"device_temperature" => {
|
||||||
|
validate_flow_comparison(&condition.config)?;
|
||||||
|
let id = flow_string(&condition.config, "device_id").ok_or_else(|| AppError::BadRequest("device temperature block needs device".into()))?;
|
||||||
|
if state.db.get_device(&id)?.is_none() { return Err(AppError::BadRequest("flow references a missing device".into())); }
|
||||||
|
}
|
||||||
|
"zone_temperature" => {
|
||||||
|
validate_flow_comparison(&condition.config)?;
|
||||||
|
let id = flow_string(&condition.config, "zone_id").ok_or_else(|| AppError::BadRequest("zone temperature block needs zone".into()))?;
|
||||||
|
if state.db.get_zone(&id)?.is_none() { return Err(AppError::BadRequest("flow references a missing zone".into())); }
|
||||||
|
}
|
||||||
|
"ha_state" => {
|
||||||
|
if flow_string(&condition.config, "entity_id").is_none() { return Err(AppError::BadRequest("Home Assistant state block needs entity_id".into())); }
|
||||||
|
validate_text_comparison(&condition.config)?;
|
||||||
|
}
|
||||||
|
"ha_numeric" => {
|
||||||
|
if flow_string(&condition.config, "entity_id").is_none() { return Err(AppError::BadRequest("Home Assistant numeric block needs entity_id".into())); }
|
||||||
|
validate_flow_comparison(&condition.config)?;
|
||||||
|
}
|
||||||
|
"ha_attribute" => {
|
||||||
|
if flow_string(&condition.config, "entity_id").is_none() || flow_string(&condition.config, "attribute").is_none() {
|
||||||
|
return Err(AppError::BadRequest("Home Assistant attribute block needs entity_id and attribute".into()));
|
||||||
|
}
|
||||||
|
let op = flow_string(&condition.config, "operator").unwrap_or_else(|| "eq".into());
|
||||||
|
if !matches!(op.as_str(), "lt" | "lte" | "gt" | "gte" | "eq" | "neq") { return Err(AppError::BadRequest("unsupported Home Assistant attribute operator".into())); }
|
||||||
|
if condition.config.get("value").is_none() { return Err(AppError::BadRequest("Home Assistant attribute block needs a value".into())); }
|
||||||
|
}
|
||||||
|
"ha_available" => {
|
||||||
|
if flow_string(&condition.config, "entity_id").is_none() { return Err(AppError::BadRequest("Home Assistant availability block needs entity_id".into())); }
|
||||||
|
}
|
||||||
|
"house_mode" => {
|
||||||
|
let value = flow_string(&condition.config, "value").ok_or_else(|| AppError::BadRequest("house mode block needs a mode".into()))?;
|
||||||
|
if !matches!(value.as_str(), "cool" | "heat" | "off") { return Err(AppError::BadRequest("house mode must be cool, heat or off".into())); }
|
||||||
|
validate_text_comparison(&condition.config)?;
|
||||||
|
}
|
||||||
|
"device_state" => {
|
||||||
|
let id = flow_string(&condition.config, "device_id").ok_or_else(|| AppError::BadRequest("device state block needs device".into()))?;
|
||||||
|
if state.db.get_device(&id)?.is_none() { return Err(AppError::BadRequest("flow references a missing device".into())); }
|
||||||
|
let field = flow_string(&condition.config, "field").ok_or_else(|| AppError::BadRequest("device state block needs a field".into()))?;
|
||||||
|
if !matches!(field.as_str(), "enabled" | "online" | "power" | "mode" | "fan_speed" | "swing_vertical" | "swing_horizontal" | "quiet" | "turbo" | "light" | "air" | "xfan" | "health" | "sleep") { return Err(AppError::BadRequest("unsupported device state field".into())); }
|
||||||
|
validate_text_comparison(&condition.config)?;
|
||||||
|
}
|
||||||
|
"zone_state" => {
|
||||||
|
let id = flow_string(&condition.config, "zone_id").ok_or_else(|| AppError::BadRequest("zone state block needs zone".into()))?;
|
||||||
|
if state.db.get_zone(&id)?.is_none() { return Err(AppError::BadRequest("flow references a missing zone".into())); }
|
||||||
|
let field = flow_string(&condition.config, "field").ok_or_else(|| AppError::BadRequest("zone state block needs a field".into()))?;
|
||||||
|
if !matches!(field.as_str(), "enabled" | "mode" | "active_preset" | "demand" | "control_owner" | "device_manual_override" | "local_thermostat_power") { return Err(AppError::BadRequest("unsupported zone state field".into())); }
|
||||||
|
validate_text_comparison(&condition.config)?;
|
||||||
|
}
|
||||||
|
"group_state" => {
|
||||||
|
let id = flow_string(&condition.config, "group_id").ok_or_else(|| AppError::BadRequest("group state block needs group".into()))?;
|
||||||
|
if state.db.get_group(&id)?.is_none() { return Err(AppError::BadRequest("flow references a missing group".into())); }
|
||||||
|
let field = flow_string(&condition.config, "field").ok_or_else(|| AppError::BadRequest("group state block needs a field".into()))?;
|
||||||
|
if field != "power_enabled" { return Err(AppError::BadRequest("unsupported group state field".into())); }
|
||||||
|
validate_text_comparison(&condition.config)?;
|
||||||
|
}
|
||||||
|
"night_mode" => {}
|
||||||
|
"constant" => {
|
||||||
|
if condition.config.get("value").and_then(Value::as_bool).is_none() { return Err(AppError::BadRequest("constant block needs a boolean value".into())); }
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
fn validate_flow_comparison(config: &Value) -> Result<(), AppError> {
|
||||||
|
let op = flow_string(config, "operator").unwrap_or_else(|| "lt".into());
|
||||||
|
if !matches!(op.as_str(), "lt" | "lte" | "gt" | "gte" | "eq" | "neq") { return Err(AppError::BadRequest("unsupported comparison operator".into())); }
|
||||||
|
if flow_f64(config, "value").is_none() { return Err(AppError::BadRequest("comparison block needs a numeric value".into())); }
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compile_flow(state: &AppState, mut flow: crate::models::Flow) -> Result<(crate::models::Flow, Vec<Schedule>, Vec<Automation>), AppError> {
|
||||||
|
let mut schedules = Vec::new();
|
||||||
|
let mut automations = Vec::new();
|
||||||
|
let now = Utc::now();
|
||||||
|
let zones = state.db.list_zones()?;
|
||||||
|
let groups = state.db.list_groups()?;
|
||||||
|
let devices = state.db.list_devices()?;
|
||||||
|
let previous_schedules: std::collections::HashMap<String, Schedule> = state.db.list_schedules()?.into_iter()
|
||||||
|
.filter(|item| item.flow_id.as_deref() == Some(flow.id.as_str())).map(|item| (item.id.clone(), item)).collect();
|
||||||
|
let previous_automations: std::collections::HashMap<String, Automation> = state.db.list_automations()?.into_iter()
|
||||||
|
.filter(|item| item.flow_id.as_deref() == Some(flow.id.as_str())).map(|item| (item.id.clone(), item)).collect();
|
||||||
|
let actions: Vec<_> = flow.nodes.iter().filter(|node| flow_action_kind(&node.kind)).cloned().collect();
|
||||||
|
for action_node in actions {
|
||||||
|
let conditions = compile_flow_program(&action_node.id, &flow.nodes, &flow.edges)?;
|
||||||
|
for condition in conditions.iter().filter(|condition| flow_condition_kind(&condition.kind)) { validate_condition(condition, state)?; }
|
||||||
|
let schedule_leaves: Vec<_> = conditions.iter().filter(|condition| flow_condition_kind(&condition.kind)).collect();
|
||||||
|
let preset_for_schedule = flow_string(&action_node.config, "preset").unwrap_or_else(|| "comfort".into());
|
||||||
|
let mode_for_schedule = flow_string(&action_node.config, "mode").unwrap_or_else(|| "auto".into());
|
||||||
|
let native_time_range = schedule_leaves.iter().find(|condition| condition.kind == "time_range")
|
||||||
|
.and_then(|condition| flow_string(&condition.config, "start").zip(flow_string(&condition.config, "end")))
|
||||||
|
.and_then(|(start, end)| chrono::NaiveTime::parse_from_str(&start, "%H:%M").ok().zip(chrono::NaiveTime::parse_from_str(&end, "%H:%M").ok()))
|
||||||
|
.map(|(start, end)| start < end)
|
||||||
|
.unwrap_or(false);
|
||||||
|
let schedule_only = action_node.kind == "zone_thermostat"
|
||||||
|
&& !conditions.iter().any(|condition| matches!(condition.kind.as_str(), "logic_or" | "logic_not"))
|
||||||
|
&& schedule_leaves.iter().all(|condition| matches!(condition.kind.as_str(), "weekday" | "time_range"))
|
||||||
|
&& schedule_leaves.iter().filter(|condition| condition.kind == "weekday").count() == 1
|
||||||
|
&& schedule_leaves.iter().filter(|condition| condition.kind == "time_range").count() == 1
|
||||||
|
&& native_time_range
|
||||||
|
&& preset_for_schedule != "auto"
|
||||||
|
&& mode_for_schedule == "auto"
|
||||||
|
&& flow_bool(&action_node.config, "power").is_none();
|
||||||
|
if schedule_only {
|
||||||
|
let zone_id = flow_string(&action_node.config, "zone_id").ok_or_else(|| AppError::BadRequest("thermostat block needs a zone".into()))?;
|
||||||
|
let zone = zones.iter().find(|z| z.id == zone_id).ok_or_else(|| AppError::BadRequest("thermostat block references a missing zone".into()))?;
|
||||||
|
let preset = flow_string(&action_node.config, "preset").unwrap_or_else(|| "comfort".into());
|
||||||
|
if !matches!(preset.as_str(), "comfort" | "sleep" | "away" | "custom") { return Err(AppError::BadRequest("unsupported thermostat preset".into())); }
|
||||||
|
let setpoint = flow_f64(&action_node.config, "setpoint").unwrap_or(zone.setpoint);
|
||||||
|
if preset == "custom" && !(8.0..=30.0).contains(&setpoint) { return Err(AppError::BadRequest("custom thermostat target must be between 8 and 30 C".into())); }
|
||||||
|
let weekday = schedule_leaves.iter().find(|c| c.kind == "weekday").copied().unwrap();
|
||||||
|
let time = schedule_leaves.iter().find(|c| c.kind == "time_range").copied().unwrap();
|
||||||
|
let weekdays = weekday.config.get("days").and_then(Value::as_array).unwrap().iter().filter_map(Value::as_u64).map(|v| v as u32).collect();
|
||||||
|
let id = format!("flow:{}:schedule:{}", flow.id, action_node.id);
|
||||||
|
let created_at = previous_schedules.get(&id).map(|item| item.created_at.clone()).unwrap_or_else(|| now.clone());
|
||||||
|
let schedule = Schedule {
|
||||||
|
id, zone_id, name: format!("{} · {}", flow.name, zone.name), enabled: flow.enabled,
|
||||||
|
weekdays, start_time: flow_string(&time.config, "start").unwrap(), end_time: flow_string(&time.config, "end").unwrap(), preset, setpoint,
|
||||||
|
created_at, updated_at: now.clone(), flow_id: Some(flow.id.clone()), flow_node_id: Some(action_node.id.clone()),
|
||||||
|
};
|
||||||
|
schedules.push(schedule);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
drop(schedule_leaves);
|
||||||
|
let id = format!("flow:{}:automation:{}", flow.id, action_node.id);
|
||||||
|
let created_at = previous_automations.get(&id).map(|item| item.created_at.clone()).unwrap_or_else(|| now.clone());
|
||||||
|
let last_fired_at = previous_automations.get(&id).and_then(|item| item.last_fired_at.clone());
|
||||||
|
let mut item = Automation {
|
||||||
|
id, name: format!("{} · {}", flow.name, action_node.id), enabled: flow.enabled,
|
||||||
|
trigger_kind: "flow".into(), trigger_device_id: None, threshold: None, at_time: None,
|
||||||
|
action_device_id: String::new(), action_group_id: None, action_preset: None, action: DeviceCommand::default(), cooldown_seconds: 60,
|
||||||
|
last_fired_at, action_zone_id: None, action_zone_preset: None, flow_conditions: conditions,
|
||||||
|
flow_id: Some(flow.id.clone()), flow_node_id: Some(action_node.id.clone()), created_at, updated_at: now.clone(),
|
||||||
|
};
|
||||||
|
if let Some(cooldown) = action_node.config.get("cooldown_seconds").and_then(Value::as_u64) { item.cooldown_seconds = cooldown.max(30); }
|
||||||
|
match action_node.kind.as_str() {
|
||||||
|
"zone_thermostat" => {
|
||||||
|
let zone_id = flow_string(&action_node.config, "zone_id").ok_or_else(|| AppError::BadRequest("thermostat block needs a zone".into()))?;
|
||||||
|
if !zones.iter().any(|z| z.id == zone_id) { return Err(AppError::BadRequest("thermostat block references a missing zone".into())); }
|
||||||
|
item.action_zone_id = Some(zone_id);
|
||||||
|
let preset = flow_string(&action_node.config, "preset").unwrap_or_else(|| "comfort".into());
|
||||||
|
if !matches!(preset.as_str(), "comfort" | "sleep" | "away" | "custom" | "auto") { return Err(AppError::BadRequest("unsupported thermostat preset".into())); }
|
||||||
|
item.action_zone_preset = Some(preset.clone());
|
||||||
|
if preset == "custom" { let target = flow_f64(&action_node.config, "setpoint").ok_or_else(|| AppError::BadRequest("custom thermostat block needs a target".into()))?; if !(8.0..=30.0).contains(&target) { return Err(AppError::BadRequest("custom thermostat target must be between 8 and 30 C".into())); } item.action.target_temperature = Some(target); }
|
||||||
|
if let Some(power) = flow_bool(&action_node.config, "power") { item.action.power = Some(power); }
|
||||||
|
if let Some(mode) = flow_string(&action_node.config, "mode") { if !matches!(mode.as_str(), "auto" | "heat" | "cool") { return Err(AppError::BadRequest("unsupported thermostat mode".into())); } item.action.mode = Some(mode); }
|
||||||
|
}
|
||||||
|
"device_action" => {
|
||||||
|
let id = flow_string(&action_node.config, "device_id").ok_or_else(|| AppError::BadRequest("device action needs a device".into()))?;
|
||||||
|
if !devices.iter().any(|d| d.id == id) { return Err(AppError::BadRequest("device action references a missing device".into())); }
|
||||||
|
item.action_device_id = id;
|
||||||
|
item.action.power = flow_bool(&action_node.config, "power");
|
||||||
|
item.action.mode = flow_string(&action_node.config, "mode");
|
||||||
|
item.action.target_temperature = flow_f64(&action_node.config, "target_temperature");
|
||||||
|
item.action.fan_speed = flow_u8(&action_node.config, "fan_speed");
|
||||||
|
item.action.swing_vertical = flow_bool(&action_node.config, "swing_vertical");
|
||||||
|
item.action.swing_horizontal = flow_bool(&action_node.config, "swing_horizontal");
|
||||||
|
item.action.quiet = flow_bool(&action_node.config, "quiet");
|
||||||
|
item.action.turbo = flow_bool(&action_node.config, "turbo");
|
||||||
|
item.action.light = flow_bool(&action_node.config, "light");
|
||||||
|
item.action.air = flow_bool(&action_node.config, "air");
|
||||||
|
item.action.xfan = flow_bool(&action_node.config, "xfan");
|
||||||
|
item.action.health = flow_bool(&action_node.config, "health");
|
||||||
|
item.action.sleep = flow_bool(&action_node.config, "sleep");
|
||||||
|
engine::validate_command(&item.action)?; if item.action.is_empty() { return Err(AppError::BadRequest("device action cannot be empty".into())); }
|
||||||
|
}
|
||||||
|
"group_action" => {
|
||||||
|
let id = flow_string(&action_node.config, "group_id").ok_or_else(|| AppError::BadRequest("group action needs a group".into()))?;
|
||||||
|
if !groups.iter().any(|g| g.id == id) { return Err(AppError::BadRequest("group action references a missing group".into())); }
|
||||||
|
item.action_group_id = Some(id); item.action.power = flow_bool(&action_node.config, "power"); item.action.mode = flow_string(&action_node.config, "mode"); item.action_preset = flow_string(&action_node.config, "preset");
|
||||||
|
if let Some(mode) = item.action.mode.as_deref() {
|
||||||
|
if !matches!(mode, "auto" | "house" | "cool" | "heat") { return Err(AppError::BadRequest("unsupported Flow group mode".into())); }
|
||||||
|
}
|
||||||
|
if let Some(preset) = item.action_preset.as_deref() {
|
||||||
|
if !matches!(preset, "auto" | "comfort" | "sleep" | "away" | "custom") { return Err(AppError::BadRequest("unsupported Flow group preset".into())); }
|
||||||
|
if preset == "custom" {
|
||||||
|
let target = flow_f64(&action_node.config, "setpoint").ok_or_else(|| AppError::BadRequest("custom Flow group preset needs a target".into()))?;
|
||||||
|
if !(8.0..=30.0).contains(&target) { return Err(AppError::BadRequest("custom Flow group target must be between 8 and 30 C".into())); }
|
||||||
|
item.action.target_temperature = Some(target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if item.action.power.is_none() && item.action.mode.is_none() && item.action_preset.is_none() { return Err(AppError::BadRequest("group action cannot be empty".into())); }
|
||||||
|
}
|
||||||
|
_ => unreachable!(),
|
||||||
|
}
|
||||||
|
automations.push(item);
|
||||||
|
}
|
||||||
|
let existing: Vec<_> = state.db.list_schedules()?.into_iter().filter(|s| s.flow_id.as_deref() != Some(flow.id.as_str())).collect();
|
||||||
|
for candidate in &schedules {
|
||||||
|
for other in existing.iter().chain(schedules.iter().filter(|s| s.id != candidate.id)) {
|
||||||
|
if engine::schedules_overlap(candidate, other) { return Err(AppError::BadRequest(format!("flow schedule '{}' overlaps with '{}'", candidate.name, other.name))); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flow.compiled_schedule_ids = schedules.iter().map(|s| s.id.clone()).collect();
|
||||||
|
flow.compiled_automation_ids = automations.iter().map(|a| a.id.clone()).collect();
|
||||||
|
flow.summary = format!("{} bloków · {} harmonogramów · {} automatyzacji", flow.nodes.len(), schedules.len(), automations.len());
|
||||||
|
Ok((flow, schedules, automations))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn flow_from_input(id: String, input: FlowInput, created_at: chrono::DateTime<Utc>, revision: u64) -> crate::models::Flow {
|
||||||
|
crate::models::Flow {
|
||||||
|
id, name: input.name.trim().into(), enabled: input.enabled, description: input.description.trim().into(), nodes: input.nodes, edges: input.edges,
|
||||||
|
summary: String::new(), compiled_schedule_ids: vec![], compiled_automation_ids: vec![], revision, created_at, updated_at: Utc::now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_flows(State(state): State<AppState>) -> Result<Json<Vec<crate::models::Flow>>, AppError> { Ok(Json(state.db.list_flows()?)) }
|
||||||
|
async fn get_flow(State(state): State<AppState>, Path(id): Path<String>) -> Result<Json<crate::models::Flow>, AppError> { state.db.get_flow(&id)?.map(Json).ok_or_else(|| AppError::NotFound(format!("flow {id}"))) }
|
||||||
|
|
||||||
|
async fn create_flow(State(state): State<AppState>, Json(input): Json<FlowInput>) -> Result<(StatusCode, Json<crate::models::Flow>), AppError> {
|
||||||
|
validate_flow_graph(&input)?;
|
||||||
|
let _configuration_guard = state.lock_configuration_operation().await;
|
||||||
|
let _automation_guard = state.lock_automation_operation().await;
|
||||||
|
let _schedule_guard = state.lock_schedule_operation().await;
|
||||||
|
let _cycle_guard = state.lock_zone_control_cycle().await;
|
||||||
|
let flow = flow_from_input(Uuid::new_v4().to_string(), input, Utc::now(), 1);
|
||||||
|
let (flow, schedules, automations) = compile_flow(&state, flow)?;
|
||||||
|
state.db.replace_flow_outputs(&flow, &schedules, &automations)?;
|
||||||
|
for zone_id in schedules.iter().map(|s| s.zone_id.as_str()).collect::<std::collections::HashSet<_>>() { refresh_zone_override_boundary(&state, zone_id).await?; }
|
||||||
|
state.log("info", "flow.created", &format!("Created Flow {}", flow.name), json!({"flow_id": flow.id, "revision": flow.revision, "schedules": schedules.len(), "automations": automations.len()}));
|
||||||
|
state.broadcast("flow.created", serde_json::to_value(&flow)?); state.wake_zone_control();
|
||||||
|
Ok((StatusCode::CREATED, Json(flow)))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update_flow(State(state): State<AppState>, Path(id): Path<String>, Json(input): Json<FlowInput>) -> Result<Json<crate::models::Flow>, AppError> {
|
||||||
|
validate_flow_graph(&input)?;
|
||||||
|
let _configuration_guard = state.lock_configuration_operation().await;
|
||||||
|
let _automation_guard = state.lock_automation_operation().await;
|
||||||
|
let _schedule_guard = state.lock_schedule_operation().await;
|
||||||
|
let _cycle_guard = state.lock_zone_control_cycle().await;
|
||||||
|
let existing = state.db.get_flow(&id)?.ok_or_else(|| AppError::NotFound(format!("flow {id}")))?;
|
||||||
|
let expected = input.expected_revision.ok_or_else(|| AppError::BadRequest("Flow update requires expected_revision".into()))?;
|
||||||
|
if expected != existing.revision { return Err(AppError::Conflict(format!("flow {id} changed; expected revision {expected}, current revision {}", existing.revision))); }
|
||||||
|
let old_zone_ids: std::collections::HashSet<String> = state.db.list_schedules()?.into_iter().filter(|s| s.flow_id.as_deref() == Some(id.as_str())).map(|s| s.zone_id).collect();
|
||||||
|
let next_revision = existing.revision.saturating_add(1).max(1);
|
||||||
|
let flow = flow_from_input(id, input, existing.created_at, next_revision);
|
||||||
|
let (flow, schedules, automations) = compile_flow(&state, flow)?;
|
||||||
|
state.db.replace_flow_outputs(&flow, &schedules, &automations)?;
|
||||||
|
let mut zone_ids = old_zone_ids; zone_ids.extend(schedules.iter().map(|s| s.zone_id.clone()));
|
||||||
|
for zone_id in zone_ids { refresh_zone_override_boundary(&state, &zone_id).await?; }
|
||||||
|
state.log("info", "flow.updated", &format!("Updated Flow {}", flow.name), json!({"flow_id": flow.id, "revision": flow.revision, "schedules": schedules.len(), "automations": automations.len()}));
|
||||||
|
state.broadcast("flow.updated", serde_json::to_value(&flow)?); state.wake_zone_control();
|
||||||
|
Ok(Json(flow))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_flow(State(state): State<AppState>, Path(id): Path<String>) -> Result<StatusCode, AppError> {
|
||||||
|
let _configuration_guard = state.lock_configuration_operation().await;
|
||||||
|
let _automation_guard = state.lock_automation_operation().await;
|
||||||
|
let _schedule_guard = state.lock_schedule_operation().await;
|
||||||
|
let _cycle_guard = state.lock_zone_control_cycle().await;
|
||||||
|
let zone_ids: std::collections::HashSet<String> = state.db.list_schedules()?.into_iter().filter(|s| s.flow_id.as_deref() == Some(id.as_str())).map(|s| s.zone_id).collect();
|
||||||
|
let existing = state.db.get_flow(&id)?.ok_or_else(|| AppError::NotFound(format!("flow {id}")))?;
|
||||||
|
if !state.db.delete_flow(&id)? { return Err(AppError::NotFound(format!("flow {id}"))); }
|
||||||
|
for zone_id in zone_ids { refresh_zone_override_boundary(&state, &zone_id).await?; }
|
||||||
|
state.log("info", "flow.deleted", &format!("Deleted Flow {}", existing.name), json!({"flow_id": id}));
|
||||||
|
state.broadcast("flow.deleted", json!({"id": id})); state.wake_zone_control();
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn export_flow(State(state): State<AppState>, Path(id): Path<String>) -> Result<Json<Value>, AppError> {
|
||||||
|
let flow = state.db.get_flow(&id)?.ok_or_else(|| AppError::NotFound(format!("flow {id}")))?;
|
||||||
|
Ok(Json(json!({
|
||||||
|
"format": "gree-controller-flow",
|
||||||
|
"version": 1,
|
||||||
|
"exported_at": Utc::now(),
|
||||||
|
"flow": {
|
||||||
|
"name": flow.name,
|
||||||
|
"enabled": flow.enabled,
|
||||||
|
"description": flow.description,
|
||||||
|
"nodes": flow.nodes,
|
||||||
|
"edges": flow.edges
|
||||||
|
}
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn import_flow(State(state): State<AppState>, Json(document): Json<Value>) -> Result<(StatusCode, Json<crate::models::Flow>), AppError> {
|
||||||
|
if let Some(format) = document.get("format").and_then(Value::as_str) {
|
||||||
|
if format != "gree-controller-flow" { return Err(AppError::BadRequest("unsupported Flow import format".into())); }
|
||||||
|
}
|
||||||
|
let payload = document.get("flow").cloned().unwrap_or(document);
|
||||||
|
let mut input: FlowInput = serde_json::from_value(payload).map_err(|err| AppError::BadRequest(format!("invalid Flow import: {err}")))?;
|
||||||
|
input.expected_revision = None;
|
||||||
|
validate_flow_graph(&input)?;
|
||||||
|
let _configuration_guard = state.lock_configuration_operation().await;
|
||||||
|
let _automation_guard = state.lock_automation_operation().await;
|
||||||
|
let _schedule_guard = state.lock_schedule_operation().await;
|
||||||
|
let _cycle_guard = state.lock_zone_control_cycle().await;
|
||||||
|
let flow = flow_from_input(Uuid::new_v4().to_string(), input, Utc::now(), 1);
|
||||||
|
let (flow, schedules, automations) = compile_flow(&state, flow)?;
|
||||||
|
state.db.replace_flow_outputs(&flow, &schedules, &automations)?;
|
||||||
|
for zone_id in schedules.iter().map(|s| s.zone_id.as_str()).collect::<std::collections::HashSet<_>>() { refresh_zone_override_boundary(&state, zone_id).await?; }
|
||||||
|
state.log("info", "flow.imported", &format!("Imported Flow {}", flow.name), json!({"flow_id": flow.id, "revision": flow.revision}));
|
||||||
|
state.broadcast("flow.created", serde_json::to_value(&flow)?); state.wake_zone_control();
|
||||||
|
Ok((StatusCode::CREATED, Json(flow)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dry_run_block_reason(state: &AppState, action: &crate::models::FlowNode) -> Result<Option<String>, AppError> {
|
||||||
|
match action.kind.as_str() {
|
||||||
|
"zone_thermostat" => {
|
||||||
|
let Some(zone_id) = flow_string(&action.config, "zone_id") else { return Ok(Some("missing_zone".into())); };
|
||||||
|
let Some(zone) = state.db.get_zone(&zone_id)? else { return Ok(Some("missing_zone".into())); };
|
||||||
|
if state.db.get_device(&zone.device_id)?.is_none() { return Ok(Some("missing_device".into())); }
|
||||||
|
if zone.device_manual_override { return Ok(Some("device_manual_override".into())); }
|
||||||
|
if zone.local_thermostat_power.is_some() { return Ok(Some("local_thermostat_override".into())); }
|
||||||
|
if engine::temporary_quick_thermostat_is_active(&zone, Utc::now()) { return Ok(Some("temporary_quick_thermostat".into())); }
|
||||||
|
if !zone.enabled && flow_bool(&action.config, "power") != Some(true) { return Ok(Some("zone_disabled".into())); }
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
"device_action" => {
|
||||||
|
let Some(device_id) = flow_string(&action.config, "device_id") else { return Ok(Some("missing_device".into())); };
|
||||||
|
let Some(device) = state.db.get_device(&device_id)? else { return Ok(Some("missing_device".into())); };
|
||||||
|
if !device.enabled { return Ok(Some("device_disabled".into())); }
|
||||||
|
let zones = state.db.list_zones()?;
|
||||||
|
if zones.iter().any(|z| z.device_id == device_id && z.device_manual_override) { return Ok(Some("device_manual_override".into())); }
|
||||||
|
if zones.iter().any(|z| z.device_id == device_id && z.local_thermostat_power.is_some()) { return Ok(Some("local_thermostat_override".into())); }
|
||||||
|
if zones.iter().any(|z| z.device_id == device_id && engine::temporary_quick_thermostat_is_active(z, Utc::now())) { return Ok(Some("temporary_quick_thermostat".into())); }
|
||||||
|
if zones.iter().any(|z| z.device_id == device_id && !z.enabled) && flow_bool(&action.config, "power") != Some(true) { return Ok(Some("zone_disabled".into())); }
|
||||||
|
let mut command = DeviceCommand::default();
|
||||||
|
command.power = flow_bool(&action.config, "power");
|
||||||
|
command.mode = flow_string(&action.config, "mode");
|
||||||
|
command.target_temperature = flow_f64(&action.config, "target_temperature");
|
||||||
|
command.fan_speed = flow_u8(&action.config, "fan_speed");
|
||||||
|
command.swing_vertical = flow_bool(&action.config, "swing_vertical");
|
||||||
|
command.swing_horizontal = flow_bool(&action.config, "swing_horizontal");
|
||||||
|
command.quiet = flow_bool(&action.config, "quiet");
|
||||||
|
command.turbo = flow_bool(&action.config, "turbo");
|
||||||
|
command.light = flow_bool(&action.config, "light");
|
||||||
|
command.air = flow_bool(&action.config, "air");
|
||||||
|
command.xfan = flow_bool(&action.config, "xfan");
|
||||||
|
command.health = flow_bool(&action.config, "health");
|
||||||
|
command.sleep = flow_bool(&action.config, "sleep");
|
||||||
|
if zones.iter().any(|z| z.device_id == device_id) && engine::automation_action_conflicts_with_thermostat(&command) {
|
||||||
|
return Ok(Some("thermostat_owner_conflict".into()));
|
||||||
|
}
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
"group_action" => {
|
||||||
|
let Some(group_id) = flow_string(&action.config, "group_id") else { return Ok(Some("missing_group".into())); };
|
||||||
|
let Some(group) = state.db.get_group(&group_id)? else { return Ok(Some("missing_group".into())); };
|
||||||
|
let climate_change = flow_string(&action.config, "mode").is_some() || flow_string(&action.config, "preset").is_some();
|
||||||
|
let resulting_enabled = flow_bool(&action.config, "power").unwrap_or(group.power_enabled);
|
||||||
|
if climate_change && !resulting_enabled { return Ok(Some("group_control_disabled".into())); }
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
_ => Ok(Some("unsupported_action".into())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn simulate_flow(State(state): State<AppState>, Json(input): Json<FlowSimulationInput>) -> Result<Json<Value>, AppError> {
|
||||||
|
validate_flow_graph(&input.flow)?;
|
||||||
|
let at = match input.at.as_deref().map(str::trim).filter(|v| !v.is_empty()) {
|
||||||
|
Some(value) => chrono::DateTime::parse_from_rfc3339(value).map_err(|_| AppError::BadRequest("simulation time must be RFC3339".into()))?.with_timezone(&chrono::Local),
|
||||||
|
None => chrono::Local::now(),
|
||||||
|
};
|
||||||
|
let preview_id = input.flow_id.clone().filter(|v| !v.trim().is_empty()).unwrap_or_else(|| format!("dry-run-{}", Uuid::new_v4()));
|
||||||
|
let preview = crate::models::Flow {
|
||||||
|
id: preview_id.clone(), name: input.flow.name.trim().into(), enabled: input.flow.enabled, description: input.flow.description.trim().into(),
|
||||||
|
nodes: input.flow.nodes.clone(), edges: input.flow.edges.clone(), summary: String::new(), compiled_schedule_ids: vec![], compiled_automation_ids: vec![], revision: 0,
|
||||||
|
created_at: Utc::now(), updated_at: Utc::now(),
|
||||||
|
};
|
||||||
|
let (compiled, schedules, automations) = compile_flow(&state, preview)?;
|
||||||
|
let devices = state.db.list_devices()?;
|
||||||
|
let mut actions = Vec::new();
|
||||||
|
for action in input.flow.nodes.iter().filter(|node| flow_action_kind(&node.kind)) {
|
||||||
|
let program = compile_flow_program(&action.id, &input.flow.nodes, &input.flow.edges)?;
|
||||||
|
let (matched, trace) = engine::evaluate_flow_conditions_trace(&state, &devices, &program, at.clone(), &input.overrides).await?;
|
||||||
|
let blocked_reason = if matched && !input.flow.enabled { Some("flow_disabled".into()) }
|
||||||
|
else if matched { dry_run_block_reason(&state, action)? } else { None };
|
||||||
|
actions.push(json!({
|
||||||
|
"node_id": action.id,
|
||||||
|
"kind": action.kind,
|
||||||
|
"matched": matched,
|
||||||
|
"would_execute": matched && blocked_reason.is_none(),
|
||||||
|
"blocked_reason": blocked_reason,
|
||||||
|
"config": action.config,
|
||||||
|
"trace": trace
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
let result = json!({
|
||||||
|
"dry_run": true,
|
||||||
|
"at": at.to_rfc3339(),
|
||||||
|
"flow_id": input.flow_id,
|
||||||
|
"summary": compiled.summary,
|
||||||
|
"compiled": {"schedules": schedules.len(), "automations": automations.len()},
|
||||||
|
"actions": actions,
|
||||||
|
"note": "Dry-run never changes thermostat, device, group, schedule or automation state."
|
||||||
|
});
|
||||||
|
if input.log {
|
||||||
|
state.log("info", "flow.dry_run", &format!("Dry-run Flow {}", input.flow.name.trim()), json!({
|
||||||
|
"flow_id": input.flow_id, "at": at.to_rfc3339(), "actions": result.get("actions").cloned().unwrap_or(Value::Null)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
Ok(Json(result))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn flow_logs(State(state): State<AppState>, Path(id): Path<String>, Query(query): Query<FlowLogsQuery>) -> Result<Json<Value>, AppError> {
|
||||||
|
if state.db.get_flow(&id)?.is_none() { return Err(AppError::NotFound(format!("flow {id}"))); }
|
||||||
|
let limit = query.limit.unwrap_or(100).clamp(1, 250) as usize;
|
||||||
|
let events = state.db.list_events(1000)?.into_iter().filter(|event| {
|
||||||
|
event.metadata.get("flow_id").and_then(Value::as_str) == Some(id.as_str())
|
||||||
|
|| event.metadata.get("automation_id").and_then(Value::as_str).map(|value| value.starts_with(&format!("flow:{id}:"))).unwrap_or(false)
|
||||||
|
}).take(limit).collect::<Vec<_>>();
|
||||||
|
Ok(Json(json!({"events": events})))
|
||||||
|
}
|
||||||
+1
-1
@@ -256,7 +256,7 @@ async fn apply_schedule_template(State(state): State<AppState>, Path(id): Path<S
|
|||||||
items.push(Schedule {
|
items.push(Schedule {
|
||||||
id: Uuid::new_v4().to_string(), zone_id: id.clone(), name: name.into(), enabled: true,
|
id: Uuid::new_v4().to_string(), zone_id: id.clone(), name: name.into(), enabled: true,
|
||||||
weekdays: days, start_time: start.into(), end_time: end.into(), preset: preset.into(),
|
weekdays: days, start_time: start.into(), end_time: end.into(), preset: preset.into(),
|
||||||
setpoint: zone.setpoint, created_at: Utc::now(), updated_at: Utc::now(),
|
setpoint: zone.setpoint, created_at: Utc::now(), updated_at: Utc::now(), flow_id: None, flow_node_id: None,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
let all = vec![1,2,3,4,5,6,7];
|
let all = vec![1,2,3,4,5,6,7];
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ impl ScheduleInput {
|
|||||||
fn into_schedule(self, id: String, created_at: chrono::DateTime<Utc>) -> Schedule {
|
fn into_schedule(self, id: String, created_at: chrono::DateTime<Utc>) -> Schedule {
|
||||||
Schedule { id, zone_id: self.zone_id, name: self.name.trim().into(), enabled: self.enabled,
|
Schedule { id, zone_id: self.zone_id, name: self.name.trim().into(), enabled: self.enabled,
|
||||||
weekdays: self.weekdays, start_time: self.start_time, end_time: self.end_time,
|
weekdays: self.weekdays, start_time: self.start_time, end_time: self.end_time,
|
||||||
preset: self.preset, setpoint: self.setpoint, created_at, updated_at: Utc::now() }
|
preset: self.preset, setpoint: self.setpoint, created_at, updated_at: Utc::now(), flow_id: None, flow_node_id: None }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fn validate_schedule_set(items: &[Schedule]) -> Result<(), AppError> {
|
fn validate_schedule_set(items: &[Schedule]) -> Result<(), AppError> {
|
||||||
@@ -119,6 +119,7 @@ async fn update_schedule(State(state): State<AppState>, Path(id): Path<String>,
|
|||||||
let _cycle_guard = state.lock_zone_control_cycle().await;
|
let _cycle_guard = state.lock_zone_control_cycle().await;
|
||||||
input.validate()?;
|
input.validate()?;
|
||||||
let existing = state.db.get_schedule(&id)?.ok_or_else(|| AppError::NotFound(format!("schedule {id}")))?;
|
let existing = state.db.get_schedule(&id)?.ok_or_else(|| AppError::NotFound(format!("schedule {id}")))?;
|
||||||
|
if existing.flow_id.is_some() { return Err(AppError::BadRequest("this schedule is generated by Flow; edit it in the Flow editor".into())); }
|
||||||
if state.db.get_zone(&input.zone_id)?.is_none() { return Err(AppError::BadRequest("schedule zone does not exist".into())); }
|
if state.db.get_zone(&input.zone_id)?.is_none() { return Err(AppError::BadRequest("schedule zone does not exist".into())); }
|
||||||
let old_zone_id = existing.zone_id.clone();
|
let old_zone_id = existing.zone_id.clone();
|
||||||
let item = input.into_schedule(id.clone(), existing.created_at);
|
let item = input.into_schedule(id.clone(), existing.created_at);
|
||||||
@@ -135,6 +136,7 @@ async fn delete_schedule(State(state): State<AppState>, Path(id): Path<String>)
|
|||||||
let _schedule_guard = state.lock_schedule_operation().await;
|
let _schedule_guard = state.lock_schedule_operation().await;
|
||||||
let _cycle_guard = state.lock_zone_control_cycle().await;
|
let _cycle_guard = state.lock_zone_control_cycle().await;
|
||||||
let existing = state.db.get_schedule(&id)?.ok_or_else(|| AppError::NotFound(format!("schedule {id}")))?;
|
let existing = state.db.get_schedule(&id)?.ok_or_else(|| AppError::NotFound(format!("schedule {id}")))?;
|
||||||
|
if existing.flow_id.is_some() { return Err(AppError::BadRequest("this schedule is generated by Flow; delete it from the Flow editor".into())); }
|
||||||
if !state.db.delete_schedule(&id)? { return Err(AppError::NotFound(format!("schedule {id}"))); }
|
if !state.db.delete_schedule(&id)? { return Err(AppError::NotFound(format!("schedule {id}"))); }
|
||||||
refresh_zone_override_boundary(&state, &existing.zone_id).await?;
|
refresh_zone_override_boundary(&state, &existing.zone_id).await?;
|
||||||
state.broadcast("schedule.deleted", json!({"id": id}));
|
state.broadcast("schedule.deleted", json!({"id": id}));
|
||||||
|
|||||||
+36
-9
@@ -154,7 +154,7 @@ async fn export_settings(State(state): State<AppState>) -> Result<Json<Configura
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn validate_configuration_export(export: &ConfigurationExport) -> Result<(), AppError> {
|
fn validate_configuration_export(export: &ConfigurationExport) -> Result<(), AppError> {
|
||||||
if export.format_version != 1 { return Err(AppError::BadRequest("unsupported configuration export version".into())); }
|
if !matches!(export.format_version, 1 | 2) { return Err(AppError::BadRequest("unsupported configuration export version".into())); }
|
||||||
influxdb::validate(&export.settings.influxdb).map_err(|err| AppError::BadRequest(err.to_string()))?;
|
influxdb::validate(&export.settings.influxdb).map_err(|err| AppError::BadRequest(err.to_string()))?;
|
||||||
if !matches!(export.settings.house_mode.as_str(), "cool" | "heat" | "off") {
|
if !matches!(export.settings.house_mode.as_str(), "cool" | "heat" | "off") {
|
||||||
return Err(AppError::BadRequest("import contains an invalid house mode".into()));
|
return Err(AppError::BadRequest("import contains an invalid house mode".into()));
|
||||||
@@ -164,9 +164,10 @@ fn validate_configuration_export(export: &ConfigurationExport) -> Result<(), App
|
|||||||
let zones: std::collections::HashSet<&str> = export.zones.iter().map(|item| item.id.as_str()).collect();
|
let zones: std::collections::HashSet<&str> = export.zones.iter().map(|item| item.id.as_str()).collect();
|
||||||
let schedules: std::collections::HashSet<&str> = export.schedules.iter().map(|item| item.id.as_str()).collect();
|
let schedules: std::collections::HashSet<&str> = export.schedules.iter().map(|item| item.id.as_str()).collect();
|
||||||
let automations: std::collections::HashSet<&str> = export.automations.iter().map(|item| item.id.as_str()).collect();
|
let automations: std::collections::HashSet<&str> = export.automations.iter().map(|item| item.id.as_str()).collect();
|
||||||
|
let flows: std::collections::HashSet<&str> = export.flows.iter().map(|item| item.id.as_str()).collect();
|
||||||
if devices.len() != export.devices.len() || zones.len() != export.zones.len()
|
if devices.len() != export.devices.len() || zones.len() != export.zones.len()
|
||||||
|| schedules.len() != export.schedules.len() || automations.len() != export.automations.len()
|
|| schedules.len() != export.schedules.len() || automations.len() != export.automations.len() || flows.len() != export.flows.len()
|
||||||
|| devices.contains("") || zones.contains("") || schedules.contains("") || automations.contains("")
|
|| devices.contains("") || zones.contains("") || schedules.contains("") || automations.contains("") || flows.contains("")
|
||||||
{
|
{
|
||||||
return Err(AppError::BadRequest("import contains duplicate or empty resource IDs".into()));
|
return Err(AppError::BadRequest("import contains duplicate or empty resource IDs".into()));
|
||||||
}
|
}
|
||||||
@@ -194,6 +195,9 @@ fn validate_configuration_export(export: &ConfigurationExport) -> Result<(), App
|
|||||||
return Err(AppError::BadRequest("import contains a schedule referencing a missing zone".into()));
|
return Err(AppError::BadRequest("import contains a schedule referencing a missing zone".into()));
|
||||||
}
|
}
|
||||||
for item in &export.schedules {
|
for item in &export.schedules {
|
||||||
|
if item.flow_id.as_deref().is_some_and(|flow_id| !flows.contains(flow_id)) {
|
||||||
|
return Err(AppError::BadRequest("import contains a Flow-generated schedule referencing a missing Flow".into()));
|
||||||
|
}
|
||||||
if item.weekdays.is_empty() || item.weekdays.iter().any(|day| !(1..=7).contains(day)) {
|
if item.weekdays.is_empty() || item.weekdays.iter().any(|day| !(1..=7).contains(day)) {
|
||||||
return Err(AppError::BadRequest("import contains invalid schedule weekdays".into()));
|
return Err(AppError::BadRequest("import contains invalid schedule weekdays".into()));
|
||||||
}
|
}
|
||||||
@@ -234,10 +238,27 @@ fn validate_configuration_export(export: &ConfigurationExport) -> Result<(), App
|
|||||||
let at = item.at_time.as_deref().ok_or_else(|| AppError::BadRequest("import contains a time automation without at_time".into()))?;
|
let at = item.at_time.as_deref().ok_or_else(|| AppError::BadRequest("import contains a time automation without at_time".into()))?;
|
||||||
NaiveTime::parse_from_str(at, "%H:%M").map_err(|_| AppError::BadRequest("import contains an invalid automation time".into()))?;
|
NaiveTime::parse_from_str(at, "%H:%M").map_err(|_| AppError::BadRequest("import contains an invalid automation time".into()))?;
|
||||||
}
|
}
|
||||||
|
"flow" => {
|
||||||
|
if item.flow_id.as_deref().filter(|id| flows.contains(*id)).is_none() || item.flow_conditions.is_empty() {
|
||||||
|
return Err(AppError::BadRequest("import contains an invalid Flow-generated automation".into()));
|
||||||
|
}
|
||||||
|
}
|
||||||
_ => return Err(AppError::BadRequest("import contains an unsupported automation trigger".into())),
|
_ => return Err(AppError::BadRequest("import contains an unsupported automation trigger".into())),
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(group_id) = item.action_group_id.as_deref().filter(|value| !value.is_empty()) {
|
if let Some(zone_id) = item.action_zone_id.as_deref().filter(|value| !value.is_empty()) {
|
||||||
|
if !zones.contains(zone_id) { return Err(AppError::BadRequest("import contains a Flow automation referencing a missing zone".into())); }
|
||||||
|
if let Some(preset) = item.action_zone_preset.as_deref() {
|
||||||
|
if !matches!(preset, "auto" | "custom" | "comfort" | "sleep" | "away") {
|
||||||
|
return Err(AppError::BadRequest("import contains an invalid Flow thermostat preset".into()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if item.action_zone_preset.as_deref() == Some("custom")
|
||||||
|
&& item.action.target_temperature.is_some_and(|value| !(8.0..=30.0).contains(&value))
|
||||||
|
{
|
||||||
|
return Err(AppError::BadRequest("import contains an invalid Flow thermostat target".into()));
|
||||||
|
}
|
||||||
|
} else if let Some(group_id) = item.action_group_id.as_deref().filter(|value| !value.is_empty()) {
|
||||||
if !groups.contains(group_id) {
|
if !groups.contains(group_id) {
|
||||||
return Err(AppError::BadRequest("import contains an automation referencing a missing group".into()));
|
return Err(AppError::BadRequest("import contains an automation referencing a missing group".into()));
|
||||||
}
|
}
|
||||||
@@ -246,17 +267,23 @@ fn validate_configuration_export(export: &ConfigurationExport) -> Result<(), App
|
|||||||
return Err(AppError::BadRequest("import contains an invalid group automation mode".into()));
|
return Err(AppError::BadRequest("import contains an invalid group automation mode".into()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
let flow_custom_group = item.flow_id.is_some() && item.action_preset.as_deref() == Some("custom");
|
||||||
if let Some(preset) = item.action_preset.as_deref() {
|
if let Some(preset) = item.action_preset.as_deref() {
|
||||||
if !matches!(preset, "auto" | "comfort" | "sleep" | "away") {
|
if !matches!(preset, "auto" | "comfort" | "sleep" | "away") && !(flow_custom_group && preset == "custom") {
|
||||||
return Err(AppError::BadRequest("import contains an invalid group automation preset".into()));
|
return Err(AppError::BadRequest("import contains an invalid group automation preset".into()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if item.action.target_temperature.is_some() || item.action.fan_speed.is_some()
|
if flow_custom_group {
|
||||||
|| item.action.swing_vertical.is_some() || item.action.swing_horizontal.is_some()
|
let Some(target) = item.action.target_temperature else { return Err(AppError::BadRequest("import contains a Flow custom group preset without a target".into())); };
|
||||||
|
if !(8.0..=30.0).contains(&target) { return Err(AppError::BadRequest("import contains an invalid Flow group target".into())); }
|
||||||
|
} else if item.action.target_temperature.is_some() {
|
||||||
|
return Err(AppError::BadRequest("import contains unsupported target temperature in a group automation".into()));
|
||||||
|
}
|
||||||
|
if item.action.fan_speed.is_some() || item.action.swing_vertical.is_some() || item.action.swing_horizontal.is_some()
|
||||||
|| item.action.quiet.is_some() || item.action.turbo.is_some() || item.action.light.is_some()
|
|| item.action.quiet.is_some() || item.action.turbo.is_some() || item.action.light.is_some()
|
||||||
|| item.action.air.is_some() || item.action.xfan.is_some() || item.action.health.is_some() || item.action.sleep.is_some()
|
|| item.action.air.is_some() || item.action.xfan.is_some() || item.action.health.is_some() || item.action.sleep.is_some()
|
||||||
{
|
{
|
||||||
return Err(AppError::BadRequest("import contains unsupported fields in a group automation".into()));
|
return Err(AppError::BadRequest("import contains unsupported device fields in a group automation".into()));
|
||||||
}
|
}
|
||||||
if item.action.power.is_none() && item.action.mode.is_none() && item.action_preset.as_deref().filter(|v| !v.is_empty()).is_none() {
|
if item.action.power.is_none() && item.action.mode.is_none() && item.action_preset.as_deref().filter(|v| !v.is_empty()).is_none() {
|
||||||
return Err(AppError::BadRequest("import contains an empty group automation action".into()));
|
return Err(AppError::BadRequest("import contains an empty group automation action".into()));
|
||||||
@@ -361,7 +388,7 @@ async fn import_settings(State(state): State<AppState>, Json(mut export): Json<C
|
|||||||
|
|
||||||
// Configuration replacement is the broadest mutation in the application. Serialize it
|
// Configuration replacement is the broadest mutation in the application. Serialize it
|
||||||
// against every structural editor and every live owner that can write zone/device state.
|
// against every structural editor and every live owner that can write zone/device state.
|
||||||
// Global lock order: configuration -> automation -> house -> schedule -> zones -> devices.
|
// Global lock order: configuration -> automation -> house -> schedule -> thermostat-cycle -> zones -> devices.
|
||||||
let _configuration_guard = state.lock_configuration_operation().await;
|
let _configuration_guard = state.lock_configuration_operation().await;
|
||||||
let _automation_guard = state.lock_automation_operation().await;
|
let _automation_guard = state.lock_automation_operation().await;
|
||||||
let _house_guard = state.lock_house_operation().await;
|
let _house_guard = state.lock_house_operation().await;
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ async fn build_bootstrap(state: &AppState) -> Result<Value, AppError> {
|
|||||||
"groups": state.db.list_groups()?,
|
"groups": state.db.list_groups()?,
|
||||||
"schedules": state.db.list_schedules()?,
|
"schedules": state.db.list_schedules()?,
|
||||||
"automations": state.db.list_automations()?,
|
"automations": state.db.list_automations()?,
|
||||||
|
"flows": state.db.list_flows()?,
|
||||||
"access_tokens": state.db.list_api_tokens()?,
|
"access_tokens": state.db.list_api_tokens()?,
|
||||||
"settings": public_settings(&settings),
|
"settings": public_settings(&settings),
|
||||||
"outdoor_temperature": *state.outdoor_temperature.read().await,
|
"outdoor_temperature": *state.outdoor_temperature.read().await,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ use rusqlite::{params, Connection, OptionalExtension};
|
|||||||
use serde::{de::DeserializeOwned, Serialize};
|
use serde::{de::DeserializeOwned, Serialize};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use crate::{
|
use crate::{
|
||||||
models::{ApiTokenInfo, Automation, ClimateGroup, ConfigurationExport, Device, EventLog, HaReading, Reading, RuntimeSettings, Schedule, Zone, ZoneReading},
|
models::{ApiTokenInfo, Automation, ClimateGroup, ConfigurationExport, Device, EventLog, Flow, HaReading, Reading, RuntimeSettings, Schedule, Zone, ZoneReading},
|
||||||
queries,
|
queries,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -19,6 +19,7 @@ pub struct Db {
|
|||||||
include!("db/core_devices.rs");
|
include!("db/core_devices.rs");
|
||||||
include!("db/climate.rs");
|
include!("db/climate.rs");
|
||||||
include!("db/schedules_automations.rs");
|
include!("db/schedules_automations.rs");
|
||||||
|
include!("db/flows.rs");
|
||||||
include!("db/device_history.rs");
|
include!("db/device_history.rs");
|
||||||
include!("db/zone_history.rs");
|
include!("db/zone_history.rs");
|
||||||
include!("db/ha_history.rs");
|
include!("db/ha_history.rs");
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
impl Db {
|
impl Db {
|
||||||
pub fn export_configuration(&self, settings: RuntimeSettings) -> Result<ConfigurationExport> {
|
pub fn export_configuration(&self, settings: RuntimeSettings) -> Result<ConfigurationExport> {
|
||||||
Ok(ConfigurationExport {
|
Ok(ConfigurationExport {
|
||||||
format_version: 1,
|
format_version: 2,
|
||||||
exported_at: Utc::now(),
|
exported_at: Utc::now(),
|
||||||
settings,
|
settings,
|
||||||
devices: self.list_devices()?,
|
devices: self.list_devices()?,
|
||||||
@@ -9,6 +9,7 @@ impl Db {
|
|||||||
groups: self.list_groups()?,
|
groups: self.list_groups()?,
|
||||||
schedules: self.list_schedules()?,
|
schedules: self.list_schedules()?,
|
||||||
automations: self.list_automations()?,
|
automations: self.list_automations()?,
|
||||||
|
flows: self.list_flows()?,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,6 +37,10 @@ impl Db {
|
|||||||
let payload = Self::to_json(item)?;
|
let payload = Self::to_json(item)?;
|
||||||
tx.execute(queries::UPSERT_AUTOMATION, params![item.id, payload, item.updated_at.to_rfc3339()])?;
|
tx.execute(queries::UPSERT_AUTOMATION, params![item.id, payload, item.updated_at.to_rfc3339()])?;
|
||||||
}
|
}
|
||||||
|
for flow in &export.flows {
|
||||||
|
let payload = Self::to_json(flow)?;
|
||||||
|
tx.execute(queries::UPSERT_FLOW, params![flow.id, payload, flow.updated_at.to_rfc3339()])?;
|
||||||
|
}
|
||||||
let settings_json = Self::to_json(&export.settings)?;
|
let settings_json = Self::to_json(&export.settings)?;
|
||||||
tx.execute(queries::UPSERT_RUNTIME_SETTINGS, params![settings_json, Utc::now().to_rfc3339()])?;
|
tx.execute(queries::UPSERT_RUNTIME_SETTINGS, params![settings_json, Utc::now().to_rfc3339()])?;
|
||||||
tx.commit()?;
|
tx.commit()?;
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
impl Db {
|
||||||
|
pub fn list_flows(&self) -> Result<Vec<Flow>> {
|
||||||
|
self.list_payloads(queries::LIST_FLOWS)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_flow(&self, id: &str) -> Result<Option<Flow>> {
|
||||||
|
self.get_payload(queries::GET_FLOW, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn replace_flow_outputs(&self, flow: &Flow, schedules: &[Schedule], automations: &[Automation]) -> Result<()> {
|
||||||
|
let mut conn = self.lock()?;
|
||||||
|
let tx = conn.transaction()?;
|
||||||
|
tx.execute(queries::DELETE_SCHEDULES_BY_FLOW_ID, [flow.id.as_str()])?;
|
||||||
|
tx.execute(queries::DELETE_AUTOMATIONS_BY_FLOW_ID, [flow.id.as_str()])?;
|
||||||
|
for schedule in schedules {
|
||||||
|
let payload = Self::to_json(schedule)?;
|
||||||
|
tx.execute(queries::UPSERT_SCHEDULE, params![schedule.id, schedule.zone_id, payload, schedule.updated_at.to_rfc3339()])?;
|
||||||
|
}
|
||||||
|
for item in automations {
|
||||||
|
let payload = Self::to_json(item)?;
|
||||||
|
tx.execute(queries::UPSERT_AUTOMATION, params![item.id, payload, item.updated_at.to_rfc3339()])?;
|
||||||
|
}
|
||||||
|
let payload = Self::to_json(flow)?;
|
||||||
|
tx.execute(queries::UPSERT_FLOW, params![flow.id, payload, flow.updated_at.to_rfc3339()])?;
|
||||||
|
tx.commit()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn delete_flow(&self, id: &str) -> Result<bool> {
|
||||||
|
let mut conn = self.lock()?;
|
||||||
|
let tx = conn.transaction()?;
|
||||||
|
tx.execute(queries::DELETE_SCHEDULES_BY_FLOW_ID, [id])?;
|
||||||
|
tx.execute(queries::DELETE_AUTOMATIONS_BY_FLOW_ID, [id])?;
|
||||||
|
let changed = tx.execute(queries::DELETE_FLOW, [id])? > 0;
|
||||||
|
tx.commit()?;
|
||||||
|
Ok(changed)
|
||||||
|
}
|
||||||
|
}
|
||||||
+411
-22
@@ -1,14 +1,15 @@
|
|||||||
|
|
||||||
pub fn automation_action_conflicts_with_thermostat(command: &DeviceCommand) -> bool {
|
pub fn automation_action_conflicts_with_thermostat(command: &DeviceCommand) -> bool {
|
||||||
// Power/mode/target are translated by apply_automatic_device_action into durable zone
|
// Power/auto-heat-cool/target are translated by apply_automatic_device_action into durable
|
||||||
// state, so they do not fight the thermostat. Fan/quiet/sleep are thermostat outputs with
|
// zone state, so they do not fight the thermostat. Dry/fan HVAC modes do not belong to the
|
||||||
// no independent zone override model; accepting them as one-shot direct automation would
|
// thermostat domain. Fan/quiet/sleep are outputs continuously owned by the regulator and
|
||||||
// let the next thermostat cycle immediately overwrite them.
|
// have no independent zone override model, so a one-shot automation would be overwritten.
|
||||||
command.fan_speed.is_some() || command.quiet.is_some() || command.sleep.is_some()
|
command.mode.as_deref().is_some_and(|mode| !matches!(mode, "auto" | "heat" | "cool"))
|
||||||
|
|| command.fan_speed.is_some() || command.quiet.is_some() || command.sleep.is_some()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn device_has_enabled_thermostat_zone(device_id: &str, zones: &[Zone]) -> bool {
|
fn device_has_thermostat_zone(device_id: &str, zones: &[Zone]) -> bool {
|
||||||
zones.iter().any(|zone| zone.device_id == device_id && zone.enabled)
|
zones.iter().any(|zone| zone.device_id == device_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn run_automations(state: &AppState) -> Result<()> {
|
async fn run_automations(state: &AppState) -> Result<()> {
|
||||||
@@ -27,6 +28,13 @@ async fn run_automations(state: &AppState) -> Result<()> {
|
|||||||
"temperature_below" => find_temperature(&devices, item.trigger_device_id.as_deref())
|
"temperature_below" => find_temperature(&devices, item.trigger_device_id.as_deref())
|
||||||
.zip(item.threshold).map(|(t, threshold)| t < threshold).unwrap_or(false),
|
.zip(item.threshold).map(|(t, threshold)| t < threshold).unwrap_or(false),
|
||||||
"time" => time_automation_due(&item, Local::now()),
|
"time" => time_automation_due(&item, Local::now()),
|
||||||
|
"flow" => match flow_conditions_match(state, &devices, &item.flow_conditions).await {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(err) => {
|
||||||
|
state.log("warn", "flow.condition_error", &format!("Flow automation {} condition evaluation failed: {}", item.name, err), json!({"automation_id": item.id, "flow_id": item.flow_id, "flow_node_id": item.flow_node_id}));
|
||||||
|
false
|
||||||
|
}
|
||||||
|
},
|
||||||
_ => false,
|
_ => false,
|
||||||
};
|
};
|
||||||
if !should_fire { continue; }
|
if !should_fire { continue; }
|
||||||
@@ -44,41 +52,49 @@ async fn run_automations(state: &AppState) -> Result<()> {
|
|||||||
let zones = state.db.list_zones()?;
|
let zones = state.db.list_zones()?;
|
||||||
let groups = state.db.list_groups()?;
|
let groups = state.db.list_groups()?;
|
||||||
|
|
||||||
if item.action_group_id.is_none()
|
if item.action_group_id.is_none() && item.action_zone_id.is_none()
|
||||||
&& device_blocked_by_disabled_zone(&item.action_device_id, &zones)
|
&& device_blocked_by_disabled_zone(&item.action_device_id, &zones)
|
||||||
&& item.action.power != Some(true)
|
&& item.action.power != Some(true)
|
||||||
{
|
{
|
||||||
state.log("info", "automation.blocked_by_zone", &format!("Automation {} suppressed by disabled zone", item.name), json!({
|
state.log("info", "automation.blocked_by_zone", &format!("Automation {} suppressed by disabled zone", item.name), json!({
|
||||||
"automation_id": item.id, "device_id": item.action_device_id
|
"automation_id": item.id, "device_id": item.action_device_id, "flow_id": item.flow_id, "flow_node_id": item.flow_node_id
|
||||||
}));
|
}));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if item.action_group_id.is_none() && device_blocked_by_manual_override(&item.action_device_id, &zones) {
|
if item.action_group_id.is_none() && item.action_zone_id.is_none() && device_blocked_by_manual_override(&item.action_device_id, &zones) {
|
||||||
state.log("info", "automation.blocked_by_manual_override", &format!("Automation {} suppressed by manual device control", item.name), json!({
|
state.log("info", "automation.blocked_by_manual_override", &format!("Automation {} suppressed by manual device control", item.name), json!({
|
||||||
"automation_id": item.id, "device_id": item.action_device_id
|
"automation_id": item.id, "device_id": item.action_device_id, "flow_id": item.flow_id, "flow_node_id": item.flow_node_id
|
||||||
}));
|
}));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if item.action_group_id.is_none() && device_blocked_by_local_thermostat(&item.action_device_id, &zones) {
|
if item.action_group_id.is_none() && item.action_zone_id.is_none() && device_blocked_by_local_thermostat(&item.action_device_id, &zones) {
|
||||||
state.log("info", "automation.blocked_by_local_thermostat", &format!("Automation {} suppressed by local thermostat ownership", item.name), json!({
|
state.log("info", "automation.blocked_by_local_thermostat", &format!("Automation {} suppressed by local thermostat ownership", item.name), json!({
|
||||||
"automation_id": item.id, "device_id": item.action_device_id
|
"automation_id": item.id, "device_id": item.action_device_id, "flow_id": item.flow_id, "flow_node_id": item.flow_node_id
|
||||||
}));
|
}));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if item.action_group_id.is_none()
|
if item.action_group_id.is_none() && item.action_zone_id.is_none() && device_blocked_by_temporary_thermostat(&item.action_device_id, &zones) {
|
||||||
&& device_has_enabled_thermostat_zone(&item.action_device_id, &zones)
|
state.log("info", "automation.blocked_by_temporary_thermostat", &format!("Automation {} suppressed by Temporary Quick Thermostat ownership", item.name), json!({
|
||||||
|
"automation_id": item.id, "device_id": item.action_device_id, "flow_id": item.flow_id, "flow_node_id": item.flow_node_id
|
||||||
|
}));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if item.action_group_id.is_none() && item.action_zone_id.is_none()
|
||||||
|
&& device_has_thermostat_zone(&item.action_device_id, &zones)
|
||||||
&& automation_action_conflicts_with_thermostat(&item.action)
|
&& automation_action_conflicts_with_thermostat(&item.action)
|
||||||
{
|
{
|
||||||
// Fan/quiet/sleep are outputs continuously managed by the thermostat. Unlike
|
// Fan/quiet/sleep are outputs continuously managed by the thermostat. Unlike
|
||||||
// power/mode/target they cannot be translated into durable zone state, so a direct
|
// power/mode/target they cannot be translated into durable zone state, so a direct
|
||||||
// automation would be immediately overwritten by the next thermostat cycle.
|
// automation would be immediately overwritten by the next thermostat cycle.
|
||||||
state.log("warn", "automation.blocked_by_thermostat_owner", &format!("Automation {} suppressed because the device is owned by an enabled thermostat zone", item.name), json!({
|
state.log("warn", "automation.blocked_by_thermostat_owner", &format!("Automation {} suppressed because the device is owned by an enabled thermostat zone", item.name), json!({
|
||||||
"automation_id": item.id, "device_id": item.action_device_id
|
"automation_id": item.id, "device_id": item.action_device_id, "flow_id": item.flow_id, "flow_node_id": item.flow_node_id
|
||||||
}));
|
}));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let target_devices: Vec<String> = if let Some(group_id) = item.action_group_id.as_deref() {
|
let target_devices: Vec<String> = if let Some(zone_id) = item.action_zone_id.as_deref() {
|
||||||
|
zones.iter().find(|zone| zone.id == zone_id).map(|zone| vec![zone.device_id.clone()]).unwrap_or_default()
|
||||||
|
} else if let Some(group_id) = item.action_group_id.as_deref() {
|
||||||
groups.iter().find(|group| group.id == group_id)
|
groups.iter().find(|group| group.id == group_id)
|
||||||
.map(|group| group.zone_ids.iter()
|
.map(|group| group.zone_ids.iter()
|
||||||
.filter_map(|zone_id| zones.iter().find(|zone| &zone.id == zone_id).map(|zone| zone.device_id.clone()))
|
.filter_map(|zone_id| zones.iter().find(|zone| &zone.id == zone_id).map(|zone| zone.device_id.clone()))
|
||||||
@@ -90,6 +106,8 @@ async fn run_automations(state: &AppState) -> Result<()> {
|
|||||||
if target_devices.iter().any(|device_id| claimed_devices.contains(device_id)) {
|
if target_devices.iter().any(|device_id| claimed_devices.contains(device_id)) {
|
||||||
state.log("warn", "automation.conflict", &format!("Automation {} skipped because an older due automation already claimed the same target", item.name), json!({
|
state.log("warn", "automation.conflict", &format!("Automation {} skipped because an older due automation already claimed the same target", item.name), json!({
|
||||||
"automation_id": item.id,
|
"automation_id": item.id,
|
||||||
|
"flow_id": item.flow_id,
|
||||||
|
"flow_node_id": item.flow_node_id,
|
||||||
"group_id": item.action_group_id,
|
"group_id": item.action_group_id,
|
||||||
"device_id": item.action_device_id,
|
"device_id": item.action_device_id,
|
||||||
"target_devices": target_devices,
|
"target_devices": target_devices,
|
||||||
@@ -97,20 +115,22 @@ async fn run_automations(state: &AppState) -> Result<()> {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let result: Result<bool, AppError> = if let Some(group_id) = item.action_group_id.as_deref() {
|
let result: Result<bool, AppError> = if let Some(zone_id) = item.action_zone_id.as_deref() {
|
||||||
|
apply_flow_zone_action(state, zone_id, item.action_zone_preset.as_deref(), &item.action).await
|
||||||
|
} else if let Some(group_id) = item.action_group_id.as_deref() {
|
||||||
let group_mode = item.action.mode.as_deref().map(|mode| if mode == "auto" { "house".to_string() } else { mode.to_string() });
|
let group_mode = item.action.mode.as_deref().map(|mode| if mode == "auto" { "house".to_string() } else { mode.to_string() });
|
||||||
control_group(state, group_id, GroupControlPatch {
|
control_group(state, group_id, GroupControlPatch {
|
||||||
power: item.action.power,
|
power: item.action.power,
|
||||||
mode: group_mode,
|
mode: group_mode,
|
||||||
preset: item.action_preset.clone(),
|
preset: item.action_preset.clone(),
|
||||||
setpoint: None,
|
setpoint: item.action.target_temperature,
|
||||||
}, "automation.group").await.map(|value| !value.get("suppressed").and_then(Value::as_bool).unwrap_or(false))
|
}, "automation.group").await.map(|value| !value.get("suppressed").and_then(Value::as_bool).unwrap_or(false))
|
||||||
} else {
|
} else {
|
||||||
match apply_automatic_device_action(state, &item.action_device_id, item.action.clone()).await {
|
match apply_automatic_device_action(state, &item.action_device_id, item.action.clone()).await {
|
||||||
Ok(Some(_)) => Ok(true),
|
Ok(Some(_)) => Ok(true),
|
||||||
Ok(None) => {
|
Ok(None) => {
|
||||||
state.log("info", "automation.blocked_by_fresh_ownership", &format!("Automation {} was suppressed after ownership changed", item.name), json!({
|
state.log("info", "automation.blocked_by_fresh_ownership", &format!("Automation {} was suppressed after ownership changed", item.name), json!({
|
||||||
"automation_id": item.id, "device_id": item.action_device_id
|
"automation_id": item.id, "device_id": item.action_device_id, "flow_id": item.flow_id, "flow_node_id": item.flow_node_id
|
||||||
}));
|
}));
|
||||||
Ok(false)
|
Ok(false)
|
||||||
}
|
}
|
||||||
@@ -124,12 +144,18 @@ async fn run_automations(state: &AppState) -> Result<()> {
|
|||||||
item.updated_at = Utc::now();
|
item.updated_at = Utc::now();
|
||||||
state.db.save_automation(&item)?;
|
state.db.save_automation(&item)?;
|
||||||
state.log("info", "automation.fired", &format!("Automation {} fired", item.name), json!({
|
state.log("info", "automation.fired", &format!("Automation {} fired", item.name), json!({
|
||||||
"automation_id": item.id, "group_id": item.action_group_id, "device_id": item.action_device_id
|
"automation_id": item.id, "flow_id": item.flow_id, "flow_node_id": item.flow_node_id, "group_id": item.action_group_id, "device_id": item.action_device_id
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
Ok(false) => {
|
Ok(false) => {
|
||||||
// Ownership suppression is not an execution. Do not consume cooldown (M3),
|
// Ownership suppression is not an execution. Do not consume cooldown (M3),
|
||||||
// so a still-valid trigger may run as soon as the higher-priority owner leaves.
|
// so a still-valid trigger may run as soon as the higher-priority owner leaves.
|
||||||
|
if item.flow_id.is_some() {
|
||||||
|
state.log("info", "flow.action_suppressed", &format!("Flow action {} was suppressed by current ownership", item.name), json!({
|
||||||
|
"automation_id": item.id, "flow_id": item.flow_id, "flow_node_id": item.flow_node_id,
|
||||||
|
"group_id": item.action_group_id, "zone_id": item.action_zone_id, "device_id": item.action_device_id
|
||||||
|
}));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
// A failed action is still an execution attempt. Apply the configured cooldown
|
// A failed action is still an execution attempt. Apply the configured cooldown
|
||||||
@@ -137,7 +163,7 @@ async fn run_automations(state: &AppState) -> Result<()> {
|
|||||||
item.last_fired_at = Some(Utc::now());
|
item.last_fired_at = Some(Utc::now());
|
||||||
item.updated_at = Utc::now();
|
item.updated_at = Utc::now();
|
||||||
state.db.save_automation(&item)?;
|
state.db.save_automation(&item)?;
|
||||||
state.log("error", "automation.error", &err.to_string(), json!({"automation_id": item.id}));
|
state.log("error", "automation.error", &err.to_string(), json!({"automation_id": item.id, "flow_id": item.flow_id, "flow_node_id": item.flow_node_id}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -156,6 +182,11 @@ fn device_blocked_by_local_thermostat(device_id: &str, zones: &[Zone]) -> bool {
|
|||||||
zones.iter().any(|zone| zone.device_id == device_id && zone.local_thermostat_power.is_some())
|
zones.iter().any(|zone| zone.device_id == device_id && zone.local_thermostat_power.is_some())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn device_blocked_by_temporary_thermostat(device_id: &str, zones: &[Zone]) -> bool {
|
||||||
|
let now = Utc::now();
|
||||||
|
zones.iter().any(|zone| zone.device_id == device_id && temporary_quick_thermostat_is_active(zone, now.clone()))
|
||||||
|
}
|
||||||
|
|
||||||
fn find_temperature(devices: &[Device], device_id: Option<&str>) -> Option<f64> {
|
fn find_temperature(devices: &[Device], device_id: Option<&str>) -> Option<f64> {
|
||||||
let id = device_id?;
|
let id = device_id?;
|
||||||
// Never fire a temperature automation from stale cached data of an offline/disabled unit.
|
// Never fire a temperature automation from stale cached data of an offline/disabled unit.
|
||||||
@@ -182,3 +213,361 @@ fn time_automation_due(item: &Automation, now: DateTime<Local>) -> bool {
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
fn flow_compare(actual: f64, operator: &str, expected: f64) -> bool {
|
||||||
|
match operator { "lt" => actual < expected, "lte" => actual <= expected, "gt" => actual > expected, "gte" => actual >= expected, "eq" => (actual - expected).abs() < 0.0001, "neq" => (actual - expected).abs() >= 0.0001, _ => false }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn flow_value_text(value: &Value) -> String {
|
||||||
|
match value {
|
||||||
|
Value::String(v) => v.clone(),
|
||||||
|
Value::Bool(v) => v.to_string(),
|
||||||
|
Value::Number(v) => v.to_string(),
|
||||||
|
Value::Null => "null".into(),
|
||||||
|
other => other.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn flow_compare_value(actual: &Value, operator: &str, expected: &Value) -> bool {
|
||||||
|
if let (Some(a), Some(e)) = (actual.as_f64(), expected.as_f64()) {
|
||||||
|
return flow_compare(a, operator, e);
|
||||||
|
}
|
||||||
|
if matches!(operator, "lt" | "lte" | "gt" | "gte") {
|
||||||
|
let a = flow_value_text(actual).parse::<f64>().ok();
|
||||||
|
let e = flow_value_text(expected).parse::<f64>().ok();
|
||||||
|
return a.zip(e).map(|(a, e)| flow_compare(a, operator, e)).unwrap_or(false);
|
||||||
|
}
|
||||||
|
let equal = flow_value_text(actual).eq_ignore_ascii_case(&flow_value_text(expected));
|
||||||
|
if operator == "neq" { !equal } else { equal }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn flow_device_state_value(device: &Device, field: &str) -> Option<Value> {
|
||||||
|
Some(match field {
|
||||||
|
"enabled" => json!(device.enabled),
|
||||||
|
"online" => json!(device.online),
|
||||||
|
"power" => json!(device.power),
|
||||||
|
"mode" => json!(device.mode),
|
||||||
|
"fan_speed" => json!(device.fan_speed),
|
||||||
|
"swing_vertical" => json!(device.swing_vertical),
|
||||||
|
"swing_horizontal" => json!(device.swing_horizontal),
|
||||||
|
"quiet" => json!(device.quiet),
|
||||||
|
"turbo" => json!(device.turbo),
|
||||||
|
"light" => json!(device.light),
|
||||||
|
"air" => json!(device.air),
|
||||||
|
"xfan" => json!(device.xfan),
|
||||||
|
"health" => json!(device.health),
|
||||||
|
"sleep" => json!(device.sleep),
|
||||||
|
_ => return None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn flow_zone_state_value(zone: &Zone, field: &str) -> Option<Value> {
|
||||||
|
Some(match field {
|
||||||
|
"enabled" => json!(zone.enabled),
|
||||||
|
"mode" => json!(zone.mode),
|
||||||
|
"active_preset" => json!(zone.active_preset),
|
||||||
|
"demand" => json!(zone.demand),
|
||||||
|
"control_owner" => json!(zone.control_owner),
|
||||||
|
"device_manual_override" => json!(zone.device_manual_override),
|
||||||
|
"local_thermostat_power" => zone.local_thermostat_power.map(Value::Bool).unwrap_or(Value::Null),
|
||||||
|
_ => return None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn flow_leaf_observation(
|
||||||
|
state: &AppState,
|
||||||
|
devices: &[Device],
|
||||||
|
zones: &[Zone],
|
||||||
|
outdoor_temperature: Option<f64>,
|
||||||
|
condition: &crate::models::FlowCondition,
|
||||||
|
now: &DateTime<Local>,
|
||||||
|
settings: &crate::models::RuntimeSettings,
|
||||||
|
overrides: &HashMap<String, Value>,
|
||||||
|
) -> Result<(bool, Value), AppError> {
|
||||||
|
let c = &condition.config;
|
||||||
|
let override_value = overrides.get(&condition.id).cloned();
|
||||||
|
let (matched, actual) = match condition.kind.as_str() {
|
||||||
|
"weekday" => {
|
||||||
|
let actual = override_value.unwrap_or_else(|| json!(now.weekday().number_from_monday()));
|
||||||
|
let day = actual.as_u64().unwrap_or(now.weekday().number_from_monday() as u64);
|
||||||
|
let matched = c.get("days").and_then(Value::as_array)
|
||||||
|
.map(|days| days.iter().filter_map(Value::as_u64).any(|expected| expected == day)).unwrap_or(false);
|
||||||
|
(matched, json!(day))
|
||||||
|
}
|
||||||
|
"time_range" => {
|
||||||
|
let actual = override_value.and_then(|v| v.as_str().map(str::to_string)).unwrap_or_else(|| now.format("%H:%M").to_string());
|
||||||
|
let current = NaiveTime::parse_from_str(&actual, "%H:%M").unwrap_or_else(|_| now.time());
|
||||||
|
let start = c.get("start").and_then(Value::as_str).and_then(|v| NaiveTime::parse_from_str(v, "%H:%M").ok());
|
||||||
|
let end = c.get("end").and_then(Value::as_str).and_then(|v| NaiveTime::parse_from_str(v, "%H:%M").ok());
|
||||||
|
let matched = match (start, end) {
|
||||||
|
(Some(start), Some(end)) if start == end => true,
|
||||||
|
(Some(start), Some(end)) if start < end => current >= start && current < end,
|
||||||
|
(Some(start), Some(end)) => current >= start || current < end,
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
(matched, json!(actual))
|
||||||
|
}
|
||||||
|
"date_range" => {
|
||||||
|
let actual = override_value.and_then(|v| v.as_str().map(str::to_string)).unwrap_or_else(|| now.date_naive().format("%Y-%m-%d").to_string());
|
||||||
|
let current = chrono::NaiveDate::parse_from_str(&actual, "%Y-%m-%d").unwrap_or_else(|_| now.date_naive());
|
||||||
|
let start = c.get("start").and_then(Value::as_str).and_then(|v| chrono::NaiveDate::parse_from_str(v, "%Y-%m-%d").ok());
|
||||||
|
let end = c.get("end").and_then(Value::as_str).and_then(|v| chrono::NaiveDate::parse_from_str(v, "%Y-%m-%d").ok());
|
||||||
|
(matches!((start, end), (Some(a), Some(b)) if current >= a && current <= b), json!(actual))
|
||||||
|
}
|
||||||
|
"outdoor_temperature" => {
|
||||||
|
let actual = if let Some(v) = override_value { v.as_f64() } else { outdoor_temperature };
|
||||||
|
let expected = c.get("value").and_then(Value::as_f64);
|
||||||
|
(actual.zip(expected).map(|(a, e)| flow_compare(a, c.get("operator").and_then(Value::as_str).unwrap_or("lt"), e)).unwrap_or(false), actual.map_or(Value::Null, |v| json!(v)))
|
||||||
|
}
|
||||||
|
"device_temperature" => {
|
||||||
|
let id = c.get("device_id").and_then(Value::as_str).unwrap_or("");
|
||||||
|
let actual = if let Some(v) = override_value { v.as_f64() } else { find_temperature(devices, Some(id)) };
|
||||||
|
let expected = c.get("value").and_then(Value::as_f64);
|
||||||
|
(actual.zip(expected).map(|(a, e)| flow_compare(a, c.get("operator").and_then(Value::as_str).unwrap_or("lt"), e)).unwrap_or(false), actual.map_or(Value::Null, |v| json!(v)))
|
||||||
|
}
|
||||||
|
"zone_temperature" => {
|
||||||
|
let id = c.get("zone_id").and_then(Value::as_str).unwrap_or("");
|
||||||
|
let actual = if let Some(v) = override_value { v.as_f64() } else { zones.iter().find(|zone| zone.id == id).and_then(|zone| zone.current_temperature) };
|
||||||
|
let expected = c.get("value").and_then(Value::as_f64);
|
||||||
|
(actual.zip(expected).map(|(a, e)| flow_compare(a, c.get("operator").and_then(Value::as_str).unwrap_or("lt"), e)).unwrap_or(false), actual.map_or(Value::Null, |v| json!(v)))
|
||||||
|
}
|
||||||
|
"ha_state" => {
|
||||||
|
let entity = c.get("entity_id").and_then(Value::as_str).unwrap_or("");
|
||||||
|
let actual = match override_value {
|
||||||
|
Some(value) => value,
|
||||||
|
None => match home_assistant::read_state(&state.http, &settings.home_assistant, Some(entity)).await {
|
||||||
|
Ok(value) => json!(value),
|
||||||
|
Err(err) => return Ok((false, json!({"error": err.to_string(), "entity_id": entity}))),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let expected = c.get("value").cloned().unwrap_or(Value::Null);
|
||||||
|
(flow_compare_value(&actual, c.get("operator").and_then(Value::as_str).unwrap_or("eq"), &expected), actual)
|
||||||
|
}
|
||||||
|
"ha_numeric" => {
|
||||||
|
let entity = c.get("entity_id").and_then(Value::as_str).unwrap_or("");
|
||||||
|
let actual = match override_value {
|
||||||
|
Some(value) => value,
|
||||||
|
None => {
|
||||||
|
let raw = match home_assistant::read_state(&state.http, &settings.home_assistant, Some(entity)).await {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(err) => return Ok((false, json!({"error": err.to_string(), "entity_id": entity}))),
|
||||||
|
};
|
||||||
|
match raw.parse::<f64>() {
|
||||||
|
Ok(value) => json!(value),
|
||||||
|
Err(_) => return Ok((false, json!({"error": "non_numeric_state", "entity_id": entity, "state": raw}))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let expected = c.get("value").cloned().unwrap_or(Value::Null);
|
||||||
|
(flow_compare_value(&actual, c.get("operator").and_then(Value::as_str).unwrap_or("lt"), &expected), actual)
|
||||||
|
}
|
||||||
|
"ha_attribute" => {
|
||||||
|
let entity = c.get("entity_id").and_then(Value::as_str).unwrap_or("");
|
||||||
|
let attribute = c.get("attribute").and_then(Value::as_str).unwrap_or("");
|
||||||
|
let actual = match override_value {
|
||||||
|
Some(value) => value,
|
||||||
|
None => {
|
||||||
|
let payload = match home_assistant::read_entity(&state.http, &settings.home_assistant, Some(entity)).await {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(err) => return Ok((false, json!({"error": err.to_string(), "entity_id": entity, "attribute": attribute}))),
|
||||||
|
};
|
||||||
|
let Some(value) = payload.get("attributes").and_then(|attrs| attrs.get(attribute)).cloned() else {
|
||||||
|
return Ok((false, json!({"error": "missing_attribute", "entity_id": entity, "attribute": attribute})));
|
||||||
|
};
|
||||||
|
value
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let expected = c.get("value").cloned().unwrap_or(Value::Null);
|
||||||
|
(flow_compare_value(&actual, c.get("operator").and_then(Value::as_str).unwrap_or("eq"), &expected), actual)
|
||||||
|
}
|
||||||
|
"ha_available" => {
|
||||||
|
let entity = c.get("entity_id").and_then(Value::as_str).unwrap_or("");
|
||||||
|
let actual = match override_value {
|
||||||
|
Some(value) => value,
|
||||||
|
None => match home_assistant::read_state(&state.http, &settings.home_assistant, Some(entity)).await {
|
||||||
|
Ok(value) => Value::String(value),
|
||||||
|
Err(err) => return Ok((false, json!({"error": err.to_string(), "entity_id": entity}))),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let matched = actual.as_bool().unwrap_or_else(|| actual.as_str().map(|value| {
|
||||||
|
let value = value.trim();
|
||||||
|
!value.is_empty() && !value.eq_ignore_ascii_case("unknown") && !value.eq_ignore_ascii_case("unavailable")
|
||||||
|
}).unwrap_or(false));
|
||||||
|
(matched, actual)
|
||||||
|
}
|
||||||
|
"house_mode" => {
|
||||||
|
let actual = override_value.unwrap_or_else(|| json!(settings.house_mode));
|
||||||
|
let expected = c.get("value").cloned().unwrap_or(Value::Null);
|
||||||
|
(flow_compare_value(&actual, c.get("operator").and_then(Value::as_str).unwrap_or("eq"), &expected), actual)
|
||||||
|
}
|
||||||
|
"device_state" => {
|
||||||
|
let id = c.get("device_id").and_then(Value::as_str).unwrap_or("");
|
||||||
|
let field = c.get("field").and_then(Value::as_str).unwrap_or("");
|
||||||
|
let actual = override_value.unwrap_or_else(|| devices.iter().find(|device| device.id == id).and_then(|device| flow_device_state_value(device, field)).unwrap_or(Value::Null));
|
||||||
|
let expected = c.get("value").cloned().unwrap_or(Value::Null);
|
||||||
|
(flow_compare_value(&actual, c.get("operator").and_then(Value::as_str).unwrap_or("eq"), &expected), actual)
|
||||||
|
}
|
||||||
|
"zone_state" => {
|
||||||
|
let id = c.get("zone_id").and_then(Value::as_str).unwrap_or("");
|
||||||
|
let field = c.get("field").and_then(Value::as_str).unwrap_or("");
|
||||||
|
let actual = override_value.unwrap_or_else(|| zones.iter().find(|zone| zone.id == id).and_then(|zone| flow_zone_state_value(zone, field)).unwrap_or(Value::Null));
|
||||||
|
let expected = c.get("value").cloned().unwrap_or(Value::Null);
|
||||||
|
(flow_compare_value(&actual, c.get("operator").and_then(Value::as_str).unwrap_or("eq"), &expected), actual)
|
||||||
|
}
|
||||||
|
"group_state" => {
|
||||||
|
let id = c.get("group_id").and_then(Value::as_str).unwrap_or("");
|
||||||
|
let field = c.get("field").and_then(Value::as_str).unwrap_or("");
|
||||||
|
let actual = override_value.unwrap_or_else(|| state.db.get_group(id).ok().flatten().and_then(|group| match field {
|
||||||
|
"power_enabled" => Some(json!(group.power_enabled)),
|
||||||
|
_ => None,
|
||||||
|
}).unwrap_or(Value::Null));
|
||||||
|
let expected = c.get("value").cloned().unwrap_or(Value::Null);
|
||||||
|
(flow_compare_value(&actual, c.get("operator").and_then(Value::as_str).unwrap_or("eq"), &expected), actual)
|
||||||
|
}
|
||||||
|
"night_mode" => {
|
||||||
|
let actual = override_value.unwrap_or_else(|| json!(night_mode_active(&settings.night_mode, now.time())));
|
||||||
|
(actual.as_bool().unwrap_or(false), actual)
|
||||||
|
}
|
||||||
|
"constant" => {
|
||||||
|
let actual = override_value.unwrap_or_else(|| c.get("value").cloned().unwrap_or(Value::Bool(false)));
|
||||||
|
(actual.as_bool().unwrap_or(false), actual)
|
||||||
|
}
|
||||||
|
_ => (false, Value::Null),
|
||||||
|
};
|
||||||
|
Ok((matched, actual))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn evaluate_flow_conditions_trace(
|
||||||
|
state: &AppState,
|
||||||
|
devices: &[Device],
|
||||||
|
conditions: &[crate::models::FlowCondition],
|
||||||
|
now: DateTime<Local>,
|
||||||
|
overrides: &HashMap<String, Value>,
|
||||||
|
) -> Result<(bool, Vec<Value>), AppError> {
|
||||||
|
if conditions.is_empty() { return Ok((false, Vec::new())); }
|
||||||
|
let settings = state.settings.read().await.clone();
|
||||||
|
let zones = state.db.list_zones()?;
|
||||||
|
let outdoor_temperature = *state.outdoor_temperature.read().await;
|
||||||
|
let mut trace = Vec::new();
|
||||||
|
|
||||||
|
if conditions.iter().all(|condition| condition.id.is_empty()) {
|
||||||
|
let mut final_value = true;
|
||||||
|
for condition in conditions {
|
||||||
|
let (matched, actual) = flow_leaf_observation(state, devices, &zones, outdoor_temperature, condition, &now, &settings, overrides).await?;
|
||||||
|
final_value &= matched;
|
||||||
|
trace.push(json!({"node_id": condition.id, "kind": condition.kind, "matched": matched, "actual": actual, "expected": condition.config.get("value")}));
|
||||||
|
}
|
||||||
|
return Ok((final_value, trace));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut values = HashMap::<String, bool>::new();
|
||||||
|
let mut final_id = None::<String>;
|
||||||
|
for condition in conditions {
|
||||||
|
if condition.id.is_empty() { return Ok((false, trace)); }
|
||||||
|
let (matched, actual) = match condition.kind.as_str() {
|
||||||
|
"logic_and" => {
|
||||||
|
let value = !condition.inputs.is_empty() && condition.inputs.iter().all(|id| values.get(id).copied().unwrap_or(false));
|
||||||
|
(value, json!(condition.inputs.iter().map(|id| values.get(id).copied().unwrap_or(false)).collect::<Vec<_>>()))
|
||||||
|
}
|
||||||
|
"logic_or" => {
|
||||||
|
let value = !condition.inputs.is_empty() && condition.inputs.iter().any(|id| values.get(id).copied().unwrap_or(false));
|
||||||
|
(value, json!(condition.inputs.iter().map(|id| values.get(id).copied().unwrap_or(false)).collect::<Vec<_>>()))
|
||||||
|
}
|
||||||
|
"logic_not" => {
|
||||||
|
let value = condition.inputs.len() == 1 && !values.get(&condition.inputs[0]).copied().unwrap_or(false);
|
||||||
|
(value, json!(condition.inputs.first().and_then(|id| values.get(id)).copied()))
|
||||||
|
}
|
||||||
|
_ if flow_condition_kind_runtime(&condition.kind) => {
|
||||||
|
let predecessors_match = condition.inputs.iter().all(|id| values.get(id).copied().unwrap_or(false));
|
||||||
|
let (leaf, actual) = flow_leaf_observation(state, devices, &zones, outdoor_temperature, condition, &now, &settings, overrides).await?;
|
||||||
|
(predecessors_match && leaf, actual)
|
||||||
|
}
|
||||||
|
_ => (false, Value::Null),
|
||||||
|
};
|
||||||
|
values.insert(condition.id.clone(), matched);
|
||||||
|
final_id = Some(condition.id.clone());
|
||||||
|
trace.push(json!({
|
||||||
|
"node_id": condition.id,
|
||||||
|
"kind": condition.kind,
|
||||||
|
"matched": matched,
|
||||||
|
"actual": actual,
|
||||||
|
"expected": condition.config.get("value"),
|
||||||
|
"inputs": condition.inputs
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
Ok((final_id.and_then(|id| values.get(&id).copied()).unwrap_or(false), trace))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn flow_conditions_match(state: &AppState, devices: &[Device], conditions: &[crate::models::FlowCondition]) -> Result<bool, AppError> {
|
||||||
|
let overrides = HashMap::new();
|
||||||
|
evaluate_flow_conditions_trace(state, devices, conditions, Local::now(), &overrides).await.map(|(matched, _)| matched)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn flow_condition_kind_runtime(kind: &str) -> bool {
|
||||||
|
matches!(kind,
|
||||||
|
"weekday" | "time_range" | "date_range" | "outdoor_temperature" | "device_temperature" |
|
||||||
|
"zone_temperature" | "ha_state" | "ha_numeric" | "ha_attribute" | "ha_available" | "house_mode" |
|
||||||
|
"device_state" | "zone_state" | "group_state" | "night_mode" | "constant"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn apply_flow_zone_action(state: &AppState, zone_id: &str, preset: Option<&str>, action: &DeviceCommand) -> Result<bool, AppError> {
|
||||||
|
let _schedule_guard = state.lock_schedule_operation().await;
|
||||||
|
// Match the canonical thermostat ordering used by Web/HA zone control. This prevents
|
||||||
|
// a Flow action from racing a thermostat arbitration cycle that already holds a stale snapshot.
|
||||||
|
let _cycle_guard = state.lock_zone_control_cycle().await;
|
||||||
|
let _zone_guard = state.lock_zone_operation(zone_id).await;
|
||||||
|
let Some(mut zone) = state.db.get_zone(zone_id)? else { return Err(AppError::NotFound(format!("zone {zone_id}"))); };
|
||||||
|
if zone.device_manual_override || zone.local_thermostat_power.is_some() || temporary_quick_thermostat_is_active(&zone, Utc::now()) { return Ok(false); }
|
||||||
|
// Match the existing automatic-device ownership semantics: a disabled thermostat zone
|
||||||
|
// remains an explicit gate unless this Flow action is the actor re-enabling it.
|
||||||
|
if !zone.enabled && action.power != Some(true) { return Ok(false); }
|
||||||
|
let device_id = zone.device_id.clone();
|
||||||
|
if let Some(power) = action.power { zone.enabled = power; }
|
||||||
|
if let Some(mode) = action.mode.as_deref() {
|
||||||
|
match mode { "auto" => zone.inherit_house_mode = true, "heat" | "cool" => { zone.mode = mode.to_string(); zone.inherit_house_mode = false; }, _ => return Err(AppError::BadRequest("unsupported Flow thermostat mode".into())) }
|
||||||
|
}
|
||||||
|
match preset.unwrap_or("auto") {
|
||||||
|
"auto" => { zone.manual_preset = None; zone.manual_setpoint = None; zone.manual_override_until = None; }
|
||||||
|
"custom" => {
|
||||||
|
let target = action.target_temperature.ok_or_else(|| AppError::BadRequest("Flow custom thermostat action has no target".into()))?;
|
||||||
|
zone.manual_preset = Some("custom".into()); zone.manual_setpoint = Some((target.clamp(8.0,30.0)*2.0).round()/2.0);
|
||||||
|
zone.manual_override_until = next_schedule_boundary_utc(&zone.id, &state.db.list_schedules()?, Local::now());
|
||||||
|
}
|
||||||
|
value @ ("comfort" | "sleep" | "away") => {
|
||||||
|
zone.manual_preset = Some(value.into()); zone.manual_setpoint = None;
|
||||||
|
zone.manual_override_until = next_schedule_boundary_utc(&zone.id, &state.db.list_schedules()?, Local::now());
|
||||||
|
}
|
||||||
|
_ => return Err(AppError::BadRequest("unsupported Flow thermostat preset".into())),
|
||||||
|
}
|
||||||
|
rearm_compressor_queue(&mut zone);
|
||||||
|
if action.power == Some(false) {
|
||||||
|
zone.demand = false;
|
||||||
|
zone.demand_since = None;
|
||||||
|
zone.effective_mode = "off".into();
|
||||||
|
zone.device_setpoint = None;
|
||||||
|
}
|
||||||
|
zone.revision = zone.revision.saturating_add(1);
|
||||||
|
zone.updated_at = Utc::now();
|
||||||
|
zone.control_owner = "automation".into();
|
||||||
|
zone.control_source = "automation.flow".into();
|
||||||
|
zone.control_reason = "Visual Flow automation".into();
|
||||||
|
let schedules = state.db.list_schedules()?;
|
||||||
|
let house_mode = state.settings.read().await.house_mode.clone();
|
||||||
|
refresh_control_ownership(&mut zone, true);
|
||||||
|
if zone.control_owner == "automation" {
|
||||||
|
zone.control_source = "automation.flow".into();
|
||||||
|
zone.control_reason = "Visual Flow automation".into();
|
||||||
|
}
|
||||||
|
refresh_zone_runtime_target(&mut zone, &schedules, &house_mode);
|
||||||
|
state.db.save_zone(&zone)?; state.broadcast("zone.updated", serde_json::to_value(&zone)?); state.wake_zone_control();
|
||||||
|
if action.power == Some(false) {
|
||||||
|
// Disabled zones are intentionally skipped by the normal thermostat cycle. Perform the
|
||||||
|
// physical OFF under the canonical zone -> device lock order so the durable Flow intent
|
||||||
|
// cannot leave a unit running and polling/manual control cannot interleave with the frame.
|
||||||
|
let _device_guard = state.lock_device_operation(&device_id).await;
|
||||||
|
send_command_locked_forced(state, &device_id, DeviceCommand { power: Some(false), ..Default::default() }).await?;
|
||||||
|
}
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ pub async fn build_control_plan(state: &AppState) -> Result<ControlPlan, AppErro
|
|||||||
let devices = state.db.list_devices()?;
|
let devices = state.db.list_devices()?;
|
||||||
let zones = state.db.list_zones()?;
|
let zones = state.db.list_zones()?;
|
||||||
let groups = state.db.list_groups()?;
|
let groups = state.db.list_groups()?;
|
||||||
|
let zone_names: HashMap<String, String> = zones.iter().map(|zone| (zone.id.clone(), zone.name.clone())).collect();
|
||||||
let house_preset = zones.first().and_then(|first| {
|
let house_preset = zones.first().and_then(|first| {
|
||||||
let first_preset = first.manual_preset.as_deref().unwrap_or("auto");
|
let first_preset = first.manual_preset.as_deref().unwrap_or("auto");
|
||||||
zones.iter().all(|zone| zone.manual_preset.as_deref().unwrap_or("auto") == first_preset)
|
zones.iter().all(|zone| zone.manual_preset.as_deref().unwrap_or("auto") == first_preset)
|
||||||
@@ -111,10 +112,13 @@ pub async fn build_control_plan(state: &AppState) -> Result<ControlPlan, AppErro
|
|||||||
}
|
}
|
||||||
let mut rules = Vec::new();
|
let mut rules = Vec::new();
|
||||||
for item in state.db.list_automations()? {
|
for item in state.db.list_automations()? {
|
||||||
|
let action_zone_name = item.action_zone_id.as_deref()
|
||||||
|
.and_then(|id| zone_names.get(id))
|
||||||
|
.cloned();
|
||||||
let action_group_name = item.action_group_id.as_deref()
|
let action_group_name = item.action_group_id.as_deref()
|
||||||
.and_then(|id| groups.iter().find(|group| group.id == id))
|
.and_then(|id| groups.iter().find(|group| group.id == id))
|
||||||
.map(|group| group.name.clone());
|
.map(|group| group.name.clone());
|
||||||
let action_name = action_group_name.clone().unwrap_or_else(|| {
|
let action_name = action_zone_name.or_else(|| action_group_name.clone()).unwrap_or_else(|| {
|
||||||
devices.iter().find(|device| device.id == item.action_device_id)
|
devices.iter().find(|device| device.id == item.action_device_id)
|
||||||
.map(|device| device.name.clone())
|
.map(|device| device.name.clone())
|
||||||
.unwrap_or_else(|| item.action_device_id.clone())
|
.unwrap_or_else(|| item.action_device_id.clone())
|
||||||
|
|||||||
+14
-3
@@ -17,7 +17,7 @@ mod tests {
|
|||||||
let item = Schedule {
|
let item = Schedule {
|
||||||
id: "1".into(), zone_id: "z".into(), name: "night".into(), enabled: true,
|
id: "1".into(), zone_id: "z".into(), name: "night".into(), enabled: true,
|
||||||
weekdays: vec![1], start_time: "22:00".into(), end_time: "06:00".into(), preset: "custom".into(), setpoint: 20.0,
|
weekdays: vec![1], start_time: "22:00".into(), end_time: "06:00".into(), preset: "custom".into(), setpoint: 20.0,
|
||||||
created_at: Utc::now(), updated_at: Utc::now(),
|
created_at: Utc::now(), updated_at: Utc::now(), flow_id: None, flow_node_id: None,
|
||||||
};
|
};
|
||||||
assert!(schedule_active(&item, now));
|
assert!(schedule_active(&item, now));
|
||||||
}
|
}
|
||||||
@@ -26,7 +26,7 @@ mod tests {
|
|||||||
Schedule {
|
Schedule {
|
||||||
id: id.into(), zone_id: "z".into(), name: id.into(), enabled: true,
|
id: id.into(), zone_id: "z".into(), name: id.into(), enabled: true,
|
||||||
weekdays, start_time: start.into(), end_time: end.into(), preset: "comfort".into(), setpoint: 21.0,
|
weekdays, start_time: start.into(), end_time: end.into(), preset: "comfort".into(), setpoint: 21.0,
|
||||||
created_at: Utc::now(), updated_at: Utc::now(),
|
created_at: Utc::now(), updated_at: Utc::now(), flow_id: None, flow_node_id: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,7 +94,7 @@ mod tests {
|
|||||||
trigger_device_id: None, threshold: None, at_time: Some("10:15".into()),
|
trigger_device_id: None, threshold: None, at_time: Some("10:15".into()),
|
||||||
action_device_id: "d".into(), action_group_id: None, action_preset: None,
|
action_device_id: "d".into(), action_group_id: None, action_preset: None,
|
||||||
action: DeviceCommand { power: Some(true), ..Default::default() }, cooldown_seconds: 30,
|
action: DeviceCommand { power: Some(true), ..Default::default() }, cooldown_seconds: 30,
|
||||||
last_fired_at: None, created_at: Utc::now(), updated_at: Utc::now(),
|
last_fired_at: None, action_zone_id: None, action_zone_preset: None, flow_conditions: vec![], flow_id: None, flow_node_id: None, created_at: Utc::now(), updated_at: Utc::now(),
|
||||||
};
|
};
|
||||||
assert!(time_automation_due(&item, now.clone()));
|
assert!(time_automation_due(&item, now.clone()));
|
||||||
item.last_fired_at = Some((now.clone() - chrono::Duration::seconds(35)).with_timezone(&Utc));
|
item.last_fired_at = Some((now.clone() - chrono::Duration::seconds(35)).with_timezone(&Utc));
|
||||||
@@ -173,6 +173,9 @@ mod tests {
|
|||||||
assert!(!automation_action_conflicts_with_thermostat(&DeviceCommand { target_temperature: Some(22.0), ..Default::default() }));
|
assert!(!automation_action_conflicts_with_thermostat(&DeviceCommand { target_temperature: Some(22.0), ..Default::default() }));
|
||||||
assert!(automation_action_conflicts_with_thermostat(&DeviceCommand { fan_speed: Some(3), ..Default::default() }));
|
assert!(automation_action_conflicts_with_thermostat(&DeviceCommand { fan_speed: Some(3), ..Default::default() }));
|
||||||
assert!(automation_action_conflicts_with_thermostat(&DeviceCommand { quiet: Some(true), ..Default::default() }));
|
assert!(automation_action_conflicts_with_thermostat(&DeviceCommand { quiet: Some(true), ..Default::default() }));
|
||||||
|
assert!(automation_action_conflicts_with_thermostat(&DeviceCommand { mode: Some("fan".into()), ..Default::default() }));
|
||||||
|
assert!(automation_action_conflicts_with_thermostat(&DeviceCommand { mode: Some("dry".into()), ..Default::default() }));
|
||||||
|
assert!(!automation_action_conflicts_with_thermostat(&DeviceCommand { mode: Some("heat".into()), ..Default::default() }));
|
||||||
assert!(!automation_action_conflicts_with_thermostat(&DeviceCommand { light: Some(false), turbo: Some(true), ..Default::default() }));
|
assert!(!automation_action_conflicts_with_thermostat(&DeviceCommand { light: Some(false), turbo: Some(true), ..Default::default() }));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,6 +198,14 @@ mod tests {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn active_temporary_thermostat_blocks_direct_automation() {
|
||||||
|
let now = Utc::now();
|
||||||
|
let mut zone = test_zone("device");
|
||||||
|
zone.temporary_quick_thermostat = Some(temporary_session(now));
|
||||||
|
assert!(device_blocked_by_temporary_thermostat(&zone.device_id, std::slice::from_ref(&zone)));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn local_thermostat_resume_clears_only_local_quick_control_state() {
|
fn local_thermostat_resume_clears_only_local_quick_control_state() {
|
||||||
let mut zone = test_zone("device");
|
let mut zone = test_zone("device");
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ async fn send_automatic_device_command_if_owned(
|
|||||||
if device_blocked_by_disabled_zone(device_id, &zones)
|
if device_blocked_by_disabled_zone(device_id, &zones)
|
||||||
|| device_blocked_by_manual_override(device_id, &zones)
|
|| device_blocked_by_manual_override(device_id, &zones)
|
||||||
|| device_blocked_by_local_thermostat(device_id, &zones)
|
|| device_blocked_by_local_thermostat(device_id, &zones)
|
||||||
|
|| device_blocked_by_temporary_thermostat(device_id, &zones)
|
||||||
{
|
{
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
@@ -63,6 +64,9 @@ async fn apply_automatic_device_action(
|
|||||||
// Direct automation target/setpoint is translated into zone state and may use the next
|
// Direct automation target/setpoint is translated into zone state and may use the next
|
||||||
// schedule boundary. Serialize that derivation with schedule edits before locking the zone.
|
// schedule boundary. Serialize that derivation with schedule edits before locking the zone.
|
||||||
let _schedule_guard = state.lock_schedule_operation().await;
|
let _schedule_guard = state.lock_schedule_operation().await;
|
||||||
|
// Device automations can alter durable thermostat state. Serialize that transition with
|
||||||
|
// the thermostat cycle so it cannot act on a snapshot taken before this automation.
|
||||||
|
let _cycle_guard = state.lock_zone_control_cycle().await;
|
||||||
let zones = state.db.list_zones()?;
|
let zones = state.db.list_zones()?;
|
||||||
let Some(zone_id) = zones.iter().find(|zone| zone.device_id == device_id).map(|zone| zone.id.clone()) else {
|
let Some(zone_id) = zones.iter().find(|zone| zone.device_id == device_id).map(|zone| zone.id.clone()) else {
|
||||||
return send_automatic_device_command_if_owned(state, device_id, command).await;
|
return send_automatic_device_command_if_owned(state, device_id, command).await;
|
||||||
@@ -71,7 +75,7 @@ async fn apply_automatic_device_action(
|
|||||||
let _zone_guard = state.lock_zone_operation(&zone_id).await;
|
let _zone_guard = state.lock_zone_operation(&zone_id).await;
|
||||||
let _device_guard = state.lock_device_operation(device_id).await;
|
let _device_guard = state.lock_device_operation(device_id).await;
|
||||||
let mut zone = state.db.get_zone(&zone_id)?.ok_or_else(|| AppError::NotFound(format!("zone {zone_id}")))?;
|
let mut zone = state.db.get_zone(&zone_id)?.ok_or_else(|| AppError::NotFound(format!("zone {zone_id}")))?;
|
||||||
if zone.device_manual_override || zone.local_thermostat_power.is_some() {
|
if zone.device_manual_override || zone.local_thermostat_power.is_some() || temporary_quick_thermostat_is_active(&zone, Utc::now()) {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
// A power-on automation is an explicit domain transition and may re-enable a zone that
|
// A power-on automation is an explicit domain transition and may re-enable a zone that
|
||||||
|
|||||||
@@ -674,6 +674,9 @@ async fn control_zones(state: &AppState) -> Result<()> {
|
|||||||
"quiet": updated_device.quiet,
|
"quiet": updated_device.quiet,
|
||||||
"sleep": updated_device.sleep,
|
"sleep": updated_device.sleep,
|
||||||
"night_mode": night_active,
|
"night_mode": night_active,
|
||||||
|
"schedule_id": active_schedule.map(|item| item.id.as_str()),
|
||||||
|
"flow_id": active_schedule.and_then(|item| item.flow_id.as_deref()),
|
||||||
|
"flow_node_id": active_schedule.and_then(|item| item.flow_node_id.as_deref()),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
Ok(None) => {
|
Ok(None) => {
|
||||||
|
|||||||
@@ -107,3 +107,40 @@ mod tests {
|
|||||||
assert_eq!(resolve_entity_id(&settings, None).as_deref(), Some("sensor.salon_temperature"));
|
assert_eq!(resolve_entity_id(&settings, None).as_deref(), Some("sensor.salon_temperature"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Read a complete Home Assistant entity document for Flow conditions. Keeping this helper
|
||||||
|
/// centralized means state and attribute blocks share the same URL validation, TLS and auth path.
|
||||||
|
pub async fn read_entity(
|
||||||
|
default_client: &reqwest::Client,
|
||||||
|
settings: &HomeAssistantSettings,
|
||||||
|
entity_override: Option<&str>,
|
||||||
|
) -> Result<Value> {
|
||||||
|
if settings.url.trim().is_empty() { bail!("Home Assistant URL is not configured") }
|
||||||
|
if settings.token.trim().is_empty() { bail!("Home Assistant token is not configured") }
|
||||||
|
let entity = resolve_entity_id(settings, entity_override)
|
||||||
|
.ok_or_else(|| anyhow!("Home Assistant entity_id is not configured"))?;
|
||||||
|
let mut base = Url::parse(settings.url.trim()).context("invalid Home Assistant URL")?;
|
||||||
|
if !matches!(base.scheme(), "http" | "https") { bail!("Home Assistant URL must use http or https") }
|
||||||
|
base = base.join(&format!("api/states/{}", entity.trim_start_matches('/'))).context("cannot build Home Assistant API URL")?;
|
||||||
|
let client = request_client(default_client, settings)?;
|
||||||
|
let response = client.get(base).bearer_auth(settings.token.trim()).header("Accept", "application/json")
|
||||||
|
.send().await.context("Home Assistant request failed")?;
|
||||||
|
if !response.status().is_success() {
|
||||||
|
let status = response.status();
|
||||||
|
let body = response.text().await.unwrap_or_default();
|
||||||
|
bail!("Home Assistant returned {status}: {}", body.chars().take(200).collect::<String>())
|
||||||
|
}
|
||||||
|
response.json().await.context("invalid Home Assistant JSON")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read the raw Home Assistant state for Flow conditions. Unlike `read_temperature`, this
|
||||||
|
/// intentionally keeps the state as text so binary_sensor, switch, input_boolean and custom
|
||||||
|
/// entities can participate in visual automations.
|
||||||
|
pub async fn read_state(
|
||||||
|
default_client: &reqwest::Client,
|
||||||
|
settings: &HomeAssistantSettings,
|
||||||
|
entity_override: Option<&str>,
|
||||||
|
) -> Result<String> {
|
||||||
|
let payload = read_entity(default_client, settings, entity_override).await?;
|
||||||
|
payload.get("state").and_then(Value::as_str).map(str::to_string).ok_or_else(|| anyhow!("Home Assistant state is missing"))
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ include!("models/defaults.rs");
|
|||||||
include!("models/device.rs");
|
include!("models/device.rs");
|
||||||
include!("models/temporary_thermostat.rs");
|
include!("models/temporary_thermostat.rs");
|
||||||
include!("models/zone.rs");
|
include!("models/zone.rs");
|
||||||
|
include!("models/flow.rs");
|
||||||
include!("models/automation.rs");
|
include!("models/automation.rs");
|
||||||
include!("models/history.rs");
|
include!("models/history.rs");
|
||||||
include!("models/integrations.rs");
|
include!("models/integrations.rs");
|
||||||
|
|||||||
@@ -17,6 +17,12 @@ pub struct Schedule {
|
|||||||
pub setpoint: f64,
|
pub setpoint: f64,
|
||||||
pub created_at: DateTime<Utc>,
|
pub created_at: DateTime<Utc>,
|
||||||
pub updated_at: DateTime<Utc>,
|
pub updated_at: DateTime<Utc>,
|
||||||
|
/// Present for records generated by the visual Flow editor. Generated records are read-only
|
||||||
|
/// in the legacy schedule/automation editors; the Flow remains the source of truth.
|
||||||
|
#[serde(default)]
|
||||||
|
pub flow_id: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub flow_node_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -25,7 +31,7 @@ pub struct Automation {
|
|||||||
pub name: String,
|
pub name: String,
|
||||||
#[serde(default = "default_true")]
|
#[serde(default = "default_true")]
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
/// temperature_above, temperature_below, time
|
/// temperature_above, temperature_below, time, flow
|
||||||
pub trigger_kind: String,
|
pub trigger_kind: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub trigger_device_id: Option<String>,
|
pub trigger_device_id: Option<String>,
|
||||||
@@ -48,6 +54,18 @@ pub struct Automation {
|
|||||||
pub cooldown_seconds: u64,
|
pub cooldown_seconds: u64,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub last_fired_at: Option<DateTime<Utc>>,
|
pub last_fired_at: Option<DateTime<Utc>>,
|
||||||
|
/// Optional Flow-owned thermostat zone target.
|
||||||
|
#[serde(default)]
|
||||||
|
pub action_zone_id: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub action_zone_preset: Option<String>,
|
||||||
|
/// Topologically ordered condition/logic program compiled from the visual Flow graph.
|
||||||
|
#[serde(default)]
|
||||||
|
pub flow_conditions: Vec<FlowCondition>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub flow_id: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub flow_node_id: Option<String>,
|
||||||
pub created_at: DateTime<Utc>,
|
pub created_at: DateTime<Utc>,
|
||||||
pub updated_at: DateTime<Utc>,
|
pub updated_at: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ pub struct ConfigurationExport {
|
|||||||
pub groups: Vec<ClimateGroup>,
|
pub groups: Vec<ClimateGroup>,
|
||||||
pub schedules: Vec<Schedule>,
|
pub schedules: Vec<Schedule>,
|
||||||
pub automations: Vec<Automation>,
|
pub automations: Vec<Automation>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub flows: Vec<Flow>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct FlowNode {
|
||||||
|
pub id: String,
|
||||||
|
pub kind: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub x: f64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub y: f64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub config: Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct FlowEdge {
|
||||||
|
pub id: String,
|
||||||
|
pub from: String,
|
||||||
|
pub to: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct FlowCondition {
|
||||||
|
/// Stable block ID used by the compiled Flow program.
|
||||||
|
#[serde(default)]
|
||||||
|
pub id: String,
|
||||||
|
pub kind: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub config: Value,
|
||||||
|
/// IDs of predecessor blocks. Condition blocks implicitly AND their own predicate with
|
||||||
|
/// predecessor results; logic blocks combine predecessors according to their kind.
|
||||||
|
#[serde(default)]
|
||||||
|
pub inputs: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Flow {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub enabled: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub description: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub nodes: Vec<FlowNode>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub edges: Vec<FlowEdge>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub summary: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub compiled_schedule_ids: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub compiled_automation_ids: Vec<String>,
|
||||||
|
/// Optimistic concurrency token for visual-editor saves. Legacy flows deserialize as 0.
|
||||||
|
#[serde(default)]
|
||||||
|
pub revision: u64,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
pub updated_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
@@ -68,3 +68,16 @@ pub const LIST_AUTOMATIONS: &str =
|
|||||||
pub const GET_AUTOMATION: &str = "SELECT payload FROM automations WHERE id=?1";
|
pub const GET_AUTOMATION: &str = "SELECT payload FROM automations WHERE id=?1";
|
||||||
pub const DELETE_AUTOMATION: &str = "DELETE FROM automations WHERE id=?1";
|
pub const DELETE_AUTOMATION: &str = "DELETE FROM automations WHERE id=?1";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
pub const UPSERT_FLOW: &str = r#"
|
||||||
|
INSERT INTO flows(id,payload,updated_at) VALUES(?1,?2,?3)
|
||||||
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
|
payload=excluded.payload,
|
||||||
|
updated_at=excluded.updated_at
|
||||||
|
"#;
|
||||||
|
pub const LIST_FLOWS: &str = "SELECT payload FROM flows ORDER BY json_extract(payload, '$.name') COLLATE NOCASE";
|
||||||
|
pub const GET_FLOW: &str = "SELECT payload FROM flows WHERE id=?1";
|
||||||
|
pub const DELETE_FLOW: &str = "DELETE FROM flows WHERE id=?1";
|
||||||
|
pub const DELETE_SCHEDULES_BY_FLOW_ID: &str = "DELETE FROM schedules WHERE json_extract(payload, '$.flow_id')=?1";
|
||||||
|
pub const DELETE_AUTOMATIONS_BY_FLOW_ID: &str = "DELETE FROM automations WHERE json_extract(payload, '$.flow_id')=?1";
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ DELETE FROM ha_readings WHERE id IN (
|
|||||||
pub const CLEAR_CONFIGURATION: &str = r#"
|
pub const CLEAR_CONFIGURATION: &str = r#"
|
||||||
DELETE FROM schedules;
|
DELETE FROM schedules;
|
||||||
DELETE FROM automations;
|
DELETE FROM automations;
|
||||||
|
DELETE FROM flows;
|
||||||
DELETE FROM climate_groups;
|
DELETE FROM climate_groups;
|
||||||
DELETE FROM zones;
|
DELETE FROM zones;
|
||||||
DELETE FROM devices;
|
DELETE FROM devices;
|
||||||
|
|||||||
@@ -50,6 +50,12 @@ CREATE TABLE IF NOT EXISTS automations (
|
|||||||
updated_at TEXT NOT NULL
|
updated_at TEXT NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS flows (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
payload TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS readings (
|
CREATE TABLE IF NOT EXISTS readings (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
device_id TEXT NOT NULL,
|
device_id TEXT NOT NULL,
|
||||||
@@ -127,5 +133,7 @@ INSERT OR IGNORE INTO schema_migrations(version, applied_at)
|
|||||||
VALUES (4, strftime('%Y-%m-%dT%H:%M:%fZ','now'));
|
VALUES (4, strftime('%Y-%m-%dT%H:%M:%fZ','now'));
|
||||||
INSERT OR IGNORE INTO schema_migrations(version, applied_at)
|
INSERT OR IGNORE INTO schema_migrations(version, applied_at)
|
||||||
VALUES (5, strftime('%Y-%m-%dT%H:%M:%fZ','now'));
|
VALUES (5, strftime('%Y-%m-%dT%H:%M:%fZ','now'));
|
||||||
|
INSERT OR IGNORE INTO schema_migrations(version, applied_at)
|
||||||
|
VALUES (6, strftime('%Y-%m-%dT%H:%M:%fZ','now'));
|
||||||
"#;
|
"#;
|
||||||
|
|
||||||
|
|||||||
@@ -176,6 +176,20 @@
|
|||||||
<div class="list-grid" id="groupList"></div>
|
<div class="list-grid" id="groupList"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="view" data-view="flows">
|
||||||
|
<div class="section-heading">
|
||||||
|
<div><span class="eyebrow" data-i18n="flow.eyebrow">Visual logic</span>
|
||||||
|
<h1 data-i18n="nav.flows">Flow</h1>
|
||||||
|
</div><div class="section-actions"><button class="secondary" type="button" data-action="import-flow" data-i18n="flow.import">Import Flow</button><button class="primary" type="button" data-action="new-flow" data-i18n="flow.new">New Flow</button></div>
|
||||||
|
</div>
|
||||||
|
<div class="panel flow-info-panel">
|
||||||
|
<div><strong data-i18n="flow.infoTitle">One place for schedules and automations</strong>
|
||||||
|
<p data-i18n="flow.infoText">Build the logic from blocks. GREE Controller translates Flow into generated schedules and automations. Generated entries are read-only outside this editor.</p></div>
|
||||||
|
<span class="badge" data-i18n="flow.sourceOfTruth">Flow = source of truth</span>
|
||||||
|
</div>
|
||||||
|
<div class="list-grid" id="flowList"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="view" data-view="schedules">
|
<section class="view" data-view="schedules">
|
||||||
<div class="section-heading">
|
<div class="section-heading">
|
||||||
<div><span class="eyebrow" data-i18n="schedules.calendar">Calendar</span>
|
<div><span class="eyebrow" data-i18n="schedules.calendar">Calendar</span>
|
||||||
@@ -694,6 +708,53 @@
|
|||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|
||||||
|
<section id="flowEditor" class="flow-editor" hidden aria-label="Flow editor">
|
||||||
|
<header class="flow-editor-bar">
|
||||||
|
<div class="flow-editor-title"><button type="button" class="icon-button" data-action="close-flow-editor" aria-label="Back">←</button><div><span class="eyebrow" data-i18n="flow.editorEyebrow">Flow editor</span><input id="flowName" maxlength="100" data-i18n-placeholder="flow.namePlaceholder" placeholder="My automation"></div></div>
|
||||||
|
<div class="flow-editor-actions"><button class="secondary" type="button" data-action="flow-templates" data-i18n="flow.templates">Templates</button><button class="secondary" type="button" data-action="import-flow" data-i18n="flow.import">Import</button><button class="secondary" type="button" data-action="export-flow" data-i18n="flow.export">Export</button><button class="secondary" type="button" data-action="flow-dry-run" data-i18n="flow.dryRun">Dry-run</button><button class="secondary" type="button" data-action="flow-logs" data-i18n="flow.logs">Logs</button><label class="flow-enabled"><input type="checkbox" id="flowEnabled" checked><span data-i18n="common.enabled">Enabled</span></label><span id="flowCompileStatus" class="badge"></span><button class="primary" type="button" data-action="save-flow" data-i18n="actions.save">Save</button></div>
|
||||||
|
</header>
|
||||||
|
<div class="flow-editor-body">
|
||||||
|
<aside class="flow-palette">
|
||||||
|
<div class="flow-palette-head"><strong data-i18n="flow.blocks">Blocks</strong><small data-i18n="flow.paletteHint">Add a block, then connect its output to the next block.</small></div>
|
||||||
|
<div class="flow-palette-group"><span data-i18n="flow.time">Time</span>
|
||||||
|
<button type="button" data-flow-add="weekday" data-i18n="flow.node.weekday">Dni tygodnia</button><button type="button" data-flow-add="time_range" data-i18n="flow.node.timeRange">Przedział godzin</button><button type="button" data-flow-add="date_range" data-i18n="flow.node.dateRange">Zakres dat</button><button type="button" data-flow-add="night_mode" data-i18n="flow.node.nightMode">Tryb nocny</button></div>
|
||||||
|
<div class="flow-palette-group"><span data-i18n="flow.sensors">Sensors</span>
|
||||||
|
<button type="button" data-flow-add="outdoor_temperature" data-i18n="flow.node.outdoorTemperature">Temperatura zewn.</button><button type="button" data-flow-add="device_temperature" data-i18n="flow.node.deviceTemperature">Temperatura urządzenia</button><button type="button" data-flow-add="zone_temperature" data-i18n="flow.node.zoneTemperature">Temperatura strefy</button><button type="button" data-flow-add="house_mode" data-i18n="flow.node.houseMode">Tryb domu</button><button type="button" data-flow-add="device_state" data-i18n="flow.node.deviceState">Stan urządzenia</button><button type="button" data-flow-add="zone_state" data-i18n="flow.node.zoneState">Stan termostatu</button><button type="button" data-flow-add="group_state" data-i18n="flow.node.groupState">Stan grupy</button><button type="button" data-flow-add="ha_state" data-i18n="flow.node.haState">HA: stan</button><button type="button" data-flow-add="ha_numeric" data-i18n="flow.node.haNumeric">HA: wartość</button><button type="button" data-flow-add="ha_attribute" data-i18n="flow.node.haAttribute">HA: atrybut</button><button type="button" data-flow-add="ha_available" data-i18n="flow.node.haAvailable">HA: dostępność</button><button type="button" data-flow-add="constant" data-i18n="flow.node.constant">Stała testowa</button></div>
|
||||||
|
<div class="flow-palette-group"><span data-i18n="flow.logic">Logic</span><button type="button" data-flow-add="logic_and" data-i18n="flow.node.and">AND</button><button type="button" data-flow-add="logic_or" data-i18n="flow.node.or">OR</button><button type="button" data-flow-add="logic_not" data-i18n="flow.node.not">NOT</button></div>
|
||||||
|
<div class="flow-palette-group"><span data-i18n="flow.actions">Actions</span>
|
||||||
|
<button type="button" data-flow-add="zone_thermostat" data-i18n="flow.node.thermostat">Termostat</button><button type="button" data-flow-add="device_action" data-i18n="flow.node.greeDevice">Urządzenie GREE</button><button type="button" data-flow-add="group_action" data-i18n="flow.node.group">Grupa</button></div>
|
||||||
|
</aside>
|
||||||
|
<div class="flow-workspace-wrap">
|
||||||
|
<div id="flowWorkspace" class="flow-workspace" tabindex="0">
|
||||||
|
<svg id="flowEdges" class="flow-edges" aria-hidden="true"></svg><div id="flowNodes" class="flow-nodes"></div>
|
||||||
|
<div id="flowEmptyHint" class="flow-empty-hint"><strong data-i18n="flow.emptyCanvas">Start by adding blocks</strong><span data-i18n="flow.emptyCanvasHint">Conditions go on the left, actions on the right.</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="flow-natural-preview"><strong data-i18n="flow.interpretation">Interpretation</strong><span id="flowInterpretation"></span></div>
|
||||||
|
</div>
|
||||||
|
<aside id="flowInspector" class="flow-inspector"><div class="empty compact"><strong data-i18n="flow.selectBlock">Select a block</strong><span data-i18n="flow.selectBlockHint">Its settings will appear here.</span></div></aside>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<input id="flowImportFile" type="file" accept="application/json,.json,.flow.json" hidden>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
<p class="field-note" data-i18n="flow.templatesHint">Ready-made layouts create an editable graph using existing zones, devices and groups.</p>
|
||||||
|
<div id="flowTemplateList" class="flow-template-list"></div>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<dialog id="flowTestDialog" class="flow-tool-dialog">
|
||||||
|
<div class="dialog-head"><div><span class="eyebrow" data-i18n="flow.testMode">Flow diagnostics</span><h2 id="flowTestTitle" data-i18n="flow.dryRun">Dry-run</h2></div><button type="button" data-close>×</button></div>
|
||||||
|
<div id="flowSimulationControls" class="flow-simulation-controls">
|
||||||
|
<p class="field-note" data-i18n="flow.dryRunHint">Dry-run does not change thermostat, device, group, schedule or automation state.</p>
|
||||||
|
<label><span data-i18n="flow.simulationTime">Simulation date and time</span><input id="flowSimulationAt" type="datetime-local"></label>
|
||||||
|
<div><strong data-i18n="flow.simulationOverrides">Sensor overrides</strong><p class="field-note" data-i18n="flow.simulationOverridesHint">Leave blank to use the current application or Home Assistant state.</p><div id="flowSimulationOverrides" class="flow-simulation-overrides"></div></div>
|
||||||
|
<div class="dialog-actions"><button class="primary" type="button" data-action="run-flow-dry-run" data-i18n="flow.runSimulation">Run simulation</button></div>
|
||||||
|
</div>
|
||||||
|
<div id="flowTestResults" class="flow-test-results"></div>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
<nav class="bottom-nav" data-i18n-aria="nav.navigation" aria-label="Navigation">
|
<nav class="bottom-nav" data-i18n-aria="nav.navigation" aria-label="Navigation">
|
||||||
<button class="active" data-nav="dashboard"><span>⌂</span><b data-i18n="nav.dashboard">Dashboard</b></button>
|
<button class="active" data-nav="dashboard"><span>⌂</span><b data-i18n="nav.dashboard">Dashboard</b></button>
|
||||||
<button data-nav="devices"><span>▣</span><b data-i18n="nav.devices">Devices</b></button>
|
<button data-nav="devices"><span>▣</span><b data-i18n="nav.devices">Devices</b></button>
|
||||||
@@ -708,6 +769,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="menu-list">
|
<div class="menu-list">
|
||||||
<button data-go="groups"><span data-i18n="nav.groups">Groups</span><span>›</span></button>
|
<button data-go="groups"><span data-i18n="nav.groups">Groups</span><span>›</span></button>
|
||||||
|
<button data-go="flows"><span data-i18n="nav.flows">Flow</span><span>›</span></button>
|
||||||
<button data-go="schedules"><span data-i18n="nav.schedules">Schedules</span><span>›</span></button>
|
<button data-go="schedules"><span data-i18n="nav.schedules">Schedules</span><span>›</span></button>
|
||||||
<button data-go="automations"><span data-i18n="nav.automations">Automations</span><span>›</span></button>
|
<button data-go="automations"><span data-i18n="nav.automations">Automations</span><span>›</span></button>
|
||||||
<button data-go="simulation"><span data-i18n="nav.simulation">Simulator</span><span>›</span></button>
|
<button data-go="simulation"><span data-i18n="nav.simulation">Simulator</span><span>›</span></button>
|
||||||
|
|||||||
Vendored
+1
@@ -8,6 +8,7 @@ async function loadBootstrap(showMessage = false) {
|
|||||||
app.groups = data.groups || [];
|
app.groups = data.groups || [];
|
||||||
app.schedules = data.schedules || [];
|
app.schedules = data.schedules || [];
|
||||||
app.automations = data.automations || [];
|
app.automations = data.automations || [];
|
||||||
|
app.flows = data.flows || [];
|
||||||
app.accessTokens = data.access_tokens || [];
|
app.accessTokens = data.access_tokens || [];
|
||||||
app.settings = data.settings || null;
|
app.settings = data.settings || null;
|
||||||
app.sensorAliases = { ...(app.settings?.home_assistant?.sensor_aliases || {}) };
|
app.sensorAliases = { ...(app.settings?.home_assistant?.sensor_aliases || {}) };
|
||||||
|
|||||||
+2
-1
@@ -25,7 +25,7 @@ const preferredLanguage = getCookie('gree_controller_language') || DEFAULT_LANGU
|
|||||||
const preferredTheme = ['system', 'light', 'dark'].includes(getCookie('gree_controller_theme')) ? getCookie('gree_controller_theme') : 'system';
|
const preferredTheme = ['system', 'light', 'dark'].includes(getCookie('gree_controller_theme')) ? getCookie('gree_controller_theme') : 'system';
|
||||||
|
|
||||||
const app = {
|
const app = {
|
||||||
devices: [], zones: [], groups: [], schedules: [], automations: [], accessTokens: [], settings: null, system: {}, outdoorTemperature: null,
|
devices: [], zones: [], groups: [], schedules: [], automations: [], flows: [], accessTokens: [], settings: null, system: {}, outdoorTemperature: null,
|
||||||
token: localStorage.getItem('gree_controller_token') || '', ws: null, wsTimer: null,
|
token: localStorage.getItem('gree_controller_token') || '', ws: null, wsTimer: null,
|
||||||
currentView: 'dashboard', loading: false, language: preferredLanguage, theme: preferredTheme, connectionStatus: 'connecting',
|
currentView: 'dashboard', loading: false, language: preferredLanguage, theme: preferredTheme, connectionStatus: 'connecting',
|
||||||
languages: [], translations: {}, locales: {},
|
languages: [], translations: {}, locales: {},
|
||||||
@@ -34,6 +34,7 @@ const app = {
|
|||||||
customChartSeries: [], savedCharts: [], chartZooms: {}, chartHiddenSeries: {}, zoneControlSeq: {}, groupControlSeq: {}, zoneControlQueue: {}, groupControlQueue: {}, deviceControlQueue: {}, houseControlQueue: null, climateControlQueue: null, groupCustomDrafts: {}, zoneTemperatureTimers: {},
|
customChartSeries: [], savedCharts: [], chartZooms: {}, chartHiddenSeries: {}, zoneControlSeq: {}, groupControlSeq: {}, zoneControlQueue: {}, groupControlQueue: {}, deviceControlQueue: {}, houseControlQueue: null, climateControlQueue: null, groupCustomDrafts: {}, zoneTemperatureTimers: {},
|
||||||
controlPlan: null, controlPlanTimer: null, debugLines: [], debugBacklogLoaded: false, debugFilter: 'all', sensorAliases: {},
|
controlPlan: null, controlPlanTimer: null, debugLines: [], debugBacklogLoaded: false, debugFilter: 'all', sensorAliases: {},
|
||||||
simulationScope: 'units', simulationTarget: 'all', standaloneSimulation: false, dashboardTab: 'main', settingsTab: 'app', systemSnapshotAt: Date.now(),
|
simulationScope: 'units', simulationTarget: 'all', standaloneSimulation: false, dashboardTab: 'main', settingsTab: 'app', systemSnapshotAt: Date.now(),
|
||||||
|
flowDraft: null, flowSelectedNodeId: null, flowConnectFrom: null, flowDirty: false,
|
||||||
};
|
};
|
||||||
try { app.savedCharts = JSON.parse(localStorage.getItem('gree_controller_saved_charts') || '[]'); } catch (_) { app.savedCharts = []; }
|
try { app.savedCharts = JSON.parse(localStorage.getItem('gree_controller_saved_charts') || '[]'); } catch (_) { app.savedCharts = []; }
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ function renderAll() {
|
|||||||
renderZones();
|
renderZones();
|
||||||
renderSchedules();
|
renderSchedules();
|
||||||
renderAutomations();
|
renderAutomations();
|
||||||
|
renderFlows();
|
||||||
renderAccessTokens();
|
renderAccessTokens();
|
||||||
fillSelects();
|
fillSelects();
|
||||||
renderSettings();
|
renderSettings();
|
||||||
@@ -65,6 +66,7 @@ function planEventMarkup(event) {
|
|||||||
|
|
||||||
function automationTriggerLabel(item) {
|
function automationTriggerLabel(item) {
|
||||||
if (item.trigger_kind === 'time') return tr('automations.triggerAt', { time: item.at_time || '—' });
|
if (item.trigger_kind === 'time') return tr('automations.triggerAt', { time: item.at_time || '—' });
|
||||||
|
if (item.trigger_kind === 'flow') return tr('flow.generated');
|
||||||
const key = item.trigger_kind === 'temperature_above' ? 'automations.triggerAbove' : 'automations.triggerBelow';
|
const key = item.trigger_kind === 'temperature_above' ? 'automations.triggerAbove' : 'automations.triggerBelow';
|
||||||
return tr(key, { temperature: fmtTemp(item.threshold) });
|
return tr(key, { temperature: fmtTemp(item.threshold) });
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-5
@@ -455,20 +455,21 @@ function renderSchedules() {
|
|||||||
const days = item.weekdays.map(day => dayNames[day - 1]).join(', ');
|
const days = item.weekdays.map(day => dayNames[day - 1]).join(', ');
|
||||||
return `<article class="list-card"><div class="list-card-head"><div><h3>${esc(item.name)}</h3><p>${esc(zone?.name || tr('common.noZone'))} · ${esc(days)}</p></div><span class="badge ${item.enabled ? 'active' : ''}">${esc(item.enabled ? tr('common.enabled') : tr('common.disabled'))}</span></div>
|
return `<article class="list-card"><div class="list-card-head"><div><h3>${esc(item.name)}</h3><p>${esc(zone?.name || tr('common.noZone'))} · ${esc(days)}</p></div><span class="badge ${item.enabled ? 'active' : ''}">${esc(item.enabled ? tr('common.enabled') : tr('common.disabled'))}</span></div>
|
||||||
<div class="card-stats"><div class="card-stat"><small>${esc(tr('common.from'))}</small><strong>${esc(item.start_time)}</strong></div><div class="card-stat"><small>${esc(tr('common.to'))}</small><strong>${esc(item.end_time)}</strong></div><div class="card-stat"><small>${esc(tr('schedules.profile'))}</small><strong>${esc(item.preset === 'custom' ? fmtTemp(item.setpoint) : zonePresetLabel(item.preset))}</strong></div></div>
|
<div class="card-stats"><div class="card-stat"><small>${esc(tr('common.from'))}</small><strong>${esc(item.start_time)}</strong></div><div class="card-stat"><small>${esc(tr('common.to'))}</small><strong>${esc(item.end_time)}</strong></div><div class="card-stat"><small>${esc(tr('schedules.profile'))}</small><strong>${esc(item.preset === 'custom' ? fmtTemp(item.setpoint) : zonePresetLabel(item.preset))}</strong></div></div>
|
||||||
<div class="card-footer"><small>${esc(tr('schedules.crossMidnight'))}</small><div class="card-menu"><button data-action="edit-schedule" data-id="${esc(item.id)}">${esc(tr('actions.edit'))}</button><button class="danger" data-action="delete-schedule" data-id="${esc(item.id)}">${esc(tr('actions.delete'))}</button></div></div></article>`;
|
<div class="card-footer"><small>${esc(item.flow_id ? tr('flow.generatedReadOnly') : tr('schedules.crossMidnight'))}</small><div class="card-menu">${item.flow_id ? `<button data-action="edit-flow" data-id="${esc(item.flow_id)}">${esc(tr('flow.openEditor'))}</button>` : `<button data-action="edit-schedule" data-id="${esc(item.id)}">${esc(tr('actions.edit'))}</button><button class="danger" data-action="delete-schedule" data-id="${esc(item.id)}">${esc(tr('actions.delete'))}</button>`}</div></div></article>`;
|
||||||
}).join('') : `<div class="empty"><strong>${esc(tr('schedules.emptyTitle'))}</strong>${esc(tr('schedules.emptyText'))}</div>`;
|
}).join('') : `<div class="empty"><strong>${esc(tr('schedules.emptyTitle'))}</strong>${esc(tr('schedules.emptyText'))}</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderAutomations() {
|
function renderAutomations() {
|
||||||
$('#automationList').innerHTML = app.automations.length ? app.automations.map(item => {
|
$('#automationList').innerHTML = app.automations.length ? app.automations.map(item => {
|
||||||
|
const actionZone = item.action_zone_id ? app.zones.find(zone => zone.id === item.action_zone_id) : null;
|
||||||
const actionGroup = item.action_group_id ? app.groups.find(group => group.id === item.action_group_id) : null;
|
const actionGroup = item.action_group_id ? app.groups.find(group => group.id === item.action_group_id) : null;
|
||||||
const actionDevice = app.devices.find(device => device.id === item.action_device_id);
|
const actionDevice = app.devices.find(device => device.id === item.action_device_id);
|
||||||
const targetName = actionGroup?.name || actionDevice?.name || tr('common.noDevice');
|
const targetName = actionZone?.name || actionGroup?.name || actionDevice?.name || tr('common.noDevice');
|
||||||
const mode = item.action.mode === 'auto' && actionGroup ? tr('groups.followHouse') : (item.action.mode ? modeLabel(item.action.mode) : '—');
|
const mode = item.action.mode === 'auto' && (actionGroup || actionZone) ? tr('groups.followHouse') : (item.action.mode ? modeLabel(item.action.mode) : '—');
|
||||||
const preset = item.action_preset ? zonePresetLabel(item.action_preset) : '—';
|
const preset = item.action_zone_preset ? zonePresetLabel(item.action_zone_preset) : (item.action_preset ? zonePresetLabel(item.action_preset) : '—');
|
||||||
return `<article class="list-card"><div class="list-card-head"><div><h3>${esc(item.name)}</h3><p>${esc(tr('automations.triggerSummary', { trigger: automationTriggerLabel(item), device: targetName }))}</p></div><span class="badge ${item.enabled ? 'active' : ''}">${esc(item.enabled ? tr('common.active') : tr('common.disabled'))}</span></div>
|
return `<article class="list-card"><div class="list-card-head"><div><h3>${esc(item.name)}</h3><p>${esc(tr('automations.triggerSummary', { trigger: automationTriggerLabel(item), device: targetName }))}</p></div><span class="badge ${item.enabled ? 'active' : ''}">${esc(item.enabled ? tr('common.active') : tr('common.disabled'))}</span></div>
|
||||||
<div class="card-stats"><div class="card-stat"><small>${esc(tr('common.power'))}</small><strong>${item.action.power == null ? '—' : item.action.power ? tr('common.on') : tr('common.off')}</strong></div><div class="card-stat"><small>${esc(tr('common.mode'))}</small><strong>${esc(mode)}</strong></div><div class="card-stat"><small>${esc(tr('groups.profile'))}</small><strong>${esc(preset)}</strong></div><div class="card-stat"><small>${esc(tr('automations.last'))}</small><strong>${item.last_fired_at ? new Date(item.last_fired_at).toLocaleTimeString(locale(), { hour: '2-digit', minute: '2-digit' }) : '—'}</strong></div></div>
|
<div class="card-stats"><div class="card-stat"><small>${esc(tr('common.power'))}</small><strong>${item.action.power == null ? '—' : item.action.power ? tr('common.on') : tr('common.off')}</strong></div><div class="card-stat"><small>${esc(tr('common.mode'))}</small><strong>${esc(mode)}</strong></div><div class="card-stat"><small>${esc(tr('groups.profile'))}</small><strong>${esc(preset)}</strong></div><div class="card-stat"><small>${esc(tr('automations.last'))}</small><strong>${item.last_fired_at ? new Date(item.last_fired_at).toLocaleTimeString(locale(), { hour: '2-digit', minute: '2-digit' }) : '—'}</strong></div></div>
|
||||||
<div class="card-footer"><small>${esc(actionGroup ? `${tr('groups.group')}: ${targetName}` : `${tr('common.device')}: ${targetName}`)} · ${esc(tr('common.cooldown'))} ${item.cooldown_seconds}s</small><div class="card-menu"><button data-action="edit-automation" data-id="${esc(item.id)}">${esc(tr('actions.edit'))}</button><button class="danger" data-action="delete-automation" data-id="${esc(item.id)}">${esc(tr('actions.delete'))}</button></div></div></article>`;
|
<div class="card-footer"><small>${esc(item.flow_id ? tr('flow.generatedReadOnly') : (actionGroup ? `${tr('groups.group')}: ${targetName}` : `${tr('common.device')}: ${targetName}`))} · ${esc(tr('common.cooldown'))} ${item.cooldown_seconds}s</small><div class="card-menu">${item.flow_id ? `<button data-action="edit-flow" data-id="${esc(item.flow_id)}">${esc(tr('flow.openEditor'))}</button>` : `<button data-action="edit-automation" data-id="${esc(item.id)}">${esc(tr('actions.edit'))}</button><button class="danger" data-action="delete-automation" data-id="${esc(item.id)}">${esc(tr('actions.delete'))}</button>`}</div></div></article>`;
|
||||||
}).join('') : `<div class="empty"><strong>${esc(tr('automations.emptyTitle'))}</strong>${esc(tr('automations.emptyText'))}</div>`;
|
}).join('') : `<div class="empty"><strong>${esc(tr('automations.emptyTitle'))}</strong>${esc(tr('automations.emptyText'))}</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+470
@@ -0,0 +1,470 @@
|
|||||||
|
const FLOW_NODE_META = Object.freeze({
|
||||||
|
weekday: { titleKey: 'flow.node.weekday', category: 'time' },
|
||||||
|
time_range: { titleKey: 'flow.node.timeRange', category: 'time' },
|
||||||
|
date_range: { titleKey: 'flow.node.dateRange', category: 'time' },
|
||||||
|
outdoor_temperature: { titleKey: 'flow.node.outdoorTemperature', category: 'sensor' },
|
||||||
|
device_temperature: { titleKey: 'flow.node.deviceTemperature', category: 'sensor' },
|
||||||
|
zone_temperature: { titleKey: 'flow.node.zoneTemperature', category: 'sensor' },
|
||||||
|
ha_state: { titleKey: 'flow.node.haState', category: 'sensor' },
|
||||||
|
ha_numeric: { titleKey: 'flow.node.haNumeric', category: 'sensor' },
|
||||||
|
ha_attribute: { titleKey: 'flow.node.haAttribute', category: 'sensor' },
|
||||||
|
ha_available: { titleKey: 'flow.node.haAvailable', category: 'sensor' },
|
||||||
|
house_mode: { titleKey: 'flow.node.houseMode', category: 'sensor' },
|
||||||
|
device_state: { titleKey: 'flow.node.deviceState', category: 'sensor' },
|
||||||
|
zone_state: { titleKey: 'flow.node.zoneState', category: 'sensor' },
|
||||||
|
group_state: { titleKey: 'flow.node.groupState', category: 'sensor' },
|
||||||
|
night_mode: { titleKey: 'flow.node.nightMode', category: 'time' },
|
||||||
|
constant: { titleKey: 'flow.node.constant', category: 'sensor' },
|
||||||
|
logic_and: { titleKey: 'flow.node.and', category: 'logic' },
|
||||||
|
logic_or: { titleKey: 'flow.node.or', category: 'logic' },
|
||||||
|
logic_not: { titleKey: 'flow.node.not', category: 'logic' },
|
||||||
|
zone_thermostat: { titleKey: 'flow.node.thermostat', category: 'action' },
|
||||||
|
device_action: { titleKey: 'flow.node.greeDevice', category: 'action' },
|
||||||
|
group_action: { titleKey: 'flow.node.group', category: 'action' },
|
||||||
|
});
|
||||||
|
|
||||||
|
function newFlowId(prefix = 'node') {
|
||||||
|
if (crypto?.randomUUID) return `${prefix}-${crypto.randomUUID()}`;
|
||||||
|
return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function flowDefaultConfig(kind) {
|
||||||
|
if (kind === 'weekday') return { days: [1, 2, 3, 4, 5] };
|
||||||
|
if (kind === 'time_range') return { start: '06:00', end: '08:00' };
|
||||||
|
if (kind === 'date_range') { const today = new Date().toISOString().slice(0, 10); return { start: today, end: today }; }
|
||||||
|
if (kind === 'outdoor_temperature') return { operator: 'lt', value: 5 };
|
||||||
|
if (kind === 'device_temperature') return { device_id: app.devices[0]?.id || '', operator: 'lt', value: 20 };
|
||||||
|
if (kind === 'zone_temperature') return { zone_id: app.zones[0]?.id || '', operator: 'lt', value: 20 };
|
||||||
|
if (kind === 'ha_state') return { entity_id: '', operator: 'eq', value: 'on' };
|
||||||
|
if (kind === 'ha_numeric') return { entity_id: '', operator: 'lt', value: 20 };
|
||||||
|
if (kind === 'ha_attribute') return { entity_id: '', attribute: '', operator: 'eq', value: '' };
|
||||||
|
if (kind === 'ha_available') return { entity_id: '' };
|
||||||
|
if (kind === 'house_mode') return { operator: 'eq', value: 'cool' };
|
||||||
|
if (kind === 'device_state') return { device_id: app.devices[0]?.id || '', field: 'online', operator: 'eq', value: 'true' };
|
||||||
|
if (kind === 'zone_state') return { zone_id: app.zones[0]?.id || '', field: 'demand', operator: 'eq', value: 'true' };
|
||||||
|
if (kind === 'group_state') return { group_id: app.groups[0]?.id || '', field: 'power_enabled', operator: 'eq', value: 'true' };
|
||||||
|
if (kind === 'night_mode') return {};
|
||||||
|
if (kind === 'constant') return { value: true };
|
||||||
|
if (kind === 'zone_thermostat') return { zone_id: app.zones[0]?.id || '', preset: 'comfort', setpoint: 21, mode: 'auto', cooldown_seconds: 60 };
|
||||||
|
if (kind === 'device_action') return { device_id: app.devices[0]?.id || '', power: true, mode: '', target_temperature: null, fan_speed: null, swing_vertical: null, swing_horizontal: null, quiet: null, turbo: null, light: null, air: null, xfan: null, health: null, sleep: null, cooldown_seconds: 60 };
|
||||||
|
if (kind === 'group_action') return { group_id: app.groups[0]?.id || '', power: true, mode: '', preset: '', setpoint: 21, cooldown_seconds: 60 };
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFlows() {
|
||||||
|
const host = $('#flowList'); if (!host) return;
|
||||||
|
host.innerHTML = app.flows.length ? app.flows.map(flow => `<article class="list-card flow-card">
|
||||||
|
<div class="list-card-head"><div><h3>${esc(flow.name)}</h3><p>${esc(flow.description || tr('flow.defaultDescription'))}</p></div><span class="badge ${flow.enabled ? 'active' : ''}">${esc(flow.enabled ? tr('common.enabled') : tr('common.disabled'))}</span></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>
|
||||||
|
</article>`).join('') : `<div class="empty"><strong>${esc(tr('flow.emptyTitle'))}</strong>${esc(tr('flow.emptyText'))}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function flowDraftFrom(flow) {
|
||||||
|
return flow ? JSON.parse(JSON.stringify(flow)) : {
|
||||||
|
id: '', revision: 0, name: tr('flow.newDefaultName'), enabled: true, description: '', nodes: [], edges: [], summary: '', compiled_schedule_ids: [], compiled_automation_ids: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function openFlowEditor(id = '', { push = true } = {}) {
|
||||||
|
const flow = id ? app.flows.find(item => item.id === id) : null;
|
||||||
|
if (id && !flow) return toast(tr('flow.notFound'), true);
|
||||||
|
app.flowDraft = flowDraftFrom(flow);
|
||||||
|
app.flowSelectedNodeId = null; app.flowConnectFrom = null; app.flowDirty = false;
|
||||||
|
$('#flowName').value = app.flowDraft.name || '';
|
||||||
|
$('#flowEnabled').checked = app.flowDraft.enabled !== false;
|
||||||
|
$('#flowEditor').hidden = false;
|
||||||
|
document.body.classList.add('flow-editor-open');
|
||||||
|
renderFlowEditor();
|
||||||
|
if (push) updateBrowserUrl(`/flows/${flow?.id || 'new'}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeFlowEditor({ push = true, force = false } = {}) {
|
||||||
|
if (!force && app.flowDirty && !confirm(tr('confirm.discardChanges'))) return false;
|
||||||
|
$('#flowEditor').hidden = true; document.body.classList.remove('flow-editor-open');
|
||||||
|
app.flowDraft = null; app.flowSelectedNodeId = null; app.flowConnectFrom = null; app.flowDirty = false;
|
||||||
|
if (push) updateBrowserUrl('/flows');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function flowNodeSummary(node) {
|
||||||
|
const c = node.config || {};
|
||||||
|
if (node.kind === 'weekday') return (c.days || []).map(day => tr(`day.${day}`)).join(', ') || '—';
|
||||||
|
if (node.kind === 'time_range') return `${c.start || '—'}–${c.end || '—'}`;
|
||||||
|
if (node.kind === 'date_range') return `${c.start || '—'} → ${c.end || '—'}`;
|
||||||
|
if (node.kind === 'outdoor_temperature') return `${flowOperatorLabel(c.operator)} ${fmtTemp(c.value)}`;
|
||||||
|
if (node.kind === 'device_temperature') return `${app.devices.find(d => d.id === c.device_id)?.name || tr('common.noDevice')} · ${flowOperatorLabel(c.operator)} ${fmtTemp(c.value)}`;
|
||||||
|
if (node.kind === 'zone_temperature') return `${app.zones.find(z => z.id === c.zone_id)?.name || tr('common.noZone')} · ${flowOperatorLabel(c.operator)} ${fmtTemp(c.value)}`;
|
||||||
|
if (node.kind === 'ha_state') return `${c.entity_id || 'entity_id'} ${c.operator === 'neq' ? '≠' : '='} ${c.value || '—'}`;
|
||||||
|
if (node.kind === 'ha_numeric') return `${c.entity_id || 'entity_id'} · ${flowOperatorLabel(c.operator)} ${c.value ?? '—'}`;
|
||||||
|
if (node.kind === 'ha_attribute') return `${c.entity_id || 'entity_id'}.${c.attribute || 'attribute'} ${flowOperatorLabel(c.operator)} ${c.value ?? '—'}`;
|
||||||
|
if (node.kind === 'ha_available') return `${c.entity_id || 'entity_id'} · ${tr('flow.available')}`;
|
||||||
|
if (node.kind === 'house_mode') return `${tr('flow.houseMode')} ${flowOperatorLabel(c.operator)} ${c.value || '—'}`;
|
||||||
|
if (node.kind === 'device_state') return `${app.devices.find(d => d.id === c.device_id)?.name || tr('common.noDevice')} · ${c.field || 'state'} ${flowOperatorLabel(c.operator)} ${c.value ?? '—'}`;
|
||||||
|
if (node.kind === 'zone_state') return `${app.zones.find(z => z.id === c.zone_id)?.name || tr('common.noZone')} · ${c.field || 'state'} ${flowOperatorLabel(c.operator)} ${c.value ?? '—'}`;
|
||||||
|
if (node.kind === 'group_state') return `${app.groups.find(g => g.id === c.group_id)?.name || tr('groups.group')} · ${c.field || 'power_enabled'} ${flowOperatorLabel(c.operator)} ${c.value ?? '—'}`;
|
||||||
|
if (node.kind === 'night_mode') return tr('flow.nightModeActive');
|
||||||
|
if (node.kind === 'constant') return c.value === false ? tr('common.no') : tr('common.yes');
|
||||||
|
if (node.kind === 'logic_and') return tr('flow.allConditions');
|
||||||
|
if (node.kind === 'logic_or') return tr('flow.anyCondition');
|
||||||
|
if (node.kind === 'logic_not') return tr('flow.invertCondition');
|
||||||
|
if (node.kind === 'zone_thermostat') { const zone = app.zones.find(z => z.id === c.zone_id); return `${zone?.name || tr('common.noZone')} · ${c.preset === 'custom' ? fmtTemp(c.setpoint) : zonePresetLabel(c.preset || 'comfort')}`; }
|
||||||
|
if (node.kind === 'device_action') { const d = app.devices.find(v => v.id === c.device_id); return `${d?.name || tr('common.noDevice')} · ${c.power == null ? tr('actions.noChange') : c.power ? tr('common.on') : tr('common.off')}`; }
|
||||||
|
if (node.kind === 'group_action') { const g = app.groups.find(v => v.id === c.group_id); return `${g?.name || tr('groups.group')} · ${c.power == null ? tr('actions.noChange') : c.power ? tr('common.on') : tr('common.off')}`; }
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function flowNodeTitle(meta) { return meta?.titleKey ? tr(meta.titleKey) : (meta?.title || ''); }
|
||||||
|
function flowOperatorLabel(op) { return ({ lt: '<', lte: '≤', gt: '>', gte: '≥', eq: '=', neq: '≠' })[op] || '<'; }
|
||||||
|
function flowNodeById(id) { return app.flowDraft?.nodes?.find(node => node.id === id); }
|
||||||
|
|
||||||
|
function renderFlowEditor() {
|
||||||
|
const draft = app.flowDraft; if (!draft) return;
|
||||||
|
const nodesHost = $('#flowNodes');
|
||||||
|
nodesHost.innerHTML = draft.nodes.map(node => {
|
||||||
|
const meta = FLOW_NODE_META[node.kind] || { title: node.kind, category: 'logic' };
|
||||||
|
return `<article class="flow-node flow-node-${esc(meta.category)} ${app.flowSelectedNodeId === node.id ? 'selected' : ''}" data-flow-node="${esc(node.id)}" style="left:${Number(node.x || 0)}px;top:${Number(node.y || 0)}px">
|
||||||
|
<button class="flow-port flow-port-in" type="button" data-flow-input="${esc(node.id)}" title="${esc(tr('flow.connectHere'))}"></button>
|
||||||
|
<div class="flow-node-head"><span>${esc(flowNodeTitle(meta))}</span><button type="button" data-flow-remove="${esc(node.id)}" aria-label="${esc(tr('actions.delete'))}">×</button></div>
|
||||||
|
<div class="flow-node-body">${esc(flowNodeSummary(node))}</div>
|
||||||
|
<button class="flow-port flow-port-out ${app.flowConnectFrom === node.id ? 'armed' : ''}" type="button" data-flow-output="${esc(node.id)}" title="${esc(tr('flow.startConnection'))}"></button>
|
||||||
|
</article>`;
|
||||||
|
}).join('');
|
||||||
|
$('#flowEmptyHint').hidden = draft.nodes.length > 0;
|
||||||
|
renderFlowEdges(); renderFlowInspector(); renderFlowInterpretation();
|
||||||
|
const status = $('#flowCompileStatus');
|
||||||
|
status.textContent = tr('flow.compileCount', { schedules: draft.compiled_schedule_ids?.length || 0, automations: draft.compiled_automation_ids?.length || 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFlowEdges() {
|
||||||
|
const svg = $('#flowEdges'), workspace = $('#flowWorkspace'); if (!svg || !workspace || !app.flowDraft) return;
|
||||||
|
const rect = workspace.getBoundingClientRect();
|
||||||
|
svg.setAttribute('viewBox', `0 0 ${Math.max(workspace.clientWidth, workspace.scrollWidth)} ${Math.max(workspace.clientHeight, workspace.scrollHeight)}`);
|
||||||
|
svg.innerHTML = app.flowDraft.edges.map(edge => {
|
||||||
|
const from = $(`[data-flow-node="${CSS.escape(edge.from)}"]`), to = $(`[data-flow-node="${CSS.escape(edge.to)}"]`); if (!from || !to) return '';
|
||||||
|
const a = from.getBoundingClientRect(), b = to.getBoundingClientRect();
|
||||||
|
const x1 = a.right - rect.left + workspace.scrollLeft - 2, y1 = a.top - rect.top + workspace.scrollTop + a.height / 2;
|
||||||
|
const x2 = b.left - rect.left + workspace.scrollLeft + 2, y2 = b.top - rect.top + workspace.scrollTop + b.height / 2;
|
||||||
|
const bend = Math.max(55, Math.abs(x2 - x1) * .45);
|
||||||
|
return `<path data-flow-edge="${esc(edge.id)}" d="M ${x1} ${y1} C ${x1 + bend} ${y1}, ${x2 - bend} ${y2}, ${x2} ${y2}"/>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function flowSelectOptions(items, selected, nameFn = item => item.name) {
|
||||||
|
return items.map(item => `<option value="${esc(item.id)}" ${item.id === selected ? 'selected' : ''}>${esc(nameFn(item))}</option>`).join('');
|
||||||
|
}
|
||||||
|
function flowOperatorOptions(selected) { return [['lt','<'],['lte','≤'],['gt','>'],['gte','≥'],['eq','='],['neq','≠']].map(([v,l]) => `<option value="${v}" ${v === selected ? 'selected' : ''}>${l}</option>`).join(''); }
|
||||||
|
|
||||||
|
function renderFlowInspector() {
|
||||||
|
const host = $('#flowInspector'), node = flowNodeById(app.flowSelectedNodeId); if (!host) return;
|
||||||
|
if (!node) { host.innerHTML = `<div class="empty compact"><strong>${esc(tr('flow.selectBlock'))}</strong><span>${esc(tr('flow.selectBlockHint'))}</span></div>`; return; }
|
||||||
|
const c = node.config || {}, meta = FLOW_NODE_META[node.kind] || { title: node.kind };
|
||||||
|
let fields = '';
|
||||||
|
if (node.kind === 'weekday') fields = `<div class="flow-day-grid">${[1,2,3,4,5,6,7].map(day => `<label><input type="checkbox" data-flow-config="days" value="${day}" ${(c.days || []).includes(day) ? 'checked' : ''}><span>${esc(tr(`day.${day}`))}</span></label>`).join('')}</div>`;
|
||||||
|
else if (node.kind === 'time_range') fields = `<div class="two"><label><span>${esc(tr('common.from'))}</span><input type="time" data-flow-config="start" value="${esc(c.start || '06:00')}"></label><label><span>${esc(tr('common.to'))}</span><input type="time" data-flow-config="end" value="${esc(c.end || '08:00')}"></label></div>`;
|
||||||
|
else if (node.kind === 'date_range') fields = `<div class="two"><label><span>${esc(tr('common.from'))}</span><input type="date" data-flow-config="start" value="${esc(c.start || '')}"></label><label><span>${esc(tr('common.to'))}</span><input type="date" data-flow-config="end" value="${esc(c.end || '')}"></label></div>`;
|
||||||
|
else if (node.kind === 'outdoor_temperature') fields = flowComparisonFields(c);
|
||||||
|
else if (node.kind === 'device_temperature') fields = `<label><span>${esc(tr('common.device'))}</span><select data-flow-config="device_id">${flowSelectOptions(app.devices, c.device_id)}</select></label>${flowComparisonFields(c)}`;
|
||||||
|
else if (node.kind === 'zone_temperature') fields = `<label><span>${esc(tr('common.zone'))}</span><select data-flow-config="zone_id">${flowSelectOptions(app.zones, c.zone_id)}</select></label>${flowComparisonFields(c)}`;
|
||||||
|
else if (node.kind === 'ha_state') fields = `<label><span>entity_id</span><input data-flow-config="entity_id" value="${esc(c.entity_id || '')}" placeholder="binary_sensor.window"></label><div class="two"><label><span>${esc(tr('flow.operator'))}</span><select data-flow-config="operator"><option value="eq" ${c.operator !== 'neq' ? 'selected' : ''}>=</option><option value="neq" ${c.operator === 'neq' ? 'selected' : ''}>≠</option></select></label><label><span>${esc(tr('flow.state'))}</span><input data-flow-config="value" value="${esc(c.value || '')}" placeholder="on"></label></div>`;
|
||||||
|
else if (node.kind === 'ha_numeric') fields = `<label><span>entity_id</span><input data-flow-config="entity_id" value="${esc(c.entity_id || '')}" placeholder="sensor.outdoor_temperature"></label>${flowComparisonFields(c, false)}`;
|
||||||
|
else if (node.kind === 'ha_attribute') fields = `<label><span>entity_id</span><input data-flow-config="entity_id" value="${esc(c.entity_id || '')}" placeholder="climate.living_room"></label><label><span>${esc(tr('flow.attribute'))}</span><input data-flow-config="attribute" value="${esc(c.attribute || '')}" placeholder="hvac_action"></label>${flowTextComparisonFields(c, true)}`;
|
||||||
|
else if (node.kind === 'ha_available') fields = `<label><span>entity_id</span><input data-flow-config="entity_id" value="${esc(c.entity_id || '')}" placeholder="binary_sensor.window"></label><p class="field-note">${esc(tr('flow.haAvailableHint'))}</p>`;
|
||||||
|
else if (node.kind === 'house_mode') fields = `<div class="two"><label><span>${esc(tr('flow.operator'))}</span><select data-flow-config="operator"><option value="eq" ${c.operator !== 'neq' ? 'selected' : ''}>=</option><option value="neq" ${c.operator === 'neq' ? 'selected' : ''}>≠</option></select></label><label><span>${esc(tr('flow.houseMode'))}</span><select data-flow-config="value">${['cool','heat','off'].map(v => `<option value="${v}" ${c.value === v ? 'selected' : ''}>${esc(v === 'off' ? tr('common.off') : tr(`mode.${v}`))}</option>`).join('')}</select></label></div>`;
|
||||||
|
else if (node.kind === 'device_state') fields = `<label><span>${esc(tr('common.device'))}</span><select data-flow-config="device_id">${flowSelectOptions(app.devices, c.device_id)}</select></label><label><span>${esc(tr('flow.field'))}</span><select data-flow-config="field">${['enabled','online','power','mode','fan_speed','swing_vertical','swing_horizontal','quiet','turbo','light','air','xfan','health','sleep'].map(v => `<option value="${v}" ${c.field === v ? 'selected' : ''}>${esc(v)}</option>`).join('')}</select></label>${flowTextComparisonFields(c)}`;
|
||||||
|
else if (node.kind === 'zone_state') fields = `<label><span>${esc(tr('common.zone'))}</span><select data-flow-config="zone_id">${flowSelectOptions(app.zones, c.zone_id)}</select></label><label><span>${esc(tr('flow.field'))}</span><select data-flow-config="field">${['enabled','mode','active_preset','demand','control_owner','device_manual_override','local_thermostat_power'].map(v => `<option value="${v}" ${c.field === v ? 'selected' : ''}>${esc(v)}</option>`).join('')}</select></label>${flowTextComparisonFields(c)}`;
|
||||||
|
else if (node.kind === 'group_state') fields = `<label><span>${esc(tr('groups.group'))}</span><select data-flow-config="group_id">${flowSelectOptions(app.groups, c.group_id)}</select></label><label><span>${esc(tr('flow.field'))}</span><select data-flow-config="field"><option value="power_enabled" selected>power_enabled</option></select></label>${flowTextComparisonFields(c)}`;
|
||||||
|
else if (node.kind === 'night_mode') fields = `<p class="field-note">${esc(tr('flow.nightModeHint'))}</p>`;
|
||||||
|
else if (node.kind === 'constant') fields = `<label><span>${esc(tr('flow.value'))}</span><select data-flow-config="value"><option value="true" ${c.value !== false ? 'selected' : ''}>${esc(tr('common.yes'))}</option><option value="false" ${c.value === false ? 'selected' : ''}>${esc(tr('common.no'))}</option></select></label>`;
|
||||||
|
else if (node.kind === 'logic_and') fields = `<p class="field-note">${esc(tr('flow.andHint'))}</p>`;
|
||||||
|
else if (node.kind === 'logic_or') fields = `<p class="field-note">${esc(tr('flow.orHint'))}</p>`;
|
||||||
|
else if (node.kind === 'logic_not') fields = `<p class="field-note">${esc(tr('flow.notHint'))}</p>`;
|
||||||
|
else if (node.kind === 'zone_thermostat') fields = `<label><span>${esc(tr('common.zone'))}</span><select data-flow-config="zone_id">${flowSelectOptions(app.zones, c.zone_id)}</select></label><div class="two"><label><span>${esc(tr('schedules.profile'))}</span><select data-flow-config="preset">${['auto','comfort','sleep','away','custom'].map(v => `<option value="${v}" ${c.preset === v ? 'selected' : ''}>${esc(zonePresetLabel(v))}</option>`).join('')}</select></label><label><span>${esc(tr('common.targetC'))}</span><input type="number" min="8" max="30" step="0.5" data-flow-config="setpoint" value="${Number(c.setpoint ?? 21)}"></label></div><div class="two"><label><span>${esc(tr('common.mode'))}</span><select data-flow-config="mode"><option value="auto" ${c.mode === 'auto' ? 'selected' : ''}>Auto</option><option value="heat" ${c.mode === 'heat' ? 'selected' : ''}>${esc(tr('mode.heat'))}</option><option value="cool" ${c.mode === 'cool' ? 'selected' : ''}>${esc(tr('mode.cool'))}</option></select></label><label><span>${esc(tr('common.power'))}</span><select data-flow-config="power"><option value="" ${c.power == null ? 'selected' : ''}>${esc(tr('actions.noChange'))}</option><option value="true" ${c.power === true ? 'selected' : ''}>${esc(tr('common.on'))}</option><option value="false" ${c.power === false ? 'selected' : ''}>${esc(tr('common.off'))}</option></select></label></div><label><span>${esc(tr('common.cooldownSeconds'))}</span><input type="number" min="30" data-flow-config="cooldown_seconds" value="${Number(c.cooldown_seconds || 60)}"></label>`;
|
||||||
|
else if (node.kind === 'device_action') fields = `<label><span>${esc(tr('common.device'))}</span><select data-flow-config="device_id">${flowSelectOptions(app.devices, c.device_id)}</select></label>${flowActionFields(c, false)}`;
|
||||||
|
else if (node.kind === 'group_action') fields = `<label><span>${esc(tr('groups.group'))}</span><select data-flow-config="group_id">${flowSelectOptions(app.groups, c.group_id)}</select></label>${flowActionFields(c, true)}`;
|
||||||
|
host.innerHTML = `<div class="flow-inspector-head"><div><span class="eyebrow">${esc(tr('flow.blockSettings'))}</span><h3>${esc(flowNodeTitle(meta))}</h3></div><button type="button" class="danger" data-flow-remove="${esc(node.id)}">${esc(tr('actions.delete'))}</button></div>${fields}<div class="flow-inspector-meta"><small>ID</small><code>${esc(node.id)}</code></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function flowTextComparisonFields(c, numeric = false) { const options = numeric ? flowOperatorOptions(c.operator || 'eq') : [['eq','='],['neq','≠']].map(([v,l]) => `<option value="${v}" ${v === (c.operator || 'eq') ? 'selected' : ''}>${l}</option>`).join(''); return `<div class="two"><label><span>${esc(tr('flow.operator'))}</span><select data-flow-config="operator">${options}</select></label><label><span>${esc(tr('flow.value'))}</span><input data-flow-config="value" value="${esc(c.value ?? '')}"></label></div>`; }
|
||||||
|
function flowComparisonFields(c, temperature = true) { return `<div class="two"><label><span>${esc(tr('flow.operator'))}</span><select data-flow-config="operator">${flowOperatorOptions(c.operator || 'lt')}</select></label><label><span>${esc(temperature ? tr('automations.thresholdC') : tr('flow.value'))}</span><input type="number" step="0.1" data-flow-config="value" value="${Number(c.value ?? 0)}"></label></div>`; }
|
||||||
|
function flowOptionalBoolField(c, key) {
|
||||||
|
return `<label><span>${esc(key)}</span><select data-flow-config="${esc(key)}"><option value="" ${c[key] == null ? 'selected' : ''}>${esc(tr('actions.noChange'))}</option><option value="true" ${c[key] === true ? 'selected' : ''}>${esc(tr('common.on'))}</option><option value="false" ${c[key] === false ? 'selected' : ''}>${esc(tr('common.off'))}</option></select></label>`;
|
||||||
|
}
|
||||||
|
function flowActionFields(c, group) {
|
||||||
|
const modes = group ? ['auto','house','heat','cool'] : ['auto','cool','dry','fan','heat'];
|
||||||
|
const modeOptions = modes.map(v => `<option value="${v}" ${c.mode === v ? 'selected' : ''}>${esc(v === 'auto' ? 'Auto' : v === 'house' ? tr('flow.houseMode') : (tr(`mode.${v}`) || v))}</option>`).join('');
|
||||||
|
const base = `<div class="two"><label><span>${esc(tr('common.power'))}</span><select data-flow-config="power"><option value="" ${c.power == null ? 'selected' : ''}>${esc(tr('actions.noChange'))}</option><option value="true" ${c.power === true ? 'selected' : ''}>${esc(tr('common.on'))}</option><option value="false" ${c.power === false ? 'selected' : ''}>${esc(tr('common.off'))}</option></select></label><label><span>${esc(tr('common.mode'))}</span><select data-flow-config="mode"><option value="">${esc(tr('actions.noChange'))}</option>${modeOptions}</select></label></div>`;
|
||||||
|
const target = group
|
||||||
|
? `<label><span>${esc(tr('groups.profile'))}</span><select data-flow-config="preset"><option value="">${esc(tr('actions.noChange'))}</option>${['auto','comfort','sleep','away','custom'].map(v => `<option value="${v}" ${c.preset === v ? 'selected' : ''}>${esc(zonePresetLabel(v))}</option>`).join('')}</select></label><label><span>${esc(tr('common.targetC'))}</span><input type="number" min="8" max="30" step="0.5" data-flow-config="setpoint" value="${c.setpoint ?? 21}"></label>`
|
||||||
|
: `<label><span>${esc(tr('common.targetC'))}</span><input type="number" min="8" max="30" step="0.5" data-flow-config="target_temperature" value="${c.target_temperature ?? ''}"></label>`;
|
||||||
|
const deviceOptions = group ? '' : `<details class="flow-device-options"><summary>${esc(tr('flow.deviceOptions'))}</summary><div class="two"><label><span>fan_speed</span><select data-flow-config="fan_speed"><option value="" ${c.fan_speed == null ? 'selected' : ''}>${esc(tr('actions.noChange'))}</option>${[0,1,2,3,4,5].map(v => `<option value="${v}" ${Number(c.fan_speed) === v ? 'selected' : ''}>${v}</option>`).join('')}</select></label>${flowOptionalBoolField(c,'swing_vertical')}${flowOptionalBoolField(c,'swing_horizontal')}${flowOptionalBoolField(c,'quiet')}${flowOptionalBoolField(c,'turbo')}${flowOptionalBoolField(c,'light')}${flowOptionalBoolField(c,'air')}${flowOptionalBoolField(c,'xfan')}${flowOptionalBoolField(c,'health')}${flowOptionalBoolField(c,'sleep')}</div><p class="field-note">${esc(tr('flow.deviceOptionsHint'))}</p></details>`;
|
||||||
|
return `${base}${target}${deviceOptions}<label><span>${esc(tr('common.cooldownSeconds'))}</span><input type="number" min="30" data-flow-config="cooldown_seconds" value="${Number(c.cooldown_seconds || 60)}"></label>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function flowExpressionSummary(actionId) {
|
||||||
|
if (!app.flowDraft) return '';
|
||||||
|
const byId = new Map(app.flowDraft.nodes.map(node => [node.id, node]));
|
||||||
|
const incoming = id => app.flowDraft.edges.filter(edge => edge.to === id).map(edge => edge.from);
|
||||||
|
const describe = (id, stack = new Set()) => {
|
||||||
|
if (stack.has(id)) return '…';
|
||||||
|
const node = byId.get(id); if (!node) return '';
|
||||||
|
const next = new Set(stack); next.add(id);
|
||||||
|
const parts = incoming(id).map(input => describe(input, next)).filter(Boolean);
|
||||||
|
if (node.kind === 'logic_and') return `(${parts.join(` ${tr('flow.and')} `)})`;
|
||||||
|
if (node.kind === 'logic_or') return `(${parts.join(` ${tr('flow.or')} `)})`;
|
||||||
|
if (node.kind === 'logic_not') return `${tr('flow.not')} (${parts[0] || '—'})`;
|
||||||
|
const own = flowNodeSummary(node);
|
||||||
|
return parts.length ? `(${parts.join(` ${tr('flow.and')} `)}) ${tr('flow.and')} ${own}` : own;
|
||||||
|
};
|
||||||
|
const roots = incoming(actionId).map(id => describe(id)).filter(Boolean);
|
||||||
|
return roots.length ? roots.join(` ${tr('flow.and')} `) : tr('flow.always');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFlowInterpretation() {
|
||||||
|
const target = $('#flowInterpretation'); if (!target || !app.flowDraft) return;
|
||||||
|
const actions = app.flowDraft.nodes.filter(node => FLOW_NODE_META[node.kind]?.category === 'action');
|
||||||
|
if (!actions.length) { target.textContent = tr('flow.noActionsYet'); return; }
|
||||||
|
target.textContent = actions.map(action => `${flowExpressionSummary(action.id)} → ${flowNodeTitle(FLOW_NODE_META[action.kind])}: ${flowNodeSummary(action)}`).join(' · ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function flowUpstreamNodes(id) {
|
||||||
|
if (!app.flowDraft) return [];
|
||||||
|
const byId = new Map(app.flowDraft.nodes.map(node => [node.id, node])); const seen = new Set(), stack = [id], out = [];
|
||||||
|
while (stack.length) { const current = stack.pop(); app.flowDraft.edges.filter(edge => edge.to === current).forEach(edge => { if (seen.has(edge.from)) return; seen.add(edge.from); const node = byId.get(edge.from); if (node) { out.push(node); stack.push(node.id); } }); }
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addFlowNode(kind) {
|
||||||
|
if (!app.flowDraft || !FLOW_NODE_META[kind]) return;
|
||||||
|
const count = app.flowDraft.nodes.length;
|
||||||
|
const node = { id: newFlowId('node'), kind, x: 80 + (count % 5) * 190, y: 70 + Math.floor(count / 5) * 130, config: flowDefaultConfig(kind) };
|
||||||
|
app.flowDraft.nodes.push(node); app.flowSelectedNodeId = node.id; app.flowDirty = true; renderFlowEditor();
|
||||||
|
}
|
||||||
|
function removeFlowNode(id) {
|
||||||
|
if (!app.flowDraft) return;
|
||||||
|
app.flowDraft.nodes = app.flowDraft.nodes.filter(node => node.id !== id); app.flowDraft.edges = app.flowDraft.edges.filter(edge => edge.from !== id && edge.to !== id);
|
||||||
|
if (app.flowSelectedNodeId === id) app.flowSelectedNodeId = null; if (app.flowConnectFrom === id) app.flowConnectFrom = null; app.flowDirty = true; renderFlowEditor();
|
||||||
|
}
|
||||||
|
function connectFlowNodes(from, to) {
|
||||||
|
if (!app.flowDraft || !from || !to || from === to) return;
|
||||||
|
if (app.flowDraft.edges.some(edge => edge.from === from && edge.to === to)) return;
|
||||||
|
app.flowDraft.edges.push({ id: newFlowId('edge'), from, to }); app.flowDirty = true; app.flowConnectFrom = null; renderFlowEditor();
|
||||||
|
}
|
||||||
|
function removeFlowEdge(id) {
|
||||||
|
if (!app.flowDraft) return;
|
||||||
|
app.flowDraft.edges = app.flowDraft.edges.filter(edge => edge.id !== id); app.flowDirty = true; renderFlowEditor();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function flowSourcePayload() {
|
||||||
|
if (!app.flowDraft) return null;
|
||||||
|
return {
|
||||||
|
name: ($('#flowName')?.value || app.flowDraft.name || '').trim(),
|
||||||
|
enabled: $('#flowEnabled')?.checked !== false,
|
||||||
|
description: app.flowDraft.description || '',
|
||||||
|
nodes: app.flowDraft.nodes || [], edges: app.flowDraft.edges || [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadJsonFile(filename, data) {
|
||||||
|
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
||||||
|
const url = URL.createObjectURL(blob); const a = document.createElement('a');
|
||||||
|
a.href = url; a.download = filename; document.body.appendChild(a); a.click(); a.remove();
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportFlow() {
|
||||||
|
if (!app.flowDraft) return;
|
||||||
|
try {
|
||||||
|
const envelope = app.flowDraft.id && !app.flowDirty
|
||||||
|
? await api(`/api/flows/${encodeURIComponent(app.flowDraft.id)}/export`)
|
||||||
|
: { format: 'gree-controller-flow', version: 1, exported_at: new Date().toISOString(), flow: flowSourcePayload() };
|
||||||
|
const safe = (flowSourcePayload()?.name || 'flow').replace(/[^a-z0-9_-]+/gi, '-').replace(/^-+|-+$/g, '').toLowerCase() || 'flow';
|
||||||
|
downloadJsonFile(`${safe}.flow.json`, envelope); toast(tr('flow.exported'));
|
||||||
|
} catch (error) { toast(error.message, true); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function importFlowFile(file) {
|
||||||
|
if (!file) return;
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(await file.text());
|
||||||
|
const saved = await api('/api/flows/import', { method: 'POST', body: payload });
|
||||||
|
const index = app.flows.findIndex(flow => flow.id === saved.id); if (index >= 0) app.flows[index] = saved; else app.flows.push(saved);
|
||||||
|
renderFlows(); await loadBootstrap(); openFlowEditor(saved.id); toast(tr('flow.imported'));
|
||||||
|
} catch (error) { toast(`${tr('flow.importFailed')}: ${error.message}`, true); }
|
||||||
|
finally { const input = $('#flowImportFile'); if (input) input.value = ''; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function flowTemplateGraph(key) {
|
||||||
|
const zone = app.zones[0]?.id || '', device = app.devices[0]?.id || '', group = app.groups[0]?.id || '';
|
||||||
|
const nodes = [], edges = [];
|
||||||
|
const add = (kind, x, y, config = flowDefaultConfig(kind)) => { const node = { id: newFlowId('node'), kind, x, y, config: { ...config } }; nodes.push(node); return node; };
|
||||||
|
const link = (a, b) => edges.push({ id: newFlowId('edge'), from: a.id, to: b.id });
|
||||||
|
let titleKey = `flow.template.${key}.title`, descriptionKey = `flow.template.${key}.description`;
|
||||||
|
if (key === 'workday_comfort') {
|
||||||
|
const days=add('weekday',50,80), time=add('time_range',250,80,{start:'06:00',end:'08:30'}), action=add('zone_thermostat',500,80,{zone_id:zone,preset:'comfort',setpoint:21,mode:'auto',cooldown_seconds:60}); link(days,time); link(time,action);
|
||||||
|
} else if (key === 'weather_comfort') {
|
||||||
|
const days=add('weekday',40,50), time=add('time_range',40,170,{start:'06:00',end:'22:30'}), outside=add('outdoor_temperature',40,290,{operator:'lt',value:12}), and=add('logic_and',290,160), action=add('zone_thermostat',520,160,{zone_id:zone,preset:'comfort',setpoint:21,mode:'auto',cooldown_seconds:60}); link(days,and); link(time,and); link(outside,and); link(and,action);
|
||||||
|
} else if (key === 'smart_demand') {
|
||||||
|
const time=add('time_range',35,65,{start:'05:30',end:'23:00'}), outside=add('outdoor_temperature',35,185,{operator:'lt',value:10}), room=add('zone_temperature',35,305,{zone_id:zone,operator:'lt',value:20}), or=add('logic_or',280,245), mode=add('house_mode',280,80,{operator:'neq',value:'off'}), and=add('logic_and',500,165), action=add('zone_thermostat',735,165,{zone_id:zone,preset:'comfort',setpoint:21,mode:'auto',cooldown_seconds:90}); link(outside,or); link(room,or); link(time,and); link(mode,and); link(or,and); link(and,action);
|
||||||
|
} else if (key === 'device_resilience') {
|
||||||
|
const online=add('device_state',45,70,{device_id:device,field:'online',operator:'eq',value:'true'}), enabled=add('device_state',45,190,{device_id:device,field:'enabled',operator:'eq',value:'true'}), mode=add('house_mode',45,310,{operator:'neq',value:'off'}), and=add('logic_and',315,190), action=add('zone_thermostat',570,190,{zone_id:zone,preset:'auto',setpoint:21,mode:'auto',cooldown_seconds:60}); link(online,and); link(enabled,and); link(mode,and); link(and,action);
|
||||||
|
} else if (key === 'night_group') {
|
||||||
|
const time=add('time_range',60,120,{start:'22:30',end:'06:00'}), mode=add('house_mode',60,250,{operator:'neq',value:'off'}), and=add('logic_and',310,180), action=group ? add('group_action',560,180,{group_id:group,power:true,mode:'auto',preset:'sleep',cooldown_seconds:120}) : add('zone_thermostat',560,180,{zone_id:zone,preset:'sleep',setpoint:20,mode:'auto',cooldown_seconds:120}); link(time,and); link(mode,and); link(and,action);
|
||||||
|
} else if (key === 'ha_window_guard') {
|
||||||
|
const open=add('ha_state',45,120,{entity_id:'binary_sensor.window',operator:'eq',value:'on'}), mode=add('house_mode',45,250,{operator:'neq',value:'off'}), and=add('logic_and',300,180), action=add('zone_thermostat',555,180,{zone_id:zone,preset:'auto',setpoint:21,mode:'auto',power:false,cooldown_seconds:60}); link(open,and); link(mode,and); link(and,action);
|
||||||
|
} else if (key === 'presence_eco') {
|
||||||
|
const home=add('ha_state',45,80,{entity_id:'person.someone',operator:'eq',value:'home'}), away=add('ha_state',45,250,{entity_id:'person.someone',operator:'eq',value:'not_home'}), comfort=add('zone_thermostat',355,70,{zone_id:zone,preset:'comfort',setpoint:21,mode:'auto',cooldown_seconds:120}), eco=add('zone_thermostat',355,250,{zone_id:zone,preset:'away',setpoint:18,mode:'auto',cooldown_seconds:120}); link(home,comfort); link(away,eco);
|
||||||
|
} else if (key === 'frost_guard') {
|
||||||
|
const heat=add('house_mode',35,55,{operator:'eq',value:'heat'}), outside=add('outdoor_temperature',35,180,{operator:'lt',value:3}), room=add('zone_temperature',35,305,{zone_id:zone,operator:'lt',value:9}), and=add('logic_and',315,180), action=add('zone_thermostat',575,180,{zone_id:zone,preset:'custom',setpoint:12,mode:'heat',cooldown_seconds:300}); link(heat,and); link(outside,and); link(room,and); link(and,action);
|
||||||
|
} else if (key === 'nested_guard') {
|
||||||
|
const days=add('weekday',30,40), time=add('time_range',30,150,{start:'06:00',end:'22:30'}), outside=add('outdoor_temperature',30,270,{operator:'lt',value:8}), room=add('zone_temperature',30,390,{zone_id:zone,operator:'lt',value:20}), or=add('logic_or',285,330), available=add('ha_available',285,450,{entity_id:'binary_sensor.window'}), window=add('ha_state',285,560,{entity_id:'binary_sensor.window',operator:'eq',value:'on'}), not=add('logic_not',500,560), and=add('logic_and',520,245), action=add('zone_thermostat',780,245,{zone_id:zone,preset:'comfort',setpoint:21,mode:'auto',cooldown_seconds:90}); link(outside,or); link(room,or); link(window,not); link(days,and); link(time,and); link(or,and); link(available,and); link(not,and); link(and,action);
|
||||||
|
} else if (key === 'night_quiet') {
|
||||||
|
const night=add('night_mode',50,100), and=add('logic_and',315,165), action=group ? add('group_action',565,165,{group_id:group,power:null,mode:'auto',preset:'sleep',cooldown_seconds:180}) : add('zone_thermostat',565,165,{zone_id:zone,preset:'sleep',setpoint:20,mode:'auto',cooldown_seconds:180}); link(night,and); if (group) { const groupOn=add('group_state',50,235,{group_id:group,field:'power_enabled',operator:'eq',value:'true'}); link(groupOn,and); } link(and,action);
|
||||||
|
}
|
||||||
|
return { titleKey, descriptionKey, nodes, edges };
|
||||||
|
}
|
||||||
|
|
||||||
|
const FLOW_TEMPLATE_KEYS = ['workday_comfort','weather_comfort','smart_demand','device_resilience','night_group','ha_window_guard','presence_eco','frost_guard','nested_guard','night_quiet'];
|
||||||
|
function openFlowTemplates() {
|
||||||
|
if (!app.flowDraft) return;
|
||||||
|
const host = $('#flowTemplateList');
|
||||||
|
host.innerHTML = FLOW_TEMPLATE_KEYS.map(key => `<button type="button" class="flow-template-card" data-flow-template="${esc(key)}"><strong>${esc(tr(`flow.template.${key}.title`))}</strong><span>${esc(tr(`flow.template.${key}.description`))}</span></button>`).join('');
|
||||||
|
$('#flowTemplateDialog')?.showModal();
|
||||||
|
}
|
||||||
|
function applyFlowTemplate(key) {
|
||||||
|
if (!app.flowDraft) return;
|
||||||
|
if (app.flowDraft.nodes.length && !confirm(tr('flow.templateReplaceConfirm'))) return;
|
||||||
|
const template = flowTemplateGraph(key); app.flowDraft.nodes = template.nodes; app.flowDraft.edges = template.edges;
|
||||||
|
app.flowDraft.description = tr(template.descriptionKey); app.flowSelectedNodeId = null; app.flowDirty = true;
|
||||||
|
if (!app.flowDraft.id || $('#flowName').value === tr('flow.newDefaultName')) $('#flowName').value = tr(template.titleKey);
|
||||||
|
$('#flowTemplateDialog')?.close(); renderFlowEditor();
|
||||||
|
}
|
||||||
|
|
||||||
|
function localDateTimeInputValue(date = new Date()) {
|
||||||
|
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())}`;
|
||||||
|
}
|
||||||
|
function flowSimulationOverrideNodes() {
|
||||||
|
return (app.flowDraft?.nodes || []).filter(node => ['outdoor_temperature','device_temperature','zone_temperature','ha_state','ha_numeric','ha_attribute','ha_available','house_mode','device_state','zone_state','group_state','night_mode'].includes(node.kind));
|
||||||
|
}
|
||||||
|
function renderFlowSimulationOverrides() {
|
||||||
|
const host = $('#flowSimulationOverrides'); if (!host) return;
|
||||||
|
const nodes = flowSimulationOverrideNodes();
|
||||||
|
host.innerHTML = nodes.length ? nodes.map(node => {
|
||||||
|
const numeric = ['outdoor_temperature','device_temperature','zone_temperature','ha_numeric'].includes(node.kind);
|
||||||
|
return `<label><span>${esc(flowNodeTitle(FLOW_NODE_META[node.kind]))} · ${esc(flowNodeSummary(node))}</span><input ${numeric ? 'type="number" step="0.1"' : 'type="text"'} data-flow-sim-node="${esc(node.id)}" placeholder="${esc(tr('flow.useLiveValue'))}"></label>`;
|
||||||
|
}).join('') : `<p class="field-note">${esc(tr('flow.noSimulationOverrides'))}</p>`;
|
||||||
|
}
|
||||||
|
function collectFlowSimulationOverrides() {
|
||||||
|
const result = {};
|
||||||
|
$$('[data-flow-sim-node]', $('#flowSimulationOverrides')).forEach(input => {
|
||||||
|
if (input.value.trim() === '') return;
|
||||||
|
const node = flowNodeById(input.dataset.flowSimNode); let value = input.value.trim();
|
||||||
|
if (node && ['outdoor_temperature','device_temperature','zone_temperature','ha_numeric'].includes(node.kind)) value = Number(value);
|
||||||
|
else if (/^(true|false)$/i.test(value)) value = value.toLowerCase() === 'true';
|
||||||
|
result[input.dataset.flowSimNode] = value;
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
function openFlowDryRun() {
|
||||||
|
if (!app.flowDraft) return;
|
||||||
|
$('#flowTestTitle').textContent = tr('flow.dryRun'); $('#flowSimulationControls').hidden = false; $('#flowTestResults').innerHTML = '';
|
||||||
|
$('#flowSimulationAt').value = localDateTimeInputValue(); renderFlowSimulationOverrides(); $('#flowTestDialog')?.showModal();
|
||||||
|
}
|
||||||
|
function renderFlowDryRunResult(result) {
|
||||||
|
const host = $('#flowTestResults');
|
||||||
|
const actionName = id => flowNodeById(id) ? `${flowNodeTitle(FLOW_NODE_META[flowNodeById(id).kind])}: ${flowNodeSummary(flowNodeById(id))}` : id;
|
||||||
|
host.innerHTML = `<div class="flow-test-summary"><strong>${esc(result.summary || tr('flow.dryRun'))}</strong><span>${esc(tr('flow.compiledPreview', result.compiled || { schedules:0, automations:0 }))}</span></div>${(result.actions || []).map(action => `<article class="flow-test-action ${action.would_execute ? 'pass' : 'blocked'}"><div><strong>${esc(actionName(action.node_id))}</strong><span class="badge ${action.would_execute ? 'active' : ''}">${esc(action.would_execute ? tr('flow.wouldExecute') : action.matched ? tr('flow.blockedByOwnership') : tr('flow.conditionFalse'))}</span></div>${action.blocked_reason ? `<p>${esc(tr('flow.blockReason'))}: <code>${esc(action.blocked_reason)}</code></p>` : ''}<div class="flow-trace">${(action.trace || []).map(item => `<div><span>${item.matched ? '✓' : '×'} ${esc(flowNodeTitle(FLOW_NODE_META[item.kind] || { title:item.kind }))}</span><code>${esc(JSON.stringify(item.actual))}</code></div>`).join('')}</div></article>`).join('')}`;
|
||||||
|
}
|
||||||
|
async function runFlowDryRun() {
|
||||||
|
if (!app.flowDraft) return;
|
||||||
|
const input = $('#flowSimulationAt').value; const at = input ? new Date(input).toISOString() : new Date().toISOString();
|
||||||
|
$('#flowTestResults').innerHTML = `<p class="field-note">${esc(tr('flow.runningSimulation'))}</p>`;
|
||||||
|
try {
|
||||||
|
const result = await api('/api/flows/simulate', { method:'POST', body:{ flow:flowSourcePayload(), flow_id:app.flowDraft.id || null, at, overrides:collectFlowSimulationOverrides(), log:true } });
|
||||||
|
renderFlowDryRunResult(result);
|
||||||
|
} catch (error) { $('#flowTestResults').innerHTML = `<div class="empty compact"><strong>${esc(tr('flow.simulationFailed'))}</strong><span>${esc(error.message)}</span></div>`; }
|
||||||
|
}
|
||||||
|
async function openFlowLogs() {
|
||||||
|
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();
|
||||||
|
try {
|
||||||
|
const result = await api(`/api/flows/${encodeURIComponent(app.flowDraft.id)}/logs?limit=150`);
|
||||||
|
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>`;
|
||||||
|
} catch (error) { $('#flowTestResults').innerHTML = `<div class="empty compact"><strong>${esc(tr('flow.logsFailed'))}</strong><span>${esc(error.message)}</span></div>`; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveFlow() {
|
||||||
|
if (!app.flowDraft) return;
|
||||||
|
const name = $('#flowName').value.trim(); if (!name) return toast(tr('flow.nameRequired'), true);
|
||||||
|
const body = { name, enabled: $('#flowEnabled').checked, description: app.flowDraft.description || '', nodes: app.flowDraft.nodes, edges: app.flowDraft.edges };
|
||||||
|
if (app.flowDraft.id) body.expected_revision = Number(app.flowDraft.revision || 0);
|
||||||
|
try {
|
||||||
|
const saved = await api(app.flowDraft.id ? `/api/flows/${encodeURIComponent(app.flowDraft.id)}` : '/api/flows', { method: app.flowDraft.id ? 'PUT' : 'POST', body });
|
||||||
|
const index = app.flows.findIndex(flow => flow.id === saved.id); if (index >= 0) app.flows[index] = saved; else app.flows.push(saved);
|
||||||
|
app.flowDraft = flowDraftFrom(saved); app.flowDirty = false; renderFlows(); renderFlowEditor(); updateBrowserUrl(`/flows/${saved.id}`, true); await loadBootstrap(); toast(tr('flow.saved'));
|
||||||
|
} catch (error) { toast(error.message, true); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteFlow(id) {
|
||||||
|
const flow = app.flows.find(item => item.id === id); if (!flow) return;
|
||||||
|
if (!confirm(tr('flow.deleteConfirm', { name: flow.name }))) return;
|
||||||
|
try { await api(`/api/flows/${encodeURIComponent(id)}`, { method: 'DELETE' }); app.flows = app.flows.filter(item => item.id !== id); renderFlows(); await loadBootstrap(); toast(tr('flow.deleted')); } catch (error) { toast(error.message, true); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateFlowConfig(input) {
|
||||||
|
const node = flowNodeById(app.flowSelectedNodeId); if (!node) return;
|
||||||
|
const key = input.dataset.flowConfig; if (!key) return;
|
||||||
|
if (key === 'days') node.config.days = $$('[data-flow-config="days"]', $('#flowInspector')).filter(el => el.checked).map(el => Number(el.value));
|
||||||
|
else if (['power','swing_vertical','swing_horizontal','quiet','turbo','light','air','xfan','health','sleep'].includes(key)) node.config[key] = input.value === '' ? null : input.value === 'true';
|
||||||
|
else if (key === 'value' && node.kind === 'constant') node.config[key] = input.value === 'true';
|
||||||
|
else if (['value','setpoint','target_temperature','cooldown_seconds','fan_speed'].includes(key) && ['outdoor_temperature','device_temperature','zone_temperature','ha_numeric','zone_thermostat','device_action','group_action'].includes(node.kind)) node.config[key] = input.value === '' ? null : Number(input.value);
|
||||||
|
else node.config[key] = input.value;
|
||||||
|
app.flowDirty = true; renderFlowEditor();
|
||||||
|
}
|
||||||
|
|
||||||
|
let flowDrag = null;
|
||||||
|
document.addEventListener('pointerdown', event => {
|
||||||
|
const nodeEl = event.target.closest?.('[data-flow-node]');
|
||||||
|
if (!nodeEl || event.target.closest('button,input,select,label')) return;
|
||||||
|
const node = flowNodeById(nodeEl.dataset.flowNode); if (!node) return;
|
||||||
|
app.flowSelectedNodeId = node.id; renderFlowInspector();
|
||||||
|
flowDrag = { id: node.id, x: event.clientX, y: event.clientY, left: Number(node.x || 0), top: Number(node.y || 0) };
|
||||||
|
nodeEl.setPointerCapture?.(event.pointerId); event.preventDefault();
|
||||||
|
});
|
||||||
|
document.addEventListener('pointermove', event => {
|
||||||
|
if (!flowDrag || !app.flowDraft) return;
|
||||||
|
const node = flowNodeById(flowDrag.id); if (!node) return;
|
||||||
|
node.x = Math.max(12, flowDrag.left + event.clientX - flowDrag.x); node.y = Math.max(12, flowDrag.top + event.clientY - flowDrag.y); app.flowDirty = true;
|
||||||
|
const el = $(`[data-flow-node="${CSS.escape(node.id)}"]`); if (el) { el.style.left = `${node.x}px`; el.style.top = `${node.y}px`; renderFlowEdges(); }
|
||||||
|
});
|
||||||
|
document.addEventListener('pointerup', () => { flowDrag = null; });
|
||||||
|
|
||||||
|
document.addEventListener('click', event => {
|
||||||
|
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 template = event.target.closest?.('[data-flow-template]'); if (template) { applyFlowTemplate(template.dataset.flowTemplate); 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 input = event.target.closest?.('[data-flow-input]'); if (input) { if (app.flowConnectFrom) connectFlowNodes(app.flowConnectFrom, input.dataset.flowInput); return; }
|
||||||
|
const node = event.target.closest?.('[data-flow-node]'); if (node) { app.flowSelectedNodeId = node.dataset.flowNode; renderFlowEditor(); return; }
|
||||||
|
const action = event.target.closest?.('[data-action]')?.dataset.action;
|
||||||
|
if (action === 'new-flow') openFlowEditor();
|
||||||
|
else if (action === 'edit-flow') openFlowEditor(event.target.closest('[data-action]').dataset.id);
|
||||||
|
else if (action === 'delete-flow') deleteFlow(event.target.closest('[data-action]').dataset.id);
|
||||||
|
else if (action === 'save-flow') saveFlow();
|
||||||
|
else if (action === 'import-flow') $('#flowImportFile')?.click();
|
||||||
|
else if (action === 'export-flow') exportFlow();
|
||||||
|
else if (action === 'flow-templates') openFlowTemplates();
|
||||||
|
else if (action === 'flow-dry-run') openFlowDryRun();
|
||||||
|
else if (action === 'run-flow-dry-run') runFlowDryRun();
|
||||||
|
else if (action === 'flow-logs') openFlowLogs();
|
||||||
|
else if (action === 'close-flow-editor') closeFlowEditor();
|
||||||
|
});
|
||||||
|
document.addEventListener('change', event => { if (event.target.matches?.('[data-flow-config]')) updateFlowConfig(event.target); });
|
||||||
|
$('#flowImportFile')?.addEventListener('change', event => importFlowFile(event.target.files?.[0]));
|
||||||
|
$('#flowName')?.addEventListener('input', () => { if (app.flowDraft) app.flowDirty = true; });
|
||||||
|
$('#flowEnabled')?.addEventListener('change', () => { if (app.flowDraft) app.flowDirty = true; });
|
||||||
|
window.addEventListener('resize', () => { if (!$('#flowEditor')?.hidden) renderFlowEdges(); });
|
||||||
@@ -16,9 +16,10 @@ function setDashboardTab(tab, { scroll = true } = {}) {
|
|||||||
|
|
||||||
function showView(name, { push = true, scroll = true } = {}) {
|
function showView(name, { push = true, scroll = true } = {}) {
|
||||||
if (!canLeaveCurrentView(name)) return false;
|
if (!canLeaveCurrentView(name)) return false;
|
||||||
|
if (name !== 'flows' && !$('#flowEditor')?.hidden) closeFlowEditor({ push: false, force: true });
|
||||||
app.currentView = name;
|
app.currentView = name;
|
||||||
$$('.view').forEach(view => view.classList.toggle('active', view.dataset.view === name));
|
$$('.view').forEach(view => view.classList.toggle('active', view.dataset.view === name));
|
||||||
$$('.bottom-nav button').forEach(button => button.classList.toggle('active', button.dataset.nav === name || (button.dataset.nav === 'more' && ['groups', 'schedules', 'automations', 'simulation', 'night', 'homeassistant', 'settings', 'logs'].includes(name))));
|
$$('.bottom-nav button').forEach(button => button.classList.toggle('active', button.dataset.nav === name || (button.dataset.nav === 'more' && ['groups', 'flows', 'schedules', 'automations', 'simulation', 'night', 'homeassistant', 'settings', 'logs'].includes(name))));
|
||||||
if (push) updateBrowserUrl(pathForView(name));
|
if (push) updateBrowserUrl(pathForView(name));
|
||||||
if (scroll) window.scrollTo({ top: 0, behavior: 'smooth' });
|
if (scroll) window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
if (name === 'dashboard') setDashboardTab(app.dashboardTab, { scroll: false });
|
if (name === 'dashboard') setDashboardTab(app.dashboardTab, { scroll: false });
|
||||||
|
|||||||
+5
-2
@@ -23,7 +23,8 @@ function connectWebSocket() {
|
|||||||
try {
|
try {
|
||||||
const message = JSON.parse(event.data);
|
const message = JSON.parse(event.data);
|
||||||
if (message.event === 'bootstrap') {
|
if (message.event === 'bootstrap') {
|
||||||
const data = message.data; app.devices = data.devices || []; app.zones = data.zones || []; app.groups = data.groups || []; app.schedules = data.schedules || []; app.automations = data.automations || []; app.settings = data.settings || app.settings; app.sensorAliases = { ...(app.settings?.home_assistant?.sensor_aliases || {}) }; app.system = data.system || app.system; app.systemSnapshotAt = Date.now(); app.outdoorTemperature = Number.isFinite(Number(data.outdoor_temperature)) ? Number(data.outdoor_temperature) : app.outdoorTemperature; renderAll(); scheduleControlPlanLoad(); if (app.settings?.debug?.overlay_enabled) loadDebugBacklog(); return;
|
const data = message.data; app.devices = data.devices || []; app.zones = data.zones || []; app.groups = data.groups || []; app.schedules = data.schedules || []; app.automations = data.automations || [];
|
||||||
|
app.flows = data.flows || []; app.settings = data.settings || app.settings; app.sensorAliases = { ...(app.settings?.home_assistant?.sensor_aliases || {}) }; app.system = data.system || app.system; app.systemSnapshotAt = Date.now(); app.outdoorTemperature = Number.isFinite(Number(data.outdoor_temperature)) ? Number(data.outdoor_temperature) : app.outdoorTemperature; renderAll(); scheduleControlPlanLoad(); if (app.settings?.debug?.overlay_enabled) loadDebugBacklog(); return;
|
||||||
}
|
}
|
||||||
const data = message.data || {};
|
const data = message.data || {};
|
||||||
if (['device.updated', 'device.created'].includes(message.event)) { updateDevice(data); renderSummary(); renderDevices(); renderGroups(); scheduleControlPlanLoad(); }
|
if (['device.updated', 'device.created'].includes(message.event)) { updateDevice(data); renderSummary(); renderDevices(); renderGroups(); scheduleControlPlanLoad(); }
|
||||||
@@ -33,6 +34,8 @@ function connectWebSocket() {
|
|||||||
else if (message.event === 'zone.deleted') { app.zones = app.zones.filter(v => v.id !== data.id); renderAll(); scheduleControlPlanLoad(); }
|
else if (message.event === 'zone.deleted') { app.zones = app.zones.filter(v => v.id !== data.id); renderAll(); scheduleControlPlanLoad(); }
|
||||||
else if (['group.updated', 'group.created'].includes(message.event)) { const i = app.groups.findIndex(v => v.id === data.id); if (i >= 0) app.groups[i] = data; else app.groups.push(data); renderGroups(); fillSelects(); scheduleControlPlanLoad(); }
|
else if (['group.updated', 'group.created'].includes(message.event)) { const i = app.groups.findIndex(v => v.id === data.id); if (i >= 0) app.groups[i] = data; else app.groups.push(data); renderGroups(); fillSelects(); scheduleControlPlanLoad(); }
|
||||||
else if (message.event === 'group.deleted') { app.groups = app.groups.filter(v => v.id !== data.id); renderGroups(); fillSelects(); scheduleControlPlanLoad(); }
|
else if (message.event === 'group.deleted') { app.groups = app.groups.filter(v => v.id !== data.id); renderGroups(); fillSelects(); scheduleControlPlanLoad(); }
|
||||||
|
else if (['flow.updated', 'flow.created'].includes(message.event)) { const i = app.flows.findIndex(v => v.id === data.id); if (i >= 0) app.flows[i] = data; else app.flows.push(data); renderFlows(); scheduleControlPlanLoad(); }
|
||||||
|
else if (message.event === 'flow.deleted') { app.flows = app.flows.filter(v => v.id !== data.id); renderFlows(); scheduleControlPlanLoad(); }
|
||||||
else if (message.event === 'settings.updated') {
|
else if (message.event === 'settings.updated') {
|
||||||
const settingsDirty = isFormDirty($('#settingsForm')), nightDirty = isFormDirty($('#nightModeForm')), haDirty = isFormDirty($('#homeAssistantForm'));
|
const settingsDirty = isFormDirty($('#settingsForm')), nightDirty = isFormDirty($('#nightModeForm')), haDirty = isFormDirty($('#homeAssistantForm'));
|
||||||
app.settings = data;
|
app.settings = data;
|
||||||
@@ -56,7 +59,7 @@ function connectWebSocket() {
|
|||||||
else if (message.event === 'gree.frame') { if (app.settings?.debug?.overlay_enabled) debugLine('GREE', `${data.direction || '?'} ${data.protocol_version || ''}`, data.device_name || data.device_id || data.target || '', message.timestamp, data.payload); }
|
else if (message.event === 'gree.frame') { if (app.settings?.debug?.overlay_enabled) debugLine('GREE', `${data.direction || '?'} ${data.protocol_version || ''}`, data.device_name || data.device_id || data.target || '', message.timestamp, data.payload); }
|
||||||
else if (message.event === 'api.request') { if (app.settings?.debug?.overlay_enabled) debugLine('HTTP', `${data.method || '?'} ${data.status || ''}`, `${data.path || ''} · ${data.duration_ms ?? '?'} ms`, message.timestamp); }
|
else if (message.event === 'api.request') { if (app.settings?.debug?.overlay_enabled) debugLine('HTTP', `${data.method || '?'} ${data.status || ''}`, `${data.path || ''} · ${data.duration_ms ?? '?'} ms`, message.timestamp); }
|
||||||
else if (message.event === 'log.created') { if (app.settings?.debug?.overlay_enabled) debugLine('API', data.kind || data.level || 'log', data.message || '', message.timestamp, data.metadata); if (app.currentView === 'logs') loadLogs(); }
|
else if (message.event === 'log.created') { if (app.settings?.debug?.overlay_enabled) debugLine('API', data.kind || data.level || 'log', data.message || '', message.timestamp, data.metadata); if (app.currentView === 'logs') loadLogs(); }
|
||||||
else if (message.event.startsWith('schedule.') || message.event.startsWith('automation.')) scheduleControlPlanLoad();
|
else if (message.event.startsWith('schedule.') || message.event.startsWith('automation.') || message.event.startsWith('flow.')) scheduleControlPlanLoad();
|
||||||
} catch (_) { }
|
} catch (_) { }
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ const VIEW_ROUTES = Object.freeze({
|
|||||||
groups: '/groups',
|
groups: '/groups',
|
||||||
schedules: '/schedules',
|
schedules: '/schedules',
|
||||||
automations: '/automations',
|
automations: '/automations',
|
||||||
|
flows: '/flows',
|
||||||
simulation: '/simulation',
|
simulation: '/simulation',
|
||||||
night: '/night-mode',
|
night: '/night-mode',
|
||||||
homeassistant: '/home-assistant',
|
homeassistant: '/home-assistant',
|
||||||
@@ -84,6 +85,10 @@ function applyRouteFromLocation() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (first === 'flows') {
|
||||||
|
if (parts[1]) requestAnimationFrame(() => openFlowEditor(parts[1] === 'new' ? '' : parts[1], { push: false }));
|
||||||
|
else if (!$('#flowEditor')?.hidden) closeFlowEditor({ push: false, force: true });
|
||||||
|
}
|
||||||
if (!parts.length || first === 'index.html') updateBrowserUrl(VIEW_ROUTES.dashboard, true);
|
if (!parts.length || first === 'index.html') updateBrowserUrl(VIEW_ROUTES.dashboard, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+104
-1
@@ -6122,4 +6122,107 @@ textarea[aria-invalid="true"] {
|
|||||||
.compressor-queue-reason {
|
.compressor-queue-reason {
|
||||||
justify-self: start;
|
justify-self: start;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/* Visual Flow editor */
|
||||||
|
.flow-info-panel { display:flex; align-items:center; justify-content:space-between; gap:20px; }
|
||||||
|
.flow-info-panel p { margin:.35rem 0 0; color:var(--muted); max-width:780px; }
|
||||||
|
.flow-card .card-footer small { max-width:70%; }
|
||||||
|
body.flow-editor-open { overflow:hidden; }
|
||||||
|
.flow-editor { position:fixed; inset:0; z-index:80; background:var(--bg); display:flex; flex-direction:column; }
|
||||||
|
.flow-editor[hidden] { display:none !important; }
|
||||||
|
.flow-editor-bar { min-height:76px; display:flex; align-items:center; justify-content:space-between; gap:20px; padding:10px 18px; border-bottom:1px solid var(--line); background:var(--surface); }
|
||||||
|
.flow-editor-title { display:flex; align-items:center; gap:12px; min-width:0; }
|
||||||
|
.flow-editor-title > div { display:grid; gap:2px; min-width:0; }
|
||||||
|
.flow-editor-title input { border:0; background:transparent; color:var(--text); font-weight:750; font-size:1.12rem; padding:2px 0; min-width:280px; outline:none; }
|
||||||
|
.flow-editor-actions { display:flex; align-items:center; gap:10px; }
|
||||||
|
.flow-enabled { display:flex; align-items:center; gap:8px; color:var(--muted); }
|
||||||
|
.flow-editor-body { flex:1; min-height:0; display:grid; grid-template-columns:220px minmax(420px,1fr) 300px; }
|
||||||
|
.flow-palette,.flow-inspector { background:var(--surface); overflow:auto; padding:16px; }
|
||||||
|
.flow-palette { border-right:1px solid var(--line); }
|
||||||
|
.flow-inspector { border-left:1px solid var(--line); }
|
||||||
|
.flow-palette-head { display:grid; gap:4px; margin-bottom:18px; }
|
||||||
|
.flow-palette-head small { color:var(--muted); line-height:1.35; }
|
||||||
|
.flow-palette-group { display:grid; gap:7px; margin:0 0 18px; }
|
||||||
|
.flow-palette-group > span { color:var(--muted); font-size:.72rem; font-weight:750; text-transform:uppercase; letter-spacing:.08em; margin-bottom:2px; }
|
||||||
|
.flow-palette-group button { text-align:left; border:1px solid var(--line); background:var(--surface-2); color:var(--text); border-radius:10px; padding:9px 10px; }
|
||||||
|
.flow-palette-group button:hover { border-color:var(--accent-border); background:var(--accent-soft); }
|
||||||
|
.flow-workspace-wrap { min-width:0; min-height:0; display:grid; grid-template-rows:minmax(0,1fr) auto; background:var(--canvas); }
|
||||||
|
.flow-workspace { position:relative; min-height:0; overflow:auto; outline:none; background-color:var(--canvas); background-image:linear-gradient(var(--grid) 1px,transparent 1px),linear-gradient(90deg,var(--grid) 1px,transparent 1px); background-size:24px 24px; }
|
||||||
|
.flow-nodes { position:relative; width:2400px; height:1500px; }
|
||||||
|
.flow-edges { position:absolute; inset:0; width:2400px; height:1500px; overflow:visible; pointer-events:none; z-index:1; }
|
||||||
|
.flow-edges path { fill:none; stroke:var(--muted-2); stroke-width:2.5; pointer-events:stroke; cursor:pointer; }
|
||||||
|
.flow-edges path:hover { stroke:var(--danger); stroke-width:4; }
|
||||||
|
.flow-editor .flow-node { position:absolute; z-index:2; display:block; align-content:normal; gap:0; width:170px; min-height:78px; padding:0; border:1px solid var(--line-strong); border-radius:12px; background:var(--surface); box-shadow:0 10px 28px rgba(0,0,0,.18); user-select:none; }
|
||||||
|
.flow-editor .flow-node.selected { border-color:var(--accent); box-shadow:0 0 0 2px var(--accent-soft),0 12px 30px rgba(0,0,0,.22); }
|
||||||
|
.flow-editor .flow-node-time { border-top:3px solid var(--blue); }
|
||||||
|
.flow-editor .flow-node-sensor { border-top:3px solid var(--teal); }
|
||||||
|
.flow-editor .flow-node-logic { border-top:3px solid var(--purple); }
|
||||||
|
.flow-editor .flow-node-action { border-top:3px solid var(--orange); }
|
||||||
|
.flow-editor .flow-node-head { display:flex; justify-content:space-between; gap:8px; align-items:center; padding:8px 9px 6px; font-size:.82rem; font-weight:750; cursor:grab; }
|
||||||
|
.flow-editor .flow-node-head button { border:0; background:transparent; color:var(--muted); padding:0 2px; font-size:1rem; }
|
||||||
|
.flow-editor .flow-node-body { padding:5px 10px 10px; color:var(--muted); font-size:.76rem; line-height:1.35; overflow-wrap:anywhere; }
|
||||||
|
.flow-editor .flow-port { position:absolute; top:50%; width:14px; height:14px; margin-top:-7px; border-radius:50%; border:2px solid var(--surface); background:var(--muted-2); padding:0; z-index:4; }
|
||||||
|
.flow-editor .flow-port-in { left:-8px; }
|
||||||
|
.flow-editor .flow-port-out { right:-8px; }
|
||||||
|
.flow-editor .flow-port:hover,.flow-editor .flow-port.armed { background:var(--accent); transform:scale(1.15); }
|
||||||
|
.flow-empty-hint { position:absolute; z-index:0; left:50%; top:42%; transform:translate(-50%,-50%); text-align:center; display:grid; gap:5px; color:var(--muted); pointer-events:none; }
|
||||||
|
.flow-natural-preview { display:grid; grid-template-columns:auto 1fr; gap:14px; align-items:center; min-height:58px; padding:10px 18px; border-top:1px solid var(--line); background:var(--surface); }
|
||||||
|
.flow-natural-preview span { color:var(--muted); font-size:.86rem; line-height:1.4; }
|
||||||
|
.flow-inspector-head { display:flex; align-items:flex-start; justify-content:space-between; gap:10px; margin-bottom:18px; }
|
||||||
|
.flow-inspector-head h3 { margin:2px 0 0; }
|
||||||
|
.flow-inspector label { display:grid; gap:6px; margin-bottom:13px; color:var(--muted); font-size:.82rem; }
|
||||||
|
.flow-inspector input,.flow-inspector select { width:100%; border:1px solid var(--line); border-radius:10px; background:var(--input-bg); color:var(--text); padding:9px 10px; }
|
||||||
|
.flow-inspector .two { display:grid; grid-template-columns:1fr 1fr; gap:10px; }
|
||||||
|
.flow-day-grid { display:grid; grid-template-columns:1fr 1fr; gap:7px; }
|
||||||
|
.flow-day-grid label { display:flex; align-items:center; gap:7px; padding:8px; border:1px solid var(--line); border-radius:9px; margin:0; }
|
||||||
|
.flow-day-grid input { width:auto; }
|
||||||
|
.flow-inspector-meta { display:grid; gap:5px; margin-top:20px; padding-top:14px; border-top:1px solid var(--line); color:var(--muted); }
|
||||||
|
.flow-inspector-meta code { font-size:.72rem; overflow-wrap:anywhere; }
|
||||||
|
|
||||||
|
@media (max-width: 980px) {
|
||||||
|
.flow-editor-body { grid-template-columns:180px minmax(360px,1fr) 260px; }
|
||||||
|
.flow-editor-title input { min-width:180px; }
|
||||||
|
}
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.flow-editor-bar { align-items:flex-start; flex-direction:column; }
|
||||||
|
.flow-editor-actions { width:100%; justify-content:flex-end; }
|
||||||
|
.flow-editor-body { grid-template-columns:132px minmax(500px,1fr); }
|
||||||
|
.flow-inspector { position:absolute; right:0; top:138px; bottom:0; width:min(310px,86vw); z-index:8; box-shadow:-12px 0 30px rgba(0,0,0,.28); }
|
||||||
|
.flow-palette { padding:10px; }
|
||||||
|
.flow-palette-group button { font-size:.76rem; padding:8px; }
|
||||||
|
.flow-natural-preview { grid-template-columns:1fr; gap:4px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Flow diagnostics, templates and portable Flow files */
|
||||||
|
.flow-tool-dialog { width:min(860px,calc(100vw - 32px)); max-height:min(820px,calc(100vh - 32px)); overflow:auto; border:1px solid var(--line); border-radius:16px; background:var(--surface); color:var(--text); padding:18px; box-shadow:0 24px 70px rgba(0,0,0,.32); }
|
||||||
|
.flow-tool-dialog::backdrop { background:rgba(0,0,0,.48); }
|
||||||
|
.flow-template-list { display:grid; grid-template-columns:repeat(auto-fit,minmax(230px,1fr)); gap:12px; margin-top:16px; }
|
||||||
|
.flow-template-card { display:grid; gap:7px; text-align:left; align-content:start; min-height:118px; border:1px solid var(--line); border-radius:14px; background:var(--surface-2); color:var(--text); padding:14px; }
|
||||||
|
.flow-template-card:hover { border-color:var(--accent); }
|
||||||
|
.flow-template-card span { color:var(--muted); font-size:.86rem; line-height:1.4; }
|
||||||
|
.flow-simulation-controls { display:grid; gap:14px; padding:4px 0 16px; border-bottom:1px solid var(--line); }
|
||||||
|
.flow-simulation-overrides { display:grid; grid-template-columns:repeat(auto-fit,minmax(220px,1fr)); gap:10px; margin-top:8px; }
|
||||||
|
.flow-test-results { display:grid; gap:10px; margin-top:16px; }
|
||||||
|
.flow-test-summary,.flow-test-action,.flow-log-row { border:1px solid var(--line); border-radius:12px; background:var(--surface-2); padding:12px; }
|
||||||
|
.flow-test-summary { display:flex; justify-content:space-between; gap:12px; align-items:center; }
|
||||||
|
.flow-test-action > div:first-child,.flow-log-row > div:first-child { display:flex; justify-content:space-between; gap:10px; align-items:center; }
|
||||||
|
.flow-test-action.pass { border-left:4px solid var(--green); }
|
||||||
|
.flow-test-action.blocked { border-left:4px solid var(--line-strong); }
|
||||||
|
.flow-trace { display:grid; gap:5px; margin-top:10px; }
|
||||||
|
.flow-trace > div { display:flex; justify-content:space-between; gap:10px; padding-top:5px; border-top:1px solid var(--line); font-size:.82rem; }
|
||||||
|
.flow-trace code { color:var(--muted); max-width:48%; overflow-wrap:anywhere; text-align:right; }
|
||||||
|
.flow-log-row p { margin:8px 0 4px; }
|
||||||
|
.flow-log-row small { color:var(--muted); }
|
||||||
|
@media (max-width:760px) { .flow-test-summary { align-items:flex-start; flex-direction:column; } }
|
||||||
|
.flow-editor-actions { flex-wrap:wrap; justify-content:flex-end; }
|
||||||
|
.flow-editor-actions .secondary { padding-inline:10px; }
|
||||||
|
@media (max-width:1100px) {
|
||||||
|
.flow-editor-bar { align-items:flex-start; }
|
||||||
|
.flow-editor-actions { max-width:70%; }
|
||||||
|
}
|
||||||
|
@media (max-width:760px) {
|
||||||
|
.flow-editor-actions { max-width:none; justify-content:flex-start; }
|
||||||
|
}
|
||||||
|
.flow-device-options { margin:10px 0; padding:10px 12px; border:1px solid var(--line); border-radius:12px; background:var(--surface-2); }
|
||||||
|
.flow-device-options summary { cursor:pointer; font-weight:650; }
|
||||||
|
.flow-device-options[open] summary { margin-bottom:10px; }
|
||||||
|
|||||||
Reference in New Issue
Block a user