diff --git a/Cargo.lock b/Cargo.lock index 4c74808..b8c2b52 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -700,7 +700,7 @@ dependencies = [ [[package]] name = "gree-controller" -version = "0.14.0" +version = "0.14.1" dependencies = [ "aes", "aes-gcm", diff --git a/Cargo.toml b/Cargo.toml index a8bd343..e72d143 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "gree-controller" -version = "0.14.0" +version = "0.14.1" edition = "2021" authors = ["GREE Controller contributors"] description = "Standalone local GREE HVAC controller with Web UI, SQLite and Home Assistant sensor support" diff --git a/FILE_MANIFEST.sha256 b/FILE_MANIFEST.sha256 index 99c81ce..60b2961 100644 --- a/FILE_MANIFEST.sha256 +++ b/FILE_MANIFEST.sha256 @@ -1,18 +1,14 @@ d67af429e4da9ce08e9d2f2a8472849ffbd70d135b1c5da535a076026794d04c ./.env.example a4ec3874a2e3ab1bad28fb40bb620f7b01f64d01ad9b699306bf70ada31227db ./.gitignore -468abc4e9e6d71859f26cfe83226b971e11c4d990746e1da1ef93c9ede24c405 ./Cargo.lock -87a90bab477bda5fdb067983c1bd4ddc4189dc34c64e2f280488e54dfdf50032 ./Cargo.toml -97681dd3c632c841c2e28a9272fffe461fc778cab8de7e856abebf8c38770ff4 ./G+_integration_wip.md +1baa24e6934a05f48ea7f289ce5f8804fc0981236a2b6c468539a052c0d4e583 ./Cargo.lock +f3204447421f334110e1a626ec29fdd8ee88bb2b9d1e9e1a391c2720c9af7de9 ./Cargo.toml 19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE -818572366846c59e8915142e626e6607d0ce11d3dc5690f345b78c31c8107de6 ./README.md -140fe98aa2ebce67d89ce47b5885f9364c26dfa557e748ab6b5bc62f269e035d ./RELEASE_VALIDATION_0.14.0.md -13b222296d0c078ace0440c1336b3d7d1c98d0d9508d06fbd28b46f505028bf5 ./SOURCE_MANIFEST.sha256 -0d883bc1ff1b4c9bfe045d4b16ae46f6e1aab773072cf233bf9c25750951e971 ./TESTING_0.14.0.md -70852f174e604a552745d5dcf88e9cef7387fc899264417f1db278eacc796def ./TESTING_0.14.0_CLOUD_BETA.md +a4f7dc180e422d6de9d78b1235206ebc3b0436d6041730df8ca065532c36a939 ./README.md +b27097a57e62a3096b133899dea455a68aa04c0fa428f6228d08615e1b11ca29 ./SOURCE_MANIFEST.sha256 bb549919b96b0413152dd905aec8640bb53301f07ec7d0b1be48ccbd37936fef ./build.rs -df6c518cbc7bb0155b4b0e5b856160627f9d1f63195b48c36bc54dc90f601767 ./docs/API.md +50a46eac5dfcdc80c5db068f0eb1ed7d696a2b6070e2ddff808f2bb2a33a913b ./docs/API.md 2e1e18fd8167dabfe2469c26e85cce62486c7cb6a502c63c6f6b0cd74d5885e0 ./docs/FLOW.md -0416652c4421801675864f8f410fdbac7dd315b2f29df366d94ace6e49154eea ./docs/openapi.json +919d54a6825385fdb4e9b8f07db756640d512b2c21d2d33f407f850680285ed0 ./docs/openapi.json c65aa543d2e99f8f7d52736307b07ebd182597d80124661d53a16bb23f6f1118 ./ha-addon/.env.example a18d2460340264d34c267fa704ee51a42e59bcc1783ee4c0dbb2a8ef42fa0f16 ./ha-addon/Dockerfile 0417c88a990c72aecf071fba64d5d9da65dcda4d396cd7565477f7a4532c8c5e ./ha-addon/README.md @@ -26,7 +22,7 @@ b7f0873109c52be9d7f09bea3dffc416103c50085e1f0680d11661a969479898 ./ha-addon/hom 41a8958a6fe6d10e5679c95ac34832583a2a15ecfd67d09b4e094a83d06f5c47 ./ha-addon/home-assistant/custom_components/gree_controller/coordinator.py 5a96fe8f5c035c34f1339370270cd078056202d09e236dec75735be11de92a7d ./ha-addon/home-assistant/custom_components/gree_controller/entity_map.py c4fb75c246db651087900ebfc2291ff41ac87652cd6194fc0b776b0005c1cbcf ./ha-addon/home-assistant/custom_components/gree_controller/icon.png -e3da92df75e4361d03cddcb9e5ad228f5a9a14c85ee8d816ae7e165b4aeb545e ./ha-addon/home-assistant/custom_components/gree_controller/manifest.json +5a7a8cf9545c9346d9bcc92e16265fa2ef63567fa28abe153a2e361cc279c082 ./ha-addon/home-assistant/custom_components/gree_controller/manifest.json c52a484b671ce738ecc00228a1db19a5d703f69ab27530c2896a5c2ae3b4a96f ./ha-addon/home-assistant/custom_components/gree_controller/number.py 39c4309001b75abb56234f05662bc06e077054986876f1927937edbce528ec95 ./ha-addon/home-assistant/custom_components/gree_controller/select.py cca65482e36d48035aca178121a378fe7d578d600acff267ae81a6399c0da653 ./ha-addon/home-assistant/custom_components/gree_controller/sensor.py @@ -34,10 +30,10 @@ cca65482e36d48035aca178121a378fe7d578d600acff267ae81a6399c0da653 ./ha-addon/hom 6bddb7b4620021ecd2099a86a77ef5c7f2c2dcd3d07d5db4e7b4c4ce6d3e8c03 ./ha-addon/home-assistant/custom_components/gree_controller/translations/en.json 13f30e2dcdcedbd1b6c3f99c2335e0487108fd72c8e86922368b84f2fa2038ae ./ha-addon/home-assistant/custom_components/gree_controller/translations/pl.json c5fc5c87273d82d2834a0a5a365c821a416413ed509613441e24e4dc3507c4d2 ./ha-addon/home-assistant/generated/gree_controller_entities.example.json -d9135c678e6a30d7d31b49683b052d091c6cc0c8db2a16cabeba89285bdb9b20 ./ha-addon/repository/gree-controller/CHANGELOG.md +131509e7dfefcca4ad61a3a060bc0a33ed2f731141496ba99a3cb81a563399b1 ./ha-addon/repository/gree-controller/CHANGELOG.md 3c06bd11671452ea6472afd5543fb91a39aba73a1d2d95c7762c04f94ab64030 ./ha-addon/repository/gree-controller/DOCS.md c3db302f38d26dc9f8caf37a1a8a07a1334cc07ae301d452f2940a5ec5915719 ./ha-addon/repository/gree-controller/README.md -4c1ab41a2e01098b6ad549f187b47119e608d3cc752c38cf334286fae34eacd2 ./ha-addon/repository/gree-controller/config.yaml +09e38d1d821a6c318152e5a023954310e9f361985cfddc456abfcc0b88d79c62 ./ha-addon/repository/gree-controller/config.yaml 3aae6cdae4c3aaab7786a9575e7093b7e2c5b69288167075f4b7475611691a6b ./ha-addon/repository/gree-controller/topologia-ha-vlan-gree-pl.png 0d0b42a7639b128946eec379175436fa1f150294f39d2dfd169077bfa87fd14d ./ha-addon/repository/gree-controller/topology-ha-vlan-gree-en.png d13e4c2b993d209078342c0c86d852b300692089b377aa044f811308dd188a1e ./ha-addon/repository/gree-controller/translations/en.yaml @@ -47,8 +43,8 @@ c123b190219556382f0e7f3058056fb233f6261f257a01eceb96a2c0bbfa12f3 ./ha-addon/run 081e27b6e43070d44f865335c30a42a5a29668eaa6a5ea5ab6967ac4447b8e06 ./ha-addon/sync-repository.sh f9e31de3d109ead32bf531c074d46a087df398d62488caee0b46b5e0a1c027f7 ./ha_addon.md 253a0bc912786e67ea7fc92a64e4a510ad973bec343a88ccfb1f28fca3e8cf01 ./lang/README.md -ca747385adf261ab4ce7d0fae51ec326d8cfffffe20b0c48ce4f6d9d1a419874 ./lang/en.json -0364575c2ba26f1e988f39ec31b9b29c9678a58e6c5ab519cd07509e9ebcee0c ./lang/pl.json +301df7a84ce546db1c2e25da841aa0fc9696435d7bb4db358e3fb082a723afd9 ./lang/en.json +c01e081782251122fec4fb50500a5f3ee4d865a2213af89cc77f3f3661606dbd ./lang/pl.json d8459024f04ca514bd8e9d6bd3af872fb942fd85a7cdd5583f4a9d28aab6faba ./make_zip.py b14233a8987e53bbbdd6770386ba10fa166ec25c1e098275c173c544b37846fc ./presets/bedroom_window_night.json 1960841119c0b673fb2f03621b32237552fd828045304cd221401214b997c910 ./presets/device_resilience.json @@ -89,7 +85,7 @@ fdcd9a5055d08037278b842e7ab69265345c5811f0a06867136c511d140bb191 ./presets/wind 804f22123cd3e8db0fac791826c8dd9f758fb866c8e3b5655fb6d25d259dccf1 ./presets/workday_comfort.json 01952aa92b217f8eae2493b88870e2dec595100cd15c4d561ff11ae2b936c46f ./regenerate-sha.sh c64b1c6deeb24f20af662bfce7fa3683703914987241107ce664fa2f6d1a9dcb ./scripts/README.md -ea99c1ea4bfc63d7734f4a3b57f90501236879995e66a630f1ee57397af0d0ce ./scripts/api_dev_test.py +3461b89eff40737f5f250b50bcccadb882bb0b5afbd96542d9d67184142191f8 ./scripts/api_dev_test.py 5bc736c7bc76ca80aaa406bb171d2aa91baf4c3aa8695dce0e09b888b6ab3146 ./scripts/common.sh 6403786610ee6d2f628193c25aee0dd058d62e904aa1a31d5f62fdaae0e94b4f ./scripts/configure-gree-network.sh 1d6e14e26e49aa9d3527f30a23668bf8d9c48b67e6628ef686c3155c012155de ./scripts/dev.sh @@ -102,18 +98,19 @@ e00d211e3885e30d7fed1e43b44e6fdad40a67019060156c0641816a93e3365f ./scripts/netw 81345b6a0b51736bdbc98fd23199b62e4c721b4e7437e02dab7ea79b97dff29a ./scripts/service.sh 472db31959be29ba99247f7e9cc07a44bc090c538cd654ad8d16e094726037a3 ./scripts/smoke.sh b50782b3742dfbf8a319c60571c968e93fdf8547db747c759edcffae68cb98bf ./scripts/update.sh -98c6dd9a5f714257957e831b3d02cbb08de1cdc30dc4a98a64b7c1f5523f76d2 ./src/api.rs +e89b3503b838670f3213e696395024e74d91c718bc67a4cb295077e944ea4c6c ./src/api.rs 3dfd1e053cbfa0d684dd88cf99b244a96a36e2ff23d2a0715b8327ab62258254 ./src/api/assets.rs 05c255919f7b3d81188fc5f1410be6a0037471dfc44e93bffb009f29a1e6a3fc ./src/api/auth.rs 9e4fc675306e111ed3db7af9822e2925d88109c31b9e309851665a975c0bd83c ./src/api/automations.rs -4bc5a8993231ea48805d93dd888b15ff0c03c0a9dd12b44866a1f7a11a12f718 ./src/api/configuration.rs +be865a0be2167131a3778d7ceb825d9a65991a7c063fffadbe7188eac459d078 ./src/api/configuration.rs 19bc141286a336ad67e99079496eb0ae5126f986ab28be62b5f97758e5f6969c ./src/api/debug_tokens.rs +32ade46ec45d6faf7852242b9f5ba1be6eacbacbeb33d2deabfab59bf1610ce0 ./src/api/device_groups.rs fcbb3da0538aab451f71810f829cc248d6bb67e586dc418d71a572980583823d ./src/api/devices.rs 4b5b784670ae761a6848b9a33fe3fb391bebd39d65e87936907428711e96ca44 ./src/api/events.rs 44b74965f3256c2972d6b3cd384a63c68e3b422f414a0902c085bc92e17369bf ./src/api/flows.rs 2cfee7029f065e81628a8942e3dad00a03c3a5cf4f8492d07f12b75d05c07359 ./src/api/gree_cloud.rs b61b0b4f196c5e72e47cf06ca0c14df3cf8eedbdef0242a9d2af33623663489f ./src/api/groups.rs -9f41c01c3be646f0e5853363f1abea7fcf45d4bd011a696aec5e7e80e493d2f9 ./src/api/history.rs +63733cf1fd70b9c2cb1bde3c9304365e8940af4a7524bfc82b47444458a56f4a ./src/api/history.rs e8edfbb725d6de650442444b0a929ad2be0bd2c2d7d227d2b3fdd99d05c94f94 ./src/api/house.rs 27e85400ccbdb0adcb8f5c1624e7187e9b649237cfb97349fcf1c0d78300540d ./src/api/integrations.rs 6bfbe5135d4f93bd8d19a707877b53ed2029fd8490b4b1954a7520888fb1d664 ./src/api/middleware.rs @@ -121,33 +118,33 @@ e8edfbb725d6de650442444b0a929ad2be0bd2c2d7d227d2b3fdd99d05c94f94 ./src/api/hous 744ce18aa9e0227c6cdfb6980063d0d738d74c5865d45c20fd20026d4742bb3e ./src/api/public_settings.rs 5b29f2e5aa2c422c5829ef89c7b59cf753883aa7f60661c95ab0f95cf676fa3f ./src/api/schedules.rs e69df8a5f612cd37d25fbf9f8e824c5a7d655ac5a6376ec3b31addf1fe8597e2 ./src/api/settings.rs -4a02639ec2499a98f11afcc6ddd22f35f07189ab47c1e1b7a93cc1b76d674d35 ./src/api/system.rs +264f884e2fdef24303d441ce9cfe11190ed8f2bba8ca5782c1d13c2ee30e6a7f ./src/api/system.rs 9118a5a3a381b010e22d646498ff56d2e05f51656789682cc4371b09c384aa98 ./src/api/websocket.rs 455c7238c9788d378e909a4fbc94d5390b67edd92e127f48b4b913ddcbe82fb0 ./src/api/zones.rs f4feaff281163eda543bf094d0fe9158dc6662be5f76f39c81570da7ed0355e7 ./src/config.rs -d4afd8e9f47a68e32bcb12f6b599e44dc2d72a7a4d7422c647fca55b8c94b064 ./src/db.rs -6c278fc5728124ab2f53ac7789a733942a0462f397144a231cc878c8cfcf7426 ./src/db/climate.rs -4b3eda9202efcf70296ae295aeb6027e5f59ff97c6c544acc0c8a221d961bd90 ./src/db/configuration.rs -4e577dda5e248b927d29fe8978013be565b69ca4212fe31a1f3334eae1b4a694 ./src/db/core_devices.rs +d067e3048803bb1eeef1892cbae5bf719872daa0c5ccc8575affcc37c6615965 ./src/db.rs +fdc9d019097becc5c36a0560caa1619aa35d802c0d208902b3bfecbe1643e3e8 ./src/db/climate.rs +e5d2027d40a99e61c2c572499af617a92de9e554f8916f3ea6f6731d1d6b09b4 ./src/db/configuration.rs +0a4b4dbfc582d1c8ee942325ac16aa73d1e64555904ec2f6896f4b552bd5e731 ./src/db/core_devices.rs 4c20289c08b0cb9c3c4324d9dbc6a47c7dd953aff6fd1746f04b6024043d3bc5 ./src/db/device_history.rs f99de2230082f17eafaf03f5361bb67419205a23d64f22480c996f9c32c55ac5 ./src/db/energy_history.rs 88cb4b7ff61a8a558949faa7157d4bc2c45facb951c57240412c4c9d3befdb86 ./src/db/events_tokens.rs bb5048ee60053f39ce3c1203dc1f24c7047310538d2357f105a052c016d21ec4 ./src/db/flows.rs 5f4b567c6bc866d0674b3b0c66f4aeccf561774623f575b872ff25bf015b91c2 ./src/db/ha_history.rs -c0a7fa3eac198737d7261ad00c542a614e6d7291f6581af8e7f7aef9dec1a049 ./src/db/schedules_automations.rs +d6b9b70a04c774173c97f7426487d5e2932d7713e12ae3fb7f64cf2b4e0b3c10 ./src/db/schedules_automations.rs a76938b2e49719b97f8abf40a5cce03df6740ce2e8ac138b179c761619b32ce6 ./src/db/tests.rs f90fbb14f29a20b799b3856bdaa22812a9f0d9dfa46693f1341c160dccda8a97 ./src/db/zone_history.rs -aef5601b127fc1dc8a5bf993027ba597b3c019dd8ced6dfe1eeb62ff0cf578a9 ./src/engine.rs +3c97e04e93d14f37d39ee9f8f53257101517a3d90a0dfeba81ca3dcd5e39ff31 ./src/engine.rs 6046bdbc9c02ac74f47068229c59af69abc8bfa46cfeff6fe7ba573b493e128c ./src/engine/automations.rs 0bf7fe7d28b4749e53ec25d71effbb78bc8f70c716714f838a4a2d8f20627d63 ./src/engine/commands.rs 925135985f640ba9ae0a6109d908187b659341a91451e91d27e533adfd610b76 ./src/engine/control_plan.rs 7b48a33343d519a2ff2d81e9d757eb56a0c1beacfc0b7b8ef61f49c2ad363f9a ./src/engine/deadlines.rs -8d63674f7f0923943df0b2d37f1bae839b25697afe2b34d610849f10dde9f2e1 ./src/engine/energy.rs +9a51cc46b8befb992d7f7dd3dbe5025cd739755a684ed36013be78eaaf1a1743 ./src/engine/energy.rs e350aa63f3bb14ca887eaaa7555edbeae00fb584cbcaf552cc972f0c8918f5d8 ./src/engine/groups.rs -f0c8f3384b6174771aeb5640ee1eec87f3d6d9ff79a4fbd6a35554a4eec00cfa ./src/engine/history.rs +0e78e8d1c31503b313926af025281714a6e22a23f46af081b3da8e94a2d7315a ./src/engine/history.rs 41b7045877771b7c352328998cb45ce2433e7f9631fad3eb1a882ab4897dc31f ./src/engine/local_thermostat.rs 99527adab9fea1caede9ebb24faa6a63d5cef4810c64c06306b571cf5cbbb0da ./src/engine/ownership.rs -7f975e13845aeeef3362dc815206e80a04abbd2114904a4bd0ed4538c2cb6372 ./src/engine/polling.rs +1a29b7fd8de77f0a5e5964ca05e41cc5ea78855c1ab961750d40461faec305e5 ./src/engine/polling.rs d13497acd807a74d707e85046b87fb010e13428758336f736df4a30a5ef0b32c ./src/engine/runtime.rs c1e8de5392681ba1255e3e2d871a6ee860fb8ca987a0a8f973d9ded3977a68fb ./src/engine/schedules.rs b0189f28d0f337dfd3ce0934da7a565e4a5535afc1c242214c26ed584b3b7840 ./src/engine/targets.rs @@ -165,9 +162,9 @@ da4068295f37c23222bb13bebac022d88ba7729604a49ad25438c55e45458a49 ./src/influxdb 059346de38c710fadf301b467c0eb45dcc2f764561b868a155c2e089a5027422 ./src/main.rs 23688a5cb68efded89471ce959328d27adff370d451bd9f378a283ab51437e76 ./src/models.rs 920e028d96a14cca9f74a964bb296e68a109cfead314d3164c180201e606d151 ./src/models/automation.rs -60cac63e6cb41d835e35cc654363f6ffd4386a79f562df0e656b69034acfbac2 ./src/models/control_plan.rs +fc5f2802a073a175a8985e56bcb87eb1ff481226d5b4eeabad73dbba1839fc45 ./src/models/control_plan.rs f1bd2ae1acdc342f9310d01502f478f74c43a7e3b1d284ca55f97900a151b7cd ./src/models/defaults.rs -edcd031427ca903ef640e0c6b0598e28b22dcf9a0c045d13033f4395955f8ffe ./src/models/device.rs +5e212a072bf74ef32a186b172404844b02a92363d216167f814056e1f2a9a7a8 ./src/models/device.rs aeab5b5acf35d38a54fd1d82e964fe56635ade69c0149ad423df317095d01555 ./src/models/flow.rs b10abd201d84f88bf6d71e6ed24a47946b67ef9de8dd0cda0608eeb663b7b417 ./src/models/history.rs 14e053b0dbea9ed7e88e8affaa595fa126ff833c18696fa695e923488ec40d70 ./src/models/integrations.rs @@ -190,37 +187,37 @@ c09c931fa8d512a8883fb14f3142be694af3c51290e023d25c7c972e156e4bd2 ./src/protocol 49dcca1e4a4ad858b312386e3b729f36da691916d8fe62ad682ddb86d40695b7 ./src/protocol/gree_cloud.rs cd1e880ea947150300f798fa313eb36c57325856c3ceab862e53a7b597b477b1 ./src/protocol/gree_cloud_mqtt.rs 6258ee3888a5f9a4f8bb6cfbbac97f2621de07f9da6bcb2fa596d452f47f2e49 ./src/protocol/mod.rs -df33269fc1570ffd87a0ab47ce0631bdf45d63e219c04800b333657d8382e6fc ./src/provider.rs +858932d98576e1863bb9dfc5a43a0fa72db13d84815595fc998d007571f48a2d ./src/provider.rs 830621fa75265da7288b8135704c0ce27e0687f87d55abf3cdbe40a6e2f3d2b1 ./src/queries.rs 7fac65a9dcbcecee09816f5447c7792dc94f736b1efb66ac85629db75f6280be ./src/queries/device_history.rs -917e13be9db10ea629bd99fe6c62a869c31609780702cc6a44e787b78a62e03a ./src/queries/entities.rs +580917b91441fe9b3276cf0cfc31536ba7954b6a86f4080347b93f9367a5b1ac ./src/queries/entities.rs 2c5fba462b72158b04dbaaa53c8536263f8d72a1338a6c4ff06cb28a36f6ae27 ./src/queries/ha_history.rs -3112452367b68aeda0a5e3143b1a51ce62e3159859fb41e92c35642a3cd72322 ./src/queries/maintenance.rs -776ed66e363289e188b04a194caaa476ccdf7045b61485792ffe3973caf0bf4d ./src/queries/schema.rs +d051ea7cef7fc733d5fcb0a9d496c419be7ddbe799fc93842b9389d220837747 ./src/queries/maintenance.rs +84c3c149cde1728b20ee5723e02d7a7cbb6db9cee3b71a3d4709d6aaa4230e3e ./src/queries/schema.rs f13f1be3d5789539eba3fc1b59c14835f8c681eca7634e118fa3763a53aeee2b ./src/queries/zone_history.rs 7e7037a059b585c5709367b9431a225789e7039796e3ed19fc132776ac234099 ./src/state.rs b92a6cb158b494fe145b43c7641e65f6fafff47201d7d76edbec2cfd8b94835c ./systemd/gree-controller.service 544a31cb2e5374227026afd6abd0d910381f3fca1638154ebd74e3c3d96a35d1 ./web/404.html e98bdd7204349cce1ec6f57283509697af0bbc72280622a6c3efa6fed242db4f ./web/favicon.svg -d48f4adab24f6524915a179b58abdc64659195ac6d4236e55a48a4eac0eda402 ./web/index.html +6cd0fa21dcc1b5170c4375d7fb70b72fc753f6ac80fbd1f6a91ba0e52ccca7e4 ./web/index.html 9ba3f6fa05b72aa989139b2a909982571b2a02055052e4c40104f1e81a9aa7eb ./web/js/README.md -06c8ab1c7e5c0e57ada4e2530d69a379a037678412e1e52268c2aaf8d17b2243 ./web/js/bootstrap.js -5dae06e1193685b7dec29234aa1ae0a29bc6f91cf9fa42370a7fd0d6fdef4e32 ./web/js/charts.js -87598f48469a6dd17e526420d552652c9405632b285d566691b010b59e99043e ./web/js/core.js +dfea965a2093f9f85744b50993c867118afc7a65d2c5d3abd975d4b6f4acc6f2 ./web/js/bootstrap.js +761ad87ed20a819d8d87bc260bcd7f5b39f19582c346a69c8b717c94e7e94302 ./web/js/charts.js +e6c459f92f18d72f11ffaead5caede0b9c8e6b6a1c9cf1a6e97e6b07d5425ab5 ./web/js/core.js d880ffdac6aa07cd10d79fbacf12f87eb4c69b48a80564bbfdd0248abe8884d5 ./web/js/dashboard.js -fcaa6b8d863bc93bc9b7350329d261b2fc98877eb5c9a323d66aa809dd4ef16b ./web/js/entities.js -dada07699ba93efee918e7b1f90f3a326a381fe4c85a63d2811448a048878258 ./web/js/events.js +83189a47d114e7b911c9cbf368bf80232a7a481d416297db5fc451c12bb710c7 ./web/js/entities.js +1ff265beab8eb15ced854b5da260fd3952add3acc6b290ea73c76b1a54853165 ./web/js/events.js 2e822a00b5a6941b6a251eaf2c54ca95a53399b5d1056f3d8240e11f82d6065f ./web/js/flows.js -cf1ebf3b9d666f309a1add070b4da86be86d990b02c8d6474f6d121c6d332746 ./web/js/forms.js -7d8c2aedcf97196f081367de0933af13c2bc2e531d59e7464760b6d64c895004 ./web/js/history.js +9e2209d41df7624c29741f03f12ed8e83b47cee2891c5a80cea8ca1bdacf1d5a ./web/js/forms.js +6d2c2fc497f0ea4e3ef4a35e6518c9e4be680ace9100ed09a327f9dbe4261d0b ./web/js/history.js 0dc879c0056251618633c9efd188fc1593eeae6965ad1bc993132dea6e171b44 ./web/js/main.js -5f5ae7bde7c9c0009c6789343f78e77a43bd8aa756f10422320c932388a6e730 ./web/js/navigation.js -ab41961830fd7872023f4d7952487fcb8c00289da7780599072864d089c6d981 ./web/js/realtime.js -cdcd55741f5c468127876ae8d03bdb3f5286b7f4f400591599ef7f0de4b1bc34 ./web/js/router.js -fb9830248ad69eb1d85c94b0f7136e189a177811c93cc37656645668a1c95c39 ./web/js/settings-ui.js -4fcfcc09a6a9a1695d37fbe37c4645377006de58f50406a4722da927b389ed86 ./web/js/settings.js +341c4bc441782bf60bd92c899154ca565ac1ba884a6f3a1ea89aae58f5d0322f ./web/js/navigation.js +d105fb2a3b4000a9f22ed23c7b030c1018fee4d2a78dba3ad96b3d9d95689f2a ./web/js/realtime.js +abdedaca20ecad50ae2b5c7f6081c80a142112bd867cda19949d5253e609f1b4 ./web/js/router.js +3426ec31011173172199bff7deacbef1d5eb0121012b5ea0e53b7a72a674a853 ./web/js/settings-ui.js +13806217b2312dff3bd6cc2b16f75d9bdffc7a587215ae9f862706cd7699673f ./web/js/settings.js 3815fea7ae1694568c2f249bc8460e0563ced6bde89214f74f21b41fe8cad9a6 ./web/lang-init.js 6b653e4fe2d4db4ffae6ff8f39b8ce57a10a990dc0000ee4aae51d949afadd52 ./web/manifest.webmanifest -48d03c265a597af4fc11ae8103eb7b6cb3e5932307783f5287c67781f702b800 ./web/styles.css +10a47e3645316103b8ad85a8635ec6305da9717226568e76db58e2d77e894e8f ./web/styles.css fefdf12cbbb0a5d8a2cb89275cae28455aa42aaf179542305cc8f5e0f4825898 ./web/sw.js d505d793ce7cc9485b45b78bba1c0d51887adc7451ab59a42702946e5b991382 ./web/theme-init.js diff --git a/README.md b/README.md index bf173f6..45ba4aa 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,9 @@ Self-hosted controller for GREE-compatible air conditioners with a local Web UI, thermostat zones, schedules, Home Assistant integration, history, notifications and a documented HTTP/WebSocket API. -**Version: 0.14.0** +**Version: 0.14.1** -Version 0.14.0 keeps the existing LAN/UDP 7000 transport unchanged and adds GREE Cloud as a separate first-class provider with encrypted REST discovery, persistent MQTT/TLS synchronization, Cloud commands, per-device capabilities, diagnostics and energy history. Local and Cloud entries are deliberately independent, even when they represent the same physical MAC. +Version 0.14.1 builds on the GREE Cloud and energy support from 0.14.0 with split/multisplit installation groups, shared energy and outdoor-temperature sources, multi-series energy comparison, cleaner Cloud status/alerts and safer MQTT cleanup after device removal. Local and Cloud entries remain deliberately independent, even when they represent the same physical MAC. > [Full API reference](docs/API.md) — authentication, every endpoint, request bodies, response models, WebSocket events and examples. diff --git a/SOURCE_MANIFEST.sha256 b/SOURCE_MANIFEST.sha256 index 0440479..7cec844 100644 --- a/SOURCE_MANIFEST.sha256 +++ b/SOURCE_MANIFEST.sha256 @@ -1,17 +1,13 @@ d67af429e4da9ce08e9d2f2a8472849ffbd70d135b1c5da535a076026794d04c ./.env.example a4ec3874a2e3ab1bad28fb40bb620f7b01f64d01ad9b699306bf70ada31227db ./.gitignore -468abc4e9e6d71859f26cfe83226b971e11c4d990746e1da1ef93c9ede24c405 ./Cargo.lock -87a90bab477bda5fdb067983c1bd4ddc4189dc34c64e2f280488e54dfdf50032 ./Cargo.toml -97681dd3c632c841c2e28a9272fffe461fc778cab8de7e856abebf8c38770ff4 ./G+_integration_wip.md +1baa24e6934a05f48ea7f289ce5f8804fc0981236a2b6c468539a052c0d4e583 ./Cargo.lock +f3204447421f334110e1a626ec29fdd8ee88bb2b9d1e9e1a391c2720c9af7de9 ./Cargo.toml 19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE -818572366846c59e8915142e626e6607d0ce11d3dc5690f345b78c31c8107de6 ./README.md -140fe98aa2ebce67d89ce47b5885f9364c26dfa557e748ab6b5bc62f269e035d ./RELEASE_VALIDATION_0.14.0.md -0d883bc1ff1b4c9bfe045d4b16ae46f6e1aab773072cf233bf9c25750951e971 ./TESTING_0.14.0.md -70852f174e604a552745d5dcf88e9cef7387fc899264417f1db278eacc796def ./TESTING_0.14.0_CLOUD_BETA.md +a4f7dc180e422d6de9d78b1235206ebc3b0436d6041730df8ca065532c36a939 ./README.md bb549919b96b0413152dd905aec8640bb53301f07ec7d0b1be48ccbd37936fef ./build.rs -df6c518cbc7bb0155b4b0e5b856160627f9d1f63195b48c36bc54dc90f601767 ./docs/API.md +50a46eac5dfcdc80c5db068f0eb1ed7d696a2b6070e2ddff808f2bb2a33a913b ./docs/API.md 2e1e18fd8167dabfe2469c26e85cce62486c7cb6a502c63c6f6b0cd74d5885e0 ./docs/FLOW.md -0416652c4421801675864f8f410fdbac7dd315b2f29df366d94ace6e49154eea ./docs/openapi.json +919d54a6825385fdb4e9b8f07db756640d512b2c21d2d33f407f850680285ed0 ./docs/openapi.json c65aa543d2e99f8f7d52736307b07ebd182597d80124661d53a16bb23f6f1118 ./ha-addon/.env.example a18d2460340264d34c267fa704ee51a42e59bcc1783ee4c0dbb2a8ef42fa0f16 ./ha-addon/Dockerfile 0417c88a990c72aecf071fba64d5d9da65dcda4d396cd7565477f7a4532c8c5e ./ha-addon/README.md @@ -25,7 +21,7 @@ b7f0873109c52be9d7f09bea3dffc416103c50085e1f0680d11661a969479898 ./ha-addon/hom 41a8958a6fe6d10e5679c95ac34832583a2a15ecfd67d09b4e094a83d06f5c47 ./ha-addon/home-assistant/custom_components/gree_controller/coordinator.py 5a96fe8f5c035c34f1339370270cd078056202d09e236dec75735be11de92a7d ./ha-addon/home-assistant/custom_components/gree_controller/entity_map.py c4fb75c246db651087900ebfc2291ff41ac87652cd6194fc0b776b0005c1cbcf ./ha-addon/home-assistant/custom_components/gree_controller/icon.png -e3da92df75e4361d03cddcb9e5ad228f5a9a14c85ee8d816ae7e165b4aeb545e ./ha-addon/home-assistant/custom_components/gree_controller/manifest.json +5a7a8cf9545c9346d9bcc92e16265fa2ef63567fa28abe153a2e361cc279c082 ./ha-addon/home-assistant/custom_components/gree_controller/manifest.json c52a484b671ce738ecc00228a1db19a5d703f69ab27530c2896a5c2ae3b4a96f ./ha-addon/home-assistant/custom_components/gree_controller/number.py 39c4309001b75abb56234f05662bc06e077054986876f1927937edbce528ec95 ./ha-addon/home-assistant/custom_components/gree_controller/select.py cca65482e36d48035aca178121a378fe7d578d600acff267ae81a6399c0da653 ./ha-addon/home-assistant/custom_components/gree_controller/sensor.py @@ -33,10 +29,10 @@ cca65482e36d48035aca178121a378fe7d578d600acff267ae81a6399c0da653 ./ha-addon/hom 6bddb7b4620021ecd2099a86a77ef5c7f2c2dcd3d07d5db4e7b4c4ce6d3e8c03 ./ha-addon/home-assistant/custom_components/gree_controller/translations/en.json 13f30e2dcdcedbd1b6c3f99c2335e0487108fd72c8e86922368b84f2fa2038ae ./ha-addon/home-assistant/custom_components/gree_controller/translations/pl.json c5fc5c87273d82d2834a0a5a365c821a416413ed509613441e24e4dc3507c4d2 ./ha-addon/home-assistant/generated/gree_controller_entities.example.json -d9135c678e6a30d7d31b49683b052d091c6cc0c8db2a16cabeba89285bdb9b20 ./ha-addon/repository/gree-controller/CHANGELOG.md +131509e7dfefcca4ad61a3a060bc0a33ed2f731141496ba99a3cb81a563399b1 ./ha-addon/repository/gree-controller/CHANGELOG.md 3c06bd11671452ea6472afd5543fb91a39aba73a1d2d95c7762c04f94ab64030 ./ha-addon/repository/gree-controller/DOCS.md c3db302f38d26dc9f8caf37a1a8a07a1334cc07ae301d452f2940a5ec5915719 ./ha-addon/repository/gree-controller/README.md -4c1ab41a2e01098b6ad549f187b47119e608d3cc752c38cf334286fae34eacd2 ./ha-addon/repository/gree-controller/config.yaml +09e38d1d821a6c318152e5a023954310e9f361985cfddc456abfcc0b88d79c62 ./ha-addon/repository/gree-controller/config.yaml 3aae6cdae4c3aaab7786a9575e7093b7e2c5b69288167075f4b7475611691a6b ./ha-addon/repository/gree-controller/topologia-ha-vlan-gree-pl.png 0d0b42a7639b128946eec379175436fa1f150294f39d2dfd169077bfa87fd14d ./ha-addon/repository/gree-controller/topology-ha-vlan-gree-en.png d13e4c2b993d209078342c0c86d852b300692089b377aa044f811308dd188a1e ./ha-addon/repository/gree-controller/translations/en.yaml @@ -46,8 +42,8 @@ c123b190219556382f0e7f3058056fb233f6261f257a01eceb96a2c0bbfa12f3 ./ha-addon/run 081e27b6e43070d44f865335c30a42a5a29668eaa6a5ea5ab6967ac4447b8e06 ./ha-addon/sync-repository.sh f9e31de3d109ead32bf531c074d46a087df398d62488caee0b46b5e0a1c027f7 ./ha_addon.md 253a0bc912786e67ea7fc92a64e4a510ad973bec343a88ccfb1f28fca3e8cf01 ./lang/README.md -ca747385adf261ab4ce7d0fae51ec326d8cfffffe20b0c48ce4f6d9d1a419874 ./lang/en.json -0364575c2ba26f1e988f39ec31b9b29c9678a58e6c5ab519cd07509e9ebcee0c ./lang/pl.json +301df7a84ce546db1c2e25da841aa0fc9696435d7bb4db358e3fb082a723afd9 ./lang/en.json +c01e081782251122fec4fb50500a5f3ee4d865a2213af89cc77f3f3661606dbd ./lang/pl.json d8459024f04ca514bd8e9d6bd3af872fb942fd85a7cdd5583f4a9d28aab6faba ./make_zip.py b14233a8987e53bbbdd6770386ba10fa166ec25c1e098275c173c544b37846fc ./presets/bedroom_window_night.json 1960841119c0b673fb2f03621b32237552fd828045304cd221401214b997c910 ./presets/device_resilience.json @@ -88,7 +84,7 @@ fdcd9a5055d08037278b842e7ab69265345c5811f0a06867136c511d140bb191 ./presets/wind 804f22123cd3e8db0fac791826c8dd9f758fb866c8e3b5655fb6d25d259dccf1 ./presets/workday_comfort.json 01952aa92b217f8eae2493b88870e2dec595100cd15c4d561ff11ae2b936c46f ./regenerate-sha.sh c64b1c6deeb24f20af662bfce7fa3683703914987241107ce664fa2f6d1a9dcb ./scripts/README.md -ea99c1ea4bfc63d7734f4a3b57f90501236879995e66a630f1ee57397af0d0ce ./scripts/api_dev_test.py +3461b89eff40737f5f250b50bcccadb882bb0b5afbd96542d9d67184142191f8 ./scripts/api_dev_test.py 5bc736c7bc76ca80aaa406bb171d2aa91baf4c3aa8695dce0e09b888b6ab3146 ./scripts/common.sh 6403786610ee6d2f628193c25aee0dd058d62e904aa1a31d5f62fdaae0e94b4f ./scripts/configure-gree-network.sh 1d6e14e26e49aa9d3527f30a23668bf8d9c48b67e6628ef686c3155c012155de ./scripts/dev.sh @@ -101,18 +97,19 @@ e00d211e3885e30d7fed1e43b44e6fdad40a67019060156c0641816a93e3365f ./scripts/netw 81345b6a0b51736bdbc98fd23199b62e4c721b4e7437e02dab7ea79b97dff29a ./scripts/service.sh 472db31959be29ba99247f7e9cc07a44bc090c538cd654ad8d16e094726037a3 ./scripts/smoke.sh b50782b3742dfbf8a319c60571c968e93fdf8547db747c759edcffae68cb98bf ./scripts/update.sh -98c6dd9a5f714257957e831b3d02cbb08de1cdc30dc4a98a64b7c1f5523f76d2 ./src/api.rs +e89b3503b838670f3213e696395024e74d91c718bc67a4cb295077e944ea4c6c ./src/api.rs 3dfd1e053cbfa0d684dd88cf99b244a96a36e2ff23d2a0715b8327ab62258254 ./src/api/assets.rs 05c255919f7b3d81188fc5f1410be6a0037471dfc44e93bffb009f29a1e6a3fc ./src/api/auth.rs 9e4fc675306e111ed3db7af9822e2925d88109c31b9e309851665a975c0bd83c ./src/api/automations.rs -4bc5a8993231ea48805d93dd888b15ff0c03c0a9dd12b44866a1f7a11a12f718 ./src/api/configuration.rs +be865a0be2167131a3778d7ceb825d9a65991a7c063fffadbe7188eac459d078 ./src/api/configuration.rs 19bc141286a336ad67e99079496eb0ae5126f986ab28be62b5f97758e5f6969c ./src/api/debug_tokens.rs +32ade46ec45d6faf7852242b9f5ba1be6eacbacbeb33d2deabfab59bf1610ce0 ./src/api/device_groups.rs fcbb3da0538aab451f71810f829cc248d6bb67e586dc418d71a572980583823d ./src/api/devices.rs 4b5b784670ae761a6848b9a33fe3fb391bebd39d65e87936907428711e96ca44 ./src/api/events.rs 44b74965f3256c2972d6b3cd384a63c68e3b422f414a0902c085bc92e17369bf ./src/api/flows.rs 2cfee7029f065e81628a8942e3dad00a03c3a5cf4f8492d07f12b75d05c07359 ./src/api/gree_cloud.rs b61b0b4f196c5e72e47cf06ca0c14df3cf8eedbdef0242a9d2af33623663489f ./src/api/groups.rs -9f41c01c3be646f0e5853363f1abea7fcf45d4bd011a696aec5e7e80e493d2f9 ./src/api/history.rs +63733cf1fd70b9c2cb1bde3c9304365e8940af4a7524bfc82b47444458a56f4a ./src/api/history.rs e8edfbb725d6de650442444b0a929ad2be0bd2c2d7d227d2b3fdd99d05c94f94 ./src/api/house.rs 27e85400ccbdb0adcb8f5c1624e7187e9b649237cfb97349fcf1c0d78300540d ./src/api/integrations.rs 6bfbe5135d4f93bd8d19a707877b53ed2029fd8490b4b1954a7520888fb1d664 ./src/api/middleware.rs @@ -120,33 +117,33 @@ e8edfbb725d6de650442444b0a929ad2be0bd2c2d7d227d2b3fdd99d05c94f94 ./src/api/hous 744ce18aa9e0227c6cdfb6980063d0d738d74c5865d45c20fd20026d4742bb3e ./src/api/public_settings.rs 5b29f2e5aa2c422c5829ef89c7b59cf753883aa7f60661c95ab0f95cf676fa3f ./src/api/schedules.rs e69df8a5f612cd37d25fbf9f8e824c5a7d655ac5a6376ec3b31addf1fe8597e2 ./src/api/settings.rs -4a02639ec2499a98f11afcc6ddd22f35f07189ab47c1e1b7a93cc1b76d674d35 ./src/api/system.rs +264f884e2fdef24303d441ce9cfe11190ed8f2bba8ca5782c1d13c2ee30e6a7f ./src/api/system.rs 9118a5a3a381b010e22d646498ff56d2e05f51656789682cc4371b09c384aa98 ./src/api/websocket.rs 455c7238c9788d378e909a4fbc94d5390b67edd92e127f48b4b913ddcbe82fb0 ./src/api/zones.rs f4feaff281163eda543bf094d0fe9158dc6662be5f76f39c81570da7ed0355e7 ./src/config.rs -d4afd8e9f47a68e32bcb12f6b599e44dc2d72a7a4d7422c647fca55b8c94b064 ./src/db.rs -6c278fc5728124ab2f53ac7789a733942a0462f397144a231cc878c8cfcf7426 ./src/db/climate.rs -4b3eda9202efcf70296ae295aeb6027e5f59ff97c6c544acc0c8a221d961bd90 ./src/db/configuration.rs -4e577dda5e248b927d29fe8978013be565b69ca4212fe31a1f3334eae1b4a694 ./src/db/core_devices.rs +d067e3048803bb1eeef1892cbae5bf719872daa0c5ccc8575affcc37c6615965 ./src/db.rs +fdc9d019097becc5c36a0560caa1619aa35d802c0d208902b3bfecbe1643e3e8 ./src/db/climate.rs +e5d2027d40a99e61c2c572499af617a92de9e554f8916f3ea6f6731d1d6b09b4 ./src/db/configuration.rs +0a4b4dbfc582d1c8ee942325ac16aa73d1e64555904ec2f6896f4b552bd5e731 ./src/db/core_devices.rs 4c20289c08b0cb9c3c4324d9dbc6a47c7dd953aff6fd1746f04b6024043d3bc5 ./src/db/device_history.rs f99de2230082f17eafaf03f5361bb67419205a23d64f22480c996f9c32c55ac5 ./src/db/energy_history.rs 88cb4b7ff61a8a558949faa7157d4bc2c45facb951c57240412c4c9d3befdb86 ./src/db/events_tokens.rs bb5048ee60053f39ce3c1203dc1f24c7047310538d2357f105a052c016d21ec4 ./src/db/flows.rs 5f4b567c6bc866d0674b3b0c66f4aeccf561774623f575b872ff25bf015b91c2 ./src/db/ha_history.rs -c0a7fa3eac198737d7261ad00c542a614e6d7291f6581af8e7f7aef9dec1a049 ./src/db/schedules_automations.rs +d6b9b70a04c774173c97f7426487d5e2932d7713e12ae3fb7f64cf2b4e0b3c10 ./src/db/schedules_automations.rs a76938b2e49719b97f8abf40a5cce03df6740ce2e8ac138b179c761619b32ce6 ./src/db/tests.rs f90fbb14f29a20b799b3856bdaa22812a9f0d9dfa46693f1341c160dccda8a97 ./src/db/zone_history.rs -aef5601b127fc1dc8a5bf993027ba597b3c019dd8ced6dfe1eeb62ff0cf578a9 ./src/engine.rs +3c97e04e93d14f37d39ee9f8f53257101517a3d90a0dfeba81ca3dcd5e39ff31 ./src/engine.rs 6046bdbc9c02ac74f47068229c59af69abc8bfa46cfeff6fe7ba573b493e128c ./src/engine/automations.rs 0bf7fe7d28b4749e53ec25d71effbb78bc8f70c716714f838a4a2d8f20627d63 ./src/engine/commands.rs 925135985f640ba9ae0a6109d908187b659341a91451e91d27e533adfd610b76 ./src/engine/control_plan.rs 7b48a33343d519a2ff2d81e9d757eb56a0c1beacfc0b7b8ef61f49c2ad363f9a ./src/engine/deadlines.rs -8d63674f7f0923943df0b2d37f1bae839b25697afe2b34d610849f10dde9f2e1 ./src/engine/energy.rs +9a51cc46b8befb992d7f7dd3dbe5025cd739755a684ed36013be78eaaf1a1743 ./src/engine/energy.rs e350aa63f3bb14ca887eaaa7555edbeae00fb584cbcaf552cc972f0c8918f5d8 ./src/engine/groups.rs -f0c8f3384b6174771aeb5640ee1eec87f3d6d9ff79a4fbd6a35554a4eec00cfa ./src/engine/history.rs +0e78e8d1c31503b313926af025281714a6e22a23f46af081b3da8e94a2d7315a ./src/engine/history.rs 41b7045877771b7c352328998cb45ce2433e7f9631fad3eb1a882ab4897dc31f ./src/engine/local_thermostat.rs 99527adab9fea1caede9ebb24faa6a63d5cef4810c64c06306b571cf5cbbb0da ./src/engine/ownership.rs -7f975e13845aeeef3362dc815206e80a04abbd2114904a4bd0ed4538c2cb6372 ./src/engine/polling.rs +1a29b7fd8de77f0a5e5964ca05e41cc5ea78855c1ab961750d40461faec305e5 ./src/engine/polling.rs d13497acd807a74d707e85046b87fb010e13428758336f736df4a30a5ef0b32c ./src/engine/runtime.rs c1e8de5392681ba1255e3e2d871a6ee860fb8ca987a0a8f973d9ded3977a68fb ./src/engine/schedules.rs b0189f28d0f337dfd3ce0934da7a565e4a5535afc1c242214c26ed584b3b7840 ./src/engine/targets.rs @@ -164,9 +161,9 @@ da4068295f37c23222bb13bebac022d88ba7729604a49ad25438c55e45458a49 ./src/influxdb 059346de38c710fadf301b467c0eb45dcc2f764561b868a155c2e089a5027422 ./src/main.rs 23688a5cb68efded89471ce959328d27adff370d451bd9f378a283ab51437e76 ./src/models.rs 920e028d96a14cca9f74a964bb296e68a109cfead314d3164c180201e606d151 ./src/models/automation.rs -60cac63e6cb41d835e35cc654363f6ffd4386a79f562df0e656b69034acfbac2 ./src/models/control_plan.rs +fc5f2802a073a175a8985e56bcb87eb1ff481226d5b4eeabad73dbba1839fc45 ./src/models/control_plan.rs f1bd2ae1acdc342f9310d01502f478f74c43a7e3b1d284ca55f97900a151b7cd ./src/models/defaults.rs -edcd031427ca903ef640e0c6b0598e28b22dcf9a0c045d13033f4395955f8ffe ./src/models/device.rs +5e212a072bf74ef32a186b172404844b02a92363d216167f814056e1f2a9a7a8 ./src/models/device.rs aeab5b5acf35d38a54fd1d82e964fe56635ade69c0149ad423df317095d01555 ./src/models/flow.rs b10abd201d84f88bf6d71e6ed24a47946b67ef9de8dd0cda0608eeb663b7b417 ./src/models/history.rs 14e053b0dbea9ed7e88e8affaa595fa126ff833c18696fa695e923488ec40d70 ./src/models/integrations.rs @@ -189,37 +186,37 @@ c09c931fa8d512a8883fb14f3142be694af3c51290e023d25c7c972e156e4bd2 ./src/protocol 49dcca1e4a4ad858b312386e3b729f36da691916d8fe62ad682ddb86d40695b7 ./src/protocol/gree_cloud.rs cd1e880ea947150300f798fa313eb36c57325856c3ceab862e53a7b597b477b1 ./src/protocol/gree_cloud_mqtt.rs 6258ee3888a5f9a4f8bb6cfbbac97f2621de07f9da6bcb2fa596d452f47f2e49 ./src/protocol/mod.rs -df33269fc1570ffd87a0ab47ce0631bdf45d63e219c04800b333657d8382e6fc ./src/provider.rs +858932d98576e1863bb9dfc5a43a0fa72db13d84815595fc998d007571f48a2d ./src/provider.rs 830621fa75265da7288b8135704c0ce27e0687f87d55abf3cdbe40a6e2f3d2b1 ./src/queries.rs 7fac65a9dcbcecee09816f5447c7792dc94f736b1efb66ac85629db75f6280be ./src/queries/device_history.rs -917e13be9db10ea629bd99fe6c62a869c31609780702cc6a44e787b78a62e03a ./src/queries/entities.rs +580917b91441fe9b3276cf0cfc31536ba7954b6a86f4080347b93f9367a5b1ac ./src/queries/entities.rs 2c5fba462b72158b04dbaaa53c8536263f8d72a1338a6c4ff06cb28a36f6ae27 ./src/queries/ha_history.rs -3112452367b68aeda0a5e3143b1a51ce62e3159859fb41e92c35642a3cd72322 ./src/queries/maintenance.rs -776ed66e363289e188b04a194caaa476ccdf7045b61485792ffe3973caf0bf4d ./src/queries/schema.rs +d051ea7cef7fc733d5fcb0a9d496c419be7ddbe799fc93842b9389d220837747 ./src/queries/maintenance.rs +84c3c149cde1728b20ee5723e02d7a7cbb6db9cee3b71a3d4709d6aaa4230e3e ./src/queries/schema.rs f13f1be3d5789539eba3fc1b59c14835f8c681eca7634e118fa3763a53aeee2b ./src/queries/zone_history.rs 7e7037a059b585c5709367b9431a225789e7039796e3ed19fc132776ac234099 ./src/state.rs b92a6cb158b494fe145b43c7641e65f6fafff47201d7d76edbec2cfd8b94835c ./systemd/gree-controller.service 544a31cb2e5374227026afd6abd0d910381f3fca1638154ebd74e3c3d96a35d1 ./web/404.html e98bdd7204349cce1ec6f57283509697af0bbc72280622a6c3efa6fed242db4f ./web/favicon.svg -d48f4adab24f6524915a179b58abdc64659195ac6d4236e55a48a4eac0eda402 ./web/index.html +6cd0fa21dcc1b5170c4375d7fb70b72fc753f6ac80fbd1f6a91ba0e52ccca7e4 ./web/index.html 9ba3f6fa05b72aa989139b2a909982571b2a02055052e4c40104f1e81a9aa7eb ./web/js/README.md -06c8ab1c7e5c0e57ada4e2530d69a379a037678412e1e52268c2aaf8d17b2243 ./web/js/bootstrap.js -5dae06e1193685b7dec29234aa1ae0a29bc6f91cf9fa42370a7fd0d6fdef4e32 ./web/js/charts.js -87598f48469a6dd17e526420d552652c9405632b285d566691b010b59e99043e ./web/js/core.js +dfea965a2093f9f85744b50993c867118afc7a65d2c5d3abd975d4b6f4acc6f2 ./web/js/bootstrap.js +761ad87ed20a819d8d87bc260bcd7f5b39f19582c346a69c8b717c94e7e94302 ./web/js/charts.js +e6c459f92f18d72f11ffaead5caede0b9c8e6b6a1c9cf1a6e97e6b07d5425ab5 ./web/js/core.js d880ffdac6aa07cd10d79fbacf12f87eb4c69b48a80564bbfdd0248abe8884d5 ./web/js/dashboard.js -fcaa6b8d863bc93bc9b7350329d261b2fc98877eb5c9a323d66aa809dd4ef16b ./web/js/entities.js -dada07699ba93efee918e7b1f90f3a326a381fe4c85a63d2811448a048878258 ./web/js/events.js +83189a47d114e7b911c9cbf368bf80232a7a481d416297db5fc451c12bb710c7 ./web/js/entities.js +1ff265beab8eb15ced854b5da260fd3952add3acc6b290ea73c76b1a54853165 ./web/js/events.js 2e822a00b5a6941b6a251eaf2c54ca95a53399b5d1056f3d8240e11f82d6065f ./web/js/flows.js -cf1ebf3b9d666f309a1add070b4da86be86d990b02c8d6474f6d121c6d332746 ./web/js/forms.js -7d8c2aedcf97196f081367de0933af13c2bc2e531d59e7464760b6d64c895004 ./web/js/history.js +9e2209d41df7624c29741f03f12ed8e83b47cee2891c5a80cea8ca1bdacf1d5a ./web/js/forms.js +6d2c2fc497f0ea4e3ef4a35e6518c9e4be680ace9100ed09a327f9dbe4261d0b ./web/js/history.js 0dc879c0056251618633c9efd188fc1593eeae6965ad1bc993132dea6e171b44 ./web/js/main.js -5f5ae7bde7c9c0009c6789343f78e77a43bd8aa756f10422320c932388a6e730 ./web/js/navigation.js -ab41961830fd7872023f4d7952487fcb8c00289da7780599072864d089c6d981 ./web/js/realtime.js -cdcd55741f5c468127876ae8d03bdb3f5286b7f4f400591599ef7f0de4b1bc34 ./web/js/router.js -fb9830248ad69eb1d85c94b0f7136e189a177811c93cc37656645668a1c95c39 ./web/js/settings-ui.js -4fcfcc09a6a9a1695d37fbe37c4645377006de58f50406a4722da927b389ed86 ./web/js/settings.js +341c4bc441782bf60bd92c899154ca565ac1ba884a6f3a1ea89aae58f5d0322f ./web/js/navigation.js +d105fb2a3b4000a9f22ed23c7b030c1018fee4d2a78dba3ad96b3d9d95689f2a ./web/js/realtime.js +abdedaca20ecad50ae2b5c7f6081c80a142112bd867cda19949d5253e609f1b4 ./web/js/router.js +3426ec31011173172199bff7deacbef1d5eb0121012b5ea0e53b7a72a674a853 ./web/js/settings-ui.js +13806217b2312dff3bd6cc2b16f75d9bdffc7a587215ae9f862706cd7699673f ./web/js/settings.js 3815fea7ae1694568c2f249bc8460e0563ced6bde89214f74f21b41fe8cad9a6 ./web/lang-init.js 6b653e4fe2d4db4ffae6ff8f39b8ce57a10a990dc0000ee4aae51d949afadd52 ./web/manifest.webmanifest -48d03c265a597af4fc11ae8103eb7b6cb3e5932307783f5287c67781f702b800 ./web/styles.css +10a47e3645316103b8ad85a8635ec6305da9717226568e76db58e2d77e894e8f ./web/styles.css fefdf12cbbb0a5d8a2cb89275cae28455aa42aaf179542305cc8f5e0f4825898 ./web/sw.js d505d793ce7cc9485b45b78bba1c0d51887adc7451ab59a42702946e5b991382 ./web/theme-init.js diff --git a/docs/API.md b/docs/API.md index ce6cd47..1f7c586 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.14.0**. +HTTP and WebSocket API for GREE Controller **0.14.1**. [← Main documentation](../README.md) @@ -232,7 +232,7 @@ Response: { "status": "ok", "name": "gree-controller", - "version": "0.14.0", + "version": "0.14.1", "uptime_seconds": 1234, "control_ready": true, "time": "2026-08-30T06:54:00Z" @@ -295,7 +295,7 @@ Returns the initial Web UI snapshot: "control_plan": {"generated_at": "2026-09-04T08:00:00Z", "zones": [], "rules": []}, "control_plan_revision": 42, "system": { - "version": "0.14.0", + "version": "0.14.1", "uptime_seconds": 1234, "auth_required": false, "control_ready": true, @@ -1653,6 +1653,20 @@ Existing Flow updates require `expected_revision`. A mismatched revision returns Additional Flow condition blocks are `house_mode`, `device_state`, `zone_state`, `group_state`, `night_mode`, `ha_attribute`, `ha_available`, `constant` and `shared_input`, alongside weekday/time/date, temperature, Home Assistant state/numeric and AND/OR/NOT blocks. `device_state` can inspect enabled/online/power/mode/fan/swing/quiet/turbo/light/air/xfan/health/sleep state. The editor ships 37 categorized editable templates covering comfort, energy, safety, night, reliability, Home Assistant heat-source coordination and advanced multi-branch logic. The template UI adds search, favorites/recent history, a graph preview and runtime requirement checks; shared inputs expose usage links and HA-backed inputs can be tested against live entity state. + +## Split/multisplit installations and energy comparison — 0.14.1 + +Installation configuration endpoints: + +- `GET|POST /api/device-groups` +- `GET|PUT|DELETE /api/device-groups/{id}` + +A device group represents one physical split or multisplit installation. It contains one or more indoor `device_ids`, an optional shared GREE Cloud energy device or Home Assistant cumulative-energy entity, and an optional member device used as the shared outdoor-temperature source. One device may belong to only one installation. + +Energy history accepts either a device ID or `group:` as a target. The Web UI can request several targets in parallel and overlay them on one chart. `compare=previous_day|previous_period|previous_year` shifts the requested range for period comparison; `compare=none` disables comparison. Daily buckets are presented as calendar dates. + +When the final registered GREE Cloud device is removed, the controller closes the unused MQTT session. MQTT messages already in flight after removal are ignored while no Cloud devices are registered, preventing repeated stale-device warnings. + ## GREE Cloud and energy — 0.14.0 Devices now expose `connection_type`, `connection_status`, `capabilities`, Cloud synchronization fields and energy-source metadata through the existing device API. Local and Cloud devices use the same command endpoint; dispatch is selected strictly by `connection_type`. diff --git a/docs/openapi.json b/docs/openapi.json index e30076a..9943e22 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -2,9 +2,9 @@ "openapi": "3.1.0", "info": { "title": "GREE Controller API", - "version": "0.14.0", + "version": "0.14.1", "summary": "Local HTTP/WebSocket API for GREE HVAC control", - "description": "Self-hosted API used by the GREE Controller Web UI and Home Assistant integration.\n\n### Authentication\nAdministrator endpoints accept `Authorization: Bearer ` 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.", + "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.1\nAdds split/multisplit installation groups with shared energy and outdoor-temperature sources, multi-target energy charts with period comparison, cleaner Cloud status/alerts and safe MQTT cleanup after the last Cloud device is removed.\n\n### 0.14.0\nAdds first-class GREE Cloud MQTT status/control, reconnect/resubscribe lifecycle, Cloud diagnostics, per-device capabilities, Cloud/HA energy sources and energy-history aggregation while preserving the LAN transport contract.", "license": { "name": "MIT" } @@ -5391,7 +5391,7 @@ }, "version": { "type": "string", - "example": "0.14.0" + "example": "0.14.1" }, "uptime_seconds": { "type": "integer", 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 074fd9d..92b6c62 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.14.0", + "version": "0.14.1", "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 2941650..1af6db1 100644 --- a/ha-addon/repository/gree-controller/CHANGELOG.md +++ b/ha-addon/repository/gree-controller/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +## 0.14.1 + +- Adds split and multisplit installation groups in the Devices section, including assignment of indoor units to a shared outdoor installation. +- Adds installation-level energy sources so a multisplit can use one cumulative GREE Cloud meter or one Home Assistant energy entity instead of reporting duplicated per-unit consumption. +- Shows on each grouped device that its energy is provided by the assigned split/multisplit installation and identifies the configured shared source. +- Adds an optional shared outdoor-temperature source for an installation, allowing all member units to use the outdoor temperature reported by one selected unit. +- Adds installation groups to configuration export/import, bootstrap/API data and persistence, with validation that prevents duplicate membership and invalid energy/outdoor source references. +- Keeps installation configuration consistent when devices are removed, including removing empty groups and resetting a deleted GREE Cloud energy source safely. +- Adds multi-select energy charts so several devices and/or installations can be displayed together. +- Adds energy period comparison against the previous day, previous equivalent period or previous year. +- Fixes daily energy chart labels so daily aggregation is rendered as calendar dates instead of timestamps such as `02:00`. +- Unifies GREE Cloud validation/error alerts with the alert style used for device errors and removes redundant styling paths. +- Hides empty GREE Cloud status fields such as disabled/disconnected, `0 / 0` and `Unavailable`; status details are shown only when useful data or an actionable error exists. +- Preserves and updates the Polish/English translations used by the new installation, energy and Cloud UI, including the simplified Cloud details label (`Details` / `Szczegóły`). +- Fixes repeated `GREE Cloud MQTT message does not match a registered device` warnings after deleting all Cloud devices by ignoring in-flight stale MQTT frames when no devices are registered and closing the now-unused MQTT session after the last device is unregistered. +- Adds regression coverage ensuring stale MQTT payloads are ignored only when no Cloud devices remain, while unknown-device payloads still fail when registered devices exist. + ## 0.14.0 - Adds GREE Cloud as a first-class provider while preserving the existing LAN/UDP transport. diff --git a/ha-addon/repository/gree-controller/config.yaml b/ha-addon/repository/gree-controller/config.yaml index 64f8c0d..b527e7e 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.14.0" +version: "0.14.1" slug: "gree_controller" description: "Local GREE HVAC controller with Web UI and Home Assistant integration" url: "https://git.linuxiarz.pl/gru/gree-controller-ha-addon/" diff --git a/lang/en.json b/lang/en.json index 83efc02..0596c64 100644 --- a/lang/en.json +++ b/lang/en.json @@ -1183,7 +1183,7 @@ "devices.transport": "Transport", "devices.cloudDeviceId": "Cloud Device ID", "devices.lastSync": "Last synchronization", - "devices.cloudDetails": "Cloud details", + "devices.cloudDetails": "Details", "devices.cloudDiagnostics": "Cloud diagnostics", "devices.cloudDiagnosticsHint": "Decrypted and sanitized protocol data. Passwords, tokens and cipher keys are never included.", "energy.title": "Energy", @@ -1197,7 +1197,7 @@ "energy.currentMonth": "Current month", "energy.previousMonth": "Previous month", "energy.periodTotal": "Period total", - "energy.noData": "No energy source is configured for this device.", + "energy.noData": "No energy source is configured for the selected devices or installations.", "energy.hourly": "Hourly", "energy.daily": "Daily", "energy.monthly": "Monthly", @@ -1258,6 +1258,34 @@ "devices.cloudTransportProblem": "GREE Cloud connection is unavailable", "devices.cloudDeviceOffline": "GREE Cloud is connected, but the unit is not responding", "settings.cloudRestResponseTime": "Last REST response time", - "common.pending": "Pending" + "common.pending": "Pending", + "devices.installations": "Split / multisplit installations", + "devices.installationsHint": "Group indoor units that share one outdoor unit. Energy and outdoor temperature can use one shared source.", + "devices.installation": "Installation", + "devices.installationName": "Installation name", + "devices.installationType": "Installation type", + "devices.split": "Split", + "devices.multisplit": "Multisplit", + "devices.installationDevices": "Indoor units", + "devices.energySourceDevice": "GREE Cloud energy source", + "devices.outdoorSourceDevice": "Outdoor temperature source", + "devices.noSharedOutdoor": "No shared source", + "devices.installationSaved": "Installation saved.", + "devices.installationDeleted": "Installation deleted.", + "devices.newInstallation": "New installation", + "devices.editInstallation": "Edit installation", + "devices.groupedEnergyNote": "This unit belongs to {name}. Energy is reported at installation level: {source}.", + "energy.targets": "Devices / installations", + "energy.weekly": "Weekly", + "energy.compare": "Compare", + "energy.compareNone": "No comparison", + "energy.comparePreviousDay": "Previous day", + "energy.comparePreviousPeriod": "Previous period", + "energy.comparePreviousYear": "Year earlier", + "energy.currentPeriod": "Current period", + "energy.comparisonPeriod": "Comparison period", + "energy.groupSource": "Group source", + "energy.selectAtLeastOne": "Select at least one device or installation.", + "energy.multiselectHint": "You can select several items (Ctrl/Cmd or Shift)." } -} +} \ No newline at end of file diff --git a/lang/pl.json b/lang/pl.json index a1347c3..05fa6aa 100644 --- a/lang/pl.json +++ b/lang/pl.json @@ -1183,7 +1183,7 @@ "devices.transport": "Transport", "devices.cloudDeviceId": "Cloud Device ID", "devices.lastSync": "Ostatnia synchronizacja", - "devices.cloudDetails": "Szczegóły Cloud", + "devices.cloudDetails": "Szczegóły", "devices.cloudDiagnostics": "Diagnostyka Cloud", "devices.cloudDiagnosticsHint": "Odszyfrowane i zsanityzowane dane protokołu. Hasła, tokeny i klucze szyfrujące nigdy nie są tu zwracane.", "energy.title": "Energia", @@ -1197,7 +1197,7 @@ "energy.currentMonth": "Bieżący miesiąc", "energy.previousMonth": "Poprzedni miesiąc", "energy.periodTotal": "Suma okresu", - "energy.noData": "Dla tego urządzenia nie skonfigurowano źródła energii.", + "energy.noData": "Dla wybranych urządzeń lub instalacji nie skonfigurowano źródła energii.", "energy.hourly": "Godzinowo", "energy.daily": "Dziennie", "energy.monthly": "Miesięcznie", @@ -1258,6 +1258,34 @@ "devices.cloudTransportProblem": "Połączenie z GREE Cloud jest niedostępne", "devices.cloudDeviceOffline": "GREE Cloud jest połączony, ale urządzenie nie odpowiada", "settings.cloudRestResponseTime": "Czas ostatniej odpowiedzi REST", - "common.pending": "Oczekiwanie" + "common.pending": "Oczekiwanie", + "devices.installations": "Instalacje split / multisplit", + "devices.installationsHint": "Grupuj jednostki wewnętrzne współdzielące jedną jednostkę zewnętrzną. Energia i temperatura zewnętrzna mogą korzystać z jednego wspólnego źródła.", + "devices.installation": "Instalacja", + "devices.installationName": "Nazwa instalacji", + "devices.installationType": "Typ instalacji", + "devices.split": "Split", + "devices.multisplit": "Multisplit", + "devices.installationDevices": "Jednostki wewnętrzne", + "devices.energySourceDevice": "Źródło energii GREE Cloud", + "devices.outdoorSourceDevice": "Źródło temperatury zewnętrznej", + "devices.noSharedOutdoor": "Brak wspólnego źródła", + "devices.installationSaved": "Zapisano instalację.", + "devices.installationDeleted": "Usunięto instalację.", + "devices.newInstallation": "Nowa instalacja", + "devices.editInstallation": "Edytuj instalację", + "devices.groupedEnergyNote": "Ta jednostka należy do instalacji {name}. Energia jest raportowana na poziomie instalacji: {source}.", + "energy.targets": "Urządzenia / instalacje", + "energy.weekly": "Tygodniowo", + "energy.compare": "Porównaj", + "energy.compareNone": "Bez porównania", + "energy.comparePreviousDay": "Poprzedni dzień", + "energy.comparePreviousPeriod": "Poprzedni okres", + "energy.comparePreviousYear": "Rok wcześniej", + "energy.currentPeriod": "Bieżący okres", + "energy.comparisonPeriod": "Okres porównawczy", + "energy.groupSource": "Źródło grupy", + "energy.selectAtLeastOne": "Wybierz co najmniej jedno urządzenie lub instalację.", + "energy.multiselectHint": "Możesz zaznaczyć kilka pozycji (Ctrl/Cmd lub Shift)." } -} +} \ No newline at end of file diff --git a/scripts/api_dev_test.py b/scripts/api_dev_test.py index 6234779..c37189e 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.14.0. +"""Developer smoke/integration tests for GREE Controller API 0.14.1. Default mode is read-only and safe to run against a real controller. Use --settings-write to additionally round-trip all split settings resources and @@ -26,7 +26,7 @@ from typing import Any, Callable, Iterable, Optional DEFAULT_BASE_URL = os.environ.get("GREE_API_URL", "http://127.0.0.1:8787") DEFAULT_TOKEN = os.environ.get("GREE_API_TOKEN", "") -DEFAULT_EXPECTED_VERSION = "0.14.0" +DEFAULT_EXPECTED_VERSION = "0.14.1" SETTINGS_PATHS: dict[str, set[str]] = { "/api/settings/application": {"simulator_enabled"}, @@ -571,7 +571,7 @@ def run_suite(args: argparse.Namespace) -> int: print(f"Settings write tests: {'ENABLED' if args.settings_write else 'disabled'}\n") runner.run("public health", lambda: test_health(client, args.expected_version)) - runner.run("OpenAPI 0.14.0 contract", lambda: test_openapi(client, args.expected_version)) + runner.run("OpenAPI 0.14.1 contract", lambda: test_openapi(client, args.expected_version)) runner.run("bootstrap snapshot contract", lambda: test_bootstrap_contract(client)) runner.run("protected API requires auth", lambda: test_protected_auth(client)) @@ -640,7 +640,7 @@ def run_suite(args: argparse.Namespace) -> int: def parse_args(argv: Optional[Iterable[str]] = None) -> argparse.Namespace: parser = argparse.ArgumentParser( - description="Developer smoke/integration tests for GREE Controller API 0.14.0.", + description="Developer smoke/integration tests for GREE Controller API 0.14.1.", formatter_class=argparse.RawDescriptionHelpFormatter, epilog="""Examples: python3 scripts/api_dev_test.py diff --git a/src/api.rs b/src/api.rs index f6573d0..ba0c482 100644 --- a/src/api.rs +++ b/src/api.rs @@ -4,8 +4,8 @@ use crate::{ home_assistant, influxdb, models::{ ApiTokenInfo, ApplicationSettings, Automation, ClimateGroup, ConfigurationExport, - ConnectionStatus, ConnectionType, DebugSettings, Device, DeviceCommand, DevicePatch, DiscoveryRequest, - EnergySourcePreference, Flow, GreeSettings, + ConnectionStatus, ConnectionType, DebugSettings, Device, DeviceCommand, DeviceGroup, DeviceGroupKind, DevicePatch, DiscoveryRequest, + EnergyReading, EnergySourcePreference, Flow, GreeSettings, GreeCloudSettings, GreeCloudSettingsUpdate, GreeCloudSettingsView, GroupControlPatch, HaReading, HistorySettings, HomeAssistantSettings, HomeAssistantSettingsUpdate, HomeAssistantSettingsView, InfluxDbSettings, @@ -95,6 +95,11 @@ pub fn router(state: AppState) -> Router { .route("/api/devices/:id/poll", post(poll_device)) .route("/api/devices/:id/probe", post(probe_device)) .route("/api/devices/:id/command", post(command_device)) + .route("/api/device-groups", get(list_device_groups).post(create_device_group)) + .route( + "/api/device-groups/:id", + get(get_device_group).put(update_device_group).delete(delete_device_group), + ) .route("/api/zones", get(list_zones).post(create_zone)) .route( "/api/zones/:id", @@ -344,6 +349,7 @@ pub fn router(state: AppState) -> Router { include!("api/auth.rs"); include!("api/system.rs"); include!("api/devices.rs"); +include!("api/device_groups.rs"); include!("api/zones.rs"); include!("api/groups.rs"); include!("api/house.rs"); diff --git a/src/api/configuration.rs b/src/api/configuration.rs index 820e89b..dc2ea33 100644 --- a/src/api/configuration.rs +++ b/src/api/configuration.rs @@ -1,6 +1,7 @@ struct ConfigurationIds<'a> { devices: std::collections::HashSet<&'a str>, zones: std::collections::HashSet<&'a str>, + device_groups: std::collections::HashSet<&'a str>, schedules: std::collections::HashSet<&'a str>, automations: std::collections::HashSet<&'a str>, flows: std::collections::HashSet<&'a str>, @@ -26,7 +27,7 @@ async fn export_configuration( fn validate_configuration_header(export: &ConfigurationExport) -> Result<(), AppError> { if export.format_version != 3 { - return Err(AppError::BadRequest("unsupported configuration export version; version 3 is required by GREE Controller 0.14.0".into())); + return Err(AppError::BadRequest("unsupported configuration export version; version 3 is required by GREE Controller 0.14.1".into())); } if export.settings.control_strategy != "setpoint" { return Err(AppError::BadRequest( @@ -49,6 +50,7 @@ fn collect_configuration_ids( let ids = ConfigurationIds { devices: export.devices.iter().map(|item| item.id.as_str()).collect(), zones: export.zones.iter().map(|item| item.id.as_str()).collect(), + device_groups: export.device_groups.iter().map(|item| item.id.as_str()).collect(), schedules: export .schedules .iter() @@ -63,11 +65,13 @@ fn collect_configuration_ids( }; let duplicate_or_empty = ids.devices.len() != export.devices.len() || ids.zones.len() != export.zones.len() + || ids.device_groups.len() != export.device_groups.len() || ids.schedules.len() != export.schedules.len() || ids.automations.len() != export.automations.len() || ids.flows.len() != export.flows.len() || ids.devices.contains("") || ids.zones.contains("") + || ids.device_groups.contains("") || ids.schedules.contains("") || ids.automations.contains("") || ids.flows.contains(""); @@ -154,6 +158,71 @@ fn validate_configuration_devices_and_zones( )); } } + let mut installation_devices = std::collections::HashSet::new(); + for group in &export.device_groups { + if group.name.trim().is_empty() || group.device_ids.is_empty() { + return Err(AppError::BadRequest( + "import contains an empty split/multisplit installation".into(), + )); + } + if group.kind == DeviceGroupKind::Split && group.device_ids.len() != 1 { + return Err(AppError::BadRequest( + "import contains a split installation with more than one unit".into(), + )); + } + for device_id in &group.device_ids { + if !ids.devices.contains(device_id.as_str()) { + return Err(AppError::BadRequest( + "import contains an installation referencing a missing device".into(), + )); + } + if !installation_devices.insert(device_id.as_str()) { + return Err(AppError::BadRequest( + "import assigns one device to more than one split/multisplit installation".into(), + )); + } + } + if group.energy_device_id.as_deref().is_some_and(|id| !group.device_ids.iter().any(|member| member == id)) { + return Err(AppError::BadRequest( + "import contains an installation energy source outside the installation".into(), + )); + } + if group.energy_source == EnergySourcePreference::GreeCloud && group.energy_device_id.is_none() { + return Err(AppError::BadRequest( + "import contains a GREE Cloud installation without an energy source device".into(), + )); + } + if group.energy_source == EnergySourcePreference::HomeAssistant && group.ha_energy_entity_id.as_deref().map_or(true, |value| value.trim().is_empty()) { + return Err(AppError::BadRequest( + "import contains a Home Assistant installation without an energy entity".into(), + )); + } + if let Some(energy_device_id) = group.energy_device_id.as_deref() { + let energy_device = export.devices.iter().find(|device| device.id == energy_device_id).ok_or_else(|| { + AppError::BadRequest("import contains an installation energy source referencing a missing device".into()) + })?; + if energy_device.connection_type != ConnectionType::GreeCloud || !energy_device.capabilities.energy_meter { + return Err(AppError::BadRequest( + "import contains an installation energy source without a GREE Cloud energy meter".into(), + )); + } + } + if group.ha_energy_entity_id.as_deref().is_some_and(|value| !value.trim().is_empty()) { + if group.ha_energy_device_class.as_deref() != Some("energy") + || !matches!(group.ha_energy_state_class.as_deref(), Some("total" | "total_increasing")) + || !matches!(group.ha_energy_unit.as_deref().map(str::to_ascii_lowercase).as_deref(), Some("wh" | "kwh")) + { + return Err(AppError::BadRequest( + "import contains an invalid Home Assistant cumulative energy entity".into(), + )); + } + } + if group.outdoor_temperature_device_id.as_deref().is_some_and(|id| !group.device_ids.iter().any(|member| member == id)) { + return Err(AppError::BadRequest( + "import contains an installation outdoor-temperature source outside the installation".into(), + )); + } + } Ok(()) } diff --git a/src/api/device_groups.rs b/src/api/device_groups.rs new file mode 100644 index 0000000..5a75600 --- /dev/null +++ b/src/api/device_groups.rs @@ -0,0 +1,145 @@ +#[derive(Debug, Deserialize)] +struct DeviceGroupInput { + name: String, + #[serde(default)] + kind: DeviceGroupKind, + #[serde(default)] + device_ids: Vec, + #[serde(default)] + energy_source: EnergySourcePreference, + #[serde(default)] + energy_device_id: Option, + #[serde(default)] + ha_energy_entity_id: Option, + #[serde(default)] + ha_energy_unit: Option, + #[serde(default)] + ha_energy_device_class: Option, + #[serde(default)] + ha_energy_state_class: Option, + #[serde(default)] + outdoor_temperature_device_id: Option, +} + +fn normalize_optional(value: Option) -> Option { + value.map(|item| item.trim().to_string()).filter(|item| !item.is_empty()) +} + +fn validate_device_group_input( + state: &AppState, + input: &DeviceGroupInput, + editing_id: Option<&str>, +) -> Result, AppError> { + if input.name.trim().is_empty() { + return Err(AppError::BadRequest("installation name is required".into())); + } + let mut device_ids = input.device_ids.iter().map(|id| id.trim().to_string()).filter(|id| !id.is_empty()).collect::>(); + device_ids.sort(); + device_ids.dedup(); + if device_ids.is_empty() { + return Err(AppError::BadRequest("installation must contain at least one device".into())); + } + if input.kind == DeviceGroupKind::Split && device_ids.len() != 1 { + return Err(AppError::BadRequest("split installation must contain exactly one device".into())); + } + for device_id in &device_ids { + if state.db.get_device(device_id)?.is_none() { + return Err(AppError::BadRequest(format!("installation references missing device {device_id}"))); + } + } + for existing in state.db.list_device_groups()? { + if editing_id == Some(existing.id.as_str()) { continue; } + if let Some(device_id) = device_ids.iter().find(|id| existing.device_ids.iter().any(|other| other == *id)) { + return Err(AppError::BadRequest(format!("device {device_id} already belongs to installation '{}'", existing.name))); + } + } + let energy_device_id = normalize_optional(input.energy_device_id.clone()); + if let Some(ref id) = energy_device_id { + if !device_ids.iter().any(|device_id| device_id == id) { + return Err(AppError::BadRequest("energy source device must belong to this installation".into())); + } + let device = state.db.get_device(id)?.ok_or_else(|| AppError::BadRequest("energy source device does not exist".into()))?; + if device.connection_type != ConnectionType::GreeCloud || !device.capabilities.energy_meter { + return Err(AppError::BadRequest("selected device does not expose GREE Cloud energy".into())); + } + } + if input.energy_source == EnergySourcePreference::GreeCloud && energy_device_id.is_none() { + return Err(AppError::BadRequest("select a GREE Cloud energy source device".into())); + } + let entity = normalize_optional(input.ha_energy_entity_id.clone()); + if input.energy_source == EnergySourcePreference::HomeAssistant && entity.is_none() { + return Err(AppError::BadRequest("select a Home Assistant cumulative energy sensor".into())); + } + if entity.is_some() { + if input.ha_energy_device_class.as_deref() != Some("energy") { + return Err(AppError::BadRequest("Home Assistant energy sensor must have device_class=energy".into())); + } + if !matches!(input.ha_energy_state_class.as_deref(), Some("total" | "total_increasing")) { + return Err(AppError::BadRequest("Home Assistant energy sensor must have state_class=total or total_increasing".into())); + } + if !matches!(input.ha_energy_unit.as_deref().map(str::to_ascii_lowercase).as_deref(), Some("wh" | "kwh")) { + return Err(AppError::BadRequest("Home Assistant energy sensor must use Wh or kWh".into())); + } + } + if let Some(id) = normalize_optional(input.outdoor_temperature_device_id.clone()) { + if !device_ids.iter().any(|device_id| device_id == &id) { + return Err(AppError::BadRequest("outdoor temperature source must belong to this installation".into())); + } + } + Ok(device_ids) +} + +fn device_group_from_input(id: String, existing: Option, input: DeviceGroupInput, device_ids: Vec) -> DeviceGroup { + let now = Utc::now(); + DeviceGroup { + id, + name: input.name.trim().to_string(), + kind: input.kind, + device_ids, + energy_source: input.energy_source, + energy_device_id: normalize_optional(input.energy_device_id), + ha_energy_entity_id: normalize_optional(input.ha_energy_entity_id), + ha_energy_unit: normalize_optional(input.ha_energy_unit), + ha_energy_device_class: normalize_optional(input.ha_energy_device_class), + ha_energy_state_class: normalize_optional(input.ha_energy_state_class), + outdoor_temperature_device_id: normalize_optional(input.outdoor_temperature_device_id), + created_at: existing.as_ref().map(|item| item.created_at).unwrap_or(now), + updated_at: now, + } +} + +async fn list_device_groups(State(state): State) -> Result>, AppError> { + Ok(Json(state.db.list_device_groups()?)) +} + +async fn get_device_group(State(state): State, Path(id): Path) -> Result, AppError> { + state.db.get_device_group(&id)?.map(Json).ok_or_else(|| AppError::NotFound(format!("device group {id}"))) +} + +async fn create_device_group(State(state): State, Json(input): Json) -> Result<(StatusCode, Json), AppError> { + let _guard = state.lock_configuration_operation().await; + let device_ids = validate_device_group_input(&state, &input, None)?; + let group = device_group_from_input(Uuid::new_v4().to_string(), None, input, device_ids); + state.db.save_device_group(&group)?; + state.broadcast("device_group.created", serde_json::to_value(&group)?); + Ok((StatusCode::CREATED, Json(group))) +} + +async fn update_device_group(State(state): State, Path(id): Path, Json(input): Json) -> Result, AppError> { + let _guard = state.lock_configuration_operation().await; + let existing = state.db.get_device_group(&id)?.ok_or_else(|| AppError::NotFound(format!("device group {id}")))?; + let device_ids = validate_device_group_input(&state, &input, Some(&id))?; + let group = device_group_from_input(id, Some(existing), input, device_ids); + state.db.save_device_group(&group)?; + state.broadcast("device_group.updated", serde_json::to_value(&group)?); + Ok(Json(group)) +} + +async fn delete_device_group(State(state): State, Path(id): Path) -> Result { + let _guard = state.lock_configuration_operation().await; + if !state.db.delete_device_group(&id)? { + return Err(AppError::NotFound(format!("device group {id}"))); + } + state.broadcast("device_group.deleted", json!({"id": id})); + Ok(StatusCode::NO_CONTENT) +} diff --git a/src/api/history.rs b/src/api/history.rs index 6be48d4..582e67e 100644 --- a/src/api/history.rs +++ b/src/api/history.rs @@ -491,11 +491,16 @@ async fn control_plan(State(state): State) -> Result, AppE #[derive(Debug, Deserialize)] struct EnergyHistoryQuery { - device_id: String, + /// Backward-compatible single-device selector. + device_id: Option, + /// Device id or `group:`. + target_id: Option, interval: Option, source: Option, days: Option, limit: Option, + /// none, previous_day, previous_period or previous_year. + compare: Option, } async fn energy_history( @@ -505,10 +510,12 @@ async fn energy_history( use chrono::{Datelike, NaiveDate, Timelike, Weekday}; use std::collections::BTreeMap; - let device = state - .db - .get_device(&query.device_id)? - .ok_or_else(|| AppError::NotFound(format!("device {}", query.device_id)))?; + let target_id = query + .target_id + .as_deref() + .or(query.device_id.as_deref()) + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| AppError::BadRequest("energy target_id is required".into()))?; let interval_name = query.interval.as_deref().unwrap_or("daily"); if !matches!(interval_name, "hourly" | "daily" | "weekly" | "monthly") { return Err(AppError::BadRequest( @@ -516,8 +523,86 @@ async fn energy_history( )); } let days = query.days.unwrap_or(31).clamp(1, 3650); + let compare_name = query.compare.as_deref().unwrap_or("none"); + let compare_shift_days = match compare_name { + "none" => None, + "previous_day" => Some(1), + "previous_period" => Some(days), + "previous_year" => Some(365), + _ => return Err(AppError::BadRequest("energy compare must be none, previous_day, previous_period or previous_year".into())), + }; + + let requested_source = query.source.as_deref().unwrap_or("auto"); + if !matches!(requested_source, "auto" | "gree_cloud" | "home_assistant") { + return Err(AppError::BadRequest( + "energy source must be auto, gree_cloud or home_assistant".into(), + )); + } + + let (target_type, public_target_id, target_name, member_device_ids, configured_source, storage_id, preferred_source) = + if let Some(group_id) = target_id.strip_prefix("group:") { + let group = state + .db + .get_device_group(group_id)? + .ok_or_else(|| AppError::NotFound(format!("device group {group_id}")))?; + let auto_source = if group.energy_device_id.is_some() { + Some("gree_cloud") + } else if group.ha_energy_entity_id.is_some() { + Some("home_assistant") + } else { + None + }; + let selected = match requested_source { + "gree_cloud" => Some("gree_cloud"), + "home_assistant" => Some("home_assistant"), + _ => match group.energy_source { + EnergySourcePreference::GreeCloud => Some("gree_cloud"), + EnergySourcePreference::HomeAssistant => Some("home_assistant"), + EnergySourcePreference::Auto => auto_source, + }, + }; + let storage_id = match selected { + Some("gree_cloud") => group.energy_device_id.clone().ok_or_else(|| AppError::BadRequest("installation has no GREE Cloud energy source device".into()))?, + _ => format!("group:{}", group.id), + }; + ( + "group", + format!("group:{}", group.id), + group.name, + group.device_ids, + group.energy_source, + storage_id, + selected, + ) + } else { + let device = state + .db + .get_device(target_id)? + .ok_or_else(|| AppError::NotFound(format!("device {target_id}")))?; + let selected = match requested_source { + "gree_cloud" => Some("gree_cloud"), + "home_assistant" => Some("home_assistant"), + "auto" => match device.energy_source { + EnergySourcePreference::GreeCloud => Some("gree_cloud"), + EnergySourcePreference::HomeAssistant => Some("home_assistant"), + EnergySourcePreference::Auto => None, + }, + _ => None, + }; + ( + "device", + device.id.clone(), + device.name, + vec![device.id.clone()], + device.energy_source, + device.id, + selected, + ) + }; + let now = Utc::now(); let since = now - ChronoDuration::days(days); + let comparison_since = compare_shift_days.map(|shift| since - ChronoDuration::days(shift)); let month_start_for_load = chrono::DateTime::::from_naive_utc_and_offset( chrono::NaiveDate::from_ymd_opt(now.year(), now.month(), 1) .expect("valid current month") @@ -537,7 +622,10 @@ async fn energy_history( .expect("valid midnight"), Utc, ); - let load_since = since.min(previous_month_start_for_load); + let mut load_since = since.min(previous_month_start_for_load); + if let Some(compare_since) = comparison_since { + load_since = load_since.min(compare_since); + } let limit = query.limit.unwrap_or(100_000).clamp(1, 200_000); let influx = state.settings.read().await.influxdb.clone(); let cutoff = now - ChronoDuration::days(influx.history_threshold_days as i64); @@ -547,7 +635,7 @@ async fn energy_history( match influxdb::query_energy( &state.http, &influx, - &device.id, + &storage_id, None, load_since, cutoff, @@ -557,7 +645,7 @@ async fn energy_history( .await { Ok(mut archived) => { - archived.extend(state.db.list_energy_readings(&device.id, cutoff, limit)?); + archived.extend(state.db.list_energy_readings(&storage_id, cutoff, limit)?); storage = "influx+sqlite".into(); archived } @@ -568,38 +656,28 @@ async fn energy_history( "warn", "influx.query_error", "InfluxDB energy history query failed", - json!({"device_id": device.id, "error": err.to_string()}), + json!({"target_id": public_target_id, "storage_id": storage_id, "error": err.to_string()}), ); - state.db.list_energy_readings(&device.id, load_since, limit)? + state.db.list_energy_readings(&storage_id, load_since, limit)? } } } else { - state.db.list_energy_readings(&device.id, load_since, limit)? + state.db.list_energy_readings(&storage_id, load_since, limit)? }; samples.sort_by_key(|row| row.timestamp); - let requested_source = query.source.as_deref().unwrap_or("auto"); - let selected_source = match requested_source { - "gree_cloud" => Some("gree_cloud"), - "home_assistant" => Some("home_assistant"), - "auto" => match device.energy_source { - EnergySourcePreference::GreeCloud => Some("gree_cloud"), - EnergySourcePreference::HomeAssistant => Some("home_assistant"), - EnergySourcePreference::Auto => { - if samples.iter().any(|row| row.source == "gree_cloud") { - Some("gree_cloud") - } else if samples.iter().any(|row| row.source == "home_assistant") { - Some("home_assistant") - } else { - None - } - } - }, - _ => { - return Err(AppError::BadRequest( - "energy source must be auto, gree_cloud or home_assistant".into(), - )) + let selected_source = if let Some(source) = preferred_source { + Some(source) + } else if requested_source == "auto" { + if samples.iter().any(|row| row.source == "gree_cloud") { + Some("gree_cloud") + } else if samples.iter().any(|row| row.source == "home_assistant") { + Some("home_assistant") + } else { + None } + } else { + Some(requested_source) }; if let Some(source) = selected_source { samples.retain(|row| row.source == source); @@ -613,82 +691,80 @@ async fn energy_history( Utc, ) } - fn bucket_start( - timestamp: chrono::DateTime, - interval_name: &str, - ) -> chrono::DateTime { + 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"), + date.and_hms_opt(timestamp.hour(), 0, 0).expect("valid hour"), Utc, ), "weekly" => { let iso = date.iso_week(); - midnight( - NaiveDate::from_isoywd_opt(iso.year(), iso.week(), Weekday::Mon) - .expect("valid ISO week"), - ) + midnight(NaiveDate::from_isoywd_opt(iso.year(), iso.week(), Weekday::Mon).expect("valid ISO week")) } - "monthly" => midnight( - NaiveDate::from_ymd_opt(date.year(), date.month(), 1) - .expect("valid month"), - ), + "monthly" => midnight(NaiveDate::from_ymd_opt(date.year(), date.month(), 1).expect("valid month")), _ => midnight(date), } } - - let period_samples = samples - .iter() - .filter(|row| row.timestamp >= since) - .collect::>(); - 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); + fn bucket_rows<'a>( + rows: impl Iterator, + interval_name: &str, + shift_days: i64, + ) -> Vec { + let mut buckets: BTreeMap, f64> = BTreeMap::new(); + for sample in rows { + let shifted = sample.timestamp + ChronoDuration::days(shift_days); + *buckets.entry(bucket_start(shifted, interval_name)).or_default() += sample.consumption_kwh.max(0.0); + } + buckets.into_iter().map(|(start, consumption_kwh)| json!({"start": start, "consumption_kwh": consumption_kwh.max(0.0)})).collect() } - let buckets = buckets - .into_iter() - .map(|(start, consumption_kwh)| { - json!({"start": start, "consumption_kwh": consumption_kwh.max(0.0)}) - }) - .collect::>(); + + let period_samples = samples.iter().filter(|row| row.timestamp >= since && row.timestamp <= now).collect::>(); + let buckets = bucket_rows(period_samples.iter().copied(), interval_name, 0); + + let comparison = if let Some(shift) = compare_shift_days { + let compare_end = now - ChronoDuration::days(shift); + let compare_start = since - ChronoDuration::days(shift); + let rows = samples.iter().filter(|row| row.timestamp >= compare_start && row.timestamp <= compare_end).collect::>(); + let total: f64 = rows.iter().map(|row| row.consumption_kwh.max(0.0)).sum(); + Some(json!({ + "kind": compare_name, + "shift_days": shift, + "period_start": compare_start, + "period_end": compare_end, + "period_total": total, + "buckets": bucket_rows(rows.iter().copied(), interval_name, shift), + })) + } else { + None + }; let today_start = midnight(now.date_naive()); let yesterday_start = today_start - ChronoDuration::days(1); - let month_start = midnight( - NaiveDate::from_ymd_opt(now.year(), now.month(), 1).expect("valid current month"), - ); + let month_start = midnight(NaiveDate::from_ymd_opt(now.year(), now.month(), 1).expect("valid current month")); let previous_month_date = month_start.date_naive() - ChronoDuration::days(1); - let previous_month_start = midnight( - NaiveDate::from_ymd_opt(previous_month_date.year(), previous_month_date.month(), 1) - .expect("valid previous month"), - ); + let previous_month_start = midnight(NaiveDate::from_ymd_opt(previous_month_date.year(), previous_month_date.month(), 1).expect("valid previous month")); let sum_range = |start: chrono::DateTime, stop: chrono::DateTime| -> f64 { - samples - .iter() - .filter(|row| row.timestamp >= start && row.timestamp < stop) - .map(|row| row.consumption_kwh.max(0.0)) - .sum() + samples.iter().filter(|row| row.timestamp >= start && row.timestamp < stop).map(|row| row.consumption_kwh.max(0.0)).sum() }; - let period_total: f64 = period_samples - .iter() - .map(|row| row.consumption_kwh.max(0.0)) - .sum(); + let period_total: f64 = period_samples.iter().map(|row| row.consumption_kwh.max(0.0)).sum(); let latest = samples.last().cloned(); Ok(Json(json!({ - "device_id": device.id, + "target_id": public_target_id, + "target_type": target_type, + "target_name": target_name, + "member_device_ids": member_device_ids, + "device_id": if target_type == "device" { Some(public_target_id.clone()) } else { None:: }, "source": selected_source.unwrap_or("none"), - "configured_source": device.energy_source, + "configured_source": configured_source, "interval": interval_name, "unit": "kWh", "period_days": days, "storage": storage, "storage_warning": storage_warning, "buckets": buckets, + "comparison": comparison, "summary": { "today": sum_range(today_start, now + ChronoDuration::seconds(1)), "yesterday": sum_range(yesterday_start, today_start), diff --git a/src/api/system.rs b/src/api/system.rs index 13a14e3..f482ce6 100644 --- a/src/api/system.rs +++ b/src/api/system.rs @@ -25,6 +25,7 @@ struct BootstrapResponse { devices: Vec, zones: Vec, groups: Vec, + device_groups: Vec, schedules: Vec, automations: Vec, flows: Vec, @@ -65,6 +66,7 @@ async fn build_bootstrap(state: &AppState) -> Result Result { self.delete_by_id("groups", id) } + + pub fn save_device_group(&self, group: &DeviceGroup) -> Result<()> { + let payload = Self::to_json(group)?; + let conn = self.lock()?; + conn.execute( + queries::UPSERT_DEVICE_GROUP, + params![group.id, payload, group.updated_at.to_rfc3339()], + )?; + Ok(()) + } + + pub fn list_device_groups(&self) -> Result> { + self.list_payloads(queries::LIST_DEVICE_GROUPS) + } + + pub fn get_device_group(&self, id: &str) -> Result> { + self.get_payload(queries::GET_DEVICE_GROUP, id) + } + + pub fn device_group_for_device(&self, device_id: &str) -> Result> { + Ok(self.list_device_groups()?.into_iter().find(|group| group.device_ids.iter().any(|id| id == device_id))) + } + + pub fn delete_device_group(&self, id: &str) -> Result { + self.delete_by_id("device_groups", id) + } } impl Db { diff --git a/src/db/configuration.rs b/src/db/configuration.rs index 1a070e4..ed1fe97 100644 --- a/src/db/configuration.rs +++ b/src/db/configuration.rs @@ -7,6 +7,7 @@ impl Db { devices: self.list_devices()?, zones: self.list_zones()?, groups: self.list_groups()?, + device_groups: self.list_device_groups()?, schedules: self.list_schedules()?, automations: self.list_automations()?, flows: self.list_flows()?, @@ -50,6 +51,13 @@ impl Db { params![group.id, payload, group.updated_at.to_rfc3339()], )?; } + for device_group in &export.device_groups { + let payload = Self::to_json(device_group)?; + tx.execute( + queries::UPSERT_DEVICE_GROUP, + params![device_group.id, payload, device_group.updated_at.to_rfc3339()], + )?; + } for schedule in &export.schedules { let payload = Self::to_json(schedule)?; tx.execute( diff --git a/src/db/core_devices.rs b/src/db/core_devices.rs index 3d73bd5..98efc0e 100644 --- a/src/db/core_devices.rs +++ b/src/db/core_devices.rs @@ -89,6 +89,23 @@ impl Db { } pub fn delete_device(&self, id: &str) -> Result { + for mut group in self.list_device_groups()? { + if !group.device_ids.iter().any(|device_id| device_id == id) { continue; } + group.device_ids.retain(|device_id| device_id != id); + if group.energy_device_id.as_deref() == Some(id) { + group.energy_device_id = None; + if group.energy_source == EnergySourcePreference::GreeCloud { + group.energy_source = EnergySourcePreference::Auto; + } + } + if group.outdoor_temperature_device_id.as_deref() == Some(id) { group.outdoor_temperature_device_id = None; } + if group.device_ids.is_empty() { + self.delete_device_group(&group.id)?; + } else { + group.updated_at = Utc::now(); + self.save_device_group(&group)?; + } + } let mut conn = self.lock()?; let tx = conn.transaction()?; tx.execute(queries::DELETE_DEVICE_READINGS, [id])?; diff --git a/src/db/schedules_automations.rs b/src/db/schedules_automations.rs index ba7e7b2..0838ec3 100644 --- a/src/db/schedules_automations.rs +++ b/src/db/schedules_automations.rs @@ -88,6 +88,7 @@ impl Db { "schedules" => queries::DELETE_SCHEDULE, "automations" => queries::DELETE_AUTOMATION, "groups" => queries::DELETE_GROUP, + "device_groups" => queries::DELETE_DEVICE_GROUP, _ => anyhow::bail!("unsupported table"), }; let conn = self.lock()?; diff --git a/src/engine.rs b/src/engine.rs index 65ff8af..d413489 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -2,7 +2,7 @@ use crate::{ error::AppError, home_assistant, influxdb, models::{ - Automation, AutomationPlanRule, ClimateGroup, ConnectionStatus, ConnectionType, ControlPlan, ControlPlanEvent, Device, EnergyReading, + Automation, AutomationPlanRule, ClimateGroup, ConnectionStatus, ConnectionType, ControlPlan, ControlPlanEvent, Device, EnergyReading, EnergySourcePreference, DeviceCommand, GroupControlPatch, HaReading, NightModeSettings, Reading, RuntimeSettings, Schedule, TemporaryQuickThermostat, Zone, ZoneControlPlan, ZoneReading, }, diff --git a/src/engine/energy.rs b/src/engine/energy.rs index 27a9a92..fccbba7 100644 --- a/src/engine/energy.rs +++ b/src/engine/energy.rs @@ -71,10 +71,7 @@ fn queue_influx_energy(state: &AppState, reading: EnergyReading) { }); } -async fn sample_home_assistant_energy_device(state: &AppState, device: &Device) -> Result<(), AppError> { - let Some(entity_id) = device.ha_energy_entity_id.as_deref().filter(|value| !value.trim().is_empty()) else { - return Ok(()); - }; +async fn sample_home_assistant_energy_target(state: &AppState, target_id: &str, entity_id: &str) -> Result<(), AppError> { let settings = state.settings.read().await.home_assistant.clone(); let payload = home_assistant::read_entity(&state.http, &settings, Some(entity_id)) .await @@ -92,10 +89,15 @@ async fn sample_home_assistant_energy_device(state: &AppState, device: &Device) if !matches!(unit.to_ascii_lowercase().as_str(), "wh" | "kwh") { return Err(AppError::BadRequest("Home Assistant energy sensor must use Wh or kWh".into())); } - let _ = record_cumulative_energy_sample(state, &device.id, "home_assistant", raw_value, unit, None)?; + let _ = record_cumulative_energy_sample(state, target_id, "home_assistant", raw_value, unit, None)?; Ok(()) } +async fn sample_home_assistant_energy_device(state: &AppState, device: &Device) -> Result<(), AppError> { + let Some(entity_id) = device.ha_energy_entity_id.as_deref().filter(|value| !value.trim().is_empty()) else { return Ok(()); }; + sample_home_assistant_energy_target(state, &device.id, entity_id).await +} + pub(crate) async fn home_assistant_energy_loop(state: AppState) { sleep(Duration::from_secs(10)).await; loop { @@ -108,6 +110,15 @@ pub(crate) async fn home_assistant_energy_loop(state: AppState) { } } } + if let Ok(groups) = state.db.list_device_groups() { + for group in groups.into_iter().filter(|group| matches!(group.energy_source, EnergySourcePreference::HomeAssistant | EnergySourcePreference::Auto)) { + let Some(entity_id) = group.ha_energy_entity_id.as_deref().filter(|value| !value.trim().is_empty()) else { continue; }; + let target_id = format!("group:{}", group.id); + if let Err(err) = sample_home_assistant_energy_target(&state, &target_id, entity_id).await { + tracing::warn!(group=%group.id, error=?err, "Home Assistant installation energy sample failed"); + } + } + } } sleep(Duration::from_secs(60)).await; } diff --git a/src/engine/history.rs b/src/engine/history.rs index eb3a75f..0a323e3 100644 --- a/src/engine/history.rs +++ b/src/engine/history.rs @@ -1,3 +1,9 @@ +fn installation_outdoor_temperature(state: &AppState, device_id: &str) -> Option { + let group = state.db.device_group_for_device(device_id).ok().flatten()?; + let source_id = group.outdoor_temperature_device_id.as_deref()?; + state.db.get_device(source_id).ok().flatten()?.outdoor_temperature +} + fn record_zone_history( state: &AppState, zone: &Zone, @@ -28,7 +34,9 @@ fn record_zone_history( .or(zone.manual_setpoint) .or(Some(zone.setpoint)), device_setpoint: zone.device_setpoint.or(Some(device.target_temperature)), - outdoor_temperature: outdoor_temperature.or(device.outdoor_temperature), + outdoor_temperature: installation_outdoor_temperature(state, &device.id) + .or(outdoor_temperature) + .or(device.outdoor_temperature), power: device.power, mode: if zone.effective_mode.is_empty() { device.mode.clone() diff --git a/src/engine/polling.rs b/src/engine/polling.rs index 8e0c96f..777f1e1 100644 --- a/src/engine/polling.rs +++ b/src/engine/polling.rs @@ -279,7 +279,7 @@ fn record_reading(state: &AppState, device: &Device) -> Result<()> { device_id: device.id.clone(), timestamp: Utc::now(), indoor_temperature: device.current_temperature, - outdoor_temperature: device.outdoor_temperature, + outdoor_temperature: installation_outdoor_temperature(state, &device.id).or(device.outdoor_temperature), target_temperature: device.target_temperature, power: device.power, source: if device.simulated { "simulator".into() } else { "gree".into() }, diff --git a/src/models/control_plan.rs b/src/models/control_plan.rs index 3ba1873..1a38ff1 100644 --- a/src/models/control_plan.rs +++ b/src/models/control_plan.rs @@ -7,6 +7,8 @@ pub struct ConfigurationExport { pub zones: Vec, #[serde(default)] pub groups: Vec, + #[serde(default)] + pub device_groups: Vec, pub schedules: Vec, pub automations: Vec, #[serde(default)] diff --git a/src/models/device.rs b/src/models/device.rs index 0912570..3542cf2 100644 --- a/src/models/device.rs +++ b/src/models/device.rs @@ -72,6 +72,42 @@ pub enum EnergySourcePreference { HomeAssistant, } +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "snake_case")] +pub enum DeviceGroupKind { + #[default] + Split, + Multisplit, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DeviceGroup { + pub id: String, + pub name: String, + #[serde(default)] + pub kind: DeviceGroupKind, + #[serde(default)] + pub device_ids: Vec, + #[serde(default)] + pub energy_source: EnergySourcePreference, + /// Member device whose cumulative GREE Cloud meter represents the whole installation. + #[serde(default)] + pub energy_device_id: Option, + #[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, + /// Member device whose outdoor sensor is shared by every unit in this installation. + #[serde(default)] + pub outdoor_temperature_device_id: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Device { pub id: String, diff --git a/src/provider.rs b/src/provider.rs index 4edb00f..c0ec481 100644 --- a/src/provider.rs +++ b/src/provider.rs @@ -267,7 +267,7 @@ impl GreeCloudProvider { } pub async fn unregister_device(&self, device_id: &str) { - let cloud_ids = { + let (cloud_ids, no_registered_devices) = { let mut registered = self.inner.registered.write().await; let ids = registered .iter() @@ -275,7 +275,8 @@ impl GreeCloudProvider { .map(|(cloud_id, _)| cloud_id.clone()) .collect::>(); registered.retain(|_, item| item.device_id != device_id); - ids + let empty = registered.is_empty(); + (ids, empty) }; let mut pending = self.inner.pending.lock().await; for cloud_id in cloud_ids { @@ -284,6 +285,18 @@ impl GreeCloudProvider { drop(pending); self.inner.command_locks.lock().await.remove(device_id); self.inner.diagnostics.write().await.remove(device_id); + + // The broker can continue publishing retained/status traffic for topics from the + // previous subscription until the MQTT session is closed. Once the last registered + // Cloud device is removed there is nothing useful to receive, so close the session + // immediately instead of leaving a stale subscription alive until process restart. + if no_registered_devices { + let session = self.inner.session.write().await.take(); + if let Some(session) = session { + session.mqtt.disconnect().await; + tracing::info!("GREE Cloud MQTT disconnected; no registered devices remain"); + } + } } pub async fn poll( @@ -1115,6 +1128,14 @@ impl GreeCloudProvider { "topic": topic, "bytes": payload.len(), })); + // A broker message can race with device deletion. If the last Cloud device has + // already been unregistered, any in-flight message belongs to a stale subscription + // and must be ignored rather than reported as a payload error every few seconds. + let registered = self.inner.registered.read().await.clone(); + if registered.is_empty() { + tracing::debug!(topic, "ignoring GREE Cloud MQTT payload without registered devices"); + return Ok(()); + } if topic.starts_with("connect/") { let parent = topic.split('/').nth(1).unwrap_or_default().to_string(); self.emit_cloud_mqtt(json!({ @@ -1143,7 +1164,6 @@ impl GreeCloudProvider { "cipher": if envelope.tag.is_some() { 2 } else { 1 }, })); let parent_from_topic = topic.split('/').nth(1).unwrap_or_default().to_string(); - let registered = self.inner.registered.read().await.clone(); // Match the reference client primarily by subscribed parent topic. Some GREE // responses use a parent/variant tcid that is not byte-for-byte equal to the // discovery child id; restricting candidates to tcid caused valid frames to be @@ -1752,6 +1772,40 @@ mod tests { assert!(!LEGACY_CLOUD_PROPERTIES.contains(&"CompressorFqy")); } + + #[tokio::test] + async fn mqtt_payload_is_ignored_after_all_cloud_devices_are_removed() { + let provider = GreeCloudProvider::new(reqwest::Client::new()); + let result = provider + .handle_raw_message("status/stale-parent/device", b"not-json-anymore") + .await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn mqtt_payload_for_unknown_parent_still_errors_when_devices_are_registered() { + let provider = GreeCloudProvider::new(reqwest::Client::new()); + provider.inner.registered.write().await.insert( + "AABBCCDDEEFF".into(), + RegisteredDevice { + device_id: "cloud-test".into(), + key: "0123456789abcdef".into(), + parent_mac: "AABBCCDDEE".into(), + cipher_version: 1, + }, + ); + let err = provider + .handle_raw_message( + "status/1122334455/device", + br#"{"pack":"x","tcid":"112233445566"}"#, + ) + .await + .unwrap_err(); + assert!(err + .to_string() + .contains("does not match a registered device")); + } + #[tokio::test] async fn provider_dispatcher_selects_transport_from_connection_type() { let local = GreeClient::new( diff --git a/src/queries/entities.rs b/src/queries/entities.rs index 5ba9edb..ff9852a 100644 --- a/src/queries/entities.rs +++ b/src/queries/entities.rs @@ -47,6 +47,17 @@ pub const LIST_GROUPS: &str = pub const GET_GROUP: &str = "SELECT payload FROM climate_groups WHERE id=?1"; pub const DELETE_GROUP: &str = "DELETE FROM climate_groups WHERE id=?1"; +pub const UPSERT_DEVICE_GROUP: &str = r#" +INSERT INTO device_groups(id,payload,updated_at) VALUES(?1,?2,?3) +ON CONFLICT(id) DO UPDATE SET + payload=excluded.payload, + updated_at=excluded.updated_at +"#; +pub const LIST_DEVICE_GROUPS: &str = + "SELECT payload FROM device_groups ORDER BY json_extract(payload, '$.name') COLLATE NOCASE"; +pub const GET_DEVICE_GROUP: &str = "SELECT payload FROM device_groups WHERE id=?1"; +pub const DELETE_DEVICE_GROUP: &str = "DELETE FROM device_groups WHERE id=?1"; + pub const UPSERT_SCHEDULE: &str = r#" INSERT INTO schedules(id,zone_id,payload,updated_at) VALUES(?1,?2,?3,?4) ON CONFLICT(id) DO UPDATE SET diff --git a/src/queries/maintenance.rs b/src/queries/maintenance.rs index 3c33f04..4930eb3 100644 --- a/src/queries/maintenance.rs +++ b/src/queries/maintenance.rs @@ -39,6 +39,7 @@ DELETE FROM schedules; DELETE FROM automations; DELETE FROM flows; DELETE FROM climate_groups; +DELETE FROM device_groups; DELETE FROM zones; DELETE FROM devices; "#; diff --git a/src/queries/schema.rs b/src/queries/schema.rs index add69ea..4627cc9 100644 --- a/src/queries/schema.rs +++ b/src/queries/schema.rs @@ -36,6 +36,12 @@ CREATE TABLE IF NOT EXISTS climate_groups ( updated_at TEXT NOT NULL ); +CREATE TABLE IF NOT EXISTS device_groups ( + id TEXT PRIMARY KEY, + payload TEXT NOT NULL, + updated_at TEXT NOT NULL +); + CREATE TABLE IF NOT EXISTS schedules ( id TEXT PRIMARY KEY, zone_id TEXT NOT NULL, @@ -155,5 +161,7 @@ INSERT OR IGNORE INTO schema_migrations(version, applied_at) VALUES (6, strftime('%Y-%m-%dT%H:%M:%fZ','now')); INSERT OR IGNORE INTO schema_migrations(version, applied_at) VALUES (7, strftime('%Y-%m-%dT%H:%M:%fZ','now')); +INSERT OR IGNORE INTO schema_migrations(version, applied_at) + VALUES (8, strftime('%Y-%m-%dT%H:%M:%fZ','now')); "#; diff --git a/web/index.html b/web/index.html index 039776f..76c1a2f 100644 --- a/web/index.html +++ b/web/index.html @@ -146,10 +146,11 @@
Local / GREE Cloud

Devices

-
+

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

+
@@ -752,7 +753,7 @@ -
+

MQTT push is primary. Polling is used only for initial state, recovery and periodic verification.

- +
@@ -1187,7 +1188,7 @@

Changing protocol clears the saved device key. Save and check connection immediately performs the required bind and test.

- +
+ +
+
Installation

Split / multisplit installations

+

Group indoor units that share one outdoor unit. Energy and outdoor temperature can use one shared source.

+
+
+ +
+ + +
Indoor units
+ + + + +

+
+
+
+
+
+
GREE Cloud

Cloud diagnostics

diff --git a/web/js/bootstrap.js b/web/js/bootstrap.js index 5e15725..ec58d4a 100644 --- a/web/js/bootstrap.js +++ b/web/js/bootstrap.js @@ -34,6 +34,7 @@ function settingsFromSnapshot(sections, houseMode = app.settings?.house_mode || function applyBootstrapSnapshot(data) { app.devices = data.devices || []; + app.deviceGroups = data.device_groups || []; app.zones = data.zones || []; app.groups = data.groups || []; app.schedules = data.schedules || []; diff --git a/web/js/charts.js b/web/js/charts.js index a9ba980..438e9b0 100644 --- a/web/js/charts.js +++ b/web/js/charts.js @@ -25,6 +25,32 @@ function historyEntityOptions() { return { zones, devices, sensors, entities }; } +function energyHistoryTargets() { + const configuredGroups = (app.deviceGroups || []).filter(group => !!group.energy_device_id || !!group.ha_energy_entity_id); + const groups = configuredGroups.map(group => ({ + id: `group:${group.id}`, + label: `${group.name} · ${deviceInstallationKindLabel(group)}`, + type: 'group', + group, + })); + const groupedIds = new Set(configuredGroups.flatMap(group => group.device_ids || [])); + const devices = app.devices + .filter(device => (device.capabilities?.energy_meter === true || !!device.ha_energy_entity_id) && !groupedIds.has(device.id)) + .map(device => ({ id: device.id, label: `${device.name} · ${deviceTransportLabel(device)}`, type: 'device', device })); + return [...groups, ...devices]; +} + +function normalizeEnergyHistoryTargets() { + const available = energyHistoryTargets(); + const ids = new Set(available.map(item => item.id)); + app.historyEnergyTargets = (app.historyEnergyTargets || []).filter(id => ids.has(id)); + if (!app.historyEnergyTargets.length && app.historyEnergyDevice && ids.has(app.historyEnergyDevice)) app.historyEnergyTargets = [app.historyEnergyDevice]; + if (!app.historyEnergyTargets.length && available.length) app.historyEnergyTargets = [available[0].id]; + app.historyEnergyTargets = app.historyEnergyTargets.slice(0, 8); + app.historyEnergyDevice = app.historyEnergyTargets[0] || ''; + return available; +} + function renderHistoryNavigation() { $$('#historyTabs [data-history-tab]').forEach(button => button.classList.toggle('active', button.dataset.historyTab === app.historyTab)); const host = $('#historyContextControls'); if (!host) return; @@ -36,14 +62,15 @@ function renderHistoryNavigation() { host.innerHTML = ``; 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 - ? `` + const targets = normalizeEnergyHistoryTargets(); + const targetOptions = targets.map(target => ``).join(''); + host.innerHTML = targets.length + ? `` : `${esc(tr('energy.noData'))}`; - const deviceSelect = $('#historyEnergyDeviceSelect'); if (deviceSelect && [...deviceSelect.options].some(option => option.value === app.historyEnergyDevice)) deviceSelect.value = app.historyEnergyDevice; + const targetSelect = $('#historyEnergyTargetSelect'); + if (targetSelect) [...targetSelect.options].forEach(option => { option.selected = app.historyEnergyTargets.includes(option.value); }); const intervalSelect = $('#historyEnergyInterval'); if (intervalSelect) intervalSelect.value = app.historyEnergyInterval; + const compareSelect = $('#historyEnergyCompare'); if (compareSelect) compareSelect.value = app.historyEnergyCompare; } else if (app.historyTab === 'sensors') { host.innerHTML = ``; const select = $('#historySensorSelect'); if ([...select.options].some(option => option.value === app.historySensor)) select.value = app.historySensor; @@ -55,7 +82,7 @@ function renderHistoryNavigation() { } async function loadHistory() { - if (app.historyLoading) return; + if (app.historyLoading) { app.historyReloadPending = true; return; } app.historyLoading = true; const hours = $('#historyHours')?.value || '24'; try { @@ -75,7 +102,10 @@ async function loadHistory() { } catch (error) { toast(error.message, true); renderHistoryPage(); - } finally { app.historyLoading = false; } + } finally { + app.historyLoading = false; + if (app.historyReloadPending) { app.historyReloadPending = false; setTimeout(() => loadHistory(), 0); } + } } const HISTORY_COLORS = ['--accent', '--info', '--warning', '--purple', '--danger', '--teal', '--orange', '--blue']; @@ -116,7 +146,7 @@ function redrawHistoryChart(id) { if (!runtime || !canvas) return; canvas.dataset.chartZoom = String(clamp(Number(app.chartZooms[id] || 1), 1, MAX_CHART_ZOOM)); if (runtime.kind === 'energy') { - drawEnergyBarChart(canvas, runtime.buckets, runtime.options); + drawEnergyBarChart(canvas, runtime.series, runtime.options); } else { drawLineChart(canvas, runtime.series, runtime.rows, runtime.options); renderLegend(document.getElementById(`${id}Legend`), runtime.series); @@ -438,24 +468,42 @@ function drawLineChart(canvas, series, rows, { height = 340, minValue = null, ma bindChartTooltip(canvas, visibleSeries, sortedRows, { pad, width, height, firstTs, lastTs, binaryLabels }); } -function drawEnergyBarChart(canvas, buckets, { height = 340 } = {}) { +function energyBucketLabel(timestamp, interval) { + const date = new Date(timestamp); + if (interval === 'hourly') return new Intl.DateTimeFormat(locale(), { hour: '2-digit', minute: '2-digit' }).format(date); + if (interval === 'monthly') return new Intl.DateTimeFormat(locale(), { month: 'short', year: '2-digit' }).format(date); + return new Intl.DateTimeFormat(locale(), { day: '2-digit', month: '2-digit', ...(interval === 'weekly' ? { year: '2-digit' } : {}) }).format(date); +} + +function drawEnergyBarChart(canvas, seriesInput, { height = 340, interval = app.historyEnergyInterval } = {}) { if (!canvas) return; - const options = { height }; - if (canvas.id) chartRuntime.set(canvas.id, { kind: 'energy', buckets, options }); - if (!buckets?.length) return drawEmptyChart(canvas, height); + const series = Array.isArray(seriesInput) && seriesInput.length && Array.isArray(seriesInput[0]?.buckets) + ? seriesInput + : [{ key: 'energy', label: 'kWh', color: historySeriesColor(0), buckets: Array.isArray(seriesInput) ? seriesInput : [] }]; + const options = { height, interval }; + if (canvas.id) chartRuntime.set(canvas.id, { kind: 'energy', series, options }); + const visibleSeries = series.filter((item, index) => !isChartSeriesHidden(canvas.id, item, index)); + const starts = [...new Set(visibleSeries.flatMap(item => (item.buckets || []).map(row => row.start)))].sort((a, b) => new Date(a) - new Date(b)); + if (!starts.length || !visibleSeries.length) { + drawEmptyChart(canvas, height); + renderLegend(document.getElementById(`${canvas.id}Legend`), series); + updateChartZoomControls(canvas.id); + return; + } + const maps = visibleSeries.map(item => new Map((item.buckets || []).map(row => [row.start, Math.max(0, Number(row.consumption_kwh) || 0)]))); + const values = maps.flatMap(map => starts.map(start => map.get(start) || 0)); const prepared = prepareCanvas(canvas, height); const { ctx, width } = prepared; height = prepared.height; const text = cssColor('--muted', '#888'); const grid = cssColor('--grid', '#333'); - const fill = cssColor('--accent', '#3ecf8e'); const pad = { left: 58, right: 18, top: 20, bottom: 48 }; - const values = buckets.map(row => Math.max(0, Number(row.consumption_kwh) || 0)); const max = Math.max(0.1, ...values) * 1.1; const plotW = width - pad.left - pad.right; const plotH = height - pad.top - pad.bottom; - const slot = plotW / Math.max(1, buckets.length); - const barW = Math.max(2, Math.min(slot * 0.72, 42)); + const slot = plotW / Math.max(1, starts.length); + const groupW = Math.max(4, Math.min(slot * 0.78, 56)); + const barW = Math.max(2, groupW / Math.max(1, visibleSeries.length)); ctx.font = '10px system-ui'; ctx.fillStyle = text; ctx.strokeStyle = grid; ctx.lineWidth = 1; for (let i = 0; i <= 5; i++) { const value = max * i / 5; @@ -463,25 +511,28 @@ function drawEnergyBarChart(canvas, buckets, { height = 340 } = {}) { ctx.beginPath(); ctx.moveTo(pad.left, y); ctx.lineTo(width - pad.right, y); ctx.stroke(); ctx.textAlign = 'right'; ctx.fillText(`${value.toFixed(value < 1 ? 2 : 1)}`, pad.left - 8, y + 3); } - ctx.fillStyle = fill; - buckets.forEach((row, index) => { - const value = values[index]; - const x = pad.left + slot * index + (slot - barW) / 2; - const barH = (value / max) * plotH; - ctx.fillRect(x, pad.top + plotH - barH, barW, barH); + starts.forEach((start, bucketIndex) => { + const baseX = pad.left + slot * bucketIndex + (slot - groupW) / 2; + visibleSeries.forEach((item, seriesIndex) => { + const value = maps[seriesIndex].get(start) || 0; + const barH = (value / max) * plotH; + ctx.save(); + ctx.globalAlpha = item.comparison ? 0.45 : 0.92; + ctx.fillStyle = item.color || historySeriesColor(seriesIndex); + ctx.fillRect(baseX + seriesIndex * barW, pad.top + plotH - barH, Math.max(1, barW - 1), barH); + ctx.restore(); + }); }); ctx.fillStyle = text; - const ticks = Math.min(6, buckets.length); + const ticks = Math.min(6, starts.length); for (let i = 0; i < ticks; i++) { - const index = ticks === 1 ? 0 : Math.round(i * (buckets.length - 1) / (ticks - 1)); + const index = ticks === 1 ? 0 : Math.round(i * (starts.length - 1) / (ticks - 1)); const x = pad.left + slot * index + slot / 2; ctx.textAlign = 'center'; - ctx.fillText(timeLabel(buckets[index].start, $('#historyHours')?.value), x, height - 18); + ctx.fillText(energyBucketLabel(starts[index], interval), x, height - 18); } - ctx.textAlign = 'left'; - ctx.fillText('kWh', 8, pad.top + 4); - const legend = document.getElementById(`${canvas.id}Legend`); - if (legend) legend.innerHTML = `kWh`; + ctx.textAlign = 'left'; ctx.fillText('kWh', 8, pad.top + 4); + renderLegend(document.getElementById(`${canvas.id}Legend`), series); updateChartZoomControls(canvas.id); } diff --git a/web/js/core.js b/web/js/core.js index e6787c7..6b54457 100644 --- a/web/js/core.js +++ b/web/js/core.js @@ -25,12 +25,12 @@ const preferredLanguage = getCookie('gree_controller_language') || DEFAULT_LANGU const preferredTheme = ['system', 'light', 'dark'].includes(getCookie('gree_controller_theme')) ? getCookie('gree_controller_theme') : 'system'; const app = { - devices: [], zones: [], groups: [], schedules: [], automations: [], flows: [], accessTokens: [], settings: null, system: {}, outdoorTemperature: null, + devices: [], zones: [], groups: [], deviceGroups: [], schedules: [], automations: [], flows: [], accessTokens: [], settings: null, system: {}, outdoorTemperature: null, token: localStorage.getItem('gree_controller_token') || '', ws: null, wsTimer: null, currentView: 'dashboard', loading: false, bootstrapReloadPending: false, language: preferredLanguage, theme: preferredTheme, connectionStatus: 'connecting', languages: [], translations: {}, locales: {}, historyTab: 'overview', historyData: { zones: [], devices: [], sensors: [] }, historyCounts: {}, - historyZone: 'all', historyDevice: 'all', historySensor: 'all', historyEnergyDevice: '', historyEnergyInterval: 'daily', historyEnergy: null, historyLoading: false, + historyZone: 'all', historyDevice: 'all', historySensor: 'all', historyEnergyDevice: '', historyEnergyTargets: [], historyEnergyInterval: 'daily', historyEnergyCompare: 'none', historyEnergy: [], historyLoading: false, historyReloadPending: false, customChartSeries: [], savedCharts: [], chartZooms: {}, chartHiddenSeries: {}, zoneControlSeq: {}, groupControlSeq: {}, zoneControlQueue: {}, groupControlQueue: {}, deviceControlQueue: {}, houseControlQueue: null, climateControlQueue: null, groupCustomDrafts: {}, zoneTemperatureTimers: {}, deviceTemperatureDrafts: {}, controlPlan: null, controlPlanRevision: null, controlPlanPushReady: false, controlPlanTimer: null, debugLines: [], debugBacklogLoaded: false, debugFilter: 'all', sensorAliases: {}, flowSharedInputs: [], flowSharedInputValueCache: {}, flowSharedInputValueRequests: {}, flowSharedInputValueTimer: null, simulationScope: 'units', simulationTarget: 'all', standaloneSimulation: false, dashboardTab: 'main', settingsTab: 'app', systemSnapshotAt: Date.now(), diff --git a/web/js/entities.js b/web/js/entities.js index 47bc894..c1e3c4b 100644 --- a/web/js/entities.js +++ b/web/js/entities.js @@ -24,6 +24,35 @@ function deviceTransportLabel(device) { return device.connection_type === 'gree_cloud' ? 'GREE Cloud' : 'Local'; } +function deviceInstallationForDevice(deviceId) { + return (app.deviceGroups || []).find(group => (group.device_ids || []).includes(deviceId)) || null; +} + +function deviceInstallationKindLabel(group) { + return tr(group?.kind === 'multisplit' ? 'devices.multisplit' : 'devices.split'); +} + +function installationEnergySourceLabel(group) { + if (!group) return '—'; + if (group.energy_source === 'home_assistant' || (group.energy_source === 'auto' && group.ha_energy_entity_id && !group.energy_device_id)) { + return group.ha_energy_entity_id ? `Home Assistant · ${group.ha_energy_entity_id}` : 'Home Assistant'; + } + if (group.energy_source === 'gree_cloud' || group.energy_device_id) { + const source = app.devices.find(device => device.id === group.energy_device_id); + return source ? `GREE Cloud · ${source.name}` : 'GREE Cloud'; + } + return tr('energy.auto'); +} + +function effectiveDeviceOutdoorTemperature(device) { + const group = deviceInstallationForDevice(device?.id); + if (group?.outdoor_temperature_device_id) { + const source = app.devices.find(item => item.id === group.outdoor_temperature_device_id); + if (source?.outdoor_temperature != null) return source.outdoor_temperature; + } + return device?.outdoor_temperature; +} + function deviceConnectionStatusLabel(device) { const key = { online: 'status.online', @@ -85,7 +114,7 @@ function manualLocalDeviceCard(device) {
${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)}`}
+
${esc(tr('devices.currentTemperature'))}: ${fmtTemp(device.current_temperature)}${effectiveDeviceOutdoorTemperature(device) == null ? '' : ` · ${esc(tr('devices.outdoor'))} ${fmtTemp(effectiveDeviceOutdoorTemperature(device))}`}
${compressorQueuePanel(managedZone)}
${modes.map(mode => ``).join('')}
${fans.map(fan => ``).join('')}
@@ -123,7 +152,7 @@ function manualCloudDeviceCard(device) {
${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)}`}
+
${esc(tr('devices.currentTemperature'))}: ${fmtTemp(device.current_temperature)}${effectiveDeviceOutdoorTemperature(device) == null ? '' : ` · ${esc(tr('devices.outdoor'))} ${fmtTemp(effectiveDeviceOutdoorTemperature(device))}`}
${compressorQueuePanel(managedZone)} ${modes.length ? `
${modes.map(mode => ``).join('')}
` : ''} ${fans.length ? `
${fans.map(fan => ``).join('')}
` : ''} @@ -148,7 +177,7 @@ function technicalDeviceCard(device) { const model = device.model || tr('common.unavailable'); const firmware = device.firmware || tr('common.unavailable'); const cid = device.cid || tr('common.unavailable'); - const error = device.last_error ? `
${esc(tr('devices.lastError'))}${esc(device.last_error)}
` : ''; + const error = device.last_error ? `
${esc(tr('devices.lastError'))}${esc(device.last_error)}
` : ''; return `
${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)}

@@ -162,6 +191,7 @@ function technicalDeviceCard(device) {
${esc(tr('devices.modelFirmware'))}${esc(model)}${esc(firmware)}
${esc(tr('devices.lastSeen'))}${esc(lastSeen)}
${esc(tr('devices.communicationFailures'))}${esc(device.communication_failures ?? 0)}
+ ${deviceInstallationForDevice(device.id) ? `
${esc(tr('devices.installation'))}${esc(deviceInstallationForDevice(device.id).name)}${esc(deviceInstallationKindLabel(deviceInstallationForDevice(device.id)))}
` : ''}
${error}
@@ -182,7 +212,7 @@ function cloudTechnicalDeviceCard(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)}
` : ''; + 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

@@ -198,6 +228,7 @@ function cloudTechnicalDeviceCard(device) {
${esc(tr('devices.lastSync'))}${esc(lastSync)}
${esc(tr('devices.lastResponse'))}${esc(lastSeen)}${esc(responseTime)}
${esc(tr('devices.communicationFailures'))}${esc(device.communication_failures ?? 0)}
+ ${deviceInstallationForDevice(device.id) ? `
${esc(tr('devices.installation'))}${esc(deviceInstallationForDevice(device.id).name)}${esc(deviceInstallationKindLabel(deviceInstallationForDevice(device.id)))}
` : ''}
${error}
@@ -209,7 +240,19 @@ function cloudTechnicalDeviceCard(device) {
`; } +function renderDeviceInstallationsSummary() { + const host = $('#deviceGroupsSummary'); + if (!host) return; + const groups = app.deviceGroups || []; + host.innerHTML = groups.length ? groups.map(group => { + const members = (group.device_ids || []).map(id => app.devices.find(device => device.id === id)?.name).filter(Boolean); + const outdoor = app.devices.find(device => device.id === group.outdoor_temperature_device_id)?.name; + return `
${esc(deviceInstallationKindLabel(group))}${esc(group.name)}${esc(members.join(' · ') || '—')}
${esc(tr('energy.source'))}${esc(installationEnergySourceLabel(group))}${outdoor ? `${esc(tr('devices.outdoorSourceDevice'))}: ${esc(outdoor)}` : ''}
`; + }).join('') : ''; +} + function renderDevices() { + renderDeviceInstallationsSummary(); const empty = `
${esc(tr('devices.emptyTitle'))}${esc(tr('devices.emptyText'))}
`; $('#dashboardDevices').innerHTML = app.devices.length ? app.devices.map(manualDeviceCard).join('') : empty; $('#deviceList').innerHTML = app.devices.length ? app.devices.map(technicalDeviceCard).join('') : empty; diff --git a/web/js/events.js b/web/js/events.js index 834f617..3f9af6c 100644 --- a/web/js/events.js +++ b/web/js/events.js @@ -85,6 +85,19 @@ document.addEventListener('click', async event => { finally { button.disabled = false; } return; } + if (action === 'edit-device-group') { await openDeviceGroupsDialog(button.dataset.id || ''); return; } + if (action === 'delete-device-group') { + const group = (app.deviceGroups || []).find(item => item.id === button.dataset.id); + if (!group || !confirm(`${tr('actions.delete')} ${group.name}?`)) return; + try { + await api(`/api/device-groups/${encodeURIComponent(group.id)}`, { method: 'DELETE' }); + await loadBootstrap(); + renderDeviceGroupsDialogList(); + await populateDeviceGroupForm(null); + toast(tr('devices.installationDeleted')); + } catch (error) { toast(error.message, true); } + return; + } const device = app.devices.find(v => v.id === button.dataset.device); if (action === 'open-ping' && device) { openDevicePing(device.id); return; } if (action === 'ping-toggle') { if (app.pingMonitor.running) stopPingMonitor(); else startPingMonitor(); return; } @@ -361,7 +374,7 @@ $('#renameDeviceForm').addEventListener('submit', async event => { if (isCloud) { const check = await api('/api/integrations/gree-cloud/test', { method: 'POST' }); if (!check.ok) throw new Error(check.message || check.status || tr('settings.cloudConnectionFailed')); - if (result) { result.hidden = false; result.classList.add('success'); result.textContent = tr('settings.cloudConnected', { count: Number(check.device_count || 0) }); } + if (result) { const message = tr('settings.cloudConnected', { count: Number(check.device_count || 0) }); result.hidden = false; result.classList.add('success'); result.innerHTML = `${esc(tr('status.online'))}${esc(message)}`; } markFormClean(form); renderAll(); toast(tr('devices.savedAndChecked')); return; } const protocolChanged = previous && Number(previous.protocol_version) !== Number(raw.protocol_version); @@ -380,7 +393,8 @@ $('#renameDeviceForm').addEventListener('submit', async event => { } if (result) { result.hidden = false; result.classList.add('success'); - result.textContent = tr('devices.connectionCheckOk', { ms: Math.round(Number(probe.response_time_ms || 0)) }); + const message = tr('devices.connectionCheckOk', { ms: Math.round(Number(probe.response_time_ms || 0)) }); + result.innerHTML = `${esc(tr('status.online'))}${esc(message)}`; } markFormClean(form); renderAll(); @@ -388,7 +402,8 @@ $('#renameDeviceForm').addEventListener('submit', async event => { } catch (error) { if (result) { result.hidden = false; result.classList.add('error'); - result.textContent = tr('devices.connectionCheckFailed', { error: error.message || String(error) }); + const message = tr('devices.connectionCheckFailed', { error: error.message || String(error) }); + result.innerHTML = `${esc(tr('devices.lastError'))}${esc(message)}`; } markFormClean(form); renderAll(); @@ -401,17 +416,22 @@ $('#deviceDetailsForm')?.addEventListener('submit', async event => { event.preventDefault(); const form = event.currentTarget; const raw = Object.fromEntries(new FormData(form)); - const option = form.ha_energy_entity_id.selectedOptions?.[0]; - const patch = { - name: raw.name.trim(), - energy_source: raw.energy_source || 'auto', - ha_energy_entity_id: raw.ha_energy_entity_id || null, - ha_energy_unit: option?.value ? (option.dataset.unit || null) : null, - ha_energy_device_class: option?.value ? (option.dataset.deviceClass || null) : null, - ha_energy_state_class: option?.value ? (option.dataset.stateClass || null) : null, - }; + const deviceId = form.elements.id.value; + const installation = deviceInstallationForDevice(deviceId); + const groupOwnsEnergy = !!installation && (!!installation.energy_device_id || !!installation.ha_energy_entity_id); + const patch = { name: String(raw.name || '').trim() }; + if (!groupOwnsEnergy) { + const option = form.ha_energy_entity_id.selectedOptions?.[0]; + Object.assign(patch, { + energy_source: raw.energy_source || 'auto', + ha_energy_entity_id: raw.ha_energy_entity_id || null, + ha_energy_unit: option?.value ? (option.dataset.unit || null) : null, + ha_energy_device_class: option?.value ? (option.dataset.deviceClass || null) : null, + ha_energy_state_class: option?.value ? (option.dataset.stateClass || null) : null, + }); + } await runFormTask(form, async () => { - const device = await api(`/api/devices/${encodeURIComponent(raw.id)}`, { method: 'PATCH', body: patch }); + const device = await api(`/api/devices/${encodeURIComponent(deviceId)}`, { method: 'PATCH', body: patch }); updateDevice(device); form.closest('dialog').close(); renderAll(); @@ -421,6 +441,52 @@ $('#deviceDetailsForm')?.addEventListener('submit', async event => { $('#deviceDetailsForm')?.ha_energy_entity_id?.addEventListener('change', updateDeviceEnergySensorMeta); + +$('#deviceGroupsButton')?.addEventListener('click', () => openDeviceGroupsDialog()); +$('#deviceGroupNew')?.addEventListener('click', () => populateDeviceGroupForm(null)); +$('#deviceGroupForm')?.addEventListener('change', event => { + const form = event.currentTarget; + if (event.target.name === 'kind' && form.kind.value === 'split') { + const checked = $$('#deviceGroupDeviceChoices input[name="device_ids"]:checked'); + checked.slice(1).forEach(input => { input.checked = false; }); + syncDeviceGroupMemberSelects(); + } + if (event.target.name === 'device_ids') { + if (form.kind.value === 'split' && event.target.checked) { + $$('#deviceGroupDeviceChoices input[name="device_ids"]:checked').forEach(input => { if (input !== event.target) input.checked = false; }); + } + syncDeviceGroupMemberSelects(); + } + if (event.target.name === 'ha_energy_entity_id') updateDeviceGroupEnergySensorMeta(); +}); +$('#deviceGroupForm')?.addEventListener('submit', async event => { + event.preventDefault(); + const form = event.currentTarget; + const ids = selectedDeviceGroupIds(); + if (!ids.length) return showFormError(form, tr('energy.selectAtLeastOne')); + const option = form.ha_energy_entity_id.selectedOptions?.[0]; + const body = { + name: form.name.value.trim(), + kind: form.kind.value || 'split', + device_ids: ids, + energy_source: form.energy_source.value || 'auto', + energy_device_id: form.energy_device_id.value || null, + ha_energy_entity_id: form.ha_energy_entity_id.value || null, + ha_energy_unit: option?.value ? (option.dataset.unit || null) : null, + ha_energy_device_class: option?.value ? (option.dataset.deviceClass || null) : null, + ha_energy_state_class: option?.value ? (option.dataset.stateClass || null) : null, + outdoor_temperature_device_id: form.outdoor_temperature_device_id.value || null, + }; + const id = form.elements.id.value; + await runFormTask(form, async () => { + await api(id ? `/api/device-groups/${encodeURIComponent(id)}` : '/api/device-groups', { method: id ? 'PUT' : 'POST', body }); + await loadBootstrap(); + renderDeviceGroupsDialogList(); + await populateDeviceGroupForm(null); + toast(tr('devices.installationSaved')); + }); +}); + $('#cloudDiagnosticsRefresh')?.addEventListener('click', () => { const id = $('#cloudDiagnosticsDialog')?.dataset.deviceId; if (id) openCloudDiagnostics(id); diff --git a/web/js/forms.js b/web/js/forms.js index ec138c9..a07ac2c 100644 --- a/web/js/forms.js +++ b/web/js/forms.js @@ -63,9 +63,9 @@ function showFieldError(field, message) { function showFormError(form, message) { if (!form || !message) return; const summary = document.createElement('div'); - summary.className = 'form-error-summary'; + summary.className = 'inline-alert error form-error-summary'; summary.setAttribute('role', 'alert'); - summary.textContent = message; + summary.innerHTML = `${esc(tr('devices.lastError'))}${esc(message)}`; const anchor = $('.settings-save-bar', form) || $('.form-actions', form); if (anchor?.parentElement) anchor.parentElement.insertBefore(summary, anchor); else form.appendChild(summary); diff --git a/web/js/history.js b/web/js/history.js index 5e846ae..20c8950 100644 --- a/web/js/history.js +++ b/web/js/history.js @@ -200,40 +200,66 @@ async function handleHistoryAction(button) { async function loadEnergyHistory() { - const energyDevices = app.devices.filter(device => device.capabilities?.energy_meter === true || !!device.ha_energy_entity_id); - if (!energyDevices.length) { - app.historyEnergy = null; + const targets = normalizeEnergyHistoryTargets(); + if (!targets.length || !app.historyEnergyTargets.length) { + app.historyEnergy = []; return; } - if (!energyDevices.some(device => device.id === app.historyEnergyDevice)) app.historyEnergyDevice = energyDevices[0].id; const hours = Number($('#historyHours')?.value || 24); const days = Math.max(1, Math.ceil(hours / 24)); - app.historyEnergy = await api(`/api/history/energy?device_id=${encodeURIComponent(app.historyEnergyDevice)}&interval=${encodeURIComponent(app.historyEnergyInterval)}&days=${days}&limit=100000`); + const selected = app.historyEnergyTargets.slice(0, 8); + app.historyEnergy = await Promise.all(selected.map(targetId => api(`/api/history/energy?target_id=${encodeURIComponent(targetId)}&interval=${encodeURIComponent(app.historyEnergyInterval)}&days=${days}&limit=100000&compare=${encodeURIComponent(app.historyEnergyCompare)}`))); } function renderEnergyHistorySummary() { const host = $('#historySummary'); if (!host) return; - const data = app.historyEnergy; - if (!data || data.source === 'none') { host.innerHTML = ''; return; } - const summary = data.summary || {}; - const rows = [ - [tr('energy.today'), summary.today], - [tr('energy.yesterday'), summary.yesterday], - [tr('energy.currentMonth'), summary.current_month], - [tr('energy.previousMonth'), summary.previous_month], - [tr('energy.periodTotal'), summary.period_total], - ]; - host.innerHTML = rows.map(([label, value]) => `
${esc(label)}${Number(value || 0).toLocaleString(locale(), { maximumFractionDigits: 3 })} kWh${esc(data.source === 'gree_cloud' ? 'GREE Cloud' : 'Home Assistant')}
`).join(''); + const data = (app.historyEnergy || []).filter(item => item && item.source !== 'none'); + if (!data.length) { host.innerHTML = ''; return; } + if (data.length === 1) { + const item = data[0]; + const summary = item.summary || {}; + const rows = [ + [tr('energy.today'), summary.today], + [tr('energy.yesterday'), summary.yesterday], + [tr('energy.currentMonth'), summary.current_month], + [tr('energy.previousMonth'), summary.previous_month], + [tr('energy.periodTotal'), summary.period_total], + ]; + if (item.comparison) rows.push([tr('energy.comparisonPeriod'), item.comparison.period_total]); + host.innerHTML = rows.map(([label, value]) => `
${esc(label)}${Number(value || 0).toLocaleString(locale(), { maximumFractionDigits: 3 })} kWh${esc(item.target_name || '')} · ${esc(item.source === 'gree_cloud' ? 'GREE Cloud' : 'Home Assistant')}
`).join(''); + return; + } + host.innerHTML = data.map(item => `
${esc(item.target_name || item.target_id)}${Number(item.summary?.period_total || 0).toLocaleString(locale(), { maximumFractionDigits: 3 })} kWh${esc(item.source === 'gree_cloud' ? 'GREE Cloud' : 'Home Assistant')}${item.comparison ? ` · ${esc(tr('energy.comparisonPeriod'))}: ${Number(item.comparison.period_total || 0).toLocaleString(locale(), { maximumFractionDigits: 3 })} kWh` : ''}
`).join(''); } function renderEnergyHistory() { const host = $('#historyCharts'); if (!host) return; - const data = app.historyEnergy; + const data = (app.historyEnergy || []).filter(item => item && item.source !== 'none'); renderEnergyHistorySummary(); - if (!data || data.source === 'none') { + if (!data.length) { host.innerHTML = `
${esc(tr('energy.noData'))}
`; return; } + const series = []; + data.forEach((item, index) => { + const color = historySeriesColor(index); + series.push({ + key: `${item.target_id}:current`, + label: data.length === 1 ? `${item.target_name} · ${tr('energy.currentPeriod')}` : item.target_name, + color, + buckets: item.buckets || [], + }); + if (app.historyEnergyCompare !== 'none' && item.comparison?.buckets?.length) { + series.push({ + key: `${item.target_id}:comparison`, + label: `${item.target_name} · ${tr('energy.comparisonPeriod')}`, + color, + comparison: true, + buckets: item.comparison.buckets, + }); + } + }); host.innerHTML = historyChartMarkup('energyConsumptionChart', tr('energy.title'), tr('energy.chartHint')); - drawEnergyBarChart($('#energyConsumptionChart'), data.buckets || [], { height: 360 }); + drawEnergyBarChart($('#energyConsumptionChart'), series, { height: 360, interval: app.historyEnergyInterval }); } + diff --git a/web/js/navigation.js b/web/js/navigation.js index 2177fa1..7b58620 100644 --- a/web/js/navigation.js +++ b/web/js/navigation.js @@ -698,7 +698,7 @@ async function openDeviceDetails(id) { const device = app.devices.find(item => item.id === id); if (!device) return; const form = $('#deviceDetailsForm'); if (!form) return; form.reset(); - form.id.value = device.id; + form.elements.id.value = device.id; form.name.value = device.name || ''; form.energy_source.value = device.energy_source || 'auto'; const title = $('#deviceDetailsTitle'); @@ -707,10 +707,24 @@ async function openDeviceDetails(id) { if (meta) meta.innerHTML = device.connection_type === 'gree_cloud' ? `
${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 installation = deviceInstallationForDevice(device.id); + const groupNote = $('#deviceEnergyGroupNote'); + const groupOwnsEnergy = !!installation && (!!installation.energy_device_id || !!installation.ha_energy_entity_id); + if (groupNote) { + groupNote.hidden = !groupOwnsEnergy; + groupNote.innerHTML = groupOwnsEnergy + ? `${esc(deviceInstallationKindLabel(installation))}${esc(tr('devices.groupedEnergyNote', { name: installation.name, source: installationEnergySourceLabel(installation) }))}` + : ''; + } + const source = form.energy_source; + if (groupOwnsEnergy) source.value = installation.energy_source || 'auto'; + source.disabled = groupOwnsEnergy; const cloudOption = [...source.options].find(option => option.value === 'gree_cloud'); if (cloudOption) cloudOption.disabled = device.capabilities?.energy_meter !== true; const sensorSelect = form.ha_energy_entity_id; + sensorSelect.disabled = groupOwnsEnergy; sensorSelect.innerHTML = ''; try { const response = await api('/api/integrations/home-assistant/energy-sensors'); @@ -723,23 +737,135 @@ async function openDeviceDetails(id) { option.dataset.stateClass = sensor.state_class || ''; sensorSelect.append(option); } - } catch (_) { - // Home Assistant is optional; keep any already-saved entity available even when HA is offline. - } - if (device.ha_energy_entity_id && ![...sensorSelect.options].some(option => option.value === device.ha_energy_entity_id)) { + } catch (_) { } + const energyEntity = groupOwnsEnergy ? installation.ha_energy_entity_id : device.ha_energy_entity_id; + const energyUnit = groupOwnsEnergy ? installation.ha_energy_unit : device.ha_energy_unit; + const energyDeviceClass = groupOwnsEnergy ? installation.ha_energy_device_class : device.ha_energy_device_class; + const energyStateClass = groupOwnsEnergy ? installation.ha_energy_state_class : device.ha_energy_state_class; + if (energyEntity && ![...sensorSelect.options].some(option => option.value === energyEntity)) { const option = document.createElement('option'); - option.value = device.ha_energy_entity_id; - option.textContent = device.ha_energy_entity_id; - option.dataset.unit = device.ha_energy_unit || ''; - option.dataset.deviceClass = device.ha_energy_device_class || ''; - option.dataset.stateClass = device.ha_energy_state_class || ''; + option.value = energyEntity; + option.textContent = energyEntity; + option.dataset.unit = energyUnit || ''; + option.dataset.deviceClass = energyDeviceClass || ''; + option.dataset.stateClass = energyStateClass || ''; sensorSelect.append(option); } - sensorSelect.value = device.ha_energy_entity_id || ''; + sensorSelect.value = energyEntity || ''; updateDeviceEnergySensorMeta(); openDialog('deviceDetailsDialog'); } +function renderDeviceGroupsDialogList() { + const host = $('#deviceGroupsList'); if (!host) return; + const groups = app.deviceGroups || []; + host.innerHTML = groups.length ? groups.map(group => { + const members = (group.device_ids || []).map(id => app.devices.find(device => device.id === id)?.name).filter(Boolean); + return `
`; + }).join('') : `
${esc(tr('devices.newInstallation'))}${esc(tr('devices.installationsHint'))}
`; +} + +function selectedDeviceGroupIds() { + return $$('#deviceGroupDeviceChoices input[type="checkbox"]:checked').map(input => input.value); +} + +function syncDeviceGroupMemberSelects() { + const form = $('#deviceGroupForm'); if (!form) return; + const ids = selectedDeviceGroupIds(); + const energy = form.energy_device_id; + const outdoor = form.outdoor_temperature_device_id; + const oldEnergy = energy.value, oldOutdoor = outdoor.value; + energy.innerHTML = '' + ids.map(id => { + const device = app.devices.find(item => item.id === id); + if (!device) return ''; + const supported = device.connection_type === 'gree_cloud' && device.capabilities?.energy_meter === true; + return ``; + }).join(''); + outdoor.innerHTML = `` + ids.map(id => { + const device = app.devices.find(item => item.id === id); + return device ? `` : ''; + }).join(''); + if ([...energy.options].some(option => option.value === oldEnergy && !option.disabled)) energy.value = oldEnergy; + if ([...outdoor.options].some(option => option.value === oldOutdoor)) outdoor.value = oldOutdoor; +} + +function renderDeviceGroupDeviceChoices(selectedIds = []) { + const form = $('#deviceGroupForm'); + const host = $('#deviceGroupDeviceChoices'); if (!form || !host) return; + const currentId = form.elements.id.value; + const occupied = new Map(); + for (const group of (app.deviceGroups || [])) { + if (group.id === currentId) continue; + for (const id of (group.device_ids || [])) occupied.set(id, group.name); + } + host.innerHTML = app.devices.map(device => { + const owner = occupied.get(device.id); + const checked = selectedIds.includes(device.id); + return ``; + }).join(''); + syncDeviceGroupMemberSelects(); +} + +async function loadDeviceGroupEnergySensors(group = null) { + const form = $('#deviceGroupForm'); if (!form) return; + const select = form.ha_energy_entity_id; + select.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 || ''; + select.append(option); + } + } catch (_) { } + if (group?.ha_energy_entity_id && ![...select.options].some(option => option.value === group.ha_energy_entity_id)) { + const option = document.createElement('option'); + option.value = group.ha_energy_entity_id; + option.textContent = group.ha_energy_entity_id; + option.dataset.unit = group.ha_energy_unit || ''; + option.dataset.deviceClass = group.ha_energy_device_class || ''; + option.dataset.stateClass = group.ha_energy_state_class || ''; + select.append(option); + } + select.value = group?.ha_energy_entity_id || ''; + updateDeviceGroupEnergySensorMeta(); +} + +async function populateDeviceGroupForm(group = null) { + const form = $('#deviceGroupForm'); if (!form) return; + form.reset(); + form.elements.id.value = group?.id || ''; + form.name.value = group?.name || ''; + form.kind.value = group?.kind || 'split'; + form.energy_source.value = group?.energy_source || 'auto'; + renderDeviceGroupDeviceChoices(group?.device_ids || []); + syncDeviceGroupMemberSelects(); + form.energy_device_id.value = group?.energy_device_id || ''; + form.outdoor_temperature_device_id.value = group?.outdoor_temperature_device_id || ''; + await loadDeviceGroupEnergySensors(group); +} + +async function openDeviceGroupsDialog(groupId = '') { + renderDeviceGroupsDialogList(); + const group = groupId ? (app.deviceGroups || []).find(item => item.id === groupId) : null; + await populateDeviceGroupForm(group || null); + openDialog('deviceGroupsDialog'); +} + +function updateDeviceGroupEnergySensorMeta() { + const select = $('#deviceGroupForm')?.ha_energy_entity_id; + const meta = $('#deviceGroupEnergySensorMeta'); + if (!select || !meta) return; + const option = select.selectedOptions?.[0]; + meta.textContent = option?.value + ? `${option.value} · ${option.dataset.deviceClass || 'energy'} · ${option.dataset.stateClass || 'total'} · ${option.dataset.unit || 'kWh'}` + : tr('energy.noHaSensor'); +} + function updateDeviceEnergySensorMeta() { const select = $('#deviceDetailsForm')?.ha_energy_entity_id; const meta = $('#deviceEnergySensorMeta'); diff --git a/web/js/realtime.js b/web/js/realtime.js index 23781d0..eb474dd 100644 --- a/web/js/realtime.js +++ b/web/js/realtime.js @@ -46,6 +46,8 @@ async function handleWebSocketMessage(event) { else if (message.event === 'zone.deleted') { app.zones = app.zones.filter(v => v.id !== data.id); renderAll(); scheduleControlPlanLoad(); } else if (['group.updated', 'group.created'].includes(message.event)) { const i = app.groups.findIndex(v => v.id === data.id); if (i >= 0) app.groups[i] = data; else app.groups.push(data); renderGroups(); fillSelects(); scheduleControlPlanLoad(); } else if (message.event === 'group.deleted') { app.groups = app.groups.filter(v => v.id !== data.id); renderGroups(); fillSelects(); scheduleControlPlanLoad(); } + else if (['device_group.updated', 'device_group.created'].includes(message.event)) { const i = app.deviceGroups.findIndex(v => v.id === data.id); if (i >= 0) app.deviceGroups[i] = data; else app.deviceGroups.push(data); renderDevices(); renderHistoryNavigation(); } + else if (message.event === 'device_group.deleted') { app.deviceGroups = app.deviceGroups.filter(v => v.id !== data.id); renderDevices(); renderHistoryNavigation(); } else if (['schedule.updated', 'schedule.created'].includes(message.event)) { const i = app.schedules.findIndex(v => v.id === data.id); if (i >= 0) app.schedules[i] = data; else app.schedules.push(data); renderSchedules(); fillSelects(); scheduleControlPlanLoad(); } else if (message.event === 'schedule.deleted') { app.schedules = app.schedules.filter(v => v.id !== data.id); renderSchedules(); fillSelects(); scheduleControlPlanLoad(); } else if (message.event === 'schedule.template_applied') { try { app.schedules = await api('/api/schedules'); renderSchedules(); fillSelects(); } catch (_) { } scheduleControlPlanLoad(); } diff --git a/web/js/router.js b/web/js/router.js index dccc62a..db261f0 100644 --- a/web/js/router.js +++ b/web/js/router.js @@ -23,8 +23,9 @@ function currentHistoryPath() { if (hours !== '24') params.set('hours', hours); if (tab === 'zones' && app.historyZone !== 'all') params.set('zone', app.historyZone); if (tab === 'devices' && app.historyDevice !== 'all') params.set('device', app.historyDevice); - if (tab === 'energy' && app.historyEnergyDevice) params.set('device', app.historyEnergyDevice); + if (tab === 'energy' && app.historyEnergyTargets?.length) params.set('targets', app.historyEnergyTargets.join(',')); if (tab === 'energy' && app.historyEnergyInterval !== 'daily') params.set('interval', app.historyEnergyInterval); + if (tab === 'energy' && app.historyEnergyCompare !== 'none') params.set('compare', app.historyEnergyCompare); if (tab === 'sensors' && app.historySensor !== 'all') params.set('sensor', app.historySensor); if (tab === 'custom' && app.customChartSeries.length) params.set('chart', encodeChartSpec(app.customChartSeries)); const query = params.toString(); @@ -73,8 +74,13 @@ function applyRouteFromLocation() { app.historyTab = HISTORY_TABS.includes(parts[1]) ? parts[1] : 'overview'; app.historyZone = params.get('zone') || 'all'; app.historyDevice = params.get('device') || 'all'; - if (app.historyTab === 'energy') app.historyEnergyDevice = params.get('device') || app.historyEnergyDevice || ''; - if (app.historyTab === 'energy' && ['hourly', 'daily', 'monthly'].includes(params.get('interval'))) app.historyEnergyInterval = params.get('interval'); + if (app.historyTab === 'energy') { + const targetParam = params.get('targets') || params.get('device') || ''; + app.historyEnergyTargets = targetParam ? targetParam.split(',').filter(Boolean).slice(0, 8) : (app.historyEnergyTargets || []); + app.historyEnergyDevice = app.historyEnergyTargets[0] || ''; + if (['hourly', 'daily', 'weekly', 'monthly'].includes(params.get('interval'))) app.historyEnergyInterval = params.get('interval'); + if (['none', 'previous_day', 'previous_period', 'previous_year'].includes(params.get('compare'))) app.historyEnergyCompare = params.get('compare'); + } app.historySensor = params.get('sensor') || 'all'; const hours = params.get('hours'); if (hours && ['6', '24', '168', '720', '2160', '8760'].includes(hours) && $('#historyHours')) $('#historyHours').value = hours; diff --git a/web/js/settings-ui.js b/web/js/settings-ui.js index e970fdb..f924aa1 100644 --- a/web/js/settings-ui.js +++ b/web/js/settings-ui.js @@ -395,28 +395,50 @@ setInterval(() => { if (app.currentView === 'settings') renderSystemInfo(); }, 3 async function refreshGreeCloudRuntimeStatus() { - const account = $('#greeCloudAccountStatus'); - const mqtt = $('#greeCloudMqttStatus'); - if (!account || !mqtt) return; - const setText = (selector, value) => { const node = $(selector); if (node) node.textContent = value; }; + const summary = $('.cloud-runtime-summary'); + if (!summary) return; + const metricSelectors = [ + '#greeCloudAccountStatus', '#greeCloudMqttStatus', '#greeCloudDevicesOnline', '#greeCloudRestResponseTime', + '#greeCloudResponseTime', '#greeCloudLastDeviceResponse', '#greeCloudLastMqttMessage', '#greeCloudConnectedSince', + '#greeCloudBroker', '#greeCloudTraffic', + ]; + const setMetric = (selector, value, visible) => { + const node = $(selector); if (!node) return 0; + const row = node.closest('div'); + if (row) row.hidden = !visible; + if (visible) node.textContent = value; + return visible ? 1 : 0; + }; + const lastContact = $('#greeCloudLastContact'); + const lastContactRow = lastContact?.closest('small'); try { const status = await api('/api/integrations/gree-cloud/status'); const runtime = status.runtime || {}; - account.textContent = String(status.account_status || 'unknown').replaceAll('_', ' '); - mqtt.textContent = String(status.mqtt_status || 'disconnected').replaceAll('_', ' '); - setText('#greeCloudDevicesOnline', `${Number(status.online_device_count || 0)} / ${Number(status.device_count || 0)}`); - setText('#greeCloudRestResponseTime', status.last_rest_response_time_ms != null && Number.isFinite(Number(status.last_rest_response_time_ms)) ? `${Number(status.last_rest_response_time_ms)} ms` : tr('common.unavailable')); - setText('#greeCloudResponseTime', runtime.last_response_time_ms != null && Number.isFinite(Number(runtime.last_response_time_ms)) ? `${Number(runtime.last_response_time_ms)} ms` : tr('common.unavailable')); - setText('#greeCloudLastDeviceResponse', runtime.last_device_response ? dateTime(runtime.last_device_response) : tr('common.unavailable')); - setText('#greeCloudLastMqttMessage', runtime.last_mqtt_message ? dateTime(runtime.last_mqtt_message) : tr('common.unavailable')); - setText('#greeCloudConnectedSince', runtime.mqtt_connected_since ? dateTime(runtime.mqtt_connected_since) : tr('common.unavailable')); - setText('#greeCloudBroker', runtime.broker_host || tr('common.unavailable')); - setText('#greeCloudTraffic', `${Number(runtime.requests_sent || 0)} / ${Number(runtime.responses_received || 0)} / ${Number(runtime.request_timeouts || 0)}`); - if (status.last_successful_contact) setText('#greeCloudLastContact', dateTime(status.last_successful_contact)); + const enabled = status.enabled === true; + const deviceCount = Number(status.device_count || 0); + const onlineCount = Number(status.online_device_count || 0); + const accountStatus = String(status.account_status || '').trim(); + const mqttStatus = String(status.mqtt_status || '').trim(); + const restMs = Number(status.last_rest_response_time_ms); + const responseMs = Number(runtime.last_response_time_ms); + const traffic = [Number(runtime.requests_sent || 0), Number(runtime.responses_received || 0), Number(runtime.request_timeouts || 0)]; + let visibleCount = 0; + visibleCount += setMetric('#greeCloudAccountStatus', accountStatus.replaceAll('_', ' '), enabled && !!accountStatus && accountStatus !== 'disabled'); + visibleCount += setMetric('#greeCloudMqttStatus', mqttStatus.replaceAll('_', ' '), enabled && !!mqttStatus && (mqttStatus === 'connected' || deviceCount > 0 || !['disconnected', 'disabled'].includes(mqttStatus))); + visibleCount += setMetric('#greeCloudDevicesOnline', `${onlineCount} / ${deviceCount}`, deviceCount > 0); + visibleCount += setMetric('#greeCloudRestResponseTime', `${restMs} ms`, Number.isFinite(restMs) && restMs >= 0); + visibleCount += setMetric('#greeCloudResponseTime', `${responseMs} ms`, Number.isFinite(responseMs) && responseMs >= 0); + visibleCount += setMetric('#greeCloudLastDeviceResponse', dateTime(runtime.last_device_response), !!runtime.last_device_response); + visibleCount += setMetric('#greeCloudLastMqttMessage', dateTime(runtime.last_mqtt_message), !!runtime.last_mqtt_message); + visibleCount += setMetric('#greeCloudConnectedSince', dateTime(runtime.mqtt_connected_since), !!runtime.mqtt_connected_since); + visibleCount += setMetric('#greeCloudBroker', runtime.broker_host || '', !!String(runtime.broker_host || '').trim()); + visibleCount += setMetric('#greeCloudTraffic', traffic.join(' / '), traffic.some(value => value > 0)); + summary.hidden = visibleCount === 0; + if (lastContactRow) lastContactRow.hidden = !status.last_successful_contact; + if (lastContact && status.last_successful_contact) lastContact.textContent = dateTime(status.last_successful_contact); } catch (_) { - account.textContent = 'unknown'; - mqtt.textContent = 'disconnected'; - ['#greeCloudDevicesOnline', '#greeCloudRestResponseTime', '#greeCloudResponseTime', '#greeCloudLastDeviceResponse', '#greeCloudLastMqttMessage', '#greeCloudConnectedSince', '#greeCloudBroker', '#greeCloudTraffic'].forEach(selector => setText(selector, tr('common.unavailable'))); + metricSelectors.forEach(selector => setMetric(selector, '', false)); + summary.hidden = true; + if (lastContactRow) lastContactRow.hidden = true; } } - diff --git a/web/js/settings.js b/web/js/settings.js index 0a9dad9..e77b635 100644 --- a/web/js/settings.js +++ b/web/js/settings.js @@ -321,8 +321,9 @@ document.addEventListener('change', event => { const target = event.target; if (target.id === 'historyZoneSelect') { app.historyZone = target.value; updateBrowserUrl(currentHistoryPath()); renderHistoryPage(); } else if (target.id === 'historyDeviceSelect') { app.historyDevice = target.value; updateBrowserUrl(currentHistoryPath()); renderHistoryPage(); } - else if (target.id === 'historyEnergyDeviceSelect') { app.historyEnergyDevice = target.value; updateBrowserUrl(currentHistoryPath()); loadHistory(); } + else if (target.id === 'historyEnergyTargetSelect') { app.historyEnergyTargets = [...target.selectedOptions].map(option => option.value).slice(0, 8); app.historyEnergyDevice = app.historyEnergyTargets[0] || ''; updateBrowserUrl(currentHistoryPath()); loadHistory(); } else if (target.id === 'historyEnergyInterval') { app.historyEnergyInterval = target.value; updateBrowserUrl(currentHistoryPath()); loadHistory(); } + else if (target.id === 'historyEnergyCompare') { app.historyEnergyCompare = target.value; updateBrowserUrl(currentHistoryPath()); loadHistory(); } else if (target.id === 'historySensorSelect') { app.historySensor = target.value; updateBrowserUrl(currentHistoryPath()); renderHistoryPage(); } else if (target.name === 'influx_version') updateInfluxFields(); }); @@ -445,7 +446,8 @@ $('#greeCloudTestButton')?.addEventListener('click', async event => { if (resultBox) { resultBox.hidden = false; resultBox.classList.add(result.ok ? 'success' : 'error'); - resultBox.textContent = result.ok ? `Connected. ${Number(result.device_count || 0)} device(s) found.` : (result.message || result.status || 'Connection failed'); + const message = result.ok ? `Connected. ${Number(result.device_count || 0)} device(s) found.` : (result.message || result.status || 'Connection failed'); + resultBox.innerHTML = `${esc(result.ok ? tr('status.online') : tr('devices.lastError'))}${esc(message)}`; } if (result.ok) { const refreshed = await api(SETTINGS_ENDPOINTS.greeCloud); @@ -453,7 +455,7 @@ $('#greeCloudTestButton')?.addEventListener('click', async event => { renderSettings(); } } catch (error) { - if (resultBox) { resultBox.hidden = false; resultBox.classList.add('error'); resultBox.textContent = error.message; } + if (resultBox) { resultBox.hidden = false; resultBox.classList.add('error'); resultBox.innerHTML = `${esc(tr('devices.lastError'))}${esc(error.message)}`; } } finally { button.disabled = false; } }); diff --git a/web/styles.css b/web/styles.css index 22eb177..e16c3a0 100644 --- a/web/styles.css +++ b/web/styles.css @@ -5192,16 +5192,6 @@ textarea[aria-invalid="true"] { outline: 2px solid color-mix(in srgb, var(--danger) 13%, transparent); } -.form-error-summary { - padding: 10px 12px; - border: 1px solid color-mix(in srgb, var(--danger) 36%, var(--line)); - border-radius: 12px; - background: var(--danger-soft); - color: var(--danger); - font-size: 11px; - font-weight: 650; - line-height: 1.4; -} .settings-form>.form-error-summary { margin: 0 0 12px; @@ -8621,8 +8611,7 @@ body.flow-editor-open { background: var(--surface-muted); } -.technical-device-grid span, -.technical-device-error span { +.technical-device-grid span { color: var(--muted); font-size: 9px; font-weight: 750; @@ -8647,23 +8636,6 @@ body.flow-editor-open { font-size: 10px; } -.technical-device-error { - display: grid; - gap: 4px; - padding: 9px 10px; - border: 1px solid color-mix(in srgb, var(--danger) 36%, var(--line)); - border-radius: 10px; - background: var(--danger-soft); -} - -.technical-device-error strong { - overflow: hidden; - color: var(--danger); - font-size: 11px; - font-weight: 650; - text-overflow: ellipsis; - white-space: nowrap; -} .technical-device-actions { display: flex; @@ -8978,22 +8950,6 @@ body.flow-editor-open { } -.config-check-result { - padding: 10px 12px; - border: 1px solid var(--border); - border-radius: 10px; - font-size: .88rem; - font-weight: 650; - overflow-wrap: anywhere; -} - -.config-check-result.success { - border-color: color-mix(in srgb, var(--accent) 48%, var(--border)); -} - -.config-check-result.error { - border-color: color-mix(in srgb, var(--danger) 48%, var(--border)); -} .technical-config-actions { flex-wrap: wrap; @@ -9494,3 +9450,81 @@ body.flow-editor-open { grid-template-columns: 1fr; } } + +/* Shared alerts and split/multisplit installation configuration */ +.inline-alert { + display: grid; + gap: 4px; + padding: 9px 10px; + border: 1px solid var(--line); + border-radius: 10px; + background: var(--surface-muted); + overflow-wrap: anywhere; +} + +.inline-alert span { + color: var(--muted); + font-size: 9px; + font-weight: 750; + text-transform: uppercase; + letter-spacing: .06em; +} + +.inline-alert strong { + color: var(--text-soft); + font-size: 11px; + font-weight: 650; +} + +.inline-alert.error { + border-color: color-mix(in srgb, var(--danger) 36%, var(--line)); + background: var(--danger-soft); + color: var(--danger); +} + +.inline-alert.error strong { color: var(--danger); } +.inline-alert.success { + border-color: color-mix(in srgb, var(--accent) 42%, var(--line)); + background: color-mix(in srgb, var(--accent) 10%, var(--surface)); +} +.inline-alert.info { + border-color: color-mix(in srgb, var(--info) 38%, var(--line)); + background: color-mix(in srgb, var(--info) 9%, var(--surface)); +} +.inline-alert.wide { grid-column: 1 / -1; } + +.device-installation-summary { display: grid; gap: 8px; margin-bottom: 12px; } +.installation-summary-card, +.installation-list-row { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(220px, .8fr) auto; + align-items: center; + gap: 12px; + padding: 10px 12px; + border: 1px solid var(--line); + border-radius: 12px; + background: var(--surface-muted); +} +.installation-summary-card > div, +.installation-list-row > button:first-child { display: grid; gap: 3px; } +.installation-summary-card strong, +.installation-list-row strong { color: var(--text); } +.installation-summary-card small, +.installation-list-row small { color: var(--muted); } +.installation-list { display: grid; gap: 8px; } +.installation-list-row > button:first-child { border: 0; background: transparent; padding: 0; text-align: left; } +.installation-list-row > div { display: flex; align-items: center; justify-content: flex-end; gap: 8px; flex-wrap: wrap; } +.installation-form { border-top: 1px solid var(--line); padding-top: 14px; margin-top: 14px; } +.installation-device-choices { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 6px; margin-top: 6px; } +.installation-device-choices .check { margin: 0; padding: 8px 9px; border: 1px solid var(--line); border-radius: 9px; background: var(--surface); } +.installation-device-choices .check.disabled { opacity: .55; } +.history-energy-targets select { min-width: min(360px, 72vw); } +.history-energy-targets small { display: block; max-width: 360px; color: var(--muted); font-size: 10px; } + +@media (max-width: 760px) { + .installation-summary-card, + .installation-list-row { grid-template-columns: 1fr; } + .installation-list-row > div { justify-content: flex-start; } + .installation-device-choices { grid-template-columns: 1fr; } +} +