From 021cbddba5ddabcb8778579a1f3edf7186b36613 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Mon, 14 Sep 2026 16:32:28 +0200 Subject: [PATCH] v0.14.0 --- Cargo.lock | 5 +- Cargo.toml | 4 +- FILE_MANIFEST.sha256 | 131 +- README.md | 48 +- SOURCE_MANIFEST.sha256 | 225 ++ docs/API.md | 28 +- docs/openapi.json | 1102 +++++++++- .../gree_controller/manifest.json | 2 +- .../repository/gree-controller/CHANGELOG.md | 8 + .../repository/gree-controller/config.yaml | 2 +- lang/en.json | 83 +- lang/pl.json | 83 +- scripts/api_dev_test.py | 30 +- src/api.rs | 39 +- src/api/configuration.rs | 107 +- src/api/devices.rs | 139 +- src/api/gree_cloud.rs | 418 ++++ src/api/history.rs | 211 ++ src/api/integrations.rs | 33 + src/api/settings.rs | 99 + src/api/system.rs | 2 +- src/config.rs | 11 +- src/db.rs | 5 +- src/db/configuration.rs | 8 +- src/db/core_devices.rs | 21 +- src/db/energy_history.rs | 95 + src/db/tests.rs | 26 +- src/engine.rs | 5 +- src/engine/commands.rs | 216 +- src/engine/energy.rs | 147 ++ src/engine/ownership.rs | 3 +- src/engine/polling.rs | 219 +- src/engine/runtime.rs | 40 + src/engine/zone_control.rs | 23 +- src/error.rs | 5 + src/home_assistant.rs | 37 + src/influxdb.rs | 3 +- src/influxdb/query.rs | 84 + src/influxdb/write.rs | 46 + src/main.rs | 89 +- src/models/defaults.rs | 6 + src/models/device.rs | 187 +- src/models/history.rs | 21 + src/models/integrations.rs | 4 + src/models/runtime.rs | 43 + src/models/settings_api.rs | 24 + src/protocol/gree.rs | 2 +- src/protocol/gree/discovery.rs | 17 + src/protocol/gree_cloud.rs | 476 +++++ src/protocol/gree_cloud_mqtt.rs | 525 +++++ src/protocol/mod.rs | 2 + src/provider.rs | 1875 +++++++++++++++++ src/queries/entities.rs | 2 + src/queries/schema.rs | 20 + src/state.rs | 40 +- web/index.html | 92 +- web/js/bootstrap.js | 2 + web/js/charts.js | 71 +- web/js/core.js | 2 +- web/js/entities.js | 121 +- web/js/events.js | 75 +- web/js/history.js | 41 + web/js/navigation.js | 149 +- web/js/realtime.js | 3 + web/js/router.js | 6 +- web/js/settings-ui.js | 60 +- web/js/settings.js | 91 +- web/styles.css | 143 +- 68 files changed, 7780 insertions(+), 202 deletions(-) create mode 100644 SOURCE_MANIFEST.sha256 create mode 100644 src/api/gree_cloud.rs create mode 100644 src/db/energy_history.rs create mode 100644 src/engine/energy.rs create mode 100644 src/protocol/gree_cloud.rs create mode 100644 src/protocol/gree_cloud_mqtt.rs create mode 100644 src/provider.rs diff --git a/Cargo.lock b/Cargo.lock index 7df0308..4c74808 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/Cargo.toml b/Cargo.toml index 65162c8..a8bd343 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/FILE_MANIFEST.sha256 b/FILE_MANIFEST.sha256 index 3152ffe..d14552d 100644 --- a/FILE_MANIFEST.sha256 +++ b/FILE_MANIFEST.sha256 @@ -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 diff --git a/README.md b/README.md index f587c69..bf173f6 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/SOURCE_MANIFEST.sha256 b/SOURCE_MANIFEST.sha256 new file mode 100644 index 0000000..02bbcc4 --- /dev/null +++ b/SOURCE_MANIFEST.sha256 @@ -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 diff --git a/docs/API.md b/docs/API.md index 5bc8bc6..ce6cd47 100644 --- a/docs/API.md +++ b/docs/API.md @@ -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. diff --git a/docs/openapi.json b/docs/openapi.json index 8a5ab3b..e30076a 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -2,9 +2,9 @@ "openapi": "3.1.0", "info": { "title": "GREE Controller API", - "version": "0.13.10", + "version": "0.14.0", "summary": "Local HTTP/WebSocket API for GREE HVAC control", - "description": "Self-hosted API used by the GREE Controller Web UI and Home Assistant integration.\n\n### Authentication\nNormal administrator endpoints accept `Authorization: Bearer ` or `x-api-token: ` when `GREE_CONTROLLER_APP_TOKEN` is configured. If the app token is empty, those endpoints intentionally run in trusted-LAN mode without authentication.\n\nThe restricted `/api/integrations/home-assistant/*` control surface always requires either a generated access token or the administrator app token. Generated tokens do **not** grant normal administrator API access.\n\nIn Swagger UI use **Authorize** and enter the token in either the bearer field or `x-api-token` field.\n\n### Control ownership\nThermostat zones, groups, house rules, direct device control, schedules and visual Flows share an ownership/arbitration model. Prefer zone/group/house endpoints for thermostat intent; direct device commands are technical/manual control and can deliberately take ownership.\n\n### 0.12.0 breaking API\nRuntime settings are split into functional `/api/settings/*` resources. The former `/api/settings`, `/api/debug`, `/api/events/retention`, `/api/settings/export` and `/api/settings/import` endpoints were removed without compatibility aliases.\n\n### 0.13.0\n`control-plan` keeps its existing HTTP response shape but is materialized in memory and pushed to WebSocket clients as `control_plan.updated`; HTTP remains the fallback/resync path. A restricted Home Assistant snapshot endpoint combines devices, groups and the control plan.\n\n### 0.13.1\nRuntime WebSocket churn is reduced without changing contracts: no-op zone regulator heartbeats are deduplicated and device heartbeat polls only invalidate the materialized control plan when plan-relevant device state changes.\n\n### 0.13.2\nCompletes live WebSocket synchronization for entity creation, schedules, automations, configuration imports and runtime device/automation state while preserving the existing HTTP API contracts and control-plan fallback behavior.\n\n### 0.13.3\nLogical thermostat targets are normalized to 0.1 C consistently, including Temporary Quick Thermostat and thermostat-managed automation/Flow actions. The bundled Web UI accepts comma or dot decimal input for the temporary thermostat target; physical GREE setpoint rounding is unchanged.\n\n### 0.13.4\nDisabled-zone thermostat controls remain visually active in the bundled Web UI because explicit temporary/manual thermostat and direct manual control remain available while zone automation is disabled. HTTP and WebSocket contracts are unchanged.\n\n### 0.13.5\nThe zone settings dialog now explains directly beside the enabled/disabled option that disabling a zone stops automation but does not block manual thermostat, temporary thermostat or direct manual control. HTTP and WebSocket contracts remain unchanged.\n\n### 0.13.6\nNotification settings add a dedicated sensor_discrepancy alert type for GREE vs Home Assistant room-temperature divergence. Disabling it suppresses only that notification; the GREE safety fallback remains active.\n\n### 0.13.7\nThe bootstrap snapshot now embeds all eight redacted runtime settings views. The bundled Web UI consumes those settings directly for HTTP startup and WebSocket resynchronization, removing the previous settings-request fan-out while preserving the split settings endpoints for direct reads and writes.\n\n### 0.13.8\nThe bundled Web UI suppresses the initial English-to-selected-language flash with a dedicated pre-paint language initializer and reveals the application only after localization is initialized. Language-pack URLs are content-versioned so immutable browser caching stays safe across updates.\n\n### 0.13.9\nThe bundled Web UI keeps dashboard thermostat cards at their intrinsic height when a local thermostat or manual device-control takeover panel is shown, preventing unrelated thermostat cards in the same grid row from stretching. HTTP and WebSocket contracts are unchanged.\n\n### 0.13.10\nGREE status parsing now tolerates multi-state non-zero encodings for optional feature flags. `Quiet` preserves active wire values 1/2/3 reported by the unit and reuses the observed encoding when enabled, while legacy behavior still defaults to `Quiet=1`. Existing API contracts are unchanged.", + "description": "Self-hosted API used by the GREE Controller Web UI and Home Assistant integration.\n\n### Authentication\nAdministrator endpoints accept `Authorization: Bearer ` or `x-api-token: ` when configured. Restricted Home Assistant control endpoints also accept generated integration tokens.\n\n### Local and GREE Cloud\nDevices use one API model and one command surface. `connection_type=local` is dispatched only to the existing LAN/UDP provider; `connection_type=gree_cloud` is dispatched only to the GREE Cloud provider. No automatic transport fallback is performed.\n\n### GREE Cloud security\nCloud passwords, REST/MQTT tokens, Authorization values and device cipher keys are never returned in normal API responses or Cloud diagnostics. MQTT uses TLS with certificate and hostname validation.\n\n### Energy\nGREE Cloud `ElcAll` and selected Home Assistant cumulative energy sensors are normalized to kWh and converted to consumption deltas before aggregation. Raw cumulative values are retained only as meter samples/baselines.\n\n### 0.14.0\nAdds first-class GREE Cloud MQTT status/control, reconnect/resubscribe lifecycle, Cloud diagnostics, per-device capabilities, Cloud/HA energy sources and energy-history aggregation while preserving the LAN transport contract.", "license": { "name": "MIT" } @@ -87,6 +87,14 @@ { "name": "Catalogs", "description": "Public embedded language and Flow-preset catalogs." + }, + { + "name": "GREE Cloud", + "description": "GREE Cloud account, discovery, MQTT state and diagnostics." + }, + { + "name": "Energy", + "description": "Energy-source discovery and consumption history." } ], "paths": { @@ -4836,6 +4844,498 @@ } } } + }, + "/api/settings/gree-cloud": { + "get": { + "tags": [ + "Settings", + "GREE Cloud" + ], + "summary": "Get GREE Cloud settings", + "description": "Returns redacted account/provider settings. The saved password is never returned.", + "operationId": "getGreeCloudSettings", + "security": [ + { + "BearerToken": [] + }, + { + "ApiTokenHeader": [] + } + ], + "responses": { + "200": { + "description": "GREE Cloud settings", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GreeCloudSettingsView" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "put": { + "tags": [ + "Settings", + "GREE Cloud" + ], + "summary": "Update GREE Cloud settings", + "description": "Updates central GREE Cloud account/provider settings. Polling is clamped to 30–3600 seconds; changing account settings drops the existing MQTT session.", + "operationId": "updateGreeCloudSettings", + "security": [ + { + "BearerToken": [] + }, + { + "ApiTokenHeader": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GreeCloudSettingsUpdate" + } + } + } + }, + "responses": { + "200": { + "description": "Updated GREE Cloud settings", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GreeCloudSettingsView" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "400": { + "$ref": "#/components/responses/BadRequest" + } + } + } + }, + "/api/integrations/gree-cloud/test": { + "post": { + "tags": [ + "GREE Cloud", + "Diagnostics" + ], + "summary": "Test GREE Cloud account", + "description": "Performs encrypted REST login and account discovery without returning credentials or tokens.", + "operationId": "testGreeCloud", + "security": [ + { + "BearerToken": [] + }, + { + "ApiTokenHeader": [] + } + ], + "responses": { + "200": { + "description": "Connection test result", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/integrations/gree-cloud/devices": { + "get": { + "tags": [ + "GREE Cloud" + ], + "summary": "Discover GREE Cloud devices", + "description": "Logs in server-side and lists account devices without exposing device cipher keys.", + "operationId": "discoverGreeCloudDevices", + "security": [ + { + "BearerToken": [] + }, + { + "ApiTokenHeader": [] + } + ], + "responses": { + "200": { + "description": "Discovered devices", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/integrations/gree-cloud/devices/{cloud_id}/add": { + "post": { + "tags": [ + "GREE Cloud", + "Devices" + ], + "summary": "Add GREE Cloud device", + "description": "Re-discovers the selected Cloud device server-side and stores it as an independent gree_cloud device.", + "operationId": "addGreeCloudDevice", + "parameters": [ + { + "name": "cloud_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Stable Cloud device identifier/MAC." + } + ], + "security": [ + { + "BearerToken": [] + }, + { + "ApiTokenHeader": [] + } + ], + "responses": { + "200": { + "description": "Created Cloud device", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Device" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + } + } + } + }, + "/api/integrations/gree-cloud/status": { + "get": { + "tags": [ + "GREE Cloud" + ], + "summary": "Get GREE Cloud provider status", + "description": "Returns redacted account/MQTT state, Cloud-device online counts, REST/device response timing, broker activity and sanitized traffic counters.", + "operationId": "greeCloudStatus", + "security": [ + { + "BearerToken": [] + }, + { + "ApiTokenHeader": [] + } + ], + "responses": { + "200": { + "description": "GREE Cloud status", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GreeCloudStatus" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/integrations/gree-cloud/reconnect": { + "post": { + "tags": [ + "GREE Cloud" + ], + "summary": "Reconnect GREE Cloud MQTT", + "description": "Closes the current broker session and performs a fresh authenticated MQTT connection and subscriptions.", + "operationId": "reconnectGreeCloud", + "security": [ + { + "BearerToken": [] + }, + { + "ApiTokenHeader": [] + } + ], + "responses": { + "200": { + "description": "Reconnect result", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "502": { + "$ref": "#/components/responses/BadGateway" + } + } + } + }, + "/api/devices/{id}/cloud-diagnostics": { + "get": { + "tags": [ + "GREE Cloud", + "Diagnostics" + ], + "summary": "Get Cloud device diagnostics", + "description": "Returns sanitized MQTT/cipher/property diagnostics only for gree_cloud devices.", + "operationId": "cloudDeviceDiagnostics", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Controller device ID." + } + ], + "security": [ + { + "BearerToken": [] + }, + { + "ApiTokenHeader": [] + } + ], + "responses": { + "200": { + "description": "Cloud diagnostics", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CloudDiagnostics" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + } + }, + "/api/integrations/home-assistant/energy-sensors": { + "get": { + "tags": [ + "Home Assistant", + "Energy" + ], + "summary": "List compatible Home Assistant energy sensors", + "description": "Lists entities compatible with cumulative energy ingestion: device_class=energy, state_class total/total_increasing and Wh or kWh.", + "operationId": "listHomeAssistantEnergySensors", + "security": [ + { + "BearerToken": [] + }, + { + "ApiTokenHeader": [] + } + ], + "responses": { + "200": { + "description": "Compatible energy sensors", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/history/energy": { + "get": { + "tags": [ + "History", + "Energy" + ], + "summary": "Get energy consumption history", + "description": "Returns consumption_kwh buckets and Today/Yesterday/current/previous month/period totals. Raw cumulative meter values are not summed as consumption.", + "operationId": "energyHistory", + "parameters": [ + { + "name": "device_id", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "interval", + "in": "query", + "required": false, + "schema": { + "type": "string", + "enum": [ + "hourly", + "daily", + "weekly", + "monthly" + ], + "default": "daily" + } + }, + { + "name": "source", + "in": "query", + "required": false, + "schema": { + "type": "string", + "enum": [ + "auto", + "gree_cloud", + "home_assistant" + ], + "default": "auto" + } + }, + { + "name": "days", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 3650, + "default": 31 + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 200000 + } + } + ], + "security": [ + { + "BearerToken": [] + }, + { + "ApiTokenHeader": [] + } + ], + "responses": { + "200": { + "description": "Energy history payload", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + } } }, "components": { @@ -4891,7 +5391,7 @@ }, "version": { "type": "string", - "example": "0.13.10" + "example": "0.14.0" }, "uptime_seconds": { "type": "integer", @@ -5222,7 +5722,7 @@ }, "Device": { "type": "object", - "description": "Physical or simulated GREE unit plus latest communication/state snapshot.", + "description": "Physical, simulated or GREE Cloud unit plus latest state. Secret binding/cipher keys are never serialized.", "properties": { "id": { "type": "string", @@ -5258,13 +5758,6 @@ "firmware": { "type": "string" }, - "key": { - "type": [ - "string", - "null" - ], - "description": "GREE binding key; treat as secret" - }, "cid": { "type": [ "string", @@ -5372,6 +5865,162 @@ "updated_at": { "type": "string", "format": "date-time" + }, + "connection_type": { + "type": "string", + "enum": [ + "local", + "gree_cloud" + ], + "description": "Explicit transport/provider." + }, + "connection_status": { + "type": "string", + "enum": [ + "online", + "offline", + "cloud_disconnected", + "authentication_error", + "unknown" + ] + }, + "cloud_device_id": { + "type": [ + "string", + "null" + ] + }, + "cloud_parent_mac": { + "type": [ + "string", + "null" + ] + }, + "cloud_account_id": { + "type": [ + "string", + "null" + ] + }, + "quiet_wire_value": { + "type": [ + "integer", + "null" + ], + "minimum": 1 + }, + "supports_light": { + "type": [ + "boolean", + "null" + ] + }, + "supports_quiet": { + "type": [ + "boolean", + "null" + ] + }, + "supports_turbo": { + "type": [ + "boolean", + "null" + ] + }, + "supports_air": { + "type": [ + "boolean", + "null" + ] + }, + "supports_xfan": { + "type": [ + "boolean", + "null" + ] + }, + "supports_health": { + "type": [ + "boolean", + "null" + ] + }, + "supports_sleep": { + "type": [ + "boolean", + "null" + ] + }, + "supports_buzzer_control": { + "type": [ + "boolean", + "null" + ] + }, + "supports_energy_meter": { + "type": [ + "boolean", + "null" + ] + }, + "total_energy_kwh": { + "type": [ + "number", + "null" + ], + "minimum": 0 + }, + "compressor_frequency_hz": { + "type": [ + "number", + "null" + ], + "minimum": 0 + }, + "last_cloud_sync": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "pending_command": { + "type": "boolean" + }, + "capabilities": { + "$ref": "#/components/schemas/DeviceCapabilities" + }, + "energy_source": { + "type": "string", + "enum": [ + "auto", + "gree_cloud", + "home_assistant" + ] + }, + "ha_energy_entity_id": { + "type": [ + "string", + "null" + ] + }, + "ha_energy_unit": { + "type": [ + "string", + "null" + ] + }, + "ha_energy_device_class": { + "type": [ + "string", + "null" + ] + }, + "ha_energy_state_class": { + "type": [ + "string", + "null" + ] } }, "required": [ @@ -5386,7 +6035,11 @@ "target_temperature", "online", "created_at", - "updated_at" + "updated_at", + "connection_type", + "connection_status", + "capabilities", + "energy_source" ] }, "DiscoveryRequest": { @@ -6974,6 +7627,16 @@ "gree_frames": { "type": "boolean", "example": false + }, + "cloud_requests": { + "type": "boolean", + "description": "Emit sanitized GREE Cloud REST/provider request events to Live Debug.", + "example": false + }, + "cloud_mqtt": { + "type": "boolean", + "description": "Emit sanitized MQTT connection/topic/envelope events to Live Debug. Encrypted pack/token/key/password are never exposed.", + "example": false } }, "required": [ @@ -7797,6 +8460,421 @@ "outdoor_assist_enabled" ], "additionalProperties": false + }, + "DeviceCapabilities": { + "type": "object", + "description": "Per-device controls and metric capabilities learned from transport/model/status data.", + "properties": { + "power": { + "type": "boolean" + }, + "mode": { + "type": "boolean" + }, + "fan_modes": { + "type": "array", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 5 + } + }, + "min_temperature": { + "type": "number" + }, + "max_temperature": { + "type": "number" + }, + "temperature_step": { + "type": "number", + "enum": [ + 0.5, + 1.0 + ] + }, + "vertical_swing": { + "type": "boolean" + }, + "horizontal_swing": { + "type": "boolean" + }, + "turbo": { + "type": "boolean" + }, + "quiet": { + "type": "boolean" + }, + "sleep": { + "type": "boolean" + }, + "light": { + "type": "boolean" + }, + "health": { + "type": "boolean" + }, + "buzzer_control": { + "type": "boolean" + }, + "energy_meter": { + "type": "boolean" + }, + "compressor_frequency": { + "type": "boolean" + } + }, + "additionalProperties": false + }, + "GreeCloudSettingsView": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "region": { + "type": "string" + }, + "username": { + "type": "string" + }, + "password_configured": { + "type": "boolean" + }, + "polling_interval_seconds": { + "type": "integer", + "minimum": 30, + "maximum": 3600 + }, + "installation_id": { + "type": "string" + }, + "account_id": { + "type": "string" + }, + "last_successful_contact": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "last_rest_response_time_ms": { + "type": [ + "integer", + "null" + ], + "minimum": 0, + "description": "Duration in milliseconds of the most recent successful GREE Cloud REST operation." + } + }, + "required": [ + "enabled", + "region", + "username", + "password_configured", + "polling_interval_seconds", + "installation_id", + "account_id" + ], + "additionalProperties": false + }, + "GreeCloudSettingsUpdate": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "region": { + "type": "string" + }, + "username": { + "type": "string" + }, + "password": { + "type": [ + "string", + "null" + ], + "writeOnly": true, + "description": "Omit/null to keep the currently stored password." + }, + "polling_interval_seconds": { + "type": "integer", + "minimum": 30, + "maximum": 3600 + } + }, + "required": [ + "enabled", + "region", + "username", + "polling_interval_seconds" + ], + "additionalProperties": false + }, + "CloudDiagnostics": { + "type": "object", + "description": "Sanitized Cloud transport diagnostics. Authentication tokens and cipher keys are excluded.", + "properties": { + "device_id": { + "type": "string" + }, + "cloud_device_id": { + "type": [ + "string", + "null" + ] + }, + "connection_status": { + "type": "string" + }, + "capabilities": { + "$ref": "#/components/schemas/DeviceCapabilities" + }, + "energy_source": { + "type": "string" + }, + "connection_state": { + "type": "string" + }, + "mqtt_state": { + "type": "string" + }, + "last_connect": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "last_disconnect": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "reconnect_count": { + "type": "integer", + "minimum": 0 + }, + "last_message_timestamp": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "last_status_timestamp": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "selected_cipher_version": { + "type": [ + "integer", + "null" + ], + "enum": [ + null, + 1, + 2 + ] + }, + "status_profile": { + "type": [ + "string", + "null" + ], + "description": "Cloud status request profile selected at runtime (full, legacy, or a temporary compatibility probe)." + }, + "wire_device_id": { + "type": [ + "string", + "null" + ], + "description": "Exact device MAC spelling from the current GREE Cloud REST discovery used on the MQTT wire." + }, + "rest_device_online": { + "type": [ + "boolean", + "null" + ], + "description": "Online flag reported by the latest GREE Cloud REST discovery." + }, + "fresh_discovery_key_used": { + "type": [ + "boolean", + "null" + ], + "description": "Whether MQTT crypto is using the current server-side discovery key. The key itself is never exposed." + }, + "saved_key_changed": { + "type": [ + "boolean", + "null" + ], + "description": "Whether the fresh discovery key differs from the previously persisted key. No key material is exposed." + }, + "broker_host": { + "type": [ + "string", + "null" + ] + }, + "subscribed_topics": { + "type": "array", + "items": { + "type": "string" + } + }, + "raw_device_properties": { + "type": "object", + "additionalProperties": true + }, + "parsed_device_properties": { + "type": "object", + "additionalProperties": true + }, + "unknown_properties": { + "type": "object", + "additionalProperties": true + }, + "energy_related_properties": { + "type": "object", + "additionalProperties": true + }, + "last_error": { + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": true + }, + "GreeCloudRuntimeStatus": { + "type": "object", + "properties": { + "broker_host": { + "type": [ + "string", + "null" + ] + }, + "mqtt_connected_since": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "last_mqtt_message": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "last_device_response": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "last_response_time_ms": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "requests_sent": { + "type": "integer", + "minimum": 0 + }, + "responses_received": { + "type": "integer", + "minimum": 0 + }, + "request_timeouts": { + "type": "integer", + "minimum": 0 + }, + "reconnect_count": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "requests_sent", + "responses_received", + "request_timeouts", + "reconnect_count" + ], + "additionalProperties": false + }, + "GreeCloudStatus": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "account_status": { + "type": "string", + "enum": [ + "disabled", + "not_configured", + "connected", + "cloud_disconnected", + "authentication_error" + ] + }, + "mqtt_status": { + "type": "string", + "enum": [ + "connected", + "disconnected" + ] + }, + "last_successful_contact": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "last_rest_response_time_ms": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "device_count": { + "type": "integer", + "minimum": 0 + }, + "online_device_count": { + "type": "integer", + "minimum": 0 + }, + "runtime": { + "$ref": "#/components/schemas/GreeCloudRuntimeStatus" + } + }, + "required": [ + "enabled", + "account_status", + "mqtt_status", + "device_count", + "online_device_count", + "runtime" + ], + "additionalProperties": false } }, "responses": { diff --git a/ha-addon/home-assistant/custom_components/gree_controller/manifest.json b/ha-addon/home-assistant/custom_components/gree_controller/manifest.json index 9144f35..074fd9d 100644 --- a/ha-addon/home-assistant/custom_components/gree_controller/manifest.json +++ b/ha-addon/home-assistant/custom_components/gree_controller/manifest.json @@ -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", diff --git a/ha-addon/repository/gree-controller/CHANGELOG.md b/ha-addon/repository/gree-controller/CHANGELOG.md index 055e4d3..2941650 100644 --- a/ha-addon/repository/gree-controller/CHANGELOG.md +++ b/ha-addon/repository/gree-controller/CHANGELOG.md @@ -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. diff --git a/ha-addon/repository/gree-controller/config.yaml b/ha-addon/repository/gree-controller/config.yaml index fccacbe..64f8c0d 100644 --- a/ha-addon/repository/gree-controller/config.yaml +++ b/ha-addon/repository/gree-controller/config.yaml @@ -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/" diff --git a/lang/en.json b/lang/en.json index 384a30d..83efc02 100644 --- a/lang/en.json +++ b/lang/en.json @@ -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" } } diff --git a/lang/pl.json b/lang/pl.json index fc49773..a1347c3 100644 --- a/lang/pl.json +++ b/lang/pl.json @@ -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" } } diff --git a/scripts/api_dev_test.py b/scripts/api_dev_test.py index 2262fd9..6234779 100644 --- a/scripts/api_dev_test.py +++ b/scripts/api_dev_test.py @@ -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 diff --git a/src/api.rs b/src/api.rs index 47f308c..f6573d0 100644 --- a/src/api.rs +++ b/src/api.rs @@ -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"); diff --git a/src/api/configuration.rs b/src/api/configuration.rs index 7bfd1c1..820e89b 100644 --- a/src/api/configuration.rs +++ b/src/api/configuration.rs @@ -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, 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 diff --git a/src/api/devices.rs b/src/api/devices.rs index fb8e0c6..7fae407 100644 --- a/src/api/devices.rs +++ b/src/api/devices.rs @@ -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| { - item.trigger_device_id.as_deref() == Some(id.as_str()) - || (item.action_group_id.is_none() && item.action_device_id == id) - }) { + 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); } - ensure_zone_removal_safe(&state, &removed_zone_ids)?; - ensure_device_stopped_for_detach(&state, &id, "device.deleted").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 = 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()))?; diff --git a/src/api/gree_cloud.rs b/src/api/gree_cloud.rs new file mode 100644 index 0000000..a20dfa9 --- /dev/null +++ b/src/api/gree_cloud.rs @@ -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 { + 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) -> Result, 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::(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, +) -> Result, 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::>(); + 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, + Path(cloud_id): Path, +) -> Result<(StatusCode, Json), 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) -> Result, 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::>(); + 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) -> Result, 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, + Path(id): Path, +) -> Result, 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() + } +} diff --git a/src/api/history.rs b/src/api/history.rs index f0922b1..6be48d4 100644 --- a/src/api/history.rs +++ b/src/api/history.rs @@ -488,3 +488,214 @@ async fn control_plan(State(state): State) -> Result, 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, + source: Option, + days: Option, + limit: Option, +} + +async fn energy_history( + State(state): State, + Query(query): Query, +) -> Result, 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::::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::::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 = 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 { + chrono::DateTime::::from_naive_utc_and_offset( + date.and_hms_opt(0, 0, 0).expect("valid midnight"), + Utc, + ) + } + fn bucket_start( + timestamp: chrono::DateTime, + interval_name: &str, + ) -> chrono::DateTime { + let date = timestamp.date_naive(); + match interval_name { + "hourly" => chrono::DateTime::::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::>(); + let mut buckets: BTreeMap, 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::>(); + + 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, stop: chrono::DateTime| -> 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, + }))) +} diff --git a/src/api/integrations.rs b/src/api/integrations.rs index 2d26d4b..dd93fc6 100644 --- a/src/api/integrations.rs +++ b/src/api/integrations.rs @@ -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, +) -> Result, 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::>(); + Ok(Json(json!({"sensors": sensors}))) +} diff --git a/src/api/settings.rs b/src/api/settings.rs index 8051d3d..bb947ac 100644 --- a/src/api/settings.rs +++ b/src/api/settings.rs @@ -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, +) -> Json { + Json(gree_cloud_settings(&*state.settings.read().await)) +} + +fn apply_gree_cloud_update( + current: &GreeCloudSettings, + input: GreeCloudSettingsUpdate, +) -> Result { + 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, + Json(input): Json, +) -> Result, 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) -> Json { 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)) } diff --git a/src/api/system.rs b/src/api/system.rs index 81f0fb3..13a14e3 100644 --- a/src/api/system.rs +++ b/src/api/system.rs @@ -79,7 +79,7 @@ async fn build_bootstrap(state: &AppState) -> Result 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(), diff --git a/src/config.rs b/src/config.rs index 8a17a56..f5a5d23 100644 --- a/src/config.rs +++ b/src/config.rs @@ -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; } diff --git a/src/db.rs b/src/db.rs index 38fe608..355bf3e 100644 --- a/src/db.rs +++ b/src/db.rs @@ -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"); diff --git a/src/db/configuration.rs b/src/db/configuration.rs index d0b1752..1a070e4 100644 --- a/src/db/configuration.rs +++ b/src/db/configuration.rs @@ -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, diff --git a/src/db/core_devices.rs b/src/db/core_devices.rs index 3106a19..3d73bd5 100644 --- a/src/db/core_devices.rs +++ b/src/db/core_devices.rs @@ -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 { + 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 { 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])?; diff --git a/src/db/energy_history.rs b/src/db/energy_history.rs new file mode 100644 index 0000000..264d58c --- /dev/null +++ b/src/db/energy_history.rs @@ -0,0 +1,95 @@ +impl Db { + pub fn add_energy_reading(&self, reading: &EnergyReading) -> Result { + 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> { + 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, + limit: u32, + ) -> Result> { + 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::>>().map_err(Into::into) + } + + pub fn energy_before(&self, before: DateTime, limit: u32) -> Result> { + 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>>().map_err(Into::into) + } + + pub fn delete_energy_batch(&self, rows: &[EnergyReading]) -> Result { + 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 { + 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 { + let timestamp: String = row.get(2)?; + Ok(EnergyReading { + id: row.get(0)?, + device_id: row.get(1)?, + timestamp: DateTime::parse_from_rfc3339(×tamp) + .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, + }) + } +} diff --git a/src/db/tests.rs b/src/db/tests.rs index ed343a3..9a69379 100644 --- a/src/db/tests.rs +++ b/src/db/tests.rs @@ -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)); + } + } diff --git a/src/engine.rs b/src/engine.rs index 99054ac..65ff8af 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -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"); diff --git a/src/engine/commands.rs b/src/engine/commands.rs index 0358899..200ce1b 100644 --- a/src/engine/commands.rs +++ b/src/engine/commands.rs @@ -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 { + 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, diff --git a/src/engine/energy.rs b/src/engine/energy.rs new file mode 100644 index 0000000..27a9a92 --- /dev/null +++ b/src/engine/energy.rs @@ -0,0 +1,147 @@ +const ENERGY_ANOMALY_MAX_DELTA_KWH: f64 = 100.0; + +fn cumulative_energy_delta(previous_kwh: Option, 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 { + 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, +) -> Result { + 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); + } +} diff --git a/src/engine/ownership.rs b/src/engine/ownership.rs index 708523a..32de600 100644 --- a/src/engine/ownership.rs +++ b/src/engine/ownership.rs @@ -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, diff --git a/src/engine/polling.rs b/src/engine/polling.rs index 64ad665..8e0c96f 100644 --- a/src/engine/polling.rs +++ b/src/engine/polling.rs @@ -56,14 +56,58 @@ async fn poll_one_locked(state: &AppState, device_id: &str) -> Result Result<()> { - let device_ids: Vec = 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 = 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::().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, + } + } +} diff --git a/src/engine/runtime.rs b/src/engine/runtime.rs index f8d27e4..5fa4888 100644 --- a/src/engine/runtime.rs +++ b/src/engine/runtime.rs @@ -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 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, ); diff --git a/src/error.rs b/src/error.rs index 931cfc1..364f107 100644 --- a/src/error.rs +++ b/src/error.rs @@ -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"); ( diff --git a/src/home_assistant.rs b/src/home_assistant.rs index ba169ee..df9a59d 100644 --- a/src/home_assistant.rs +++ b/src/home_assistant.rs @@ -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> { + 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::() + ) + } + response + .json::>() + .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. diff --git a/src/influxdb.rs b/src/influxdb.rs index bf42fe9..4115a78 100644 --- a/src/influxdb.rs +++ b/src/influxdb.rs @@ -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"); diff --git a/src/influxdb/query.rs b/src/influxdb/query.rs index bd02cdc..4b6fe61 100644 --- a/src/influxdb/query.rs +++ b/src/influxdb/query.rs @@ -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, + stop: DateTime, + bucket_seconds: i64, + limit: u32, +) -> Result> { + 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) +} diff --git a/src/influxdb/write.rs b/src/influxdb/write.rs index cc102d0..b577605 100644 --- a/src/influxdb/write.rs +++ b/src/influxdb/write.rs @@ -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(()); diff --git a/src/main.rs b/src/main.rs index 837f08e..8acd70d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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 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()), - gree: 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(), - ), + 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) { + 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() diff --git a/src/models/defaults.rs b/src/models/defaults.rs index a0d1367..73c6538 100644 --- a/src/models/defaults.rs +++ b/src/models/defaults.rs @@ -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() } diff --git a/src/models/device.rs b/src/models/device.rs index 0a840e6..0912570 100644 --- a/src/models/device.rs +++ b/src/models/device.rs @@ -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, + #[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 { 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, + #[serde(default)] + pub cloud_parent_mac: Option, + #[serde(default)] + pub cloud_account_id: Option, 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, #[serde(default)] pub cid: Option, @@ -70,6 +156,16 @@ pub struct Device { #[serde(default)] pub supports_sleep: Option, #[serde(default)] + pub supports_buzzer_control: Option, + #[serde(default)] + pub supports_energy_meter: Option, + #[serde(default)] + pub total_energy_kwh: Option, + #[serde(default)] + pub compressor_frequency_hz: Option, + #[serde(default)] + pub last_cloud_sync: Option>, + #[serde(default)] pub current_temperature: Option, #[serde(default)] pub outdoor_temperature: Option, @@ -87,6 +183,22 @@ pub struct Device { pub last_error: Option, #[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, + #[serde(default)] + pub ha_energy_unit: Option, + #[serde(default)] + pub ha_energy_device_class: Option, + #[serde(default)] + pub ha_energy_state_class: Option, pub created_at: DateTime, pub updated_at: DateTime, } @@ -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, pub key: Option>, pub enabled: Option, + pub energy_source: Option, + pub ha_energy_entity_id: Option>, + pub ha_energy_unit: Option>, + pub ha_energy_device_class: Option>, + pub ha_energy_state_class: Option>, } #[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); + } +} diff --git a/src/models/history.rs b/src/models/history.rs index 8b49860..793c063 100644 --- a/src/models/history.rs +++ b/src/models/history.rs @@ -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, + 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, + pub quality: String, + #[serde(default)] + pub reset_detected: bool, +} + diff --git a/src/models/integrations.rs b/src/models/integrations.rs index f3f16e2..62055b6 100644 --- a/src/models/integrations.rs +++ b/src/models/integrations.rs @@ -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)] diff --git a/src/models/runtime.rs b/src/models/runtime.rs index acd165e..d86a382 100644 --- a/src/models/runtime.rs +++ b/src/models/runtime.rs @@ -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>, + /// Duration of the most recent successful REST operation against GREE Cloud. + #[serde(default)] + pub last_rest_response_time_ms: Option, +} + +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, } diff --git a/src/models/settings_api.rs b/src/models/settings_api.rs index 85d12b7..69dae59 100644 --- a/src/models/settings_api.rs +++ b/src/models/settings_api.rs @@ -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, + 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>, + pub last_rest_response_time_ms: Option, +} + #[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, diff --git a/src/protocol/gree.rs b/src/protocol/gree.rs index be047c9..59c5251 100644 --- a/src/protocol/gree.rs +++ b/src/protocol/gree.rs @@ -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}; diff --git a/src/protocol/gree/discovery.rs b/src/protocol/gree/discovery.rs index 4d90182..8b7ec9a 100644 --- a/src/protocol/gree/discovery.rs +++ b/src/protocol/gree/discovery.rs @@ -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, })) diff --git a/src/protocol/gree_cloud.rs b/src/protocol/gree_cloud.rs new file mode 100644 index 0000000..174d6d7 --- /dev/null +++ b/src/protocol/gree_cloud.rs @@ -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, + pub version: Option, + 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, + pub version: Option, + pub online: bool, + pub already_added: bool, +} + +#[derive(Clone)] +pub struct GreeCloudApi { + http: reqwest::Client, + base_url: String, + username: String, + password: String, + credentials: Option, +} + +impl GreeCloudApi { + pub fn for_region( + http: reqwest::Client, + region: &str, + username: &str, + password: &str, + ) -> Result { + 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 { + 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> { + 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> { + 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> { + 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 { + 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, + ) -> Result { + 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::>() + .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) -> Vec { + let mut groups: HashMap> = 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 { + 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 { + 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::().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 { + 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"); + } +} diff --git a/src/protocol/gree_cloud_mqtt.rs b/src/protocol/gree_cloud_mqtt.rs new file mode 100644 index 0000000..9777289 --- /dev/null +++ b/src/protocol/gree_cloud_mqtt.rs @@ -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, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ts: Option, +} + +#[derive(Debug, Clone)] +pub enum MqttEvent { + Message { topic: String, payload: Vec }, + /// 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), + Publish { topic: String, payload: Vec }, + Raw(Vec), + Ping, + Disconnect, +} + +#[derive(Clone)] +pub struct MqttConnection { + tx: mpsc::Sender, + connected: Arc, +} + +impl MqttConnection { + pub async fn connect( + host: &str, + port: u16, + user_id: i64, + token: &str, + connect_timeout: Duration, + events: broadcast::Sender, + ) -> Result { + 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::()); + 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) -> 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, + connected: Arc, + events: broadcast::Sender, + last_rx_ms: Arc, +) { + 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( + mut writer: W, + mut rx: mpsc::Receiver, + connected: Arc, + events: broadcast::Sender, + packet_ids: Arc, +) 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( + mut reader: R, + tx: mpsc::Sender, + connected: Arc, + events: broadcast::Sender, + last_rx_ms: Arc, +) 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, + events: &broadcast::Sender, +) -> 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, ack: Vec) -> 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, + 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> { + 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> { + 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> { + 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) -> Result> { + 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, 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) -> 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(reader: &mut R) -> Result<(u8, Vec)> { + 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")); + } +} diff --git a/src/protocol/mod.rs b/src/protocol/mod.rs index b6747a3..332c0a6 100644 --- a/src/protocol/mod.rs +++ b/src/protocol/mod.rs @@ -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}; diff --git a/src/provider.rs b/src/provider.rs new file mode 100644 index 0000000..224a58b --- /dev/null +++ b/src/provider.rs @@ -0,0 +1,1875 @@ +use crate::{ + models::{ApiEvent, ConnectionStatus, ConnectionType, Device, DeviceCommand, GreeCloudSettings}, + protocol::{ + crypto::{decrypt_v1, decrypt_v2, encrypt_v1, encrypt_v2}, + gree::{BindResult, GreeClient}, + gree_cloud::{parent_mac, CloudCredentials, GreeCloudApi}, + gree_cloud_mqtt::{broker_for_region, MqttConnection, MqttDeviceEnvelope, MqttEvent, MQTT_PORT}, + }, +}; +use anyhow::{anyhow, bail, Context, Result}; +use chrono::{DateTime, Utc}; +use rand::Rng; +use serde::Serialize; +use serde_json::{json, Value}; +use std::{ + collections::{BTreeMap, HashMap, HashSet}, + sync::{ + atomic::{AtomicBool, AtomicU64, Ordering}, + Arc, + }, + time::{Duration, Instant}, +}; +use tokio::{ + sync::{broadcast, oneshot, Mutex, Notify, RwLock, Semaphore}, + time::{sleep, timeout}, +}; + +const CLOUD_COMMAND_TIMEOUT: Duration = Duration::from_secs(2); +const CLOUD_STATUS_TIMEOUT: Duration = Duration::from_secs(10); +const CLOUD_COMPAT_STATUS_TIMEOUT: Duration = Duration::from_secs(6); +const CLOUD_CONNECT_TIMEOUT: Duration = Duration::from_secs(30); +const CLOUD_MAX_CONCURRENT_REQUESTS: usize = 8; +const CLOUD_QUEUE_WAIT_TIMEOUT: Duration = Duration::from_secs(2); +const CLOUD_DEVICE_LOCK_TIMEOUT: Duration = Duration::from_secs(2); +const KNOWN_CLOUD_PROPERTIES: &[&str] = &[ + "Pow", "Mod", "Dwet", "DwatSen", "Dfltr", "DwatFul", "Dmod", "SetTem", "TemSen", + "TemUn", "TemRec", "HalfTemEn", "SetDeciTem", "Add0.5", "WdSpd", "Air", "Blo", + "Health", "SwhSlp", "SlpMod", "Lig", "SwingLfRig", "SwUpDn", "Quiet", "Tur", "StHt", + "SvSt", "HeatCoolType", "hid", "ElcAll", "CompressorFqy", +]; +// Property set used by older GREE Wi-Fi modules before the dehumidifier, +// half-degree and cloud energy fields were added. Some V1.x modules silently +// drop a whole status request when it contains an unknown column, so probing +// with the modern superset can look exactly like a broken MQTT connection. +// This list matches the long-standing greeclimate status schema used by those +// modules and intentionally excludes newer optional fields. +const LEGACY_CLOUD_PROPERTIES: &[&str] = &[ + "Pow", "Mod", "SetTem", "TemSen", "TemUn", "TemRec", "WdSpd", "Air", "Blo", + "Health", "SwhSlp", "SlpMod", "Lig", "SwingLfRig", "SwUpDn", "Quiet", "Tur", + "StHt", "SvSt", "HeatCoolType", +]; + +#[derive(Clone)] +pub struct LocalProvider { + client: GreeClient, +} + +impl LocalProvider { + pub fn new(client: GreeClient) -> Self { + Self { client } + } + + pub fn client(&self) -> &GreeClient { + &self.client + } + + pub async fn bind(&self, device: &Device) -> Result { + debug_assert_eq!(device.connection_type, ConnectionType::Local); + self.client.bind(device).await + } + + pub async fn poll(&self, device: &mut Device) -> Result<()> { + debug_assert_eq!(device.connection_type, ConnectionType::Local); + self.client.poll(device).await + } + + pub async fn command( + &self, + device: &Device, + command: &DeviceCommand, + suppress_beep: bool, + ) -> Result { + debug_assert_eq!(device.connection_type, ConnectionType::Local); + self.client.command(device, command, suppress_beep).await + } +} + +#[derive(Debug, Clone, Serialize, Default)] +pub struct CloudDiagnostics { + pub connection_state: String, + pub mqtt_state: String, + pub last_connect: Option>, + pub last_disconnect: Option>, + pub reconnect_count: u64, + pub last_message_timestamp: Option>, + pub last_status_timestamp: Option>, + pub last_request_timestamp: Option>, + pub last_response_timestamp: Option>, + pub last_response_time_ms: Option, + pub last_request_kind: Option, + pub last_request_topic: Option, + pub last_mqtt_topic: Option, + pub requests_sent: u64, + pub responses_received: u64, + pub request_timeouts: u64, + pub selected_cipher_version: Option, + pub status_profile: Option, + pub wire_device_id: Option, + pub rest_device_online: Option, + pub fresh_discovery_key_used: Option, + pub saved_key_changed: Option, + pub broker_host: Option, + pub subscribed_topics: Vec, + pub raw_device_properties: BTreeMap, + pub parsed_device_properties: BTreeMap, + pub unknown_properties: BTreeMap, + pub energy_related_properties: BTreeMap, + pub last_error: Option, +} + +#[derive(Debug, Clone)] +pub struct CloudPushEvent { + pub cloud_device_id: Option, + pub parent_mac: String, + pub properties: BTreeMap, + pub connected: Option, + pub cipher_version: Option, +} + +#[derive(Clone)] +struct RegisteredDevice { + device_id: String, + key: String, + parent_mac: String, + cipher_version: u8, +} + +#[derive(Clone)] +struct CloudSession { + credentials: CloudCredentials, + mqtt: MqttConnection, + broker: String, + connected_at: DateTime, + /// Exact MAC spelling returned by GREE REST, keyed by normalized device id. + /// MQTT topic names are case-sensitive, so this must not be reconstructed from + /// the application's upper-case display/stable identifier. + wire_macs: HashMap, + /// Fresh per-device key returned by the current REST discovery. Keep this only + /// in backend memory; it is never serialized to diagnostics or frontend APIs. + /// The reference greeclimate cloud path recreates CloudDevice from this fresh + /// key on every setup instead of relying on a previously persisted value. + wire_keys: HashMap, +} + +#[derive(Debug, Clone)] +struct ParsedCloudMessage { + properties: BTreeMap, + cipher_version: u8, +} + +struct CloudInner { + http: reqwest::Client, + session: RwLock>, + connecting: AtomicBool, + connect_notify: Notify, + raw_events: broadcast::Sender, + push_events: broadcast::Sender, + api_events: Option>, + debug_cloud_requests: Arc, + debug_cloud_mqtt: Arc, + registered: RwLock>, + pending: Mutex>>, + command_locks: Mutex>>>, + diagnostics: RwLock>, + last_mqtt_message: RwLock>>, + reconnects: AtomicU64, + request_limit: Semaphore, +} + +#[derive(Clone)] +pub struct GreeCloudProvider { + inner: Arc, +} + +impl GreeCloudProvider { + #[cfg(test)] + pub fn new(http: reqwest::Client) -> Self { + Self::new_with_debug( + http, + None, + Arc::new(AtomicBool::new(false)), + Arc::new(AtomicBool::new(false)), + ) + } + + pub fn new_with_debug( + http: reqwest::Client, + api_events: Option>, + debug_cloud_requests: Arc, + debug_cloud_mqtt: Arc, + ) -> Self { + let (raw_events, _) = broadcast::channel(512); + let (push_events, _) = broadcast::channel(512); + let inner = Arc::new(CloudInner { + http, + session: RwLock::new(None), + connecting: AtomicBool::new(false), + connect_notify: Notify::new(), + raw_events, + push_events, + api_events, + debug_cloud_requests, + debug_cloud_mqtt, + registered: RwLock::new(HashMap::new()), + pending: Mutex::new(HashMap::new()), + command_locks: Mutex::new(HashMap::new()), + diagnostics: RwLock::new(HashMap::new()), + last_mqtt_message: RwLock::new(None), + reconnects: AtomicU64::new(0), + request_limit: Semaphore::new(CLOUD_MAX_CONCURRENT_REQUESTS), + }); + let provider = Self { inner }; + provider.spawn_message_router(); + provider + } + + pub fn subscribe_push(&self) -> broadcast::Receiver { + self.inner.push_events.subscribe() + } + + pub async fn diagnostics(&self, device_id: &str) -> CloudDiagnostics { + self.inner + .diagnostics + .read() + .await + .get(device_id) + .cloned() + .unwrap_or_default() + } + + pub async fn is_connected(&self) -> bool { + self.inner + .session + .read() + .await + .as_ref() + .is_some_and(|session| session.mqtt.is_connected()) + } + + pub async fn shutdown(&self) { + // Shutdown must stay bounded even if a Cloud writer or broker is wedged. Dropping + // pending response senders wakes request waiters immediately instead of making the + // HTTP server's graceful shutdown wait for their device timeouts. + let session = match timeout(Duration::from_secs(1), self.inner.session.write()).await { + Ok(mut guard) => guard.take(), + Err(_) => { + tracing::warn!("GREE Cloud session lock did not drain during shutdown"); + None + } + }; + if let Ok(mut pending) = timeout(Duration::from_secs(1), self.inner.pending.lock()).await { + pending.clear(); + } + if let Some(session) = session { + let _ = timeout(Duration::from_secs(1), session.mqtt.disconnect()).await; + } + } + + pub async fn unregister_device(&self, device_id: &str) { + let cloud_ids = { + let mut registered = self.inner.registered.write().await; + let ids = registered + .iter() + .filter(|(_, item)| item.device_id == device_id) + .map(|(cloud_id, _)| cloud_id.clone()) + .collect::>(); + registered.retain(|_, item| item.device_id != device_id); + ids + }; + let mut pending = self.inner.pending.lock().await; + for cloud_id in cloud_ids { + pending.remove(&cloud_id); + } + drop(pending); + self.inner.command_locks.lock().await.remove(device_id); + self.inner.diagnostics.write().await.remove(device_id); + } + + pub async fn poll( + &self, + settings: &GreeCloudSettings, + all_devices: &[Device], + device: &mut Device, + ) -> Result<()> { + self.register_device(device).await?; + self.ensure_connected(settings, all_devices).await?; + let lock = self.command_lock(&device.id).await; + let _guard = timeout(CLOUD_DEVICE_LOCK_TIMEOUT, lock.lock()) + .await + .context("GREE Cloud device operation queue timed out")?; + let session = self.current_session().await?; + let parent = cloud_wire_parent(&session, device); + session.mqtt.subscribe_device(&parent).await?; + self.set_subscriptions(device, &session.broker, &parent).await; + + let response = self.poll_status_compatible(device, &session).await?; + apply_cloud_capability_snapshot(device, &response.properties); + apply_cloud_properties(device, &response.properties); + device.protocol_version = response.cipher_version; + device.connection_status = ConnectionStatus::Online; + device.online = true; + device.communication_failures = 0; + device.last_error = None; + device.last_seen = Some(Utc::now()); + device.last_cloud_sync = Some(Utc::now()); + device.updated_at = Utc::now(); + self.update_diagnostics_from_message(device, &response).await; + Ok(()) + } + + async fn poll_status_compatible( + &self, + device: &Device, + session: &CloudSession, + ) -> Result { + let current_cipher = if device.protocol_version == 2 { 2 } else { 1 }; + let current_wire = cloud_wire_mac(session, device); + let diagnostics = self.inner.diagnostics.read().await.get(&device.id).cloned(); + let legacy_hint = is_legacy_cloud_firmware(&device.firmware) + || diagnostics + .as_ref() + .and_then(|item| item.status_profile.as_deref()) + == Some("legacy"); + + if !legacy_hint { + // One bounded request per poll. If a modern profile times out, mark the next + // attempt as legacy instead of running several compatibility probes back-to-back + // while higher layers are waiting on the same device lock. + let payload = json!({"t":"status", "cols": KNOWN_CLOUD_PROPERTIES}); + match self + .request_with_options( + device, + session, + payload, + CLOUD_STATUS_TIMEOUT, + Some(current_cipher), + Some(¤t_wire), + Some("full"), + ) + .await + { + Ok(response) => { + self.remember_status_compatibility(device, ¤t_wire, "full") + .await; + return Ok(response); + } + Err(err) if !is_timeout_error(&err) => return Err(err), + Err(err) => { + self.inner + .diagnostics + .write() + .await + .entry(device.id.clone()) + .or_default() + .status_profile = Some("legacy".into()); + return Err(err).context( + "GREE Cloud status timed out; legacy compatibility will be tried on the next poll", + ); + } + } + } + + // Old modules need compatibility probing, but never probe all variants in one + // operation. Rotate one safe status-read variant per polling interval. This keeps an + // offline unit bounded to a single ~6s network wait and prevents it from monopolizing + // application/zone/device locks for 25-30 seconds at a time. + let step = diagnostics + .as_ref() + .map(|item| item.request_timeouts % 4) + .unwrap_or(0); + let alternate_cipher = if current_cipher == 2 { 1 } else { 2 }; + let alternate_wire = alternate_wire_mac_case(¤t_wire); + let (cipher, wire, profile) = match step { + 1 => (alternate_cipher, current_wire.clone(), "legacy-cipher-probe"), + 2 => ( + current_cipher, + alternate_wire.clone().unwrap_or_else(|| current_wire.clone()), + "legacy-case-probe", + ), + 3 => ( + alternate_cipher, + alternate_wire.unwrap_or_else(|| current_wire.clone()), + "legacy-case-cipher-probe", + ), + _ => (current_cipher, current_wire.clone(), "legacy"), + }; + let parent = parent_mac_preserve_case(&wire); + session.mqtt.subscribe_device(&parent).await?; + let payload = json!({"t":"status", "cols": LEGACY_CLOUD_PROPERTIES}); + let response = self + .request_with_options( + device, + session, + payload, + CLOUD_COMPAT_STATUS_TIMEOUT, + Some(cipher), + Some(&wire), + Some(profile), + ) + .await?; + self.remember_status_compatibility(device, &wire, "legacy") + .await; + if wire != current_wire { + self.set_subscriptions(device, &session.broker, &parent).await; + } + Ok(response) + } + + async fn remember_status_compatibility( + &self, + device: &Device, + wire_mac: &str, + profile: &str, + ) { + let stable = cloud_id(device); + if let Some(session) = self.inner.session.write().await.as_mut() { + session.wire_macs.insert(stable, wire_mac.to_string()); + } + let mut diagnostics = self.inner.diagnostics.write().await; + diagnostics + .entry(device.id.clone()) + .or_default() + .status_profile = Some(profile.to_string()); + } + + pub async fn command( + &self, + settings: &GreeCloudSettings, + all_devices: &[Device], + device: &mut Device, + command: &DeviceCommand, + suppress_beep: bool, + ) -> Result { + self.register_device(device).await?; + self.ensure_connected(settings, all_devices).await?; + let lock = self.command_lock(&device.id).await; + let _guard = timeout(CLOUD_DEVICE_LOCK_TIMEOUT, lock.lock()) + .await + .context("GREE Cloud device operation queue timed out")?; + let session = self.current_session().await?; + let parent = cloud_wire_parent(&session, device); + session.mqtt.subscribe_device(&parent).await?; + self.set_subscriptions(device, &session.broker, &parent).await; + + let commands = self.build_command_sequence(device, command, suppress_beep).await?; + let mut applied = DeviceCommand::default(); + for (opt, values, fragment) in commands { + let payload = json!({"t":"cmd", "opt": opt, "p": values}); + // greeclimate treats a 2s no-ACK as uncertain success. Preserve that semantic, + // then let the engine's bounded status verification decide the final UI state. + match self.request(device, &session, payload, CLOUD_COMMAND_TIMEOUT).await { + Ok(response) => { + self.update_diagnostics_from_message(device, &response).await; + fragment.apply(device); + merge_command(&mut applied, &fragment); + } + Err(err) if is_timeout_error(&err) => { + tracing::debug!(device=%device.id, "GREE Cloud command did not ACK within 2s; status verification will confirm it"); + fragment.apply(device); + merge_command(&mut applied, &fragment); + } + Err(err) => return Err(err), + } + } + Ok(applied) + } + + async fn build_command_sequence( + &self, + device: &Device, + command: &DeviceCommand, + suppress_beep: bool, + ) -> Result, Vec, DeviceCommand)>> { + let mut out = Vec::new(); + if let Some(mode) = command.mode.as_deref() { + let value = mode_to_wire(mode)?; + out.push((vec!["Mod".into()], vec![json!(value)], DeviceCommand { mode: Some(mode.into()), ..Default::default() })); + } + + if let Some(target) = command.target_temperature { + let raw = self.raw_properties(&device.id).await; + let half_enabled = raw.get("HalfTemEn").and_then(value_i64) == Some(1); + let whole = target.floor() as i64; + let half = if target.fract() >= 0.5 { 1_i64 } else { 0_i64 }; + let mut opt = vec!["SetTem".into(), "TemRec".into()]; + let mut values = vec![json!(whole), json!(half)]; + if half_enabled { + opt.push("SetDeciTem".into()); + values.push(json!((target * 10.0).round() as i64)); + opt.push("Add0.5".into()); + values.push(json!(half)); + } + out.push((opt, values, DeviceCommand { target_temperature: Some(target), ..Default::default() })); + } + + let mut simple = Vec::new(); + if let Some(value) = command.fan_speed { simple.push(("WdSpd", json!(value.min(5)), DeviceCommand { fan_speed: Some(value.min(5)), ..Default::default() })); } + if let Some(value) = command.swing_vertical { simple.push(("SwUpDn", json!(if value { 1 } else { 0 }), DeviceCommand { swing_vertical: Some(value), ..Default::default() })); } + if let Some(value) = command.swing_horizontal { simple.push(("SwingLfRig", json!(if value { 1 } else { 0 }), DeviceCommand { swing_horizontal: Some(value), ..Default::default() })); } + if let Some(value) = command.quiet { + let wire = if value { device.quiet_wire_value.unwrap_or(2).max(1) } else { 0 }; + simple.push(("Quiet", json!(wire), DeviceCommand { quiet: Some(value), ..Default::default() })); + } + if let Some(value) = command.turbo { simple.push(("Tur", json!(u8::from(value)), DeviceCommand { turbo: Some(value), ..Default::default() })); } + if let Some(value) = command.light { simple.push(("Lig", json!(u8::from(value)), DeviceCommand { light: Some(value), ..Default::default() })); } + if let Some(value) = command.air { simple.push(("Air", json!(u8::from(value)), DeviceCommand { air: Some(value), ..Default::default() })); } + if let Some(value) = command.xfan { simple.push(("Blo", json!(u8::from(value)), DeviceCommand { xfan: Some(value), ..Default::default() })); } + if let Some(value) = command.health { simple.push(("Health", json!(u8::from(value)), DeviceCommand { health: Some(value), ..Default::default() })); } + if let Some(value) = command.sleep { + simple.push(("SwhSlp", json!(u8::from(value)), DeviceCommand { sleep: Some(value), ..Default::default() })); + simple.push(("SlpMod", json!(u8::from(value)), DeviceCommand::default())); + } + for (name, value, fragment) in simple { + out.push((vec![name.into()], vec![value], fragment)); + } + + if let Some(value) = command.power { + out.push((vec!["Pow".into()], vec![json!(u8::from(value))], DeviceCommand { power: Some(value), ..Default::default() })); + } + + // greeclimate adds Buzzer_ON_OFF=1 to every state command when its shared + // buzzer preference is disabled. Some devices ignore the field, which is the + // reference behavior; it is not a separately reported Cloud capability. + if suppress_beep { + for (opt, values, _) in &mut out { + opt.push("Buzzer_ON_OFF".into()); + values.push(json!(1)); + } + } + Ok(out) + } + + async fn raw_properties(&self, device_id: &str) -> BTreeMap { + self.inner + .diagnostics + .read() + .await + .get(device_id) + .map(|d| d.raw_device_properties.clone()) + .unwrap_or_default() + } + + async fn command_lock(&self, device_id: &str) -> Arc> { + let mut locks = self.inner.command_locks.lock().await; + locks + .entry(device_id.to_string()) + .or_insert_with(|| Arc::new(Mutex::new(()))) + .clone() + } + + async fn register_device(&self, device: &Device) -> Result<()> { + if device.connection_type != ConnectionType::GreeCloud { + bail!("GREE Cloud provider received a Local device"); + } + let cloud_id = cloud_id(device); + // Once a Cloud session exists, prefer the key and exact wire MAC from the + // current REST discovery. Calling register_device() before every poll/command + // must not overwrite a refreshed key with an older value persisted in SQLite. + let session = self.inner.session.read().await; + let session_key = session + .as_ref() + .and_then(|session| session.wire_keys.get(&cloud_id)) + .cloned(); + let session_parent = session + .as_ref() + .map(|session| cloud_wire_parent(session, device)); + drop(session); + let key = session_key + .or_else(|| device.key.clone().filter(|value| !value.is_empty())) + .ok_or_else(|| anyhow!("GREE Cloud device key is missing"))?; + self.inner.registered.write().await.insert( + cloud_id, + RegisteredDevice { + device_id: device.id.clone(), + key, + parent_mac: session_parent.unwrap_or_else(|| cloud_parent(device)), + cipher_version: if device.protocol_version == 2 { 2 } else { 1 }, + }, + ); + Ok(()) + } + + async fn current_session(&self) -> Result { + self.inner + .session + .read() + .await + .as_ref() + .filter(|session| session.mqtt.is_connected()) + .cloned() + .ok_or_else(|| anyhow!("GREE Cloud MQTT is disconnected")) + } + + pub async fn ensure_connected(&self, settings: &GreeCloudSettings, devices: &[Device]) -> Result<()> { + if !settings.enabled { + bail!("GREE Cloud is disabled"); + } + if self.is_connected().await { + return Ok(()); + } + + // Create the notification future before racing for the connection leadership so a + // very fast connect/notify cannot be missed by concurrent callers. + let connection_finished = self.inner.connect_notify.notified(); + if self + .inner + .connecting + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_err() + { + timeout(CLOUD_CONNECT_TIMEOUT + Duration::from_secs(5), connection_finished) + .await + .context("timed out waiting for GREE Cloud connection attempt")?; + return if self.is_connected().await { + Ok(()) + } else { + Err(anyhow!("GREE Cloud MQTT connection failed")) + }; + } + + let result = match timeout( + CLOUD_CONNECT_TIMEOUT + Duration::from_secs(15), + self.connect_once(settings, devices), + ) + .await + { + Ok(result) => result, + Err(_) => Err(anyhow!("GREE Cloud connection attempt timed out")), + }; + self.inner.connecting.store(false, Ordering::Release); + self.inner.connect_notify.notify_waiters(); + result + } + + async fn connect_once(&self, settings: &GreeCloudSettings, devices: &[Device]) -> Result<()> { + let broker = broker_for_region(&settings.region) + .ok_or_else(|| anyhow!("unknown GREE Cloud region: {}", settings.region))? + .to_string(); + tracing::info!(broker=%broker, "GREE Cloud MQTT connecting"); + + // Re-login on every new broker connection exactly like the HA integration. The token + // is then reused for the whole MQTT session and is never exposed through diagnostics. + let mut api = GreeCloudApi::for_region( + self.inner.http.clone(), + &settings.region, + &settings.username, + &settings.password, + )?; + let login_started = Instant::now(); + self.emit_cloud_request(json!({ + "phase": "sent", + "operation": "login", + "transport": "REST", + "region": settings.region, + })); + let credentials = match api.login().await { + Ok(credentials) => { + self.emit_cloud_request(json!({ + "phase": "response", + "operation": "login", + "transport": "REST", + "region": settings.region, + "duration_ms": login_started.elapsed().as_millis().min(u64::MAX as u128) as u64, + "status": "ok", + })); + credentials + } + Err(err) => { + self.emit_cloud_request(json!({ + "phase": "error", + "operation": "login", + "transport": "REST", + "region": settings.region, + "duration_ms": login_started.elapsed().as_millis().min(u64::MAX as u128) as u64, + "error": sanitize_error(&err.to_string()), + })); + return Err(err).context("GREE Cloud login failed"); + } + }; + tracing::info!("GREE Cloud login success"); + + // Preserve the exact MAC spelling returned by the Cloud API. MQTT topic names are + // case-sensitive; normalizing them to upper case (as the UI/stable ID does) can + // produce a perfectly connected MQTT session with zero device responses. This + // mirrors greeclimate, which passes CloudDeviceInfo.mac through unchanged. + let (wire_macs, wire_keys, rest_online): ( + HashMap, + HashMap, + HashMap, + ) = match api.get_all_devices().await { + Ok(discovered) => { + let mut macs = HashMap::new(); + let mut keys = HashMap::new(); + let mut online = HashMap::new(); + for item in discovered { + let wire = item.mac.trim().replace([':', '-'], ""); + let stable = normalize_mac(&wire); + macs.insert(stable.clone(), wire); + keys.insert(stable.clone(), item.key); + online.insert(stable, item.online); + } + (macs, keys, online) + } + Err(err) => { + tracing::warn!(error=%sanitize_error(&err.to_string()), "GREE Cloud MQTT device refresh failed; using persisted device metadata as fallback"); + (HashMap::new(), HashMap::new(), HashMap::new()) + } + }; + + let mqtt = MqttConnection::connect( + &broker, + MQTT_PORT, + credentials.user_id, + &credentials.token, + CLOUD_CONNECT_TIMEOUT, + self.inner.raw_events.clone(), + ) + .await?; + + let mut parents = HashSet::new(); + let mut registration_failures = HashSet::new(); + for device in devices + .iter() + .filter(|d| d.enabled && d.connection_type == ConnectionType::GreeCloud) + { + match self.register_device(device).await { + Ok(()) => { + let stable = cloud_id(device); + let wire = wire_macs + .get(&stable) + .cloned() + .unwrap_or_else(|| stable.to_ascii_lowercase()); + let parent = parent_mac_preserve_case(&wire); + let fresh_key = wire_keys.get(&stable); + let saved_key_changed = fresh_key + .zip(device.key.as_ref()) + .map(|(fresh, saved)| fresh != saved); + if let Some(registered) = self.inner.registered.write().await.get_mut(&stable) { + if let Some(fresh_key) = fresh_key { + registered.key = fresh_key.clone(); + } + registered.parent_mac = parent.clone(); + } + { + let mut diagnostics = self.inner.diagnostics.write().await; + let item = diagnostics.entry(device.id.clone()).or_default(); + item.wire_device_id = Some(wire.clone()); + item.rest_device_online = rest_online.get(&stable).copied(); + item.fresh_discovery_key_used = Some(fresh_key.is_some()); + item.saved_key_changed = saved_key_changed; + } + self.emit_cloud_request(json!({ + "operation": "device_registration", + "phase": "ready", + "device_id": device.id, + "cloud_device_id": stable, + "wire_device_id": wire, + "wire_parent_mac": parent, + "rest_online": rest_online.get(&cloud_id(device)).copied(), + "fresh_discovery_key_used": fresh_key.is_some(), + "saved_key_changed": saved_key_changed, + })); + parents.insert(cloud_wire_parent_from_map(&wire_macs, device)); + } + Err(err) => { + let message = sanitize_error(&err.to_string()); + tracing::warn!(device=%device.id, error=%message, "GREE Cloud device registration skipped"); + let mut diagnostics = self.inner.diagnostics.write().await; + let item = diagnostics.entry(device.id.clone()).or_default(); + item.connection_state = "offline".into(); + item.mqtt_state = "connected".into(); + item.last_error = Some(message); + registration_failures.insert(device.id.clone()); + } + } + } + for parent in &parents { + mqtt.subscribe_device(parent).await?; + } + + let reconnect_count = self.inner.reconnects.fetch_add(1, Ordering::AcqRel); + *self.inner.session.write().await = Some(CloudSession { + credentials, + mqtt, + broker: broker.clone(), + connected_at: Utc::now(), + wire_macs, + wire_keys, + }); + let now = Utc::now(); + self.emit_cloud_mqtt(json!({ + "direction": "connected", + "broker": broker, + "port": MQTT_PORT, + "parents": parents.len(), + })); + let mut diagnostics = self.inner.diagnostics.write().await; + for device in devices.iter().filter(|d| d.connection_type == ConnectionType::GreeCloud) { + let item = diagnostics.entry(device.id.clone()).or_default(); + item.mqtt_state = "connected".into(); + item.last_connect = Some(now); + item.reconnect_count = reconnect_count; + item.broker_host = Some(broker.clone()); + if !registration_failures.contains(&device.id) { + item.connection_state = "connected".into(); + item.last_error = None; + } + } + tracing::info!(broker=%broker, "GREE Cloud MQTT connected"); + tracing::info!(count=parents.len(), "GREE Cloud MQTT subscriptions restored"); + Ok(()) + } + + async fn request( + &self, + device: &Device, + session: &CloudSession, + inner_payload: Value, + wait: Duration, + ) -> Result { + self.request_with_options( + device, + session, + inner_payload, + wait, + None, + None, + None, + ) + .await + } + + async fn request_with_options( + &self, + device: &Device, + session: &CloudSession, + inner_payload: Value, + wait: Duration, + cipher_override: Option, + wire_cloud_id_override: Option<&str>, + status_profile: Option<&str>, + ) -> Result { + let _permit = timeout(CLOUD_QUEUE_WAIT_TIMEOUT, self.inner.request_limit.acquire()) + .await + .context("GREE Cloud request queue timed out")??; + let cloud_id = cloud_id(device); + let wire_cloud_id = wire_cloud_id_override + .map(|value| value.trim().replace([':', '-'], "")) + .unwrap_or_else(|| cloud_wire_mac(session, device)); + let wire_parent = parent_mac_preserve_case(&wire_cloud_id); + let key = session + .wire_keys + .get(&cloud_id) + .map(String::as_str) + .or(device.key.as_deref()) + .ok_or_else(|| anyhow!("GREE Cloud device key is missing"))?; + let cipher = cipher_override.unwrap_or_else(|| if device.protocol_version == 2 { 2 } else { 1 }); + let plaintext = serde_json::to_vec(&inner_payload)?; + let (pack, tag) = if cipher == 2 { + let encrypted = encrypt_v2(key, &plaintext)?; + (encrypted.ciphertext, Some(encrypted.tag)) + } else { + (encrypt_v1(key, &plaintext)?, None) + }; + let cid = rand::thread_rng() + .gen_range(1_000_000_000_u64..=9_999_999_999_u64) + .to_string(); + let request_kind = inner_payload + .get("t") + .and_then(Value::as_str) + .unwrap_or("request") + .to_string(); + let envelope = MqttDeviceEnvelope { + cid: cid.clone(), + i: 0, + pack, + t: "pack".into(), + tcid: wire_cloud_id.clone(), + uid: json!(session.credentials.user_id), + tag, + ts: None, + }; + let payload = serde_json::to_vec(&envelope)?; + let (tx, rx) = oneshot::channel(); + { + let mut pending = self.inner.pending.lock().await; + if pending.insert(cloud_id.clone(), tx).is_some() { + bail!("another GREE Cloud request is already pending for this device"); + } + } + let topic = format!("request/{wire_parent}"); + let started_at = Utc::now(); + let started = Instant::now(); + { + let mut diagnostics = self.inner.diagnostics.write().await; + let item = diagnostics.entry(device.id.clone()).or_default(); + item.last_request_timestamp = Some(started_at); + item.last_request_kind = Some(request_kind.clone()); + item.last_request_topic = Some(topic.clone()); + if let Some(profile) = status_profile { + item.status_profile = Some(profile.to_string()); + } + item.requests_sent = item.requests_sent.saturating_add(1); + } + self.emit_cloud_request(json!({ + "phase": "sent", + "operation": request_kind, + "device_id": device.id, + "device_name": device.name, + "cloud_device_id": cloud_id, + "topic": topic, + "cid": cid, + "cipher_version": cipher, + "status_profile": status_profile, + "wire_device_id": wire_cloud_id, + "payload": inner_payload, + })); + self.emit_cloud_mqtt(json!({ + "direction": "tx", + "topic": topic, + "device_id": device.id, + "cloud_device_id": cloud_id, + "cid": cid, + "tcid": envelope.tcid, + "qos": 1, + "bytes": payload.len(), + })); + if let Err(err) = session.mqtt.publish(topic.clone(), payload).await { + self.inner.pending.lock().await.remove(&cloud_id); + let duration_ms = started.elapsed().as_millis().min(u64::MAX as u128) as u64; + self.emit_cloud_request(json!({ + "phase": "error", + "operation": request_kind, + "device_id": device.id, + "topic": topic, + "duration_ms": duration_ms, + "error": sanitize_error(&err.to_string()), + })); + return Err(err); + } + tracing::debug!(device=%device.id, kind=%request_kind, "GREE Cloud command/status request sent"); + let response = timeout(wait, rx).await; + self.inner.pending.lock().await.remove(&cloud_id); + let duration_ms = started.elapsed().as_millis().min(u64::MAX as u128) as u64; + match response { + Ok(Ok(message)) => { + let now = Utc::now(); + { + let mut diagnostics = self.inner.diagnostics.write().await; + let item = diagnostics.entry(device.id.clone()).or_default(); + item.last_response_timestamp = Some(now); + item.last_response_time_ms = Some(duration_ms); + item.responses_received = item.responses_received.saturating_add(1); + } + self.emit_cloud_request(json!({ + "phase": "response", + "operation": request_kind, + "device_id": device.id, + "cloud_device_id": cloud_id, + "topic": topic, + "duration_ms": duration_ms, + "properties": message.properties.keys().cloned().collect::>(), + })); + Ok(message) + } + Ok(Err(_)) => { + self.emit_cloud_request(json!({ + "phase": "error", + "operation": request_kind, + "device_id": device.id, + "topic": topic, + "duration_ms": duration_ms, + "error": "response waiter cancelled", + })); + bail!("GREE Cloud response waiter was cancelled") + } + Err(_) => { + { + let mut diagnostics = self.inner.diagnostics.write().await; + let item = diagnostics.entry(device.id.clone()).or_default(); + item.request_timeouts = item.request_timeouts.saturating_add(1); + item.last_error = Some(format!("GREE Cloud {request_kind} request timed out")); + } + self.emit_cloud_request(json!({ + "phase": "timeout", + "operation": request_kind, + "device_id": device.id, + "cloud_device_id": cloud_id, + "topic": topic, + "duration_ms": duration_ms, + })); + bail!("GREE Cloud request timed out") + } + } + } + + fn emit_cloud_request(&self, data: Value) { + if !self.inner.debug_cloud_requests.load(Ordering::Relaxed) { + return; + } + if let Some(events) = &self.inner.api_events { + let _ = events.send(ApiEvent { + event: "gree_cloud.request".into(), + timestamp: Utc::now(), + data, + }); + } + } + + fn emit_cloud_mqtt(&self, data: Value) { + if !self.inner.debug_cloud_mqtt.load(Ordering::Relaxed) { + return; + } + if let Some(events) = &self.inner.api_events { + let _ = events.send(ApiEvent { + event: "gree_cloud.mqtt".into(), + timestamp: Utc::now(), + data, + }); + } + } + + pub async fn runtime_status(&self) -> Value { + let session = self.inner.session.read().await.clone(); + let diagnostics = self.inner.diagnostics.read().await; + let last_mqtt_message = self.inner.last_mqtt_message.read().await.clone(); + let mut last_device_response = None; + let mut last_response_time_ms = None; + let mut requests_sent = 0_u64; + let mut responses_received = 0_u64; + let mut request_timeouts = 0_u64; + for item in diagnostics.values() { + if item.last_response_timestamp > last_device_response { + last_device_response = item.last_response_timestamp; + last_response_time_ms = item.last_response_time_ms; + } + requests_sent = requests_sent.saturating_add(item.requests_sent); + responses_received = responses_received.saturating_add(item.responses_received); + request_timeouts = request_timeouts.saturating_add(item.request_timeouts); + } + json!({ + "broker_host": session.as_ref().map(|value| value.broker.clone()), + "mqtt_connected_since": session.as_ref().map(|value| value.connected_at.clone()), + "last_mqtt_message": last_mqtt_message, + "last_device_response": last_device_response, + "last_response_time_ms": last_response_time_ms, + "requests_sent": requests_sent, + "responses_received": responses_received, + "request_timeouts": request_timeouts, + "reconnect_count": self.inner.reconnects.load(Ordering::Relaxed), + }) + } + + fn spawn_message_router(&self) { + let provider = self.clone(); + let mut rx = self.inner.raw_events.subscribe(); + tokio::spawn(async move { + loop { + match rx.recv().await { + Ok(MqttEvent::Message { topic, payload }) => { + if let Err(err) = provider.handle_raw_message(&topic, &payload).await { + tracing::warn!(error=?err, "GREE Cloud MQTT payload error"); + } + } + Ok(MqttEvent::Traffic { kind }) => { + *provider.inner.last_mqtt_message.write().await = Some(Utc::now()); + provider.emit_cloud_mqtt(json!({ + "direction": "rx_protocol", + "kind": kind, + })); + } + Ok(MqttEvent::Disconnected { reason }) => { + provider.handle_disconnect(&reason).await; + } + Err(broadcast::error::RecvError::Lagged(skipped)) => { + tracing::warn!(skipped, "GREE Cloud MQTT event receiver lagged"); + } + Err(broadcast::error::RecvError::Closed) => break, + } + } + }); + } + + async fn handle_disconnect(&self, reason: &str) { + self.emit_cloud_mqtt(json!({ + "direction": "disconnect", + "reason": sanitize_error(reason), + })); + *self.inner.session.write().await = None; + let now = Utc::now(); + for item in self.inner.diagnostics.write().await.values_mut() { + item.connection_state = "cloud_disconnected".into(); + item.mqtt_state = "disconnected".into(); + item.last_disconnect = Some(now); + item.last_error = Some(sanitize_error(reason)); + } + tracing::warn!(reason=%sanitize_error(reason), "GREE Cloud MQTT disconnected"); + } + + async fn handle_raw_message(&self, topic: &str, payload: &[u8]) -> Result<()> { + let now = Utc::now(); + *self.inner.last_mqtt_message.write().await = Some(now); + self.emit_cloud_mqtt(json!({ + "direction": "rx", + "topic": topic, + "bytes": payload.len(), + })); + if topic.starts_with("connect/") { + let parent = topic.split('/').nth(1).unwrap_or_default().to_string(); + self.emit_cloud_mqtt(json!({ + "direction": "device_connect", + "topic": topic, + "parent_mac": parent, + })); + let _ = self.inner.push_events.send(CloudPushEvent { + cloud_device_id: None, + parent_mac: parent, + properties: BTreeMap::new(), + connected: Some(true), + cipher_version: None, + }); + return Ok(()); + } + let envelope: MqttDeviceEnvelope = serde_json::from_slice(payload).context("invalid GREE Cloud MQTT JSON")?; + if envelope.pack.is_empty() { + bail!("GREE Cloud MQTT payload has no encrypted pack"); + } + self.emit_cloud_mqtt(json!({ + "direction": "rx_envelope", + "topic": topic, + "cid": envelope.cid.clone(), + "tcid": envelope.tcid.clone(), + "cipher": if envelope.tag.is_some() { 2 } else { 1 }, + })); + let parent_from_topic = topic.split('/').nth(1).unwrap_or_default().to_string(); + let registered = self.inner.registered.read().await.clone(); + // Match the reference client primarily by subscribed parent topic. Some GREE + // responses use a parent/variant tcid that is not byte-for-byte equal to the + // discovery child id; restricting candidates to tcid caused valid frames to be + // discarded and the status request to time out. Prefer exact tcid first, then + // try every registered child under the topic parent. + let normalized_tcid = normalize_mac(&envelope.tcid); + let mut candidates: Vec<(String, RegisteredDevice)> = registered + .iter() + .filter(|(_, item)| item.parent_mac.eq_ignore_ascii_case(&parent_from_topic)) + .map(|(id, item)| (id.clone(), item.clone())) + .collect(); + candidates.sort_by_key(|(id, _)| if !normalized_tcid.is_empty() && id.eq_ignore_ascii_case(&normalized_tcid) { 0 } else { 1 }); + if candidates.is_empty() { + bail!("GREE Cloud MQTT message does not match a registered device"); + } + + let mut last_error = None; + for (cloud_id, registered) in candidates { + let preferred = if envelope.tag.is_some() { 2 } else { registered.cipher_version }; + let (plaintext, cipher_version) = match decrypt_cloud_payload(®istered.key, &envelope, preferred) { + Ok(value) => (value, preferred), + Err(first) => { + let alternate = if preferred == 2 { 1 } else { 2 }; + match decrypt_cloud_payload(®istered.key, &envelope, alternate) { + Ok(value) => (value, alternate), + Err(second) => { + last_error = Some(format!("{first}; alternate cipher: {second}")); + continue; + } + } + } + }; + let parsed: Value = serde_json::from_slice(&plaintext).context("invalid decrypted GREE Cloud JSON")?; + validate_cloud_response(&parsed)?; + let properties = properties_from_payload(&parsed)?; + let message = ParsedCloudMessage { + properties: properties.clone(), + cipher_version, + }; + if let Some(item) = self.inner.registered.write().await.get_mut(&cloud_id) { + item.cipher_version = cipher_version; + } + // The reference client accepts both response/* and status/* frames while a + // sequential request is pending. There is only one in-flight request per device; + // final command state is independently verified by the engine. + let waiter = self.inner.pending.lock().await.remove(&cloud_id); + if let Some(waiter) = waiter { + let _ = waiter.send(message.clone()); + } + let _ = self.inner.push_events.send(CloudPushEvent { + cloud_device_id: Some(cloud_id.clone()), + parent_mac: registered.parent_mac.clone(), + properties: properties.clone(), + connected: Some(true), + cipher_version: Some(cipher_version), + }); + let mut diagnostics = self.inner.diagnostics.write().await; + let item = diagnostics.entry(registered.device_id.clone()).or_default(); + item.connection_state = "online".into(); + item.mqtt_state = "connected".into(); + item.last_message_timestamp = Some(now); + item.last_mqtt_topic = Some(topic.to_string()); + if !properties.is_empty() { + item.last_status_timestamp = Some(now); + for (key, value) in &properties { + item.raw_device_properties.insert(key.clone(), value.clone()); + } + item.parsed_device_properties = parsed_properties(&item.raw_device_properties); + item.unknown_properties = item.raw_device_properties + .iter() + .filter(|(key, _)| !KNOWN_CLOUD_PROPERTIES.contains(&key.as_str())) + .map(|(key, value)| (key.clone(), value.clone())) + .collect(); + item.energy_related_properties = item.raw_device_properties + .iter() + .filter(|(key, _)| matches!(key.as_str(), "ElcAll" | "ElcAllConsumption" | "CompressorFqy")) + .map(|(key, value)| (key.clone(), value.clone())) + .collect(); + } + item.selected_cipher_version = Some(cipher_version); + item.last_error = None; + drop(diagnostics); + self.emit_cloud_mqtt(json!({ + "direction": "rx_decrypted", + "topic": topic, + "device_id": registered.device_id, + "cloud_device_id": cloud_id, + "cipher_version": cipher_version, + "properties": properties, + })); + return Ok(()); + } + bail!("cannot decrypt GREE Cloud MQTT payload: {}", last_error.unwrap_or_else(|| "unknown cipher error".into())) + } + + async fn update_diagnostics_from_message(&self, device: &Device, message: &ParsedCloudMessage) { + let mut diagnostics = self.inner.diagnostics.write().await; + let item = diagnostics.entry(device.id.clone()).or_default(); + item.connection_state = "online".into(); + item.mqtt_state = "connected".into(); + item.last_message_timestamp = Some(Utc::now()); + item.last_status_timestamp = Some(Utc::now()); + item.selected_cipher_version = Some(message.cipher_version); + for (key, value) in &message.properties { + item.raw_device_properties.insert(key.clone(), value.clone()); + } + item.parsed_device_properties = parsed_properties(&item.raw_device_properties); + item.unknown_properties = item.raw_device_properties + .iter() + .filter(|(key, _)| !KNOWN_CLOUD_PROPERTIES.contains(&key.as_str())) + .map(|(key, value)| (key.clone(), value.clone())) + .collect(); + item.energy_related_properties = item.raw_device_properties + .iter() + .filter(|(key, _)| matches!(key.as_str(), "ElcAll" | "ElcAllConsumption" | "CompressorFqy")) + .map(|(key, value)| (key.clone(), value.clone())) + .collect(); + item.last_error = None; + } + + async fn set_subscriptions(&self, device: &Device, broker: &str, wire_parent: &str) { + let topics = vec![ + format!("response/{wire_parent}/#"), + format!("status/{wire_parent}/#"), + format!("connect/{wire_parent}"), + ]; + { + let mut diagnostics = self.inner.diagnostics.write().await; + let item = diagnostics.entry(device.id.clone()).or_default(); + item.broker_host = Some(broker.into()); + item.subscribed_topics = topics.clone(); + } + self.emit_cloud_mqtt(json!({ + "direction": "subscribe", + "device_id": device.id, + "parent_mac": wire_parent, + "topics": topics, + "qos": 1, + })); + } +} + +#[derive(Clone)] +pub struct ProviderDispatcher { + local: LocalProvider, + cloud: GreeCloudProvider, +} + +impl ProviderDispatcher { + #[cfg(test)] + pub fn new(local: GreeClient, http: reqwest::Client) -> Self { + Self { + local: LocalProvider::new(local), + cloud: GreeCloudProvider::new(http), + } + } + + pub fn new_with_debug( + local: GreeClient, + http: reqwest::Client, + api_events: Option>, + debug_cloud_requests: Arc, + debug_cloud_mqtt: Arc, + ) -> Self { + Self { + local: LocalProvider::new(local), + cloud: GreeCloudProvider::new_with_debug( + http, + api_events, + debug_cloud_requests, + debug_cloud_mqtt, + ), + } + } + + pub fn local(&self) -> &LocalProvider { + &self.local + } + + pub fn cloud(&self) -> &GreeCloudProvider { + &self.cloud + } + + #[cfg(test)] + pub fn provider_name(&self, device: &Device) -> &'static str { + match device.connection_type { + ConnectionType::Local => "local", + ConnectionType::GreeCloud => "gree_cloud", + } + } +} + +fn decrypt_cloud_payload(key: &str, envelope: &MqttDeviceEnvelope, cipher: u8) -> Result> { + match cipher { + 2 => decrypt_v2( + key, + &envelope.pack, + envelope.tag.as_deref().ok_or_else(|| anyhow!("GCM payload is missing tag"))?, + ), + _ => decrypt_v1(key, &envelope.pack), + } +} + +fn validate_cloud_response(payload: &Value) -> Result<()> { + if let Some(code) = payload.get("r").and_then(value_i64) { + if code != 0 && code != 200 { + let message = payload + .get("msg") + .and_then(Value::as_str) + .unwrap_or("GREE Cloud command rejected"); + bail!("GREE Cloud response error {code}: {message}"); + } + } + if matches!(payload.get("t").and_then(Value::as_str), Some("error" | "err")) { + let message = payload + .get("msg") + .or_else(|| payload.get("error")) + .and_then(Value::as_str) + .unwrap_or("GREE Cloud command rejected"); + bail!("GREE Cloud response error: {message}"); + } + Ok(()) +} + +fn properties_from_payload(payload: &Value) -> Result> { + if payload.get("t").and_then(Value::as_str) != Some("dat") { + return Ok(BTreeMap::new()); + } + let cols = payload.get("cols").and_then(Value::as_array).ok_or_else(|| anyhow!("GREE Cloud dat payload has no cols"))?; + let dat = payload.get("dat").and_then(Value::as_array).ok_or_else(|| anyhow!("GREE Cloud dat payload has no dat"))?; + if cols.len() != dat.len() { + bail!("GREE Cloud dat payload column/value count mismatch"); + } + Ok(cols + .iter() + .zip(dat) + .filter_map(|(key, value)| key.as_str().map(|key| (key.to_string(), value.clone()))) + .collect()) +} + +fn apply_cloud_capability_snapshot(device: &mut Device, props: &BTreeMap) { + device.capabilities.temperature_step = if props + .get("HalfTemEn") + .and_then(value_i64) + .is_some_and(|value| value == 1) + { + 0.5 + } else { + 1.0 + }; + device.capabilities.vertical_swing = props.contains_key("SwUpDn"); + device.capabilities.horizontal_swing = props.contains_key("SwingLfRig"); + device.supports_light = Some(props.contains_key("Lig")); + device.supports_quiet = Some(props.contains_key("Quiet")); + device.supports_turbo = Some(props.contains_key("Tur")); + device.supports_air = Some(props.contains_key("Air")); + device.supports_xfan = Some(props.contains_key("Blo")); + device.supports_health = Some(props.contains_key("Health")); + device.supports_sleep = Some(props.contains_key("SwhSlp")); + if props.contains_key("Buzzer_ON_OFF") || props.contains_key("BuzzerCtrl") { + device.supports_buzzer_control = Some(true); + } + device.supports_energy_meter = Some(props.contains_key("ElcAll")); +} + +pub fn apply_cloud_properties(device: &mut Device, props: &BTreeMap) { + if let Some(value) = props.get("Pow").and_then(value_i64) { device.power = value != 0; } + if let Some(value) = props.get("Mod").and_then(value_i64) { if let Some(mode) = wire_to_mode(value) { device.mode = mode.into(); } } + if let Some(value) = props.get("WdSpd").and_then(value_i64) { device.fan_speed = value.clamp(0, 5) as u8; } + if let Some(value) = props.get("SwUpDn").and_then(value_i64) { device.swing_vertical = value != 0; } + if let Some(value) = props.get("SwingLfRig").and_then(value_i64) { device.swing_horizontal = value != 0; } + if let Some(value) = props.get("Quiet").and_then(value_i64) { device.quiet = value != 0; if value > 0 { device.quiet_wire_value = Some(value.clamp(1, 255) as u8); } } + if let Some(value) = props.get("Tur").and_then(value_i64) { device.turbo = value != 0; } + if let Some(value) = props.get("Lig").and_then(value_i64) { device.light = value != 0; } + if let Some(value) = props.get("Air").and_then(value_i64) { device.air = value != 0; } + if let Some(value) = props.get("Blo").and_then(value_i64) { device.xfan = value != 0; } + if let Some(value) = props.get("Health").and_then(value_i64) { device.health = value != 0; } + if let Some(value) = props.get("SwhSlp").and_then(value_i64) { device.sleep = value != 0; } + if let Some(raw) = props.get("TemSen").and_then(value_f64) { + if let Some((temperature, offset)) = decode_indoor_temperature(raw) { + device.current_temperature = Some(temperature); + device.temperature_sensor_offset = Some(offset); + } + } + let outdoor_raw = props + .get("OutEnvTem") + .and_then(value_f64) + .or_else(|| props.get("TemsSenOut").and_then(value_f64)); + if let Some(raw) = outdoor_raw { + if let Some(temperature) = decode_outdoor_temperature(raw, device.temperature_sensor_offset) { + device.outdoor_temperature = Some(temperature); + } + } + if let Some(half_enabled) = props.get("HalfTemEn").and_then(value_i64) { + device.capabilities.temperature_step = if half_enabled == 1 { 0.5 } else { 1.0 }; + } + if props.contains_key("SwUpDn") { device.capabilities.vertical_swing = true; } + if props.contains_key("SwingLfRig") { device.capabilities.horizontal_swing = true; } + if let Some(value) = props.get("SetDeciTem").and_then(value_f64) { + device.target_temperature = value / 10.0; + } else if let Some(value) = props.get("SetTem").and_then(value_f64) { + let half = props.get("TemRec").and_then(value_f64).unwrap_or(0.0); + device.target_temperature = value + if half > 0.0 { 0.5 } else { 0.0 }; + } + if props.contains_key("Lig") { device.supports_light = Some(true); } + if props.contains_key("Quiet") { device.supports_quiet = Some(true); } + if props.contains_key("Tur") { device.supports_turbo = Some(true); } + if props.contains_key("Air") { device.supports_air = Some(true); } + if props.contains_key("Blo") { device.supports_xfan = Some(true); } + if props.contains_key("Health") { device.supports_health = Some(true); } + if props.contains_key("SwhSlp") { device.supports_sleep = Some(true); } + if props.contains_key("Buzzer_ON_OFF") || props.contains_key("BuzzerCtrl") { device.supports_buzzer_control = Some(true); } + if props.contains_key("ElcAll") { device.supports_energy_meter = Some(true); } + if let Some(value) = props.get("ElcAll").and_then(value_f64) { device.total_energy_kwh = Some(value * 0.1); } + if let Some(value) = props.get("CompressorFqy").and_then(value_f64) { device.compressor_frequency_hz = Some(value); } + if let Some(hid) = props.get("hid").and_then(Value::as_str) { + if device.firmware.is_empty() { device.firmware = firmware_from_hid(hid).unwrap_or_default(); } + } +} + +fn parsed_properties(props: &BTreeMap) -> BTreeMap { + let mut parsed = BTreeMap::new(); + if let Some(value) = props.get("Pow").and_then(value_i64) { parsed.insert("power".into(), json!(value != 0)); } + if let Some(value) = props.get("Mod").and_then(value_i64).and_then(wire_to_mode) { parsed.insert("mode".into(), json!(value)); } + let mut sensor_offset = None; + if let Some(raw) = props.get("TemSen").and_then(value_f64) { + if let Some((temperature, offset)) = decode_indoor_temperature(raw) { + sensor_offset = Some(offset); + parsed.insert("indoor_temperature_c".into(), json!(temperature)); + } + } + let outdoor_raw = props + .get("OutEnvTem") + .and_then(value_f64) + .or_else(|| props.get("TemsSenOut").and_then(value_f64)); + if let Some(raw) = outdoor_raw { + if let Some(temperature) = decode_outdoor_temperature(raw, sensor_offset) { + parsed.insert("outdoor_temperature_c".into(), json!(temperature)); + } + } + if let Some(value) = props.get("ElcAll").and_then(value_f64) { parsed.insert("total_energy_kwh".into(), json!(value * 0.1)); } + if let Some(value) = props.get("CompressorFqy").and_then(value_f64) { parsed.insert("compressor_frequency_hz".into(), json!(value)); } + parsed +} + +fn merge_command(target: &mut DeviceCommand, source: &DeviceCommand) { + if source.power.is_some() { target.power = source.power; } + if source.mode.is_some() { target.mode = source.mode.clone(); } + if source.target_temperature.is_some() { target.target_temperature = source.target_temperature; } + if source.fan_speed.is_some() { target.fan_speed = source.fan_speed; } + if source.swing_vertical.is_some() { target.swing_vertical = source.swing_vertical; } + if source.swing_horizontal.is_some() { target.swing_horizontal = source.swing_horizontal; } + if source.quiet.is_some() { target.quiet = source.quiet; } + if source.turbo.is_some() { target.turbo = source.turbo; } + if source.light.is_some() { target.light = source.light; } + if source.air.is_some() { target.air = source.air; } + if source.xfan.is_some() { target.xfan = source.xfan; } + if source.health.is_some() { target.health = source.health; } + if source.sleep.is_some() { target.sleep = source.sleep; } +} + +fn mode_to_wire(mode: &str) -> Result { + match mode { + "auto" => Ok(0), "cool" => Ok(1), "dry" => Ok(2), "fan" => Ok(3), "heat" => Ok(4), + _ => bail!("unsupported GREE Cloud mode: {mode}"), + } +} + +fn decode_indoor_temperature(raw: f64) -> Option<(f64, bool)> { + if raw == 0.0 || !raw.is_finite() { + return None; + } + // GREE TemSen uses a +40 C wire offset on the modules that report values such as + // 64 for 24 C. Older/local-compatible firmware can also report the real value. + let offset = raw > 40.0; + let temperature = if offset { raw - 40.0 } else { raw }; + (-40.0..=80.0).contains(&temperature).then_some((temperature, offset)) +} + +fn decode_outdoor_temperature(raw: f64, sensor_offset: Option) -> Option { + if raw == 0.0 || !raw.is_finite() { + return None; + } + // Follow the same rule already used by the LAN parser: once TemSen proves the +40 + // encoding, apply it to OutEnvTem/TemsSenOut as well. Without TemSen, >50 is the + // conservative outdoor sentinel for the encoded form. + let offset = sensor_offset.unwrap_or(raw > 50.0); + let temperature = if offset { raw - 40.0 } else { raw }; + (-60.0..=80.0).contains(&temperature).then_some(temperature) +} + +fn wire_to_mode(mode: i64) -> Option<&'static str> { + match mode { 0 => Some("auto"), 1 => Some("cool"), 2 => Some("dry"), 3 => Some("fan"), 4 => Some("heat"), _ => None } +} + +fn value_i64(value: &Value) -> Option { + value.as_i64().or_else(|| value.as_u64().and_then(|v| i64::try_from(v).ok())).or_else(|| value.as_f64().map(|v| v as i64)).or_else(|| value.as_str().and_then(|v| v.parse().ok())) +} +fn value_f64(value: &Value) -> Option { + 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().ok())) +} +fn parent_mac_preserve_case(mac: &str) -> String { + let compact = mac.trim().replace([':', '-'], ""); + if compact.len() > 12 && compact.ends_with("00") { + compact[..compact.len() - 2].to_string() + } else { + compact + } +} + +fn cloud_wire_mac(session: &CloudSession, device: &Device) -> String { + let stable = cloud_id(device); + session + .wire_macs + .get(&stable) + .cloned() + // Old databases only contain the normalized ID. GREE's API commonly returns + // lower-case MACs, and MQTT topics are case-sensitive, so lower-case is the + // safest reference-compatible fallback until the next REST refresh succeeds. + .unwrap_or_else(|| stable.to_ascii_lowercase()) +} + +fn cloud_wire_parent(session: &CloudSession, device: &Device) -> String { + parent_mac_preserve_case(&cloud_wire_mac(session, device)) +} + +fn cloud_wire_parent_from_map(wire_macs: &HashMap, device: &Device) -> String { + let stable = cloud_id(device); + let wire = wire_macs + .get(&stable) + .cloned() + .unwrap_or_else(|| stable.to_ascii_lowercase()); + parent_mac_preserve_case(&wire) +} + +fn alternate_wire_mac_case(mac: &str) -> Option { + let compact = mac.trim().replace([':', '-'], ""); + let upper = compact.to_ascii_uppercase(); + if upper != compact { + return Some(upper); + } + let lower = compact.to_ascii_lowercase(); + (lower != compact).then_some(lower) +} + +fn is_legacy_cloud_firmware(firmware: &str) -> bool { + let value = firmware.trim().to_ascii_uppercase(); + value.starts_with("V1.") || value.starts_with("1.") +} + +fn normalize_mac(value: &str) -> String { value.trim().replace([':', '-'], "").to_ascii_uppercase() } +fn cloud_id(device: &Device) -> String { normalize_mac(device.cloud_device_id.as_deref().unwrap_or(&device.mac)) } +fn cloud_parent(device: &Device) -> String { device.cloud_parent_mac.clone().unwrap_or_else(|| parent_mac(&cloud_id(device))) } +fn is_timeout_error(error: &anyhow::Error) -> bool { error.to_string().to_ascii_lowercase().contains("timed out") } +fn sanitize_error(value: &str) -> String { + let lower = value.to_ascii_lowercase(); + if lower.contains("password") || lower.contains("token") || lower.contains("authorization") { "GREE Cloud authentication/transport error".into() } else { value.chars().take(300).collect() } +} +fn firmware_from_hid(hid: &str) -> Option { + let marker = hid.rfind('V')?; + let value = hid.get(marker + 1..)?.strip_suffix(".bin").unwrap_or(&hid[marker + 1..]); + (!value.is_empty()).then(|| value.to_string()) +} + +pub async fn cloud_reconnect_loop(provider: GreeCloudProvider, settings: Arc>, db: crate::db::Db) { + let mut attempt = 0_u32; + loop { + let cloud = settings.read().await.gree_cloud.clone(); + if !cloud.enabled { + attempt = 0; + sleep(Duration::from_secs(15)).await; + continue; + } + if provider.is_connected().await { + attempt = 0; + sleep(Duration::from_secs(10)).await; + continue; + } + let devices = match db.list_devices() { + Ok(items) => items, + Err(err) => { tracing::warn!(error=?err, "cannot list devices for GREE Cloud reconnect"); sleep(Duration::from_secs(10)).await; continue; } + }; + if !devices.iter().any(|d| d.enabled && d.connection_type == ConnectionType::GreeCloud) { + sleep(Duration::from_secs(15)).await; + continue; + } + tracing::info!(attempt, "GREE Cloud MQTT reconnect"); + match provider.ensure_connected(&cloud, &devices).await { + Ok(()) => { + attempt = 0; + let now = Utc::now(); + let mut runtime = settings.write().await; + runtime.gree_cloud.last_successful_contact = Some(now); + if let Err(err) = db.save_runtime_settings(&runtime) { + tracing::warn!(error=?err, "cannot persist GREE Cloud contact timestamp"); + } + } + Err(err) => { + attempt = attempt.saturating_add(1).min(8); + tracing::warn!(error=%sanitize_error(&err.to_string()), attempt, "GREE Cloud MQTT reconnect failed"); + } + } + let base = (1_u64 << attempt.min(6)).min(60); + let jitter = rand::thread_rng().gen_range(0..=base.min(10)); + sleep(Duration::from_secs(base + jitter)).await; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn status_parser_rejects_malformed_column_count() { + assert!(properties_from_payload(&json!({"t":"dat","cols":["Pow"],"dat":[]})).is_err()); + } + + #[test] + fn cloud_temperature_wire_offset_matches_gree_encoding() { + let mut device = Device::simulated_default(); + device.connection_type = ConnectionType::GreeCloud; + device.current_temperature = None; + device.outdoor_temperature = None; + device.temperature_sensor_offset = None; + let props = BTreeMap::from([ + ("TemSen".into(), json!(64)), + ("OutEnvTem".into(), json!(66)), + ]); + apply_cloud_properties(&mut device, &props); + assert_eq!(device.current_temperature, Some(24.0)); + assert_eq!(device.outdoor_temperature, Some(26.0)); + assert_eq!(device.temperature_sensor_offset, Some(true)); + let parsed = parsed_properties(&props); + assert_eq!(parsed.get("indoor_temperature_c"), Some(&json!(24.0))); + assert_eq!(parsed.get("outdoor_temperature_c"), Some(&json!(26.0))); + } + + #[test] + fn cloud_temperature_without_wire_offset_is_preserved() { + let mut device = Device::simulated_default(); + device.connection_type = ConnectionType::GreeCloud; + device.temperature_sensor_offset = None; + let props = BTreeMap::from([ + ("TemSen".into(), json!(23)), + ("OutEnvTem".into(), json!(18)), + ]); + apply_cloud_properties(&mut device, &props); + assert_eq!(device.current_temperature, Some(23.0)); + assert_eq!(device.outdoor_temperature, Some(18.0)); + assert_eq!(device.temperature_sensor_offset, Some(false)); + } + + #[test] + fn cloud_energy_scale_is_tenths_of_kwh() { + let mut device = Device::simulated_default(); + device.connection_type = ConnectionType::GreeCloud; + let props = BTreeMap::from([("ElcAll".into(), json!(1521))]); + apply_cloud_properties(&mut device, &props); + assert_eq!(device.total_energy_kwh, Some(152.1)); + } + + #[test] + fn command_mode_mapping_matches_reference() { + assert_eq!(mode_to_wire("auto").unwrap(), 0); + assert_eq!(mode_to_wire("heat").unwrap(), 4); + assert!(mode_to_wire("unsupported").is_err()); + } + + + #[test] + fn cloud_wire_parent_preserves_rest_mac_case_for_mqtt_topics() { + let mut device = Device::simulated_default(); + device.connection_type = ConnectionType::GreeCloud; + device.cloud_device_id = Some("9424B80C5DB0".into()); + let wire = HashMap::from([ + ("9424B80C5DB0".to_string(), "9424b80c5db0".to_string()), + ]); + assert_eq!(cloud_wire_parent_from_map(&wire, &device), "9424b80c5db0"); + } + + #[test] + fn legacy_cloud_firmware_uses_compatibility_status_profile() { + assert!(is_legacy_cloud_firmware("V1.2.1")); + assert!(is_legacy_cloud_firmware("1.21")); + assert!(!is_legacy_cloud_firmware("V3.4.M")); + } + + #[test] + fn mqtt_wire_case_probe_keeps_mac_bytes_and_changes_only_case() { + assert_eq!( + alternate_wire_mac_case("502cc699c117").as_deref(), + Some("502CC699C117") + ); + assert_eq!( + alternate_wire_mac_case("502CC699C117").as_deref(), + Some("502cc699c117") + ); + } + + #[test] + fn legacy_status_profile_excludes_newer_optional_columns() { + assert!(LEGACY_CLOUD_PROPERTIES.contains(&"Pow")); + assert!(LEGACY_CLOUD_PROPERTIES.contains(&"SetTem")); + assert!(!LEGACY_CLOUD_PROPERTIES.contains(&"HalfTemEn")); + assert!(!LEGACY_CLOUD_PROPERTIES.contains(&"ElcAll")); + assert!(!LEGACY_CLOUD_PROPERTIES.contains(&"CompressorFqy")); + } + + #[test] + fn provider_dispatcher_selects_transport_from_connection_type() { + let local = GreeClient::new( + "test-controller".into(), + None, + None, + Arc::new(AtomicBool::new(false)), + ); + let providers = ProviderDispatcher::new(local, reqwest::Client::new()); + let mut device = Device::simulated_default(); + device.connection_type = ConnectionType::Local; + assert_eq!(providers.provider_name(&device), "local"); + device.connection_type = ConnectionType::GreeCloud; + assert_eq!(providers.provider_name(&device), "gree_cloud"); + } + + #[tokio::test] + async fn cloud_provider_rejects_local_device_before_network_io() { + let provider = GreeCloudProvider::new(reqwest::Client::new()); + let device = Device::simulated_default(); + let error = provider.register_device(&device).await.unwrap_err(); + assert!(error.to_string().contains("Local device")); + } + + #[test] + fn full_cloud_snapshot_can_disable_unreported_capabilities() { + let mut device = Device::simulated_default(); + device.connection_type = ConnectionType::GreeCloud; + let props = BTreeMap::from([ + ("Pow".into(), json!(1)), + ("Lig".into(), json!(0)), + ("HalfTemEn".into(), json!(1)), + ]); + apply_cloud_capability_snapshot(&mut device, &props); + assert_eq!(device.supports_light, Some(true)); + assert_eq!(device.supports_turbo, Some(false)); + assert!(!device.capabilities.vertical_swing); + assert_eq!(device.capabilities.temperature_step, 0.5); + } + + #[test] + fn partial_cloud_push_does_not_clear_known_capabilities() { + let mut device = Device::simulated_default(); + device.connection_type = ConnectionType::GreeCloud; + device.supports_light = Some(true); + device.capabilities.vertical_swing = true; + apply_cloud_properties(&mut device, &BTreeMap::from([("Pow".into(), json!(1))])); + assert_eq!(device.supports_light, Some(true)); + assert!(device.capabilities.vertical_swing); + } + + #[test] + fn cloud_error_response_is_rejected() { + assert!(validate_cloud_response(&json!({"r": 1, "msg": "rejected"})).is_err()); + assert!(validate_cloud_response(&json!({"t": "error", "msg": "bad"})).is_err()); + assert!(validate_cloud_response(&json!({"r": 0})).is_ok()); + } + + #[tokio::test] + async fn cloud_command_order_matches_reference() { + let provider = GreeCloudProvider::new(reqwest::Client::new()); + let mut device = Device::simulated_default(); + device.id = "cloud-test".into(); + device.connection_type = ConnectionType::GreeCloud; + device.cloud_device_id = Some("AABBCCDDEEFF".into()); + device.key = Some("0123456789abcdef".into()); + provider.inner.diagnostics.write().await.insert( + device.id.clone(), + CloudDiagnostics { + raw_device_properties: BTreeMap::from([ + ("HalfTemEn".into(), json!(1)), + ("TemUn".into(), json!(0)), + ]), + ..Default::default() + }, + ); + let command = DeviceCommand { + power: Some(true), + mode: Some("cool".into()), + target_temperature: Some(23.5), + fan_speed: Some(3), + ..Default::default() + }; + let sequence = provider + .build_command_sequence(&device, &command, false) + .await + .unwrap(); + assert_eq!(sequence.first().unwrap().0, vec!["Mod".to_string()]); + assert_eq!(sequence.last().unwrap().0, vec!["Pow".to_string()]); + let temperature = sequence + .iter() + .find(|(opt, _, _)| opt.first().is_some_and(|value| value == "SetTem")) + .unwrap(); + assert_eq!( + temperature.0.iter().map(String::as_str).collect::>(), + vec!["SetTem", "TemRec", "SetDeciTem", "Add0.5"] + ); + assert_eq!(temperature.1[2], json!(235)); + + let sleep_sequence = provider + .build_command_sequence( + &device, + &DeviceCommand { sleep: Some(true), ..Default::default() }, + false, + ) + .await + .unwrap(); + assert_eq!(sleep_sequence.len(), 2); + assert_eq!(sleep_sequence[0].0, vec!["SwhSlp".to_string()]); + assert_eq!(sleep_sequence[1].0, vec!["SlpMod".to_string()]); + + let muted = provider + .build_command_sequence(&device, &command, true) + .await + .unwrap(); + assert!(muted.iter().all(|(opt, values, _)| { + opt.last().is_some_and(|name| name == "Buzzer_ON_OFF") + && values.last() == Some(&json!(1)) + })); + } +} diff --git a/src/queries/entities.rs b/src/queries/entities.rs index e5981d9..5ba9edb 100644 --- a/src/queries/entities.rs +++ b/src/queries/entities.rs @@ -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 = diff --git a/src/queries/schema.rs b/src/queries/schema.rs index 5275003..add69ea 100644 --- a/src/queries/schema.rs +++ b/src/queries/schema.rs @@ -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')); "#; diff --git a/src/state.rs b/src/state.rs index 02e81db..11c158c 100644 --- a/src/state.rs +++ b/src/state.rs @@ -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, } +pub(crate) struct CloudPollGuard { + inflight: Arc>>, + 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>, pub config: Arc, - pub gree: GreeClient, + pub providers: ProviderDispatcher, pub events: broadcast::Sender, pub http: reqwest::Client, pub outdoor_temperature: Arc>>, pub debug_gree_frames: Arc, + pub debug_cloud_requests: Arc, + pub debug_cloud_mqtt: Arc, /// 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, @@ -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>>, + /// 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>>, pub started: Instant, } impl AppState { + pub(crate) fn try_begin_cloud_poll(&self, device_id: &str) -> Option { + 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; diff --git a/web/index.html b/web/index.html index c990d2d..039776f 100644 --- a/web/index.html +++ b/web/index.html @@ -143,12 +143,10 @@
-
LAN network +
Local / GREE Cloud

Devices

-
+

Technical configuration and diagnostics only. Current operating settings are available in Manual control.

@@ -327,6 +325,7 @@ +
@@ -513,6 +512,7 @@ data-i18n="settings.applicationSettings">Application +
@@ -666,6 +666,10 @@ data-i18n="settings.debugOverlay">Show debug window on every page + +
@@ -737,6 +741,39 @@
+
Save changes made in the blocks above.
@@ -1089,6 +1126,15 @@ + +
+
GREE Cloud

Devices on account

+

Devices are loaded from the saved GREE Cloud account. Nothing is added automatically; choose the units you want to register.

+
+
+
+
+
@@ -1149,6 +1195,40 @@
+ +
+ +
+
Cloud details

Device details

+ +
+ +
+
+

Energy

Choose the cumulative energy source used for charts and period totals.

+
+ + +

+
+
+
+
+
+ + +
+
GREE Cloud

Cloud diagnostics

+

Decrypted and sanitized protocol data. Passwords, tokens and cipher keys are never included.

+
+
+
+
+
@@ -1527,6 +1607,10 @@ data-i18n="debug.requests">Requests + +
diff --git a/web/js/bootstrap.js b/web/js/bootstrap.js index 2d74db3..5e15725 100644 --- a/web/js/bootstrap.js +++ b/web/js/bootstrap.js @@ -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), diff --git a/web/js/charts.js b/web/js/charts.js index 493b73a..a9ba980 100644 --- a/web/js/charts.js +++ b/web/js/charts.js @@ -35,6 +35,15 @@ function renderHistoryNavigation() { } else if (app.historyTab === 'devices') { host.innerHTML = ``; 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 => ``).join(''); + host.innerHTML = energyDevices.length + ? `` + : `${esc(tr('energy.noData'))}`; + 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 = ``; 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)); - drawLineChart(canvas, runtime.series, runtime.rows, runtime.options); - renderLegend(document.getElementById(`${id}Legend`), runtime.series); + 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 = `kWh`; + updateChartZoomControls(canvas.id); +} + function renderLegend(host, series) { if (!host) return; const chartId = host.id.replace(/Legend$/, ''); diff --git a/web/js/core.js b/web/js/core.js index a374eaf..e6787c7 100644 --- a/web/js/core.js +++ b/web/js/core.js @@ -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(), diff --git a/web/js/entities.js b/web/js/entities.js index 1908c59..47bc894 100644 --- a/web/js/entities.js +++ b/web/js/entities.js @@ -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 `
${esc(tr('devices.features'))}${esc(tr('devices.noExtraFeatures'))}
`; return `
${esc(tr('devices.features'))}
${features.map(([field, , label]) => ``).join('')}
`; } @@ -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 ? `${esc(device.last_error)}` : ''; + const pending = device.pending_command ? `${esc(tr('common.pending'))}` : ''; + return `

${esc(label)}${detail}${pending}

`; +} + +// 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 `
${esc(tr('devices.manualDeviceDisabled'))}
`; @@ -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 ? `
${esc(tr('devices.manualDisabledZoneTitle'))}

