v0.9.7
This commit is contained in:
Generated
+1
-1
@@ -633,7 +633,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gree-controller"
|
||||
version = "0.9.6"
|
||||
version = "0.9.7"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
+26
-25
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
+6
-6
@@ -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-<stable-unique-id>`, and cannot be edited or deleted through their legacy endpoints; edit the owning Flow instead.
|
||||
|
||||
Condition blocks support weekday, time/date ranges, application Night mode, outdoor/device/zone temperatures, house/device/zone/group state, 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.
|
||||
|
||||
+4
-13
@@ -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-<stable-unique-id>` 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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
+98
-2
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
+98
-2
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
+11
-3
@@ -36,11 +36,16 @@ fn flow_f64(config: &Value, key: &str) -> Option<f64> { config.get(key).and_then
|
||||
fn flow_u8(config: &Value, key: &str) -> Option<u8> { config.get(key).and_then(Value::as_u64).and_then(|value| u8::try_from(value).ok()) }
|
||||
fn flow_bool(config: &Value, key: &str) -> Option<bool> { config.get(key).and_then(Value::as_bool) }
|
||||
|
||||
fn 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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ async fn update_settings(State(state): State<AppState>, Json(mut input): Json<Ru
|
||||
let compressor_settings_changed = input.compressor_protection_enabled != old.compressor_protection_enabled
|
||||
|| input.compressor_protection_seconds != old.compressor_protection_seconds;
|
||||
normalize_sensor_aliases(&mut input);
|
||||
validate_flow_shared_inputs(&mut input, &state)?;
|
||||
canonicalize_home_assistant_entities(&mut input);
|
||||
validate_night_mode(&mut input)?;
|
||||
input.influxdb.history_threshold_days = input.influxdb.history_threshold_days.clamp(1, 3650);
|
||||
@@ -97,6 +98,39 @@ fn normalize_sensor_aliases(settings: &mut RuntimeSettings) {
|
||||
.collect();
|
||||
}
|
||||
|
||||
fn validate_flow_shared_inputs(settings: &mut RuntimeSettings, state: &AppState) -> 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)) {
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -285,9 +285,19 @@ async fn flow_leaf_observation(
|
||||
settings: &crate::models::RuntimeSettings,
|
||||
overrides: &HashMap<String, Value>,
|
||||
) -> 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"
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -96,6 +96,7 @@ mod tests {
|
||||
sensor_stale_after_seconds: 300,
|
||||
allow_invalid_tls: false,
|
||||
sensor_aliases,
|
||||
flow_inputs: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<String, String>,
|
||||
/// Reusable Flow condition inputs shared by any visual Flow.
|
||||
#[serde(default)]
|
||||
pub flow_inputs: Vec<FlowSharedInput>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
|
||||
+51
-58
@@ -180,14 +180,17 @@
|
||||
<div class="section-heading">
|
||||
<div><span class="eyebrow" data-i18n="flow.eyebrow">Visual logic</span>
|
||||
<h1 data-i18n="nav.flows">Flow</h1>
|
||||
</div><div class="section-actions"><button class="secondary" type="button" data-action="import-flow" data-i18n="flow.import">Import Flow</button><button class="primary" type="button" data-action="new-flow" data-i18n="flow.new">New Flow</button></div>
|
||||
</div><div class="section-actions flow-page-actions"><button class="secondary" type="button" data-action="import-flow" data-i18n="flow.import">Import Flow</button><button class="primary" type="button" data-action="new-flow" data-i18n="flow.new">New Flow</button></div>
|
||||
</div>
|
||||
<div class="panel flow-info-panel">
|
||||
<div><strong data-i18n="flow.infoTitle">One place for schedules and automations</strong>
|
||||
<p data-i18n="flow.infoText">Build the logic from blocks. GREE Controller translates Flow into generated schedules and automations. Generated entries are read-only outside this editor.</p></div>
|
||||
<span class="badge" data-i18n="flow.sourceOfTruth">Flow = source of truth</span>
|
||||
</div>
|
||||
<div class="list-grid" id="flowList"></div>
|
||||
<section class="flow-list-section">
|
||||
<div class="flow-list-heading"><div><span class="eyebrow" data-i18n="flow.listEyebrow">Twoje automatyzacje</span><h2 data-i18n="flow.listTitle">Lista Flow</h2></div><span id="flowListCount" class="badge"></span></div>
|
||||
<div class="list-grid" id="flowList"></div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<section class="view" data-view="schedules">
|
||||
@@ -370,67 +373,45 @@
|
||||
tokens for the Home Assistant integration.</p>
|
||||
<form class="settings-form standalone-settings-form" id="homeAssistantForm">
|
||||
<section class="panel settings-block">
|
||||
<div class="settings-block-head">
|
||||
<div>
|
||||
<h3 data-i18n="settings.haSensorInput">Home Assistant sensor input</h3>
|
||||
<p data-i18n="settings.haSensorInputHint">Optional. Used when a room zone reads an external Home Assistant
|
||||
temperature sensor.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-block-head"><div><h3 data-i18n="settings.haConnection">Połączenie z Home Assistant</h3><p data-i18n="settings.haConnectionHint">Adres serwera i dane dostępu używane przez wszystkie funkcje Home Assistant.</p></div></div>
|
||||
<div class="settings-grid">
|
||||
<label class="wide"><span>URL</span><input type="url" name="ha_url"
|
||||
placeholder="http://homeassistant.local:8123"></label>
|
||||
<label class="wide"><span data-i18n="settings.haLongLivedToken">Long-Lived Access Token</span><input
|
||||
type="password" name="ha_token" autocomplete="new-password" data-i18n-placeholder="settings.haTokenKeep"
|
||||
placeholder="Leave empty to keep the saved token"></label>
|
||||
<label class="wide"><span data-i18n="settings.defaultEntity">Default room entity_id</span><input
|
||||
name="ha_entity_id" placeholder="sensor.living_room_temperature"></label>
|
||||
<label class="wide"><span data-i18n="settings.outdoorEntity">Outdoor temperature entity_id</span><input
|
||||
name="ha_outdoor_entity_id" placeholder="sensor.outdoor_temperature"></label>
|
||||
<label class="wide"><span data-i18n="settings.haSensorStaleAfterMinutes">Maximum HA sensor reading age
|
||||
(min)</span><input type="number" name="ha_sensor_stale_after_minutes" min="1" max="1440" step="1"
|
||||
value="5"></label>
|
||||
<p class="field-note wide" data-i18n="settings.haSensorStaleAfterHint">If the HA sensor is not updated
|
||||
within this time, the reading is treated as stale. The thermostat falls back to the GREE sensor when
|
||||
available.</p>
|
||||
<label class="check wide"><input type="checkbox" name="outdoor_assist_enabled"> <span
|
||||
data-i18n="settings.outdoorAssist">Use outdoor temperature as smart-control assist</span></label>
|
||||
<label class="check wide"><input type="checkbox" name="ha_allow_invalid_tls"> <span
|
||||
data-i18n="settings.allowInvalidTls">Allow invalid/self-signed HTTPS certificate</span></label>
|
||||
<p class="field-note wide warning-note" data-i18n="settings.allowInvalidTlsHint">Use only for a trusted
|
||||
local Home Assistant server, for example https://192.168.50.25.</p>
|
||||
<div class="wide sensor-alias-manager">
|
||||
<div class="settings-subhead">
|
||||
<div><strong data-i18n="settings.sensorAliases">Sensor aliases</strong><small
|
||||
data-i18n="settings.sensorAliasesHint">Friendly names are used in charts and selectors; the original
|
||||
entity_id remains unchanged.</small></div>
|
||||
</div>
|
||||
<div id="sensorAliasList" class="sensor-alias-list"></div>
|
||||
<div class="sensor-alias-add"><input id="sensorAliasEntity"
|
||||
placeholder="sensor.gabinet_temperature"><input id="sensorAliasName"
|
||||
data-i18n-placeholder="settings.aliasPlaceholder" placeholder="Gabinet"><button type="button"
|
||||
class="secondary" id="addSensorAlias" data-i18n="actions.add">Add</button></div>
|
||||
</div>
|
||||
<div class="form-actions wide"><button type="button" class="secondary" id="haTest"
|
||||
data-i18n="settings.testHa">Test HA</button></div>
|
||||
<label class="wide"><span>URL</span><input type="url" name="ha_url" placeholder="http://homeassistant.local:8123"></label>
|
||||
<label class="wide"><span data-i18n="settings.haLongLivedToken">Long-Lived Access Token</span><input type="password" name="ha_token" autocomplete="new-password" data-i18n-placeholder="settings.haTokenKeep" placeholder="Leave empty to keep the saved token"></label>
|
||||
<label class="check wide"><input type="checkbox" name="ha_allow_invalid_tls"> <span data-i18n="settings.allowInvalidTls">Allow invalid/self-signed HTTPS certificate</span></label>
|
||||
<p class="field-note wide warning-note" data-i18n="settings.allowInvalidTlsHint">Use only for a trusted local Home Assistant server.</p>
|
||||
<div class="form-actions wide"><button type="button" class="secondary" id="haTest" data-i18n="settings.testHa">Test HA</button></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel settings-block">
|
||||
<div class="settings-block-head">
|
||||
<div>
|
||||
<h3 data-i18n="settings.haIntegrationAccess">Home Assistant integration access</h3>
|
||||
<p data-i18n="settings.haIntegrationHint">Create a controller token and paste it into the GREE Controller
|
||||
integration in Home Assistant.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="token-manager">
|
||||
<div id="accessTokenList" class="token-list"></div>
|
||||
<div class="form-actions"><button type="button" class="primary" id="createAccessToken"
|
||||
data-i18n="settings.newToken">Create new token</button></div>
|
||||
<div class="settings-block-head"><div><h3 data-i18n="settings.haThermostatSources">Źródła temperatury sterowania</h3><p data-i18n="settings.haThermostatSourcesHint">Te ustawienia należą do logiki termostatów i źródeł temperatury, nie do wspólnych wejść Flow.</p></div></div>
|
||||
<div class="settings-grid">
|
||||
<label class="wide"><span data-i18n="settings.defaultEntity">Default room entity_id</span><input name="ha_entity_id" placeholder="sensor.living_room_temperature"></label>
|
||||
<label class="wide"><span data-i18n="settings.outdoorEntity">Outdoor temperature entity_id</span><input name="ha_outdoor_entity_id" placeholder="sensor.outdoor_temperature"></label>
|
||||
<label class="wide"><span data-i18n="settings.haSensorStaleAfterMinutes">Maximum HA sensor reading age (min)</span><input type="number" name="ha_sensor_stale_after_minutes" min="1" max="1440" step="1" value="5"></label>
|
||||
<p class="field-note wide" data-i18n="settings.haSensorStaleAfterHint">If the HA sensor is not updated within this time, the reading is treated as stale.</p>
|
||||
<label class="check wide"><input type="checkbox" name="outdoor_assist_enabled"> <span data-i18n="settings.outdoorAssist">Use outdoor temperature as smart-control assist</span></label>
|
||||
</div>
|
||||
</section>
|
||||
<div class="settings-save-bar"><span data-i18n="homeAssistant.saveHint">Save Home Assistant and sensor
|
||||
changes.</span><button class="primary" type="submit" data-i18n="actions.save">Save</button></div>
|
||||
|
||||
<section class="panel settings-block flow-shared-settings">
|
||||
<div class="settings-block-head"><div><h3 data-i18n="flow.sharedInputsTitle">Wspólne wejścia Flow</h3><p data-i18n="flow.sharedInputsHint">Zdefiniuj stałe i warunki czujników raz, a potem używaj ich w wielu Flow.</p></div><button type="button" class="primary" id="addFlowSharedInput" data-i18n="flow.sharedInputAdd">Dodaj wejście</button></div>
|
||||
<input type="hidden" id="flowSharedInputsRevision" name="flow_inputs_revision" value="[]"><div id="flowSharedInputList" class="flow-shared-input-list"></div>
|
||||
</section>
|
||||
|
||||
<section class="panel settings-block">
|
||||
<div class="settings-block-head"><div><h3 data-i18n="settings.sensorAliases">Sensor aliases</h3><p data-i18n="settings.sensorAliasesHint">Friendly names are used in charts and selectors; the original entity_id remains unchanged.</p></div></div>
|
||||
<div class="sensor-alias-manager">
|
||||
<div id="sensorAliasList" class="sensor-alias-list"></div>
|
||||
<div class="sensor-alias-add"><input id="sensorAliasEntity" placeholder="sensor.gabinet_temperature"><input id="sensorAliasName" data-i18n-placeholder="settings.aliasPlaceholder" placeholder="Gabinet"><button type="button" class="secondary" id="addSensorAlias" data-i18n="actions.add">Add</button></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel settings-block">
|
||||
<div class="settings-block-head"><div><h3 data-i18n="settings.haIntegrationAccess">Home Assistant integration access</h3><p data-i18n="settings.haIntegrationHint">Create a controller token and paste it into the GREE Controller integration in Home Assistant.</p></div></div>
|
||||
<div class="token-manager"><div id="accessTokenList" class="token-list"></div><div class="form-actions"><button type="button" class="primary" id="createAccessToken" data-i18n="settings.newToken">Create new token</button></div></div>
|
||||
</section>
|
||||
<div class="settings-save-bar"><span data-i18n="homeAssistant.saveHint">Save Home Assistant and sensor changes.</span><button class="primary" type="submit" data-i18n="actions.save">Save</button></div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
@@ -720,12 +701,13 @@
|
||||
<div class="flow-palette-group"><span data-i18n="flow.time">Time</span>
|
||||
<button type="button" data-flow-add="weekday" data-i18n="flow.node.weekday">Dni tygodnia</button><button type="button" data-flow-add="time_range" data-i18n="flow.node.timeRange">Przedział godzin</button><button type="button" data-flow-add="date_range" data-i18n="flow.node.dateRange">Zakres dat</button><button type="button" data-flow-add="night_mode" data-i18n="flow.node.nightMode">Tryb nocny</button></div>
|
||||
<div class="flow-palette-group"><span data-i18n="flow.sensors">Sensors</span>
|
||||
<button type="button" data-flow-add="outdoor_temperature" data-i18n="flow.node.outdoorTemperature">Temperatura zewn.</button><button type="button" data-flow-add="device_temperature" data-i18n="flow.node.deviceTemperature">Temperatura urządzenia</button><button type="button" data-flow-add="zone_temperature" data-i18n="flow.node.zoneTemperature">Temperatura strefy</button><button type="button" data-flow-add="house_mode" data-i18n="flow.node.houseMode">Tryb domu</button><button type="button" data-flow-add="device_state" data-i18n="flow.node.deviceState">Stan urządzenia</button><button type="button" data-flow-add="zone_state" data-i18n="flow.node.zoneState">Stan termostatu</button><button type="button" data-flow-add="group_state" data-i18n="flow.node.groupState">Stan grupy</button><button type="button" data-flow-add="ha_state" data-i18n="flow.node.haState">HA: stan</button><button type="button" data-flow-add="ha_numeric" data-i18n="flow.node.haNumeric">HA: wartość</button><button type="button" data-flow-add="ha_attribute" data-i18n="flow.node.haAttribute">HA: atrybut</button><button type="button" data-flow-add="ha_available" data-i18n="flow.node.haAvailable">HA: dostępność</button><button type="button" data-flow-add="constant" data-i18n="flow.node.constant">Stała testowa</button></div>
|
||||
<button type="button" data-flow-add="outdoor_temperature" data-i18n="flow.node.outdoorTemperature">Temperatura zewn.</button><button type="button" data-flow-add="device_temperature" data-i18n="flow.node.deviceTemperature">Temperatura urządzenia</button><button type="button" data-flow-add="zone_temperature" data-i18n="flow.node.zoneTemperature">Temperatura strefy</button><button type="button" data-flow-add="house_mode" data-i18n="flow.node.houseMode">Tryb domu</button><button type="button" data-flow-add="device_state" data-i18n="flow.node.deviceState">Stan urządzenia</button><button type="button" data-flow-add="zone_state" data-i18n="flow.node.zoneState">Stan termostatu</button><button type="button" data-flow-add="group_state" data-i18n="flow.node.groupState">Stan grupy</button><button type="button" data-flow-add="ha_state" data-i18n="flow.node.haState">HA: stan</button><button type="button" data-flow-add="ha_numeric" data-i18n="flow.node.haNumeric">HA: wartość</button><button type="button" data-flow-add="ha_attribute" data-i18n="flow.node.haAttribute">HA: atrybut</button><button type="button" data-flow-add="ha_available" data-i18n="flow.node.haAvailable">HA: dostępność</button><button type="button" data-flow-add="constant" data-i18n="flow.node.constant">Stała testowa</button><button type="button" data-flow-add="shared_input" data-i18n="flow.node.sharedInput">Wspólne wejście</button></div>
|
||||
<div class="flow-palette-group"><span data-i18n="flow.logic">Logic</span><button type="button" data-flow-add="logic_and" data-i18n="flow.node.and">AND</button><button type="button" data-flow-add="logic_or" data-i18n="flow.node.or">OR</button><button type="button" data-flow-add="logic_not" data-i18n="flow.node.not">NOT</button></div>
|
||||
<div class="flow-palette-group"><span data-i18n="flow.actions">Actions</span>
|
||||
<button type="button" data-flow-add="zone_thermostat" data-i18n="flow.node.thermostat">Termostat</button><button type="button" data-flow-add="device_action" data-i18n="flow.node.greeDevice">Urządzenie GREE</button><button type="button" data-flow-add="group_action" data-i18n="flow.node.group">Grupa</button></div>
|
||||
</aside>
|
||||
<div class="flow-workspace-wrap">
|
||||
<div class="flow-workspace-toolbar"><button type="button" class="secondary" data-action="flow-select-all" data-i18n="flow.selectAll">Zaznacz wszystko</button><button type="button" class="secondary" data-action="flow-clear-selection" data-i18n="flow.clearSelection">Wyczyść zaznaczenie</button><span id="flowSelectionCount" class="badge"></span></div>
|
||||
<div id="flowWorkspace" class="flow-workspace" tabindex="0">
|
||||
<svg id="flowEdges" class="flow-edges" aria-hidden="true"></svg><div id="flowNodes" class="flow-nodes"></div>
|
||||
<div id="flowEmptyHint" class="flow-empty-hint"><strong data-i18n="flow.emptyCanvas">Start by adding blocks</strong><span data-i18n="flow.emptyCanvasHint">Conditions go on the left, actions on the right.</span></div>
|
||||
@@ -738,6 +720,17 @@
|
||||
|
||||
<input id="flowImportFile" type="file" accept="application/json,.json,.flow.json" hidden>
|
||||
|
||||
<dialog id="flowSharedInputDialog" class="flow-tool-dialog">
|
||||
<form id="flowSharedInputForm" class="dialog-form">
|
||||
<div class="dialog-head"><div><span class="eyebrow" data-i18n="flow.sharedInputEyebrow">Wspólne dane</span><h2 id="flowSharedInputDialogTitle" data-i18n="flow.sharedInputTitle">Wspólne wejście Flow</h2></div><button type="button" data-close>×</button></div>
|
||||
<input type="hidden" name="id">
|
||||
<label><span data-i18n="common.name">Nazwa</span><input name="name" maxlength="100" required placeholder="Okno w salonie zamknięte"></label>
|
||||
<label><span data-i18n="flow.sharedInputType">Typ źródła</span><select name="kind" id="flowSharedInputKind"></select></label>
|
||||
<div id="flowSharedInputFields" class="flow-shared-input-fields"></div>
|
||||
<div class="dialog-actions"><button type="button" class="secondary" data-close data-i18n="actions.cancel">Anuluj</button><button type="submit" class="primary" data-i18n="actions.save">Zapisz</button></div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<dialog id="flowTemplateDialog" class="flow-tool-dialog">
|
||||
<div class="dialog-head"><div><span class="eyebrow" data-i18n="flow.templateLibrary">Flow library</span><h2 data-i18n="flow.templates">Templates</h2></div><button type="button" data-close>×</button></div>
|
||||
<p class="field-note" data-i18n="flow.templatesHint">Ready-made layouts create an editable graph using existing zones, devices and groups.</p>
|
||||
|
||||
Vendored
+1
@@ -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;
|
||||
|
||||
+2
-2
@@ -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 = []; }
|
||||
|
||||
|
||||
+187
-42
@@ -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 => `<article class="list-card flow-card">
|
||||
<div class="list-card-head"><div><h3>${esc(flow.name)}</h3><p>${esc(flow.description || tr('flow.defaultDescription'))}</p></div><span class="badge ${flow.enabled ? 'active' : ''}">${esc(flow.enabled ? tr('common.enabled') : tr('common.disabled'))}</span></div>
|
||||
const count = $('#flowListCount'); if (count) count.textContent = tr('flow.listCount', { count: app.flows.length });
|
||||
host.innerHTML = app.flows.length ? app.flows.map(flow => `<article class="list-card flow-card ${flow.enabled ? '' : 'is-disabled'}">
|
||||
<div class="list-card-head"><div><h3>${esc(flow.name)}</h3><p>${esc(flow.description || tr('flow.defaultDescription'))}</p></div><label class="flow-quick-toggle" title="${esc(tr('flow.quickToggleHint'))}"><input type="checkbox" data-flow-toggle="${esc(flow.id)}" ${flow.enabled ? 'checked' : ''}><span>${esc(flow.enabled ? tr('common.enabled') : tr('common.disabled'))}</span></label></div>
|
||||
<div class="card-stats"><div class="card-stat"><small>${esc(tr('flow.blocks'))}</small><strong>${flow.nodes?.length || 0}</strong></div><div class="card-stat"><small>${esc(tr('nav.schedules'))}</small><strong>${flow.compiled_schedule_ids?.length || 0}</strong></div><div class="card-stat"><small>${esc(tr('nav.automations'))}</small><strong>${flow.compiled_automation_ids?.length || 0}</strong></div></div>
|
||||
<div class="card-footer"><small>${esc(tr('flow.compileCount', { schedules: flow.compiled_schedule_ids?.length || 0, automations: flow.compiled_automation_ids?.length || 0 }))}</small><div class="card-menu"><button data-action="edit-flow" data-id="${esc(flow.id)}">${esc(tr('flow.openEditor'))}</button><button class="danger" data-action="delete-flow" data-id="${esc(flow.id)}">${esc(tr('actions.delete'))}</button></div></div>
|
||||
</article>`).join('') : `<div class="empty"><strong>${esc(tr('flow.emptyTitle'))}</strong>${esc(tr('flow.emptyText'))}</div>`;
|
||||
}
|
||||
|
||||
|
||||
function flowDraftFrom(flow) {
|
||||
return flow ? JSON.parse(JSON.stringify(flow)) : {
|
||||
id: '', revision: 0, name: tr('flow.newDefaultName'), enabled: true, description: '', nodes: [], edges: [], summary: '', compiled_schedule_ids: [], compiled_automation_ids: [],
|
||||
@@ -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 `<article class="flow-node flow-node-${esc(meta.category)} ${app.flowSelectedNodeId === node.id ? 'selected' : ''}" data-flow-node="${esc(node.id)}" style="left:${Number(node.x || 0)}px;top:${Number(node.y || 0)}px">
|
||||
return `<article class="flow-node flow-node-${esc(meta.category)} ${(app.flowSelectedNodeIds || []).includes(node.id) ? 'selected' : ''}" data-flow-node="${esc(node.id)}" style="left:${Number(node.x || 0)}px;top:${Number(node.y || 0)}px">
|
||||
<button class="flow-port flow-port-in" type="button" data-flow-input="${esc(node.id)}" title="${esc(tr('flow.connectHere'))}"></button>
|
||||
<div class="flow-node-head"><span>${esc(flowNodeTitle(meta))}</span><button type="button" data-flow-remove="${esc(node.id)}" aria-label="${esc(tr('actions.delete'))}">×</button></div>
|
||||
<div class="flow-node-body">${esc(flowNodeSummary(node))}</div>
|
||||
@@ -131,6 +136,7 @@ function renderFlowEditor() {
|
||||
</article>`;
|
||||
}).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 = `<label><span>${esc(tr('groups.group'))}</span><select data-flow-config="group_id">${flowSelectOptions(app.groups, c.group_id)}</select></label><label><span>${esc(tr('flow.field'))}</span><select data-flow-config="field"><option value="power_enabled" selected>power_enabled</option></select></label>${flowTextComparisonFields(c)}`;
|
||||
else if (node.kind === 'night_mode') fields = `<p class="field-note">${esc(tr('flow.nightModeHint'))}</p>`;
|
||||
else if (node.kind === 'constant') fields = `<label><span>${esc(tr('flow.value'))}</span><select data-flow-config="value"><option value="true" ${c.value !== false ? 'selected' : ''}>${esc(tr('common.yes'))}</option><option value="false" ${c.value === false ? 'selected' : ''}>${esc(tr('common.no'))}</option></select></label>`;
|
||||
else if (node.kind === 'shared_input') fields = (app.flowSharedInputs || []).length ? `<label><span>${esc(tr('flow.sharedInputTitle'))}</span><select data-flow-config="input_id">${flowSelectOptions(app.flowSharedInputs || [], c.input_id)}</select></label><p class="field-note">${esc(tr('flow.sharedInputNodeHint'))}</p>` : `<div class="empty compact"><strong>${esc(tr('flow.sharedInputsEmpty'))}</strong><span>${esc(tr('flow.sharedInputNodeEmptyHint'))}</span></div>`;
|
||||
else if (node.kind === 'logic_and') fields = `<p class="field-note">${esc(tr('flow.andHint'))}</p>`;
|
||||
else if (node.kind === 'logic_or') fields = `<p class="field-note">${esc(tr('flow.orHint'))}</p>`;
|
||||
else if (node.kind === 'logic_not') fields = `<p class="field-note">${esc(tr('flow.notHint'))}</p>`;
|
||||
@@ -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 => `<button type="button" class="flow-template-card" data-flow-template="${esc(key)}"><strong>${esc(tr(`flow.template.${key}.title`))}</strong><span>${esc(tr(`flow.template.${key}.description`))}</span></button>`).join('');
|
||||
host.innerHTML = FLOW_TEMPLATE_CATEGORIES.map(([category, keys]) => `<section class="flow-template-category"><div class="flow-template-category-head"><strong>${esc(tr(`flow.templateCategory.${category}`))}</strong><span>${esc(tr(`flow.templateCategory.${category}.hint`))}</span></div><div class="flow-template-category-grid">${keys.map(key => `<button type="button" class="flow-template-card" data-flow-template="${esc(key)}"><strong>${esc(tr(`flow.template.${key}.title`))}</strong><span>${esc(tr(`flow.template.${key}.description`))}</span></button>`).join('')}</div></section>`).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 `<label><span>${esc(flowNodeTitle(FLOW_NODE_META[node.kind]))} · ${esc(flowNodeSummary(node))}</span><input ${numeric ? 'type="number" step="0.1"' : 'type="text"'} data-flow-sim-node="${esc(node.id)}" placeholder="${esc(tr('flow.useLiveValue'))}"></label>`;
|
||||
}).join('') : `<p class="field-note">${esc(tr('flow.noSimulationOverrides'))}</p>`;
|
||||
}
|
||||
@@ -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; });
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
+2
-2
@@ -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();
|
||||
|
||||
@@ -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 => `<div class="sensor-alias-row"><span class="mono" title="${esc(entity)}">${esc(entity)}</span><input data-sensor-alias="${esc(entity)}" value="${esc(app.sensorAliases?.[entity] || '')}" placeholder="${esc(tr('settings.aliasPlaceholder'))}"><button type="button" class="sensor-alias-clear" data-clear-sensor-alias="${esc(entity)}" title="${esc(tr('actions.clear'))}">×</button></div>`).join('') : `<div class="empty compact">${esc(tr('settings.noSensorAliases'))}</div>`;
|
||||
}
|
||||
|
||||
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 => `<div class="flow-shared-input-row">
|
||||
<div><strong>${esc(item.name)}</strong><small>${esc(flowNodeTitle(FLOW_NODE_META[item.kind] || { title:item.kind }))} · ${esc(sharedFlowInputSummary(item))}</small><code>${esc(item.id)}</code></div>
|
||||
<div class="flow-shared-input-actions"><button type="button" class="secondary" data-flow-shared-edit="${esc(item.id)}">${esc(tr('actions.edit'))}</button><button type="button" class="danger" data-flow-shared-delete="${esc(item.id)}">${esc(tr('actions.delete'))}</button></div>
|
||||
</div>`).join('') : `<div class="empty compact"><strong>${esc(tr('flow.sharedInputsEmpty'))}</strong><span>${esc(tr('flow.sharedInputsEmptyHint'))}</span></div>`;
|
||||
const revision = $('#flowSharedInputsRevision');
|
||||
if (revision) revision.value = JSON.stringify(items);
|
||||
}
|
||||
|
||||
function sharedFlowOptions(items, selected, label = item => item.name) {
|
||||
return items.map(item => `<option value="${esc(item.id)}" ${item.id === selected ? 'selected' : ''}>${esc(label(item))}</option>`).join('');
|
||||
}
|
||||
|
||||
function sharedFlowOperatorOptions(selected = 'eq') {
|
||||
return [['lt','<'],['lte','≤'],['gt','>'],['gte','≥'],['eq','='],['neq','≠']].map(([value,label]) => `<option value="${value}" ${value === selected ? 'selected' : ''}>${label}</option>`).join('');
|
||||
}
|
||||
|
||||
function renderFlowSharedInputFields(kind, config = {}) {
|
||||
const host = $('#flowSharedInputFields'); if (!host) return;
|
||||
const c = config || {};
|
||||
const comparison = (temperature = false) => `<div class="two"><label><span>${esc(tr('flow.operator'))}</span><select data-shared-config="operator">${sharedFlowOperatorOptions(c.operator || 'lt')}</select></label><label><span>${esc(temperature ? tr('automations.thresholdC') : tr('flow.value'))}</span><input type="number" step="0.1" data-shared-config="value" value="${Number(c.value ?? 0)}"></label></div>`;
|
||||
const textComparison = () => `<div class="two"><label><span>${esc(tr('flow.operator'))}</span><select data-shared-config="operator"><option value="eq" ${c.operator !== 'neq' ? 'selected' : ''}>=</option><option value="neq" ${c.operator === 'neq' ? 'selected' : ''}>≠</option></select></label><label><span>${esc(tr('flow.value'))}</span><input data-shared-config="value" value="${esc(c.value ?? '')}"></label></div>`;
|
||||
let fields = '';
|
||||
if (kind === 'constant') fields = `<label><span>${esc(tr('flow.value'))}</span><select data-shared-config="value"><option value="true" ${c.value !== false ? 'selected' : ''}>${esc(tr('common.yes'))}</option><option value="false" ${c.value === false ? 'selected' : ''}>${esc(tr('common.no'))}</option></select></label>`;
|
||||
else if (kind === 'ha_state') fields = `<label><span>entity_id</span><input data-shared-config="entity_id" value="${esc(c.entity_id || '')}" placeholder="binary_sensor.window"></label>${textComparison()}`;
|
||||
else if (kind === 'ha_numeric') fields = `<label><span>entity_id</span><input data-shared-config="entity_id" value="${esc(c.entity_id || '')}" placeholder="sensor.energy_price"></label>${comparison(false)}`;
|
||||
else if (kind === 'ha_attribute') fields = `<label><span>entity_id</span><input data-shared-config="entity_id" value="${esc(c.entity_id || '')}" placeholder="climate.living_room"></label><label><span>${esc(tr('flow.attribute'))}</span><input data-shared-config="attribute" value="${esc(c.attribute || '')}" placeholder="hvac_action"></label><div class="two"><label><span>${esc(tr('flow.operator'))}</span><select data-shared-config="operator">${sharedFlowOperatorOptions(c.operator || 'eq')}</select></label><label><span>${esc(tr('flow.value'))}</span><input data-shared-config="value" value="${esc(c.value ?? '')}"></label></div>`;
|
||||
else if (kind === 'ha_available') fields = `<label><span>entity_id</span><input data-shared-config="entity_id" value="${esc(c.entity_id || '')}" placeholder="binary_sensor.window"></label><p class="field-note">${esc(tr('flow.haAvailableHint'))}</p>`;
|
||||
else if (kind === 'outdoor_temperature') fields = comparison(true);
|
||||
else if (kind === 'device_temperature') fields = `<label><span>${esc(tr('common.device'))}</span><select data-shared-config="device_id">${sharedFlowOptions(app.devices, c.device_id)}</select></label>${comparison(true)}`;
|
||||
else if (kind === 'zone_temperature') fields = `<label><span>${esc(tr('common.zone'))}</span><select data-shared-config="zone_id">${sharedFlowOptions(app.zones, c.zone_id)}</select></label>${comparison(true)}`;
|
||||
else if (kind === 'house_mode') fields = `<div class="two"><label><span>${esc(tr('flow.operator'))}</span><select data-shared-config="operator"><option value="eq" ${c.operator !== 'neq' ? 'selected' : ''}>=</option><option value="neq" ${c.operator === 'neq' ? 'selected' : ''}>≠</option></select></label><label><span>${esc(tr('flow.houseMode'))}</span><select data-shared-config="value">${['cool','heat','off'].map(v => `<option value="${v}" ${c.value === v ? 'selected' : ''}>${esc(v === 'off' ? tr('common.off') : tr(`mode.${v}`))}</option>`).join('')}</select></label></div>`;
|
||||
else if (kind === 'device_state') fields = `<label><span>${esc(tr('common.device'))}</span><select data-shared-config="device_id">${sharedFlowOptions(app.devices, c.device_id)}</select></label><label><span>${esc(tr('flow.field'))}</span><select data-shared-config="field">${['enabled','online','power','mode','fan_speed','swing_vertical','swing_horizontal','quiet','turbo','light','air','xfan','health','sleep'].map(v => `<option value="${v}" ${c.field === v ? 'selected' : ''}>${esc(v)}</option>`).join('')}</select></label>${textComparison()}`;
|
||||
else if (kind === 'zone_state') fields = `<label><span>${esc(tr('common.zone'))}</span><select data-shared-config="zone_id">${sharedFlowOptions(app.zones, c.zone_id)}</select></label><label><span>${esc(tr('flow.field'))}</span><select data-shared-config="field">${['enabled','mode','active_preset','demand','control_owner','device_manual_override','local_thermostat_power'].map(v => `<option value="${v}" ${c.field === v ? 'selected' : ''}>${esc(v)}</option>`).join('')}</select></label>${textComparison()}`;
|
||||
else if (kind === 'group_state') fields = `<label><span>${esc(tr('groups.group'))}</span><select data-shared-config="group_id">${sharedFlowOptions(app.groups, c.group_id)}</select></label><input type="hidden" data-shared-config="field" value="power_enabled">${textComparison()}`;
|
||||
else if (kind === 'night_mode') fields = `<p class="field-note">${esc(tr('flow.nightModeHint'))}</p>`;
|
||||
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]) => `<option value="${kind}">${esc(tr(key))}</option>`).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);
|
||||
}
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
|
||||
+34
-1
@@ -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; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user