This commit is contained in:
Mateusz Gruszczyński
2026-09-15 09:04:29 +02:00
parent 0a2fb8c6c7
commit 2ff310bdc5
37 changed files with 731 additions and 141 deletions
Generated
+1 -1
View File
@@ -700,7 +700,7 @@ dependencies = [
[[package]]
name = "gree-controller"
version = "0.14.1"
version = "0.14.2"
dependencies = [
"aes",
"aes-gcm",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "gree-controller"
version = "0.14.1"
version = "0.14.2"
edition = "2021"
authors = ["GREE Controller contributors"]
description = "Standalone local GREE HVAC controller with Web UI, SQLite and Home Assistant sensor support"
+36 -36
View File
@@ -1,14 +1,14 @@
d67af429e4da9ce08e9d2f2a8472849ffbd70d135b1c5da535a076026794d04c ./.env.example
a4ec3874a2e3ab1bad28fb40bb620f7b01f64d01ad9b699306bf70ada31227db ./.gitignore
1baa24e6934a05f48ea7f289ce5f8804fc0981236a2b6c468539a052c0d4e583 ./Cargo.lock
f3204447421f334110e1a626ec29fdd8ee88bb2b9d1e9e1a391c2720c9af7de9 ./Cargo.toml
59fe6d7de7f780481d0a7f614d4325f905a8aee8d971717566fc8889340223b3 ./Cargo.lock
97b67fd716185b18bb12d9170b502f81c6ffaf099816c893305c9f837048cb1f ./Cargo.toml
19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE
a4f7dc180e422d6de9d78b1235206ebc3b0436d6041730df8ca065532c36a939 ./README.md
b27097a57e62a3096b133899dea455a68aa04c0fa428f6228d08615e1b11ca29 ./SOURCE_MANIFEST.sha256
de56b105caea31afd80b4051912329a0c1c03233979fd710732cb606acae1dbd ./README.md
c45e67ac73f7606e0b4f90fa05bd9c3a20dc07441ac04e8bc68235ef558143cc ./SOURCE_MANIFEST.sha256
bb549919b96b0413152dd905aec8640bb53301f07ec7d0b1be48ccbd37936fef ./build.rs
50a46eac5dfcdc80c5db068f0eb1ed7d696a2b6070e2ddff808f2bb2a33a913b ./docs/API.md
2e1e18fd8167dabfe2469c26e85cce62486c7cb6a502c63c6f6b0cd74d5885e0 ./docs/FLOW.md
919d54a6825385fdb4e9b8f07db756640d512b2c21d2d33f407f850680285ed0 ./docs/openapi.json
0eb39057762c51718e7e7ba220ae1e3a729409c3d91736afa3baa88c64d3a8d6 ./docs/API.md
ff213ad348e7f8b5cae7fe4adcad84d72db693765d424f270faa1acca9c23ce7 ./docs/FLOW.md
52181be4d4a161710bbb5e7cde1b3061976e41691d9e10bf99f0c224c65732d1 ./docs/openapi.json
c65aa543d2e99f8f7d52736307b07ebd182597d80124661d53a16bb23f6f1118 ./ha-addon/.env.example
a18d2460340264d34c267fa704ee51a42e59bcc1783ee4c0dbb2a8ef42fa0f16 ./ha-addon/Dockerfile
0417c88a990c72aecf071fba64d5d9da65dcda4d396cd7565477f7a4532c8c5e ./ha-addon/README.md
@@ -16,13 +16,13 @@ cd7fd4b937a1d6138699f7effd184e3c077912b881e9c73c37b90bbafba68477 ./ha-addon/bui
fd041032f353ceb0a2b02005f5a78d74a460b12e4e78de13dcb60129859f8c4e ./ha-addon/home-assistant/README.md
f8e8559fe10fe523ac5bc9aac25c6e26e862f679d502e8f3c39f38a0a8e40911 ./ha-addon/home-assistant/custom_components/gree_controller/__init__.py
3f6ef15ef58456376ac53fde7cace1ef359d6a6f7a64c5b575ad77ea6e55ccc4 ./ha-addon/home-assistant/custom_components/gree_controller/api.py
021a235617852c3adc4990936c3daf497309ea9a18574223805ae7496b1366c3 ./ha-addon/home-assistant/custom_components/gree_controller/climate.py
0c008329aad15f5af6328380c1afe1f49db186b37b3d1cafec4b6f4088386e8e ./ha-addon/home-assistant/custom_components/gree_controller/climate.py
5e4aef2143e81bedb5a15dd4be5c71b64a3ab448ec6d33a20851edd098e5f529 ./ha-addon/home-assistant/custom_components/gree_controller/config_flow.py
b7f0873109c52be9d7f09bea3dffc416103c50085e1f0680d11661a969479898 ./ha-addon/home-assistant/custom_components/gree_controller/const.py
41a8958a6fe6d10e5679c95ac34832583a2a15ecfd67d09b4e094a83d06f5c47 ./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
5a7a8cf9545c9346d9bcc92e16265fa2ef63567fa28abe153a2e361cc279c082 ./ha-addon/home-assistant/custom_components/gree_controller/manifest.json
25c5ffb84460bf81f0c54abfc912e5bfd85535d3558df7a10e1de9541dd2afa3 ./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
6bddb7b4620021ecd2099a86a77ef5c7f2c2dcd3d07d5db4e7b4c4ce6d3e8c03 ./ha-addon/home-assistant/custom_components/gree_controller/translations/en.json
13f30e2dcdcedbd1b6c3f99c2335e0487108fd72c8e86922368b84f2fa2038ae ./ha-addon/home-assistant/custom_components/gree_controller/translations/pl.json
c5fc5c87273d82d2834a0a5a365c821a416413ed509613441e24e4dc3507c4d2 ./ha-addon/home-assistant/generated/gree_controller_entities.example.json
131509e7dfefcca4ad61a3a060bc0a33ed2f731141496ba99a3cb81a563399b1 ./ha-addon/repository/gree-controller/CHANGELOG.md
e561712958ce5ef1068989af0515687358adc69b1d92af29d6b576d46a182859 ./ha-addon/repository/gree-controller/CHANGELOG.md
3c06bd11671452ea6472afd5543fb91a39aba73a1d2d95c7762c04f94ab64030 ./ha-addon/repository/gree-controller/DOCS.md
c3db302f38d26dc9f8caf37a1a8a07a1334cc07ae301d452f2940a5ec5915719 ./ha-addon/repository/gree-controller/README.md
09e38d1d821a6c318152e5a023954310e9f361985cfddc456abfcc0b88d79c62 ./ha-addon/repository/gree-controller/config.yaml
a3a74b2aaa20fd39a050f8f360eec543d70b3fd43271617a60a0b95fd0cdf2ca ./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
d13e4c2b993d209078342c0c86d852b300692089b377aa044f811308dd188a1e ./ha-addon/repository/gree-controller/translations/en.yaml
@@ -43,8 +43,8 @@ c123b190219556382f0e7f3058056fb233f6261f257a01eceb96a2c0bbfa12f3 ./ha-addon/run
081e27b6e43070d44f865335c30a42a5a29668eaa6a5ea5ab6967ac4447b8e06 ./ha-addon/sync-repository.sh
f9e31de3d109ead32bf531c074d46a087df398d62488caee0b46b5e0a1c027f7 ./ha_addon.md
253a0bc912786e67ea7fc92a64e4a510ad973bec343a88ccfb1f28fca3e8cf01 ./lang/README.md
301df7a84ce546db1c2e25da841aa0fc9696435d7bb4db358e3fb082a723afd9 ./lang/en.json
c01e081782251122fec4fb50500a5f3ee4d865a2213af89cc77f3f3661606dbd ./lang/pl.json
4bbfa7cfb7644b6949aa8bc212154fce4dbf1f8bc5c7c56863a30fa63a0efd80 ./lang/en.json
bd35f928fbca252a90057a04dc1d363c85bd844b29076238e00751d8ecf196cc ./lang/pl.json
d8459024f04ca514bd8e9d6bd3af872fb942fd85a7cdd5583f4a9d28aab6faba ./make_zip.py
b14233a8987e53bbbdd6770386ba10fa166ec25c1e098275c173c544b37846fc ./presets/bedroom_window_night.json
1960841119c0b673fb2f03621b32237552fd828045304cd221401214b997c910 ./presets/device_resilience.json
@@ -85,7 +85,7 @@ fdcd9a5055d08037278b842e7ab69265345c5811f0a06867136c511d140bb191 ./presets/wind
804f22123cd3e8db0fac791826c8dd9f758fb866c8e3b5655fb6d25d259dccf1 ./presets/workday_comfort.json
01952aa92b217f8eae2493b88870e2dec595100cd15c4d561ff11ae2b936c46f ./regenerate-sha.sh
c64b1c6deeb24f20af662bfce7fa3683703914987241107ce664fa2f6d1a9dcb ./scripts/README.md
3461b89eff40737f5f250b50bcccadb882bb0b5afbd96542d9d67184142191f8 ./scripts/api_dev_test.py
506942d9c96b586b7f265742421fcea45c8d7e86824ad8934147a4388873c3cc ./scripts/api_dev_test.py
5bc736c7bc76ca80aaa406bb171d2aa91baf4c3aa8695dce0e09b888b6ab3146 ./scripts/common.sh
6403786610ee6d2f628193c25aee0dd058d62e904aa1a31d5f62fdaae0e94b4f ./scripts/configure-gree-network.sh
1d6e14e26e49aa9d3527f30a23668bf8d9c48b67e6628ef686c3155c012155de ./scripts/dev.sh
@@ -102,12 +102,12 @@ e89b3503b838670f3213e696395024e74d91c718bc67a4cb295077e944ea4c6c ./src/api.rs
3dfd1e053cbfa0d684dd88cf99b244a96a36e2ff23d2a0715b8327ab62258254 ./src/api/assets.rs
05c255919f7b3d81188fc5f1410be6a0037471dfc44e93bffb009f29a1e6a3fc ./src/api/auth.rs
9e4fc675306e111ed3db7af9822e2925d88109c31b9e309851665a975c0bd83c ./src/api/automations.rs
be865a0be2167131a3778d7ceb825d9a65991a7c063fffadbe7188eac459d078 ./src/api/configuration.rs
a9e8b74c9ba3ae723d36d2d87c0b1a72138f9ad2fed8e62dc5ad442278a1986d ./src/api/configuration.rs
19bc141286a336ad67e99079496eb0ae5126f986ab28be62b5f97758e5f6969c ./src/api/debug_tokens.rs
32ade46ec45d6faf7852242b9f5ba1be6eacbacbeb33d2deabfab59bf1610ce0 ./src/api/device_groups.rs
fcbb3da0538aab451f71810f829cc248d6bb67e586dc418d71a572980583823d ./src/api/devices.rs
4b5b784670ae761a6848b9a33fe3fb391bebd39d65e87936907428711e96ca44 ./src/api/events.rs
44b74965f3256c2972d6b3cd384a63c68e3b422f414a0902c085bc92e17369bf ./src/api/flows.rs
d59b080b392b574ec94276c7b485b455d0d1abacc7de2ef29dcbfcf7d86dbdb0 ./src/api/flows.rs
2cfee7029f065e81628a8942e3dad00a03c3a5cf4f8492d07f12b75d05c07359 ./src/api/gree_cloud.rs
b61b0b4f196c5e72e47cf06ca0c14df3cf8eedbdef0242a9d2af33623663489f ./src/api/groups.rs
63733cf1fd70b9c2cb1bde3c9304365e8940af4a7524bfc82b47444458a56f4a ./src/api/history.rs
@@ -118,7 +118,7 @@ e8edfbb725d6de650442444b0a929ad2be0bd2c2d7d227d2b3fdd99d05c94f94 ./src/api/hous
744ce18aa9e0227c6cdfb6980063d0d738d74c5865d45c20fd20026d4742bb3e ./src/api/public_settings.rs
5b29f2e5aa2c422c5829ef89c7b59cf753883aa7f60661c95ab0f95cf676fa3f ./src/api/schedules.rs
e69df8a5f612cd37d25fbf9f8e824c5a7d655ac5a6376ec3b31addf1fe8597e2 ./src/api/settings.rs
264f884e2fdef24303d441ce9cfe11190ed8f2bba8ca5782c1d13c2ee30e6a7f ./src/api/system.rs
1c1fe39aec55d10608ca3db83827855b07249cd1a81ae054eb1ee6464514092b ./src/api/system.rs
9118a5a3a381b010e22d646498ff56d2e05f51656789682cc4371b09c384aa98 ./src/api/websocket.rs
455c7238c9788d378e909a4fbc94d5390b67edd92e127f48b4b913ddcbe82fb0 ./src/api/zones.rs
f4feaff281163eda543bf094d0fe9158dc6662be5f76f39c81570da7ed0355e7 ./src/config.rs
@@ -134,14 +134,14 @@ bb5048ee60053f39ce3c1203dc1f24c7047310538d2357f105a052c016d21ec4 ./src/db/flows
d6b9b70a04c774173c97f7426487d5e2932d7713e12ae3fb7f64cf2b4e0b3c10 ./src/db/schedules_automations.rs
a76938b2e49719b97f8abf40a5cce03df6740ce2e8ac138b179c761619b32ce6 ./src/db/tests.rs
f90fbb14f29a20b799b3856bdaa22812a9f0d9dfa46693f1341c160dccda8a97 ./src/db/zone_history.rs
3c97e04e93d14f37d39ee9f8f53257101517a3d90a0dfeba81ca3dcd5e39ff31 ./src/engine.rs
6046bdbc9c02ac74f47068229c59af69abc8bfa46cfeff6fe7ba573b493e128c ./src/engine/automations.rs
a66b4c2f4f62bd4a981f57ad574c797b73d8400f3492f8ff69126f74b5010899 ./src/engine.rs
ed5c9a0c61c6e597acb33f58593ebe02b562ddc68fa61271e1c1cd436c1003d3 ./src/engine/automations.rs
0bf7fe7d28b4749e53ec25d71effbb78bc8f70c716714f838a4a2d8f20627d63 ./src/engine/commands.rs
925135985f640ba9ae0a6109d908187b659341a91451e91d27e533adfd610b76 ./src/engine/control_plan.rs
7b48a33343d519a2ff2d81e9d757eb56a0c1beacfc0b7b8ef61f49c2ad363f9a ./src/engine/deadlines.rs
9a51cc46b8befb992d7f7dd3dbe5025cd739755a684ed36013be78eaaf1a1743 ./src/engine/energy.rs
67ddb482ad08608e799a88faa6183d9bb65f62ca8ac138c6314cab18bc033033 ./src/engine/energy.rs
e350aa63f3bb14ca887eaaa7555edbeae00fb584cbcaf552cc972f0c8918f5d8 ./src/engine/groups.rs
0e78e8d1c31503b313926af025281714a6e22a23f46af081b3da8e94a2d7315a ./src/engine/history.rs
9f650203959acebc72f3ee87faaeebe4c5c8beee7d77d2039d56fa2c0277aab4 ./src/engine/history.rs
41b7045877771b7c352328998cb45ce2433e7f9631fad3eb1a882ab4897dc31f ./src/engine/local_thermostat.rs
99527adab9fea1caede9ebb24faa6a63d5cef4810c64c06306b571cf5cbbb0da ./src/engine/ownership.rs
1a29b7fd8de77f0a5e5964ca05e41cc5ea78855c1ab961750d40461faec305e5 ./src/engine/polling.rs
@@ -150,9 +150,9 @@ c1e8de5392681ba1255e3e2d871a6ee860fb8ca987a0a8f973d9ded3977a68fb ./src/engine/s
b0189f28d0f337dfd3ce0934da7a565e4a5535afc1c242214c26ed584b3b7840 ./src/engine/targets.rs
682c8b3956c657b655746cbd19ee97ee2f4cf953bc9de67d6d70a1b14f3dd746 ./src/engine/temperature.rs
833b72907375e75c3f42b3921ad70a8e298f5a93743bf0d0c19059aa272462d1 ./src/engine/temporary_thermostat.rs
338161bd94c868be6155efabdc63e81e9a572d559cc00493d6c69f15cc1612a3 ./src/engine/tests.rs
47330f0d8ff170ea91ba37d019c0d5577151c87f0474bcc2a287850415f6b953 ./src/engine/tests.rs
c89ff36c6a67bf4fbf68c43e18aa0a556de2dc69263fd79db376363afc741a4f ./src/engine/zone_actions.rs
6db07dc9c3003c97b80d3955761329ffea9876279e7afc0644bfba8878e41209 ./src/engine/zone_control.rs
28571e25e539fb1fe5e77e8bd003caf2da9e84fc34d156fd5f3e65e805798ca0 ./src/engine/zone_control.rs
bfac95cec87f0df6ce562153cd3cfe6731359d703e6c592d2c2f7ba01ea62d99 ./src/error.rs
ea553a1d8b44c74b300d7c4a76dcd3b370524eb57766730a1376d1ab3079913b ./src/home_assistant.rs
b43d95ac42f5edc1a38323e47626b6c0ec6cbbda3c30ad6f245d23865cd1993e ./src/influxdb.rs
@@ -199,25 +199,25 @@ f13f1be3d5789539eba3fc1b59c14835f8c681eca7634e118fa3763a53aeee2b ./src/queries/
b92a6cb158b494fe145b43c7641e65f6fafff47201d7d76edbec2cfd8b94835c ./systemd/gree-controller.service
544a31cb2e5374227026afd6abd0d910381f3fca1638154ebd74e3c3d96a35d1 ./web/404.html
e98bdd7204349cce1ec6f57283509697af0bbc72280622a6c3efa6fed242db4f ./web/favicon.svg
6cd0fa21dcc1b5170c4375d7fb70b72fc753f6ac80fbd1f6a91ba0e52ccca7e4 ./web/index.html
54350093b25e335f7e2bcf99fd0e17ba6df9035a0711c02bcbe0c4dd612c62e5 ./web/index.html
9ba3f6fa05b72aa989139b2a909982571b2a02055052e4c40104f1e81a9aa7eb ./web/js/README.md
dfea965a2093f9f85744b50993c867118afc7a65d2c5d3abd975d4b6f4acc6f2 ./web/js/bootstrap.js
761ad87ed20a819d8d87bc260bcd7f5b39f19582c346a69c8b717c94e7e94302 ./web/js/charts.js
e6c459f92f18d72f11ffaead5caede0b9c8e6b6a1c9cf1a6e97e6b07d5425ab5 ./web/js/core.js
a183492c9cf91d1318675f242c57ed30c11661ffc8a5a52f03e5aaeea060ef24 ./web/js/bootstrap.js
e987328623938637135c8a8d3952043fe7ae5d8568eff12bf4d95a601409f1df ./web/js/charts.js
3c61d86420d1f3765c9ccc184553425132fc432fd60d93763da4669d113b449b ./web/js/core.js
d880ffdac6aa07cd10d79fbacf12f87eb4c69b48a80564bbfdd0248abe8884d5 ./web/js/dashboard.js
83189a47d114e7b911c9cbf368bf80232a7a481d416297db5fc451c12bb710c7 ./web/js/entities.js
1ff265beab8eb15ced854b5da260fd3952add3acc6b290ea73c76b1a54853165 ./web/js/events.js
2e822a00b5a6941b6a251eaf2c54ca95a53399b5d1056f3d8240e11f82d6065f ./web/js/flows.js
ed31601df41cc82032709f960a2b4061966abd58cde456ef7dda05e42eb91432 ./web/js/entities.js
424b52fa05cc6f45ab3bcac0f5f1fd0ac2102d840334fc3d762fd208816565a8 ./web/js/events.js
8c156909d2d2e59b7d7dc969b9520c978141a448a6cc5aae8fe74a8dc124f2bc ./web/js/flows.js
9e2209d41df7624c29741f03f12ed8e83b47cee2891c5a80cea8ca1bdacf1d5a ./web/js/forms.js
6d2c2fc497f0ea4e3ef4a35e6518c9e4be680ace9100ed09a327f9dbe4261d0b ./web/js/history.js
688390149f023b4f7e8701ca30ea5cb56c27692dc5083344739411405e320bb1 ./web/js/history.js
0dc879c0056251618633c9efd188fc1593eeae6965ad1bc993132dea6e171b44 ./web/js/main.js
341c4bc441782bf60bd92c899154ca565ac1ba884a6f3a1ea89aae58f5d0322f ./web/js/navigation.js
d105fb2a3b4000a9f22ed23c7b030c1018fee4d2a78dba3ad96b3d9d95689f2a ./web/js/realtime.js
419e0859d1553972f670bb1719be6c0aca14a4ab450041d0017abd41a094b1da ./web/js/navigation.js
e3d146a683b8beb332c752e533826160c2cf2236bc269ced7a4dd1249871027b ./web/js/realtime.js
abdedaca20ecad50ae2b5c7f6081c80a142112bd867cda19949d5253e609f1b4 ./web/js/router.js
3426ec31011173172199bff7deacbef1d5eb0121012b5ea0e53b7a72a674a853 ./web/js/settings-ui.js
13806217b2312dff3bd6cc2b16f75d9bdffc7a587215ae9f862706cd7699673f ./web/js/settings.js
55c163fce9520979d380739546ef3036186297466d5fc3ac8cb0680408fb444e ./web/js/settings-ui.js
29e6ca03eb62c6158de9ab0b9e74100129478794ae5c697321e217fb1b69ff23 ./web/js/settings.js
3815fea7ae1694568c2f249bc8460e0563ced6bde89214f74f21b41fe8cad9a6 ./web/lang-init.js
6b653e4fe2d4db4ffae6ff8f39b8ce57a10a990dc0000ee4aae51d949afadd52 ./web/manifest.webmanifest
10a47e3645316103b8ad85a8635ec6305da9717226568e76db58e2d77e894e8f ./web/styles.css
3274600badfc59219db1d76e7767d7448e4cf1a9aedee94e90435c906c8269b3 ./web/styles.css
fefdf12cbbb0a5d8a2cb89275cae28455aa42aaf179542305cc8f5e0f4825898 ./web/sw.js
d505d793ce7cc9485b45b78bba1c0d51887adc7451ab59a42702946e5b991382 ./web/theme-init.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.14.1**
**Version: 0.14.2**
Version 0.14.1 builds on the GREE Cloud and energy support from 0.14.0 with split/multisplit installation groups, shared energy and outdoor-temperature sources, multi-series energy comparison, cleaner Cloud status/alerts and safer MQTT cleanup after device removal. Local and Cloud entries remain deliberately independent, even when they represent the same physical MAC.
Version 0.14.2 adds separate vertical and horizontal swing control across manual unit control, thermostat views, legacy automations, Visual Flow and Home Assistant zone thermostats. It keeps the split/multisplit energy and outdoor-temperature improvements from 0.14.1, while preserving independent Local and Cloud device entries.
> [Full API reference](docs/API.md) — authentication, every endpoint, request bodies, response models, WebSocket events and examples.
+35 -35
View File
@@ -1,13 +1,13 @@
d67af429e4da9ce08e9d2f2a8472849ffbd70d135b1c5da535a076026794d04c ./.env.example
a4ec3874a2e3ab1bad28fb40bb620f7b01f64d01ad9b699306bf70ada31227db ./.gitignore
1baa24e6934a05f48ea7f289ce5f8804fc0981236a2b6c468539a052c0d4e583 ./Cargo.lock
f3204447421f334110e1a626ec29fdd8ee88bb2b9d1e9e1a391c2720c9af7de9 ./Cargo.toml
59fe6d7de7f780481d0a7f614d4325f905a8aee8d971717566fc8889340223b3 ./Cargo.lock
97b67fd716185b18bb12d9170b502f81c6ffaf099816c893305c9f837048cb1f ./Cargo.toml
19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE
a4f7dc180e422d6de9d78b1235206ebc3b0436d6041730df8ca065532c36a939 ./README.md
de56b105caea31afd80b4051912329a0c1c03233979fd710732cb606acae1dbd ./README.md
bb549919b96b0413152dd905aec8640bb53301f07ec7d0b1be48ccbd37936fef ./build.rs
50a46eac5dfcdc80c5db068f0eb1ed7d696a2b6070e2ddff808f2bb2a33a913b ./docs/API.md
2e1e18fd8167dabfe2469c26e85cce62486c7cb6a502c63c6f6b0cd74d5885e0 ./docs/FLOW.md
919d54a6825385fdb4e9b8f07db756640d512b2c21d2d33f407f850680285ed0 ./docs/openapi.json
0eb39057762c51718e7e7ba220ae1e3a729409c3d91736afa3baa88c64d3a8d6 ./docs/API.md
ff213ad348e7f8b5cae7fe4adcad84d72db693765d424f270faa1acca9c23ce7 ./docs/FLOW.md
52181be4d4a161710bbb5e7cde1b3061976e41691d9e10bf99f0c224c65732d1 ./docs/openapi.json
c65aa543d2e99f8f7d52736307b07ebd182597d80124661d53a16bb23f6f1118 ./ha-addon/.env.example
a18d2460340264d34c267fa704ee51a42e59bcc1783ee4c0dbb2a8ef42fa0f16 ./ha-addon/Dockerfile
0417c88a990c72aecf071fba64d5d9da65dcda4d396cd7565477f7a4532c8c5e ./ha-addon/README.md
@@ -15,13 +15,13 @@ cd7fd4b937a1d6138699f7effd184e3c077912b881e9c73c37b90bbafba68477 ./ha-addon/bui
fd041032f353ceb0a2b02005f5a78d74a460b12e4e78de13dcb60129859f8c4e ./ha-addon/home-assistant/README.md
f8e8559fe10fe523ac5bc9aac25c6e26e862f679d502e8f3c39f38a0a8e40911 ./ha-addon/home-assistant/custom_components/gree_controller/__init__.py
3f6ef15ef58456376ac53fde7cace1ef359d6a6f7a64c5b575ad77ea6e55ccc4 ./ha-addon/home-assistant/custom_components/gree_controller/api.py
021a235617852c3adc4990936c3daf497309ea9a18574223805ae7496b1366c3 ./ha-addon/home-assistant/custom_components/gree_controller/climate.py
0c008329aad15f5af6328380c1afe1f49db186b37b3d1cafec4b6f4088386e8e ./ha-addon/home-assistant/custom_components/gree_controller/climate.py
5e4aef2143e81bedb5a15dd4be5c71b64a3ab448ec6d33a20851edd098e5f529 ./ha-addon/home-assistant/custom_components/gree_controller/config_flow.py
b7f0873109c52be9d7f09bea3dffc416103c50085e1f0680d11661a969479898 ./ha-addon/home-assistant/custom_components/gree_controller/const.py
41a8958a6fe6d10e5679c95ac34832583a2a15ecfd67d09b4e094a83d06f5c47 ./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
5a7a8cf9545c9346d9bcc92e16265fa2ef63567fa28abe153a2e361cc279c082 ./ha-addon/home-assistant/custom_components/gree_controller/manifest.json
25c5ffb84460bf81f0c54abfc912e5bfd85535d3558df7a10e1de9541dd2afa3 ./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
6bddb7b4620021ecd2099a86a77ef5c7f2c2dcd3d07d5db4e7b4c4ce6d3e8c03 ./ha-addon/home-assistant/custom_components/gree_controller/translations/en.json
13f30e2dcdcedbd1b6c3f99c2335e0487108fd72c8e86922368b84f2fa2038ae ./ha-addon/home-assistant/custom_components/gree_controller/translations/pl.json
c5fc5c87273d82d2834a0a5a365c821a416413ed509613441e24e4dc3507c4d2 ./ha-addon/home-assistant/generated/gree_controller_entities.example.json
131509e7dfefcca4ad61a3a060bc0a33ed2f731141496ba99a3cb81a563399b1 ./ha-addon/repository/gree-controller/CHANGELOG.md
e561712958ce5ef1068989af0515687358adc69b1d92af29d6b576d46a182859 ./ha-addon/repository/gree-controller/CHANGELOG.md
3c06bd11671452ea6472afd5543fb91a39aba73a1d2d95c7762c04f94ab64030 ./ha-addon/repository/gree-controller/DOCS.md
c3db302f38d26dc9f8caf37a1a8a07a1334cc07ae301d452f2940a5ec5915719 ./ha-addon/repository/gree-controller/README.md
09e38d1d821a6c318152e5a023954310e9f361985cfddc456abfcc0b88d79c62 ./ha-addon/repository/gree-controller/config.yaml
a3a74b2aaa20fd39a050f8f360eec543d70b3fd43271617a60a0b95fd0cdf2ca ./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
d13e4c2b993d209078342c0c86d852b300692089b377aa044f811308dd188a1e ./ha-addon/repository/gree-controller/translations/en.yaml
@@ -42,8 +42,8 @@ c123b190219556382f0e7f3058056fb233f6261f257a01eceb96a2c0bbfa12f3 ./ha-addon/run
081e27b6e43070d44f865335c30a42a5a29668eaa6a5ea5ab6967ac4447b8e06 ./ha-addon/sync-repository.sh
f9e31de3d109ead32bf531c074d46a087df398d62488caee0b46b5e0a1c027f7 ./ha_addon.md
253a0bc912786e67ea7fc92a64e4a510ad973bec343a88ccfb1f28fca3e8cf01 ./lang/README.md
301df7a84ce546db1c2e25da841aa0fc9696435d7bb4db358e3fb082a723afd9 ./lang/en.json
c01e081782251122fec4fb50500a5f3ee4d865a2213af89cc77f3f3661606dbd ./lang/pl.json
4bbfa7cfb7644b6949aa8bc212154fce4dbf1f8bc5c7c56863a30fa63a0efd80 ./lang/en.json
bd35f928fbca252a90057a04dc1d363c85bd844b29076238e00751d8ecf196cc ./lang/pl.json
d8459024f04ca514bd8e9d6bd3af872fb942fd85a7cdd5583f4a9d28aab6faba ./make_zip.py
b14233a8987e53bbbdd6770386ba10fa166ec25c1e098275c173c544b37846fc ./presets/bedroom_window_night.json
1960841119c0b673fb2f03621b32237552fd828045304cd221401214b997c910 ./presets/device_resilience.json
@@ -84,7 +84,7 @@ fdcd9a5055d08037278b842e7ab69265345c5811f0a06867136c511d140bb191 ./presets/wind
804f22123cd3e8db0fac791826c8dd9f758fb866c8e3b5655fb6d25d259dccf1 ./presets/workday_comfort.json
01952aa92b217f8eae2493b88870e2dec595100cd15c4d561ff11ae2b936c46f ./regenerate-sha.sh
c64b1c6deeb24f20af662bfce7fa3683703914987241107ce664fa2f6d1a9dcb ./scripts/README.md
3461b89eff40737f5f250b50bcccadb882bb0b5afbd96542d9d67184142191f8 ./scripts/api_dev_test.py
506942d9c96b586b7f265742421fcea45c8d7e86824ad8934147a4388873c3cc ./scripts/api_dev_test.py
5bc736c7bc76ca80aaa406bb171d2aa91baf4c3aa8695dce0e09b888b6ab3146 ./scripts/common.sh
6403786610ee6d2f628193c25aee0dd058d62e904aa1a31d5f62fdaae0e94b4f ./scripts/configure-gree-network.sh
1d6e14e26e49aa9d3527f30a23668bf8d9c48b67e6628ef686c3155c012155de ./scripts/dev.sh
@@ -101,12 +101,12 @@ e89b3503b838670f3213e696395024e74d91c718bc67a4cb295077e944ea4c6c ./src/api.rs
3dfd1e053cbfa0d684dd88cf99b244a96a36e2ff23d2a0715b8327ab62258254 ./src/api/assets.rs
05c255919f7b3d81188fc5f1410be6a0037471dfc44e93bffb009f29a1e6a3fc ./src/api/auth.rs
9e4fc675306e111ed3db7af9822e2925d88109c31b9e309851665a975c0bd83c ./src/api/automations.rs
be865a0be2167131a3778d7ceb825d9a65991a7c063fffadbe7188eac459d078 ./src/api/configuration.rs
a9e8b74c9ba3ae723d36d2d87c0b1a72138f9ad2fed8e62dc5ad442278a1986d ./src/api/configuration.rs
19bc141286a336ad67e99079496eb0ae5126f986ab28be62b5f97758e5f6969c ./src/api/debug_tokens.rs
32ade46ec45d6faf7852242b9f5ba1be6eacbacbeb33d2deabfab59bf1610ce0 ./src/api/device_groups.rs
fcbb3da0538aab451f71810f829cc248d6bb67e586dc418d71a572980583823d ./src/api/devices.rs
4b5b784670ae761a6848b9a33fe3fb391bebd39d65e87936907428711e96ca44 ./src/api/events.rs
44b74965f3256c2972d6b3cd384a63c68e3b422f414a0902c085bc92e17369bf ./src/api/flows.rs
d59b080b392b574ec94276c7b485b455d0d1abacc7de2ef29dcbfcf7d86dbdb0 ./src/api/flows.rs
2cfee7029f065e81628a8942e3dad00a03c3a5cf4f8492d07f12b75d05c07359 ./src/api/gree_cloud.rs
b61b0b4f196c5e72e47cf06ca0c14df3cf8eedbdef0242a9d2af33623663489f ./src/api/groups.rs
63733cf1fd70b9c2cb1bde3c9304365e8940af4a7524bfc82b47444458a56f4a ./src/api/history.rs
@@ -117,7 +117,7 @@ e8edfbb725d6de650442444b0a929ad2be0bd2c2d7d227d2b3fdd99d05c94f94 ./src/api/hous
744ce18aa9e0227c6cdfb6980063d0d738d74c5865d45c20fd20026d4742bb3e ./src/api/public_settings.rs
5b29f2e5aa2c422c5829ef89c7b59cf753883aa7f60661c95ab0f95cf676fa3f ./src/api/schedules.rs
e69df8a5f612cd37d25fbf9f8e824c5a7d655ac5a6376ec3b31addf1fe8597e2 ./src/api/settings.rs
264f884e2fdef24303d441ce9cfe11190ed8f2bba8ca5782c1d13c2ee30e6a7f ./src/api/system.rs
1c1fe39aec55d10608ca3db83827855b07249cd1a81ae054eb1ee6464514092b ./src/api/system.rs
9118a5a3a381b010e22d646498ff56d2e05f51656789682cc4371b09c384aa98 ./src/api/websocket.rs
455c7238c9788d378e909a4fbc94d5390b67edd92e127f48b4b913ddcbe82fb0 ./src/api/zones.rs
f4feaff281163eda543bf094d0fe9158dc6662be5f76f39c81570da7ed0355e7 ./src/config.rs
@@ -133,14 +133,14 @@ bb5048ee60053f39ce3c1203dc1f24c7047310538d2357f105a052c016d21ec4 ./src/db/flows
d6b9b70a04c774173c97f7426487d5e2932d7713e12ae3fb7f64cf2b4e0b3c10 ./src/db/schedules_automations.rs
a76938b2e49719b97f8abf40a5cce03df6740ce2e8ac138b179c761619b32ce6 ./src/db/tests.rs
f90fbb14f29a20b799b3856bdaa22812a9f0d9dfa46693f1341c160dccda8a97 ./src/db/zone_history.rs
3c97e04e93d14f37d39ee9f8f53257101517a3d90a0dfeba81ca3dcd5e39ff31 ./src/engine.rs
6046bdbc9c02ac74f47068229c59af69abc8bfa46cfeff6fe7ba573b493e128c ./src/engine/automations.rs
a66b4c2f4f62bd4a981f57ad574c797b73d8400f3492f8ff69126f74b5010899 ./src/engine.rs
ed5c9a0c61c6e597acb33f58593ebe02b562ddc68fa61271e1c1cd436c1003d3 ./src/engine/automations.rs
0bf7fe7d28b4749e53ec25d71effbb78bc8f70c716714f838a4a2d8f20627d63 ./src/engine/commands.rs
925135985f640ba9ae0a6109d908187b659341a91451e91d27e533adfd610b76 ./src/engine/control_plan.rs
7b48a33343d519a2ff2d81e9d757eb56a0c1beacfc0b7b8ef61f49c2ad363f9a ./src/engine/deadlines.rs
9a51cc46b8befb992d7f7dd3dbe5025cd739755a684ed36013be78eaaf1a1743 ./src/engine/energy.rs
67ddb482ad08608e799a88faa6183d9bb65f62ca8ac138c6314cab18bc033033 ./src/engine/energy.rs
e350aa63f3bb14ca887eaaa7555edbeae00fb584cbcaf552cc972f0c8918f5d8 ./src/engine/groups.rs
0e78e8d1c31503b313926af025281714a6e22a23f46af081b3da8e94a2d7315a ./src/engine/history.rs
9f650203959acebc72f3ee87faaeebe4c5c8beee7d77d2039d56fa2c0277aab4 ./src/engine/history.rs
41b7045877771b7c352328998cb45ce2433e7f9631fad3eb1a882ab4897dc31f ./src/engine/local_thermostat.rs
99527adab9fea1caede9ebb24faa6a63d5cef4810c64c06306b571cf5cbbb0da ./src/engine/ownership.rs
1a29b7fd8de77f0a5e5964ca05e41cc5ea78855c1ab961750d40461faec305e5 ./src/engine/polling.rs
@@ -149,9 +149,9 @@ c1e8de5392681ba1255e3e2d871a6ee860fb8ca987a0a8f973d9ded3977a68fb ./src/engine/s
b0189f28d0f337dfd3ce0934da7a565e4a5535afc1c242214c26ed584b3b7840 ./src/engine/targets.rs
682c8b3956c657b655746cbd19ee97ee2f4cf953bc9de67d6d70a1b14f3dd746 ./src/engine/temperature.rs
833b72907375e75c3f42b3921ad70a8e298f5a93743bf0d0c19059aa272462d1 ./src/engine/temporary_thermostat.rs
338161bd94c868be6155efabdc63e81e9a572d559cc00493d6c69f15cc1612a3 ./src/engine/tests.rs
47330f0d8ff170ea91ba37d019c0d5577151c87f0474bcc2a287850415f6b953 ./src/engine/tests.rs
c89ff36c6a67bf4fbf68c43e18aa0a556de2dc69263fd79db376363afc741a4f ./src/engine/zone_actions.rs
6db07dc9c3003c97b80d3955761329ffea9876279e7afc0644bfba8878e41209 ./src/engine/zone_control.rs
28571e25e539fb1fe5e77e8bd003caf2da9e84fc34d156fd5f3e65e805798ca0 ./src/engine/zone_control.rs
bfac95cec87f0df6ce562153cd3cfe6731359d703e6c592d2c2f7ba01ea62d99 ./src/error.rs
ea553a1d8b44c74b300d7c4a76dcd3b370524eb57766730a1376d1ab3079913b ./src/home_assistant.rs
b43d95ac42f5edc1a38323e47626b6c0ec6cbbda3c30ad6f245d23865cd1993e ./src/influxdb.rs
@@ -198,25 +198,25 @@ f13f1be3d5789539eba3fc1b59c14835f8c681eca7634e118fa3763a53aeee2b ./src/queries/
b92a6cb158b494fe145b43c7641e65f6fafff47201d7d76edbec2cfd8b94835c ./systemd/gree-controller.service
544a31cb2e5374227026afd6abd0d910381f3fca1638154ebd74e3c3d96a35d1 ./web/404.html
e98bdd7204349cce1ec6f57283509697af0bbc72280622a6c3efa6fed242db4f ./web/favicon.svg
6cd0fa21dcc1b5170c4375d7fb70b72fc753f6ac80fbd1f6a91ba0e52ccca7e4 ./web/index.html
54350093b25e335f7e2bcf99fd0e17ba6df9035a0711c02bcbe0c4dd612c62e5 ./web/index.html
9ba3f6fa05b72aa989139b2a909982571b2a02055052e4c40104f1e81a9aa7eb ./web/js/README.md
dfea965a2093f9f85744b50993c867118afc7a65d2c5d3abd975d4b6f4acc6f2 ./web/js/bootstrap.js
761ad87ed20a819d8d87bc260bcd7f5b39f19582c346a69c8b717c94e7e94302 ./web/js/charts.js
e6c459f92f18d72f11ffaead5caede0b9c8e6b6a1c9cf1a6e97e6b07d5425ab5 ./web/js/core.js
a183492c9cf91d1318675f242c57ed30c11661ffc8a5a52f03e5aaeea060ef24 ./web/js/bootstrap.js
e987328623938637135c8a8d3952043fe7ae5d8568eff12bf4d95a601409f1df ./web/js/charts.js
3c61d86420d1f3765c9ccc184553425132fc432fd60d93763da4669d113b449b ./web/js/core.js
d880ffdac6aa07cd10d79fbacf12f87eb4c69b48a80564bbfdd0248abe8884d5 ./web/js/dashboard.js
83189a47d114e7b911c9cbf368bf80232a7a481d416297db5fc451c12bb710c7 ./web/js/entities.js
1ff265beab8eb15ced854b5da260fd3952add3acc6b290ea73c76b1a54853165 ./web/js/events.js
2e822a00b5a6941b6a251eaf2c54ca95a53399b5d1056f3d8240e11f82d6065f ./web/js/flows.js
ed31601df41cc82032709f960a2b4061966abd58cde456ef7dda05e42eb91432 ./web/js/entities.js
424b52fa05cc6f45ab3bcac0f5f1fd0ac2102d840334fc3d762fd208816565a8 ./web/js/events.js
8c156909d2d2e59b7d7dc969b9520c978141a448a6cc5aae8fe74a8dc124f2bc ./web/js/flows.js
9e2209d41df7624c29741f03f12ed8e83b47cee2891c5a80cea8ca1bdacf1d5a ./web/js/forms.js
6d2c2fc497f0ea4e3ef4a35e6518c9e4be680ace9100ed09a327f9dbe4261d0b ./web/js/history.js
688390149f023b4f7e8701ca30ea5cb56c27692dc5083344739411405e320bb1 ./web/js/history.js
0dc879c0056251618633c9efd188fc1593eeae6965ad1bc993132dea6e171b44 ./web/js/main.js
341c4bc441782bf60bd92c899154ca565ac1ba884a6f3a1ea89aae58f5d0322f ./web/js/navigation.js
d105fb2a3b4000a9f22ed23c7b030c1018fee4d2a78dba3ad96b3d9d95689f2a ./web/js/realtime.js
419e0859d1553972f670bb1719be6c0aca14a4ab450041d0017abd41a094b1da ./web/js/navigation.js
e3d146a683b8beb332c752e533826160c2cf2236bc269ced7a4dd1249871027b ./web/js/realtime.js
abdedaca20ecad50ae2b5c7f6081c80a142112bd867cda19949d5253e609f1b4 ./web/js/router.js
3426ec31011173172199bff7deacbef1d5eb0121012b5ea0e53b7a72a674a853 ./web/js/settings-ui.js
13806217b2312dff3bd6cc2b16f75d9bdffc7a587215ae9f862706cd7699673f ./web/js/settings.js
55c163fce9520979d380739546ef3036186297466d5fc3ac8cb0680408fb444e ./web/js/settings-ui.js
29e6ca03eb62c6158de9ab0b9e74100129478794ae5c697321e217fb1b69ff23 ./web/js/settings.js
3815fea7ae1694568c2f249bc8460e0563ced6bde89214f74f21b41fe8cad9a6 ./web/lang-init.js
6b653e4fe2d4db4ffae6ff8f39b8ce57a10a990dc0000ee4aae51d949afadd52 ./web/manifest.webmanifest
10a47e3645316103b8ad85a8635ec6305da9717226568e76db58e2d77e894e8f ./web/styles.css
3274600badfc59219db1d76e7767d7448e4cf1a9aedee94e90435c906c8269b3 ./web/styles.css
fefdf12cbbb0a5d8a2cb89275cae28455aa42aaf179542305cc8f5e0f4825898 ./web/sw.js
d505d793ce7cc9485b45b78bba1c0d51887adc7451ab59a42702946e5b991382 ./web/theme-init.js
+9 -3
View File
@@ -1,6 +1,6 @@
# GREE Controller API reference
HTTP and WebSocket API for GREE Controller **0.14.1**.
HTTP and WebSocket API for GREE Controller **0.14.2**.
[← Main documentation](../README.md)
@@ -232,7 +232,7 @@ Response:
{
"status": "ok",
"name": "gree-controller",
"version": "0.14.1",
"version": "0.14.2",
"uptime_seconds": 1234,
"control_ready": true,
"time": "2026-08-30T06:54:00Z"
@@ -295,7 +295,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.14.1",
"version": "0.14.2",
"uptime_seconds": 1234,
"auth_required": false,
"control_ready": true,
@@ -1654,6 +1654,12 @@ Additional Flow condition blocks are `house_mode`, `device_state`, `zone_state`,
## Swing control across manual, thermostat and automation surfaces — 0.14.2
Local device control exposes vertical (`swing_vertical`) and horizontal (`swing_horizontal`) swing independently. Thermostat zone UI and the bundled Home Assistant zone climate entity expose the same auxiliary controls when the assigned device reports support; changing swing does not hand thermostat ownership to manual control.
Legacy direct-device automations accept optional `swing_vertical` and `swing_horizontal` action fields. Visual Flow exposes both axes in `device_action` and `zone_thermostat` actions and in device-state conditions. A `zone_thermostat` Flow using swing compiles to an automation rather than a native schedule so the auxiliary command is preserved. Swing remains outside the thermostat's continuously regulated fan/temperature outputs.
## Split/multisplit installations and energy comparison — 0.14.1
Installation configuration endpoints:
+2 -2
View File
@@ -4,7 +4,7 @@ Flow is an authoring/orchestration layer. It does not replace the thermostat, sc
## Compilation
- `weekday + time_range -> zone_thermostat` with schedule-compatible settings compiles to a native `Schedule`.
- `weekday + time_range -> zone_thermostat` with schedule-compatible settings compiles to a native `Schedule`; selecting vertical or horizontal swing makes the action compile as an `Automation` so the swing command is not lost.
- Richer DAGs compile to native `Automation` rows with `trigger_kind = "flow"`.
- 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.
@@ -14,7 +14,7 @@ Flow is an authoring/orchestration layer. It does not replace the thermostat, sc
## Existing control domains remain authoritative
- `zone_thermostat` writes the existing persistent Zone intent and wakes the normal thermostat cycle.
- `zone_thermostat` writes the existing persistent Zone intent and wakes the normal thermostat cycle. It can also issue one-shot vertical/horizontal swing 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.
- Thermostat hysteresis, compressor protection, schedule hand-back, manual/local ownership and Temporary Quick Thermostat keep their existing priority.
+3 -3
View File
@@ -2,9 +2,9 @@
"openapi": "3.1.0",
"info": {
"title": "GREE Controller API",
"version": "0.14.1",
"version": "0.14.2",
"summary": "Local HTTP/WebSocket API for GREE HVAC control",
"description": "Self-hosted API used by the GREE Controller Web UI and Home Assistant integration.\n\n### Authentication\nAdministrator endpoints accept `Authorization: Bearer <APP_TOKEN>` or `x-api-token: <APP_TOKEN>` when configured. Restricted Home Assistant control endpoints also accept generated integration tokens.\n\n### Local and GREE Cloud\nDevices use one API model and one command surface. `connection_type=local` is dispatched only to the existing LAN/UDP provider; `connection_type=gree_cloud` is dispatched only to the GREE Cloud provider. No automatic transport fallback is performed.\n\n### GREE Cloud security\nCloud passwords, REST/MQTT tokens, Authorization values and device cipher keys are never returned in normal API responses or Cloud diagnostics. MQTT uses TLS with certificate and hostname validation.\n\n### Energy\nGREE Cloud `ElcAll` and selected Home Assistant cumulative energy sensors are normalized to kWh and converted to consumption deltas before aggregation. Raw cumulative values are retained only as meter samples/baselines.\n\n### 0.14.1\nAdds split/multisplit installation groups with shared energy and outdoor-temperature sources, multi-target energy charts with period comparison, cleaner Cloud status/alerts and safe MQTT cleanup after the last Cloud device is removed.\n\n### 0.14.0\nAdds first-class GREE Cloud MQTT status/control, reconnect/resubscribe lifecycle, Cloud diagnostics, per-device capabilities, Cloud/HA energy sources and energy-history aggregation while preserving the LAN transport contract.",
"description": "Self-hosted API used by the GREE Controller Web UI and Home Assistant integration.\n\n### Authentication\nAdministrator endpoints accept `Authorization: Bearer <APP_TOKEN>` or `x-api-token: <APP_TOKEN>` when configured. Restricted Home Assistant control endpoints also accept generated integration tokens.\n\n### Local and GREE Cloud\nDevices use one API model and one command surface. `connection_type=local` is dispatched only to the existing LAN/UDP provider; `connection_type=gree_cloud` is dispatched only to the GREE Cloud provider. No automatic transport fallback is performed.\n\n### GREE Cloud security\nCloud passwords, REST/MQTT tokens, Authorization values and device cipher keys are never returned in normal API responses or Cloud diagnostics. MQTT uses TLS with certificate and hostname validation.\n\n### Energy\nGREE Cloud `ElcAll` and selected Home Assistant cumulative energy sensors are normalized to kWh and converted to consumption deltas before aggregation. Raw cumulative values are retained only as meter samples/baselines.\n\n### 0.14.2\nAdds independent vertical/horizontal swing controls across manual device control, thermostat zones, legacy automations, Visual Flow and Home Assistant zone climates. Swing is treated as an auxiliary one-shot unit setting and does not replace thermostat ownership.\n\n### 0.14.1\nAdds split/multisplit installation groups with shared energy and outdoor-temperature sources, multi-target energy charts with period comparison, cleaner Cloud status/alerts and safe MQTT cleanup after the last Cloud device is removed.\n\n### 0.14.0\nAdds first-class GREE Cloud MQTT status/control, reconnect/resubscribe lifecycle, Cloud diagnostics, per-device capabilities, Cloud/HA energy sources and energy-history aggregation while preserving the LAN transport contract.",
"license": {
"name": "MIT"
}
@@ -5391,7 +5391,7 @@
},
"version": {
"type": "string",
"example": "0.14.1"
"example": "0.14.2"
},
"uptime_seconds": {
"type": "integer",
@@ -266,12 +266,8 @@ class GreeControllerZoneClimate(CoordinatorEntity[GreeControllerCoordinator], Cl
_attr_target_temperature_step = 0.5
_attr_hvac_modes = [HVACMode.OFF, HVACMode.AUTO, HVACMode.COOL, HVACMode.HEAT]
_attr_preset_modes = ["auto", "comfort", "sleep", "away"]
_attr_supported_features = (
ClimateEntityFeature.TARGET_TEMPERATURE
| ClimateEntityFeature.PRESET_MODE
| ClimateEntityFeature.TURN_ON
| ClimateEntityFeature.TURN_OFF
)
_attr_swing_modes = [SWING_OFF, SWING_ON]
_attr_swing_horizontal_modes = [SWING_OFF, SWING_ON]
def __init__(self, coordinator: GreeControllerCoordinator, zone_id: str) -> None:
super().__init__(coordinator)
@@ -285,10 +281,30 @@ class GreeControllerZoneClimate(CoordinatorEntity[GreeControllerCoordinator], Cl
return zone
return {}
@property
def _device(self) -> dict[str, Any]:
device_id = str(self._zone.get("device_id") or "").strip()
return self.coordinator.data.get(device_id, {}) if device_id else {}
@property
def available(self) -> bool:
return super().available and bool(self._zone)
@property
def supported_features(self) -> ClimateEntityFeature:
features = (
ClimateEntityFeature.TARGET_TEMPERATURE
| ClimateEntityFeature.PRESET_MODE
| ClimateEntityFeature.TURN_ON
| ClimateEntityFeature.TURN_OFF
)
capabilities = self._device.get("capabilities") or {}
if capabilities.get("vertical_swing", True) is not False:
features |= ClimateEntityFeature.SWING_MODE
if capabilities.get("horizontal_swing", True) is not False:
features |= ClimateEntityFeature.SWING_HORIZONTAL_MODE
return features
@property
def device_info(self) -> DeviceInfo:
zone = self._zone
@@ -325,6 +341,14 @@ class GreeControllerZoneClimate(CoordinatorEntity[GreeControllerCoordinator], Cl
def preset_mode(self) -> str:
return str(self._zone.get("preset_override") or "auto")
@property
def swing_mode(self) -> str:
return SWING_ON if self._device.get("swing_vertical", False) else SWING_OFF
@property
def swing_horizontal_mode(self) -> str:
return SWING_ON if self._device.get("swing_horizontal", False) else SWING_OFF
@property
def extra_state_attributes(self) -> dict[str, Any]:
zone = self._zone
@@ -380,6 +404,20 @@ class GreeControllerZoneClimate(CoordinatorEntity[GreeControllerCoordinator], Cl
return
await self.coordinator.async_zone_control(self._zone_id, {"preset": preset_mode})
async def async_set_swing_mode(self, swing_mode: str) -> None:
device_id = str(self._zone.get("device_id") or "").strip()
if device_id:
await self.coordinator.async_device_command(
device_id, {"swing_vertical": swing_mode == SWING_ON}
)
async def async_set_swing_horizontal_mode(self, swing_horizontal_mode: str) -> None:
device_id = str(self._zone.get("device_id") or "").strip()
if device_id:
await self.coordinator.async_device_command(
device_id, {"swing_horizontal": swing_horizontal_mode == SWING_ON}
)
async def async_turn_on(self) -> None:
await self.coordinator.async_zone_control(self._zone_id, {"power": True})
@@ -1,7 +1,7 @@
{
"domain": "gree_controller",
"name": "GREE Controller",
"version": "0.14.1",
"version": "0.14.2",
"config_flow": true,
"integration_type": "hub",
"iot_class": "local_polling",
@@ -1,5 +1,14 @@
# Changelog
## 0.14.2
- Adds separate vertical and horizontal swing controls for local units in Manual Control, respecting per-device capabilities.
- Adds vertical and horizontal swing controls to thermostat zone cards without switching the zone into manual device ownership.
- Adds optional `swing_vertical` and `swing_horizontal` actions to legacy direct-device automations.
- Extends Visual Flow so both `device_action` and `zone_thermostat` can control vertical/horizontal swing, and device-state selectors use readable swing labels.
- Prevents swing-enabled thermostat Flows from being reduced to native schedules, preserving the auxiliary swing action at runtime.
- Exposes vertical and horizontal swing on Home Assistant zone thermostat climate entities when supported by the assigned unit.
## 0.14.1
- Adds split and multisplit installation groups in the Devices section, including assignment of indoor units to a shared outdoor installation.
@@ -1,5 +1,5 @@
name: "GREE Controller"
version: "0.14.1"
version: "0.14.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/"
+15 -2
View File
@@ -1286,6 +1286,19 @@
"energy.comparisonPeriod": "Comparison period",
"energy.groupSource": "Group source",
"energy.selectAtLeastOne": "Select at least one device or installation.",
"energy.multiselectHint": "You can select several items (Ctrl/Cmd or Shift)."
"energy.multiselectHint": "Select up to 8 items.",
"energy.totalMeter": "Total consumption",
"energy.lastReading": "Last reading",
"devices.sharedOutdoorMetric": "Shared outdoor temperature",
"history.sharedOutdoor": "shared outdoor temperature",
"energy.chooseTargets": "Choose devices / installations",
"energy.selectedCount": "Selected: {count}",
"energy.maxTargets": "You can select up to 8 devices or installations.",
"energy.period": "Data period",
"energy.periodHint": "Time range used for energy bars and totals.",
"devices.swingVertical": "Vertical swing",
"devices.swingHorizontal": "Horizontal swing",
"flow.thermostatDeviceOptions": "Unit swing control",
"flow.thermostatDeviceOptionsHint": "Vertical and horizontal swing are sent to the unit as independent settings and do not change thermostat logic."
}
}
}
+15 -2
View File
@@ -1286,6 +1286,19 @@
"energy.comparisonPeriod": "Okres porównawczy",
"energy.groupSource": "Źródło grupy",
"energy.selectAtLeastOne": "Wybierz co najmniej jedno urządzenie lub instalację.",
"energy.multiselectHint": "Możesz zaznaczyć kilka pozycji (Ctrl/Cmd lub Shift)."
"energy.multiselectHint": "Wybierz do 8 pozycji.",
"energy.totalMeter": "Całkowite zużycie",
"energy.lastReading": "Ostatni odczyt",
"devices.sharedOutdoorMetric": "Wspólna temperatura zewnętrzna",
"history.sharedOutdoor": "wspólna temperatura zewnętrzna",
"energy.chooseTargets": "Wybierz urządzenia / instalacje",
"energy.selectedCount": "Wybrano: {count}",
"energy.maxTargets": "Możesz wybrać maksymalnie 8 urządzeń lub instalacji.",
"energy.period": "Okres danych",
"energy.periodHint": "Zakres czasu używany do słupków i sum energii.",
"devices.swingVertical": "Swing pionowy",
"devices.swingHorizontal": "Swing poziomy",
"flow.thermostatDeviceOptions": "Sterowanie żaluzjami jednostki",
"flow.thermostatDeviceOptionsHint": "Swing pionowy i poziomy są wysyłane do jednostki jako niezależne ustawienia i nie zmieniają logiki termostatu."
}
}
}
+4 -4
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""Developer smoke/integration tests for GREE Controller API 0.14.1.
"""Developer smoke/integration tests for GREE Controller API 0.14.2.
Default mode is read-only and safe to run against a real controller.
Use --settings-write to additionally round-trip all split settings resources and
@@ -26,7 +26,7 @@ from typing import Any, Callable, Iterable, Optional
DEFAULT_BASE_URL = os.environ.get("GREE_API_URL", "http://127.0.0.1:8787")
DEFAULT_TOKEN = os.environ.get("GREE_API_TOKEN", "")
DEFAULT_EXPECTED_VERSION = "0.14.1"
DEFAULT_EXPECTED_VERSION = "0.14.2"
SETTINGS_PATHS: dict[str, set[str]] = {
"/api/settings/application": {"simulator_enabled"},
@@ -571,7 +571,7 @@ def run_suite(args: argparse.Namespace) -> int:
print(f"Settings write tests: {'ENABLED' if args.settings_write else 'disabled'}\n")
runner.run("public health", lambda: test_health(client, args.expected_version))
runner.run("OpenAPI 0.14.1 contract", lambda: test_openapi(client, args.expected_version))
runner.run("OpenAPI 0.14.2 contract", lambda: test_openapi(client, args.expected_version))
runner.run("bootstrap snapshot contract", lambda: test_bootstrap_contract(client))
runner.run("protected API requires auth", lambda: test_protected_auth(client))
@@ -640,7 +640,7 @@ def run_suite(args: argparse.Namespace) -> int:
def parse_args(argv: Optional[Iterable[str]] = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Developer smoke/integration tests for GREE Controller API 0.14.1.",
description="Developer smoke/integration tests for GREE Controller API 0.14.2.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""Examples:
python3 scripts/api_dev_test.py
+1 -1
View File
@@ -27,7 +27,7 @@ async fn export_configuration(
fn validate_configuration_header(export: &ConfigurationExport) -> Result<(), AppError> {
if export.format_version != 3 {
return Err(AppError::BadRequest("unsupported configuration export version; version 3 is required by GREE Controller 0.14.1".into()));
return Err(AppError::BadRequest("unsupported configuration export version; version 3 is required by GREE Controller 0.14.2".into()));
}
if export.settings.control_strategy != "setpoint" {
return Err(AppError::BadRequest(
+5 -1
View File
@@ -1107,7 +1107,9 @@ fn compile_flow(
&& native_time_range
&& preset_for_schedule != "auto"
&& mode_for_schedule == "auto"
&& flow_bool(&action_node.config, "power").is_none();
&& flow_bool(&action_node.config, "power").is_none()
&& flow_bool(&action_node.config, "swing_vertical").is_none()
&& flow_bool(&action_node.config, "swing_horizontal").is_none();
if schedule_only {
let zone_id = flow_string(&action_node.config, "zone_id")
.ok_or_else(|| AppError::BadRequest("thermostat block needs a zone".into()))?;
@@ -1285,6 +1287,8 @@ fn compile_flow(
}
item.action.mode = Some(mode);
}
item.action.swing_vertical = flow_bool(&action_node.config, "swing_vertical");
item.action.swing_horizontal = flow_bool(&action_node.config, "swing_horizontal");
}
"device_action" => {
let id = flow_string(&action_node.config, "device_id")
+78 -1
View File
@@ -20,12 +20,81 @@ struct SystemInfoResponse {
gree_received_frames_by_device: std::collections::HashMap<String, u64>,
}
#[derive(Debug, Clone, serde::Serialize)]
struct DeviceGroupEnergySnapshot {
total_kwh: f64,
timestamp: Option<chrono::DateTime<Utc>>,
source: String,
origin: String,
}
fn device_group_energy_snapshot(
state: &AppState,
group: &DeviceGroup,
devices: &[Device],
) -> Result<Option<DeviceGroupEnergySnapshot>, AppError> {
let selected_source = match group.energy_source {
EnergySourcePreference::GreeCloud => Some("gree_cloud"),
EnergySourcePreference::HomeAssistant => Some("home_assistant"),
EnergySourcePreference::Auto if group.energy_device_id.is_some() => Some("gree_cloud"),
EnergySourcePreference::Auto if group.ha_energy_entity_id.is_some() => Some("home_assistant"),
EnergySourcePreference::Auto => None,
};
match selected_source {
Some("gree_cloud") => {
let Some(device_id) = group.energy_device_id.as_deref() else {
return Ok(None);
};
if let Some(device) = devices.iter().find(|device| device.id == device_id) {
if let Some(total_kwh) = device
.total_energy_kwh
.filter(|value| value.is_finite() && *value >= 0.0)
{
return Ok(Some(DeviceGroupEnergySnapshot {
total_kwh,
timestamp: device
.last_cloud_sync
.clone()
.or_else(|| device.last_seen.clone()),
source: "gree_cloud".into(),
origin: "cloud".into(),
}));
}
}
Ok(state
.db
.last_energy_reading(device_id, "gree_cloud")?
.map(|reading| DeviceGroupEnergySnapshot {
total_kwh: reading.normalized_meter_kwh,
timestamp: Some(reading.timestamp),
source: "gree_cloud".into(),
origin: "database".into(),
}))
}
Some("home_assistant") => {
let storage_id = format!("group:{}", group.id);
Ok(state
.db
.last_energy_reading(&storage_id, "home_assistant")?
.map(|reading| DeviceGroupEnergySnapshot {
total_kwh: reading.normalized_meter_kwh,
timestamp: Some(reading.timestamp),
source: "home_assistant".into(),
origin: "database".into(),
}))
}
_ => Ok(None),
}
}
#[derive(Debug, serde::Serialize)]
struct BootstrapResponse {
devices: Vec<Device>,
zones: Vec<Zone>,
groups: Vec<ClimateGroup>,
device_groups: Vec<DeviceGroup>,
device_group_energy: std::collections::HashMap<String, DeviceGroupEnergySnapshot>,
schedules: Vec<Schedule>,
automations: Vec<Automation>,
flows: Vec<Flow>,
@@ -59,6 +128,13 @@ async fn build_bootstrap(state: &AppState) -> Result<BootstrapResponse, AppError
(settings_snapshot(&settings), settings.house_mode.clone())
};
let devices = state.db.list_devices()?;
let device_groups = state.db.list_device_groups()?;
let mut device_group_energy = std::collections::HashMap::new();
for group in &device_groups {
if let Some(snapshot) = device_group_energy_snapshot(state, group, &devices)? {
device_group_energy.insert(group.id.clone(), snapshot);
}
}
let system = build_system_info(state, &devices);
let control_plan = engine::get_control_plan_snapshot(state).await?;
@@ -66,7 +142,8 @@ async fn build_bootstrap(state: &AppState) -> Result<BootstrapResponse, AppError
devices,
zones: state.db.list_zones()?,
groups: state.db.list_groups()?,
device_groups: state.db.list_device_groups()?,
device_groups,
device_group_energy,
schedules: state.db.list_schedules()?,
automations: state.db.list_automations()?,
flows: state.db.list_flows()?,
+1 -1
View File
@@ -2,7 +2,7 @@ use crate::{
error::AppError,
home_assistant, influxdb,
models::{
Automation, AutomationPlanRule, ClimateGroup, ConnectionStatus, ConnectionType, ControlPlan, ControlPlanEvent, Device, EnergyReading, EnergySourcePreference,
Automation, AutomationPlanRule, ClimateGroup, ConnectionStatus, ConnectionType, ControlPlan, ControlPlanEvent, Device, DeviceGroup, EnergyReading, EnergySourcePreference,
DeviceCommand, GroupControlPatch, HaReading, NightModeSettings, Reading, RuntimeSettings,
Schedule, TemporaryQuickThermostat, Zone, ZoneControlPlan, ZoneReading,
},
+25 -2
View File
@@ -897,13 +897,36 @@ async fn apply_flow_zone_action(state: &AppState, zone_id: &str, preset: Option<
zone.control_reason = "Visual Flow automation".into();
}
refresh_zone_runtime_target(&mut zone, &schedules, &house_mode);
state.db.save_zone(&zone)?; state.broadcast("zone.updated", serde_json::to_value(&zone)?); state.wake_zone_control();
state.db.save_zone(&zone)?;
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
let swing_command = DeviceCommand {
swing_vertical: action.swing_vertical,
swing_horizontal: action.swing_horizontal,
..Default::default()
};
if action.power == Some(false) {
// Disabled zones are intentionally skipped by the normal thermostat cycle. Perform the
// physical OFF under the canonical zone -> device lock order so the durable Flow intent
// cannot leave a unit running and polling/manual control cannot interleave with the frame.
// Swing can safely share this explicit frame because it is outside thermostat regulation.
let _device_guard = state.lock_device_operation(&device_id).await;
send_command_locked_forced(state, &device_id, DeviceCommand { power: Some(false), ..Default::default() }).await?;
send_command_locked_forced(
state,
&device_id,
DeviceCommand {
power: Some(false),
swing_vertical: swing_command.swing_vertical,
swing_horizontal: swing_command.swing_horizontal,
..Default::default()
},
)
.await?;
} else if !swing_command.is_empty() {
// Swing is intentionally a one-shot auxiliary unit setting. It does not participate in
// temperature/fan regulation, so the thermostat keeps ownership of the zone.
let _ = send_automatic_device_command_if_owned(state, &device_id, swing_command).await?;
}
state.wake_zone_control();
Ok(true)
}
+9
View File
@@ -56,6 +56,15 @@ pub(crate) fn record_cumulative_energy_sample(
reset_detected,
};
state.db.add_energy_reading(&reading)?;
state.broadcast(
"energy.updated",
json!({
"target_id": reading.device_id.clone(),
"source": reading.source.clone(),
"total_kwh": reading.normalized_meter_kwh,
"timestamp": reading.timestamp,
}),
);
queue_influx_energy(state, reading.clone());
Ok(reading)
}
+51 -7
View File
@@ -121,13 +121,7 @@ fn queue_influx_ha(state: &AppState, reading: HaReading) {
});
}
fn gree_outdoor_temperature(devices: &[Device]) -> Option<f64> {
let mut values: Vec<f64> = devices
.iter()
.filter(|device| device.enabled && device.online && device.communication_failures == 0)
.filter_map(|device| device.outdoor_temperature)
.filter(|value| value.is_finite() && (-60.0..=70.0).contains(value))
.collect();
fn median_outdoor_temperature(mut values: Vec<f64>) -> Option<f64> {
if values.is_empty() {
return None;
}
@@ -140,3 +134,53 @@ fn gree_outdoor_temperature(devices: &[Device]) -> Option<f64> {
};
Some((value * 10.0).round() / 10.0)
}
fn valid_outdoor_temperature(device: &Device) -> Option<f64> {
if !device.enabled || !device.online || device.communication_failures != 0 {
return None;
}
device
.outdoor_temperature
.filter(|value| value.is_finite() && (-60.0..=70.0).contains(value))
}
fn gree_outdoor_temperature(devices: &[Device], device_groups: &[DeviceGroup]) -> Option<f64> {
let by_id: HashMap<&str, &Device> = devices
.iter()
.map(|device| (device.id.as_str(), device))
.collect();
let mut grouped_ids = HashSet::new();
let mut values = Vec::new();
for group in device_groups {
for device_id in &group.device_ids {
grouped_ids.insert(device_id.as_str());
}
let group_value = group
.outdoor_temperature_device_id
.as_deref()
.and_then(|source_id| by_id.get(source_id).copied())
.and_then(valid_outdoor_temperature)
.or_else(|| {
median_outdoor_temperature(
group
.device_ids
.iter()
.filter_map(|id| by_id.get(id.as_str()).copied())
.filter_map(valid_outdoor_temperature)
.collect(),
)
});
if let Some(value) = group_value {
values.push(value);
}
}
values.extend(
devices
.iter()
.filter(|device| !grouped_ids.contains(device.id.as_str()))
.filter_map(valid_outdoor_temperature),
);
median_outdoor_temperature(values)
}
+31 -1
View File
@@ -352,6 +352,7 @@ mod tests {
assert!(automation_action_conflicts_with_thermostat(&DeviceCommand { mode: Some("dry".into()), ..Default::default() }));
assert!(!automation_action_conflicts_with_thermostat(&DeviceCommand { mode: Some("heat".into()), ..Default::default() }));
assert!(!automation_action_conflicts_with_thermostat(&DeviceCommand { light: Some(false), turbo: Some(true), ..Default::default() }));
assert!(!automation_action_conflicts_with_thermostat(&DeviceCommand { swing_vertical: Some(true), swing_horizontal: Some(false), ..Default::default() }));
}
#[test]
@@ -988,9 +989,38 @@ mod tests {
let mut c = Device::simulated_default();
c.id = "sim-c".into();
c.outdoor_temperature = Some(40.0);
assert_eq!(gree_outdoor_temperature(&[a, b, c]), Some(12.0));
assert_eq!(gree_outdoor_temperature(&[a, b, c], &[]), Some(12.0));
}
#[test]
fn gree_outdoor_fallback_counts_multisplit_once() {
let mut a = Device::simulated_default();
a.id = "a".into();
a.outdoor_temperature = Some(10.0);
let mut b = Device::simulated_default();
b.id = "b".into();
b.outdoor_temperature = Some(10.0);
let mut c = Device::simulated_default();
c.id = "c".into();
c.outdoor_temperature = Some(30.0);
let now = Utc::now();
let group = DeviceGroup {
id: "multi".into(),
name: "Multi".into(),
kind: crate::models::DeviceGroupKind::Multisplit,
device_ids: vec!["a".into(), "b".into()],
energy_source: EnergySourcePreference::Auto,
energy_device_id: None,
ha_energy_entity_id: None,
ha_energy_unit: None,
ha_energy_device_class: None,
ha_energy_state_class: None,
outdoor_temperature_device_id: Some("a".into()),
created_at: now,
updated_at: now,
};
assert_eq!(gree_outdoor_temperature(&[a, b, c], &[group]), Some(20.0));
}
#[test]
fn global_one_shot_queue_does_not_change_zone_ownership() {
+2 -1
View File
@@ -76,7 +76,8 @@ async fn resolve_cycle_outdoor_temperature(
} else {
None
};
let temperature = from_home_assistant.or_else(|| gree_outdoor_temperature(devices));
let device_groups = state.db.list_device_groups().unwrap_or_default();
let temperature = from_home_assistant.or_else(|| gree_outdoor_temperature(devices, &device_groups));
let mut current = state.outdoor_temperature.write().await;
if *current != temperature {
*current = temperature;
+14 -2
View File
@@ -333,7 +333,7 @@
<div class="panel chart-panel history-toolbar-panel">
<div class="chart-toolbar">
<div id="historyContextControls" class="history-context-controls"></div>
<label class="history-range-control"><span data-i18n="history.range">Range</span><select id="historyHours"
<label class="history-range-control" id="historyRangeControl"><span data-i18n="history.range">Range</span><select id="historyHours"
data-i18n-aria="history.range" aria-label="History range">
<option value="6" data-i18n="history.6h">6 hours</option>
<option value="24" selected data-i18n="history.24h">24 hours</option>
@@ -341,7 +341,7 @@
<option value="720" data-i18n="history.30d">30 days</option>
<option value="2160" data-i18n="history.90d">90 days</option>
<option value="8760" data-i18n="history.1y">1 year</option>
</select></label>
</select><small id="historyRangeHint" hidden></small></label>
<button class="secondary history-refresh-button" id="historyRefresh"
data-i18n="actions.refresh">Refresh</button>
</div>
@@ -1584,6 +1584,18 @@
type="text" inputmode="decimal" step="0.1" min="8" max="30"
name="action_target_temperature"></label><label><span data-i18n="common.cooldownSeconds">Cooldown
(s)</span><input type="number" min="30" name="cooldown_seconds" value="300"></label></div>
<div class="two" id="automationDeviceSwingOptions">
<label><span data-i18n="devices.swingVertical">Vertical swing</span><select name="action_swing_vertical">
<option value="" data-i18n="actions.noChange">No change</option>
<option value="true" data-i18n="actions.turnOn">Turn on</option>
<option value="false" data-i18n="actions.turnOff">Turn off</option>
</select></label>
<label><span data-i18n="devices.swingHorizontal">Horizontal swing</span><select name="action_swing_horizontal">
<option value="" data-i18n="actions.noChange">No change</option>
<option value="true" data-i18n="actions.turnOn">Turn on</option>
<option value="false" data-i18n="actions.turnOff">Turn off</option>
</select></label>
</div>
<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
+1
View File
@@ -35,6 +35,7 @@ function settingsFromSnapshot(sections, houseMode = app.settings?.house_mode ||
function applyBootstrapSnapshot(data) {
app.devices = data.devices || [];
app.deviceGroups = data.device_groups || [];
app.deviceGroupEnergy = data.device_group_energy || {};
app.zones = data.zones || [];
app.groups = data.groups || [];
app.schedules = data.schedules || [];
+97 -4
View File
@@ -51,9 +51,45 @@ function normalizeEnergyHistoryTargets() {
return available;
}
function energyTargetPickerSummary(targets) {
const selected = targets.filter(target => app.historyEnergyTargets.includes(target.id));
if (!selected.length) return tr('energy.chooseTargets');
if (selected.length === 1) return selected[0].label;
return tr('energy.selectedCount', { count: selected.length });
}
function resetHistoryRangeControl(host) {
const range = $('#historyRangeControl');
const toolbar = host?.closest('.chart-toolbar');
const refresh = $('#historyRefresh');
if (range && toolbar && range.parentElement === host) toolbar.insertBefore(range, refresh);
}
function configureHistoryRangeControl(tab, host) {
const range = $('#historyRangeControl');
if (!range) return;
const toolbar = range.closest('.chart-toolbar') || host?.closest('.chart-toolbar');
const label = range.querySelector('span');
const hint = $('#historyRangeHint');
const sixHours = range.querySelector('option[value="6"]');
const energy = tab === 'energy';
const rangeLabel = tr(energy ? 'energy.period' : 'history.range');
if (label) label.textContent = rangeLabel;
range.setAttribute('aria-label', rangeLabel);
if (hint) { hint.textContent = energy ? tr('energy.periodHint') : ''; hint.hidden = !energy; }
if (sixHours) { sixHours.hidden = energy; sixHours.disabled = energy; }
if (energy && $('#historyHours')?.value === '6') $('#historyHours').value = '24';
range.classList.toggle('energy-period-control', energy);
if (toolbar) toolbar.classList.toggle('energy-toolbar', energy);
if (energy && host) host.appendChild(range);
}
function renderHistoryNavigation() {
$$('#historyTabs [data-history-tab]').forEach(button => button.classList.toggle('active', button.dataset.historyTab === app.historyTab));
const energyPickerWasOpen = $('#historyEnergyTargetPicker')?.open === true;
const host = $('#historyContextControls'); if (!host) return;
resetHistoryRangeControl(host);
host.classList.toggle('energy-context-controls', app.historyTab === 'energy');
const options = historyEntityOptions();
if (app.historyTab === 'zones') {
host.innerHTML = `<label><span>${esc(tr('common.zone'))}</span><select id="historyZoneSelect"><option value="all">${esc(tr('history.allZones'))}</option>${options.zones}</select></label>`;
@@ -63,14 +99,13 @@ function renderHistoryNavigation() {
const select = $('#historyDeviceSelect'); if ([...select.options].some(option => option.value === app.historyDevice)) select.value = app.historyDevice;
} else if (app.historyTab === 'energy') {
const targets = normalizeEnergyHistoryTargets();
const targetOptions = targets.map(target => `<option value="${esc(target.id)}">${esc(target.label)}</option>`).join('');
const targetOptions = targets.map(target => `<label class="history-energy-option"><input type="checkbox" data-history-energy-target="${esc(target.id)}" ${app.historyEnergyTargets.includes(target.id) ? 'checked' : ''}><span>${esc(target.label)}</span></label>`).join('');
host.innerHTML = targets.length
? `<label class="history-energy-targets"><span>${esc(tr('energy.targets'))}</span><select id="historyEnergyTargetSelect" multiple size="${Math.min(6, Math.max(2, targets.length))}">${targetOptions}</select><small>${esc(tr('energy.multiselectHint'))}</small></label><label><span>${esc(tr('history.bucket'))}</span><select id="historyEnergyInterval"><option value="hourly">${esc(tr('energy.hourly'))}</option><option value="daily">${esc(tr('energy.daily'))}</option><option value="weekly">${esc(tr('energy.weekly'))}</option><option value="monthly">${esc(tr('energy.monthly'))}</option></select></label><label><span>${esc(tr('energy.compare'))}</span><select id="historyEnergyCompare"><option value="none">${esc(tr('energy.compareNone'))}</option><option value="previous_day">${esc(tr('energy.comparePreviousDay'))}</option><option value="previous_period">${esc(tr('energy.comparePreviousPeriod'))}</option><option value="previous_year">${esc(tr('energy.comparePreviousYear'))}</option></select></label>`
? `<div class="history-energy-targets"><span class="history-control-label">${esc(tr('energy.targets'))}</span><details class="history-energy-picker" id="historyEnergyTargetPicker"><summary><span>${esc(energyTargetPickerSummary(targets))}</span><b>${app.historyEnergyTargets.length}/8</b></summary><div class="history-energy-options">${targetOptions}</div></details><small>${esc(tr('energy.multiselectHint'))}</small></div><label><span>${esc(tr('history.bucket'))}</span><select id="historyEnergyInterval"><option value="hourly">${esc(tr('energy.hourly'))}</option><option value="daily">${esc(tr('energy.daily'))}</option><option value="weekly">${esc(tr('energy.weekly'))}</option><option value="monthly">${esc(tr('energy.monthly'))}</option></select></label><label><span>${esc(tr('energy.compare'))}</span><select id="historyEnergyCompare"><option value="none">${esc(tr('energy.compareNone'))}</option><option value="previous_day">${esc(tr('energy.comparePreviousDay'))}</option><option value="previous_period">${esc(tr('energy.comparePreviousPeriod'))}</option><option value="previous_year">${esc(tr('energy.comparePreviousYear'))}</option></select></label>`
: `<span class="history-context-hint">${esc(tr('energy.noData'))}</span>`;
const targetSelect = $('#historyEnergyTargetSelect');
if (targetSelect) [...targetSelect.options].forEach(option => { option.selected = app.historyEnergyTargets.includes(option.value); });
const intervalSelect = $('#historyEnergyInterval'); if (intervalSelect) intervalSelect.value = app.historyEnergyInterval;
const compareSelect = $('#historyEnergyCompare'); if (compareSelect) compareSelect.value = app.historyEnergyCompare;
if (energyPickerWasOpen && $('#historyEnergyTargetPicker')) $('#historyEnergyTargetPicker').open = true;
} else if (app.historyTab === 'sensors') {
host.innerHTML = `<label><span>${esc(tr('history.haSensor'))}</span><select id="historySensorSelect"><option value="all">${esc(tr('history.allSensors'))}</option>${options.sensors}</select></label>`;
const select = $('#historySensorSelect'); if ([...select.options].some(option => option.value === app.historySensor)) select.value = app.historySensor;
@@ -79,6 +114,7 @@ function renderHistoryNavigation() {
} else {
host.innerHTML = `<span class="history-context-hint">${esc(tr('history.overviewHint'))}</span>`;
}
configureHistoryRangeControl(app.historyTab, host);
}
async function loadHistory() {
@@ -475,6 +511,62 @@ function energyBucketLabel(timestamp, interval) {
return new Intl.DateTimeFormat(locale(), { day: '2-digit', month: '2-digit', ...(interval === 'weekly' ? { year: '2-digit' } : {}) }).format(date);
}
function energyBucketTooltipLabel(timestamp, interval) {
const date = new Date(timestamp);
if (interval === 'hourly') return chartPreciseTime(timestamp);
if (interval === 'monthly') return new Intl.DateTimeFormat(locale(), { month: 'long', year: 'numeric' }).format(date);
if (interval === 'weekly') return new Intl.DateTimeFormat(locale(), { day: '2-digit', month: '2-digit', year: 'numeric' }).format(date);
return new Intl.DateTimeFormat(locale(), { day: '2-digit', month: '2-digit', year: 'numeric' }).format(date);
}
function bindEnergyChartTooltip(canvas, series, starts, maps, geometry) {
const wrap = canvas?.parentElement;
const tooltip = wrap?.querySelector('.chart-tooltip');
const line = wrap?.querySelector('.chart-hover-line');
if (!canvas || !wrap || !tooltip || !line || !starts.length) return;
const { pad, width, height, slot, interval } = geometry;
let activeIndex = -1;
const hide = () => { tooltip.hidden = true; line.hidden = true; activeIndex = -1; };
const showIndex = index => {
index = clamp(index, 0, starts.length - 1);
const start = starts[index];
const px = pad.left + slot * index + slot / 2;
const rows = series.map((item, seriesIndex) => ({ item, value: maps[seriesIndex].get(start) || 0 }));
$$('.chart-tooltip').forEach(node => { if (node !== tooltip) node.hidden = true; });
$$('.chart-hover-line').forEach(node => { if (node !== line) node.hidden = true; });
tooltip.innerHTML = `<strong class="chart-tooltip-time">${esc(energyBucketTooltipLabel(start, interval))}</strong><div class="chart-tooltip-values">${rows.map(({ item, value }) => `<div class="chart-tooltip-row"><span class="chart-tooltip-label"><i style="--tooltip-color:${esc(item.color)}"></i><span>${esc(item.label)}</span></span><span class="chart-tooltip-value">${esc(Number(value).toLocaleString(locale(), { minimumFractionDigits: value > 0 && value < 0.1 ? 3 : 2, maximumFractionDigits: 3 }))} kWh</span></div>`).join('')}</div>`;
tooltip.hidden = false;
line.hidden = false;
line.style.left = `${px}px`;
line.style.top = `${pad.top}px`;
line.style.height = `${Math.max(0, height - pad.top - pad.bottom)}px`;
const minLeft = wrap.scrollLeft + 8;
const maxLeft = wrap.scrollLeft + wrap.clientWidth - tooltip.offsetWidth - 8;
let left = px + 12;
if (left + tooltip.offsetWidth > wrap.scrollLeft + wrap.clientWidth - 8) left = px - tooltip.offsetWidth - 12;
tooltip.style.left = `${Math.max(minLeft, Math.min(left, Math.max(minLeft, maxLeft)))}px`;
tooltip.style.top = `${pad.top + 8}px`;
activeIndex = index;
};
const indexFromClientX = clientX => {
const rect = canvas.getBoundingClientRect();
const x = clamp(clientX - rect.left, pad.left, width - pad.right - 1);
return clamp(Math.floor((x - pad.left) / Math.max(1, slot)), 0, starts.length - 1);
};
canvas.onpointermove = event => { if (event.pointerType !== 'touch') showIndex(indexFromClientX(event.clientX)); };
canvas.onpointerleave = event => { if (event.pointerType !== 'touch') hide(); };
canvas.onpointerdown = null; canvas.onpointerup = null; canvas.onpointercancel = null;
canvas.onkeydown = event => {
if (!['ArrowLeft', 'ArrowRight'].includes(event.key)) return;
event.preventDefault();
if (activeIndex < 0) activeIndex = event.key === 'ArrowRight' ? 0 : starts.length - 1;
else activeIndex = clamp(activeIndex + (event.key === 'ArrowRight' ? 1 : -1), 0, starts.length - 1);
showIndex(activeIndex);
};
}
function drawEnergyBarChart(canvas, seriesInput, { height = 340, interval = app.historyEnergyInterval } = {}) {
if (!canvas) return;
const series = Array.isArray(seriesInput) && seriesInput.length && Array.isArray(seriesInput[0]?.buckets)
@@ -532,6 +624,7 @@ function drawEnergyBarChart(canvas, seriesInput, { height = 340, interval = app.
ctx.fillText(energyBucketLabel(starts[index], interval), x, height - 18);
}
ctx.textAlign = 'left'; ctx.fillText('kWh', 8, pad.top + 4);
bindEnergyChartTooltip(canvas, visibleSeries, starts, maps, { pad, width, height, slot, interval });
renderLegend(document.getElementById(`${canvas.id}Legend`), series);
updateChartZoomControls(canvas.id);
}
+16 -1
View File
@@ -25,7 +25,7 @@ const preferredLanguage = getCookie('gree_controller_language') || DEFAULT_LANGU
const preferredTheme = ['system', 'light', 'dark'].includes(getCookie('gree_controller_theme')) ? getCookie('gree_controller_theme') : 'system';
const app = {
devices: [], zones: [], groups: [], deviceGroups: [], schedules: [], automations: [], flows: [], accessTokens: [], settings: null, system: {}, outdoorTemperature: null,
devices: [], zones: [], groups: [], deviceGroups: [], deviceGroupEnergy: {}, schedules: [], automations: [], flows: [], accessTokens: [], settings: null, system: {}, outdoorTemperature: null,
token: localStorage.getItem('gree_controller_token') || '', ws: null, wsTimer: null,
currentView: 'dashboard', loading: false, bootstrapReloadPending: false, language: preferredLanguage, theme: preferredTheme, connectionStatus: 'connecting',
languages: [], translations: {}, locales: {},
@@ -59,6 +59,21 @@ const historyNumber = value => value === null || value === undefined || value ==
const modeLabel = mode => tr(`mode.${mode}`) === `mode.${mode}` ? mode : tr(`mode.${mode}`);
const houseModeLabel = mode => mode === 'off' ? tr('house.noControl') : modeLabel(mode);
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 = {
fan_speed: 'common.fan',
swing_vertical: 'devices.swingVertical',
swing_horizontal: 'devices.swingHorizontal',
quiet: 'devices.quiet',
turbo: 'devices.turbo',
light: 'devices.light',
air: 'devices.air',
xfan: 'devices.xfan',
health: 'devices.health',
sleep: 'devices.sleep',
}[key];
return translationKey ? tr(translationKey) : key;
};
const dateTime = value => value ? new Intl.DateTimeFormat(locale(), { dateStyle: 'short', timeStyle: 'short' }).format(new Date(value)) : '—';
const localResumeSeconds = value => {
const timestamp = value ? new Date(value).getTime() : NaN;
+48 -6
View File
@@ -53,6 +53,36 @@ function effectiveDeviceOutdoorTemperature(device) {
return device?.outdoor_temperature;
}
function installationEnergySnapshot(group) {
if (!group) return null;
const selectedSource = group.energy_source === 'gree_cloud' || (group.energy_source === 'auto' && !!group.energy_device_id)
? 'gree_cloud'
: (group.energy_source === 'home_assistant' || (group.energy_source === 'auto' && !!group.ha_energy_entity_id) ? 'home_assistant' : '');
if (selectedSource === 'gree_cloud' && group.energy_device_id) {
const device = app.devices.find(item => item.id === group.energy_device_id);
const live = device?.total_energy_kwh == null ? NaN : Number(device.total_energy_kwh);
if (Number.isFinite(live) && live >= 0) {
return { total_kwh: live, timestamp: device.last_cloud_sync || device.last_seen || null, source: 'gree_cloud', origin: 'cloud' };
}
}
const cached = app.deviceGroupEnergy?.[group.id] || null;
return cached?.source === selectedSource ? cached : null;
}
function formatEnergyMeterTotal(value) {
const number = Number(value);
if (!Number.isFinite(number) || number < 0) return tr('common.unavailable');
return `${number.toLocaleString(locale(), { maximumFractionDigits: 3 })} kWh`;
}
function installationEnergySourceShortLabel(group, snapshot) {
if (snapshot?.source === 'home_assistant') return 'Home Assistant';
if (snapshot?.source === 'gree_cloud') return 'GREE Cloud';
if (group?.energy_source === 'home_assistant' || (group?.energy_source === 'auto' && group?.ha_energy_entity_id && !group?.energy_device_id)) return 'Home Assistant';
if (group?.energy_source === 'gree_cloud' || group?.energy_device_id) return 'GREE Cloud';
return tr('energy.auto');
}
function deviceConnectionStatusLabel(device) {
const key = {
online: 'status.online',
@@ -97,6 +127,7 @@ function manualDeviceProblem(device) {
}
function manualLocalDeviceCard(device) {
const caps = device.capabilities || {};
const modes = ['auto', 'cool', 'dry', 'fan', 'heat'];
const fans = [0, 1, 3, 5];
const disabledZone = disabledZoneForDevice(device.id);
@@ -118,8 +149,9 @@ function manualLocalDeviceCard(device) {
${compressorQueuePanel(managedZone)}
<div class="mode-row quick-control-row quick-control-row-5">${modes.map(mode => `<button class="${device.mode === mode ? 'active' : ''}" data-action="mode" data-value="${mode}" data-device="${esc(device.id)}">${esc(modeLabel(mode))}</button>`).join('')}</div>
<div class="fan-row quick-control-row quick-control-row-4">${fans.map(fan => `<button class="${Number(device.fan_speed) === fan ? 'active' : ''}" data-action="fan" data-value="${fan}" data-device="${esc(device.id)}">${esc(fanLabel(fan))}</button>`).join('')}</div>
<div class="device-toggles quick-control-row quick-control-row-3">
<button class="${device.swing_vertical ? 'active' : ''}" data-action="toggle" data-field="swing_vertical" data-device="${esc(device.id)}">${esc(tr('devices.swing'))}</button>
<div class="device-toggles quick-control-row quick-control-row-4">
${caps.vertical_swing === false ? '' : `<button class="${device.swing_vertical ? 'active' : ''}" data-action="toggle" data-field="swing_vertical" data-device="${esc(device.id)}">${esc(tr('devices.swingVertical'))}</button>`}
${caps.horizontal_swing === false ? '' : `<button class="${device.swing_horizontal ? 'active' : ''}" data-action="toggle" data-field="swing_horizontal" data-device="${esc(device.id)}">${esc(tr('devices.swingHorizontal'))}</button>`}
${device.supports_quiet === false ? '' : `<button class="${device.quiet ? 'active' : ''}" data-action="toggle" data-field="quiet" data-device="${esc(device.id)}">${esc(tr('devices.quiet'))}</button>`}
${device.supports_turbo === false ? '' : `<button class="${device.turbo ? 'active' : ''}" data-action="toggle" data-field="turbo" data-device="${esc(device.id)}">${esc(tr('devices.turbo'))}</button>`}
</div>
@@ -156,8 +188,9 @@ function manualCloudDeviceCard(device) {
${compressorQueuePanel(managedZone)}
${modes.length ? `<div class="mode-row quick-control-row quick-control-row-5">${modes.map(mode => `<button class="${device.mode === mode ? 'active' : ''}" data-action="mode" data-value="${mode}" data-device="${esc(device.id)}">${esc(modeLabel(mode))}</button>`).join('')}</div>` : ''}
${fans.length ? `<div class="fan-row quick-control-row quick-control-row-${Math.min(6, fans.length)}">${fans.map(fan => `<button class="${Number(device.fan_speed) === fan ? 'active' : ''}" data-action="fan" data-value="${fan}" data-device="${esc(device.id)}">${esc(fanLabel(fan))}</button>`).join('')}</div>` : ''}
<div class="device-toggles quick-control-row quick-control-row-3">
${caps.vertical_swing === false ? '' : `<button class="${device.swing_vertical ? 'active' : ''}" data-action="toggle" data-field="swing_vertical" data-device="${esc(device.id)}">${esc(tr('devices.swing'))}</button>`}
<div class="device-toggles quick-control-row quick-control-row-4">
${caps.vertical_swing === false ? '' : `<button class="${device.swing_vertical ? 'active' : ''}" data-action="toggle" data-field="swing_vertical" data-device="${esc(device.id)}">${esc(tr('devices.swingVertical'))}</button>`}
${caps.horizontal_swing === false ? '' : `<button class="${device.swing_horizontal ? 'active' : ''}" data-action="toggle" data-field="swing_horizontal" data-device="${esc(device.id)}">${esc(tr('devices.swingHorizontal'))}</button>`}
${device.supports_quiet === false ? '' : `<button class="${device.quiet ? 'active' : ''}" data-action="toggle" data-field="quiet" data-device="${esc(device.id)}">${esc(tr('devices.quiet'))}</button>`}
${device.supports_turbo === false ? '' : `<button class="${device.turbo ? 'active' : ''}" data-action="toggle" data-field="turbo" data-device="${esc(device.id)}">${esc(tr('devices.turbo'))}</button>`}
</div>
@@ -246,8 +279,16 @@ function renderDeviceInstallationsSummary() {
const groups = app.deviceGroups || [];
host.innerHTML = groups.length ? groups.map(group => {
const members = (group.device_ids || []).map(id => app.devices.find(device => device.id === id)?.name).filter(Boolean);
const outdoor = app.devices.find(device => device.id === group.outdoor_temperature_device_id)?.name;
return `<article class="installation-summary-card"><div><span class="eyebrow">${esc(deviceInstallationKindLabel(group))}</span><strong>${esc(group.name)}</strong><small>${esc(members.join(' · ') || '—')}</small></div><div><span>${esc(tr('energy.source'))}</span><b>${esc(installationEnergySourceLabel(group))}</b>${outdoor ? `<small>${esc(tr('devices.outdoorSourceDevice'))}: ${esc(outdoor)}</small>` : ''}</div><button type="button" class="secondary" data-action="edit-device-group" data-id="${esc(group.id)}">${esc(tr('actions.edit'))}</button></article>`;
const outdoorDevice = app.devices.find(device => device.id === group.outdoor_temperature_device_id);
const outdoorValue = outdoorDevice?.outdoor_temperature == null ? NaN : Number(outdoorDevice.outdoor_temperature);
const energy = installationEnergySnapshot(group);
const sourceTitle = installationEnergySourceLabel(group);
const sourceShort = installationEnergySourceShortLabel(group, energy);
const lastReading = energy?.timestamp ? `${tr('energy.lastReading')}: ${dateTime(energy.timestamp)}` : '';
const outdoor = outdoorDevice
? `${tr('devices.sharedOutdoorMetric')}: ${Number.isFinite(outdoorValue) ? fmtTemp(outdoorValue) : tr('common.unavailable')} · ${outdoorDevice.name}`
: '';
return `<article class="installation-summary-card"><div><span class="eyebrow">${esc(deviceInstallationKindLabel(group))}</span><strong>${esc(group.name)}</strong><small>${esc(members.join(' · ') || '—')}</small></div><div class="installation-energy-metric"><span>${esc(tr('energy.totalMeter'))}</span><b title="${esc(sourceTitle)}">${esc(formatEnergyMeterTotal(energy?.total_kwh))}</b><small>${esc(sourceShort)}${lastReading ? ` · ${esc(lastReading)}` : ''}</small>${outdoor ? `<small>${esc(outdoor)}</small>` : ''}</div><button type="button" class="secondary" data-action="edit-device-group" data-id="${esc(group.id)}">${esc(tr('actions.edit'))}</button></article>`;
}).join('') : '';
}
@@ -519,6 +560,7 @@ function zoneCard(zone, detailed = true) {
${['auto', 'comfort', 'sleep', 'away'].map(preset => `<button class="${manual === preset ? 'active' : ''}" data-action="zone-preset" data-id="${esc(zone.id)}" data-value="${preset}">${esc(preset === 'sleep' ? tr('zones.sleepNow') : zonePresetLabel(preset))}</button>`).join('')}
</div>
<div class="mode-row zone-mode-row quick-control-row quick-control-row-3"><button class="${mode === 'house' && globalModeAvailable ? 'active' : ''}" data-action="zone-mode" data-id="${esc(zone.id)}" data-value="house"${globalModeDisabled}>${esc(tr('zones.followHouseShort'))}</button><button class="${mode === 'heat' ? 'active' : ''}" data-action="zone-mode" data-id="${esc(zone.id)}" data-value="heat">${esc(modeLabel('heat'))}</button><button class="${mode === 'cool' ? 'active' : ''}" data-action="zone-mode" data-id="${esc(zone.id)}" data-value="cool">${esc(modeLabel('cool'))}</button></div>
${device ? `<div class="device-toggles thermostat-swing-row quick-control-row quick-control-row-2">${device.capabilities?.vertical_swing === false ? '' : `<button class="${device.swing_vertical ? 'active' : ''}" data-action="zone-swing" data-field="swing_vertical" data-device="${esc(device.id)}"${deviceUnavailable ? ' disabled' : ''}>${esc(tr('devices.swingVertical'))}</button>`}${device.capabilities?.horizontal_swing === false ? '' : `<button class="${device.swing_horizontal ? 'active' : ''}" data-action="zone-swing" data-field="swing_horizontal" data-device="${esc(device.id)}"${deviceUnavailable ? ' disabled' : ''}>${esc(tr('devices.swingHorizontal'))}</button>`}</div>` : ''}
<div class="zone-state-line"><span class="zone-runtime-status" title="${esc(runtimeStatus)}">${esc(runtimeStatus)}${controlGroup ? ` <b class="group-control-tag">${esc(controlGroup.name)}</b>` : ''}</span><span>${esc(override)}</span></div>
<div class="zone-state-line control-owner-line"><span><strong>${esc(tr('zones.controlOwner'))}:</strong> ${esc(zoneControlOwnerLabel(zone))}</span><span>${esc(zoneControlOwnerMeta(zone))}</span></div>
${compressorQueuePanel(zone)}
+2 -1
View File
@@ -179,6 +179,7 @@ document.addEventListener('click', async event => {
if (action === 'cancel-compressor-task') return cancelCompressorTask(button.dataset.id);
if (action === 'cancel-all-compressor-tasks') { if (!confirm(tr('zones.queueCancelAllConfirm', { count: app.zones.filter(zone => !!zone.compressor_pending_action).length }))) return; return cancelAllCompressorTasks(); }
if (action === 'zone-device-power') return sendZoneLocalPower(button.dataset.id, button.dataset.value === 'true');
if (action === 'zone-swing' && device) return sendDeviceCommand(device.id, current => ({ [button.dataset.field]: !current?.[button.dataset.field] }));
if (action === 'zone-open-temporary') return populateTemporaryThermostat(button.dataset.id);
if (action === 'zone-temperature') { const zone = app.zones.find(v => v.id === button.dataset.id); if (zone) { const base = Number(zone.manual_setpoint ?? zone.effective_setpoint ?? zone.setpoint); queueZoneTemperature(zone, base + Number(button.dataset.delta)); } return; }
if (action === 'zone-mode') return sendZoneControl(button.dataset.id, { mode: button.dataset.value });
@@ -614,7 +615,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);
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 = raw.action_swing_vertical === 'true'; if (!groupTarget && raw.action_swing_horizontal !== '') action.swing_horizontal = raw.action_swing_horizontal === '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'));
+5 -5
View File
@@ -87,7 +87,7 @@ function flowDefaultConfig(kind) {
if (kind === 'night_mode') return {};
if (kind === 'constant') return { value: true };
if (kind === 'shared_input') return sharedFlowReferenceDefaultConfig(app.flowSharedInputs?.[0]);
if (kind === 'zone_thermostat') return { zone_id: app.zones[0]?.id || '', preset: 'comfort', setpoint: 21, mode: 'auto', cooldown_seconds: 60 };
if (kind === '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 === '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 };
@@ -516,7 +516,7 @@ function renderFlowInspector() {
else if (node.kind === 'ha_attribute') fields = `<label><span>entity_id</span><input data-flow-config="entity_id" value="${esc(c.entity_id || '')}" placeholder="climate.living_room"></label><label><span>${esc(tr('flow.attribute'))}</span><input data-flow-config="attribute" value="${esc(c.attribute || '')}" placeholder="hvac_action"></label>${flowTextComparisonFields(c, true)}`;
else if (node.kind === 'ha_available') fields = `<label><span>entity_id</span><input data-flow-config="entity_id" value="${esc(c.entity_id || '')}" placeholder="binary_sensor.window"></label><p class="field-note">${esc(tr('flow.haAvailableHint'))}</p>`;
else if (node.kind === 'house_mode') fields = `<div class="two"><label><span>${esc(tr('flow.operator'))}</span><select data-flow-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-flow-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 (node.kind === 'device_state') 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.field'))}</span><select data-flow-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>${flowTextComparisonFields(c)}`;
else if (node.kind === 'device_state') 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.field'))}</span><select data-flow-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(deviceCommandFieldLabel(v))}</option>`).join('')}</select></label>${flowTextComparisonFields(c)}`;
else if (node.kind === 'zone_state') fields = `<label><span>${esc(tr('common.zone'))}</span><select data-flow-config="zone_id">${flowSelectOptions(app.zones, c.zone_id)}</select></label><label><span>${esc(tr('flow.field'))}</span><select data-flow-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>${flowTextComparisonFields(c)}`;
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>`;
@@ -530,7 +530,7 @@ function renderFlowInspector() {
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>`;
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><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 === '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">${flowOptionalBoolField(c, 'swing_vertical')}${flowOptionalBoolField(c, 'swing_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 === '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" 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>`;
@@ -540,7 +540,7 @@ function renderFlowInspector() {
function flowTextComparisonFields(c, numeric = false) { const options = numeric ? flowOperatorOptions(c.operator || 'eq') : [['eq', '='], ['neq', '≠']].map(([v, l]) => `<option value="${v}" ${v === (c.operator || 'eq') ? 'selected' : ''}>${l}</option>`).join(''); 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><input data-flow-config="value" value="${esc(c.value ?? '')}"></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>`; }
function flowOptionalBoolField(c, key) {
return `<label><span>${esc(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>`;
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>`;
}
function flowActionFields(c, group) {
const modes = group ? ['auto', 'house', 'heat', 'cool'] : ['auto', 'cool', 'dry', 'fan', 'heat'];
@@ -549,7 +549,7 @@ function flowActionFields(c, group) {
const target = group
? `<label><span>${esc(tr('groups.profile'))}</span><select data-flow-config="preset"><option value="">${esc(tr('actions.noChange'))}</option>${['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="${c.setpoint ?? 21}"></label>`
: `<label><span>${esc(tr('common.targetC'))}</span><input type="number" min="8" max="30" step="0.5" data-flow-config="target_temperature" value="${c.target_temperature ?? ''}"></label>`;
const deviceOptions = group ? '' : `<details class="flow-device-options"><summary>${esc(tr('flow.deviceOptions'))}</summary><div class="two"><label><span>fan_speed</span><select data-flow-config="fan_speed"><option value="" ${c.fan_speed == null ? 'selected' : ''}>${esc(tr('actions.noChange'))}</option>${[0, 1, 2, 3, 4, 5].map(v => `<option value="${v}" ${Number(c.fan_speed) === v ? 'selected' : ''}>${v}</option>`).join('')}</select></label>${flowOptionalBoolField(c, 'swing_vertical')}${flowOptionalBoolField(c, 'swing_horizontal')}${flowOptionalBoolField(c, 'quiet')}${flowOptionalBoolField(c, 'turbo')}${flowOptionalBoolField(c, 'light')}${flowOptionalBoolField(c, 'air')}${flowOptionalBoolField(c, 'xfan')}${flowOptionalBoolField(c, 'health')}${flowOptionalBoolField(c, 'sleep')}</div><p class="field-note">${esc(tr('flow.deviceOptionsHint'))}</p></details>`;
const deviceOptions = group ? '' : `<details class="flow-device-options"><summary>${esc(tr('flow.deviceOptions'))}</summary><div class="two"><label><span>${esc(deviceCommandFieldLabel('fan_speed'))}</span><select data-flow-config="fan_speed"><option value="" ${c.fan_speed == null ? 'selected' : ''}>${esc(tr('actions.noChange'))}</option>${[0, 1, 2, 3, 4, 5].map(v => `<option value="${v}" ${Number(c.fan_speed) === v ? 'selected' : ''}>${v}</option>`).join('')}</select></label>${flowOptionalBoolField(c, 'swing_vertical')}${flowOptionalBoolField(c, 'swing_horizontal')}${flowOptionalBoolField(c, 'quiet')}${flowOptionalBoolField(c, 'turbo')}${flowOptionalBoolField(c, 'light')}${flowOptionalBoolField(c, 'air')}${flowOptionalBoolField(c, 'xfan')}${flowOptionalBoolField(c, 'health')}${flowOptionalBoolField(c, 'sleep')}</div><p class="field-note">${esc(tr('flow.deviceOptionsHint'))}</p></details>`;
return `${base}${target}${deviceOptions}<label><span>${esc(tr('common.cooldownSeconds'))}</span><input type="number" min="30" data-flow-config="cooldown_seconds" value="${Number(c.cooldown_seconds || 60)}"></label>`;
}
+49 -3
View File
@@ -1,3 +1,31 @@
function outdoorHistorySeries(devices, rows) {
const selectedIds = new Set((devices || []).map(device => device.id));
const handled = new Set();
const definitions = [];
(app.deviceGroups || []).forEach(group => {
if (!group.outdoor_temperature_device_id) return;
const members = (group.device_ids || []).filter(id => selectedIds.has(id));
if (!members.length) return;
const representative = members.includes(group.outdoor_temperature_device_id) ? group.outdoor_temperature_device_id : members[0];
if (!rows.some(row => row.device_id === representative && Number.isFinite(historyNumber(row.outdoor_temperature)))) return;
members.forEach(id => handled.add(id));
definitions.push({ representative, label: `${group.name} · ${tr('history.sharedOutdoor')}` });
});
(devices || []).filter(device => !handled.has(device.id)).forEach(device => {
if (rows.some(row => row.device_id === device.id && Number.isFinite(historyNumber(row.outdoor_temperature)))) {
definitions.push({ representative: device.id, label: device.name });
}
});
return definitions.map((item, index) => ({
label: item.label,
color: historySeriesColor(index),
value: row => row.device_id === item.representative ? historyNumber(row.outdoor_temperature) : NaN,
}));
}
function renderHistorySummary() {
const host = $('#historySummary'); if (!host) return;
if (app.historyTab === 'energy') { renderEnergyHistorySummary(); return; }
@@ -20,7 +48,7 @@ function renderOverviewHistory() {
const indoorSeries = app.devices.map((device, index) => ({ label: device.name, color: historySeriesColor(index), value: row => !row.zone_id && !row.entity_id && row.device_id === device.id ? historyNumber(row.indoor_temperature) : NaN }));
drawLineChart($('#overviewIndoorChart'), indoorSeries, deviceRows, { height: 360 }); renderLegend($('#overviewIndoorChartLegend'), indoorSeries);
const outdoorDeviceSeries = app.devices.filter(device => deviceRows.some(row => row.device_id === device.id && Number.isFinite(historyNumber(row.outdoor_temperature)))).map((device, index) => ({ label: `${device.name} · ${tr('history.greeOutdoor')}`, color: historySeriesColor(index), value: row => !row.zone_id && !row.entity_id && row.device_id === device.id ? historyNumber(row.outdoor_temperature) : NaN }));
const outdoorDeviceSeries = outdoorHistorySeries(app.devices, deviceRows).map(item => ({ ...item, label: item.label.includes(' · ') ? item.label : `${item.label} · ${tr('history.greeOutdoor')}` }));
const outdoorEntities = [...new Set(app.historyData.sensors.filter(row => row.kind === 'outdoor').map(row => row.entity_id))];
const outdoorHaSeries = outdoorEntities.map((entity, index) => ({ label: `HA · ${haSensorLabel(entity)}`, color: historySeriesColor(index + outdoorDeviceSeries.length), dash: [6, 4], value: row => row.entity_id === entity ? historyNumber(row.temperature) : NaN }));
const outdoorRows = [...deviceRows, ...app.historyData.sensors.filter(row => row.kind === 'outdoor')];
@@ -68,7 +96,7 @@ function renderDeviceHistory() {
host.innerHTML = historyChartMarkup('deviceIndoorChart', tr('history.deviceIndoor'), tr('history.deviceIndoorHint')) + historyChartMarkup('deviceOutdoorChart', tr('history.deviceOutdoor'), tr('history.deviceOutdoorHint')) + historyChartMarkup('deviceTargetChart', tr('history.deviceTargets'), tr('history.deviceTargetsHint'), true);
const devices = selected === 'all' ? app.devices : app.devices.filter(device => device.id === selected);
const indoor = devices.map((device, index) => ({ label: device.name, color: historySeriesColor(index), value: row => row.device_id === device.id ? historyNumber(row.indoor_temperature) : NaN }));
const outdoor = devices.filter(device => rows.some(row => row.device_id === device.id && Number.isFinite(historyNumber(row.outdoor_temperature)))).map((device, index) => ({ label: device.name, color: historySeriesColor(index), value: row => row.device_id === device.id ? historyNumber(row.outdoor_temperature) : NaN }));
const outdoor = outdoorHistorySeries(devices, rows);
const targets = devices.map((device, index) => ({ label: device.name, color: historySeriesColor(index), dash: [6, 4], value: row => row.device_id === device.id ? historyNumber(row.target_temperature) : NaN }));
drawLineChart($('#deviceIndoorChart'), indoor, rows, { height: 350 }); renderLegend($('#deviceIndoorChartLegend'), indoor);
drawLineChart($('#deviceOutdoorChart'), outdoor, rows, { height: 310 }); renderLegend($('#deviceOutdoorChartLegend'), outdoor);
@@ -87,9 +115,19 @@ function renderSensorHistory() {
function customSeriesOptions() {
const items = [];
const groupedOutdoorIds = new Set();
(app.deviceGroups || []).forEach(group => {
if (!group.outdoor_temperature_device_id) return;
const representative = (group.device_ids || []).includes(group.outdoor_temperature_device_id)
? group.outdoor_temperature_device_id
: (group.device_ids || [])[0];
if (!representative) return;
(group.device_ids || []).forEach(id => groupedOutdoorIds.add(id));
items.push([`installation|${group.id}|outdoor`, `${group.name} · ${tr('history.sharedOutdoor')}`]);
});
app.devices.forEach(device => {
items.push([`device|${device.id}|indoor`, `${device.name} · ${tr('history.indoorTemperature')}`]);
items.push([`device|${device.id}|outdoor`, `${device.name} · ${tr('history.greeOutdoor')}`]);
if (!groupedOutdoorIds.has(device.id)) items.push([`device|${device.id}|outdoor`, `${device.name} · ${tr('history.greeOutdoor')}`]);
items.push([`device|${device.id}|target`, `${device.name} · ${tr('history.deviceTarget')}`]);
});
app.zones.forEach(zone => {
@@ -113,6 +151,14 @@ function customSeriesDefinition(key, index = 0) {
const fields = { indoor: 'indoor_temperature', outdoor: 'outdoor_temperature', target: 'target_temperature' };
return { key, label: `${device.name} · ${labels[field] || field}`, color, dash: field === 'target' ? [6, 4] : [], value: row => !row.zone_id && !row.entity_id && row.device_id === id ? historyNumber(row[fields[field]]) : NaN };
}
if (kind === 'installation' && field === 'outdoor') {
const group = (app.deviceGroups || []).find(item => item.id === id); if (!group) return null;
const representative = (group.device_ids || []).includes(group.outdoor_temperature_device_id)
? group.outdoor_temperature_device_id
: (group.device_ids || [])[0];
if (!representative) return null;
return { key, label: `${group.name} · ${tr('history.sharedOutdoor')}`, color, value: row => !row.zone_id && !row.entity_id && row.device_id === representative ? historyNumber(row.outdoor_temperature) : NaN };
}
if (kind === 'zone') {
const zone = app.zones.find(item => item.id === id); if (!zone) return null;
const fields = { control: ['control_temperature', tr('history.controlTemperature')], gree: ['gree_temperature', tr('history.greeSensor')], external: ['external_temperature', tr('history.roomSensor')], target: ['target_temperature', tr('history.comfortTarget')], device_target: ['device_setpoint', tr('history.deviceSetpoint')], outdoor: ['outdoor_temperature', tr('history.outdoorTemperature')] };
+10 -1
View File
@@ -540,11 +540,18 @@ function updateAutomationTargetFields() {
$('#automationGroupTarget').hidden = !groupTarget;
$('#automationGroupPreset').hidden = !groupTarget;
$('#automationTargetTemperature').hidden = groupTarget;
$('#automationDeviceSwingOptions').hidden = groupTarget;
$('#automationGroupHint').hidden = !groupTarget;
form.action_device_id.required = !groupTarget;
form.action_group_id.required = groupTarget;
form.action_target_temperature.disabled = groupTarget;
if (groupTarget) form.action_target_temperature.value = '';
form.action_swing_vertical.disabled = groupTarget;
form.action_swing_horizontal.disabled = groupTarget;
if (groupTarget) {
form.action_target_temperature.value = '';
form.action_swing_vertical.value = '';
form.action_swing_horizontal.value = '';
}
[...form.action_mode.options].forEach(option => {
if (!['dry', 'fan'].includes(option.value)) return;
option.disabled = groupTarget;
@@ -688,6 +695,8 @@ function populateAutomation(id) {
form.action_mode.value = item.action.mode || '';
form.action_preset.value = item.action_preset || '';
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.enabled.checked = item.enabled;
updateAutomationTargetFields();
openDialog('automationDialog');
+19
View File
@@ -48,6 +48,25 @@ async function handleWebSocketMessage(event) {
else if (message.event === 'group.deleted') { app.groups = app.groups.filter(v => v.id !== data.id); renderGroups(); fillSelects(); scheduleControlPlanLoad(); }
else if (['device_group.updated', 'device_group.created'].includes(message.event)) { const i = app.deviceGroups.findIndex(v => v.id === data.id); if (i >= 0) app.deviceGroups[i] = data; else app.deviceGroups.push(data); renderDevices(); renderHistoryNavigation(); }
else if (message.event === 'device_group.deleted') { app.deviceGroups = app.deviceGroups.filter(v => v.id !== data.id); renderDevices(); renderHistoryNavigation(); }
else if (message.event === 'energy.updated') {
const total = Number(data.total_kwh);
if (Number.isFinite(total)) {
const snapshot = { total_kwh: total, timestamp: data.timestamp || message.timestamp, source: data.source || '', origin: 'realtime' };
const selectedEnergySource = group => group.energy_source === 'gree_cloud' || (group.energy_source === 'auto' && !!group.energy_device_id)
? 'gree_cloud'
: (group.energy_source === 'home_assistant' || (group.energy_source === 'auto' && !!group.ha_energy_entity_id) ? 'home_assistant' : '');
if (String(data.target_id || '').startsWith('group:')) {
const groupId = String(data.target_id).slice(6);
const group = (app.deviceGroups || []).find(item => item.id === groupId);
if (group && selectedEnergySource(group) === snapshot.source) app.deviceGroupEnergy[groupId] = snapshot;
} else if (snapshot.source === 'gree_cloud') {
(app.deviceGroups || [])
.filter(group => group.energy_device_id === data.target_id && selectedEnergySource(group) === 'gree_cloud')
.forEach(group => { app.deviceGroupEnergy[group.id] = snapshot; });
}
renderDevices();
}
}
else if (['schedule.updated', 'schedule.created'].includes(message.event)) { const i = app.schedules.findIndex(v => v.id === data.id); if (i >= 0) app.schedules[i] = data; else app.schedules.push(data); renderSchedules(); fillSelects(); scheduleControlPlanLoad(); }
else if (message.event === 'schedule.deleted') { app.schedules = app.schedules.filter(v => v.id !== data.id); renderSchedules(); fillSelects(); scheduleControlPlanLoad(); }
else if (message.event === 'schedule.template_applied') { try { app.schedules = await api('/api/schedules'); renderSchedules(); fillSelects(); } catch (_) { } scheduleControlPlanLoad(); }
+1 -1
View File
@@ -119,7 +119,7 @@ function renderFlowSharedInputFields(kind, config = {}) {
else if (kind === 'device_temperature') fields = `<label><span>${esc(tr('common.device'))}</span><select data-shared-config="device_id">${sharedFlowOptions(app.devices, c.device_id)}</select></label>`;
else if (kind === 'zone_temperature') fields = `<label><span>${esc(tr('common.zone'))}</span><select data-shared-config="zone_id">${sharedFlowOptions(app.zones, c.zone_id)}</select></label>`;
else if (kind === 'house_mode') fields = `<p class="field-note">${esc(tr('flow.sharedInputSourceOnlyHint'))}</p>`;
else if (kind === 'device_state') fields = `<label><span>${esc(tr('common.device'))}</span><select data-shared-config="device_id">${sharedFlowOptions(app.devices, c.device_id)}</select></label><label><span>${esc(tr('flow.field'))}</span><select data-shared-config="field">${['enabled', 'online', 'power', 'mode', 'fan_speed', 'swing_vertical', 'swing_horizontal', 'quiet', 'turbo', 'light', 'air', 'xfan', 'health', 'sleep'].map(v => `<option value="${v}" ${c.field === v ? 'selected' : ''}>${esc(v)}</option>`).join('')}</select></label>`;
else if (kind === '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(deviceCommandFieldLabel(v))}</option>`).join('')}</select></label>`;
else if (kind === 'zone_state') fields = `<label><span>${esc(tr('common.zone'))}</span><select data-shared-config="zone_id">${sharedFlowOptions(app.zones, c.zone_id)}</select></label><label><span>${esc(tr('flow.field'))}</span><select data-shared-config="field">${['enabled', 'mode', 'active_preset', 'demand', 'control_owner', 'device_manual_override', 'local_thermostat_power'].map(v => `<option value="${v}" ${c.field === v ? 'selected' : ''}>${esc(v)}</option>`).join('')}</select></label>`;
else if (kind === 'group_state') fields = `<label><span>${esc(tr('groups.group'))}</span><select data-shared-config="group_id">${sharedFlowOptions(app.groups, c.group_id)}</select></label><input type="hidden" data-shared-config="field" value="power_enabled">`;
else if (kind === 'night_mode') fields = `<p class="field-note">${esc(tr('flow.nightModeHint'))}</p>`;
+8 -1
View File
@@ -321,7 +321,14 @@ document.addEventListener('change', event => {
const target = event.target;
if (target.id === 'historyZoneSelect') { app.historyZone = target.value; updateBrowserUrl(currentHistoryPath()); renderHistoryPage(); }
else if (target.id === 'historyDeviceSelect') { app.historyDevice = target.value; updateBrowserUrl(currentHistoryPath()); renderHistoryPage(); }
else if (target.id === 'historyEnergyTargetSelect') { app.historyEnergyTargets = [...target.selectedOptions].map(option => option.value).slice(0, 8); app.historyEnergyDevice = app.historyEnergyTargets[0] || ''; updateBrowserUrl(currentHistoryPath()); loadHistory(); }
else if (target.matches?.('[data-history-energy-target]')) {
const checked = $$('[data-history-energy-target]:checked').map(input => input.dataset.historyEnergyTarget).filter(Boolean);
if (checked.length > 8) { target.checked = false; toast(tr('energy.maxTargets'), true); return; }
app.historyEnergyTargets = checked;
app.historyEnergyDevice = app.historyEnergyTargets[0] || '';
updateBrowserUrl(currentHistoryPath());
loadHistory();
}
else if (target.id === 'historyEnergyInterval') { app.historyEnergyInterval = target.value; updateBrowserUrl(currentHistoryPath()); loadHistory(); }
else if (target.id === 'historyEnergyCompare') { app.historyEnergyCompare = target.value; updateBrowserUrl(currentHistoryPath()); loadHistory(); }
else if (target.id === 'historySensorSelect') { app.historySensor = target.value; updateBrowserUrl(currentHistoryPath()); renderHistoryPage(); }
+80 -2
View File
@@ -2087,6 +2087,7 @@ legend {
.history-toolbar-panel {
margin-bottom: 14px;
overflow: visible;
}
.history-context-controls {
@@ -2777,6 +2778,10 @@ legend {
min-width: 0;
}
.history-toolbar-panel .chart-toolbar.energy-toolbar {
grid-template-columns: minmax(0, 1fr) auto;
}
.history-context-controls {
display: block;
width: 100%;
@@ -9518,13 +9523,86 @@ body.flow-editor-open {
.installation-device-choices { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 6px; margin-top: 6px; }
.installation-device-choices .check { margin: 0; padding: 8px 9px; border: 1px solid var(--line); border-radius: 9px; background: var(--surface); }
.installation-device-choices .check.disabled { opacity: .55; }
.history-energy-targets select { min-width: min(360px, 72vw); }
.history-energy-targets small { display: block; max-width: 360px; color: var(--muted); font-size: 10px; }
.installation-energy-metric b {
color: var(--text);
font-size: 18px;
font-variant-numeric: tabular-nums;
letter-spacing: -.01em;
}
.history-context-controls.energy-context-controls {
display: grid;
grid-template-columns: minmax(240px, 1.5fr) repeat(3, minmax(132px, .8fr));
gap: 10px;
align-items: end;
}
.history-energy-targets { display: grid; gap: 7px; min-width: 0; color: var(--muted); font-size: 12px; font-weight: 650; }
.history-energy-targets small,
.history-range-control small { display: block; color: var(--muted); font-size: 10px; font-weight: 550; line-height: 1.35; }
.history-energy-picker { position: relative; min-width: 0; }
.history-energy-picker > summary {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 10px;
min-height: 45px;
padding: 10px 12px;
border: 1px solid var(--line);
border-radius: 12px;
background: var(--input-bg);
color: var(--text);
cursor: pointer;
list-style: none;
}
.history-energy-picker > summary::-webkit-details-marker { display: none; }
.history-energy-picker > summary span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.history-energy-picker > summary b {
min-width: 34px;
padding: 3px 6px;
border-radius: 999px;
background: var(--surface-muted);
color: var(--muted);
font-size: 10px;
text-align: center;
}
.history-energy-picker[open] > summary { border-color: var(--accent); outline: 2px solid color-mix(in srgb, var(--accent) 18%, transparent); outline-offset: 1px; }
.history-energy-options {
position: absolute;
z-index: 20;
top: calc(100% + 6px);
left: 0;
width: min(420px, calc(100vw - 40px));
max-height: 300px;
overflow: auto;
padding: 7px;
border: 1px solid var(--line);
border-radius: 12px;
background: var(--surface);
box-shadow: 0 14px 34px rgba(0, 0, 0, .22);
}
.history-energy-option {
display: grid;
grid-template-columns: 20px minmax(0, 1fr);
align-items: center;
gap: 9px;
min-height: 38px;
padding: 7px 8px;
border-radius: 8px;
color: var(--text-soft);
cursor: pointer;
font-size: 11px;
font-weight: 600;
}
.history-energy-option:hover { background: var(--surface-muted); }
.history-energy-option input { width: 17px; min-height: 17px; margin: 0; padding: 0; accent-color: var(--accent); }
.history-energy-option span { overflow-wrap: anywhere; }
.history-range-control.energy-period-control { min-width: 0; }
@media (max-width: 760px) {
.installation-summary-card,
.installation-list-row { grid-template-columns: 1fr; }
.installation-list-row > div { justify-content: flex-start; }
.installation-device-choices { grid-template-columns: 1fr; }
.history-context-controls.energy-context-controls { grid-template-columns: 1fr; }
.history-energy-options { width: min(420px, calc(100vw - 40px)); max-height: 46vh; }
}