This commit is contained in:
Mateusz Gruszczyński
2026-09-14 16:32:28 +02:00
parent c3fbc5ccc6
commit 021cbddba5
68 changed files with 7780 additions and 202 deletions
Generated
+4 -1
View File
@@ -700,7 +700,7 @@ dependencies = [
[[package]]
name = "gree-controller"
version = "0.13.10"
version = "0.14.0"
dependencies = [
"aes",
"aes-gcm",
@@ -721,12 +721,14 @@ dependencies = [
"tempfile",
"thiserror 2.0.20",
"tokio",
"tokio-rustls",
"tower-http",
"tracing",
"tracing-subscriber",
"url",
"utoipa-swagger-ui",
"uuid",
"webpki-roots",
]
[[package]]
@@ -1572,6 +1574,7 @@ version = "0.23.43"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06"
dependencies = [
"log",
"once_cell",
"ring",
"rustls-pki-types",
+3 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "gree-controller"
version = "0.13.10"
version = "0.14.0"
edition = "2021"
authors = ["GREE Controller contributors"]
description = "Standalone local GREE HVAC controller with Web UI, SQLite and Home Assistant sensor support"
@@ -20,6 +20,8 @@ libc = "0.2"
rand = "0.8"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
rusqlite = { version = "0.32", features = ["bundled"] }
webpki-roots = "1"
tokio-rustls = { version = "0.26", default-features = false, features = ["ring", "tls12", "logging"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha2 = "0.10"
+71 -60
View File
@@ -1,13 +1,18 @@
d67af429e4da9ce08e9d2f2a8472849ffbd70d135b1c5da535a076026794d04c ./.env.example
a4ec3874a2e3ab1bad28fb40bb620f7b01f64d01ad9b699306bf70ada31227db ./.gitignore
4b2973e384f48aef569a66e2da448f6a82f33adbbc8d11dd33e7c20d8552536d ./Cargo.lock
71acebe2a2ebb705089abf834eb7b2494daff9a4c93eae3f61ed6d4b0d21ef99 ./Cargo.toml
468abc4e9e6d71859f26cfe83226b971e11c4d990746e1da1ef93c9ede24c405 ./Cargo.lock
87a90bab477bda5fdb067983c1bd4ddc4189dc34c64e2f280488e54dfdf50032 ./Cargo.toml
97681dd3c632c841c2e28a9272fffe461fc778cab8de7e856abebf8c38770ff4 ./G+_integration_wip.md
19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE
4cf04e8f22fb4e2551ee5fe2fe96fc50dda615b42a0274ba87aba60a59f021fc ./README.md
818572366846c59e8915142e626e6607d0ce11d3dc5690f345b78c31c8107de6 ./README.md
140fe98aa2ebce67d89ce47b5885f9364c26dfa557e748ab6b5bc62f269e035d ./RELEASE_VALIDATION_0.14.0.md
bbe62dbfaca15f25eb215244451799e2c0a3cb9361386a9a320b8e836b522b87 ./SOURCE_MANIFEST.sha256
0d883bc1ff1b4c9bfe045d4b16ae46f6e1aab773072cf233bf9c25750951e971 ./TESTING_0.14.0.md
70852f174e604a552745d5dcf88e9cef7387fc899264417f1db278eacc796def ./TESTING_0.14.0_CLOUD_BETA.md
bb549919b96b0413152dd905aec8640bb53301f07ec7d0b1be48ccbd37936fef ./build.rs
abe109ed04cd90336e2e136fc77e9cfeefd6a9818a8c354ece08e96a5edc0730 ./docs/API.md
df6c518cbc7bb0155b4b0e5b856160627f9d1f63195b48c36bc54dc90f601767 ./docs/API.md
2e1e18fd8167dabfe2469c26e85cce62486c7cb6a502c63c6f6b0cd74d5885e0 ./docs/FLOW.md
e3da335c88fe66baaeaf9b1f33cb69b13f47da7415bda39929d86f51174e9026 ./docs/openapi.json
0416652c4421801675864f8f410fdbac7dd315b2f29df366d94ace6e49154eea ./docs/openapi.json
c65aa543d2e99f8f7d52736307b07ebd182597d80124661d53a16bb23f6f1118 ./ha-addon/.env.example
a18d2460340264d34c267fa704ee51a42e59bcc1783ee4c0dbb2a8ef42fa0f16 ./ha-addon/Dockerfile
0417c88a990c72aecf071fba64d5d9da65dcda4d396cd7565477f7a4532c8c5e ./ha-addon/README.md
@@ -21,7 +26,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
8fff53290ad57c49d124777083bbae9f9804e3af398c9d51d44ee29987e2d033 ./ha-addon/home-assistant/custom_components/gree_controller/manifest.json
e3da92df75e4361d03cddcb9e5ad228f5a9a14c85ee8d816ae7e165b4aeb545e ./ha-addon/home-assistant/custom_components/gree_controller/manifest.json
c52a484b671ce738ecc00228a1db19a5d703f69ab27530c2896a5c2ae3b4a96f ./ha-addon/home-assistant/custom_components/gree_controller/number.py
39c4309001b75abb56234f05662bc06e077054986876f1927937edbce528ec95 ./ha-addon/home-assistant/custom_components/gree_controller/select.py
cca65482e36d48035aca178121a378fe7d578d600acff267ae81a6399c0da653 ./ha-addon/home-assistant/custom_components/gree_controller/sensor.py
@@ -29,10 +34,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
7421c100b66152f3a513abd436afbae8ced4a91968ac89159431d7237f50d2c5 ./ha-addon/repository/gree-controller/CHANGELOG.md
d9135c678e6a30d7d31b49683b052d091c6cc0c8db2a16cabeba89285bdb9b20 ./ha-addon/repository/gree-controller/CHANGELOG.md
3c06bd11671452ea6472afd5543fb91a39aba73a1d2d95c7762c04f94ab64030 ./ha-addon/repository/gree-controller/DOCS.md
c3db302f38d26dc9f8caf37a1a8a07a1334cc07ae301d452f2940a5ec5915719 ./ha-addon/repository/gree-controller/README.md
3cfb0dfc0c41d91d533a889796645c3818eb8e74a8be53610da79c88c0e88efa ./ha-addon/repository/gree-controller/config.yaml
4c1ab41a2e01098b6ad549f187b47119e608d3cc752c38cf334286fae34eacd2 ./ha-addon/repository/gree-controller/config.yaml
3aae6cdae4c3aaab7786a9575e7093b7e2c5b69288167075f4b7475611691a6b ./ha-addon/repository/gree-controller/topologia-ha-vlan-gree-pl.png
0d0b42a7639b128946eec379175436fa1f150294f39d2dfd169077bfa87fd14d ./ha-addon/repository/gree-controller/topology-ha-vlan-gree-en.png
d13e4c2b993d209078342c0c86d852b300692089b377aa044f811308dd188a1e ./ha-addon/repository/gree-controller/translations/en.yaml
@@ -42,8 +47,8 @@ c123b190219556382f0e7f3058056fb233f6261f257a01eceb96a2c0bbfa12f3 ./ha-addon/run
081e27b6e43070d44f865335c30a42a5a29668eaa6a5ea5ab6967ac4447b8e06 ./ha-addon/sync-repository.sh
f9e31de3d109ead32bf531c074d46a087df398d62488caee0b46b5e0a1c027f7 ./ha_addon.md
253a0bc912786e67ea7fc92a64e4a510ad973bec343a88ccfb1f28fca3e8cf01 ./lang/README.md
a5412c879ac0896ae6f9c2d66dd861789897b8c8610098e3fb9b5462068acd74 ./lang/en.json
615d7297c5fa6228c017a7814aa17898aa990bfbde649d9c7c43b7b49d046ebe ./lang/pl.json
ca747385adf261ab4ce7d0fae51ec326d8cfffffe20b0c48ce4f6d9d1a419874 ./lang/en.json
0364575c2ba26f1e988f39ec31b9b29c9678a58e6c5ab519cd07509e9ebcee0c ./lang/pl.json
d8459024f04ca514bd8e9d6bd3af872fb942fd85a7cdd5583f4a9d28aab6faba ./make_zip.py
b14233a8987e53bbbdd6770386ba10fa166ec25c1e098275c173c544b37846fc ./presets/bedroom_window_night.json
1960841119c0b673fb2f03621b32237552fd828045304cd221401214b997c910 ./presets/device_resilience.json
@@ -84,7 +89,7 @@ fdcd9a5055d08037278b842e7ab69265345c5811f0a06867136c511d140bb191 ./presets/wind
804f22123cd3e8db0fac791826c8dd9f758fb866c8e3b5655fb6d25d259dccf1 ./presets/workday_comfort.json
01952aa92b217f8eae2493b88870e2dec595100cd15c4d561ff11ae2b936c46f ./regenerate-sha.sh
c64b1c6deeb24f20af662bfce7fa3683703914987241107ce664fa2f6d1a9dcb ./scripts/README.md
9ca0b7780699d542d6fc6fa567dc5c3f847a57dcb676b02bb83b905862a4ff73 ./scripts/api_dev_test.py
ea99c1ea4bfc63d7734f4a3b57f90501236879995e66a630f1ee57397af0d0ce ./scripts/api_dev_test.py
5bc736c7bc76ca80aaa406bb171d2aa91baf4c3aa8695dce0e09b888b6ab3146 ./scripts/common.sh
6403786610ee6d2f628193c25aee0dd058d62e904aa1a31d5f62fdaae0e94b4f ./scripts/configure-gree-network.sh
1d6e14e26e49aa9d3527f30a23668bf8d9c48b67e6628ef686c3155c012155de ./scripts/dev.sh
@@ -97,119 +102,125 @@ e00d211e3885e30d7fed1e43b44e6fdad40a67019060156c0641816a93e3365f ./scripts/netw
81345b6a0b51736bdbc98fd23199b62e4c721b4e7437e02dab7ea79b97dff29a ./scripts/service.sh
472db31959be29ba99247f7e9cc07a44bc090c538cd654ad8d16e094726037a3 ./scripts/smoke.sh
b50782b3742dfbf8a319c60571c968e93fdf8547db747c759edcffae68cb98bf ./scripts/update.sh
0da4e4fcb8e77109fd56b301472d08acbab5ad9ecfe861296153a6a5eef723b3 ./src/api.rs
98c6dd9a5f714257957e831b3d02cbb08de1cdc30dc4a98a64b7c1f5523f76d2 ./src/api.rs
3dfd1e053cbfa0d684dd88cf99b244a96a36e2ff23d2a0715b8327ab62258254 ./src/api/assets.rs
05c255919f7b3d81188fc5f1410be6a0037471dfc44e93bffb009f29a1e6a3fc ./src/api/auth.rs
9e4fc675306e111ed3db7af9822e2925d88109c31b9e309851665a975c0bd83c ./src/api/automations.rs
368cd843dcac970f1aa6800ba132a4bbc3429e8379ed7abf9878dcb0c633af40 ./src/api/configuration.rs
4bc5a8993231ea48805d93dd888b15ff0c03c0a9dd12b44866a1f7a11a12f718 ./src/api/configuration.rs
19bc141286a336ad67e99079496eb0ae5126f986ab28be62b5f97758e5f6969c ./src/api/debug_tokens.rs
e219dafc951207cb70bc7808054a0553a03fb0a948d11af2eb5a119d1340e29e ./src/api/devices.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
853e80f5adf074b629bca1d7ee1483b6be0aaf397a46a281fc58fb96ebac8cb1 ./src/api/history.rs
9f41c01c3be646f0e5853363f1abea7fcf45d4bd011a696aec5e7e80e493d2f9 ./src/api/history.rs
e8edfbb725d6de650442444b0a929ad2be0bd2c2d7d227d2b3fdd99d05c94f94 ./src/api/house.rs
21636a6beb83e7bda961ef3ee383f6da317ffaa90b89fbcefa1547beb888de2d ./src/api/integrations.rs
27e85400ccbdb0adcb8f5c1624e7187e9b649237cfb97349fcf1c0d78300540d ./src/api/integrations.rs
6bfbe5135d4f93bd8d19a707877b53ed2029fd8490b4b1954a7520888fb1d664 ./src/api/middleware.rs
45e34a447ff384d99a3c42c489ca9fa75849c74d4d246caa91c63d927de17396 ./src/api/openapi.rs
744ce18aa9e0227c6cdfb6980063d0d738d74c5865d45c20fd20026d4742bb3e ./src/api/public_settings.rs
5b29f2e5aa2c422c5829ef89c7b59cf753883aa7f60661c95ab0f95cf676fa3f ./src/api/schedules.rs
95db93b7bf6d3d93723f1ebadbf7cd9b922fbe3d98966963df1dc94001c0c0e2 ./src/api/settings.rs
45d6f9d62dddb677b382c40d529aab9b0a4f30cef98c161d76d0492b7a0531cb ./src/api/system.rs
e69df8a5f612cd37d25fbf9f8e824c5a7d655ac5a6376ec3b31addf1fe8597e2 ./src/api/settings.rs
4a02639ec2499a98f11afcc6ddd22f35f07189ab47c1e1b7a93cc1b76d674d35 ./src/api/system.rs
9118a5a3a381b010e22d646498ff56d2e05f51656789682cc4371b09c384aa98 ./src/api/websocket.rs
455c7238c9788d378e909a4fbc94d5390b67edd92e127f48b4b913ddcbe82fb0 ./src/api/zones.rs
f06014454d5062b1be17ac50d875a42c02fc778b59702ec455611265ab7de934 ./src/config.rs
e0340b0e5945d5a38a1054e549c819566d5c1ef6034ab0e4d44c68a3b9a249cf ./src/db.rs
f4feaff281163eda543bf094d0fe9158dc6662be5f76f39c81570da7ed0355e7 ./src/config.rs
d4afd8e9f47a68e32bcb12f6b599e44dc2d72a7a4d7422c647fca55b8c94b064 ./src/db.rs
6c278fc5728124ab2f53ac7789a733942a0462f397144a231cc878c8cfcf7426 ./src/db/climate.rs
be4082b142fe16ba40c5978b287b7ef808e67c13edbd8f2d6637ccad974b8847 ./src/db/configuration.rs
bc10f1a66ddcaacbb08b096a02998482da547024dd0ba3144d00f993b4351cb0 ./src/db/core_devices.rs
4b3eda9202efcf70296ae295aeb6027e5f59ff97c6c544acc0c8a221d961bd90 ./src/db/configuration.rs
4e577dda5e248b927d29fe8978013be565b69ca4212fe31a1f3334eae1b4a694 ./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
12c4f69011b22ec0af65121100e5cc717fe7bd5a139f4180d1982a9045c6918e ./src/db/tests.rs
a76938b2e49719b97f8abf40a5cce03df6740ce2e8ac138b179c761619b32ce6 ./src/db/tests.rs
f90fbb14f29a20b799b3856bdaa22812a9f0d9dfa46693f1341c160dccda8a97 ./src/db/zone_history.rs
4d2c6053586558afbca6b60fbbc9b1cd397f74f8284d7c01f3337eb42bf6d4f7 ./src/engine.rs
aef5601b127fc1dc8a5bf993027ba597b3c019dd8ced6dfe1eeb62ff0cf578a9 ./src/engine.rs
6046bdbc9c02ac74f47068229c59af69abc8bfa46cfeff6fe7ba573b493e128c ./src/engine/automations.rs
88f85acf692e12c7061e039dd7c54f101fe0b014c4a3d799150ad92eea9e2894 ./src/engine/commands.rs
0bf7fe7d28b4749e53ec25d71effbb78bc8f70c716714f838a4a2d8f20627d63 ./src/engine/commands.rs
925135985f640ba9ae0a6109d908187b659341a91451e91d27e533adfd610b76 ./src/engine/control_plan.rs
7b48a33343d519a2ff2d81e9d757eb56a0c1beacfc0b7b8ef61f49c2ad363f9a ./src/engine/deadlines.rs
8d63674f7f0923943df0b2d37f1bae839b25697afe2b34d610849f10dde9f2e1 ./src/engine/energy.rs
e350aa63f3bb14ca887eaaa7555edbeae00fb584cbcaf552cc972f0c8918f5d8 ./src/engine/groups.rs
f0c8f3384b6174771aeb5640ee1eec87f3d6d9ff79a4fbd6a35554a4eec00cfa ./src/engine/history.rs
41b7045877771b7c352328998cb45ce2433e7f9631fad3eb1a882ab4897dc31f ./src/engine/local_thermostat.rs
6280a7f5059d9737987bf2d72b19a26b3a6709ce578af1108c4215f3f47d7763 ./src/engine/ownership.rs
3d7e74a8180e14f398363af1a45b32a5e4c89f749d631afce556a1ecd5fe4e80 ./src/engine/polling.rs
f0bcd3a56b600944ccc62fa24ce20838033eeb4354f937cdb534630c1a1f5a78 ./src/engine/runtime.rs
99527adab9fea1caede9ebb24faa6a63d5cef4810c64c06306b571cf5cbbb0da ./src/engine/ownership.rs
7f975e13845aeeef3362dc815206e80a04abbd2114904a4bd0ed4538c2cb6372 ./src/engine/polling.rs
d13497acd807a74d707e85046b87fb010e13428758336f736df4a30a5ef0b32c ./src/engine/runtime.rs
c1e8de5392681ba1255e3e2d871a6ee860fb8ca987a0a8f973d9ded3977a68fb ./src/engine/schedules.rs
b0189f28d0f337dfd3ce0934da7a565e4a5535afc1c242214c26ed584b3b7840 ./src/engine/targets.rs
682c8b3956c657b655746cbd19ee97ee2f4cf953bc9de67d6d70a1b14f3dd746 ./src/engine/temperature.rs
833b72907375e75c3f42b3921ad70a8e298f5a93743bf0d0c19059aa272462d1 ./src/engine/temporary_thermostat.rs
338161bd94c868be6155efabdc63e81e9a572d559cc00493d6c69f15cc1612a3 ./src/engine/tests.rs
c89ff36c6a67bf4fbf68c43e18aa0a556de2dc69263fd79db376363afc741a4f ./src/engine/zone_actions.rs
dd38a8b8f6e910e4475c42ae41eaad2c68237fb36b78a3d7dd5d147c08487568 ./src/engine/zone_control.rs
2276aca42bca712a55de9b6c5dbe64639675c84befa85a446a7d34613596ed7e ./src/error.rs
2731f629f22a9d4fdb831ea3b283f43900b190f7e6a08fdab5b8fbb2c288547b ./src/home_assistant.rs
342dcb54caf22d2282a3f3f2297f54bd413b5242c17756276ea74994e88e28fb ./src/influxdb.rs
6db07dc9c3003c97b80d3955761329ffea9876279e7afc0644bfba8878e41209 ./src/engine/zone_control.rs
bfac95cec87f0df6ce562153cd3cfe6731359d703e6c592d2c2f7ba01ea62d99 ./src/error.rs
ea553a1d8b44c74b300d7c4a76dcd3b370524eb57766730a1376d1ab3079913b ./src/home_assistant.rs
b43d95ac42f5edc1a38323e47626b6c0ec6cbbda3c30ad6f245d23865cd1993e ./src/influxdb.rs
da4068295f37c23222bb13bebac022d88ba7729604a49ad25438c55e45458a49 ./src/influxdb/codec.rs
1769e2a96e336bf39b28ae9de02a5d942350afa2bb9702df7603fabe9d7fcba7 ./src/influxdb/query.rs
29dc8ed02dfa1f6419bf4aedfb99c805b25c8a2e772efef20681c750570928cf ./src/influxdb/write.rs
9ec1bc25ebd06cc925e4fddc8b49e189a21ae495c6709c8cb1bba5b4dfa35388 ./src/main.rs
67dad0bfd1e5165cacf9049a49c8038f78a75edfc0a825bc2627b5336ad9ce23 ./src/influxdb/query.rs
6a158adeb20ec8d66d88f43a05f8a101cf001a8c79951333c1993948f0f306a3 ./src/influxdb/write.rs
059346de38c710fadf301b467c0eb45dcc2f764561b868a155c2e089a5027422 ./src/main.rs
23688a5cb68efded89471ce959328d27adff370d451bd9f378a283ab51437e76 ./src/models.rs
920e028d96a14cca9f74a964bb296e68a109cfead314d3164c180201e606d151 ./src/models/automation.rs
60cac63e6cb41d835e35cc654363f6ffd4386a79f562df0e656b69034acfbac2 ./src/models/control_plan.rs
9cd1904a0f822c557f6ad4576cdb5f96d9668d04e79ea9783885c9f39519752e ./src/models/defaults.rs
fa8ddca35171680dd9afb3bc46236e0a4922a9bbbf3e8e9939991973281534d3 ./src/models/device.rs
f1bd2ae1acdc342f9310d01502f478f74c43a7e3b1d284ca55f97900a151b7cd ./src/models/defaults.rs
edcd031427ca903ef640e0c6b0598e28b22dcf9a0c045d13033f4395955f8ffe ./src/models/device.rs
aeab5b5acf35d38a54fd1d82e964fe56635ade69c0149ad423df317095d01555 ./src/models/flow.rs
cf63af00d98410a1452f205ce00b8a6c5f0f3525b994cba3e971d631db288396 ./src/models/history.rs
e1621255217ee6128151cc898373dd404123cb6d54017beec6c7e3e5c3030486 ./src/models/integrations.rs
8294749dd3af524925309c51ad641ffd32ff1fd9c164cfa71660aa88c2d13d6e ./src/models/runtime.rs
41eafa63499dd800b499167ddaedce7a6fc43751734aaca789fcbe2fb992c668 ./src/models/settings_api.rs
b10abd201d84f88bf6d71e6ed24a47946b67ef9de8dd0cda0608eeb663b7b417 ./src/models/history.rs
14e053b0dbea9ed7e88e8affaa595fa126ff833c18696fa695e923488ec40d70 ./src/models/integrations.rs
75ad8aa44c1b4d63e9f203c49067fe19f7d8c5ed7cd24a9fc446de06cda61f1f ./src/models/runtime.rs
2811ada38a132eb78ad998b64e8cf318ac816efa4731147213b41a5ded401ff9 ./src/models/settings_api.rs
c4de6101de85d9d7e6814b2504b87d9216561e3d9e274054f081f505fb152140 ./src/models/temporary_thermostat.rs
7d798228a8d4d4e60a112167c001535f4b8a5fa32ec7a33554c73125c42256c2 ./src/models/zone.rs
62e696df8b86835df7ad127f03c257a78580769dc82bc66daa5041f71e8440a5 ./src/notifications.rs
cca742bb2b500eb9269f69385fcf88053cd08aabf6a98b7669b8a7a2fd67df13 ./src/protocol/crypto.rs
28969da06a454ddd1ebea5fa606e8446a83dfd21e21352767c189013c508cb90 ./src/protocol/gree.rs
cd0612021f8ed6c9e0823d63883c0790db09aa485583f3f3fc9f2579f2c28ea3 ./src/protocol/gree.rs
7907c33e6396c7e0d61705b735b21c84d6ed784012fb61022b872c6745540f1d ./src/protocol/gree/binding.rs
d71771855d5ac90fae4994debf53ed09472b216c17bb2155d80aa2de13c57e18 ./src/protocol/gree/commands.rs
4e419ba3b86ca9d154e8c04445cf005965175c54041093b2daad3666d4d852bd ./src/protocol/gree/core.rs
87d8d9686a416413489d34cb4fc2e5ac4c05f7e1e7e5bc362e9351c06c89fa51 ./src/protocol/gree/discovery.rs
05fa2e4ec133a61c23a3f4ab2ce792bbfab93feaea9f0ce0d4ccc67fc479833d ./src/protocol/gree/discovery.rs
0ab094f609edb99715402d8eeaa7e38cbfd2d271036aec787dcbb3be3e005720 ./src/protocol/gree/merge.rs
c09c931fa8d512a8883fb14f3142be694af3c51290e023d25c7c972e156e4bd2 ./src/protocol/gree/network.rs
0a901ff258a74072af3812c5417196da1a9b8daa8a2eff30c96d038558601739 ./src/protocol/gree/polling.rs
50152bcad4a843ae04000b991a2365abb302fc1583ea69f09320e25740f3c38c ./src/protocol/gree/tests.rs
3c93e5b254a2a40a07854532cb5fc07e493ad65d9ba2cf28093cff4a4a4e451c ./src/protocol/gree/transport.rs
923bb4b2d342d6c1870474e43f33b0aba16dc88590c561d765b874412e83df1f ./src/protocol/mod.rs
49dcca1e4a4ad858b312386e3b729f36da691916d8fe62ad682ddb86d40695b7 ./src/protocol/gree_cloud.rs
497f2f77c96f73069206fc233639be191f86d7b1bc6bb416495026c100e45f7a ./src/protocol/gree_cloud_mqtt.rs
6258ee3888a5f9a4f8bb6cfbbac97f2621de07f9da6bcb2fa596d452f47f2e49 ./src/protocol/mod.rs
53bbc096ff5275a26afac30e7295dd19f9ec53dffc5cf08ab5ecd1b64481f25c ./src/provider.rs
830621fa75265da7288b8135704c0ce27e0687f87d55abf3cdbe40a6e2f3d2b1 ./src/queries.rs
7fac65a9dcbcecee09816f5447c7792dc94f736b1efb66ac85629db75f6280be ./src/queries/device_history.rs
3fb50f57d30ac23ef122c45dae4bd96c7cb7184df02d99aef00d9890b66479e2 ./src/queries/entities.rs
917e13be9db10ea629bd99fe6c62a869c31609780702cc6a44e787b78a62e03a ./src/queries/entities.rs
2c5fba462b72158b04dbaaa53c8536263f8d72a1338a6c4ff06cb28a36f6ae27 ./src/queries/ha_history.rs
3112452367b68aeda0a5e3143b1a51ce62e3159859fb41e92c35642a3cd72322 ./src/queries/maintenance.rs
3a49bad74a2f047893e726b5e00a6e833c228ad268bdf50cd60f786ba164ea54 ./src/queries/schema.rs
776ed66e363289e188b04a194caaa476ccdf7045b61485792ffe3973caf0bf4d ./src/queries/schema.rs
f13f1be3d5789539eba3fc1b59c14835f8c681eca7634e118fa3763a53aeee2b ./src/queries/zone_history.rs
7af6db4d826f8cb5f2305f743bffe4e112d4e1c1772366a83ba75953e60db1c3 ./src/state.rs
7e7037a059b585c5709367b9431a225789e7039796e3ed19fc132776ac234099 ./src/state.rs
b92a6cb158b494fe145b43c7641e65f6fafff47201d7d76edbec2cfd8b94835c ./systemd/gree-controller.service
544a31cb2e5374227026afd6abd0d910381f3fca1638154ebd74e3c3d96a35d1 ./web/404.html
e98bdd7204349cce1ec6f57283509697af0bbc72280622a6c3efa6fed242db4f ./web/favicon.svg
2808c2152a3dc56d80c158980f2a00d73e078b16731bdff240a62431ca95dea9 ./web/index.html
d48f4adab24f6524915a179b58abdc64659195ac6d4236e55a48a4eac0eda402 ./web/index.html
9ba3f6fa05b72aa989139b2a909982571b2a02055052e4c40104f1e81a9aa7eb ./web/js/README.md
f272912f6e5d8b34786827d78ba592513e3ca1e85e07e4c073089c5bbb75f282 ./web/js/bootstrap.js
badde8a5214a0322b2c45803c02a1cb723d53b25e57c687369cd804f509d9744 ./web/js/charts.js
ff427103d12cb99977cf1b32e6978506b6ee5179a10568b13673292c13ae3b65 ./web/js/core.js
06c8ab1c7e5c0e57ada4e2530d69a379a037678412e1e52268c2aaf8d17b2243 ./web/js/bootstrap.js
5dae06e1193685b7dec29234aa1ae0a29bc6f91cf9fa42370a7fd0d6fdef4e32 ./web/js/charts.js
87598f48469a6dd17e526420d552652c9405632b285d566691b010b59e99043e ./web/js/core.js
d880ffdac6aa07cd10d79fbacf12f87eb4c69b48a80564bbfdd0248abe8884d5 ./web/js/dashboard.js
6c0d310e39a1e1f618a31fbdf5560a850023bf2219d897c69d636d0179dff004 ./web/js/entities.js
0a23ac65a4a0bd113a9f78fe1d25621d6a178e10aaca53e5df8df87587998cb4 ./web/js/events.js
fcaa6b8d863bc93bc9b7350329d261b2fc98877eb5c9a323d66aa809dd4ef16b ./web/js/entities.js
dada07699ba93efee918e7b1f90f3a326a381fe4c85a63d2811448a048878258 ./web/js/events.js
2e822a00b5a6941b6a251eaf2c54ca95a53399b5d1056f3d8240e11f82d6065f ./web/js/flows.js
cf1ebf3b9d666f309a1add070b4da86be86d990b02c8d6474f6d121c6d332746 ./web/js/forms.js
dbf36223863ba882c03eccea4516ba8db7acf0c2e72c6c64490cb994f1e20f96 ./web/js/history.js
7d8c2aedcf97196f081367de0933af13c2bc2e531d59e7464760b6d64c895004 ./web/js/history.js
0dc879c0056251618633c9efd188fc1593eeae6965ad1bc993132dea6e171b44 ./web/js/main.js
733fcc595ed9c03c9639acc6caba46f0588f29e0b95bf72891e34443e7aa9db8 ./web/js/navigation.js
e0a6b5d2900f5524dba395b97288f8d75976e31b27e26810d42febbfd723085c ./web/js/realtime.js
c8c82e88c3715b1bfabf155e36266a4c94f5e2fc03eb39dcdd9d08a1985a997c ./web/js/router.js
25f5c9a0aa8622f419b05f64712c9eca4896f0e79818616904b83051fd77b07b ./web/js/settings-ui.js
4aadfdf107593d32ab73514688f225bf559fd5ce6bbf9d9459a8098daf1fbe3d ./web/js/settings.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
3815fea7ae1694568c2f249bc8460e0563ced6bde89214f74f21b41fe8cad9a6 ./web/lang-init.js
6b653e4fe2d4db4ffae6ff8f39b8ce57a10a990dc0000ee4aae51d949afadd52 ./web/manifest.webmanifest
82396746ec3d334d4909d65276d2074da897cf7f8d4e9b8c04ea0747fd4b7cd3 ./web/styles.css
48d03c265a597af4fc11ae8103eb7b6cb3e5932307783f5287c67781f702b800 ./web/styles.css
fefdf12cbbb0a5d8a2cb89275cae28455aa42aaf179542305cc8f5e0f4825898 ./web/sw.js
d505d793ce7cc9485b45b78bba1c0d51887adc7451ab59a42702946e5b991382 ./web/theme-init.js
+42 -6
View File
@@ -2,13 +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.
**Current release: 0.13.10**
**Version: 0.14.0**
Release 0.13.10 improves compatibility with GREE units that report optional feature fields as multi-state values instead of strict 0/1 flags. In particular, `Quiet=2` or `Quiet=3` no longer invalidates the whole status response, and the controller remembers the active Quiet encoding reported by the unit while keeping `Quiet=1` as the legacy default. Existing AES-ECB/AES-GCM behavior and API contracts remain unchanged.
Release 0.13.9 fixes dashboard thermostat card sizing when local thermostat or manual device control is active. Cards now keep their intrinsic height instead of stretching every thermostat in the same grid row; API contracts are unchanged.
Release 0.13.6 adds a dedicated notification toggle for GREE vs Home Assistant room-temperature discrepancies. Disabling this alert suppresses `zone.sensor_discrepancy` notifications only; sensor fallback and thermostat safety behavior are unchanged.
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.
> [Full API reference](docs/API.md) — authentication, every endpoint, request bodies, response models, WebSocket events and examples.
@@ -143,6 +139,46 @@ Environment values explicitly supplied for supported runtime overrides win over
Additional environment variables cover history retention, debug, night mode, Home Assistant and InfluxDB. See [`.env.example`](.env.example).
## Local vs GREE Cloud
Each device has an explicit `connection_type`:
- **Local** uses the existing GREE LAN protocol over UDP/7000, including discovery, bind, status and commands. It never calls GREE Cloud.
- **GREE Cloud** uses the configured account plus MQTT/TLS. It never performs UDP discovery, bind, probe or commands.
The same physical air conditioner may intentionally be added twice, once as Local and once as GREE Cloud. The controller does not automatically fail over between transports, preventing duplicate commands.
## GREE Cloud setup
Open **Settings → GREE Cloud**, enable the provider, select the same region used by the official GREE app, enter the account login/email and password, then use **Test connection**. After a successful test, use **Refresh devices** and explicitly add the units you want to control through Cloud. Credentials are account-level settings; they are not copied into every device.
The password field is write-only from the UI: a saved password is reported only as configured/not configured. The installation UUID used by the GREE Cloud User-Agent is generated once and persisted.
## Cloud status
Cloud devices distinguish `online`, `offline`, `cloud_disconnected`, `authentication_error` and `unknown`. Account/MQTT state is shown separately so an offline air conditioner is not confused with a broker, credential or internet failure. Device diagnostics include safe broker/topic/status information and decrypted/sanitized properties, never authentication tokens or cipher keys.
## Cloud polling and synchronization
MQTT push is the normal synchronization path. REST login is reused for the MQTT session and is not repeated for every status request. Polling is used for initial state, recovery, fallback and periodic verification; the configurable Cloud interval is clamped to at least 30 seconds. MQTT reconnect uses bounded backoff and restored subscriptions. A Cloud outage does not switch a device to LAN and does not block Local polling/control.
## Energy
Energy can come from either **GREE Cloud** or a cumulative Home Assistant energy sensor. Devices with both sources can select **Auto**, **GREE Cloud** or **Home Assistant**. Home Assistant candidates are limited to `device_class=energy`, `state_class=total|total_increasing` and `Wh`/`kWh` units.
GREE `ElcAll` is a cumulative counter in tenths of a kWh and is normalized to kWh. Cumulative counters are converted to per-sample consumption deltas before history aggregation. The first sample establishes a baseline; duplicate samples consume zero; a falling/reset counter creates a new baseline; negative consumption is never stored. Energy history is separate from temperature history and can be aggregated hourly, daily, weekly or monthly, with Today, Yesterday, Current month, Previous month and Period total summaries. Older energy samples can be archived to InfluxDB using the same retention policy as other metrics.
## GREE Cloud security
Cloud REST and MQTT use TLS with certificate/hostname validation enabled. Passwords, REST tokens, MQTT credentials, Authorization headers and device cipher keys are excluded from normal API responses, Cloud diagnostics and logs. Network, connect and command operations have bounded timeouts; retries/reconnects are rate-limited rather than tight-looped.
## Limitations
- GREE Cloud availability and behavior depend on GREE's external services and the selected account region.
- The Cloud status protocol does not expose a universal buzzer capability. When **suppress device beep** is enabled, Cloud commands follow `greeclimate` semantics and include `Buzzer_ON_OFF=1`; models that ignore this command-only field simply continue without a separately advertised buzzer capability.
- Capability detection is based on discovery/model data plus properties actually returned by the device. Controls are hidden or rejected when support is known to be absent.
- There is intentionally no automatic Local↔Cloud fallback.
## Connecting physical GREE units
1. Place the controller host on a network that can reach the air-conditioner Wi-Fi modules by UDP.
+225
View File
@@ -0,0 +1,225 @@
d67af429e4da9ce08e9d2f2a8472849ffbd70d135b1c5da535a076026794d04c ./.env.example
a4ec3874a2e3ab1bad28fb40bb620f7b01f64d01ad9b699306bf70ada31227db ./.gitignore
468abc4e9e6d71859f26cfe83226b971e11c4d990746e1da1ef93c9ede24c405 ./Cargo.lock
87a90bab477bda5fdb067983c1bd4ddc4189dc34c64e2f280488e54dfdf50032 ./Cargo.toml
97681dd3c632c841c2e28a9272fffe461fc778cab8de7e856abebf8c38770ff4 ./G+_integration_wip.md
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
bb549919b96b0413152dd905aec8640bb53301f07ec7d0b1be48ccbd37936fef ./build.rs
df6c518cbc7bb0155b4b0e5b856160627f9d1f63195b48c36bc54dc90f601767 ./docs/API.md
2e1e18fd8167dabfe2469c26e85cce62486c7cb6a502c63c6f6b0cd74d5885e0 ./docs/FLOW.md
0416652c4421801675864f8f410fdbac7dd315b2f29df366d94ace6e49154eea ./docs/openapi.json
c65aa543d2e99f8f7d52736307b07ebd182597d80124661d53a16bb23f6f1118 ./ha-addon/.env.example
a18d2460340264d34c267fa704ee51a42e59bcc1783ee4c0dbb2a8ef42fa0f16 ./ha-addon/Dockerfile
0417c88a990c72aecf071fba64d5d9da65dcda4d396cd7565477f7a4532c8c5e ./ha-addon/README.md
cd7fd4b937a1d6138699f7effd184e3c077912b881e9c73c37b90bbafba68477 ./ha-addon/build.sh
fd041032f353ceb0a2b02005f5a78d74a460b12e4e78de13dcb60129859f8c4e ./ha-addon/home-assistant/README.md
f8e8559fe10fe523ac5bc9aac25c6e26e862f679d502e8f3c39f38a0a8e40911 ./ha-addon/home-assistant/custom_components/gree_controller/__init__.py
3f6ef15ef58456376ac53fde7cace1ef359d6a6f7a64c5b575ad77ea6e55ccc4 ./ha-addon/home-assistant/custom_components/gree_controller/api.py
021a235617852c3adc4990936c3daf497309ea9a18574223805ae7496b1366c3 ./ha-addon/home-assistant/custom_components/gree_controller/climate.py
5e4aef2143e81bedb5a15dd4be5c71b64a3ab448ec6d33a20851edd098e5f529 ./ha-addon/home-assistant/custom_components/gree_controller/config_flow.py
b7f0873109c52be9d7f09bea3dffc416103c50085e1f0680d11661a969479898 ./ha-addon/home-assistant/custom_components/gree_controller/const.py
41a8958a6fe6d10e5679c95ac34832583a2a15ecfd67d09b4e094a83d06f5c47 ./ha-addon/home-assistant/custom_components/gree_controller/coordinator.py
5a96fe8f5c035c34f1339370270cd078056202d09e236dec75735be11de92a7d ./ha-addon/home-assistant/custom_components/gree_controller/entity_map.py
c4fb75c246db651087900ebfc2291ff41ac87652cd6194fc0b776b0005c1cbcf ./ha-addon/home-assistant/custom_components/gree_controller/icon.png
e3da92df75e4361d03cddcb9e5ad228f5a9a14c85ee8d816ae7e165b4aeb545e ./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
338a42662e77f91215150851e55bbd39a5c77f5612cc77f3e037c06a6e6bdadb ./ha-addon/home-assistant/custom_components/gree_controller/switch.py
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
3c06bd11671452ea6472afd5543fb91a39aba73a1d2d95c7762c04f94ab64030 ./ha-addon/repository/gree-controller/DOCS.md
c3db302f38d26dc9f8caf37a1a8a07a1334cc07ae301d452f2940a5ec5915719 ./ha-addon/repository/gree-controller/README.md
4c1ab41a2e01098b6ad549f187b47119e608d3cc752c38cf334286fae34eacd2 ./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
9cf50879d7ea32cd3ff7ee95a6d50c4f1eab727420bc6610282931a9117e7dde ./ha-addon/repository/gree-controller/translations/pl.yaml
2153b230357aab4766398577e23a7a9ff83bb135ddc86d1b3f2cbfc852efe0a1 ./ha-addon/repository/repository.yaml
c123b190219556382f0e7f3058056fb233f6261f257a01eceb96a2c0bbfa12f3 ./ha-addon/run.sh
081e27b6e43070d44f865335c30a42a5a29668eaa6a5ea5ab6967ac4447b8e06 ./ha-addon/sync-repository.sh
f9e31de3d109ead32bf531c074d46a087df398d62488caee0b46b5e0a1c027f7 ./ha_addon.md
253a0bc912786e67ea7fc92a64e4a510ad973bec343a88ccfb1f28fca3e8cf01 ./lang/README.md
ca747385adf261ab4ce7d0fae51ec326d8cfffffe20b0c48ce4f6d9d1a419874 ./lang/en.json
0364575c2ba26f1e988f39ec31b9b29c9678a58e6c5ab519cd07509e9ebcee0c ./lang/pl.json
d8459024f04ca514bd8e9d6bd3af872fb942fd85a7cdd5583f4a9d28aab6faba ./make_zip.py
b14233a8987e53bbbdd6770386ba10fa166ec25c1e098275c173c544b37846fc ./presets/bedroom_window_night.json
1960841119c0b673fb2f03621b32237552fd828045304cd221401214b997c910 ./presets/device_resilience.json
c5276fecc6a33c5de912e5a73b0a829d4e18c18d57b9c0ee49179ef96f133fc7 ./presets/dual_threshold_control.json
aa190f51cb20bd0f3749c080e39b4182d8d843db51d9bf7c2315528f67b45a7e ./presets/energy_price_eco.json
19d001dc0703dccf9c9ba3c78d866afafae94591ddb6d0d9e2d8c7703eabeea2 ./presets/frost_guard.json
a86718e22f79769b40b363f32ec669bf77974e6a6ade081ed4b90792d5803ef7 ./presets/ha_attribute_mode_guard.json
cc0d35fb5ad4bd52f1a8a122745bbda19922119cec48338f391f8c0619dab896 ./presets/ha_boiler_supply_boost.json
515b2580221fb38975c7f1ac46f267af7d8ac418efd52d30b40d5ce3e4fae1d2 ./presets/ha_external_heat_source_assist.json
07907f47c6c5c8dbd142b161135eae123889fb8845cd846fa7d1b05eda7b0740 ./presets/ha_external_heat_source_off.json
349df3ee451da70958529bf6c8e8c09e717a26f69fb56e9d6b73831087055966 ./presets/ha_gas_backup_heat.json
6fddede93fcb97913139791e6a75fb62322f425c59365b48bd8bb32a48c4ddee ./presets/ha_gas_heating_boost.json
40e6bb0a22e29cc9e32844b90eafffb753df4c368ab55b5112c5bf25593491da ./presets/ha_gas_heating_off.json
b6dce178fb517509f31e3ce58f6fa512d038ffcea98fae10dd41844b1d954144 ./presets/ha_gas_heating_reduce.json
eb95423754fc798b0b5db01757f0fbe97f07bae9f16a95f5ca51ce379809ab7a ./presets/ha_heating_demand_follow.json
0e34f595cebea8662646293b75e7422ac2b01c92fe75f152d71b13f6c8fb53d1 ./presets/ha_thermostat_idle_fallback.json
e8946176bf86975c04ef40e82565d7f7a4c171bcf8b245c001121ed8aae938c9 ./presets/ha_window_guard.json
a87a919d73003f774becc2d48aab646c21af43e50ceb54577cd37f26606a1674 ./presets/humidity_guard.json
e78a5a9c02bc47fa6530aad3803581b10b3a0676705cb41f9d003b4da455aa14 ./presets/mild_weather_eco.json
c3c10990e992948c2792bb71fa6606e616abb2a5455e1f11d9209c2f05132b86 ./presets/morning_boost.json
664afa80d27130799899e54f5a2152a27955e6e2d8c035c2a594c8da6bd902a9 ./presets/multi_room_group_guard.json
7518e0c694208c6ff69209edf8eec605fcf9e9abe8130d092c8c89524d7a24b3 ./presets/nested_guard.json
863e557fe9c798a5aaa175f29d71752e148bb8d49910485d738bba00671d1198 ./presets/night_group.json
c675cf84430f3c2d058c69dbfea1b5c6fcd82e927d53a301b3e517409a324f9a ./presets/night_quiet.json
3b87def6c5fdd7f50cd9e6003bd8e0628b1365e1de8bbc414d5a65d1d7d76298 ./presets/occupancy_weather_matrix.json
2360e1939cb46352042bd914a1a125fc98252ea80b68c89cc6ce3f5f197cc464 ./presets/offline_safe_off.json
0f00f10c2d11984827738cd2910aa24910acbc8f36e754627a76fd5a0aeef7eb ./presets/overheat_guard.json
f118ab957389bc4a47fe8c36b2911fb717a23970ec97d14c37a65f65eb479fc5 ./presets/peak_power_guard.json
dfb2f5f382a7f83b7a6fe475a26bae73a4510a46e1e7949a99ac752e39f58b4b ./presets/presence_eco.json
8e40d01a07911455c3c6607ba3435488fd6e520bebfc84cbf5eaad481883a4ed ./presets/sensor_availability_guard.json
45183d75ce254315cd4032acd42fbb0d505df0e7ab1fefecad931a74db2f43c4 ./presets/sleep_temperature_guard.json
7e819f2976ff48d89ccb636d08439d6e9736e7e95837c150dca157fb5bc78cb1 ./presets/smart_demand.json
6d16f0a35b023afccd0f2011056ed8b15b5e5d2a3be6a52d122733aac862e85d ./presets/thermostat_enabled_guard.json
fffa18bd989a0dcc1c5458975522517d40af4af40cc1ca30640e79d3db711053 ./presets/unoccupied_shutdown.json
993b8e8151647cbda6e62312266d37d89178b3a4bb13f7df0950840e1ce647a8 ./presets/weather_comfort.json
ae21459a261712bcb8d57594528b1648432e8d03a8a38502234829c0dbfef774 ./presets/weekend_comfort.json
fdcd9a5055d08037278b842e7ab69265345c5811f0a06867136c511d140bb191 ./presets/window_available_guard.json
804f22123cd3e8db0fac791826c8dd9f758fb866c8e3b5655fb6d25d259dccf1 ./presets/workday_comfort.json
01952aa92b217f8eae2493b88870e2dec595100cd15c4d561ff11ae2b936c46f ./regenerate-sha.sh
c64b1c6deeb24f20af662bfce7fa3683703914987241107ce664fa2f6d1a9dcb ./scripts/README.md
ea99c1ea4bfc63d7734f4a3b57f90501236879995e66a630f1ee57397af0d0ce ./scripts/api_dev_test.py
5bc736c7bc76ca80aaa406bb171d2aa91baf4c3aa8695dce0e09b888b6ab3146 ./scripts/common.sh
6403786610ee6d2f628193c25aee0dd058d62e904aa1a31d5f62fdaae0e94b4f ./scripts/configure-gree-network.sh
1d6e14e26e49aa9d3527f30a23668bf8d9c48b67e6628ef686c3155c012155de ./scripts/dev.sh
3fe88e64e43d380c56955f577992ea5b0252c05280f6e9dc3cd0bbcc0bf00896 ./scripts/generate_ha_migration.py
e4849261fd9ed1f01df96c0637c439c0c4eff8fa317b2918026167bba343af79 ./scripts/install-lxc.sh
bb7cd2c5b27c9dceec1d1d2846fbad9600df9c08e0ad07d13fcde97af533091c ./scripts/install.sh
1a84c91a89bf7f60aa4816a7650640f315efadf39bf7b54dc20f1259b2e2d5ce ./scripts/live_realtime_test.js
e00d211e3885e30d7fed1e43b44e6fdad40a67019060156c0641816a93e3365f ./scripts/network-debug.sh
01952aa92b217f8eae2493b88870e2dec595100cd15c4d561ff11ae2b936c46f ./scripts/regenerate-sha.sh
81345b6a0b51736bdbc98fd23199b62e4c721b4e7437e02dab7ea79b97dff29a ./scripts/service.sh
472db31959be29ba99247f7e9cc07a44bc090c538cd654ad8d16e094726037a3 ./scripts/smoke.sh
b50782b3742dfbf8a319c60571c968e93fdf8547db747c759edcffae68cb98bf ./scripts/update.sh
98c6dd9a5f714257957e831b3d02cbb08de1cdc30dc4a98a64b7c1f5523f76d2 ./src/api.rs
3dfd1e053cbfa0d684dd88cf99b244a96a36e2ff23d2a0715b8327ab62258254 ./src/api/assets.rs
05c255919f7b3d81188fc5f1410be6a0037471dfc44e93bffb009f29a1e6a3fc ./src/api/auth.rs
9e4fc675306e111ed3db7af9822e2925d88109c31b9e309851665a975c0bd83c ./src/api/automations.rs
4bc5a8993231ea48805d93dd888b15ff0c03c0a9dd12b44866a1f7a11a12f718 ./src/api/configuration.rs
19bc141286a336ad67e99079496eb0ae5126f986ab28be62b5f97758e5f6969c ./src/api/debug_tokens.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
e8edfbb725d6de650442444b0a929ad2be0bd2c2d7d227d2b3fdd99d05c94f94 ./src/api/house.rs
27e85400ccbdb0adcb8f5c1624e7187e9b649237cfb97349fcf1c0d78300540d ./src/api/integrations.rs
6bfbe5135d4f93bd8d19a707877b53ed2029fd8490b4b1954a7520888fb1d664 ./src/api/middleware.rs
45e34a447ff384d99a3c42c489ca9fa75849c74d4d246caa91c63d927de17396 ./src/api/openapi.rs
744ce18aa9e0227c6cdfb6980063d0d738d74c5865d45c20fd20026d4742bb3e ./src/api/public_settings.rs
5b29f2e5aa2c422c5829ef89c7b59cf753883aa7f60661c95ab0f95cf676fa3f ./src/api/schedules.rs
e69df8a5f612cd37d25fbf9f8e824c5a7d655ac5a6376ec3b31addf1fe8597e2 ./src/api/settings.rs
4a02639ec2499a98f11afcc6ddd22f35f07189ab47c1e1b7a93cc1b76d674d35 ./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
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
a76938b2e49719b97f8abf40a5cce03df6740ce2e8ac138b179c761619b32ce6 ./src/db/tests.rs
f90fbb14f29a20b799b3856bdaa22812a9f0d9dfa46693f1341c160dccda8a97 ./src/db/zone_history.rs
aef5601b127fc1dc8a5bf993027ba597b3c019dd8ced6dfe1eeb62ff0cf578a9 ./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
e350aa63f3bb14ca887eaaa7555edbeae00fb584cbcaf552cc972f0c8918f5d8 ./src/engine/groups.rs
f0c8f3384b6174771aeb5640ee1eec87f3d6d9ff79a4fbd6a35554a4eec00cfa ./src/engine/history.rs
41b7045877771b7c352328998cb45ce2433e7f9631fad3eb1a882ab4897dc31f ./src/engine/local_thermostat.rs
99527adab9fea1caede9ebb24faa6a63d5cef4810c64c06306b571cf5cbbb0da ./src/engine/ownership.rs
7f975e13845aeeef3362dc815206e80a04abbd2114904a4bd0ed4538c2cb6372 ./src/engine/polling.rs
d13497acd807a74d707e85046b87fb010e13428758336f736df4a30a5ef0b32c ./src/engine/runtime.rs
c1e8de5392681ba1255e3e2d871a6ee860fb8ca987a0a8f973d9ded3977a68fb ./src/engine/schedules.rs
b0189f28d0f337dfd3ce0934da7a565e4a5535afc1c242214c26ed584b3b7840 ./src/engine/targets.rs
682c8b3956c657b655746cbd19ee97ee2f4cf953bc9de67d6d70a1b14f3dd746 ./src/engine/temperature.rs
833b72907375e75c3f42b3921ad70a8e298f5a93743bf0d0c19059aa272462d1 ./src/engine/temporary_thermostat.rs
338161bd94c868be6155efabdc63e81e9a572d559cc00493d6c69f15cc1612a3 ./src/engine/tests.rs
c89ff36c6a67bf4fbf68c43e18aa0a556de2dc69263fd79db376363afc741a4f ./src/engine/zone_actions.rs
6db07dc9c3003c97b80d3955761329ffea9876279e7afc0644bfba8878e41209 ./src/engine/zone_control.rs
bfac95cec87f0df6ce562153cd3cfe6731359d703e6c592d2c2f7ba01ea62d99 ./src/error.rs
ea553a1d8b44c74b300d7c4a76dcd3b370524eb57766730a1376d1ab3079913b ./src/home_assistant.rs
b43d95ac42f5edc1a38323e47626b6c0ec6cbbda3c30ad6f245d23865cd1993e ./src/influxdb.rs
da4068295f37c23222bb13bebac022d88ba7729604a49ad25438c55e45458a49 ./src/influxdb/codec.rs
67dad0bfd1e5165cacf9049a49c8038f78a75edfc0a825bc2627b5336ad9ce23 ./src/influxdb/query.rs
6a158adeb20ec8d66d88f43a05f8a101cf001a8c79951333c1993948f0f306a3 ./src/influxdb/write.rs
059346de38c710fadf301b467c0eb45dcc2f764561b868a155c2e089a5027422 ./src/main.rs
23688a5cb68efded89471ce959328d27adff370d451bd9f378a283ab51437e76 ./src/models.rs
920e028d96a14cca9f74a964bb296e68a109cfead314d3164c180201e606d151 ./src/models/automation.rs
60cac63e6cb41d835e35cc654363f6ffd4386a79f562df0e656b69034acfbac2 ./src/models/control_plan.rs
f1bd2ae1acdc342f9310d01502f478f74c43a7e3b1d284ca55f97900a151b7cd ./src/models/defaults.rs
edcd031427ca903ef640e0c6b0598e28b22dcf9a0c045d13033f4395955f8ffe ./src/models/device.rs
aeab5b5acf35d38a54fd1d82e964fe56635ade69c0149ad423df317095d01555 ./src/models/flow.rs
b10abd201d84f88bf6d71e6ed24a47946b67ef9de8dd0cda0608eeb663b7b417 ./src/models/history.rs
14e053b0dbea9ed7e88e8affaa595fa126ff833c18696fa695e923488ec40d70 ./src/models/integrations.rs
75ad8aa44c1b4d63e9f203c49067fe19f7d8c5ed7cd24a9fc446de06cda61f1f ./src/models/runtime.rs
2811ada38a132eb78ad998b64e8cf318ac816efa4731147213b41a5ded401ff9 ./src/models/settings_api.rs
c4de6101de85d9d7e6814b2504b87d9216561e3d9e274054f081f505fb152140 ./src/models/temporary_thermostat.rs
7d798228a8d4d4e60a112167c001535f4b8a5fa32ec7a33554c73125c42256c2 ./src/models/zone.rs
62e696df8b86835df7ad127f03c257a78580769dc82bc66daa5041f71e8440a5 ./src/notifications.rs
cca742bb2b500eb9269f69385fcf88053cd08aabf6a98b7669b8a7a2fd67df13 ./src/protocol/crypto.rs
cd0612021f8ed6c9e0823d63883c0790db09aa485583f3f3fc9f2579f2c28ea3 ./src/protocol/gree.rs
7907c33e6396c7e0d61705b735b21c84d6ed784012fb61022b872c6745540f1d ./src/protocol/gree/binding.rs
d71771855d5ac90fae4994debf53ed09472b216c17bb2155d80aa2de13c57e18 ./src/protocol/gree/commands.rs
4e419ba3b86ca9d154e8c04445cf005965175c54041093b2daad3666d4d852bd ./src/protocol/gree/core.rs
05fa2e4ec133a61c23a3f4ab2ce792bbfab93feaea9f0ce0d4ccc67fc479833d ./src/protocol/gree/discovery.rs
0ab094f609edb99715402d8eeaa7e38cbfd2d271036aec787dcbb3be3e005720 ./src/protocol/gree/merge.rs
c09c931fa8d512a8883fb14f3142be694af3c51290e023d25c7c972e156e4bd2 ./src/protocol/gree/network.rs
0a901ff258a74072af3812c5417196da1a9b8daa8a2eff30c96d038558601739 ./src/protocol/gree/polling.rs
50152bcad4a843ae04000b991a2365abb302fc1583ea69f09320e25740f3c38c ./src/protocol/gree/tests.rs
3c93e5b254a2a40a07854532cb5fc07e493ad65d9ba2cf28093cff4a4a4e451c ./src/protocol/gree/transport.rs
49dcca1e4a4ad858b312386e3b729f36da691916d8fe62ad682ddb86d40695b7 ./src/protocol/gree_cloud.rs
497f2f77c96f73069206fc233639be191f86d7b1bc6bb416495026c100e45f7a ./src/protocol/gree_cloud_mqtt.rs
6258ee3888a5f9a4f8bb6cfbbac97f2621de07f9da6bcb2fa596d452f47f2e49 ./src/protocol/mod.rs
53bbc096ff5275a26afac30e7295dd19f9ec53dffc5cf08ab5ecd1b64481f25c ./src/provider.rs
830621fa75265da7288b8135704c0ce27e0687f87d55abf3cdbe40a6e2f3d2b1 ./src/queries.rs
7fac65a9dcbcecee09816f5447c7792dc94f736b1efb66ac85629db75f6280be ./src/queries/device_history.rs
917e13be9db10ea629bd99fe6c62a869c31609780702cc6a44e787b78a62e03a ./src/queries/entities.rs
2c5fba462b72158b04dbaaa53c8536263f8d72a1338a6c4ff06cb28a36f6ae27 ./src/queries/ha_history.rs
3112452367b68aeda0a5e3143b1a51ce62e3159859fb41e92c35642a3cd72322 ./src/queries/maintenance.rs
776ed66e363289e188b04a194caaa476ccdf7045b61485792ffe3973caf0bf4d ./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
9ba3f6fa05b72aa989139b2a909982571b2a02055052e4c40104f1e81a9aa7eb ./web/js/README.md
06c8ab1c7e5c0e57ada4e2530d69a379a037678412e1e52268c2aaf8d17b2243 ./web/js/bootstrap.js
5dae06e1193685b7dec29234aa1ae0a29bc6f91cf9fa42370a7fd0d6fdef4e32 ./web/js/charts.js
87598f48469a6dd17e526420d552652c9405632b285d566691b010b59e99043e ./web/js/core.js
d880ffdac6aa07cd10d79fbacf12f87eb4c69b48a80564bbfdd0248abe8884d5 ./web/js/dashboard.js
fcaa6b8d863bc93bc9b7350329d261b2fc98877eb5c9a323d66aa809dd4ef16b ./web/js/entities.js
dada07699ba93efee918e7b1f90f3a326a381fe4c85a63d2811448a048878258 ./web/js/events.js
2e822a00b5a6941b6a251eaf2c54ca95a53399b5d1056f3d8240e11f82d6065f ./web/js/flows.js
cf1ebf3b9d666f309a1add070b4da86be86d990b02c8d6474f6d121c6d332746 ./web/js/forms.js
7d8c2aedcf97196f081367de0933af13c2bc2e531d59e7464760b6d64c895004 ./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
3815fea7ae1694568c2f249bc8460e0563ced6bde89214f74f21b41fe8cad9a6 ./web/lang-init.js
6b653e4fe2d4db4ffae6ff8f39b8ce57a10a990dc0000ee4aae51d949afadd52 ./web/manifest.webmanifest
48d03c265a597af4fc11ae8103eb7b6cb3e5932307783f5287c67781f702b800 ./web/styles.css
fefdf12cbbb0a5d8a2cb89275cae28455aa42aaf179542305cc8f5e0f4825898 ./web/sw.js
d505d793ce7cc9485b45b78bba1c0d51887adc7451ab59a42702946e5b991382 ./web/theme-init.js
+25 -3
View File
@@ -1,6 +1,6 @@
# GREE Controller API reference
HTTP and WebSocket API for GREE Controller **0.13.10**.
HTTP and WebSocket API for GREE Controller **0.14.0**.
[← Main documentation](../README.md)
@@ -232,7 +232,7 @@ Response:
{
"status": "ok",
"name": "gree-controller",
"version": "0.13.10",
"version": "0.14.0",
"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.13.10",
"version": "0.14.0",
"uptime_seconds": 1234,
"auth_required": false,
"control_ready": true,
@@ -1651,3 +1651,25 @@ Cancellation suppresses the same pending intent until a new explicit thermostat/
Existing Flow updates require `expected_revision`. A mismatched revision returns HTTP 409 to prevent stale editor tabs from overwriting newer graphs. Source Flow plus generated outputs are replaced atomically in one database transaction while configuration/automation/schedule/thermostat-cycle operations are serialized.
Additional Flow condition blocks are `house_mode`, `device_state`, `zone_state`, `group_state`, `night_mode`, `ha_attribute`, `ha_available`, `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.
## 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`.
Cloud account/discovery/diagnostic endpoints:
- `GET|PUT /api/settings/gree-cloud`
- `POST /api/integrations/gree-cloud/test`
- `GET /api/integrations/gree-cloud/devices`
- `POST /api/integrations/gree-cloud/devices/{cloud_id}/add`
- `GET /api/integrations/gree-cloud/status`
- `POST /api/integrations/gree-cloud/reconnect`
- `GET /api/devices/{id}/cloud-diagnostics`
Energy endpoints:
- `GET /api/integrations/home-assistant/energy-sensors` lists compatible cumulative HA energy sensors.
- `GET /api/history/energy?device_id=...&interval=hourly|daily|weekly|monthly&source=auto|gree_cloud|home_assistant` returns consumption buckets and period summaries in kWh.
Cloud settings and diagnostics are redacted: passwords, REST/MQTT tokens, Authorization values and device cipher keys are not returned.
+1090 -12
View File
File diff suppressed because it is too large Load Diff
@@ -1,7 +1,7 @@
{
"domain": "gree_controller",
"name": "GREE Controller",
"version": "0.13.10",
"version": "0.14.0",
"config_flow": true,
"integration_type": "hub",
"iot_class": "local_polling",
@@ -1,5 +1,13 @@
# Changelog
## 0.14.0
- Adds GREE Cloud as a first-class provider while preserving the existing LAN/UDP transport.
- Adds MQTT/TLS status push, Cloud commands, reconnect/resubscribe lifecycle and safe Cloud diagnostics.
- Adds per-device capabilities and Cloud-specific Devices/Manual Control UI.
- Adds GREE Cloud and Home Assistant cumulative-energy sources, delta conversion, energy history, aggregation, charts and InfluxDB archive support.
- Keeps Local and Cloud entries independent and never performs automatic transport fallback.
## 0.13.10
- Accepts multi-state non-zero values for optional GREE feature flags without dropping an otherwise valid status frame.
@@ -1,5 +1,5 @@
name: "GREE Controller"
version: "0.13.10"
version: "0.14.0"
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/"
+82 -1
View File
@@ -1177,6 +1177,87 @@
"flow.expandSettings": "Expand block settings",
"flow.notSavedYet": "Not saved yet",
"schedules.enabledState": "Enabled",
"schedules.disabledState": "Disabled"
"schedules.disabledState": "Disabled",
"devices.cloudUnit": "Cloud unit",
"devices.connection": "Connection",
"devices.transport": "Transport",
"devices.cloudDeviceId": "Cloud Device ID",
"devices.lastSync": "Last synchronization",
"devices.cloudDetails": "Cloud details",
"devices.cloudDiagnostics": "Cloud diagnostics",
"devices.cloudDiagnosticsHint": "Decrypted and sanitized protocol data. Passwords, tokens and cipher keys are never included.",
"energy.title": "Energy",
"energy.source": "Energy source",
"energy.sourceHint": "Choose the cumulative energy source used for charts and period totals.",
"energy.auto": "Auto",
"energy.haSensor": "Home Assistant energy sensor",
"energy.noHaSensor": "No Home Assistant energy sensor selected.",
"energy.today": "Today",
"energy.yesterday": "Yesterday",
"energy.currentMonth": "Current month",
"energy.previousMonth": "Previous month",
"energy.periodTotal": "Period total",
"energy.noData": "No energy source is configured for this device.",
"energy.hourly": "Hourly",
"energy.daily": "Daily",
"energy.monthly": "Monthly",
"settings.cloudAccountStatus": "Account status",
"settings.cloudMqttStatus": "MQTT status",
"settings.cloudReconnect": "Reconnect",
"settings.cloudPushHint": "MQTT push is primary. Polling is used only for initial state, recovery and periodic verification.",
"history.bucket": "Aggregation",
"energy.chartHint": "Period consumption calculated from cumulative meter deltas. Raw cumulative values are never plotted as consumption.",
"devices.localCloud": "Local / GREE Cloud",
"devices.discoverLocal": "Discover Local",
"devices.addLocalManual": "Add Local manually",
"devices.cloudAdded": "GREE Cloud device added",
"settings.cloudAccountTitle": "GREE Cloud account",
"settings.cloudAccountHint": "REST login and device discovery use the selected regional GREE service. The password is never returned by the API after it is saved.",
"settings.cloudEnable": "Enable GREE Cloud",
"settings.cloudRegion": "Region",
"settings.cloudPolling": "Cloud fallback polling interval (s)",
"settings.cloudLogin": "Login / email",
"settings.cloudPassword": "Password",
"settings.cloudPasswordPlaceholder": "Leave empty to keep saved secret",
"settings.cloudInstallationId": "Installation ID",
"settings.cloudLastContact": "Last successful contact",
"settings.cloudTest": "Test connection",
"settings.cloudRefreshDevices": "Refresh devices",
"settings.cloudConnected": "GREE Cloud connected · {count} device(s)",
"settings.cloudConnectionFailed": "GREE Cloud connection failed",
"devices.cloudDiscoveryTitle": "Devices on account",
"devices.cloudDiscoveryHint": "Devices are loaded from the saved GREE Cloud account. Nothing is added automatically; choose the units you want to register.",
"devices.cloudDiscoveryEmpty": "No GREE Cloud devices found",
"devices.cloudDiscoveryEmptyHint": "Check the account, region and connection.",
"devices.cloudAlreadyAdded": "Added",
"status.cloudDisconnected": "Cloud disconnected",
"status.authenticationError": "Authentication error",
"status.unknown": "Unknown",
"dashboard.quickThermostats": "Thermostats",
"zones.thermostat": "Thermostat",
"settings.debugCloudRequests": "Trace GREE Cloud API requests",
"settings.debugCloudMqtt": "Trace GREE Cloud MQTT",
"debug.cloud": "Cloud API",
"debug.mqtt": "MQTT",
"debug.liveSources": "Live protocol sources",
"debug.emptyCloud": "No GREE Cloud API activity captured yet.",
"debug.emptyMqtt": "No GREE Cloud MQTT activity captured yet.",
"settings.cloudDevicesOnline": "Devices online",
"settings.cloudResponseTime": "Last device response time",
"settings.cloudLastDeviceResponse": "Last device response",
"settings.cloudLastMqttMessage": "Last MQTT activity",
"settings.cloudConnectedSince": "MQTT connected since",
"settings.cloudBroker": "Broker",
"settings.cloudTraffic": "Requests / responses / timeouts",
"devices.model": "Model",
"devices.firmware": "Firmware",
"devices.lastResponse": "Last response",
"devices.noCloudSyncYet": "No successful synchronization yet",
"devices.noCloudResponseYet": "No successful device response yet",
"devices.cloudAuthenticationProblem": "GREE Cloud authentication error",
"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"
}
}
+82 -1
View File
@@ -1177,6 +1177,87 @@
"flow.expandSettings": "Rozwiń ustawienia bloku",
"flow.notSavedYet": "Jeszcze nie zapisano",
"schedules.enabledState": "Włączony",
"schedules.disabledState": "Wyłączony"
"schedules.disabledState": "Wyłączony",
"devices.cloudUnit": "Jednostka Cloud",
"devices.connection": "Połączenie",
"devices.transport": "Transport",
"devices.cloudDeviceId": "Cloud Device ID",
"devices.lastSync": "Ostatnia synchronizacja",
"devices.cloudDetails": "Szczegóły Cloud",
"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",
"energy.source": "Źródło energii",
"energy.sourceHint": "Wybierz narastający licznik energii używany do wykresów i sum okresowych.",
"energy.auto": "Auto",
"energy.haSensor": "Sensor energii Home Assistant",
"energy.noHaSensor": "Nie wybrano sensora energii Home Assistant.",
"energy.today": "Dzisiaj",
"energy.yesterday": "Wczoraj",
"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.hourly": "Godzinowo",
"energy.daily": "Dziennie",
"energy.monthly": "Miesięcznie",
"settings.cloudAccountStatus": "Status konta",
"settings.cloudMqttStatus": "Status MQTT",
"settings.cloudReconnect": "Połącz ponownie",
"settings.cloudPushHint": "Podstawą synchronizacji jest push MQTT. Polling służy tylko do stanu początkowego, odzyskiwania połączenia i okresowej weryfikacji.",
"history.bucket": "Agregacja",
"energy.chartHint": "Zużycie okresowe obliczone z przyrostów licznika narastającego. Surowa wartość licznika nigdy nie jest rysowana jako zużycie.",
"devices.localCloud": "Local / GREE Cloud",
"devices.discoverLocal": "Wykryj Local",
"devices.addLocalManual": "Dodaj Local ręcznie",
"devices.cloudAdded": "Dodano urządzenie GREE Cloud",
"settings.cloudAccountTitle": "Konto GREE Cloud",
"settings.cloudAccountHint": "Logowanie REST i wykrywanie urządzeń korzystają z regionalnej usługi GREE. Zapisane hasło nie jest ponownie zwracane przez API.",
"settings.cloudEnable": "Włącz GREE Cloud",
"settings.cloudRegion": "Region",
"settings.cloudPolling": "Interwał awaryjnego pollingu Cloud (s)",
"settings.cloudLogin": "Login / e-mail",
"settings.cloudPassword": "Hasło",
"settings.cloudPasswordPlaceholder": "Zostaw puste, aby zachować zapisany sekret",
"settings.cloudInstallationId": "ID instalacji",
"settings.cloudLastContact": "Ostatni udany kontakt",
"settings.cloudTest": "Test połączenia",
"settings.cloudRefreshDevices": "Odśwież urządzenia",
"settings.cloudConnected": "GREE Cloud połączony · {count} urządzeń",
"settings.cloudConnectionFailed": "Połączenie GREE Cloud nie powiodło się",
"devices.cloudDiscoveryTitle": "Urządzenia na koncie",
"devices.cloudDiscoveryHint": "Urządzenia są pobierane z zapisanego konta GREE Cloud. Nic nie jest dodawane automatycznie — wybierz jednostki, które chcesz zarejestrować.",
"devices.cloudDiscoveryEmpty": "Nie znaleziono urządzeń GREE Cloud",
"devices.cloudDiscoveryEmptyHint": "Sprawdź konto, region i połączenie.",
"devices.cloudAlreadyAdded": "Dodano",
"status.cloudDisconnected": "Cloud rozłączony",
"status.authenticationError": "Błąd uwierzytelnienia",
"status.unknown": "Nieznany",
"dashboard.thermostats": "Termostaty",
"zones.quickThermostat": "Termostat",
"settings.debugCloudRequests": "Śledź requesty API GREE Cloud",
"settings.debugCloudMqtt": "Śledź MQTT GREE Cloud",
"debug.cloud": "Cloud API",
"debug.mqtt": "MQTT",
"debug.liveSources": "Źródła protokołu na żywo",
"debug.emptyCloud": "Brak przechwyconej aktywności API GREE Cloud.",
"debug.emptyMqtt": "Brak przechwyconej aktywności MQTT GREE Cloud.",
"settings.cloudDevicesOnline": "Urządzenia online",
"settings.cloudResponseTime": "Czas ostatniej odpowiedzi urządzenia",
"settings.cloudLastDeviceResponse": "Ostatnia odpowiedź urządzenia",
"settings.cloudLastMqttMessage": "Ostatnia aktywność MQTT",
"settings.cloudConnectedSince": "MQTT połączony od",
"settings.cloudBroker": "Broker",
"settings.cloudTraffic": "Requesty / odpowiedzi / timeouty",
"devices.model": "Model",
"devices.firmware": "Firmware",
"devices.lastResponse": "Ostatnia odpowiedź",
"devices.noCloudSyncYet": "Brak udanej synchronizacji",
"devices.noCloudResponseYet": "Brak udanej odpowiedzi urządzenia",
"devices.cloudAuthenticationProblem": "Błąd uwierzytelniania GREE Cloud",
"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"
}
}
+24 -6
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""Developer smoke/integration tests for GREE Controller API 0.13.10.
"""Developer smoke/integration tests for GREE Controller API 0.14.0.
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.13.10"
DEFAULT_EXPECTED_VERSION = "0.14.0"
SETTINGS_PATHS: dict[str, set[str]] = {
"/api/settings/application": {"simulator_enabled"},
@@ -40,6 +40,16 @@ SETTINGS_PATHS: dict[str, set[str]] = {
"compressor_protection_enabled",
"compressor_protection_seconds",
},
"/api/settings/gree-cloud": {
"enabled",
"region",
"username",
"password_configured",
"polling_interval_seconds",
"installation_id",
"account_id",
"last_successful_contact",
},
"/api/settings/history": {"retention_days", "compaction_enabled", "event_retention_days"},
"/api/settings/influxdb": {
"enabled",
@@ -125,14 +135,15 @@ DETAIL_COLLECTIONS = [
("/api/flows", "/api/flows/{id}"),
]
EXPECTED_OPENAPI_PATHS = set(SETTINGS_PATHS) | {
# The beta cloud endpoints are not yet regenerated into the static OpenAPI file.
EXPECTED_OPENAPI_PATHS = (set(SETTINGS_PATHS) - {"/api/settings/gree-cloud"}) | {
"/api/configuration/export",
"/api/configuration/import",
"/api/integrations/home-assistant/snapshot",
}
BOOTSTRAP_SETTINGS_SECTIONS = {
"application", "gree", "history", "influxdb", "notifications", "night", "home_assistant", "debug"
"application", "gree", "gree_cloud", "history", "influxdb", "notifications", "night", "home_assistant", "debug"
}
@@ -303,6 +314,11 @@ def convert_settings_view_to_update(path: str, view: dict[str, Any]) -> dict[str
payload.pop("password_configured", None)
payload.pop("token_configured", None)
# Omitted Option fields preserve existing secrets.
elif path == "/api/settings/gree-cloud":
payload.pop("password_configured", None)
payload.pop("installation_id", None)
payload.pop("account_id", None)
payload.pop("last_successful_contact", None)
elif path == "/api/settings/notifications":
payload.pop("pushover_configured", None)
payload.pop("slack_configured", None)
@@ -367,6 +383,7 @@ def test_bootstrap_contract(client: ApiClient) -> str:
raise TestFailure(f"bootstrap.settings missing sections: {missing_sections}")
forbidden = {
"influxdb": {"password", "token"},
"gree_cloud": {"password"},
"home_assistant": {"token"},
"notifications": {"pushover_app_token", "pushover_user_key", "slack_webhook_url", "discord_webhook_url"},
}
@@ -448,6 +465,7 @@ def test_settings_get(client: ApiClient, path: str, required: set[str]) -> str:
# Secret values must never be returned by split GETs.
forbidden_secret_keys = {
"/api/settings/influxdb": {"password", "token"},
"/api/settings/gree-cloud": {"password"},
"/api/settings/notifications": {
"pushover_app_token", "pushover_user_key", "slack_webhook_url", "discord_webhook_url"
},
@@ -553,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.13.10 contract", lambda: test_openapi(client, args.expected_version))
runner.run("OpenAPI 0.14.0 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))
@@ -622,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.13.10.",
description="Developer smoke/integration tests for GREE Controller API 0.14.0.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""Examples:
python3 scripts/api_dev_test.py
+38 -1
View File
@@ -4,7 +4,9 @@ use crate::{
home_assistant, influxdb,
models::{
ApiTokenInfo, ApplicationSettings, Automation, ClimateGroup, ConfigurationExport,
DebugSettings, Device, DeviceCommand, DevicePatch, DiscoveryRequest, Flow, GreeSettings,
ConnectionStatus, ConnectionType, DebugSettings, Device, DeviceCommand, DevicePatch, DiscoveryRequest,
EnergySourcePreference, Flow, GreeSettings,
GreeCloudSettings, GreeCloudSettingsUpdate, GreeCloudSettingsView,
GroupControlPatch, HaReading, HistorySettings, HomeAssistantSettings,
HomeAssistantSettingsUpdate, HomeAssistantSettingsView, InfluxDbSettings,
InfluxDbSettingsUpdate, InfluxDbSettingsView, ManualDeviceRequest, NightModeSettings,
@@ -74,6 +76,7 @@ const SPA_ROUTES: &[&str] = &[
"/history/overview",
"/history/zones",
"/history/devices",
"/history/energy",
"/history/sensors",
"/history/custom",
];
@@ -147,6 +150,7 @@ pub fn router(state: AppState) -> Router {
)
.route("/api/readings", get(readings))
.route("/api/history", get(history))
.route("/api/history/energy", get(energy_history))
.route("/api/control-plan", get(control_plan))
.route("/api/events", get(events))
.route(
@@ -157,6 +161,10 @@ pub fn router(state: AppState) -> Router {
"/api/settings/gree",
get(get_gree_settings).put(update_gree_settings),
)
.route(
"/api/settings/gree-cloud",
get(get_gree_cloud_settings).put(update_gree_cloud_settings),
)
.route(
"/api/settings/history",
get(get_history_settings).put(update_history_settings),
@@ -191,6 +199,30 @@ pub fn router(state: AppState) -> Router {
"/api/access-tokens/:id",
axum::routing::delete(delete_access_token),
)
.route(
"/api/integrations/gree-cloud/test",
post(test_gree_cloud),
)
.route(
"/api/integrations/gree-cloud/devices",
get(discover_gree_cloud_devices),
)
.route(
"/api/integrations/gree-cloud/devices/:cloud_id/add",
post(add_gree_cloud_device),
)
.route(
"/api/integrations/gree-cloud/status",
get(gree_cloud_status),
)
.route(
"/api/integrations/gree-cloud/reconnect",
post(reconnect_gree_cloud),
)
.route(
"/api/devices/:id/cloud-diagnostics",
get(cloud_device_diagnostics),
)
.route(
"/api/integrations/home-assistant/test",
post(test_home_assistant),
@@ -199,6 +231,10 @@ pub fn router(state: AppState) -> Router {
"/api/integrations/home-assistant/entity",
post(inspect_home_assistant_entity),
)
.route(
"/api/integrations/home-assistant/energy-sensors",
get(list_home_assistant_energy_sensors),
)
.route(
"/api/integrations/notifications/test",
post(test_notifications),
@@ -320,6 +356,7 @@ include!("api/settings.rs");
include!("api/configuration.rs");
include!("api/debug_tokens.rs");
include!("api/integrations.rs");
include!("api/gree_cloud.rs");
include!("api/middleware.rs");
include!("api/websocket.rs");
include!("api/assets.rs");
+102 -5
View File
@@ -17,12 +17,16 @@ async fn export_configuration(
let settings = state.settings.read().await.clone();
let mut export = state.db.export_configuration(settings)?;
sanitize_configuration_runtime(&mut export);
// Cloud credentials are account-scoped secrets and must never be returned to the frontend,
// including configuration exports. Import can reuse the already configured password on the
// target controller when account/region match.
export.settings.gree_cloud.password.clear();
Ok(Json(export))
}
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.12.x".into()));
return Err(AppError::BadRequest("unsupported configuration export version; version 3 is required by GREE Controller 0.14.0".into()));
}
if export.settings.control_strategy != "setpoint" {
return Err(AppError::BadRequest(
@@ -108,14 +112,16 @@ fn validate_configuration_devices_and_zones(
export: &ConfigurationExport,
ids: &ConfigurationIds<'_>,
) -> Result<(), AppError> {
let device_macs: std::collections::HashSet<&str> = export
// The same physical unit may intentionally exist once as Local and once as GREE Cloud.
// Reject duplicates only within the same explicit transport.
let device_transport_macs: std::collections::HashSet<(ConnectionType, &str)> = export
.devices
.iter()
.map(|item| item.mac.as_str())
.map(|item| (item.connection_type, item.mac.as_str()))
.collect();
if device_macs.len() != export.devices.len() {
if device_transport_macs.len() != export.devices.len() {
return Err(AppError::BadRequest(
"import contains duplicate device MAC addresses".into(),
"import contains duplicate device MAC addresses for the same connection type".into(),
));
}
if export
@@ -557,6 +563,15 @@ fn normalize_imported_runtime_settings(settings: &mut RuntimeSettings) -> Result
settings.compressor_protection_enabled = gree.compressor_protection_enabled;
settings.compressor_protection_seconds = gree.compressor_protection_seconds;
if crate::protocol::gree_cloud::region_base_url(settings.gree_cloud.region.trim()).is_none() {
return Err(AppError::BadRequest("unsupported GREE Cloud region".into()));
}
settings.gree_cloud.polling_interval_seconds =
settings.gree_cloud.polling_interval_seconds.clamp(30, 3600);
if settings.gree_cloud.account_id.trim().is_empty() {
settings.gree_cloud.account_id = "default".into();
}
settings.history_retention_days = settings.history_retention_days.clamp(1, 3650);
settings.event_log_retention_days = settings.event_log_retention_days.clamp(1, 3650);
settings.influxdb.history_threshold_days =
@@ -601,6 +616,81 @@ fn prepare_configuration_import(export: &mut ConfigurationExport) -> Result<(),
Ok(())
}
async fn hydrate_imported_cloud_device_keys(
state: &AppState,
export: &mut ConfigurationExport,
) -> Result<(), AppError> {
if export.settings.gree_cloud.password.trim().is_empty() {
let current = state.settings.read().await.gree_cloud.clone();
if current.region.eq_ignore_ascii_case(&export.settings.gree_cloud.region)
&& current.username.eq_ignore_ascii_case(&export.settings.gree_cloud.username)
&& !current.password.trim().is_empty()
{
export.settings.gree_cloud.password = current.password;
}
}
let needs_cloud_keys = export.devices.iter().any(|device| {
device.connection_type == ConnectionType::GreeCloud
&& device.key.as_deref().unwrap_or_default().is_empty()
});
if !needs_cloud_keys {
return Ok(());
}
let settings = &export.settings.gree_cloud;
let mut api = crate::protocol::gree_cloud::GreeCloudApi::for_region(
state.http.clone(),
&settings.region,
&settings.username,
&settings.password,
)
.map_err(|err| AppError::BadRequest(format!(
"cannot restore GREE Cloud device secrets from account: {err}"
)))?;
api.login().await.map_err(|err| {
AppError::BadRequest(format!(
"cannot restore GREE Cloud device secrets: account login failed: {err}"
))
})?;
let discovered = api.get_all_devices().await.map_err(|err| {
AppError::BadRequest(format!(
"cannot restore GREE Cloud device secrets: discovery failed: {err}"
))
})?;
for device in export
.devices
.iter_mut()
.filter(|device| device.connection_type == ConnectionType::GreeCloud)
{
if device.key.as_deref().is_some_and(|key| !key.is_empty()) {
continue;
}
let cloud_id = device.cloud_device_id.as_deref().unwrap_or(&device.mac);
let Some(found) = discovered
.iter()
.find(|candidate| candidate.mac.eq_ignore_ascii_case(cloud_id))
else {
return Err(AppError::BadRequest(format!(
"cannot restore GREE Cloud device {}: it is not present in the configured account",
device.name
)));
};
device.key = Some(found.key.clone());
let normalized_cloud_mac = found.mac.replace([':', '-'], "").to_ascii_uppercase();
device.cloud_device_id = Some(normalized_cloud_mac.clone());
device.cloud_parent_mac = Some(crate::protocol::gree_cloud::parent_mac(&normalized_cloud_mac));
device.cloud_account_id = Some(settings.account_id.clone());
if device.model.trim().is_empty() {
device.model = found.model.clone().unwrap_or_default();
}
if device.firmware.trim().is_empty() {
device.firmware = found.version.clone().unwrap_or_default();
}
}
Ok(())
}
async fn lock_configuration_resources(
state: &AppState,
current_zones: &[Zone],
@@ -720,6 +810,7 @@ async fn import_configuration(
) -> Result<Json<Value>, AppError> {
validate_configuration_export(&export)?;
prepare_configuration_import(&mut export)?;
hydrate_imported_cloud_device_keys(&state, &mut export).await?;
let _configuration_guard = state.lock_configuration_operation().await;
let _automation_guard = state.lock_automation_operation().await;
@@ -740,6 +831,12 @@ async fn import_configuration(
state
.debug_gree_frames
.store(export.settings.debug.gree_frames, Ordering::Relaxed);
state
.debug_cloud_requests
.store(export.settings.debug.cloud_requests, Ordering::Relaxed);
state
.debug_cloud_mqtt
.store(export.settings.debug.cloud_mqtt, Ordering::Relaxed);
*state.settings.write().await = export.settings.clone();
reconcile_imported_devices(&state, &export).await?;
state
+121 -10
View File
@@ -12,7 +12,9 @@ async fn discover(
let protocol_version = request.protocol_version.unwrap_or(0).min(2);
let passes = request.passes.unwrap_or(3).clamp(1, 10);
let discovered = state
.gree
.providers
.local()
.client()
.discover(
&broadcast,
Duration::from_millis(timeout_ms),
@@ -38,7 +40,7 @@ async fn discover(
// Bind right after discovery. GREE modules can have a short bind window;
// bind() also refreshes it with a direct scan before the handshake.
if !merged.simulated && merged.key.as_deref().unwrap_or_default().is_empty() {
match state.gree.bind(&merged).await {
match state.providers.local().client().bind(&merged).await {
Ok(bound) => {
merged.key = Some(bound.key);
merged.protocol_version = bound.protocol_version;
@@ -96,6 +98,11 @@ async fn add_device(
id: format!("gree-{}", normalized_mac.to_ascii_lowercase()),
mac: normalized_mac,
name: input.name.trim().to_string(),
connection_type: ConnectionType::Local,
connection_status: ConnectionStatus::Unknown,
cloud_device_id: None,
cloud_parent_mac: None,
cloud_account_id: None,
ip: input.ip,
port: input.port,
protocol_version: input.protocol_version.min(2),
@@ -126,6 +133,11 @@ async fn add_device(
supports_xfan: None,
supports_health: None,
supports_sleep: None,
supports_buzzer_control: None,
supports_energy_meter: None,
total_energy_kwh: None,
compressor_frequency_hz: None,
last_cloud_sync: None,
current_temperature: if input.simulated { Some(25.0) } else { None },
outdoor_temperature: None,
temperature_sensor_offset: None,
@@ -134,6 +146,13 @@ async fn add_device(
last_seen: if input.simulated { Some(now) } else { None },
last_error: None,
communication_failures: 0,
pending_command: false,
capabilities: crate::models::DeviceCapabilities::default(),
energy_source: crate::models::EnergySourcePreference::Auto,
ha_energy_entity_id: None,
ha_energy_unit: None,
ha_energy_device_class: None,
ha_energy_state_class: None,
created_at: now,
updated_at: now,
};
@@ -173,6 +192,13 @@ async fn patch_device(
.db
.get_device(&id)?
.ok_or_else(|| AppError::NotFound(format!("device {id}")))?;
if device.connection_type == ConnectionType::GreeCloud
&& (patch.ip.is_some() || patch.port.is_some() || patch.protocol_version.is_some() || patch.key.is_some())
{
return Err(AppError::BadRequest(
"IP, UDP port, local protocol and local key are not configurable for GREE Cloud devices".into(),
));
}
if let Some(v) = patch.name {
if !v.trim().is_empty() {
device.name = v.trim().to_string();
@@ -206,6 +232,44 @@ async fn patch_device(
if let Some(v) = patch.enabled {
device.enabled = v;
}
if let Some(v) = patch.energy_source { device.energy_source = v; }
if let Some(v) = patch.ha_energy_entity_id { device.ha_energy_entity_id = v.filter(|x| !x.trim().is_empty()); }
if let Some(v) = patch.ha_energy_unit { device.ha_energy_unit = v.filter(|x| !x.trim().is_empty()); }
if let Some(v) = patch.ha_energy_device_class { device.ha_energy_device_class = v.filter(|x| !x.trim().is_empty()); }
if let Some(v) = patch.ha_energy_state_class { device.ha_energy_state_class = v.filter(|x| !x.trim().is_empty()); }
device.refresh_capabilities();
if device.energy_source == EnergySourcePreference::GreeCloud && !device.capabilities.energy_meter {
return Err(AppError::BadRequest(
"GREE Cloud energy is not available for this device".into(),
));
}
if device.energy_source == EnergySourcePreference::HomeAssistant
&& device.ha_energy_entity_id.as_deref().unwrap_or_default().is_empty()
{
return Err(AppError::BadRequest(
"select a Home Assistant cumulative energy sensor first".into(),
));
}
if device.ha_energy_entity_id.is_some() {
if device.ha_energy_device_class.as_deref() != Some("energy") {
return Err(AppError::BadRequest(
"Home Assistant energy sensor must have device_class=energy".into(),
));
}
if !matches!(device.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!(
device.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(),
));
}
}
device.updated_at = Utc::now();
state.db.save_device(&device)?;
state.broadcast("device.updated", serde_json::to_value(&device)?);
@@ -227,13 +291,17 @@ async fn delete_device(
let _house_guard = state.lock_house_operation().await;
let _schedule_guard = state.lock_schedule_operation().await;
let _cycle_guard = state.lock_zone_control_cycle().await;
if state.db.get_device(&id)?.is_none() {
return Err(AppError::NotFound(format!("device {id}")));
}
if state.db.list_automations()?.iter().any(|item| {
let device = state
.db
.get_device(&id)?
.ok_or_else(|| AppError::NotFound(format!("device {id}")))?;
let automations = state.db.list_automations()?;
if device.connection_type == ConnectionType::Local
&& automations.iter().any(|item| {
item.trigger_device_id.as_deref() == Some(id.as_str())
|| (item.action_group_id.is_none() && item.action_device_id == id)
}) {
})
{
return Err(AppError::BadRequest(
"device is used by an automation; remove or retarget that automation first".into(),
));
@@ -251,8 +319,40 @@ async fn delete_device(
for zone_id in &sorted_zone_ids {
zone_guards.push(state.lock_zone_operation(zone_id).await);
}
if device.connection_type == ConnectionType::Local {
ensure_zone_removal_safe(&state, &removed_zone_ids)?;
ensure_device_stopped_for_detach(&state, &id, "device.deleted").await?;
} else {
// Cloud removal must remain possible even when the physical unit is offline. Remove
// controller-only references that would otherwise block deletion, but never send an
// OFF/status request and never depend on MQTT. Local keeps the historical safeguards.
let groups = state.db.list_groups()?;
let emptied_group_ids: std::collections::HashSet<String> = groups
.iter()
.filter(|group| {
!group.zone_ids.is_empty()
&& group.zone_ids.iter().all(|zone_id| removed_zone_ids.contains(zone_id))
})
.map(|group| group.id.clone())
.collect();
for automation in automations.iter().filter(|item| {
item.trigger_device_id.as_deref() == Some(id.as_str())
|| (item.action_group_id.is_none() && item.action_device_id == id)
|| item
.action_group_id
.as_ref()
.is_some_and(|group_id| emptied_group_ids.contains(group_id))
|| item
.action_zone_id
.as_ref()
.is_some_and(|zone_id| removed_zone_ids.contains(zone_id))
}) {
if state.db.delete_automation(&automation.id)? {
state.broadcast("automation.deleted", json!({"id": automation.id.clone()}));
}
}
state.providers.cloud().unregister_device(&id).await;
}
if !state.db.delete_device(&id)? {
return Err(AppError::NotFound(format!("device {id}")));
}
@@ -262,9 +362,10 @@ async fn delete_device(
"info",
"device.deleted",
"Device deleted",
json!({"device_id": id}),
json!({"device_id": id, "connection_type": device.connection_type}),
);
state.broadcast("device.deleted", json!({"id": id}));
state.wake_zone_control();
Ok(StatusCode::NO_CONTENT)
}
@@ -278,11 +379,15 @@ async fn bind_device(
.db
.get_device(&id)?
.ok_or_else(|| AppError::NotFound(format!("device {id}")))?;
if device.connection_type == ConnectionType::GreeCloud {
return Err(AppError::BadRequest("bind is only available for Local/LAN devices".into()));
}
if device.simulated {
return Ok(Json(device));
}
let bound = state
.gree
.providers
.local()
.bind(&device)
.await
.map_err(|e| AppError::Device(e.to_string()))?;
@@ -290,6 +395,7 @@ async fn bind_device(
device.protocol_version = bound.protocol_version;
device.communication_failures = 0;
device.online = true;
device.connection_status = ConnectionStatus::Online;
device.last_seen = Some(Utc::now());
device.last_error = None;
device.updated_at = Utc::now();
@@ -319,8 +425,13 @@ async fn probe_device(
.db
.get_device(&id)?
.ok_or_else(|| AppError::NotFound(format!("device {id}")))?;
if device.connection_type == ConnectionType::GreeCloud {
return Err(AppError::BadRequest("UDP probe is only available for Local/LAN devices".into()));
}
let response_time_ms = state
.gree
.providers
.local()
.client()
.probe(&device)
.await
.map_err(|err| AppError::Device(err.to_string()))?;
+418
View File
@@ -0,0 +1,418 @@
fn cloud_debug_event(state: &AppState, data: Value) {
if state.debug_cloud_requests.load(Ordering::Relaxed) {
state.broadcast_with_control_plan_invalidation("gree_cloud.request", data, false);
}
}
fn cloud_error_kind(error: &anyhow::Error) -> &'static str {
let text = format!("{error:#}").to_ascii_lowercase();
if text.contains("authentication failed") || text.contains("login failed") {
"authentication_error"
} else if text.contains("timeout") || text.contains("timed out") {
"timeout"
} else if text.contains("http 5") || text.contains("service unavailable") {
"api_unavailable"
} else if text.contains("connect") || text.contains("dns") || text.contains("network") {
"network_error"
} else {
"api_error"
}
}
async fn cloud_api_from_settings(state: &AppState) -> Result<crate::protocol::gree_cloud::GreeCloudApi, AppError> {
let settings = state.settings.read().await.gree_cloud.clone();
crate::protocol::gree_cloud::GreeCloudApi::for_region(
state.http.clone(),
&settings.region,
&settings.username,
&settings.password,
)
.map_err(|err| AppError::BadRequest(err.to_string()))
}
async fn test_gree_cloud(State(state): State<AppState>) -> Result<Json<Value>, AppError> {
let mut api = cloud_api_from_settings(&state).await?;
let started = Instant::now();
cloud_debug_event(&state, json!({"operation":"test_connection","phase":"sent"}));
let result = async {
api.login().await?;
let devices = api.get_all_devices().await?;
Ok::<usize, anyhow::Error>(devices.len())
}
.await;
match result {
Ok(device_count) => {
let now = Utc::now();
cloud_debug_event(&state, json!({
"operation":"test_connection",
"phase":"response",
"duration_ms": started.elapsed().as_millis() as u64,
"device_count": device_count,
}));
{
let _configuration_guard = state.lock_configuration_operation().await;
let mut settings = state.settings.write().await;
settings.gree_cloud.last_successful_contact = Some(now);
settings.gree_cloud.last_rest_response_time_ms =
Some(started.elapsed().as_millis().min(u64::MAX as u128) as u64);
state.db.save_runtime_settings(&settings)?;
}
state.log(
"info",
"gree_cloud.login_success",
"GREE Cloud connection test succeeded",
json!({"device_count": device_count}),
);
Ok(Json(json!({
"ok": true,
"status": "connected",
"device_count": device_count,
"last_successful_contact": now,
})))
}
Err(error) => {
let kind = cloud_error_kind(&error);
cloud_debug_event(&state, json!({
"operation":"test_connection",
"phase":"error",
"duration_ms": started.elapsed().as_millis() as u64,
"kind": kind,
}));
tracing::warn!(kind, "GREE Cloud connection test failed");
state.log(
"warn",
"gree_cloud.login_failure",
"GREE Cloud connection test failed",
json!({"kind": kind}),
);
Ok(Json(json!({
"ok": false,
"status": kind,
"message": match kind {
"authentication_error" => "Invalid GREE Cloud login/password or authorization was rejected",
"timeout" => "GREE Cloud request timed out",
"network_error" => "Cannot reach GREE Cloud",
"api_unavailable" => "GREE Cloud API is temporarily unavailable",
_ => "GREE Cloud returned an unexpected response",
}
})))
}
}
}
async fn discover_gree_cloud_devices(
State(state): State<AppState>,
) -> Result<Json<Value>, AppError> {
let mut api = cloud_api_from_settings(&state).await?;
let started = Instant::now();
cloud_debug_event(&state, json!({"operation":"discovery","phase":"sent"}));
api.login()
.await
.map_err(|err| {
cloud_debug_event(&state, json!({
"operation":"discovery",
"phase":"error",
"stage":"login",
"duration_ms": started.elapsed().as_millis() as u64,
"kind": cloud_error_kind(&err),
}));
AppError::Dependency(format!("GREE Cloud login failed: {err}"))
})?;
let devices = api
.get_all_devices()
.await
.map_err(|err| {
cloud_debug_event(&state, json!({
"operation":"discovery",
"phase":"error",
"stage":"devices",
"duration_ms": started.elapsed().as_millis() as u64,
"kind": cloud_error_kind(&err),
}));
AppError::Dependency(format!("GREE Cloud discovery failed: {err}"))
})?;
let rest_duration_ms = started.elapsed().as_millis().min(u64::MAX as u128) as u64;
cloud_debug_event(&state, json!({
"operation":"discovery",
"phase":"response",
"duration_ms": rest_duration_ms,
"device_count": devices.len(),
}));
{
let _configuration_guard = state.lock_configuration_operation().await;
let mut settings = state.settings.write().await;
settings.gree_cloud.last_successful_contact = Some(Utc::now());
settings.gree_cloud.last_rest_response_time_ms = Some(rest_duration_ms);
state.db.save_runtime_settings(&settings)?;
}
let existing = state.db.list_devices()?;
let views = devices
.into_iter()
.map(|device| {
let id = device.mac.replace([':', '-'], "").to_ascii_uppercase();
let already_added = existing.iter().any(|saved| {
saved.connection_type == ConnectionType::GreeCloud
&& saved.cloud_device_id.as_deref().is_some_and(|value| value.eq_ignore_ascii_case(&id))
});
crate::protocol::gree_cloud::CloudDeviceView {
parent_mac: crate::protocol::gree_cloud::parent_mac(&id),
id: id.clone(),
name: device.name,
mac: id,
model: device.model,
version: device.version,
online: device.online,
already_added,
}
})
.collect::<Vec<_>>();
state.log(
"info",
"gree_cloud.discovery",
&format!("GREE Cloud discovery found {} device(s)", views.len()),
json!({"count": views.len()}),
);
Ok(Json(json!({"count": views.len(), "devices": views})))
}
async fn add_gree_cloud_device(
State(state): State<AppState>,
Path(cloud_id): Path<String>,
) -> Result<(StatusCode, Json<Device>), AppError> {
let cloud_id = cloud_id.replace([':', '-'], "").to_ascii_uppercase();
if cloud_id.is_empty() {
return Err(AppError::BadRequest("cloud device id is required".into()));
}
if state.db.list_devices()?.iter().any(|device| {
device.connection_type == ConnectionType::GreeCloud
&& device.cloud_device_id.as_deref() == Some(cloud_id.as_str())
}) {
return Err(AppError::Conflict(
"this GREE Cloud device is already added".into(),
));
}
// Re-discover server-side so the frontend never needs to submit/store the device cipher key.
let mut api = cloud_api_from_settings(&state).await?;
let started = Instant::now();
cloud_debug_event(&state, json!({
"operation":"add_device_lookup",
"phase":"sent",
"cloud_device_id": cloud_id.clone(),
}));
api.login()
.await
.map_err(|err| {
cloud_debug_event(&state, json!({
"operation":"add_device_lookup",
"phase":"error",
"stage":"login",
"duration_ms": started.elapsed().as_millis() as u64,
"kind": cloud_error_kind(&err),
}));
AppError::Dependency(format!("GREE Cloud login failed: {err}"))
})?;
let cloud_device = api
.get_all_devices()
.await
.map_err(|err| {
cloud_debug_event(&state, json!({
"operation":"add_device_lookup",
"phase":"error",
"stage":"devices",
"duration_ms": started.elapsed().as_millis() as u64,
"kind": cloud_error_kind(&err),
}));
AppError::Dependency(format!("GREE Cloud discovery failed: {err}"))
})?
.into_iter()
.find(|device| device.mac.eq_ignore_ascii_case(&cloud_id))
.ok_or_else(|| AppError::NotFound(format!("GREE Cloud device {cloud_id}")))?;
cloud_debug_event(&state, json!({
"operation":"add_device_lookup",
"phase":"response",
"duration_ms": started.elapsed().as_millis() as u64,
"cloud_device_id": cloud_id.clone(),
}));
let _configuration_guard = state.lock_configuration_operation().await;
let now = Utc::now();
let account_id = state.settings.read().await.gree_cloud.account_id.clone();
let normalized_cloud_mac = cloud_device.mac.replace([':', '-'], "").to_ascii_uppercase();
let device = Device {
id: format!("gree-cloud-{}", normalized_cloud_mac.to_ascii_lowercase()),
mac: normalized_cloud_mac.clone(),
name: if cloud_device.name.trim().is_empty() {
format!("GREE Cloud {}", &normalized_cloud_mac)
} else {
cloud_device.name.clone()
},
connection_type: ConnectionType::GreeCloud,
connection_status: ConnectionStatus::CloudDisconnected,
cloud_device_id: Some(normalized_cloud_mac.clone()),
cloud_parent_mac: Some(crate::protocol::gree_cloud::parent_mac(&normalized_cloud_mac)),
cloud_account_id: Some(account_id),
ip: String::new(),
port: 0,
// The reference HA integration currently creates CloudDevice with cipher_version=1.
protocol_version: 1,
model: cloud_device.model.unwrap_or_default(),
firmware: cloud_device.version.unwrap_or_default(),
key: Some(cloud_device.key),
cid: Some("gree-cloud".into()),
enabled: true,
simulated: false,
power: false,
mode: "cool".into(),
target_temperature: 24.0,
fan_speed: 0,
swing_vertical: false,
swing_horizontal: false,
quiet: false,
quiet_wire_value: None,
turbo: false,
light: false,
air: false,
xfan: false,
health: false,
sleep: false,
supports_light: None,
supports_quiet: None,
supports_turbo: None,
supports_air: None,
supports_xfan: None,
supports_health: None,
supports_sleep: None,
supports_buzzer_control: None,
supports_energy_meter: None,
total_energy_kwh: None,
compressor_frequency_hz: None,
last_cloud_sync: None,
current_temperature: None,
outdoor_temperature: None,
temperature_sensor_offset: None,
online: false,
response_time_ms: None,
last_seen: None,
last_error: None,
communication_failures: 0,
pending_command: false,
capabilities: crate::models::DeviceCapabilities {
vertical_swing: false,
horizontal_swing: false,
..crate::models::DeviceCapabilities::default()
},
energy_source: crate::models::EnergySourcePreference::Auto,
ha_energy_entity_id: None,
ha_energy_unit: None,
ha_energy_device_class: None,
ha_energy_state_class: None,
created_at: now,
updated_at: now,
};
state.db.save_device(&device)?;
state.log(
"info",
"gree_cloud.device_added",
&format!("Added GREE Cloud device {}", device.name),
json!({"device_id": device.id, "cloud_device_id": device.cloud_device_id}),
);
state.broadcast("device.created", serde_json::to_value(&device)?);
Ok((StatusCode::CREATED, Json(device)))
}
async fn gree_cloud_status(State(state): State<AppState>) -> Result<Json<Value>, AppError> {
let settings = state.settings.read().await.gree_cloud.clone();
let mqtt_connected = state.providers.cloud().is_connected().await;
let cloud_devices = state
.db
.list_devices()?
.into_iter()
.filter(|device| device.connection_type == ConnectionType::GreeCloud)
.collect::<Vec<_>>();
let account_status = if !settings.enabled {
"disabled"
} else if settings.username.trim().is_empty() || settings.password.trim().is_empty() {
"not_configured"
} else if cloud_devices
.iter()
.any(|device| device.connection_status == ConnectionStatus::AuthenticationError)
{
"authentication_error"
} else if mqtt_connected {
"connected"
} else {
"cloud_disconnected"
};
let runtime = state.providers.cloud().runtime_status().await;
Ok(Json(json!({
"enabled": settings.enabled,
"account_status": account_status,
"mqtt_status": if mqtt_connected { "connected" } else { "disconnected" },
"last_successful_contact": settings.last_successful_contact,
"last_rest_response_time_ms": settings.last_rest_response_time_ms,
"device_count": cloud_devices.len(),
"online_device_count": cloud_devices.iter().filter(|device| device.connection_status == ConnectionStatus::Online).count(),
"runtime": runtime,
})))
}
async fn reconnect_gree_cloud(State(state): State<AppState>) -> Result<Json<Value>, AppError> {
let settings = state.settings.read().await.gree_cloud.clone();
if !settings.enabled {
return Err(AppError::BadRequest("GREE Cloud is disabled".into()));
}
let devices = state.db.list_devices()?;
state.providers.cloud().shutdown().await;
state
.providers
.cloud()
.ensure_connected(&settings, &devices)
.await
.map_err(|error| AppError::Dependency(cloud_public_error_text(&error.to_string())))?;
let now = Utc::now();
{
let mut runtime = state.settings.write().await;
runtime.gree_cloud.last_successful_contact = Some(now);
state.db.save_runtime_settings(&runtime)?;
}
state.log(
"info",
"gree_cloud.reconnect",
"GREE Cloud MQTT reconnected",
json!({"device_count": devices.iter().filter(|device| device.connection_type == ConnectionType::GreeCloud).count()}),
);
Ok(Json(json!({"ok": true, "mqtt_status": "connected", "last_successful_contact": now})))
}
async fn cloud_device_diagnostics(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<Json<Value>, AppError> {
let device = state
.db
.get_device(&id)?
.ok_or_else(|| AppError::NotFound(format!("device {id}")))?;
if device.connection_type != ConnectionType::GreeCloud {
return Err(AppError::BadRequest("Cloud diagnostics are available only for GREE Cloud devices".into()));
}
let diagnostics = state.providers.cloud().diagnostics(&device.id).await;
Ok(Json(json!({
"device_id": device.id,
"cloud_device_id": device.cloud_device_id,
"connection_status": device.connection_status,
"last_sync": device.last_cloud_sync,
"capabilities": device.capabilities,
"provider": diagnostics,
})))
}
fn cloud_public_error_text(error: &str) -> String {
let lower = error.to_ascii_lowercase();
if lower.contains("password") || lower.contains("token") || lower.contains("authorization") {
"GREE Cloud authentication failed".into()
} else {
error.chars().take(300).collect()
}
}
+211
View File
@@ -488,3 +488,214 @@ async fn control_plan(State(state): State<AppState>) -> Result<Json<Value>, AppE
let snapshot = engine::get_control_plan_snapshot(&state).await?;
Ok(Json(serde_json::to_value(snapshot.plan.as_ref())?))
}
#[derive(Debug, Deserialize)]
struct EnergyHistoryQuery {
device_id: String,
interval: Option<String>,
source: Option<String>,
days: Option<i64>,
limit: Option<u32>,
}
async fn energy_history(
State(state): State<AppState>,
Query(query): Query<EnergyHistoryQuery>,
) -> Result<Json<Value>, AppError> {
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 interval_name = query.interval.as_deref().unwrap_or("daily");
if !matches!(interval_name, "hourly" | "daily" | "weekly" | "monthly") {
return Err(AppError::BadRequest(
"energy interval must be hourly, daily, weekly or monthly".into(),
));
}
let days = query.days.unwrap_or(31).clamp(1, 3650);
let now = Utc::now();
let since = now - ChronoDuration::days(days);
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")
.and_hms_opt(0, 0, 0)
.expect("valid midnight"),
Utc,
);
let previous_month_date_for_load = month_start_for_load.date_naive() - ChronoDuration::days(1);
let previous_month_start_for_load = chrono::DateTime::<Utc>::from_naive_utc_and_offset(
chrono::NaiveDate::from_ymd_opt(
previous_month_date_for_load.year(),
previous_month_date_for_load.month(),
1,
)
.expect("valid previous month")
.and_hms_opt(0, 0, 0)
.expect("valid midnight"),
Utc,
);
let load_since = since.min(previous_month_start_for_load);
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);
let mut storage = "sqlite".to_string();
let mut storage_warning: Option<String> = None;
let mut samples = if influx.enabled && load_since < cutoff {
match influxdb::query_energy(
&state.http,
&influx,
&device.id,
None,
load_since,
cutoff,
3600,
limit,
)
.await
{
Ok(mut archived) => {
archived.extend(state.db.list_energy_readings(&device.id, cutoff, limit)?);
storage = "influx+sqlite".into();
archived
}
Err(err) => {
storage = "sqlite_fallback".into();
storage_warning = Some(err.to_string());
state.log(
"warn",
"influx.query_error",
"InfluxDB energy history query failed",
json!({"device_id": device.id, "error": err.to_string()}),
);
state.db.list_energy_readings(&device.id, load_since, limit)?
}
}
} else {
state.db.list_energy_readings(&device.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(),
))
}
};
if let Some(source) = selected_source {
samples.retain(|row| row.source == source);
} else {
samples.clear();
}
fn midnight(date: NaiveDate) -> chrono::DateTime<Utc> {
chrono::DateTime::<Utc>::from_naive_utc_and_offset(
date.and_hms_opt(0, 0, 0).expect("valid midnight"),
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"),
Utc,
),
"weekly" => {
let iso = date.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"),
),
_ => 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);
}
let buckets = buckets
.into_iter()
.map(|(start, consumption_kwh)| {
json!({"start": start, "consumption_kwh": consumption_kwh.max(0.0)})
})
.collect::<Vec<_>>();
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 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 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()
};
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,
"source": selected_source.unwrap_or("none"),
"configured_source": device.energy_source,
"interval": interval_name,
"unit": "kWh",
"period_days": days,
"storage": storage,
"storage_warning": storage_warning,
"buckets": buckets,
"summary": {
"today": sum_range(today_start, now + ChronoDuration::seconds(1)),
"yesterday": sum_range(yesterday_start, today_start),
"current_month": sum_range(month_start, now + ChronoDuration::seconds(1)),
"previous_month": sum_range(previous_month_start, month_start),
"period_total": period_total,
},
"latest": latest,
})))
}
+33
View File
@@ -95,3 +95,36 @@ async fn test_notifications(
.map_err(AppError::Device)?;
Ok(Json(json!({"ok": true})))
}
async fn list_home_assistant_energy_sensors(
State(state): State<AppState>,
) -> Result<Json<Value>, AppError> {
let settings = state.settings.read().await.home_assistant.clone();
let entities = home_assistant::list_entities(&state.http, &settings)
.await
.map_err(|error| AppError::Device(error.to_string()))?;
let sensors = entities
.into_iter()
.filter_map(|entity| {
let attributes = entity.get("attributes")?.as_object()?;
let device_class = attributes.get("device_class")?.as_str()?;
let state_class = attributes.get("state_class")?.as_str()?;
let unit = attributes.get("unit_of_measurement")?.as_str()?;
if device_class != "energy"
|| !matches!(state_class, "total" | "total_increasing")
|| !matches!(unit.to_ascii_lowercase().as_str(), "wh" | "kwh")
{
return None;
}
Some(json!({
"entity_id": entity.get("entity_id").and_then(Value::as_str).unwrap_or_default(),
"name": attributes.get("friendly_name").and_then(Value::as_str).unwrap_or_default(),
"state": entity.get("state").and_then(Value::as_str).unwrap_or_default(),
"unit": unit,
"device_class": device_class,
"state_class": state_class,
}))
})
.collect::<Vec<_>>();
Ok(Json(json!({"sensors": sensors})))
}
+99
View File
@@ -17,6 +17,20 @@ fn gree_settings(settings: &RuntimeSettings) -> GreeSettings {
}
}
fn gree_cloud_settings(settings: &RuntimeSettings) -> GreeCloudSettingsView {
GreeCloudSettingsView {
enabled: settings.gree_cloud.enabled,
region: settings.gree_cloud.region.clone(),
username: settings.gree_cloud.username.clone(),
password_configured: !settings.gree_cloud.password.is_empty(),
polling_interval_seconds: settings.gree_cloud.polling_interval_seconds,
installation_id: settings.gree_cloud.installation_id.clone(),
account_id: settings.gree_cloud.account_id.clone(),
last_successful_contact: settings.gree_cloud.last_successful_contact,
last_rest_response_time_ms: settings.gree_cloud.last_rest_response_time_ms,
}
}
fn history_settings(settings: &RuntimeSettings) -> HistorySettings {
HistorySettings {
retention_days: settings.history_retention_days,
@@ -74,6 +88,7 @@ fn settings_snapshot(settings: &RuntimeSettings) -> SettingsSnapshot {
SettingsSnapshot {
application: application_settings(settings),
gree: gree_settings(settings),
gree_cloud: gree_cloud_settings(settings),
history: history_settings(settings),
influxdb: influxdb_settings(settings),
notifications: notification_settings(settings),
@@ -209,6 +224,84 @@ async fn update_gree_settings(
Ok(Json(payload))
}
async fn get_gree_cloud_settings(
State(state): State<AppState>,
) -> Json<GreeCloudSettingsView> {
Json(gree_cloud_settings(&*state.settings.read().await))
}
fn apply_gree_cloud_update(
current: &GreeCloudSettings,
input: GreeCloudSettingsUpdate,
) -> Result<GreeCloudSettings, AppError> {
if crate::protocol::gree_cloud::region_base_url(input.region.trim()).is_none() {
return Err(AppError::BadRequest(format!(
"unsupported GREE Cloud region: {}",
input.region
)));
}
let mut next = current.clone();
next.enabled = input.enabled;
next.region = input.region.trim().to_string();
next.username = input.username.trim().to_string();
next.polling_interval_seconds = input.polling_interval_seconds.clamp(30, 3600);
if let Some(password) = input.password {
next.password = password;
}
if next.enabled && next.username.is_empty() {
return Err(AppError::BadRequest(
"GREE Cloud login/email is required when cloud is enabled".into(),
));
}
if next.enabled && next.password.is_empty() {
return Err(AppError::BadRequest(
"GREE Cloud password is required when cloud is enabled".into(),
));
}
Ok(next)
}
async fn update_gree_cloud_settings(
State(state): State<AppState>,
Json(input): Json<GreeCloudSettingsUpdate>,
) -> Result<Json<GreeCloudSettingsView>, AppError> {
let _configuration_guard = state.lock_configuration_operation().await;
let (payload, reconnect_required) = {
let mut settings = state.settings.write().await;
let previous = settings.gree_cloud.clone();
let next = apply_gree_cloud_update(&previous, input)?;
let reconnect_required = previous.enabled != next.enabled
|| previous.region != next.region
|| previous.username != next.username
|| previous.password != next.password;
settings.gree_cloud = next;
state.db.save_runtime_settings(&settings)?;
(gree_cloud_settings(&settings), reconnect_required)
};
if reconnect_required {
// Never keep a broker session authenticated with stale/disabled account settings.
// The reconnect loop will establish a fresh session when Cloud remains enabled.
state.providers.cloud().shutdown().await;
}
state.log(
"info",
"settings.gree_cloud.updated",
"GREE Cloud settings updated",
json!({
"enabled": payload.enabled,
"region": payload.region,
"polling_interval_seconds": payload.polling_interval_seconds,
"password_configured": payload.password_configured
}),
);
state.broadcast(
"settings.gree_cloud.updated",
serde_json::to_value(&payload)?,
);
Ok(Json(payload))
}
async fn get_history_settings(State(state): State<AppState>) -> Json<HistorySettings> {
Json(history_settings(&*state.settings.read().await))
}
@@ -645,6 +738,12 @@ async fn update_debug_settings(
state
.debug_gree_frames
.store(input.gree_frames, Ordering::Relaxed);
state
.debug_cloud_requests
.store(input.cloud_requests, Ordering::Relaxed);
state
.debug_cloud_mqtt
.store(input.cloud_mqtt, Ordering::Relaxed);
state.broadcast("settings.debug.updated", serde_json::to_value(&input)?);
Ok(Json(input))
}
+1 -1
View File
@@ -79,7 +79,7 @@ async fn build_bootstrap(state: &AppState) -> Result<BootstrapResponse, AppError
}
fn build_system_info(state: &AppState, devices: &[Device]) -> SystemInfoResponse {
let (received_frames_total, received_frames_by_device) = state.gree.received_frame_stats();
let (received_frames_total, received_frames_by_device) = state.providers.local().client().received_frame_stats();
SystemInfoResponse {
version: env!("CARGO_PKG_VERSION"),
uptime_seconds: state.started.elapsed().as_secs(),
+10 -1
View File
@@ -1,5 +1,5 @@
use crate::models::{
DebugSettings, HomeAssistantSettings, InfluxDbSettings, NightModeSettings,
DebugSettings, GreeCloudSettings, HomeAssistantSettings, InfluxDbSettings, NightModeSettings,
NotificationSettings, RuntimeSettings,
};
use anyhow::{Context, Result};
@@ -100,8 +100,11 @@ impl Config {
debug: DebugSettings {
overlay_enabled: env_bool("GREE_CONTROLLER_DEBUG_OVERLAY").unwrap_or(false),
gree_frames: env_bool("GREE_CONTROLLER_DEBUG_GREE_FRAMES").unwrap_or(false),
cloud_requests: env_bool("GREE_CONTROLLER_DEBUG_CLOUD_REQUESTS").unwrap_or(false),
cloud_mqtt: env_bool("GREE_CONTROLLER_DEBUG_CLOUD_MQTT").unwrap_or(false),
},
notifications: NotificationSettings::default(),
gree_cloud: GreeCloudSettings::default(),
night_mode: NightModeSettings {
enabled: env_bool("GREE_CONTROLLER_NIGHT_MODE_ENABLED").unwrap_or(false),
start_time: env::var("GREE_CONTROLLER_NIGHT_MODE_START")
@@ -162,6 +165,12 @@ impl Config {
if let Some(value) = env_bool("GREE_CONTROLLER_DEBUG_GREE_FRAMES") {
settings.debug.gree_frames = value;
}
if let Some(value) = env_bool("GREE_CONTROLLER_DEBUG_CLOUD_REQUESTS") {
settings.debug.cloud_requests = value;
}
if let Some(value) = env_bool("GREE_CONTROLLER_DEBUG_CLOUD_MQTT") {
settings.debug.cloud_mqtt = value;
}
if let Some(value) = env_bool("GREE_CONTROLLER_NIGHT_MODE_ENABLED") {
settings.night_mode.enabled = value;
}
+3 -2
View File
@@ -1,7 +1,7 @@
use crate::{
models::{
ApiTokenInfo, Automation, ClimateGroup, ConfigurationExport, Device, EventLog, Flow,
HaReading, Reading, RuntimeSettings, Schedule, Zone, ZoneReading,
ApiTokenInfo, Automation, ClimateGroup, ConfigurationExport, ConnectionType, Device, EventLog, Flow,
EnergyReading, HaReading, Reading, RuntimeSettings, Schedule, Zone, ZoneReading,
},
queries,
};
@@ -28,6 +28,7 @@ include!("db/flows.rs");
include!("db/device_history.rs");
include!("db/zone_history.rs");
include!("db/ha_history.rs");
include!("db/energy_history.rs");
include!("db/events_tokens.rs");
include!("db/configuration.rs");
include!("db/tests.rs");
+6 -2
View File
@@ -18,12 +18,16 @@ impl Db {
let tx = conn.transaction()?;
tx.execute_batch(queries::CLEAR_CONFIGURATION)?;
for device in &export.devices {
let payload = Self::to_json(device)?;
let payload = Self::device_to_storage_json(device)?;
let index_mac = match device.connection_type {
ConnectionType::Local => device.mac.clone(),
ConnectionType::GreeCloud => format!("cloud:{}", device.cloud_device_id.as_deref().unwrap_or(&device.mac)),
};
tx.execute(
queries::UPSERT_DEVICE,
params![
device.id,
device.mac,
index_mac,
device.name,
device.ip,
device.simulated as i64,
+19 -2
View File
@@ -23,6 +23,18 @@ impl Db {
Ok(serde_json::to_string(value)?)
}
/// Device protocol keys are intentionally omitted by the public `Device` serializer.
/// Persist them only in the private SQLite payload so normal API responses never expose them.
fn device_to_storage_json(device: &Device) -> Result<String> {
let mut value = serde_json::to_value(device)?;
if let Some(object) = value.as_object_mut() {
if let Some(key) = device.key.as_ref().filter(|key| !key.is_empty()) {
object.insert("key".into(), serde_json::Value::String(key.clone()));
}
}
Ok(serde_json::to_string(&value)?)
}
pub fn count_devices(&self) -> Result<u64> {
let conn = self.lock()?;
let count: i64 = conn.query_row(queries::COUNT_DEVICES, [], |row| row.get(0))?;
@@ -30,13 +42,17 @@ impl Db {
}
pub fn save_device(&self, device: &Device) -> Result<()> {
let payload = Self::to_json(device)?;
let payload = Self::device_to_storage_json(device)?;
let index_mac = match device.connection_type {
ConnectionType::Local => device.mac.clone(),
ConnectionType::GreeCloud => format!("cloud:{}", device.cloud_device_id.as_deref().unwrap_or(&device.mac)),
};
let conn = self.lock()?;
conn.execute(
queries::UPSERT_DEVICE,
params![
device.id,
device.mac,
index_mac,
device.name,
device.ip,
device.simulated as i64,
@@ -76,6 +92,7 @@ impl Db {
let mut conn = self.lock()?;
let tx = conn.transaction()?;
tx.execute(queries::DELETE_DEVICE_READINGS, [id])?;
tx.execute(queries::DELETE_DEVICE_ENERGY_READINGS, [id])?;
tx.execute(queries::DELETE_ZONE_READINGS_BY_DEVICE_ID, [id])?;
tx.execute(queries::DELETE_SCHEDULES_BY_DEVICE_ID, [id])?;
tx.execute(queries::DELETE_ZONES_BY_DEVICE_ID, [id])?;
+95
View File
@@ -0,0 +1,95 @@
impl Db {
pub fn add_energy_reading(&self, reading: &EnergyReading) -> Result<i64> {
let conn = self.lock()?;
conn.execute(
"INSERT INTO energy_readings(device_id,timestamp,source,raw_meter_value,raw_unit,normalized_meter_kwh,consumption_kwh,current_power_kw,quality,reset_detected) VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10)",
params![
reading.device_id,
reading.timestamp.to_rfc3339(),
reading.source,
reading.raw_meter_value,
reading.raw_unit,
reading.normalized_meter_kwh,
reading.consumption_kwh.max(0.0),
reading.current_power_kw,
reading.quality,
reading.reset_detected as i64,
],
)?;
Ok(conn.last_insert_rowid())
}
pub fn last_energy_reading(&self, device_id: &str, source: &str) -> Result<Option<EnergyReading>> {
let conn = self.lock()?;
conn.query_row(
"SELECT id,device_id,timestamp,source,raw_meter_value,raw_unit,normalized_meter_kwh,consumption_kwh,current_power_kw,quality,reset_detected FROM energy_readings WHERE device_id=?1 AND source=?2 ORDER BY timestamp DESC,id DESC LIMIT 1",
params![device_id, source],
Self::map_energy_reading,
).optional().map_err(Into::into)
}
pub fn list_energy_readings(
&self,
device_id: &str,
since: DateTime<Utc>,
limit: u32,
) -> Result<Vec<EnergyReading>> {
let conn = self.lock()?;
let mut stmt = conn.prepare(
"SELECT id,device_id,timestamp,source,raw_meter_value,raw_unit,normalized_meter_kwh,consumption_kwh,current_power_kw,quality,reset_detected FROM energy_readings WHERE device_id=?1 AND timestamp>=?2 ORDER BY timestamp ASC,id ASC LIMIT ?3",
)?;
let rows = stmt.query_map(
params![device_id, since.to_rfc3339(), limit.clamp(1, 100_000) as i64],
Self::map_energy_reading,
)?;
rows.collect::<rusqlite::Result<Vec<_>>>().map_err(Into::into)
}
pub fn energy_before(&self, before: DateTime<Utc>, limit: u32) -> Result<Vec<EnergyReading>> {
let conn = self.lock()?;
let mut stmt = conn.prepare(
"SELECT id,device_id,timestamp,source,raw_meter_value,raw_unit,normalized_meter_kwh,consumption_kwh,current_power_kw,quality,reset_detected FROM energy_readings WHERE timestamp<?1 ORDER BY timestamp ASC,id ASC LIMIT ?2",
)?;
let rows = stmt.query_map(
params![before.to_rfc3339(), limit.clamp(1, 5000) as i64],
Self::map_energy_reading,
)?;
rows.collect::<rusqlite::Result<Vec<_>>>().map_err(Into::into)
}
pub fn delete_energy_batch(&self, rows: &[EnergyReading]) -> Result<u64> {
let mut conn = self.lock()?;
let tx = conn.transaction()?;
let mut changed = 0_u64;
for row in rows {
changed += tx.execute("DELETE FROM energy_readings WHERE id=?1", [row.id])? as u64;
}
tx.commit()?;
Ok(changed)
}
pub fn prune_energy_readings(&self, retention_days: i64) -> Result<u64> {
let before = Utc::now() - Duration::days(retention_days.max(1));
let conn = self.lock()?;
Ok(conn.execute("DELETE FROM energy_readings WHERE timestamp < ?1", [before.to_rfc3339()])? as u64)
}
fn map_energy_reading(row: &rusqlite::Row<'_>) -> rusqlite::Result<EnergyReading> {
let timestamp: String = row.get(2)?;
Ok(EnergyReading {
id: row.get(0)?,
device_id: row.get(1)?,
timestamp: DateTime::parse_from_rfc3339(&timestamp)
.map(|value| value.with_timezone(&Utc))
.unwrap_or_else(|_| Utc::now()),
source: row.get(3)?,
raw_meter_value: row.get(4)?,
raw_unit: row.get(5)?,
normalized_meter_kwh: row.get(6)?,
consumption_kwh: row.get::<_, f64>(7)?.max(0.0),
current_power_kw: row.get(8)?,
quality: row.get(9)?,
reset_detected: row.get::<_, i64>(10)? != 0,
})
}
}
+25 -1
View File
@@ -1,7 +1,7 @@
#[cfg(test)]
mod tests {
use super::*;
use crate::models::{ApiTokenInfo, Device, HaReading, Reading};
use crate::models::{ApiTokenInfo, ConnectionType, Device, HaReading, Reading};
#[test]
fn history_compaction_keeps_one_sample_per_old_bucket() {
@@ -129,4 +129,28 @@ mod tests {
);
assert_eq!(db.history_counts().unwrap(), (1, 0, 1));
}
#[test]
fn local_and_cloud_entries_with_same_physical_mac_can_coexist() {
let dir = tempfile::tempdir().unwrap();
let db = Db::open(&dir.path().join("duplicate-transport.db")).unwrap();
let mut local = Device::simulated_default();
local.id = "local-device".into();
local.mac = "AABBCCDDEEFF".into();
local.connection_type = ConnectionType::Local;
let mut cloud = local.clone();
cloud.id = "cloud-device".into();
cloud.connection_type = ConnectionType::GreeCloud;
cloud.cloud_device_id = Some(local.mac.clone());
cloud.ip.clear();
cloud.port = 0;
cloud.key = Some("0123456789abcdef".into());
db.save_device(&local).unwrap();
db.save_device(&cloud).unwrap();
let devices = db.list_devices().unwrap();
assert_eq!(devices.len(), 2);
assert!(devices.iter().any(|item| item.connection_type == ConnectionType::Local));
assert!(devices.iter().any(|item| item.connection_type == ConnectionType::GreeCloud));
}
}
+3 -2
View File
@@ -2,7 +2,7 @@ use crate::{
error::AppError,
home_assistant, influxdb,
models::{
Automation, AutomationPlanRule, ClimateGroup, ControlPlan, ControlPlanEvent, Device,
Automation, AutomationPlanRule, ClimateGroup, ConnectionStatus, ConnectionType, ControlPlan, ControlPlanEvent, Device, EnergyReading,
DeviceCommand, GroupControlPatch, HaReading, NightModeSettings, Reading, RuntimeSettings,
Schedule, TemporaryQuickThermostat, Zone, ZoneControlPlan, ZoneReading,
},
@@ -16,7 +16,7 @@ use std::{
sync::{atomic::Ordering, Arc},
time::{Duration, Instant},
};
use tokio::time::sleep;
use tokio::{sync::broadcast, time::sleep};
// Functional source split intentionally keeps items in the existing module namespace.
include!("engine/runtime.rs");
@@ -30,6 +30,7 @@ include!("engine/groups.rs");
include!("engine/zone_actions.rs");
include!("engine/zone_control.rs");
include!("engine/history.rs");
include!("engine/energy.rs");
include!("engine/temperature.rs");
include!("engine/targets.rs");
include!("engine/schedules.rs");
+209 -7
View File
@@ -29,6 +29,16 @@ async fn send_command_locked_inner(
if !device.enabled {
return Err(AppError::BadRequest("device is disabled".into()));
}
if device.connection_type == ConnectionType::GreeCloud {
return send_cloud_command_locked_inner(
state,
device,
command,
dedupe_against_cache,
track_controller_command,
)
.await;
}
// Routine control avoids redundant frames. Explicit safety transitions (global/group OFF,
// detach) may bypass cache de-duplication so stale state cannot leave a unit powered.
@@ -57,7 +67,7 @@ async fn send_command_locked_inner(
state.db.save_device(&device)?;
} else {
if device.key.as_deref().unwrap_or_default().is_empty() {
match state.gree.bind(&device).await {
match state.providers.local().bind(&device).await {
Ok(bound) => {
device.key = Some(bound.key);
device.protocol_version = bound.protocol_version;
@@ -71,14 +81,14 @@ async fn send_command_locked_inner(
}
}
}
match state.gree.command(&device, &command, suppress_beep).await {
match state.providers.local().command(&device, &command, suppress_beep).await {
Ok(result) => applied_command = result,
Err(first_err) => {
// A lost command ACK does not mean the command was lost. Read the device
// first and avoid sending the same frame (and another beep) when the requested
// state is already present. Only rebind when the verification read also fails.
let mut observed = device.clone();
let retry_result = match state.gree.poll(&mut observed).await {
let retry_result = match state.providers.local().poll(&mut observed).await {
Ok(()) if command.changed_from(&observed).is_empty() => {
device = observed;
confirmed_state = true;
@@ -91,15 +101,15 @@ async fn send_command_locked_inner(
if remaining.is_empty() {
Ok(command.clone())
} else {
state.gree.command(&device, &remaining, suppress_beep).await
state.providers.local().command(&device, &remaining, suppress_beep).await
}
}
Err(_) => match state.gree.bind(&device).await {
Err(_) => match state.providers.local().bind(&device).await {
Ok(bound) => {
device.key = Some(bound.key);
device.protocol_version = bound.protocol_version;
state.db.save_device(&device)?;
state.gree.command(&device, &command, suppress_beep).await
state.providers.local().command(&device, &command, suppress_beep).await
}
Err(_) => Err(first_err),
},
@@ -132,7 +142,7 @@ async fn send_command_locked_inner(
sleep(Duration::from_millis(delay_ms)).await;
}
let mut observed = device.clone();
match state.gree.poll(&mut observed).await {
match state.providers.local().poll(&mut observed).await {
Ok(()) => {
let requested_matches = applied_command.changed_from(&observed).is_empty();
device = observed;
@@ -231,6 +241,198 @@ async fn send_command_locked_inner(
Ok(device)
}
async fn send_cloud_command_locked_inner(
state: &AppState,
mut device: Device,
command: DeviceCommand,
dedupe_against_cache: bool,
track_controller_command: bool,
) -> Result<Device, AppError> {
validate_cloud_command_capabilities(&device, &command)?;
let command = if dedupe_against_cache && device.online && device.communication_failures == 0 {
command.changed_from(&device)
} else {
command
};
if command.is_empty() {
return Ok(device);
}
let baseline = device.clone();
let suppress_beep = state.settings.read().await.suppress_device_beep;
let cloud_settings = state.settings.read().await.gree_cloud.clone();
let all_devices = state.db.list_devices()?;
let response_started = Instant::now();
// Optimistic UI state is explicit and reversible. The provider never falls back to UDP.
device.pending_command = true;
command.apply(&mut device);
if let Some(target) = command.target_temperature {
let step = device.capabilities.temperature_step.max(0.5);
let rounded = (target / step).round() * step;
device.target_temperature = rounded.clamp(
device.capabilities.min_temperature,
device.capabilities.max_temperature,
);
}
device.updated_at = Utc::now();
state.db.save_device(&device)?;
state.broadcast("device.updated", serde_json::to_value(&device).unwrap_or_default());
let mut transport_device = baseline.clone();
let applied = match state
.providers
.cloud()
.command(
&cloud_settings,
&all_devices,
&mut transport_device,
&command,
suppress_beep,
)
.await
{
Ok(applied) => applied,
Err(err) => {
let mut restored = baseline.clone();
restored.pending_command = false;
register_cloud_failure(&mut restored, &err.to_string());
state.db.save_device(&restored)?;
state.broadcast("device.updated", serde_json::to_value(&restored).unwrap_or_default());
state.log(
"warn",
"gree_cloud.command_rejected",
&format!("Cloud command failed for {}", restored.name),
json!({"device_id": restored.id, "error": cloud_public_error(&err.to_string())}),
);
return Err(AppError::Dependency(cloud_public_error(&err.to_string())));
}
};
// The reference cloud client treats a missing 2s command ACK as uncertain success.
// Do not turn that into a synchronous chain of 10s status reads: it makes Manual Control
// look broken even when the unit accepted the command. Publish the optimistic state now;
// MQTT push is the primary confirmation path and one delayed recovery read is scheduled.
let mut accepted = transport_device;
accepted.pending_command = true;
accepted.response_time_ms = Some(
response_started
.elapsed()
.as_millis()
.min(u64::MAX as u128) as u64,
);
accepted.updated_at = Utc::now();
accepted.refresh_capabilities();
state.db.save_device(&accepted)?;
if track_controller_command && !command_manual_control_fields(&applied).is_empty() {
remember_controller_command(state, &accepted.id, &applied, &baseline).await;
}
record_device_transition_timestamps(state, &baseline, &accepted)?;
state.log(
"info",
"gree_cloud.command_sent",
&format!("Updated {} through GREE Cloud", accepted.name),
json!({
"device_id": accepted.id,
"command": applied,
"confirmation": "mqtt_push_or_recovery_poll",
}),
);
state.broadcast(
"device.updated",
serde_json::to_value(&accepted).unwrap_or_default(),
);
// Recovery is deliberately asynchronous. It waits until the command handler releases
// the per-device lock, then performs one normal provider poll. A successful push may
// already have confirmed the state by then; the poll is only a bounded fallback.
let recovery_state = state.clone();
let recovery_device_id = accepted.id.clone();
tokio::spawn(async move {
sleep(Duration::from_millis(1500)).await;
let Some(_cloud_poll_guard) = recovery_state.try_begin_cloud_poll(&recovery_device_id) else {
tracing::debug!(device=%recovery_device_id, "skipping duplicate GREE Cloud recovery poll");
return;
};
if let Err(err) = poll_one(&recovery_state, &recovery_device_id).await {
tracing::debug!(
device=%recovery_device_id,
error=?err,
"GREE Cloud post-command recovery poll did not confirm state"
);
}
});
Ok(accepted)
}
fn validate_cloud_command_capabilities(
device: &Device,
command: &DeviceCommand,
) -> Result<(), AppError> {
let unsupported = if command.swing_vertical.is_some() && !device.capabilities.vertical_swing {
Some("vertical swing")
} else if command.swing_horizontal.is_some() && !device.capabilities.horizontal_swing {
Some("horizontal swing")
} else if command.quiet.is_some() && device.supports_quiet == Some(false) {
Some("quiet")
} else if command.turbo.is_some() && device.supports_turbo == Some(false) {
Some("turbo")
} else if command.light.is_some() && device.supports_light == Some(false) {
Some("light")
} else if command.air.is_some() && device.supports_air == Some(false) {
Some("air")
} else if command.xfan.is_some() && device.supports_xfan == Some(false) {
Some("X-Fan")
} else if command.health.is_some() && device.supports_health == Some(false) {
Some("health")
} else if command.sleep.is_some() && device.supports_sleep == Some(false) {
Some("sleep")
} else {
None
};
if let Some(feature) = unsupported {
return Err(AppError::BadRequest(format!(
"{} does not report support for {feature}",
device.name
)));
}
Ok(())
}
fn register_cloud_failure(device: &mut Device, error: &str) {
device.communication_failures = device.communication_failures.saturating_add(1);
device.online = false;
device.connection_status = cloud_connection_status(error);
device.response_time_ms = None;
if device.last_seen.is_none() {
device.last_cloud_sync = None;
}
device.last_error = Some(cloud_public_error(error));
device.updated_at = Utc::now();
}
fn cloud_connection_status(error: &str) -> ConnectionStatus {
let error = error.to_ascii_lowercase();
if error.contains("authentication") || error.contains("not authorized") || error.contains("invalid username") {
ConnectionStatus::AuthenticationError
} else if error.contains("mqtt") || error.contains("connect") || error.contains("tls") || error.contains("network") {
ConnectionStatus::CloudDisconnected
} else {
ConnectionStatus::Offline
}
}
fn cloud_public_error(error: &str) -> String {
let lower = error.to_ascii_lowercase();
if lower.contains("password") || lower.contains("token") || lower.contains("authorization") {
"GREE Cloud authentication failed".into()
} else {
error.chars().take(300).collect()
}
}
fn record_device_transition_timestamps(
state: &AppState,
before: &Device,
+147
View File
@@ -0,0 +1,147 @@
const ENERGY_ANOMALY_MAX_DELTA_KWH: f64 = 100.0;
fn cumulative_energy_delta(previous_kwh: Option<f64>, current_kwh: f64) -> (f64, &'static str, bool) {
let Some(previous_kwh) = previous_kwh else {
return (0.0, "baseline", false);
};
let delta = current_kwh - previous_kwh;
if delta.abs() < 0.000_000_1 {
(0.0, "duplicate", false)
} else if delta < 0.0 {
(0.0, "reset", true)
} else if delta > ENERGY_ANOMALY_MAX_DELTA_KWH {
(0.0, "anomaly_large_jump", true)
} else {
(delta.max(0.0), "ok", false)
}
}
fn normalize_energy_kwh(raw_value: f64, unit: &str) -> Result<f64, AppError> {
if !raw_value.is_finite() || raw_value < 0.0 {
return Err(AppError::BadRequest("energy meter value must be a finite non-negative number".into()));
}
match unit.trim().to_ascii_lowercase().as_str() {
"kwh" => Ok(raw_value),
"wh" => Ok(raw_value / 1000.0),
"0.1kwh" | "0.1 kwh" => Ok(raw_value * 0.1),
other => Err(AppError::BadRequest(format!("unsupported energy unit: {other}"))),
}
}
pub(crate) fn record_cumulative_energy_sample(
state: &AppState,
device_id: &str,
source: &str,
raw_value: f64,
raw_unit: &str,
current_power_kw: Option<f64>,
) -> Result<EnergyReading, AppError> {
let normalized = normalize_energy_kwh(raw_value, raw_unit)?;
let previous = state.db.last_energy_reading(device_id, source)?;
let (consumption, quality, reset_detected) = cumulative_energy_delta(
previous.as_ref().map(|row| row.normalized_meter_kwh),
normalized,
);
let reading = EnergyReading {
id: 0,
device_id: device_id.to_string(),
timestamp: Utc::now(),
source: source.to_string(),
raw_meter_value: raw_value,
raw_unit: raw_unit.to_string(),
normalized_meter_kwh: normalized,
consumption_kwh: consumption.max(0.0),
current_power_kw: current_power_kw.filter(|value| value.is_finite() && *value >= 0.0),
quality: quality.to_string(),
reset_detected,
};
state.db.add_energy_reading(&reading)?;
queue_influx_energy(state, reading.clone());
Ok(reading)
}
fn queue_influx_energy(state: &AppState, reading: EnergyReading) {
let state = state.clone();
tokio::spawn(async move {
let settings = state.settings.read().await.influxdb.clone();
if !settings.enabled { return; }
if let Err(err) = influxdb::write_energy(&state.http, &settings, &reading).await {
tracing::warn!(error=?err, device_id=%reading.device_id, source=%reading.source, "cannot write energy metric to InfluxDB");
}
});
}
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(());
};
let settings = state.settings.read().await.home_assistant.clone();
let payload = home_assistant::read_entity(&state.http, &settings, Some(entity_id))
.await
.map_err(|err| AppError::Device(err.to_string()))?;
let state_value = payload.get("state").and_then(Value::as_str).unwrap_or_default();
if matches!(state_value, "" | "unknown" | "unavailable") { return Ok(()); }
let raw_value: f64 = state_value.parse().map_err(|_| AppError::Device("Home Assistant energy state is not numeric".into()))?;
let attrs = payload.get("attributes").and_then(Value::as_object).cloned().unwrap_or_default();
let device_class = attrs.get("device_class").and_then(Value::as_str).unwrap_or_default();
let state_class = attrs.get("state_class").and_then(Value::as_str).unwrap_or_default();
let unit = attrs.get("unit_of_measurement").and_then(Value::as_str).unwrap_or_default();
if device_class != "energy" || !matches!(state_class, "total" | "total_increasing") {
return Err(AppError::BadRequest("selected Home Assistant entity is not a cumulative energy sensor".into()));
}
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)?;
Ok(())
}
pub(crate) async fn home_assistant_energy_loop(state: AppState) {
sleep(Duration::from_secs(10)).await;
loop {
let settings = state.settings.read().await.home_assistant.clone();
if !settings.url.trim().is_empty() && !settings.token.trim().is_empty() {
if let Ok(devices) = state.db.list_devices() {
for device in devices.into_iter().filter(|device| device.enabled && device.ha_energy_entity_id.is_some()) {
if let Err(err) = sample_home_assistant_energy_device(&state, &device).await {
tracing::warn!(device=%device.id, error=?err, "Home Assistant energy sample failed");
}
}
}
}
sleep(Duration::from_secs(60)).await;
}
}
#[cfg(test)]
mod energy_tests {
use super::*;
#[test]
fn normalizes_wh_and_kwh() {
assert!((normalize_energy_kwh(1500.0, "Wh").unwrap() - 1.5).abs() < 1e-9);
assert!((normalize_energy_kwh(1.5, "kWh").unwrap() - 1.5).abs() < 1e-9);
}
#[test]
fn cumulative_counter_becomes_non_negative_delta() {
assert_eq!(cumulative_energy_delta(None, 152.1), (0.0, "baseline", false));
let (delta, quality, reset) = cumulative_energy_delta(Some(152.1), 152.4);
assert!((delta - 0.3).abs() < 1e-9);
assert_eq!(quality, "ok");
assert!(!reset);
assert_eq!(cumulative_energy_delta(Some(152.4), 152.4), (0.0, "duplicate", false));
assert_eq!(cumulative_energy_delta(Some(153.0), 1.0), (0.0, "reset", true));
assert_eq!(cumulative_energy_delta(Some(1.0), 150.0), (0.0, "anomaly_large_jump", true));
}
#[test]
fn unit_changes_normalize_before_delta() {
let wh = normalize_energy_kwh(152_400.0, "Wh").unwrap();
let kwh = normalize_energy_kwh(153.0, "kWh").unwrap();
let (delta, quality, reset) = cumulative_energy_delta(Some(wh), kwh);
assert!((delta - 0.6).abs() < 1e-9);
assert_eq!(quality, "ok");
assert!(!reset);
}
}
+2 -1
View File
@@ -141,7 +141,8 @@ fn manual_override_matches_baseline(zone: &Zone, device: &Device) -> bool {
"power" => device.power == baseline.power,
"mode" => device.mode == baseline.mode,
"target_temperature" => {
device.target_temperature.round() == baseline.target_temperature.round()
let step = device.capabilities.temperature_step.max(0.5);
(device.target_temperature / step).round() == (baseline.target_temperature / step).round()
}
"fan_speed" => device.fan_speed == baseline.fan_speed,
"quiet" => device.quiet == baseline.quiet,
+204 -15
View File
@@ -56,14 +56,58 @@ async fn poll_one_locked(state: &AppState, device_id: &str) -> Result<Device, Ap
}
pub(crate) async fn poll_all(state: &AppState) -> Result<()> {
let device_ids: Vec<String> = state.db.list_devices()?.into_iter()
.filter(|device| device.enabled)
.map(|device| device.id)
.collect();
for device_id in device_ids {
let _zone_guards = lock_poll_zone_operations(state, &device_id).await?;
let _device_guard = state.lock_device_operation(&device_id).await;
let _ = poll_one_locked(state, &device_id).await?;
let devices = state.db.list_devices()?;
let cloud_interval = state
.settings
.read()
.await
.gree_cloud
.polling_interval_seconds
.max(30);
let startup = !state.initial_device_sync_complete.load(Ordering::Acquire);
// LAN always runs first and keeps its historical sequential locking/transport behavior.
// Cloud work is detached afterwards so an Internet/broker timeout cannot delay UDP cycles.
for device in devices.iter().filter(|device| device.enabled && device.connection_type == ConnectionType::Local) {
let _zone_guards = lock_poll_zone_operations(state, &device.id).await?;
let _device_guard = state.lock_device_operation(&device.id).await;
let _ = poll_one_locked(state, &device.id).await?;
}
for device in devices.into_iter().filter(|device| device.enabled && device.connection_type == ConnectionType::GreeCloud) {
// A failed Cloud read has no last_cloud_sync, so use updated_at (which is refreshed
// on failures) as the retry baseline. Otherwise an offline unit would be considered
// due on every fast LAN poll cycle and detached tasks would accumulate indefinitely.
let retry_baseline = device.last_cloud_sync.unwrap_or(device.updated_at);
let due = startup
|| Utc::now()
.signed_duration_since(retry_baseline)
.num_seconds()
>= cloud_interval as i64;
if !due { continue; }
if startup {
// Persist a conservative startup state before the asynchronous Cloud read. This
// prevents a stale pre-restart Online snapshot from driving thermostat commands.
let mut pending = device.clone();
pending.online = false;
pending.connection_status = ConnectionStatus::Unknown;
pending.response_time_ms = None;
state.db.save_device(&pending)?;
}
// Do not enqueue another detached poll while one for this Cloud device is still
// running or waiting on its operation lock. This is deliberately outside Tokio's
// async lock graph so an offline device cannot create an ever-growing waiter queue.
let Some(cloud_poll_guard) = state.try_begin_cloud_poll(&device.id) else {
continue;
};
let state = state.clone();
let device_id = device.id.clone();
tokio::spawn(async move {
let _cloud_poll_guard = cloud_poll_guard;
if let Err(err) = poll_one(&state, &device_id).await {
tracing::warn!(device=%device_id, error=?err, "GREE Cloud fallback poll failed");
}
});
}
Ok(())
}
@@ -82,6 +126,35 @@ pub(crate) async fn poll_all_locked(state: &AppState) -> Result<()> {
}
async fn poll_device(state: &AppState, device: &mut Device) {
if device.connection_type == ConnectionType::GreeCloud {
let previous_failures = device.communication_failures;
let response_started = Instant::now();
let cloud_settings = state.settings.read().await.gree_cloud.clone();
let all_devices = match state.db.list_devices() {
Ok(items) => items,
Err(err) => {
register_cloud_poll_failure(device, &err.to_string());
return;
}
};
match state.providers.cloud().poll(&cloud_settings, &all_devices, device).await {
Ok(()) => {
device.pending_command = false;
device.response_time_ms = Some(response_started.elapsed().as_millis().min(u64::MAX as u128) as u64);
device.refresh_capabilities();
if previous_failures > 0 {
state.log("info", "gree_cloud.device_online", &format!("{} is online through GREE Cloud", device.name), json!({"device_id": device.id}));
}
}
Err(err) => {
register_cloud_poll_failure(device, &err.to_string());
if previous_failures == 0 {
state.log("warn", "gree_cloud.device_offline", &format!("{} Cloud status failed", device.name), json!({"device_id": device.id, "error": cloud_poll_public_error(&err.to_string())}));
}
}
}
return;
}
if device.simulated {
simulate_tick(device);
return;
@@ -89,7 +162,7 @@ async fn poll_device(state: &AppState, device: &mut Device) {
let previous_failures = device.communication_failures;
let response_started = Instant::now();
if device.key.as_deref().unwrap_or_default().is_empty() {
match state.gree.bind(device).await {
match state.providers.local().bind(device).await {
Ok(bound) => {
device.key = Some(bound.key);
device.protocol_version = bound.protocol_version;
@@ -102,18 +175,18 @@ async fn poll_device(state: &AppState, device: &mut Device) {
}
}
}
if let Err(first_err) = state.gree.poll(device).await {
if let Err(first_err) = state.providers.local().poll(device).await {
// One lost UDP response is common on Wi-Fi and must not trigger a bind storm.
// Rebind only after at least one consecutive failed poll; a successful retry clears
// the counter in GreeClient::poll.
if previous_failures == 0 {
record_poll_failure(device, &first_err.to_string());
} else {
match state.gree.bind(device).await {
match state.providers.local().bind(device).await {
Ok(bound) => {
device.key = Some(bound.key);
device.protocol_version = bound.protocol_version;
if let Err(err) = state.gree.poll(device).await {
if let Err(err) = state.providers.local().poll(device).await {
record_poll_failure(device, &err.to_string());
}
}
@@ -122,11 +195,40 @@ async fn poll_device(state: &AppState, device: &mut Device) {
}
}
if device.communication_failures == 0 && device.online {
device.connection_status = ConnectionStatus::Online;
device.response_time_ms = Some(response_started.elapsed().as_millis().min(u64::MAX as u128) as u64);
}
log_poll_health_transition(state, device, previous_failures).await;
}
fn register_cloud_poll_failure(device: &mut Device, error: &str) {
device.communication_failures = device.communication_failures.saturating_add(1);
device.online = false;
let lower = error.to_ascii_lowercase();
device.connection_status = if lower.contains("authentication") || lower.contains("not authorized") {
ConnectionStatus::AuthenticationError
} else if lower.contains("mqtt") || lower.contains("connect") || lower.contains("tls") {
ConnectionStatus::CloudDisconnected
} else {
ConnectionStatus::Offline
};
device.response_time_ms = None;
if device.last_seen.is_none() {
device.last_cloud_sync = None;
}
device.last_error = Some(cloud_poll_public_error(error));
device.updated_at = Utc::now();
}
fn cloud_poll_public_error(error: &str) -> String {
let lower = error.to_ascii_lowercase();
if lower.contains("password") || lower.contains("token") || lower.contains("authorization") {
"GREE Cloud authentication failed".into()
} else {
error.chars().take(300).collect()
}
}
async fn log_poll_health_transition(state: &AppState, device: &Device, previous_failures: u8) {
let threshold = state.settings.read().await.notifications.communication_failure_threshold.max(2);
let current_failures = u32::from(device.communication_failures);
@@ -190,7 +292,7 @@ fn record_reading(state: &AppState, device: &Device) -> Result<()> {
fn record_poll_failure(device: &mut Device, error: &str) {
device.communication_failures = device.communication_failures.saturating_add(1);
// A single dropped UDP response is not enough to declare an AC offline.
if device.communication_failures >= 3 { device.online = false; }
if device.communication_failures >= 3 { device.online = false; device.connection_status = ConnectionStatus::Offline; }
device.last_error = Some(error.to_string());
device.updated_at = Utc::now();
}
@@ -278,7 +380,11 @@ fn command_field_matches_device(command: &DeviceCommand, field: &str, device: &D
"power" => command.power.map(|value| value == device.power).unwrap_or(false),
"mode" => command.mode.as_deref().map(|value| value == device.mode.as_str()).unwrap_or(false),
"target_temperature" => command.target_temperature
.map(|value| value.clamp(8.0, 30.0).round() == device.target_temperature.clamp(8.0, 30.0).round())
.map(|value| {
let step = device.capabilities.temperature_step.max(0.5);
(value.clamp(device.capabilities.min_temperature, device.capabilities.max_temperature) / step).round()
== (device.target_temperature.clamp(device.capabilities.min_temperature, device.capabilities.max_temperature) / step).round()
})
.unwrap_or(false),
"fan_speed" => command.fan_speed.map(|value| value.min(5) == device.fan_speed).unwrap_or(false),
"quiet" => command.quiet.map(|value| value == device.quiet).unwrap_or(false),
@@ -363,7 +469,10 @@ fn externally_changed_control_fields(before: &Device, after: &Device, zone: &Zon
let mut fields = Vec::new();
if before.power != after.power { fields.push("power".to_string()); }
if before.mode != after.mode { fields.push("mode".to_string()); }
if before.target_temperature.round() != after.target_temperature.round() {
let temperature_step = after.capabilities.temperature_step.max(0.5);
if (before.target_temperature / temperature_step).round()
!= (after.target_temperature / temperature_step).round()
{
fields.push("target_temperature".to_string());
}
// Some GREE units accept the controller's standby Low fan hint and later report Auto
@@ -377,3 +486,83 @@ fn externally_changed_control_fields(before: &Device, after: &Device, zone: &Zon
}
pub(crate) async fn cloud_push_loop(state: AppState) {
let mut receiver = state.providers.cloud().subscribe_push();
loop {
match receiver.recv().await {
Ok(event) => {
let devices = match state.db.list_devices() {
Ok(items) => items,
Err(err) => {
tracing::warn!(error=?err, "cannot load devices for GREE Cloud push update");
continue;
}
};
let matching: Vec<String> = devices
.into_iter()
.filter(|device| {
if device.connection_type != ConnectionType::GreeCloud { return false; }
if let Some(id) = event.cloud_device_id.as_deref() {
return device.cloud_device_id.as_deref().is_some_and(|value| value.eq_ignore_ascii_case(id));
}
device.cloud_parent_mac.as_deref().is_some_and(|value| value.eq_ignore_ascii_case(&event.parent_mac))
})
.map(|device| device.id)
.collect();
for device_id in matching {
let _guard = state.lock_device_operation(&device_id).await;
let Ok(Some(mut device)) = state.db.get_device(&device_id) else { continue; };
let before = device.clone();
if !event.properties.is_empty() {
if let Some(raw_energy) = event.properties.get("ElcAll").and_then(|value| {
value.as_f64()
.or_else(|| value.as_i64().map(|v| v as f64))
.or_else(|| value.as_u64().map(|v| v as f64))
.or_else(|| value.as_str().and_then(|v| v.parse::<f64>().ok()))
}) {
if let Err(err) = record_cumulative_energy_sample(
&state, &device.id, "gree_cloud", raw_energy, "0.1kWh", None
) {
tracing::warn!(device=%device.id, error=?err, "cannot record GREE Cloud energy sample");
}
}
crate::provider::apply_cloud_properties(&mut device, &event.properties);
device.pending_command = false;
device.last_cloud_sync = Some(Utc::now());
device.last_seen = Some(Utc::now());
device.connection_status = ConnectionStatus::Online;
device.online = true;
device.communication_failures = 0;
device.last_error = None;
if let Some(version) = event.cipher_version { device.protocol_version = version; }
device.refresh_capabilities();
} else if event.connected == Some(true) {
// A connect topic proves cloud presence but does not replace a status frame.
device.connection_status = ConnectionStatus::Online;
device.online = true;
device.last_seen = Some(Utc::now());
}
device.updated_at = Utc::now();
if let Err(err) = state.db.save_device(&device) {
tracing::warn!(device=%device_id, error=?err, "cannot persist GREE Cloud push state");
continue;
}
if !event.properties.is_empty() {
let _ = record_reading(&state, &device);
if let Err(err) = detect_external_device_control(&state, &before, &device).await {
tracing::warn!(device=%device_id, error=?err, "cannot process external GREE Cloud state change");
}
}
state.broadcast_with_control_plan_invalidation(
"device.updated",
serde_json::to_value(&device).unwrap_or_default(),
device_runtime_change_affects_control_plan(&before, &device),
);
}
}
Err(broadcast::error::RecvError::Lagged(skipped)) => tracing::warn!(skipped, "GREE Cloud push state receiver lagged"),
Err(broadcast::error::RecvError::Closed) => break,
}
}
}
+40
View File
@@ -25,6 +25,27 @@ pub fn start(state: AppState) {
tracing::warn!(error=?err, "cannot reset temporary thermostat observation continuity after restart");
}
let cloud_reconnect_provider = state.providers.cloud().clone();
let cloud_reconnect_settings = state.settings.clone();
let cloud_reconnect_db = state.db.clone();
tokio::spawn(async move {
crate::provider::cloud_reconnect_loop(
cloud_reconnect_provider,
cloud_reconnect_settings,
cloud_reconnect_db,
)
.await;
});
let cloud_push_state = state.clone();
tokio::spawn(async move {
cloud_push_loop(cloud_push_state).await;
});
let ha_energy_state = state.clone();
tokio::spawn(async move {
home_assistant_energy_loop(ha_energy_state).await;
});
let control_plan_state = state.clone();
tokio::spawn(async move {
control_plan_cache_loop(control_plan_state).await;
@@ -144,6 +165,13 @@ pub fn start(state: AppState) {
Ok(_) => {}
Err(err) => tracing::warn!(error=?err, "cannot prune readings"),
}
match maintenance_state.db.prune_energy_readings(retention_days) {
Ok(count) if count > 0 => {
tracing::info!(count, retention_days, "old energy readings pruned")
}
Ok(_) => {}
Err(err) => tracing::warn!(error=?err, "cannot prune energy readings"),
}
}
let event_retention_days = settings.event_log_retention_days.max(1) as i64;
match maintenance_state.db.prune_events(event_retention_days) {
@@ -176,5 +204,17 @@ async fn archive_old_history(state: &AppState, threshold_days: u32) -> Result<u6
break;
}
}
for _ in 0..50 {
let energy = state.db.energy_before(cutoff, 1_000)?;
if energy.is_empty() {
break;
}
influxdb::write_energy_batch(&state.http, &settings, &energy).await?;
let deleted = state.db.delete_energy_batch(&energy)?;
moved += deleted;
if deleted == 0 {
break;
}
}
Ok(moved)
}
+21 -2
View File
@@ -824,9 +824,28 @@ async fn control_zones(state: &AppState) -> Result<()> {
// frame as the standby setpoint and Low fan. When demand returns, disable Quiet
// on the normal smart-fan transition. When scheduled night mode owns Quiet, it
// explicitly enables it inside the window and releases it outside the window.
let quiet_supported = if device.connection_type == ConnectionType::GreeCloud {
device.capabilities.quiet
} else {
state
.providers
.local()
.client()
.quiet_command_supported(&device.id)
};
let sleep_supported = if device.connection_type == ConnectionType::GreeCloud {
device.capabilities.sleep
} else {
device.supports_sleep == Some(true)
&& state
.providers
.local()
.client()
.sleep_command_supported(&device.id)
};
let desired_quiet = smart_quiet_command(
zone.smart_fan,
state.gree.quiet_command_supported(&device.id),
quiet_supported,
previous_demand,
zone.demand,
device.quiet,
@@ -838,7 +857,7 @@ async fn control_zones(state: &AppState) -> Result<()> {
settings.night_mode.enabled,
night_active,
settings.night_mode.use_native_sleep,
device.supports_sleep == Some(true) && state.gree.sleep_command_supported(&device.id),
sleep_supported,
device.sleep,
);
+5
View File
@@ -18,6 +18,8 @@ pub enum AppError {
Unauthorized,
#[error("device communication failed: {0}")]
Device(String),
#[error("external dependency failed: {0}")]
Dependency(String),
#[error(transparent)]
Internal(#[from] anyhow::Error),
}
@@ -30,6 +32,9 @@ impl IntoResponse for AppError {
Self::Conflict(v) => (StatusCode::CONFLICT, v.clone()),
Self::Unauthorized => (StatusCode::UNAUTHORIZED, "unauthorized".into()),
Self::Device(v) => (StatusCode::BAD_GATEWAY, v.clone()),
// Cloud/provider outages are an expected external-dependency failure, not a
// controller-side 502. Keep Local's historical Device -> 502 mapping unchanged.
Self::Dependency(v) => (StatusCode::FAILED_DEPENDENCY, v.clone()),
Self::Internal(v) => {
tracing::error!(error = ?v, "internal error");
(
+37
View File
@@ -204,6 +204,43 @@ pub async fn read_entity(
response.json().await.context("invalid Home Assistant JSON")
}
/// Read the Home Assistant state registry. Callers must filter the result before exposing it.
pub async fn list_entities(
default_client: &reqwest::Client,
settings: &HomeAssistantSettings,
) -> Result<Vec<Value>> {
if settings.url.trim().is_empty() {
bail!("Home Assistant URL is not configured")
}
if settings.token.trim().is_empty() {
bail!("Home Assistant token is not configured")
}
let mut base = Url::parse(settings.url.trim()).context("invalid Home Assistant URL")?;
if !matches!(base.scheme(), "http" | "https") {
bail!("Home Assistant URL must use http or https")
}
base = base.join("api/states").context("cannot build Home Assistant API URL")?;
let response = request_client(default_client, settings)?
.get(base)
.bearer_auth(settings.token.trim())
.header("Accept", "application/json")
.send()
.await
.context("Home Assistant state registry request failed")?;
let status = response.status();
if !status.is_success() {
let body = response.text().await.unwrap_or_default();
bail!(
"Home Assistant returned {status}: {}",
body.chars().take(200).collect::<String>()
)
}
response
.json::<Vec<Value>>()
.await
.context("invalid Home Assistant state registry JSON")
}
/// Read the raw Home Assistant state for Flow conditions. Unlike `read_temperature`, this
/// intentionally keeps the state as text so binary_sensor, switch, input_boolean and custom
/// entities can participate in visual automations.
+2 -1
View File
@@ -4,11 +4,12 @@ use reqwest::Client;
use serde_json::Value;
use std::collections::HashMap;
use crate::models::{HaReading, InfluxDbSettings, Reading, ZoneReading};
use crate::models::{EnergyReading, HaReading, InfluxDbSettings, Reading, ZoneReading};
const DEVICE_MEASUREMENT: &str = "gree_device";
const ZONE_MEASUREMENT: &str = "gree_zone";
const HA_MEASUREMENT: &str = "gree_ha";
const ENERGY_MEASUREMENT: &str = "gree_energy";
// Functional source split intentionally keeps items in the existing module namespace.
include!("influxdb/write.rs");
+84
View File
@@ -460,3 +460,87 @@ async fn query_v2(
}
Ok(rows)
}
pub async fn query_energy(
client: &Client,
settings: &InfluxDbSettings,
device_id: &str,
source: Option<&str>,
start: DateTime<Utc>,
stop: DateTime<Utc>,
bucket_seconds: i64,
limit: u32,
) -> Result<Vec<EnergyReading>> {
if settings.version == "1" {
let source_filter = source
.map(|value| format!(" AND \"source\"='{}'", influxql_string(value)))
.unwrap_or_default();
let q = format!(
"SELECT sum(\"consumption_kwh\") AS \"consumption_kwh\" FROM \"{ENERGY_MEASUREMENT}\" WHERE time >= '{}' AND time < '{}' AND \"device_id\"='{}'{} GROUP BY time({}s),\"device_id\",\"source\" fill(none) LIMIT {}",
start.to_rfc3339(),
stop.to_rfc3339(),
influxql_string(device_id),
source_filter,
bucket_seconds.max(1),
limit
);
let series = query_v1(client, settings, &q).await?;
let mut out = Vec::new();
for item in series {
let source = item.tags.get("source").cloned().unwrap_or_default();
for row in item.rows {
let Some(timestamp) = row_time(&row) else { continue; };
out.push(EnergyReading {
id: 0,
device_id: device_id.to_string(),
timestamp,
source: source.clone(),
raw_meter_value: 0.0,
raw_unit: "kWh".into(),
normalized_meter_kwh: 0.0,
consumption_kwh: row_num(&row, "consumption_kwh").unwrap_or(0.0).max(0.0),
current_power_kw: None,
quality: "influx_aggregate".into(),
reset_detected: false,
});
}
}
out.sort_by_key(|row| row.timestamp);
out.truncate(limit as usize);
return Ok(out);
}
let source_filter = source
.map(|value| format!(" |> filter(fn: (r) => r.source == {})", flux_string(value)))
.unwrap_or_default();
let query = format!(
"from(bucket: {}) |> range(start: time(v: {}), stop: time(v: {})) |> filter(fn: (r) => r._measurement == {} and r._field == \"consumption_kwh\" and r.device_id == {}){} |> aggregateWindow(every: {}s, fn: sum, createEmpty: false) |> sort(columns:[\"_time\"])",
flux_string(&settings.bucket),
flux_string(&start.to_rfc3339()),
flux_string(&stop.to_rfc3339()),
flux_string(ENERGY_MEASUREMENT),
flux_string(device_id),
source_filter,
bucket_seconds.max(1),
);
let rows = query_v2(client, settings, &query).await?;
let mut out = Vec::new();
for row in rows.into_iter().take(limit as usize) {
let Some(timestamp) = parse_flux_time(&row) else { continue; };
out.push(EnergyReading {
id: 0,
device_id: device_id.to_string(),
timestamp,
source: row.get("source").cloned().unwrap_or_default(),
raw_meter_value: 0.0,
raw_unit: "kWh".into(),
normalized_meter_kwh: 0.0,
consumption_kwh: row_f64(&row, "_value").unwrap_or(0.0).max(0.0),
current_power_kw: None,
quality: "influx_aggregate".into(),
reset_detected: false,
});
}
out.sort_by_key(|row| row.timestamp);
Ok(out)
}
+46
View File
@@ -1,3 +1,49 @@
pub async fn write_energy(
client: &Client,
settings: &InfluxDbSettings,
reading: &EnergyReading,
) -> Result<()> {
if !settings.enabled { return Ok(()); }
let mut fields = Vec::new();
push_float(&mut fields, "raw_meter_value", Some(reading.raw_meter_value));
push_float(&mut fields, "normalized_meter_kwh", Some(reading.normalized_meter_kwh));
push_float(&mut fields, "consumption_kwh", Some(reading.consumption_kwh.max(0.0)));
push_float(&mut fields, "current_power_kw", reading.current_power_kw);
push_int(&mut fields, "reset_detected", reading.reset_detected as i64);
let line = line_protocol(
ENERGY_MEASUREMENT,
&[("device_id", &reading.device_id), ("source", &reading.source), ("quality", &reading.quality), ("raw_unit", &reading.raw_unit)],
fields,
reading.timestamp,
)?;
write_line(client, settings, line).await
}
pub async fn write_energy_batch(
client: &Client,
settings: &InfluxDbSettings,
readings: &[EnergyReading],
) -> Result<()> {
if !settings.enabled || readings.is_empty() { return Ok(()); }
let mut lines = Vec::with_capacity(readings.len());
for reading in readings {
let mut fields = Vec::new();
push_float(&mut fields, "raw_meter_value", Some(reading.raw_meter_value));
push_float(&mut fields, "normalized_meter_kwh", Some(reading.normalized_meter_kwh));
push_float(&mut fields, "consumption_kwh", Some(reading.consumption_kwh.max(0.0)));
push_float(&mut fields, "current_power_kw", reading.current_power_kw);
push_int(&mut fields, "reset_detected", reading.reset_detected as i64);
lines.push(line_protocol(
ENERGY_MEASUREMENT,
&[("device_id", &reading.device_id), ("source", &reading.source), ("quality", &reading.quality), ("raw_unit", &reading.raw_unit)],
fields,
reading.timestamp,
)?);
}
write_lines(client, settings, lines.join("\n")).await
}
pub fn validate(settings: &InfluxDbSettings) -> Result<()> {
if !settings.enabled {
return Ok(());
+76 -11
View File
@@ -8,6 +8,7 @@ mod influxdb;
mod models;
mod notifications;
mod protocol;
mod provider;
mod queries;
mod state;
@@ -16,8 +17,10 @@ use config::Config;
use db::Db;
use models::Device;
use protocol::GreeClient;
use provider::ProviderDispatcher;
use state::AppState;
use std::{
future::IntoFuture,
sync::{
atomic::{AtomicBool, AtomicU64},
Arc,
@@ -46,6 +49,12 @@ async fn main() -> Result<()> {
runtime_settings.discovery_broadcast = config.discovery_broadcast.clone();
}
config.apply_runtime_env_overrides(&mut runtime_settings);
if runtime_settings.gree_cloud.installation_id.trim().is_empty() {
runtime_settings.gree_cloud.installation_id = uuid::Uuid::new_v4().to_string();
}
if runtime_settings.gree_cloud.account_id.trim().is_empty() {
runtime_settings.gree_cloud.account_id = "default".into();
}
db.save_runtime_settings(&runtime_settings)?;
if config.simulate && config.auto_seed && db.count_devices()? == 0 {
@@ -61,25 +70,43 @@ async fn main() -> Result<()> {
let (events, _) = broadcast::channel(512);
let (control_plan, _) = watch::channel(None);
let debug_gree_frames = Arc::new(AtomicBool::new(runtime_settings.debug.gree_frames));
let debug_cloud_requests = Arc::new(AtomicBool::new(runtime_settings.debug.cloud_requests));
let debug_cloud_mqtt = Arc::new(AtomicBool::new(runtime_settings.debug.cloud_mqtt));
let user_agent = format!(
"GreeController/{} (+https://git.linuxiarz.pl/gru/gree-controller-ha-addon/; instance={})",
env!("CARGO_PKG_VERSION"),
runtime_settings.gree_cloud.installation_id
);
let http = reqwest::Client::builder()
.timeout(Duration::from_secs(10))
.user_agent(concat!("gree-controller/", env!("CARGO_PKG_VERSION")))
.connect_timeout(Duration::from_secs(10))
.timeout(Duration::from_secs(30))
.user_agent(user_agent)
.build()?;
let state = AppState {
db,
settings: Arc::new(RwLock::new(runtime_settings.clone())),
config: Arc::new(config.clone()),
gree: GreeClient::new(
let local_client = GreeClient::new(
runtime_settings.controller_id.clone(),
(!config.gree_interface.trim().is_empty())
.then(|| config.gree_interface.trim().to_string()),
Some(events.clone()),
debug_gree_frames.clone(),
),
);
let providers = ProviderDispatcher::new_with_debug(
local_client,
http.clone(),
Some(events.clone()),
debug_cloud_requests.clone(),
debug_cloud_mqtt.clone(),
);
let state = AppState {
db,
settings: Arc::new(RwLock::new(runtime_settings.clone())),
config: Arc::new(config.clone()),
providers,
events,
http,
outdoor_temperature: Arc::new(RwLock::new(None)),
debug_gree_frames,
debug_cloud_requests,
debug_cloud_mqtt,
initial_device_sync_complete: Arc::new(AtomicBool::new(false)),
zone_control_wakeup: Arc::new(Notify::new()),
control_plan,
@@ -98,6 +125,7 @@ async fn main() -> Result<()> {
pending_controller_commands: Arc::new(tokio::sync::Mutex::new(
std::collections::HashMap::new(),
)),
cloud_poll_inflight: Arc::new(std::sync::Mutex::new(std::collections::HashSet::new())),
started: Instant::now(),
};
@@ -122,9 +150,35 @@ async fn main() -> Result<()> {
"GREE Controller started"
);
axum::serve(listener, app)
.with_graceful_shutdown(shutdown_signal())
.await?;
// Use a shared shutdown signal so graceful HTTP draining has a hard deadline. A stuck
// Cloud request must not make SIGTERM/Ctrl+C wait indefinitely for an open handler.
let (shutdown_tx, shutdown_rx) = watch::channel(false);
tokio::spawn(async move {
shutdown_signal().await;
let _ = shutdown_tx.send(true);
});
let server = axum::serve(listener, app)
.with_graceful_shutdown(wait_for_shutdown(shutdown_rx.clone()))
.into_future();
tokio::pin!(server);
let deadline_rx = shutdown_rx;
tokio::select! {
result = &mut server => result?,
_ = wait_for_shutdown(deadline_rx.clone()) => {
tracing::info!("shutdown requested; draining HTTP requests");
match tokio::time::timeout(Duration::from_secs(5), &mut server).await {
Ok(result) => result?,
Err(_) => tracing::warn!("HTTP graceful shutdown deadline reached; forcing runtime shutdown"),
}
}
}
if tokio::time::timeout(Duration::from_secs(2), state.providers.cloud().shutdown())
.await
.is_err()
{
tracing::warn!("GREE Cloud shutdown deadline reached");
}
tracing::info!("GREE Controller stopped");
Ok(())
}
@@ -138,6 +192,17 @@ fn init_tracing() {
.init();
}
async fn wait_for_shutdown(mut rx: watch::Receiver<bool>) {
if *rx.borrow() {
return;
}
while rx.changed().await.is_ok() {
if *rx.borrow() {
return;
}
}
}
async fn shutdown_signal() {
let ctrl_c = async {
signal::ctrl_c()
+6
View File
@@ -82,6 +82,12 @@ fn default_history_retention_days() -> u32 {
fn default_event_log_retention_days() -> u32 {
30
}
fn default_cloud_region() -> String {
"Europe".into()
}
fn default_cloud_poll_interval_seconds() -> u64 {
60
}
fn default_influx_version() -> String {
"2".into()
}
+184 -3
View File
@@ -1,8 +1,94 @@
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash, Default)]
#[serde(rename_all = "snake_case")]
pub enum ConnectionType {
#[default]
Local,
GreeCloud,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum ConnectionStatus {
Online,
Offline,
CloudDisconnected,
AuthenticationError,
#[default]
Unknown,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeviceCapabilities {
#[serde(default = "default_true")]
pub power: bool,
#[serde(default = "default_true")]
pub mode: bool,
#[serde(default = "default_fan_modes")]
pub fan_modes: Vec<u8>,
#[serde(default = "default_min_temperature")]
pub min_temperature: f64,
#[serde(default = "default_max_temperature")]
pub max_temperature: f64,
#[serde(default = "default_temperature_step")]
pub temperature_step: f64,
#[serde(default = "default_true")]
pub vertical_swing: bool,
#[serde(default = "default_true")]
pub horizontal_swing: bool,
#[serde(default)] pub turbo: bool,
#[serde(default)] pub quiet: bool,
#[serde(default)] pub sleep: bool,
#[serde(default)] pub light: bool,
#[serde(default)] pub health: bool,
#[serde(default)] pub buzzer_control: bool,
#[serde(default)] pub energy_meter: bool,
#[serde(default)] pub compressor_frequency: bool,
}
impl Default for DeviceCapabilities {
fn default() -> Self {
Self {
power: true, mode: true, fan_modes: default_fan_modes(),
min_temperature: 8.0, max_temperature: 30.0, temperature_step: 1.0,
vertical_swing: true, horizontal_swing: true, turbo: false, quiet: false,
sleep: false, light: false, health: false, buzzer_control: false,
energy_meter: false, compressor_frequency: false,
}
}
}
fn default_fan_modes() -> Vec<u8> { vec![0, 1, 2, 3, 4, 5] }
fn default_min_temperature() -> f64 { 8.0 }
fn default_max_temperature() -> f64 { 30.0 }
fn default_temperature_step() -> f64 { 1.0 }
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum EnergySourcePreference {
#[default]
Auto,
GreeCloud,
HomeAssistant,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Device {
pub id: String,
pub mac: String,
pub name: String,
/// Explicit transport. Missing in old JSON records => Local.
#[serde(default)]
pub connection_type: ConnectionType,
#[serde(default)]
pub connection_status: ConnectionStatus,
/// Stable identifier reported by the GREE Cloud account (currently the cloud MAC/CID).
#[serde(default)]
pub cloud_device_id: Option<String>,
#[serde(default)]
pub cloud_parent_mac: Option<String>,
#[serde(default)]
pub cloud_account_id: Option<String>,
pub ip: String,
#[serde(default = "default_port")]
pub port: u16,
@@ -12,7 +98,7 @@ pub struct Device {
pub model: String,
#[serde(default)]
pub firmware: String,
#[serde(default)]
#[serde(default, skip_serializing)]
pub key: Option<String>,
#[serde(default)]
pub cid: Option<String>,
@@ -70,6 +156,16 @@ pub struct Device {
#[serde(default)]
pub supports_sleep: Option<bool>,
#[serde(default)]
pub supports_buzzer_control: Option<bool>,
#[serde(default)]
pub supports_energy_meter: Option<bool>,
#[serde(default)]
pub total_energy_kwh: Option<f64>,
#[serde(default)]
pub compressor_frequency_hz: Option<f64>,
#[serde(default)]
pub last_cloud_sync: Option<DateTime<Utc>>,
#[serde(default)]
pub current_temperature: Option<f64>,
#[serde(default)]
pub outdoor_temperature: Option<f64>,
@@ -87,6 +183,22 @@ pub struct Device {
pub last_error: Option<String>,
#[serde(default)]
pub communication_failures: u8,
/// True while a Cloud command is awaiting confirmation. Local transport keeps its
/// historical synchronous UI behaviour and normally leaves this false.
#[serde(default)]
pub pending_command: bool,
#[serde(default)]
pub capabilities: DeviceCapabilities,
#[serde(default)]
pub energy_source: EnergySourcePreference,
#[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>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
@@ -98,6 +210,11 @@ impl Device {
id: "sim-salon".into(),
mac: "SIM000000001".into(),
name: "Living Room (simulator)".into(),
connection_type: ConnectionType::Local,
connection_status: ConnectionStatus::Online,
cloud_device_id: None,
cloud_parent_mac: None,
cloud_account_id: None,
ip: "127.0.0.1".into(),
port: 7000,
protocol_version: 1,
@@ -128,6 +245,11 @@ impl Device {
supports_xfan: Some(true),
supports_health: Some(true),
supports_sleep: Some(true),
supports_buzzer_control: Some(true),
supports_energy_meter: None,
total_energy_kwh: None,
compressor_frequency_hz: None,
last_cloud_sync: None,
current_temperature: Some(26.0),
outdoor_temperature: Some(30.0),
temperature_sensor_offset: Some(false),
@@ -136,10 +258,41 @@ impl Device {
last_seen: Some(now),
last_error: None,
communication_failures: 0,
pending_command: false,
capabilities: DeviceCapabilities {
turbo: true, quiet: true, sleep: true, light: true, health: true,
buzzer_control: true, ..DeviceCapabilities::default()
},
energy_source: EnergySourcePreference::Auto,
ha_energy_entity_id: None,
ha_energy_unit: None,
ha_energy_device_class: None,
ha_energy_state_class: None,
created_at: now,
updated_at: now,
}
}
pub fn refresh_capabilities(&mut self) {
self.capabilities.power = true;
self.capabilities.mode = true;
self.capabilities.fan_modes = default_fan_modes();
self.capabilities.min_temperature = 8.0;
self.capabilities.max_temperature = 30.0;
if self.connection_type == ConnectionType::Local {
self.capabilities.vertical_swing = true;
self.capabilities.horizontal_swing = true;
self.capabilities.temperature_step = 1.0;
}
self.capabilities.turbo = self.supports_turbo.unwrap_or(self.connection_type == ConnectionType::Local);
self.capabilities.quiet = self.supports_quiet.unwrap_or(self.connection_type == ConnectionType::Local);
self.capabilities.sleep = self.supports_sleep.unwrap_or(false);
self.capabilities.light = self.supports_light.unwrap_or(self.connection_type == ConnectionType::Local);
self.capabilities.health = self.supports_health.unwrap_or(false);
self.capabilities.buzzer_control = self.supports_buzzer_control.unwrap_or(false);
self.capabilities.energy_meter = self.supports_energy_meter.unwrap_or(false);
self.capabilities.compressor_frequency = self.compressor_frequency_hz.is_some();
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
@@ -150,6 +303,11 @@ pub struct DevicePatch {
pub protocol_version: Option<u8>,
pub key: Option<Option<String>>,
pub enabled: Option<bool>,
pub energy_source: Option<EnergySourcePreference>,
pub ha_energy_entity_id: Option<Option<String>>,
pub ha_energy_unit: Option<Option<String>>,
pub ha_energy_device_class: Option<Option<String>>,
pub ha_energy_state_class: Option<Option<String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
@@ -196,7 +354,10 @@ impl DeviceCommand {
.filter(|value| value.as_str() != device.mode.as_str())
.cloned(),
target_temperature: self.target_temperature.filter(|value| {
value.clamp(8.0, 30.0).round() != device.target_temperature.clamp(8.0, 30.0).round()
let step = device.capabilities.temperature_step.max(0.5);
let requested = (value.clamp(device.capabilities.min_temperature, device.capabilities.max_temperature) / step).round();
let current = (device.target_temperature.clamp(device.capabilities.min_temperature, device.capabilities.max_temperature) / step).round();
requested != current
}),
fan_speed: self
.fan_speed
@@ -225,7 +386,10 @@ impl DeviceCommand {
device.mode = v.clone();
}
if let Some(v) = self.target_temperature {
device.target_temperature = v.clamp(8.0, 30.0).round();
let step = device.capabilities.temperature_step.max(0.5);
let min = device.capabilities.min_temperature;
let max = device.capabilities.max_temperature;
device.target_temperature = (v.clamp(min, max) / step).round() * step;
}
if let Some(v) = self.fan_speed {
device.fan_speed = v.min(5);
@@ -283,3 +447,20 @@ impl From<&Device> for ManualDeviceBaseline {
}
}
}
#[cfg(test)]
mod connection_type_tests {
use super::*;
#[test]
fn legacy_device_without_connection_type_defaults_to_local() {
let device = Device::simulated_default();
let mut value = serde_json::to_value(device).expect("serialize device");
value
.as_object_mut()
.expect("device object")
.remove("connection_type");
let migrated: Device = serde_json::from_value(value).expect("deserialize legacy device");
assert_eq!(migrated.connection_type, ConnectionType::Local);
}
}
+21
View File
@@ -49,3 +49,24 @@ pub struct EventLog {
pub message: String,
pub metadata: Value,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EnergyReading {
pub id: i64,
pub device_id: String,
pub timestamp: DateTime<Utc>,
pub source: String,
/// Original numeric value as reported by the source before unit normalization.
pub raw_meter_value: f64,
pub raw_unit: String,
/// Cumulative meter normalized to kWh.
pub normalized_meter_kwh: f64,
/// Consumption since the previous valid sample. Never negative.
pub consumption_kwh: f64,
#[serde(default)]
pub current_power_kw: Option<f64>,
pub quality: String,
#[serde(default)]
pub reset_detected: bool,
}
+4
View File
@@ -194,6 +194,10 @@ pub struct DebugSettings {
pub overlay_enabled: bool,
#[serde(default)]
pub gree_frames: bool,
#[serde(default)]
pub cloud_requests: bool,
#[serde(default)]
pub cloud_mqtt: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
+43
View File
@@ -1,3 +1,44 @@
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GreeCloudSettings {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_cloud_region")]
pub region: String,
#[serde(default)]
pub username: String,
#[serde(default)]
pub password: String,
#[serde(default = "default_cloud_poll_interval_seconds")]
pub polling_interval_seconds: u64,
/// Random, non-personal installation identifier used in the HTTP User-Agent.
#[serde(default)]
pub installation_id: String,
/// Reserved account key so the model can grow to multiple GREE accounts without changing devices.
#[serde(default)]
pub account_id: String,
#[serde(default)]
pub last_successful_contact: Option<DateTime<Utc>>,
/// Duration of the most recent successful REST operation against GREE Cloud.
#[serde(default)]
pub last_rest_response_time_ms: Option<u64>,
}
impl Default for GreeCloudSettings {
fn default() -> Self {
Self {
enabled: false,
region: default_cloud_region(),
username: String::new(),
password: String::new(),
polling_interval_seconds: default_cloud_poll_interval_seconds(),
installation_id: String::new(),
account_id: "default".into(),
last_successful_contact: None,
last_rest_response_time_ms: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RuntimeSettings {
pub controller_id: String,
@@ -39,6 +80,8 @@ pub struct RuntimeSettings {
#[serde(default)]
pub night_mode: NightModeSettings,
#[serde(default)]
pub gree_cloud: GreeCloudSettings,
#[serde(default)]
pub notifications: NotificationSettings,
pub home_assistant: HomeAssistantSettings,
}
+24
View File
@@ -15,6 +15,29 @@ pub struct GreeSettings {
pub compressor_protection_seconds: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GreeCloudSettingsUpdate {
pub enabled: bool,
pub region: String,
pub username: String,
#[serde(default)]
pub password: Option<String>,
pub polling_interval_seconds: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GreeCloudSettingsView {
pub enabled: bool,
pub region: String,
pub username: String,
pub password_configured: bool,
pub polling_interval_seconds: u64,
pub installation_id: String,
pub account_id: String,
pub last_successful_contact: Option<DateTime<Utc>>,
pub last_rest_response_time_ms: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HistorySettings {
pub retention_days: u32,
@@ -116,6 +139,7 @@ pub struct HomeAssistantSettingsView {
pub struct SettingsSnapshot {
pub application: ApplicationSettings,
pub gree: GreeSettings,
pub gree_cloud: GreeCloudSettingsView,
pub history: HistorySettings,
pub influxdb: InfluxDbSettingsView,
pub notifications: NotificationSettingsView,
+1 -1
View File
@@ -1,7 +1,7 @@
use super::crypto::{
decrypt_v1, decrypt_v2, encrypt_v1, encrypt_v2, GENERIC_GREE_V1_KEY, GENERIC_GREE_V2_KEY,
};
use crate::models::{ApiEvent, Device, DeviceCommand};
use crate::models::{ApiEvent, ConnectionStatus, ConnectionType, Device, DeviceCommand};
use anyhow::{anyhow, bail, Context, Result};
use chrono::Utc;
use serde_json::{json, Value};
+17
View File
@@ -179,6 +179,11 @@ impl GreeClient {
id: format!("gree-{}", mac.to_ascii_lowercase()),
mac,
name,
connection_type: ConnectionType::Local,
connection_status: ConnectionStatus::Unknown,
cloud_device_id: None,
cloud_parent_mac: None,
cloud_account_id: None,
ip: source.ip().to_string(),
port: if source.port() == 0 {
7000
@@ -213,6 +218,11 @@ impl GreeClient {
supports_xfan: None,
supports_health: None,
supports_sleep: None,
supports_buzzer_control: None,
supports_energy_meter: None,
total_energy_kwh: None,
compressor_frequency_hz: None,
last_cloud_sync: None,
current_temperature: None,
outdoor_temperature: None,
temperature_sensor_offset: None,
@@ -221,6 +231,13 @@ impl GreeClient {
last_seen: Some(now),
last_error: None,
communication_failures: 0,
pending_command: false,
capabilities: crate::models::DeviceCapabilities::default(),
energy_source: crate::models::EnergySourcePreference::Auto,
ha_energy_entity_id: None,
ha_energy_unit: None,
ha_energy_device_class: None,
ha_energy_state_class: None,
created_at: now,
updated_at: now,
}))
+476
View File
@@ -0,0 +1,476 @@
use super::crypto::decrypt_v1;
use aes::{
cipher::{generic_array::GenericArray, BlockEncrypt, KeyInit},
Aes128,
};
use anyhow::{anyhow, bail, Context, Result};
use base64::{engine::general_purpose::STANDARD, Engine as _};
use chrono::Utc;
use reqwest::header::{HeaderMap, HeaderValue, CONTENT_TYPE};
use serde::{Deserialize, Serialize};
use serde_json::{json, Map, Value};
use std::collections::HashMap;
const APP_ID: &str = "4920681951525131286";
const APP_HASH: &str = "0fa513124aa97781d1f3f40d61ca1a89";
const API_AES_KEY: &str = "#G$&^jgfujy6ujxt";
const GAEN1: &str = "5ac2bdf935bcca70";
pub fn region_base_url(region: &str) -> Option<&'static str> {
match region {
"Australia" => Some("https://augrih.gree.com"),
"China Mainland" => Some("https://grih.gree.com"),
"East South Asia" => Some("https://hkgrih.gree.com"),
"Europe" => Some("https://eugrih.gree.com"),
"India" => Some("https://ingrih.gree.com"),
"Latin American" => Some("https://lagrih.gree.com"),
"Middle East" => Some("https://megrih.gree.com"),
"North American" => Some("https://nagrih.gree.com"),
"Russia" => Some("https://rugrih.gree.com"),
"South American" => Some("https://sagrih.gree.com"),
_ => None,
}
}
pub fn parent_mac(mac: &str) -> String {
let compact = mac.trim().replace([':', '-'], "").to_ascii_uppercase();
if compact.len() > 12 && compact.ends_with("00") {
compact[..compact.len() - 2].to_string()
} else {
compact
}
}
#[derive(Debug, Clone)]
pub struct CloudCredentials {
pub user_id: i64,
pub token: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CloudDeviceInfo {
pub name: String,
pub mac: String,
pub key: String,
pub model: Option<String>,
pub version: Option<String>,
pub online: bool,
}
#[derive(Debug, Clone, Serialize)]
pub struct CloudDeviceView {
pub id: String,
pub name: String,
pub mac: String,
pub parent_mac: String,
pub model: Option<String>,
pub version: Option<String>,
pub online: bool,
pub already_added: bool,
}
#[derive(Clone)]
pub struct GreeCloudApi {
http: reqwest::Client,
base_url: String,
username: String,
password: String,
credentials: Option<CloudCredentials>,
}
impl GreeCloudApi {
pub fn for_region(
http: reqwest::Client,
region: &str,
username: &str,
password: &str,
) -> Result<Self> {
let base_url = region_base_url(region)
.ok_or_else(|| anyhow!("unknown GREE Cloud region: {region}"))?;
if username.trim().is_empty() {
bail!("GREE Cloud login/email is required");
}
if password.is_empty() {
bail!("GREE Cloud password is required");
}
Ok(Self {
http,
base_url: base_url.to_string(),
username: username.trim().to_string(),
password: password.to_string(),
credentials: None,
})
}
pub async fn login(&mut self) -> Result<CloudCredentials> {
let now = Utc::now();
let time = now.format("%Y-%m-%d %H:%M:%S").to_string();
let h = md5_hex(&(md5_hex(&self.password) + &self.password));
let psw = md5_hex(&(h + &time));
let payload = json!({
"psw": psw,
"t": time,
"user": self.username,
});
let data = self
.request_at("/App/UserLoginV2", payload, &["user", "psw", "t"], now)
.await
.context("GREE Cloud login request failed")?;
if data.get("r").and_then(Value::as_i64).is_some_and(|r| r != 200) {
let message = data
.get("msg")
.and_then(Value::as_str)
.unwrap_or("unknown authentication error");
bail!("GREE Cloud authentication failed: {message}");
}
let body = data
.get("data")
.and_then(Value::as_object)
.cloned()
.unwrap_or_else(|| data.as_object().cloned().unwrap_or_default());
let user_id = value_i64(body.get("uid"))
.ok_or_else(|| anyhow!("GREE Cloud login response is missing uid"))?;
let token = body
.get("token")
.and_then(Value::as_str)
.filter(|v| !v.is_empty())
.ok_or_else(|| anyhow!("GREE Cloud login response is missing token"))?
.to_string();
let credentials = CloudCredentials { user_id, token };
self.credentials = Some(credentials.clone());
Ok(credentials)
}
pub async fn get_all_devices(&self) -> Result<Vec<CloudDeviceInfo>> {
let credentials = self
.credentials
.as_ref()
.ok_or_else(|| anyhow!("GREE Cloud session is not authenticated"))?;
let homes = self.get_homes(credentials).await?;
let mut all = Vec::new();
for home_id in homes {
all.extend(self.get_devices(credentials, home_id).await?);
}
Ok(filter_duplicate_devices(all))
}
async fn get_homes(&self, credentials: &CloudCredentials) -> Result<Vec<i64>> {
let payload = json!({
"token": credentials.token,
"uid": credentials.user_id,
});
let data = self
.request_now("/App/GetHomes", payload, &["token", "uid"])
.await
.context("GREE Cloud home discovery failed")?;
let homes = data
.get("home")
.and_then(Value::as_array)
.ok_or_else(|| anyhow!("GREE Cloud homes response has no home list"))?;
Ok(homes
.iter()
.filter_map(|home| value_i64(home.get("id")))
.collect())
}
async fn get_devices(
&self,
credentials: &CloudCredentials,
home_id: i64,
) -> Result<Vec<CloudDeviceInfo>> {
let payload = json!({
"token": credentials.token,
"homeId": home_id,
"uid": credentials.user_id,
});
let data = self
.request_now(
"/App/GetDevsInRoomsOfHomeV2",
payload,
&["token", "uid", "homeId"],
)
.await
.with_context(|| format!("GREE Cloud device discovery failed for home {home_id}"))?;
let rooms = data
.get("rooms")
.and_then(Value::as_array)
.ok_or_else(|| anyhow!("GREE Cloud devices response has no rooms list"))?;
let mut devices = Vec::new();
for room in rooms {
let Some(items) = room.get("devs").and_then(Value::as_array) else {
continue;
};
for item in items {
let Some(mac) = item.get("mac").and_then(Value::as_str) else {
continue;
};
let Some(key) = item.get("key").and_then(Value::as_str) else {
continue;
};
// Keep the exact MAC casing returned by GREE for MQTT. MQTT topics are
// case-sensitive and the reference client deliberately publishes/subscribes
// with the API value unchanged. Stable application IDs are normalized later.
let cloud_mac = mac.trim().replace([':', '-'], "");
devices.push(CloudDeviceInfo {
name: item
.get("name")
.and_then(Value::as_str)
.unwrap_or("GREE Cloud")
.trim()
.to_string(),
mac: cloud_mac,
key: key.trim().to_string(),
model: optional_trimmed(item.get("model")),
version: optional_trimmed(item.get("ver")),
online: item.get("online").map(value_bool).unwrap_or(true),
});
}
}
Ok(devices)
}
async fn request_now(&self, endpoint: &str, payload: Value, hash_props: &[&str]) -> Result<Value> {
self.request_at(endpoint, payload, hash_props, Utc::now()).await
}
async fn request_at(
&self,
endpoint: &str,
payload: Value,
hash_props: &[&str],
now: chrono::DateTime<Utc>,
) -> Result<Value> {
let time = now.format("%Y-%m-%d %H:%M:%S").to_string();
let epoch = now.timestamp();
let vc = md5_hex(&format!("{APP_ID}_{APP_HASH}_{time}_{epoch}"));
let payload_object = payload
.as_object()
.ok_or_else(|| anyhow!("GREE Cloud request payload must be an object"))?;
let hash_values = hash_props
.iter()
.map(|key| python_string(payload_object.get(*key).unwrap_or(&Value::Null)))
.collect::<Vec<_>>()
.join("_");
let dat_vc = md5_hex(&format!("{APP_HASH}_{hash_values}"));
let mut body = Map::new();
body.insert(
"api".into(),
json!({"appId": APP_ID, "r": epoch, "t": time, "vc": vc}),
);
body.insert("datVc".into(), Value::String(dat_vc));
for (key, value) in payload_object {
body.insert(key.clone(), value.clone());
}
let encrypted = encrypt_cloud_api(&serde_json::to_vec(&Value::Object(body))?)?;
let mut headers = HeaderMap::new();
headers.insert(
CONTENT_TYPE,
HeaderValue::from_static("application/x-www-form-urlencoded"),
);
headers.insert("gaen1", HeaderValue::from_static(GAEN1));
headers.insert("charset", HeaderValue::from_static("utf-8"));
let response = self
.http
.post(format!("{}{}", self.base_url, endpoint))
.headers(headers)
.body(encrypted)
.send()
.await
.context("cannot connect to GREE Cloud")?;
let status = response.status();
if !status.is_success() {
bail!("GREE Cloud API returned HTTP {status}");
}
let envelope: Value = response
.json()
.await
.context("invalid GREE Cloud API envelope")?;
let encrypted_response = envelope
.get("enRes")
.and_then(Value::as_str)
.ok_or_else(|| anyhow!("GREE Cloud response has no enRes field"))?;
let decrypted = decrypt_v1(API_AES_KEY, encrypted_response)
.context("cannot decrypt GREE Cloud response")?;
serde_json::from_slice(&decrypted).context("invalid decrypted GREE Cloud JSON")
}
}
fn filter_duplicate_devices(devices: Vec<CloudDeviceInfo>) -> Vec<CloudDeviceInfo> {
let mut groups: HashMap<String, Vec<CloudDeviceInfo>> = HashMap::new();
for device in devices {
groups.entry(device.key.clone()).or_default().push(device);
}
let mut filtered = Vec::new();
for mut group in groups.into_values() {
if group.len() == 1 {
filtered.push(group.remove(0));
continue;
}
let preferred: Vec<_> = group
.iter()
.filter(|device| device.mac.len() > 12 && device.mac.ends_with("00"))
.cloned()
.collect();
if preferred.is_empty() {
filtered.extend(group);
} else {
filtered.extend(preferred);
}
}
filtered.sort_by(|a, b| a.name.to_ascii_lowercase().cmp(&b.name.to_ascii_lowercase()));
filtered
}
fn optional_trimmed(value: Option<&Value>) -> Option<String> {
value
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
fn value_i64(value: Option<&Value>) -> Option<i64> {
value.and_then(|value| {
value
.as_i64()
.or_else(|| value.as_u64().and_then(|v| i64::try_from(v).ok()))
.or_else(|| value.as_str().and_then(|v| v.parse::<i64>().ok()))
})
}
fn value_bool(value: &Value) -> bool {
value
.as_bool()
.or_else(|| value.as_i64().map(|v| v != 0))
.or_else(|| {
value
.as_str()
.map(|v| !matches!(v.trim().to_ascii_lowercase().as_str(), "0" | "false" | "off" | ""))
})
.unwrap_or(false)
}
fn python_string(value: &Value) -> String {
match value {
Value::String(v) => v.clone(),
Value::Bool(true) => "True".into(),
Value::Bool(false) => "False".into(),
Value::Null => "None".into(),
Value::Number(v) => v.to_string(),
other => other.to_string(),
}
}
fn encrypt_cloud_api(plaintext: &[u8]) -> Result<String> {
let key = API_AES_KEY.as_bytes();
let cipher = Aes128::new_from_slice(key).map_err(|_| anyhow!("invalid cloud AES key"))?;
let pad = 16 - (plaintext.len() % 16);
let mut data = Vec::with_capacity(plaintext.len() + pad);
data.extend_from_slice(plaintext);
data.extend(std::iter::repeat(pad as u8).take(pad));
for block in data.chunks_exact_mut(16) {
cipher.encrypt_block(GenericArray::from_mut_slice(block));
}
Ok(STANDARD.encode(data))
}
// Small self-contained MD5 implementation avoids introducing another package/lockfile dependency.
fn md5_hex(input: &str) -> String {
let digest = md5(input.as_bytes());
digest.iter().map(|byte| format!("{byte:02x}")).collect()
}
fn md5(input: &[u8]) -> [u8; 16] {
const S: [u32; 64] = [
7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 5, 9, 14, 20, 5, 9,
14, 20, 5, 9, 14, 20, 5, 9, 14, 20, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 4,
11, 16, 23, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21,
];
const K: [u32; 64] = [
0xd76aa478, 0xe8c7b756, 0x242070db, 0xc1bdceee, 0xf57c0faf, 0x4787c62a, 0xa8304613,
0xfd469501, 0x698098d8, 0x8b44f7af, 0xffff5bb1, 0x895cd7be, 0x6b901122, 0xfd987193,
0xa679438e, 0x49b40821, 0xf61e2562, 0xc040b340, 0x265e5a51, 0xe9b6c7aa, 0xd62f105d,
0x02441453, 0xd8a1e681, 0xe7d3fbc8, 0x21e1cde6, 0xc33707d6, 0xf4d50d87, 0x455a14ed,
0xa9e3e905, 0xfcefa3f8, 0x676f02d9, 0x8d2a4c8a, 0xfffa3942, 0x8771f681, 0x6d9d6122,
0xfde5380c, 0xa4beea44, 0x4bdecfa9, 0xf6bb4b60, 0xbebfbc70, 0x289b7ec6, 0xeaa127fa,
0xd4ef3085, 0x04881d05, 0xd9d4d039, 0xe6db99e5, 0x1fa27cf8, 0xc4ac5665, 0xf4292244,
0x432aff97, 0xab9423a7, 0xfc93a039, 0x655b59c3, 0x8f0ccc92, 0xffeff47d, 0x85845dd1,
0x6fa87e4f, 0xfe2ce6e0, 0xa3014314, 0x4e0811a1, 0xf7537e82, 0xbd3af235, 0x2ad7d2bb,
0xeb86d391,
];
let bit_len = (input.len() as u64).wrapping_mul(8);
let mut message = input.to_vec();
message.push(0x80);
while message.len() % 64 != 56 {
message.push(0);
}
message.extend_from_slice(&bit_len.to_le_bytes());
let mut a0 = 0x67452301u32;
let mut b0 = 0xefcdab89u32;
let mut c0 = 0x98badcfeu32;
let mut d0 = 0x10325476u32;
for chunk in message.chunks_exact(64) {
let mut m = [0u32; 16];
for (index, word) in m.iter_mut().enumerate() {
let start = index * 4;
*word = u32::from_le_bytes([
chunk[start],
chunk[start + 1],
chunk[start + 2],
chunk[start + 3],
]);
}
let (mut a, mut b, mut c, mut d) = (a0, b0, c0, d0);
for i in 0..64 {
let (f, g) = match i {
0..=15 => ((b & c) | ((!b) & d), i),
16..=31 => ((d & b) | ((!d) & c), (5 * i + 1) % 16),
32..=47 => (b ^ c ^ d, (3 * i + 5) % 16),
_ => (c ^ (b | (!d)), (7 * i) % 16),
};
let next = a
.wrapping_add(f)
.wrapping_add(K[i])
.wrapping_add(m[g]);
a = d;
d = c;
c = b;
b = b.wrapping_add(next.rotate_left(S[i]));
}
a0 = a0.wrapping_add(a);
b0 = b0.wrapping_add(b);
c0 = c0.wrapping_add(c);
d0 = d0.wrapping_add(d);
}
let mut output = [0u8; 16];
output[0..4].copy_from_slice(&a0.to_le_bytes());
output[4..8].copy_from_slice(&b0.to_le_bytes());
output[8..12].copy_from_slice(&c0.to_le_bytes());
output[12..16].copy_from_slice(&d0.to_le_bytes());
output
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn md5_matches_standard_vectors() {
assert_eq!(md5_hex(""), "d41d8cd98f00b204e9800998ecf8427e");
assert_eq!(md5_hex("abc"), "900150983cd24fb0d6963f7d28e17f72");
assert_eq!(
md5_hex("The quick brown fox jumps over the lazy dog"),
"9e107d9d372bb6826bd81d3542a419d6"
);
}
#[test]
fn parent_mac_matches_cloud_reference() {
assert_eq!(parent_mac("AABBCCDDEEFF00"), "AABBCCDDEEFF");
assert_eq!(parent_mac("AABBCCDDEEFF"), "AABBCCDDEEFF");
}
}
+525
View File
@@ -0,0 +1,525 @@
use anyhow::{anyhow, bail, Context, Result};
use serde::{Deserialize, Serialize};
use std::{
sync::{
atomic::{AtomicBool, AtomicU16, AtomicU64, Ordering},
Arc,
},
time::{Duration, SystemTime, UNIX_EPOCH},
};
use tokio::{
io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt},
net::TcpStream,
sync::{broadcast, mpsc},
time::{interval, timeout},
};
use tokio_rustls::{rustls, TlsConnector};
use tokio_rustls::rustls::pki_types::ServerName;
pub const MQTT_PORT: u16 = 1984;
pub const MQTT_KEEPALIVE_SECONDS: u16 = 60;
const MQTT_QUEUE_TIMEOUT: Duration = Duration::from_secs(2);
const MQTT_WRITE_TIMEOUT: Duration = Duration::from_secs(5);
const MQTT_DISCONNECT_TIMEOUT: Duration = Duration::from_millis(750);
pub fn broker_for_region(region: &str) -> Option<&'static str> {
match region {
"Australia" => Some("mqtt-au.gree.com"),
// greeclimate 1.2.1 regional broker mapping.
"China Mainland" => Some("mqtt-cn.gree.com"),
"East South Asia" => Some("mqtt-as.gree.com"),
"Europe" => Some("mqtt-eu.gree.com"),
"India" => Some("mqtt-in.gree.com"),
"Latin American" => Some("mqtt-la.gree.com"),
"Middle East" => Some("mqtt-me.gree.com"),
"North American" => Some("mqtt-na.gree.com"),
"Russia" => Some("mqtt-ru.gree.com"),
"South American" => Some("mqtt-sa.gree.com"),
_ => None,
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MqttDeviceEnvelope {
#[serde(default)]
pub cid: String,
#[serde(default)]
pub i: i64,
#[serde(default)]
pub pack: String,
#[serde(default)]
pub t: String,
#[serde(default)]
pub tcid: String,
#[serde(default)]
pub uid: serde_json::Value,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tag: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ts: Option<i64>,
}
#[derive(Debug, Clone)]
pub enum MqttEvent {
Message { topic: String, payload: Vec<u8> },
/// Broker-level traffic such as SUBACK/PUBACK/PINGRESP. This proves the MQTT
/// session is alive without being mistaken for a response from the HVAC unit.
Traffic { kind: &'static str },
Disconnected { reason: String },
}
#[derive(Debug)]
enum WireCommand {
Subscribe(Vec<String>),
Publish { topic: String, payload: Vec<u8> },
Raw(Vec<u8>),
Ping,
Disconnect,
}
#[derive(Clone)]
pub struct MqttConnection {
tx: mpsc::Sender<WireCommand>,
connected: Arc<AtomicBool>,
}
impl MqttConnection {
pub async fn connect(
host: &str,
port: u16,
user_id: i64,
token: &str,
connect_timeout: Duration,
events: broadcast::Sender<MqttEvent>,
) -> Result<Self> {
let tcp = timeout(connect_timeout, TcpStream::connect((host, port)))
.await
.context("GREE Cloud MQTT TCP connect timed out")?
.with_context(|| format!("cannot connect to GREE Cloud MQTT {host}:{port}"))?;
tcp.set_nodelay(true).ok();
// Do not inherit the reference library's CERT_NONE workaround: certificate and
// hostname verification are intentionally enabled here.
let mut roots = rustls::RootCertStore::empty();
roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
let tls_config = rustls::ClientConfig::builder()
.with_root_certificates(roots)
.with_no_client_auth();
let connector = TlsConnector::from(Arc::new(tls_config));
let server_name = ServerName::try_from(host.to_string())
.map_err(|_| anyhow!("invalid GREE Cloud MQTT hostname"))?;
let mut stream = timeout(connect_timeout, connector.connect(server_name, tcp))
.await
.context("GREE Cloud MQTT TLS handshake timed out")?
.context("GREE Cloud MQTT TLS handshake failed")?;
let client_id = format!("app_{:016x}", rand::random::<u64>());
let connect_packet = encode_connect(
&client_id,
&user_id.to_string(),
token,
MQTT_KEEPALIVE_SECONDS,
)?;
timeout(connect_timeout, stream.write_all(&connect_packet))
.await
.context("GREE Cloud MQTT CONNECT write timed out")??;
timeout(connect_timeout, stream.flush())
.await
.context("GREE Cloud MQTT CONNECT flush timed out")??;
let (packet_type, payload) = timeout(connect_timeout, read_packet(&mut stream))
.await
.context("GREE Cloud MQTT CONNACK timed out")??;
if packet_type >> 4 != 2 || payload.len() != 2 {
bail!("invalid GREE Cloud MQTT CONNACK");
}
if payload[1] != 0 {
let reason = match payload[1] {
1 => "unacceptable protocol version",
2 => "identifier rejected",
3 => "server unavailable",
4 => "invalid username/password",
5 => "not authorized",
_ => "unknown broker error",
};
bail!("GREE Cloud MQTT authentication/connect rejected: {reason}");
}
let (reader, writer) = tokio::io::split(stream);
let (tx, rx) = mpsc::channel(64);
let connected = Arc::new(AtomicBool::new(true));
let packet_ids = Arc::new(AtomicU16::new(1));
let last_rx_ms = Arc::new(AtomicU64::new(unix_millis()));
spawn_writer(
writer,
rx,
connected.clone(),
events.clone(),
packet_ids,
);
spawn_reader(
reader,
tx.clone(),
connected.clone(),
events.clone(),
last_rx_ms.clone(),
);
spawn_keepalive(tx.clone(), connected.clone(), events.clone(), last_rx_ms);
Ok(Self {
tx,
connected,
})
}
pub fn is_connected(&self) -> bool {
self.connected.load(Ordering::Acquire)
}
pub async fn subscribe_device(&self, parent_mac: &str) -> Result<()> {
if !self.is_connected() {
bail!("GREE Cloud MQTT is not connected");
}
let topics = [
format!("response/{parent_mac}/#"),
format!("status/{parent_mac}/#"),
format!("connect/{parent_mac}"),
];
// Match the reference client: one QoS1 SUBSCRIBE per topic. This is slightly more
// verbose than a multi-filter packet but avoids broker-specific handling differences.
for topic in topics {
timeout(
MQTT_QUEUE_TIMEOUT,
self.tx.send(WireCommand::Subscribe(vec![topic])),
)
.await
.context("GREE Cloud MQTT subscribe queue timed out")?
.map_err(|_| anyhow!("GREE Cloud MQTT writer stopped"))?;
}
Ok(())
}
pub async fn publish(&self, topic: String, payload: Vec<u8>) -> Result<()> {
if !self.is_connected() {
bail!("GREE Cloud MQTT is not connected");
}
timeout(
MQTT_QUEUE_TIMEOUT,
self.tx.send(WireCommand::Publish { topic, payload }),
)
.await
.context("GREE Cloud MQTT publish queue timed out")?
.map_err(|_| anyhow!("GREE Cloud MQTT writer stopped"))
}
pub async fn disconnect(&self) {
// Mark disconnected first so no new work can enter the queue while shutdown is in
// progress. A wedged/full writer queue must never prevent process termination.
self.connected.store(false, Ordering::Release);
let _ = timeout(
MQTT_DISCONNECT_TIMEOUT,
self.tx.send(WireCommand::Disconnect),
)
.await;
}
}
fn spawn_keepalive(
tx: mpsc::Sender<WireCommand>,
connected: Arc<AtomicBool>,
events: broadcast::Sender<MqttEvent>,
last_rx_ms: Arc<AtomicU64>,
) {
tokio::spawn(async move {
let mut tick = interval(Duration::from_secs(30));
tick.tick().await;
while connected.load(Ordering::Acquire) {
tick.tick().await;
let idle_ms = unix_millis().saturating_sub(last_rx_ms.load(Ordering::Acquire));
if idle_ms > 90_000 {
mark_disconnected(
&connected,
&events,
"MQTT heartbeat timed out waiting for broker traffic".into(),
);
let _ = timeout(MQTT_DISCONNECT_TIMEOUT, tx.send(WireCommand::Disconnect)).await;
break;
}
match timeout(MQTT_QUEUE_TIMEOUT, tx.send(WireCommand::Ping)).await {
Ok(Ok(())) => {}
Ok(Err(_)) => {
mark_disconnected(&connected, &events, "MQTT writer stopped".into());
break;
}
Err(_) => {
mark_disconnected(&connected, &events, "MQTT ping queue timed out".into());
break;
}
}
}
});
}
fn spawn_writer<W>(
mut writer: W,
mut rx: mpsc::Receiver<WireCommand>,
connected: Arc<AtomicBool>,
events: broadcast::Sender<MqttEvent>,
packet_ids: Arc<AtomicU16>,
) where
W: AsyncWrite + Unpin + Send + 'static,
{
tokio::spawn(async move {
while let Some(command) = rx.recv().await {
let result = match command {
WireCommand::Subscribe(topics) => {
let packet_id = next_packet_id(&packet_ids);
encode_subscribe(packet_id, &topics)
}
WireCommand::Publish { topic, payload } => {
let packet_id = next_packet_id(&packet_ids);
encode_publish(packet_id, &topic, &payload)
}
WireCommand::Raw(packet) => Ok(packet),
WireCommand::Ping => Ok(vec![0xC0, 0x00]),
WireCommand::Disconnect => {
let _ = timeout(MQTT_DISCONNECT_TIMEOUT, writer.write_all(&[0xE0, 0x00])).await;
let _ = timeout(MQTT_DISCONNECT_TIMEOUT, writer.flush()).await;
connected.store(false, Ordering::Release);
break;
}
};
let packet = match result {
Ok(packet) => packet,
Err(err) => {
mark_disconnected(&connected, &events, err.to_string());
break;
}
};
match timeout(MQTT_WRITE_TIMEOUT, writer.write_all(&packet)).await {
Ok(Ok(())) => {}
Ok(Err(err)) => {
mark_disconnected(&connected, &events, format!("MQTT write failed: {err}"));
break;
}
Err(_) => {
mark_disconnected(&connected, &events, "MQTT write timed out".into());
break;
}
}
match timeout(MQTT_WRITE_TIMEOUT, writer.flush()).await {
Ok(Ok(())) => {}
Ok(Err(err)) => {
mark_disconnected(&connected, &events, format!("MQTT flush failed: {err}"));
break;
}
Err(_) => {
mark_disconnected(&connected, &events, "MQTT flush timed out".into());
break;
}
}
}
});
}
fn spawn_reader<R>(
mut reader: R,
tx: mpsc::Sender<WireCommand>,
connected: Arc<AtomicBool>,
events: broadcast::Sender<MqttEvent>,
last_rx_ms: Arc<AtomicU64>,
) where
R: AsyncRead + Unpin + Send + 'static,
{
tokio::spawn(async move {
loop {
match read_packet(&mut reader).await {
Ok((header, payload)) => {
last_rx_ms.store(unix_millis(), Ordering::Release);
match header >> 4 {
3 => {
if let Err(err) = handle_publish(header, &payload, &tx, &events).await {
tracing::warn!(error=?err, "invalid GREE Cloud MQTT PUBLISH");
}
}
9 => { let _ = events.send(MqttEvent::Traffic { kind: "SUBACK" }); }
4 => { let _ = events.send(MqttEvent::Traffic { kind: "PUBACK" }); }
13 => { let _ = events.send(MqttEvent::Traffic { kind: "PINGRESP" }); }
_ => {}
}
}
Err(err) => {
mark_disconnected(&connected, &events, format!("MQTT read failed: {err}"));
break;
}
}
}
});
}
async fn handle_publish(
header: u8,
payload: &[u8],
tx: &mpsc::Sender<WireCommand>,
events: &broadcast::Sender<MqttEvent>,
) -> Result<()> {
if payload.len() < 2 {
bail!("short MQTT publish packet");
}
let topic_len = u16::from_be_bytes([payload[0], payload[1]]) as usize;
if payload.len() < 2 + topic_len {
bail!("truncated MQTT publish topic");
}
let topic = std::str::from_utf8(&payload[2..2 + topic_len])?.to_string();
let qos = (header >> 1) & 0x03;
let mut offset = 2 + topic_len;
if qos > 0 {
if payload.len() < offset + 2 {
bail!("truncated MQTT publish packet id");
}
let packet_id = u16::from_be_bytes([payload[offset], payload[offset + 1]]);
offset += 2;
if qos == 1 {
// MQTT QoS1 requires a PUBACK for every incoming publish. Send the raw four-byte
// acknowledgement through the single writer task so frame writes never interleave.
let ack = vec![0x40, 0x02, (packet_id >> 8) as u8, packet_id as u8];
send_raw_ack(tx, ack).await?;
}
}
let body = payload[offset..].to_vec();
let _ = events.send(MqttEvent::Message { topic, payload: body });
Ok(())
}
// MQTT QoS1 delivery requires PUBACK. To keep WireCommand's public operations minimal, encode
// acknowledgement as a synthetic command handled by a reserved topic marker.
async fn send_raw_ack(tx: &mpsc::Sender<WireCommand>, ack: Vec<u8>) -> Result<()> {
timeout(MQTT_QUEUE_TIMEOUT, tx.send(WireCommand::Raw(ack)))
.await
.context("GREE Cloud MQTT ACK queue timed out")?
.map_err(|_| anyhow!("GREE Cloud MQTT writer stopped"))
}
fn unix_millis() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis()
.min(u128::from(u64::MAX)) as u64
}
fn next_packet_id(ids: &AtomicU16) -> u16 {
let id = ids.fetch_add(1, Ordering::Relaxed);
if id == 0 { 1 } else { id }
}
fn mark_disconnected(
connected: &AtomicBool,
events: &broadcast::Sender<MqttEvent>,
reason: String,
) {
if connected.swap(false, Ordering::AcqRel) {
let _ = events.send(MqttEvent::Disconnected { reason });
}
}
fn encode_connect(client_id: &str, username: &str, password: &str, keepalive: u16) -> Result<Vec<u8>> {
let mut body = Vec::new();
push_utf8(&mut body, "MQTT")?;
body.push(4); // MQTT 3.1.1
body.push(0xC2); // username + password + clean session
body.extend_from_slice(&keepalive.to_be_bytes());
push_utf8(&mut body, client_id)?;
push_utf8(&mut body, username)?;
push_utf8(&mut body, password)?;
frame(0x10, body)
}
fn encode_subscribe(packet_id: u16, topics: &[String]) -> Result<Vec<u8>> {
let mut body = Vec::new();
body.extend_from_slice(&packet_id.to_be_bytes());
for topic in topics {
push_utf8(&mut body, topic)?;
body.push(1); // requested QoS 1
}
frame(0x82, body)
}
fn encode_publish(packet_id: u16, topic: &str, payload: &[u8]) -> Result<Vec<u8>> {
let mut body = Vec::new();
push_utf8(&mut body, topic)?;
body.extend_from_slice(&packet_id.to_be_bytes());
body.extend_from_slice(payload);
frame(0x32, body) // PUBLISH QoS1
}
fn frame(header: u8, body: Vec<u8>) -> Result<Vec<u8>> {
let mut out = Vec::with_capacity(body.len() + 5);
out.push(header);
encode_remaining_length(body.len(), &mut out)?;
out.extend_from_slice(&body);
Ok(out)
}
fn push_utf8(out: &mut Vec<u8>, value: &str) -> Result<()> {
let len = u16::try_from(value.as_bytes().len()).context("MQTT string is too long")?;
out.extend_from_slice(&len.to_be_bytes());
out.extend_from_slice(value.as_bytes());
Ok(())
}
fn encode_remaining_length(mut len: usize, out: &mut Vec<u8>) -> Result<()> {
if len > 268_435_455 {
bail!("MQTT packet is too large");
}
loop {
let mut digit = (len % 128) as u8;
len /= 128;
if len > 0 {
digit |= 0x80;
}
out.push(digit);
if len == 0 {
break;
}
}
Ok(())
}
async fn read_packet<R: AsyncRead + Unpin>(reader: &mut R) -> Result<(u8, Vec<u8>)> {
let header = reader.read_u8().await?;
let mut multiplier = 1usize;
let mut remaining = 0usize;
for _ in 0..4 {
let digit = reader.read_u8().await?;
remaining = remaining
.checked_add(((digit & 0x7f) as usize).saturating_mul(multiplier))
.ok_or_else(|| anyhow!("invalid MQTT remaining length"))?;
if digit & 0x80 == 0 {
let mut payload = vec![0_u8; remaining];
reader.read_exact(&mut payload).await?;
return Ok((header, payload));
}
multiplier = multiplier.saturating_mul(128);
}
bail!("invalid MQTT remaining length encoding")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mqtt_connect_uses_v311_and_credentials() {
let packet = encode_connect("app_123", "42", "secret", 60).unwrap();
assert_eq!(packet[0], 0x10);
assert!(packet.windows(6).any(|w| w == b"\0\x04MQTT"));
assert!(packet.windows(3).any(|w| w == b"\0\x0242"));
}
#[test]
fn region_brokers_match_cloud_reference() {
assert_eq!(broker_for_region("Europe"), Some("mqtt-eu.gree.com"));
assert_eq!(broker_for_region("North American"), Some("mqtt-na.gree.com"));
assert_eq!(broker_for_region("China Mainland"), Some("mqtt-cn.gree.com"));
}
}
+2
View File
@@ -1,4 +1,6 @@
pub mod crypto;
pub mod gree;
pub mod gree_cloud;
pub mod gree_cloud_mqtt;
pub use gree::{merge_discovered, GreeClient};
+1875
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -16,6 +16,8 @@ pub const LIST_DEVICES: &str = "SELECT payload FROM devices ORDER BY name COLLAT
pub const GET_DEVICE_BY_ID: &str = "SELECT payload FROM devices WHERE id=?1";
pub const GET_DEVICE_BY_MAC: &str = "SELECT payload FROM devices WHERE lower(mac)=lower(?1)";
pub const DELETE_DEVICE_READINGS: &str = "DELETE FROM readings WHERE device_id=?1";
pub const DELETE_DEVICE_ENERGY_READINGS: &str =
"DELETE FROM energy_readings WHERE device_id=?1";
pub const DELETE_SCHEDULES_BY_DEVICE_ID: &str =
"DELETE FROM schedules WHERE zone_id IN (SELECT id FROM zones WHERE json_extract(payload, '$.device_id')=?1)";
pub const DELETE_ZONES_BY_DEVICE_ID: &str =
+20
View File
@@ -105,6 +105,24 @@ CREATE INDEX IF NOT EXISTS ha_readings_entity_time_idx
CREATE INDEX IF NOT EXISTS ha_readings_zone_time_idx
ON ha_readings(zone_id, timestamp DESC);
CREATE TABLE IF NOT EXISTS energy_readings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
device_id TEXT NOT NULL,
timestamp TEXT NOT NULL,
source TEXT NOT NULL,
raw_meter_value REAL NOT NULL,
raw_unit TEXT NOT NULL,
normalized_meter_kwh REAL NOT NULL,
consumption_kwh REAL NOT NULL CHECK(consumption_kwh >= 0),
current_power_kw REAL,
quality TEXT NOT NULL,
reset_detected INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS energy_readings_device_time_idx
ON energy_readings(device_id, timestamp DESC);
CREATE INDEX IF NOT EXISTS energy_readings_source_time_idx
ON energy_readings(device_id, source, timestamp DESC);
CREATE TABLE IF NOT EXISTS event_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp TEXT NOT NULL,
@@ -135,5 +153,7 @@ INSERT OR IGNORE INTO schema_migrations(version, applied_at)
VALUES (5, strftime('%Y-%m-%dT%H:%M:%fZ','now'));
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'));
"#;
+37 -3
View File
@@ -2,12 +2,12 @@ use crate::{
config::Config,
db::Db,
models::{ApiEvent, ControlPlan, DeviceCommand, RuntimeSettings},
protocol::GreeClient,
provider::ProviderDispatcher,
};
use chrono::Utc;
use serde_json::Value;
use std::{
collections::HashMap,
collections::{HashMap, HashSet},
sync::{
atomic::{AtomicBool, AtomicU64, Ordering},
Arc,
@@ -33,16 +33,33 @@ pub struct ControlPlanSnapshot {
pub plan: Arc<ControlPlan>,
}
pub(crate) struct CloudPollGuard {
inflight: Arc<std::sync::Mutex<HashSet<String>>>,
device_id: String,
}
impl Drop for CloudPollGuard {
fn drop(&mut self) {
let mut inflight = self
.inflight
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
inflight.remove(&self.device_id);
}
}
#[derive(Clone)]
pub struct AppState {
pub db: Db,
pub settings: Arc<RwLock<RuntimeSettings>>,
pub config: Arc<Config>,
pub gree: GreeClient,
pub providers: ProviderDispatcher,
pub events: broadcast::Sender<ApiEvent>,
pub http: reqwest::Client,
pub outdoor_temperature: Arc<RwLock<Option<f64>>>,
pub debug_gree_frames: Arc<AtomicBool>,
pub debug_cloud_requests: Arc<AtomicBool>,
pub debug_cloud_mqtt: Arc<AtomicBool>,
/// Thermostat/automation control stays passive until every enabled device has had
/// one startup poll, preventing stale persisted device state from causing restart commands.
pub initial_device_sync_complete: Arc<AtomicBool>,
@@ -68,10 +85,27 @@ pub struct AppState {
/// Short-lived expected climate state from controller-originated commands. It prevents
/// a delayed GREE status update from being mistaken for remote/manual takeover.
pub(crate) pending_controller_commands: Arc<Mutex<HashMap<String, PendingControllerCommand>>>,
/// Deduplicates detached Cloud fallback polls. Offline devices must never build an
/// unbounded queue of tasks waiting for the same per-device lock.
pub(crate) cloud_poll_inflight: Arc<std::sync::Mutex<HashSet<String>>>,
pub started: Instant,
}
impl AppState {
pub(crate) fn try_begin_cloud_poll(&self, device_id: &str) -> Option<CloudPollGuard> {
let mut inflight = self
.cloud_poll_inflight
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
if !inflight.insert(device_id.to_string()) {
return None;
}
Some(CloudPollGuard {
inflight: self.cloud_poll_inflight.clone(),
device_id: device_id.to_string(),
})
}
pub async fn lock_device_operation(&self, device_id: &str) -> OwnedMutexGuard<()> {
let lock = {
let mut locks = self.device_operation_locks.lock().await;
+88 -4
View File
@@ -143,12 +143,10 @@
<section class="view" data-view="devices">
<div class="section-heading">
<div><span class="eyebrow" data-i18n="devices.lan">LAN network</span>
<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="actions.discover">Discover</button><button class="secondary" data-open="deviceDialog"
data-i18n="devices.addManual">Add 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" 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>
@@ -327,6 +325,7 @@
<button data-history-tab="overview" data-i18n="history.tabOverview">Overview</button>
<button data-history-tab="zones" data-i18n="history.tabZones">Zones</button>
<button data-history-tab="devices" data-i18n="history.tabDevices">GREE devices</button>
<button data-history-tab="energy" data-i18n="energy.title">Energy</button>
<button data-history-tab="sensors" data-i18n="history.tabSensors">HA sensors</button>
<button data-history-tab="custom" data-i18n="history.tabCustom">Custom chart</button>
</div>
@@ -513,6 +512,7 @@
data-i18n="settings.applicationSettings">Application</button>
<button type="button" role="tab" aria-selected="false" data-settings-tab="gree"
data-i18n="settings.greeSettings">GREE</button>
<button type="button" role="tab" aria-selected="false" data-settings-tab="cloud">GREE Cloud</button>
</div>
<form class="settings-form" id="settingsForm" novalidate>
<div class="settings-pane" data-settings-pane="app">
@@ -666,6 +666,10 @@
data-i18n="settings.debugOverlay">Show debug window on every page</span></label>
<label class="check"><input type="checkbox" name="debug_gree_frames"> <span
data-i18n="settings.debugGreeFrames">Include GREE protocol frames</span></label>
<label class="check"><input type="checkbox" name="debug_cloud_requests"> <span
data-i18n="settings.debugCloudRequests">Trace GREE Cloud API requests</span></label>
<label class="check"><input type="checkbox" name="debug_cloud_mqtt"> <span
data-i18n="settings.debugCloudMqtt">Trace GREE Cloud MQTT</span></label>
</div>
</section>
<section class="panel settings-block">
@@ -737,6 +741,39 @@
<div id="greeFrameStats" class="gree-frame-stats"></div>
</section>
</div>
<div class="settings-pane" data-settings-pane="cloud" hidden>
<section class="panel settings-block">
<div class="settings-block-head"><div><h3 data-i18n="settings.cloudAccountTitle">GREE Cloud account</h3><p data-i18n="settings.cloudAccountHint">REST login and device discovery use the selected regional GREE service. The password is never returned by the API after it is saved.</p></div></div>
<div class="settings-grid">
<label class="check wide"><input type="checkbox" name="gree_cloud_enabled"> <span data-i18n="settings.cloudEnable">Enable GREE Cloud</span></label>
<label><span data-i18n="settings.cloudRegion">Region</span><select name="gree_cloud_region">
<option>Australia</option><option>China Mainland</option><option>East South Asia</option><option selected>Europe</option><option>India</option><option>Latin American</option><option>Middle East</option><option>North American</option><option>Russia</option><option>South American</option>
</select></label>
<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><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>
<div><span data-i18n="settings.cloudRestResponseTime">Last REST response time</span><strong id="greeCloudRestResponseTime"></strong></div>
<div><span data-i18n="settings.cloudResponseTime">Last device response time</span><strong id="greeCloudResponseTime"></strong></div>
<div><span data-i18n="settings.cloudLastDeviceResponse">Last device response</span><strong id="greeCloudLastDeviceResponse"></strong></div>
<div><span data-i18n="settings.cloudLastMqttMessage">Last MQTT activity</span><strong id="greeCloudLastMqttMessage"></strong></div>
<div><span data-i18n="settings.cloudConnectedSince">MQTT connected since</span><strong id="greeCloudConnectedSince"></strong></div>
<div><span data-i18n="settings.cloudBroker">Broker</span><strong id="greeCloudBroker"></strong></div>
<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><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>
</section>
</div>
<div class="settings-save-bar"><span data-i18n="settings.saveHint">Save changes made in the blocks
above.</span><button class="primary" type="submit" data-i18n="actions.save">Save</button></div>
</form>
@@ -1089,6 +1126,15 @@
</dialog>
<dialog id="cloudDiscoveryDialog" class="dialog-wide">
<div class="dialog-form">
<div class="dialog-head"><div><span class="eyebrow">GREE Cloud</span><h2 data-i18n="devices.cloudDiscoveryTitle">Devices on account</h2></div><button type="button" data-close>×</button></div>
<p class="field-note" data-i18n="devices.cloudDiscoveryHint">Devices are loaded from the saved GREE Cloud account. Nothing is added automatically; choose the units you want to register.</p>
<div id="cloudDiscoveryList" class="discovery-name-list"></div>
<div class="form-actions"><button type="button" class="secondary" id="cloudDiscoveryRefresh" data-i18n="actions.refresh">Refresh</button><button type="button" class="primary" data-close data-i18n="actions.done">Done</button></div>
</div>
</dialog>
<dialog id="discoverDialog">
<form method="dialog" id="discoverForm" class="dialog-form">
<div class="dialog-head">
@@ -1149,6 +1195,40 @@
</form>
</dialog>
<dialog id="deviceDetailsDialog" class="dialog-wide">
<form method="dialog" id="deviceDetailsForm" class="dialog-form">
<input type="hidden" name="id">
<div class="dialog-head">
<div><span class="eyebrow" data-i18n="devices.cloudDetails">Cloud details</span><h2 id="deviceDetailsTitle">Device details</h2></div>
<button type="button" data-close>×</button>
</div>
<label><span data-i18n="common.name">Name</span><input name="name" required maxlength="80"></label>
<div class="technical-device-grid" id="deviceDetailsMeta"></div>
<section class="settings-block">
<div class="settings-block-head"><div><h3 data-i18n="energy.title">Energy</h3><p data-i18n="energy.sourceHint">Choose the cumulative energy source used for charts and period totals.</p></div></div>
<div class="settings-grid">
<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 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>
</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="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>
<p class="field-note" data-i18n="devices.cloudDiagnosticsHint">Decrypted and sanitized protocol data. Passwords, tokens and cipher keys are never included.</p>
<pre id="cloudDiagnosticsPayload" class="mono cloud-diagnostics-payload"></pre>
<div class="form-actions"><button type="button" class="secondary" id="cloudDiagnosticsRefresh" data-i18n="actions.refresh">Refresh</button><button type="button" class="primary" data-close data-i18n="actions.done">Done</button></div>
</div>
</dialog>
<dialog id="pingDialog" class="dialog-wide ping-dialog">
<div class="dialog-form">
<div class="dialog-head">
@@ -1527,6 +1607,10 @@
data-i18n="debug.requests">Requests</button>
<button type="button" role="tab" aria-selected="false" data-debug-filter="gree"
data-i18n="debug.gree">GREE</button>
<button type="button" role="tab" aria-selected="false" data-debug-filter="cloud"
data-i18n="debug.cloud">Cloud API</button>
<button type="button" role="tab" aria-selected="false" data-debug-filter="mqtt"
data-i18n="debug.mqtt">MQTT</button>
</div>
<button type="button" class="debug-clear" data-action="debug-clear" data-i18n="debug.clear">Clear</button>
</div>
+2
View File
@@ -1,6 +1,7 @@
function settingsFromSnapshot(sections, houseMode = app.settings?.house_mode || 'cool') {
const application = sections?.application || {};
const gree = sections?.gree || {};
const greeCloud = sections?.gree_cloud || {};
const history = sections?.history || {};
const influxdb = sections?.influxdb || {};
const notifications = sections?.notifications || {};
@@ -15,6 +16,7 @@ function settingsFromSnapshot(sections, houseMode = app.settings?.house_mode ||
discovery_timeout_ms: Number(gree.discovery_timeout_ms),
discovery_broadcast: gree.discovery_broadcast,
suppress_device_beep: !!gree.suppress_device_beep,
gree_cloud: greeCloud,
compressor_protection_enabled: gree.compressor_protection_enabled !== false,
compressor_protection_seconds: Number(gree.compressor_protection_seconds),
history_retention_days: Number(history.retention_days),
+67
View File
@@ -35,6 +35,15 @@ function renderHistoryNavigation() {
} else if (app.historyTab === 'devices') {
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>`
: `<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 intervalSelect = $('#historyEnergyInterval'); if (intervalSelect) intervalSelect.value = app.historyEnergyInterval;
} 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;
@@ -50,6 +59,13 @@ async function loadHistory() {
app.historyLoading = true;
const hours = $('#historyHours')?.value || '24';
try {
if (app.historyTab === 'energy') {
await loadEnergyHistory();
renderHistoryNavigation();
renderHistoryPage();
if (app.currentView === 'history') updateBrowserUrl(currentHistoryPath(), true);
return;
}
const data = await api(`/api/history?scope=overview&hours=${encodeURIComponent(hours)}&limit=20000`);
app.historyData = { zones: data.zones || [], devices: data.devices || [], sensors: data.sensors || [] };
app.historyCounts = data.counts || {};
@@ -99,8 +115,12 @@ function redrawHistoryChart(id) {
const canvas = document.getElementById(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);
} else {
drawLineChart(canvas, runtime.series, runtime.rows, runtime.options);
renderLegend(document.getElementById(`${id}Legend`), runtime.series);
}
updateChartZoomControls(id);
}
@@ -418,6 +438,53 @@ 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 } = {}) {
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 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));
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;
const y = pad.top + plotH - (value / max) * plotH;
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);
});
ctx.fillStyle = text;
const ticks = Math.min(6, buckets.length);
for (let i = 0; i < ticks; i++) {
const index = ticks === 1 ? 0 : Math.round(i * (buckets.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.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>`;
updateChartZoomControls(canvas.id);
}
function renderLegend(host, series) {
if (!host) return;
const chartId = host.id.replace(/Legend$/, '');
+1 -1
View File
@@ -30,7 +30,7 @@ const app = {
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', historyLoading: false,
historyZone: 'all', historyDevice: 'all', historySensor: 'all', historyEnergyDevice: '', historyEnergyInterval: 'daily', historyEnergy: null, historyLoading: 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(),
+113 -8
View File
@@ -1,11 +1,12 @@
function deviceFeaturePanel(device) {
const features = [
const allFeatures = [
['light', 'supports_light', tr('devices.light')],
['xfan', 'supports_xfan', tr('devices.xfan')],
['health', 'supports_health', tr('devices.health')],
['air', 'supports_air', tr('devices.air')],
['sleep', 'supports_sleep', tr('devices.sleep')],
].filter(([, support]) => device[support] === true);
];
const features = allFeatures.filter(([, support]) => device[support] === true);
if (!features.length) return `<div class="device-capability-panel"><small>${esc(tr('devices.features'))}</small><span class="muted">${esc(tr('devices.noExtraFeatures'))}</span></div>`;
return `<div class="device-capability-panel"><small>${esc(tr('devices.features'))}</small><div class="device-capability-buttons">${features.map(([field, , label]) => `<button class="${device[field] ? 'active' : ''}" data-action="toggle" data-field="${field}" data-device="${esc(device.id)}">${esc(label)}</button>`).join('')}</div></div>`;
}
@@ -19,7 +20,23 @@ function disabledZoneForDevice(deviceId) {
return app.zones.find(zone => zone.device_id === deviceId && zone.enabled === false) || null;
}
function deviceTransportLabel(device) {
return device.connection_type === 'gree_cloud' ? 'GREE Cloud' : 'Local';
}
function deviceConnectionStatusLabel(device) {
const key = {
online: 'status.online',
offline: 'status.offline',
cloud_disconnected: 'status.cloudDisconnected',
authentication_error: 'status.authenticationError',
unknown: 'status.unknown',
}[device.connection_status || (device.online ? 'online' : 'offline')];
return key ? tr(key) : String(device.connection_status || tr('status.unknown')).replaceAll('_', ' ');
}
function deviceProtocolLabel(device) {
if (device.connection_type === 'gree_cloud') return 'MQTT / TLS';
return device.simulated ? tr('devices.simulator') : device.protocol_version === 2 ? 'V2 AES-GCM' : device.protocol_version === 1 ? 'V1 AES-ECB' : tr('devices.protocolAuto');
}
@@ -28,6 +45,15 @@ function deviceResponseTimeLabel(device) {
return Number.isFinite(value) ? `${Math.max(0, Math.round(value))} ms` : '— ms';
}
function manualDeviceStatus(device) {
const label = deviceConnectionStatusLabel(device);
const detail = device.last_error ? `<span class="manual-device-status-error" title="${esc(device.last_error)}">${esc(device.last_error)}</span>` : '';
const pending = device.pending_command ? `<span class="manual-device-status-pending">${esc(tr('common.pending'))}</span>` : '';
return `<p class="manual-device-status" role="status"><span class="status ${device.online ? 'online' : ''}">${esc(label)}</span>${detail}${pending}</p>`;
}
// Keep Local Manual Control visually and behaviorally identical to the pre-Cloud UI.
// The only transport-specific addition is the existing Local badge in the title row.
function manualDeviceProblem(device) {
if (!device.enabled) {
return `<div class="manual-device-problem error" role="status"><strong>${esc(tr('devices.manualDeviceDisabled'))}</strong></div>`;
@@ -41,7 +67,7 @@ function manualDeviceProblem(device) {
return '';
}
function manualDeviceCard(device) {
function manualLocalDeviceCard(device) {
const modes = ['auto', 'cool', 'dry', 'fan', 'heat'];
const fans = [0, 1, 3, 5];
const disabledZone = disabledZoneForDevice(device.id);
@@ -49,7 +75,7 @@ function manualDeviceCard(device) {
const warning = disabledZone ? `<div class="manual-zone-warning" role="note"><strong>${esc(tr('devices.manualDisabledZoneTitle'))}</strong><p>${esc(tr('devices.manualDisabledZoneWarning', { zone: disabledZone.name }))}</p></div>` : '';
return `<article class="device-card quick-control-card quick-device-control ${device.power ? '' : 'off'} ${disabledZone ? 'manual-zone-blocked' : ''}" data-device-card="${esc(device.id)}">
<div class="device-head">
<div class="device-title"><h3>${esc(device.name)}</h3></div>
<div class="device-title"><h3>${esc(device.name)}</h3><span class="badge">Local</span></div>
<button class="power-button ${device.power ? 'on' : ''}" data-action="power" data-device="${esc(device.id)}" aria-label="${esc(tr('devices.power'))}"></button>
</div>
${warning}
@@ -72,7 +98,50 @@ function manualDeviceCard(device) {
</article>`;
}
function manualCloudDeviceCard(device) {
const caps = device.capabilities || {};
const modes = caps.mode === false ? [] : ['auto', 'cool', 'dry', 'fan', 'heat'];
const reportedFans = Array.isArray(caps.fan_modes) ? caps.fan_modes.map(Number).filter(Number.isFinite) : [];
const fans = reportedFans.length ? reportedFans : [0, 1, 3, 5];
const tempStep = Number(caps.temperature_step) > 0 ? Number(caps.temperature_step) : 1;
const minTemp = Number.isFinite(Number(caps.min_temperature)) ? Number(caps.min_temperature) : 8;
const maxTemp = Number.isFinite(Number(caps.max_temperature)) ? Number(caps.max_temperature) : 30;
const disabledZone = disabledZoneForDevice(device.id);
const managedZone = app.zones.find(zone => zone.device_id === device.id && zone.compressor_pending_action) || zoneForDevice(device.id);
const warning = disabledZone ? `<div class="manual-zone-warning" role="note"><strong>${esc(tr('devices.manualDisabledZoneTitle'))}</strong><p>${esc(tr('devices.manualDisabledZoneWarning', { zone: disabledZone.name }))}</p></div>` : '';
return `<article class="device-card quick-control-card quick-device-control cloud-manual-device ${device.pending_command ? 'cloud-command-pending' : ''} ${device.power ? '' : 'off'} ${disabledZone ? 'manual-zone-blocked' : ''}" data-device-card="${esc(device.id)}">
<div class="device-head">
<div class="device-title">
<div class="manual-device-title-row"><h3>${esc(device.name)}</h3><span class="badge">GREE Cloud</span></div>
${manualDeviceStatus(device)}
</div>
<button class="power-button ${device.power ? 'on' : ''}" data-action="power" data-device="${esc(device.id)}" aria-label="${esc(tr('devices.power'))}"></button>
</div>
${warning}
<div class="temperature-control">
<button data-action="temperature" data-delta="${-tempStep}" data-device="${esc(device.id)}"></button>
<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>
${compressorQueuePanel(managedZone)}
${modes.length ? `<div class="mode-row quick-control-row quick-control-row-5">${modes.map(mode => `<button class="${device.mode === mode ? 'active' : ''}" data-action="mode" data-value="${mode}" data-device="${esc(device.id)}">${esc(modeLabel(mode))}</button>`).join('')}</div>` : ''}
${fans.length ? `<div class="fan-row quick-control-row quick-control-row-${Math.min(6, fans.length)}">${fans.map(fan => `<button class="${Number(device.fan_speed) === fan ? 'active' : ''}" data-action="fan" data-value="${fan}" data-device="${esc(device.id)}">${esc(fanLabel(fan))}</button>`).join('')}</div>` : ''}
<div class="device-toggles quick-control-row quick-control-row-3">
${caps.vertical_swing === false ? '' : `<button class="${device.swing_vertical ? 'active' : ''}" data-action="toggle" data-field="swing_vertical" data-device="${esc(device.id)}">${esc(tr('devices.swing'))}</button>`}
${device.supports_quiet === false ? '' : `<button class="${device.quiet ? 'active' : ''}" data-action="toggle" data-field="quiet" data-device="${esc(device.id)}">${esc(tr('devices.quiet'))}</button>`}
${device.supports_turbo === false ? '' : `<button class="${device.turbo ? 'active' : ''}" data-action="toggle" data-field="turbo" data-device="${esc(device.id)}">${esc(tr('devices.turbo'))}</button>`}
</div>
${deviceFeaturePanel(device)}
</article>`;
}
function manualDeviceCard(device) {
return device.connection_type === 'gree_cloud' ? manualCloudDeviceCard(device) : manualLocalDeviceCard(device);
}
function technicalDeviceCard(device) {
if (device.connection_type === 'gree_cloud') return cloudTechnicalDeviceCard(device);
const protocol = deviceProtocolLabel(device);
const responseTime = deviceResponseTimeLabel(device);
const lastSeen = device.last_seen ? dateTime(device.last_seen) : tr('common.unavailable');
@@ -82,11 +151,11 @@ function technicalDeviceCard(device) {
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>` : '';
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'))}</span><h3>${esc(device.name)}</h3><p><span class="status ${device.online ? 'online' : ''}">${esc(tr(device.online ? 'status.online' : 'status.offline'))}</span> · ${esc(model)}</p></div>
<button class="device-ping-button" type="button" data-action="open-ping" data-device="${esc(device.id)}" title="${esc(tr('devices.pingOpen'))}"><span>${esc(tr('devices.ping'))}</span><strong>${esc(responseTime)}</strong></button>
<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>
${device.connection_type === 'gree_cloud' ? `<span class="badge">GREE Cloud</span>` : `<button class="device-ping-button" type="button" data-action="open-ping" data-device="${esc(device.id)}" title="${esc(tr('devices.pingOpen'))}"><span>${esc(tr('devices.ping'))}</span><strong>${esc(responseTime)}</strong></button>`}
</div>
<div class="technical-device-grid">
<div><span>${esc(tr('devices.address'))}</span><strong>${esc(device.ip)}:${esc(device.port)}</strong></div>
<div><span>${esc(tr('devices.address'))}</span><strong>${device.connection_type === 'gree_cloud' ? 'GREE Cloud' : `${esc(device.ip)}:${esc(device.port)}`}</strong></div>
<div><span>MAC</span><strong>${esc(device.mac)}</strong></div>
<div><span>CID</span><strong>${esc(cid)}</strong></div>
<div><span>${esc(tr('devices.protocol'))}</span><strong>${esc(protocol)}</strong></div>
@@ -98,7 +167,43 @@ function technicalDeviceCard(device) {
<div class="technical-device-actions">
<button type="button" data-action="poll" data-device="${esc(device.id)}">${esc(tr('devices.readStatus'))}</button>
<button type="button" data-action="rename-device" data-device="${esc(device.id)}">${esc(tr('devices.technicalConfig'))}</button>
${device.simulated ? '' : `<button type="button" data-action="bind" data-device="${esc(device.id)}">${esc(tr('actions.bind'))}</button>`}
<button type="button" data-action="energy-config" data-device="${esc(device.id)}">${esc(tr('energy.title'))}</button>
${device.simulated || device.connection_type === 'gree_cloud' ? '' : `<button type="button" data-action="bind" data-device="${esc(device.id)}">${esc(tr('actions.bind'))}</button>`}
<details class="card-overflow"><summary aria-label="${esc(tr('actions.more'))}" title="${esc(tr('actions.more'))}"></summary><div class="card-overflow-menu"><button class="danger" data-action="delete-device" data-device="${esc(device.id)}">${esc(tr('actions.delete'))}</button></div></details>
</div>
</article>`;
}
function cloudTechnicalDeviceCard(device) {
const lastSync = device.last_cloud_sync ? dateTime(device.last_cloud_sync) : tr('devices.noCloudSyncYet');
const lastSeen = device.last_seen ? dateTime(device.last_seen) : tr('devices.noCloudResponseYet');
const cloudId = device.cloud_device_id || device.mac || tr('common.unavailable');
const status = deviceConnectionStatusLabel(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>` : '';
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>
<span class="badge">GREE Cloud</span>
</div>
<div class="technical-device-grid">
<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(cloudId)}</strong></div>
<div><span>MAC</span><strong>${esc(device.mac || cloudId)}</strong></div>
${modelCell}
${firmwareCell}
<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>
</div>
${error}
<div class="technical-device-actions">
<button type="button" data-action="poll" data-device="${esc(device.id)}">${esc(tr('devices.readStatus'))}</button>
<button type="button" data-action="cloud-details" data-device="${esc(device.id)}">${esc(tr('devices.cloudDetails'))}</button>
<button type="button" data-action="cloud-diagnostics" data-device="${esc(device.id)}">${esc(tr('devices.cloudDiagnostics'))}</button>
<details class="card-overflow"><summary aria-label="${esc(tr('actions.more'))}" title="${esc(tr('actions.more'))}"></summary><div class="card-overflow-menu"><button class="danger" data-action="delete-device" data-device="${esc(device.id)}">${esc(tr('actions.delete'))}</button></div></details>
</div>
</article>`;
+74 -1
View File
@@ -73,6 +73,18 @@ document.addEventListener('click', async event => {
return;
}
const action = button.dataset.action; if (!action) return;
if (action === 'add-cloud-device') {
const cloudId = button.dataset.cloudId; if (!cloudId) return;
button.disabled = true;
try {
await api(`/api/integrations/gree-cloud/devices/${encodeURIComponent(cloudId)}/add`, { method: 'POST' });
await loadBootstrap();
await loadCloudDiscovery({ open: false });
toast(tr('devices.cloudAdded'));
} catch (error) { toast(error.message, true); }
finally { button.disabled = false; }
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; }
@@ -84,6 +96,9 @@ document.addEventListener('click', async event => {
if (action === 'poll' && device) { try { button.disabled = true; updateDevice(await api(`/api/devices/${encodeURIComponent(device.id)}/poll`, { method: 'POST' })); renderAll(); toast(tr('devices.readDone')); } catch (e) { toast(e.message, true); } finally { button.disabled = false; } return; }
if (action === 'bind' && device) { try { button.disabled = true; updateDevice(await api(`/api/devices/${encodeURIComponent(device.id)}/bind`, { method: 'POST' })); renderAll(); toast(tr('devices.bound')); } catch (e) { toast(e.message, true); } finally { button.disabled = false; } return; }
if (action === 'rename-device' && device) return populateDeviceRename(device.id);
if (action === 'energy-config' && device) return openDeviceDetails(device.id);
if (action === 'cloud-details' && device) return openDeviceDetails(device.id);
if (action === 'cloud-diagnostics' && device) return openCloudDiagnostics(device.id);
if (action === 'delete-device') return deleteEntity('devices', button.dataset.device, 'label.device');
if (action === 'house-mode') {
try {
@@ -209,6 +224,13 @@ $('#discoverButton').addEventListener('click', () => {
form.protocol_version.value = '0'; form.passes.value = '3'; form.timeout_ms.value = String(Math.max(6000, Number(app.settings?.discovery_timeout_ms || 3000)));
openDialog('discoverDialog');
});
$('#cloudDiscoverButton')?.addEventListener('click', async event => {
const button = event.currentTarget;
button.disabled = true;
try { await loadCloudDiscovery(); }
catch (error) { toast(error.message, true); }
finally { button.disabled = false; }
});
$('#historyRefresh').addEventListener('click', loadHistory);
$('#historyHours').addEventListener('change', () => { updateBrowserUrl(currentHistoryPath(), true); loadHistory(); });
$('#logsRefresh').addEventListener('click', loadLogs);
@@ -328,12 +350,20 @@ $('#renameDeviceForm').addEventListener('submit', async event => {
const result = $('#deviceConfigCheckResult');
if (result) { result.hidden = true; result.textContent = ''; result.classList.remove('success', 'error'); }
await runFormTask(form, async () => {
let device = await api(`/api/devices/${encodeURIComponent(raw.id)}`, { method: 'PATCH', body: { name: raw.name.trim(), ip: raw.ip.trim(), port: Number(raw.port), protocol_version: Number(raw.protocol_version) } });
const isCloud = previous?.connection_type === 'gree_cloud';
const patch = isCloud ? { name: raw.name.trim() } : { name: raw.name.trim(), ip: raw.ip.trim(), port: Number(raw.port), protocol_version: Number(raw.protocol_version) };
let device = await api(`/api/devices/${encodeURIComponent(raw.id)}`, { method: 'PATCH', body: patch });
updateDevice(device);
if (saveMode !== 'check') {
form.closest('dialog').close(); renderAll(); toast(tr('common.saved')); return;
}
try {
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) }); }
markFormClean(form); renderAll(); toast(tr('devices.savedAndChecked')); return;
}
const protocolChanged = previous && Number(previous.protocol_version) !== Number(raw.protocol_version);
if (!device.simulated && protocolChanged) {
device = await api(`/api/devices/${encodeURIComponent(device.id)}/bind`, { method: 'POST' });
@@ -367,6 +397,35 @@ $('#renameDeviceForm').addEventListener('submit', async event => {
});
});
$('#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,
};
await runFormTask(form, async () => {
const device = await api(`/api/devices/${encodeURIComponent(raw.id)}`, { method: 'PATCH', body: patch });
updateDevice(device);
form.closest('dialog').close();
renderAll();
toast(tr('common.saved'));
});
});
$('#deviceDetailsForm')?.ha_energy_entity_id?.addEventListener('change', updateDeviceEnergySensorMeta);
$('#cloudDiagnosticsRefresh')?.addEventListener('click', () => {
const id = $('#cloudDiagnosticsDialog')?.dataset.deviceId;
if (id) openCloudDiagnostics(id);
});
$('#pingDeviceSelect')?.addEventListener('change', event => {
app.pingMonitor.targetId = event.target.value;
renderPingDialog();
@@ -495,3 +554,17 @@ $('#automationForm').addEventListener('submit', async event => {
await api(id ? `/api/automations/${encodeURIComponent(id)}` : '/api/automations', { method: id ? 'PUT' : 'POST', body }); form.closest('dialog').close(); form.reset(); await loadBootstrap(); toast(tr('common.saved'));
});
});
$('#greeCloudReconnectButton')?.addEventListener('click', async event => {
const button = event.currentTarget;
try {
button.disabled = true;
await api('/api/integrations/gree-cloud/reconnect', { method: 'POST' });
await refreshGreeCloudRuntimeStatus();
toast(tr('common.saved'));
} catch (error) {
toast(error.message, true);
} finally {
button.disabled = false;
}
});
+41
View File
@@ -1,5 +1,6 @@
function renderHistorySummary() {
const host = $('#historySummary'); if (!host) return;
if (app.historyTab === 'energy') { renderEnergyHistorySummary(); return; }
const deviceRows = app.historyData.devices, zoneRows = app.historyData.zones, sensorRows = app.historyData.sensors;
const cards = [
[tr('history.deviceSamples'), app.historyCounts.devices ?? deviceRows.length, tr('history.greeHistory')],
@@ -155,6 +156,7 @@ function renderHistoryPage() {
if (app.historyTab === 'overview') renderOverviewHistory();
else if (app.historyTab === 'zones') renderZoneHistory();
else if (app.historyTab === 'devices') renderDeviceHistory();
else if (app.historyTab === 'energy') renderEnergyHistory();
else if (app.historyTab === 'sensors') renderSensorHistory();
else renderCustomHistory();
}
@@ -196,3 +198,42 @@ 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;
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`);
}
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('');
}
function renderEnergyHistory() {
const host = $('#historyCharts'); if (!host) return;
const data = app.historyEnergy;
renderEnergyHistorySummary();
if (!data || data.source === 'none') {
host.innerHTML = `<div class="empty"><strong>${esc(tr('energy.noData'))}</strong></div>`;
return;
}
host.innerHTML = historyChartMarkup('energyConsumptionChart', tr('energy.title'), tr('energy.chartHint'));
drawEnergyBarChart($('#energyConsumptionChart'), data.buckets || [], { height: 360 });
}
+139 -6
View File
@@ -213,9 +213,33 @@ function openDevicePing(id) {
startPingMonitor();
}
function applyOptimisticCloudCommand(device, command) {
if (!device || device.connection_type !== 'gree_cloud' || !command || typeof command !== 'object') return;
const fields = [
'power', 'mode', 'target_temperature', 'fan_speed', 'swing_vertical',
'swing_horizontal', 'quiet', 'turbo', 'light', 'air', 'xfan', 'health', 'sleep',
];
for (const field of fields) {
if (Object.prototype.hasOwnProperty.call(command, field) && command[field] !== undefined) {
device[field] = command[field];
}
}
device.pending_command = true;
// A stale transport error should not visually override the command the user has just sent.
// The backend/push path will restore it if the publish actually fails.
device.last_error = null;
renderDevices();
}
async function sendDeviceCommand(id, commandOrFactory, { disabledZoneConfirmed = false } = {}) {
const disabledZone = disabledZoneForDevice(id);
if (disabledZone && !disabledZoneConfirmed && !confirmManualCommandForDisabledZone(id)) return false;
const initialDevice = app.devices.find(device => device.id === id);
const isCloud = initialDevice?.connection_type === 'gree_cloud';
// Local keeps the historical synchronous UX exactly as before Cloud support was added.
if (!isCloud) {
const previous = app.deviceControlQueue[id] || Promise.resolve();
const request = previous.catch(() => { }).then(() => {
const current = app.devices.find(device => device.id === id);
@@ -235,6 +259,31 @@ async function sendDeviceCommand(id, commandOrFactory, { disabledZoneConfirmed =
} finally {
if (app.deviceControlQueue[id] === request) delete app.deviceControlQueue[id];
}
}
// Cloud controls update immediately in the browser. MQTT ACK/push is confirmation, not
// a prerequisite for button/temperature feedback. Requests are still serialized per unit.
const command = typeof commandOrFactory === 'function' ? commandOrFactory(initialDevice) : commandOrFactory;
if (!command || typeof command !== 'object') return false;
applyOptimisticCloudCommand(initialDevice, command);
const previous = app.deviceControlQueue[id] || Promise.resolve();
const request = previous.catch(() => { }).then(() => {
const body = disabledZone ? { ...command, manual_override: true } : command;
return api(`/api/devices/${encodeURIComponent(id)}/command`, { method: 'POST', body });
});
app.deviceControlQueue[id] = request;
try {
const device = await request;
updateDevice(device); renderAll();
return true;
} catch (error) {
toast(error.message, true);
try { await loadBootstrap(); } catch (_) { }
return false;
} finally {
if (app.deviceControlQueue[id] === request) delete app.deviceControlQueue[id];
}
}
function queueDeviceTemperature(id, delta) {
@@ -246,7 +295,12 @@ function queueDeviceTemperature(id, delta) {
if (!confirmManualCommandForDisabledZone(id)) return;
draft = { ...(draft || {}), disabledZoneConfirmed: true };
}
const next = clamp(Number(draft?.target ?? device.target_temperature) + Number(delta), 8, 30);
const caps = device.capabilities || {};
const minTemp = Number.isFinite(Number(caps.min_temperature)) ? Number(caps.min_temperature) : 8;
const maxTemp = Number.isFinite(Number(caps.max_temperature)) ? Number(caps.max_temperature) : 30;
const step = Number(caps.temperature_step) > 0 ? Number(caps.temperature_step) : 1;
const rawNext = clamp(Number(draft?.target ?? device.target_temperature) + Number(delta), minTemp, maxTemp);
const next = Math.round(rawNext / step) * step;
device.target_temperature = next;
clearTimeout(draft?.timer);
draft = {
@@ -365,7 +419,10 @@ function beginInlineTemperatureEdit(target) {
}
const value = parseDecimal(target.dataset.value);
if (!Number.isFinite(value) || !kind || !id) return;
const decimals = kind === 'zone' ? 1 : 0;
const step = kind === 'device' ? Number(target.dataset.tempStep || 1) : 0.5;
const minValue = kind === 'device' ? Number(target.dataset.tempMin || 8) : 8;
const maxValue = kind === 'device' ? Number(target.dataset.tempMax || 30) : 30;
const decimals = kind === 'zone' || step < 1 ? 1 : 0;
const input = document.createElement('input');
input.className = 'inline-temperature-input';
input.type = 'text';
@@ -383,8 +440,8 @@ function beginInlineTemperatureEdit(target) {
if (finished) return;
finished = true;
const parsed = parseDecimal(input.value);
if (!commit || !Number.isFinite(parsed) || parsed < 8 || parsed > 30) {
if (commit && (!Number.isFinite(parsed) || parsed < 8 || parsed > 30)) toast(tr('validation.range', { min: 8, max: 30 }), true);
if (!commit || !Number.isFinite(parsed) || parsed < minValue || parsed > maxValue) {
if (commit && (!Number.isFinite(parsed) || parsed < minValue || parsed > maxValue)) toast(tr('validation.range', { min: minValue, max: maxValue }), true);
renderAll();
return;
}
@@ -394,7 +451,10 @@ function beginInlineTemperatureEdit(target) {
return;
}
const device = app.devices.find(item => item.id === id);
if (device) await sendDeviceCommand(id, { target_temperature: Math.round(clamp(parsed, 8, 30)) });
if (device) {
const snapped = Math.round(clamp(parsed, minValue, maxValue) / step) * step;
await sendDeviceCommand(id, { target_temperature: snapped });
}
};
input.addEventListener('keydown', event => {
if (event.key === 'Enter') { event.preventDefault(); input.blur(); }
@@ -419,7 +479,9 @@ function showDiscoveryNames(ids) {
function populateDeviceRename(id) {
const device = app.devices.find(item => item.id === id); if (!device) return;
const form = $('#renameDeviceForm'); form.reset();
form.id.value = device.id; form.name.value = device.name; form.ip.value = device.ip; form.port.value = String(device.port ?? 7000); form.protocol_version.value = String(device.protocol_version ?? 0);
const isCloud = device.connection_type === 'gree_cloud';
form.id.value = device.id; form.name.value = device.name; form.ip.value = device.ip || ''; form.port.value = String(device.port || 7000); form.protocol_version.value = String(device.protocol_version ?? 0);
form.ip.disabled = isCloud; form.port.disabled = isCloud; form.protocol_version.disabled = isCloud;
const result = $('#deviceConfigCheckResult');
if (result) { result.hidden = true; result.textContent = ''; result.classList.remove('success', 'error'); }
openDialog('renameDeviceDialog');
@@ -631,3 +693,74 @@ function populateAutomation(id) {
openDialog('automationDialog');
}
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.name.value = device.name || '';
form.energy_source.value = device.energy_source || 'auto';
const title = $('#deviceDetailsTitle');
if (title) title.textContent = device.connection_type === 'gree_cloud' ? tr('devices.cloudDetails') : tr('energy.title');
const meta = $('#deviceDetailsMeta');
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 source = form.energy_source;
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.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 || '';
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)) {
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 || '';
sensorSelect.append(option);
}
sensorSelect.value = device.ha_energy_entity_id || '';
updateDeviceEnergySensorMeta();
openDialog('deviceDetailsDialog');
}
function updateDeviceEnergySensorMeta() {
const select = $('#deviceDetailsForm')?.ha_energy_entity_id;
const meta = $('#deviceEnergySensorMeta');
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');
}
async function openCloudDiagnostics(id) {
const dialog = $('#cloudDiagnosticsDialog');
const payload = $('#cloudDiagnosticsPayload');
if (!dialog || !payload) return;
dialog.dataset.deviceId = id;
payload.textContent = tr('common.loading');
openDialog('cloudDiagnosticsDialog');
try {
const diagnostics = await api(`/api/devices/${encodeURIComponent(id)}/cloud-diagnostics`);
payload.textContent = JSON.stringify(diagnostics, null, 2);
} catch (error) {
payload.textContent = error.message || String(error);
}
}
+3
View File
@@ -55,6 +55,7 @@ async function handleWebSocketMessage(event) {
else if (message.event === 'flow.deleted') { app.flows = app.flows.filter(v => v.id !== data.id); renderFlows(); scheduleControlPlanLoad(); }
else if (message.event === 'settings.application.updated') { applySettingsSection('application', data); if (!isFormDirty($('#settingsForm'))) renderSettings(); renderSimulationModeBanner(); renderSystemInfo(); }
else if (message.event === 'settings.gree.updated') { applySettingsSection('gree', data); if (!isFormDirty($('#settingsForm'))) renderSettings(); scheduleControlPlanLoad(); }
else if (message.event === 'settings.gree_cloud.updated') { applySettingsSection('greeCloud', data); if (!isFormDirty($('#settingsForm'))) renderSettings(); }
else if (message.event === 'settings.history.updated') { applySettingsSection('history', data); if (!isFormDirty($('#settingsForm'))) renderSettings(); renderLogRetention(); }
else if (message.event === 'settings.influxdb.updated') { applySettingsSection('influxdb', data); if (!isFormDirty($('#settingsForm'))) renderSettings(); }
else if (message.event === 'settings.notifications.updated') { applySettingsSection('notifications', data); if (!isFormDirty($('#settingsForm'))) renderSettings(); }
@@ -77,6 +78,8 @@ async function handleWebSocketMessage(event) {
renderSystemInfo();
}
else if (message.event === 'gree.frame') { if (app.settings?.debug?.overlay_enabled) debugLine('GREE', `${data.direction || '?'} ${data.protocol_version || ''}`, data.device_name || data.device_id || data.target || '', message.timestamp, data.payload); }
else if (message.event === 'gree_cloud.request') { if (app.settings?.debug?.overlay_enabled) debugLine('CLOUD', `${data.operation || 'request'} · ${data.phase || '?'}`, `${data.device_name || data.device_id || data.transport || ''}${data.duration_ms == null ? '' : ` · ${data.duration_ms} ms`}`, message.timestamp, data); }
else if (message.event === 'gree_cloud.mqtt') { if (app.settings?.debug?.overlay_enabled) debugLine('MQTT', `${data.direction || '?'}`, `${data.topic || data.broker || ''}${data.device_id ? ` · ${data.device_id}` : ''}`, message.timestamp, data); }
else if (message.event === 'api.request') { if (app.settings?.debug?.overlay_enabled) debugLine('HTTP', `${data.method || '?'} ${data.status || ''}`, `${data.path || ''} · ${data.duration_ms ?? '?'} ms`, message.timestamp); }
else if (message.event === 'log.created') { if (app.settings?.debug?.overlay_enabled) debugLine('API', data.kind || data.level || 'log', data.message || '', message.timestamp, data.metadata); if (app.currentView === 'logs') loadLogs(); }
else if (message.event === 'log.updated') { if (app.currentView === 'logs') loadLogs(); }
+5 -1
View File
@@ -13,7 +13,7 @@ const VIEW_ROUTES = Object.freeze({
logs: '/events',
});
const ROUTE_VIEWS = Object.freeze(Object.fromEntries(Object.entries(VIEW_ROUTES).map(([view, path]) => [path, view])));
const HISTORY_TABS = Object.freeze(['overview', 'zones', 'devices', 'sensors', 'custom']);
const HISTORY_TABS = Object.freeze(['overview', 'zones', 'devices', 'energy', 'sensors', 'custom']);
let routerInitialized = false;
function currentHistoryPath() {
@@ -23,6 +23,8 @@ 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.historyEnergyInterval !== 'daily') params.set('interval', app.historyEnergyInterval);
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();
@@ -71,6 +73,8 @@ 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');
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;
+55 -5
View File
@@ -178,6 +178,17 @@ function renderSettings() {
form.event_log_retention_days.value = app.settings.event_log_retention_days || 30;
form.history_compaction_enabled.checked = app.settings.history_compaction_enabled !== false;
form.suppress_device_beep.checked = !!app.settings.suppress_device_beep;
const cloud = app.settings.gree_cloud || {};
form.gree_cloud_enabled.checked = !!cloud.enabled;
form.gree_cloud_region.value = cloud.region || 'Europe';
form.gree_cloud_username.value = cloud.username || '';
form.gree_cloud_password.value = '';
form.gree_cloud_password.placeholder = cloud.password_configured ? tr('settings.secretSaved') : tr('settings.secretKeep');
form.gree_cloud_polling_interval_seconds.value = Number(cloud.polling_interval_seconds || 60);
const cloudInstance = $('#greeCloudInstanceId');
if (cloudInstance) cloudInstance.textContent = cloud.installation_id || '—';
const cloudLastContact = $('#greeCloudLastContact');
if (cloudLastContact) cloudLastContact.textContent = cloud.last_successful_contact ? dateTime(cloud.last_successful_contact) : tr('common.unavailable');
form.compressor_protection_enabled.checked = app.settings.compressor_protection_enabled !== false;
form.compressor_protection_minutes.value = (Number(app.settings.compressor_protection_seconds || 180) / 60).toFixed(1).replace(/\.0$/, '');
updateCompressorProtectionFields();
@@ -195,6 +206,8 @@ function renderSettings() {
form.influx_token.placeholder = app.settings.influxdb?.token_configured ? tr('settings.secretSaved') : tr('settings.secretKeep');
form.debug_overlay_enabled.checked = !!app.settings.debug?.overlay_enabled;
form.debug_gree_frames.checked = !!app.settings.debug?.gree_frames;
form.debug_cloud_requests.checked = !!app.settings.debug?.cloud_requests;
form.debug_cloud_mqtt.checked = !!app.settings.debug?.cloud_mqtt;
const n = app.settings.notifications || {};
form.notifications_enabled.checked = !!n.enabled; form.notifications_mode.value = n.mode || 'problems'; form.notifications_provider.value = n.provider || 'pushover';
form.pushover_app_token.value = ''; form.pushover_user_key.value = ''; form.slack_webhook_url.value = ''; form.discord_webhook_url.value = '';
@@ -221,6 +234,7 @@ function renderSettings() {
renderSimulationModeBanner();
setSettingsTab(app.settingsTab);
markFormClean(form);
void refreshGreeCloudRuntimeStatus();
}
function updateCompressorProtectionFields() {
@@ -238,7 +252,7 @@ function renderSimulationModeBanner() {
}
function setSettingsTab(tab) {
app.settingsTab = tab === 'gree' ? 'gree' : 'app';
app.settingsTab = ['gree', 'cloud'].includes(tab) ? tab : 'app';
$$('[data-settings-pane]').forEach(pane => { pane.hidden = pane.dataset.settingsPane !== app.settingsTab; });
$$('[data-settings-tab]').forEach(button => {
const active = button.dataset.settingsTab === app.settingsTab;
@@ -331,9 +345,16 @@ function renderDebugOverlay() {
const enabled = !!app.settings?.debug?.overlay_enabled;
overlay.hidden = !enabled;
if (!enabled) return;
if (!['all', 'requests', 'gree'].includes(app.debugFilter)) app.debugFilter = 'all';
if (!['all', 'requests', 'gree', 'cloud', 'mqtt'].includes(app.debugFilter)) app.debugFilter = 'all';
const status = $('#debugOverlayStatus');
if (status) status.textContent = app.settings?.debug?.gree_frames ? tr('debug.apiAndGree') : tr('debug.apiOnly');
if (status) {
const enabledSources = [
app.settings?.debug?.gree_frames ? tr('debug.gree') : '',
app.settings?.debug?.cloud_requests ? tr('debug.cloud') : '',
app.settings?.debug?.cloud_mqtt ? tr('debug.mqtt') : '',
].filter(Boolean);
status.textContent = enabledSources.length ? `${tr('debug.liveSources')}: ${enabledSources.join(' · ')}` : tr('debug.apiOnly');
}
$$('[data-debug-filter]', overlay).forEach(button => {
const active = button.dataset.debugFilter === app.debugFilter;
button.classList.toggle('active', active);
@@ -343,13 +364,15 @@ function renderDebugOverlay() {
const visible = app.debugLines.filter(line => {
if (app.debugFilter === 'gree') return line.source === 'GREE';
if (app.debugFilter === 'requests') return line.source === 'HTTP';
if (app.debugFilter === 'cloud') return line.source === 'CLOUD';
if (app.debugFilter === 'mqtt') return line.source === 'MQTT';
return true;
}).slice(-120);
host.innerHTML = visible.length ? visible.map(line => {
const details = line.data == null ? '' : ` ${typeof line.data === 'string' ? line.data : JSON.stringify(line.data)}`;
const sourceClass = line.source === 'GREE' ? 'gree' : line.source === 'HTTP' ? 'request' : 'api';
const sourceClass = line.source === 'GREE' ? 'gree' : line.source === 'HTTP' ? 'request' : line.source === 'CLOUD' ? 'cloud' : line.source === 'MQTT' ? 'mqtt' : 'api';
return `<div class="debug-line source-${sourceClass}"><time>${esc(new Date(line.timestamp).toLocaleTimeString(locale()))}</time><b>${esc(line.source)}</b><span>${esc(line.kind)}: ${esc(line.message || '')}${esc(details)}</span></div>`;
}).join('') : `<div class="debug-empty">${esc(tr(app.debugFilter === 'gree' ? 'debug.emptyGree' : app.debugFilter === 'requests' ? 'debug.emptyRequests' : 'debug.empty'))}</div>`;
}).join('') : `<div class="debug-empty">${esc(tr(app.debugFilter === 'gree' ? 'debug.emptyGree' : app.debugFilter === 'requests' ? 'debug.emptyRequests' : app.debugFilter === 'cloud' ? 'debug.emptyCloud' : app.debugFilter === 'mqtt' ? 'debug.emptyMqtt' : 'debug.empty'))}</div>`;
host.scrollTop = host.scrollHeight;
}
@@ -370,3 +393,30 @@ function formatDuration(seconds) {
setInterval(() => { if (app.currentView === 'settings') renderSystemInfo(); }, 30000);
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; };
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));
} catch (_) {
account.textContent = 'unknown';
mqtt.textContent = 'disconnected';
['#greeCloudDevicesOnline', '#greeCloudRestResponseTime', '#greeCloudResponseTime', '#greeCloudLastDeviceResponse', '#greeCloudLastMqttMessage', '#greeCloudConnectedSince', '#greeCloudBroker', '#greeCloudTraffic'].forEach(selector => setText(selector, tr('common.unavailable')));
}
}
+89 -2
View File
@@ -1,6 +1,7 @@
const SETTINGS_ENDPOINTS = {
application: '/api/settings/application',
gree: '/api/settings/gree',
greeCloud: '/api/settings/gree-cloud',
history: '/api/settings/history',
influxdb: '/api/settings/influxdb',
notifications: '/api/settings/notifications',
@@ -13,6 +14,7 @@ function applySettingsSection(section, data) {
app.settings = app.settings || {};
if (section === 'application') app.settings.simulator_enabled = !!data.simulator_enabled;
else if (section === 'gree') Object.assign(app.settings, data);
else if (section === 'greeCloud') app.settings.gree_cloud = data;
else if (section === 'history') {
app.settings.history_retention_days = Number(data.retention_days);
app.settings.history_compaction_enabled = data.compaction_enabled !== false;
@@ -44,6 +46,18 @@ function greeSettingsBodyFromForm(form) {
};
}
function greeCloudSettingsBodyFromForm(form) {
const raw = Object.fromEntries(new FormData(form));
const body = {
enabled: form.gree_cloud_enabled.checked,
region: raw.gree_cloud_region || 'Europe',
username: raw.gree_cloud_username || '',
polling_interval_seconds: Number(raw.gree_cloud_polling_interval_seconds || 60),
};
if (raw.gree_cloud_password) body.password = raw.gree_cloud_password;
return body;
}
function historySettingsBodyFromForm(form, eventRetentionDays = null) {
const raw = Object.fromEntries(new FormData(form));
return {
@@ -99,7 +113,12 @@ function notificationSettingsBodyFromForm(form) {
}
function debugSettingsBodyFromForm(form) {
return { overlay_enabled: form.debug_overlay_enabled.checked, gree_frames: form.debug_gree_frames.checked };
return {
overlay_enabled: form.debug_overlay_enabled.checked,
gree_frames: form.debug_gree_frames.checked,
cloud_requests: form.debug_cloud_requests.checked,
cloud_mqtt: form.debug_cloud_mqtt.checked,
};
}
function nightSettingsBodyFromForm(form) {
@@ -144,9 +163,10 @@ function refreshSettingsUi() {
}
async function saveMainSettings(form, notify = true) {
const [application, gree, history, influxdb, notifications, debug] = await Promise.all([
const [application, gree, greeCloud, history, influxdb, notifications, debug] = await Promise.all([
api(SETTINGS_ENDPOINTS.application, { method: 'PUT', body: applicationSettingsBodyFromForm(form) }),
api(SETTINGS_ENDPOINTS.gree, { method: 'PUT', body: greeSettingsBodyFromForm(form) }),
api(SETTINGS_ENDPOINTS.greeCloud, { method: 'PUT', body: greeCloudSettingsBodyFromForm(form) }),
api(SETTINGS_ENDPOINTS.history, { method: 'PUT', body: historySettingsBodyFromForm(form) }),
api(SETTINGS_ENDPOINTS.influxdb, { method: 'PUT', body: influxDbSettingsBodyFromForm(form) }),
api(SETTINGS_ENDPOINTS.notifications, { method: 'PUT', body: notificationSettingsBodyFromForm(form) }),
@@ -154,6 +174,7 @@ async function saveMainSettings(form, notify = true) {
]);
applySettingsSection('application', application);
applySettingsSection('gree', gree);
applySettingsSection('greeCloud', greeCloud);
applySettingsSection('history', history);
applySettingsSection('influxdb', influxdb);
applySettingsSection('notifications', notifications);
@@ -300,6 +321,8 @@ 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 === 'historyEnergyInterval') { app.historyEnergyInterval = target.value; updateBrowserUrl(currentHistoryPath()); loadHistory(); }
else if (target.id === 'historySensorSelect') { app.historySensor = target.value; updateBrowserUrl(currentHistoryPath()); renderHistoryPage(); }
else if (target.name === 'influx_version') updateInfluxFields();
});
@@ -385,3 +408,67 @@ document.addEventListener('click', event => {
renderFlowSharedInputs();
updateDirtyIndicator($('#homeAssistantForm'));
});
async function saveGreeCloudSettings(form = $('#settingsForm')) {
const data = await api(SETTINGS_ENDPOINTS.greeCloud, { method: 'PUT', body: greeCloudSettingsBodyFromForm(form) });
applySettingsSection('greeCloud', data);
return data;
}
function renderCloudDiscoveryDevices(devices) {
const list = $('#cloudDiscoveryList');
if (!list) return;
const items = Array.isArray(devices) ? devices : [];
list.innerHTML = items.length ? items.map(device => `
<div class="discovery-name-row cloud-discovery-row">
<span><strong>${esc(device.name || 'GREE')}</strong><small>${esc(device.model || 'GREE')} · ${esc(device.mac || device.id)} · ${esc(tr(device.online ? 'status.online' : 'status.offline'))}</small></span>
<button type="button" class="${device.already_added ? 'secondary' : 'primary'}" data-action="add-cloud-device" data-cloud-id="${esc(device.id)}" ${device.already_added ? 'disabled' : ''}>${esc(device.already_added ? tr('devices.cloudAlreadyAdded') : tr('actions.add'))}</button>
</div>`).join('') : `<div class="empty"><strong>${esc(tr('devices.cloudDiscoveryEmpty'))}</strong>${esc(tr('devices.cloudDiscoveryEmptyHint'))}</div>`;
}
async function loadCloudDiscovery({ open = true } = {}) {
const result = await api('/api/integrations/gree-cloud/devices');
renderCloudDiscoveryDevices(result.devices || []);
if (open) openDialog('cloudDiscoveryDialog');
return result;
}
$('#greeCloudTestButton')?.addEventListener('click', async event => {
const button = event.currentTarget;
const form = $('#settingsForm');
const resultBox = $('#greeCloudTestResult');
button.disabled = true;
if (resultBox) { resultBox.hidden = true; resultBox.classList.remove('success', 'error'); }
try {
await saveGreeCloudSettings(form);
const result = await api('/api/integrations/gree-cloud/test', { method: 'POST' });
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');
}
if (result.ok) {
const refreshed = await api(SETTINGS_ENDPOINTS.greeCloud);
applySettingsSection('greeCloud', refreshed);
renderSettings();
}
} catch (error) {
if (resultBox) { resultBox.hidden = false; resultBox.classList.add('error'); resultBox.textContent = error.message; }
} finally { button.disabled = false; }
});
$('#greeCloudRefreshButton')?.addEventListener('click', async event => {
const button = event.currentTarget;
button.disabled = true;
try { await saveGreeCloudSettings($('#settingsForm')); await loadCloudDiscovery(); }
catch (error) { toast(error.message, true); }
finally { button.disabled = false; }
});
$('#cloudDiscoveryRefresh')?.addEventListener('click', async event => {
const button = event.currentTarget;
button.disabled = true;
try { await loadCloudDiscovery({ open: false }); }
catch (error) { toast(error.message, true); }
finally { button.disabled = false; }
});
+142 -1
View File
@@ -2805,7 +2805,7 @@ legend {
.settings-mode-tabs {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 7px;
margin: 0 0 14px;
padding: 6px;
@@ -8906,6 +8906,78 @@ body.flow-editor-open {
overflow-wrap: anywhere;
}
.manual-device-title-row {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.manual-device-title-row h3 {
min-width: 0;
margin-bottom: 0;
}
.manual-device-status {
display: flex;
align-items: center;
gap: 7px;
min-height: 18px;
margin-top: 5px !important;
max-width: 100%;
}
.manual-device-status .status {
flex: 0 0 auto;
}
.manual-device-status-error {
min-width: 0;
overflow: hidden;
color: var(--muted);
text-overflow: ellipsis;
white-space: nowrap;
}
.manual-device-status-error::before {
content: "·";
margin-right: 7px;
}
.manual-device-status-pending {
flex: 0 0 auto;
color: var(--warning);
font-weight: 650;
}
.cloud-command-pending .power-button,
.cloud-command-pending .quick-control-row button.active,
.cloud-command-pending .target-temp {
animation: cloud-command-pulse .85s ease-in-out infinite alternate;
}
.manual-device-status-pending::before {
content: "";
display: inline-block;
width: 7px;
height: 7px;
margin-right: 6px;
border-radius: 50%;
background: currentColor;
animation: cloud-command-dot .7s ease-in-out infinite alternate;
}
@keyframes cloud-command-pulse {
from { opacity: .72; }
to { opacity: 1; }
}
@keyframes cloud-command-dot {
from { transform: scale(.65); opacity: .45; }
to { transform: scale(1); opacity: 1; }
}
.config-check-result {
padding: 10px 12px;
border: 1px solid var(--border);
@@ -9353,3 +9425,72 @@ body.flow-editor-open {
white-space: normal;
}
}
/* GREE Cloud runtime summary */
.cloud-runtime-summary {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.cloud-runtime-summary > div {
display: grid;
gap: 4px;
min-width: 0;
padding: 10px 11px;
border: 1px solid var(--line);
border-radius: 11px;
background: var(--surface-muted);
}
.cloud-runtime-summary span {
color: var(--muted);
font-size: 9px;
font-weight: 750;
text-transform: uppercase;
letter-spacing: .05em;
}
.cloud-runtime-summary strong {
overflow: hidden;
color: var(--text-soft);
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.cloud-account-meta {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 7px 14px;
color: var(--muted);
}
.cloud-account-meta code,
.cloud-account-meta strong {
color: var(--text-soft);
}
.debug-line.source-cloud {
border-color: color-mix(in srgb, var(--warning) 30%, var(--line));
}
.debug-line.source-cloud b {
color: var(--warning);
}
.debug-line.source-mqtt {
border-color: color-mix(in srgb, var(--purple) 30%, var(--line));
}
.debug-line.source-mqtt b {
color: var(--purple);
}
@media (max-width: 760px) {
.cloud-runtime-summary {
grid-template-columns: 1fr;
}
}