From 16e0d945641e46830923b0a0b3d834fa47426115 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Tue, 1 Sep 2026 22:20:10 +0200 Subject: [PATCH] v0.9.6 --- Cargo.lock | 2 +- Cargo.toml | 2 +- FILE_MANIFEST.sha256 | 110 ++-- README.md | 25 +- build.rs | 1 + docs/API.md | 29 +- docs/FLOW.md | 95 +++ .../gree_controller/manifest.json | 2 +- lang/en.json | 135 ++++- lang/pl.json | 135 ++++- regenerate-sha.sh | 21 +- scripts/FILE_MANIFEST.sha256 | 11 + scripts/dev.sh | 103 ++++ scripts/smoke.sh | 64 ++ src/api.rs | 9 + src/api/automations.rs | 4 + src/api/flows.rs | 561 ++++++++++++++++++ src/api/house.rs | 2 +- src/api/schedules.rs | 4 +- src/api/settings.rs | 45 +- src/api/system.rs | 1 + src/db.rs | 3 +- src/db/configuration.rs | 7 +- src/db/flows.rs | 38 ++ src/engine/automations.rs | 433 +++++++++++++- src/engine/control_plan.rs | 6 +- src/engine/tests.rs | 17 +- src/engine/zone_actions.rs | 6 +- src/engine/zone_control.rs | 3 + src/home_assistant.rs | 37 ++ src/models.rs | 1 + src/models/automation.rs | 20 +- src/models/control_plan.rs | 2 + src/models/flow.rs | 57 ++ src/queries/entities.rs | 13 + src/queries/maintenance.rs | 1 + src/queries/schema.rs | 8 + web/index.html | 62 ++ web/js/bootstrap.js | 1 + web/js/core.js | 3 +- web/js/dashboard.js | 2 + web/js/entities.js | 11 +- web/js/flows.js | 470 +++++++++++++++ web/js/navigation.js | 3 +- web/js/realtime.js | 7 +- web/js/router.js | 5 + web/styles.css | 105 +++- 47 files changed, 2565 insertions(+), 117 deletions(-) create mode 100644 docs/FLOW.md mode change 120000 => 100644 regenerate-sha.sh create mode 100644 scripts/FILE_MANIFEST.sha256 create mode 100644 scripts/dev.sh create mode 100644 src/api/flows.rs create mode 100644 src/db/flows.rs create mode 100644 src/models/flow.rs create mode 100644 web/js/flows.js diff --git a/Cargo.lock b/Cargo.lock index 87967e3..8e36399 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -633,7 +633,7 @@ dependencies = [ [[package]] name = "gree-controller" -version = "0.9.5" +version = "0.9.6" dependencies = [ "aes", "aes-gcm", diff --git a/Cargo.toml b/Cargo.toml index 7645d7b..6654578 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/FILE_MANIFEST.sha256 b/FILE_MANIFEST.sha256 index 613b904..bf1f112 100644 --- a/FILE_MANIFEST.sha256 +++ b/FILE_MANIFEST.sha256 @@ -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 diff --git a/README.md b/README.md index 329ddce..ea7b590 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/build.rs b/build.rs index b4f8641..462016c 100644 --- a/build.rs +++ b/build.rs @@ -29,6 +29,7 @@ const APP_JS_MODULES: &[&str] = &[ "bootstrap.js", "dashboard.js", "entities.js", + "flows.js", "settings-ui.js", "router.js", "navigation.js", diff --git a/docs/API.md b/docs/API.md index 1230b92..06ad15a 100644 --- a/docs/API.md +++ b/docs/API.md @@ -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` (30–1800; default 180). While enabled, thermostat starts and Heat/Cool reversals that fall inside the protection window are represented on the owning zone by `compressor_pending_action`, `compressor_pending_since`, and `compressor_pending_until`. @@ -1490,3 +1499,17 @@ Runtime settings expose `compressor_protection_enabled` and `compressor_protecti - `POST /api/compressor-queue/cancel-all` cancels all currently pending compressor-protection tasks. 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. diff --git a/docs/FLOW.md b/docs/FLOW.md new file mode 100644 index 0000000..2a87aaf --- /dev/null +++ b/docs/FLOW.md @@ -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. diff --git a/home-assistant/custom_components/gree_controller/manifest.json b/home-assistant/custom_components/gree_controller/manifest.json index 112d5d2..28be40a 100644 --- a/home-assistant/custom_components/gree_controller/manifest.json +++ b/home-assistant/custom_components/gree_controller/manifest.json @@ -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", diff --git a/lang/en.json b/lang/en.json index aca2481..52dd12e 100644 --- a/lang/en.json +++ b/lang/en.json @@ -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." } } diff --git a/lang/pl.json b/lang/pl.json index ff9da29..8c96465 100644 --- a/lang/pl.json +++ b/lang/pl.json @@ -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." } } diff --git a/regenerate-sha.sh b/regenerate-sha.sh deleted file mode 120000 index ab40103..0000000 --- a/regenerate-sha.sh +++ /dev/null @@ -1 +0,0 @@ -scripts/regenerate-sha.sh \ No newline at end of file diff --git a/regenerate-sha.sh b/regenerate-sha.sh new file mode 100644 index 0000000..6c8fb74 --- /dev/null +++ b/regenerate-sha.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd "$(dirname "$0")" + +MANIFEST="FILE_MANIFEST.sha256" +TMP="${MANIFEST}.tmp" + +find . -type f \ + ! -path "./${MANIFEST}" \ + ! -path "./${TMP}" \ + ! -path "./.git/*" \ + ! -path "./target/*" \ + -print0 \ + | sort -z \ + | xargs -0 sha256sum > "$TMP" + +mv "$TMP" "$MANIFEST" + +sha256sum -c "$MANIFEST" diff --git a/scripts/FILE_MANIFEST.sha256 b/scripts/FILE_MANIFEST.sha256 new file mode 100644 index 0000000..4920279 --- /dev/null +++ b/scripts/FILE_MANIFEST.sha256 @@ -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 diff --git a/scripts/dev.sh b/scripts/dev.sh new file mode 100644 index 0000000..488603c --- /dev/null +++ b/scripts/dev.sh @@ -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 diff --git a/scripts/smoke.sh b/scripts/smoke.sh index 6f33b19..35628ce 100755 --- a/scripts/smoke.sh +++ b/scripts/smoke.sh @@ -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" <"$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" <"$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" <"$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" <"$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"}' \ diff --git a/src/api.rs b/src/api.rs index cff27c4..4a9cd0e 100644 --- a/src/api.rs +++ b/src/api.rs @@ -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"); diff --git a/src/api/automations.rs b/src/api/automations.rs index 43b351f..8adc59e 100644 --- a/src/api/automations.rs +++ b/src/api/automations.rs @@ -87,6 +87,7 @@ impl AutomationInput { action_group_id: self.action_group_id.map(|value| value.trim().to_string()).filter(|value| !value.is_empty()), action_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, Path(id): Path 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, Path(id): Path async fn delete_automation(State(state): State, Path(id): Path) -> Result { 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) diff --git a/src/api/flows.rs b/src/api/flows.rs new file mode 100644 index 0000000..f03b107 --- /dev/null +++ b/src/api/flows.rs @@ -0,0 +1,561 @@ +#[derive(Debug, Deserialize)] +struct FlowInput { + name: String, + #[serde(default = "yes")] + enabled: bool, + #[serde(default)] + description: String, + #[serde(default)] + nodes: Vec, + #[serde(default)] + edges: Vec, + #[serde(default)] + expected_revision: Option, +} + +#[derive(Debug, Deserialize)] +struct FlowSimulationInput { + flow: FlowInput, + #[serde(default)] + flow_id: Option, + #[serde(default)] + at: Option, + #[serde(default)] + overrides: std::collections::HashMap, + #[serde(default = "yes")] + log: bool, +} + +#[derive(Debug, Deserialize)] +struct FlowLogsQuery { limit: Option } + +fn flow_string(config: &Value, key: &str) -> Option { + 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 { config.get(key).and_then(Value::as_f64) } +fn flow_u8(config: &Value, key: &str) -> Option { config.get(key).and_then(Value::as_u64).and_then(|value| u8::try_from(value).ok()) } +fn flow_bool(config: &Value, key: &str) -> Option { 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::::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::::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::>::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>, temp: &mut std::collections::HashSet, done: &mut std::collections::HashSet) -> 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, AppError> { + let by_id: std::collections::HashMap = nodes.iter().cloned().map(|node| (node.id.clone(), node)).collect(); + let mut incoming = std::collections::HashMap::>::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, + incoming: &std::collections::HashMap>, + seen: &mut std::collections::HashSet, + out: &mut Vec, + ) -> 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, Vec), 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 = 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 = 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, 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) -> Result>, AppError> { Ok(Json(state.db.list_flows()?)) } +async fn get_flow(State(state): State, Path(id): Path) -> Result, AppError> { state.db.get_flow(&id)?.map(Json).ok_or_else(|| AppError::NotFound(format!("flow {id}"))) } + +async fn create_flow(State(state): State, Json(input): Json) -> Result<(StatusCode, Json), 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::>() { 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, Path(id): Path, Json(input): Json) -> Result, 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 = 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, Path(id): Path) -> Result { + 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 = 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, Path(id): Path) -> Result, 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, Json(document): Json) -> Result<(StatusCode, Json), 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::>() { 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, 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, Json(input): Json) -> Result, 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, Path(id): Path, Query(query): Query) -> Result, 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::>(); + Ok(Json(json!({"events": events}))) +} diff --git a/src/api/house.rs b/src/api/house.rs index 9dd3704..ad4d86a 100644 --- a/src/api/house.rs +++ b/src/api/house.rs @@ -256,7 +256,7 @@ async fn apply_schedule_template(State(state): State, Path(id): Path) -> 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, Path(id): Path, 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, Path(id): Path) 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})); diff --git a/src/api/settings.rs b/src/api/settings.rs index a94afc2..63f6fd4 100644 --- a/src/api/settings.rs +++ b/src/api/settings.rs @@ -154,7 +154,7 @@ async fn export_settings(State(state): State) -> Result 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, Json(mut export): Json 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; diff --git a/src/api/system.rs b/src/api/system.rs index f6cd1cc..4aa0fa3 100644 --- a/src/api/system.rs +++ b/src/api/system.rs @@ -26,6 +26,7 @@ async fn build_bootstrap(state: &AppState) -> Result { "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, diff --git a/src/db.rs b/src/db.rs index fb774b6..38a36d6 100644 --- a/src/db.rs +++ b/src/db.rs @@ -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"); diff --git a/src/db/configuration.rs b/src/db/configuration.rs index 0ebfec5..d703a3a 100644 --- a/src/db/configuration.rs +++ b/src/db/configuration.rs @@ -1,7 +1,7 @@ impl Db { pub fn export_configuration(&self, settings: RuntimeSettings) -> Result { 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()?; diff --git a/src/db/flows.rs b/src/db/flows.rs new file mode 100644 index 0000000..da671fd --- /dev/null +++ b/src/db/flows.rs @@ -0,0 +1,38 @@ +impl Db { + pub fn list_flows(&self) -> Result> { + self.list_payloads(queries::LIST_FLOWS) + } + + pub fn get_flow(&self, id: &str) -> Result> { + 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 { + 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) + } +} diff --git a/src/engine/automations.rs b/src/engine/automations.rs index a5f16e7..1a1158d 100644 --- a/src/engine/automations.rs +++ b/src/engine/automations.rs @@ -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 = if let Some(group_id) = item.action_group_id.as_deref() { + let target_devices: Vec = 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 = if let Some(group_id) = item.action_group_id.as_deref() { + let result: Result = 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 { 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) -> 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::().ok(); + let e = flow_value_text(expected).parse::().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 { + 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 { + 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, + condition: &crate::models::FlowCondition, + now: &DateTime, + settings: &crate::models::RuntimeSettings, + overrides: &HashMap, +) -> 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::() { + 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, + overrides: &HashMap, +) -> Result<(bool, Vec), 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::::new(); + let mut final_id = None::; + 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::>())) + } + "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::>())) + } + "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 { + 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 { + 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) +} diff --git a/src/engine/control_plan.rs b/src/engine/control_plan.rs index 02eba63..5cddf0a 100644 --- a/src/engine/control_plan.rs +++ b/src/engine/control_plan.rs @@ -4,6 +4,7 @@ pub async fn build_control_plan(state: &AppState) -> Result = 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 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) => { diff --git a/src/home_assistant.rs b/src/home_assistant.rs index db3d2f2..ced7000 100644 --- a/src/home_assistant.rs +++ b/src/home_assistant.rs @@ -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 { + 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::()) + } + 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 { + 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")) +} diff --git a/src/models.rs b/src/models.rs index 4c73ca1..d2765f7 100644 --- a/src/models.rs +++ b/src/models.rs @@ -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"); diff --git a/src/models/automation.rs b/src/models/automation.rs index 35424d9..2d4c520 100644 --- a/src/models/automation.rs +++ b/src/models/automation.rs @@ -17,6 +17,12 @@ pub struct Schedule { pub setpoint: f64, pub created_at: DateTime, pub updated_at: DateTime, + /// 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, + #[serde(default)] + pub flow_node_id: Option, } #[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, @@ -48,6 +54,18 @@ pub struct Automation { pub cooldown_seconds: u64, #[serde(default)] pub last_fired_at: Option>, + /// Optional Flow-owned thermostat zone target. + #[serde(default)] + pub action_zone_id: Option, + #[serde(default)] + pub action_zone_preset: Option, + /// Topologically ordered condition/logic program compiled from the visual Flow graph. + #[serde(default)] + pub flow_conditions: Vec, + #[serde(default)] + pub flow_id: Option, + #[serde(default)] + pub flow_node_id: Option, pub created_at: DateTime, pub updated_at: DateTime, } diff --git a/src/models/control_plan.rs b/src/models/control_plan.rs index b2ce987..6702094 100644 --- a/src/models/control_plan.rs +++ b/src/models/control_plan.rs @@ -9,6 +9,8 @@ pub struct ConfigurationExport { pub groups: Vec, pub schedules: Vec, pub automations: Vec, + #[serde(default)] + pub flows: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/src/models/flow.rs b/src/models/flow.rs new file mode 100644 index 0000000..4d57351 --- /dev/null +++ b/src/models/flow.rs @@ -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, +} + +#[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, + #[serde(default)] + pub edges: Vec, + #[serde(default)] + pub summary: String, + #[serde(default)] + pub compiled_schedule_ids: Vec, + #[serde(default)] + pub compiled_automation_ids: Vec, + /// Optimistic concurrency token for visual-editor saves. Legacy flows deserialize as 0. + #[serde(default)] + pub revision: u64, + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/src/queries/entities.rs b/src/queries/entities.rs index e9f8e6d..e5981d9 100644 --- a/src/queries/entities.rs +++ b/src/queries/entities.rs @@ -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"; diff --git a/src/queries/maintenance.rs b/src/queries/maintenance.rs index cd06c68..d616608 100644 --- a/src/queries/maintenance.rs +++ b/src/queries/maintenance.rs @@ -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; diff --git a/src/queries/schema.rs b/src/queries/schema.rs index d78fa4e..5275003 100644 --- a/src/queries/schema.rs +++ b/src/queries/schema.rs @@ -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')); "#; diff --git a/web/index.html b/web/index.html index 3ff256b..8fd8701 100644 --- a/web/index.html +++ b/web/index.html @@ -176,6 +176,20 @@
+
+
+
Visual logic +

Flow

+
+
+
+
One place for schedules and automations +

Build the logic from blocks. GREE Controller translates Flow into generated schedules and automations. Generated entries are read-only outside this editor.

+ Flow = source of truth +
+
+
+
Calendar @@ -694,6 +708,53 @@
+ + + + + + +
Flow library

Templates

+

Ready-made layouts create an editable graph using existing zones, devices and groups.

+
+
+ + +
Flow diagnostics

Dry-run

+
+

Dry-run does not change thermostat, device, group, schedule or automation state.

+ +
Sensor overrides

Leave blank to use the current application or Home Assistant state.

+
+
+
+
+