From e50c94b6a3ee6fe2b01ef30ca10b2997433ef776 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Wed, 2 Sep 2026 10:12:51 +0200 Subject: [PATCH] v0.10.0 --- Cargo.lock | 2 +- Cargo.toml | 2 +- FILE_MANIFEST.sha256 | 40 +++++----- README.md | 2 +- docs/API.md | 10 ++- docs/FLOW.md | 4 +- .../gree_controller/manifest.json | 2 +- lang/en.json | 16 ++-- lang/pl.json | 16 ++-- scripts/FILE_MANIFEST.sha256 | 2 +- scripts/smoke.sh | 43 +++++++++++ src/api/flows.rs | 76 ++++++++++++++++++- src/api/settings.rs | 17 +++-- src/engine/automations.rs | 23 +++++- src/models/integrations.rs | 2 +- web/index.html | 5 +- web/js/flows.js | 73 +++++++++++++++++- web/js/settings-ui.js | 57 +++++++++----- web/js/settings.js | 39 ++++------ 19 files changed, 323 insertions(+), 108 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ad32905..0688d64 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -633,7 +633,7 @@ dependencies = [ [[package]] name = "gree-controller" -version = "0.9.14" +version = "0.10.0" dependencies = [ "aes", "aes-gcm", diff --git a/Cargo.toml b/Cargo.toml index 170ac5f..7bc0832 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "gree-controller" -version = "0.9.14" +version = "0.10.0" 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 dbcee74..f2d0d51 100644 --- a/FILE_MANIFEST.sha256 +++ b/FILE_MANIFEST.sha256 @@ -1,12 +1,12 @@ d67af429e4da9ce08e9d2f2a8472849ffbd70d135b1c5da535a076026794d04c ./.env.example a4ec3874a2e3ab1bad28fb40bb620f7b01f64d01ad9b699306bf70ada31227db ./.gitignore -18765f4d1c77c7c44ad4438aa3d31e90030577f5395190d0ec1450c0fadd5987 ./Cargo.lock -d3e13a2ef72326abae6de8eb727d858d191bb2eeb1a1680e2c028c2b560f13ea ./Cargo.toml +bf9a8e40ddb34e4ad07c8af2fb2701d80694ae735b2cbca1a9d227a3f1450ca1 ./Cargo.lock +7142adffb9b6a110ea8aa6337aeb7b43b52a2a35024ef3bc9e9ff3c3b0437590 ./Cargo.toml 19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE -6955218013ff72cc2b92b930e664f0215d067c0ea88f928df09d244b69958ba9 ./README.md +77904d7a2d5bf7206bd1d350f2a15e827b0f821e31da511a4afdd0ba403542c9 ./README.md 41dfdc6d099b54f87d4dd51f696122b3c88d3bf3ddf420bea8f4bc621ba920b0 ./build.rs -b232adc48c1737bd2a7d6ee47c97ff17750243027f6737ae8209f9111c8e819c ./docs/API.md -5322117989c854763d87a4ea6ec5e957863d370ca4ebdc332e2c8c11f23338d1 ./docs/FLOW.md +f2d6d4177a3628e0939ee691a3071c876e95c56d564fcd6e34aedbc3f25d1b0c ./docs/API.md +bdbb1bf4f46fe4e4e99ef3a484a091442c082f57304880b4a1535da167a1d830 ./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 @@ -17,7 +17,7 @@ ab08fbe40e9bb48ebcbfff98760aaa0c9434b61b705aaaadea0c09c255d99b0e ./home-assista 5a96fe8f5c035c34f1339370270cd078056202d09e236dec75735be11de92a7d ./home-assistant/custom_components/gree_controller/entity_map.py c4fb75c246db651087900ebfc2291ff41ac87652cd6194fc0b776b0005c1cbcf ./home-assistant/custom_components/gree_controller/icon.png c4fb75c246db651087900ebfc2291ff41ac87652cd6194fc0b776b0005c1cbcf ./home-assistant/custom_components/gree_controller/logo.png -3593dc4b937182dcf7d372c8710f079d99f25304f6a17ab7d5b84508127047ce ./home-assistant/custom_components/gree_controller/manifest.json +ac0edf99b99e4764a6feed506795c8fb709d3ae73cf054e2a68bf7c8685ac115 ./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 @@ -26,8 +26,8 @@ cca65482e36d48035aca178121a378fe7d578d600acff267ae81a6399c0da653 ./home-assista 13f30e2dcdcedbd1b6c3f99c2335e0487108fd72c8e86922368b84f2fa2038ae ./home-assistant/custom_components/gree_controller/translations/pl.json 4513070521d3dda0efb0d974a86ba674494cfb2b66fe9e5cac5b1b0430dede97 ./home-assistant/generated/gree_controller_entities.example.json 253a0bc912786e67ea7fc92a64e4a510ad973bec343a88ccfb1f28fca3e8cf01 ./lang/README.md -608a846712e3bccfade1b799843096ea9dfcbe6628804ac1a23e9ccc2cfec794 ./lang/en.json -fece587ab929200753697419de702f3abf4661a419acfac9c89d6143b761c7fd ./lang/pl.json +8c92d35110a2610a9693c45063b2996370ad9cd63db2c667f027fcfff23dcd5e ./lang/en.json +b4e288e03408570f51a5bda2e9f5eaa3f64be3c134f805fc0b965a1eeda08f68 ./lang/pl.json d8459024f04ca514bd8e9d6bd3af872fb942fd85a7cdd5583f4a9d28aab6faba ./make_zip.py b14233a8987e53bbbdd6770386ba10fa166ec25c1e098275c173c544b37846fc ./presets/bedroom_window_night.json 1960841119c0b673fb2f03621b32237552fd828045304cd221401214b997c910 ./presets/device_resilience.json @@ -67,7 +67,7 @@ ae21459a261712bcb8d57594528b1648432e8d03a8a38502234829c0dbfef774 ./presets/week fdcd9a5055d08037278b842e7ab69265345c5811f0a06867136c511d140bb191 ./presets/window_available_guard.json 804f22123cd3e8db0fac791826c8dd9f758fb866c8e3b5655fb6d25d259dccf1 ./presets/workday_comfort.json 01952aa92b217f8eae2493b88870e2dec595100cd15c4d561ff11ae2b936c46f ./regenerate-sha.sh -6408d99fbb6154afa65a9bda016dab9c9236e57cf4ae4ccb89943c1a4c5a5a56 ./scripts/FILE_MANIFEST.sha256 +feb34a6d60da49a5135eae8de5028988cca1ea1dd0971c76aec848f965fdea67 ./scripts/FILE_MANIFEST.sha256 bb89bac237e750e9b1bf73761d7df97a6b81853091615878c03f13d7b6399aa7 ./scripts/README.md 5bc736c7bc76ca80aaa406bb171d2aa91baf4c3aa8695dce0e09b888b6ab3146 ./scripts/common.sh 6403786610ee6d2f628193c25aee0dd058d62e904aa1a31d5f62fdaae0e94b4f ./scripts/configure-gree-network.sh @@ -78,7 +78,7 @@ bb7cd2c5b27c9dceec1d1d2846fbad9600df9c08e0ad07d13fcde97af533091c ./scripts/inst e00d211e3885e30d7fed1e43b44e6fdad40a67019060156c0641816a93e3365f ./scripts/network-debug.sh 01952aa92b217f8eae2493b88870e2dec595100cd15c4d561ff11ae2b936c46f ./scripts/regenerate-sha.sh 81345b6a0b51736bdbc98fd23199b62e4c721b4e7437e02dab7ea79b97dff29a ./scripts/service.sh -8ccca8ed49576cf0b8e3453436ac3ff9c2a222cd41b27db98ceb62bd5f87aeef ./scripts/smoke.sh +fec8b0362e763bbc115b5cc5c56f9ebe815730cee161632d81c5dc0ee1475f80 ./scripts/smoke.sh b50782b3742dfbf8a319c60571c968e93fdf8547db747c759edcffae68cb98bf ./scripts/update.sh da16e16dfb330d528d54d2138b07950da701edc46af1ff174a9f6a54090122b3 ./src/api.rs 825370f409124a719043200742c01c3441bc65f483108106140d13fe7bbcba91 ./src/api/assets.rs @@ -87,7 +87,7 @@ da16e16dfb330d528d54d2138b07950da701edc46af1ff174a9f6a54090122b3 ./src/api.rs 2317b7cd0c2437c128ac4982d64412c8184ecb5e774825761d61aab6e45fae4a ./src/api/debug_tokens.rs 4f82206fb48a291bdc643c96d4d6c917d669c7bdf2a35312c0a056dddecc7062 ./src/api/devices.rs 417940b467c6f00bbf01cc47eb4479880dee9b2f284a80e3662a6d433dc11b92 ./src/api/events.rs -787fdee57945c8542b692c849160a3447a8089b03e322f1cc137aa50305e14aa ./src/api/flows.rs +10dcc9330f4d0b1b88b4fd3e80dd85b67cb45c5205c3fe03bbadecf3003e9e99 ./src/api/flows.rs d096000e94997cf9aaf9d3cc567214a78b8b2da7da57a76e2b3519247af6e592 ./src/api/groups.rs a13d4e217fe3ddaa73873ba6e0d1bc93750cf61764d867ff7372274ed21d9599 ./src/api/history.rs b3ed82de9d885a6325647bff3d0ead6ac46fc74d0250d5e14d75319555ad88d7 ./src/api/house.rs @@ -95,7 +95,7 @@ b3ed82de9d885a6325647bff3d0ead6ac46fc74d0250d5e14d75319555ad88d7 ./src/api/hous bb2a746ecdcc2da5fe54e18b455c7bd19453486dd5c008e71951a2c81d0e7d64 ./src/api/middleware.rs 50cfd47e44e22cc802f22f97267c157fa5a6d19b51be921a6ca6b1ea7c9799d1 ./src/api/public_settings.rs a1d1a4ac071493b052e6bf20b03be67cf75726de95649e9a73aeba8d22395a15 ./src/api/schedules.rs -85d1843a830a079e9993dcc01329c0fea7e6cfe62f22b60617665e7300b405b0 ./src/api/settings.rs +09ac8aae6e51f36379908e48b3b18d845d3c0b246277ed945c29e0c648197c4a ./src/api/settings.rs bc1614b9948b8904d0f678c79cc9fffecb75977fbec951aca1ba087527808836 ./src/api/system.rs 681cccb8d09f4ad9c2e6467a4dbe2f9d991125775a5d2bfc9043daf940fecd29 ./src/api/websocket.rs 923e7434e8f5789150eb34ffc4d18d25fa701f4a9040bf856d7926016f1936aa ./src/api/zones.rs @@ -112,7 +112,7 @@ f3bd91d0bdb7699f319b9341d928d3cc3b25f759653fcd2095a5c5f695d08fac ./src/db/event 35cbb521a8ec456756496b8aee71f9dfebad528a2cf2653ba8b48f6035be0c01 ./src/db/tests.rs a384ea4042d1d11692b5f8693df1f54850dcc4e80726780b44b2b096bf667b78 ./src/db/zone_history.rs 952ea5906d39ef917ad71edc345f76b29c07c63079644fd5699b12aa254cb35f ./src/engine.rs -58a09f3980d6a054921b5e484fa3fd2327f8bd90a48ffa5751a60a80542ee47a ./src/engine/automations.rs +db8a94f464e0e92f9b80ec4e9a1f2c46cabbf69e6540c3d3b2155f96ba6b55cb ./src/engine/automations.rs 77931af524e9402fc826050b5fcec422d01b02b625c8ce7f2004f1f6520c9b4d ./src/engine/commands.rs ce7e809765c8ad7dc4819b7f775ba7a632031d4d0e68780cc6ea953f2fd890e7 ./src/engine/control_plan.rs 38f42c86c31048780ca414f2c328f363ef803a0edbc1e1ed85503909a10b69ae ./src/engine/deadlines.rs @@ -143,7 +143,7 @@ a4f2d6d5a8d1ef58e45721e4640a63d7dd2e80b24accd22f32c74ce4a541ae21 ./src/models/a 7cdc99a6f0920baf4ac88fbe7e6f1859e8598a00f00b8c3d84f20bc3a4de35f9 ./src/models/device.rs 42f968d9a99ac154b0bd83e5baa498659b1378be4d47d2a05665be641a2fb938 ./src/models/flow.rs cfbaa853a5185c4be572588577b313a2d618837f7c07a3860d0e8d2cca04ee60 ./src/models/history.rs -5d98d82af11090701d5e654a9c7733b995c571dbd0f7a1905d95ffb7b234b44f ./src/models/integrations.rs +552dfc1717ea88f3a23b1fb7705f1dbb0df7792ca9a6ae7ad7bd275eacef27af ./src/models/integrations.rs 268b4211df93f08423e39374ab9ffe93a7eba647c173168821736af299b36549 ./src/models/runtime.rs e77f8cdcb5fc0e51cdbf2527a69585a2f6f9089e159d1e45ec4a48735ab0e304 ./src/models/temporary_thermostat.rs 5b5c80e25ae2aad1b511bd3f0a82b8fbae2440abf8c656ecb90814587d0be137 ./src/models/zone.rs @@ -171,24 +171,24 @@ f13f1be3d5789539eba3fc1b59c14835f8c681eca7634e118fa3763a53aeee2b ./src/queries/ b92a6cb158b494fe145b43c7641e65f6fafff47201d7d76edbec2cfd8b94835c ./systemd/gree-controller.service 544a31cb2e5374227026afd6abd0d910381f3fca1638154ebd74e3c3d96a35d1 ./web/404.html e98bdd7204349cce1ec6f57283509697af0bbc72280622a6c3efa6fed242db4f ./web/favicon.svg -0c10450b9241a9b7a811fe8ad44b4413018e33fcd5b932f024a73e1ba6a3f946 ./web/index.html +d6df434f3fbd33ba38dd244830c980e54433c6dff94dd39c716b377255bd2ebf ./web/index.html 9ba3f6fa05b72aa989139b2a909982571b2a02055052e4c40104f1e81a9aa7eb ./web/js/README.md 21b662914590e720dafefe4dc3dccc91dbeb963cce9ae91f007c835d2d0c1fe5 ./web/js/bootstrap.js 72fa280996ba331a05bdb29a1f49b4e61bdd8964953c8ce1db952156620f08fe ./web/js/charts.js 7dd04683781137deedc5dfccafcb5a4d15954041f2e5207312816de216904513 ./web/js/core.js -d34953aec21ade81ca9770c9c99873b0b2a4862a1bf671a6e13b507a5dd5b7ed ./web/js/dashboard.js +dfc123885a9b6b5c1d07c12d2d2625ff15aa0844e5cd3736092ccd0a360da39c ./web/js/dashboard.js 2887856e35e086bb575e9fc0d8b0ed1160b6912297a2957535d5416bda68b4b7 ./web/js/entities.js 9a0e87911f78cd8a969f4c57ce74774f5fa829fdecd79494d9a7f9d5804f9792 ./web/js/events.js -ee094f55b3f6d0c40fafaf422776ee87b17e0d83eb228ca6c197dfd3efd4c774 ./web/js/flows.js +92674d277d12aa2bca0fbd7dffcd6e76f182a2601ae60dbdd33210e906a5fee2 ./web/js/flows.js f06557c6d338259059b990f8f79c91d68ac8ec1f245c302468f08929b5506901 ./web/js/forms.js dbf36223863ba882c03eccea4516ba8db7acf0c2e72c6c64490cb994f1e20f96 ./web/js/history.js 7eaa04992ea828dc89f5eaebf674cbbfd160f53eb8d0d6ef0d4e47bd8c28c435 ./web/js/main.js 7f23507b2f1f632d69b466688f68f3f0bf661dc6856a1ed14a462e063114a980 ./web/js/navigation.js c56059693d09e35261dc5dd1940289ffc67c82ea7c547750d57e6c79fe75b05b ./web/js/realtime.js c8c82e88c3715b1bfabf155e36266a4c94f5e2fc03eb39dcdd9d08a1985a997c ./web/js/router.js -3d44b5457200a71f66fbf7bde89c7b9adf416ca185ad0e3c61f2c4e551bceb1e ./web/js/settings-ui.js -a06d718217dc6ffb6d4fb5b002fedac02ba7eea3a5b00066c2241df7c1957e51 ./web/js/settings.js +34513d9ec2e45c60443dffe028eea74f415b8a29016df6af3377a80a4ac0df5b ./web/js/settings-ui.js +aa6a2ba22648de44547efd09f41c4335ebe38ed564f4684e942d7827df40f1a2 ./web/js/settings.js 6b653e4fe2d4db4ffae6ff8f39b8ce57a10a990dc0000ee4aae51d949afadd52 ./web/manifest.webmanifest -c8ca437e032fe81327f3bfe7457b5510f21bad21c021bfc83caf44527ce9633f ./web/styles.css +cad5c59275ab306b0de88b76dcc7b667a3a83585bd9c64ba0221bde7744dd1a1 ./web/styles.css ae1b03f30b494f474a781a5d32072f1e73eba2b7c768109fc1cc8028cb6662a4 ./web/sw.js d505d793ce7cc9485b45b78bba1c0d51887adc7451ab59a42702946e5b991382 ./web/theme-init.js diff --git a/README.md b/README.md index 6d5d2f4..cb0bd87 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.14** +**Current release: 0.10.0** > [Full API reference](docs/API.md) — authentication, every endpoint, request bodies, response models, WebSocket events and examples. diff --git a/docs/API.md b/docs/API.md index d83485c..80f2684 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.14**. +HTTP and WebSocket API for GREE Controller **0.10.0**. [← Main documentation](../README.md) @@ -213,7 +213,7 @@ Response: { "status": "ok", "name": "gree-controller", - "version": "0.9.14", + "version": "0.10.0", "uptime_seconds": 1234, "control_ready": true, "time": "2026-08-30T06:54:00Z" @@ -237,7 +237,7 @@ Returns the initial Web UI snapshot: "settings": {}, "outdoor_temperature": null, "system": { - "version": "0.9.14", + "version": "0.10.0", "uptime_seconds": 1234, "auth_required": false, "control_ready": true, @@ -1053,6 +1053,8 @@ Shape: } ``` +`default_entity_id` is primarily the entity used by the Home Assistant connection test when the request does not provide another entity. Thermostat zones use their own configured `ha_entity_id`; this setting does not automatically become a zone temperature source. + #### InfluxDB settings ```json @@ -1514,7 +1516,7 @@ Flow is the source-of-truth representation for visual schedule/automation logic. Saving a Flow validates the DAG and compiles it atomically. A thermostat action driven only by one weekday block and one time-range block is emitted as a native schedule when its settings are schedule-compatible. More complex graphs are emitted as Flow-triggered automations. Generated schedules and automations expose `flow_id` / `flow_node_id`, use stable names in the form `flow-`, 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, arbitrary Home Assistant state/numeric/attribute/availability sources, and `shared_input` references. Shared inputs are stored in `home_assistant.flow_inputs` and resolve dynamically at evaluation time, allowing the same condition definition to be reused across multiple Flows. 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. +Condition blocks support weekday, time/date ranges, application Night mode, outdoor/device/zone temperatures, house/device/zone/group state, arbitrary Home Assistant state/numeric/attribute/availability sources, and `shared_input` references. Shared inputs are stored in `home_assistant.flow_inputs` and resolve dynamically at evaluation time. Shared inputs store reusable value sources only. They never store a comparison operator or threshold. For comparison-capable source kinds, each `shared_input` Flow node defines its own `operator` and `value`. `PUT /api/settings` rejects shared-input configs that contain an `operator`, and rejects comparison `value` fields for comparison-capable source kinds. Logic blocks support AND, OR and NOT. Action blocks target thermostat zones, GREE devices 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 diff --git a/docs/FLOW.md b/docs/FLOW.md index 6961e10..b8c0360 100644 --- a/docs/FLOW.md +++ b/docs/FLOW.md @@ -57,7 +57,7 @@ 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, a diagnostic constant, and `shared_input`. Shared inputs are configured centrally in Home Assistant / Sensors and referenced by ID, so one source definition can be reused by multiple Flows. Logic blocks are AND, OR and NOT. +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, a diagnostic constant, and `shared_input`. Shared inputs are configured centrally in Home Assistant / Sensors and referenced by ID. Shared inputs store reusable value sources only; operators and comparison values are configured exclusively in each Flow reference. 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. @@ -65,7 +65,7 @@ Actions include thermostat zone, GREE device and climate group. Direct GREE acti The editor ships 37 editable presets grouped into seven categories: Comfort, Energy & cost, Protection & safety, Night, Reliability, Home Assistant / heat sources and Advanced. The library UI also provides search, Favorites, Recent, a mini graph preview and requirement checks before applying a preset. The library includes workday/weekend comfort, weather-aware demand, morning boost, presence and energy-price eco modes, peak-power limiting, open-window/humidity/overheat/frost guards, night profiles, sensor/device resilience, dual heating/cooling thresholds, multi-room group control, nested AND/OR/NOT Home Assistant scenarios, and gas-boiler/external-heat-source coordination including off, reduced-target, boost and fallback heating patterns. -Shared Flow inputs show the Flows that reference them and link directly to those editors. Home Assistant-backed shared inputs can be tested live in the input modal, including the current raw value/attribute and the resulting TRUE/FALSE condition. +Shared Flow inputs show the Flows that reference them and link directly to those editors. Home Assistant-backed shared inputs can be tested live in the input modal. Shared inputs report the current raw value/attribute. Each Flow reference defines its own comparison without changing the shared source. Presets are data files, not JavaScript definitions. Every preset lives in `presets/.json` and contains `id`, `category`, localized `name`, localized `description`, and the complete `flow` graph (`nodes` + `edges`). `build.rs` validates and embeds the directory, exposes `/presets/index.json` plus `/presets/.json`, and the Flow editor loads that library dynamically. Device/zone/group placeholders such as `$zone1`, `$zone2`, `$device1` and `$group1` are resolved when a preset is applied. Adding a preset therefore does not require adding graph-building code to `web/js/flows.js`. diff --git a/home-assistant/custom_components/gree_controller/manifest.json b/home-assistant/custom_components/gree_controller/manifest.json index 95e4c97..75c2be9 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.14", + "version": "0.10.0", "config_flow": true, "integration_type": "hub", "iot_class": "local_polling", diff --git a/lang/en.json b/lang/en.json index 7881fda..a4a34b9 100644 --- a/lang/en.json +++ b/lang/en.json @@ -148,7 +148,8 @@ "settings.haTokenKeep": "Leave empty to keep the saved token", "settings.haTokenSaved": "Token saved — leave empty to keep it", "settings.haLongLivedToken": "Long-Lived Access Token", - "settings.defaultEntity": "Default entity_id", + "settings.defaultEntity": "Connection test entity_id", + "settings.defaultEntityHint": "This entity is used by the Home Assistant connection test when no other entity is specified. It is not automatically used as a zone temperature source.", "settings.testHa": "Test HA", "settings.systemState": "System status", "settings.version": "Version", @@ -941,10 +942,12 @@ "flow.sharedInputType": "Source type", "flow.sharedInputAdd": "Add input", "flow.sharedInputsTitle": "Shared Flow inputs", - "flow.sharedInputsHint": "Define constants, states and sensors once, then reuse them in many Flows. Changes here apply to every Flow that references the input.", + "flow.sharedInputsHint": "Define reusable value sources once, then use them in many Flows. Operators and comparison values are configured only in Flow blocks.", "flow.sharedInputsEmpty": "No shared inputs", "flow.sharedInputsEmptyHint": "Add a constant, Home Assistant state, or another shared source for multiple Flows.", - "flow.sharedInputNodeHint": "This block uses the central shared-input configuration.", + "flow.sharedInputNodeHint": "This block uses a central value source. The comparison below belongs only to this Flow.", + "flow.sharedInputFlowComparisonHint": "The shared input provides only the value source. Define this Flow's comparison here.", + "flow.selectOperator": "Select operator", "flow.sharedInputNodeEmptyHint": "Add a shared input in Home Assistant / Sensors first.", "flow.sharedInputNameRequired": "Enter a shared input name.", "flow.sharedInputDeleteConfirm": "Delete shared input “{name}”?", @@ -971,9 +974,7 @@ "flow.sharedInputTestHint": "The test uses the saved Home Assistant connection and does not save this input.", "flow.sharedInputTestEntityRequired": "Enter entity_id before testing.", "flow.sharedInputTestCurrent": "Current value", - "flow.sharedInputTestExpected": "Condition", - "flow.sharedInputTestMatched": "TRUE — condition matched", - "flow.sharedInputTestNotMatched": "FALSE — condition did not match", + "flow.sharedInputTestValueRead": "Value read successfully", "flow.sharedInputTestAvailable": "Entity available", "flow.sharedInputTestUnavailable": "Entity unavailable", "flow.sharedInputUsedBy": "Used in {count} Flows", @@ -1042,6 +1043,7 @@ "flow.logMessageBlockedThermostatOwner": "The Flow action was suppressed because the device is controlled by an enabled thermostat zone.", "flow.logMessageBlockedOwnership": "The Flow action was suppressed because control ownership changed during execution.", "flow.logMessageConflict": "The Flow action was skipped because another automation already claimed the same target.", - "flow.nameAliasHint": "This is only the user-facing display name. The technical Flow ID and generated task names remain unchanged." + "flow.nameAliasHint": "This is only the user-facing display name. The technical Flow ID and generated task names remain unchanged.", + "flow.sharedInputSourceOnlyHint": "This shared input stores only the value source. Configure the comparison in the Flow block." } } diff --git a/lang/pl.json b/lang/pl.json index 486acdf..b03a326 100644 --- a/lang/pl.json +++ b/lang/pl.json @@ -148,7 +148,8 @@ "settings.haTokenKeep": "Pozostaw puste, aby zachować zapisany token", "settings.haTokenSaved": "Token zapisany — pozostaw puste", "settings.haLongLivedToken": "Długotrwały token dostępu", - "settings.defaultEntity": "Domyślny entity_id", + "settings.defaultEntity": "Entity_id do testu połączenia", + "settings.defaultEntityHint": "Ta encja jest używana przez test połączenia z Home Assistant, gdy nie wskazano innej encji. Nie jest automatycznie źródłem temperatury strefy.", "settings.testHa": "Testuj HA", "settings.systemState": "Stan systemu", "settings.version": "Wersja", @@ -941,10 +942,12 @@ "flow.sharedInputType": "Typ źródła", "flow.sharedInputAdd": "Dodaj wejście", "flow.sharedInputsTitle": "Wspólne wejścia Flow", - "flow.sharedInputsHint": "Zdefiniuj stałe, stany i czujniki raz, a potem używaj ich w wielu Flow. Zmiana tutaj obowiązuje we wszystkich odwołujących się Flow.", + "flow.sharedInputsHint": "Zdefiniuj wspólne źródła wartości raz i używaj ich w wielu Flow. Operatory i wartości porównań ustawia się wyłącznie w blokach Flow.", "flow.sharedInputsEmpty": "Brak wspólnych wejść", "flow.sharedInputsEmptyHint": "Dodaj stałą, stan Home Assistant albo inne współdzielone źródło dla wielu Flow.", - "flow.sharedInputNodeHint": "Ten blok korzysta z centralnej konfiguracji wspólnego wejścia.", + "flow.sharedInputNodeHint": "Ten blok korzysta z centralnego źródła wartości. Poniższe porównanie należy tylko do tego Flow.", + "flow.sharedInputFlowComparisonHint": "Wspólne wejście dostarcza tylko źródło wartości. Warunek dla tego Flow ustaw tutaj.", + "flow.selectOperator": "Wybierz operator", "flow.sharedInputNodeEmptyHint": "Najpierw dodaj wspólne wejście w Home Assistant / Czujniki.", "flow.sharedInputNameRequired": "Podaj nazwę wspólnego wejścia.", "flow.sharedInputDeleteConfirm": "Usunąć wspólne wejście „{name}”?", @@ -971,9 +974,7 @@ "flow.sharedInputTestHint": "Test używa zapisanej konfiguracji połączenia Home Assistant i nie zapisuje tego wejścia.", "flow.sharedInputTestEntityRequired": "Podaj entity_id przed testem.", "flow.sharedInputTestCurrent": "Aktualna wartość", - "flow.sharedInputTestExpected": "Warunek", - "flow.sharedInputTestMatched": "TRUE — warunek spełniony", - "flow.sharedInputTestNotMatched": "FALSE — warunek niespełniony", + "flow.sharedInputTestValueRead": "Wartość odczytana poprawnie", "flow.sharedInputTestAvailable": "Encja dostępna", "flow.sharedInputTestUnavailable": "Encja niedostępna", "flow.sharedInputUsedBy": "Używane w {count} Flow", @@ -1042,6 +1043,7 @@ "flow.logMessageBlockedThermostatOwner": "Akcja Flow została pominięta, ponieważ urządzeniem steruje aktywna strefa termostatu.", "flow.logMessageBlockedOwnership": "Akcja Flow została pominięta, ponieważ w trakcie wykonania zmienił się właściciel sterowania.", "flow.logMessageConflict": "Akcja Flow została pominięta z powodu konfliktu z inną automatyzacją dla tego samego celu.", - "flow.nameAliasHint": "To tylko nazwa wyświetlana użytkownikowi. Techniczne ID Flow i nazwy wygenerowanych zadań pozostają bez zmian." + "flow.nameAliasHint": "To tylko nazwa wyświetlana użytkownikowi. Techniczne ID Flow i nazwy wygenerowanych zadań pozostają bez zmian.", + "flow.sharedInputSourceOnlyHint": "Wspólne wejście zapisuje tylko źródło wartości. Porównanie ustawiasz w bloku Flow." } } diff --git a/scripts/FILE_MANIFEST.sha256 b/scripts/FILE_MANIFEST.sha256 index e58b1f6..d70a6df 100644 --- a/scripts/FILE_MANIFEST.sha256 +++ b/scripts/FILE_MANIFEST.sha256 @@ -8,5 +8,5 @@ bb7cd2c5b27c9dceec1d1d2846fbad9600df9c08e0ad07d13fcde97af533091c ./install.sh e00d211e3885e30d7fed1e43b44e6fdad40a67019060156c0641816a93e3365f ./network-debug.sh 01952aa92b217f8eae2493b88870e2dec595100cd15c4d561ff11ae2b936c46f ./regenerate-sha.sh 81345b6a0b51736bdbc98fd23199b62e4c721b4e7437e02dab7ea79b97dff29a ./service.sh -8ccca8ed49576cf0b8e3453436ac3ff9c2a222cd41b27db98ceb62bd5f87aeef ./smoke.sh +fec8b0362e763bbc115b5cc5c56f9ebe815730cee161632d81c5dc0ee1475f80 ./smoke.sh b50782b3742dfbf8a319c60571c968e93fdf8547db747c759edcffae68cb98bf ./update.sh diff --git a/scripts/smoke.sh b/scripts/smoke.sh index 35628ce..42c9844 100755 --- a/scripts/smoke.sh +++ b/scripts/smoke.sh @@ -83,6 +83,49 @@ 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" ]] +# Shared Flow inputs are source-only; comparisons belong exclusively to Flow references. +curl -fsS "http://127.0.0.1:$PORT/api/settings" >"$TMP/settings-shared-input.json" +python3 - "$TMP/settings-shared-input.json" "$TMP/settings-shared-input-put.json" <<'PYFLOW' +import json,sys +settings=json.load(open(sys.argv[1])) +items=settings.setdefault("home_assistant",{}).setdefault("flow_inputs",[]) +items=[item for item in items if item.get("id") != "smoke-shared-outdoor"] +items.append({"id":"smoke-shared-outdoor","name":"Smoke outdoor value","kind":"outdoor_temperature","config":{}}) +settings["home_assistant"]["flow_inputs"]=items +json.dump(settings,open(sys.argv[2],"w")) +PYFLOW +curl -fsS -X PUT -H 'Content-Type: application/json' --data-binary @"$TMP/settings-shared-input-put.json" \ + "http://127.0.0.1:$PORT/api/settings" >"$TMP/settings-shared-input-result.json" +python3 - "$TMP/settings-shared-input-result.json" <<'PYFLOW' +import json,sys +settings=json.load(open(sys.argv[1])) +item=next(item for item in settings["home_assistant"]["flow_inputs"] if item["id"] == "smoke-shared-outdoor") +assert item["config"] == {}, item +PYFLOW +python3 - "$TMP/settings-shared-input-put.json" "$TMP/settings-shared-input-invalid.json" <<'PYFLOW' +import json,sys +settings=json.load(open(sys.argv[1])) +item=next(item for item in settings["home_assistant"]["flow_inputs"] if item["id"] == "smoke-shared-outdoor") +item["config"]={"operator":"lt","value":20} +json.dump(settings,open(sys.argv[2],"w")) +PYFLOW +INVALID_SHARED_STATUS="$(curl -sS -o /dev/null -w '%{http_code}' -X PUT -H 'Content-Type: application/json' --data-binary @"$TMP/settings-shared-input-invalid.json" "http://127.0.0.1:$PORT/api/settings")" +[[ "$INVALID_SHARED_STATUS" == "400" ]] +cat >"$TMP/flow-shared-value.json" <"$TMP/flow-shared-value-result.json" +python3 - "$TMP/flow-shared-value-result.json" <<'PYFLOW' +import json,sys +result=json.load(open(sys.argv[1])) +action=result["actions"][0] +assert action["matched"] is True, result +trace={item["node_id"]:item for item in action["trace"]} +assert trace["low"]["matched"] is True and trace["low"]["expected"] == 20, trace +assert trace["high"]["matched"] is False and trace["high"]["expected"] == 30, trace +PYFLOW + # A schedule-compatible Flow must keep using the native schedule engine. cat >"$TMP/flow-schedule.json" < bool { 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 shared_input_comparison_kind(kind: &str) -> bool { + matches!(kind, + "outdoor_temperature" | "device_temperature" | "zone_temperature" | + "ha_state" | "ha_numeric" | "ha_attribute" | "house_mode" | + "device_state" | "zone_state" | "group_state" + ) +} + +fn validate_shared_input_source(kind: &str, config: &Value, state: &AppState) -> Result<(), AppError> { + match kind { + "outdoor_temperature" | "house_mode" | "night_mode" => {} + "device_temperature" => { + let id = flow_string(config, "device_id").ok_or_else(|| AppError::BadRequest("shared device temperature input needs device".into()))?; + if state.db.get_device(&id)?.is_none() { return Err(AppError::BadRequest("shared Flow input references a missing device".into())); } + } + "zone_temperature" => { + let id = flow_string(config, "zone_id").ok_or_else(|| AppError::BadRequest("shared zone temperature input needs zone".into()))?; + if state.db.get_zone(&id)?.is_none() { return Err(AppError::BadRequest("shared Flow input references a missing zone".into())); } + } + "ha_state" | "ha_numeric" | "ha_available" => { + if flow_string(config, "entity_id").is_none() { return Err(AppError::BadRequest("shared Home Assistant input needs entity_id".into())); } + } + "ha_attribute" => { + if flow_string(config, "entity_id").is_none() || flow_string(config, "attribute").is_none() { + return Err(AppError::BadRequest("shared Home Assistant attribute input needs entity_id and attribute".into())); + } + } + "device_state" => { + let id = flow_string(config, "device_id").ok_or_else(|| AppError::BadRequest("shared device state input needs device".into()))?; + if state.db.get_device(&id)?.is_none() { return Err(AppError::BadRequest("shared Flow input references a missing device".into())); } + let field = flow_string(config, "field").ok_or_else(|| AppError::BadRequest("shared device state input 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 shared device state field".into())); + } + } + "zone_state" => { + let id = flow_string(config, "zone_id").ok_or_else(|| AppError::BadRequest("shared zone state input needs zone".into()))?; + if state.db.get_zone(&id)?.is_none() { return Err(AppError::BadRequest("shared Flow input references a missing zone".into())); } + let field = flow_string(config, "field").ok_or_else(|| AppError::BadRequest("shared zone state input 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 shared zone state field".into())); + } + } + "group_state" => { + let id = flow_string(config, "group_id").ok_or_else(|| AppError::BadRequest("shared group state input needs group".into()))?; + if state.db.get_group(&id)?.is_none() { return Err(AppError::BadRequest("shared Flow input references a missing group".into())); } + if flow_string(config, "field").as_deref() != Some("power_enabled") { return Err(AppError::BadRequest("unsupported shared group state field".into())); } + } + "constant" => { + if config.get("value").and_then(Value::as_bool).is_none() { return Err(AppError::BadRequest("shared constant input needs a boolean value".into())); } + } + _ => return Err(AppError::BadRequest(format!("unsupported shared Flow input kind: {kind}"))), + } + Ok(()) +} + 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())); } @@ -221,7 +277,25 @@ fn validate_condition(condition: &crate::models::FlowCondition, state: &AppState if condition.config.get("value").and_then(Value::as_bool).is_none() { return Err(AppError::BadRequest("constant block needs a boolean value".into())); } } "shared_input" => { - if flow_string(&condition.config, "input_id").is_none() { return Err(AppError::BadRequest("shared Flow input block needs input_id".into())); } + let input_id = flow_string(&condition.config, "input_id").ok_or_else(|| AppError::BadRequest("shared Flow input block needs input_id".into()))?; + let settings = state.db.load_runtime_settings()?.ok_or_else(|| AppError::BadRequest("runtime settings are unavailable".into()))?; + let shared = settings.home_assistant.flow_inputs.iter().find(|item| item.id == input_id) + .ok_or_else(|| AppError::BadRequest("shared Flow input block references a missing input".into()))?; + validate_shared_input_source(&shared.kind, &shared.config, state)?; + if shared_input_comparison_kind(&shared.kind) { + let operator = flow_string(&condition.config, "operator") + .ok_or_else(|| AppError::BadRequest("shared Flow value needs an operator in the Flow block".into()))?; + let mut config = shared.config.clone(); + let map = config.as_object_mut().ok_or_else(|| AppError::BadRequest("shared Flow input config must be an object".into()))?; + map.insert("operator".into(), Value::String(operator)); + map.insert("value".into(), condition.config.get("value").cloned().unwrap_or(Value::Null)); + let resolved = crate::models::FlowCondition { + id: condition.id.clone(), kind: shared.kind.clone(), config, inputs: Vec::new(), + }; + validate_condition(&resolved, state)?; + } else if flow_string(&condition.config, "operator").is_some() || condition.config.get("value").is_some() { + return Err(AppError::BadRequest("this shared Flow input is already boolean and does not accept a comparison".into())); + } } _ => {} } diff --git a/src/api/settings.rs b/src/api/settings.rs index a1f8019..71ec0e6 100644 --- a/src/api/settings.rs +++ b/src/api/settings.rs @@ -120,13 +120,16 @@ fn validate_flow_shared_inputs(settings: &mut RuntimeSettings, state: &AppState) "night_mode" | "constant") { return Err(AppError::BadRequest(format!("unsupported shared Flow input kind: {}", item.kind))); } - let condition = crate::models::FlowCondition { - id: item.id.clone(), - kind: item.kind.clone(), - config: item.config.clone(), - inputs: Vec::new(), - }; - validate_condition(&condition, state)?; + if !item.config.is_object() { + return Err(AppError::BadRequest("shared Flow input config must be an object".into())); + } + if item.config.get("operator").is_some() { + return Err(AppError::BadRequest("shared Flow inputs are value sources; operator belongs to the Flow block".into())); + } + if shared_input_comparison_kind(&item.kind) && item.config.get("value").is_some() { + return Err(AppError::BadRequest("shared Flow inputs are value sources; comparison value belongs to the Flow block".into())); + } + validate_shared_input_source(&item.kind, &item.config, state)?; } Ok(()) } diff --git a/src/engine/automations.rs b/src/engine/automations.rs index 6b7ff0f..aa31584 100644 --- a/src/engine/automations.rs +++ b/src/engine/automations.rs @@ -286,16 +286,30 @@ async fn flow_leaf_observation( overrides: &HashMap, ) -> Result<(bool, Value), AppError> { let mut resolved_kind = condition.kind.as_str(); - let mut resolved_config: Option<&Value> = None; + let mut resolved_config: Option = None; if condition.kind == "shared_input" { let input_id = condition.config.get("input_id").and_then(Value::as_str).unwrap_or(""); let Some(shared) = settings.home_assistant.flow_inputs.iter().find(|item| item.id == input_id) else { return Ok((false, json!({"error": "missing_shared_input", "input_id": input_id}))); }; resolved_kind = shared.kind.as_str(); - resolved_config = Some(&shared.config); + let mut config = shared.config.clone(); + if matches!(shared.kind.as_str(), + "outdoor_temperature" | "device_temperature" | "zone_temperature" | + "ha_state" | "ha_numeric" | "ha_attribute" | "house_mode" | + "device_state" | "zone_state" | "group_state") { + let Some(operator) = condition.config.get("operator").and_then(Value::as_str).map(str::trim).filter(|value| !value.is_empty()) else { + return Ok((false, json!({"error": "missing_shared_input_operator", "input_id": input_id}))); + }; + let Some(map) = config.as_object_mut() else { + return Ok((false, json!({"error": "invalid_shared_input_config", "input_id": input_id}))); + }; + map.insert("operator".into(), Value::String(operator.to_string())); + map.insert("value".into(), condition.config.get("value").cloned().unwrap_or(Value::Null)); + } + resolved_config = Some(config); } - let c = resolved_config.unwrap_or(&condition.config); + let c = resolved_config.as_ref().unwrap_or(&condition.config); let override_value = overrides.get(&condition.id).cloned(); let (matched, actual) = match resolved_kind { "weekday" => { @@ -497,12 +511,13 @@ pub async fn evaluate_flow_conditions_trace( }; values.insert(condition.id.clone(), matched); final_id = Some(condition.id.clone()); + let expected = condition.config.get("value").cloned(); trace.push(json!({ "node_id": condition.id, "kind": condition.kind, "matched": matched, "actual": actual, - "expected": condition.config.get("value"), + "expected": expected, "inputs": condition.inputs })); } diff --git a/src/models/integrations.rs b/src/models/integrations.rs index 853c58f..58842ce 100644 --- a/src/models/integrations.rs +++ b/src/models/integrations.rs @@ -27,7 +27,7 @@ pub struct HomeAssistantSettings { /// Friendly labels used only by the controller UI/charts; entity_id remains the storage key. #[serde(default)] pub sensor_aliases: BTreeMap, - /// Reusable Flow condition inputs shared by any visual Flow. + /// Reusable value sources shared by visual Flows. Comparisons belong to Flow nodes. #[serde(default)] pub flow_inputs: Vec, } diff --git a/web/index.html b/web/index.html index 6477d9b..d5e7fe4 100644 --- a/web/index.html +++ b/web/index.html @@ -386,7 +386,8 @@

Źródła temperatury sterowania

Te ustawienia należą do logiki termostatów i źródeł temperatury, nie do wspólnych wejść Flow.

- + +

This entity is used by the Home Assistant connection test when no other entity is specified. It is not automatically used as a zone temperature source.

If the HA sensor is not updated within this time, the reading is treated as stale.

@@ -395,7 +396,7 @@
-

Wspólne wejścia Flow

Zdefiniuj stałe i warunki czujników raz, a potem używaj ich w wielu Flow.

+

Wspólne wejścia Flow

Zdefiniuj wartości, stany i czujniki raz, a potem używaj ich w wielu Flow. Operator jest opcjonalny.

diff --git a/web/js/flows.js b/web/js/flows.js index b0276be..bf33a5b 100644 --- a/web/js/flows.js +++ b/web/js/flows.js @@ -29,6 +29,29 @@ function newFlowId(prefix = 'node') { return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`; } +function sharedFlowInputRequiresComparison(kind) { + return ['outdoor_temperature','device_temperature','zone_temperature','ha_state','ha_numeric','ha_attribute','house_mode','device_state','zone_state','group_state'].includes(kind); +} + +function sharedFlowReferenceComparisonDefaults(item) { + const kind = item?.kind || ''; + if (['outdoor_temperature','device_temperature','zone_temperature'].includes(kind)) return { operator:'lt', value:20 }; + if (kind === 'ha_numeric') return { operator:'lt', value:0 }; + if (kind === 'ha_state') return { operator:'eq', value:'on' }; + if (kind === 'ha_attribute') return { operator:'eq', value:'' }; + if (kind === 'house_mode') return { operator:'eq', value:'cool' }; + if (kind === 'device_state') return { operator:'eq', value:'true' }; + if (kind === 'zone_state') return { operator:'eq', value:'true' }; + if (kind === 'group_state') return { operator:'eq', value:'true' }; + return {}; +} + +function sharedFlowReferenceDefaultConfig(item) { + const config = { input_id:item?.id || '' }; + if (item && sharedFlowInputRequiresComparison(item.kind)) Object.assign(config, sharedFlowReferenceComparisonDefaults(item)); + return config; +} + function flowDefaultConfig(kind) { if (kind === 'weekday') return { days: [1, 2, 3, 4, 5] }; if (kind === 'time_range') return { start: '06:00', end: '08:00' }; @@ -46,7 +69,7 @@ function flowDefaultConfig(kind) { if (kind === 'group_state') return { group_id: app.groups[0]?.id || '', field: 'power_enabled', operator: 'eq', value: 'true' }; if (kind === 'night_mode') return {}; if (kind === 'constant') return { value: true }; - if (kind === 'shared_input') return { input_id: app.flowSharedInputs?.[0]?.id || '' }; + if (kind === 'shared_input') return sharedFlowReferenceDefaultConfig(app.flowSharedInputs?.[0]); if (kind === 'zone_thermostat') return { zone_id: app.zones[0]?.id || '', preset: 'comfort', setpoint: 21, mode: 'auto', cooldown_seconds: 60 }; if (kind === 'device_action') return { device_id: app.devices[0]?.id || '', power: true, mode: '', target_temperature: null, fan_speed: null, swing_vertical: null, swing_horizontal: null, quiet: null, turbo: null, light: null, air: null, xfan: null, health: null, sleep: null, cooldown_seconds: 60 }; if (kind === 'group_action') return { group_id: app.groups[0]?.id || '', power: true, mode: '', preset: '', setpoint: 21, cooldown_seconds: 60 }; @@ -109,7 +132,16 @@ function flowNodeSummary(node) { if (node.kind === 'group_state') return `${app.groups.find(g => g.id === c.group_id)?.name || tr('groups.group')} · ${c.field || 'power_enabled'} ${flowOperatorLabel(c.operator)} ${c.value ?? '—'}`; if (node.kind === 'night_mode') return tr('flow.nightModeActive'); if (node.kind === 'constant') return c.value === false ? tr('common.no') : tr('common.yes'); - if (node.kind === 'shared_input') { const item = (app.flowSharedInputs || []).find(value => value.id === c.input_id); return item ? `${item.name} · ${sharedFlowInputSummary(item)}` : tr('flow.sharedInputMissing'); } + if (node.kind === 'shared_input') { + const item = (app.flowSharedInputs || []).find(value => value.id === c.input_id); + if (!item) return tr('flow.sharedInputMissing'); + if (sharedFlowInputRequiresComparison(item.kind)) { + return c.operator + ? `${item.name} · ${sharedFlowInputSourceSummary(item)} · ${flowOperatorLabel(c.operator)} ${c.value ?? '—'}` + : `${item.name} · ${tr('flow.selectOperator')}`; + } + return `${item.name} · ${sharedFlowInputSourceSummary(item)}`; + } if (node.kind === 'logic_and') return tr('flow.allConditions'); if (node.kind === 'logic_or') return tr('flow.anyCondition'); if (node.kind === 'logic_not') return tr('flow.invertCondition'); @@ -161,6 +193,20 @@ function flowSelectOptions(items, selected, nameFn = item => item.name) { } function flowOperatorOptions(selected) { return [['lt','<'],['lte','≤'],['gt','>'],['gte','≥'],['eq','='],['neq','≠']].map(([v,l]) => ``).join(''); } +function sharedFlowReferenceComparisonFields(item, config) { + if (!item || !sharedFlowInputRequiresComparison(item.kind)) return ''; + const defaults = sharedFlowReferenceComparisonDefaults(item); + const selected = config.operator || ''; + const numeric = ['outdoor_temperature','device_temperature','zone_temperature','ha_numeric'].includes(item.kind); + const fullOperators = numeric || item.kind === 'ha_attribute'; + const operatorOptions = `${!selected ? `` : ''}${fullOperators ? flowOperatorOptions(selected) : [['eq','='],['neq','≠']].map(([value,label]) => ``).join('')}`; + const value = config.value ?? defaults.value ?? ''; + let valueField = ``; + if (numeric) valueField = ``; + else if (item.kind === 'house_mode') valueField = ``; + return `

${esc(tr('flow.sharedInputFlowComparisonHint'))}

`; +} + function renderFlowInspector() { const host = $('#flowInspector'), node = flowNodeById(app.flowSelectedNodeId); if (!host) return; if (!node) { host.innerHTML = `
${esc(tr('flow.selectBlock'))}${esc(tr('flow.selectBlockHint'))}
`; return; } @@ -182,7 +228,12 @@ function renderFlowInspector() { else if (node.kind === 'group_state') fields = `${flowTextComparisonFields(c)}`; else if (node.kind === 'night_mode') fields = `

${esc(tr('flow.nightModeHint'))}

`; else if (node.kind === 'constant') fields = ``; - else if (node.kind === 'shared_input') fields = (app.flowSharedInputs || []).length ? `

${esc(tr('flow.sharedInputNodeHint'))}

` : `
${esc(tr('flow.sharedInputsEmpty'))}${esc(tr('flow.sharedInputNodeEmptyHint'))}
`; + else if (node.kind === 'shared_input') { + const item = (app.flowSharedInputs || []).find(value => value.id === c.input_id) || app.flowSharedInputs?.[0]; + fields = (app.flowSharedInputs || []).length + ? `

${esc(tr('flow.sharedInputNodeHint'))}

${sharedFlowReferenceComparisonFields(item, c)}` + : `
${esc(tr('flow.sharedInputsEmpty'))}${esc(tr('flow.sharedInputNodeEmptyHint'))}
`; + } else if (node.kind === 'logic_and') fields = `

${esc(tr('flow.andHint'))}

`; else if (node.kind === 'logic_or') fields = `

${esc(tr('flow.orHint'))}

`; else if (node.kind === 'logic_not') fields = `

${esc(tr('flow.notHint'))}

`; @@ -698,6 +749,22 @@ function updateFlowConfig(input) { const node = flowNodeById(app.flowSelectedNodeId); if (!node) return; const key = input.dataset.flowConfig; if (!key) return; if (key === 'days') node.config.days = $$('[data-flow-config="days"]', $('#flowInspector')).filter(el => el.checked).map(el => Number(el.value)); + else if (node.kind === 'shared_input' && key === 'input_id') { + const item = (app.flowSharedInputs || []).find(value => value.id === input.value); + node.config = sharedFlowReferenceDefaultConfig(item); + } + else if (node.kind === 'shared_input' && key === 'operator') { + node.config.operator = input.value; + if (node.config.value == null) { + const item = (app.flowSharedInputs || []).find(value => value.id === node.config.input_id); + node.config.value = sharedFlowReferenceComparisonDefaults(item).value ?? ''; + } + } + else if (node.kind === 'shared_input' && key === 'value') { + const item = (app.flowSharedInputs || []).find(value => value.id === node.config.input_id); + const numeric = ['outdoor_temperature','device_temperature','zone_temperature','ha_numeric'].includes(item?.kind); + node.config.value = numeric ? (input.value === '' ? null : Number(input.value)) : input.value; + } else if (['power','swing_vertical','swing_horizontal','quiet','turbo','light','air','xfan','health','sleep'].includes(key)) node.config[key] = input.value === '' ? null : input.value === 'true'; else if (key === 'value' && node.kind === 'constant') node.config[key] = input.value === 'true'; else if (['value','setpoint','target_temperature','cooldown_seconds','fan_speed'].includes(key) && ['outdoor_temperature','device_temperature','zone_temperature','ha_numeric','zone_thermostat','device_action','group_action'].includes(node.kind)) node.config[key] = input.value === '' ? null : Number(input.value); diff --git a/web/js/settings-ui.js b/web/js/settings-ui.js index 5e57731..3f3a758 100644 --- a/web/js/settings-ui.js +++ b/web/js/settings-ui.js @@ -36,9 +36,31 @@ function flowSharedInputKinds() { ]; } -function sharedFlowInputSummary(item) { +function flowSharedInputDefaultConfig(kind) { + if (kind === 'device_temperature') return { device_id: app.devices[0]?.id || '' }; + if (kind === 'zone_temperature') return { zone_id: app.zones[0]?.id || '' }; + if (kind === 'ha_state' || kind === 'ha_numeric' || kind === 'ha_available') return { entity_id: '' }; + if (kind === 'ha_attribute') return { entity_id: '', attribute: '' }; + if (kind === 'device_state') return { device_id: app.devices[0]?.id || '', field: 'online' }; + if (kind === 'zone_state') return { zone_id: app.zones[0]?.id || '', field: 'demand' }; + if (kind === 'group_state') return { group_id: app.groups[0]?.id || '', field: 'power_enabled' }; + if (kind === 'constant') return { value: true }; + return {}; +} + +function sharedFlowInputSourceSummary(item) { if (!item) return '—'; - try { return flowNodeSummary({ kind: item.kind, config: item.config || {} }); } catch (_) { return item.kind || '—'; } + const c = item.config || {}; + if (item.kind === 'outdoor_temperature') return tr('flow.node.outdoorTemperature'); + if (item.kind === 'device_temperature') return app.devices.find(value => value.id === c.device_id)?.name || tr('common.noDevice'); + if (item.kind === 'zone_temperature') return app.zones.find(value => value.id === c.zone_id)?.name || tr('common.noZone'); + if (item.kind === 'ha_state' || item.kind === 'ha_numeric' || item.kind === 'ha_available') return c.entity_id || 'entity_id'; + if (item.kind === 'ha_attribute') return `${c.entity_id || 'entity_id'}.${c.attribute || 'attribute'}`; + if (item.kind === 'house_mode') return tr('flow.houseMode'); + if (item.kind === 'device_state') return `${app.devices.find(value => value.id === c.device_id)?.name || tr('common.noDevice')} · ${c.field || 'state'}`; + if (item.kind === 'zone_state') return `${app.zones.find(value => value.id === c.zone_id)?.name || tr('common.noZone')} · ${c.field || 'state'}`; + if (item.kind === 'group_state') return `${app.groups.find(value => value.id === c.group_id)?.name || tr('groups.group')} · ${c.field || 'power_enabled'}`; + return flowNodeSummary({ kind:item.kind, config:c }); } function isHaSharedInputKind(kind) { return ['ha_state','ha_numeric','ha_attribute','ha_available'].includes(kind); } @@ -56,7 +78,7 @@ function renderFlowSharedInputs() { ? `
${esc(tr('flow.sharedInputUsedBy', { count:usages.length }))}
${usages.slice(0, 4).map(flow => ``).join('')}${usages.length > 4 ? `+${usages.length - 4}` : ''}
` : `${esc(tr('flow.sharedInputUnused'))}`; return `
-
${esc(item.name)}${esc(flowNodeTitle(FLOW_NODE_META[item.kind] || { title:item.kind }))} · ${esc(sharedFlowInputSummary(item))}${esc(item.id)}${usageMarkup}
+
${esc(item.name)}${esc(flowNodeTitle(FLOW_NODE_META[item.kind] || { title:item.kind }))} · ${esc(sharedFlowInputSourceSummary(item))}${esc(item.id)}${usageMarkup}
`; }).join('') : `
${esc(tr('flow.sharedInputsEmpty'))}${esc(tr('flow.sharedInputsEmptyHint'))}
`; @@ -68,28 +90,22 @@ function sharedFlowOptions(items, selected, label = item => item.name) { return items.map(item => ``).join(''); } -function sharedFlowOperatorOptions(selected = 'eq') { - return [['lt','<'],['lte','≤'],['gt','>'],['gte','≥'],['eq','='],['neq','≠']].map(([value,label]) => ``).join(''); -} - function renderFlowSharedInputFields(kind, config = {}) { const host = $('#flowSharedInputFields'); if (!host) return; const c = config || {}; - const comparison = (temperature = false) => `
`; - const textComparison = () => `
`; let fields = ''; if (kind === 'constant') fields = ``; - else if (kind === 'ha_state') fields = `${textComparison()}`; - else if (kind === 'ha_numeric') fields = `${comparison(false)}`; - else if (kind === 'ha_attribute') fields = `
`; + else if (kind === 'ha_state') fields = ``; + else if (kind === 'ha_numeric') fields = ``; + else if (kind === 'ha_attribute') fields = ``; else if (kind === 'ha_available') fields = `

${esc(tr('flow.haAvailableHint'))}

`; - else if (kind === 'outdoor_temperature') fields = comparison(true); - else if (kind === 'device_temperature') fields = `${comparison(true)}`; - else if (kind === 'zone_temperature') fields = `${comparison(true)}`; - else if (kind === 'house_mode') fields = `
`; - else if (kind === 'device_state') fields = `${textComparison()}`; - else if (kind === 'zone_state') fields = `${textComparison()}`; - else if (kind === 'group_state') fields = `${textComparison()}`; + else if (kind === 'outdoor_temperature') fields = `

${esc(tr('flow.sharedInputSourceOnlyHint'))}

`; + else if (kind === 'device_temperature') fields = ``; + else if (kind === 'zone_temperature') fields = ``; + else if (kind === 'house_mode') fields = `

${esc(tr('flow.sharedInputSourceOnlyHint'))}

`; + else if (kind === 'device_state') fields = ``; + else if (kind === 'zone_state') fields = ``; + else if (kind === 'group_state') fields = ``; else if (kind === 'night_mode') fields = `

${esc(tr('flow.nightModeHint'))}

`; host.innerHTML = fields; const testPanel = $('#flowSharedInputTestPanel'), testResult = $('#flowSharedInputTestResult'); @@ -106,7 +122,7 @@ function openFlowSharedInputEditor(id = '') { form.name.value = item?.name || ''; form.kind.value = item?.kind || 'constant'; form.dataset.editingId = item?.id || ''; - renderFlowSharedInputFields(form.kind.value, item?.config || flowDefaultConfig(form.kind.value)); + renderFlowSharedInputFields(form.kind.value, item?.config || flowSharedInputDefaultConfig(form.kind.value)); dialog.showModal(); setTimeout(() => form.name.focus(), 0); } @@ -117,7 +133,6 @@ function collectFlowSharedInputConfig(kind) { const key = field.dataset.sharedConfig; let value = field.value; if (kind === 'constant' && key === 'value') value = value === 'true'; - else if (field.type === 'number') value = Number(value); config[key] = value; }); return config; diff --git a/web/js/settings.js b/web/js/settings.js index 1890e78..4c25d30 100644 --- a/web/js/settings.js +++ b/web/js/settings.js @@ -293,28 +293,19 @@ document.addEventListener('change', event => { $('#addFlowSharedInput')?.addEventListener('click', () => openFlowSharedInputEditor()); $('#flowSharedInputKind')?.addEventListener('change', event => { - renderFlowSharedInputFields(event.target.value, flowDefaultConfig(event.target.value)); + renderFlowSharedInputFields(event.target.value, flowSharedInputDefaultConfig(event.target.value)); }); - -function compareFlowSharedHaValue(actual, operator, expected) { - if (['lt','lte','gt','gte'].includes(operator)) { - const a = Number(actual), b = Number(expected); - if (!Number.isFinite(a) || !Number.isFinite(b)) return false; - if (operator === 'lt') return a < b; - if (operator === 'lte') return a <= b; - if (operator === 'gt') return a > b; - return a >= b; - } - const left = actual == null ? '' : String(actual); - const right = expected == null ? '' : String(expected); - return operator === 'neq' ? left !== right : left === right; -} - function evaluateFlowSharedHaTest(kind, config, result) { - if (kind === 'ha_available') return { actual:result.state, matched:result.available === true }; - let actual = result.state; - if (kind === 'ha_attribute') actual = result.attributes?.[config.attribute]; - return { actual, matched:compareFlowSharedHaValue(actual, config.operator || 'eq', config.value) }; + if (kind === 'ha_available') return { actual:result.available === true, valid:true }; + if (kind === 'ha_attribute') { + const actual = result.attributes?.[config.attribute]; + return { actual, valid:actual !== undefined }; + } + if (kind === 'ha_numeric') { + const actual = Number(result.state); + return { actual, valid:Number.isFinite(actual) }; + } + return { actual:result.state, valid:true }; } $('#flowSharedInputTest')?.addEventListener('click', async event => { @@ -336,10 +327,10 @@ $('#flowSharedInputTest')?.addEventListener('click', async event => { const result = await api('/api/integrations/home-assistant/entity', { method:'POST', body:{ entity_id:entityId } }); const evaluation = evaluateFlowSharedHaTest(kind, config, result); const actual = evaluation.actual == null ? 'null' : (typeof evaluation.actual === 'object' ? JSON.stringify(evaluation.actual) : String(evaluation.actual)); - const expectation = kind === 'ha_available' ? tr('flow.sharedInputTestAvailable') : `${config.operator || 'eq'} ${config.value ?? ''}`; - resultHost.classList.toggle('pass', evaluation.matched); - resultHost.classList.toggle('fail', !evaluation.matched); - resultHost.innerHTML = `
${esc(evaluation.matched ? tr('flow.sharedInputTestMatched') : tr('flow.sharedInputTestNotMatched'))}${esc(result.available ? tr('flow.sharedInputTestAvailable') : tr('flow.sharedInputTestUnavailable'))}
${esc(tr('flow.sharedInputTestCurrent'))}
${esc(actual)}
${esc(tr('flow.sharedInputTestExpected'))}
${esc(expectation)}
${esc(result.entity_id)}${result.last_updated ? ` · ${esc(dateTime(result.last_updated))}` : ''}`; + const success = evaluation.valid; + resultHost.classList.toggle('pass', success); + resultHost.classList.toggle('fail', !success); + resultHost.innerHTML = `
${esc(success ? tr('flow.sharedInputTestValueRead') : tr('flow.sharedInputTestUnavailable'))}${esc(result.available ? tr('flow.sharedInputTestAvailable') : tr('flow.sharedInputTestUnavailable'))}
${esc(tr('flow.sharedInputTestCurrent'))}
${esc(actual)}
${esc(result.entity_id)}${result.last_updated ? ` · ${esc(dateTime(result.last_updated))}` : ''}`; } catch (error) { resultHost.classList.remove('pass'); resultHost.classList.add('fail'); resultHost.innerHTML = `${esc(tr('flow.sharedInputTestUnavailable'))}${esc(error.message)}`;