${esc(tr('devices.manualDisabledZoneWarning', { zone: disabledZone.name }))}

` : ''; return `
-

${esc(device.name)}

+

${esc(device.name)}

Local
${warning} @@ -72,7 +98,50 @@ function manualDeviceCard(device) {
`; } +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 ? `
${esc(tr('devices.manualDisabledZoneTitle'))}

${esc(tr('devices.manualDisabledZoneWarning', { zone: disabledZone.name }))}

` : ''; + return `
+
+
+

${esc(device.name)}

GREE Cloud
+ ${manualDeviceStatus(device)} +
+ +
+ ${warning} +
+ +
${Number(device.target_temperature).toFixed(1)}°C
+ +
+
${esc(tr('devices.currentTemperature'))}: ${fmtTemp(device.current_temperature)}${device.outdoor_temperature == null ? '' : ` · ${esc(tr('devices.outdoor'))} ${fmtTemp(device.outdoor_temperature)}`}
+ ${compressorQueuePanel(managedZone)} + ${modes.length ? `
${modes.map(mode => ``).join('')}
` : ''} + ${fans.length ? `
${fans.map(fan => ``).join('')}
` : ''} +
+ ${caps.vertical_swing === false ? '' : ``} + ${device.supports_quiet === false ? '' : ``} + ${device.supports_turbo === false ? '' : ``} +
+ ${deviceFeaturePanel(device)} +
`; +} + +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 ? `
${esc(tr('devices.lastError'))}${esc(device.last_error)}
` : ''; return `
-
${esc(tr('devices.technicalUnit'))}

${esc(device.name)}

${esc(tr(device.online ? 'status.online' : 'status.offline'))} · ${esc(model)}

- +
${esc(tr('devices.technicalUnit'))} · ${esc(deviceTransportLabel(device))}

${esc(device.name)}

${esc(device.connection_type === 'gree_cloud' ? (device.connection_status || 'unknown').replaceAll('_', ' ') : tr(device.online ? 'status.online' : 'status.offline'))} · ${esc(model)}

+ ${device.connection_type === 'gree_cloud' ? `GREE Cloud` : ``}
-
${esc(tr('devices.address'))}${esc(device.ip)}:${esc(device.port)}
+
${esc(tr('devices.address'))}${device.connection_type === 'gree_cloud' ? 'GREE Cloud' : `${esc(device.ip)}:${esc(device.port)}`}
MAC${esc(device.mac)}
CID${esc(cid)}
${esc(tr('devices.protocol'))}${esc(protocol)}
@@ -98,7 +167,43 @@ function technicalDeviceCard(device) {
- ${device.simulated ? '' : ``} + + ${device.simulated || device.connection_type === 'gree_cloud' ? '' : ``} +
•••
+
+
`; +} + +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 ? `
${esc(tr('devices.model'))}${esc(device.model)}
` : ''; + const firmwareCell = device.firmware ? `
${esc(tr('devices.firmware'))}${esc(device.firmware)}
` : ''; + const error = device.last_error ? `
${esc(tr('devices.lastError'))}${esc(device.last_error)}
` : ''; + return `
+
+
${esc(tr('devices.cloudUnit'))}

${esc(device.name)}

${esc(status)} · GREE Cloud

+ GREE Cloud +
+
+
${esc(tr('devices.connection'))}GREE Cloud
+
${esc(tr('devices.transport'))}MQTT / TLS
+
${esc(tr('devices.cloudDeviceId'))}${esc(cloudId)}
+
MAC${esc(device.mac || cloudId)}
+ ${modelCell} + ${firmwareCell} +
${esc(tr('devices.lastSync'))}${esc(lastSync)}
+
${esc(tr('devices.lastResponse'))}${esc(lastSeen)}${esc(responseTime)}
+
${esc(tr('devices.communicationFailures'))}${esc(device.communication_failures ?? 0)}
+
+ ${error} +
+ + +
•••
`; diff --git a/web/js/events.js b/web/js/events.js index 9c55a00..834f617 100644 --- a/web/js/events.js +++ b/web/js/events.js @@ -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; + } +}); diff --git a/web/js/history.js b/web/js/history.js index 61706f4..5e846ae 100644 --- a/web/js/history.js +++ b/web/js/history.js @@ -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]) => `
${esc(label)}${Number(value || 0).toLocaleString(locale(), { maximumFractionDigits: 3 })} kWh${esc(data.source === 'gree_cloud' ? 'GREE Cloud' : 'Home Assistant')}
`).join(''); +} + +function renderEnergyHistory() { + const host = $('#historyCharts'); if (!host) return; + const data = app.historyEnergy; + renderEnergyHistorySummary(); + if (!data || data.source === 'none') { + host.innerHTML = `
${esc(tr('energy.noData'))}
`; + return; + } + host.innerHTML = historyChartMarkup('energyConsumptionChart', tr('energy.title'), tr('energy.chartHint')); + drawEnergyBarChart($('#energyConsumptionChart'), data.buckets || [], { height: 360 }); +} diff --git a/web/js/navigation.js b/web/js/navigation.js index 19d22b7..2177fa1 100644 --- a/web/js/navigation.js +++ b/web/js/navigation.js @@ -213,13 +213,62 @@ 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); + const command = typeof commandOrFactory === 'function' ? commandOrFactory(current) : commandOrFactory; + 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]; + } + } + + // 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 current = app.devices.find(device => device.id === id); - const command = typeof commandOrFactory === 'function' ? commandOrFactory(current) : commandOrFactory; const body = disabledZone ? { ...command, manual_override: true } : command; return api(`/api/devices/${encodeURIComponent(id)}/command`, { method: 'POST', body }); }); @@ -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' + ? `
${esc(tr('devices.connection'))}GREE Cloud
${esc(tr('devices.transport'))}MQTT / TLS
${esc(tr('devices.cloudDeviceId'))}${esc(device.cloud_device_id || device.mac || '—')}
MAC${esc(device.mac || '—')}
` + : `
${esc(tr('devices.connection'))}Local
${esc(tr('devices.address'))}${esc(device.ip || '—')}:${esc(device.port || 7000)}
MAC${esc(device.mac || '—')}
`; + 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 = ''; + 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); + } +} diff --git a/web/js/realtime.js b/web/js/realtime.js index c213ead..23781d0 100644 --- a/web/js/realtime.js +++ b/web/js/realtime.js @@ -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(); } diff --git a/web/js/router.js b/web/js/router.js index 069e276..dccc62a 100644 --- a/web/js/router.js +++ b/web/js/router.js @@ -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; diff --git a/web/js/settings-ui.js b/web/js/settings-ui.js index 71c9dd2..e970fdb 100644 --- a/web/js/settings-ui.js +++ b/web/js/settings-ui.js @@ -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 `
${esc(line.source)}${esc(line.kind)}: ${esc(line.message || '')}${esc(details)}
`; - }).join('') : `
${esc(tr(app.debugFilter === 'gree' ? 'debug.emptyGree' : app.debugFilter === 'requests' ? 'debug.emptyRequests' : 'debug.empty'))}
`; + }).join('') : `
${esc(tr(app.debugFilter === 'gree' ? 'debug.emptyGree' : app.debugFilter === 'requests' ? 'debug.emptyRequests' : app.debugFilter === 'cloud' ? 'debug.emptyCloud' : app.debugFilter === 'mqtt' ? 'debug.emptyMqtt' : 'debug.empty'))}
`; 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'))); + } +} + diff --git a/web/js/settings.js b/web/js/settings.js index ab9d882..0a9dad9 100644 --- a/web/js/settings.js +++ b/web/js/settings.js @@ -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 => ` +
+ ${esc(device.name || 'GREE')}${esc(device.model || 'GREE')} · ${esc(device.mac || device.id)} · ${esc(tr(device.online ? 'status.online' : 'status.offline'))} + +
`).join('') : `
${esc(tr('devices.cloudDiscoveryEmpty'))}${esc(tr('devices.cloudDiscoveryEmptyHint'))}
`; +} + +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; } +}); diff --git a/web/styles.css b/web/styles.css index 3dd815d..22eb177 100644 --- a/web/styles.css +++ b/web/styles.css @@ -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; + } +}