This commit is contained in:
Mateusz Gruszczyński
2026-09-14 23:10:17 +02:00
parent 4bb9c8621a
commit 0a2fb8c6c7
47 changed files with 1337 additions and 372 deletions
Generated
+1 -1
View File
@@ -700,7 +700,7 @@ dependencies = [
[[package]]
name = "gree-controller"
version = "0.14.0"
version = "0.14.1"
dependencies = [
"aes",
"aes-gcm",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "gree-controller"
version = "0.14.0"
version = "0.14.1"
edition = "2021"
authors = ["GREE Controller contributors"]
description = "Standalone local GREE HVAC controller with Web UI, SQLite and Home Assistant sensor support"
+46 -49
View File
@@ -1,18 +1,14 @@
d67af429e4da9ce08e9d2f2a8472849ffbd70d135b1c5da535a076026794d04c ./.env.example
a4ec3874a2e3ab1bad28fb40bb620f7b01f64d01ad9b699306bf70ada31227db ./.gitignore
468abc4e9e6d71859f26cfe83226b971e11c4d990746e1da1ef93c9ede24c405 ./Cargo.lock
87a90bab477bda5fdb067983c1bd4ddc4189dc34c64e2f280488e54dfdf50032 ./Cargo.toml
97681dd3c632c841c2e28a9272fffe461fc778cab8de7e856abebf8c38770ff4 ./G+_integration_wip.md
1baa24e6934a05f48ea7f289ce5f8804fc0981236a2b6c468539a052c0d4e583 ./Cargo.lock
f3204447421f334110e1a626ec29fdd8ee88bb2b9d1e9e1a391c2720c9af7de9 ./Cargo.toml
19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE
818572366846c59e8915142e626e6607d0ce11d3dc5690f345b78c31c8107de6 ./README.md
140fe98aa2ebce67d89ce47b5885f9364c26dfa557e748ab6b5bc62f269e035d ./RELEASE_VALIDATION_0.14.0.md
13b222296d0c078ace0440c1336b3d7d1c98d0d9508d06fbd28b46f505028bf5 ./SOURCE_MANIFEST.sha256
0d883bc1ff1b4c9bfe045d4b16ae46f6e1aab773072cf233bf9c25750951e971 ./TESTING_0.14.0.md
70852f174e604a552745d5dcf88e9cef7387fc899264417f1db278eacc796def ./TESTING_0.14.0_CLOUD_BETA.md
a4f7dc180e422d6de9d78b1235206ebc3b0436d6041730df8ca065532c36a939 ./README.md
b27097a57e62a3096b133899dea455a68aa04c0fa428f6228d08615e1b11ca29 ./SOURCE_MANIFEST.sha256
bb549919b96b0413152dd905aec8640bb53301f07ec7d0b1be48ccbd37936fef ./build.rs
df6c518cbc7bb0155b4b0e5b856160627f9d1f63195b48c36bc54dc90f601767 ./docs/API.md
50a46eac5dfcdc80c5db068f0eb1ed7d696a2b6070e2ddff808f2bb2a33a913b ./docs/API.md
2e1e18fd8167dabfe2469c26e85cce62486c7cb6a502c63c6f6b0cd74d5885e0 ./docs/FLOW.md
0416652c4421801675864f8f410fdbac7dd315b2f29df366d94ace6e49154eea ./docs/openapi.json
919d54a6825385fdb4e9b8f07db756640d512b2c21d2d33f407f850680285ed0 ./docs/openapi.json
c65aa543d2e99f8f7d52736307b07ebd182597d80124661d53a16bb23f6f1118 ./ha-addon/.env.example
a18d2460340264d34c267fa704ee51a42e59bcc1783ee4c0dbb2a8ef42fa0f16 ./ha-addon/Dockerfile
0417c88a990c72aecf071fba64d5d9da65dcda4d396cd7565477f7a4532c8c5e ./ha-addon/README.md
@@ -26,7 +22,7 @@ b7f0873109c52be9d7f09bea3dffc416103c50085e1f0680d11661a969479898 ./ha-addon/hom
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
e3da92df75e4361d03cddcb9e5ad228f5a9a14c85ee8d816ae7e165b4aeb545e ./ha-addon/home-assistant/custom_components/gree_controller/manifest.json
5a7a8cf9545c9346d9bcc92e16265fa2ef63567fa28abe153a2e361cc279c082 ./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
@@ -34,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
d9135c678e6a30d7d31b49683b052d091c6cc0c8db2a16cabeba89285bdb9b20 ./ha-addon/repository/gree-controller/CHANGELOG.md
131509e7dfefcca4ad61a3a060bc0a33ed2f731141496ba99a3cb81a563399b1 ./ha-addon/repository/gree-controller/CHANGELOG.md
3c06bd11671452ea6472afd5543fb91a39aba73a1d2d95c7762c04f94ab64030 ./ha-addon/repository/gree-controller/DOCS.md
c3db302f38d26dc9f8caf37a1a8a07a1334cc07ae301d452f2940a5ec5915719 ./ha-addon/repository/gree-controller/README.md
4c1ab41a2e01098b6ad549f187b47119e608d3cc752c38cf334286fae34eacd2 ./ha-addon/repository/gree-controller/config.yaml
09e38d1d821a6c318152e5a023954310e9f361985cfddc456abfcc0b88d79c62 ./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
@@ -47,8 +43,8 @@ c123b190219556382f0e7f3058056fb233f6261f257a01eceb96a2c0bbfa12f3 ./ha-addon/run
081e27b6e43070d44f865335c30a42a5a29668eaa6a5ea5ab6967ac4447b8e06 ./ha-addon/sync-repository.sh
f9e31de3d109ead32bf531c074d46a087df398d62488caee0b46b5e0a1c027f7 ./ha_addon.md
253a0bc912786e67ea7fc92a64e4a510ad973bec343a88ccfb1f28fca3e8cf01 ./lang/README.md
ca747385adf261ab4ce7d0fae51ec326d8cfffffe20b0c48ce4f6d9d1a419874 ./lang/en.json
0364575c2ba26f1e988f39ec31b9b29c9678a58e6c5ab519cd07509e9ebcee0c ./lang/pl.json
301df7a84ce546db1c2e25da841aa0fc9696435d7bb4db358e3fb082a723afd9 ./lang/en.json
c01e081782251122fec4fb50500a5f3ee4d865a2213af89cc77f3f3661606dbd ./lang/pl.json
d8459024f04ca514bd8e9d6bd3af872fb942fd85a7cdd5583f4a9d28aab6faba ./make_zip.py
b14233a8987e53bbbdd6770386ba10fa166ec25c1e098275c173c544b37846fc ./presets/bedroom_window_night.json
1960841119c0b673fb2f03621b32237552fd828045304cd221401214b997c910 ./presets/device_resilience.json
@@ -89,7 +85,7 @@ fdcd9a5055d08037278b842e7ab69265345c5811f0a06867136c511d140bb191 ./presets/wind
804f22123cd3e8db0fac791826c8dd9f758fb866c8e3b5655fb6d25d259dccf1 ./presets/workday_comfort.json
01952aa92b217f8eae2493b88870e2dec595100cd15c4d561ff11ae2b936c46f ./regenerate-sha.sh
c64b1c6deeb24f20af662bfce7fa3683703914987241107ce664fa2f6d1a9dcb ./scripts/README.md
ea99c1ea4bfc63d7734f4a3b57f90501236879995e66a630f1ee57397af0d0ce ./scripts/api_dev_test.py
3461b89eff40737f5f250b50bcccadb882bb0b5afbd96542d9d67184142191f8 ./scripts/api_dev_test.py
5bc736c7bc76ca80aaa406bb171d2aa91baf4c3aa8695dce0e09b888b6ab3146 ./scripts/common.sh
6403786610ee6d2f628193c25aee0dd058d62e904aa1a31d5f62fdaae0e94b4f ./scripts/configure-gree-network.sh
1d6e14e26e49aa9d3527f30a23668bf8d9c48b67e6628ef686c3155c012155de ./scripts/dev.sh
@@ -102,18 +98,19 @@ e00d211e3885e30d7fed1e43b44e6fdad40a67019060156c0641816a93e3365f ./scripts/netw
81345b6a0b51736bdbc98fd23199b62e4c721b4e7437e02dab7ea79b97dff29a ./scripts/service.sh
472db31959be29ba99247f7e9cc07a44bc090c538cd654ad8d16e094726037a3 ./scripts/smoke.sh
b50782b3742dfbf8a319c60571c968e93fdf8547db747c759edcffae68cb98bf ./scripts/update.sh
98c6dd9a5f714257957e831b3d02cbb08de1cdc30dc4a98a64b7c1f5523f76d2 ./src/api.rs
e89b3503b838670f3213e696395024e74d91c718bc67a4cb295077e944ea4c6c ./src/api.rs
3dfd1e053cbfa0d684dd88cf99b244a96a36e2ff23d2a0715b8327ab62258254 ./src/api/assets.rs
05c255919f7b3d81188fc5f1410be6a0037471dfc44e93bffb009f29a1e6a3fc ./src/api/auth.rs
9e4fc675306e111ed3db7af9822e2925d88109c31b9e309851665a975c0bd83c ./src/api/automations.rs
4bc5a8993231ea48805d93dd888b15ff0c03c0a9dd12b44866a1f7a11a12f718 ./src/api/configuration.rs
be865a0be2167131a3778d7ceb825d9a65991a7c063fffadbe7188eac459d078 ./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
2cfee7029f065e81628a8942e3dad00a03c3a5cf4f8492d07f12b75d05c07359 ./src/api/gree_cloud.rs
b61b0b4f196c5e72e47cf06ca0c14df3cf8eedbdef0242a9d2af33623663489f ./src/api/groups.rs
9f41c01c3be646f0e5853363f1abea7fcf45d4bd011a696aec5e7e80e493d2f9 ./src/api/history.rs
63733cf1fd70b9c2cb1bde3c9304365e8940af4a7524bfc82b47444458a56f4a ./src/api/history.rs
e8edfbb725d6de650442444b0a929ad2be0bd2c2d7d227d2b3fdd99d05c94f94 ./src/api/house.rs
27e85400ccbdb0adcb8f5c1624e7187e9b649237cfb97349fcf1c0d78300540d ./src/api/integrations.rs
6bfbe5135d4f93bd8d19a707877b53ed2029fd8490b4b1954a7520888fb1d664 ./src/api/middleware.rs
@@ -121,33 +118,33 @@ e8edfbb725d6de650442444b0a929ad2be0bd2c2d7d227d2b3fdd99d05c94f94 ./src/api/hous
744ce18aa9e0227c6cdfb6980063d0d738d74c5865d45c20fd20026d4742bb3e ./src/api/public_settings.rs
5b29f2e5aa2c422c5829ef89c7b59cf753883aa7f60661c95ab0f95cf676fa3f ./src/api/schedules.rs
e69df8a5f612cd37d25fbf9f8e824c5a7d655ac5a6376ec3b31addf1fe8597e2 ./src/api/settings.rs
4a02639ec2499a98f11afcc6ddd22f35f07189ab47c1e1b7a93cc1b76d674d35 ./src/api/system.rs
264f884e2fdef24303d441ce9cfe11190ed8f2bba8ca5782c1d13c2ee30e6a7f ./src/api/system.rs
9118a5a3a381b010e22d646498ff56d2e05f51656789682cc4371b09c384aa98 ./src/api/websocket.rs
455c7238c9788d378e909a4fbc94d5390b67edd92e127f48b4b913ddcbe82fb0 ./src/api/zones.rs
f4feaff281163eda543bf094d0fe9158dc6662be5f76f39c81570da7ed0355e7 ./src/config.rs
d4afd8e9f47a68e32bcb12f6b599e44dc2d72a7a4d7422c647fca55b8c94b064 ./src/db.rs
6c278fc5728124ab2f53ac7789a733942a0462f397144a231cc878c8cfcf7426 ./src/db/climate.rs
4b3eda9202efcf70296ae295aeb6027e5f59ff97c6c544acc0c8a221d961bd90 ./src/db/configuration.rs
4e577dda5e248b927d29fe8978013be565b69ca4212fe31a1f3334eae1b4a694 ./src/db/core_devices.rs
d067e3048803bb1eeef1892cbae5bf719872daa0c5ccc8575affcc37c6615965 ./src/db.rs
fdc9d019097becc5c36a0560caa1619aa35d802c0d208902b3bfecbe1643e3e8 ./src/db/climate.rs
e5d2027d40a99e61c2c572499af617a92de9e554f8916f3ea6f6731d1d6b09b4 ./src/db/configuration.rs
0a4b4dbfc582d1c8ee942325ac16aa73d1e64555904ec2f6896f4b552bd5e731 ./src/db/core_devices.rs
4c20289c08b0cb9c3c4324d9dbc6a47c7dd953aff6fd1746f04b6024043d3bc5 ./src/db/device_history.rs
f99de2230082f17eafaf03f5361bb67419205a23d64f22480c996f9c32c55ac5 ./src/db/energy_history.rs
88cb4b7ff61a8a558949faa7157d4bc2c45facb951c57240412c4c9d3befdb86 ./src/db/events_tokens.rs
bb5048ee60053f39ce3c1203dc1f24c7047310538d2357f105a052c016d21ec4 ./src/db/flows.rs
5f4b567c6bc866d0674b3b0c66f4aeccf561774623f575b872ff25bf015b91c2 ./src/db/ha_history.rs
c0a7fa3eac198737d7261ad00c542a614e6d7291f6581af8e7f7aef9dec1a049 ./src/db/schedules_automations.rs
d6b9b70a04c774173c97f7426487d5e2932d7713e12ae3fb7f64cf2b4e0b3c10 ./src/db/schedules_automations.rs
a76938b2e49719b97f8abf40a5cce03df6740ce2e8ac138b179c761619b32ce6 ./src/db/tests.rs
f90fbb14f29a20b799b3856bdaa22812a9f0d9dfa46693f1341c160dccda8a97 ./src/db/zone_history.rs
aef5601b127fc1dc8a5bf993027ba597b3c019dd8ced6dfe1eeb62ff0cf578a9 ./src/engine.rs
3c97e04e93d14f37d39ee9f8f53257101517a3d90a0dfeba81ca3dcd5e39ff31 ./src/engine.rs
6046bdbc9c02ac74f47068229c59af69abc8bfa46cfeff6fe7ba573b493e128c ./src/engine/automations.rs
0bf7fe7d28b4749e53ec25d71effbb78bc8f70c716714f838a4a2d8f20627d63 ./src/engine/commands.rs
925135985f640ba9ae0a6109d908187b659341a91451e91d27e533adfd610b76 ./src/engine/control_plan.rs
7b48a33343d519a2ff2d81e9d757eb56a0c1beacfc0b7b8ef61f49c2ad363f9a ./src/engine/deadlines.rs
8d63674f7f0923943df0b2d37f1bae839b25697afe2b34d610849f10dde9f2e1 ./src/engine/energy.rs
9a51cc46b8befb992d7f7dd3dbe5025cd739755a684ed36013be78eaaf1a1743 ./src/engine/energy.rs
e350aa63f3bb14ca887eaaa7555edbeae00fb584cbcaf552cc972f0c8918f5d8 ./src/engine/groups.rs
f0c8f3384b6174771aeb5640ee1eec87f3d6d9ff79a4fbd6a35554a4eec00cfa ./src/engine/history.rs
0e78e8d1c31503b313926af025281714a6e22a23f46af081b3da8e94a2d7315a ./src/engine/history.rs
41b7045877771b7c352328998cb45ce2433e7f9631fad3eb1a882ab4897dc31f ./src/engine/local_thermostat.rs
99527adab9fea1caede9ebb24faa6a63d5cef4810c64c06306b571cf5cbbb0da ./src/engine/ownership.rs
7f975e13845aeeef3362dc815206e80a04abbd2114904a4bd0ed4538c2cb6372 ./src/engine/polling.rs
1a29b7fd8de77f0a5e5964ca05e41cc5ea78855c1ab961750d40461faec305e5 ./src/engine/polling.rs
d13497acd807a74d707e85046b87fb010e13428758336f736df4a30a5ef0b32c ./src/engine/runtime.rs
c1e8de5392681ba1255e3e2d871a6ee860fb8ca987a0a8f973d9ded3977a68fb ./src/engine/schedules.rs
b0189f28d0f337dfd3ce0934da7a565e4a5535afc1c242214c26ed584b3b7840 ./src/engine/targets.rs
@@ -165,9 +162,9 @@ da4068295f37c23222bb13bebac022d88ba7729604a49ad25438c55e45458a49 ./src/influxdb
059346de38c710fadf301b467c0eb45dcc2f764561b868a155c2e089a5027422 ./src/main.rs
23688a5cb68efded89471ce959328d27adff370d451bd9f378a283ab51437e76 ./src/models.rs
920e028d96a14cca9f74a964bb296e68a109cfead314d3164c180201e606d151 ./src/models/automation.rs
60cac63e6cb41d835e35cc654363f6ffd4386a79f562df0e656b69034acfbac2 ./src/models/control_plan.rs
fc5f2802a073a175a8985e56bcb87eb1ff481226d5b4eeabad73dbba1839fc45 ./src/models/control_plan.rs
f1bd2ae1acdc342f9310d01502f478f74c43a7e3b1d284ca55f97900a151b7cd ./src/models/defaults.rs
edcd031427ca903ef640e0c6b0598e28b22dcf9a0c045d13033f4395955f8ffe ./src/models/device.rs
5e212a072bf74ef32a186b172404844b02a92363d216167f814056e1f2a9a7a8 ./src/models/device.rs
aeab5b5acf35d38a54fd1d82e964fe56635ade69c0149ad423df317095d01555 ./src/models/flow.rs
b10abd201d84f88bf6d71e6ed24a47946b67ef9de8dd0cda0608eeb663b7b417 ./src/models/history.rs
14e053b0dbea9ed7e88e8affaa595fa126ff833c18696fa695e923488ec40d70 ./src/models/integrations.rs
@@ -190,37 +187,37 @@ c09c931fa8d512a8883fb14f3142be694af3c51290e023d25c7c972e156e4bd2 ./src/protocol
49dcca1e4a4ad858b312386e3b729f36da691916d8fe62ad682ddb86d40695b7 ./src/protocol/gree_cloud.rs
cd1e880ea947150300f798fa313eb36c57325856c3ceab862e53a7b597b477b1 ./src/protocol/gree_cloud_mqtt.rs
6258ee3888a5f9a4f8bb6cfbbac97f2621de07f9da6bcb2fa596d452f47f2e49 ./src/protocol/mod.rs
df33269fc1570ffd87a0ab47ce0631bdf45d63e219c04800b333657d8382e6fc ./src/provider.rs
858932d98576e1863bb9dfc5a43a0fa72db13d84815595fc998d007571f48a2d ./src/provider.rs
830621fa75265da7288b8135704c0ce27e0687f87d55abf3cdbe40a6e2f3d2b1 ./src/queries.rs
7fac65a9dcbcecee09816f5447c7792dc94f736b1efb66ac85629db75f6280be ./src/queries/device_history.rs
917e13be9db10ea629bd99fe6c62a869c31609780702cc6a44e787b78a62e03a ./src/queries/entities.rs
580917b91441fe9b3276cf0cfc31536ba7954b6a86f4080347b93f9367a5b1ac ./src/queries/entities.rs
2c5fba462b72158b04dbaaa53c8536263f8d72a1338a6c4ff06cb28a36f6ae27 ./src/queries/ha_history.rs
3112452367b68aeda0a5e3143b1a51ce62e3159859fb41e92c35642a3cd72322 ./src/queries/maintenance.rs
776ed66e363289e188b04a194caaa476ccdf7045b61485792ffe3973caf0bf4d ./src/queries/schema.rs
d051ea7cef7fc733d5fcb0a9d496c419be7ddbe799fc93842b9389d220837747 ./src/queries/maintenance.rs
84c3c149cde1728b20ee5723e02d7a7cbb6db9cee3b71a3d4709d6aaa4230e3e ./src/queries/schema.rs
f13f1be3d5789539eba3fc1b59c14835f8c681eca7634e118fa3763a53aeee2b ./src/queries/zone_history.rs
7e7037a059b585c5709367b9431a225789e7039796e3ed19fc132776ac234099 ./src/state.rs
b92a6cb158b494fe145b43c7641e65f6fafff47201d7d76edbec2cfd8b94835c ./systemd/gree-controller.service
544a31cb2e5374227026afd6abd0d910381f3fca1638154ebd74e3c3d96a35d1 ./web/404.html
e98bdd7204349cce1ec6f57283509697af0bbc72280622a6c3efa6fed242db4f ./web/favicon.svg
d48f4adab24f6524915a179b58abdc64659195ac6d4236e55a48a4eac0eda402 ./web/index.html
6cd0fa21dcc1b5170c4375d7fb70b72fc753f6ac80fbd1f6a91ba0e52ccca7e4 ./web/index.html
9ba3f6fa05b72aa989139b2a909982571b2a02055052e4c40104f1e81a9aa7eb ./web/js/README.md
06c8ab1c7e5c0e57ada4e2530d69a379a037678412e1e52268c2aaf8d17b2243 ./web/js/bootstrap.js
5dae06e1193685b7dec29234aa1ae0a29bc6f91cf9fa42370a7fd0d6fdef4e32 ./web/js/charts.js
87598f48469a6dd17e526420d552652c9405632b285d566691b010b59e99043e ./web/js/core.js
dfea965a2093f9f85744b50993c867118afc7a65d2c5d3abd975d4b6f4acc6f2 ./web/js/bootstrap.js
761ad87ed20a819d8d87bc260bcd7f5b39f19582c346a69c8b717c94e7e94302 ./web/js/charts.js
e6c459f92f18d72f11ffaead5caede0b9c8e6b6a1c9cf1a6e97e6b07d5425ab5 ./web/js/core.js
d880ffdac6aa07cd10d79fbacf12f87eb4c69b48a80564bbfdd0248abe8884d5 ./web/js/dashboard.js
fcaa6b8d863bc93bc9b7350329d261b2fc98877eb5c9a323d66aa809dd4ef16b ./web/js/entities.js
dada07699ba93efee918e7b1f90f3a326a381fe4c85a63d2811448a048878258 ./web/js/events.js
83189a47d114e7b911c9cbf368bf80232a7a481d416297db5fc451c12bb710c7 ./web/js/entities.js
1ff265beab8eb15ced854b5da260fd3952add3acc6b290ea73c76b1a54853165 ./web/js/events.js
2e822a00b5a6941b6a251eaf2c54ca95a53399b5d1056f3d8240e11f82d6065f ./web/js/flows.js
cf1ebf3b9d666f309a1add070b4da86be86d990b02c8d6474f6d121c6d332746 ./web/js/forms.js
7d8c2aedcf97196f081367de0933af13c2bc2e531d59e7464760b6d64c895004 ./web/js/history.js
9e2209d41df7624c29741f03f12ed8e83b47cee2891c5a80cea8ca1bdacf1d5a ./web/js/forms.js
6d2c2fc497f0ea4e3ef4a35e6518c9e4be680ace9100ed09a327f9dbe4261d0b ./web/js/history.js
0dc879c0056251618633c9efd188fc1593eeae6965ad1bc993132dea6e171b44 ./web/js/main.js
5f5ae7bde7c9c0009c6789343f78e77a43bd8aa756f10422320c932388a6e730 ./web/js/navigation.js
ab41961830fd7872023f4d7952487fcb8c00289da7780599072864d089c6d981 ./web/js/realtime.js
cdcd55741f5c468127876ae8d03bdb3f5286b7f4f400591599ef7f0de4b1bc34 ./web/js/router.js
fb9830248ad69eb1d85c94b0f7136e189a177811c93cc37656645668a1c95c39 ./web/js/settings-ui.js
4fcfcc09a6a9a1695d37fbe37c4645377006de58f50406a4722da927b389ed86 ./web/js/settings.js
341c4bc441782bf60bd92c899154ca565ac1ba884a6f3a1ea89aae58f5d0322f ./web/js/navigation.js
d105fb2a3b4000a9f22ed23c7b030c1018fee4d2a78dba3ad96b3d9d95689f2a ./web/js/realtime.js
abdedaca20ecad50ae2b5c7f6081c80a142112bd867cda19949d5253e609f1b4 ./web/js/router.js
3426ec31011173172199bff7deacbef1d5eb0121012b5ea0e53b7a72a674a853 ./web/js/settings-ui.js
13806217b2312dff3bd6cc2b16f75d9bdffc7a587215ae9f862706cd7699673f ./web/js/settings.js
3815fea7ae1694568c2f249bc8460e0563ced6bde89214f74f21b41fe8cad9a6 ./web/lang-init.js
6b653e4fe2d4db4ffae6ff8f39b8ce57a10a990dc0000ee4aae51d949afadd52 ./web/manifest.webmanifest
48d03c265a597af4fc11ae8103eb7b6cb3e5932307783f5287c67781f702b800 ./web/styles.css
10a47e3645316103b8ad85a8635ec6305da9717226568e76db58e2d77e894e8f ./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.0**
**Version: 0.14.1**
Version 0.14.0 keeps the existing LAN/UDP 7000 transport unchanged and adds GREE Cloud as a separate first-class provider with encrypted REST discovery, persistent MQTT/TLS synchronization, Cloud commands, per-device capabilities, diagnostics and energy history. Local and Cloud entries are deliberately independent, even when they represent the same physical MAC.
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.
> [Full API reference](docs/API.md) — authentication, every endpoint, request bodies, response models, WebSocket events and examples.
+45 -48
View File
@@ -1,17 +1,13 @@
d67af429e4da9ce08e9d2f2a8472849ffbd70d135b1c5da535a076026794d04c ./.env.example
a4ec3874a2e3ab1bad28fb40bb620f7b01f64d01ad9b699306bf70ada31227db ./.gitignore
468abc4e9e6d71859f26cfe83226b971e11c4d990746e1da1ef93c9ede24c405 ./Cargo.lock
87a90bab477bda5fdb067983c1bd4ddc4189dc34c64e2f280488e54dfdf50032 ./Cargo.toml
97681dd3c632c841c2e28a9272fffe461fc778cab8de7e856abebf8c38770ff4 ./G+_integration_wip.md
1baa24e6934a05f48ea7f289ce5f8804fc0981236a2b6c468539a052c0d4e583 ./Cargo.lock
f3204447421f334110e1a626ec29fdd8ee88bb2b9d1e9e1a391c2720c9af7de9 ./Cargo.toml
19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE
818572366846c59e8915142e626e6607d0ce11d3dc5690f345b78c31c8107de6 ./README.md
140fe98aa2ebce67d89ce47b5885f9364c26dfa557e748ab6b5bc62f269e035d ./RELEASE_VALIDATION_0.14.0.md
0d883bc1ff1b4c9bfe045d4b16ae46f6e1aab773072cf233bf9c25750951e971 ./TESTING_0.14.0.md
70852f174e604a552745d5dcf88e9cef7387fc899264417f1db278eacc796def ./TESTING_0.14.0_CLOUD_BETA.md
a4f7dc180e422d6de9d78b1235206ebc3b0436d6041730df8ca065532c36a939 ./README.md
bb549919b96b0413152dd905aec8640bb53301f07ec7d0b1be48ccbd37936fef ./build.rs
df6c518cbc7bb0155b4b0e5b856160627f9d1f63195b48c36bc54dc90f601767 ./docs/API.md
50a46eac5dfcdc80c5db068f0eb1ed7d696a2b6070e2ddff808f2bb2a33a913b ./docs/API.md
2e1e18fd8167dabfe2469c26e85cce62486c7cb6a502c63c6f6b0cd74d5885e0 ./docs/FLOW.md
0416652c4421801675864f8f410fdbac7dd315b2f29df366d94ace6e49154eea ./docs/openapi.json
919d54a6825385fdb4e9b8f07db756640d512b2c21d2d33f407f850680285ed0 ./docs/openapi.json
c65aa543d2e99f8f7d52736307b07ebd182597d80124661d53a16bb23f6f1118 ./ha-addon/.env.example
a18d2460340264d34c267fa704ee51a42e59bcc1783ee4c0dbb2a8ef42fa0f16 ./ha-addon/Dockerfile
0417c88a990c72aecf071fba64d5d9da65dcda4d396cd7565477f7a4532c8c5e ./ha-addon/README.md
@@ -25,7 +21,7 @@ b7f0873109c52be9d7f09bea3dffc416103c50085e1f0680d11661a969479898 ./ha-addon/hom
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
e3da92df75e4361d03cddcb9e5ad228f5a9a14c85ee8d816ae7e165b4aeb545e ./ha-addon/home-assistant/custom_components/gree_controller/manifest.json
5a7a8cf9545c9346d9bcc92e16265fa2ef63567fa28abe153a2e361cc279c082 ./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
@@ -33,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
d9135c678e6a30d7d31b49683b052d091c6cc0c8db2a16cabeba89285bdb9b20 ./ha-addon/repository/gree-controller/CHANGELOG.md
131509e7dfefcca4ad61a3a060bc0a33ed2f731141496ba99a3cb81a563399b1 ./ha-addon/repository/gree-controller/CHANGELOG.md
3c06bd11671452ea6472afd5543fb91a39aba73a1d2d95c7762c04f94ab64030 ./ha-addon/repository/gree-controller/DOCS.md
c3db302f38d26dc9f8caf37a1a8a07a1334cc07ae301d452f2940a5ec5915719 ./ha-addon/repository/gree-controller/README.md
4c1ab41a2e01098b6ad549f187b47119e608d3cc752c38cf334286fae34eacd2 ./ha-addon/repository/gree-controller/config.yaml
09e38d1d821a6c318152e5a023954310e9f361985cfddc456abfcc0b88d79c62 ./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
@@ -46,8 +42,8 @@ c123b190219556382f0e7f3058056fb233f6261f257a01eceb96a2c0bbfa12f3 ./ha-addon/run
081e27b6e43070d44f865335c30a42a5a29668eaa6a5ea5ab6967ac4447b8e06 ./ha-addon/sync-repository.sh
f9e31de3d109ead32bf531c074d46a087df398d62488caee0b46b5e0a1c027f7 ./ha_addon.md
253a0bc912786e67ea7fc92a64e4a510ad973bec343a88ccfb1f28fca3e8cf01 ./lang/README.md
ca747385adf261ab4ce7d0fae51ec326d8cfffffe20b0c48ce4f6d9d1a419874 ./lang/en.json
0364575c2ba26f1e988f39ec31b9b29c9678a58e6c5ab519cd07509e9ebcee0c ./lang/pl.json
301df7a84ce546db1c2e25da841aa0fc9696435d7bb4db358e3fb082a723afd9 ./lang/en.json
c01e081782251122fec4fb50500a5f3ee4d865a2213af89cc77f3f3661606dbd ./lang/pl.json
d8459024f04ca514bd8e9d6bd3af872fb942fd85a7cdd5583f4a9d28aab6faba ./make_zip.py
b14233a8987e53bbbdd6770386ba10fa166ec25c1e098275c173c544b37846fc ./presets/bedroom_window_night.json
1960841119c0b673fb2f03621b32237552fd828045304cd221401214b997c910 ./presets/device_resilience.json
@@ -88,7 +84,7 @@ fdcd9a5055d08037278b842e7ab69265345c5811f0a06867136c511d140bb191 ./presets/wind
804f22123cd3e8db0fac791826c8dd9f758fb866c8e3b5655fb6d25d259dccf1 ./presets/workday_comfort.json
01952aa92b217f8eae2493b88870e2dec595100cd15c4d561ff11ae2b936c46f ./regenerate-sha.sh
c64b1c6deeb24f20af662bfce7fa3683703914987241107ce664fa2f6d1a9dcb ./scripts/README.md
ea99c1ea4bfc63d7734f4a3b57f90501236879995e66a630f1ee57397af0d0ce ./scripts/api_dev_test.py
3461b89eff40737f5f250b50bcccadb882bb0b5afbd96542d9d67184142191f8 ./scripts/api_dev_test.py
5bc736c7bc76ca80aaa406bb171d2aa91baf4c3aa8695dce0e09b888b6ab3146 ./scripts/common.sh
6403786610ee6d2f628193c25aee0dd058d62e904aa1a31d5f62fdaae0e94b4f ./scripts/configure-gree-network.sh
1d6e14e26e49aa9d3527f30a23668bf8d9c48b67e6628ef686c3155c012155de ./scripts/dev.sh
@@ -101,18 +97,19 @@ e00d211e3885e30d7fed1e43b44e6fdad40a67019060156c0641816a93e3365f ./scripts/netw
81345b6a0b51736bdbc98fd23199b62e4c721b4e7437e02dab7ea79b97dff29a ./scripts/service.sh
472db31959be29ba99247f7e9cc07a44bc090c538cd654ad8d16e094726037a3 ./scripts/smoke.sh
b50782b3742dfbf8a319c60571c968e93fdf8547db747c759edcffae68cb98bf ./scripts/update.sh
98c6dd9a5f714257957e831b3d02cbb08de1cdc30dc4a98a64b7c1f5523f76d2 ./src/api.rs
e89b3503b838670f3213e696395024e74d91c718bc67a4cb295077e944ea4c6c ./src/api.rs
3dfd1e053cbfa0d684dd88cf99b244a96a36e2ff23d2a0715b8327ab62258254 ./src/api/assets.rs
05c255919f7b3d81188fc5f1410be6a0037471dfc44e93bffb009f29a1e6a3fc ./src/api/auth.rs
9e4fc675306e111ed3db7af9822e2925d88109c31b9e309851665a975c0bd83c ./src/api/automations.rs
4bc5a8993231ea48805d93dd888b15ff0c03c0a9dd12b44866a1f7a11a12f718 ./src/api/configuration.rs
be865a0be2167131a3778d7ceb825d9a65991a7c063fffadbe7188eac459d078 ./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
2cfee7029f065e81628a8942e3dad00a03c3a5cf4f8492d07f12b75d05c07359 ./src/api/gree_cloud.rs
b61b0b4f196c5e72e47cf06ca0c14df3cf8eedbdef0242a9d2af33623663489f ./src/api/groups.rs
9f41c01c3be646f0e5853363f1abea7fcf45d4bd011a696aec5e7e80e493d2f9 ./src/api/history.rs
63733cf1fd70b9c2cb1bde3c9304365e8940af4a7524bfc82b47444458a56f4a ./src/api/history.rs
e8edfbb725d6de650442444b0a929ad2be0bd2c2d7d227d2b3fdd99d05c94f94 ./src/api/house.rs
27e85400ccbdb0adcb8f5c1624e7187e9b649237cfb97349fcf1c0d78300540d ./src/api/integrations.rs
6bfbe5135d4f93bd8d19a707877b53ed2029fd8490b4b1954a7520888fb1d664 ./src/api/middleware.rs
@@ -120,33 +117,33 @@ e8edfbb725d6de650442444b0a929ad2be0bd2c2d7d227d2b3fdd99d05c94f94 ./src/api/hous
744ce18aa9e0227c6cdfb6980063d0d738d74c5865d45c20fd20026d4742bb3e ./src/api/public_settings.rs
5b29f2e5aa2c422c5829ef89c7b59cf753883aa7f60661c95ab0f95cf676fa3f ./src/api/schedules.rs
e69df8a5f612cd37d25fbf9f8e824c5a7d655ac5a6376ec3b31addf1fe8597e2 ./src/api/settings.rs
4a02639ec2499a98f11afcc6ddd22f35f07189ab47c1e1b7a93cc1b76d674d35 ./src/api/system.rs
264f884e2fdef24303d441ce9cfe11190ed8f2bba8ca5782c1d13c2ee30e6a7f ./src/api/system.rs
9118a5a3a381b010e22d646498ff56d2e05f51656789682cc4371b09c384aa98 ./src/api/websocket.rs
455c7238c9788d378e909a4fbc94d5390b67edd92e127f48b4b913ddcbe82fb0 ./src/api/zones.rs
f4feaff281163eda543bf094d0fe9158dc6662be5f76f39c81570da7ed0355e7 ./src/config.rs
d4afd8e9f47a68e32bcb12f6b599e44dc2d72a7a4d7422c647fca55b8c94b064 ./src/db.rs
6c278fc5728124ab2f53ac7789a733942a0462f397144a231cc878c8cfcf7426 ./src/db/climate.rs
4b3eda9202efcf70296ae295aeb6027e5f59ff97c6c544acc0c8a221d961bd90 ./src/db/configuration.rs
4e577dda5e248b927d29fe8978013be565b69ca4212fe31a1f3334eae1b4a694 ./src/db/core_devices.rs
d067e3048803bb1eeef1892cbae5bf719872daa0c5ccc8575affcc37c6615965 ./src/db.rs
fdc9d019097becc5c36a0560caa1619aa35d802c0d208902b3bfecbe1643e3e8 ./src/db/climate.rs
e5d2027d40a99e61c2c572499af617a92de9e554f8916f3ea6f6731d1d6b09b4 ./src/db/configuration.rs
0a4b4dbfc582d1c8ee942325ac16aa73d1e64555904ec2f6896f4b552bd5e731 ./src/db/core_devices.rs
4c20289c08b0cb9c3c4324d9dbc6a47c7dd953aff6fd1746f04b6024043d3bc5 ./src/db/device_history.rs
f99de2230082f17eafaf03f5361bb67419205a23d64f22480c996f9c32c55ac5 ./src/db/energy_history.rs
88cb4b7ff61a8a558949faa7157d4bc2c45facb951c57240412c4c9d3befdb86 ./src/db/events_tokens.rs
bb5048ee60053f39ce3c1203dc1f24c7047310538d2357f105a052c016d21ec4 ./src/db/flows.rs
5f4b567c6bc866d0674b3b0c66f4aeccf561774623f575b872ff25bf015b91c2 ./src/db/ha_history.rs
c0a7fa3eac198737d7261ad00c542a614e6d7291f6581af8e7f7aef9dec1a049 ./src/db/schedules_automations.rs
d6b9b70a04c774173c97f7426487d5e2932d7713e12ae3fb7f64cf2b4e0b3c10 ./src/db/schedules_automations.rs
a76938b2e49719b97f8abf40a5cce03df6740ce2e8ac138b179c761619b32ce6 ./src/db/tests.rs
f90fbb14f29a20b799b3856bdaa22812a9f0d9dfa46693f1341c160dccda8a97 ./src/db/zone_history.rs
aef5601b127fc1dc8a5bf993027ba597b3c019dd8ced6dfe1eeb62ff0cf578a9 ./src/engine.rs
3c97e04e93d14f37d39ee9f8f53257101517a3d90a0dfeba81ca3dcd5e39ff31 ./src/engine.rs
6046bdbc9c02ac74f47068229c59af69abc8bfa46cfeff6fe7ba573b493e128c ./src/engine/automations.rs
0bf7fe7d28b4749e53ec25d71effbb78bc8f70c716714f838a4a2d8f20627d63 ./src/engine/commands.rs
925135985f640ba9ae0a6109d908187b659341a91451e91d27e533adfd610b76 ./src/engine/control_plan.rs
7b48a33343d519a2ff2d81e9d757eb56a0c1beacfc0b7b8ef61f49c2ad363f9a ./src/engine/deadlines.rs
8d63674f7f0923943df0b2d37f1bae839b25697afe2b34d610849f10dde9f2e1 ./src/engine/energy.rs
9a51cc46b8befb992d7f7dd3dbe5025cd739755a684ed36013be78eaaf1a1743 ./src/engine/energy.rs
e350aa63f3bb14ca887eaaa7555edbeae00fb584cbcaf552cc972f0c8918f5d8 ./src/engine/groups.rs
f0c8f3384b6174771aeb5640ee1eec87f3d6d9ff79a4fbd6a35554a4eec00cfa ./src/engine/history.rs
0e78e8d1c31503b313926af025281714a6e22a23f46af081b3da8e94a2d7315a ./src/engine/history.rs
41b7045877771b7c352328998cb45ce2433e7f9631fad3eb1a882ab4897dc31f ./src/engine/local_thermostat.rs
99527adab9fea1caede9ebb24faa6a63d5cef4810c64c06306b571cf5cbbb0da ./src/engine/ownership.rs
7f975e13845aeeef3362dc815206e80a04abbd2114904a4bd0ed4538c2cb6372 ./src/engine/polling.rs
1a29b7fd8de77f0a5e5964ca05e41cc5ea78855c1ab961750d40461faec305e5 ./src/engine/polling.rs
d13497acd807a74d707e85046b87fb010e13428758336f736df4a30a5ef0b32c ./src/engine/runtime.rs
c1e8de5392681ba1255e3e2d871a6ee860fb8ca987a0a8f973d9ded3977a68fb ./src/engine/schedules.rs
b0189f28d0f337dfd3ce0934da7a565e4a5535afc1c242214c26ed584b3b7840 ./src/engine/targets.rs
@@ -164,9 +161,9 @@ da4068295f37c23222bb13bebac022d88ba7729604a49ad25438c55e45458a49 ./src/influxdb
059346de38c710fadf301b467c0eb45dcc2f764561b868a155c2e089a5027422 ./src/main.rs
23688a5cb68efded89471ce959328d27adff370d451bd9f378a283ab51437e76 ./src/models.rs
920e028d96a14cca9f74a964bb296e68a109cfead314d3164c180201e606d151 ./src/models/automation.rs
60cac63e6cb41d835e35cc654363f6ffd4386a79f562df0e656b69034acfbac2 ./src/models/control_plan.rs
fc5f2802a073a175a8985e56bcb87eb1ff481226d5b4eeabad73dbba1839fc45 ./src/models/control_plan.rs
f1bd2ae1acdc342f9310d01502f478f74c43a7e3b1d284ca55f97900a151b7cd ./src/models/defaults.rs
edcd031427ca903ef640e0c6b0598e28b22dcf9a0c045d13033f4395955f8ffe ./src/models/device.rs
5e212a072bf74ef32a186b172404844b02a92363d216167f814056e1f2a9a7a8 ./src/models/device.rs
aeab5b5acf35d38a54fd1d82e964fe56635ade69c0149ad423df317095d01555 ./src/models/flow.rs
b10abd201d84f88bf6d71e6ed24a47946b67ef9de8dd0cda0608eeb663b7b417 ./src/models/history.rs
14e053b0dbea9ed7e88e8affaa595fa126ff833c18696fa695e923488ec40d70 ./src/models/integrations.rs
@@ -189,37 +186,37 @@ c09c931fa8d512a8883fb14f3142be694af3c51290e023d25c7c972e156e4bd2 ./src/protocol
49dcca1e4a4ad858b312386e3b729f36da691916d8fe62ad682ddb86d40695b7 ./src/protocol/gree_cloud.rs
cd1e880ea947150300f798fa313eb36c57325856c3ceab862e53a7b597b477b1 ./src/protocol/gree_cloud_mqtt.rs
6258ee3888a5f9a4f8bb6cfbbac97f2621de07f9da6bcb2fa596d452f47f2e49 ./src/protocol/mod.rs
df33269fc1570ffd87a0ab47ce0631bdf45d63e219c04800b333657d8382e6fc ./src/provider.rs
858932d98576e1863bb9dfc5a43a0fa72db13d84815595fc998d007571f48a2d ./src/provider.rs
830621fa75265da7288b8135704c0ce27e0687f87d55abf3cdbe40a6e2f3d2b1 ./src/queries.rs
7fac65a9dcbcecee09816f5447c7792dc94f736b1efb66ac85629db75f6280be ./src/queries/device_history.rs
917e13be9db10ea629bd99fe6c62a869c31609780702cc6a44e787b78a62e03a ./src/queries/entities.rs
580917b91441fe9b3276cf0cfc31536ba7954b6a86f4080347b93f9367a5b1ac ./src/queries/entities.rs
2c5fba462b72158b04dbaaa53c8536263f8d72a1338a6c4ff06cb28a36f6ae27 ./src/queries/ha_history.rs
3112452367b68aeda0a5e3143b1a51ce62e3159859fb41e92c35642a3cd72322 ./src/queries/maintenance.rs
776ed66e363289e188b04a194caaa476ccdf7045b61485792ffe3973caf0bf4d ./src/queries/schema.rs
d051ea7cef7fc733d5fcb0a9d496c419be7ddbe799fc93842b9389d220837747 ./src/queries/maintenance.rs
84c3c149cde1728b20ee5723e02d7a7cbb6db9cee3b71a3d4709d6aaa4230e3e ./src/queries/schema.rs
f13f1be3d5789539eba3fc1b59c14835f8c681eca7634e118fa3763a53aeee2b ./src/queries/zone_history.rs
7e7037a059b585c5709367b9431a225789e7039796e3ed19fc132776ac234099 ./src/state.rs
b92a6cb158b494fe145b43c7641e65f6fafff47201d7d76edbec2cfd8b94835c ./systemd/gree-controller.service
544a31cb2e5374227026afd6abd0d910381f3fca1638154ebd74e3c3d96a35d1 ./web/404.html
e98bdd7204349cce1ec6f57283509697af0bbc72280622a6c3efa6fed242db4f ./web/favicon.svg
d48f4adab24f6524915a179b58abdc64659195ac6d4236e55a48a4eac0eda402 ./web/index.html
6cd0fa21dcc1b5170c4375d7fb70b72fc753f6ac80fbd1f6a91ba0e52ccca7e4 ./web/index.html
9ba3f6fa05b72aa989139b2a909982571b2a02055052e4c40104f1e81a9aa7eb ./web/js/README.md
06c8ab1c7e5c0e57ada4e2530d69a379a037678412e1e52268c2aaf8d17b2243 ./web/js/bootstrap.js
5dae06e1193685b7dec29234aa1ae0a29bc6f91cf9fa42370a7fd0d6fdef4e32 ./web/js/charts.js
87598f48469a6dd17e526420d552652c9405632b285d566691b010b59e99043e ./web/js/core.js
dfea965a2093f9f85744b50993c867118afc7a65d2c5d3abd975d4b6f4acc6f2 ./web/js/bootstrap.js
761ad87ed20a819d8d87bc260bcd7f5b39f19582c346a69c8b717c94e7e94302 ./web/js/charts.js
e6c459f92f18d72f11ffaead5caede0b9c8e6b6a1c9cf1a6e97e6b07d5425ab5 ./web/js/core.js
d880ffdac6aa07cd10d79fbacf12f87eb4c69b48a80564bbfdd0248abe8884d5 ./web/js/dashboard.js
fcaa6b8d863bc93bc9b7350329d261b2fc98877eb5c9a323d66aa809dd4ef16b ./web/js/entities.js
dada07699ba93efee918e7b1f90f3a326a381fe4c85a63d2811448a048878258 ./web/js/events.js
83189a47d114e7b911c9cbf368bf80232a7a481d416297db5fc451c12bb710c7 ./web/js/entities.js
1ff265beab8eb15ced854b5da260fd3952add3acc6b290ea73c76b1a54853165 ./web/js/events.js
2e822a00b5a6941b6a251eaf2c54ca95a53399b5d1056f3d8240e11f82d6065f ./web/js/flows.js
cf1ebf3b9d666f309a1add070b4da86be86d990b02c8d6474f6d121c6d332746 ./web/js/forms.js
7d8c2aedcf97196f081367de0933af13c2bc2e531d59e7464760b6d64c895004 ./web/js/history.js
9e2209d41df7624c29741f03f12ed8e83b47cee2891c5a80cea8ca1bdacf1d5a ./web/js/forms.js
6d2c2fc497f0ea4e3ef4a35e6518c9e4be680ace9100ed09a327f9dbe4261d0b ./web/js/history.js
0dc879c0056251618633c9efd188fc1593eeae6965ad1bc993132dea6e171b44 ./web/js/main.js
5f5ae7bde7c9c0009c6789343f78e77a43bd8aa756f10422320c932388a6e730 ./web/js/navigation.js
ab41961830fd7872023f4d7952487fcb8c00289da7780599072864d089c6d981 ./web/js/realtime.js
cdcd55741f5c468127876ae8d03bdb3f5286b7f4f400591599ef7f0de4b1bc34 ./web/js/router.js
fb9830248ad69eb1d85c94b0f7136e189a177811c93cc37656645668a1c95c39 ./web/js/settings-ui.js
4fcfcc09a6a9a1695d37fbe37c4645377006de58f50406a4722da927b389ed86 ./web/js/settings.js
341c4bc441782bf60bd92c899154ca565ac1ba884a6f3a1ea89aae58f5d0322f ./web/js/navigation.js
d105fb2a3b4000a9f22ed23c7b030c1018fee4d2a78dba3ad96b3d9d95689f2a ./web/js/realtime.js
abdedaca20ecad50ae2b5c7f6081c80a142112bd867cda19949d5253e609f1b4 ./web/js/router.js
3426ec31011173172199bff7deacbef1d5eb0121012b5ea0e53b7a72a674a853 ./web/js/settings-ui.js
13806217b2312dff3bd6cc2b16f75d9bdffc7a587215ae9f862706cd7699673f ./web/js/settings.js
3815fea7ae1694568c2f249bc8460e0563ced6bde89214f74f21b41fe8cad9a6 ./web/lang-init.js
6b653e4fe2d4db4ffae6ff8f39b8ce57a10a990dc0000ee4aae51d949afadd52 ./web/manifest.webmanifest
48d03c265a597af4fc11ae8103eb7b6cb3e5932307783f5287c67781f702b800 ./web/styles.css
10a47e3645316103b8ad85a8635ec6305da9717226568e76db58e2d77e894e8f ./web/styles.css
fefdf12cbbb0a5d8a2cb89275cae28455aa42aaf179542305cc8f5e0f4825898 ./web/sw.js
d505d793ce7cc9485b45b78bba1c0d51887adc7451ab59a42702946e5b991382 ./web/theme-init.js
+17 -3
View File
@@ -1,6 +1,6 @@
# GREE Controller API reference
HTTP and WebSocket API for GREE Controller **0.14.0**.
HTTP and WebSocket API for GREE Controller **0.14.1**.
[← Main documentation](../README.md)
@@ -232,7 +232,7 @@ Response:
{
"status": "ok",
"name": "gree-controller",
"version": "0.14.0",
"version": "0.14.1",
"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.0",
"version": "0.14.1",
"uptime_seconds": 1234,
"auth_required": false,
"control_ready": true,
@@ -1653,6 +1653,20 @@ Existing Flow updates require `expected_revision`. A mismatched revision returns
Additional Flow condition blocks are `house_mode`, `device_state`, `zone_state`, `group_state`, `night_mode`, `ha_attribute`, `ha_available`, `constant` and `shared_input`, alongside weekday/time/date, temperature, Home Assistant state/numeric and AND/OR/NOT blocks. `device_state` can inspect enabled/online/power/mode/fan/swing/quiet/turbo/light/air/xfan/health/sleep state. The editor ships 37 categorized editable templates covering comfort, energy, safety, night, reliability, Home Assistant heat-source coordination and advanced multi-branch logic. The template UI adds search, favorites/recent history, a graph preview and runtime requirement checks; shared inputs expose usage links and HA-backed inputs can be tested against live entity state.
## Split/multisplit installations and energy comparison — 0.14.1
Installation configuration endpoints:
- `GET|POST /api/device-groups`
- `GET|PUT|DELETE /api/device-groups/{id}`
A device group represents one physical split or multisplit installation. It contains one or more indoor `device_ids`, an optional shared GREE Cloud energy device or Home Assistant cumulative-energy entity, and an optional member device used as the shared outdoor-temperature source. One device may belong to only one installation.
Energy history accepts either a device ID or `group:<installation-id>` as a target. The Web UI can request several targets in parallel and overlay them on one chart. `compare=previous_day|previous_period|previous_year` shifts the requested range for period comparison; `compare=none` disables comparison. Daily buckets are presented as calendar dates.
When the final registered GREE Cloud device is removed, the controller closes the unused MQTT session. MQTT messages already in flight after removal are ignored while no Cloud devices are registered, preventing repeated stale-device warnings.
## GREE Cloud and energy — 0.14.0
Devices now expose `connection_type`, `connection_status`, `capabilities`, Cloud synchronization fields and energy-source metadata through the existing device API. Local and Cloud devices use the same command endpoint; dispatch is selected strictly by `connection_type`.
+3 -3
View File
@@ -2,9 +2,9 @@
"openapi": "3.1.0",
"info": {
"title": "GREE Controller API",
"version": "0.14.0",
"version": "0.14.1",
"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.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.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.0"
"example": "0.14.1"
},
"uptime_seconds": {
"type": "integer",
@@ -1,7 +1,7 @@
{
"domain": "gree_controller",
"name": "GREE Controller",
"version": "0.14.0",
"version": "0.14.1",
"config_flow": true,
"integration_type": "hub",
"iot_class": "local_polling",
@@ -1,5 +1,22 @@
# Changelog
## 0.14.1
- Adds split and multisplit installation groups in the Devices section, including assignment of indoor units to a shared outdoor installation.
- Adds installation-level energy sources so a multisplit can use one cumulative GREE Cloud meter or one Home Assistant energy entity instead of reporting duplicated per-unit consumption.
- Shows on each grouped device that its energy is provided by the assigned split/multisplit installation and identifies the configured shared source.
- Adds an optional shared outdoor-temperature source for an installation, allowing all member units to use the outdoor temperature reported by one selected unit.
- Adds installation groups to configuration export/import, bootstrap/API data and persistence, with validation that prevents duplicate membership and invalid energy/outdoor source references.
- Keeps installation configuration consistent when devices are removed, including removing empty groups and resetting a deleted GREE Cloud energy source safely.
- Adds multi-select energy charts so several devices and/or installations can be displayed together.
- Adds energy period comparison against the previous day, previous equivalent period or previous year.
- Fixes daily energy chart labels so daily aggregation is rendered as calendar dates instead of timestamps such as `02:00`.
- Unifies GREE Cloud validation/error alerts with the alert style used for device errors and removes redundant styling paths.
- Hides empty GREE Cloud status fields such as disabled/disconnected, `0 / 0` and `Unavailable`; status details are shown only when useful data or an actionable error exists.
- Preserves and updates the Polish/English translations used by the new installation, energy and Cloud UI, including the simplified Cloud details label (`Details` / `Szczegóły`).
- Fixes repeated `GREE Cloud MQTT message does not match a registered device` warnings after deleting all Cloud devices by ignoring in-flight stale MQTT frames when no devices are registered and closing the now-unused MQTT session after the last device is unregistered.
- Adds regression coverage ensuring stale MQTT payloads are ignored only when no Cloud devices remain, while unknown-device payloads still fail when registered devices exist.
## 0.14.0
- Adds GREE Cloud as a first-class provider while preserving the existing LAN/UDP transport.
@@ -1,5 +1,5 @@
name: "GREE Controller"
version: "0.14.0"
version: "0.14.1"
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/"
+32 -4
View File
@@ -1183,7 +1183,7 @@
"devices.transport": "Transport",
"devices.cloudDeviceId": "Cloud Device ID",
"devices.lastSync": "Last synchronization",
"devices.cloudDetails": "Cloud details",
"devices.cloudDetails": "Details",
"devices.cloudDiagnostics": "Cloud diagnostics",
"devices.cloudDiagnosticsHint": "Decrypted and sanitized protocol data. Passwords, tokens and cipher keys are never included.",
"energy.title": "Energy",
@@ -1197,7 +1197,7 @@
"energy.currentMonth": "Current month",
"energy.previousMonth": "Previous month",
"energy.periodTotal": "Period total",
"energy.noData": "No energy source is configured for this device.",
"energy.noData": "No energy source is configured for the selected devices or installations.",
"energy.hourly": "Hourly",
"energy.daily": "Daily",
"energy.monthly": "Monthly",
@@ -1258,6 +1258,34 @@
"devices.cloudTransportProblem": "GREE Cloud connection is unavailable",
"devices.cloudDeviceOffline": "GREE Cloud is connected, but the unit is not responding",
"settings.cloudRestResponseTime": "Last REST response time",
"common.pending": "Pending"
"common.pending": "Pending",
"devices.installations": "Split / multisplit installations",
"devices.installationsHint": "Group indoor units that share one outdoor unit. Energy and outdoor temperature can use one shared source.",
"devices.installation": "Installation",
"devices.installationName": "Installation name",
"devices.installationType": "Installation type",
"devices.split": "Split",
"devices.multisplit": "Multisplit",
"devices.installationDevices": "Indoor units",
"devices.energySourceDevice": "GREE Cloud energy source",
"devices.outdoorSourceDevice": "Outdoor temperature source",
"devices.noSharedOutdoor": "No shared source",
"devices.installationSaved": "Installation saved.",
"devices.installationDeleted": "Installation deleted.",
"devices.newInstallation": "New installation",
"devices.editInstallation": "Edit installation",
"devices.groupedEnergyNote": "This unit belongs to {name}. Energy is reported at installation level: {source}.",
"energy.targets": "Devices / installations",
"energy.weekly": "Weekly",
"energy.compare": "Compare",
"energy.compareNone": "No comparison",
"energy.comparePreviousDay": "Previous day",
"energy.comparePreviousPeriod": "Previous period",
"energy.comparePreviousYear": "Year earlier",
"energy.currentPeriod": "Current period",
"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)."
}
}
}
+32 -4
View File
@@ -1183,7 +1183,7 @@
"devices.transport": "Transport",
"devices.cloudDeviceId": "Cloud Device ID",
"devices.lastSync": "Ostatnia synchronizacja",
"devices.cloudDetails": "Szczegóły Cloud",
"devices.cloudDetails": "Szczegóły",
"devices.cloudDiagnostics": "Diagnostyka Cloud",
"devices.cloudDiagnosticsHint": "Odszyfrowane i zsanityzowane dane protokołu. Hasła, tokeny i klucze szyfrujące nigdy nie są tu zwracane.",
"energy.title": "Energia",
@@ -1197,7 +1197,7 @@
"energy.currentMonth": "Bieżący miesiąc",
"energy.previousMonth": "Poprzedni miesiąc",
"energy.periodTotal": "Suma okresu",
"energy.noData": "Dla tego urządzenia nie skonfigurowano źródła energii.",
"energy.noData": "Dla wybranych urządzeń lub instalacji nie skonfigurowano źródła energii.",
"energy.hourly": "Godzinowo",
"energy.daily": "Dziennie",
"energy.monthly": "Miesięcznie",
@@ -1258,6 +1258,34 @@
"devices.cloudTransportProblem": "Połączenie z GREE Cloud jest niedostępne",
"devices.cloudDeviceOffline": "GREE Cloud jest połączony, ale urządzenie nie odpowiada",
"settings.cloudRestResponseTime": "Czas ostatniej odpowiedzi REST",
"common.pending": "Oczekiwanie"
"common.pending": "Oczekiwanie",
"devices.installations": "Instalacje split / multisplit",
"devices.installationsHint": "Grupuj jednostki wewnętrzne współdzielące jedną jednostkę zewnętrzną. Energia i temperatura zewnętrzna mogą korzystać z jednego wspólnego źródła.",
"devices.installation": "Instalacja",
"devices.installationName": "Nazwa instalacji",
"devices.installationType": "Typ instalacji",
"devices.split": "Split",
"devices.multisplit": "Multisplit",
"devices.installationDevices": "Jednostki wewnętrzne",
"devices.energySourceDevice": "Źródło energii GREE Cloud",
"devices.outdoorSourceDevice": "Źródło temperatury zewnętrznej",
"devices.noSharedOutdoor": "Brak wspólnego źródła",
"devices.installationSaved": "Zapisano instalację.",
"devices.installationDeleted": "Usunięto instalację.",
"devices.newInstallation": "Nowa instalacja",
"devices.editInstallation": "Edytuj instalację",
"devices.groupedEnergyNote": "Ta jednostka należy do instalacji {name}. Energia jest raportowana na poziomie instalacji: {source}.",
"energy.targets": "Urządzenia / instalacje",
"energy.weekly": "Tygodniowo",
"energy.compare": "Porównaj",
"energy.compareNone": "Bez porównania",
"energy.comparePreviousDay": "Poprzedni dzień",
"energy.comparePreviousPeriod": "Poprzedni okres",
"energy.comparePreviousYear": "Rok wcześniej",
"energy.currentPeriod": "Bieżący okres",
"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)."
}
}
}
+4 -4
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""Developer smoke/integration tests for GREE Controller API 0.14.0.
"""Developer smoke/integration tests for GREE Controller API 0.14.1.
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.0"
DEFAULT_EXPECTED_VERSION = "0.14.1"
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.0 contract", lambda: test_openapi(client, args.expected_version))
runner.run("OpenAPI 0.14.1 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.0.",
description="Developer smoke/integration tests for GREE Controller API 0.14.1.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""Examples:
python3 scripts/api_dev_test.py
+8 -2
View File
@@ -4,8 +4,8 @@ use crate::{
home_assistant, influxdb,
models::{
ApiTokenInfo, ApplicationSettings, Automation, ClimateGroup, ConfigurationExport,
ConnectionStatus, ConnectionType, DebugSettings, Device, DeviceCommand, DevicePatch, DiscoveryRequest,
EnergySourcePreference, Flow, GreeSettings,
ConnectionStatus, ConnectionType, DebugSettings, Device, DeviceCommand, DeviceGroup, DeviceGroupKind, DevicePatch, DiscoveryRequest,
EnergyReading, EnergySourcePreference, Flow, GreeSettings,
GreeCloudSettings, GreeCloudSettingsUpdate, GreeCloudSettingsView,
GroupControlPatch, HaReading, HistorySettings, HomeAssistantSettings,
HomeAssistantSettingsUpdate, HomeAssistantSettingsView, InfluxDbSettings,
@@ -95,6 +95,11 @@ pub fn router(state: AppState) -> Router {
.route("/api/devices/:id/poll", post(poll_device))
.route("/api/devices/:id/probe", post(probe_device))
.route("/api/devices/:id/command", post(command_device))
.route("/api/device-groups", get(list_device_groups).post(create_device_group))
.route(
"/api/device-groups/:id",
get(get_device_group).put(update_device_group).delete(delete_device_group),
)
.route("/api/zones", get(list_zones).post(create_zone))
.route(
"/api/zones/:id",
@@ -344,6 +349,7 @@ pub fn router(state: AppState) -> Router {
include!("api/auth.rs");
include!("api/system.rs");
include!("api/devices.rs");
include!("api/device_groups.rs");
include!("api/zones.rs");
include!("api/groups.rs");
include!("api/house.rs");
+70 -1
View File
@@ -1,6 +1,7 @@
struct ConfigurationIds<'a> {
devices: std::collections::HashSet<&'a str>,
zones: std::collections::HashSet<&'a str>,
device_groups: std::collections::HashSet<&'a str>,
schedules: std::collections::HashSet<&'a str>,
automations: std::collections::HashSet<&'a str>,
flows: std::collections::HashSet<&'a str>,
@@ -26,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.0".into()));
return Err(AppError::BadRequest("unsupported configuration export version; version 3 is required by GREE Controller 0.14.1".into()));
}
if export.settings.control_strategy != "setpoint" {
return Err(AppError::BadRequest(
@@ -49,6 +50,7 @@ fn collect_configuration_ids(
let ids = ConfigurationIds {
devices: export.devices.iter().map(|item| item.id.as_str()).collect(),
zones: export.zones.iter().map(|item| item.id.as_str()).collect(),
device_groups: export.device_groups.iter().map(|item| item.id.as_str()).collect(),
schedules: export
.schedules
.iter()
@@ -63,11 +65,13 @@ fn collect_configuration_ids(
};
let duplicate_or_empty = ids.devices.len() != export.devices.len()
|| ids.zones.len() != export.zones.len()
|| ids.device_groups.len() != export.device_groups.len()
|| ids.schedules.len() != export.schedules.len()
|| ids.automations.len() != export.automations.len()
|| ids.flows.len() != export.flows.len()
|| ids.devices.contains("")
|| ids.zones.contains("")
|| ids.device_groups.contains("")
|| ids.schedules.contains("")
|| ids.automations.contains("")
|| ids.flows.contains("");
@@ -154,6 +158,71 @@ fn validate_configuration_devices_and_zones(
));
}
}
let mut installation_devices = std::collections::HashSet::new();
for group in &export.device_groups {
if group.name.trim().is_empty() || group.device_ids.is_empty() {
return Err(AppError::BadRequest(
"import contains an empty split/multisplit installation".into(),
));
}
if group.kind == DeviceGroupKind::Split && group.device_ids.len() != 1 {
return Err(AppError::BadRequest(
"import contains a split installation with more than one unit".into(),
));
}
for device_id in &group.device_ids {
if !ids.devices.contains(device_id.as_str()) {
return Err(AppError::BadRequest(
"import contains an installation referencing a missing device".into(),
));
}
if !installation_devices.insert(device_id.as_str()) {
return Err(AppError::BadRequest(
"import assigns one device to more than one split/multisplit installation".into(),
));
}
}
if group.energy_device_id.as_deref().is_some_and(|id| !group.device_ids.iter().any(|member| member == id)) {
return Err(AppError::BadRequest(
"import contains an installation energy source outside the installation".into(),
));
}
if group.energy_source == EnergySourcePreference::GreeCloud && group.energy_device_id.is_none() {
return Err(AppError::BadRequest(
"import contains a GREE Cloud installation without an energy source device".into(),
));
}
if group.energy_source == EnergySourcePreference::HomeAssistant && group.ha_energy_entity_id.as_deref().map_or(true, |value| value.trim().is_empty()) {
return Err(AppError::BadRequest(
"import contains a Home Assistant installation without an energy entity".into(),
));
}
if let Some(energy_device_id) = group.energy_device_id.as_deref() {
let energy_device = export.devices.iter().find(|device| device.id == energy_device_id).ok_or_else(|| {
AppError::BadRequest("import contains an installation energy source referencing a missing device".into())
})?;
if energy_device.connection_type != ConnectionType::GreeCloud || !energy_device.capabilities.energy_meter {
return Err(AppError::BadRequest(
"import contains an installation energy source without a GREE Cloud energy meter".into(),
));
}
}
if group.ha_energy_entity_id.as_deref().is_some_and(|value| !value.trim().is_empty()) {
if group.ha_energy_device_class.as_deref() != Some("energy")
|| !matches!(group.ha_energy_state_class.as_deref(), Some("total" | "total_increasing"))
|| !matches!(group.ha_energy_unit.as_deref().map(str::to_ascii_lowercase).as_deref(), Some("wh" | "kwh"))
{
return Err(AppError::BadRequest(
"import contains an invalid Home Assistant cumulative energy entity".into(),
));
}
}
if group.outdoor_temperature_device_id.as_deref().is_some_and(|id| !group.device_ids.iter().any(|member| member == id)) {
return Err(AppError::BadRequest(
"import contains an installation outdoor-temperature source outside the installation".into(),
));
}
}
Ok(())
}
+145
View File
@@ -0,0 +1,145 @@
#[derive(Debug, Deserialize)]
struct DeviceGroupInput {
name: String,
#[serde(default)]
kind: DeviceGroupKind,
#[serde(default)]
device_ids: Vec<String>,
#[serde(default)]
energy_source: EnergySourcePreference,
#[serde(default)]
energy_device_id: Option<String>,
#[serde(default)]
ha_energy_entity_id: Option<String>,
#[serde(default)]
ha_energy_unit: Option<String>,
#[serde(default)]
ha_energy_device_class: Option<String>,
#[serde(default)]
ha_energy_state_class: Option<String>,
#[serde(default)]
outdoor_temperature_device_id: Option<String>,
}
fn normalize_optional(value: Option<String>) -> Option<String> {
value.map(|item| item.trim().to_string()).filter(|item| !item.is_empty())
}
fn validate_device_group_input(
state: &AppState,
input: &DeviceGroupInput,
editing_id: Option<&str>,
) -> Result<Vec<String>, AppError> {
if input.name.trim().is_empty() {
return Err(AppError::BadRequest("installation name is required".into()));
}
let mut device_ids = input.device_ids.iter().map(|id| id.trim().to_string()).filter(|id| !id.is_empty()).collect::<Vec<_>>();
device_ids.sort();
device_ids.dedup();
if device_ids.is_empty() {
return Err(AppError::BadRequest("installation must contain at least one device".into()));
}
if input.kind == DeviceGroupKind::Split && device_ids.len() != 1 {
return Err(AppError::BadRequest("split installation must contain exactly one device".into()));
}
for device_id in &device_ids {
if state.db.get_device(device_id)?.is_none() {
return Err(AppError::BadRequest(format!("installation references missing device {device_id}")));
}
}
for existing in state.db.list_device_groups()? {
if editing_id == Some(existing.id.as_str()) { continue; }
if let Some(device_id) = device_ids.iter().find(|id| existing.device_ids.iter().any(|other| other == *id)) {
return Err(AppError::BadRequest(format!("device {device_id} already belongs to installation '{}'", existing.name)));
}
}
let energy_device_id = normalize_optional(input.energy_device_id.clone());
if let Some(ref id) = energy_device_id {
if !device_ids.iter().any(|device_id| device_id == id) {
return Err(AppError::BadRequest("energy source device must belong to this installation".into()));
}
let device = state.db.get_device(id)?.ok_or_else(|| AppError::BadRequest("energy source device does not exist".into()))?;
if device.connection_type != ConnectionType::GreeCloud || !device.capabilities.energy_meter {
return Err(AppError::BadRequest("selected device does not expose GREE Cloud energy".into()));
}
}
if input.energy_source == EnergySourcePreference::GreeCloud && energy_device_id.is_none() {
return Err(AppError::BadRequest("select a GREE Cloud energy source device".into()));
}
let entity = normalize_optional(input.ha_energy_entity_id.clone());
if input.energy_source == EnergySourcePreference::HomeAssistant && entity.is_none() {
return Err(AppError::BadRequest("select a Home Assistant cumulative energy sensor".into()));
}
if entity.is_some() {
if input.ha_energy_device_class.as_deref() != Some("energy") {
return Err(AppError::BadRequest("Home Assistant energy sensor must have device_class=energy".into()));
}
if !matches!(input.ha_energy_state_class.as_deref(), Some("total" | "total_increasing")) {
return Err(AppError::BadRequest("Home Assistant energy sensor must have state_class=total or total_increasing".into()));
}
if !matches!(input.ha_energy_unit.as_deref().map(str::to_ascii_lowercase).as_deref(), Some("wh" | "kwh")) {
return Err(AppError::BadRequest("Home Assistant energy sensor must use Wh or kWh".into()));
}
}
if let Some(id) = normalize_optional(input.outdoor_temperature_device_id.clone()) {
if !device_ids.iter().any(|device_id| device_id == &id) {
return Err(AppError::BadRequest("outdoor temperature source must belong to this installation".into()));
}
}
Ok(device_ids)
}
fn device_group_from_input(id: String, existing: Option<DeviceGroup>, input: DeviceGroupInput, device_ids: Vec<String>) -> DeviceGroup {
let now = Utc::now();
DeviceGroup {
id,
name: input.name.trim().to_string(),
kind: input.kind,
device_ids,
energy_source: input.energy_source,
energy_device_id: normalize_optional(input.energy_device_id),
ha_energy_entity_id: normalize_optional(input.ha_energy_entity_id),
ha_energy_unit: normalize_optional(input.ha_energy_unit),
ha_energy_device_class: normalize_optional(input.ha_energy_device_class),
ha_energy_state_class: normalize_optional(input.ha_energy_state_class),
outdoor_temperature_device_id: normalize_optional(input.outdoor_temperature_device_id),
created_at: existing.as_ref().map(|item| item.created_at).unwrap_or(now),
updated_at: now,
}
}
async fn list_device_groups(State(state): State<AppState>) -> Result<Json<Vec<DeviceGroup>>, AppError> {
Ok(Json(state.db.list_device_groups()?))
}
async fn get_device_group(State(state): State<AppState>, Path(id): Path<String>) -> Result<Json<DeviceGroup>, AppError> {
state.db.get_device_group(&id)?.map(Json).ok_or_else(|| AppError::NotFound(format!("device group {id}")))
}
async fn create_device_group(State(state): State<AppState>, Json(input): Json<DeviceGroupInput>) -> Result<(StatusCode, Json<DeviceGroup>), AppError> {
let _guard = state.lock_configuration_operation().await;
let device_ids = validate_device_group_input(&state, &input, None)?;
let group = device_group_from_input(Uuid::new_v4().to_string(), None, input, device_ids);
state.db.save_device_group(&group)?;
state.broadcast("device_group.created", serde_json::to_value(&group)?);
Ok((StatusCode::CREATED, Json(group)))
}
async fn update_device_group(State(state): State<AppState>, Path(id): Path<String>, Json(input): Json<DeviceGroupInput>) -> Result<Json<DeviceGroup>, AppError> {
let _guard = state.lock_configuration_operation().await;
let existing = state.db.get_device_group(&id)?.ok_or_else(|| AppError::NotFound(format!("device group {id}")))?;
let device_ids = validate_device_group_input(&state, &input, Some(&id))?;
let group = device_group_from_input(id, Some(existing), input, device_ids);
state.db.save_device_group(&group)?;
state.broadcast("device_group.updated", serde_json::to_value(&group)?);
Ok(Json(group))
}
async fn delete_device_group(State(state): State<AppState>, Path(id): Path<String>) -> Result<StatusCode, AppError> {
let _guard = state.lock_configuration_operation().await;
if !state.db.delete_device_group(&id)? {
return Err(AppError::NotFound(format!("device group {id}")));
}
state.broadcast("device_group.deleted", json!({"id": id}));
Ok(StatusCode::NO_CONTENT)
}
+156 -80
View File
@@ -491,11 +491,16 @@ async fn control_plan(State(state): State<AppState>) -> Result<Json<Value>, AppE
#[derive(Debug, Deserialize)]
struct EnergyHistoryQuery {
device_id: String,
/// Backward-compatible single-device selector.
device_id: Option<String>,
/// Device id or `group:<installation-id>`.
target_id: Option<String>,
interval: Option<String>,
source: Option<String>,
days: Option<i64>,
limit: Option<u32>,
/// none, previous_day, previous_period or previous_year.
compare: Option<String>,
}
async fn energy_history(
@@ -505,10 +510,12 @@ async fn energy_history(
use chrono::{Datelike, NaiveDate, Timelike, Weekday};
use std::collections::BTreeMap;
let device = state
.db
.get_device(&query.device_id)?
.ok_or_else(|| AppError::NotFound(format!("device {}", query.device_id)))?;
let target_id = query
.target_id
.as_deref()
.or(query.device_id.as_deref())
.filter(|value| !value.trim().is_empty())
.ok_or_else(|| AppError::BadRequest("energy target_id is required".into()))?;
let interval_name = query.interval.as_deref().unwrap_or("daily");
if !matches!(interval_name, "hourly" | "daily" | "weekly" | "monthly") {
return Err(AppError::BadRequest(
@@ -516,8 +523,86 @@ async fn energy_history(
));
}
let days = query.days.unwrap_or(31).clamp(1, 3650);
let compare_name = query.compare.as_deref().unwrap_or("none");
let compare_shift_days = match compare_name {
"none" => None,
"previous_day" => Some(1),
"previous_period" => Some(days),
"previous_year" => Some(365),
_ => return Err(AppError::BadRequest("energy compare must be none, previous_day, previous_period or previous_year".into())),
};
let requested_source = query.source.as_deref().unwrap_or("auto");
if !matches!(requested_source, "auto" | "gree_cloud" | "home_assistant") {
return Err(AppError::BadRequest(
"energy source must be auto, gree_cloud or home_assistant".into(),
));
}
let (target_type, public_target_id, target_name, member_device_ids, configured_source, storage_id, preferred_source) =
if let Some(group_id) = target_id.strip_prefix("group:") {
let group = state
.db
.get_device_group(group_id)?
.ok_or_else(|| AppError::NotFound(format!("device group {group_id}")))?;
let auto_source = if group.energy_device_id.is_some() {
Some("gree_cloud")
} else if group.ha_energy_entity_id.is_some() {
Some("home_assistant")
} else {
None
};
let selected = match requested_source {
"gree_cloud" => Some("gree_cloud"),
"home_assistant" => Some("home_assistant"),
_ => match group.energy_source {
EnergySourcePreference::GreeCloud => Some("gree_cloud"),
EnergySourcePreference::HomeAssistant => Some("home_assistant"),
EnergySourcePreference::Auto => auto_source,
},
};
let storage_id = match selected {
Some("gree_cloud") => group.energy_device_id.clone().ok_or_else(|| AppError::BadRequest("installation has no GREE Cloud energy source device".into()))?,
_ => format!("group:{}", group.id),
};
(
"group",
format!("group:{}", group.id),
group.name,
group.device_ids,
group.energy_source,
storage_id,
selected,
)
} else {
let device = state
.db
.get_device(target_id)?
.ok_or_else(|| AppError::NotFound(format!("device {target_id}")))?;
let selected = match requested_source {
"gree_cloud" => Some("gree_cloud"),
"home_assistant" => Some("home_assistant"),
"auto" => match device.energy_source {
EnergySourcePreference::GreeCloud => Some("gree_cloud"),
EnergySourcePreference::HomeAssistant => Some("home_assistant"),
EnergySourcePreference::Auto => None,
},
_ => None,
};
(
"device",
device.id.clone(),
device.name,
vec![device.id.clone()],
device.energy_source,
device.id,
selected,
)
};
let now = Utc::now();
let since = now - ChronoDuration::days(days);
let comparison_since = compare_shift_days.map(|shift| since - ChronoDuration::days(shift));
let month_start_for_load = chrono::DateTime::<Utc>::from_naive_utc_and_offset(
chrono::NaiveDate::from_ymd_opt(now.year(), now.month(), 1)
.expect("valid current month")
@@ -537,7 +622,10 @@ async fn energy_history(
.expect("valid midnight"),
Utc,
);
let load_since = since.min(previous_month_start_for_load);
let mut load_since = since.min(previous_month_start_for_load);
if let Some(compare_since) = comparison_since {
load_since = load_since.min(compare_since);
}
let limit = query.limit.unwrap_or(100_000).clamp(1, 200_000);
let influx = state.settings.read().await.influxdb.clone();
let cutoff = now - ChronoDuration::days(influx.history_threshold_days as i64);
@@ -547,7 +635,7 @@ async fn energy_history(
match influxdb::query_energy(
&state.http,
&influx,
&device.id,
&storage_id,
None,
load_since,
cutoff,
@@ -557,7 +645,7 @@ async fn energy_history(
.await
{
Ok(mut archived) => {
archived.extend(state.db.list_energy_readings(&device.id, cutoff, limit)?);
archived.extend(state.db.list_energy_readings(&storage_id, cutoff, limit)?);
storage = "influx+sqlite".into();
archived
}
@@ -568,38 +656,28 @@ async fn energy_history(
"warn",
"influx.query_error",
"InfluxDB energy history query failed",
json!({"device_id": device.id, "error": err.to_string()}),
json!({"target_id": public_target_id, "storage_id": storage_id, "error": err.to_string()}),
);
state.db.list_energy_readings(&device.id, load_since, limit)?
state.db.list_energy_readings(&storage_id, load_since, limit)?
}
}
} else {
state.db.list_energy_readings(&device.id, load_since, limit)?
state.db.list_energy_readings(&storage_id, load_since, limit)?
};
samples.sort_by_key(|row| row.timestamp);
let requested_source = query.source.as_deref().unwrap_or("auto");
let selected_source = match requested_source {
"gree_cloud" => Some("gree_cloud"),
"home_assistant" => Some("home_assistant"),
"auto" => match device.energy_source {
EnergySourcePreference::GreeCloud => Some("gree_cloud"),
EnergySourcePreference::HomeAssistant => Some("home_assistant"),
EnergySourcePreference::Auto => {
if samples.iter().any(|row| row.source == "gree_cloud") {
Some("gree_cloud")
} else if samples.iter().any(|row| row.source == "home_assistant") {
Some("home_assistant")
} else {
None
}
}
},
_ => {
return Err(AppError::BadRequest(
"energy source must be auto, gree_cloud or home_assistant".into(),
))
let selected_source = if let Some(source) = preferred_source {
Some(source)
} else if requested_source == "auto" {
if samples.iter().any(|row| row.source == "gree_cloud") {
Some("gree_cloud")
} else if samples.iter().any(|row| row.source == "home_assistant") {
Some("home_assistant")
} else {
None
}
} else {
Some(requested_source)
};
if let Some(source) = selected_source {
samples.retain(|row| row.source == source);
@@ -613,82 +691,80 @@ async fn energy_history(
Utc,
)
}
fn bucket_start(
timestamp: chrono::DateTime<Utc>,
interval_name: &str,
) -> chrono::DateTime<Utc> {
fn bucket_start(timestamp: chrono::DateTime<Utc>, interval_name: &str) -> chrono::DateTime<Utc> {
let date = timestamp.date_naive();
match interval_name {
"hourly" => chrono::DateTime::<Utc>::from_naive_utc_and_offset(
date.and_hms_opt(timestamp.hour(), 0, 0)
.expect("valid hour"),
date.and_hms_opt(timestamp.hour(), 0, 0).expect("valid hour"),
Utc,
),
"weekly" => {
let iso = date.iso_week();
midnight(
NaiveDate::from_isoywd_opt(iso.year(), iso.week(), Weekday::Mon)
.expect("valid ISO week"),
)
midnight(NaiveDate::from_isoywd_opt(iso.year(), iso.week(), Weekday::Mon).expect("valid ISO week"))
}
"monthly" => midnight(
NaiveDate::from_ymd_opt(date.year(), date.month(), 1)
.expect("valid month"),
),
"monthly" => midnight(NaiveDate::from_ymd_opt(date.year(), date.month(), 1).expect("valid month")),
_ => midnight(date),
}
}
let period_samples = samples
.iter()
.filter(|row| row.timestamp >= since)
.collect::<Vec<_>>();
let mut buckets: BTreeMap<chrono::DateTime<Utc>, f64> = BTreeMap::new();
for sample in &period_samples {
*buckets
.entry(bucket_start(sample.timestamp, interval_name))
.or_default() += sample.consumption_kwh.max(0.0);
fn bucket_rows<'a>(
rows: impl Iterator<Item = &'a EnergyReading>,
interval_name: &str,
shift_days: i64,
) -> Vec<Value> {
let mut buckets: BTreeMap<chrono::DateTime<Utc>, f64> = BTreeMap::new();
for sample in rows {
let shifted = sample.timestamp + ChronoDuration::days(shift_days);
*buckets.entry(bucket_start(shifted, interval_name)).or_default() += sample.consumption_kwh.max(0.0);
}
buckets.into_iter().map(|(start, consumption_kwh)| json!({"start": start, "consumption_kwh": consumption_kwh.max(0.0)})).collect()
}
let buckets = buckets
.into_iter()
.map(|(start, consumption_kwh)| {
json!({"start": start, "consumption_kwh": consumption_kwh.max(0.0)})
})
.collect::<Vec<_>>();
let period_samples = samples.iter().filter(|row| row.timestamp >= since && row.timestamp <= now).collect::<Vec<_>>();
let buckets = bucket_rows(period_samples.iter().copied(), interval_name, 0);
let comparison = if let Some(shift) = compare_shift_days {
let compare_end = now - ChronoDuration::days(shift);
let compare_start = since - ChronoDuration::days(shift);
let rows = samples.iter().filter(|row| row.timestamp >= compare_start && row.timestamp <= compare_end).collect::<Vec<_>>();
let total: f64 = rows.iter().map(|row| row.consumption_kwh.max(0.0)).sum();
Some(json!({
"kind": compare_name,
"shift_days": shift,
"period_start": compare_start,
"period_end": compare_end,
"period_total": total,
"buckets": bucket_rows(rows.iter().copied(), interval_name, shift),
}))
} else {
None
};
let today_start = midnight(now.date_naive());
let yesterday_start = today_start - ChronoDuration::days(1);
let month_start = midnight(
NaiveDate::from_ymd_opt(now.year(), now.month(), 1).expect("valid current month"),
);
let month_start = midnight(NaiveDate::from_ymd_opt(now.year(), now.month(), 1).expect("valid current month"));
let previous_month_date = month_start.date_naive() - ChronoDuration::days(1);
let previous_month_start = midnight(
NaiveDate::from_ymd_opt(previous_month_date.year(), previous_month_date.month(), 1)
.expect("valid previous month"),
);
let previous_month_start = midnight(NaiveDate::from_ymd_opt(previous_month_date.year(), previous_month_date.month(), 1).expect("valid previous month"));
let sum_range = |start: chrono::DateTime<Utc>, stop: chrono::DateTime<Utc>| -> f64 {
samples
.iter()
.filter(|row| row.timestamp >= start && row.timestamp < stop)
.map(|row| row.consumption_kwh.max(0.0))
.sum()
samples.iter().filter(|row| row.timestamp >= start && row.timestamp < stop).map(|row| row.consumption_kwh.max(0.0)).sum()
};
let period_total: f64 = period_samples
.iter()
.map(|row| row.consumption_kwh.max(0.0))
.sum();
let period_total: f64 = period_samples.iter().map(|row| row.consumption_kwh.max(0.0)).sum();
let latest = samples.last().cloned();
Ok(Json(json!({
"device_id": device.id,
"target_id": public_target_id,
"target_type": target_type,
"target_name": target_name,
"member_device_ids": member_device_ids,
"device_id": if target_type == "device" { Some(public_target_id.clone()) } else { None::<String> },
"source": selected_source.unwrap_or("none"),
"configured_source": device.energy_source,
"configured_source": configured_source,
"interval": interval_name,
"unit": "kWh",
"period_days": days,
"storage": storage,
"storage_warning": storage_warning,
"buckets": buckets,
"comparison": comparison,
"summary": {
"today": sum_range(today_start, now + ChronoDuration::seconds(1)),
"yesterday": sum_range(yesterday_start, today_start),
+2
View File
@@ -25,6 +25,7 @@ struct BootstrapResponse {
devices: Vec<Device>,
zones: Vec<Zone>,
groups: Vec<ClimateGroup>,
device_groups: Vec<DeviceGroup>,
schedules: Vec<Schedule>,
automations: Vec<Automation>,
flows: Vec<Flow>,
@@ -65,6 +66,7 @@ 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()?,
schedules: state.db.list_schedules()?,
automations: state.db.list_automations()?,
flows: state.db.list_flows()?,
+1 -1
View File
@@ -1,6 +1,6 @@
use crate::{
models::{
ApiTokenInfo, Automation, ClimateGroup, ConfigurationExport, ConnectionType, Device, EventLog, Flow,
ApiTokenInfo, Automation, ClimateGroup, ConfigurationExport, ConnectionType, Device, DeviceGroup, EnergySourcePreference, EventLog, Flow,
EnergyReading, HaReading, Reading, RuntimeSettings, Schedule, Zone, ZoneReading,
},
queries,
+26
View File
@@ -48,6 +48,32 @@ impl Db {
pub fn delete_group(&self, id: &str) -> Result<bool> {
self.delete_by_id("groups", id)
}
pub fn save_device_group(&self, group: &DeviceGroup) -> Result<()> {
let payload = Self::to_json(group)?;
let conn = self.lock()?;
conn.execute(
queries::UPSERT_DEVICE_GROUP,
params![group.id, payload, group.updated_at.to_rfc3339()],
)?;
Ok(())
}
pub fn list_device_groups(&self) -> Result<Vec<DeviceGroup>> {
self.list_payloads(queries::LIST_DEVICE_GROUPS)
}
pub fn get_device_group(&self, id: &str) -> Result<Option<DeviceGroup>> {
self.get_payload(queries::GET_DEVICE_GROUP, id)
}
pub fn device_group_for_device(&self, device_id: &str) -> Result<Option<DeviceGroup>> {
Ok(self.list_device_groups()?.into_iter().find(|group| group.device_ids.iter().any(|id| id == device_id)))
}
pub fn delete_device_group(&self, id: &str) -> Result<bool> {
self.delete_by_id("device_groups", id)
}
}
impl Db {
+8
View File
@@ -7,6 +7,7 @@ impl Db {
devices: self.list_devices()?,
zones: self.list_zones()?,
groups: self.list_groups()?,
device_groups: self.list_device_groups()?,
schedules: self.list_schedules()?,
automations: self.list_automations()?,
flows: self.list_flows()?,
@@ -50,6 +51,13 @@ impl Db {
params![group.id, payload, group.updated_at.to_rfc3339()],
)?;
}
for device_group in &export.device_groups {
let payload = Self::to_json(device_group)?;
tx.execute(
queries::UPSERT_DEVICE_GROUP,
params![device_group.id, payload, device_group.updated_at.to_rfc3339()],
)?;
}
for schedule in &export.schedules {
let payload = Self::to_json(schedule)?;
tx.execute(
+17
View File
@@ -89,6 +89,23 @@ impl Db {
}
pub fn delete_device(&self, id: &str) -> Result<bool> {
for mut group in self.list_device_groups()? {
if !group.device_ids.iter().any(|device_id| device_id == id) { continue; }
group.device_ids.retain(|device_id| device_id != id);
if group.energy_device_id.as_deref() == Some(id) {
group.energy_device_id = None;
if group.energy_source == EnergySourcePreference::GreeCloud {
group.energy_source = EnergySourcePreference::Auto;
}
}
if group.outdoor_temperature_device_id.as_deref() == Some(id) { group.outdoor_temperature_device_id = None; }
if group.device_ids.is_empty() {
self.delete_device_group(&group.id)?;
} else {
group.updated_at = Utc::now();
self.save_device_group(&group)?;
}
}
let mut conn = self.lock()?;
let tx = conn.transaction()?;
tx.execute(queries::DELETE_DEVICE_READINGS, [id])?;
+1
View File
@@ -88,6 +88,7 @@ impl Db {
"schedules" => queries::DELETE_SCHEDULE,
"automations" => queries::DELETE_AUTOMATION,
"groups" => queries::DELETE_GROUP,
"device_groups" => queries::DELETE_DEVICE_GROUP,
_ => anyhow::bail!("unsupported table"),
};
let conn = self.lock()?;
+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,
Automation, AutomationPlanRule, ClimateGroup, ConnectionStatus, ConnectionType, ControlPlan, ControlPlanEvent, Device, EnergyReading, EnergySourcePreference,
DeviceCommand, GroupControlPatch, HaReading, NightModeSettings, Reading, RuntimeSettings,
Schedule, TemporaryQuickThermostat, Zone, ZoneControlPlan, ZoneReading,
},
+16 -5
View File
@@ -71,10 +71,7 @@ fn queue_influx_energy(state: &AppState, reading: EnergyReading) {
});
}
async fn sample_home_assistant_energy_device(state: &AppState, device: &Device) -> Result<(), AppError> {
let Some(entity_id) = device.ha_energy_entity_id.as_deref().filter(|value| !value.trim().is_empty()) else {
return Ok(());
};
async fn sample_home_assistant_energy_target(state: &AppState, target_id: &str, entity_id: &str) -> Result<(), AppError> {
let settings = state.settings.read().await.home_assistant.clone();
let payload = home_assistant::read_entity(&state.http, &settings, Some(entity_id))
.await
@@ -92,10 +89,15 @@ async fn sample_home_assistant_energy_device(state: &AppState, device: &Device)
if !matches!(unit.to_ascii_lowercase().as_str(), "wh" | "kwh") {
return Err(AppError::BadRequest("Home Assistant energy sensor must use Wh or kWh".into()));
}
let _ = record_cumulative_energy_sample(state, &device.id, "home_assistant", raw_value, unit, None)?;
let _ = record_cumulative_energy_sample(state, target_id, "home_assistant", raw_value, unit, None)?;
Ok(())
}
async fn sample_home_assistant_energy_device(state: &AppState, device: &Device) -> Result<(), AppError> {
let Some(entity_id) = device.ha_energy_entity_id.as_deref().filter(|value| !value.trim().is_empty()) else { return Ok(()); };
sample_home_assistant_energy_target(state, &device.id, entity_id).await
}
pub(crate) async fn home_assistant_energy_loop(state: AppState) {
sleep(Duration::from_secs(10)).await;
loop {
@@ -108,6 +110,15 @@ pub(crate) async fn home_assistant_energy_loop(state: AppState) {
}
}
}
if let Ok(groups) = state.db.list_device_groups() {
for group in groups.into_iter().filter(|group| matches!(group.energy_source, EnergySourcePreference::HomeAssistant | EnergySourcePreference::Auto)) {
let Some(entity_id) = group.ha_energy_entity_id.as_deref().filter(|value| !value.trim().is_empty()) else { continue; };
let target_id = format!("group:{}", group.id);
if let Err(err) = sample_home_assistant_energy_target(&state, &target_id, entity_id).await {
tracing::warn!(group=%group.id, error=?err, "Home Assistant installation energy sample failed");
}
}
}
}
sleep(Duration::from_secs(60)).await;
}
+9 -1
View File
@@ -1,3 +1,9 @@
fn installation_outdoor_temperature(state: &AppState, device_id: &str) -> Option<f64> {
let group = state.db.device_group_for_device(device_id).ok().flatten()?;
let source_id = group.outdoor_temperature_device_id.as_deref()?;
state.db.get_device(source_id).ok().flatten()?.outdoor_temperature
}
fn record_zone_history(
state: &AppState,
zone: &Zone,
@@ -28,7 +34,9 @@ fn record_zone_history(
.or(zone.manual_setpoint)
.or(Some(zone.setpoint)),
device_setpoint: zone.device_setpoint.or(Some(device.target_temperature)),
outdoor_temperature: outdoor_temperature.or(device.outdoor_temperature),
outdoor_temperature: installation_outdoor_temperature(state, &device.id)
.or(outdoor_temperature)
.or(device.outdoor_temperature),
power: device.power,
mode: if zone.effective_mode.is_empty() {
device.mode.clone()
+1 -1
View File
@@ -279,7 +279,7 @@ fn record_reading(state: &AppState, device: &Device) -> Result<()> {
device_id: device.id.clone(),
timestamp: Utc::now(),
indoor_temperature: device.current_temperature,
outdoor_temperature: device.outdoor_temperature,
outdoor_temperature: installation_outdoor_temperature(state, &device.id).or(device.outdoor_temperature),
target_temperature: device.target_temperature,
power: device.power,
source: if device.simulated { "simulator".into() } else { "gree".into() },
+2
View File
@@ -7,6 +7,8 @@ pub struct ConfigurationExport {
pub zones: Vec<Zone>,
#[serde(default)]
pub groups: Vec<ClimateGroup>,
#[serde(default)]
pub device_groups: Vec<DeviceGroup>,
pub schedules: Vec<Schedule>,
pub automations: Vec<Automation>,
#[serde(default)]
+36
View File
@@ -72,6 +72,42 @@ pub enum EnergySourcePreference {
HomeAssistant,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum DeviceGroupKind {
#[default]
Split,
Multisplit,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeviceGroup {
pub id: String,
pub name: String,
#[serde(default)]
pub kind: DeviceGroupKind,
#[serde(default)]
pub device_ids: Vec<String>,
#[serde(default)]
pub energy_source: EnergySourcePreference,
/// Member device whose cumulative GREE Cloud meter represents the whole installation.
#[serde(default)]
pub energy_device_id: Option<String>,
#[serde(default)]
pub ha_energy_entity_id: Option<String>,
#[serde(default)]
pub ha_energy_unit: Option<String>,
#[serde(default)]
pub ha_energy_device_class: Option<String>,
#[serde(default)]
pub ha_energy_state_class: Option<String>,
/// Member device whose outdoor sensor is shared by every unit in this installation.
#[serde(default)]
pub outdoor_temperature_device_id: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Device {
pub id: String,
+57 -3
View File
@@ -267,7 +267,7 @@ impl GreeCloudProvider {
}
pub async fn unregister_device(&self, device_id: &str) {
let cloud_ids = {
let (cloud_ids, no_registered_devices) = {
let mut registered = self.inner.registered.write().await;
let ids = registered
.iter()
@@ -275,7 +275,8 @@ impl GreeCloudProvider {
.map(|(cloud_id, _)| cloud_id.clone())
.collect::<Vec<_>>();
registered.retain(|_, item| item.device_id != device_id);
ids
let empty = registered.is_empty();
(ids, empty)
};
let mut pending = self.inner.pending.lock().await;
for cloud_id in cloud_ids {
@@ -284,6 +285,18 @@ impl GreeCloudProvider {
drop(pending);
self.inner.command_locks.lock().await.remove(device_id);
self.inner.diagnostics.write().await.remove(device_id);
// The broker can continue publishing retained/status traffic for topics from the
// previous subscription until the MQTT session is closed. Once the last registered
// Cloud device is removed there is nothing useful to receive, so close the session
// immediately instead of leaving a stale subscription alive until process restart.
if no_registered_devices {
let session = self.inner.session.write().await.take();
if let Some(session) = session {
session.mqtt.disconnect().await;
tracing::info!("GREE Cloud MQTT disconnected; no registered devices remain");
}
}
}
pub async fn poll(
@@ -1115,6 +1128,14 @@ impl GreeCloudProvider {
"topic": topic,
"bytes": payload.len(),
}));
// A broker message can race with device deletion. If the last Cloud device has
// already been unregistered, any in-flight message belongs to a stale subscription
// and must be ignored rather than reported as a payload error every few seconds.
let registered = self.inner.registered.read().await.clone();
if registered.is_empty() {
tracing::debug!(topic, "ignoring GREE Cloud MQTT payload without registered devices");
return Ok(());
}
if topic.starts_with("connect/") {
let parent = topic.split('/').nth(1).unwrap_or_default().to_string();
self.emit_cloud_mqtt(json!({
@@ -1143,7 +1164,6 @@ impl GreeCloudProvider {
"cipher": if envelope.tag.is_some() { 2 } else { 1 },
}));
let parent_from_topic = topic.split('/').nth(1).unwrap_or_default().to_string();
let registered = self.inner.registered.read().await.clone();
// Match the reference client primarily by subscribed parent topic. Some GREE
// responses use a parent/variant tcid that is not byte-for-byte equal to the
// discovery child id; restricting candidates to tcid caused valid frames to be
@@ -1752,6 +1772,40 @@ mod tests {
assert!(!LEGACY_CLOUD_PROPERTIES.contains(&"CompressorFqy"));
}
#[tokio::test]
async fn mqtt_payload_is_ignored_after_all_cloud_devices_are_removed() {
let provider = GreeCloudProvider::new(reqwest::Client::new());
let result = provider
.handle_raw_message("status/stale-parent/device", b"not-json-anymore")
.await;
assert!(result.is_ok());
}
#[tokio::test]
async fn mqtt_payload_for_unknown_parent_still_errors_when_devices_are_registered() {
let provider = GreeCloudProvider::new(reqwest::Client::new());
provider.inner.registered.write().await.insert(
"AABBCCDDEEFF".into(),
RegisteredDevice {
device_id: "cloud-test".into(),
key: "0123456789abcdef".into(),
parent_mac: "AABBCCDDEE".into(),
cipher_version: 1,
},
);
let err = provider
.handle_raw_message(
"status/1122334455/device",
br#"{"pack":"x","tcid":"112233445566"}"#,
)
.await
.unwrap_err();
assert!(err
.to_string()
.contains("does not match a registered device"));
}
#[tokio::test]
async fn provider_dispatcher_selects_transport_from_connection_type() {
let local = GreeClient::new(
+11
View File
@@ -47,6 +47,17 @@ pub const LIST_GROUPS: &str =
pub const GET_GROUP: &str = "SELECT payload FROM climate_groups WHERE id=?1";
pub const DELETE_GROUP: &str = "DELETE FROM climate_groups WHERE id=?1";
pub const UPSERT_DEVICE_GROUP: &str = r#"
INSERT INTO device_groups(id,payload,updated_at) VALUES(?1,?2,?3)
ON CONFLICT(id) DO UPDATE SET
payload=excluded.payload,
updated_at=excluded.updated_at
"#;
pub const LIST_DEVICE_GROUPS: &str =
"SELECT payload FROM device_groups ORDER BY json_extract(payload, '$.name') COLLATE NOCASE";
pub const GET_DEVICE_GROUP: &str = "SELECT payload FROM device_groups WHERE id=?1";
pub const DELETE_DEVICE_GROUP: &str = "DELETE FROM device_groups WHERE id=?1";
pub const UPSERT_SCHEDULE: &str = r#"
INSERT INTO schedules(id,zone_id,payload,updated_at) VALUES(?1,?2,?3,?4)
ON CONFLICT(id) DO UPDATE SET
+1
View File
@@ -39,6 +39,7 @@ DELETE FROM schedules;
DELETE FROM automations;
DELETE FROM flows;
DELETE FROM climate_groups;
DELETE FROM device_groups;
DELETE FROM zones;
DELETE FROM devices;
"#;
+8
View File
@@ -36,6 +36,12 @@ CREATE TABLE IF NOT EXISTS climate_groups (
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS device_groups (
id TEXT PRIMARY KEY,
payload TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS schedules (
id TEXT PRIMARY KEY,
zone_id TEXT NOT NULL,
@@ -155,5 +161,7 @@ INSERT OR IGNORE INTO schema_migrations(version, applied_at)
VALUES (6, strftime('%Y-%m-%dT%H:%M:%fZ','now'));
INSERT OR IGNORE INTO schema_migrations(version, applied_at)
VALUES (7, strftime('%Y-%m-%dT%H:%M:%fZ','now'));
INSERT OR IGNORE INTO schema_migrations(version, applied_at)
VALUES (8, strftime('%Y-%m-%dT%H:%M:%fZ','now'));
"#;
+29 -5
View File
@@ -146,10 +146,11 @@
<div><span class="eyebrow" data-i18n="devices.localCloud">Local / GREE Cloud</span>
<h1 data-i18n="nav.devices">Devices</h1>
</div>
<div class="section-heading-actions"><button class="primary compact" id="discoverButton" data-i18n="devices.discoverLocal">Discover Local</button><button class="secondary" type="button" id="cloudDiscoverButton">GREE Cloud</button><button class="secondary" data-open="deviceDialog" data-i18n="devices.addLocalManual">Add Local manually</button></div>
<div class="section-heading-actions"><button class="primary compact" id="discoverButton" data-i18n="devices.discoverLocal">Discover Local</button><button class="secondary" type="button" id="cloudDiscoverButton">GREE Cloud</button><button class="secondary" type="button" id="deviceGroupsButton" data-i18n="devices.installations">Split / multisplit installations</button><button class="secondary" data-open="deviceDialog" data-i18n="devices.addLocalManual">Add Local manually</button></div>
</div>
<p class="lead" data-i18n="devices.technicalDescription">Technical configuration and diagnostics only. Current
operating settings are available in Manual control.</p>
<div id="deviceGroupsSummary" class="device-installation-summary"></div>
<div class="device-grid" id="deviceList"></div>
</section>
@@ -752,7 +753,7 @@
<label><span data-i18n="settings.cloudPolling">Cloud fallback polling interval (s)</span><input type="number" name="gree_cloud_polling_interval_seconds" min="30" max="3600" value="60"></label>
<label class="wide"><span data-i18n="settings.cloudLogin">Login / email</span><input name="gree_cloud_username" autocomplete="username"></label>
<label class="wide"><span data-i18n="settings.cloudPassword">Password</span><input type="password" name="gree_cloud_password" autocomplete="new-password" data-i18n-placeholder="settings.cloudPasswordPlaceholder" placeholder="Leave empty to keep saved secret"></label>
<div class="wide cloud-runtime-summary" aria-live="polite">
<div class="wide cloud-runtime-summary" aria-live="polite" hidden>
<div><span data-i18n="settings.cloudAccountStatus">Account status</span><strong id="greeCloudAccountStatus"></strong></div>
<div><span data-i18n="settings.cloudMqttStatus">MQTT status</span><strong id="greeCloudMqttStatus"></strong></div>
<div><span data-i18n="settings.cloudDevicesOnline">Devices online</span><strong id="greeCloudDevicesOnline"></strong></div>
@@ -765,12 +766,12 @@
<div><span data-i18n="settings.cloudTraffic">Requests / responses / timeouts</span><strong id="greeCloudTraffic"></strong></div>
</div>
<div class="wide cloud-account-meta">
<small><span data-i18n="settings.cloudLastContact">Last successful REST contact</span>: <strong id="greeCloudLastContact"></strong></small>
<small hidden><span data-i18n="settings.cloudLastContact">Last successful REST contact</span>: <strong id="greeCloudLastContact"></strong></small>
<small><span data-i18n="settings.cloudInstallationId">Installation ID</span>: <code id="greeCloudInstanceId"></code></small>
</div>
<p class="field-note wide" data-i18n="settings.cloudPushHint">MQTT push is primary. Polling is used only for initial state, recovery and periodic verification.</p>
<div class="form-actions wide"><button type="button" class="secondary" id="greeCloudTestButton" data-i18n="settings.cloudTest">Test connection</button><button type="button" class="secondary" id="greeCloudRefreshButton" data-i18n="settings.cloudRefreshDevices">Refresh devices</button><button type="button" class="secondary" id="greeCloudReconnectButton" data-i18n="settings.cloudReconnect">Reconnect</button></div>
<div id="greeCloudTestResult" class="config-check-result wide" hidden></div>
<div id="greeCloudTestResult" class="inline-alert wide" hidden></div>
</div>
</section>
</div>
@@ -1187,7 +1188,7 @@
</select></label>
<p class="field-note" data-i18n="devices.protocolChangeHint">Changing protocol clears the saved device key. Save
and check connection immediately performs the required bind and test.</p>
<div id="deviceConfigCheckResult" class="config-check-result" hidden></div>
<div id="deviceConfigCheckResult" class="inline-alert" hidden></div>
<div class="form-actions technical-config-actions"><button type="button" class="secondary" data-close
data-i18n="actions.cancel">Cancel</button><button class="secondary" type="submit" data-save-mode="save"
data-i18n="actions.save">Save</button><button class="primary" type="submit" data-save-mode="check"
@@ -1214,12 +1215,35 @@
</select></label>
<label class="wide"><span data-i18n="energy.haSensor">Home Assistant energy sensor</span><select name="ha_energy_entity_id"><option value=""></option></select></label>
<p class="field-note wide" id="deviceEnergySensorMeta"></p>
<div class="inline-alert info wide" id="deviceEnergyGroupNote" hidden></div>
</div>
</section>
<div class="form-actions"><button type="button" class="secondary" data-close data-i18n="actions.cancel">Cancel</button><button class="primary" type="submit" data-i18n="actions.save">Save</button></div>
</form>
</dialog>
<dialog id="deviceGroupsDialog" class="dialog-wide">
<div class="dialog-form">
<div class="dialog-head"><div><span class="eyebrow" data-i18n="devices.installation">Installation</span><h2 data-i18n="devices.installations">Split / multisplit installations</h2></div><button type="button" data-close>×</button></div>
<p class="field-note" data-i18n="devices.installationsHint">Group indoor units that share one outdoor unit. Energy and outdoor temperature can use one shared source.</p>
<div id="deviceGroupsList" class="installation-list"></div>
<form id="deviceGroupForm" class="installation-form">
<input type="hidden" name="id">
<div class="settings-grid">
<label><span data-i18n="devices.installationName">Installation name</span><input name="name" maxlength="80" required></label>
<label><span data-i18n="devices.installationType">Installation type</span><select name="kind"><option value="split" data-i18n="devices.split">Split</option><option value="multisplit" data-i18n="devices.multisplit">Multisplit</option></select></label>
<div class="wide"><span class="field-label" data-i18n="devices.installationDevices">Indoor units</span><div id="deviceGroupDeviceChoices" class="installation-device-choices"></div></div>
<label><span data-i18n="energy.source">Energy source</span><select name="energy_source"><option value="auto" data-i18n="energy.auto">Auto</option><option value="gree_cloud">GREE Cloud</option><option value="home_assistant">Home Assistant</option></select></label>
<label><span data-i18n="devices.energySourceDevice">GREE Cloud energy source</span><select name="energy_device_id"><option value=""></option></select></label>
<label class="wide"><span data-i18n="energy.haSensor">Home Assistant energy sensor</span><select name="ha_energy_entity_id"><option value=""></option></select></label>
<label class="wide"><span data-i18n="devices.outdoorSourceDevice">Outdoor temperature source</span><select name="outdoor_temperature_device_id"><option value="" data-i18n="devices.noSharedOutdoor">No shared source</option></select></label>
<p class="field-note wide" id="deviceGroupEnergySensorMeta"></p>
</div>
<div class="form-actions"><button type="button" class="secondary" id="deviceGroupNew" data-i18n="devices.newInstallation">New installation</button><button type="submit" class="primary" data-i18n="actions.save">Save</button></div>
</form>
</div>
</dialog>
<dialog id="cloudDiagnosticsDialog" class="dialog-wide">
<div class="dialog-form">
<div class="dialog-head"><div><span class="eyebrow">GREE Cloud</span><h2 data-i18n="devices.cloudDiagnostics">Cloud diagnostics</h2></div><button type="button" data-close>×</button></div>
+1
View File
@@ -34,6 +34,7 @@ function settingsFromSnapshot(sections, houseMode = app.settings?.house_mode ||
function applyBootstrapSnapshot(data) {
app.devices = data.devices || [];
app.deviceGroups = data.device_groups || [];
app.zones = data.zones || [];
app.groups = data.groups || [];
app.schedules = data.schedules || [];
+81 -30
View File
@@ -25,6 +25,32 @@ function historyEntityOptions() {
return { zones, devices, sensors, entities };
}
function energyHistoryTargets() {
const configuredGroups = (app.deviceGroups || []).filter(group => !!group.energy_device_id || !!group.ha_energy_entity_id);
const groups = configuredGroups.map(group => ({
id: `group:${group.id}`,
label: `${group.name} · ${deviceInstallationKindLabel(group)}`,
type: 'group',
group,
}));
const groupedIds = new Set(configuredGroups.flatMap(group => group.device_ids || []));
const devices = app.devices
.filter(device => (device.capabilities?.energy_meter === true || !!device.ha_energy_entity_id) && !groupedIds.has(device.id))
.map(device => ({ id: device.id, label: `${device.name} · ${deviceTransportLabel(device)}`, type: 'device', device }));
return [...groups, ...devices];
}
function normalizeEnergyHistoryTargets() {
const available = energyHistoryTargets();
const ids = new Set(available.map(item => item.id));
app.historyEnergyTargets = (app.historyEnergyTargets || []).filter(id => ids.has(id));
if (!app.historyEnergyTargets.length && app.historyEnergyDevice && ids.has(app.historyEnergyDevice)) app.historyEnergyTargets = [app.historyEnergyDevice];
if (!app.historyEnergyTargets.length && available.length) app.historyEnergyTargets = [available[0].id];
app.historyEnergyTargets = app.historyEnergyTargets.slice(0, 8);
app.historyEnergyDevice = app.historyEnergyTargets[0] || '';
return available;
}
function renderHistoryNavigation() {
$$('#historyTabs [data-history-tab]').forEach(button => button.classList.toggle('active', button.dataset.historyTab === app.historyTab));
const host = $('#historyContextControls'); if (!host) return;
@@ -36,14 +62,15 @@ function renderHistoryNavigation() {
host.innerHTML = `<label><span>${esc(tr('common.device'))}</span><select id="historyDeviceSelect"><option value="all">${esc(tr('history.allDevices'))}</option>${options.devices}</select></label>`;
const select = $('#historyDeviceSelect'); if ([...select.options].some(option => option.value === app.historyDevice)) select.value = app.historyDevice;
} else if (app.historyTab === 'energy') {
const energyDevices = app.devices.filter(device => device.capabilities?.energy_meter === true || !!device.ha_energy_entity_id);
if (!app.historyEnergyDevice && energyDevices.length) app.historyEnergyDevice = energyDevices[0].id;
const deviceOptions = energyDevices.map(device => `<option value="${esc(device.id)}">${esc(device.name)} · ${esc(deviceTransportLabel(device))}</option>`).join('');
host.innerHTML = energyDevices.length
? `<label><span>${esc(tr('common.device'))}</span><select id="historyEnergyDeviceSelect">${deviceOptions}</select></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="monthly">${esc(tr('energy.monthly'))}</option></select></label>`
const targets = normalizeEnergyHistoryTargets();
const targetOptions = targets.map(target => `<option value="${esc(target.id)}">${esc(target.label)}</option>`).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>`
: `<span class="history-context-hint">${esc(tr('energy.noData'))}</span>`;
const deviceSelect = $('#historyEnergyDeviceSelect'); if (deviceSelect && [...deviceSelect.options].some(option => option.value === app.historyEnergyDevice)) deviceSelect.value = app.historyEnergyDevice;
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;
} 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;
@@ -55,7 +82,7 @@ function renderHistoryNavigation() {
}
async function loadHistory() {
if (app.historyLoading) return;
if (app.historyLoading) { app.historyReloadPending = true; return; }
app.historyLoading = true;
const hours = $('#historyHours')?.value || '24';
try {
@@ -75,7 +102,10 @@ async function loadHistory() {
} catch (error) {
toast(error.message, true);
renderHistoryPage();
} finally { app.historyLoading = false; }
} finally {
app.historyLoading = false;
if (app.historyReloadPending) { app.historyReloadPending = false; setTimeout(() => loadHistory(), 0); }
}
}
const HISTORY_COLORS = ['--accent', '--info', '--warning', '--purple', '--danger', '--teal', '--orange', '--blue'];
@@ -116,7 +146,7 @@ function redrawHistoryChart(id) {
if (!runtime || !canvas) return;
canvas.dataset.chartZoom = String(clamp(Number(app.chartZooms[id] || 1), 1, MAX_CHART_ZOOM));
if (runtime.kind === 'energy') {
drawEnergyBarChart(canvas, runtime.buckets, runtime.options);
drawEnergyBarChart(canvas, runtime.series, runtime.options);
} else {
drawLineChart(canvas, runtime.series, runtime.rows, runtime.options);
renderLegend(document.getElementById(`${id}Legend`), runtime.series);
@@ -438,24 +468,42 @@ function drawLineChart(canvas, series, rows, { height = 340, minValue = null, ma
bindChartTooltip(canvas, visibleSeries, sortedRows, { pad, width, height, firstTs, lastTs, binaryLabels });
}
function drawEnergyBarChart(canvas, buckets, { height = 340 } = {}) {
function energyBucketLabel(timestamp, interval) {
const date = new Date(timestamp);
if (interval === 'hourly') return new Intl.DateTimeFormat(locale(), { hour: '2-digit', minute: '2-digit' }).format(date);
if (interval === 'monthly') return new Intl.DateTimeFormat(locale(), { month: 'short', year: '2-digit' }).format(date);
return new Intl.DateTimeFormat(locale(), { day: '2-digit', month: '2-digit', ...(interval === 'weekly' ? { year: '2-digit' } : {}) }).format(date);
}
function drawEnergyBarChart(canvas, seriesInput, { height = 340, interval = app.historyEnergyInterval } = {}) {
if (!canvas) return;
const options = { height };
if (canvas.id) chartRuntime.set(canvas.id, { kind: 'energy', buckets, options });
if (!buckets?.length) return drawEmptyChart(canvas, height);
const series = Array.isArray(seriesInput) && seriesInput.length && Array.isArray(seriesInput[0]?.buckets)
? seriesInput
: [{ key: 'energy', label: 'kWh', color: historySeriesColor(0), buckets: Array.isArray(seriesInput) ? seriesInput : [] }];
const options = { height, interval };
if (canvas.id) chartRuntime.set(canvas.id, { kind: 'energy', series, options });
const visibleSeries = series.filter((item, index) => !isChartSeriesHidden(canvas.id, item, index));
const starts = [...new Set(visibleSeries.flatMap(item => (item.buckets || []).map(row => row.start)))].sort((a, b) => new Date(a) - new Date(b));
if (!starts.length || !visibleSeries.length) {
drawEmptyChart(canvas, height);
renderLegend(document.getElementById(`${canvas.id}Legend`), series);
updateChartZoomControls(canvas.id);
return;
}
const maps = visibleSeries.map(item => new Map((item.buckets || []).map(row => [row.start, Math.max(0, Number(row.consumption_kwh) || 0)])));
const values = maps.flatMap(map => starts.map(start => map.get(start) || 0));
const prepared = prepareCanvas(canvas, height);
const { ctx, width } = prepared;
height = prepared.height;
const text = cssColor('--muted', '#888');
const grid = cssColor('--grid', '#333');
const fill = cssColor('--accent', '#3ecf8e');
const pad = { left: 58, right: 18, top: 20, bottom: 48 };
const values = buckets.map(row => Math.max(0, Number(row.consumption_kwh) || 0));
const max = Math.max(0.1, ...values) * 1.1;
const plotW = width - pad.left - pad.right;
const plotH = height - pad.top - pad.bottom;
const slot = plotW / Math.max(1, buckets.length);
const barW = Math.max(2, Math.min(slot * 0.72, 42));
const slot = plotW / Math.max(1, starts.length);
const groupW = Math.max(4, Math.min(slot * 0.78, 56));
const barW = Math.max(2, groupW / Math.max(1, visibleSeries.length));
ctx.font = '10px system-ui'; ctx.fillStyle = text; ctx.strokeStyle = grid; ctx.lineWidth = 1;
for (let i = 0; i <= 5; i++) {
const value = max * i / 5;
@@ -463,25 +511,28 @@ function drawEnergyBarChart(canvas, buckets, { height = 340 } = {}) {
ctx.beginPath(); ctx.moveTo(pad.left, y); ctx.lineTo(width - pad.right, y); ctx.stroke();
ctx.textAlign = 'right'; ctx.fillText(`${value.toFixed(value < 1 ? 2 : 1)}`, pad.left - 8, y + 3);
}
ctx.fillStyle = fill;
buckets.forEach((row, index) => {
const value = values[index];
const x = pad.left + slot * index + (slot - barW) / 2;
const barH = (value / max) * plotH;
ctx.fillRect(x, pad.top + plotH - barH, barW, barH);
starts.forEach((start, bucketIndex) => {
const baseX = pad.left + slot * bucketIndex + (slot - groupW) / 2;
visibleSeries.forEach((item, seriesIndex) => {
const value = maps[seriesIndex].get(start) || 0;
const barH = (value / max) * plotH;
ctx.save();
ctx.globalAlpha = item.comparison ? 0.45 : 0.92;
ctx.fillStyle = item.color || historySeriesColor(seriesIndex);
ctx.fillRect(baseX + seriesIndex * barW, pad.top + plotH - barH, Math.max(1, barW - 1), barH);
ctx.restore();
});
});
ctx.fillStyle = text;
const ticks = Math.min(6, buckets.length);
const ticks = Math.min(6, starts.length);
for (let i = 0; i < ticks; i++) {
const index = ticks === 1 ? 0 : Math.round(i * (buckets.length - 1) / (ticks - 1));
const index = ticks === 1 ? 0 : Math.round(i * (starts.length - 1) / (ticks - 1));
const x = pad.left + slot * index + slot / 2;
ctx.textAlign = 'center';
ctx.fillText(timeLabel(buckets[index].start, $('#historyHours')?.value), x, height - 18);
ctx.fillText(energyBucketLabel(starts[index], interval), x, height - 18);
}
ctx.textAlign = 'left';
ctx.fillText('kWh', 8, pad.top + 4);
const legend = document.getElementById(`${canvas.id}Legend`);
if (legend) legend.innerHTML = `<span class="legend-item"><i class="legend-line" style="--legend-color:${esc(fill)}"></i><span>kWh</span></span>`;
ctx.textAlign = 'left'; ctx.fillText('kWh', 8, pad.top + 4);
renderLegend(document.getElementById(`${canvas.id}Legend`), series);
updateChartZoomControls(canvas.id);
}
+2 -2
View File
@@ -25,12 +25,12 @@ 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: [], schedules: [], automations: [], flows: [], accessTokens: [], settings: null, system: {}, outdoorTemperature: null,
devices: [], zones: [], groups: [], deviceGroups: [], 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: {},
historyTab: 'overview', historyData: { zones: [], devices: [], sensors: [] }, historyCounts: {},
historyZone: 'all', historyDevice: 'all', historySensor: 'all', historyEnergyDevice: '', historyEnergyInterval: 'daily', historyEnergy: null, historyLoading: false,
historyZone: 'all', historyDevice: 'all', historySensor: 'all', historyEnergyDevice: '', historyEnergyTargets: [], historyEnergyInterval: 'daily', historyEnergyCompare: 'none', historyEnergy: [], historyLoading: false, historyReloadPending: false,
customChartSeries: [], savedCharts: [], chartZooms: {}, chartHiddenSeries: {}, zoneControlSeq: {}, groupControlSeq: {}, zoneControlQueue: {}, groupControlQueue: {}, deviceControlQueue: {}, houseControlQueue: null, climateControlQueue: null, groupCustomDrafts: {}, zoneTemperatureTimers: {}, deviceTemperatureDrafts: {},
controlPlan: null, controlPlanRevision: null, controlPlanPushReady: false, controlPlanTimer: null, debugLines: [], debugBacklogLoaded: false, debugFilter: 'all', sensorAliases: {}, flowSharedInputs: [], flowSharedInputValueCache: {}, flowSharedInputValueRequests: {}, flowSharedInputValueTimer: null,
simulationScope: 'units', simulationTarget: 'all', standaloneSimulation: false, dashboardTab: 'main', settingsTab: 'app', systemSnapshotAt: Date.now(),
+47 -4
View File
@@ -24,6 +24,35 @@ function deviceTransportLabel(device) {
return device.connection_type === 'gree_cloud' ? 'GREE Cloud' : 'Local';
}
function deviceInstallationForDevice(deviceId) {
return (app.deviceGroups || []).find(group => (group.device_ids || []).includes(deviceId)) || null;
}
function deviceInstallationKindLabel(group) {
return tr(group?.kind === 'multisplit' ? 'devices.multisplit' : 'devices.split');
}
function installationEnergySourceLabel(group) {
if (!group) return '—';
if (group.energy_source === 'home_assistant' || (group.energy_source === 'auto' && group.ha_energy_entity_id && !group.energy_device_id)) {
return group.ha_energy_entity_id ? `Home Assistant · ${group.ha_energy_entity_id}` : 'Home Assistant';
}
if (group.energy_source === 'gree_cloud' || group.energy_device_id) {
const source = app.devices.find(device => device.id === group.energy_device_id);
return source ? `GREE Cloud · ${source.name}` : 'GREE Cloud';
}
return tr('energy.auto');
}
function effectiveDeviceOutdoorTemperature(device) {
const group = deviceInstallationForDevice(device?.id);
if (group?.outdoor_temperature_device_id) {
const source = app.devices.find(item => item.id === group.outdoor_temperature_device_id);
if (source?.outdoor_temperature != null) return source.outdoor_temperature;
}
return device?.outdoor_temperature;
}
function deviceConnectionStatusLabel(device) {
const key = {
online: 'status.online',
@@ -85,7 +114,7 @@ function manualLocalDeviceCard(device) {
<div class="target-temp editable-target" data-temperature-kind="device" data-id="${esc(device.id)}" data-value="${Number(device.target_temperature)}" data-editable="true" tabindex="0" role="button" title="${esc(tr('common.clickTemperatureToEdit'))}">${Number(device.target_temperature).toFixed(1)}<small>°C</small></div>
<button data-action="temperature" data-delta="1" data-device="${esc(device.id)}">+</button>
</div>
<div class="current-line">${esc(tr('devices.currentTemperature'))}: <strong>${fmtTemp(device.current_temperature)}</strong>${device.outdoor_temperature == null ? '' : ` · ${esc(tr('devices.outdoor'))} ${fmtTemp(device.outdoor_temperature)}`}</div>
<div class="current-line">${esc(tr('devices.currentTemperature'))}: <strong>${fmtTemp(device.current_temperature)}</strong>${effectiveDeviceOutdoorTemperature(device) == null ? '' : ` · ${esc(tr('devices.outdoor'))} ${fmtTemp(effectiveDeviceOutdoorTemperature(device))}`}</div>
${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>
@@ -123,7 +152,7 @@ function manualCloudDeviceCard(device) {
<div class="target-temp editable-target" data-temperature-kind="device" data-id="${esc(device.id)}" data-value="${Number(device.target_temperature)}" data-temp-step="${tempStep}" data-temp-min="${minTemp}" data-temp-max="${maxTemp}" data-editable="true" tabindex="0" role="button" title="${esc(tr('common.clickTemperatureToEdit'))}">${Number(device.target_temperature).toFixed(1)}<small>°C</small></div>
<button data-action="temperature" data-delta="${tempStep}" data-device="${esc(device.id)}">+</button>
</div>
<div class="current-line">${esc(tr('devices.currentTemperature'))}: <strong>${fmtTemp(device.current_temperature)}</strong>${device.outdoor_temperature == null ? '' : ` · ${esc(tr('devices.outdoor'))} ${fmtTemp(device.outdoor_temperature)}`}</div>
<div class="current-line">${esc(tr('devices.currentTemperature'))}: <strong>${fmtTemp(device.current_temperature)}</strong>${effectiveDeviceOutdoorTemperature(device) == null ? '' : ` · ${esc(tr('devices.outdoor'))} ${fmtTemp(effectiveDeviceOutdoorTemperature(device))}`}</div>
${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>` : ''}
@@ -148,7 +177,7 @@ function technicalDeviceCard(device) {
const model = device.model || tr('common.unavailable');
const firmware = device.firmware || tr('common.unavailable');
const cid = device.cid || tr('common.unavailable');
const error = device.last_error ? `<div class="technical-device-error"><span>${esc(tr('devices.lastError'))}</span><strong title="${esc(device.last_error)}">${esc(device.last_error)}</strong></div>` : '';
const error = device.last_error ? `<div class="inline-alert error"><span>${esc(tr('devices.lastError'))}</span><strong title="${esc(device.last_error)}">${esc(device.last_error)}</strong></div>` : '';
return `<article class="device-card technical-device-card" data-device-card="${esc(device.id)}">
<div class="technical-device-head">
<div class="device-title"><span class="eyebrow">${esc(tr('devices.technicalUnit'))} · ${esc(deviceTransportLabel(device))}</span><h3>${esc(device.name)}</h3><p><span class="status ${device.online ? 'online' : ''}">${esc(device.connection_type === 'gree_cloud' ? (device.connection_status || 'unknown').replaceAll('_', ' ') : tr(device.online ? 'status.online' : 'status.offline'))}</span> · ${esc(model)}</p></div>
@@ -162,6 +191,7 @@ function technicalDeviceCard(device) {
<div><span>${esc(tr('devices.modelFirmware'))}</span><strong>${esc(model)}</strong><small>${esc(firmware)}</small></div>
<div><span>${esc(tr('devices.lastSeen'))}</span><strong>${esc(lastSeen)}</strong></div>
<div><span>${esc(tr('devices.communicationFailures'))}</span><strong>${esc(device.communication_failures ?? 0)}</strong></div>
${deviceInstallationForDevice(device.id) ? `<div><span>${esc(tr('devices.installation'))}</span><strong>${esc(deviceInstallationForDevice(device.id).name)}</strong><small>${esc(deviceInstallationKindLabel(deviceInstallationForDevice(device.id)))}</small></div>` : ''}
</div>
${error}
<div class="technical-device-actions">
@@ -182,7 +212,7 @@ function cloudTechnicalDeviceCard(device) {
const responseTime = deviceResponseTimeLabel(device);
const modelCell = device.model ? `<div><span>${esc(tr('devices.model'))}</span><strong>${esc(device.model)}</strong></div>` : '';
const firmwareCell = device.firmware ? `<div><span>${esc(tr('devices.firmware'))}</span><strong>${esc(device.firmware)}</strong></div>` : '';
const error = device.last_error ? `<div class="technical-device-error"><span>${esc(tr('devices.lastError'))}</span><strong title="${esc(device.last_error)}">${esc(device.last_error)}</strong></div>` : '';
const error = device.last_error ? `<div class="inline-alert error"><span>${esc(tr('devices.lastError'))}</span><strong title="${esc(device.last_error)}">${esc(device.last_error)}</strong></div>` : '';
return `<article class="device-card technical-device-card" data-device-card="${esc(device.id)}">
<div class="technical-device-head">
<div class="device-title"><span class="eyebrow">${esc(tr('devices.cloudUnit'))}</span><h3>${esc(device.name)}</h3><p><span class="status ${device.online ? 'online' : ''}">${esc(status)}</span> · GREE Cloud</p></div>
@@ -198,6 +228,7 @@ function cloudTechnicalDeviceCard(device) {
<div><span>${esc(tr('devices.lastSync'))}</span><strong>${esc(lastSync)}</strong></div>
<div><span>${esc(tr('devices.lastResponse'))}</span><strong>${esc(lastSeen)}</strong><small>${esc(responseTime)}</small></div>
<div><span>${esc(tr('devices.communicationFailures'))}</span><strong>${esc(device.communication_failures ?? 0)}</strong></div>
${deviceInstallationForDevice(device.id) ? `<div><span>${esc(tr('devices.installation'))}</span><strong>${esc(deviceInstallationForDevice(device.id).name)}</strong><small>${esc(deviceInstallationKindLabel(deviceInstallationForDevice(device.id)))}</small></div>` : ''}
</div>
${error}
<div class="technical-device-actions">
@@ -209,7 +240,19 @@ function cloudTechnicalDeviceCard(device) {
</article>`;
}
function renderDeviceInstallationsSummary() {
const host = $('#deviceGroupsSummary');
if (!host) return;
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>`;
}).join('') : '';
}
function renderDevices() {
renderDeviceInstallationsSummary();
const empty = `<div class="empty"><strong>${esc(tr('devices.emptyTitle'))}</strong>${esc(tr('devices.emptyText'))}</div>`;
$('#dashboardDevices').innerHTML = app.devices.length ? app.devices.map(manualDeviceCard).join('') : empty;
$('#deviceList').innerHTML = app.devices.length ? app.devices.map(technicalDeviceCard).join('') : empty;
+79 -13
View File
@@ -85,6 +85,19 @@ document.addEventListener('click', async event => {
finally { button.disabled = false; }
return;
}
if (action === 'edit-device-group') { await openDeviceGroupsDialog(button.dataset.id || ''); return; }
if (action === 'delete-device-group') {
const group = (app.deviceGroups || []).find(item => item.id === button.dataset.id);
if (!group || !confirm(`${tr('actions.delete')} ${group.name}?`)) return;
try {
await api(`/api/device-groups/${encodeURIComponent(group.id)}`, { method: 'DELETE' });
await loadBootstrap();
renderDeviceGroupsDialogList();
await populateDeviceGroupForm(null);
toast(tr('devices.installationDeleted'));
} catch (error) { toast(error.message, true); }
return;
}
const device = app.devices.find(v => v.id === button.dataset.device);
if (action === 'open-ping' && device) { openDevicePing(device.id); return; }
if (action === 'ping-toggle') { if (app.pingMonitor.running) stopPingMonitor(); else startPingMonitor(); return; }
@@ -361,7 +374,7 @@ $('#renameDeviceForm').addEventListener('submit', async event => {
if (isCloud) {
const check = await api('/api/integrations/gree-cloud/test', { method: 'POST' });
if (!check.ok) throw new Error(check.message || check.status || tr('settings.cloudConnectionFailed'));
if (result) { result.hidden = false; result.classList.add('success'); result.textContent = tr('settings.cloudConnected', { count: Number(check.device_count || 0) }); }
if (result) { const message = tr('settings.cloudConnected', { count: Number(check.device_count || 0) }); result.hidden = false; result.classList.add('success'); result.innerHTML = `<span>${esc(tr('status.online'))}</span><strong>${esc(message)}</strong>`; }
markFormClean(form); renderAll(); toast(tr('devices.savedAndChecked')); return;
}
const protocolChanged = previous && Number(previous.protocol_version) !== Number(raw.protocol_version);
@@ -380,7 +393,8 @@ $('#renameDeviceForm').addEventListener('submit', async event => {
}
if (result) {
result.hidden = false; result.classList.add('success');
result.textContent = tr('devices.connectionCheckOk', { ms: Math.round(Number(probe.response_time_ms || 0)) });
const message = tr('devices.connectionCheckOk', { ms: Math.round(Number(probe.response_time_ms || 0)) });
result.innerHTML = `<span>${esc(tr('status.online'))}</span><strong>${esc(message)}</strong>`;
}
markFormClean(form);
renderAll();
@@ -388,7 +402,8 @@ $('#renameDeviceForm').addEventListener('submit', async event => {
} catch (error) {
if (result) {
result.hidden = false; result.classList.add('error');
result.textContent = tr('devices.connectionCheckFailed', { error: error.message || String(error) });
const message = tr('devices.connectionCheckFailed', { error: error.message || String(error) });
result.innerHTML = `<span>${esc(tr('devices.lastError'))}</span><strong>${esc(message)}</strong>`;
}
markFormClean(form);
renderAll();
@@ -401,17 +416,22 @@ $('#deviceDetailsForm')?.addEventListener('submit', async event => {
event.preventDefault();
const form = event.currentTarget;
const raw = Object.fromEntries(new FormData(form));
const option = form.ha_energy_entity_id.selectedOptions?.[0];
const patch = {
name: raw.name.trim(),
energy_source: raw.energy_source || 'auto',
ha_energy_entity_id: raw.ha_energy_entity_id || null,
ha_energy_unit: option?.value ? (option.dataset.unit || null) : null,
ha_energy_device_class: option?.value ? (option.dataset.deviceClass || null) : null,
ha_energy_state_class: option?.value ? (option.dataset.stateClass || null) : null,
};
const deviceId = form.elements.id.value;
const installation = deviceInstallationForDevice(deviceId);
const groupOwnsEnergy = !!installation && (!!installation.energy_device_id || !!installation.ha_energy_entity_id);
const patch = { name: String(raw.name || '').trim() };
if (!groupOwnsEnergy) {
const option = form.ha_energy_entity_id.selectedOptions?.[0];
Object.assign(patch, {
energy_source: raw.energy_source || 'auto',
ha_energy_entity_id: raw.ha_energy_entity_id || null,
ha_energy_unit: option?.value ? (option.dataset.unit || null) : null,
ha_energy_device_class: option?.value ? (option.dataset.deviceClass || null) : null,
ha_energy_state_class: option?.value ? (option.dataset.stateClass || null) : null,
});
}
await runFormTask(form, async () => {
const device = await api(`/api/devices/${encodeURIComponent(raw.id)}`, { method: 'PATCH', body: patch });
const device = await api(`/api/devices/${encodeURIComponent(deviceId)}`, { method: 'PATCH', body: patch });
updateDevice(device);
form.closest('dialog').close();
renderAll();
@@ -421,6 +441,52 @@ $('#deviceDetailsForm')?.addEventListener('submit', async event => {
$('#deviceDetailsForm')?.ha_energy_entity_id?.addEventListener('change', updateDeviceEnergySensorMeta);
$('#deviceGroupsButton')?.addEventListener('click', () => openDeviceGroupsDialog());
$('#deviceGroupNew')?.addEventListener('click', () => populateDeviceGroupForm(null));
$('#deviceGroupForm')?.addEventListener('change', event => {
const form = event.currentTarget;
if (event.target.name === 'kind' && form.kind.value === 'split') {
const checked = $$('#deviceGroupDeviceChoices input[name="device_ids"]:checked');
checked.slice(1).forEach(input => { input.checked = false; });
syncDeviceGroupMemberSelects();
}
if (event.target.name === 'device_ids') {
if (form.kind.value === 'split' && event.target.checked) {
$$('#deviceGroupDeviceChoices input[name="device_ids"]:checked').forEach(input => { if (input !== event.target) input.checked = false; });
}
syncDeviceGroupMemberSelects();
}
if (event.target.name === 'ha_energy_entity_id') updateDeviceGroupEnergySensorMeta();
});
$('#deviceGroupForm')?.addEventListener('submit', async event => {
event.preventDefault();
const form = event.currentTarget;
const ids = selectedDeviceGroupIds();
if (!ids.length) return showFormError(form, tr('energy.selectAtLeastOne'));
const option = form.ha_energy_entity_id.selectedOptions?.[0];
const body = {
name: form.name.value.trim(),
kind: form.kind.value || 'split',
device_ids: ids,
energy_source: form.energy_source.value || 'auto',
energy_device_id: form.energy_device_id.value || null,
ha_energy_entity_id: form.ha_energy_entity_id.value || null,
ha_energy_unit: option?.value ? (option.dataset.unit || null) : null,
ha_energy_device_class: option?.value ? (option.dataset.deviceClass || null) : null,
ha_energy_state_class: option?.value ? (option.dataset.stateClass || null) : null,
outdoor_temperature_device_id: form.outdoor_temperature_device_id.value || null,
};
const id = form.elements.id.value;
await runFormTask(form, async () => {
await api(id ? `/api/device-groups/${encodeURIComponent(id)}` : '/api/device-groups', { method: id ? 'PUT' : 'POST', body });
await loadBootstrap();
renderDeviceGroupsDialogList();
await populateDeviceGroupForm(null);
toast(tr('devices.installationSaved'));
});
});
$('#cloudDiagnosticsRefresh')?.addEventListener('click', () => {
const id = $('#cloudDiagnosticsDialog')?.dataset.deviceId;
if (id) openCloudDiagnostics(id);
+2 -2
View File
@@ -63,9 +63,9 @@ function showFieldError(field, message) {
function showFormError(form, message) {
if (!form || !message) return;
const summary = document.createElement('div');
summary.className = 'form-error-summary';
summary.className = 'inline-alert error form-error-summary';
summary.setAttribute('role', 'alert');
summary.textContent = message;
summary.innerHTML = `<span>${esc(tr('devices.lastError'))}</span><strong>${esc(message)}</strong>`;
const anchor = $('.settings-save-bar', form) || $('.form-actions', form);
if (anchor?.parentElement) anchor.parentElement.insertBefore(summary, anchor);
else form.appendChild(summary);
+45 -19
View File
@@ -200,40 +200,66 @@ async function handleHistoryAction(button) {
async function loadEnergyHistory() {
const energyDevices = app.devices.filter(device => device.capabilities?.energy_meter === true || !!device.ha_energy_entity_id);
if (!energyDevices.length) {
app.historyEnergy = null;
const targets = normalizeEnergyHistoryTargets();
if (!targets.length || !app.historyEnergyTargets.length) {
app.historyEnergy = [];
return;
}
if (!energyDevices.some(device => device.id === app.historyEnergyDevice)) app.historyEnergyDevice = energyDevices[0].id;
const hours = Number($('#historyHours')?.value || 24);
const days = Math.max(1, Math.ceil(hours / 24));
app.historyEnergy = await api(`/api/history/energy?device_id=${encodeURIComponent(app.historyEnergyDevice)}&interval=${encodeURIComponent(app.historyEnergyInterval)}&days=${days}&limit=100000`);
const selected = app.historyEnergyTargets.slice(0, 8);
app.historyEnergy = await Promise.all(selected.map(targetId => api(`/api/history/energy?target_id=${encodeURIComponent(targetId)}&interval=${encodeURIComponent(app.historyEnergyInterval)}&days=${days}&limit=100000&compare=${encodeURIComponent(app.historyEnergyCompare)}`)));
}
function renderEnergyHistorySummary() {
const host = $('#historySummary'); if (!host) return;
const data = app.historyEnergy;
if (!data || data.source === 'none') { host.innerHTML = ''; return; }
const summary = data.summary || {};
const rows = [
[tr('energy.today'), summary.today],
[tr('energy.yesterday'), summary.yesterday],
[tr('energy.currentMonth'), summary.current_month],
[tr('energy.previousMonth'), summary.previous_month],
[tr('energy.periodTotal'), summary.period_total],
];
host.innerHTML = rows.map(([label, value]) => `<div class="history-stat"><small>${esc(label)}</small><strong>${Number(value || 0).toLocaleString(locale(), { maximumFractionDigits: 3 })} kWh</strong><span>${esc(data.source === 'gree_cloud' ? 'GREE Cloud' : 'Home Assistant')}</span></div>`).join('');
const data = (app.historyEnergy || []).filter(item => item && item.source !== 'none');
if (!data.length) { host.innerHTML = ''; return; }
if (data.length === 1) {
const item = data[0];
const summary = item.summary || {};
const rows = [
[tr('energy.today'), summary.today],
[tr('energy.yesterday'), summary.yesterday],
[tr('energy.currentMonth'), summary.current_month],
[tr('energy.previousMonth'), summary.previous_month],
[tr('energy.periodTotal'), summary.period_total],
];
if (item.comparison) rows.push([tr('energy.comparisonPeriod'), item.comparison.period_total]);
host.innerHTML = rows.map(([label, value]) => `<div class="history-stat"><small>${esc(label)}</small><strong>${Number(value || 0).toLocaleString(locale(), { maximumFractionDigits: 3 })} kWh</strong><span>${esc(item.target_name || '')} · ${esc(item.source === 'gree_cloud' ? 'GREE Cloud' : 'Home Assistant')}</span></div>`).join('');
return;
}
host.innerHTML = data.map(item => `<div class="history-stat"><small>${esc(item.target_name || item.target_id)}</small><strong>${Number(item.summary?.period_total || 0).toLocaleString(locale(), { maximumFractionDigits: 3 })} kWh</strong><span>${esc(item.source === 'gree_cloud' ? 'GREE Cloud' : 'Home Assistant')}${item.comparison ? ` · ${esc(tr('energy.comparisonPeriod'))}: ${Number(item.comparison.period_total || 0).toLocaleString(locale(), { maximumFractionDigits: 3 })} kWh` : ''}</span></div>`).join('');
}
function renderEnergyHistory() {
const host = $('#historyCharts'); if (!host) return;
const data = app.historyEnergy;
const data = (app.historyEnergy || []).filter(item => item && item.source !== 'none');
renderEnergyHistorySummary();
if (!data || data.source === 'none') {
if (!data.length) {
host.innerHTML = `<div class="empty"><strong>${esc(tr('energy.noData'))}</strong></div>`;
return;
}
const series = [];
data.forEach((item, index) => {
const color = historySeriesColor(index);
series.push({
key: `${item.target_id}:current`,
label: data.length === 1 ? `${item.target_name} · ${tr('energy.currentPeriod')}` : item.target_name,
color,
buckets: item.buckets || [],
});
if (app.historyEnergyCompare !== 'none' && item.comparison?.buckets?.length) {
series.push({
key: `${item.target_id}:comparison`,
label: `${item.target_name} · ${tr('energy.comparisonPeriod')}`,
color,
comparison: true,
buckets: item.comparison.buckets,
});
}
});
host.innerHTML = historyChartMarkup('energyConsumptionChart', tr('energy.title'), tr('energy.chartHint'));
drawEnergyBarChart($('#energyConsumptionChart'), data.buckets || [], { height: 360 });
drawEnergyBarChart($('#energyConsumptionChart'), series, { height: 360, interval: app.historyEnergyInterval });
}
+137 -11
View File
@@ -698,7 +698,7 @@ async function openDeviceDetails(id) {
const device = app.devices.find(item => item.id === id); if (!device) return;
const form = $('#deviceDetailsForm'); if (!form) return;
form.reset();
form.id.value = device.id;
form.elements.id.value = device.id;
form.name.value = device.name || '';
form.energy_source.value = device.energy_source || 'auto';
const title = $('#deviceDetailsTitle');
@@ -707,10 +707,24 @@ async function openDeviceDetails(id) {
if (meta) meta.innerHTML = device.connection_type === 'gree_cloud'
? `<div><span>${esc(tr('devices.connection'))}</span><strong>GREE Cloud</strong></div><div><span>${esc(tr('devices.transport'))}</span><strong>MQTT / TLS</strong></div><div><span>${esc(tr('devices.cloudDeviceId'))}</span><strong>${esc(device.cloud_device_id || device.mac || '—')}</strong></div><div><span>MAC</span><strong>${esc(device.mac || '—')}</strong></div>`
: `<div><span>${esc(tr('devices.connection'))}</span><strong>Local</strong></div><div><span>${esc(tr('devices.address'))}</span><strong>${esc(device.ip || '—')}:${esc(device.port || 7000)}</strong></div><div><span>MAC</span><strong>${esc(device.mac || '—')}</strong></div>`;
const installation = deviceInstallationForDevice(device.id);
const groupNote = $('#deviceEnergyGroupNote');
const groupOwnsEnergy = !!installation && (!!installation.energy_device_id || !!installation.ha_energy_entity_id);
if (groupNote) {
groupNote.hidden = !groupOwnsEnergy;
groupNote.innerHTML = groupOwnsEnergy
? `<span>${esc(deviceInstallationKindLabel(installation))}</span><strong>${esc(tr('devices.groupedEnergyNote', { name: installation.name, source: installationEnergySourceLabel(installation) }))}</strong>`
: '';
}
const source = form.energy_source;
if (groupOwnsEnergy) source.value = installation.energy_source || 'auto';
source.disabled = groupOwnsEnergy;
const cloudOption = [...source.options].find(option => option.value === 'gree_cloud');
if (cloudOption) cloudOption.disabled = device.capabilities?.energy_meter !== true;
const sensorSelect = form.ha_energy_entity_id;
sensorSelect.disabled = groupOwnsEnergy;
sensorSelect.innerHTML = '<option value="">—</option>';
try {
const response = await api('/api/integrations/home-assistant/energy-sensors');
@@ -723,23 +737,135 @@ async function openDeviceDetails(id) {
option.dataset.stateClass = sensor.state_class || '';
sensorSelect.append(option);
}
} catch (_) {
// Home Assistant is optional; keep any already-saved entity available even when HA is offline.
}
if (device.ha_energy_entity_id && ![...sensorSelect.options].some(option => option.value === device.ha_energy_entity_id)) {
} catch (_) { }
const energyEntity = groupOwnsEnergy ? installation.ha_energy_entity_id : device.ha_energy_entity_id;
const energyUnit = groupOwnsEnergy ? installation.ha_energy_unit : device.ha_energy_unit;
const energyDeviceClass = groupOwnsEnergy ? installation.ha_energy_device_class : device.ha_energy_device_class;
const energyStateClass = groupOwnsEnergy ? installation.ha_energy_state_class : device.ha_energy_state_class;
if (energyEntity && ![...sensorSelect.options].some(option => option.value === energyEntity)) {
const option = document.createElement('option');
option.value = device.ha_energy_entity_id;
option.textContent = device.ha_energy_entity_id;
option.dataset.unit = device.ha_energy_unit || '';
option.dataset.deviceClass = device.ha_energy_device_class || '';
option.dataset.stateClass = device.ha_energy_state_class || '';
option.value = energyEntity;
option.textContent = energyEntity;
option.dataset.unit = energyUnit || '';
option.dataset.deviceClass = energyDeviceClass || '';
option.dataset.stateClass = energyStateClass || '';
sensorSelect.append(option);
}
sensorSelect.value = device.ha_energy_entity_id || '';
sensorSelect.value = energyEntity || '';
updateDeviceEnergySensorMeta();
openDialog('deviceDetailsDialog');
}
function renderDeviceGroupsDialogList() {
const host = $('#deviceGroupsList'); if (!host) return;
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);
return `<div class="installation-list-row"><button type="button" data-action="edit-device-group" data-id="${esc(group.id)}"><strong>${esc(group.name)}</strong><small>${esc(deviceInstallationKindLabel(group))} · ${esc(members.join(' · ') || '—')} · ${esc(installationEnergySourceLabel(group))}</small></button><div><button type="button" class="secondary" data-action="edit-device-group" data-id="${esc(group.id)}">${esc(tr('actions.edit'))}</button><button type="button" class="danger" data-action="delete-device-group" data-id="${esc(group.id)}">${esc(tr('actions.delete'))}</button></div></div>`;
}).join('') : `<div class="empty"><strong>${esc(tr('devices.newInstallation'))}</strong><span>${esc(tr('devices.installationsHint'))}</span></div>`;
}
function selectedDeviceGroupIds() {
return $$('#deviceGroupDeviceChoices input[type="checkbox"]:checked').map(input => input.value);
}
function syncDeviceGroupMemberSelects() {
const form = $('#deviceGroupForm'); if (!form) return;
const ids = selectedDeviceGroupIds();
const energy = form.energy_device_id;
const outdoor = form.outdoor_temperature_device_id;
const oldEnergy = energy.value, oldOutdoor = outdoor.value;
energy.innerHTML = '<option value="">—</option>' + ids.map(id => {
const device = app.devices.find(item => item.id === id);
if (!device) return '';
const supported = device.connection_type === 'gree_cloud' && device.capabilities?.energy_meter === true;
return `<option value="${esc(id)}" ${supported ? '' : 'disabled'}>${esc(device.name)}${supported ? '' : ' · —'}</option>`;
}).join('');
outdoor.innerHTML = `<option value="">${esc(tr('devices.noSharedOutdoor'))}</option>` + ids.map(id => {
const device = app.devices.find(item => item.id === id);
return device ? `<option value="${esc(id)}">${esc(device.name)}</option>` : '';
}).join('');
if ([...energy.options].some(option => option.value === oldEnergy && !option.disabled)) energy.value = oldEnergy;
if ([...outdoor.options].some(option => option.value === oldOutdoor)) outdoor.value = oldOutdoor;
}
function renderDeviceGroupDeviceChoices(selectedIds = []) {
const form = $('#deviceGroupForm');
const host = $('#deviceGroupDeviceChoices'); if (!form || !host) return;
const currentId = form.elements.id.value;
const occupied = new Map();
for (const group of (app.deviceGroups || [])) {
if (group.id === currentId) continue;
for (const id of (group.device_ids || [])) occupied.set(id, group.name);
}
host.innerHTML = app.devices.map(device => {
const owner = occupied.get(device.id);
const checked = selectedIds.includes(device.id);
return `<label class="check ${owner ? 'disabled' : ''}" title="${owner ? esc(owner) : ''}"><input type="checkbox" name="device_ids" value="${esc(device.id)}" ${checked ? 'checked' : ''} ${owner ? 'disabled' : ''}> <span>${esc(device.name)}${owner ? ` · ${esc(owner)}` : ''}</span></label>`;
}).join('');
syncDeviceGroupMemberSelects();
}
async function loadDeviceGroupEnergySensors(group = null) {
const form = $('#deviceGroupForm'); if (!form) return;
const select = form.ha_energy_entity_id;
select.innerHTML = '<option value="">—</option>';
try {
const response = await api('/api/integrations/home-assistant/energy-sensors');
for (const sensor of (response.sensors || [])) {
const option = document.createElement('option');
option.value = sensor.entity_id;
option.textContent = `${sensor.name || sensor.entity_id} · ${sensor.unit || ''}`;
option.dataset.unit = sensor.unit || '';
option.dataset.deviceClass = sensor.device_class || '';
option.dataset.stateClass = sensor.state_class || '';
select.append(option);
}
} catch (_) { }
if (group?.ha_energy_entity_id && ![...select.options].some(option => option.value === group.ha_energy_entity_id)) {
const option = document.createElement('option');
option.value = group.ha_energy_entity_id;
option.textContent = group.ha_energy_entity_id;
option.dataset.unit = group.ha_energy_unit || '';
option.dataset.deviceClass = group.ha_energy_device_class || '';
option.dataset.stateClass = group.ha_energy_state_class || '';
select.append(option);
}
select.value = group?.ha_energy_entity_id || '';
updateDeviceGroupEnergySensorMeta();
}
async function populateDeviceGroupForm(group = null) {
const form = $('#deviceGroupForm'); if (!form) return;
form.reset();
form.elements.id.value = group?.id || '';
form.name.value = group?.name || '';
form.kind.value = group?.kind || 'split';
form.energy_source.value = group?.energy_source || 'auto';
renderDeviceGroupDeviceChoices(group?.device_ids || []);
syncDeviceGroupMemberSelects();
form.energy_device_id.value = group?.energy_device_id || '';
form.outdoor_temperature_device_id.value = group?.outdoor_temperature_device_id || '';
await loadDeviceGroupEnergySensors(group);
}
async function openDeviceGroupsDialog(groupId = '') {
renderDeviceGroupsDialogList();
const group = groupId ? (app.deviceGroups || []).find(item => item.id === groupId) : null;
await populateDeviceGroupForm(group || null);
openDialog('deviceGroupsDialog');
}
function updateDeviceGroupEnergySensorMeta() {
const select = $('#deviceGroupForm')?.ha_energy_entity_id;
const meta = $('#deviceGroupEnergySensorMeta');
if (!select || !meta) return;
const option = select.selectedOptions?.[0];
meta.textContent = option?.value
? `${option.value} · ${option.dataset.deviceClass || 'energy'} · ${option.dataset.stateClass || 'total'} · ${option.dataset.unit || 'kWh'}`
: tr('energy.noHaSensor');
}
function updateDeviceEnergySensorMeta() {
const select = $('#deviceDetailsForm')?.ha_energy_entity_id;
const meta = $('#deviceEnergySensorMeta');
+2
View File
@@ -46,6 +46,8 @@ async function handleWebSocketMessage(event) {
else if (message.event === 'zone.deleted') { app.zones = app.zones.filter(v => v.id !== data.id); renderAll(); scheduleControlPlanLoad(); }
else if (['group.updated', 'group.created'].includes(message.event)) { const i = app.groups.findIndex(v => v.id === data.id); if (i >= 0) app.groups[i] = data; else app.groups.push(data); renderGroups(); fillSelects(); scheduleControlPlanLoad(); }
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 (['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(); }
+9 -3
View File
@@ -23,8 +23,9 @@ function currentHistoryPath() {
if (hours !== '24') params.set('hours', hours);
if (tab === 'zones' && app.historyZone !== 'all') params.set('zone', app.historyZone);
if (tab === 'devices' && app.historyDevice !== 'all') params.set('device', app.historyDevice);
if (tab === 'energy' && app.historyEnergyDevice) params.set('device', app.historyEnergyDevice);
if (tab === 'energy' && app.historyEnergyTargets?.length) params.set('targets', app.historyEnergyTargets.join(','));
if (tab === 'energy' && app.historyEnergyInterval !== 'daily') params.set('interval', app.historyEnergyInterval);
if (tab === 'energy' && app.historyEnergyCompare !== 'none') params.set('compare', app.historyEnergyCompare);
if (tab === 'sensors' && app.historySensor !== 'all') params.set('sensor', app.historySensor);
if (tab === 'custom' && app.customChartSeries.length) params.set('chart', encodeChartSpec(app.customChartSeries));
const query = params.toString();
@@ -73,8 +74,13 @@ function applyRouteFromLocation() {
app.historyTab = HISTORY_TABS.includes(parts[1]) ? parts[1] : 'overview';
app.historyZone = params.get('zone') || 'all';
app.historyDevice = params.get('device') || 'all';
if (app.historyTab === 'energy') app.historyEnergyDevice = params.get('device') || app.historyEnergyDevice || '';
if (app.historyTab === 'energy' && ['hourly', 'daily', 'monthly'].includes(params.get('interval'))) app.historyEnergyInterval = params.get('interval');
if (app.historyTab === 'energy') {
const targetParam = params.get('targets') || params.get('device') || '';
app.historyEnergyTargets = targetParam ? targetParam.split(',').filter(Boolean).slice(0, 8) : (app.historyEnergyTargets || []);
app.historyEnergyDevice = app.historyEnergyTargets[0] || '';
if (['hourly', 'daily', 'weekly', 'monthly'].includes(params.get('interval'))) app.historyEnergyInterval = params.get('interval');
if (['none', 'previous_day', 'previous_period', 'previous_year'].includes(params.get('compare'))) app.historyEnergyCompare = params.get('compare');
}
app.historySensor = params.get('sensor') || 'all';
const hours = params.get('hours');
if (hours && ['6', '24', '168', '720', '2160', '8760'].includes(hours) && $('#historyHours')) $('#historyHours').value = hours;
+41 -19
View File
@@ -395,28 +395,50 @@ setInterval(() => { if (app.currentView === 'settings') renderSystemInfo(); }, 3
async function refreshGreeCloudRuntimeStatus() {
const account = $('#greeCloudAccountStatus');
const mqtt = $('#greeCloudMqttStatus');
if (!account || !mqtt) return;
const setText = (selector, value) => { const node = $(selector); if (node) node.textContent = value; };
const summary = $('.cloud-runtime-summary');
if (!summary) return;
const metricSelectors = [
'#greeCloudAccountStatus', '#greeCloudMqttStatus', '#greeCloudDevicesOnline', '#greeCloudRestResponseTime',
'#greeCloudResponseTime', '#greeCloudLastDeviceResponse', '#greeCloudLastMqttMessage', '#greeCloudConnectedSince',
'#greeCloudBroker', '#greeCloudTraffic',
];
const setMetric = (selector, value, visible) => {
const node = $(selector); if (!node) return 0;
const row = node.closest('div');
if (row) row.hidden = !visible;
if (visible) node.textContent = value;
return visible ? 1 : 0;
};
const lastContact = $('#greeCloudLastContact');
const lastContactRow = lastContact?.closest('small');
try {
const status = await api('/api/integrations/gree-cloud/status');
const runtime = status.runtime || {};
account.textContent = String(status.account_status || 'unknown').replaceAll('_', ' ');
mqtt.textContent = String(status.mqtt_status || 'disconnected').replaceAll('_', ' ');
setText('#greeCloudDevicesOnline', `${Number(status.online_device_count || 0)} / ${Number(status.device_count || 0)}`);
setText('#greeCloudRestResponseTime', status.last_rest_response_time_ms != null && Number.isFinite(Number(status.last_rest_response_time_ms)) ? `${Number(status.last_rest_response_time_ms)} ms` : tr('common.unavailable'));
setText('#greeCloudResponseTime', runtime.last_response_time_ms != null && Number.isFinite(Number(runtime.last_response_time_ms)) ? `${Number(runtime.last_response_time_ms)} ms` : tr('common.unavailable'));
setText('#greeCloudLastDeviceResponse', runtime.last_device_response ? dateTime(runtime.last_device_response) : tr('common.unavailable'));
setText('#greeCloudLastMqttMessage', runtime.last_mqtt_message ? dateTime(runtime.last_mqtt_message) : tr('common.unavailable'));
setText('#greeCloudConnectedSince', runtime.mqtt_connected_since ? dateTime(runtime.mqtt_connected_since) : tr('common.unavailable'));
setText('#greeCloudBroker', runtime.broker_host || tr('common.unavailable'));
setText('#greeCloudTraffic', `${Number(runtime.requests_sent || 0)} / ${Number(runtime.responses_received || 0)} / ${Number(runtime.request_timeouts || 0)}`);
if (status.last_successful_contact) setText('#greeCloudLastContact', dateTime(status.last_successful_contact));
const enabled = status.enabled === true;
const deviceCount = Number(status.device_count || 0);
const onlineCount = Number(status.online_device_count || 0);
const accountStatus = String(status.account_status || '').trim();
const mqttStatus = String(status.mqtt_status || '').trim();
const restMs = Number(status.last_rest_response_time_ms);
const responseMs = Number(runtime.last_response_time_ms);
const traffic = [Number(runtime.requests_sent || 0), Number(runtime.responses_received || 0), Number(runtime.request_timeouts || 0)];
let visibleCount = 0;
visibleCount += setMetric('#greeCloudAccountStatus', accountStatus.replaceAll('_', ' '), enabled && !!accountStatus && accountStatus !== 'disabled');
visibleCount += setMetric('#greeCloudMqttStatus', mqttStatus.replaceAll('_', ' '), enabled && !!mqttStatus && (mqttStatus === 'connected' || deviceCount > 0 || !['disconnected', 'disabled'].includes(mqttStatus)));
visibleCount += setMetric('#greeCloudDevicesOnline', `${onlineCount} / ${deviceCount}`, deviceCount > 0);
visibleCount += setMetric('#greeCloudRestResponseTime', `${restMs} ms`, Number.isFinite(restMs) && restMs >= 0);
visibleCount += setMetric('#greeCloudResponseTime', `${responseMs} ms`, Number.isFinite(responseMs) && responseMs >= 0);
visibleCount += setMetric('#greeCloudLastDeviceResponse', dateTime(runtime.last_device_response), !!runtime.last_device_response);
visibleCount += setMetric('#greeCloudLastMqttMessage', dateTime(runtime.last_mqtt_message), !!runtime.last_mqtt_message);
visibleCount += setMetric('#greeCloudConnectedSince', dateTime(runtime.mqtt_connected_since), !!runtime.mqtt_connected_since);
visibleCount += setMetric('#greeCloudBroker', runtime.broker_host || '', !!String(runtime.broker_host || '').trim());
visibleCount += setMetric('#greeCloudTraffic', traffic.join(' / '), traffic.some(value => value > 0));
summary.hidden = visibleCount === 0;
if (lastContactRow) lastContactRow.hidden = !status.last_successful_contact;
if (lastContact && status.last_successful_contact) lastContact.textContent = dateTime(status.last_successful_contact);
} catch (_) {
account.textContent = 'unknown';
mqtt.textContent = 'disconnected';
['#greeCloudDevicesOnline', '#greeCloudRestResponseTime', '#greeCloudResponseTime', '#greeCloudLastDeviceResponse', '#greeCloudLastMqttMessage', '#greeCloudConnectedSince', '#greeCloudBroker', '#greeCloudTraffic'].forEach(selector => setText(selector, tr('common.unavailable')));
metricSelectors.forEach(selector => setMetric(selector, '', false));
summary.hidden = true;
if (lastContactRow) lastContactRow.hidden = true;
}
}
+5 -3
View File
@@ -321,8 +321,9 @@ 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 === 'historyEnergyDeviceSelect') { app.historyEnergyDevice = target.value; updateBrowserUrl(currentHistoryPath()); loadHistory(); }
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.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(); }
else if (target.name === 'influx_version') updateInfluxFields();
});
@@ -445,7 +446,8 @@ $('#greeCloudTestButton')?.addEventListener('click', async event => {
if (resultBox) {
resultBox.hidden = false;
resultBox.classList.add(result.ok ? 'success' : 'error');
resultBox.textContent = result.ok ? `Connected. ${Number(result.device_count || 0)} device(s) found.` : (result.message || result.status || 'Connection failed');
const message = result.ok ? `Connected. ${Number(result.device_count || 0)} device(s) found.` : (result.message || result.status || 'Connection failed');
resultBox.innerHTML = `<span>${esc(result.ok ? tr('status.online') : tr('devices.lastError'))}</span><strong>${esc(message)}</strong>`;
}
if (result.ok) {
const refreshed = await api(SETTINGS_ENDPOINTS.greeCloud);
@@ -453,7 +455,7 @@ $('#greeCloudTestButton')?.addEventListener('click', async event => {
renderSettings();
}
} catch (error) {
if (resultBox) { resultBox.hidden = false; resultBox.classList.add('error'); resultBox.textContent = error.message; }
if (resultBox) { resultBox.hidden = false; resultBox.classList.add('error'); resultBox.innerHTML = `<span>${esc(tr('devices.lastError'))}</span><strong>${esc(error.message)}</strong>`; }
} finally { button.disabled = false; }
});
+79 -45
View File
@@ -5192,16 +5192,6 @@ textarea[aria-invalid="true"] {
outline: 2px solid color-mix(in srgb, var(--danger) 13%, transparent);
}
.form-error-summary {
padding: 10px 12px;
border: 1px solid color-mix(in srgb, var(--danger) 36%, var(--line));
border-radius: 12px;
background: var(--danger-soft);
color: var(--danger);
font-size: 11px;
font-weight: 650;
line-height: 1.4;
}
.settings-form>.form-error-summary {
margin: 0 0 12px;
@@ -8621,8 +8611,7 @@ body.flow-editor-open {
background: var(--surface-muted);
}
.technical-device-grid span,
.technical-device-error span {
.technical-device-grid span {
color: var(--muted);
font-size: 9px;
font-weight: 750;
@@ -8647,23 +8636,6 @@ body.flow-editor-open {
font-size: 10px;
}
.technical-device-error {
display: grid;
gap: 4px;
padding: 9px 10px;
border: 1px solid color-mix(in srgb, var(--danger) 36%, var(--line));
border-radius: 10px;
background: var(--danger-soft);
}
.technical-device-error strong {
overflow: hidden;
color: var(--danger);
font-size: 11px;
font-weight: 650;
text-overflow: ellipsis;
white-space: nowrap;
}
.technical-device-actions {
display: flex;
@@ -8978,22 +8950,6 @@ body.flow-editor-open {
}
.config-check-result {
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: 10px;
font-size: .88rem;
font-weight: 650;
overflow-wrap: anywhere;
}
.config-check-result.success {
border-color: color-mix(in srgb, var(--accent) 48%, var(--border));
}
.config-check-result.error {
border-color: color-mix(in srgb, var(--danger) 48%, var(--border));
}
.technical-config-actions {
flex-wrap: wrap;
@@ -9494,3 +9450,81 @@ body.flow-editor-open {
grid-template-columns: 1fr;
}
}
/* Shared alerts and split/multisplit installation configuration */
.inline-alert {
display: grid;
gap: 4px;
padding: 9px 10px;
border: 1px solid var(--line);
border-radius: 10px;
background: var(--surface-muted);
overflow-wrap: anywhere;
}
.inline-alert span {
color: var(--muted);
font-size: 9px;
font-weight: 750;
text-transform: uppercase;
letter-spacing: .06em;
}
.inline-alert strong {
color: var(--text-soft);
font-size: 11px;
font-weight: 650;
}
.inline-alert.error {
border-color: color-mix(in srgb, var(--danger) 36%, var(--line));
background: var(--danger-soft);
color: var(--danger);
}
.inline-alert.error strong { color: var(--danger); }
.inline-alert.success {
border-color: color-mix(in srgb, var(--accent) 42%, var(--line));
background: color-mix(in srgb, var(--accent) 10%, var(--surface));
}
.inline-alert.info {
border-color: color-mix(in srgb, var(--info) 38%, var(--line));
background: color-mix(in srgb, var(--info) 9%, var(--surface));
}
.inline-alert.wide { grid-column: 1 / -1; }
.device-installation-summary { display: grid; gap: 8px; margin-bottom: 12px; }
.installation-summary-card,
.installation-list-row {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(220px, .8fr) auto;
align-items: center;
gap: 12px;
padding: 10px 12px;
border: 1px solid var(--line);
border-radius: 12px;
background: var(--surface-muted);
}
.installation-summary-card > div,
.installation-list-row > button:first-child { display: grid; gap: 3px; }
.installation-summary-card strong,
.installation-list-row strong { color: var(--text); }
.installation-summary-card small,
.installation-list-row small { color: var(--muted); }
.installation-list { display: grid; gap: 8px; }
.installation-list-row > button:first-child { border: 0; background: transparent; padding: 0; text-align: left; }
.installation-list-row > div { display: flex; align-items: center; justify-content: flex-end; gap: 8px; flex-wrap: wrap; }
.installation-form { border-top: 1px solid var(--line); padding-top: 14px; margin-top: 14px; }
.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; }
@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; }
}