This commit is contained in:
Mateusz Gruszczyński
2026-09-02 10:12:51 +02:00
parent 8180d22cef
commit e50c94b6a3
19 changed files with 323 additions and 108 deletions
Generated
+1 -1
View File
@@ -633,7 +633,7 @@ dependencies = [
[[package]]
name = "gree-controller"
version = "0.9.14"
version = "0.10.0"
dependencies = [
"aes",
"aes-gcm",
+1 -1
View File
@@ -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"
+20 -20
View File
@@ -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
+1 -1
View File
@@ -2,7 +2,7 @@
Self-hosted controller for GREE-compatible air conditioners with a local Web UI, thermostat zones, schedules, Home Assistant integration, history, notifications and a documented HTTP/WebSocket API.
**Current release: 0.9.14**
**Current release: 0.10.0**
> [Full API reference](docs/API.md) — authentication, every endpoint, request bodies, response models, WebSocket events and examples.
+6 -4
View File
@@ -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-<stable-unique-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, 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
+2 -2
View File
@@ -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/<id>.json` and contains `id`, `category`, localized `name`, localized `description`, and the complete `flow` graph (`nodes` + `edges`). `build.rs` validates and embeds the directory, exposes `/presets/index.json` plus `/presets/<file>.json`, and the Flow editor loads that library dynamically. Device/zone/group placeholders such as `$zone1`, `$zone2`, `$device1` and `$group1` are resolved when a preset is applied. Adding a preset therefore does not require adding graph-building code to `web/js/flows.js`.
@@ -1,7 +1,7 @@
{
"domain": "gree_controller",
"name": "GREE Controller",
"version": "0.9.14",
"version": "0.10.0",
"config_flow": true,
"integration_type": "hub",
"iot_class": "local_polling",
+9 -7
View File
@@ -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."
}
}
+9 -7
View File
@@ -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."
}
}
+1 -1
View File
@@ -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
+43
View File
@@ -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" <<EOF
{"flow":{"name":"Shared value comparisons","enabled":true,"nodes":[{"id":"low","kind":"shared_input","x":20,"y":20,"config":{"input_id":"smoke-shared-outdoor","operator":"lt","value":20}},{"id":"high","kind":"shared_input","x":20,"y":140,"config":{"input_id":"smoke-shared-outdoor","operator":"gt","value":30}},{"id":"either","kind":"logic_or","x":220,"y":80,"config":{}},{"id":"action","kind":"zone_thermostat","x":420,"y":80,"config":{"zone_id":"$ZONE_ID","preset":"comfort","mode":"auto","cooldown_seconds":60}}],"edges":[{"id":"e1","from":"low","to":"either"},{"id":"e2","from":"high","to":"either"},{"id":"e3","from":"either","to":"action"}]},"overrides":{"low":15,"high":15},"log":false}
EOF
curl -fsS -X POST -H 'Content-Type: application/json' --data-binary @"$TMP/flow-shared-value.json" \
"http://127.0.0.1:$PORT/api/flows/simulate" >"$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" <<EOF
{"name":"Native schedule Flow","enabled":true,"nodes":[{"id":"days","kind":"weekday","x":20,"y":20,"config":{"days":[1,2,3,4,5,6,7]}},{"id":"time","kind":"time_range","x":180,"y":20,"config":{"start":"06:00","end":"08:00"}},{"id":"action","kind":"zone_thermostat","x":360,"y":20,"config":{"zone_id":"$ZONE_ID","preset":"comfort","mode":"auto","cooldown_seconds":60}}],"edges":[{"id":"e1","from":"days","to":"time"},{"id":"e2","from":"time","to":"action"}]}
+75 -1
View File
@@ -51,6 +51,62 @@ fn flow_condition_kind(kind: &str) -> 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()));
}
}
_ => {}
}
+10 -7
View File
@@ -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(())
}
+19 -4
View File
@@ -286,16 +286,30 @@ async fn flow_leaf_observation(
overrides: &HashMap<String, Value>,
) -> Result<(bool, Value), AppError> {
let mut resolved_kind = condition.kind.as_str();
let mut resolved_config: Option<&Value> = None;
let mut resolved_config: Option<Value> = 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
}));
}
+1 -1
View File
@@ -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<String, String>,
/// 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<FlowSharedInput>,
}
+3 -2
View File
@@ -386,7 +386,8 @@
<section class="panel settings-block">
<div class="settings-block-head"><div><h3 data-i18n="settings.haThermostatSources">Źródła temperatury sterowania</h3><p data-i18n="settings.haThermostatSourcesHint">Te ustawienia należą do logiki termostatów i źródeł temperatury, nie do wspólnych wejść Flow.</p></div></div>
<div class="settings-grid">
<label class="wide"><span data-i18n="settings.defaultEntity">Default room entity_id</span><input name="ha_entity_id" placeholder="sensor.living_room_temperature"></label>
<label class="wide"><span data-i18n="settings.defaultEntity">Connection test entity_id</span><input name="ha_entity_id" placeholder="sensor.living_room_temperature"></label>
<p class="field-note wide" data-i18n="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.</p>
<label class="wide"><span data-i18n="settings.outdoorEntity">Outdoor temperature entity_id</span><input name="ha_outdoor_entity_id" placeholder="sensor.outdoor_temperature"></label>
<label class="wide"><span data-i18n="settings.haSensorStaleAfterMinutes">Maximum HA sensor reading age (min)</span><input type="number" name="ha_sensor_stale_after_minutes" min="1" max="1440" step="1" value="5"></label>
<p class="field-note wide" data-i18n="settings.haSensorStaleAfterHint">If the HA sensor is not updated within this time, the reading is treated as stale.</p>
@@ -395,7 +396,7 @@
</section>
<section class="panel settings-block flow-shared-settings" id="flowSharedInputsSettings">
<div class="settings-block-head flow-shared-settings-head"><div><h3 data-i18n="flow.sharedInputsTitle">Wspólne wejścia Flow</h3><p data-i18n="flow.sharedInputsHint">Zdefiniuj stałe i warunki czujników raz, a potem używaj ich w wielu Flow.</p></div><button type="button" class="primary flow-shared-add-button" id="addFlowSharedInput" data-i18n="flow.sharedInputAdd">Dodaj wejście</button></div>
<div class="settings-block-head flow-shared-settings-head"><div><h3 data-i18n="flow.sharedInputsTitle">Wspólne wejścia Flow</h3><p data-i18n="flow.sharedInputsHint">Zdefiniuj wartości, stany i czujniki raz, a potem używaj ich w wielu Flow. Operator jest opcjonalny.</p></div><button type="button" class="primary flow-shared-add-button" id="addFlowSharedInput" data-i18n="flow.sharedInputAdd">Dodaj wejście</button></div>
<input type="hidden" id="flowSharedInputsRevision" name="flow_inputs_revision" value="[]"><div id="flowSharedInputList" class="flow-shared-input-list"></div>
</section>
+70 -3
View File
@@ -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]) => `<option value="${v}" ${v === selected ? 'selected' : ''}>${l}</option>`).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 ? `<option value="" selected disabled>${esc(tr('flow.selectOperator'))}</option>` : ''}${fullOperators ? flowOperatorOptions(selected) : [['eq','='],['neq','≠']].map(([value,label]) => `<option value="${value}" ${value === selected ? 'selected' : ''}>${label}</option>`).join('')}`;
const value = config.value ?? defaults.value ?? '';
let valueField = `<input data-flow-config="value" value="${esc(value)}">`;
if (numeric) valueField = `<input type="number" step="0.1" data-flow-config="value" value="${Number(value ?? 0)}">`;
else if (item.kind === 'house_mode') valueField = `<select data-flow-config="value">${['cool','heat','off'].map(v => `<option value="${v}" ${String(value) === v ? 'selected' : ''}>${esc(v === 'off' ? tr('common.off') : tr(`mode.${v}`))}</option>`).join('')}</select>`;
return `<p class="field-note">${esc(tr('flow.sharedInputFlowComparisonHint'))}</p><div class="two"><label><span>${esc(tr('flow.operator'))}</span><select data-flow-config="operator">${operatorOptions}</select></label><label><span>${esc(tr('flow.value'))}</span>${valueField}</label></div>`;
}
function renderFlowInspector() {
const host = $('#flowInspector'), node = flowNodeById(app.flowSelectedNodeId); if (!host) return;
if (!node) { host.innerHTML = `<div class="empty compact"><strong>${esc(tr('flow.selectBlock'))}</strong><span>${esc(tr('flow.selectBlockHint'))}</span></div>`; return; }
@@ -182,7 +228,12 @@ function renderFlowInspector() {
else if (node.kind === 'group_state') fields = `<label><span>${esc(tr('groups.group'))}</span><select data-flow-config="group_id">${flowSelectOptions(app.groups, c.group_id)}</select></label><label><span>${esc(tr('flow.field'))}</span><select data-flow-config="field"><option value="power_enabled" selected>power_enabled</option></select></label>${flowTextComparisonFields(c)}`;
else if (node.kind === 'night_mode') fields = `<p class="field-note">${esc(tr('flow.nightModeHint'))}</p>`;
else if (node.kind === 'constant') fields = `<label><span>${esc(tr('flow.value'))}</span><select data-flow-config="value"><option value="true" ${c.value !== false ? 'selected' : ''}>${esc(tr('common.yes'))}</option><option value="false" ${c.value === false ? 'selected' : ''}>${esc(tr('common.no'))}</option></select></label>`;
else if (node.kind === 'shared_input') fields = (app.flowSharedInputs || []).length ? `<label><span>${esc(tr('flow.sharedInputTitle'))}</span><select data-flow-config="input_id">${flowSelectOptions(app.flowSharedInputs || [], c.input_id)}</select></label><p class="field-note">${esc(tr('flow.sharedInputNodeHint'))}</p>` : `<div class="empty compact"><strong>${esc(tr('flow.sharedInputsEmpty'))}</strong><span>${esc(tr('flow.sharedInputNodeEmptyHint'))}</span></div>`;
else if (node.kind === 'shared_input') {
const item = (app.flowSharedInputs || []).find(value => value.id === c.input_id) || app.flowSharedInputs?.[0];
fields = (app.flowSharedInputs || []).length
? `<label><span>${esc(tr('flow.sharedInputTitle'))}</span><select data-flow-config="input_id">${flowSelectOptions(app.flowSharedInputs || [], c.input_id)}</select></label><p class="field-note">${esc(tr('flow.sharedInputNodeHint'))}</p>${sharedFlowReferenceComparisonFields(item, c)}`
: `<div class="empty compact"><strong>${esc(tr('flow.sharedInputsEmpty'))}</strong><span>${esc(tr('flow.sharedInputNodeEmptyHint'))}</span></div>`;
}
else if (node.kind === 'logic_and') fields = `<p class="field-note">${esc(tr('flow.andHint'))}</p>`;
else if (node.kind === 'logic_or') fields = `<p class="field-note">${esc(tr('flow.orHint'))}</p>`;
else if (node.kind === 'logic_not') fields = `<p class="field-note">${esc(tr('flow.notHint'))}</p>`;
@@ -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);
+36 -21
View File
@@ -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() {
? `<div class="flow-shared-usage"><small>${esc(tr('flow.sharedInputUsedBy', { count:usages.length }))}</small><div>${usages.slice(0, 4).map(flow => `<button type="button" class="link-button" data-open-shared-flow="${esc(flow.id)}" title="${esc(tr('flow.openReferencedFlow', { name:flow.name }))}">${esc(flow.name)}</button>`).join('')}${usages.length > 4 ? `<span class="muted">+${usages.length - 4}</span>` : ''}</div></div>`
: `<small class="flow-shared-unused">${esc(tr('flow.sharedInputUnused'))}</small>`;
return `<div class="flow-shared-input-row">
<div><strong>${esc(item.name)}</strong><small>${esc(flowNodeTitle(FLOW_NODE_META[item.kind] || { title:item.kind }))} · ${esc(sharedFlowInputSummary(item))}</small><code>${esc(item.id)}</code>${usageMarkup}</div>
<div><strong>${esc(item.name)}</strong><small>${esc(flowNodeTitle(FLOW_NODE_META[item.kind] || { title:item.kind }))} · ${esc(sharedFlowInputSourceSummary(item))}</small><code>${esc(item.id)}</code>${usageMarkup}</div>
<div class="flow-shared-input-actions"><button type="button" class="secondary" data-flow-shared-edit="${esc(item.id)}">${esc(tr('actions.edit'))}</button><button type="button" class="danger" data-flow-shared-delete="${esc(item.id)}">${esc(tr('actions.delete'))}</button></div>
</div>`;
}).join('') : `<div class="empty compact"><strong>${esc(tr('flow.sharedInputsEmpty'))}</strong><span>${esc(tr('flow.sharedInputsEmptyHint'))}</span></div>`;
@@ -68,28 +90,22 @@ function sharedFlowOptions(items, selected, label = item => item.name) {
return items.map(item => `<option value="${esc(item.id)}" ${item.id === selected ? 'selected' : ''}>${esc(label(item))}</option>`).join('');
}
function sharedFlowOperatorOptions(selected = 'eq') {
return [['lt','<'],['lte','≤'],['gt','>'],['gte','≥'],['eq','='],['neq','≠']].map(([value,label]) => `<option value="${value}" ${value === selected ? 'selected' : ''}>${label}</option>`).join('');
}
function renderFlowSharedInputFields(kind, config = {}) {
const host = $('#flowSharedInputFields'); if (!host) return;
const c = config || {};
const comparison = (temperature = false) => `<div class="two"><label><span>${esc(tr('flow.operator'))}</span><select data-shared-config="operator">${sharedFlowOperatorOptions(c.operator || 'lt')}</select></label><label><span>${esc(temperature ? tr('automations.thresholdC') : tr('flow.value'))}</span><input type="number" step="0.1" data-shared-config="value" value="${Number(c.value ?? 0)}"></label></div>`;
const textComparison = () => `<div class="two"><label><span>${esc(tr('flow.operator'))}</span><select data-shared-config="operator"><option value="eq" ${c.operator !== 'neq' ? 'selected' : ''}>=</option><option value="neq" ${c.operator === 'neq' ? 'selected' : ''}>≠</option></select></label><label><span>${esc(tr('flow.value'))}</span><input data-shared-config="value" value="${esc(c.value ?? '')}"></label></div>`;
let fields = '';
if (kind === 'constant') fields = `<label><span>${esc(tr('flow.value'))}</span><select data-shared-config="value"><option value="true" ${c.value !== false ? 'selected' : ''}>${esc(tr('common.yes'))}</option><option value="false" ${c.value === false ? 'selected' : ''}>${esc(tr('common.no'))}</option></select></label>`;
else if (kind === 'ha_state') fields = `<label><span>entity_id</span><input data-shared-config="entity_id" value="${esc(c.entity_id || '')}" placeholder="binary_sensor.window"></label>${textComparison()}`;
else if (kind === 'ha_numeric') fields = `<label><span>entity_id</span><input data-shared-config="entity_id" value="${esc(c.entity_id || '')}" placeholder="sensor.energy_price"></label>${comparison(false)}`;
else if (kind === 'ha_attribute') fields = `<label><span>entity_id</span><input data-shared-config="entity_id" value="${esc(c.entity_id || '')}" placeholder="climate.living_room"></label><label><span>${esc(tr('flow.attribute'))}</span><input data-shared-config="attribute" value="${esc(c.attribute || '')}" placeholder="hvac_action"></label><div class="two"><label><span>${esc(tr('flow.operator'))}</span><select data-shared-config="operator">${sharedFlowOperatorOptions(c.operator || 'eq')}</select></label><label><span>${esc(tr('flow.value'))}</span><input data-shared-config="value" value="${esc(c.value ?? '')}"></label></div>`;
else if (kind === 'ha_state') fields = `<label><span>entity_id</span><input data-shared-config="entity_id" value="${esc(c.entity_id || '')}" placeholder="binary_sensor.window"></label>`;
else if (kind === 'ha_numeric') fields = `<label><span>entity_id</span><input data-shared-config="entity_id" value="${esc(c.entity_id || '')}" placeholder="sensor.energy_price"></label>`;
else if (kind === 'ha_attribute') fields = `<label><span>entity_id</span><input data-shared-config="entity_id" value="${esc(c.entity_id || '')}" placeholder="climate.living_room"></label><label><span>${esc(tr('flow.attribute'))}</span><input data-shared-config="attribute" value="${esc(c.attribute || '')}" placeholder="hvac_action"></label>`;
else if (kind === 'ha_available') fields = `<label><span>entity_id</span><input data-shared-config="entity_id" value="${esc(c.entity_id || '')}" placeholder="binary_sensor.window"></label><p class="field-note">${esc(tr('flow.haAvailableHint'))}</p>`;
else if (kind === 'outdoor_temperature') fields = comparison(true);
else if (kind === 'device_temperature') fields = `<label><span>${esc(tr('common.device'))}</span><select data-shared-config="device_id">${sharedFlowOptions(app.devices, c.device_id)}</select></label>${comparison(true)}`;
else if (kind === 'zone_temperature') fields = `<label><span>${esc(tr('common.zone'))}</span><select data-shared-config="zone_id">${sharedFlowOptions(app.zones, c.zone_id)}</select></label>${comparison(true)}`;
else if (kind === 'house_mode') fields = `<div class="two"><label><span>${esc(tr('flow.operator'))}</span><select data-shared-config="operator"><option value="eq" ${c.operator !== 'neq' ? 'selected' : ''}>=</option><option value="neq" ${c.operator === 'neq' ? 'selected' : ''}>≠</option></select></label><label><span>${esc(tr('flow.houseMode'))}</span><select data-shared-config="value">${['cool','heat','off'].map(v => `<option value="${v}" ${c.value === v ? 'selected' : ''}>${esc(v === 'off' ? tr('common.off') : tr(`mode.${v}`))}</option>`).join('')}</select></label></div>`;
else if (kind === 'device_state') fields = `<label><span>${esc(tr('common.device'))}</span><select data-shared-config="device_id">${sharedFlowOptions(app.devices, c.device_id)}</select></label><label><span>${esc(tr('flow.field'))}</span><select data-shared-config="field">${['enabled','online','power','mode','fan_speed','swing_vertical','swing_horizontal','quiet','turbo','light','air','xfan','health','sleep'].map(v => `<option value="${v}" ${c.field === v ? 'selected' : ''}>${esc(v)}</option>`).join('')}</select></label>${textComparison()}`;
else if (kind === 'zone_state') fields = `<label><span>${esc(tr('common.zone'))}</span><select data-shared-config="zone_id">${sharedFlowOptions(app.zones, c.zone_id)}</select></label><label><span>${esc(tr('flow.field'))}</span><select data-shared-config="field">${['enabled','mode','active_preset','demand','control_owner','device_manual_override','local_thermostat_power'].map(v => `<option value="${v}" ${c.field === v ? 'selected' : ''}>${esc(v)}</option>`).join('')}</select></label>${textComparison()}`;
else if (kind === 'group_state') fields = `<label><span>${esc(tr('groups.group'))}</span><select data-shared-config="group_id">${sharedFlowOptions(app.groups, c.group_id)}</select></label><input type="hidden" data-shared-config="field" value="power_enabled">${textComparison()}`;
else if (kind === 'outdoor_temperature') fields = `<p class="field-note">${esc(tr('flow.sharedInputSourceOnlyHint'))}</p>`;
else if (kind === 'device_temperature') fields = `<label><span>${esc(tr('common.device'))}</span><select data-shared-config="device_id">${sharedFlowOptions(app.devices, c.device_id)}</select></label>`;
else if (kind === 'zone_temperature') fields = `<label><span>${esc(tr('common.zone'))}</span><select data-shared-config="zone_id">${sharedFlowOptions(app.zones, c.zone_id)}</select></label>`;
else if (kind === 'house_mode') fields = `<p class="field-note">${esc(tr('flow.sharedInputSourceOnlyHint'))}</p>`;
else if (kind === 'device_state') fields = `<label><span>${esc(tr('common.device'))}</span><select data-shared-config="device_id">${sharedFlowOptions(app.devices, c.device_id)}</select></label><label><span>${esc(tr('flow.field'))}</span><select data-shared-config="field">${['enabled','online','power','mode','fan_speed','swing_vertical','swing_horizontal','quiet','turbo','light','air','xfan','health','sleep'].map(v => `<option value="${v}" ${c.field === v ? 'selected' : ''}>${esc(v)}</option>`).join('')}</select></label>`;
else if (kind === 'zone_state') fields = `<label><span>${esc(tr('common.zone'))}</span><select data-shared-config="zone_id">${sharedFlowOptions(app.zones, c.zone_id)}</select></label><label><span>${esc(tr('flow.field'))}</span><select data-shared-config="field">${['enabled','mode','active_preset','demand','control_owner','device_manual_override','local_thermostat_power'].map(v => `<option value="${v}" ${c.field === v ? 'selected' : ''}>${esc(v)}</option>`).join('')}</select></label>`;
else if (kind === 'group_state') fields = `<label><span>${esc(tr('groups.group'))}</span><select data-shared-config="group_id">${sharedFlowOptions(app.groups, c.group_id)}</select></label><input type="hidden" data-shared-config="field" value="power_enabled">`;
else if (kind === 'night_mode') fields = `<p class="field-note">${esc(tr('flow.nightModeHint'))}</p>`;
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;
+15 -24
View File
@@ -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 = `<div><strong>${esc(evaluation.matched ? tr('flow.sharedInputTestMatched') : tr('flow.sharedInputTestNotMatched'))}</strong><span class="badge ${evaluation.matched ? 'active' : ''}">${esc(result.available ? tr('flow.sharedInputTestAvailable') : tr('flow.sharedInputTestUnavailable'))}</span></div><dl><div><dt>${esc(tr('flow.sharedInputTestCurrent'))}</dt><dd><code>${esc(actual)}</code></dd></div><div><dt>${esc(tr('flow.sharedInputTestExpected'))}</dt><dd><code>${esc(expectation)}</code></dd></div></dl><small>${esc(result.entity_id)}${result.last_updated ? ` · ${esc(dateTime(result.last_updated))}` : ''}</small>`;
const success = evaluation.valid;
resultHost.classList.toggle('pass', success);
resultHost.classList.toggle('fail', !success);
resultHost.innerHTML = `<div><strong>${esc(success ? tr('flow.sharedInputTestValueRead') : tr('flow.sharedInputTestUnavailable'))}</strong><span class="badge ${success ? 'active' : ''}">${esc(result.available ? tr('flow.sharedInputTestAvailable') : tr('flow.sharedInputTestUnavailable'))}</span></div><dl><div><dt>${esc(tr('flow.sharedInputTestCurrent'))}</dt><dd><code>${esc(actual)}</code></dd></div></dl><small>${esc(result.entity_id)}${result.last_updated ? ` · ${esc(dateTime(result.last_updated))}` : ''}</small>`;
} catch (error) {
resultHost.classList.remove('pass'); resultHost.classList.add('fail');
resultHost.innerHTML = `<strong>${esc(tr('flow.sharedInputTestUnavailable'))}</strong><span>${esc(error.message)}</span>`;