This commit is contained in:
Mateusz Gruszczyński
2026-09-17 21:46:10 +02:00
parent cafbf6e5fa
commit 7d9e6c8189
20 changed files with 281 additions and 71 deletions
Generated
+1 -1
View File
@@ -700,7 +700,7 @@ dependencies = [
[[package]]
name = "gree-controller"
version = "0.15.1"
version = "0.15.2"
dependencies = [
"aes",
"aes-gcm",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "gree-controller"
version = "0.15.1"
version = "0.15.2"
edition = "2021"
authors = ["GREE Controller contributors"]
description = "Standalone local GREE HVAC controller with Web UI, SQLite and Home Assistant sensor support"
+19 -19
View File
@@ -1,14 +1,14 @@
de0793949cf01d27d903653226ecc9b5f72523d8711f20bcab022cf42c4c4ed0 ./.env.example
a4ec3874a2e3ab1bad28fb40bb620f7b01f64d01ad9b699306bf70ada31227db ./.gitignore
852768ed3da99ac2e9bbe6c9b67e9a4a667e7115ad9482b6ce4a29ff72d8f914 ./Cargo.lock
8e58dd01d95c93ea4f5d0cc5e379cb192221705c9dda41b7b2b7182c49dbff0f ./Cargo.toml
82bb41f94fdf5c66ebd9e6caa92e0f1794431067298e0a27c95c8e5bfd68de89 ./Cargo.lock
39bdb16196253559e3487aa912ff770c09c7b08cdfbb21e9db1d8af1304ea7e0 ./Cargo.toml
19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE
4a6570c14ba56fc9a4921de6726816264e961a5b82a7fe3fcf475ef96417dad9 ./README.md
283ffafdffd463f5d670acaba251fb92fab7c88e51a6fc1e06732f6cade88b5e ./SOURCE_MANIFEST.sha256
84a0803bc75dc3199e23ef56c11c6950e8bd888c686ecda7cc369829e8e3073f ./README.md
f21732756af313565bfe0788230e45cc11c384cee1187f006d426c5463183c0c ./SOURCE_MANIFEST.sha256
29e9821e5ed216ea3565dd6d8bc0b174ed3cf9fa72c4ccebbfa99e98db3ad8f9 ./build.rs
9873e774746783d65e447b439d069f84c13d2f4e556661e4eb509af59293c2f7 ./docs/API.md
58b79297607303151c1de2da74344cd4f6277bc53d80ebe04ed4567d906c963b ./docs/FLOW.md
9483ad7cb1e0c8e38f08c722600b6ba059d4273cfb9dcd1af57c11de522d187b ./docs/openapi.json
604a795dec077dd7e4f3462a564fdda68e8084aef07da0fcf3dfb6053a46cc58 ./docs/API.md
a881ba0808dd3f189d92038f6163c39d6b6208f6dbff5c32c0ea1a2628f3fbb4 ./docs/FLOW.md
8babaa6830fa70b2d3d8208ef51808e79c9d68c4e007b9ba1537fe582ba03b3d ./docs/openapi.json
c65aa543d2e99f8f7d52736307b07ebd182597d80124661d53a16bb23f6f1118 ./ha-addon/.env.example
0993076ca45b6d31859680835204cdc111b3f9bdbf460d50b4efec56cd5e685b ./ha-addon/Dockerfile
adf7ff248d782a3b1cba82b8789c57b6849ddaaaf5fbc108af75e18b1534a885 ./ha-addon/README.md
@@ -22,7 +22,7 @@ b7f0873109c52be9d7f09bea3dffc416103c50085e1f0680d11661a969479898 ./ha-addon/hom
6509286c7b7de638dcc5d40ce53181e65d1eddc5dc36a13306dc6d87220fedbb ./ha-addon/home-assistant/custom_components/gree_controller/coordinator.py
5a96fe8f5c035c34f1339370270cd078056202d09e236dec75735be11de92a7d ./ha-addon/home-assistant/custom_components/gree_controller/entity_map.py
c4fb75c246db651087900ebfc2291ff41ac87652cd6194fc0b776b0005c1cbcf ./ha-addon/home-assistant/custom_components/gree_controller/icon.png
a6d69fad7ea1f22436e9f643ceb36c206f1450d8337aede0c5d7a1f0613baeeb ./ha-addon/home-assistant/custom_components/gree_controller/manifest.json
d467809d894b8cb9e8e04a2cc279e0cec3227c1d1c689098bdc3c0380aa9043e ./ha-addon/home-assistant/custom_components/gree_controller/manifest.json
c52a484b671ce738ecc00228a1db19a5d703f69ab27530c2896a5c2ae3b4a96f ./ha-addon/home-assistant/custom_components/gree_controller/number.py
39c4309001b75abb56234f05662bc06e077054986876f1927937edbce528ec95 ./ha-addon/home-assistant/custom_components/gree_controller/select.py
cca65482e36d48035aca178121a378fe7d578d600acff267ae81a6399c0da653 ./ha-addon/home-assistant/custom_components/gree_controller/sensor.py
@@ -30,10 +30,10 @@ cca65482e36d48035aca178121a378fe7d578d600acff267ae81a6399c0da653 ./ha-addon/hom
a745cc458c235158d5d70b6a33d8000fd8b40ff93bfe2f2796af0f4b3a9eef9b ./ha-addon/home-assistant/custom_components/gree_controller/translations/en.json
50b7d6fe6329ad4ea6bee9612ab141c2f73f8382fcc2af0bb076a8e8d4dd4465 ./ha-addon/home-assistant/custom_components/gree_controller/translations/pl.json
c5fc5c87273d82d2834a0a5a365c821a416413ed509613441e24e4dc3507c4d2 ./ha-addon/home-assistant/generated/gree_controller_entities.example.json
ea5c4eb4ca2656f8d9a2be37a35ba04bb4c53d735c9c04b416b112e456699cff ./ha-addon/repository/gree-controller/CHANGELOG.md
e3a227ffe23368b152d0ba4a4e81a95b70d35e9870b2a8dc7b2048d6901a7f05 ./ha-addon/repository/gree-controller/CHANGELOG.md
1b5599195329cc3e643c23aa1caadcec80f133e8e515eec7d92b9f3cbda5b80b ./ha-addon/repository/gree-controller/DOCS.md
c3db302f38d26dc9f8caf37a1a8a07a1334cc07ae301d452f2940a5ec5915719 ./ha-addon/repository/gree-controller/README.md
c16d31ae815dc4e324ce56b0b46529bcc78c5223688c33aa30d7463abe4f1184 ./ha-addon/repository/gree-controller/config.yaml
21b82216d0d84cc66ecbed69457caf61174fa9304528fe051c98e8ef7c634025 ./ha-addon/repository/gree-controller/config.yaml
3aae6cdae4c3aaab7786a9575e7093b7e2c5b69288167075f4b7475611691a6b ./ha-addon/repository/gree-controller/topologia-ha-vlan-gree-pl.png
0d0b42a7639b128946eec379175436fa1f150294f39d2dfd169077bfa87fd14d ./ha-addon/repository/gree-controller/topology-ha-vlan-gree-en.png
2272740b185e9520e948f74b8f45cf98b8b5fd28c93bdee760da626f91fe5c2a ./ha-addon/repository/gree-controller/translations/en.yaml
@@ -43,8 +43,8 @@ cea12bc74c066a588a5ef0ef7bad9d4906bdbcea99f2d275429b0eefc94b4b5f ./ha-addon/run
081e27b6e43070d44f865335c30a42a5a29668eaa6a5ea5ab6967ac4447b8e06 ./ha-addon/sync-repository.sh
f9e31de3d109ead32bf531c074d46a087df398d62488caee0b46b5e0a1c027f7 ./ha_addon.md
253a0bc912786e67ea7fc92a64e4a510ad973bec343a88ccfb1f28fca3e8cf01 ./lang/README.md
f7fc1aee1129cee46a30d5876e3e53f11d2661925eae19cb1e14b47deae83e9e ./lang/en.json
c05d100e9efa1a82747ffc709157a9bdd6d109b7a80c6e140d582a28d8c3de13 ./lang/pl.json
e5c6a6051381e28d74b75e48232de13d02eb87519d29c847b7ab7979ce47d082 ./lang/en.json
02c650f603bca1bd17d9d59193c176ee6ddccb8304f2e658d7ba7730f8ff5f83 ./lang/pl.json
d8459024f04ca514bd8e9d6bd3af872fb942fd85a7cdd5583f4a9d28aab6faba ./make_zip.py
b14233a8987e53bbbdd6770386ba10fa166ec25c1e098275c173c544b37846fc ./presets/bedroom_window_night.json
1960841119c0b673fb2f03621b32237552fd828045304cd221401214b997c910 ./presets/device_resilience.json
@@ -107,7 +107,7 @@ b24092f34a46626d64628b510eba82193f0b203fd097f6a621b91af5d91eb571 ./src/api/auth
ca21f6d5a407e2e3532899d53ba4e8682c2fe961a46d8cd870ddc459721513ef ./src/api/device_groups.rs
5245d5a70c4a1919d8b7757db399e1caf692c8b3a8af0343d8e462bf90373137 ./src/api/devices.rs
4b5b784670ae761a6848b9a33fe3fb391bebd39d65e87936907428711e96ca44 ./src/api/events.rs
b9a9a68e47fdaa577c28063fa68bf95075bc355bff81addca4ee448ef70dd818 ./src/api/flows.rs
2ef2f64b77886e2abd6b410adf8de91a2333d3ebdb8fcee8376ba566e5bba029 ./src/api/flows.rs
82c02fa135132ea3ebdb4d88dd1210dc536d22f987151d7f82595ee3d3d03440 ./src/api/gree_cloud.rs
b61b0b4f196c5e72e47cf06ca0c14df3cf8eedbdef0242a9d2af33623663489f ./src/api/groups.rs
3cc64b9c992805f3fbf01c0785cf29ce3121750bf30bd6420ed6e5cf2f8df422 ./src/api/history.rs
@@ -203,19 +203,19 @@ b92a6cb158b494fe145b43c7641e65f6fafff47201d7d76edbec2cfd8b94835c ./systemd/gree
2822a98a4d7f72f2b89b70878aa0f9bd3f391fde4ea04ba1b94e89a527dcd1fb ./web/css/styles.css
183d122ba366303d9952ac0e68aff4bced4e0d9a468fbe51316c13f15227402b ./web/custom-chart.html
e98bdd7204349cce1ec6f57283509697af0bbc72280622a6c3efa6fed242db4f ./web/favicon.svg
263d5554e62d2dd26b251f2c43a7e4ec14c8d8d790fa67e55c9bebf16f9d55c0 ./web/index.html
d5115a9dc59b4e753dcff9239a77fe49886951d7a4ae7456be80d9201cc42365 ./web/index.html
9ba3f6fa05b72aa989139b2a909982571b2a02055052e4c40104f1e81a9aa7eb ./web/js-dynamic/README.md
e74bb06dba646f298a4d118b5c2dca6ba24f1179ce8ea8d53c1acd6000ca332c ./web/js-dynamic/bootstrap.js
5d57253a013fd1cb79e4b8701d4be6e394e3f2acbce73dfcd726a86e5878f1a1 ./web/js-dynamic/charts.js
bb2b49361103145bd8c502f2867962d48ced409c1a1474af1214c4d8d0b378b2 ./web/js-dynamic/core.js
b5fd43eeb9c15fe8289b9e718dfbc2651417379462c4b16b88e090b436abf7c5 ./web/js-dynamic/core.js
752b3c3b59558b42d867d4a15329238f0213f354b76f7011c10e31b4e6dbb311 ./web/js-dynamic/dashboard.js
0e1992a7b93767f1fe4634121ae5aa43900d99f8c2614c2a4cbd5770a49b87c1 ./web/js-dynamic/entities.js
eeeef31f767f0aa3efb78bc44a2fea2b9eb86f4d4c50cf1ccae69b5ca30cdfda ./web/js-dynamic/events.js
22da18288de3282245f88f9f525d9ad29ec027571e7ea893720a9470170eccd2 ./web/js-dynamic/flows.js
6b166a9311e30fe948bd3236189436eea769c238f963ba00acec5cdb51ff1dd7 ./web/js-dynamic/entities.js
a48cb69ae4a2d0d6d512baef346fe499a5a6ea267e071856829df982d4a0f129 ./web/js-dynamic/events.js
8e0f294ceeec1d258edb56271469a8a05381e6443a475f76fbeded6637d17503 ./web/js-dynamic/flows.js
3ae714de6b71c068ba665c81a7689f994b1716284e8d937db669ad500f12cf63 ./web/js-dynamic/forms.js
92d013bb5d64f64aa8c55380704540a5f72732bde7ccd16554965e401daf50ea ./web/js-dynamic/history.js
511c4e7cc4c48fbbdab64558a9d1c27e6759c9b6dca255d11f301b9708e955c7 ./web/js-dynamic/main.js
86031d4f7f7d1b46412c8fbb09ff1ad1b2b4cd84eb949bc749b27663f1dc27fa ./web/js-dynamic/navigation.js
c974b6ef41ce8b6def35f5349849a06855c46ec31cb4122a39fcb5c78f6d362a ./web/js-dynamic/navigation.js
0e398bad2cde85d4a62d8733c06e73f2b6a5b07db791d4570feba38c7ff8741f ./web/js-dynamic/realtime.js
1b667f0a8bce824b27f56a8d2f44ff8feef9a011289591dc629ee288db70d137 ./web/js-dynamic/router.js
c2710fd9c1f2b31878c1e25ceb02d74ae30456c1bbb400e69fcf7619ecc72bc2 ./web/js-dynamic/select-ui.js
+2 -2
View File
@@ -2,9 +2,9 @@
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.
**Version: 0.15.1**
**Version: 0.15.2**
Version 0.15.1 improves WebSocket connection-state handling in the Web UI: transient disconnects show a reconnecting state, persistent failures escalate after a short grace period, and successful reconnects restore the connected state without affecting the existing HTTP fallback.
Version 0.15.2 adds granular single-function unit actions to Visual Flow and exposes fan/Quiet/Turbo/Panel Light/Air/X-Fan/Health/Sleep controls in legacy automations, so automations can change one unit feature without touching unrelated settings.
> [Full API reference](docs/API.md) — authentication, every endpoint, request bodies, response models, WebSocket events and examples.
+18 -18
View File
@@ -1,13 +1,13 @@
de0793949cf01d27d903653226ecc9b5f72523d8711f20bcab022cf42c4c4ed0 ./.env.example
a4ec3874a2e3ab1bad28fb40bb620f7b01f64d01ad9b699306bf70ada31227db ./.gitignore
852768ed3da99ac2e9bbe6c9b67e9a4a667e7115ad9482b6ce4a29ff72d8f914 ./Cargo.lock
8e58dd01d95c93ea4f5d0cc5e379cb192221705c9dda41b7b2b7182c49dbff0f ./Cargo.toml
82bb41f94fdf5c66ebd9e6caa92e0f1794431067298e0a27c95c8e5bfd68de89 ./Cargo.lock
39bdb16196253559e3487aa912ff770c09c7b08cdfbb21e9db1d8af1304ea7e0 ./Cargo.toml
19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE
4a6570c14ba56fc9a4921de6726816264e961a5b82a7fe3fcf475ef96417dad9 ./README.md
84a0803bc75dc3199e23ef56c11c6950e8bd888c686ecda7cc369829e8e3073f ./README.md
29e9821e5ed216ea3565dd6d8bc0b174ed3cf9fa72c4ccebbfa99e98db3ad8f9 ./build.rs
9873e774746783d65e447b439d069f84c13d2f4e556661e4eb509af59293c2f7 ./docs/API.md
58b79297607303151c1de2da74344cd4f6277bc53d80ebe04ed4567d906c963b ./docs/FLOW.md
9483ad7cb1e0c8e38f08c722600b6ba059d4273cfb9dcd1af57c11de522d187b ./docs/openapi.json
604a795dec077dd7e4f3462a564fdda68e8084aef07da0fcf3dfb6053a46cc58 ./docs/API.md
a881ba0808dd3f189d92038f6163c39d6b6208f6dbff5c32c0ea1a2628f3fbb4 ./docs/FLOW.md
8babaa6830fa70b2d3d8208ef51808e79c9d68c4e007b9ba1537fe582ba03b3d ./docs/openapi.json
c65aa543d2e99f8f7d52736307b07ebd182597d80124661d53a16bb23f6f1118 ./ha-addon/.env.example
0993076ca45b6d31859680835204cdc111b3f9bdbf460d50b4efec56cd5e685b ./ha-addon/Dockerfile
adf7ff248d782a3b1cba82b8789c57b6849ddaaaf5fbc108af75e18b1534a885 ./ha-addon/README.md
@@ -21,7 +21,7 @@ b7f0873109c52be9d7f09bea3dffc416103c50085e1f0680d11661a969479898 ./ha-addon/hom
6509286c7b7de638dcc5d40ce53181e65d1eddc5dc36a13306dc6d87220fedbb ./ha-addon/home-assistant/custom_components/gree_controller/coordinator.py
5a96fe8f5c035c34f1339370270cd078056202d09e236dec75735be11de92a7d ./ha-addon/home-assistant/custom_components/gree_controller/entity_map.py
c4fb75c246db651087900ebfc2291ff41ac87652cd6194fc0b776b0005c1cbcf ./ha-addon/home-assistant/custom_components/gree_controller/icon.png
a6d69fad7ea1f22436e9f643ceb36c206f1450d8337aede0c5d7a1f0613baeeb ./ha-addon/home-assistant/custom_components/gree_controller/manifest.json
d467809d894b8cb9e8e04a2cc279e0cec3227c1d1c689098bdc3c0380aa9043e ./ha-addon/home-assistant/custom_components/gree_controller/manifest.json
c52a484b671ce738ecc00228a1db19a5d703f69ab27530c2896a5c2ae3b4a96f ./ha-addon/home-assistant/custom_components/gree_controller/number.py
39c4309001b75abb56234f05662bc06e077054986876f1927937edbce528ec95 ./ha-addon/home-assistant/custom_components/gree_controller/select.py
cca65482e36d48035aca178121a378fe7d578d600acff267ae81a6399c0da653 ./ha-addon/home-assistant/custom_components/gree_controller/sensor.py
@@ -29,10 +29,10 @@ cca65482e36d48035aca178121a378fe7d578d600acff267ae81a6399c0da653 ./ha-addon/hom
a745cc458c235158d5d70b6a33d8000fd8b40ff93bfe2f2796af0f4b3a9eef9b ./ha-addon/home-assistant/custom_components/gree_controller/translations/en.json
50b7d6fe6329ad4ea6bee9612ab141c2f73f8382fcc2af0bb076a8e8d4dd4465 ./ha-addon/home-assistant/custom_components/gree_controller/translations/pl.json
c5fc5c87273d82d2834a0a5a365c821a416413ed509613441e24e4dc3507c4d2 ./ha-addon/home-assistant/generated/gree_controller_entities.example.json
ea5c4eb4ca2656f8d9a2be37a35ba04bb4c53d735c9c04b416b112e456699cff ./ha-addon/repository/gree-controller/CHANGELOG.md
e3a227ffe23368b152d0ba4a4e81a95b70d35e9870b2a8dc7b2048d6901a7f05 ./ha-addon/repository/gree-controller/CHANGELOG.md
1b5599195329cc3e643c23aa1caadcec80f133e8e515eec7d92b9f3cbda5b80b ./ha-addon/repository/gree-controller/DOCS.md
c3db302f38d26dc9f8caf37a1a8a07a1334cc07ae301d452f2940a5ec5915719 ./ha-addon/repository/gree-controller/README.md
c16d31ae815dc4e324ce56b0b46529bcc78c5223688c33aa30d7463abe4f1184 ./ha-addon/repository/gree-controller/config.yaml
21b82216d0d84cc66ecbed69457caf61174fa9304528fe051c98e8ef7c634025 ./ha-addon/repository/gree-controller/config.yaml
3aae6cdae4c3aaab7786a9575e7093b7e2c5b69288167075f4b7475611691a6b ./ha-addon/repository/gree-controller/topologia-ha-vlan-gree-pl.png
0d0b42a7639b128946eec379175436fa1f150294f39d2dfd169077bfa87fd14d ./ha-addon/repository/gree-controller/topology-ha-vlan-gree-en.png
2272740b185e9520e948f74b8f45cf98b8b5fd28c93bdee760da626f91fe5c2a ./ha-addon/repository/gree-controller/translations/en.yaml
@@ -42,8 +42,8 @@ cea12bc74c066a588a5ef0ef7bad9d4906bdbcea99f2d275429b0eefc94b4b5f ./ha-addon/run
081e27b6e43070d44f865335c30a42a5a29668eaa6a5ea5ab6967ac4447b8e06 ./ha-addon/sync-repository.sh
f9e31de3d109ead32bf531c074d46a087df398d62488caee0b46b5e0a1c027f7 ./ha_addon.md
253a0bc912786e67ea7fc92a64e4a510ad973bec343a88ccfb1f28fca3e8cf01 ./lang/README.md
f7fc1aee1129cee46a30d5876e3e53f11d2661925eae19cb1e14b47deae83e9e ./lang/en.json
c05d100e9efa1a82747ffc709157a9bdd6d109b7a80c6e140d582a28d8c3de13 ./lang/pl.json
e5c6a6051381e28d74b75e48232de13d02eb87519d29c847b7ab7979ce47d082 ./lang/en.json
02c650f603bca1bd17d9d59193c176ee6ddccb8304f2e658d7ba7730f8ff5f83 ./lang/pl.json
d8459024f04ca514bd8e9d6bd3af872fb942fd85a7cdd5583f4a9d28aab6faba ./make_zip.py
b14233a8987e53bbbdd6770386ba10fa166ec25c1e098275c173c544b37846fc ./presets/bedroom_window_night.json
1960841119c0b673fb2f03621b32237552fd828045304cd221401214b997c910 ./presets/device_resilience.json
@@ -106,7 +106,7 @@ b24092f34a46626d64628b510eba82193f0b203fd097f6a621b91af5d91eb571 ./src/api/auth
ca21f6d5a407e2e3532899d53ba4e8682c2fe961a46d8cd870ddc459721513ef ./src/api/device_groups.rs
5245d5a70c4a1919d8b7757db399e1caf692c8b3a8af0343d8e462bf90373137 ./src/api/devices.rs
4b5b784670ae761a6848b9a33fe3fb391bebd39d65e87936907428711e96ca44 ./src/api/events.rs
b9a9a68e47fdaa577c28063fa68bf95075bc355bff81addca4ee448ef70dd818 ./src/api/flows.rs
2ef2f64b77886e2abd6b410adf8de91a2333d3ebdb8fcee8376ba566e5bba029 ./src/api/flows.rs
82c02fa135132ea3ebdb4d88dd1210dc536d22f987151d7f82595ee3d3d03440 ./src/api/gree_cloud.rs
b61b0b4f196c5e72e47cf06ca0c14df3cf8eedbdef0242a9d2af33623663489f ./src/api/groups.rs
3cc64b9c992805f3fbf01c0785cf29ce3121750bf30bd6420ed6e5cf2f8df422 ./src/api/history.rs
@@ -202,19 +202,19 @@ b92a6cb158b494fe145b43c7641e65f6fafff47201d7d76edbec2cfd8b94835c ./systemd/gree
2822a98a4d7f72f2b89b70878aa0f9bd3f391fde4ea04ba1b94e89a527dcd1fb ./web/css/styles.css
183d122ba366303d9952ac0e68aff4bced4e0d9a468fbe51316c13f15227402b ./web/custom-chart.html
e98bdd7204349cce1ec6f57283509697af0bbc72280622a6c3efa6fed242db4f ./web/favicon.svg
263d5554e62d2dd26b251f2c43a7e4ec14c8d8d790fa67e55c9bebf16f9d55c0 ./web/index.html
d5115a9dc59b4e753dcff9239a77fe49886951d7a4ae7456be80d9201cc42365 ./web/index.html
9ba3f6fa05b72aa989139b2a909982571b2a02055052e4c40104f1e81a9aa7eb ./web/js-dynamic/README.md
e74bb06dba646f298a4d118b5c2dca6ba24f1179ce8ea8d53c1acd6000ca332c ./web/js-dynamic/bootstrap.js
5d57253a013fd1cb79e4b8701d4be6e394e3f2acbce73dfcd726a86e5878f1a1 ./web/js-dynamic/charts.js
bb2b49361103145bd8c502f2867962d48ced409c1a1474af1214c4d8d0b378b2 ./web/js-dynamic/core.js
b5fd43eeb9c15fe8289b9e718dfbc2651417379462c4b16b88e090b436abf7c5 ./web/js-dynamic/core.js
752b3c3b59558b42d867d4a15329238f0213f354b76f7011c10e31b4e6dbb311 ./web/js-dynamic/dashboard.js
0e1992a7b93767f1fe4634121ae5aa43900d99f8c2614c2a4cbd5770a49b87c1 ./web/js-dynamic/entities.js
eeeef31f767f0aa3efb78bc44a2fea2b9eb86f4d4c50cf1ccae69b5ca30cdfda ./web/js-dynamic/events.js
22da18288de3282245f88f9f525d9ad29ec027571e7ea893720a9470170eccd2 ./web/js-dynamic/flows.js
6b166a9311e30fe948bd3236189436eea769c238f963ba00acec5cdb51ff1dd7 ./web/js-dynamic/entities.js
a48cb69ae4a2d0d6d512baef346fe499a5a6ea267e071856829df982d4a0f129 ./web/js-dynamic/events.js
8e0f294ceeec1d258edb56271469a8a05381e6443a475f76fbeded6637d17503 ./web/js-dynamic/flows.js
3ae714de6b71c068ba665c81a7689f994b1716284e8d937db669ad500f12cf63 ./web/js-dynamic/forms.js
92d013bb5d64f64aa8c55380704540a5f72732bde7ccd16554965e401daf50ea ./web/js-dynamic/history.js
511c4e7cc4c48fbbdab64558a9d1c27e6759c9b6dca255d11f301b9708e955c7 ./web/js-dynamic/main.js
86031d4f7f7d1b46412c8fbb09ff1ad1b2b4cd84eb949bc749b27663f1dc27fa ./web/js-dynamic/navigation.js
c974b6ef41ce8b6def35f5349849a06855c46ec31cb4122a39fcb5c78f6d362a ./web/js-dynamic/navigation.js
0e398bad2cde85d4a62d8733c06e73f2b6a5b07db791d4570feba38c7ff8741f ./web/js-dynamic/realtime.js
1b667f0a8bce824b27f56a8d2f44ff8feef9a011289591dc629ee288db70d137 ./web/js-dynamic/router.js
c2710fd9c1f2b31878c1e25ceb02d74ae30456c1bbb400e69fcf7619ecc72bc2 ./web/js-dynamic/select-ui.js
+9 -3
View File
@@ -1,6 +1,6 @@
# GREE Controller API reference
HTTP and WebSocket API for GREE Controller **0.15.1**.
HTTP and WebSocket API for GREE Controller **0.15.2**.
[← Main documentation](../README.md)
@@ -240,7 +240,7 @@ Response:
{
"status": "ok",
"name": "gree-controller",
"version": "0.15.1",
"version": "0.15.2",
"uptime_seconds": 1234,
"control_ready": true,
"time": "2026-08-30T06:54:00Z"
@@ -303,7 +303,7 @@ Returns the initial Web UI snapshot:
"control_plan": {"generated_at": "2026-09-04T08:00:00Z", "zones": [], "rules": []},
"control_plan_revision": 42,
"system": {
"version": "0.15.1",
"version": "0.15.2",
"uptime_seconds": 1234,
"auth_required": false,
"control_ready": true,
@@ -1694,6 +1694,12 @@ Additional Flow condition blocks are `house_mode`, `device_state`, `zone_state`,
## Single-function unit automation — 0.15.2
Visual Flow adds `device_feature_action`, a dedicated action block that targets one GREE unit and sends exactly one `DeviceCommand` field. Supported fields are power, HVAC mode, target temperature, fan speed, vertical/horizontal louver position, Quiet, Turbo, Panel Light, Air, X-Fan, Health and Sleep. The block compiles to the normal Flow-owned `Automation` path, so cooldown, ownership checks and thermostat conflict protection remain unchanged.
Legacy direct-device automations expose fan speed and the same optional Quiet/Turbo/Panel Light/Air/X-Fan/Health/Sleep fields. Leaving all other fields as `No change` sends only the selected function.
## Granular louver positions — 0.15.0
Vertical (`swing_vertical`) and horizontal (`swing_horizontal`) controls now carry the raw GREE louver position instead of only on/off. Vertical supports `0..11`; horizontal supports `0..6`. The first values map to Off, Full range (Auto) and the fixed positions shown by GREE remotes/apps; vertical `7..11` additionally represent partial swing ranges.
+2 -1
View File
@@ -17,6 +17,7 @@ Flow is an authoring/orchestration layer. It does not replace the thermostat, sc
- `zone_thermostat` writes the existing persistent Zone intent and wakes the normal thermostat cycle. It can also issue one-shot granular vertical/horizontal louver commands to the assigned unit; these auxiliary fields do not take over thermostat ownership.
- `group_action` calls the existing `control_group` path, including custom group targets.
- `device_action` uses the existing `DeviceCommand` / automatic-device path.
- `device_feature_action` uses the same path but compiles exactly one selected `DeviceCommand` field, leaving all other unit settings untouched.
- Thermostat hysteresis, compressor protection, schedule hand-back, manual/local ownership and Temporary Quick Thermostat keep their existing priority.
- GREE fan/quiet/sleep and dry/fan HVAC modes are suppressed on thermostat-assigned devices when they would fight the regulator. Device-only features such as light/turbo/louver/air/xfan/health remain available through the normal command path.
@@ -61,7 +62,7 @@ Dry-run never mutates thermostats, devices, groups, schedules or automations. Fo
Condition/source blocks include weekday, time range, date range, optional 5-field CRON triggers, application Night mode, outdoor/device/zone temperature, house mode, device state, thermostat/zone state, group state, Home Assistant state/numeric/attribute/availability, rolling mean/median windows, numeric oscillation detection, a diagnostic constant, and `shared_input`. Stateful gates include `stable_for`, bounded `state_duration`, `on_change` (condition-result or raw-value edge), `rate_limit` (maximum successful executions in a rolling period), and `delay`. Their runtime state, together with rolling statistics and oscillation samples, is persisted in the generated automation payload so restarts do not silently reset timing/history. Editing a stateful block resets only that block's incompatible runtime state. Flow state continues to be observed while the action itself is in cooldown, preventing stale continuity/change state. A `rate_limit` block must be placed directly before an action and consumes quota only after a successful execution; suppressed or failed actions do not consume it. Shared inputs are configured centrally in Home Assistant / Sensors and referenced by ID. Shared inputs store reusable value sources only; operators and comparison values are configured exclusively in each Flow reference. Logic blocks are AND, OR and NOT.
Actions include thermostat zone, GREE device, climate group, and generic Home Assistant service calls (`domain`, `service`, optional `entity_id`, JSON service data). Direct GREE actions expose power, HVAC mode, target temperature, fan speed, vertical/horizontal louver position, quiet, turbo, light, air, xfan, health and sleep.
Actions include thermostat zone, full GREE device command, single GREE function, climate group, and generic Home Assistant service calls (`domain`, `service`, optional `entity_id`, JSON service data). Direct GREE actions expose power, HVAC mode, target temperature, fan speed, vertical/horizontal louver position, quiet, turbo, light, air, xfan, health and sleep. The single-function block writes exactly one of those fields.
## Built-in presets
+3 -3
View File
File diff suppressed because one or more lines are too long
@@ -1,7 +1,7 @@
{
"domain": "gree_controller",
"name": "GREE Controller",
"version": "0.15.1",
"version": "0.15.2",
"config_flow": true,
"integration_type": "hub",
"iot_class": "local_polling",
@@ -1,5 +1,10 @@
# Changelog
## 0.15.2
- Adds a dedicated Visual Flow action for changing exactly one GREE unit function without modifying unrelated settings.
- Legacy device automations now expose fan speed plus Quiet, Turbo, Panel Light, Air, X-Fan, Health and Sleep as optional single-field controls.
## 0.15.1
- Web UI now treats temporary WebSocket loss as a reconnecting state, escalates to a connection error only after 15 seconds, and returns cleanly to connected after recovery while preserving the existing HTTP fallback.
@@ -1,5 +1,5 @@
name: "GREE Controller"
version: "0.15.1"
version: "0.15.2"
slug: "gree_controller"
description: "Local GREE HVAC controller with Web UI and Home Assistant integration"
url: "https://git.linuxiarz.pl/gru/gree-controller-ha-addon/"
+6
View File
@@ -652,6 +652,9 @@
"groups.noZones": "Create thermostat zones before creating a group.",
"automations.targetType": "Target type",
"automations.groupHint": "For a group, Auto means follow the whole-house heating/cooling mode. Group profiles are applied to all member zones.",
"automations.singleDeviceFunctions": "Single device functions",
"automations.deviceFunction": "Unit function",
"automations.singleDeviceFunctionsHint": "Leave the other fields as No change to modify only the selected unit function. Fan, Quiet and Sleep still follow the existing thermostat ownership rules.",
"simulation.openTab": "Open simulator only in new tab",
"simulation.fullscreen": "Full screen",
"simulation.exitFullscreen": "Exit full screen",
@@ -952,6 +955,7 @@
"flow.node.not": "NOT",
"flow.node.thermostat": "Thermostat",
"flow.node.greeDevice": "GREE device",
"flow.node.greeFeature": "Single GREE function",
"flow.node.group": "Group",
"common.yes": "Yes",
"common.no": "No",
@@ -1002,6 +1006,8 @@
"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. Louver positions are one-shot auxiliary settings; 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.deviceFeature": "Unit function",
"flow.deviceFeatureHint": "This action sends exactly one DeviceCommand field. All other unit settings remain unchanged.",
"settings.haConnection": "Home Assistant connection",
"settings.haConnectionHint": "Server address and credentials used by all Home Assistant features.",
"settings.haThermostatSources": "Thermostat temperature sources",
+6
View File
@@ -652,6 +652,9 @@
"groups.noZones": "Najpierw utwórz strefy termostatów.",
"automations.targetType": "Typ celu",
"automations.groupHint": "Dla grupy Auto oznacza dziedziczenie globalnego trybu grzania/chłodzenia. Profil grupy jest stosowany do wszystkich stref w grupie.",
"automations.singleDeviceFunctions": "Pojedyncze funkcje urządzenia",
"automations.deviceFunction": "Funkcja jednostki",
"automations.singleDeviceFunctionsHint": "Pozostaw pozostałe pola jako Bez zmian, aby zmienić wyłącznie wybraną funkcję jednostki. Fan, Quiet i Sleep nadal respektują istniejące zasady właściciela termostatu.",
"simulation.openTab": "Otwórz sam symulator w nowej karcie",
"simulation.fullscreen": "Pełny ekran",
"simulation.exitFullscreen": "Wyjdź z pełnego ekranu",
@@ -952,6 +955,7 @@
"flow.node.not": "NOT",
"flow.node.thermostat": "Termostat",
"flow.node.greeDevice": "Urządzenie GREE",
"flow.node.greeFeature": "Pojedyncza funkcja GREE",
"flow.node.group": "Grupa",
"common.yes": "Tak",
"common.no": "Nie",
@@ -1002,6 +1006,8 @@
"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. Pozycje żaluzji są jednorazowymi ustawieniami pomocniczymi; 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.deviceFeature": "Funkcja jednostki",
"flow.deviceFeatureHint": "Ta akcja wysyła dokładnie jedno pole DeviceCommand. Pozostałe ustawienia jednostki nie są zmieniane.",
"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",
+114 -17
View File
@@ -63,6 +63,66 @@ fn flow_louver_position(config: &Value, key: &str) -> Option<u8> {
}
}
fn flow_device_feature_command(config: &Value) -> Result<DeviceCommand, AppError> {
let feature = flow_string(config, "feature")
.ok_or_else(|| AppError::BadRequest("device feature action needs a feature".into()))?;
let mut command = DeviceCommand::default();
match feature.as_str() {
"power" => {
command.power = Some(flow_bool(config, "value").ok_or_else(|| {
AppError::BadRequest("device feature power needs a boolean value".into())
})?);
}
"mode" => {
command.mode = Some(flow_string(config, "value").ok_or_else(|| {
AppError::BadRequest("device feature mode needs a value".into())
})?);
}
"target_temperature" => {
command.target_temperature = Some(flow_f64(config, "value").ok_or_else(|| {
AppError::BadRequest("device feature target temperature needs a numeric value".into())
})?);
}
"fan_speed" => {
command.fan_speed = Some(flow_u8(config, "value").ok_or_else(|| {
AppError::BadRequest("device feature fan speed needs an integer value".into())
})?);
}
"swing_vertical" => {
command.swing_vertical = Some(flow_louver_position(config, "value").ok_or_else(|| {
AppError::BadRequest("device feature vertical louver needs a position".into())
})?);
}
"swing_horizontal" => {
command.swing_horizontal = Some(flow_louver_position(config, "value").ok_or_else(|| {
AppError::BadRequest("device feature horizontal louver needs a position".into())
})?);
}
"quiet" | "turbo" | "light" | "air" | "xfan" | "health" | "sleep" => {
let value = flow_bool(config, "value").ok_or_else(|| {
AppError::BadRequest("device feature action needs an on/off value".into())
})?;
match feature.as_str() {
"quiet" => command.quiet = Some(value),
"turbo" => command.turbo = Some(value),
"light" => command.light = Some(value),
"air" => command.air = Some(value),
"xfan" => command.xfan = Some(value),
"health" => command.health = Some(value),
"sleep" => command.sleep = Some(value),
_ => unreachable!(),
}
}
_ => {
return Err(AppError::BadRequest(format!(
"unsupported device feature action: {feature}"
)))
}
}
engine::validate_command(&command)?;
Ok(command)
}
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]))
@@ -104,7 +164,11 @@ fn flow_logic_kind(kind: &str) -> bool {
fn flow_action_kind(kind: &str) -> bool {
matches!(
kind,
"zone_thermostat" | "device_action" | "group_action" | "ha_service_action"
"zone_thermostat"
| "device_action"
| "device_feature_action"
| "group_action"
| "ha_service_action"
)
}
@@ -1332,6 +1396,18 @@ fn compile_flow(
return Err(AppError::BadRequest("device action cannot be empty".into()));
}
}
"device_feature_action" => {
let id = flow_string(&action_node.config, "device_id").ok_or_else(|| {
AppError::BadRequest("device feature action needs a device".into())
})?;
if !devices.iter().any(|d| d.id == id) {
return Err(AppError::BadRequest(
"device feature action references a missing device".into(),
));
}
item.action_device_id = id;
item.action = flow_device_feature_command(&action_node.config)?;
}
"ha_service_action" => {
let domain = flow_string(&action_node.config, "domain").ok_or_else(|| {
AppError::BadRequest("Home Assistant action needs a domain".into())
@@ -1708,7 +1784,7 @@ fn dry_run_block_reason(
}
Ok(None)
}
"device_action" => {
"device_action" | "device_feature_action" => {
let Some(device_id) = flow_string(&action.config, "device_id") else {
return Ok(Some("missing_device".into()));
};
@@ -1737,25 +1813,30 @@ fn dry_run_block_reason(
}) {
return Ok(Some("temporary_quick_thermostat".into()));
}
let command = if action.kind == "device_feature_action" {
flow_device_feature_command(&action.config)?
} else {
let mut command = DeviceCommand::default();
command.power = flow_bool(&action.config, "power");
command.mode = flow_string(&action.config, "mode");
command.target_temperature = flow_f64(&action.config, "target_temperature");
command.fan_speed = flow_u8(&action.config, "fan_speed");
command.swing_vertical = flow_louver_position(&action.config, "swing_vertical");
command.swing_horizontal = flow_louver_position(&action.config, "swing_horizontal");
command.quiet = flow_bool(&action.config, "quiet");
command.turbo = flow_bool(&action.config, "turbo");
command.light = flow_bool(&action.config, "light");
command.air = flow_bool(&action.config, "air");
command.xfan = flow_bool(&action.config, "xfan");
command.health = flow_bool(&action.config, "health");
command.sleep = flow_bool(&action.config, "sleep");
command
};
if zones.iter().any(|z| z.device_id == device_id && !z.enabled)
&& flow_bool(&action.config, "power") != Some(true)
&& command.power != Some(true)
{
return Ok(Some("zone_disabled".into()));
}
let mut command = DeviceCommand::default();
command.power = flow_bool(&action.config, "power");
command.mode = flow_string(&action.config, "mode");
command.target_temperature = flow_f64(&action.config, "target_temperature");
command.fan_speed = flow_u8(&action.config, "fan_speed");
command.swing_vertical = flow_louver_position(&action.config, "swing_vertical");
command.swing_horizontal = flow_louver_position(&action.config, "swing_horizontal");
command.quiet = flow_bool(&action.config, "quiet");
command.turbo = flow_bool(&action.config, "turbo");
command.light = flow_bool(&action.config, "light");
command.air = flow_bool(&action.config, "air");
command.xfan = flow_bool(&action.config, "xfan");
command.health = flow_bool(&action.config, "health");
command.sleep = flow_bool(&action.config, "sleep");
if zones.iter().any(|z| z.device_id == device_id)
&& engine::automation_action_conflicts_with_thermostat(&command)
{
@@ -1950,4 +2031,20 @@ mod flow_draft_tests {
assert!(flow.compiled_schedule_ids.is_empty());
assert!(flow.compiled_automation_ids.is_empty());
}
#[test]
fn device_feature_action_builds_only_selected_field() {
let command = flow_device_feature_command(&json!({"feature": "light", "value": true}))
.expect("valid feature action");
assert_eq!(command.light, Some(true));
assert!(command.power.is_none());
assert!(command.mode.is_none());
assert!(command.swing_vertical.is_none());
}
#[test]
fn device_feature_action_validates_louver_range() {
let result = flow_device_feature_command(&json!({"feature": "swing_vertical", "value": 99}));
assert!(result.is_err());
}
}
+24 -1
View File
@@ -982,7 +982,8 @@
<div class="flow-palette-group block-category-action"><span data-i18n="flow.actions">Akcje</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.greeDevice">Urządzenie GREE</button><button type="button" data-flow-add="device_feature_action"
data-i18n="flow.node.greeFeature">Pojedyncza funkcja GREE</button><button type="button" data-flow-add="group_action"
data-i18n="flow.node.group">Grupa</button>
</div>
<div class="flow-palette-group block-category-haaction"><span data-i18n="flow.haActions">Home Assistant</span>
@@ -1786,6 +1787,28 @@
<option value="6" data-i18n="louver.horizontal.fixedRight">Fixed: Right</option>
</select></label>
</div>
<details id="automationDeviceFeatureOptions" class="flow-device-options">
<summary data-i18n="automations.singleDeviceFunctions">Single device functions</summary>
<div class="two">
<label><span data-i18n="common.fan">Fan</span><select name="action_fan_speed">
<option value="" data-i18n="actions.noChange">No change</option>
<option value="0" data-i18n="fan.auto">Auto</option>
<option value="1" data-i18n="fan.low">Low</option>
<option value="2" data-i18n="fan.mediumLow">Medium-low</option>
<option value="3" data-i18n="fan.medium">Medium</option>
<option value="4" data-i18n="fan.mediumHigh">Medium-high</option>
<option value="5" data-i18n="fan.high">High</option>
</select></label>
<label><span data-i18n="devices.quiet">Quiet</span><select name="action_quiet"><option value="" data-i18n="actions.noChange">No change</option><option value="true" data-i18n="common.on">On</option><option value="false" data-i18n="common.off">Off</option></select></label>
<label><span data-i18n="devices.turbo">Turbo</span><select name="action_turbo"><option value="" data-i18n="actions.noChange">No change</option><option value="true" data-i18n="common.on">On</option><option value="false" data-i18n="common.off">Off</option></select></label>
<label><span data-i18n="devices.light">Panel light</span><select name="action_light"><option value="" data-i18n="actions.noChange">No change</option><option value="true" data-i18n="common.on">On</option><option value="false" data-i18n="common.off">Off</option></select></label>
<label><span data-i18n="devices.air">Air</span><select name="action_air"><option value="" data-i18n="actions.noChange">No change</option><option value="true" data-i18n="common.on">On</option><option value="false" data-i18n="common.off">Off</option></select></label>
<label><span data-i18n="devices.xfan">X-Fan</span><select name="action_xfan"><option value="" data-i18n="actions.noChange">No change</option><option value="true" data-i18n="common.on">On</option><option value="false" data-i18n="common.off">Off</option></select></label>
<label><span data-i18n="devices.health">Health</span><select name="action_health"><option value="" data-i18n="actions.noChange">No change</option><option value="true" data-i18n="common.on">On</option><option value="false" data-i18n="common.off">Off</option></select></label>
<label><span data-i18n="devices.sleep">Sleep</span><select name="action_sleep"><option value="" data-i18n="actions.noChange">No change</option><option value="true" data-i18n="common.on">On</option><option value="false" data-i18n="common.off">Off</option></select></label>
</div>
<p class="field-note" data-i18n="automations.singleDeviceFunctionsHint">Leave every other field as No change to modify only the selected unit function.</p>
</details>
<p class="field-note" id="automationGroupHint" hidden data-i18n="automations.groupHint">For a group, Auto mode
means follow the whole-house heating/cooling mode. Group presets are applied to all member zones.</p>
<label class="check"><input type="checkbox" name="enabled" checked> <span
+3
View File
@@ -115,6 +115,9 @@ const houseModeLabel = mode => mode === 'off' ? tr('house.noControl') : modeLabe
const fanLabel = value => ({ 0: 'fan.auto', 1: 'fan.low', 2: 'fan.mediumLow', 3: 'fan.medium', 4: 'fan.mediumHigh', 5: 'fan.high' }[value] ? tr({ 0: 'fan.auto', 1: 'fan.low', 2: 'fan.mediumLow', 3: 'fan.medium', 4: 'fan.mediumHigh', 5: 'fan.high' }[value]) : value);
const deviceCommandFieldLabel = key => {
const translationKey = {
power: 'common.power',
mode: 'common.mode',
target_temperature: 'common.targetC',
fan_speed: 'common.fan',
swing_vertical: 'devices.swingVertical',
swing_horizontal: 'devices.swingHorizontal',
+15 -1
View File
@@ -712,6 +712,18 @@ function renderSchedules() {
}).join('') : `<div class="empty"><strong>${esc(tr('schedules.emptyTitle'))}</strong>${esc(tr('schedules.emptyText'))}</div>`;
}
function automationDeviceFunctionSummary(action = {}) {
const parts = [];
if (action.target_temperature != null) parts.push(fmtTemp(action.target_temperature));
if (action.fan_speed != null) parts.push(`${deviceCommandFieldLabel('fan_speed')}: ${fanLabel(action.fan_speed)}`);
if (action.swing_vertical != null) parts.push(`${deviceCommandFieldLabel('swing_vertical')}: ${louverPositionLabel('vertical', action.swing_vertical)}`);
if (action.swing_horizontal != null) parts.push(`${deviceCommandFieldLabel('swing_horizontal')}: ${louverPositionLabel('horizontal', action.swing_horizontal)}`);
['quiet', 'turbo', 'light', 'air', 'xfan', 'health', 'sleep'].forEach(key => {
if (action[key] != null) parts.push(`${deviceCommandFieldLabel(key)}: ${action[key] ? tr('common.on') : tr('common.off')}`);
});
return parts.join(' · ') || '—';
}
function renderAutomations() {
$('#automationList').innerHTML = app.automations.length ? app.automations.map(item => {
const actionZone = item.action_zone_id ? app.zones.find(zone => zone.id === item.action_zone_id) : null;
@@ -720,8 +732,10 @@ function renderAutomations() {
const targetName = actionZone?.name || actionGroup?.name || actionDevice?.name || tr('common.noDevice');
const mode = item.action.mode === 'auto' && (actionGroup || actionZone) ? tr('groups.followHouse') : (item.action.mode ? modeLabel(item.action.mode) : '—');
const preset = item.action_zone_preset ? zonePresetLabel(item.action_zone_preset) : (item.action_preset ? zonePresetLabel(item.action_preset) : '—');
const deviceFunction = actionZone || actionGroup ? preset : automationDeviceFunctionSummary(item.action);
const deviceFunctionLabel = actionZone || actionGroup ? tr('groups.profile') : tr('automations.deviceFunction');
return `<article class="list-card ${item.flow_id ? 'flow-generated-card' : ''}"><div class="list-card-head"><div><h3>${esc(item.name)}</h3><p>${esc(tr('automations.triggerSummary', { trigger: automationTriggerLabel(item), device: targetName }))}</p></div><span class="badge ${item.enabled ? 'active' : ''}">${esc(item.enabled ? tr('common.active') : tr('common.disabled'))}</span></div>
<div class="card-stats"><div class="card-stat"><small>${esc(tr('common.power'))}</small><strong>${item.action.power == null ? '' : item.action.power ? tr('common.on') : tr('common.off')}</strong></div><div class="card-stat"><small>${esc(tr('common.mode'))}</small><strong>${esc(mode)}</strong></div><div class="card-stat"><small>${esc(tr('groups.profile'))}</small><strong>${esc(preset)}</strong></div><div class="card-stat"><small>${esc(tr('automations.last'))}</small><strong>${item.last_fired_at ? new Date(item.last_fired_at).toLocaleTimeString(locale(), { hour: '2-digit', minute: '2-digit' }) : ''}</strong></div></div>
<div class="card-stats"><div class="card-stat"><small>${esc(tr('common.power'))}</small><strong>${item.action.power == null ? '' : item.action.power ? tr('common.on') : tr('common.off')}</strong></div><div class="card-stat"><small>${esc(tr('common.mode'))}</small><strong>${esc(mode)}</strong></div><div class="card-stat"><small>${esc(deviceFunctionLabel)}</small><strong>${esc(deviceFunction)}</strong></div><div class="card-stat"><small>${esc(tr('automations.last'))}</small><strong>${item.last_fired_at ? new Date(item.last_fired_at).toLocaleTimeString(locale(), { hour: '2-digit', minute: '2-digit' }) : ''}</strong></div></div>
<div class="card-footer"><small>${esc(item.flow_id ? tr('flow.generatedReadOnly') : (actionGroup ? `${tr('groups.group')}: ${targetName}` : `${tr('common.device')}: ${targetName}`))} · ${esc(tr('common.cooldown'))} ${item.cooldown_seconds}s</small><div class="card-menu">${item.flow_id ? `<button data-action="edit-flow" data-id="${esc(item.flow_id)}">${esc(tr('flow.openEditor'))}</button>` : `<button data-action="edit-automation" data-id="${esc(item.id)}">${esc(tr('actions.edit'))}</button><details class="card-overflow"><summary aria-label="${esc(tr('actions.more'))}" title="${esc(tr('actions.more'))}">${uiIcon('more-horizontal')}</summary><div class="card-overflow-menu"><button class="danger" data-action="delete-automation" data-id="${esc(item.id)}">${esc(tr('actions.delete'))}</button></div></details>`}</div></div></article>`;
}).join('') : `<div class="empty"><strong>${esc(tr('automations.emptyTitle'))}</strong>${esc(tr('automations.emptyText'))}</div>`;
}
+1 -1
View File
@@ -643,7 +643,7 @@ $('#scheduleForm').addEventListener('submit', async event => {
$('#automationForm').addEventListener('submit', async event => {
event.preventDefault(); const form = event.currentTarget, raw = Object.fromEntries(new FormData(form)); const id = raw.id;
const groupTarget = raw.action_target_kind === 'group';
const action = {}; if (raw.action_power !== '') action.power = raw.action_power === 'true'; if (raw.action_mode) action.mode = raw.action_mode; if (!groupTarget && raw.action_target_temperature !== '') action.target_temperature = parseDecimal(raw.action_target_temperature); if (!groupTarget && raw.action_swing_vertical !== '') action.swing_vertical = Number(raw.action_swing_vertical); if (!groupTarget && raw.action_swing_horizontal !== '') action.swing_horizontal = Number(raw.action_swing_horizontal);
const action = {}; if (raw.action_power !== '') action.power = raw.action_power === 'true'; if (raw.action_mode) action.mode = raw.action_mode; if (!groupTarget && raw.action_target_temperature !== '') action.target_temperature = parseDecimal(raw.action_target_temperature); if (!groupTarget && raw.action_fan_speed !== '') action.fan_speed = Number(raw.action_fan_speed); if (!groupTarget && raw.action_swing_vertical !== '') action.swing_vertical = Number(raw.action_swing_vertical); if (!groupTarget && raw.action_swing_horizontal !== '') action.swing_horizontal = Number(raw.action_swing_horizontal); if (!groupTarget) ['quiet', 'turbo', 'light', 'air', 'xfan', 'health', 'sleep'].forEach(key => { const value = raw[`action_${key}`]; if (value !== '') action[key] = value === 'true'; });
const body = { name: raw.name, enabled: form.enabled.checked, trigger_kind: raw.trigger_kind, trigger_device_id: raw.trigger_device_id || null, threshold: raw.threshold === '' ? null : parseDecimal(raw.threshold), at_time: raw.at_time || null, action_device_id: groupTarget ? '' : raw.action_device_id, action_group_id: groupTarget ? (raw.action_group_id || null) : null, action_preset: groupTarget ? (raw.action_preset || null) : null, action, cooldown_seconds: Number(raw.cooldown_seconds) };
await runFormTask(form, async () => {
await api(id ? `/api/automations/${encodeURIComponent(id)}` : '/api/automations', { method: id ? 'PUT' : 'POST', body }); form.closest('dialog').close(); form.reset(); await loadBootstrap(); toast(tr('common.saved'));
+45 -1
View File
@@ -29,6 +29,7 @@ const FLOW_NODE_META = Object.freeze({
logic_not: { titleKey: 'flow.node.not', category: 'logic' },
zone_thermostat: { titleKey: 'flow.node.thermostat', category: 'action' },
device_action: { titleKey: 'flow.node.greeDevice', category: 'action' },
device_feature_action: { titleKey: 'flow.node.greeFeature', category: 'action' },
group_action: { titleKey: 'flow.node.group', category: 'action' },
ha_service_action: { titleKey: 'flow.node.haServiceAction', category: 'haaction' },
});
@@ -97,6 +98,7 @@ function flowDefaultConfig(kind) {
if (kind === 'shared_input') return sharedFlowReferenceDefaultConfig(app.flowSharedInputs?.[0]);
if (kind === 'zone_thermostat') return { zone_id: app.zones[0]?.id || '', preset: 'comfort', setpoint: 21, mode: 'auto', power: null, swing_vertical: null, swing_horizontal: null, 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 === 'device_feature_action') return { device_id: app.devices[0]?.id || '', feature: 'light', value: true, cooldown_seconds: 60 };
if (kind === 'group_action') return { group_id: app.groups[0]?.id || '', power: true, mode: '', preset: '', setpoint: 21, cooldown_seconds: 60 };
if (kind === 'ha_service_action') return { domain: 'switch', service: 'turn_on', entity_id: '', data: {}, cooldown_seconds: 60 };
return {};
@@ -357,6 +359,7 @@ function flowNodeSummary(node) {
if (node.kind === 'logic_not') return tr('flow.invertCondition');
if (node.kind === 'zone_thermostat') { const zone = app.zones.find(z => z.id === c.zone_id); return `${zone?.name || tr('common.noZone')} · ${c.preset === 'custom' ? fmtTemp(c.setpoint) : zonePresetLabel(c.preset || 'comfort')}`; }
if (node.kind === 'device_action') { const d = app.devices.find(v => v.id === c.device_id); return `${d?.name || tr('common.noDevice')} · ${c.power == null ? tr('actions.noChange') : c.power ? tr('common.on') : tr('common.off')}`; }
if (node.kind === 'device_feature_action') { const d = app.devices.find(v => v.id === c.device_id); return `${d?.name || tr('common.noDevice')} · ${deviceCommandFieldLabel(c.feature || 'light')}${flowDeviceFeatureValueLabel(c.feature, c.value)}`; }
if (node.kind === 'group_action') { const g = app.groups.find(v => v.id === c.group_id); return `${g?.name || tr('groups.group')} · ${c.power == null ? tr('actions.noChange') : c.power ? tr('common.on') : tr('common.off')}`; }
if (node.kind === 'ha_service_action') return `${c.domain || 'domain'}.${c.service || 'service'} · ${c.entity_id || 'entity_id'}`;
return '';
@@ -550,6 +553,7 @@ function renderFlowInspector() {
else if (node.kind === 'logic_not') fields = `<p class="field-note">${esc(tr('flow.notHint'))}</p>`;
else if (node.kind === 'zone_thermostat') fields = `<label><span>${esc(tr('common.zone'))}</span><select data-flow-config="zone_id">${flowSelectOptions(app.zones, c.zone_id)}</select></label><div class="two"><label><span>${esc(tr('schedules.profile'))}</span><select data-flow-config="preset">${['auto', 'comfort', 'sleep', 'away', 'custom'].map(v => `<option value="${v}" ${c.preset === v ? 'selected' : ''}>${esc(zonePresetLabel(v))}</option>`).join('')}</select></label><label><span>${esc(tr('common.targetC'))}</span><input type="number" min="8" max="30" step="0.1" data-flow-config="setpoint" value="${Number(c.setpoint ?? 21)}"></label></div><div class="two"><label><span>${esc(tr('common.mode'))}</span><select data-flow-config="mode"><option value="auto" ${c.mode === 'auto' ? 'selected' : ''}>Auto</option><option value="heat" ${c.mode === 'heat' ? 'selected' : ''}>${esc(tr('mode.heat'))}</option><option value="cool" ${c.mode === 'cool' ? 'selected' : ''}>${esc(tr('mode.cool'))}</option></select></label><label><span>${esc(tr('common.power'))}</span><select data-flow-config="power"><option value="" ${c.power == null ? 'selected' : ''}>${esc(tr('actions.noChange'))}</option><option value="true" ${c.power === true ? 'selected' : ''}>${esc(tr('common.on'))}</option><option value="false" ${c.power === false ? 'selected' : ''}>${esc(tr('common.off'))}</option></select></label></div><details class="flow-device-options"><summary>${esc(tr('flow.thermostatDeviceOptions'))}</summary><div class="two">${flowOptionalLouverField(c, 'swing_vertical', 'vertical')}${flowOptionalLouverField(c, 'swing_horizontal', 'horizontal')}</div><p class="field-note">${esc(tr('flow.thermostatDeviceOptionsHint'))}</p></details><label><span>${esc(tr('common.cooldownSeconds'))}</span><input type="number" min="30" data-flow-config="cooldown_seconds" value="${Number(c.cooldown_seconds || 60)}"></label>`;
else if (node.kind === 'device_action') fields = `<label><span>${esc(tr('common.device'))}</span><select data-flow-config="device_id">${flowSelectOptions(app.devices, c.device_id)}</select></label>${flowActionFields(c, false)}`;
else if (node.kind === 'device_feature_action') fields = `<label><span>${esc(tr('common.device'))}</span><select data-flow-config="device_id">${flowSelectOptions(app.devices, c.device_id)}</select></label><label><span>${esc(tr('flow.deviceFeature'))}</span><select data-flow-config="feature">${flowDeviceFeatureOptions(c.feature)}</select></label>${flowDeviceFeatureValueField(c)}<p class="field-note">${esc(tr('flow.deviceFeatureHint'))}</p><label><span>${esc(tr('common.cooldownSeconds'))}</span><input type="number" min="30" data-flow-config="cooldown_seconds" value="${Number(c.cooldown_seconds || 60)}"></label>`;
else if (node.kind === 'group_action') fields = `<label><span>${esc(tr('groups.group'))}</span><select data-flow-config="group_id">${flowSelectOptions(app.groups, c.group_id)}</select></label>${flowActionFields(c, true)}`;
else if (node.kind === 'ha_service_action') fields = `<div class="two"><label><span>domain</span><input data-flow-config="domain" value="${esc(c.domain || 'switch')}" placeholder="switch"></label><label><span>service</span><input data-flow-config="service" value="${esc(c.service || 'turn_on')}" placeholder="turn_on"></label></div><p class="field-note">${esc(tr('flow.serviceExample'))}</p><label><span>entity_id</span><input data-flow-config="entity_id" list="haEntitySuggestions" value="${esc(c.entity_id || '')}" placeholder="switch.gas"></label><label><span>${esc(tr('flow.serviceData'))}</span><textarea rows="5" data-flow-config="data_json">${esc(JSON.stringify(c.data || {}, null, 2))}</textarea></label><label><span>${esc(tr('common.cooldownSeconds'))}</span><input type="number" min="30" data-flow-config="cooldown_seconds" value="${Number(c.cooldown_seconds || 60)}"></label>`;
host.innerHTML = `<div class="flow-inspector-head"><div><span class="eyebrow">${esc(tr('flow.blockSettings'))}</span><h3>${esc(flowNodeTitle(meta))}</h3></div><div class="flow-inspector-head-actions"><button type="button" class="secondary flow-inspector-expand" data-action="flow-toggle-inspector" aria-label="${esc(tr('flow.expandSettings'))}">${uiIcon('expand-vertical')}</button><button type="button" class="secondary flow-inspector-close" data-action="flow-clear-selection" aria-label="${esc(tr('flow.clearSelection'))}">${uiIcon('close')}</button><button type="button" class="danger flow-inspector-delete" data-flow-remove="${esc(node.id)}">${esc(tr('actions.delete'))}</button></div></div>${fields}<div class="flow-inspector-meta"><small>ID</small><code>${esc(node.id)}</code></div>`;
@@ -562,6 +566,40 @@ function flowLouverComparisonFields(c, axis) {
return `<div class="two"><label><span>${esc(tr('flow.operator'))}</span><select data-flow-config="operator">${options}</select></label><label><span>${esc(tr('flow.value'))}</span><select data-flow-config="value">${louverOptions(axis, value)}</select></label></div>`;
}
function flowComparisonFields(c, temperature = true) { return `<div class="two"><label><span>${esc(tr('flow.operator'))}</span><select data-flow-config="operator">${flowOperatorOptions(c.operator || 'lt')}</select></label><label><span>${esc(temperature ? tr('automations.thresholdC') : tr('flow.value'))}</span><input type="number" step="0.1" data-flow-config="value" value="${Number(c.value ?? 0)}"></label></div>`; }
const FLOW_DEVICE_FEATURES = Object.freeze(['power', 'mode', 'target_temperature', 'fan_speed', 'swing_vertical', 'swing_horizontal', 'quiet', 'turbo', 'light', 'air', 'xfan', 'health', 'sleep']);
const FLOW_DEVICE_BOOL_FEATURES = Object.freeze(['power', 'quiet', 'turbo', 'light', 'air', 'xfan', 'health', 'sleep']);
function flowDeviceFeatureDefaultValue(feature) {
if (FLOW_DEVICE_BOOL_FEATURES.includes(feature)) return true;
if (feature === 'mode') return 'auto';
if (feature === 'target_temperature') return 22;
if (feature === 'fan_speed') return 0;
if (['swing_vertical', 'swing_horizontal'].includes(feature)) return 1;
return true;
}
function flowDeviceFeatureOptions(selected) {
return FLOW_DEVICE_FEATURES.map(feature => `<option value="${feature}" ${feature === selected ? 'selected' : ''}>${esc(deviceCommandFieldLabel(feature))}</option>`).join('');
}
function flowDeviceFeatureValueLabel(feature, value) {
if (FLOW_DEVICE_BOOL_FEATURES.includes(feature)) return value === false ? tr('common.off') : tr('common.on');
if (feature === 'mode') return modeLabel(value || 'auto');
if (feature === 'target_temperature') return fmtTemp(value);
if (feature === 'fan_speed') return fanLabel(Number(value));
if (feature === 'swing_vertical') return louverPositionLabel('vertical', value);
if (feature === 'swing_horizontal') return louverPositionLabel('horizontal', value);
return value ?? '—';
}
function flowDeviceFeatureValueField(c) {
const feature = c.feature || 'light';
const value = c.value ?? flowDeviceFeatureDefaultValue(feature);
if (FLOW_DEVICE_BOOL_FEATURES.includes(feature)) return `<label><span>${esc(tr('flow.value'))}</span><select data-flow-config="value"><option value="true" ${value === true ? 'selected' : ''}>${esc(tr('common.on'))}</option><option value="false" ${value === false ? 'selected' : ''}>${esc(tr('common.off'))}</option></select></label>`;
if (feature === 'mode') return `<label><span>${esc(tr('flow.value'))}</span><select data-flow-config="value">${['auto', 'cool', 'dry', 'fan', 'heat'].map(mode => `<option value="${mode}" ${value === mode ? 'selected' : ''}>${esc(modeLabel(mode))}</option>`).join('')}</select></label>`;
if (feature === 'target_temperature') return `<label><span>${esc(tr('flow.value'))}</span><input type="number" min="8" max="30" step="0.5" data-flow-config="value" value="${Number(value)}"></label>`;
if (feature === 'fan_speed') return `<label><span>${esc(tr('flow.value'))}</span><select data-flow-config="value">${[0, 1, 2, 3, 4, 5].map(v => `<option value="${v}" ${Number(value) === v ? 'selected' : ''}>${esc(fanLabel(v))}</option>`).join('')}</select></label>`;
if (feature === 'swing_vertical') return `<label><span>${esc(tr('flow.value'))}</span><select data-flow-config="value">${louverOptions('vertical', value)}</select></label>`;
if (feature === 'swing_horizontal') return `<label><span>${esc(tr('flow.value'))}</span><select data-flow-config="value">${louverOptions('horizontal', value)}</select></label>`;
return '';
}
function flowOptionalBoolField(c, key) {
return `<label><span>${esc(deviceCommandFieldLabel(key))}</span><select data-flow-config="${esc(key)}"><option value="" ${c[key] == null ? 'selected' : ''}>${esc(tr('actions.noChange'))}</option><option value="true" ${c[key] === true ? 'selected' : ''}>${esc(tr('common.on'))}</option><option value="false" ${c[key] === false ? 'selected' : ''}>${esc(tr('common.off'))}</option></select></label>`;
}
@@ -1169,6 +1207,12 @@ function updateFlowConfig(input) {
const numeric = Boolean(sharedFlowInputLouverAxis(item)) || ['outdoor_temperature', 'device_temperature', 'zone_temperature', 'ha_numeric'].includes(item?.kind);
node.config.value = numeric ? (input.value === '' ? null : Number(input.value)) : input.value;
}
else if (node.kind === 'device_feature_action' && key === 'feature') { node.config.feature = input.value; node.config.value = flowDeviceFeatureDefaultValue(input.value); }
else if (node.kind === 'device_feature_action' && key === 'value') {
if (FLOW_DEVICE_BOOL_FEATURES.includes(node.config.feature)) node.config.value = input.value === 'true';
else if (['target_temperature', 'fan_speed', 'swing_vertical', 'swing_horizontal'].includes(node.config.feature)) node.config.value = Number(input.value);
else node.config.value = input.value;
}
else if (node.kind === 'device_state' && key === 'field') {
const wasLouver = ['swing_vertical', 'swing_horizontal'].includes(node.config.field);
node.config.field = input.value;
@@ -1189,7 +1233,7 @@ function updateFlowConfig(input) {
else if (key === 'value' && node.kind === 'rolling_stat') node.config.value = Number(input.value || 0);
else if (key === 'min_span' && node.kind === 'oscillates') node.config.min_span = Math.max(0.001, Number(input.value || 0.001));
else if (key === 'min_direction_changes' && node.kind === 'oscillates') node.config.min_direction_changes = Math.max(1, Math.floor(Number(input.value || 1)));
else if (['value', 'setpoint', 'target_temperature', 'cooldown_seconds', 'fan_speed'].includes(key) && ['outdoor_temperature', 'device_temperature', 'zone_temperature', 'ha_numeric', 'zone_thermostat', 'device_action', 'group_action', 'ha_service_action'].includes(node.kind)) node.config[key] = input.value === '' ? null : Number(input.value);
else if (['value', 'setpoint', 'target_temperature', 'cooldown_seconds', 'fan_speed'].includes(key) && ['outdoor_temperature', 'device_temperature', 'zone_temperature', 'ha_numeric', 'zone_thermostat', 'device_action', 'device_feature_action', 'group_action', 'ha_service_action'].includes(node.kind)) node.config[key] = input.value === '' ? null : Number(input.value);
else node.config[key] = input.value;
app.flowDirty = true; renderFlowEditor();
}
+5
View File
@@ -547,16 +547,19 @@ function updateAutomationTargetFields() {
$('#automationGroupPreset').hidden = !groupTarget;
$('#automationTargetTemperature').hidden = groupTarget;
$('#automationDeviceSwingOptions').hidden = groupTarget;
$('#automationDeviceFeatureOptions').hidden = groupTarget;
$('#automationGroupHint').hidden = !groupTarget;
form.action_device_id.required = !groupTarget;
form.action_group_id.required = groupTarget;
form.action_target_temperature.disabled = groupTarget;
form.action_swing_vertical.disabled = groupTarget;
form.action_swing_horizontal.disabled = groupTarget;
['action_fan_speed', 'action_quiet', 'action_turbo', 'action_light', 'action_air', 'action_xfan', 'action_health', 'action_sleep'].forEach(name => { if (form.elements[name]) form.elements[name].disabled = groupTarget; });
if (groupTarget) {
form.action_target_temperature.value = '';
form.action_swing_vertical.value = '';
form.action_swing_horizontal.value = '';
['action_fan_speed', 'action_quiet', 'action_turbo', 'action_light', 'action_air', 'action_xfan', 'action_health', 'action_sleep'].forEach(name => { if (form.elements[name]) form.elements[name].value = ''; });
}
[...form.action_mode.options].forEach(option => {
if (!['dry', 'fan'].includes(option.value)) return;
@@ -703,6 +706,8 @@ function populateAutomation(id) {
form.action_target_temperature.value = item.action.target_temperature ?? '';
form.action_swing_vertical.value = item.action.swing_vertical == null ? '' : String(item.action.swing_vertical);
form.action_swing_horizontal.value = item.action.swing_horizontal == null ? '' : String(item.action.swing_horizontal);
form.action_fan_speed.value = item.action.fan_speed == null ? '' : String(item.action.fan_speed);
['quiet', 'turbo', 'light', 'air', 'xfan', 'health', 'sleep'].forEach(key => { const field = form.elements[`action_${key}`]; if (field) field.value = item.action[key] == null ? '' : String(item.action[key]); });
form.enabled.checked = item.enabled;
updateAutomationTargetFields();
openDialog('automationDialog');