This commit is contained in:
Mateusz Gruszczyński
2026-09-01 22:20:10 +02:00
parent 1cb62c8d0b
commit 16e0d94564
47 changed files with 2565 additions and 117 deletions
Generated
+1 -1
View File
@@ -633,7 +633,7 @@ dependencies = [
[[package]]
name = "gree-controller"
version = "0.9.5"
version = "0.9.6"
dependencies = [
"aes",
"aes-gcm",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "gree-controller"
version = "0.9.5"
version = "0.9.6"
edition = "2021"
authors = ["GREE Controller contributors"]
description = "Standalone local GREE HVAC controller with Web UI, SQLite and Home Assistant sensor support"
+56 -54
View File
@@ -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
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
71c8058f85fd9ce9f063b7a84dcf2956f23cdbbd02bd4bd99d2fbb9d177c72ad ./home-assistant/custom_components/gree_controller/climate.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
5a96fe8f5c035c34f1339370270cd078056202d09e236dec75735be11de92a7d ./home-assistant/custom_components/gree_controller/entity_map.py
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
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
39c4309001b75abb56234f05662bc06e077054986876f1927937edbce528ec95 ./home-assistant/custom_components/gree_controller/select.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
13f30e2dcdcedbd1b6c3f99c2335e0487108fd72c8e86922368b84f2fa2038ae ./home-assistant/custom_components/gree_controller/translations/pl.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
19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE
379ea62648744409db229dd9f58ce874b6ec65a86c6edd734978422d3aa6a5bd ./lang/en.json
1e453b8ef6c80208d9f88454ba5d78deef56ea7f74a0a7be44c51fabf007e495 ./lang/pl.json
d8459024f04ca514bd8e9d6bd3af872fb942fd85a7cdd5583f4a9d28aab6faba ./make_zip.py
a6391cc7fc014d06205c3fac708e0316e7239eaca47e0a2cb05a18275c58f192 ./README.md
01952aa92b217f8eae2493b88870e2dec595100cd15c4d561ff11ae2b936c46f ./regenerate-sha.sh
02d8c640c31fbc3fcb772f44608cdad20e0462762d8d81bd04cd176239499c4e ./scripts/FILE_MANIFEST.sha256
bb89bac237e750e9b1bf73761d7df97a6b81853091615878c03f13d7b6399aa7 ./scripts/README.md
5bc736c7bc76ca80aaa406bb171d2aa91baf4c3aa8695dce0e09b888b6ab3146 ./scripts/common.sh
6403786610ee6d2f628193c25aee0dd058d62e904aa1a31d5f62fdaae0e94b4f ./scripts/configure-gree-network.sh
dbb92ddc27b8724faf709983e4feecd3f188052b2cba39daba96d4bc16914df5 ./scripts/dev.sh
a0cc9f6c6fa7322c48b227b1e28521ac3abd3fde91d9931a2607f18d6adcd783 ./scripts/FILE_MANIFEST.sha256
14076104c042fba1284ebb07531a6c3ff972df1f9f5b18f70da18ab774efed27 ./scripts/generate_ha_migration.py
e4849261fd9ed1f01df96c0637c439c0c4eff8fa317b2918026167bba343af79 ./scripts/install-lxc.sh
bb7cd2c5b27c9dceec1d1d2846fbad9600df9c08e0ad07d13fcde97af533091c ./scripts/install.sh
e00d211e3885e30d7fed1e43b44e6fdad40a67019060156c0641816a93e3365f ./scripts/network-debug.sh
bb89bac237e750e9b1bf73761d7df97a6b81853091615878c03f13d7b6399aa7 ./scripts/README.md
01952aa92b217f8eae2493b88870e2dec595100cd15c4d561ff11ae2b936c46f ./scripts/regenerate-sha.sh
81345b6a0b51736bdbc98fd23199b62e4c721b4e7437e02dab7ea79b97dff29a ./scripts/service.sh
b48fc84d79aab381226363ac8473f981bcba5e4911c4cc0011261182debf4250 ./scripts/smoke.sh
8ccca8ed49576cf0b8e3453436ac3ff9c2a222cd41b27db98ceb62bd5f87aeef ./scripts/smoke.sh
b50782b3742dfbf8a319c60571c968e93fdf8547db747c759edcffae68cb98bf ./scripts/update.sh
037cc98e65772dade518aea33e13270c9c91afd639126a8a95be97495aecc447 ./src/api.rs
50b653f5ae1c9d24eb2e158fb4af713f5ccabe259c83053fa3cd2a310c7780da ./src/api/assets.rs
6c34b1294d76b0eba4c56bfd0bf69bdd502de0e40838104f6ddf2db4e922683d ./src/api/auth.rs
08a732a445f44d34c644109762ac389b58c96d0fe47b309ce34f4479d67c3712 ./src/api/automations.rs
5643425214a0ad228d6229398b715d9a6823c8c5823cdb4fbf73912d9b1eb74b ./src/api/automations.rs
2317b7cd0c2437c128ac4982d64412c8184ecb5e774825761d61aab6e45fae4a ./src/api/debug_tokens.rs
4f82206fb48a291bdc643c96d4d6c917d669c7bdf2a35312c0a056dddecc7062 ./src/api/devices.rs
417940b467c6f00bbf01cc47eb4479880dee9b2f284a80e3662a6d433dc11b92 ./src/api/events.rs
ba3661eeb136ba9f63697dfcab1f3b1f86e173893ab5412b8363dd2606d3dbe8 ./src/api/flows.rs
d096000e94997cf9aaf9d3cc567214a78b8b2da7da57a76e2b3519247af6e592 ./src/api/groups.rs
a13d4e217fe3ddaa73873ba6e0d1bc93750cf61764d867ff7372274ed21d9599 ./src/api/history.rs
7b5de0bb8b6559a59a98ba56c853b712ffcc7211900f02a4a9a089027878dde1 ./src/api/house.rs
b3ed82de9d885a6325647bff3d0ead6ac46fc74d0250d5e14d75319555ad88d7 ./src/api/house.rs
0d7d598f7ed24315ed0547a60a72a06f20ebcf926489e1c454986f42d22cc305 ./src/api/integrations.rs
bb2a746ecdcc2da5fe54e18b455c7bd19453486dd5c008e71951a2c81d0e7d64 ./src/api/middleware.rs
2b3feefbafc1f813c00dfe20db147aa1f5067ec5b8455f041ac3793802911be3 ./src/api/public_settings.rs
4c8b0d10d83d6130ed6e2703c233ac380b25a6c57fc23a29821f823df90331f9 ./src/api.rs
8f7f0b8c157e9c97d7dc305b79923646ef47d76497abfcf09e9c67bb12af5dd1 ./src/api/schedules.rs
4ffab50d931ca46dc5708914b6b54256d639cc11ad1b4281f649c310db5d41a0 ./src/api/settings.rs
74c156717b37e83d329f7c52ac622e02f690621544c4eb07c4502194db55e6e9 ./src/api/system.rs
a1d1a4ac071493b052e6bf20b03be67cf75726de95649e9a73aeba8d22395a15 ./src/api/schedules.rs
07e61483d10dba134055ab540c3222f1a2e2c51a8bffbcd5e29e632895a29f41 ./src/api/settings.rs
bc1614b9948b8904d0f678c79cc9fffecb75977fbec951aca1ba087527808836 ./src/api/system.rs
681cccb8d09f4ad9c2e6467a4dbe2f9d991125775a5d2bfc9043daf940fecd29 ./src/api/websocket.rs
923e7434e8f5789150eb34ffc4d18d25fa701f4a9040bf856d7926016f1936aa ./src/api/zones.rs
2b6eecead75cd4cdb98a9f74dd1c374dbf11ebfa716abbc3476aa12398af9acd ./src/config.rs
c8b8b4a04c94b9d91f5931057d69ad38344730bffbf68e2f6541b59013441b57 ./src/db.rs
24580b279cfcd5388ba584eaba2c9bb729b8aae81e77b781c23fb804c5b5fa5d ./src/db/climate.rs
3de3fa4262be00029dde2344c4cfa41acd4acc0adec176f344761ff35ea730b6 ./src/db/configuration.rs
3eded80d8dbf13729b2ae1f31baa47aa59c01bcfa550c90874031d25c74d2eac ./src/db/configuration.rs
56b0efe84dbf6bd3ee2397b099220a68966311c187f4d5b53007a1693a5ef327 ./src/db/core_devices.rs
d8a2b323e864f2ec38ee15e3023be37a6aa05387f1ddbeedafe1f51556ad8c7d ./src/db/device_history.rs
f3bd91d0bdb7699f319b9341d928d3cc3b25f759653fcd2095a5c5f695d08fac ./src/db/events_tokens.rs
03d689fb03c4f61629fff8404b6cea7101ba3a5358247b16ad714e0be93e9150 ./src/db/flows.rs
59912e70b688daf5c4d22679d643ed3b724c262d62466aeaa9ef74db87808178 ./src/db/ha_history.rs
369455b5ffb125f1a80b767f62fd8c84a953adb69548dbd8adb653a6fbaaa45b ./src/db.rs
7e2fbce70f40aef360534ba38c6700163db77cb7288b3bf58728fce72a126435 ./src/db/schedules_automations.rs
35cbb521a8ec456756496b8aee71f9dfebad528a2cf2653ba8b48f6035be0c01 ./src/db/tests.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
e16c1a65057f6fbf11bb6f27493ed975f5c89309b31dec0fa3248b9237494ed3 ./src/engine/control_plan.rs
ce7e809765c8ad7dc4819b7f775ba7a632031d4d0e68780cc6ea953f2fd890e7 ./src/engine/control_plan.rs
38f42c86c31048780ca414f2c328f363ef803a0edbc1e1ed85503909a10b69ae ./src/engine/deadlines.rs
d15148382f0c84bdef36750a10b463378e2de6a0fb70eecadeee2b07b8648681 ./src/engine/groups.rs
18a7669318949e533f206d25cf7b63771d7f0f2c0b375a72ff78a2d921baaa61 ./src/engine/history.rs
4972c199274647bff47122c367e51bae8dab24d7ec4d652288e227a5a64c2310 ./src/engine/local_thermostat.rs
ecab67072e756f5a371864282eb470a17de53016593e2e96540674066262c865 ./src/engine/ownership.rs
9c37adf514c96840fd6a5c715c579350fa86d6dcc0655a2aa97d1fd7c7e04299 ./src/engine/polling.rs
952ea5906d39ef917ad71edc345f76b29c07c63079644fd5699b12aa254cb35f ./src/engine.rs
3d48608a065aa52a1fd8b9c59356a74d8216ecee3d47f8ff8ef7d3d47170c617 ./src/engine/runtime.rs
ffa72b93502eece187a96adb4ec94aec6048d4a6dc7af66e7c7af0931318787f ./src/engine/schedules.rs
7742046c0067c7cd5a74e2b81f8a8bdef9b76fcacf9b4af0b7eede1b050b9036 ./src/engine/targets.rs
a9188b588b2617ce1547a45d46141f7e5f1bb0027a48675dd86b274f807d0875 ./src/engine/temperature.rs
c2fe371f9245ce299015f0b4e43f6625a207016abf73d2419acb6bd7437f045a ./src/engine/temporary_thermostat.rs
5631fd8420c4137f4586d98939d24a83701400ac07a3f2fe9da8b8d486df3e64 ./src/engine/tests.rs
ebf8b9ec5c5a84850d8cad4c4faea090e52625b5a0be56625dc84060ecb35186 ./src/engine/zone_actions.rs
d2c2f3678bd13ec2d92404993173a691803da215dbdb1edafcd96e1963c7004e ./src/engine/zone_control.rs
ea3b533752b7abab113541f47ab7f49da27cc5696142796b78cd16889471371b ./src/engine/tests.rs
986c5ce1d7d5e79975b0089807408109afdef5c5a173b427b58e34f534faf411 ./src/engine/zone_actions.rs
900947200973b8e4490600f33738424a89aadf0b59c0316f4cd46deb997730b6 ./src/engine/zone_control.rs
4b271b6fc365b1078c01d6178eb563841b2ecaed5d8639196f58e1312d2236fe ./src/error.rs
c6ff66da9ad08506f839ec56a869ead3abc824b5311e0ea9244517d11f7f4207 ./src/home_assistant.rs
5639bef8cb54d2c44fb67503d107b58b4decc9413e1739079e6b8e8d125247ba ./src/home_assistant.rs
6f9ef85cd53ba030b477bc3c186f0f7e762674124210eabcb0686f2806eaada1 ./src/influxdb.rs
feb50ecd60b9a9fe9de1f7d8e1e26caab5435d59347468f6266a318f431bc3d3 ./src/influxdb/codec.rs
553b44ec0321e62480059bc2451fd34ec7aabe9fc87fc717daba0c4b55679d53 ./src/influxdb/query.rs
6f9ef85cd53ba030b477bc3c186f0f7e762674124210eabcb0686f2806eaada1 ./src/influxdb.rs
5e4d94f8a010df05bae9acab72c518080d2dba94d054df95b87e492305357222 ./src/influxdb/write.rs
ef8ad176eb2ee6ceb02bbf3d63b65460013475908fced05388b29727af67ef7e ./src/main.rs
fca124ec30b5c9286f0f44cc18e8953bb2809f5009d4d521deaebfdeb00c1bd1 ./src/models/automation.rs
da672b06295370a36c69136e15684ab10ccb8fbdc814d76527a05e01213c2d2b ./src/models/control_plan.rs
c63bd6257386969a763f44d0e4fd7b6b19078ec7a7f46e7ec2d73580d2274cab ./src/models.rs
a4f2d6d5a8d1ef58e45721e4640a63d7dd2e80b24accd22f32c74ce4a541ae21 ./src/models/automation.rs
377fc9697a70a03ad35fc3701adf4dbe8687760dc90de9f5c532a4f3b612c762 ./src/models/control_plan.rs
8228b9b6d9e8b1d13de29ce88500beacd88c8877893b7a3e41a4bd1e237ca36c ./src/models/defaults.rs
7cdc99a6f0920baf4ac88fbe7e6f1859e8598a00f00b8c3d84f20bc3a4de35f9 ./src/models/device.rs
42f968d9a99ac154b0bd83e5baa498659b1378be4d47d2a05665be641a2fb938 ./src/models/flow.rs
cfbaa853a5185c4be572588577b313a2d618837f7c07a3860d0e8d2cca04ee60 ./src/models/history.rs
668c9cf157679f8c9fe72846f743629c79e263b14827110834b4addbdf9d13ec ./src/models/integrations.rs
e935e1fcf0d379b627df5d5055dbdad3dd55786fde7191376918b05afdde1727 ./src/models.rs
268b4211df93f08423e39374ab9ffe93a7eba647c173168821736af299b36549 ./src/models/runtime.rs
e77f8cdcb5fc0e51cdbf2527a69585a2f6f9089e159d1e45ec4a48735ab0e304 ./src/models/temporary_thermostat.rs
5b5c80e25ae2aad1b511bd3f0a82b8fbae2440abf8c656ecb90814587d0be137 ./src/models/zone.rs
9c735d7c475f21f2f388c21be784376f7184f0bf18af1886544ef51585a3b290 ./src/notifications.rs
7fc31fbf8841a073a1544b8c7a6390f1a15b56087486ca0596a8418340fa232a ./src/protocol/crypto.rs
f1f765331469f4219a551414833c19b6de413114c22fdd0468e1f417659553c9 ./src/protocol/gree.rs
c935e441933ba8709432cc3ac33ed868043a89188d0f48ea6fbfe34b1f8936a3 ./src/protocol/gree/binding.rs
563ded1758b135eb523f1fa0b1bf55fe5430ef55935b301fd6d79cbb980e1820 ./src/protocol/gree/commands.rs
5d4328199e2285016fd1941e909e5c09f7cdf9fd21ab6538c1bf8bc2f4af3ba3 ./src/protocol/gree/core.rs
@@ -117,38 +119,38 @@ c935e441933ba8709432cc3ac33ed868043a89188d0f48ea6fbfe34b1f8936a3 ./src/protocol
97284ad37767625cfc2b82a3de5c40f6eecddabe185bd0bcea7cf9b165e017f0 ./src/protocol/gree/merge.rs
a96061fbbcadac6c2df2e9708a48dcf819b951eb147f32c7d0d285ac5ae2491b ./src/protocol/gree/network.rs
45d0cd0ea40b9255bd811362547f5a667dac6bc7324074726130baf60aa41d2b ./src/protocol/gree/polling.rs
f1f765331469f4219a551414833c19b6de413114c22fdd0468e1f417659553c9 ./src/protocol/gree.rs
35fc903404045129e3046b7997e941b88f98ceb42485cccc872c2e4aa530c75b ./src/protocol/gree/tests.rs
55c2d615e76c9e4b08c4067ea734e81d81915144e637f3b51c19bea4fc879137 ./src/protocol/gree/transport.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
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
6ee23fcaa62231d15006db078d4f763d1b67d2365fcc2d736d8a7da8df5ea382 ./src/state.rs
b92a6cb158b494fe145b43c7641e65f6fafff47201d7d76edbec2cfd8b94835c ./systemd/gree-controller.service
544a31cb2e5374227026afd6abd0d910381f3fca1638154ebd74e3c3d96a35d1 ./web/404.html
e98bdd7204349cce1ec6f57283509697af0bbc72280622a6c3efa6fed242db4f ./web/favicon.svg
525c7e83930cde6ea9d248220d595f52da4ade6e0f511e8b1075509635f20b32 ./web/index.html
fbe1ff0e28708f3b4acdbd361ba28457fe127e6474f4def15125e9d195c929c0 ./web/js/bootstrap.js
0e76a93e59621704879928e5f49cfa57203a9c3c6b609d054f3a5050ac739c9f ./web/index.html
9ba3f6fa05b72aa989139b2a909982571b2a02055052e4c40104f1e81a9aa7eb ./web/js/README.md
835e606b2e238efe98f750d1e234b51a7a965e048315e785a13792125e8208f5 ./web/js/bootstrap.js
72fa280996ba331a05bdb29a1f49b4e61bdd8964953c8ce1db952156620f08fe ./web/js/charts.js
80bcc8d2214c55c13574e146eb099cb371565e55c7c4b337b71ca57ad29f90d2 ./web/js/core.js
83f6a5602da72ed6a307c1b4cc8cd9e4d9a8bf73e58be43af30ba19025a82ea3 ./web/js/dashboard.js
40f2363e79e12446ec8750b56c6df063035cd9655ee89e7c536e3b1f6e71746c ./web/js/entities.js
4280d096af17ec011e0332492300c9e22713651ef0438ae2dd0559ee0f7844e9 ./web/js/core.js
d34953aec21ade81ca9770c9c99873b0b2a4862a1bf671a6e13b507a5dd5b7ed ./web/js/dashboard.js
2887856e35e086bb575e9fc0d8b0ed1160b6912297a2957535d5416bda68b4b7 ./web/js/entities.js
9a0e87911f78cd8a969f4c57ce74774f5fa829fdecd79494d9a7f9d5804f9792 ./web/js/events.js
65c6adb34a7d18bb401eb8c8d1a11d4d8302e8dfd17dbe2eb617a9f47835bc6a ./web/js/flows.js
cac7615888fd38be3cf714dcf02380c4a41a1802ee1724f91496e7c19cbdffba ./web/js/forms.js
dbf36223863ba882c03eccea4516ba8db7acf0c2e72c6c64490cb994f1e20f96 ./web/js/history.js
7eaa04992ea828dc89f5eaebf674cbbfd160f53eb8d0d6ef0d4e47bd8c28c435 ./web/js/main.js
72f2b4ef1e7de342b1d14a4b2757eb23d5a8acd6e837a0fe9fcc661dc35eeb63 ./web/js/navigation.js
9ba3f6fa05b72aa989139b2a909982571b2a02055052e4c40104f1e81a9aa7eb ./web/js/README.md
262208c2487399ebbc94747c4d71267b01b5c4ac5bb20c682fc926840e048828 ./web/js/realtime.js
415348a93ac3423c491d5b222035a5ccd073eae9dc82e9c0560829afb6e02767 ./web/js/router.js
4a05126dc95c291ede08452b179d77791e270ed0c9ab9806036934ccf7e0f704 ./web/js/settings.js
7f23507b2f1f632d69b466688f68f3f0bf661dc6856a1ed14a462e063114a980 ./web/js/navigation.js
d4aa5575fe0202acab36afd10a2c78f421af27ba4142ea20fb0cf6f182d6b59e ./web/js/realtime.js
c8c82e88c3715b1bfabf155e36266a4c94f5e2fc03eb39dcdd9d08a1985a997c ./web/js/router.js
b3e796778d6eee59d663f198115d0c2a45497fdfc23a88ff4b85893ba079d586 ./web/js/settings-ui.js
4a05126dc95c291ede08452b179d77791e270ed0c9ab9806036934ccf7e0f704 ./web/js/settings.js
6b653e4fe2d4db4ffae6ff8f39b8ce57a10a990dc0000ee4aae51d949afadd52 ./web/manifest.webmanifest
d2e533ca1a445036327095647d15eef89eb810295f4222cf7284ac31ee6b5e10 ./web/styles.css
c86801c5460f8342599affb0237e6e62fc39193277faf1bdfcbef58d8dabf2f0 ./web/styles.css
ae1b03f30b494f474a781a5d32072f1e73eba2b7c768109fc1cc8028cb6662a4 ./web/sw.js
d505d793ce7cc9485b45b78bba1c0d51887adc7451ab59a42702946e5b991382 ./web/theme-init.js
+22 -3
View File
@@ -2,7 +2,7 @@
Self-hosted controller for GREE-compatible air conditioners with a local Web UI, thermostat zones, schedules, Home Assistant integration, history, notifications and a documented HTTP/WebSocket API.
**Current release: 0.9.5**
**Current release: 0.9.6**
> [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. |
| 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. |
| Schedules | Weekly thermostat schedules and ready-made templates. |
| Automations | Time/temperature triggered device or group actions. |
| Flow | Full-screen visual logic editor. Flow is the source of truth and is automatically compiled into generated schedules and automations. |
| 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. |
| Simulation | Simulated units for safe testing. |
| 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.
### 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.
Implementation and concurrency invariants are documented in [`docs/FLOW.md`](docs/FLOW.md).
## Home Assistant
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
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.
+1
View File
@@ -29,6 +29,7 @@ const APP_JS_MODULES: &[&str] = &[
"bootstrap.js",
"dashboard.js",
"entities.js",
"flows.js",
"settings-ui.js",
"router.js",
"navigation.js",
+26 -3
View File
@@ -1,6 +1,6 @@
# 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)
@@ -212,7 +212,7 @@ Response:
{
"status": "ok",
"name": "gree-controller",
"version": "0.9.5",
"version": "0.9.6",
"uptime_seconds": 1234,
"control_ready": true,
"time": "2026-08-30T06:54:00Z"
@@ -236,7 +236,7 @@ Returns the initial Web UI snapshot:
"settings": {},
"outdoor_temperature": null,
"system": {
"version": "0.9.5",
"version": "0.9.6",
"uptime_seconds": 1234,
"auth_required": false,
"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
Runtime settings expose `compressor_protection_enabled` and `compressor_protection_seconds` (301800; 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.
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.
+95
View File
@@ -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",
"name": "GREE Controller",
"version": "0.9.5",
"version": "0.9.6",
"config_flow": true,
"integration_type": "hub",
"iot_class": "local_polling",
+134 -1
View File
@@ -809,6 +809,139 @@
"zones.queueSourceGlobal": "Global power",
"zones.queueRemaining": "Remaining {time}",
"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
View File
@@ -809,6 +809,139 @@
"zones.queueSourceGlobal": "Zasilanie globalne",
"zones.queueRemaining": "Pozostało {time}",
"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
View File
@@ -1 +0,0 @@
scripts/regenerate-sha.sh
+20
View File
@@ -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"
+11
View File
@@ -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
View File
@@ -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
+64
View File
@@ -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"
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.
curl -fsS -X POST -H 'Content-Type: application/json' \
-d '{"name":"Smoke Home Assistant"}' \
+9
View File
@@ -45,6 +45,7 @@ const SPA_ROUTES: &[&str] = &[
"/groups",
"/schedules",
"/automations",
"/flows",
"/simulation",
"/night-mode",
"/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/automations", get(list_automations).post(create_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/history", get(history))
.route("/api/control-plan", get(control_plan))
@@ -125,6 +132,7 @@ pub fn router(state: AppState) -> Router {
.route("/manifest.webmanifest", get(manifest))
.route("/sw.js", get(service_worker))
.route("/favicon.svg", get(favicon))
.route("/flows/:id", get(index))
.route("/lang/index.json", get(language_index))
.route("/lang/:file", get(language_file))
.merge(protected)
@@ -167,6 +175,7 @@ include!("api/groups.rs");
include!("api/house.rs");
include!("api/schedules.rs");
include!("api/automations.rs");
include!("api/flows.rs");
include!("api/history.rs");
include!("api/events.rs");
include!("api/settings.rs");
+4
View File
@@ -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_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_zone_id: None, action_zone_preset: None, flow_conditions: vec![], flow_id: None, flow_node_id: None,
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;
input.validate()?;
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 _group_guard = if let Some(group_id) = action_group_id.as_deref() {
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> {
let _configuration_guard = state.lock_configuration_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}"))); }
state.broadcast("automation.deleted", json!({"id": id}));
Ok(StatusCode::NO_CONTENT)
+561
View File
@@ -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
View File
@@ -256,7 +256,7 @@ async fn apply_schedule_template(State(state): State<AppState>, Path(id): Path<S
items.push(Schedule {
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(),
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];
+3 -1
View File
@@ -25,7 +25,7 @@ impl ScheduleInput {
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,
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> {
@@ -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;
input.validate()?;
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())); }
let old_zone_id = existing.zone_id.clone();
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 _cycle_guard = state.lock_zone_control_cycle().await;
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}"))); }
refresh_zone_override_boundary(&state, &existing.zone_id).await?;
state.broadcast("schedule.deleted", json!({"id": id}));
+36 -9
View File
@@ -154,7 +154,7 @@ async fn export_settings(State(state): State<AppState>) -> Result<Json<Configura
}
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()))?;
if !matches!(export.settings.house_mode.as_str(), "cool" | "heat" | "off") {
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 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 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()
|| schedules.len() != export.schedules.len() || automations.len() != export.automations.len()
|| devices.contains("") || zones.contains("") || schedules.contains("") || automations.contains("")
|| schedules.len() != export.schedules.len() || automations.len() != export.automations.len() || flows.len() != export.flows.len()
|| devices.contains("") || zones.contains("") || schedules.contains("") || automations.contains("") || flows.contains("")
{
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()));
}
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)) {
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()))?;
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())),
}
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) {
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()));
}
}
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 !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()));
}
}
if item.action.target_temperature.is_some() || item.action.fan_speed.is_some()
|| item.action.swing_vertical.is_some() || item.action.swing_horizontal.is_some()
if flow_custom_group {
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.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() {
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
// 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 _automation_guard = state.lock_automation_operation().await;
let _house_guard = state.lock_house_operation().await;
+1
View File
@@ -26,6 +26,7 @@ async fn build_bootstrap(state: &AppState) -> Result<Value, AppError> {
"groups": state.db.list_groups()?,
"schedules": state.db.list_schedules()?,
"automations": state.db.list_automations()?,
"flows": state.db.list_flows()?,
"access_tokens": state.db.list_api_tokens()?,
"settings": public_settings(&settings),
"outdoor_temperature": *state.outdoor_temperature.read().await,
+2 -1
View File
@@ -5,7 +5,7 @@ use rusqlite::{params, Connection, OptionalExtension};
use serde::{de::DeserializeOwned, Serialize};
use serde_json::Value;
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,
};
@@ -19,6 +19,7 @@ pub struct Db {
include!("db/core_devices.rs");
include!("db/climate.rs");
include!("db/schedules_automations.rs");
include!("db/flows.rs");
include!("db/device_history.rs");
include!("db/zone_history.rs");
include!("db/ha_history.rs");
+6 -1
View File
@@ -1,7 +1,7 @@
impl Db {
pub fn export_configuration(&self, settings: RuntimeSettings) -> Result<ConfigurationExport> {
Ok(ConfigurationExport {
format_version: 1,
format_version: 2,
exported_at: Utc::now(),
settings,
devices: self.list_devices()?,
@@ -9,6 +9,7 @@ impl Db {
groups: self.list_groups()?,
schedules: self.list_schedules()?,
automations: self.list_automations()?,
flows: self.list_flows()?,
})
}
@@ -36,6 +37,10 @@ impl Db {
let payload = Self::to_json(item)?;
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)?;
tx.execute(queries::UPSERT_RUNTIME_SETTINGS, params![settings_json, Utc::now().to_rfc3339()])?;
tx.commit()?;
+38
View File
@@ -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
View File
@@ -1,14 +1,15 @@
pub fn automation_action_conflicts_with_thermostat(command: &DeviceCommand) -> bool {
// Power/mode/target are translated by apply_automatic_device_action into durable zone
// state, so they do not fight the thermostat. Fan/quiet/sleep are thermostat outputs with
// no independent zone override model; accepting them as one-shot direct automation would
// let the next thermostat cycle immediately overwrite them.
command.fan_speed.is_some() || command.quiet.is_some() || command.sleep.is_some()
// Power/auto-heat-cool/target are translated by apply_automatic_device_action into durable
// zone state, so they do not fight the thermostat. Dry/fan HVAC modes do not belong to the
// thermostat domain. Fan/quiet/sleep are outputs continuously owned by the regulator and
// have no independent zone override model, so a one-shot automation would be overwritten.
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 {
zones.iter().any(|zone| zone.device_id == device_id && zone.enabled)
fn device_has_thermostat_zone(device_id: &str, zones: &[Zone]) -> bool {
zones.iter().any(|zone| zone.device_id == device_id)
}
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())
.zip(item.threshold).map(|(t, threshold)| t < threshold).unwrap_or(false),
"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,
};
if !should_fire { continue; }
@@ -44,41 +52,49 @@ async fn run_automations(state: &AppState) -> Result<()> {
let zones = state.db.list_zones()?;
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)
&& item.action.power != Some(true)
{
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;
}
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!({
"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;
}
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!({
"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;
}
if item.action_group_id.is_none()
&& device_has_enabled_thermostat_zone(&item.action_device_id, &zones)
if item.action_group_id.is_none() && item.action_zone_id.is_none() && device_blocked_by_temporary_thermostat(&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)
{
// 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
// 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!({
"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;
}
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)
.map(|group| group.zone_ids.iter()
.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)) {
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,
"flow_id": item.flow_id,
"flow_node_id": item.flow_node_id,
"group_id": item.action_group_id,
"device_id": item.action_device_id,
"target_devices": target_devices,
@@ -97,20 +115,22 @@ async fn run_automations(state: &AppState) -> Result<()> {
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() });
control_group(state, group_id, GroupControlPatch {
power: item.action.power,
mode: group_mode,
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))
} else {
match apply_automatic_device_action(state, &item.action_device_id, item.action.clone()).await {
Ok(Some(_)) => Ok(true),
Ok(None) => {
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)
}
@@ -124,12 +144,18 @@ async fn run_automations(state: &AppState) -> Result<()> {
item.updated_at = Utc::now();
state.db.save_automation(&item)?;
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) => {
// 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.
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) => {
// 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.updated_at = Utc::now();
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())
}
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> {
let id = device_id?;
// 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
}
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)
}
+5 -1
View File
@@ -4,6 +4,7 @@ pub async fn build_control_plan(state: &AppState) -> Result<ControlPlan, AppErro
let devices = state.db.list_devices()?;
let zones = state.db.list_zones()?;
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 first_preset = first.manual_preset.as_deref().unwrap_or("auto");
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();
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()
.and_then(|id| groups.iter().find(|group| group.id == id))
.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)
.map(|device| device.name.clone())
.unwrap_or_else(|| item.action_device_id.clone())
+14 -3
View File
@@ -17,7 +17,7 @@ mod tests {
let item = Schedule {
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,
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));
}
@@ -26,7 +26,7 @@ mod tests {
Schedule {
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,
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()),
action_device_id: "d".into(), action_group_id: None, action_preset: None,
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()));
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 { 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 { 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() }));
}
@@ -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]
fn local_thermostat_resume_clears_only_local_quick_control_state() {
let mut zone = test_zone("device");
+5 -1
View File
@@ -49,6 +49,7 @@ async fn send_automatic_device_command_if_owned(
if device_blocked_by_disabled_zone(device_id, &zones)
|| device_blocked_by_manual_override(device_id, &zones)
|| device_blocked_by_local_thermostat(device_id, &zones)
|| device_blocked_by_temporary_thermostat(device_id, &zones)
{
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
// schedule boundary. Serialize that derivation with schedule edits before locking the zone.
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 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;
@@ -71,7 +75,7 @@ async fn apply_automatic_device_action(
let _zone_guard = state.lock_zone_operation(&zone_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}")))?;
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);
}
// A power-on automation is an explicit domain transition and may re-enable a zone that
+3
View File
@@ -674,6 +674,9 @@ async fn control_zones(state: &AppState) -> Result<()> {
"quiet": updated_device.quiet,
"sleep": updated_device.sleep,
"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) => {
+37
View File
@@ -107,3 +107,40 @@ mod tests {
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"))
}
+1
View File
@@ -8,6 +8,7 @@ include!("models/defaults.rs");
include!("models/device.rs");
include!("models/temporary_thermostat.rs");
include!("models/zone.rs");
include!("models/flow.rs");
include!("models/automation.rs");
include!("models/history.rs");
include!("models/integrations.rs");
+19 -1
View File
@@ -17,6 +17,12 @@ pub struct Schedule {
pub setpoint: f64,
pub created_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)]
@@ -25,7 +31,7 @@ pub struct Automation {
pub name: String,
#[serde(default = "default_true")]
pub enabled: bool,
/// temperature_above, temperature_below, time
/// temperature_above, temperature_below, time, flow
pub trigger_kind: String,
#[serde(default)]
pub trigger_device_id: Option<String>,
@@ -48,6 +54,18 @@ pub struct Automation {
pub cooldown_seconds: u64,
#[serde(default)]
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 updated_at: DateTime<Utc>,
}
+2
View File
@@ -9,6 +9,8 @@ pub struct ConfigurationExport {
pub groups: Vec<ClimateGroup>,
pub schedules: Vec<Schedule>,
pub automations: Vec<Automation>,
#[serde(default)]
pub flows: Vec<Flow>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
+57
View File
@@ -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>,
}
+13
View File
@@ -68,3 +68,16 @@ pub const LIST_AUTOMATIONS: &str =
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 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";
+1
View File
@@ -37,6 +37,7 @@ DELETE FROM ha_readings WHERE id IN (
pub const CLEAR_CONFIGURATION: &str = r#"
DELETE FROM schedules;
DELETE FROM automations;
DELETE FROM flows;
DELETE FROM climate_groups;
DELETE FROM zones;
DELETE FROM devices;
+8
View File
@@ -50,6 +50,12 @@ CREATE TABLE IF NOT EXISTS automations (
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 (
id INTEGER PRIMARY KEY AUTOINCREMENT,
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'));
INSERT OR IGNORE INTO schema_migrations(version, applied_at)
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'));
"#;
+62
View File
@@ -176,6 +176,20 @@
<div class="list-grid" id="groupList"></div>
</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">
<div class="section-heading">
<div><span class="eyebrow" data-i18n="schedules.calendar">Calendar</span>
@@ -694,6 +708,53 @@
</section>
</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">
<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>
@@ -708,6 +769,7 @@
</div>
<div class="menu-list">
<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="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>
+1
View File
@@ -8,6 +8,7 @@ async function loadBootstrap(showMessage = false) {
app.groups = data.groups || [];
app.schedules = data.schedules || [];
app.automations = data.automations || [];
app.flows = data.flows || [];
app.accessTokens = data.access_tokens || [];
app.settings = data.settings || null;
app.sensorAliases = { ...(app.settings?.home_assistant?.sensor_aliases || {}) };
+2 -1
View File
@@ -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 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,
currentView: 'dashboard', loading: false, language: preferredLanguage, theme: preferredTheme, connectionStatus: 'connecting',
languages: [], translations: {}, locales: {},
@@ -34,6 +34,7 @@ const app = {
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: {},
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 = []; }
+2
View File
@@ -8,6 +8,7 @@ function renderAll() {
renderZones();
renderSchedules();
renderAutomations();
renderFlows();
renderAccessTokens();
fillSelects();
renderSettings();
@@ -65,6 +66,7 @@ function planEventMarkup(event) {
function automationTriggerLabel(item) {
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';
return tr(key, { temperature: fmtTemp(item.threshold) });
}
+6 -5
View File
@@ -455,20 +455,21 @@ function renderSchedules() {
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>
<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>`;
}
function renderAutomations() {
$('#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 actionDevice = app.devices.find(device => device.id === item.action_device_id);
const targetName = 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 preset = item.action_preset ? zonePresetLabel(item.action_preset) : '—';
const targetName = actionZone?.name || actionGroup?.name || actionDevice?.name || tr('common.noDevice');
const mode = item.action.mode === 'auto' && (actionGroup || actionZone) ? tr('groups.followHouse') : (item.action.mode ? modeLabel(item.action.mode) : '—');
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>
<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>`;
}
+470
View File
@@ -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(); });
+2 -1
View File
@@ -16,9 +16,10 @@ function setDashboardTab(tab, { scroll = true } = {}) {
function showView(name, { push = true, scroll = true } = {}) {
if (!canLeaveCurrentView(name)) return false;
if (name !== 'flows' && !$('#flowEditor')?.hidden) closeFlowEditor({ push: false, force: true });
app.currentView = 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 (scroll) window.scrollTo({ top: 0, behavior: 'smooth' });
if (name === 'dashboard') setDashboardTab(app.dashboardTab, { scroll: false });
+5 -2
View File
@@ -23,7 +23,8 @@ function connectWebSocket() {
try {
const message = JSON.parse(event.data);
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 || {};
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 (['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 (['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') {
const settingsDirty = isFormDirty($('#settingsForm')), nightDirty = isFormDirty($('#nightModeForm')), haDirty = isFormDirty($('#homeAssistantForm'));
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 === '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.startsWith('schedule.') || message.event.startsWith('automation.')) scheduleControlPlanLoad();
else if (message.event.startsWith('schedule.') || message.event.startsWith('automation.') || message.event.startsWith('flow.')) scheduleControlPlanLoad();
} catch (_) { }
};
}
+5
View File
@@ -5,6 +5,7 @@ const VIEW_ROUTES = Object.freeze({
groups: '/groups',
schedules: '/schedules',
automations: '/automations',
flows: '/flows',
simulation: '/simulation',
night: '/night-mode',
homeassistant: '/home-assistant',
@@ -84,6 +85,10 @@ function applyRouteFromLocation() {
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);
}
+103
View File
@@ -6123,3 +6123,106 @@ textarea[aria-invalid="true"] {
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; }