From a161d8785d3059674cf921ea8412d88e39b06bd4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Wed, 2 Sep 2026 08:39:20 +0200 Subject: [PATCH] v0.9.7 --- Cargo.lock | 2 +- Cargo.toml | 2 +- FILE_MANIFEST.sha256 | 51 ++-- README.md | 2 +- docs/API.md | 12 +- docs/FLOW.md | 17 +- .../gree_controller/manifest.json | 2 +- lang/en.json | 100 +++++++- lang/pl.json | 100 +++++++- scripts/FILE_MANIFEST.sha256 | 3 +- src/api/flows.rs | 14 +- src/api/public_settings.rs | 1 + src/api/settings.rs | 34 +++ src/config.rs | 1 + src/engine/automations.rs | 16 +- src/home_assistant.rs | 1 + src/models/integrations.rs | 12 + web/index.html | 109 ++++----- web/js/bootstrap.js | 1 + web/js/core.js | 4 +- web/js/flows.js | 229 ++++++++++++++---- web/js/forms.js | 1 + web/js/realtime.js | 4 +- web/js/settings-ui.js | 85 +++++++ web/js/settings.js | 38 +++ web/styles.css | 35 ++- 26 files changed, 712 insertions(+), 164 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8e36399..5d1786b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -633,7 +633,7 @@ dependencies = [ [[package]] name = "gree-controller" -version = "0.9.6" +version = "0.9.7" dependencies = [ "aes", "aes-gcm", diff --git a/Cargo.toml b/Cargo.toml index 6654578..28121fd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "gree-controller" -version = "0.9.6" +version = "0.9.7" 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 bf1f112..b94b5b6 100644 --- a/FILE_MANIFEST.sha256 +++ b/FILE_MANIFEST.sha256 @@ -1,12 +1,12 @@ d67af429e4da9ce08e9d2f2a8472849ffbd70d135b1c5da535a076026794d04c ./.env.example a4ec3874a2e3ab1bad28fb40bb620f7b01f64d01ad9b699306bf70ada31227db ./.gitignore -c1efa0e8c20ed2afc60487f36ebc86a63d664d846c8f811e4c43e2a2144ff776 ./Cargo.lock -50027832964d58224369e9ce0be561592118011dc427ea84da3fe934ea5385c3 ./Cargo.toml +53a52a76fbcec088d3aded0f4e6c9a80f3dbce7c17a682e424fb06ba335fbb38 ./Cargo.lock +dbeeca58fcd5786b2dfb005f1d13262e22dd4e75d6ee0ad94231132eddc145f0 ./Cargo.toml 19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE -5f094d78999d6e28de702a21380cafa2b9552a70c40f61a288f71210806eac2f ./README.md +ffab397a9ddbde4c9956aa1eeec6fe00ead649da2d8516fd0afb6ad5d3a976c2 ./README.md 07acba9eab602e8d5b7387b16476308799ad629565fe501b18f3c1ea024a3ccf ./build.rs -5b2b1cb7c209863891f247e14df2cbcd4c64f42392386b288b53dff0fe63fe72 ./docs/API.md -0fd82f68e66f3fb10e622ecf2f7699316c88f3a66b148deadd08f367d1dd0682 ./docs/FLOW.md +1b83a141db1d69c100f74517b2ee122d51c960fa958fe76741600d42a22d96aa ./docs/API.md +9712c0ed25f278d442c8a09cd6477ed39e801aa6c18904572f588f5faf76f4e9 ./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 -4098899fbb12ce3364e0bf44414097461f3efdfffddfe18649a267723f18bf75 ./home-assistant/custom_components/gree_controller/manifest.json +6392b1b9178cfb1394d20575adc5219476e2fa3ee65a588e0de41effe316672b ./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,14 +26,15 @@ 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 -379ea62648744409db229dd9f58ce874b6ec65a86c6edd734978422d3aa6a5bd ./lang/en.json -1e453b8ef6c80208d9f88454ba5d78deef56ea7f74a0a7be44c51fabf007e495 ./lang/pl.json +a26d514b839deff2e49c352e4056a561c4af9e48626b008e0ad524cdf086fa8f ./lang/en.json +e1a2ca5aa42158267bdf4fcbee4992eccc611e6ec3ecc5093a3567e166275fbc ./lang/pl.json d8459024f04ca514bd8e9d6bd3af872fb942fd85a7cdd5583f4a9d28aab6faba ./make_zip.py 01952aa92b217f8eae2493b88870e2dec595100cd15c4d561ff11ae2b936c46f ./regenerate-sha.sh -02d8c640c31fbc3fcb772f44608cdad20e0462762d8d81bd04cd176239499c4e ./scripts/FILE_MANIFEST.sha256 +6408d99fbb6154afa65a9bda016dab9c9236e57cf4ae4ccb89943c1a4c5a5a56 ./scripts/FILE_MANIFEST.sha256 bb89bac237e750e9b1bf73761d7df97a6b81853091615878c03f13d7b6399aa7 ./scripts/README.md 5bc736c7bc76ca80aaa406bb171d2aa91baf4c3aa8695dce0e09b888b6ab3146 ./scripts/common.sh 6403786610ee6d2f628193c25aee0dd058d62e904aa1a31d5f62fdaae0e94b4f ./scripts/configure-gree-network.sh +1d6e14e26e49aa9d3527f30a23668bf8d9c48b67e6628ef686c3155c012155de ./scripts/dev.sh 14076104c042fba1284ebb07531a6c3ff972df1f9f5b18f70da18ab774efed27 ./scripts/generate_ha_migration.py e4849261fd9ed1f01df96c0637c439c0c4eff8fa317b2918026167bba343af79 ./scripts/install-lxc.sh bb7cd2c5b27c9dceec1d1d2846fbad9600df9c08e0ad07d13fcde97af533091c ./scripts/install.sh @@ -49,19 +50,19 @@ b50782b3742dfbf8a319c60571c968e93fdf8547db747c759edcffae68cb98bf ./scripts/upda 2317b7cd0c2437c128ac4982d64412c8184ecb5e774825761d61aab6e45fae4a ./src/api/debug_tokens.rs 4f82206fb48a291bdc643c96d4d6c917d669c7bdf2a35312c0a056dddecc7062 ./src/api/devices.rs 417940b467c6f00bbf01cc47eb4479880dee9b2f284a80e3662a6d433dc11b92 ./src/api/events.rs -ba3661eeb136ba9f63697dfcab1f3b1f86e173893ab5412b8363dd2606d3dbe8 ./src/api/flows.rs +787fdee57945c8542b692c849160a3447a8089b03e322f1cc137aa50305e14aa ./src/api/flows.rs d096000e94997cf9aaf9d3cc567214a78b8b2da7da57a76e2b3519247af6e592 ./src/api/groups.rs a13d4e217fe3ddaa73873ba6e0d1bc93750cf61764d867ff7372274ed21d9599 ./src/api/history.rs b3ed82de9d885a6325647bff3d0ead6ac46fc74d0250d5e14d75319555ad88d7 ./src/api/house.rs 0d7d598f7ed24315ed0547a60a72a06f20ebcf926489e1c454986f42d22cc305 ./src/api/integrations.rs bb2a746ecdcc2da5fe54e18b455c7bd19453486dd5c008e71951a2c81d0e7d64 ./src/api/middleware.rs -2b3feefbafc1f813c00dfe20db147aa1f5067ec5b8455f041ac3793802911be3 ./src/api/public_settings.rs +50cfd47e44e22cc802f22f97267c157fa5a6d19b51be921a6ca6b1ea7c9799d1 ./src/api/public_settings.rs a1d1a4ac071493b052e6bf20b03be67cf75726de95649e9a73aeba8d22395a15 ./src/api/schedules.rs -07e61483d10dba134055ab540c3222f1a2e2c51a8bffbcd5e29e632895a29f41 ./src/api/settings.rs +85d1843a830a079e9993dcc01329c0fea7e6cfe62f22b60617665e7300b405b0 ./src/api/settings.rs bc1614b9948b8904d0f678c79cc9fffecb75977fbec951aca1ba087527808836 ./src/api/system.rs 681cccb8d09f4ad9c2e6467a4dbe2f9d991125775a5d2bfc9043daf940fecd29 ./src/api/websocket.rs 923e7434e8f5789150eb34ffc4d18d25fa701f4a9040bf856d7926016f1936aa ./src/api/zones.rs -2b6eecead75cd4cdb98a9f74dd1c374dbf11ebfa716abbc3476aa12398af9acd ./src/config.rs +7eaeb0552a51dc34e964a315cc4024e8373622d7b32746010f89881268ccf389 ./src/config.rs c8b8b4a04c94b9d91f5931057d69ad38344730bffbf68e2f6541b59013441b57 ./src/db.rs 24580b279cfcd5388ba584eaba2c9bb729b8aae81e77b781c23fb804c5b5fa5d ./src/db/climate.rs 3eded80d8dbf13729b2ae1f31baa47aa59c01bcfa550c90874031d25c74d2eac ./src/db/configuration.rs @@ -74,7 +75,7 @@ f3bd91d0bdb7699f319b9341d928d3cc3b25f759653fcd2095a5c5f695d08fac ./src/db/event 35cbb521a8ec456756496b8aee71f9dfebad528a2cf2653ba8b48f6035be0c01 ./src/db/tests.rs a384ea4042d1d11692b5f8693df1f54850dcc4e80726780b44b2b096bf667b78 ./src/db/zone_history.rs 952ea5906d39ef917ad71edc345f76b29c07c63079644fd5699b12aa254cb35f ./src/engine.rs -69a18227b295c4b20644db0dc67061440260c4e89042936f0cc46d650b143a09 ./src/engine/automations.rs +58a09f3980d6a054921b5e484fa3fd2327f8bd90a48ffa5751a60a80542ee47a ./src/engine/automations.rs 77931af524e9402fc826050b5fcec422d01b02b625c8ce7f2004f1f6520c9b4d ./src/engine/commands.rs ce7e809765c8ad7dc4819b7f775ba7a632031d4d0e68780cc6ea953f2fd890e7 ./src/engine/control_plan.rs 38f42c86c31048780ca414f2c328f363ef803a0edbc1e1ed85503909a10b69ae ./src/engine/deadlines.rs @@ -92,7 +93,7 @@ ea3b533752b7abab113541f47ab7f49da27cc5696142796b78cd16889471371b ./src/engine/t 986c5ce1d7d5e79975b0089807408109afdef5c5a173b427b58e34f534faf411 ./src/engine/zone_actions.rs 900947200973b8e4490600f33738424a89aadf0b59c0316f4cd46deb997730b6 ./src/engine/zone_control.rs 4b271b6fc365b1078c01d6178eb563841b2ecaed5d8639196f58e1312d2236fe ./src/error.rs -5639bef8cb54d2c44fb67503d107b58b4decc9413e1739079e6b8e8d125247ba ./src/home_assistant.rs +3be3961c2adefb599db54382f204233fd4e5584b6c9ad26935c4e029a63d746d ./src/home_assistant.rs 6f9ef85cd53ba030b477bc3c186f0f7e762674124210eabcb0686f2806eaada1 ./src/influxdb.rs feb50ecd60b9a9fe9de1f7d8e1e26caab5435d59347468f6266a318f431bc3d3 ./src/influxdb/codec.rs 553b44ec0321e62480059bc2451fd34ec7aabe9fc87fc717daba0c4b55679d53 ./src/influxdb/query.rs @@ -105,7 +106,7 @@ a4f2d6d5a8d1ef58e45721e4640a63d7dd2e80b24accd22f32c74ce4a541ae21 ./src/models/a 7cdc99a6f0920baf4ac88fbe7e6f1859e8598a00f00b8c3d84f20bc3a4de35f9 ./src/models/device.rs 42f968d9a99ac154b0bd83e5baa498659b1378be4d47d2a05665be641a2fb938 ./src/models/flow.rs cfbaa853a5185c4be572588577b313a2d618837f7c07a3860d0e8d2cca04ee60 ./src/models/history.rs -668c9cf157679f8c9fe72846f743629c79e263b14827110834b4addbdf9d13ec ./src/models/integrations.rs +5d98d82af11090701d5e654a9c7733b995c571dbd0f7a1905d95ffb7b234b44f ./src/models/integrations.rs 268b4211df93f08423e39374ab9ffe93a7eba647c173168821736af299b36549 ./src/models/runtime.rs e77f8cdcb5fc0e51cdbf2527a69585a2f6f9089e159d1e45ec4a48735ab0e304 ./src/models/temporary_thermostat.rs 5b5c80e25ae2aad1b511bd3f0a82b8fbae2440abf8c656ecb90814587d0be137 ./src/models/zone.rs @@ -133,24 +134,24 @@ f13f1be3d5789539eba3fc1b59c14835f8c681eca7634e118fa3763a53aeee2b ./src/queries/ b92a6cb158b494fe145b43c7641e65f6fafff47201d7d76edbec2cfd8b94835c ./systemd/gree-controller.service 544a31cb2e5374227026afd6abd0d910381f3fca1638154ebd74e3c3d96a35d1 ./web/404.html e98bdd7204349cce1ec6f57283509697af0bbc72280622a6c3efa6fed242db4f ./web/favicon.svg -0e76a93e59621704879928e5f49cfa57203a9c3c6b609d054f3a5050ac739c9f ./web/index.html +64dad888573f60e94175c93171eb3f0352c8b480958b7ac2b5e50caffd8ee801 ./web/index.html 9ba3f6fa05b72aa989139b2a909982571b2a02055052e4c40104f1e81a9aa7eb ./web/js/README.md -835e606b2e238efe98f750d1e234b51a7a965e048315e785a13792125e8208f5 ./web/js/bootstrap.js +21b662914590e720dafefe4dc3dccc91dbeb963cce9ae91f007c835d2d0c1fe5 ./web/js/bootstrap.js 72fa280996ba331a05bdb29a1f49b4e61bdd8964953c8ce1db952156620f08fe ./web/js/charts.js -4280d096af17ec011e0332492300c9e22713651ef0438ae2dd0559ee0f7844e9 ./web/js/core.js +7dd04683781137deedc5dfccafcb5a4d15954041f2e5207312816de216904513 ./web/js/core.js d34953aec21ade81ca9770c9c99873b0b2a4862a1bf671a6e13b507a5dd5b7ed ./web/js/dashboard.js 2887856e35e086bb575e9fc0d8b0ed1160b6912297a2957535d5416bda68b4b7 ./web/js/entities.js 9a0e87911f78cd8a969f4c57ce74774f5fa829fdecd79494d9a7f9d5804f9792 ./web/js/events.js -65c6adb34a7d18bb401eb8c8d1a11d4d8302e8dfd17dbe2eb617a9f47835bc6a ./web/js/flows.js -cac7615888fd38be3cf714dcf02380c4a41a1802ee1724f91496e7c19cbdffba ./web/js/forms.js +40a5f342ed1447b09ed9fcbf65ee2c1035128fc608b4b743d259d51cc6741680 ./web/js/flows.js +f06557c6d338259059b990f8f79c91d68ac8ec1f245c302468f08929b5506901 ./web/js/forms.js dbf36223863ba882c03eccea4516ba8db7acf0c2e72c6c64490cb994f1e20f96 ./web/js/history.js 7eaa04992ea828dc89f5eaebf674cbbfd160f53eb8d0d6ef0d4e47bd8c28c435 ./web/js/main.js 7f23507b2f1f632d69b466688f68f3f0bf661dc6856a1ed14a462e063114a980 ./web/js/navigation.js -d4aa5575fe0202acab36afd10a2c78f421af27ba4142ea20fb0cf6f182d6b59e ./web/js/realtime.js +c56059693d09e35261dc5dd1940289ffc67c82ea7c547750d57e6c79fe75b05b ./web/js/realtime.js c8c82e88c3715b1bfabf155e36266a4c94f5e2fc03eb39dcdd9d08a1985a997c ./web/js/router.js -b3e796778d6eee59d663f198115d0c2a45497fdfc23a88ff4b85893ba079d586 ./web/js/settings-ui.js -4a05126dc95c291ede08452b179d77791e270ed0c9ab9806036934ccf7e0f704 ./web/js/settings.js +566702c0dafd304581c0438597578bccce1cf21c428171a36415728eb4867d04 ./web/js/settings-ui.js +d299216bb707f494be77e0d46f87b203cf1a2c020e0036af86d8f5818ba03c88 ./web/js/settings.js 6b653e4fe2d4db4ffae6ff8f39b8ce57a10a990dc0000ee4aae51d949afadd52 ./web/manifest.webmanifest -c86801c5460f8342599affb0237e6e62fc39193277faf1bdfcbef58d8dabf2f0 ./web/styles.css +f1f028e197f6caef639ef0ddb20950d00bd821f94e0e4a0ef2ad8bab1bb1fd24 ./web/styles.css ae1b03f30b494f474a781a5d32072f1e73eba2b7c768109fc1cc8028cb6662a4 ./web/sw.js d505d793ce7cc9485b45b78bba1c0d51887adc7451ab59a42702946e5b991382 ./web/theme-init.js diff --git a/README.md b/README.md index ea7b590..adf0b32 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.6** +**Current release: 0.9.7** > [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 06ad15a..96eb3d6 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.6**. +HTTP and WebSocket API for GREE Controller **0.9.7**. [← Main documentation](../README.md) @@ -212,7 +212,7 @@ Response: { "status": "ok", "name": "gree-controller", - "version": "0.9.6", + "version": "0.9.7", "uptime_seconds": 1234, "control_ready": true, "time": "2026-08-30T06:54:00Z" @@ -236,7 +236,7 @@ Returns the initial Web UI snapshot: "settings": {}, "outdoor_temperature": null, "system": { - "version": "0.9.6", + "version": "0.9.7", "uptime_seconds": 1234, "auth_required": false, "control_ready": true, @@ -1487,9 +1487,9 @@ curl -H 'Authorization: Bearer gree_controller_RESTRICTED_TOKEN' \ Flow is the source-of-truth representation for visual schedule/automation logic. `GET /api/flows` lists Flows; `GET /api/flows/:id`, `POST /api/flows`, `PUT /api/flows/:id` and `DELETE /api/flows/:id` manage them. A Flow payload contains `name`, `enabled`, optional `description`, `nodes` and `edges`. -Saving a Flow validates the DAG and compiles it atomically. A thermostat action driven only by one weekday block and one time-range block is emitted as a native schedule when its settings are schedule-compatible. More complex graphs are emitted as Flow-triggered automations. Generated schedules and automations expose `flow_id` / `flow_node_id` and cannot be edited or deleted through their legacy endpoints; edit the owning Flow instead. +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, and arbitrary Home Assistant state/numeric/attribute/availability sources. Logic blocks support AND, OR and NOT. Action blocks target thermostat zones, GREE devices or climate groups. Direct GREE actions map to the existing `DeviceCommand` fields including fan, swing, quiet, turbo, light, air, xfan, health and sleep; ownership rules may suppress fields that would fight an active thermostat. Flow group actions use the existing group controller and support power, house/heat/cool mode, auto/comfort/sleep/away and the existing custom group target. +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. ## Compressor protection queue @@ -1512,4 +1512,4 @@ Cancellation suppresses the same pending intent until a new explicit thermostat/ Existing Flow updates require `expected_revision`. A mismatched revision returns HTTP 409 to prevent stale editor tabs from overwriting newer graphs. Source Flow plus generated outputs are replaced atomically in one database transaction while configuration/automation/schedule/thermostat-cycle operations are serialized. -Additional Flow condition blocks are `house_mode`, `device_state`, `zone_state`, `group_state`, `night_mode`, `ha_attribute`, `ha_available` and `constant`, alongside weekday/time/date, temperature, Home Assistant state/numeric and AND/OR/NOT blocks. `device_state` can inspect enabled/online/power/mode/fan/swing/quiet/turbo/light/air/xfan/health/sleep state. The editor ships ten editable templates: workday comfort, weather comfort, smart demand, device resilience, night group, HA window guard, presence eco, frost guard, nested guard and night quiet. +Additional Flow condition blocks are `house_mode`, `device_state`, `zone_state`, `group_state`, `night_mode`, `ha_attribute`, `ha_available`, `constant` and `shared_input`, alongside weekday/time/date, temperature, Home Assistant state/numeric and AND/OR/NOT blocks. `device_state` can inspect enabled/online/power/mode/fan/swing/quiet/turbo/light/air/xfan/health/sleep state. The editor ships 37 categorized editable templates covering comfort, energy, safety, night, reliability, Home Assistant heat-source coordination and advanced multi-branch logic. diff --git a/docs/FLOW.md b/docs/FLOW.md index 2a87aaf..720a373 100644 --- a/docs/FLOW.md +++ b/docs/FLOW.md @@ -6,7 +6,7 @@ Flow is an authoring/orchestration layer. It does not replace the thermostat, sc - `weekday + time_range -> zone_thermostat` with schedule-compatible settings compiles to a native `Schedule`. - Richer DAGs compile to native `Automation` rows with `trigger_kind = "flow"`. -- Generated rows keep `flow_id` and `flow_node_id` and are read-only in the legacy editors. +- Generated rows keep `flow_id` and `flow_node_id`, use stable `flow-` names, and are read-only in the legacy editors. - The Flow source graph plus all of its generated schedule/automation rows are replaced in one SQLite transaction. - Flow updates require `expected_revision`; stale editor tabs receive HTTP 409. @@ -57,24 +57,15 @@ Dry-run never mutates thermostats, devices, groups, schedules or automations. ## Blocks -Condition/source blocks currently include weekday, time range, date range, application Night mode, outdoor/device/zone temperature, house mode, device state, thermostat/zone state, group state, Home Assistant state/numeric/attribute/availability and a diagnostic constant. Logic blocks are AND, OR and NOT. +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. Actions include thermostat zone, GREE device and climate group. Direct GREE actions expose power, HVAC mode, target temperature, fan speed, vertical/horizontal swing, quiet, turbo, light, air, xfan, health and sleep. ## Built-in templates -The editor ships editable templates for: +The editor ships 37 editable templates grouped into seven categories: Comfort, Energy & cost, Protection & safety, Night, Reliability, Home Assistant / heat sources and Advanced. 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. -- workday comfort, -- weather-aware comfort, -- smart AND/OR demand, -- device resilience, -- night group control, -- Home Assistant window guard, -- presence comfort/eco, -- frost protection, -- nested OR + NOT guard, -- application Night-mode quiet profile. +The canvas supports multi-selection (Shift/Ctrl/Cmd), `Ctrl/Cmd+A`, Select all / Clear selection toolbar actions, and dragging the whole selected group while preserving relative positions. ## Good next stateful blocks diff --git a/home-assistant/custom_components/gree_controller/manifest.json b/home-assistant/custom_components/gree_controller/manifest.json index 28be40a..fe9dbc6 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.6", + "version": "0.9.7", "config_flow": true, "integration_type": "hub", "iot_class": "local_polling", diff --git a/lang/en.json b/lang/en.json index 52dd12e..00ac09c 100644 --- a/lang/en.json +++ b/lang/en.json @@ -512,7 +512,7 @@ "night.description": "Configure quieter thermostat behavior for sleeping hours without mixing it with application settings.", "night.saveHint": "Save night-mode changes.", "homeAssistant.eyebrow": "Integration", - "homeAssistant.description": "Connection, temperature sensors, friendly aliases and access tokens for the Home Assistant integration.", + "homeAssistant.description": "Home Assistant connection, thermostat temperature sources, shared Flow inputs, sensor aliases and integration access.", "homeAssistant.saveHint": "Save Home Assistant and sensor changes.", "notifications.title": "Notifications", "notifications.hint": "Send important events or only problems/anomalies.", @@ -942,6 +942,102 @@ "flow.available": "available", "flow.haAvailableHint": "This condition is true only when the Home Assistant entity returns a state other than unknown/unavailable. Connection errors fail closed as false. Useful as a guard before NOT and safety logic.", "flow.deviceOptions": "Advanced GREE options", - "flow.deviceOptionsHint": "These fields use the existing DeviceCommand. Fan/quiet/sleep and dry/fan HVAC modes are blocked on a thermostat-assigned device when they would fight the regulator; use the Thermostat action there instead." + "flow.deviceOptionsHint": "These fields use the existing DeviceCommand. Fan/quiet/sleep and dry/fan HVAC modes are blocked on a thermostat-assigned device when they would fight the regulator; use the Thermostat action there instead.", + "settings.haConnection": "Home Assistant connection", + "settings.haConnectionHint": "Server address and credentials used by all Home Assistant features.", + "settings.haThermostatSources": "Thermostat temperature sources", + "settings.haThermostatSourcesHint": "These settings belong to thermostat and temperature-source logic, not reusable Flow inputs.", + "flow.listEyebrow": "Your automations", + "flow.listTitle": "Flow list", + "flow.listCount": "{count} Flows", + "flow.quickToggleHint": "Enable or disable the Flow without opening the editor", + "flow.quickEnabled": "Flow enabled", + "flow.quickDisabled": "Flow disabled", + "flow.selectAll": "Select all", + "flow.clearSelection": "Clear selection", + "flow.selectedCount": "Selected: {count}", + "flow.node.sharedInput": "Shared input", + "flow.sharedInputMissing": "Shared input missing", + "flow.sharedInputTitle": "Shared Flow input", + "flow.sharedInputEyebrow": "Reusable data", + "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.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.sharedInputNodeEmptyHint": "Add a shared input in Home Assistant / Sensors first.", + "flow.sharedInputNameRequired": "Enter a shared input name.", + "flow.sharedInputDeleteConfirm": "Delete shared input “{name}”?", + "flow.sharedInputDeleteUsed": "Input “{name}” is used by {count} Flows. Those conditions will evaluate false after deletion. Delete it anyway?", + "flow.templateCategory.comfort": "Comfort", + "flow.templateCategory.comfort.hint": "Everyday comfort scenarios based on time, weather and house mode.", + "flow.templateCategory.energy": "Energy & cost", + "flow.templateCategory.energy.hint": "Reduce consumption during absence, expensive energy periods or high household load.", + "flow.templateCategory.safety": "Protection & safety", + "flow.templateCategory.safety.hint": "Guards for frost, overheating, humidity and open-window operation.", + "flow.templateCategory.night": "Night", + "flow.templateCategory.night.hint": "Quieter operation, sleep profiles and bedroom conditions.", + "flow.templateCategory.reliability": "Reliability", + "flow.templateCategory.reliability.hint": "Flows resilient to unavailable sensors, offline devices and disabled thermostats.", + "flow.templateCategory.advanced": "Advanced", + "flow.templateCategory.advanced.hint": "Nested AND/OR/NOT logic, multiple rooms and Home Assistant states.", + "flow.template.morning_boost.title": "Cold-weather morning boost", + "flow.template.morning_boost.description": "On workday mornings, only in heating mode and during cold weather, raises the target to 22.5°C.", + "flow.template.weekend_comfort.title": "Weekend comfort", + "flow.template.weekend_comfort.description": "Keeps daytime comfort on Saturday and Sunday while the house climate mode is not off.", + "flow.template.energy_price_eco.title": "Expensive energy + nobody home", + "flow.template.energy_price_eco.description": "When energy is expensive and nobody is home, switches the selected zone to an economical away profile.", + "flow.template.peak_power_guard.title": "Peak power guard", + "flow.template.peak_power_guard.description": "Turns HVAC off when household power draw exceeds the configured threshold while climate control is active.", + "flow.template.mild_weather_eco.title": "Mild weather — HVAC off", + "flow.template.mild_weather_eco.description": "When outdoor temperature is within a comfortable band and the window is closed, turns HVAC off.", + "flow.template.unoccupied_shutdown.title": "Shut down while away", + "flow.template.unoccupied_shutdown.description": "During daytime hours, turns off a group or zone when the selected person is away.", + "flow.template.humidity_guard.title": "High humidity cooling", + "flow.template.humidity_guard.description": "With high humidity, occupancy and cooling house mode, applies moderate cooling.", + "flow.template.overheat_guard.title": "Overheat guard", + "flow.template.overheat_guard.description": "If a zone is very warm, house mode is cooling and the window is closed, starts protective cooling.", + "flow.template.window_available_guard.title": "Window availability + open state", + "flow.template.window_available_guard.description": "Turns HVAC off only when the window sensor is available and reports open, avoiding false reactions to unavailable.", + "flow.template.sleep_temperature_guard.title": "Night cooling only when needed", + "flow.template.sleep_temperature_guard.description": "In night mode, cools only when room temperature is above the threshold and the house is in cooling mode.", + "flow.template.bedroom_window_night.title": "Bedroom: night + closed window", + "flow.template.bedroom_window_night.description": "Combines night mode with NOT open-window logic so the sleep profile runs only with a closed window.", + "flow.template.sensor_availability_guard.title": "Cooling with sensor validation", + "flow.template.sensor_availability_guard.description": "Checks HA sensor availability first, then temperature and house mode before starting cooling.", + "flow.template.offline_safe_off.title": "Offline device safe-off", + "flow.template.offline_safe_off.description": "When a device is offline while its thermostat remains active, issues a conservative off command with a long cooldown.", + "flow.template.thermostat_enabled_guard.title": "Control only an active thermostat", + "flow.template.thermostat_enabled_guard.description": "Requires an enabled zone, active demand and a non-off house mode before the action executes.", + "flow.template.occupancy_weather_matrix.title": "Occupancy + weather + window", + "flow.template.occupancy_weather_matrix.description": "Comfort runs only when someone is home, outdoor or room demand exists, and the window is not open.", + "flow.template.dual_threshold_control.title": "Dual threshold heating/cooling", + "flow.template.dual_threshold_control.description": "Two independent branches heat below the lower threshold and cool above the upper threshold.", + "flow.template.multi_room_group_guard.title": "Multiple rooms → one group", + "flow.template.multi_room_group_guard.description": "If either of two zones is too warm and house mode is cooling, starts a shared group or zone.", + "flow.template.ha_attribute_mode_guard.title": "HA attribute + window + availability", + "flow.template.ha_attribute_mode_guard.description": "Reads hvac_action, entity availability and window state to stop HVAC only when the data is trustworthy.", + "flow.templateCategory.home_assistant": "Home Assistant / heat sources", + "flow.templateCategory.home_assistant.hint": "Coordinate GREE with a gas boiler, external heat source, heating demand and temperatures read from Home Assistant.", + "flow.template.ha_gas_heating_off.title": "Gas boiler heating → turn GREE off", + "flow.template.ha_gas_heating_off.description": "Reads hvac_action from climate.gas_boiler. When HA reports heating and the entity is available, the selected GREE zone is turned off.", + "flow.template.ha_gas_heating_boost.title": "Gas boiler heating → GREE heat boost", + "flow.template.ha_gas_heating_boost.description": "When the gas boiler is actively heating, the room is below 20°C and house mode is heat, GREE assists with a 23.5°C target.", + "flow.template.ha_gas_heating_reduce.title": "Boiler heating + room warm → reduce GREE", + "flow.template.ha_gas_heating_reduce.description": "When gas heat is already active and the zone reaches at least 21°C, lowers the GREE target to 18°C so the heat sources do not fight each other.", + "flow.template.ha_gas_backup_heat.title": "Gas not heating + room cold → GREE backup", + "flow.template.ha_gas_backup_heat.description": "When the boiler entity is available but does not report heating and the room drops below 19°C, GREE provides backup heat to 22°C.", + "flow.template.ha_external_heat_source_off.title": "Other heat source ON → turn GREE off", + "flow.template.ha_external_heat_source_off.description": "Generic fireplace, furnace or other source preset. binary_sensor.external_heat_source = on turns the GREE group or zone off.", + "flow.template.ha_external_heat_source_assist.title": "Other heat source ON → assist with GREE", + "flow.template.ha_external_heat_source_assist.description": "When an external heat source is running but the room is still below 20°C, GREE assists in heat mode with a 23°C target.", + "flow.template.ha_heating_demand_follow.title": "Follow Home Assistant heating demand", + "flow.template.ha_heating_demand_follow.description": "binary_sensor.heating_demand = on starts GREE heating to 22.5°C while the house is in heat mode.", + "flow.template.ha_boiler_supply_boost.title": "Hot boiler supply + cold room → boost", + "flow.template.ha_boiler_supply_boost.description": "Reads sensor.boiler_supply_temperature. Above 45°C with a cold room, GREE is set to 23.5°C.", + "flow.template.ha_thermostat_idle_fallback.title": "Gas thermostat idle → GREE fallback heat", + "flow.template.ha_thermostat_idle_fallback.description": "If climate.gas_boiler is available, reports hvac_action=idle and the room falls below 19°C, GREE heats to 21.5°C." } } diff --git a/lang/pl.json b/lang/pl.json index 8c96465..5664c41 100644 --- a/lang/pl.json +++ b/lang/pl.json @@ -512,7 +512,7 @@ "night.description": "Ustaw cichszą pracę termostatu w godzinach nocnych bez mieszania tych opcji z ustawieniami aplikacji.", "night.saveHint": "Zapisz zmiany trybu nocnego.", "homeAssistant.eyebrow": "Integracja", - "homeAssistant.description": "Połączenie, czujniki temperatury, przyjazne aliasy i tokeny dostępu dla integracji Home Assistant.", + "homeAssistant.description": "Połączenie z Home Assistant, źródła temperatury termostatów, wspólne wejścia Flow, aliasy czujników i dostęp integracji.", "homeAssistant.saveHint": "Zapisz ustawienia Home Assistant i czujników.", "notifications.title": "Powiadomienia", "notifications.hint": "Wysyłaj ważne zdarzenia albo tylko błędy i anomalie.", @@ -942,6 +942,102 @@ "flow.available": "dostępna", "flow.haAvailableHint": "Warunek jest prawdziwy tylko wtedy, gdy encja Home Assistant odpowiada stanem innym niż unknown/unavailable. Błąd połączenia jest traktowany bezpiecznie jako false. Przydatne jako bramka przed NOT i logiką bezpieczeństwa.", "flow.deviceOptions": "Zaawansowane opcje GREE", - "flow.deviceOptionsHint": "Te pola używają istniejącego DeviceCommand. Fan/quiet/sleep oraz tryby dry/fan są blokowane dla urządzenia przypisanego do termostatu, gdy mogłyby walczyć z regulatorem; użyj wtedy akcji Termostat." + "flow.deviceOptionsHint": "Te pola używają istniejącego DeviceCommand. Fan/quiet/sleep oraz tryby dry/fan są blokowane dla urządzenia przypisanego do termostatu, gdy mogłyby walczyć z regulatorem; użyj wtedy akcji Termostat.", + "settings.haConnection": "Połączenie z Home Assistant", + "settings.haConnectionHint": "Adres serwera i dane dostępu używane przez wszystkie funkcje Home Assistant.", + "settings.haThermostatSources": "Źródła temperatury sterowania", + "settings.haThermostatSourcesHint": "Te ustawienia należą do logiki termostatów i źródeł temperatury, nie do wspólnych wejść Flow.", + "flow.listEyebrow": "Twoje automatyzacje", + "flow.listTitle": "Lista Flow", + "flow.listCount": "{count} Flow", + "flow.quickToggleHint": "Szybko włącz lub wyłącz Flow bez otwierania edytora", + "flow.quickEnabled": "Flow włączony", + "flow.quickDisabled": "Flow wyłączony", + "flow.selectAll": "Zaznacz wszystko", + "flow.clearSelection": "Wyczyść zaznaczenie", + "flow.selectedCount": "Zaznaczono: {count}", + "flow.node.sharedInput": "Wspólne wejście", + "flow.sharedInputMissing": "Brak wspólnego wejścia", + "flow.sharedInputTitle": "Wspólne wejście Flow", + "flow.sharedInputEyebrow": "Wspólne dane", + "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.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.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}”?", + "flow.sharedInputDeleteUsed": "Wejście „{name}” jest używane przez {count} Flow. Po usunięciu te warunki nie będą spełnione. Usunąć mimo to?", + "flow.templateCategory.comfort": "Komfort", + "flow.templateCategory.comfort.hint": "Codzienne scenariusze utrzymujące komfort w zależności od czasu, pogody i trybu domu.", + "flow.templateCategory.energy": "Energia i koszty", + "flow.templateCategory.energy.hint": "Ograniczanie zużycia przy nieobecności, wysokiej cenie energii lub dużym obciążeniu.", + "flow.templateCategory.safety": "Ochrona i bezpieczeństwo", + "flow.templateCategory.safety.hint": "Zabezpieczenia przed mrozem, przegrzaniem, wilgotnością oraz pracą przy otwartym oknie.", + "flow.templateCategory.night": "Noc", + "flow.templateCategory.night.hint": "Cichsza praca, profil sleep i warunki dla sypialni.", + "flow.templateCategory.reliability": "Niezawodność", + "flow.templateCategory.reliability.hint": "Automatyzacje odporne na niedostępne sensory, urządzenia offline i wyłączone termostaty.", + "flow.templateCategory.advanced": "Zaawansowane", + "flow.templateCategory.advanced.hint": "Wielopoziomowe AND/OR/NOT, wiele pomieszczeń i stany Home Assistant.", + "flow.template.morning_boost.title": "Poranne dogrzanie przy mrozie", + "flow.template.morning_boost.description": "W dni robocze rano, tylko w trybie grzania i przy mrozie na zewnątrz, podnosi cel do 22,5°C.", + "flow.template.weekend_comfort.title": "Weekendowy komfort", + "flow.template.weekend_comfort.description": "W sobotę i niedzielę utrzymuje komfort w ciągu dnia, jeśli tryb domu nie jest wyłączony.", + "flow.template.energy_price_eco.title": "Droga energia + brak domowników", + "flow.template.energy_price_eco.description": "Przy wysokiej cenie energii i nieobecności przełącza wybraną strefę w oszczędny profil away.", + "flow.template.peak_power_guard.title": "Ochrona przed szczytem mocy", + "flow.template.peak_power_guard.description": "Wyłącza HVAC, gdy pobór mocy domu przekroczy ustawiony próg i system klimatu jest aktywny.", + "flow.template.mild_weather_eco.title": "Łagodna pogoda — HVAC off", + "flow.template.mild_weather_eco.description": "Gdy temperatura zewnętrzna mieści się w komfortowym zakresie i okno jest zamknięte, wyłącza HVAC.", + "flow.template.unoccupied_shutdown.title": "Wyłącz podczas nieobecności", + "flow.template.unoccupied_shutdown.description": "W godzinach dziennych wyłącza grupę lub strefę, gdy wskazana osoba jest poza domem.", + "flow.template.humidity_guard.title": "Wysoka wilgotność — chłodzenie", + "flow.template.humidity_guard.description": "Przy wysokiej wilgotności, obecności domowników i trybie chłodzenia wymusza umiarkowane chłodzenie.", + "flow.template.overheat_guard.title": "Ochrona przed przegrzaniem", + "flow.template.overheat_guard.description": "Jeśli strefa jest bardzo ciepła, tryb domu to chłodzenie i okno jest zamknięte, uruchamia chłodzenie ochronne.", + "flow.template.window_available_guard.title": "Okno: dostępność + otwarcie", + "flow.template.window_available_guard.description": "Wyłącza HVAC dopiero gdy czujnik okna jest dostępny i zgłasza otwarcie — bez fałszywej reakcji na unavailable.", + "flow.template.sleep_temperature_guard.title": "Nocne chłodzenie tylko gdy trzeba", + "flow.template.sleep_temperature_guard.description": "W trybie nocnym chłodzi tylko wtedy, gdy temperatura pokoju przekracza próg i dom jest w trybie cool.", + "flow.template.bedroom_window_night.title": "Sypialnia: noc + zamknięte okno", + "flow.template.bedroom_window_night.description": "Łączy tryb nocny z NOT otwartego okna, aby profil sleep działał tylko przy zamkniętym oknie.", + "flow.template.sensor_availability_guard.title": "Chłodzenie z kontrolą sensora", + "flow.template.sensor_availability_guard.description": "Najpierw sprawdza dostępność sensora HA, potem temperaturę i tryb domu; dopiero wtedy uruchamia chłodzenie.", + "flow.template.offline_safe_off.title": "Urządzenie offline — bezpieczne wyłączenie", + "flow.template.offline_safe_off.description": "Gdy urządzenie jest offline, a termostat nadal aktywny, wydaje bezpieczne polecenie wyłączenia z długim cooldownem.", + "flow.template.thermostat_enabled_guard.title": "Steruj tylko aktywnym termostatem", + "flow.template.thermostat_enabled_guard.description": "Wymaga włączonej strefy, aktywnego zapotrzebowania i niezerowego trybu domu przed akcją.", + "flow.template.occupancy_weather_matrix.title": "Obecność + pogoda + okno", + "flow.template.occupancy_weather_matrix.description": "Komfort działa tylko gdy ktoś jest w domu, występuje zapotrzebowanie pogodowe lub pokojowe i okno nie jest otwarte.", + "flow.template.dual_threshold_control.title": "Dwa progi: grzanie i chłodzenie", + "flow.template.dual_threshold_control.description": "Dwie niezależne gałęzie sterują grzaniem poniżej dolnego progu i chłodzeniem powyżej górnego.", + "flow.template.multi_room_group_guard.title": "Wiele pokoi → jedna grupa", + "flow.template.multi_room_group_guard.description": "Jeśli dowolna z dwóch stref jest za ciepła i dom jest w trybie cool, uruchamia wspólną grupę lub strefę.", + "flow.template.ha_attribute_mode_guard.title": "Atrybut HA + okno + dostępność", + "flow.template.ha_attribute_mode_guard.description": "Czyta atrybut hvac_action, dostępność encji i stan okna, aby zatrzymać HVAC tylko przy wiarygodnych danych.", + "flow.templateCategory.home_assistant": "Home Assistant / źródła ciepła", + "flow.templateCategory.home_assistant.hint": "Koordynacja GREE z kotłem gazowym, zewnętrznym ogrzewaniem, zapotrzebowaniem i temperaturami pobieranymi z Home Assistant.", + "flow.template.ha_gas_heating_off.title": "Kocioł gazowy grzeje → wyłącz GREE", + "flow.template.ha_gas_heating_off.description": "Czyta hvac_action z climate.gas_boiler. Gdy HA zgłasza heating i encja jest dostępna, wyłącza wybraną strefę GREE.", + "flow.template.ha_gas_heating_boost.title": "Kocioł gazowy grzeje → dogrzewanie GREE", + "flow.template.ha_gas_heating_boost.description": "Gdy kocioł gazowy aktywnie grzeje, pokój ma mniej niż 20°C i dom jest w trybie heat, GREE wspomaga ogrzewanie celem 23,5°C.", + "flow.template.ha_gas_heating_reduce.title": "Kocioł grzeje + pokój ciepły → obniż GREE", + "flow.template.ha_gas_heating_reduce.description": "Jeśli gaz już grzeje, a strefa osiągnęła co najmniej 21°C, obniża cel GREE do 18°C, aby źródła ciepła nie walczyły ze sobą.", + "flow.template.ha_gas_backup_heat.title": "Gaz nie grzeje + zimno → GREE jako backup", + "flow.template.ha_gas_backup_heat.description": "Gdy encja kotła jest dostępna, ale nie zgłasza heating, a w pokoju jest poniżej 19°C, GREE przejmuje awaryjne grzanie do 22°C.", + "flow.template.ha_external_heat_source_off.title": "Inne źródło ciepła ON → wyłącz GREE", + "flow.template.ha_external_heat_source_off.description": "Ogólny preset dla kominka, pieca lub innego źródła. Stan binary_sensor.external_heat_source = on wyłącza grupę albo strefę GREE.", + "flow.template.ha_external_heat_source_assist.title": "Inne źródło ciepła ON → wspomagaj GREE", + "flow.template.ha_external_heat_source_assist.description": "Gdy zewnętrzne źródło działa, ale pokój nadal ma mniej niż 20°C, GREE wspomaga je w trybie heat z celem 23°C.", + "flow.template.ha_heating_demand_follow.title": "Podążaj za żądaniem grzania z HA", + "flow.template.ha_heating_demand_follow.description": "Stan binary_sensor.heating_demand = on uruchamia ogrzewanie GREE do 22,5°C, jeśli dom jest w trybie heat.", + "flow.template.ha_boiler_supply_boost.title": "Gorące zasilanie kotła + zimny pokój → boost", + "flow.template.ha_boiler_supply_boost.description": "Czyta sensor.boiler_supply_temperature. Przy temperaturze zasilania powyżej 45°C i zimnym pokoju ustawia GREE na 23,5°C.", + "flow.template.ha_thermostat_idle_fallback.title": "Termostat gazowy idle → awaryjne grzanie GREE", + "flow.template.ha_thermostat_idle_fallback.description": "Jeśli climate.gas_boiler jest dostępny, ma hvac_action=idle i pokój spadnie poniżej 19°C, GREE uruchamia grzanie do 21,5°C." } } diff --git a/scripts/FILE_MANIFEST.sha256 b/scripts/FILE_MANIFEST.sha256 index 4920279..e58b1f6 100644 --- a/scripts/FILE_MANIFEST.sha256 +++ b/scripts/FILE_MANIFEST.sha256 @@ -1,11 +1,12 @@ bb89bac237e750e9b1bf73761d7df97a6b81853091615878c03f13d7b6399aa7 ./README.md 5bc736c7bc76ca80aaa406bb171d2aa91baf4c3aa8695dce0e09b888b6ab3146 ./common.sh 6403786610ee6d2f628193c25aee0dd058d62e904aa1a31d5f62fdaae0e94b4f ./configure-gree-network.sh +1d6e14e26e49aa9d3527f30a23668bf8d9c48b67e6628ef686c3155c012155de ./dev.sh 14076104c042fba1284ebb07531a6c3ff972df1f9f5b18f70da18ab774efed27 ./generate_ha_migration.py e4849261fd9ed1f01df96c0637c439c0c4eff8fa317b2918026167bba343af79 ./install-lxc.sh bb7cd2c5b27c9dceec1d1d2846fbad9600df9c08e0ad07d13fcde97af533091c ./install.sh e00d211e3885e30d7fed1e43b44e6fdad40a67019060156c0641816a93e3365f ./network-debug.sh 01952aa92b217f8eae2493b88870e2dec595100cd15c4d561ff11ae2b936c46f ./regenerate-sha.sh 81345b6a0b51736bdbc98fd23199b62e4c721b4e7437e02dab7ea79b97dff29a ./service.sh -d84315307bcda654ca5d23f86ae50723f6d26c0b8046711076d503983c05eaf6 ./smoke.sh +8ccca8ed49576cf0b8e3453436ac3ff9c2a222cd41b27db98ceb62bd5f87aeef ./smoke.sh b50782b3742dfbf8a319c60571c968e93fdf8547db747c759edcffae68cb98bf ./update.sh diff --git a/src/api/flows.rs b/src/api/flows.rs index f03b107..f4bb231 100644 --- a/src/api/flows.rs +++ b/src/api/flows.rs @@ -36,11 +36,16 @@ fn flow_f64(config: &Value, key: &str) -> Option { config.get(key).and_then fn flow_u8(config: &Value, key: &str) -> Option { config.get(key).and_then(Value::as_u64).and_then(|value| u8::try_from(value).ok()) } fn flow_bool(config: &Value, key: &str) -> Option { config.get(key).and_then(Value::as_bool) } +fn generated_flow_name(flow_id: &str, action_node_id: &str) -> String { + let digest = Sha256::digest(format!("{flow_id}:{action_node_id}").as_bytes()); + format!("flow-{}", URL_SAFE_NO_PAD.encode(&digest[..12])) +} + fn flow_condition_kind(kind: &str) -> bool { matches!(kind, "weekday" | "time_range" | "date_range" | "outdoor_temperature" | "device_temperature" | "zone_temperature" | "ha_state" | "ha_numeric" | "ha_attribute" | "ha_available" | "house_mode" | - "device_state" | "zone_state" | "group_state" | "night_mode" | "constant" + "device_state" | "zone_state" | "group_state" | "night_mode" | "constant" | "shared_input" ) } fn flow_logic_kind(kind: &str) -> bool { matches!(kind, "logic_and" | "logic_or" | "logic_not") } @@ -215,6 +220,9 @@ fn validate_condition(condition: &crate::models::FlowCondition, state: &AppState "constant" => { 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())); } + } _ => {} } Ok(()) @@ -271,7 +279,7 @@ fn compile_flow(state: &AppState, mut flow: crate::models::Flow) -> Result<(crat let id = format!("flow:{}:schedule:{}", flow.id, action_node.id); let created_at = previous_schedules.get(&id).map(|item| item.created_at.clone()).unwrap_or_else(|| now.clone()); let schedule = Schedule { - id, zone_id, name: format!("{} · {}", flow.name, zone.name), enabled: flow.enabled, + id, zone_id, name: generated_flow_name(&flow.id, &action_node.id), enabled: flow.enabled, weekdays, start_time: flow_string(&time.config, "start").unwrap(), end_time: flow_string(&time.config, "end").unwrap(), preset, setpoint, created_at, updated_at: now.clone(), flow_id: Some(flow.id.clone()), flow_node_id: Some(action_node.id.clone()), }; @@ -283,7 +291,7 @@ fn compile_flow(state: &AppState, mut flow: crate::models::Flow) -> Result<(crat let created_at = previous_automations.get(&id).map(|item| item.created_at.clone()).unwrap_or_else(|| now.clone()); let last_fired_at = previous_automations.get(&id).and_then(|item| item.last_fired_at.clone()); let mut item = Automation { - id, name: format!("{} · {}", flow.name, action_node.id), enabled: flow.enabled, + id, name: generated_flow_name(&flow.id, &action_node.id), enabled: flow.enabled, trigger_kind: "flow".into(), trigger_device_id: None, threshold: None, at_time: None, action_device_id: String::new(), action_group_id: None, action_preset: None, action: DeviceCommand::default(), cooldown_seconds: 60, last_fired_at, action_zone_id: None, action_zone_preset: None, flow_conditions: conditions, diff --git a/src/api/public_settings.rs b/src/api/public_settings.rs index cadbd50..266d85b 100644 --- a/src/api/public_settings.rs +++ b/src/api/public_settings.rs @@ -57,6 +57,7 @@ fn public_settings(settings: &RuntimeSettings) -> Value { "sensor_stale_after_seconds": settings.home_assistant.sensor_stale_after_seconds, "allow_invalid_tls": settings.home_assistant.allow_invalid_tls, "sensor_aliases": settings.home_assistant.sensor_aliases, + "flow_inputs": settings.home_assistant.flow_inputs, } }) } diff --git a/src/api/settings.rs b/src/api/settings.rs index 63f6fd4..a1f8019 100644 --- a/src/api/settings.rs +++ b/src/api/settings.rs @@ -43,6 +43,7 @@ async fn update_settings(State(state): State, Json(mut input): Json Result<(), AppError> { + let mut ids = std::collections::HashSet::new(); + if settings.home_assistant.flow_inputs.len() > 128 { + return Err(AppError::BadRequest("too many shared Flow inputs (max 128)".into())); + } + for item in &mut settings.home_assistant.flow_inputs { + item.id = item.id.trim().chars().take(120).collect(); + item.name = item.name.trim().chars().take(100).collect(); + item.kind = item.kind.trim().to_string(); + if item.id.is_empty() || item.name.is_empty() { + return Err(AppError::BadRequest("shared Flow input requires id and name".into())); + } + if !ids.insert(item.id.clone()) { + return Err(AppError::BadRequest("shared Flow input IDs must be unique".into())); + } + if !matches!(item.kind.as_str(), + "outdoor_temperature" | "device_temperature" | "zone_temperature" | + "ha_state" | "ha_numeric" | "ha_attribute" | "ha_available" | + "house_mode" | "device_state" | "zone_state" | "group_state" | + "night_mode" | "constant") { + 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)?; + } + Ok(()) +} + fn canonicalize_home_assistant_entities(settings: &mut RuntimeSettings) { let default_entity = settings.home_assistant.default_entity_id.clone(); if let Some(entity_id) = home_assistant::resolve_entity_id(&settings.home_assistant, Some(&default_entity)) { diff --git a/src/config.rs b/src/config.rs index ebb58aa..003c917 100644 --- a/src/config.rs +++ b/src/config.rs @@ -86,6 +86,7 @@ impl Config { .map(|v| matches!(v.to_ascii_lowercase().as_str(), "1" | "true" | "yes" | "on")) .unwrap_or(false), sensor_aliases: Default::default(), + flow_inputs: Vec::new(), }, } } diff --git a/src/engine/automations.rs b/src/engine/automations.rs index 1a1158d..6b7ff0f 100644 --- a/src/engine/automations.rs +++ b/src/engine/automations.rs @@ -285,9 +285,19 @@ async fn flow_leaf_observation( settings: &crate::models::RuntimeSettings, overrides: &HashMap, ) -> Result<(bool, Value), AppError> { - let c = &condition.config; + let mut resolved_kind = condition.kind.as_str(); + 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 c = resolved_config.unwrap_or(&condition.config); let override_value = overrides.get(&condition.id).cloned(); - let (matched, actual) = match condition.kind.as_str() { + let (matched, actual) = match resolved_kind { "weekday" => { let actual = override_value.unwrap_or_else(|| json!(now.weekday().number_from_monday())); let day = actual.as_u64().unwrap_or(now.weekday().number_from_monday() as u64); @@ -508,7 +518,7 @@ fn flow_condition_kind_runtime(kind: &str) -> bool { matches!(kind, "weekday" | "time_range" | "date_range" | "outdoor_temperature" | "device_temperature" | "zone_temperature" | "ha_state" | "ha_numeric" | "ha_attribute" | "ha_available" | "house_mode" | - "device_state" | "zone_state" | "group_state" | "night_mode" | "constant" + "device_state" | "zone_state" | "group_state" | "night_mode" | "constant" | "shared_input" ) } diff --git a/src/home_assistant.rs b/src/home_assistant.rs index ced7000..f1ce5ff 100644 --- a/src/home_assistant.rs +++ b/src/home_assistant.rs @@ -96,6 +96,7 @@ mod tests { sensor_stale_after_seconds: 300, allow_invalid_tls: false, sensor_aliases, + flow_inputs: Vec::new(), } } diff --git a/src/models/integrations.rs b/src/models/integrations.rs index 629c8b4..853c58f 100644 --- a/src/models/integrations.rs +++ b/src/models/integrations.rs @@ -1,3 +1,12 @@ +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FlowSharedInput { + pub id: String, + pub name: String, + pub kind: String, + #[serde(default)] + pub config: Value, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct HomeAssistantSettings { #[serde(default)] @@ -18,6 +27,9 @@ 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. + #[serde(default)] + pub flow_inputs: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/web/index.html b/web/index.html index 8fd8701..918373c 100644 --- a/web/index.html +++ b/web/index.html @@ -180,14 +180,17 @@
Visual logic

Flow

-
+
One place for schedules and automations

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

Flow = source of truth
-
+
+
Twoje automatyzacje

Lista Flow

+
+
@@ -370,67 +373,45 @@ tokens for the Home Assistant integration.

-
-
-

Home Assistant sensor input

-

Optional. Used when a room zone reads an external Home Assistant - temperature sensor.

-
-
+

Połączenie z Home Assistant

Adres serwera i dane dostępu używane przez wszystkie funkcje Home Assistant.

- - - - - -

If the HA sensor is not updated - within this time, the reading is treated as stale. The thermostat falls back to the GREE sensor when - available.

- - -

Use only for a trusted - local Home Assistant server, for example https://192.168.50.25.

-
-
-
Sensor aliasesFriendly names are used in charts and selectors; the original - entity_id remains unchanged.
-
-
-
-
-
+ + + +

Use only for a trusted local Home Assistant server.

+
+
-
-
-

Home Assistant integration access

-

Create a controller token and paste it into the GREE Controller - integration in Home Assistant.

-
-
-
-
-
+

Źródła temperatury sterowania

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

+
+ + + +

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

+
-
Save Home Assistant and sensor - changes.
+ +
+

Wspólne wejścia Flow

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

+
+
+ +
+

Sensor aliases

Friendly names are used in charts and selectors; the original entity_id remains unchanged.

+
+
+
+
+
+ +
+

Home Assistant integration access

Create a controller token and paste it into the GREE Controller integration in Home Assistant.

+
+
+
Save Home Assistant and sensor changes.
@@ -720,12 +701,13 @@
Time
Sensors -
+
Logic
Actions
+
Start by adding blocksConditions go on the left, actions on the right.
@@ -738,6 +720,17 @@ + +
+
Wspólne dane

Wspólne wejście Flow

+ + + +
+
+
+
+
Flow library

Templates

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

diff --git a/web/js/bootstrap.js b/web/js/bootstrap.js index c3ec5b6..ead6983 100644 --- a/web/js/bootstrap.js +++ b/web/js/bootstrap.js @@ -12,6 +12,7 @@ async function loadBootstrap(showMessage = false) { app.accessTokens = data.access_tokens || []; app.settings = data.settings || null; app.sensorAliases = { ...(app.settings?.home_assistant?.sensor_aliases || {}) }; + app.flowSharedInputs = JSON.parse(JSON.stringify(app.settings?.home_assistant?.flow_inputs || [])); app.system = data.system || {}; app.systemSnapshotAt = Date.now(); app.outdoorTemperature = Number.isFinite(Number(data.outdoor_temperature)) ? Number(data.outdoor_temperature) : null; diff --git a/web/js/core.js b/web/js/core.js index fe18643..8da2cd5 100644 --- a/web/js/core.js +++ b/web/js/core.js @@ -32,9 +32,9 @@ const app = { historyTab: 'overview', historyData: { zones: [], devices: [], sensors: [] }, historyCounts: {}, historyZone: 'all', historyDevice: 'all', historySensor: 'all', historyLoading: false, customChartSeries: [], savedCharts: [], chartZooms: {}, chartHiddenSeries: {}, zoneControlSeq: {}, groupControlSeq: {}, zoneControlQueue: {}, groupControlQueue: {}, deviceControlQueue: {}, houseControlQueue: null, climateControlQueue: null, groupCustomDrafts: {}, zoneTemperatureTimers: {}, - controlPlan: null, controlPlanTimer: null, debugLines: [], debugBacklogLoaded: false, debugFilter: 'all', sensorAliases: {}, + controlPlan: null, controlPlanTimer: null, debugLines: [], debugBacklogLoaded: false, debugFilter: 'all', sensorAliases: {}, flowSharedInputs: [], simulationScope: 'units', simulationTarget: 'all', standaloneSimulation: false, dashboardTab: 'main', settingsTab: 'app', systemSnapshotAt: Date.now(), - flowDraft: null, flowSelectedNodeId: null, flowConnectFrom: null, flowDirty: false, + flowDraft: null, flowSelectedNodeId: null, flowSelectedNodeIds: [], flowConnectFrom: null, flowDirty: false, }; try { app.savedCharts = JSON.parse(localStorage.getItem('gree_controller_saved_charts') || '[]'); } catch (_) { app.savedCharts = []; } diff --git a/web/js/flows.js b/web/js/flows.js index a95d618..987f559 100644 --- a/web/js/flows.js +++ b/web/js/flows.js @@ -15,6 +15,7 @@ const FLOW_NODE_META = Object.freeze({ group_state: { titleKey: 'flow.node.groupState', category: 'sensor' }, night_mode: { titleKey: 'flow.node.nightMode', category: 'time' }, constant: { titleKey: 'flow.node.constant', category: 'sensor' }, + shared_input: { titleKey: 'flow.node.sharedInput', category: 'sensor' }, logic_and: { titleKey: 'flow.node.and', category: 'logic' }, logic_or: { titleKey: 'flow.node.or', category: 'logic' }, logic_not: { titleKey: 'flow.node.not', category: 'logic' }, @@ -45,6 +46,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 === '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 }; @@ -53,13 +55,15 @@ function flowDefaultConfig(kind) { function renderFlows() { const host = $('#flowList'); if (!host) return; - host.innerHTML = app.flows.length ? app.flows.map(flow => `
-

${esc(flow.name)}

${esc(flow.description || tr('flow.defaultDescription'))}

${esc(flow.enabled ? tr('common.enabled') : tr('common.disabled'))}
+ const count = $('#flowListCount'); if (count) count.textContent = tr('flow.listCount', { count: app.flows.length }); + host.innerHTML = app.flows.length ? app.flows.map(flow => `
+

${esc(flow.name)}

${esc(flow.description || tr('flow.defaultDescription'))}

${esc(tr('flow.blocks'))}${flow.nodes?.length || 0}
${esc(tr('nav.schedules'))}${flow.compiled_schedule_ids?.length || 0}
${esc(tr('nav.automations'))}${flow.compiled_automation_ids?.length || 0}
`).join('') : `
${esc(tr('flow.emptyTitle'))}${esc(tr('flow.emptyText'))}
`; } + function flowDraftFrom(flow) { return flow ? JSON.parse(JSON.stringify(flow)) : { id: '', revision: 0, name: tr('flow.newDefaultName'), enabled: true, description: '', nodes: [], edges: [], summary: '', compiled_schedule_ids: [], compiled_automation_ids: [], @@ -70,7 +74,7 @@ function openFlowEditor(id = '', { push = true } = {}) { const flow = id ? app.flows.find(item => item.id === id) : null; if (id && !flow) return toast(tr('flow.notFound'), true); app.flowDraft = flowDraftFrom(flow); - app.flowSelectedNodeId = null; app.flowConnectFrom = null; app.flowDirty = false; + app.flowSelectedNodeId = null; app.flowSelectedNodeIds = []; app.flowConnectFrom = null; app.flowDirty = false; $('#flowName').value = app.flowDraft.name || ''; $('#flowEnabled').checked = app.flowDraft.enabled !== false; $('#flowEditor').hidden = false; @@ -82,7 +86,7 @@ function openFlowEditor(id = '', { push = true } = {}) { function closeFlowEditor({ push = true, force = false } = {}) { if (!force && app.flowDirty && !confirm(tr('confirm.discardChanges'))) return false; $('#flowEditor').hidden = true; document.body.classList.remove('flow-editor-open'); - app.flowDraft = null; app.flowSelectedNodeId = null; app.flowConnectFrom = null; app.flowDirty = false; + app.flowDraft = null; app.flowSelectedNodeId = null; app.flowSelectedNodeIds = []; app.flowConnectFrom = null; app.flowDirty = false; if (push) updateBrowserUrl('/flows'); return true; } @@ -105,6 +109,7 @@ 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 === 'logic_and') return tr('flow.allConditions'); if (node.kind === 'logic_or') return tr('flow.anyCondition'); if (node.kind === 'logic_not') return tr('flow.invertCondition'); @@ -123,7 +128,7 @@ function renderFlowEditor() { const nodesHost = $('#flowNodes'); nodesHost.innerHTML = draft.nodes.map(node => { const meta = FLOW_NODE_META[node.kind] || { title: node.kind, category: 'logic' }; - return `
+ return `
${esc(flowNodeTitle(meta))}
${esc(flowNodeSummary(node))}
@@ -131,6 +136,7 @@ function renderFlowEditor() {
`; }).join(''); $('#flowEmptyHint').hidden = draft.nodes.length > 0; + const selectionCount = $('#flowSelectionCount'); if (selectionCount) selectionCount.textContent = (app.flowSelectedNodeIds || []).length ? tr('flow.selectedCount', { count:(app.flowSelectedNodeIds || []).length }) : ''; renderFlowEdges(); renderFlowInspector(); renderFlowInterpretation(); const status = $('#flowCompileStatus'); status.textContent = tr('flow.compileCount', { schedules: draft.compiled_schedule_ids?.length || 0, automations: draft.compiled_automation_ids?.length || 0 }); @@ -176,6 +182,7 @@ 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 === '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'))}

`; @@ -238,12 +245,12 @@ function addFlowNode(kind) { if (!app.flowDraft || !FLOW_NODE_META[kind]) return; const count = app.flowDraft.nodes.length; const node = { id: newFlowId('node'), kind, x: 80 + (count % 5) * 190, y: 70 + Math.floor(count / 5) * 130, config: flowDefaultConfig(kind) }; - app.flowDraft.nodes.push(node); app.flowSelectedNodeId = node.id; app.flowDirty = true; renderFlowEditor(); + app.flowDraft.nodes.push(node); app.flowSelectedNodeId = node.id; app.flowSelectedNodeIds = [node.id]; app.flowDirty = true; renderFlowEditor(); } function removeFlowNode(id) { if (!app.flowDraft) return; app.flowDraft.nodes = app.flowDraft.nodes.filter(node => node.id !== id); app.flowDraft.edges = app.flowDraft.edges.filter(edge => edge.from !== id && edge.to !== id); - if (app.flowSelectedNodeId === id) app.flowSelectedNodeId = null; if (app.flowConnectFrom === id) app.flowConnectFrom = null; app.flowDirty = true; renderFlowEditor(); + if (app.flowSelectedNodeId === id) app.flowSelectedNodeId = null; app.flowSelectedNodeIds = (app.flowSelectedNodeIds || []).filter(nodeId => nodeId !== id); if (app.flowConnectFrom === id) app.flowConnectFrom = null; app.flowDirty = true; renderFlowEditor(); } function connectFlowNodes(from, to) { if (!app.flowDraft || !from || !to || from === to) return; @@ -296,47 +303,115 @@ async function importFlowFile(file) { } function flowTemplateGraph(key) { - const zone = app.zones[0]?.id || '', device = app.devices[0]?.id || '', group = app.groups[0]?.id || ''; + const zone = app.zones[0]?.id || '', zone2 = app.zones[1]?.id || zone, device = app.devices[0]?.id || '', group = app.groups[0]?.id || ''; const nodes = [], edges = []; - const add = (kind, x, y, config = flowDefaultConfig(kind)) => { const node = { id: newFlowId('node'), kind, x, y, config: { ...config } }; nodes.push(node); return node; }; - const link = (a, b) => edges.push({ id: newFlowId('edge'), from: a.id, to: b.id }); - let titleKey = `flow.template.${key}.title`, descriptionKey = `flow.template.${key}.description`; + const add = (kind, x, y, config = flowDefaultConfig(kind)) => { const node = { id:newFlowId('node'), kind, x, y, config:{ ...config } }; nodes.push(node); return node; }; + const link = (a, b) => edges.push({ id:newFlowId('edge'), from:a.id, to:b.id }); + const links = (items, target) => items.forEach(item => link(item, target)); + const thermostat = (x, y, config = {}) => add('zone_thermostat', x, y, { zone_id:zone, preset:'comfort', setpoint:21, mode:'auto', cooldown_seconds:90, ...config }); + const groupOrZone = (x, y, config = {}) => group + ? add('group_action', x, y, { group_id:group, power:null, mode:'auto', preset:'comfort', setpoint:21, cooldown_seconds:120, ...config }) + : thermostat(x, y, config); + const titleKey = `flow.template.${key}.title`, descriptionKey = `flow.template.${key}.description`; + if (key === 'workday_comfort') { - const days=add('weekday',50,80), time=add('time_range',250,80,{start:'06:00',end:'08:30'}), action=add('zone_thermostat',500,80,{zone_id:zone,preset:'comfort',setpoint:21,mode:'auto',cooldown_seconds:60}); link(days,time); link(time,action); + const days=add('weekday',40,70), time=add('time_range',250,70,{start:'06:00',end:'08:30'}), action=thermostat(500,70); link(days,time); link(time,action); } else if (key === 'weather_comfort') { - const days=add('weekday',40,50), time=add('time_range',40,170,{start:'06:00',end:'22:30'}), outside=add('outdoor_temperature',40,290,{operator:'lt',value:12}), and=add('logic_and',290,160), action=add('zone_thermostat',520,160,{zone_id:zone,preset:'comfort',setpoint:21,mode:'auto',cooldown_seconds:60}); link(days,and); link(time,and); link(outside,and); link(and,action); + const days=add('weekday',35,40), time=add('time_range',35,155,{start:'06:00',end:'22:30'}), outside=add('outdoor_temperature',35,270,{operator:'lt',value:12}), and=add('logic_and',300,155), action=thermostat(555,155); links([days,time,outside],and); link(and,action); } else if (key === 'smart_demand') { - const time=add('time_range',35,65,{start:'05:30',end:'23:00'}), outside=add('outdoor_temperature',35,185,{operator:'lt',value:10}), room=add('zone_temperature',35,305,{zone_id:zone,operator:'lt',value:20}), or=add('logic_or',280,245), mode=add('house_mode',280,80,{operator:'neq',value:'off'}), and=add('logic_and',500,165), action=add('zone_thermostat',735,165,{zone_id:zone,preset:'comfort',setpoint:21,mode:'auto',cooldown_seconds:90}); link(outside,or); link(room,or); link(time,and); link(mode,and); link(or,and); link(and,action); - } else if (key === 'device_resilience') { - const online=add('device_state',45,70,{device_id:device,field:'online',operator:'eq',value:'true'}), enabled=add('device_state',45,190,{device_id:device,field:'enabled',operator:'eq',value:'true'}), mode=add('house_mode',45,310,{operator:'neq',value:'off'}), and=add('logic_and',315,190), action=add('zone_thermostat',570,190,{zone_id:zone,preset:'auto',setpoint:21,mode:'auto',cooldown_seconds:60}); link(online,and); link(enabled,and); link(mode,and); link(and,action); - } else if (key === 'night_group') { - const time=add('time_range',60,120,{start:'22:30',end:'06:00'}), mode=add('house_mode',60,250,{operator:'neq',value:'off'}), and=add('logic_and',310,180), action=group ? add('group_action',560,180,{group_id:group,power:true,mode:'auto',preset:'sleep',cooldown_seconds:120}) : add('zone_thermostat',560,180,{zone_id:zone,preset:'sleep',setpoint:20,mode:'auto',cooldown_seconds:120}); link(time,and); link(mode,and); link(and,action); - } else if (key === 'ha_window_guard') { - const open=add('ha_state',45,120,{entity_id:'binary_sensor.window',operator:'eq',value:'on'}), mode=add('house_mode',45,250,{operator:'neq',value:'off'}), and=add('logic_and',300,180), action=add('zone_thermostat',555,180,{zone_id:zone,preset:'auto',setpoint:21,mode:'auto',power:false,cooldown_seconds:60}); link(open,and); link(mode,and); link(and,action); + const time=add('time_range',30,40,{start:'05:30',end:'23:00'}), outside=add('outdoor_temperature',30,160,{operator:'lt',value:10}), room=add('zone_temperature',30,280,{zone_id:zone,operator:'lt',value:20}), or=add('logic_or',280,220), mode=add('house_mode',280,70,{operator:'neq',value:'off'}), and=add('logic_and',510,155), action=thermostat(760,155); links([outside,room],or); links([time,mode,or],and); link(and,action); + } else if (key === 'morning_boost') { + const days=add('weekday',35,50), time=add('time_range',35,170,{start:'05:30',end:'07:30'}), cold=add('outdoor_temperature',35,290,{operator:'lt',value:5}), heat=add('house_mode',35,410,{operator:'eq',value:'heat'}), and=add('logic_and',320,220), action=thermostat(585,220,{preset:'custom',setpoint:22.5,mode:'heat',cooldown_seconds:180}); links([days,time,cold,heat],and); link(and,action); + } else if (key === 'weekend_comfort') { + const days=add('weekday',40,90,{days:[6,7]}), time=add('time_range',250,90,{start:'08:00',end:'23:00'}), mode=add('house_mode',40,220,{operator:'neq',value:'off'}), and=add('logic_and',465,145), action=thermostat(710,145); links([days,time,mode],and); link(and,action); } else if (key === 'presence_eco') { - const home=add('ha_state',45,80,{entity_id:'person.someone',operator:'eq',value:'home'}), away=add('ha_state',45,250,{entity_id:'person.someone',operator:'eq',value:'not_home'}), comfort=add('zone_thermostat',355,70,{zone_id:zone,preset:'comfort',setpoint:21,mode:'auto',cooldown_seconds:120}), eco=add('zone_thermostat',355,250,{zone_id:zone,preset:'away',setpoint:18,mode:'auto',cooldown_seconds:120}); link(home,comfort); link(away,eco); + const home=add('ha_state',45,75,{entity_id:'person.someone',operator:'eq',value:'home'}), away=add('ha_state',45,245,{entity_id:'person.someone',operator:'eq',value:'not_home'}), comfort=thermostat(360,65), eco=thermostat(360,245,{preset:'away',setpoint:18,cooldown_seconds:180}); link(home,comfort); link(away,eco); + } else if (key === 'energy_price_eco') { + const price=add('ha_numeric',40,70,{entity_id:'sensor.energy_price',operator:'gt',value:0.8}), occupied=add('ha_state',40,190,{entity_id:'person.someone',operator:'neq',value:'home'}), and=add('logic_and',315,130), action=thermostat(570,130,{preset:'away',setpoint:18,cooldown_seconds:300}); links([price,occupied],and); link(and,action); + } else if (key === 'peak_power_guard') { + const power=add('ha_numeric',40,75,{entity_id:'sensor.house_power',operator:'gt',value:5000}), mode=add('house_mode',40,200,{operator:'neq',value:'off'}), and=add('logic_and',315,140), action=thermostat(570,140,{power:false,preset:'auto',cooldown_seconds:180}); links([power,mode],and); link(and,action); + } else if (key === 'mild_weather_eco') { + const low=add('outdoor_temperature',35,55,{operator:'gte',value:17}), high=add('outdoor_temperature',35,175,{operator:'lte',value:24}), window=add('ha_state',35,295,{entity_id:'binary_sensor.window',operator:'eq',value:'off'}), and=add('logic_and',310,175), action=thermostat(565,175,{power:false,preset:'auto',cooldown_seconds:300}); links([low,high,window],and); link(and,action); + } else if (key === 'unoccupied_shutdown') { + const away=add('ha_state',35,70,{entity_id:'person.someone',operator:'eq',value:'not_home'}), time=add('time_range',35,190,{start:'09:00',end:'16:00'}), and=add('logic_and',310,130), action=group ? add('group_action',565,130,{group_id:group,power:false,mode:'',preset:'',setpoint:21,cooldown_seconds:300}) : thermostat(565,130,{power:false,preset:'away',cooldown_seconds:300}); links([away,time],and); link(and,action); } else if (key === 'frost_guard') { - const heat=add('house_mode',35,55,{operator:'eq',value:'heat'}), outside=add('outdoor_temperature',35,180,{operator:'lt',value:3}), room=add('zone_temperature',35,305,{zone_id:zone,operator:'lt',value:9}), and=add('logic_and',315,180), action=add('zone_thermostat',575,180,{zone_id:zone,preset:'custom',setpoint:12,mode:'heat',cooldown_seconds:300}); link(heat,and); link(outside,and); link(room,and); link(and,action); - } else if (key === 'nested_guard') { - const days=add('weekday',30,40), time=add('time_range',30,150,{start:'06:00',end:'22:30'}), outside=add('outdoor_temperature',30,270,{operator:'lt',value:8}), room=add('zone_temperature',30,390,{zone_id:zone,operator:'lt',value:20}), or=add('logic_or',285,330), available=add('ha_available',285,450,{entity_id:'binary_sensor.window'}), window=add('ha_state',285,560,{entity_id:'binary_sensor.window',operator:'eq',value:'on'}), not=add('logic_not',500,560), and=add('logic_and',520,245), action=add('zone_thermostat',780,245,{zone_id:zone,preset:'comfort',setpoint:21,mode:'auto',cooldown_seconds:90}); link(outside,or); link(room,or); link(window,not); link(days,and); link(time,and); link(or,and); link(available,and); link(not,and); link(and,action); + const heat=add('house_mode',35,50,{operator:'eq',value:'heat'}), outside=add('outdoor_temperature',35,170,{operator:'lt',value:3}), room=add('zone_temperature',35,290,{zone_id:zone,operator:'lt',value:9}), and=add('logic_and',315,170), action=thermostat(575,170,{preset:'custom',setpoint:12,mode:'heat',cooldown_seconds:300}); links([heat,outside,room],and); link(and,action); + } else if (key === 'ha_window_guard') { + const open=add('ha_state',45,90,{entity_id:'binary_sensor.window',operator:'eq',value:'on'}), mode=add('house_mode',45,220,{operator:'neq',value:'off'}), and=add('logic_and',305,155), action=thermostat(560,155,{power:false,preset:'auto',cooldown_seconds:60}); links([open,mode],and); link(and,action); + } else if (key === 'humidity_guard') { + const humidity=add('ha_numeric',40,60,{entity_id:'sensor.living_room_humidity',operator:'gt',value:70}), cool=add('house_mode',40,180,{operator:'eq',value:'cool'}), occupied=add('ha_state',40,300,{entity_id:'person.someone',operator:'eq',value:'home'}), and=add('logic_and',315,180), action=thermostat(575,180,{preset:'custom',setpoint:22,mode:'cool',cooldown_seconds:180}); links([humidity,cool,occupied],and); link(and,action); + } else if (key === 'overheat_guard') { + const room=add('zone_temperature',35,65,{zone_id:zone,operator:'gt',value:28}), cool=add('house_mode',35,185,{operator:'eq',value:'cool'}), window=add('ha_state',35,305,{entity_id:'binary_sensor.window',operator:'eq',value:'off'}), and=add('logic_and',310,185), action=thermostat(570,185,{preset:'custom',setpoint:22,mode:'cool',cooldown_seconds:120}); links([room,cool,window],and); link(and,action); + } else if (key === 'window_available_guard') { + const available=add('ha_available',35,80,{entity_id:'binary_sensor.window'}), open=add('ha_state',35,210,{entity_id:'binary_sensor.window',operator:'eq',value:'on'}), and=add('logic_and',310,145), action=thermostat(565,145,{power:false,preset:'auto',cooldown_seconds:60}); links([available,open],and); link(and,action); + } else if (key === 'night_group') { + const time=add('time_range',50,100,{start:'22:30',end:'06:00'}), mode=add('house_mode',50,230,{operator:'neq',value:'off'}), and=add('logic_and',315,165), action=groupOrZone(570,165,{power:true,preset:'sleep',setpoint:20,cooldown_seconds:120}); links([time,mode],and); link(and,action); } else if (key === 'night_quiet') { - const night=add('night_mode',50,100), and=add('logic_and',315,165), action=group ? add('group_action',565,165,{group_id:group,power:null,mode:'auto',preset:'sleep',cooldown_seconds:180}) : add('zone_thermostat',565,165,{zone_id:zone,preset:'sleep',setpoint:20,mode:'auto',cooldown_seconds:180}); link(night,and); if (group) { const groupOn=add('group_state',50,235,{group_id:group,field:'power_enabled',operator:'eq',value:'true'}); link(groupOn,and); } link(and,action); + const night=add('night_mode',45,90), and=add('logic_and',310,155), action=groupOrZone(565,155,{preset:'sleep',setpoint:20,cooldown_seconds:180}); link(night,and); if (group) { const on=add('group_state',45,225,{group_id:group,field:'power_enabled',operator:'eq',value:'true'}); link(on,and); } link(and,action); + } else if (key === 'sleep_temperature_guard') { + const night=add('night_mode',35,55), room=add('zone_temperature',35,175,{zone_id:zone,operator:'gt',value:23.5}), cool=add('house_mode',35,295,{operator:'eq',value:'cool'}), and=add('logic_and',310,175), action=thermostat(570,175,{preset:'sleep',setpoint:20,mode:'cool',cooldown_seconds:180}); links([night,room,cool],and); link(and,action); + } else if (key === 'bedroom_window_night') { + const night=add('night_mode',35,65), open=add('ha_state',35,185,{entity_id:'binary_sensor.bedroom_window',operator:'eq',value:'on'}), not=add('logic_not',275,185), and=add('logic_and',500,125), action=thermostat(745,125,{preset:'sleep',setpoint:20,cooldown_seconds:180}); link(open,not); links([night,not],and); link(and,action); + } else if (key === 'device_resilience') { + const online=add('device_state',40,55,{device_id:device,field:'online',operator:'eq',value:'true'}), enabled=add('device_state',40,175,{device_id:device,field:'enabled',operator:'eq',value:'true'}), mode=add('house_mode',40,295,{operator:'neq',value:'off'}), and=add('logic_and',315,175), action=thermostat(570,175,{preset:'auto'}); links([online,enabled,mode],and); link(and,action); + } else if (key === 'sensor_availability_guard') { + const available=add('ha_available',35,55,{entity_id:'sensor.room_temperature'}), temp=add('ha_numeric',35,175,{entity_id:'sensor.room_temperature',operator:'gt',value:26}), cool=add('house_mode',35,295,{operator:'eq',value:'cool'}), and=add('logic_and',310,175), action=thermostat(570,175,{preset:'comfort',mode:'cool',cooldown_seconds:120}); links([available,temp,cool],and); link(and,action); + } else if (key === 'offline_safe_off') { + const offline=add('device_state',35,85,{device_id:device,field:'online',operator:'eq',value:'false'}), enabled=add('zone_state',35,215,{zone_id:zone,field:'enabled',operator:'eq',value:'true'}), and=add('logic_and',310,150), action=thermostat(565,150,{power:false,preset:'auto',cooldown_seconds:300}); links([offline,enabled],and); link(and,action); + } else if (key === 'thermostat_enabled_guard') { + const enabled=add('zone_state',35,55,{zone_id:zone,field:'enabled',operator:'eq',value:'true'}), demand=add('zone_state',35,175,{zone_id:zone,field:'demand',operator:'eq',value:'true'}), mode=add('house_mode',35,295,{operator:'neq',value:'off'}), and=add('logic_and',310,175), action=thermostat(570,175,{preset:'auto',cooldown_seconds:120}); links([enabled,demand,mode],and); link(and,action); + } else if (key === 'nested_guard') { + const days=add('weekday',25,35), time=add('time_range',25,145,{start:'06:00',end:'22:30'}), outside=add('outdoor_temperature',25,255,{operator:'lt',value:8}), room=add('zone_temperature',25,365,{zone_id:zone,operator:'lt',value:20}), or=add('logic_or',275,310), available=add('ha_available',275,430,{entity_id:'binary_sensor.window'}), window=add('ha_state',275,540,{entity_id:'binary_sensor.window',operator:'eq',value:'on'}), not=add('logic_not',495,540), and=add('logic_and',510,235), action=thermostat(770,235); links([outside,room],or); link(window,not); links([days,time,or,available,not],and); link(and,action); + } else if (key === 'occupancy_weather_matrix') { + const home=add('ha_state',25,40,{entity_id:'person.someone',operator:'eq',value:'home'}), outside=add('outdoor_temperature',25,155,{operator:'lt',value:9}), room=add('zone_temperature',25,270,{zone_id:zone,operator:'lt',value:20}), need=add('logic_or',275,215), window=add('ha_state',275,345,{entity_id:'binary_sensor.window',operator:'eq',value:'on'}), not=add('logic_not',495,345), and=add('logic_and',505,150), action=thermostat(765,150); links([outside,room],need); link(window,not); links([home,need,not],and); link(and,action); + } else if (key === 'dual_threshold_control') { + const cold=add('zone_temperature',35,70,{zone_id:zone,operator:'lt',value:19}), hot=add('zone_temperature',35,245,{zone_id:zone,operator:'gt',value:25}), heat=add('house_mode',250,70,{operator:'eq',value:'heat'}), cool=add('house_mode',250,245,{operator:'eq',value:'cool'}), andHeat=add('logic_and',470,70), andCool=add('logic_and',470,245), heatAction=thermostat(715,70,{preset:'comfort',mode:'heat'}), coolAction=thermostat(715,245,{preset:'comfort',mode:'cool'}); links([cold,heat],andHeat); links([hot,cool],andCool); link(andHeat,heatAction); link(andCool,coolAction); + } else if (key === 'multi_room_group_guard') { + const roomA=add('zone_temperature',30,55,{zone_id:zone,operator:'gt',value:25}), roomB=add('zone_temperature',30,175,{zone_id:zone2,operator:'gt',value:25}), need=add('logic_or',280,115), cool=add('house_mode',280,245,{operator:'eq',value:'cool'}), and=add('logic_and',505,175), action=groupOrZone(760,175,{power:true,preset:'comfort',setpoint:22,cooldown_seconds:120}); links([roomA,roomB],need); links([need,cool],and); link(and,action); + } else if (key === 'ha_attribute_mode_guard') { + const running=add('ha_attribute',35,60,{entity_id:'climate.living_room',attribute:'hvac_action',operator:'neq',value:'idle'}), window=add('ha_state',35,180,{entity_id:'binary_sensor.window',operator:'eq',value:'on'}), available=add('ha_available',35,300,{entity_id:'climate.living_room'}), and=add('logic_and',315,180), action=thermostat(575,180,{power:false,preset:'auto',cooldown_seconds:60}); links([running,window,available],and); link(and,action); + } else if (key === 'ha_gas_heating_off') { + const available=add('ha_available',35,70,{entity_id:'climate.gas_boiler'}), heating=add('ha_attribute',35,200,{entity_id:'climate.gas_boiler',attribute:'hvac_action',operator:'eq',value:'heating'}), and=add('logic_and',315,135), action=thermostat(575,135,{power:false,preset:'auto',cooldown_seconds:120}); links([available,heating],and); link(and,action); + } else if (key === 'ha_gas_heating_boost') { + const available=add('ha_available',25,35,{entity_id:'climate.gas_boiler'}), heating=add('ha_attribute',25,145,{entity_id:'climate.gas_boiler',attribute:'hvac_action',operator:'eq',value:'heating'}), cold=add('zone_temperature',25,255,{zone_id:zone,operator:'lt',value:20}), heat=add('house_mode',25,365,{operator:'eq',value:'heat'}), and=add('logic_and',320,200), action=thermostat(590,200,{power:true,preset:'custom',setpoint:23.5,mode:'heat',cooldown_seconds:180}); links([available,heating,cold,heat],and); link(and,action); + } else if (key === 'ha_gas_heating_reduce') { + const available=add('ha_available',35,50,{entity_id:'climate.gas_boiler'}), heating=add('ha_attribute',35,165,{entity_id:'climate.gas_boiler',attribute:'hvac_action',operator:'eq',value:'heating'}), warm=add('zone_temperature',35,280,{zone_id:zone,operator:'gte',value:21}), and=add('logic_and',315,165), action=thermostat(575,165,{power:true,preset:'custom',setpoint:18,mode:'heat',cooldown_seconds:180}); links([available,heating,warm],and); link(and,action); + } else if (key === 'ha_gas_backup_heat') { + const available=add('ha_available',25,40,{entity_id:'climate.gas_boiler'}), idle=add('ha_attribute',25,155,{entity_id:'climate.gas_boiler',attribute:'hvac_action',operator:'neq',value:'heating'}), cold=add('zone_temperature',25,270,{zone_id:zone,operator:'lt',value:19}), heat=add('house_mode',25,385,{operator:'eq',value:'heat'}), and=add('logic_and',320,210), action=thermostat(590,210,{power:true,preset:'custom',setpoint:22,mode:'heat',cooldown_seconds:180}); links([available,idle,cold,heat],and); link(and,action); + } else if (key === 'ha_external_heat_source_off') { + const available=add('ha_available',35,70,{entity_id:'binary_sensor.external_heat_source'}), active=add('ha_state',35,200,{entity_id:'binary_sensor.external_heat_source',operator:'eq',value:'on'}), and=add('logic_and',315,135), action=groupOrZone(575,135,{power:false,preset:'auto',cooldown_seconds:120}); links([available,active],and); link(and,action); + } else if (key === 'ha_external_heat_source_assist') { + const available=add('ha_available',25,40,{entity_id:'binary_sensor.external_heat_source'}), active=add('ha_state',25,155,{entity_id:'binary_sensor.external_heat_source',operator:'eq',value:'on'}), cold=add('zone_temperature',25,270,{zone_id:zone,operator:'lt',value:20}), heat=add('house_mode',25,385,{operator:'eq',value:'heat'}), and=add('logic_and',320,210), action=thermostat(590,210,{power:true,preset:'custom',setpoint:23,mode:'heat',cooldown_seconds:180}); links([available,active,cold,heat],and); link(and,action); + } else if (key === 'ha_heating_demand_follow') { + const available=add('ha_available',35,60,{entity_id:'binary_sensor.heating_demand'}), demand=add('ha_state',35,180,{entity_id:'binary_sensor.heating_demand',operator:'eq',value:'on'}), heat=add('house_mode',35,300,{operator:'eq',value:'heat'}), and=add('logic_and',315,180), action=thermostat(575,180,{power:true,preset:'custom',setpoint:22.5,mode:'heat',cooldown_seconds:120}); links([available,demand,heat],and); link(and,action); + } else if (key === 'ha_boiler_supply_boost') { + const available=add('ha_available',25,45,{entity_id:'sensor.boiler_supply_temperature'}), supply=add('ha_numeric',25,160,{entity_id:'sensor.boiler_supply_temperature',operator:'gt',value:45}), cold=add('zone_temperature',25,275,{zone_id:zone,operator:'lt',value:20}), heat=add('house_mode',25,390,{operator:'eq',value:'heat'}), and=add('logic_and',320,215), action=thermostat(590,215,{power:true,preset:'custom',setpoint:23.5,mode:'heat',cooldown_seconds:180}); links([available,supply,cold,heat],and); link(and,action); + } else if (key === 'ha_thermostat_idle_fallback') { + const available=add('ha_available',25,40,{entity_id:'climate.gas_boiler'}), idle=add('ha_attribute',25,155,{entity_id:'climate.gas_boiler',attribute:'hvac_action',operator:'eq',value:'idle'}), cold=add('zone_temperature',25,270,{zone_id:zone,operator:'lt',value:19}), heat=add('house_mode',25,385,{operator:'eq',value:'heat'}), and=add('logic_and',320,210), action=thermostat(590,210,{power:true,preset:'custom',setpoint:21.5,mode:'heat',cooldown_seconds:240}); links([available,idle,cold,heat],and); link(and,action); } return { titleKey, descriptionKey, nodes, edges }; } -const FLOW_TEMPLATE_KEYS = ['workday_comfort','weather_comfort','smart_demand','device_resilience','night_group','ha_window_guard','presence_eco','frost_guard','nested_guard','night_quiet']; +const FLOW_TEMPLATE_CATEGORIES = [ + ['comfort', ['workday_comfort','weather_comfort','smart_demand','morning_boost','weekend_comfort']], + ['energy', ['presence_eco','energy_price_eco','peak_power_guard','mild_weather_eco','unoccupied_shutdown']], + ['safety', ['frost_guard','ha_window_guard','humidity_guard','overheat_guard','window_available_guard']], + ['night', ['night_group','night_quiet','sleep_temperature_guard','bedroom_window_night']], + ['reliability', ['device_resilience','sensor_availability_guard','offline_safe_off','thermostat_enabled_guard']], + ['home_assistant', ['ha_gas_heating_off','ha_gas_heating_boost','ha_gas_heating_reduce','ha_gas_backup_heat','ha_external_heat_source_off','ha_external_heat_source_assist','ha_heating_demand_follow','ha_boiler_supply_boost','ha_thermostat_idle_fallback']], + ['advanced', ['nested_guard','occupancy_weather_matrix','dual_threshold_control','multi_room_group_guard','ha_attribute_mode_guard']], +]; function openFlowTemplates() { if (!app.flowDraft) return; const host = $('#flowTemplateList'); - host.innerHTML = FLOW_TEMPLATE_KEYS.map(key => ``).join(''); + host.innerHTML = FLOW_TEMPLATE_CATEGORIES.map(([category, keys]) => `
${esc(tr(`flow.templateCategory.${category}`))}${esc(tr(`flow.templateCategory.${category}.hint`))}
${keys.map(key => ``).join('')}
`).join(''); $('#flowTemplateDialog')?.showModal(); } function applyFlowTemplate(key) { if (!app.flowDraft) return; if (app.flowDraft.nodes.length && !confirm(tr('flow.templateReplaceConfirm'))) return; const template = flowTemplateGraph(key); app.flowDraft.nodes = template.nodes; app.flowDraft.edges = template.edges; - app.flowDraft.description = tr(template.descriptionKey); app.flowSelectedNodeId = null; app.flowDirty = true; + app.flowDraft.description = tr(template.descriptionKey); app.flowSelectedNodeId = null; app.flowSelectedNodeIds = []; app.flowDirty = true; if (!app.flowDraft.id || $('#flowName').value === tr('flow.newDefaultName')) $('#flowName').value = tr(template.titleKey); $('#flowTemplateDialog')?.close(); renderFlowEditor(); } @@ -346,13 +421,14 @@ function localDateTimeInputValue(date = new Date()) { return `${date.getFullYear()}-${pad(date.getMonth()+1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`; } function flowSimulationOverrideNodes() { - return (app.flowDraft?.nodes || []).filter(node => ['outdoor_temperature','device_temperature','zone_temperature','ha_state','ha_numeric','ha_attribute','ha_available','house_mode','device_state','zone_state','group_state','night_mode'].includes(node.kind)); + return (app.flowDraft?.nodes || []).filter(node => ['outdoor_temperature','device_temperature','zone_temperature','ha_state','ha_numeric','ha_attribute','ha_available','house_mode','device_state','zone_state','group_state','night_mode','shared_input'].includes(node.kind)); } function renderFlowSimulationOverrides() { const host = $('#flowSimulationOverrides'); if (!host) return; const nodes = flowSimulationOverrideNodes(); host.innerHTML = nodes.length ? nodes.map(node => { - const numeric = ['outdoor_temperature','device_temperature','zone_temperature','ha_numeric'].includes(node.kind); + const effectiveKind = node.kind === 'shared_input' ? (app.flowSharedInputs || []).find(item => item.id === node.config?.input_id)?.kind : node.kind; + const numeric = ['outdoor_temperature','device_temperature','zone_temperature','ha_numeric'].includes(effectiveKind); return ``; }).join('') : `

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

`; } @@ -361,7 +437,8 @@ function collectFlowSimulationOverrides() { $$('[data-flow-sim-node]', $('#flowSimulationOverrides')).forEach(input => { if (input.value.trim() === '') return; const node = flowNodeById(input.dataset.flowSimNode); let value = input.value.trim(); - if (node && ['outdoor_temperature','device_temperature','zone_temperature','ha_numeric'].includes(node.kind)) value = Number(value); + const effectiveKind = node?.kind === 'shared_input' ? (app.flowSharedInputs || []).find(item => item.id === node.config?.input_id)?.kind : node?.kind; + if (node && ['outdoor_temperature','device_temperature','zone_temperature','ha_numeric'].includes(effectiveKind)) value = Number(value); else if (/^(true|false)$/i.test(value)) value = value.toLowerCase() === 'true'; result[input.dataset.flowSimNode] = value; }); @@ -414,6 +491,43 @@ async function deleteFlow(id) { try { await api(`/api/flows/${encodeURIComponent(id)}`, { method: 'DELETE' }); app.flows = app.flows.filter(item => item.id !== id); renderFlows(); await loadBootstrap(); toast(tr('flow.deleted')); } catch (error) { toast(error.message, true); } } +async function toggleFlowEnabled(id, enabled) { + const flow = app.flows.find(item => item.id === id); if (!flow) return; + const toggle = $(`[data-flow-toggle="${CSS.escape(id)}"]`); if (toggle?.disabled) return; + if (toggle) toggle.disabled = true; + const body = { name:flow.name, enabled, description:flow.description || '', nodes:flow.nodes || [], edges:flow.edges || [], expected_revision:Number(flow.revision || 0) }; + try { + const saved = await api(`/api/flows/${encodeURIComponent(id)}`, { method:'PUT', body }); + const index = app.flows.findIndex(item => item.id === id); if (index >= 0) app.flows[index] = saved; + renderFlows(); await loadBootstrap(); toast(enabled ? tr('flow.quickEnabled') : tr('flow.quickDisabled')); + } catch (error) { renderFlows(); toast(error.message, true); } +} + +function setFlowSelection(ids, primary = null) { + if (!app.flowDraft) return; + const valid = new Set(app.flowDraft.nodes.map(node => node.id)); + app.flowSelectedNodeIds = [...new Set((ids || []).filter(id => valid.has(id)))]; + app.flowSelectedNodeId = primary && valid.has(primary) ? primary : (app.flowSelectedNodeIds[app.flowSelectedNodeIds.length - 1] || null); + renderFlowEditor(); +} + +function selectAllFlowNodes() { + if (!app.flowDraft) return; + const lastNode = app.flowDraft.nodes[app.flowDraft.nodes.length - 1]; + setFlowSelection(app.flowDraft.nodes.map(node => node.id), app.flowSelectedNodeId || lastNode?.id || null); +} + +function clearFlowSelection() { setFlowSelection([], null); } + +function removeSelectedFlowNodes() { + if (!app.flowDraft || !(app.flowSelectedNodeIds || []).length) return; + const selected = new Set(app.flowSelectedNodeIds); + app.flowDraft.nodes = app.flowDraft.nodes.filter(node => !selected.has(node.id)); + app.flowDraft.edges = app.flowDraft.edges.filter(edge => !selected.has(edge.from) && !selected.has(edge.to)); + if (app.flowConnectFrom && selected.has(app.flowConnectFrom)) app.flowConnectFrom = null; + app.flowSelectedNodeIds = []; app.flowSelectedNodeId = null; app.flowDirty = true; renderFlowEditor(); +} + function updateFlowConfig(input) { const node = flowNodeById(app.flowSelectedNodeId); if (!node) return; const key = input.dataset.flowConfig; if (!key) return; @@ -427,21 +541,47 @@ function updateFlowConfig(input) { let flowDrag = null; document.addEventListener('pointerdown', event => { + if ($('#flowEditor')?.hidden) return; const nodeEl = event.target.closest?.('[data-flow-node]'); - if (!nodeEl || event.target.closest('button,input,select,label')) return; + if (!nodeEl) { + if (event.target.closest?.('#flowWorkspace') && !event.target.closest('button,input,select,label')) clearFlowSelection(); + return; + } + if (event.target.closest('button,input,select,label')) return; const node = flowNodeById(nodeEl.dataset.flowNode); if (!node) return; - app.flowSelectedNodeId = node.id; renderFlowInspector(); - flowDrag = { id: node.id, x: event.clientX, y: event.clientY, left: Number(node.x || 0), top: Number(node.y || 0) }; + const multiModifier = event.shiftKey || event.ctrlKey || event.metaKey; + let selected = [...(app.flowSelectedNodeIds || [])]; + if (multiModifier) { + selected = selected.includes(node.id) ? selected.filter(id => id !== node.id) : [...selected, node.id]; + if (!selected.includes(node.id)) { setFlowSelection(selected, selected[selected.length - 1] || null); event.preventDefault(); return; } + } else if (!selected.includes(node.id)) selected = [node.id]; + app.flowSelectedNodeIds = selected; app.flowSelectedNodeId = node.id; renderFlowEditor(); + const starts = selected.map(id => flowNodeById(id)).filter(Boolean).map(item => ({ id:item.id, left:Number(item.x || 0), top:Number(item.y || 0) })); + flowDrag = { x:event.clientX, y:event.clientY, starts }; nodeEl.setPointerCapture?.(event.pointerId); event.preventDefault(); }); document.addEventListener('pointermove', event => { if (!flowDrag || !app.flowDraft) return; - const node = flowNodeById(flowDrag.id); if (!node) return; - node.x = Math.max(12, flowDrag.left + event.clientX - flowDrag.x); node.y = Math.max(12, flowDrag.top + event.clientY - flowDrag.y); app.flowDirty = true; - const el = $(`[data-flow-node="${CSS.escape(node.id)}"]`); if (el) { el.style.left = `${node.x}px`; el.style.top = `${node.y}px`; renderFlowEdges(); } + let dx = event.clientX - flowDrag.x, dy = event.clientY - flowDrag.y; + const minLeft = Math.min(...flowDrag.starts.map(item => item.left)); + const minTop = Math.min(...flowDrag.starts.map(item => item.top)); + dx = Math.max(dx, 12 - minLeft); dy = Math.max(dy, 12 - minTop); + flowDrag.starts.forEach(start => { + const node = flowNodeById(start.id); if (!node) return; + node.x = start.left + dx; node.y = start.top + dy; + const el = $(`[data-flow-node="${CSS.escape(node.id)}"]`); if (el) { el.style.left = `${node.x}px`; el.style.top = `${node.y}px`; } + }); + app.flowDirty = true; renderFlowEdges(); }); document.addEventListener('pointerup', () => { flowDrag = null; }); +document.addEventListener('keydown', event => { + if ($('#flowEditor')?.hidden || !app.flowDraft) return; + if (event.target.closest?.('input,select,textarea,[contenteditable="true"]')) return; + if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'a') { event.preventDefault(); selectAllFlowNodes(); return; } + if ((event.key === 'Delete' || event.key === 'Backspace') && (app.flowSelectedNodeIds || []).length) { event.preventDefault(); removeSelectedFlowNodes(); } +}); + document.addEventListener('click', event => { const edge = event.target.closest?.('[data-flow-edge]'); if (edge) { removeFlowEdge(edge.dataset.flowEdge); return; } const add = event.target.closest?.('[data-flow-add]'); if (add) { addFlowNode(add.dataset.flowAdd); return; } @@ -449,11 +589,11 @@ document.addEventListener('click', event => { const remove = event.target.closest?.('[data-flow-remove]'); if (remove) { removeFlowNode(remove.dataset.flowRemove); return; } const output = event.target.closest?.('[data-flow-output]'); if (output) { app.flowConnectFrom = output.dataset.flowOutput; renderFlowEditor(); return; } const input = event.target.closest?.('[data-flow-input]'); if (input) { if (app.flowConnectFrom) connectFlowNodes(app.flowConnectFrom, input.dataset.flowInput); return; } - const node = event.target.closest?.('[data-flow-node]'); if (node) { app.flowSelectedNodeId = node.dataset.flowNode; renderFlowEditor(); return; } - const action = event.target.closest?.('[data-action]')?.dataset.action; + const node = event.target.closest?.('[data-flow-node]'); if (node) { if (!(app.flowSelectedNodeIds || []).includes(node.dataset.flowNode)) app.flowSelectedNodeIds = [node.dataset.flowNode]; app.flowSelectedNodeId = node.dataset.flowNode; renderFlowEditor(); return; } + const actionButton = event.target.closest?.('[data-action]'); const action = actionButton?.dataset.action; if (action === 'new-flow') openFlowEditor(); - else if (action === 'edit-flow') openFlowEditor(event.target.closest('[data-action]').dataset.id); - else if (action === 'delete-flow') deleteFlow(event.target.closest('[data-action]').dataset.id); + else if (action === 'edit-flow') openFlowEditor(actionButton.dataset.id); + else if (action === 'delete-flow') deleteFlow(actionButton.dataset.id); else if (action === 'save-flow') saveFlow(); else if (action === 'import-flow') $('#flowImportFile')?.click(); else if (action === 'export-flow') exportFlow(); @@ -461,9 +601,14 @@ document.addEventListener('click', event => { else if (action === 'flow-dry-run') openFlowDryRun(); else if (action === 'run-flow-dry-run') runFlowDryRun(); else if (action === 'flow-logs') openFlowLogs(); + else if (action === 'flow-select-all') selectAllFlowNodes(); + else if (action === 'flow-clear-selection') clearFlowSelection(); else if (action === 'close-flow-editor') closeFlowEditor(); }); -document.addEventListener('change', event => { if (event.target.matches?.('[data-flow-config]')) updateFlowConfig(event.target); }); +document.addEventListener('change', event => { + if (event.target.matches?.('[data-flow-config]')) updateFlowConfig(event.target); + const toggle = event.target.closest?.('[data-flow-toggle]'); if (toggle) toggleFlowEnabled(toggle.dataset.flowToggle, toggle.checked); +}); $('#flowImportFile')?.addEventListener('change', event => importFlowFile(event.target.files?.[0])); $('#flowName')?.addEventListener('input', () => { if (app.flowDraft) app.flowDirty = true; }); $('#flowEnabled')?.addEventListener('change', () => { if (app.flowDraft) app.flowDirty = true; }); diff --git a/web/js/forms.js b/web/js/forms.js index 84d828c..5696114 100644 --- a/web/js/forms.js +++ b/web/js/forms.js @@ -230,6 +230,7 @@ function restoreTrackedForm(form) { else if (form.id === 'nightModeForm') renderNightSettings(); else if (form.id === 'homeAssistantForm') { app.sensorAliases = { ...(app.settings?.home_assistant?.sensor_aliases || {}) }; + app.flowSharedInputs = JSON.parse(JSON.stringify(app.settings?.home_assistant?.flow_inputs || [])); renderHomeAssistantSettings(); } else markFormClean(form); } diff --git a/web/js/realtime.js b/web/js/realtime.js index cebf4a9..79187a6 100644 --- a/web/js/realtime.js +++ b/web/js/realtime.js @@ -24,7 +24,7 @@ function connectWebSocket() { const message = JSON.parse(event.data); if (message.event === 'bootstrap') { const data = message.data; app.devices = data.devices || []; app.zones = data.zones || []; app.groups = data.groups || []; app.schedules = data.schedules || []; app.automations = data.automations || []; - app.flows = data.flows || []; app.settings = data.settings || app.settings; app.sensorAliases = { ...(app.settings?.home_assistant?.sensor_aliases || {}) }; app.system = data.system || app.system; app.systemSnapshotAt = Date.now(); app.outdoorTemperature = Number.isFinite(Number(data.outdoor_temperature)) ? Number(data.outdoor_temperature) : app.outdoorTemperature; renderAll(); scheduleControlPlanLoad(); if (app.settings?.debug?.overlay_enabled) loadDebugBacklog(); return; + app.flows = data.flows || []; app.settings = data.settings || app.settings; app.sensorAliases = { ...(app.settings?.home_assistant?.sensor_aliases || {}) }; app.flowSharedInputs = JSON.parse(JSON.stringify(app.settings?.home_assistant?.flow_inputs || [])); app.system = data.system || app.system; app.systemSnapshotAt = Date.now(); app.outdoorTemperature = Number.isFinite(Number(data.outdoor_temperature)) ? Number(data.outdoor_temperature) : app.outdoorTemperature; renderAll(); scheduleControlPlanLoad(); if (app.settings?.debug?.overlay_enabled) loadDebugBacklog(); return; } const data = message.data || {}; if (['device.updated', 'device.created'].includes(message.event)) { updateDevice(data); renderSummary(); renderDevices(); renderGroups(); scheduleControlPlanLoad(); } @@ -39,7 +39,7 @@ function connectWebSocket() { else if (message.event === 'settings.updated') { const settingsDirty = isFormDirty($('#settingsForm')), nightDirty = isFormDirty($('#nightModeForm')), haDirty = isFormDirty($('#homeAssistantForm')); app.settings = data; - if (!haDirty) app.sensorAliases = { ...(app.settings?.home_assistant?.sensor_aliases || {}) }; + if (!haDirty) { app.sensorAliases = { ...(app.settings?.home_assistant?.sensor_aliases || {}) }; app.flowSharedInputs = JSON.parse(JSON.stringify(app.settings?.home_assistant?.flow_inputs || [])); } if (!settingsDirty) renderSettings(); if (!nightDirty) renderNightSettings(); if (!haDirty) renderHomeAssistantSettings(); diff --git a/web/js/settings-ui.js b/web/js/settings-ui.js index 91db593..7fe2591 100644 --- a/web/js/settings-ui.js +++ b/web/js/settings-ui.js @@ -14,6 +14,7 @@ function knownHaEntities() { ...app.zones.map(zone => zone.ha_entity_id).filter(Boolean), app.settings?.home_assistant?.default_entity_id, app.settings?.home_assistant?.outdoor_entity_id, + ...(app.flowSharedInputs || []).map(item => item?.config?.entity_id).filter(Boolean), ...app.historyData.sensors.map(row => row.entity_id), ].filter(Boolean))].sort(); } @@ -24,6 +25,89 @@ function renderSensorAliases() { host.innerHTML = entities.length ? entities.map(entity => `
${esc(entity)}
`).join('') : `
${esc(tr('settings.noSensorAliases'))}
`; } +function flowSharedInputKinds() { + return [ + ['constant', 'flow.node.constant'], ['ha_state', 'flow.node.haState'], ['ha_numeric', 'flow.node.haNumeric'], + ['ha_attribute', 'flow.node.haAttribute'], ['ha_available', 'flow.node.haAvailable'], + ['outdoor_temperature', 'flow.node.outdoorTemperature'], ['device_temperature', 'flow.node.deviceTemperature'], + ['zone_temperature', 'flow.node.zoneTemperature'], ['house_mode', 'flow.node.houseMode'], + ['device_state', 'flow.node.deviceState'], ['zone_state', 'flow.node.zoneState'], + ['group_state', 'flow.node.groupState'], ['night_mode', 'flow.node.nightMode'], + ]; +} + +function sharedFlowInputSummary(item) { + if (!item) return '—'; + try { return flowNodeSummary({ kind: item.kind, config: item.config || {} }); } catch (_) { return item.kind || '—'; } +} + +function renderFlowSharedInputs() { + const host = $('#flowSharedInputList'); if (!host) return; + const items = app.flowSharedInputs || []; + host.innerHTML = items.length ? items.map(item => `
+
${esc(item.name)}${esc(flowNodeTitle(FLOW_NODE_META[item.kind] || { title:item.kind }))} · ${esc(sharedFlowInputSummary(item))}${esc(item.id)}
+
+
`).join('') : `
${esc(tr('flow.sharedInputsEmpty'))}${esc(tr('flow.sharedInputsEmptyHint'))}
`; + const revision = $('#flowSharedInputsRevision'); + if (revision) revision.value = JSON.stringify(items); +} + +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_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 === 'night_mode') fields = `

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

`; + host.innerHTML = fields; +} + +function openFlowSharedInputEditor(id = '') { + const form = $('#flowSharedInputForm'), dialog = $('#flowSharedInputDialog'); if (!form || !dialog) return; + const item = id ? (app.flowSharedInputs || []).find(value => value.id === id) : null; + const kindSelect = form.kind; + kindSelect.innerHTML = flowSharedInputKinds().map(([kind,key]) => ``).join(''); + form.id.value = item?.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)); + dialog.showModal(); + setTimeout(() => form.name.focus(), 0); +} + +function collectFlowSharedInputConfig(kind) { + const config = {}; + $$('[data-shared-config]', $('#flowSharedInputFields')).forEach(field => { + 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; +} + function renderLogRetention() { const select = $('#logRetentionDays'); if (!select || !app.settings) return; [...select.options].forEach(option => { option.textContent = `${option.value} ${tr('common.days')}`; }); @@ -176,6 +260,7 @@ function renderHomeAssistantSettings() { form.ha_allow_invalid_tls.checked = !!app.settings.home_assistant?.allow_invalid_tls; form.outdoor_assist_enabled.checked = !!app.settings.outdoor_assist_enabled; renderSensorAliases(); + renderFlowSharedInputs(); renderAccessTokens(); markFormClean(form); } diff --git a/web/js/settings.js b/web/js/settings.js index 1992bee..6dfe4e6 100644 --- a/web/js/settings.js +++ b/web/js/settings.js @@ -67,6 +67,7 @@ function currentSettingsBody() { sensor_stale_after_seconds: Number(ha.sensor_stale_after_seconds || 300), allow_invalid_tls: !!ha.allow_invalid_tls, sensor_aliases: { ...(ha.sensor_aliases || {}) }, + flow_inputs: JSON.parse(JSON.stringify(ha.flow_inputs || [])), }, }; } @@ -140,6 +141,7 @@ function homeAssistantSettingsBodyFromForm(form) { sensor_stale_after_seconds: Math.max(60, Math.min(86400, Math.round(Number(raw.ha_sensor_stale_after_minutes || 5) * 60))), allow_invalid_tls: form.ha_allow_invalid_tls.checked, sensor_aliases: { ...(app.sensorAliases || {}) }, + flow_inputs: JSON.parse(JSON.stringify(app.flowSharedInputs || [])), }; return body; } @@ -147,6 +149,7 @@ function homeAssistantSettingsBodyFromForm(form) { async function saveRuntimeSettings(body, notify = true) { app.settings = await api('/api/settings', { method: 'PUT', body }); app.sensorAliases = { ...(app.settings?.home_assistant?.sensor_aliases || {}) }; + app.flowSharedInputs = JSON.parse(JSON.stringify(app.settings?.home_assistant?.flow_inputs || [])); renderSettings(); renderNightSettings(); renderHomeAssistantSettings(); @@ -154,6 +157,7 @@ async function saveRuntimeSettings(body, notify = true) { renderSimulationModeBanner(); renderSystemInfo(); renderDebugOverlay(); + if (app.flowDraft) renderFlowEditor(); scheduleControlPlanLoad(); if (app.settings?.debug?.overlay_enabled) loadDebugBacklog(); if (notify) toast(tr('common.saved')); @@ -291,3 +295,37 @@ document.addEventListener('change', event => { }); + +$('#addFlowSharedInput')?.addEventListener('click', () => openFlowSharedInputEditor()); +$('#flowSharedInputKind')?.addEventListener('change', event => { + renderFlowSharedInputFields(event.target.value, flowDefaultConfig(event.target.value)); +}); +$('#flowSharedInputForm')?.addEventListener('submit', event => { + event.preventDefault(); + const form = event.currentTarget; + const name = form.name.value.trim(), kind = form.kind.value; + if (!name) return toast(tr('flow.sharedInputNameRequired'), true); + const editingId = form.dataset.editingId || ''; + const id = editingId || newFlowId('shared'); + const item = { id, name, kind, config: collectFlowSharedInputConfig(kind) }; + const index = app.flowSharedInputs.findIndex(value => value.id === id); + if (index >= 0) app.flowSharedInputs[index] = item; else app.flowSharedInputs.push(item); + renderFlowSharedInputs(); + updateDirtyIndicator($('#homeAssistantForm')); + $('#flowSharedInputDialog')?.close(); +}); + +document.addEventListener('click', event => { + const edit = event.target.closest?.('[data-flow-shared-edit]'); + if (edit) { openFlowSharedInputEditor(edit.dataset.flowSharedEdit); return; } + const remove = event.target.closest?.('[data-flow-shared-delete]'); + if (!remove) return; + const id = remove.dataset.flowSharedDelete; + const item = app.flowSharedInputs.find(value => value.id === id); if (!item) return; + const uses = app.flows.reduce((count, flow) => count + (flow.nodes || []).filter(node => node.kind === 'shared_input' && node.config?.input_id === id).length, 0); + const message = uses ? tr('flow.sharedInputDeleteUsed', { name:item.name, count:uses }) : tr('flow.sharedInputDeleteConfirm', { name:item.name }); + if (!confirm(message)) return; + app.flowSharedInputs = app.flowSharedInputs.filter(value => value.id !== id); + renderFlowSharedInputs(); + updateDirtyIndicator($('#homeAssistantForm')); +}); diff --git a/web/styles.css b/web/styles.css index 8aaac75..e748d09 100644 --- a/web/styles.css +++ b/web/styles.css @@ -6146,7 +6146,7 @@ body.flow-editor-open { overflow:hidden; } .flow-palette-group > span { color:var(--muted); font-size:.72rem; font-weight:750; text-transform:uppercase; letter-spacing:.08em; margin-bottom:2px; } .flow-palette-group button { text-align:left; border:1px solid var(--line); background:var(--surface-2); color:var(--text); border-radius:10px; padding:9px 10px; } .flow-palette-group button:hover { border-color:var(--accent-border); background:var(--accent-soft); } -.flow-workspace-wrap { min-width:0; min-height:0; display:grid; grid-template-rows:minmax(0,1fr) auto; background:var(--canvas); } +.flow-workspace-wrap { min-width:0; min-height:0; display:grid; grid-template-rows:auto minmax(0,1fr) auto; background:var(--canvas); } .flow-workspace { position:relative; min-height:0; overflow:auto; outline:none; background-color:var(--canvas); background-image:linear-gradient(var(--grid) 1px,transparent 1px),linear-gradient(90deg,var(--grid) 1px,transparent 1px); background-size:24px 24px; } .flow-nodes { position:relative; width:2400px; height:1500px; } .flow-edges { position:absolute; inset:0; width:2400px; height:1500px; overflow:visible; pointer-events:none; z-index:1; } @@ -6226,3 +6226,36 @@ body.flow-editor-open { overflow:hidden; } .flow-device-options { margin:10px 0; padding:10px 12px; border:1px solid var(--line); border-radius:12px; background:var(--surface-2); } .flow-device-options summary { cursor:pointer; font-weight:650; } .flow-device-options[open] summary { margin-bottom:10px; } + +/* Flow list, multi-select and reusable inputs */ +.flow-page-actions { display:flex; align-items:center; gap:10px; } +.flow-list-section { margin-top:22px; padding-top:20px; border-top:1px solid var(--line); } +.flow-list-heading { display:flex; align-items:flex-end; justify-content:space-between; gap:16px; margin-bottom:14px; } +.flow-list-heading h2 { margin:3px 0 0; } +.flow-card.is-disabled { opacity:.72; } +.flow-card.is-disabled:hover { opacity:.9; } +.flow-quick-toggle { display:flex; align-items:center; gap:7px; padding:7px 9px; border:1px solid var(--line); border-radius:999px; background:var(--surface-2); color:var(--muted); font-size:.78rem; cursor:pointer; white-space:nowrap; } +.flow-quick-toggle input { margin:0; } +.flow-workspace-toolbar { min-height:44px; display:flex; align-items:center; gap:7px; padding:6px 10px; border-bottom:1px solid var(--line); background:var(--surface); } +.flow-workspace-toolbar .secondary { padding:7px 9px; font-size:.78rem; } +.flow-workspace-toolbar .badge { margin-left:2px; } +.flow-shared-input-list { display:grid; gap:10px; } +.flow-shared-input-row { display:flex; align-items:center; justify-content:space-between; gap:14px; padding:12px 14px; border:1px solid var(--line); border-radius:12px; background:var(--surface-2); } +.flow-shared-input-row > div:first-child { min-width:0; display:grid; gap:4px; } +.flow-shared-input-row small,.flow-shared-input-row code { color:var(--muted); overflow-wrap:anywhere; } +.flow-shared-input-actions { display:flex; align-items:center; gap:8px; flex-shrink:0; } +.flow-shared-input-fields { display:grid; gap:12px; } +.flow-shared-input-fields .two { display:grid; grid-template-columns:1fr 1fr; gap:10px; } +.flow-template-list { display:grid; grid-template-columns:1fr; gap:22px; } +.flow-template-category { display:grid; gap:10px; } +.flow-template-category + .flow-template-category { padding-top:18px; border-top:1px solid var(--line); } +.flow-template-category-head { display:grid; gap:3px; } +.flow-template-category-head span { color:var(--muted); font-size:.84rem; } +.flow-template-category-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(230px,1fr)); gap:12px; } +@media (max-width:760px) { + .flow-list-heading { align-items:flex-start; } + .flow-shared-input-row { align-items:flex-start; flex-direction:column; } + .flow-shared-input-actions { width:100%; } + .flow-shared-input-actions button { flex:1; } + .flow-shared-input-fields .two { grid-template-columns:1fr; } +}