From 0437ef7b6fa98046e5f5902f4c769bb24f8a4aa7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Wed, 16 Sep 2026 09:04:43 +0200 Subject: [PATCH] v0.14.8 --- Cargo.lock | 2 +- Cargo.toml | 2 +- FILE_MANIFEST.sha256 | 55 ++- README.md | 4 +- SOURCE_MANIFEST.sha256 | 53 +- build.rs | 1 + docs/API.md | 11 +- docs/openapi.json | 87 +++- .../gree_controller/manifest.json | 2 +- .../repository/gree-controller/CHANGELOG.md | 29 ++ .../repository/gree-controller/config.yaml | 2 +- lang/en.json | 7 + lang/pl.json | 7 + scripts/api_dev_test.py | 22 +- src/api/configuration.rs | 2 +- src/api/integrations.rs | 49 +- src/engine/energy.rs | 4 +- web/index.html | 20 + web/js/charts.js | 75 ++- web/js/core.js | 55 ++- web/js/dashboard.js | 2 +- web/js/events.js | 14 + web/js/flows.js | 10 +- web/js/forms.js | 1 + web/js/history.js | 37 ++ web/js/navigation.js | 54 +- web/js/select-ui.js | 461 ++++++++++++++++++ web/styles.css | 407 +++++++++++++--- 28 files changed, 1254 insertions(+), 221 deletions(-) create mode 100644 web/js/select-ui.js diff --git a/Cargo.lock b/Cargo.lock index b5dc356..6afbf80 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -700,7 +700,7 @@ dependencies = [ [[package]] name = "gree-controller" -version = "0.14.4" +version = "0.14.8" dependencies = [ "aes", "aes-gcm", diff --git a/Cargo.toml b/Cargo.toml index c3956b7..9077719 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "gree-controller" -version = "0.14.4" +version = "0.14.8" 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 2c4be02..7e5ea2b 100644 --- a/FILE_MANIFEST.sha256 +++ b/FILE_MANIFEST.sha256 @@ -1,14 +1,14 @@ -b99b779e89161c581acf74b67a32aface8f410a9fdf1d6d6fd7424226c0c1075 ./.env.example +d67af429e4da9ce08e9d2f2a8472849ffbd70d135b1c5da535a076026794d04c ./.env.example a4ec3874a2e3ab1bad28fb40bb620f7b01f64d01ad9b699306bf70ada31227db ./.gitignore -6428e098916d33b88865b31840f3a0ec7d1191ed86eb30a634c5c74a188f0fb9 ./Cargo.lock -708d68ca64a4623fb2e21be1e6e770b33facd2b901b3057e7f289b0ec0611e03 ./Cargo.toml +4caed3b142792ae42e882ff91e8d9196b3cc3c96ff94baf4e2d7f8f9f34e4805 ./Cargo.lock +cd71027b96818b2e50ee97f0105a90b77af8724647095dfafda5c4afc56d8e19 ./Cargo.toml 19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE -8601961aee2029e5bc8281b7f950d7de25fb1d85c6b89827cab471625a745d6c ./README.md -2345439df2b16d0478cff70d305307580052b1d2495e699c99fd743f3d3aeb2e ./SOURCE_MANIFEST.sha256 -bb549919b96b0413152dd905aec8640bb53301f07ec7d0b1be48ccbd37936fef ./build.rs -1aa5bb37b5d0c315fcc085d40678a63964be1fd537bb3b8f35f5497fea1af6a3 ./docs/API.md +d4ee781c55005c6465b1215bdf744a8935bd09d29eea9b8d8aad467936b32099 ./README.md +25110253d250f102100c4228ab9b695356ba5a7cb83552dd11d6be2206b9e504 ./SOURCE_MANIFEST.sha256 +5302570299af2c2650bada12e123f598c5db8009949c75761385f73ed57343c5 ./build.rs +affb3ca13cc95a5ead1deeec418200aacbad8a2ca17e0e20309abda5ca3ace20 ./docs/API.md f21d732eb1147f8f3167d2172cdf0b57e61edabdfecbaf97811d46c5bff7e47a ./docs/FLOW.md -0022f50fa10077531c3d1aa6afea5b4ea5ecff5e9d015269327df701e4a23c04 ./docs/openapi.json +c6b926269cf4ba511231be8a977c397668cd97d1d1804ea29a0244c3373dec5d ./docs/openapi.json c65aa543d2e99f8f7d52736307b07ebd182597d80124661d53a16bb23f6f1118 ./ha-addon/.env.example a18d2460340264d34c267fa704ee51a42e59bcc1783ee4c0dbb2a8ef42fa0f16 ./ha-addon/Dockerfile 0417c88a990c72aecf071fba64d5d9da65dcda4d396cd7565477f7a4532c8c5e ./ha-addon/README.md @@ -22,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 -b730225f7303eb2b4a19930a2cb80c724bea7afb3427770e72a987152b5402de ./ha-addon/home-assistant/custom_components/gree_controller/manifest.json +8052d7ec114965fa01fad45fbc4dd97f991a602abf18b8a5d85b8029cf22e18f ./ha-addon/home-assistant/custom_components/gree_controller/manifest.json c52a484b671ce738ecc00228a1db19a5d703f69ab27530c2896a5c2ae3b4a96f ./ha-addon/home-assistant/custom_components/gree_controller/number.py 39c4309001b75abb56234f05662bc06e077054986876f1927937edbce528ec95 ./ha-addon/home-assistant/custom_components/gree_controller/select.py cca65482e36d48035aca178121a378fe7d578d600acff267ae81a6399c0da653 ./ha-addon/home-assistant/custom_components/gree_controller/sensor.py @@ -30,10 +30,10 @@ cca65482e36d48035aca178121a378fe7d578d600acff267ae81a6399c0da653 ./ha-addon/hom 6bddb7b4620021ecd2099a86a77ef5c7f2c2dcd3d07d5db4e7b4c4ce6d3e8c03 ./ha-addon/home-assistant/custom_components/gree_controller/translations/en.json 13f30e2dcdcedbd1b6c3f99c2335e0487108fd72c8e86922368b84f2fa2038ae ./ha-addon/home-assistant/custom_components/gree_controller/translations/pl.json c5fc5c87273d82d2834a0a5a365c821a416413ed509613441e24e4dc3507c4d2 ./ha-addon/home-assistant/generated/gree_controller_entities.example.json -9372c3d9cfcbffd4aea4d195a646570ab52be5019c756827645bbd56725eb402 ./ha-addon/repository/gree-controller/CHANGELOG.md +5763327d91cc93ca6aa9ade4cb0b2ad721b636c3ae12963fe5fa123a2c060770 ./ha-addon/repository/gree-controller/CHANGELOG.md 3c06bd11671452ea6472afd5543fb91a39aba73a1d2d95c7762c04f94ab64030 ./ha-addon/repository/gree-controller/DOCS.md c3db302f38d26dc9f8caf37a1a8a07a1334cc07ae301d452f2940a5ec5915719 ./ha-addon/repository/gree-controller/README.md -9a902d607e10f8a0dc1fffb5b41da92a2d5326ccec2b2df13aa82cd58a9ae3b4 ./ha-addon/repository/gree-controller/config.yaml +7757b6100854fbc6d634db8878fbe1fc29bb723faf18c4d400c92d1f0f4b3af5 ./ha-addon/repository/gree-controller/config.yaml 3aae6cdae4c3aaab7786a9575e7093b7e2c5b69288167075f4b7475611691a6b ./ha-addon/repository/gree-controller/topologia-ha-vlan-gree-pl.png 0d0b42a7639b128946eec379175436fa1f150294f39d2dfd169077bfa87fd14d ./ha-addon/repository/gree-controller/topology-ha-vlan-gree-en.png d13e4c2b993d209078342c0c86d852b300692089b377aa044f811308dd188a1e ./ha-addon/repository/gree-controller/translations/en.yaml @@ -43,8 +43,8 @@ c123b190219556382f0e7f3058056fb233f6261f257a01eceb96a2c0bbfa12f3 ./ha-addon/run 081e27b6e43070d44f865335c30a42a5a29668eaa6a5ea5ab6967ac4447b8e06 ./ha-addon/sync-repository.sh f9e31de3d109ead32bf531c074d46a087df398d62488caee0b46b5e0a1c027f7 ./ha_addon.md 253a0bc912786e67ea7fc92a64e4a510ad973bec343a88ccfb1f28fca3e8cf01 ./lang/README.md -8e64e916d21bca02f1a29dac76e6458c5b6b2ebd90eabf9037ae6463ee057d7e ./lang/en.json -ee29bebe2cda2cdf818c9a6829feecb9cb069974f9d3602bb1e3fbb6faba2744 ./lang/pl.json +6d4f8efd56c36cac8c4f127a12d514e364cec86038bbe0fb2135961b7223d22c ./lang/en.json +e70c1f6ef69f4f183626fa872248652e1eae6a9d677cdd2b294bd6c19ef52190 ./lang/pl.json d8459024f04ca514bd8e9d6bd3af872fb942fd85a7cdd5583f4a9d28aab6faba ./make_zip.py b14233a8987e53bbbdd6770386ba10fa166ec25c1e098275c173c544b37846fc ./presets/bedroom_window_night.json 1960841119c0b673fb2f03621b32237552fd828045304cd221401214b997c910 ./presets/device_resilience.json @@ -85,7 +85,7 @@ fdcd9a5055d08037278b842e7ab69265345c5811f0a06867136c511d140bb191 ./presets/wind 804f22123cd3e8db0fac791826c8dd9f758fb866c8e3b5655fb6d25d259dccf1 ./presets/workday_comfort.json 01952aa92b217f8eae2493b88870e2dec595100cd15c4d561ff11ae2b936c46f ./regenerate-sha.sh c64b1c6deeb24f20af662bfce7fa3683703914987241107ce664fa2f6d1a9dcb ./scripts/README.md -a23ca9caaf80da017b1186893b0c56fc89bdd87c05ebf401ee769354a1842b7e ./scripts/api_dev_test.py +063f01360b2ff3cea3d81a45b87a28464ddb6ff31db76b535755b665ca76d7da ./scripts/api_dev_test.py 5bc736c7bc76ca80aaa406bb171d2aa91baf4c3aa8695dce0e09b888b6ab3146 ./scripts/common.sh 6403786610ee6d2f628193c25aee0dd058d62e904aa1a31d5f62fdaae0e94b4f ./scripts/configure-gree-network.sh 1d6e14e26e49aa9d3527f30a23668bf8d9c48b67e6628ef686c3155c012155de ./scripts/dev.sh @@ -102,7 +102,7 @@ b50782b3742dfbf8a319c60571c968e93fdf8547db747c759edcffae68cb98bf ./scripts/upda 3dfd1e053cbfa0d684dd88cf99b244a96a36e2ff23d2a0715b8327ab62258254 ./src/api/assets.rs 05c255919f7b3d81188fc5f1410be6a0037471dfc44e93bffb009f29a1e6a3fc ./src/api/auth.rs 9e4fc675306e111ed3db7af9822e2925d88109c31b9e309851665a975c0bd83c ./src/api/automations.rs -1c913c3119deaa0f473b1e4b40e2f8ba8f508a656640539141cd22fa69fe70a8 ./src/api/configuration.rs +ddd47ed53274d53b93e8ab10c9dd2f9c92c03bd7f99a745a75726acc8d6b3384 ./src/api/configuration.rs 19bc141286a336ad67e99079496eb0ae5126f986ab28be62b5f97758e5f6969c ./src/api/debug_tokens.rs 32ade46ec45d6faf7852242b9f5ba1be6eacbacbeb33d2deabfab59bf1610ce0 ./src/api/device_groups.rs fcbb3da0538aab451f71810f829cc248d6bb67e586dc418d71a572980583823d ./src/api/devices.rs @@ -112,7 +112,7 @@ d59b080b392b574ec94276c7b485b455d0d1abacc7de2ef29dcbfcf7d86dbdb0 ./src/api/flow b61b0b4f196c5e72e47cf06ca0c14df3cf8eedbdef0242a9d2af33623663489f ./src/api/groups.rs 376ac63794378c427d798e853a060612e76a95c9aa0357c2f216ba9e4f769f2f ./src/api/history.rs e8edfbb725d6de650442444b0a929ad2be0bd2c2d7d227d2b3fdd99d05c94f94 ./src/api/house.rs -8bcaf384ab3a2c297f232800fbffa72edcaa09ea340de0d665f2dc63ccba139c ./src/api/integrations.rs +371b719cb8883c94ebe7de3b222daad503957bbff6589b19ba27595963972f1c ./src/api/integrations.rs 6bfbe5135d4f93bd8d19a707877b53ed2029fd8490b4b1954a7520888fb1d664 ./src/api/middleware.rs 45e34a447ff384d99a3c42c489ca9fa75849c74d4d246caa91c63d927de17396 ./src/api/openapi.rs 4feca416de6ce8acc34082f432721016b109c7e6e70116e5e0b710ea403c7006 ./src/api/public_settings.rs @@ -141,7 +141,7 @@ ed5c9a0c61c6e597acb33f58593ebe02b562ddc68fa61271e1c1cd436c1003d3 ./src/engine/a f7de7b9b756bef9d6cffbc6ffdb7504b3121476f188e6ff5c8077d5fc54be811 ./src/engine/connectivity.rs 925135985f640ba9ae0a6109d908187b659341a91451e91d27e533adfd610b76 ./src/engine/control_plan.rs 7b48a33343d519a2ff2d81e9d757eb56a0c1beacfc0b7b8ef61f49c2ad363f9a ./src/engine/deadlines.rs -67ddb482ad08608e799a88faa6183d9bb65f62ca8ac138c6314cab18bc033033 ./src/engine/energy.rs +f5e3a8e85c622ea623360bdac87a098785748d8e4f4ba19ec2aa18c279b61c03 ./src/engine/energy.rs e350aa63f3bb14ca887eaaa7555edbeae00fb584cbcaf552cc972f0c8918f5d8 ./src/engine/groups.rs 5d8b3c9f2243848502bba5c1e4940f8bf8c5ae4bffd891b43e2e9b4e7dc55b0c ./src/engine/history.rs 41b7045877771b7c352328998cb45ce2433e7f9631fad3eb1a882ab4897dc31f ./src/engine/local_thermostat.rs @@ -201,25 +201,26 @@ f13f1be3d5789539eba3fc1b59c14835f8c681eca7634e118fa3763a53aeee2b ./src/queries/ b92a6cb158b494fe145b43c7641e65f6fafff47201d7d76edbec2cfd8b94835c ./systemd/gree-controller.service 544a31cb2e5374227026afd6abd0d910381f3fca1638154ebd74e3c3d96a35d1 ./web/404.html e98bdd7204349cce1ec6f57283509697af0bbc72280622a6c3efa6fed242db4f ./web/favicon.svg -4c243c29743b402966f48d904a17bf48d0ce9022a0b25100f0b7cd8b81926f5c ./web/index.html +1c206e7168770128f5afbb8a95f96e72ae2e9249a646f0651399ff71d6b1895f ./web/index.html 9ba3f6fa05b72aa989139b2a909982571b2a02055052e4c40104f1e81a9aa7eb ./web/js/README.md 27eff2821c40395369be15e197fa830535d2327fcb27c93a0e8e65a1c62de3ad ./web/js/bootstrap.js -def704d31b00706c26bae73190be15b289e72e6ecc4d8c9e9a5bb1804c747206 ./web/js/charts.js -f33a59e6863346d6751a74e1f912b6f0cd7a187defc3b01267a8121c9cd7ec8f ./web/js/core.js -021736e8b8d02248a41788765af2dd46914a700e8cadb0c43a75e83ed3f2dd14 ./web/js/dashboard.js +d3266522e02efa9f8e6eca6c15b53bc1114686aedcaac9e89a59be42b7145156 ./web/js/charts.js +c4531f79ba2e15501407767a417085eab0a61bd280f8356b6960f6e320736b05 ./web/js/core.js +f682b80c3b348a6e4832dc7d51227f696ab43b26d93f5ac52cabf35aed3c9ff8 ./web/js/dashboard.js ed31601df41cc82032709f960a2b4061966abd58cde456ef7dda05e42eb91432 ./web/js/entities.js -ef778fb570958704c2e079fa5cd645f67f884881e16984daa6f3ed52627b26a5 ./web/js/events.js -89d5f4098e74162ecdee2eef9441f88484a09e9197d21e30c40c220cedbf7e53 ./web/js/flows.js -52b4807f05a1325565d5e866007f49ea83c568574155cded131ce42a49df44f5 ./web/js/forms.js -ae6195905f33a4a83d5ba7c22d698419e2de05d80ed37df6066e0380fa715b6e ./web/js/history.js +a59fd5b822029d0c1e74ba8078f55f13122347e036a96dec383293ae386f6667 ./web/js/events.js +0f63a4172fd76eed70f44ff1edbe9cdd0c20e31cfff5de15f17ed2e591fda9b4 ./web/js/flows.js +63d508dc7bee43935086e8400366c271d7ebd9e8782e058296b449237399bf61 ./web/js/forms.js +5ded2b5533700e266d30fc3cd3a0e8a46ec647140f16df5304eff513d6c72f82 ./web/js/history.js 0dc879c0056251618633c9efd188fc1593eeae6965ad1bc993132dea6e171b44 ./web/js/main.js -53ebdaa64a1dc8327db5cd4e08fb3f551dc2df33c214f218c6bb1ae3f27ad4dd ./web/js/navigation.js +6bb9386707dcf601111fea15a1a366d8798313b973ca1624878618f4c3095add ./web/js/navigation.js e3d146a683b8beb332c752e533826160c2cf2236bc269ced7a4dd1249871027b ./web/js/realtime.js 9546ca5c5566994aff3c33d92295be46b65d51d874681764abe77470dc9c9c73 ./web/js/router.js +b6c894e49e105c8afad8c5b21d322692cee6b3120ee10ef676bcd5e521c8956e ./web/js/select-ui.js cdc55061a41b7325aad552357d8359b42d881589c39b438cdc2a48bf0f690db9 ./web/js/settings-ui.js f00c3f603eb314f755a0c46424681b579e0b890453e2550007c719273815147c ./web/js/settings.js 3815fea7ae1694568c2f249bc8460e0563ced6bde89214f74f21b41fe8cad9a6 ./web/lang-init.js 6b653e4fe2d4db4ffae6ff8f39b8ce57a10a990dc0000ee4aae51d949afadd52 ./web/manifest.webmanifest -4b3c94a1988e8e18bd0c39e5ebf73ce6ef31ddea5bb352ec40b1ae7b3609f5e9 ./web/styles.css +ac5762a8656fb61a6100bb16ddd0d6d0ee506532b6c05da8ec47595ec2ebbf44 ./web/styles.css fefdf12cbbb0a5d8a2cb89275cae28455aa42aaf179542305cc8f5e0f4825898 ./web/sw.js d505d793ce7cc9485b45b78bba1c0d51887adc7451ab59a42702946e5b991382 ./web/theme-init.js diff --git a/README.md b/README.md index 4a3988d..2166df0 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.4** +**Version: 0.14.8** -Version 0.14.4 removes the obsolete Home Assistant test/default entity. The HA connection test validates only the server/API token. `HA_OUTDOOR_ENTITY_ID` is the global outdoor-temperature sensor, and each zone can optionally override it with `ha_outdoor_entity_id`; room-temperature HA sensors remain configured per zone. Outdoor overrides participate in aliases, metrics/history and Home Assistant entity suggestions in Visual Flow. +Version 0.14.8 removes remaining false 502 responses from Home Assistant and notification integration diagnostics: invalid or missing integration configuration is reported as 400, while configured external dependency failures use 424. Local/LAN GREE communication keeps its existing 502 behavior. > [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 5c2533d..04ec71f 100644 --- a/SOURCE_MANIFEST.sha256 +++ b/SOURCE_MANIFEST.sha256 @@ -1,13 +1,13 @@ -b99b779e89161c581acf74b67a32aface8f410a9fdf1d6d6fd7424226c0c1075 ./.env.example +d67af429e4da9ce08e9d2f2a8472849ffbd70d135b1c5da535a076026794d04c ./.env.example a4ec3874a2e3ab1bad28fb40bb620f7b01f64d01ad9b699306bf70ada31227db ./.gitignore -6428e098916d33b88865b31840f3a0ec7d1191ed86eb30a634c5c74a188f0fb9 ./Cargo.lock -708d68ca64a4623fb2e21be1e6e770b33facd2b901b3057e7f289b0ec0611e03 ./Cargo.toml +4caed3b142792ae42e882ff91e8d9196b3cc3c96ff94baf4e2d7f8f9f34e4805 ./Cargo.lock +cd71027b96818b2e50ee97f0105a90b77af8724647095dfafda5c4afc56d8e19 ./Cargo.toml 19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE -8601961aee2029e5bc8281b7f950d7de25fb1d85c6b89827cab471625a745d6c ./README.md -bb549919b96b0413152dd905aec8640bb53301f07ec7d0b1be48ccbd37936fef ./build.rs -1aa5bb37b5d0c315fcc085d40678a63964be1fd537bb3b8f35f5497fea1af6a3 ./docs/API.md +d4ee781c55005c6465b1215bdf744a8935bd09d29eea9b8d8aad467936b32099 ./README.md +5302570299af2c2650bada12e123f598c5db8009949c75761385f73ed57343c5 ./build.rs +affb3ca13cc95a5ead1deeec418200aacbad8a2ca17e0e20309abda5ca3ace20 ./docs/API.md f21d732eb1147f8f3167d2172cdf0b57e61edabdfecbaf97811d46c5bff7e47a ./docs/FLOW.md -0022f50fa10077531c3d1aa6afea5b4ea5ecff5e9d015269327df701e4a23c04 ./docs/openapi.json +c6b926269cf4ba511231be8a977c397668cd97d1d1804ea29a0244c3373dec5d ./docs/openapi.json c65aa543d2e99f8f7d52736307b07ebd182597d80124661d53a16bb23f6f1118 ./ha-addon/.env.example a18d2460340264d34c267fa704ee51a42e59bcc1783ee4c0dbb2a8ef42fa0f16 ./ha-addon/Dockerfile 0417c88a990c72aecf071fba64d5d9da65dcda4d396cd7565477f7a4532c8c5e ./ha-addon/README.md @@ -21,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 -b730225f7303eb2b4a19930a2cb80c724bea7afb3427770e72a987152b5402de ./ha-addon/home-assistant/custom_components/gree_controller/manifest.json +8052d7ec114965fa01fad45fbc4dd97f991a602abf18b8a5d85b8029cf22e18f ./ha-addon/home-assistant/custom_components/gree_controller/manifest.json c52a484b671ce738ecc00228a1db19a5d703f69ab27530c2896a5c2ae3b4a96f ./ha-addon/home-assistant/custom_components/gree_controller/number.py 39c4309001b75abb56234f05662bc06e077054986876f1927937edbce528ec95 ./ha-addon/home-assistant/custom_components/gree_controller/select.py cca65482e36d48035aca178121a378fe7d578d600acff267ae81a6399c0da653 ./ha-addon/home-assistant/custom_components/gree_controller/sensor.py @@ -29,10 +29,10 @@ cca65482e36d48035aca178121a378fe7d578d600acff267ae81a6399c0da653 ./ha-addon/hom 6bddb7b4620021ecd2099a86a77ef5c7f2c2dcd3d07d5db4e7b4c4ce6d3e8c03 ./ha-addon/home-assistant/custom_components/gree_controller/translations/en.json 13f30e2dcdcedbd1b6c3f99c2335e0487108fd72c8e86922368b84f2fa2038ae ./ha-addon/home-assistant/custom_components/gree_controller/translations/pl.json c5fc5c87273d82d2834a0a5a365c821a416413ed509613441e24e4dc3507c4d2 ./ha-addon/home-assistant/generated/gree_controller_entities.example.json -9372c3d9cfcbffd4aea4d195a646570ab52be5019c756827645bbd56725eb402 ./ha-addon/repository/gree-controller/CHANGELOG.md +5763327d91cc93ca6aa9ade4cb0b2ad721b636c3ae12963fe5fa123a2c060770 ./ha-addon/repository/gree-controller/CHANGELOG.md 3c06bd11671452ea6472afd5543fb91a39aba73a1d2d95c7762c04f94ab64030 ./ha-addon/repository/gree-controller/DOCS.md c3db302f38d26dc9f8caf37a1a8a07a1334cc07ae301d452f2940a5ec5915719 ./ha-addon/repository/gree-controller/README.md -9a902d607e10f8a0dc1fffb5b41da92a2d5326ccec2b2df13aa82cd58a9ae3b4 ./ha-addon/repository/gree-controller/config.yaml +7757b6100854fbc6d634db8878fbe1fc29bb723faf18c4d400c92d1f0f4b3af5 ./ha-addon/repository/gree-controller/config.yaml 3aae6cdae4c3aaab7786a9575e7093b7e2c5b69288167075f4b7475611691a6b ./ha-addon/repository/gree-controller/topologia-ha-vlan-gree-pl.png 0d0b42a7639b128946eec379175436fa1f150294f39d2dfd169077bfa87fd14d ./ha-addon/repository/gree-controller/topology-ha-vlan-gree-en.png d13e4c2b993d209078342c0c86d852b300692089b377aa044f811308dd188a1e ./ha-addon/repository/gree-controller/translations/en.yaml @@ -42,8 +42,8 @@ c123b190219556382f0e7f3058056fb233f6261f257a01eceb96a2c0bbfa12f3 ./ha-addon/run 081e27b6e43070d44f865335c30a42a5a29668eaa6a5ea5ab6967ac4447b8e06 ./ha-addon/sync-repository.sh f9e31de3d109ead32bf531c074d46a087df398d62488caee0b46b5e0a1c027f7 ./ha_addon.md 253a0bc912786e67ea7fc92a64e4a510ad973bec343a88ccfb1f28fca3e8cf01 ./lang/README.md -8e64e916d21bca02f1a29dac76e6458c5b6b2ebd90eabf9037ae6463ee057d7e ./lang/en.json -ee29bebe2cda2cdf818c9a6829feecb9cb069974f9d3602bb1e3fbb6faba2744 ./lang/pl.json +6d4f8efd56c36cac8c4f127a12d514e364cec86038bbe0fb2135961b7223d22c ./lang/en.json +e70c1f6ef69f4f183626fa872248652e1eae6a9d677cdd2b294bd6c19ef52190 ./lang/pl.json d8459024f04ca514bd8e9d6bd3af872fb942fd85a7cdd5583f4a9d28aab6faba ./make_zip.py b14233a8987e53bbbdd6770386ba10fa166ec25c1e098275c173c544b37846fc ./presets/bedroom_window_night.json 1960841119c0b673fb2f03621b32237552fd828045304cd221401214b997c910 ./presets/device_resilience.json @@ -84,7 +84,7 @@ fdcd9a5055d08037278b842e7ab69265345c5811f0a06867136c511d140bb191 ./presets/wind 804f22123cd3e8db0fac791826c8dd9f758fb866c8e3b5655fb6d25d259dccf1 ./presets/workday_comfort.json 01952aa92b217f8eae2493b88870e2dec595100cd15c4d561ff11ae2b936c46f ./regenerate-sha.sh c64b1c6deeb24f20af662bfce7fa3683703914987241107ce664fa2f6d1a9dcb ./scripts/README.md -a23ca9caaf80da017b1186893b0c56fc89bdd87c05ebf401ee769354a1842b7e ./scripts/api_dev_test.py +063f01360b2ff3cea3d81a45b87a28464ddb6ff31db76b535755b665ca76d7da ./scripts/api_dev_test.py 5bc736c7bc76ca80aaa406bb171d2aa91baf4c3aa8695dce0e09b888b6ab3146 ./scripts/common.sh 6403786610ee6d2f628193c25aee0dd058d62e904aa1a31d5f62fdaae0e94b4f ./scripts/configure-gree-network.sh 1d6e14e26e49aa9d3527f30a23668bf8d9c48b67e6628ef686c3155c012155de ./scripts/dev.sh @@ -101,7 +101,7 @@ b50782b3742dfbf8a319c60571c968e93fdf8547db747c759edcffae68cb98bf ./scripts/upda 3dfd1e053cbfa0d684dd88cf99b244a96a36e2ff23d2a0715b8327ab62258254 ./src/api/assets.rs 05c255919f7b3d81188fc5f1410be6a0037471dfc44e93bffb009f29a1e6a3fc ./src/api/auth.rs 9e4fc675306e111ed3db7af9822e2925d88109c31b9e309851665a975c0bd83c ./src/api/automations.rs -1c913c3119deaa0f473b1e4b40e2f8ba8f508a656640539141cd22fa69fe70a8 ./src/api/configuration.rs +ddd47ed53274d53b93e8ab10c9dd2f9c92c03bd7f99a745a75726acc8d6b3384 ./src/api/configuration.rs 19bc141286a336ad67e99079496eb0ae5126f986ab28be62b5f97758e5f6969c ./src/api/debug_tokens.rs 32ade46ec45d6faf7852242b9f5ba1be6eacbacbeb33d2deabfab59bf1610ce0 ./src/api/device_groups.rs fcbb3da0538aab451f71810f829cc248d6bb67e586dc418d71a572980583823d ./src/api/devices.rs @@ -111,7 +111,7 @@ d59b080b392b574ec94276c7b485b455d0d1abacc7de2ef29dcbfcf7d86dbdb0 ./src/api/flow b61b0b4f196c5e72e47cf06ca0c14df3cf8eedbdef0242a9d2af33623663489f ./src/api/groups.rs 376ac63794378c427d798e853a060612e76a95c9aa0357c2f216ba9e4f769f2f ./src/api/history.rs e8edfbb725d6de650442444b0a929ad2be0bd2c2d7d227d2b3fdd99d05c94f94 ./src/api/house.rs -8bcaf384ab3a2c297f232800fbffa72edcaa09ea340de0d665f2dc63ccba139c ./src/api/integrations.rs +371b719cb8883c94ebe7de3b222daad503957bbff6589b19ba27595963972f1c ./src/api/integrations.rs 6bfbe5135d4f93bd8d19a707877b53ed2029fd8490b4b1954a7520888fb1d664 ./src/api/middleware.rs 45e34a447ff384d99a3c42c489ca9fa75849c74d4d246caa91c63d927de17396 ./src/api/openapi.rs 4feca416de6ce8acc34082f432721016b109c7e6e70116e5e0b710ea403c7006 ./src/api/public_settings.rs @@ -140,7 +140,7 @@ ed5c9a0c61c6e597acb33f58593ebe02b562ddc68fa61271e1c1cd436c1003d3 ./src/engine/a f7de7b9b756bef9d6cffbc6ffdb7504b3121476f188e6ff5c8077d5fc54be811 ./src/engine/connectivity.rs 925135985f640ba9ae0a6109d908187b659341a91451e91d27e533adfd610b76 ./src/engine/control_plan.rs 7b48a33343d519a2ff2d81e9d757eb56a0c1beacfc0b7b8ef61f49c2ad363f9a ./src/engine/deadlines.rs -67ddb482ad08608e799a88faa6183d9bb65f62ca8ac138c6314cab18bc033033 ./src/engine/energy.rs +f5e3a8e85c622ea623360bdac87a098785748d8e4f4ba19ec2aa18c279b61c03 ./src/engine/energy.rs e350aa63f3bb14ca887eaaa7555edbeae00fb584cbcaf552cc972f0c8918f5d8 ./src/engine/groups.rs 5d8b3c9f2243848502bba5c1e4940f8bf8c5ae4bffd891b43e2e9b4e7dc55b0c ./src/engine/history.rs 41b7045877771b7c352328998cb45ce2433e7f9631fad3eb1a882ab4897dc31f ./src/engine/local_thermostat.rs @@ -200,25 +200,26 @@ f13f1be3d5789539eba3fc1b59c14835f8c681eca7634e118fa3763a53aeee2b ./src/queries/ b92a6cb158b494fe145b43c7641e65f6fafff47201d7d76edbec2cfd8b94835c ./systemd/gree-controller.service 544a31cb2e5374227026afd6abd0d910381f3fca1638154ebd74e3c3d96a35d1 ./web/404.html e98bdd7204349cce1ec6f57283509697af0bbc72280622a6c3efa6fed242db4f ./web/favicon.svg -4c243c29743b402966f48d904a17bf48d0ce9022a0b25100f0b7cd8b81926f5c ./web/index.html +1c206e7168770128f5afbb8a95f96e72ae2e9249a646f0651399ff71d6b1895f ./web/index.html 9ba3f6fa05b72aa989139b2a909982571b2a02055052e4c40104f1e81a9aa7eb ./web/js/README.md 27eff2821c40395369be15e197fa830535d2327fcb27c93a0e8e65a1c62de3ad ./web/js/bootstrap.js -def704d31b00706c26bae73190be15b289e72e6ecc4d8c9e9a5bb1804c747206 ./web/js/charts.js -f33a59e6863346d6751a74e1f912b6f0cd7a187defc3b01267a8121c9cd7ec8f ./web/js/core.js -021736e8b8d02248a41788765af2dd46914a700e8cadb0c43a75e83ed3f2dd14 ./web/js/dashboard.js +d3266522e02efa9f8e6eca6c15b53bc1114686aedcaac9e89a59be42b7145156 ./web/js/charts.js +c4531f79ba2e15501407767a417085eab0a61bd280f8356b6960f6e320736b05 ./web/js/core.js +f682b80c3b348a6e4832dc7d51227f696ab43b26d93f5ac52cabf35aed3c9ff8 ./web/js/dashboard.js ed31601df41cc82032709f960a2b4061966abd58cde456ef7dda05e42eb91432 ./web/js/entities.js -ef778fb570958704c2e079fa5cd645f67f884881e16984daa6f3ed52627b26a5 ./web/js/events.js -89d5f4098e74162ecdee2eef9441f88484a09e9197d21e30c40c220cedbf7e53 ./web/js/flows.js -52b4807f05a1325565d5e866007f49ea83c568574155cded131ce42a49df44f5 ./web/js/forms.js -ae6195905f33a4a83d5ba7c22d698419e2de05d80ed37df6066e0380fa715b6e ./web/js/history.js +a59fd5b822029d0c1e74ba8078f55f13122347e036a96dec383293ae386f6667 ./web/js/events.js +0f63a4172fd76eed70f44ff1edbe9cdd0c20e31cfff5de15f17ed2e591fda9b4 ./web/js/flows.js +63d508dc7bee43935086e8400366c271d7ebd9e8782e058296b449237399bf61 ./web/js/forms.js +5ded2b5533700e266d30fc3cd3a0e8a46ec647140f16df5304eff513d6c72f82 ./web/js/history.js 0dc879c0056251618633c9efd188fc1593eeae6965ad1bc993132dea6e171b44 ./web/js/main.js -53ebdaa64a1dc8327db5cd4e08fb3f551dc2df33c214f218c6bb1ae3f27ad4dd ./web/js/navigation.js +6bb9386707dcf601111fea15a1a366d8798313b973ca1624878618f4c3095add ./web/js/navigation.js e3d146a683b8beb332c752e533826160c2cf2236bc269ced7a4dd1249871027b ./web/js/realtime.js 9546ca5c5566994aff3c33d92295be46b65d51d874681764abe77470dc9c9c73 ./web/js/router.js +b6c894e49e105c8afad8c5b21d322692cee6b3120ee10ef676bcd5e521c8956e ./web/js/select-ui.js cdc55061a41b7325aad552357d8359b42d881589c39b438cdc2a48bf0f690db9 ./web/js/settings-ui.js f00c3f603eb314f755a0c46424681b579e0b890453e2550007c719273815147c ./web/js/settings.js 3815fea7ae1694568c2f249bc8460e0563ced6bde89214f74f21b41fe8cad9a6 ./web/lang-init.js 6b653e4fe2d4db4ffae6ff8f39b8ce57a10a990dc0000ee4aae51d949afadd52 ./web/manifest.webmanifest -4b3c94a1988e8e18bd0c39e5ebf73ce6ef31ddea5bb352ec40b1ae7b3609f5e9 ./web/styles.css +ac5762a8656fb61a6100bb16ddd0d6d0ee506532b6c05da8ec47595ec2ebbf44 ./web/styles.css fefdf12cbbb0a5d8a2cb89275cae28455aa42aaf179542305cc8f5e0f4825898 ./web/sw.js d505d793ce7cc9485b45b78bba1c0d51887adc7451ab59a42702946e5b991382 ./web/theme-init.js diff --git a/build.rs b/build.rs index 2f6d560..9980fe2 100644 --- a/build.rs +++ b/build.rs @@ -35,6 +35,7 @@ fn content_hash(bytes: &[u8]) -> String { const APP_JS_MODULES: &[&str] = &[ "core.js", + "select-ui.js", "forms.js", "bootstrap.js", "dashboard.js", diff --git a/docs/API.md b/docs/API.md index ec9fb23..bf6c607 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.4**. +HTTP and WebSocket API for GREE Controller **0.14.8**. [← Main documentation](../README.md) @@ -108,7 +108,8 @@ Common statuses: | `401 Unauthorized` | Missing/incorrect token. | | `404 Not Found` | Resource ID does not exist. | | `409 Conflict` | Revision/concurrency conflict. | -| `502 Bad Gateway` | GREE/HA/integration communication failure. | +| `424 Failed Dependency` | Configured external service/integration (for example Home Assistant, GREE Cloud or a notification provider) could not complete the request. | +| `502 Bad Gateway` | Local/LAN GREE device communication failure. | | `500 Internal Server Error` | Unexpected server/storage error. | ## Endpoint index @@ -232,7 +233,7 @@ Response: { "status": "ok", "name": "gree-controller", - "version": "0.14.4", + "version": "0.14.8", "uptime_seconds": 1234, "control_ready": true, "time": "2026-08-30T06:54:00Z" @@ -295,7 +296,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.4", + "version": "0.14.8", "uptime_seconds": 1234, "auth_required": false, "control_ready": true, @@ -1683,7 +1684,7 @@ Cloud account/discovery/diagnostic endpoints: Energy endpoints: -- `GET /api/integrations/home-assistant/energy-sensors` lists compatible cumulative HA energy sensors. +- `GET /api/integrations/home-assistant/energy-sensors` lists compatible cumulative HA energy sensors. If Home Assistant is not configured, it returns `200` with `configured: false` and an empty `sensors` array; configured dependency failures use HTTP `424`. - `GET /api/history/energy?device_id=...&interval=hourly|daily|weekly|monthly&source=auto|gree_cloud|home_assistant` returns consumption buckets and period summaries in kWh. Cloud settings and diagnostics are redacted: passwords, REST/MQTT tokens, Authorization values and device cipher keys are not returned. diff --git a/docs/openapi.json b/docs/openapi.json index ad24064..028552f 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.4", + "version": "0.14.8", "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.4\nHome Assistant connection tests validate only the configured server/API token and require no entity. `outdoor_entity_id` is the global Home Assistant outdoor-temperature sensor; zones can override it with `ha_outdoor_entity_id`. Room-temperature Home Assistant sensors remain configured per zone with `ha_entity_id`. Global/per-zone outdoor sensors are integrated with aliases, metrics/history and Visual Flow entity suggestions.\n\n### 0.14.2\nAdds independent vertical/horizontal swing controls across manual device control, thermostat zones, legacy automations, Visual Flow and Home Assistant zone climates. Swing is treated as an auxiliary one-shot unit setting and does not replace thermostat ownership.\n\n### 0.14.1\nAdds split/multisplit installation groups with shared energy and outdoor-temperature sources, multi-target energy charts with period comparison, cleaner Cloud status/alerts and safe MQTT cleanup after the last Cloud device is removed.\n\n### 0.14.0\nAdds first-class GREE Cloud MQTT status/control, reconnect/resubscribe lifecycle, Cloud diagnostics, per-device capabilities, Cloud/HA energy sources and energy-history aggregation while preserving the LAN transport contract.", + "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.8\nRemaining Home Assistant and notification diagnostic errors no longer use device-transport 502 responses. Missing/invalid integration configuration returns 400, configured external dependency failures return 424, and 502 is reserved for Local/LAN GREE device communication.\n\n### 0.14.7\nHome Assistant energy-sensor discovery now returns an empty optional result when HA is not configured, uses 424 for configured external-dependency failures, and custom select popovers use their rendered height when positioning above a field.\n\n\n### 0.14.6\nFixes dynamic custom-select duplication in History → Energy and hides Energy history controls when no energy source is configured.\n\n### 0.14.5\nWeb UI single-choice controls now use one consistent custom dropdown, language packs are loaded on demand, and the dashboard outdoor temperature opens a 24-hour history modal.\n\n### 0.14.4\nHome Assistant connection tests validate only the configured server/API token and require no entity. `outdoor_entity_id` is the global Home Assistant outdoor-temperature sensor; zones can override it with `ha_outdoor_entity_id`. Room-temperature Home Assistant sensors remain configured per zone with `ha_entity_id`. Global/per-zone outdoor sensors are integrated with aliases, metrics/history and Visual Flow entity suggestions.\n\n### 0.14.2\nAdds independent vertical/horizontal swing controls across manual device control, thermostat zones, legacy automations, Visual Flow and Home Assistant zone climates. Swing is treated as an auxiliary one-shot unit setting and does not replace thermostat ownership.\n\n### 0.14.1\nAdds split/multisplit installation groups with shared energy and outdoor-temperature sources, multi-target energy charts with period comparison, cleaner Cloud status/alerts and safe MQTT cleanup after the last Cloud device is removed.\n\n### 0.14.0\nAdds first-class GREE Cloud MQTT status/control, reconnect/resubscribe lifecycle, Cloud diagnostics, per-device capabilities, Cloud/HA energy sources and energy-history aggregation while preserving the LAN transport contract.", "license": { "name": "MIT" } @@ -782,6 +782,9 @@ }, "500": { "$ref": "#/components/responses/InternalError" + }, + "424": { + "$ref": "#/components/responses/FailedDependency" } } } @@ -1118,6 +1121,9 @@ }, "500": { "$ref": "#/components/responses/InternalError" + }, + "424": { + "$ref": "#/components/responses/FailedDependency" } } } @@ -1623,6 +1629,9 @@ }, "500": { "$ref": "#/components/responses/InternalError" + }, + "424": { + "$ref": "#/components/responses/FailedDependency" } } } @@ -1675,6 +1684,9 @@ }, "500": { "$ref": "#/components/responses/InternalError" + }, + "424": { + "$ref": "#/components/responses/FailedDependency" } } } @@ -1728,6 +1740,9 @@ }, "500": { "$ref": "#/components/responses/InternalError" + }, + "424": { + "$ref": "#/components/responses/FailedDependency" } } } @@ -1781,6 +1796,9 @@ }, "500": { "$ref": "#/components/responses/InternalError" + }, + "424": { + "$ref": "#/components/responses/FailedDependency" } } } @@ -3116,11 +3134,11 @@ "401": { "$ref": "#/components/responses/Unauthorized" }, - "502": { - "$ref": "#/components/responses/BadGateway" - }, "500": { "$ref": "#/components/responses/InternalError" + }, + "424": { + "$ref": "#/components/responses/FailedDependency" } } } @@ -3178,11 +3196,11 @@ "401": { "$ref": "#/components/responses/Unauthorized" }, - "502": { - "$ref": "#/components/responses/BadGateway" - }, "500": { "$ref": "#/components/responses/InternalError" + }, + "424": { + "$ref": "#/components/responses/FailedDependency" } } } @@ -3240,11 +3258,11 @@ "401": { "$ref": "#/components/responses/Unauthorized" }, - "502": { - "$ref": "#/components/responses/BadGateway" - }, "500": { "$ref": "#/components/responses/InternalError" + }, + "424": { + "$ref": "#/components/responses/FailedDependency" } } } @@ -3487,6 +3505,9 @@ }, "500": { "$ref": "#/components/responses/InternalError" + }, + "424": { + "$ref": "#/components/responses/FailedDependency" } } } @@ -3632,6 +3653,9 @@ }, "500": { "$ref": "#/components/responses/InternalError" + }, + "424": { + "$ref": "#/components/responses/FailedDependency" } } } @@ -3684,6 +3708,9 @@ }, "500": { "$ref": "#/components/responses/InternalError" + }, + "424": { + "$ref": "#/components/responses/FailedDependency" } } } @@ -3737,6 +3764,9 @@ }, "500": { "$ref": "#/components/responses/InternalError" + }, + "424": { + "$ref": "#/components/responses/FailedDependency" } } } @@ -3790,6 +3820,9 @@ }, "500": { "$ref": "#/components/responses/InternalError" + }, + "424": { + "$ref": "#/components/responses/FailedDependency" } } } @@ -3857,6 +3890,9 @@ }, "500": { "$ref": "#/components/responses/InternalError" + }, + "424": { + "$ref": "#/components/responses/FailedDependency" } } } @@ -4979,6 +5015,9 @@ }, "500": { "$ref": "#/components/responses/InternalError" + }, + "424": { + "$ref": "#/components/responses/FailedDependency" } } } @@ -5036,6 +5075,9 @@ }, "409": { "$ref": "#/components/responses/Conflict" + }, + "424": { + "$ref": "#/components/responses/FailedDependency" } } } @@ -5113,8 +5155,8 @@ "400": { "$ref": "#/components/responses/BadRequest" }, - "502": { - "$ref": "#/components/responses/BadGateway" + "424": { + "$ref": "#/components/responses/FailedDependency" } } } @@ -5180,7 +5222,7 @@ "Energy" ], "summary": "List compatible Home Assistant energy sensors", - "description": "Lists entities compatible with cumulative energy ingestion: device_class=energy, state_class total/total_increasing and Wh or kWh.", + "description": "Lists entities compatible with cumulative energy ingestion: device_class=energy, state_class total/total_increasing and Wh or kWh. If Home Assistant is not configured, returns configured=false with an empty sensors array.", "operationId": "listHomeAssistantEnergySensors", "security": [ { @@ -5207,6 +5249,9 @@ }, "500": { "$ref": "#/components/responses/InternalError" + }, + "424": { + "$ref": "#/components/responses/FailedDependency" } } } @@ -5475,7 +5520,7 @@ }, "version": { "type": "string", - "example": "0.14.4" + "example": "0.14.8" }, "uptime_seconds": { "type": "integer", @@ -9135,7 +9180,7 @@ } }, "BadGateway": { - "description": "GREE/Home Assistant/integration communication failure", + "description": "Local/LAN GREE device communication failure.", "content": { "application/json": { "schema": { @@ -9156,6 +9201,16 @@ } } } + }, + "FailedDependency": { + "description": "A configured external service or integration could not complete the request.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } } } } 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 285bb11..d5eb266 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.4", + "version": "0.14.8", "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 052f6ec..8ec0d3f 100644 --- a/ha-addon/repository/gree-controller/CHANGELOG.md +++ b/ha-addon/repository/gree-controller/CHANGELOG.md @@ -1,5 +1,34 @@ # Changelog +## 0.14.8 + +- Removes the remaining non-device `502 Bad Gateway` mappings from Home Assistant connection/entity diagnostics and notification tests. +- Returns `400 Bad Request` for missing/invalid integration configuration and `424 Failed Dependency` when a configured external service cannot be reached or rejects the request. +- Reclassifies background Home Assistant energy-read failures as external-dependency errors. +- Keeps `502 Bad Gateway` only for actual Local/LAN GREE device transport failures. +- Aligns API/OpenAPI error documentation with the runtime status mapping. + +## 0.14.7 + +- Treats an unconfigured Home Assistant integration as an optional unavailable source when listing HA energy sensors instead of emitting a 502 error while opening split/multisplit installation settings. +- Maps configured-but-failing Home Assistant energy discovery to the external-dependency response path rather than a device communication 502. +- Fixes short custom-select menus near the bottom of dialogs opening far above their trigger by positioning from the menu's rendered height. + +## 0.14.6 + +- Fixes custom select duplication when a select is moved in the DOM, including repeated History → Energy refreshes. +- Hides the Energy history controls and Refresh action when no energy source is configured, leaving a single empty-state message. +- Hardens global custom-select cleanup so dynamically removed controls do not leave stale wrappers or popup menus. + +## 0.14.5 + +- Fixed the outdoor-temperature modal chart returning from fullscreen with fullscreen-sized layout remnants. +- Added quick links from the outdoor-temperature modal to 7-day, 30-day, yearly and full History views. + +- Replaces native single-choice dropdown UI across the Web UI with one consistent custom select matching the energy chart picker, including language/theme controls and dynamically generated Flow/history forms. +- Loads only the active language pack at startup and fetches other language packs when selected. +- Makes the dashboard outdoor temperature clickable and opens a modal with its last 24 hours of history. + ## 0.14.4 - Removes the obsolete Home Assistant connection-test/default entity; HA connection testing uses only the configured URL and token. diff --git a/ha-addon/repository/gree-controller/config.yaml b/ha-addon/repository/gree-controller/config.yaml index 9bf6197..3e87c58 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.4" +version: "0.14.8" 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 95c26ed..b00ab32 100644 --- a/lang/en.json +++ b/lang/en.json @@ -297,6 +297,13 @@ "house.smartThermostat": "Smart thermostat", "house.setpointStrategy": "Heating or cooling selects the automation direction. After resume, a zone with no active schedule, manual target, temporary session or other explicit control stays OFF. “Do not control” leaves devices untouched.", "house.outdoor": "Outdoor", + "house.outdoorHistoryOpen": "Show the outdoor temperature chart for the last 24 hours", + "house.outdoorHistoryHint": "Outdoor temperature from the last 24 hours.", + "house.outdoorHistoryMore": "More metrics and ranges in history", + "house.outdoorHistoryFull": "Full history", + "history.range7d": "7 days", + "history.range30d": "30 days", + "history.range1y": "1 year", "house.modeUpdated": "Home climate mode updated", "zones.modePolicy": "Mode policy", "zones.modePolicyHint": "If this zone should use the global Heating/Cooling mode selected in GREE Controller, leave “Follow global mode” unchanged. “Cooling only” or “Heating only” forces a fixed mode only for this zone.", diff --git a/lang/pl.json b/lang/pl.json index a677514..a553acb 100644 --- a/lang/pl.json +++ b/lang/pl.json @@ -297,6 +297,13 @@ "house.smartThermostat": "Inteligentny termostat", "house.setpointStrategy": "Grzanie lub chłodzenie określa kierunek pracy automatyki. Po wznowieniu strefa bez aktywnego harmonogramu, ręcznego celu, trybu czasowego lub innego jawnego sterowania pozostaje OFF. „Nie steruj” nie ingeruje w urządzenia.", "house.outdoor": "Na zewnątrz", + "house.outdoorHistoryOpen": "Pokaż wykres temperatury zewnętrznej z ostatnich 24 godzin", + "house.outdoorHistoryHint": "Temperatura zewnętrzna z ostatnich 24 godzin.", + "house.outdoorHistoryMore": "Więcej metryk i zakresów w historii", + "house.outdoorHistoryFull": "Pełna historia", + "history.range7d": "7 dni", + "history.range30d": "30 dni", + "history.range1y": "1 rok", "house.modeUpdated": "Zmieniono tryb klimatu domu", "zones.modePolicy": "Polityka trybu", "zones.modePolicyHint": "Jeśli strefa ma korzystać z globalnego trybu Grzanie/Chłodzenie ustawionego w GREE Controller, zostaw „Dziedzicz tryb globalny” i nie zmieniaj tej opcji. „Tylko chłodzenie” lub „Tylko grzanie” wymusza stały tryb tylko dla tej strefy.", diff --git a/scripts/api_dev_test.py b/scripts/api_dev_test.py index 115214c..709fd2b 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.4. +"""Developer smoke/integration tests for GREE Controller API 0.14.8. 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.4" +DEFAULT_EXPECTED_VERSION = "0.14.8" SETTINGS_PATHS: dict[str, set[str]] = { "/api/settings/application": {"simulator_enabled"}, @@ -368,6 +368,20 @@ def test_openapi(client: ApiClient, expected_version: str) -> str: version = ((spec.get("info") or {}).get("version")) if expected_version and version != expected_version: raise TestFailure(f"OpenAPI info.version={version!r}, expected {expected_version!r}") + + external_dependency_operations = [ + ("/api/integrations/home-assistant/test", "post"), + ("/api/integrations/home-assistant/entity", "post"), + ("/api/integrations/notifications/test", "post"), + ("/api/integrations/gree-cloud/reconnect", "post"), + ] + for path, method in external_dependency_operations: + responses = (((paths.get(path) or {}).get(method) or {}).get("responses") or {}) + if "502" in responses: + raise TestFailure(f"{method.upper()} {path} must not document external dependency failures as 502") + if "424" not in responses: + raise TestFailure(f"{method.upper()} {path} is missing 424 Failed Dependency") + return f"paths={len(paths)}, version={version}" @@ -579,7 +593,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.4 contract", lambda: test_openapi(client, args.expected_version)) + runner.run("OpenAPI 0.14.8 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)) @@ -648,7 +662,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.4.", + description="Developer smoke/integration tests for GREE Controller API 0.14.8.", formatter_class=argparse.RawDescriptionHelpFormatter, epilog="""Examples: python3 scripts/api_dev_test.py diff --git a/src/api/configuration.rs b/src/api/configuration.rs index a2050e8..5267b3d 100644 --- a/src/api/configuration.rs +++ b/src/api/configuration.rs @@ -27,7 +27,7 @@ async fn export_configuration( fn validate_configuration_header(export: &ConfigurationExport) -> Result<(), AppError> { if export.format_version != 3 { - return Err(AppError::BadRequest("unsupported configuration export version; version 3 is required by GREE Controller 0.14.4".into())); + return Err(AppError::BadRequest("unsupported configuration export version; version 3 is required by GREE Controller 0.14.8".into())); } if export.settings.control_strategy != "setpoint" { return Err(AppError::BadRequest( diff --git a/src/api/integrations.rs b/src/api/integrations.rs index f9c2b6e..c5895ba 100644 --- a/src/api/integrations.rs +++ b/src/api/integrations.rs @@ -1,3 +1,33 @@ +fn map_home_assistant_integration_error(error: anyhow::Error) -> AppError { + let message = error.to_string(); + if message.contains("Home Assistant URL is not configured") + || message.contains("Home Assistant token is not configured") + || message.contains("invalid Home Assistant URL") + || message.contains("Home Assistant URL must use http or https") + || message.contains("cannot build Home Assistant API URL") + || message.contains("cannot build Home Assistant service URL") + { + AppError::BadRequest(message) + } else { + AppError::Dependency(message) + } +} + +fn map_notification_test_error(message: String) -> AppError { + if matches!( + message.as_str(), + "Pushover credentials are incomplete" + | "invalid webhook URL" + | "webhook URL must use HTTPS" + | "webhook host is not supported" + | "unsupported notification provider" + ) { + AppError::BadRequest(message) + } else { + AppError::Dependency(message) + } +} + async fn home_assistant_snapshot( State(state): State, ) -> Result, AppError> { @@ -17,7 +47,7 @@ async fn test_home_assistant( let settings = state.settings.read().await.clone(); home_assistant::test_connection(&state.http, &settings.home_assistant) .await - .map_err(|e| AppError::Device(e.to_string()))?; + .map_err(map_home_assistant_integration_error)?; Ok(Json(json!({"ok": true}))) } @@ -41,7 +71,7 @@ async fn inspect_home_assistant_entity( Some(entity_id.as_str()), ) .await - .map_err(|e| AppError::Device(e.to_string()))?; + .map_err(map_home_assistant_integration_error)?; let raw_state = payload .get("state") .and_then(Value::as_str) @@ -78,7 +108,7 @@ async fn test_notifications( } notifications::test(&state, input) .await - .map_err(AppError::Device)?; + .map_err(map_notification_test_error)?; Ok(Json(json!({"ok": true}))) } @@ -86,9 +116,15 @@ async fn list_home_assistant_energy_sensors( State(state): State, ) -> Result, AppError> { let settings = state.settings.read().await.home_assistant.clone(); + if settings.url.trim().is_empty() || settings.token.trim().is_empty() { + return Ok(Json(json!({ + "configured": false, + "sensors": [], + }))); + } let entities = home_assistant::list_entities(&state.http, &settings) .await - .map_err(|error| AppError::Device(error.to_string()))?; + .map_err(|error| AppError::Dependency(error.to_string()))?; let sensors = entities .into_iter() .filter_map(|entity| { @@ -112,5 +148,8 @@ async fn list_home_assistant_energy_sensors( })) }) .collect::>(); - Ok(Json(json!({"sensors": sensors}))) + Ok(Json(json!({ + "configured": true, + "sensors": sensors, + }))) } diff --git a/src/engine/energy.rs b/src/engine/energy.rs index 5f3d157..337ffcf 100644 --- a/src/engine/energy.rs +++ b/src/engine/energy.rs @@ -84,10 +84,10 @@ async fn sample_home_assistant_energy_target(state: &AppState, target_id: &str, let settings = state.settings.read().await.home_assistant.clone(); let payload = home_assistant::read_entity(&state.http, &settings, Some(entity_id)) .await - .map_err(|err| AppError::Device(err.to_string()))?; + .map_err(|err| AppError::Dependency(err.to_string()))?; let state_value = payload.get("state").and_then(Value::as_str).unwrap_or_default(); if matches!(state_value, "" | "unknown" | "unavailable") { return Ok(()); } - let raw_value: f64 = state_value.parse().map_err(|_| AppError::Device("Home Assistant energy state is not numeric".into()))?; + let raw_value: f64 = state_value.parse().map_err(|_| AppError::Dependency("Home Assistant energy state is not numeric".into()))?; let attrs = payload.get("attributes").and_then(Value::as_object).cloned().unwrap_or_default(); let device_class = attrs.get("device_class").and_then(Value::as_str).unwrap_or_default(); let state_class = attrs.get("state_class").and_then(Value::as_str).unwrap_or_default(); diff --git a/web/index.html b/web/index.html index c28dcdc..a0c5913 100644 --- a/web/index.html +++ b/web/index.html @@ -1649,6 +1649,26 @@ + +
+
+
24 hours

Outdoor temperatures

+ +
+
Outdoor--
+
+ +
+
+
diff --git a/web/js/charts.js b/web/js/charts.js index 45b8cc5..84e4d81 100644 --- a/web/js/charts.js +++ b/web/js/charts.js @@ -66,29 +66,36 @@ function resetHistoryRangeControl(host) { if (range && toolbar && range.parentElement === host) toolbar.insertBefore(range, refresh); } -function configureHistoryRangeControl(tab, host) { +function configureHistoryRangeControl(tab, host, hasEnergyTargets = true) { const range = $('#historyRangeControl'); if (!range) return; const toolbar = range.closest('.chart-toolbar') || host?.closest('.chart-toolbar'); + const toolbarPanel = toolbar?.closest('.history-toolbar-panel'); + const refresh = $('#historyRefresh'); const label = range.querySelector('span'); const hint = $('#historyRangeHint'); const sixHours = range.querySelector('option[value="6"]'); const energy = tab === 'energy'; + const energyUnavailable = energy && !hasEnergyTargets; const rangeLabel = tr(energy ? 'energy.period' : 'history.range'); if (label) label.textContent = rangeLabel; range.setAttribute('aria-label', rangeLabel); if (hint) { hint.textContent = energy ? tr('energy.periodHint') : ''; hint.hidden = !energy; } if (sixHours) { sixHours.hidden = energy; sixHours.disabled = energy; } if (energy && $('#historyHours')?.value === '6') $('#historyHours').value = '24'; + range.hidden = energyUnavailable; + if (refresh) refresh.hidden = energyUnavailable; + if (toolbarPanel) toolbarPanel.hidden = energyUnavailable; range.classList.toggle('energy-period-control', energy); if (toolbar) toolbar.classList.toggle('energy-toolbar', energy); - if (energy && host) host.appendChild(range); + if (energy && host && hasEnergyTargets) host.appendChild(range); } function renderHistoryNavigation() { $$('#historyTabs [data-history-tab]').forEach(button => button.classList.toggle('active', button.dataset.historyTab === app.historyTab)); const energyPickerWasOpen = $('#historyEnergyTargetPicker')?.open === true; const host = $('#historyContextControls'); if (!host) return; + let hasEnergyTargets = true; resetHistoryRangeControl(host); host.classList.toggle('energy-context-controls', app.historyTab === 'energy'); const options = historyEntityOptions(); @@ -100,10 +107,11 @@ function renderHistoryNavigation() { const select = $('#historyDeviceSelect'); if ([...select.options].some(option => option.value === app.historyDevice)) select.value = app.historyDevice; } else if (app.historyTab === 'energy') { const targets = normalizeEnergyHistoryTargets(); + hasEnergyTargets = targets.length > 0; const targetOptions = targets.map(target => ``).join(''); host.innerHTML = targets.length ? `
${esc(tr('energy.targets'))}
${esc(energyTargetPickerSummary(targets))}${app.historyEnergyTargets.length}/8
${targetOptions}
${esc(tr('energy.multiselectHint'))}
` - : `${esc(tr('energy.noData'))}`; + : ''; const intervalSelect = $('#historyEnergyInterval'); if (intervalSelect) intervalSelect.value = app.historyEnergyInterval; const compareSelect = $('#historyEnergyCompare'); if (compareSelect) compareSelect.value = app.historyEnergyCompare; if (energyPickerWasOpen && $('#historyEnergyTargetPicker')) $('#historyEnergyTargetPicker').open = true; @@ -121,7 +129,7 @@ function renderHistoryNavigation() { } else { host.innerHTML = `${esc(tr('history.overviewHint'))}`; } - configureHistoryRangeControl(app.historyTab, host); + configureHistoryRangeControl(app.historyTab, host, hasEnergyTargets); } async function loadHistory() { @@ -164,6 +172,7 @@ async function loadHistory() { const HISTORY_COLORS = ['--accent', '--info', '--warning', '--purple', '--danger', '--teal', '--orange', '--blue']; const MAX_CHART_ZOOM = 16; const chartRuntime = new Map(); +const chartFullscreenState = new WeakMap(); function chartSeriesKey(item, index) { return item.key || `${index}:${item.label}`; @@ -251,6 +260,28 @@ function updateChartFullscreenButton(card) { button.setAttribute('aria-label', button.title); } +function restoreChartPreviewLayout(card) { + const state = chartFullscreenState.get(card); + if (!state) return null; + const canvas = card.querySelector('canvas[id]'); + const wrap = canvas?.parentElement; + if (canvas) { + if (state.canvasStyleWidth) canvas.style.width = state.canvasStyleWidth; else canvas.style.removeProperty('width'); + if (state.canvasStyleHeight) canvas.style.height = state.canvasStyleHeight; else canvas.style.removeProperty('height'); + canvas.width = state.canvasWidth; + canvas.height = state.canvasHeight; + } + if (wrap) { + if (state.wrapStyleHeight) wrap.style.height = state.wrapStyleHeight; else wrap.style.removeProperty('height'); + if (state.wrapStyleMinHeight) wrap.style.minHeight = state.wrapStyleMinHeight; else wrap.style.removeProperty('min-height'); + if (state.wrapStyleMaxHeight) wrap.style.maxHeight = state.wrapStyleMaxHeight; else wrap.style.removeProperty('max-height'); + wrap.scrollLeft = state.scrollLeft; + wrap.scrollTop = state.scrollTop; + } + chartFullscreenState.delete(card); + return state; +} + function closeChartPreview(card) { if (!card) return; card.classList.remove('chart-fullscreen-fallback'); @@ -258,9 +289,31 @@ function closeChartPreview(card) { card.removeAttribute('aria-modal'); card.removeAttribute('aria-label'); document.body.classList.remove('chart-fullscreen-open'); + const state = restoreChartPreviewLayout(card); updateChartFullscreenButton(card); const canvas = card.querySelector('canvas[id]'); - if (canvas?.id) requestAnimationFrame(() => redrawHistoryChart(canvas.id)); + if (!canvas?.id) return; + + // Force layout after dropping the fullscreen class before sizing the canvas again. + // This prevents a fullscreen-sized canvas from keeping a dialog artificially tall. + card.getBoundingClientRect(); + requestAnimationFrame(() => { + redrawHistoryChart(canvas.id); + const wrap = canvas.parentElement; + if (wrap && state) { + wrap.scrollLeft = state.scrollLeft; + wrap.scrollTop = state.scrollTop; + } + const dialog = card.closest('dialog'); + if (dialog?.open) { + dialog.style.removeProperty('height'); + dialog.style.removeProperty('max-height'); + requestAnimationFrame(() => { + dialog.style.removeProperty('height'); + dialog.style.removeProperty('max-height'); + }); + } + }); } function toggleChartFullscreen(id) { @@ -278,6 +331,18 @@ function toggleChartFullscreen(id) { return; } + const wrap = canvas.parentElement; + chartFullscreenState.set(card, { + canvasStyleWidth: canvas.style.width, + canvasStyleHeight: canvas.style.height, + canvasWidth: canvas.width, + canvasHeight: canvas.height, + wrapStyleHeight: wrap?.style.height || '', + wrapStyleMinHeight: wrap?.style.minHeight || '', + wrapStyleMaxHeight: wrap?.style.maxHeight || '', + scrollLeft: wrap?.scrollLeft || 0, + scrollTop: wrap?.scrollTop || 0, + }); card.classList.add('chart-fullscreen-fallback'); card.setAttribute('role', 'dialog'); card.setAttribute('aria-modal', 'true'); diff --git a/web/js/core.js b/web/js/core.js index 034c714..f0ed6b8 100644 --- a/web/js/core.js +++ b/web/js/core.js @@ -261,11 +261,36 @@ function applyTranslations() { if (app.currentView === 'history' && app.zones.length) loadHistory(); } -function setLanguage(language) { +async function loadLanguagePack(language) { + if (app.translations[language]) return; + const item = app.languages.find(entry => entry.code === language); + if (!item) throw new Error(`Unknown language: ${language}`); + const response = await fetch(withBase(item.path || `/lang/${encodeURIComponent(item.code)}.json`)); + if (!response.ok) throw new Error(`Language ${item.code} HTTP ${response.status}`); + const pack = await response.json(); + app.translations[item.code] = pack.translations || {}; + app.locales[item.code] = pack.meta?.locale || item.locale || item.code; +} + +async function setLanguage(language) { const available = app.languages.some(item => item.code === language); - app.language = available ? language : DEFAULT_LANGUAGE; - setCookie('gree_controller_language', app.language); - applyTranslations(); + const nextLanguage = available ? language : DEFAULT_LANGUAGE; + const previousLanguage = app.language; + const select = $('#languageSelect'); + if (nextLanguage === previousLanguage && app.translations[nextLanguage]) return; + if (select) select.disabled = true; + try { + await loadLanguagePack(nextLanguage); + app.language = nextLanguage; + setCookie('gree_controller_language', app.language); + applyTranslations(); + } catch (error) { + console.error(`Unable to load language ${nextLanguage}:`, error); + if (select) select.value = previousLanguage; + toast(error.message, true); + } finally { + if (select) select.disabled = false; + } } function renderLanguageOptions() { @@ -286,21 +311,21 @@ async function loadLanguages() { const manifest = await response.json(); const languages = Array.isArray(manifest.languages) ? manifest.languages : []; if (!languages.some(item => item.code === DEFAULT_LANGUAGE)) throw new Error('Default English language pack is missing'); - - const loaded = await Promise.all(languages.map(async item => { - const packResponse = await fetch(withBase(item.path || `/lang/${encodeURIComponent(item.code)}.json`)); - if (!packResponse.ok) throw new Error(`Language ${item.code} HTTP ${packResponse.status}`); - const pack = await packResponse.json(); - return { item, pack }; - })); - - app.languages = loaded.map(({ item }) => item); - app.translations = Object.fromEntries(loaded.map(({ item, pack }) => [item.code, pack.translations || {}])); - app.locales = Object.fromEntries(loaded.map(({ item, pack }) => [item.code, pack.meta?.locale || item.locale || item.code])); + app.languages = languages; + app.translations = {}; + app.locales = Object.fromEntries(languages.map(item => [item.code, item.locale || item.code])); app.language = app.languages.some(item => item.code === preferredLanguage) ? preferredLanguage : (manifest.default || DEFAULT_LANGUAGE); if (!app.languages.some(item => item.code === app.language)) app.language = DEFAULT_LANGUAGE; + try { + await loadLanguagePack(app.language); + } catch (error) { + if (app.language === DEFAULT_LANGUAGE) throw error; + console.error(`Unable to load preferred language ${app.language}:`, error); + app.language = DEFAULT_LANGUAGE; + await loadLanguagePack(DEFAULT_LANGUAGE); + } renderLanguageOptions(); } catch (error) { console.error('Unable to load language packs:', error); diff --git a/web/js/dashboard.js b/web/js/dashboard.js index 6b9b8de..8116fee 100644 --- a/web/js/dashboard.js +++ b/web/js/dashboard.js @@ -49,7 +49,7 @@ function renderHouseClimate() { if (powerOn) { powerOn.classList.remove('active'); powerOn.removeAttribute('aria-pressed'); } if (powerOff) { powerOff.classList.remove('active'); powerOff.removeAttribute('aria-pressed'); } - node.innerHTML = `
${esc(tr('house.seasonMode'))}

${esc(tr('house.smartThermostat'))}

${esc(tr('house.setpointStrategy'))}

${esc(tr('house.outdoor'))}${esc(outdoor)}
+ node.innerHTML = `
${esc(tr('house.seasonMode'))}

${esc(tr('house.smartThermostat'))}

${esc(tr('house.setpointStrategy'))}

diff --git a/web/js/events.js b/web/js/events.js index 41c0bc4..ce58034 100644 --- a/web/js/events.js +++ b/web/js/events.js @@ -11,6 +11,19 @@ document.addEventListener('keydown', event => { document.addEventListener('click', async event => { const inlineTarget = event.target.closest('[data-temperature-kind]'); if (inlineTarget && !event.target.closest('button')) { beginInlineTemperatureEdit(inlineTarget); return; } + const historyRoute = event.target.closest('a[data-history-route]'); + if (historyRoute) { + event.preventDefault(); + const hours = historyRoute.dataset.historyHours; + if (hours && $('#historyHours')) $('#historyHours').value = hours; + else if ($('#historyHours')) $('#historyHours').value = '24'; + const dialog = historyRoute.closest('dialog'); + closeChartPreview(dialog?.querySelector('.history-chart-card.chart-fullscreen-fallback')); + if (dialog?.open) dialog.close(); + app.historyTab = 'overview'; + showView('history'); + return; + } const button = event.target.closest('button'); if (!button) return; if (button.dataset.settingsTab) { setSettingsTab(button.dataset.settingsTab); return; } if (button.dataset.debugFilter) { app.debugFilter = button.dataset.debugFilter; renderDebugOverlay(); return; } @@ -73,6 +86,7 @@ document.addEventListener('click', async event => { return; } const action = button.dataset.action; if (!action) return; + if (action === 'open-outdoor-history') { await openOutdoorHistory(); return; } if (action === 'add-cloud-device') { const cloudId = button.dataset.cloudId; if (!cloudId) return; button.disabled = true; diff --git a/web/js/flows.js b/web/js/flows.js index c40d780..512fefa 100644 --- a/web/js/flows.js +++ b/web/js/flows.js @@ -816,16 +816,16 @@ function flowPresetRequirements(preset) { const haEntities = [...new Set(nodes.map(node => node.config?.entity_id).filter(value => typeof value === 'string' && value && !value.startsWith('$')))]; const requirements = []; const missing = []; - const addCount = (kind, count, available, key) => { + const addCount = (count, available, key) => { if (!count) return; const label = tr(key, { count }); requirements.push({ label, ok: available >= count }); if (available < count) missing.push(label); }; - addCount('zone', placeholders.zone.size, (app.zones || []).length, 'flow.templateRequiresZone'); - addCount('device', placeholders.device.size, (app.devices || []).length, 'flow.templateRequiresDevice'); - addCount('group', placeholders.group.size, (app.groups || []).length, 'flow.templateRequiresGroup'); - addCount('shared', placeholders.shared.size, (app.flowSharedInputs || []).length, 'flow.templateRequiresShared'); + addCount(placeholders.zone.size, (app.zones || []).length, 'flow.templateRequiresZone'); + addCount(placeholders.device.size, (app.devices || []).length, 'flow.templateRequiresDevice'); + addCount(placeholders.group.size, (app.groups || []).length, 'flow.templateRequiresGroup'); + addCount(placeholders.shared.size, (app.flowSharedInputs || []).length, 'flow.templateRequiresShared'); if (hasHa) { const ready = Boolean(app.settings?.home_assistant?.url && app.settings?.home_assistant?.token_configured); requirements.push({ label: tr('flow.templateRequiresHa'), ok: ready }); diff --git a/web/js/forms.js b/web/js/forms.js index b8dc833..26b8a55 100644 --- a/web/js/forms.js +++ b/web/js/forms.js @@ -273,6 +273,7 @@ function requestDialogClose(dialog) { if (!dialog) return true; const form = $('form', dialog); if (form && !confirmDiscardForm(form)) return false; + closeChartPreview(dialog.querySelector?.('.history-chart-card.chart-fullscreen-fallback')); dialog.close(); return true; } diff --git a/web/js/history.js b/web/js/history.js index f8b6cdb..01a9158 100644 --- a/web/js/history.js +++ b/web/js/history.js @@ -26,6 +26,43 @@ function outdoorHistorySeries(devices, rows) { })); } +async function openOutdoorHistory() { + const host = $('#outdoorHistoryChartHost'); + const current = $('#outdoorHistoryCurrent'); + if (!host || !current) return; + current.textContent = app.outdoorTemperature == null ? tr('common.unavailable') : fmtTemp(app.outdoorTemperature); + host.innerHTML = `
${esc(tr('common.loading'))}
`; + $$('#outdoorHistoryDialog [data-history-route]').forEach(link => { + const hours = link.dataset.historyHours; + link.href = withBase(`/history/overview${hours ? `?hours=${encodeURIComponent(hours)}` : ''}`); + }); + openDialog('outdoorHistoryDialog'); + try { + const data = await api('/api/history?scope=overview&hours=24&limit=20000'); + const deviceRows = data.devices || []; + const outdoorDeviceSeries = outdoorHistorySeries(app.devices, deviceRows).map(item => ({ + ...item, + label: item.label.includes(' · ') ? item.label : `${item.label} · ${tr('history.greeOutdoor')}`, + })); + const sensorRows = (data.sensors || []).filter(row => row.kind === 'outdoor'); + const outdoorEntities = [...new Set(sensorRows.map(row => row.entity_id))]; + const outdoorHaSeries = outdoorEntities.map((entity, index) => ({ + label: `HA · ${haSensorLabel(entity)}`, + color: historySeriesColor(index + outdoorDeviceSeries.length), + dash: [6, 4], + value: row => row.entity_id === entity ? historyNumber(row.temperature) : NaN, + })); + const series = [...outdoorDeviceSeries, ...outdoorHaSeries]; + const rows = [...deviceRows, ...sensorRows]; + host.innerHTML = historyChartMarkup('outdoorHistoryModalChart', tr('history.allOutdoor'), tr('house.outdoorHistoryHint')); + drawLineChart($('#outdoorHistoryModalChart'), series, rows, { height: 350 }); + renderLegend($('#outdoorHistoryModalChartLegend'), series); + } catch (error) { + host.innerHTML = `
${esc(tr('history.noData'))}${esc(error.message)}
`; + toast(error.message, true); + } +} + function renderHistorySummary() { const host = $('#historySummary'); if (!host) return; if (app.historyTab === 'energy') { renderEnergyHistorySummary(); return; } diff --git a/web/js/navigation.js b/web/js/navigation.js index eae532b..3e8273f 100644 --- a/web/js/navigation.js +++ b/web/js/navigation.js @@ -738,18 +738,21 @@ async function openDeviceDetails(id) { const sensorSelect = form.ha_energy_entity_id; sensorSelect.disabled = groupOwnsEnergy; sensorSelect.innerHTML = ''; - try { - const response = await api('/api/integrations/home-assistant/energy-sensors'); - for (const sensor of (response.sensors || [])) { - const option = document.createElement('option'); - option.value = sensor.entity_id; - option.textContent = `${sensor.name || sensor.entity_id} · ${sensor.unit || ''}`; - option.dataset.unit = sensor.unit || ''; - option.dataset.deviceClass = sensor.device_class || ''; - option.dataset.stateClass = sensor.state_class || ''; - sensorSelect.append(option); - } - } catch (_) { } + const haConfigured = Boolean(String(app.settings?.home_assistant?.url || '').trim() && app.settings?.home_assistant?.token_configured); + if (haConfigured) { + try { + const response = await api('/api/integrations/home-assistant/energy-sensors'); + for (const sensor of (response.sensors || [])) { + const option = document.createElement('option'); + option.value = sensor.entity_id; + option.textContent = `${sensor.name || sensor.entity_id} · ${sensor.unit || ''}`; + option.dataset.unit = sensor.unit || ''; + option.dataset.deviceClass = sensor.device_class || ''; + option.dataset.stateClass = sensor.state_class || ''; + sensorSelect.append(option); + } + } catch (_) { } + } 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; @@ -822,18 +825,21 @@ 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 (_) { } + const haConfigured = Boolean(String(app.settings?.home_assistant?.url || '').trim() && app.settings?.home_assistant?.token_configured); + if (haConfigured) { + 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; diff --git a/web/js/select-ui.js b/web/js/select-ui.js new file mode 100644 index 0000000..113cf42 --- /dev/null +++ b/web/js/select-ui.js @@ -0,0 +1,461 @@ +'use strict'; + +const customSelectRegistry = new WeakMap(); +let customSelectOpenState = null; +let customSelectSequence = 0; +let customSelectObserver = null; +let customSelectPropertiesPatched = false; + +function patchCustomSelectProperties() { + if (customSelectPropertiesPatched) return; + customSelectPropertiesPatched = true; + ['value', 'selectedIndex'].forEach(property => { + const descriptor = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, property); + if (!descriptor?.get || !descriptor?.set || descriptor.configurable === false) return; + try { + Object.defineProperty(HTMLSelectElement.prototype, property, { + configurable: descriptor.configurable, + enumerable: descriptor.enumerable, + get: descriptor.get, + set(nextValue) { + descriptor.set.call(this, nextValue); + queueMicrotask(() => refreshCustomSelect(this)); + }, + }); + } catch (_) { } + }); +} + +function customSelectSelectedOption(select) { + return select.options?.[select.selectedIndex] || null; +} + +function customSelectLabel(select) { + const explicit = select.getAttribute('aria-label'); + if (explicit) return explicit; + const labelledBy = select.getAttribute('aria-labelledby'); + if (labelledBy) { + const text = labelledBy.split(/\s+/).map(id => document.getElementById(id)?.textContent?.trim()).filter(Boolean).join(' '); + if (text) return text; + } + const label = select.closest('label'); + const labelSpan = label?.querySelector(':scope > span'); + return labelSpan?.textContent?.trim() || select.name || select.id || ''; +} + +function customSelectOptionRows(select) { + const rows = []; + [...select.children].forEach(child => { + if (child instanceof HTMLOptGroupElement) { + rows.push({ type: 'group', label: child.label }); + [...child.children].forEach(option => { + if (option instanceof HTMLOptionElement) rows.push({ type: 'option', option, index: [...select.options].indexOf(option) }); + }); + } else if (child instanceof HTMLOptionElement) { + rows.push({ type: 'option', option: child, index: [...select.options].indexOf(child) }); + } + }); + return rows; +} + +function positionCustomSelectMenu(state) { + if (!state?.open || !state.trigger?.isConnected || !state.menu?.isConnected) return; + const rect = state.trigger.getBoundingClientRect(); + const margin = 8; + const gap = 6; + const toolbar = state.toolbar; + const viewportWidth = document.documentElement.clientWidth || window.innerWidth; + const viewportHeight = document.documentElement.clientHeight || window.innerHeight; + const width = Math.min(Math.max(rect.width, toolbar ? 210 : 180), Math.max(180, viewportWidth - margin * 2)); + const below = Math.max(0, viewportHeight - rect.bottom - gap - margin); + const above = Math.max(0, rect.top - gap - margin); + + state.menu.style.width = `${Math.round(width)}px`; + state.menu.style.maxHeight = '320px'; + + // Once the popover is visible, scrollHeight is the natural menu height. + // Use that height to choose the side and, especially, to place short menus + // directly above the trigger instead of at the top of a 320px allowance. + const naturalHeight = Math.min(320, Math.max(0, state.menu.scrollHeight || state.menu.getBoundingClientRect().height || 0)); + const desiredHeight = naturalHeight || 180; + const useBelow = below >= desiredHeight || (below >= above && above < desiredHeight); + const available = Math.max(1, Math.min(320, useBelow ? below : above)); + state.menu.style.maxHeight = `${Math.round(available)}px`; + + const renderedHeight = naturalHeight ? Math.min(naturalHeight, available) : available; + const left = Math.min(Math.max(margin, rect.left), Math.max(margin, viewportWidth - width - margin)); + const rawTop = useBelow ? rect.bottom + gap : rect.top - gap - renderedHeight; + const maxTop = Math.max(margin, viewportHeight - renderedHeight - margin); + const top = Math.min(Math.max(margin, rawTop), maxTop); + + state.menu.style.left = `${Math.round(left)}px`; + state.menu.style.top = `${Math.round(top)}px`; + state.menu.dataset.placement = useBelow ? 'bottom' : 'top'; +} + + +function rebuildCustomSelectMenu(select, state) { + const rows = customSelectOptionRows(select); + state.menu.replaceChildren(); + + if (!rows.length) { + const empty = document.createElement('div'); + empty.className = 'custom-select-empty'; + empty.textContent = '—'; + state.menu.appendChild(empty); + return; + } + + rows.forEach(row => { + if (row.type === 'group') { + const group = document.createElement('div'); + group.className = 'custom-select-group'; + group.textContent = row.label; + state.menu.appendChild(group); + return; + } + + const { option, index } = row; + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'custom-select-option'; + button.dataset.index = String(index); + button.setAttribute('role', 'option'); + button.setAttribute('aria-selected', option.selected ? 'true' : 'false'); + button.disabled = option.disabled || option.parentElement?.disabled || select.disabled; + + const text = document.createElement('span'); + text.textContent = option.textContent || option.label || option.value || '—'; + const check = document.createElement('b'); + check.setAttribute('aria-hidden', 'true'); + check.textContent = option.selected ? '✓' : ''; + button.append(text, check); + + button.addEventListener('click', event => { + event.preventDefault(); + event.stopPropagation(); + if (button.disabled) return; + const nextIndex = Number(button.dataset.index); + if (!Number.isInteger(nextIndex) || !select.options[nextIndex]) return; + const changed = select.selectedIndex !== nextIndex; + select.selectedIndex = nextIndex; + refreshCustomSelect(select); + closeCustomSelect(); + if (changed) { + select.dispatchEvent(new Event('input', { bubbles: true })); + select.dispatchEvent(new Event('change', { bubbles: true })); + } + state.trigger.focus({ preventScroll: true }); + }); + + state.menu.appendChild(button); + }); +} + +function refreshCustomSelect(select) { + const state = customSelectRegistry.get(select); + if (!state) return; + const selected = customSelectSelectedOption(select); + const text = selected?.textContent?.trim() || selected?.label || selected?.value || '—'; + const label = customSelectLabel(select); + const disabled = !!select.disabled; + + if (state.toolbar) { + state.trigger.classList.toggle('custom-select-disabled', disabled); + state.trigger.setAttribute('aria-disabled', disabled ? 'true' : 'false'); + state.trigger.setAttribute('aria-label', label ? `${label}: ${text}` : text); + state.trigger.tabIndex = disabled ? -1 : 0; + } else { + state.value.textContent = text; + state.trigger.disabled = disabled; + state.trigger.setAttribute('aria-label', label ? `${label}: ${text}` : text); + state.wrapper.hidden = !!select.hidden; + } + + if (state.open) { + rebuildCustomSelectMenu(select, state); + requestAnimationFrame(() => positionCustomSelectMenu(state)); + } +} + +function closeCustomSelect(state = customSelectOpenState) { + if (!state?.open) return; + state.open = false; + state.trigger.setAttribute('aria-expanded', 'false'); + state.trigger.classList.remove('custom-select-open'); + state.wrapper?.classList.remove('custom-select-open'); + + if (typeof state.menu.hidePopover === 'function') { + try { + if (state.menu.matches(':popover-open')) state.menu.hidePopover(); + } catch (_) { } + } + state.menu.classList.remove('custom-select-menu-fallback-open'); + state.menu.hidden = true; + if (customSelectOpenState === state) customSelectOpenState = null; +} + +function openCustomSelect(select) { + const state = customSelectRegistry.get(select); + if (!state || select.disabled) return; + if (customSelectOpenState === state && state.open) { + closeCustomSelect(state); + return; + } + closeCustomSelect(); + refreshCustomSelect(select); + rebuildCustomSelectMenu(select, state); + + state.open = true; + customSelectOpenState = state; + state.trigger.setAttribute('aria-expanded', 'true'); + state.trigger.classList.add('custom-select-open'); + state.wrapper?.classList.add('custom-select-open'); + state.menu.hidden = false; + positionCustomSelectMenu(state); + + if (typeof state.menu.showPopover === 'function') { + try { state.menu.showPopover(); } + catch (_) { state.menu.classList.add('custom-select-menu-fallback-open'); } + } else { + state.menu.classList.add('custom-select-menu-fallback-open'); + } + + positionCustomSelectMenu(state); + requestAnimationFrame(() => { + positionCustomSelectMenu(state); + state.menu.querySelector('.custom-select-option[aria-selected="true"]')?.scrollIntoView({ block: 'nearest' }); + }); +} + +function customSelectKeyboard(event, select) { + if (select.disabled) return; + const state = customSelectRegistry.get(select); + if (!state) return; + + if (event.key === 'Enter' || event.key === ' ' || event.key === 'ArrowDown' || event.key === 'ArrowUp') { + event.preventDefault(); + if (!state.open) { + openCustomSelect(select); + return; + } + const enabled = [...state.menu.querySelectorAll('.custom-select-option:not(:disabled)')]; + if (!enabled.length) return; + const active = document.activeElement; + const current = enabled.indexOf(active); + const delta = event.key === 'ArrowUp' ? -1 : 1; + enabled[(current + delta + enabled.length) % enabled.length].focus(); + } else if (event.key === 'Escape' && state.open) { + event.preventDefault(); + closeCustomSelect(state); + state.trigger.focus({ preventScroll: true }); + } else if (event.key === 'Home' && state.open) { + event.preventDefault(); + state.menu.querySelector('.custom-select-option:not(:disabled)')?.focus(); + } else if (event.key === 'End' && state.open) { + event.preventDefault(); + [...state.menu.querySelectorAll('.custom-select-option:not(:disabled)')].at(-1)?.focus(); + } +} + +function enhanceCustomSelect(select) { + if (!(select instanceof HTMLSelectElement) || customSelectRegistry.has(select) || select.multiple || select.size > 1 || select.dataset.nativeSelect === 'true') return; + + const toolbarHost = select.classList.contains('toolbar-picker-select') ? select.closest('.toolbar-picker') : null; + const originalSelectState = { + tabIndex: select.getAttribute('tabindex'), + ariaHidden: select.getAttribute('aria-hidden'), + }; + const originalToolbarState = toolbarHost ? { + role: toolbarHost.getAttribute('role'), + ariaHaspopup: toolbarHost.getAttribute('aria-haspopup'), + ariaControls: toolbarHost.getAttribute('aria-controls'), + ariaExpanded: toolbarHost.getAttribute('aria-expanded'), + ariaDisabled: toolbarHost.getAttribute('aria-disabled'), + } : null; + const menu = document.createElement('div'); + const menuId = `customSelectMenu${++customSelectSequence}`; + menu.id = menuId; + menu.className = 'custom-select-menu'; + menu.setAttribute('role', 'listbox'); + menu.setAttribute('popover', 'manual'); + menu.hidden = true; + document.body.appendChild(menu); + + let wrapper = null; + let trigger = toolbarHost; + let value = null; + + select.classList.add('select-native-proxy'); + select.tabIndex = -1; + select.setAttribute('aria-hidden', 'true'); + + if (toolbarHost) { + toolbarHost.classList.add('custom-select-toolbar'); + toolbarHost.setAttribute('role', 'button'); + toolbarHost.setAttribute('aria-haspopup', 'listbox'); + toolbarHost.setAttribute('aria-controls', menuId); + toolbarHost.setAttribute('aria-expanded', 'false'); + } else { + wrapper = document.createElement('div'); + wrapper.className = 'custom-select'; + trigger = document.createElement('button'); + trigger.type = 'button'; + trigger.className = 'custom-select-trigger'; + trigger.setAttribute('aria-haspopup', 'listbox'); + trigger.setAttribute('aria-controls', menuId); + trigger.setAttribute('aria-expanded', 'false'); + value = document.createElement('span'); + value.className = 'custom-select-value'; + const chevron = document.createElement('span'); + chevron.className = 'custom-select-chevron'; + chevron.setAttribute('aria-hidden', 'true'); + chevron.textContent = '⌄'; + trigger.append(value, chevron); + wrapper.appendChild(trigger); + select.insertAdjacentElement('afterend', wrapper); + } + + const state = { + select, + wrapper, + trigger, + value, + menu, + toolbar: !!toolbarHost, + open: false, + originalSelectState, + originalToolbarState, + }; + customSelectRegistry.set(select, state); + + trigger.addEventListener('click', event => { + event.preventDefault(); + event.stopPropagation(); + openCustomSelect(select); + }); + trigger.addEventListener('keydown', event => customSelectKeyboard(event, select)); + menu.addEventListener('keydown', event => customSelectKeyboard(event, select)); + + // Clicking an implicit