This commit is contained in:
Mateusz Gruszczyński
2026-09-16 09:04:43 +02:00
parent 3d69e74319
commit 0437ef7b6f
28 changed files with 1254 additions and 221 deletions
Generated
+1 -1
View File
@@ -700,7 +700,7 @@ dependencies = [
[[package]] [[package]]
name = "gree-controller" name = "gree-controller"
version = "0.14.4" version = "0.14.8"
dependencies = [ dependencies = [
"aes", "aes",
"aes-gcm", "aes-gcm",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "gree-controller" name = "gree-controller"
version = "0.14.4" version = "0.14.8"
edition = "2021" edition = "2021"
authors = ["GREE Controller contributors"] authors = ["GREE Controller contributors"]
description = "Standalone local GREE HVAC controller with Web UI, SQLite and Home Assistant sensor support" description = "Standalone local GREE HVAC controller with Web UI, SQLite and Home Assistant sensor support"
+28 -27
View File
@@ -1,14 +1,14 @@
b99b779e89161c581acf74b67a32aface8f410a9fdf1d6d6fd7424226c0c1075 ./.env.example d67af429e4da9ce08e9d2f2a8472849ffbd70d135b1c5da535a076026794d04c ./.env.example
a4ec3874a2e3ab1bad28fb40bb620f7b01f64d01ad9b699306bf70ada31227db ./.gitignore a4ec3874a2e3ab1bad28fb40bb620f7b01f64d01ad9b699306bf70ada31227db ./.gitignore
6428e098916d33b88865b31840f3a0ec7d1191ed86eb30a634c5c74a188f0fb9 ./Cargo.lock 4caed3b142792ae42e882ff91e8d9196b3cc3c96ff94baf4e2d7f8f9f34e4805 ./Cargo.lock
708d68ca64a4623fb2e21be1e6e770b33facd2b901b3057e7f289b0ec0611e03 ./Cargo.toml cd71027b96818b2e50ee97f0105a90b77af8724647095dfafda5c4afc56d8e19 ./Cargo.toml
19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE 19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE
8601961aee2029e5bc8281b7f950d7de25fb1d85c6b89827cab471625a745d6c ./README.md d4ee781c55005c6465b1215bdf744a8935bd09d29eea9b8d8aad467936b32099 ./README.md
2345439df2b16d0478cff70d305307580052b1d2495e699c99fd743f3d3aeb2e ./SOURCE_MANIFEST.sha256 25110253d250f102100c4228ab9b695356ba5a7cb83552dd11d6be2206b9e504 ./SOURCE_MANIFEST.sha256
bb549919b96b0413152dd905aec8640bb53301f07ec7d0b1be48ccbd37936fef ./build.rs 5302570299af2c2650bada12e123f598c5db8009949c75761385f73ed57343c5 ./build.rs
1aa5bb37b5d0c315fcc085d40678a63964be1fd537bb3b8f35f5497fea1af6a3 ./docs/API.md affb3ca13cc95a5ead1deeec418200aacbad8a2ca17e0e20309abda5ca3ace20 ./docs/API.md
f21d732eb1147f8f3167d2172cdf0b57e61edabdfecbaf97811d46c5bff7e47a ./docs/FLOW.md f21d732eb1147f8f3167d2172cdf0b57e61edabdfecbaf97811d46c5bff7e47a ./docs/FLOW.md
0022f50fa10077531c3d1aa6afea5b4ea5ecff5e9d015269327df701e4a23c04 ./docs/openapi.json c6b926269cf4ba511231be8a977c397668cd97d1d1804ea29a0244c3373dec5d ./docs/openapi.json
c65aa543d2e99f8f7d52736307b07ebd182597d80124661d53a16bb23f6f1118 ./ha-addon/.env.example c65aa543d2e99f8f7d52736307b07ebd182597d80124661d53a16bb23f6f1118 ./ha-addon/.env.example
a18d2460340264d34c267fa704ee51a42e59bcc1783ee4c0dbb2a8ef42fa0f16 ./ha-addon/Dockerfile a18d2460340264d34c267fa704ee51a42e59bcc1783ee4c0dbb2a8ef42fa0f16 ./ha-addon/Dockerfile
0417c88a990c72aecf071fba64d5d9da65dcda4d396cd7565477f7a4532c8c5e ./ha-addon/README.md 0417c88a990c72aecf071fba64d5d9da65dcda4d396cd7565477f7a4532c8c5e ./ha-addon/README.md
@@ -22,7 +22,7 @@ b7f0873109c52be9d7f09bea3dffc416103c50085e1f0680d11661a969479898 ./ha-addon/hom
41a8958a6fe6d10e5679c95ac34832583a2a15ecfd67d09b4e094a83d06f5c47 ./ha-addon/home-assistant/custom_components/gree_controller/coordinator.py 41a8958a6fe6d10e5679c95ac34832583a2a15ecfd67d09b4e094a83d06f5c47 ./ha-addon/home-assistant/custom_components/gree_controller/coordinator.py
5a96fe8f5c035c34f1339370270cd078056202d09e236dec75735be11de92a7d ./ha-addon/home-assistant/custom_components/gree_controller/entity_map.py 5a96fe8f5c035c34f1339370270cd078056202d09e236dec75735be11de92a7d ./ha-addon/home-assistant/custom_components/gree_controller/entity_map.py
c4fb75c246db651087900ebfc2291ff41ac87652cd6194fc0b776b0005c1cbcf ./ha-addon/home-assistant/custom_components/gree_controller/icon.png 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 c52a484b671ce738ecc00228a1db19a5d703f69ab27530c2896a5c2ae3b4a96f ./ha-addon/home-assistant/custom_components/gree_controller/number.py
39c4309001b75abb56234f05662bc06e077054986876f1927937edbce528ec95 ./ha-addon/home-assistant/custom_components/gree_controller/select.py 39c4309001b75abb56234f05662bc06e077054986876f1927937edbce528ec95 ./ha-addon/home-assistant/custom_components/gree_controller/select.py
cca65482e36d48035aca178121a378fe7d578d600acff267ae81a6399c0da653 ./ha-addon/home-assistant/custom_components/gree_controller/sensor.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 6bddb7b4620021ecd2099a86a77ef5c7f2c2dcd3d07d5db4e7b4c4ce6d3e8c03 ./ha-addon/home-assistant/custom_components/gree_controller/translations/en.json
13f30e2dcdcedbd1b6c3f99c2335e0487108fd72c8e86922368b84f2fa2038ae ./ha-addon/home-assistant/custom_components/gree_controller/translations/pl.json 13f30e2dcdcedbd1b6c3f99c2335e0487108fd72c8e86922368b84f2fa2038ae ./ha-addon/home-assistant/custom_components/gree_controller/translations/pl.json
c5fc5c87273d82d2834a0a5a365c821a416413ed509613441e24e4dc3507c4d2 ./ha-addon/home-assistant/generated/gree_controller_entities.example.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 3c06bd11671452ea6472afd5543fb91a39aba73a1d2d95c7762c04f94ab64030 ./ha-addon/repository/gree-controller/DOCS.md
c3db302f38d26dc9f8caf37a1a8a07a1334cc07ae301d452f2940a5ec5915719 ./ha-addon/repository/gree-controller/README.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 3aae6cdae4c3aaab7786a9575e7093b7e2c5b69288167075f4b7475611691a6b ./ha-addon/repository/gree-controller/topologia-ha-vlan-gree-pl.png
0d0b42a7639b128946eec379175436fa1f150294f39d2dfd169077bfa87fd14d ./ha-addon/repository/gree-controller/topology-ha-vlan-gree-en.png 0d0b42a7639b128946eec379175436fa1f150294f39d2dfd169077bfa87fd14d ./ha-addon/repository/gree-controller/topology-ha-vlan-gree-en.png
d13e4c2b993d209078342c0c86d852b300692089b377aa044f811308dd188a1e ./ha-addon/repository/gree-controller/translations/en.yaml d13e4c2b993d209078342c0c86d852b300692089b377aa044f811308dd188a1e ./ha-addon/repository/gree-controller/translations/en.yaml
@@ -43,8 +43,8 @@ c123b190219556382f0e7f3058056fb233f6261f257a01eceb96a2c0bbfa12f3 ./ha-addon/run
081e27b6e43070d44f865335c30a42a5a29668eaa6a5ea5ab6967ac4447b8e06 ./ha-addon/sync-repository.sh 081e27b6e43070d44f865335c30a42a5a29668eaa6a5ea5ab6967ac4447b8e06 ./ha-addon/sync-repository.sh
f9e31de3d109ead32bf531c074d46a087df398d62488caee0b46b5e0a1c027f7 ./ha_addon.md f9e31de3d109ead32bf531c074d46a087df398d62488caee0b46b5e0a1c027f7 ./ha_addon.md
253a0bc912786e67ea7fc92a64e4a510ad973bec343a88ccfb1f28fca3e8cf01 ./lang/README.md 253a0bc912786e67ea7fc92a64e4a510ad973bec343a88ccfb1f28fca3e8cf01 ./lang/README.md
8e64e916d21bca02f1a29dac76e6458c5b6b2ebd90eabf9037ae6463ee057d7e ./lang/en.json 6d4f8efd56c36cac8c4f127a12d514e364cec86038bbe0fb2135961b7223d22c ./lang/en.json
ee29bebe2cda2cdf818c9a6829feecb9cb069974f9d3602bb1e3fbb6faba2744 ./lang/pl.json e70c1f6ef69f4f183626fa872248652e1eae6a9d677cdd2b294bd6c19ef52190 ./lang/pl.json
d8459024f04ca514bd8e9d6bd3af872fb942fd85a7cdd5583f4a9d28aab6faba ./make_zip.py d8459024f04ca514bd8e9d6bd3af872fb942fd85a7cdd5583f4a9d28aab6faba ./make_zip.py
b14233a8987e53bbbdd6770386ba10fa166ec25c1e098275c173c544b37846fc ./presets/bedroom_window_night.json b14233a8987e53bbbdd6770386ba10fa166ec25c1e098275c173c544b37846fc ./presets/bedroom_window_night.json
1960841119c0b673fb2f03621b32237552fd828045304cd221401214b997c910 ./presets/device_resilience.json 1960841119c0b673fb2f03621b32237552fd828045304cd221401214b997c910 ./presets/device_resilience.json
@@ -85,7 +85,7 @@ fdcd9a5055d08037278b842e7ab69265345c5811f0a06867136c511d140bb191 ./presets/wind
804f22123cd3e8db0fac791826c8dd9f758fb866c8e3b5655fb6d25d259dccf1 ./presets/workday_comfort.json 804f22123cd3e8db0fac791826c8dd9f758fb866c8e3b5655fb6d25d259dccf1 ./presets/workday_comfort.json
01952aa92b217f8eae2493b88870e2dec595100cd15c4d561ff11ae2b936c46f ./regenerate-sha.sh 01952aa92b217f8eae2493b88870e2dec595100cd15c4d561ff11ae2b936c46f ./regenerate-sha.sh
c64b1c6deeb24f20af662bfce7fa3683703914987241107ce664fa2f6d1a9dcb ./scripts/README.md c64b1c6deeb24f20af662bfce7fa3683703914987241107ce664fa2f6d1a9dcb ./scripts/README.md
a23ca9caaf80da017b1186893b0c56fc89bdd87c05ebf401ee769354a1842b7e ./scripts/api_dev_test.py 063f01360b2ff3cea3d81a45b87a28464ddb6ff31db76b535755b665ca76d7da ./scripts/api_dev_test.py
5bc736c7bc76ca80aaa406bb171d2aa91baf4c3aa8695dce0e09b888b6ab3146 ./scripts/common.sh 5bc736c7bc76ca80aaa406bb171d2aa91baf4c3aa8695dce0e09b888b6ab3146 ./scripts/common.sh
6403786610ee6d2f628193c25aee0dd058d62e904aa1a31d5f62fdaae0e94b4f ./scripts/configure-gree-network.sh 6403786610ee6d2f628193c25aee0dd058d62e904aa1a31d5f62fdaae0e94b4f ./scripts/configure-gree-network.sh
1d6e14e26e49aa9d3527f30a23668bf8d9c48b67e6628ef686c3155c012155de ./scripts/dev.sh 1d6e14e26e49aa9d3527f30a23668bf8d9c48b67e6628ef686c3155c012155de ./scripts/dev.sh
@@ -102,7 +102,7 @@ b50782b3742dfbf8a319c60571c968e93fdf8547db747c759edcffae68cb98bf ./scripts/upda
3dfd1e053cbfa0d684dd88cf99b244a96a36e2ff23d2a0715b8327ab62258254 ./src/api/assets.rs 3dfd1e053cbfa0d684dd88cf99b244a96a36e2ff23d2a0715b8327ab62258254 ./src/api/assets.rs
05c255919f7b3d81188fc5f1410be6a0037471dfc44e93bffb009f29a1e6a3fc ./src/api/auth.rs 05c255919f7b3d81188fc5f1410be6a0037471dfc44e93bffb009f29a1e6a3fc ./src/api/auth.rs
9e4fc675306e111ed3db7af9822e2925d88109c31b9e309851665a975c0bd83c ./src/api/automations.rs 9e4fc675306e111ed3db7af9822e2925d88109c31b9e309851665a975c0bd83c ./src/api/automations.rs
1c913c3119deaa0f473b1e4b40e2f8ba8f508a656640539141cd22fa69fe70a8 ./src/api/configuration.rs ddd47ed53274d53b93e8ab10c9dd2f9c92c03bd7f99a745a75726acc8d6b3384 ./src/api/configuration.rs
19bc141286a336ad67e99079496eb0ae5126f986ab28be62b5f97758e5f6969c ./src/api/debug_tokens.rs 19bc141286a336ad67e99079496eb0ae5126f986ab28be62b5f97758e5f6969c ./src/api/debug_tokens.rs
32ade46ec45d6faf7852242b9f5ba1be6eacbacbeb33d2deabfab59bf1610ce0 ./src/api/device_groups.rs 32ade46ec45d6faf7852242b9f5ba1be6eacbacbeb33d2deabfab59bf1610ce0 ./src/api/device_groups.rs
fcbb3da0538aab451f71810f829cc248d6bb67e586dc418d71a572980583823d ./src/api/devices.rs fcbb3da0538aab451f71810f829cc248d6bb67e586dc418d71a572980583823d ./src/api/devices.rs
@@ -112,7 +112,7 @@ d59b080b392b574ec94276c7b485b455d0d1abacc7de2ef29dcbfcf7d86dbdb0 ./src/api/flow
b61b0b4f196c5e72e47cf06ca0c14df3cf8eedbdef0242a9d2af33623663489f ./src/api/groups.rs b61b0b4f196c5e72e47cf06ca0c14df3cf8eedbdef0242a9d2af33623663489f ./src/api/groups.rs
376ac63794378c427d798e853a060612e76a95c9aa0357c2f216ba9e4f769f2f ./src/api/history.rs 376ac63794378c427d798e853a060612e76a95c9aa0357c2f216ba9e4f769f2f ./src/api/history.rs
e8edfbb725d6de650442444b0a929ad2be0bd2c2d7d227d2b3fdd99d05c94f94 ./src/api/house.rs e8edfbb725d6de650442444b0a929ad2be0bd2c2d7d227d2b3fdd99d05c94f94 ./src/api/house.rs
8bcaf384ab3a2c297f232800fbffa72edcaa09ea340de0d665f2dc63ccba139c ./src/api/integrations.rs 371b719cb8883c94ebe7de3b222daad503957bbff6589b19ba27595963972f1c ./src/api/integrations.rs
6bfbe5135d4f93bd8d19a707877b53ed2029fd8490b4b1954a7520888fb1d664 ./src/api/middleware.rs 6bfbe5135d4f93bd8d19a707877b53ed2029fd8490b4b1954a7520888fb1d664 ./src/api/middleware.rs
45e34a447ff384d99a3c42c489ca9fa75849c74d4d246caa91c63d927de17396 ./src/api/openapi.rs 45e34a447ff384d99a3c42c489ca9fa75849c74d4d246caa91c63d927de17396 ./src/api/openapi.rs
4feca416de6ce8acc34082f432721016b109c7e6e70116e5e0b710ea403c7006 ./src/api/public_settings.rs 4feca416de6ce8acc34082f432721016b109c7e6e70116e5e0b710ea403c7006 ./src/api/public_settings.rs
@@ -141,7 +141,7 @@ ed5c9a0c61c6e597acb33f58593ebe02b562ddc68fa61271e1c1cd436c1003d3 ./src/engine/a
f7de7b9b756bef9d6cffbc6ffdb7504b3121476f188e6ff5c8077d5fc54be811 ./src/engine/connectivity.rs f7de7b9b756bef9d6cffbc6ffdb7504b3121476f188e6ff5c8077d5fc54be811 ./src/engine/connectivity.rs
925135985f640ba9ae0a6109d908187b659341a91451e91d27e533adfd610b76 ./src/engine/control_plan.rs 925135985f640ba9ae0a6109d908187b659341a91451e91d27e533adfd610b76 ./src/engine/control_plan.rs
7b48a33343d519a2ff2d81e9d757eb56a0c1beacfc0b7b8ef61f49c2ad363f9a ./src/engine/deadlines.rs 7b48a33343d519a2ff2d81e9d757eb56a0c1beacfc0b7b8ef61f49c2ad363f9a ./src/engine/deadlines.rs
67ddb482ad08608e799a88faa6183d9bb65f62ca8ac138c6314cab18bc033033 ./src/engine/energy.rs f5e3a8e85c622ea623360bdac87a098785748d8e4f4ba19ec2aa18c279b61c03 ./src/engine/energy.rs
e350aa63f3bb14ca887eaaa7555edbeae00fb584cbcaf552cc972f0c8918f5d8 ./src/engine/groups.rs e350aa63f3bb14ca887eaaa7555edbeae00fb584cbcaf552cc972f0c8918f5d8 ./src/engine/groups.rs
5d8b3c9f2243848502bba5c1e4940f8bf8c5ae4bffd891b43e2e9b4e7dc55b0c ./src/engine/history.rs 5d8b3c9f2243848502bba5c1e4940f8bf8c5ae4bffd891b43e2e9b4e7dc55b0c ./src/engine/history.rs
41b7045877771b7c352328998cb45ce2433e7f9631fad3eb1a882ab4897dc31f ./src/engine/local_thermostat.rs 41b7045877771b7c352328998cb45ce2433e7f9631fad3eb1a882ab4897dc31f ./src/engine/local_thermostat.rs
@@ -201,25 +201,26 @@ f13f1be3d5789539eba3fc1b59c14835f8c681eca7634e118fa3763a53aeee2b ./src/queries/
b92a6cb158b494fe145b43c7641e65f6fafff47201d7d76edbec2cfd8b94835c ./systemd/gree-controller.service b92a6cb158b494fe145b43c7641e65f6fafff47201d7d76edbec2cfd8b94835c ./systemd/gree-controller.service
544a31cb2e5374227026afd6abd0d910381f3fca1638154ebd74e3c3d96a35d1 ./web/404.html 544a31cb2e5374227026afd6abd0d910381f3fca1638154ebd74e3c3d96a35d1 ./web/404.html
e98bdd7204349cce1ec6f57283509697af0bbc72280622a6c3efa6fed242db4f ./web/favicon.svg e98bdd7204349cce1ec6f57283509697af0bbc72280622a6c3efa6fed242db4f ./web/favicon.svg
4c243c29743b402966f48d904a17bf48d0ce9022a0b25100f0b7cd8b81926f5c ./web/index.html 1c206e7168770128f5afbb8a95f96e72ae2e9249a646f0651399ff71d6b1895f ./web/index.html
9ba3f6fa05b72aa989139b2a909982571b2a02055052e4c40104f1e81a9aa7eb ./web/js/README.md 9ba3f6fa05b72aa989139b2a909982571b2a02055052e4c40104f1e81a9aa7eb ./web/js/README.md
27eff2821c40395369be15e197fa830535d2327fcb27c93a0e8e65a1c62de3ad ./web/js/bootstrap.js 27eff2821c40395369be15e197fa830535d2327fcb27c93a0e8e65a1c62de3ad ./web/js/bootstrap.js
def704d31b00706c26bae73190be15b289e72e6ecc4d8c9e9a5bb1804c747206 ./web/js/charts.js d3266522e02efa9f8e6eca6c15b53bc1114686aedcaac9e89a59be42b7145156 ./web/js/charts.js
f33a59e6863346d6751a74e1f912b6f0cd7a187defc3b01267a8121c9cd7ec8f ./web/js/core.js c4531f79ba2e15501407767a417085eab0a61bd280f8356b6960f6e320736b05 ./web/js/core.js
021736e8b8d02248a41788765af2dd46914a700e8cadb0c43a75e83ed3f2dd14 ./web/js/dashboard.js f682b80c3b348a6e4832dc7d51227f696ab43b26d93f5ac52cabf35aed3c9ff8 ./web/js/dashboard.js
ed31601df41cc82032709f960a2b4061966abd58cde456ef7dda05e42eb91432 ./web/js/entities.js ed31601df41cc82032709f960a2b4061966abd58cde456ef7dda05e42eb91432 ./web/js/entities.js
ef778fb570958704c2e079fa5cd645f67f884881e16984daa6f3ed52627b26a5 ./web/js/events.js a59fd5b822029d0c1e74ba8078f55f13122347e036a96dec383293ae386f6667 ./web/js/events.js
89d5f4098e74162ecdee2eef9441f88484a09e9197d21e30c40c220cedbf7e53 ./web/js/flows.js 0f63a4172fd76eed70f44ff1edbe9cdd0c20e31cfff5de15f17ed2e591fda9b4 ./web/js/flows.js
52b4807f05a1325565d5e866007f49ea83c568574155cded131ce42a49df44f5 ./web/js/forms.js 63d508dc7bee43935086e8400366c271d7ebd9e8782e058296b449237399bf61 ./web/js/forms.js
ae6195905f33a4a83d5ba7c22d698419e2de05d80ed37df6066e0380fa715b6e ./web/js/history.js 5ded2b5533700e266d30fc3cd3a0e8a46ec647140f16df5304eff513d6c72f82 ./web/js/history.js
0dc879c0056251618633c9efd188fc1593eeae6965ad1bc993132dea6e171b44 ./web/js/main.js 0dc879c0056251618633c9efd188fc1593eeae6965ad1bc993132dea6e171b44 ./web/js/main.js
53ebdaa64a1dc8327db5cd4e08fb3f551dc2df33c214f218c6bb1ae3f27ad4dd ./web/js/navigation.js 6bb9386707dcf601111fea15a1a366d8798313b973ca1624878618f4c3095add ./web/js/navigation.js
e3d146a683b8beb332c752e533826160c2cf2236bc269ced7a4dd1249871027b ./web/js/realtime.js e3d146a683b8beb332c752e533826160c2cf2236bc269ced7a4dd1249871027b ./web/js/realtime.js
9546ca5c5566994aff3c33d92295be46b65d51d874681764abe77470dc9c9c73 ./web/js/router.js 9546ca5c5566994aff3c33d92295be46b65d51d874681764abe77470dc9c9c73 ./web/js/router.js
b6c894e49e105c8afad8c5b21d322692cee6b3120ee10ef676bcd5e521c8956e ./web/js/select-ui.js
cdc55061a41b7325aad552357d8359b42d881589c39b438cdc2a48bf0f690db9 ./web/js/settings-ui.js cdc55061a41b7325aad552357d8359b42d881589c39b438cdc2a48bf0f690db9 ./web/js/settings-ui.js
f00c3f603eb314f755a0c46424681b579e0b890453e2550007c719273815147c ./web/js/settings.js f00c3f603eb314f755a0c46424681b579e0b890453e2550007c719273815147c ./web/js/settings.js
3815fea7ae1694568c2f249bc8460e0563ced6bde89214f74f21b41fe8cad9a6 ./web/lang-init.js 3815fea7ae1694568c2f249bc8460e0563ced6bde89214f74f21b41fe8cad9a6 ./web/lang-init.js
6b653e4fe2d4db4ffae6ff8f39b8ce57a10a990dc0000ee4aae51d949afadd52 ./web/manifest.webmanifest 6b653e4fe2d4db4ffae6ff8f39b8ce57a10a990dc0000ee4aae51d949afadd52 ./web/manifest.webmanifest
4b3c94a1988e8e18bd0c39e5ebf73ce6ef31ddea5bb352ec40b1ae7b3609f5e9 ./web/styles.css ac5762a8656fb61a6100bb16ddd0d6d0ee506532b6c05da8ec47595ec2ebbf44 ./web/styles.css
fefdf12cbbb0a5d8a2cb89275cae28455aa42aaf179542305cc8f5e0f4825898 ./web/sw.js fefdf12cbbb0a5d8a2cb89275cae28455aa42aaf179542305cc8f5e0f4825898 ./web/sw.js
d505d793ce7cc9485b45b78bba1c0d51887adc7451ab59a42702946e5b991382 ./web/theme-init.js d505d793ce7cc9485b45b78bba1c0d51887adc7451ab59a42702946e5b991382 ./web/theme-init.js
+2 -2
View File
@@ -2,9 +2,9 @@
Self-hosted controller for GREE-compatible air conditioners with a local Web UI, thermostat zones, schedules, Home Assistant integration, history, notifications and a documented HTTP/WebSocket API. 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. > [Full API reference](docs/API.md) — authentication, every endpoint, request bodies, response models, WebSocket events and examples.
+27 -26
View File
@@ -1,13 +1,13 @@
b99b779e89161c581acf74b67a32aface8f410a9fdf1d6d6fd7424226c0c1075 ./.env.example d67af429e4da9ce08e9d2f2a8472849ffbd70d135b1c5da535a076026794d04c ./.env.example
a4ec3874a2e3ab1bad28fb40bb620f7b01f64d01ad9b699306bf70ada31227db ./.gitignore a4ec3874a2e3ab1bad28fb40bb620f7b01f64d01ad9b699306bf70ada31227db ./.gitignore
6428e098916d33b88865b31840f3a0ec7d1191ed86eb30a634c5c74a188f0fb9 ./Cargo.lock 4caed3b142792ae42e882ff91e8d9196b3cc3c96ff94baf4e2d7f8f9f34e4805 ./Cargo.lock
708d68ca64a4623fb2e21be1e6e770b33facd2b901b3057e7f289b0ec0611e03 ./Cargo.toml cd71027b96818b2e50ee97f0105a90b77af8724647095dfafda5c4afc56d8e19 ./Cargo.toml
19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE 19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE
8601961aee2029e5bc8281b7f950d7de25fb1d85c6b89827cab471625a745d6c ./README.md d4ee781c55005c6465b1215bdf744a8935bd09d29eea9b8d8aad467936b32099 ./README.md
bb549919b96b0413152dd905aec8640bb53301f07ec7d0b1be48ccbd37936fef ./build.rs 5302570299af2c2650bada12e123f598c5db8009949c75761385f73ed57343c5 ./build.rs
1aa5bb37b5d0c315fcc085d40678a63964be1fd537bb3b8f35f5497fea1af6a3 ./docs/API.md affb3ca13cc95a5ead1deeec418200aacbad8a2ca17e0e20309abda5ca3ace20 ./docs/API.md
f21d732eb1147f8f3167d2172cdf0b57e61edabdfecbaf97811d46c5bff7e47a ./docs/FLOW.md f21d732eb1147f8f3167d2172cdf0b57e61edabdfecbaf97811d46c5bff7e47a ./docs/FLOW.md
0022f50fa10077531c3d1aa6afea5b4ea5ecff5e9d015269327df701e4a23c04 ./docs/openapi.json c6b926269cf4ba511231be8a977c397668cd97d1d1804ea29a0244c3373dec5d ./docs/openapi.json
c65aa543d2e99f8f7d52736307b07ebd182597d80124661d53a16bb23f6f1118 ./ha-addon/.env.example c65aa543d2e99f8f7d52736307b07ebd182597d80124661d53a16bb23f6f1118 ./ha-addon/.env.example
a18d2460340264d34c267fa704ee51a42e59bcc1783ee4c0dbb2a8ef42fa0f16 ./ha-addon/Dockerfile a18d2460340264d34c267fa704ee51a42e59bcc1783ee4c0dbb2a8ef42fa0f16 ./ha-addon/Dockerfile
0417c88a990c72aecf071fba64d5d9da65dcda4d396cd7565477f7a4532c8c5e ./ha-addon/README.md 0417c88a990c72aecf071fba64d5d9da65dcda4d396cd7565477f7a4532c8c5e ./ha-addon/README.md
@@ -21,7 +21,7 @@ b7f0873109c52be9d7f09bea3dffc416103c50085e1f0680d11661a969479898 ./ha-addon/hom
41a8958a6fe6d10e5679c95ac34832583a2a15ecfd67d09b4e094a83d06f5c47 ./ha-addon/home-assistant/custom_components/gree_controller/coordinator.py 41a8958a6fe6d10e5679c95ac34832583a2a15ecfd67d09b4e094a83d06f5c47 ./ha-addon/home-assistant/custom_components/gree_controller/coordinator.py
5a96fe8f5c035c34f1339370270cd078056202d09e236dec75735be11de92a7d ./ha-addon/home-assistant/custom_components/gree_controller/entity_map.py 5a96fe8f5c035c34f1339370270cd078056202d09e236dec75735be11de92a7d ./ha-addon/home-assistant/custom_components/gree_controller/entity_map.py
c4fb75c246db651087900ebfc2291ff41ac87652cd6194fc0b776b0005c1cbcf ./ha-addon/home-assistant/custom_components/gree_controller/icon.png 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 c52a484b671ce738ecc00228a1db19a5d703f69ab27530c2896a5c2ae3b4a96f ./ha-addon/home-assistant/custom_components/gree_controller/number.py
39c4309001b75abb56234f05662bc06e077054986876f1927937edbce528ec95 ./ha-addon/home-assistant/custom_components/gree_controller/select.py 39c4309001b75abb56234f05662bc06e077054986876f1927937edbce528ec95 ./ha-addon/home-assistant/custom_components/gree_controller/select.py
cca65482e36d48035aca178121a378fe7d578d600acff267ae81a6399c0da653 ./ha-addon/home-assistant/custom_components/gree_controller/sensor.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 6bddb7b4620021ecd2099a86a77ef5c7f2c2dcd3d07d5db4e7b4c4ce6d3e8c03 ./ha-addon/home-assistant/custom_components/gree_controller/translations/en.json
13f30e2dcdcedbd1b6c3f99c2335e0487108fd72c8e86922368b84f2fa2038ae ./ha-addon/home-assistant/custom_components/gree_controller/translations/pl.json 13f30e2dcdcedbd1b6c3f99c2335e0487108fd72c8e86922368b84f2fa2038ae ./ha-addon/home-assistant/custom_components/gree_controller/translations/pl.json
c5fc5c87273d82d2834a0a5a365c821a416413ed509613441e24e4dc3507c4d2 ./ha-addon/home-assistant/generated/gree_controller_entities.example.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 3c06bd11671452ea6472afd5543fb91a39aba73a1d2d95c7762c04f94ab64030 ./ha-addon/repository/gree-controller/DOCS.md
c3db302f38d26dc9f8caf37a1a8a07a1334cc07ae301d452f2940a5ec5915719 ./ha-addon/repository/gree-controller/README.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 3aae6cdae4c3aaab7786a9575e7093b7e2c5b69288167075f4b7475611691a6b ./ha-addon/repository/gree-controller/topologia-ha-vlan-gree-pl.png
0d0b42a7639b128946eec379175436fa1f150294f39d2dfd169077bfa87fd14d ./ha-addon/repository/gree-controller/topology-ha-vlan-gree-en.png 0d0b42a7639b128946eec379175436fa1f150294f39d2dfd169077bfa87fd14d ./ha-addon/repository/gree-controller/topology-ha-vlan-gree-en.png
d13e4c2b993d209078342c0c86d852b300692089b377aa044f811308dd188a1e ./ha-addon/repository/gree-controller/translations/en.yaml d13e4c2b993d209078342c0c86d852b300692089b377aa044f811308dd188a1e ./ha-addon/repository/gree-controller/translations/en.yaml
@@ -42,8 +42,8 @@ c123b190219556382f0e7f3058056fb233f6261f257a01eceb96a2c0bbfa12f3 ./ha-addon/run
081e27b6e43070d44f865335c30a42a5a29668eaa6a5ea5ab6967ac4447b8e06 ./ha-addon/sync-repository.sh 081e27b6e43070d44f865335c30a42a5a29668eaa6a5ea5ab6967ac4447b8e06 ./ha-addon/sync-repository.sh
f9e31de3d109ead32bf531c074d46a087df398d62488caee0b46b5e0a1c027f7 ./ha_addon.md f9e31de3d109ead32bf531c074d46a087df398d62488caee0b46b5e0a1c027f7 ./ha_addon.md
253a0bc912786e67ea7fc92a64e4a510ad973bec343a88ccfb1f28fca3e8cf01 ./lang/README.md 253a0bc912786e67ea7fc92a64e4a510ad973bec343a88ccfb1f28fca3e8cf01 ./lang/README.md
8e64e916d21bca02f1a29dac76e6458c5b6b2ebd90eabf9037ae6463ee057d7e ./lang/en.json 6d4f8efd56c36cac8c4f127a12d514e364cec86038bbe0fb2135961b7223d22c ./lang/en.json
ee29bebe2cda2cdf818c9a6829feecb9cb069974f9d3602bb1e3fbb6faba2744 ./lang/pl.json e70c1f6ef69f4f183626fa872248652e1eae6a9d677cdd2b294bd6c19ef52190 ./lang/pl.json
d8459024f04ca514bd8e9d6bd3af872fb942fd85a7cdd5583f4a9d28aab6faba ./make_zip.py d8459024f04ca514bd8e9d6bd3af872fb942fd85a7cdd5583f4a9d28aab6faba ./make_zip.py
b14233a8987e53bbbdd6770386ba10fa166ec25c1e098275c173c544b37846fc ./presets/bedroom_window_night.json b14233a8987e53bbbdd6770386ba10fa166ec25c1e098275c173c544b37846fc ./presets/bedroom_window_night.json
1960841119c0b673fb2f03621b32237552fd828045304cd221401214b997c910 ./presets/device_resilience.json 1960841119c0b673fb2f03621b32237552fd828045304cd221401214b997c910 ./presets/device_resilience.json
@@ -84,7 +84,7 @@ fdcd9a5055d08037278b842e7ab69265345c5811f0a06867136c511d140bb191 ./presets/wind
804f22123cd3e8db0fac791826c8dd9f758fb866c8e3b5655fb6d25d259dccf1 ./presets/workday_comfort.json 804f22123cd3e8db0fac791826c8dd9f758fb866c8e3b5655fb6d25d259dccf1 ./presets/workday_comfort.json
01952aa92b217f8eae2493b88870e2dec595100cd15c4d561ff11ae2b936c46f ./regenerate-sha.sh 01952aa92b217f8eae2493b88870e2dec595100cd15c4d561ff11ae2b936c46f ./regenerate-sha.sh
c64b1c6deeb24f20af662bfce7fa3683703914987241107ce664fa2f6d1a9dcb ./scripts/README.md c64b1c6deeb24f20af662bfce7fa3683703914987241107ce664fa2f6d1a9dcb ./scripts/README.md
a23ca9caaf80da017b1186893b0c56fc89bdd87c05ebf401ee769354a1842b7e ./scripts/api_dev_test.py 063f01360b2ff3cea3d81a45b87a28464ddb6ff31db76b535755b665ca76d7da ./scripts/api_dev_test.py
5bc736c7bc76ca80aaa406bb171d2aa91baf4c3aa8695dce0e09b888b6ab3146 ./scripts/common.sh 5bc736c7bc76ca80aaa406bb171d2aa91baf4c3aa8695dce0e09b888b6ab3146 ./scripts/common.sh
6403786610ee6d2f628193c25aee0dd058d62e904aa1a31d5f62fdaae0e94b4f ./scripts/configure-gree-network.sh 6403786610ee6d2f628193c25aee0dd058d62e904aa1a31d5f62fdaae0e94b4f ./scripts/configure-gree-network.sh
1d6e14e26e49aa9d3527f30a23668bf8d9c48b67e6628ef686c3155c012155de ./scripts/dev.sh 1d6e14e26e49aa9d3527f30a23668bf8d9c48b67e6628ef686c3155c012155de ./scripts/dev.sh
@@ -101,7 +101,7 @@ b50782b3742dfbf8a319c60571c968e93fdf8547db747c759edcffae68cb98bf ./scripts/upda
3dfd1e053cbfa0d684dd88cf99b244a96a36e2ff23d2a0715b8327ab62258254 ./src/api/assets.rs 3dfd1e053cbfa0d684dd88cf99b244a96a36e2ff23d2a0715b8327ab62258254 ./src/api/assets.rs
05c255919f7b3d81188fc5f1410be6a0037471dfc44e93bffb009f29a1e6a3fc ./src/api/auth.rs 05c255919f7b3d81188fc5f1410be6a0037471dfc44e93bffb009f29a1e6a3fc ./src/api/auth.rs
9e4fc675306e111ed3db7af9822e2925d88109c31b9e309851665a975c0bd83c ./src/api/automations.rs 9e4fc675306e111ed3db7af9822e2925d88109c31b9e309851665a975c0bd83c ./src/api/automations.rs
1c913c3119deaa0f473b1e4b40e2f8ba8f508a656640539141cd22fa69fe70a8 ./src/api/configuration.rs ddd47ed53274d53b93e8ab10c9dd2f9c92c03bd7f99a745a75726acc8d6b3384 ./src/api/configuration.rs
19bc141286a336ad67e99079496eb0ae5126f986ab28be62b5f97758e5f6969c ./src/api/debug_tokens.rs 19bc141286a336ad67e99079496eb0ae5126f986ab28be62b5f97758e5f6969c ./src/api/debug_tokens.rs
32ade46ec45d6faf7852242b9f5ba1be6eacbacbeb33d2deabfab59bf1610ce0 ./src/api/device_groups.rs 32ade46ec45d6faf7852242b9f5ba1be6eacbacbeb33d2deabfab59bf1610ce0 ./src/api/device_groups.rs
fcbb3da0538aab451f71810f829cc248d6bb67e586dc418d71a572980583823d ./src/api/devices.rs fcbb3da0538aab451f71810f829cc248d6bb67e586dc418d71a572980583823d ./src/api/devices.rs
@@ -111,7 +111,7 @@ d59b080b392b574ec94276c7b485b455d0d1abacc7de2ef29dcbfcf7d86dbdb0 ./src/api/flow
b61b0b4f196c5e72e47cf06ca0c14df3cf8eedbdef0242a9d2af33623663489f ./src/api/groups.rs b61b0b4f196c5e72e47cf06ca0c14df3cf8eedbdef0242a9d2af33623663489f ./src/api/groups.rs
376ac63794378c427d798e853a060612e76a95c9aa0357c2f216ba9e4f769f2f ./src/api/history.rs 376ac63794378c427d798e853a060612e76a95c9aa0357c2f216ba9e4f769f2f ./src/api/history.rs
e8edfbb725d6de650442444b0a929ad2be0bd2c2d7d227d2b3fdd99d05c94f94 ./src/api/house.rs e8edfbb725d6de650442444b0a929ad2be0bd2c2d7d227d2b3fdd99d05c94f94 ./src/api/house.rs
8bcaf384ab3a2c297f232800fbffa72edcaa09ea340de0d665f2dc63ccba139c ./src/api/integrations.rs 371b719cb8883c94ebe7de3b222daad503957bbff6589b19ba27595963972f1c ./src/api/integrations.rs
6bfbe5135d4f93bd8d19a707877b53ed2029fd8490b4b1954a7520888fb1d664 ./src/api/middleware.rs 6bfbe5135d4f93bd8d19a707877b53ed2029fd8490b4b1954a7520888fb1d664 ./src/api/middleware.rs
45e34a447ff384d99a3c42c489ca9fa75849c74d4d246caa91c63d927de17396 ./src/api/openapi.rs 45e34a447ff384d99a3c42c489ca9fa75849c74d4d246caa91c63d927de17396 ./src/api/openapi.rs
4feca416de6ce8acc34082f432721016b109c7e6e70116e5e0b710ea403c7006 ./src/api/public_settings.rs 4feca416de6ce8acc34082f432721016b109c7e6e70116e5e0b710ea403c7006 ./src/api/public_settings.rs
@@ -140,7 +140,7 @@ ed5c9a0c61c6e597acb33f58593ebe02b562ddc68fa61271e1c1cd436c1003d3 ./src/engine/a
f7de7b9b756bef9d6cffbc6ffdb7504b3121476f188e6ff5c8077d5fc54be811 ./src/engine/connectivity.rs f7de7b9b756bef9d6cffbc6ffdb7504b3121476f188e6ff5c8077d5fc54be811 ./src/engine/connectivity.rs
925135985f640ba9ae0a6109d908187b659341a91451e91d27e533adfd610b76 ./src/engine/control_plan.rs 925135985f640ba9ae0a6109d908187b659341a91451e91d27e533adfd610b76 ./src/engine/control_plan.rs
7b48a33343d519a2ff2d81e9d757eb56a0c1beacfc0b7b8ef61f49c2ad363f9a ./src/engine/deadlines.rs 7b48a33343d519a2ff2d81e9d757eb56a0c1beacfc0b7b8ef61f49c2ad363f9a ./src/engine/deadlines.rs
67ddb482ad08608e799a88faa6183d9bb65f62ca8ac138c6314cab18bc033033 ./src/engine/energy.rs f5e3a8e85c622ea623360bdac87a098785748d8e4f4ba19ec2aa18c279b61c03 ./src/engine/energy.rs
e350aa63f3bb14ca887eaaa7555edbeae00fb584cbcaf552cc972f0c8918f5d8 ./src/engine/groups.rs e350aa63f3bb14ca887eaaa7555edbeae00fb584cbcaf552cc972f0c8918f5d8 ./src/engine/groups.rs
5d8b3c9f2243848502bba5c1e4940f8bf8c5ae4bffd891b43e2e9b4e7dc55b0c ./src/engine/history.rs 5d8b3c9f2243848502bba5c1e4940f8bf8c5ae4bffd891b43e2e9b4e7dc55b0c ./src/engine/history.rs
41b7045877771b7c352328998cb45ce2433e7f9631fad3eb1a882ab4897dc31f ./src/engine/local_thermostat.rs 41b7045877771b7c352328998cb45ce2433e7f9631fad3eb1a882ab4897dc31f ./src/engine/local_thermostat.rs
@@ -200,25 +200,26 @@ f13f1be3d5789539eba3fc1b59c14835f8c681eca7634e118fa3763a53aeee2b ./src/queries/
b92a6cb158b494fe145b43c7641e65f6fafff47201d7d76edbec2cfd8b94835c ./systemd/gree-controller.service b92a6cb158b494fe145b43c7641e65f6fafff47201d7d76edbec2cfd8b94835c ./systemd/gree-controller.service
544a31cb2e5374227026afd6abd0d910381f3fca1638154ebd74e3c3d96a35d1 ./web/404.html 544a31cb2e5374227026afd6abd0d910381f3fca1638154ebd74e3c3d96a35d1 ./web/404.html
e98bdd7204349cce1ec6f57283509697af0bbc72280622a6c3efa6fed242db4f ./web/favicon.svg e98bdd7204349cce1ec6f57283509697af0bbc72280622a6c3efa6fed242db4f ./web/favicon.svg
4c243c29743b402966f48d904a17bf48d0ce9022a0b25100f0b7cd8b81926f5c ./web/index.html 1c206e7168770128f5afbb8a95f96e72ae2e9249a646f0651399ff71d6b1895f ./web/index.html
9ba3f6fa05b72aa989139b2a909982571b2a02055052e4c40104f1e81a9aa7eb ./web/js/README.md 9ba3f6fa05b72aa989139b2a909982571b2a02055052e4c40104f1e81a9aa7eb ./web/js/README.md
27eff2821c40395369be15e197fa830535d2327fcb27c93a0e8e65a1c62de3ad ./web/js/bootstrap.js 27eff2821c40395369be15e197fa830535d2327fcb27c93a0e8e65a1c62de3ad ./web/js/bootstrap.js
def704d31b00706c26bae73190be15b289e72e6ecc4d8c9e9a5bb1804c747206 ./web/js/charts.js d3266522e02efa9f8e6eca6c15b53bc1114686aedcaac9e89a59be42b7145156 ./web/js/charts.js
f33a59e6863346d6751a74e1f912b6f0cd7a187defc3b01267a8121c9cd7ec8f ./web/js/core.js c4531f79ba2e15501407767a417085eab0a61bd280f8356b6960f6e320736b05 ./web/js/core.js
021736e8b8d02248a41788765af2dd46914a700e8cadb0c43a75e83ed3f2dd14 ./web/js/dashboard.js f682b80c3b348a6e4832dc7d51227f696ab43b26d93f5ac52cabf35aed3c9ff8 ./web/js/dashboard.js
ed31601df41cc82032709f960a2b4061966abd58cde456ef7dda05e42eb91432 ./web/js/entities.js ed31601df41cc82032709f960a2b4061966abd58cde456ef7dda05e42eb91432 ./web/js/entities.js
ef778fb570958704c2e079fa5cd645f67f884881e16984daa6f3ed52627b26a5 ./web/js/events.js a59fd5b822029d0c1e74ba8078f55f13122347e036a96dec383293ae386f6667 ./web/js/events.js
89d5f4098e74162ecdee2eef9441f88484a09e9197d21e30c40c220cedbf7e53 ./web/js/flows.js 0f63a4172fd76eed70f44ff1edbe9cdd0c20e31cfff5de15f17ed2e591fda9b4 ./web/js/flows.js
52b4807f05a1325565d5e866007f49ea83c568574155cded131ce42a49df44f5 ./web/js/forms.js 63d508dc7bee43935086e8400366c271d7ebd9e8782e058296b449237399bf61 ./web/js/forms.js
ae6195905f33a4a83d5ba7c22d698419e2de05d80ed37df6066e0380fa715b6e ./web/js/history.js 5ded2b5533700e266d30fc3cd3a0e8a46ec647140f16df5304eff513d6c72f82 ./web/js/history.js
0dc879c0056251618633c9efd188fc1593eeae6965ad1bc993132dea6e171b44 ./web/js/main.js 0dc879c0056251618633c9efd188fc1593eeae6965ad1bc993132dea6e171b44 ./web/js/main.js
53ebdaa64a1dc8327db5cd4e08fb3f551dc2df33c214f218c6bb1ae3f27ad4dd ./web/js/navigation.js 6bb9386707dcf601111fea15a1a366d8798313b973ca1624878618f4c3095add ./web/js/navigation.js
e3d146a683b8beb332c752e533826160c2cf2236bc269ced7a4dd1249871027b ./web/js/realtime.js e3d146a683b8beb332c752e533826160c2cf2236bc269ced7a4dd1249871027b ./web/js/realtime.js
9546ca5c5566994aff3c33d92295be46b65d51d874681764abe77470dc9c9c73 ./web/js/router.js 9546ca5c5566994aff3c33d92295be46b65d51d874681764abe77470dc9c9c73 ./web/js/router.js
b6c894e49e105c8afad8c5b21d322692cee6b3120ee10ef676bcd5e521c8956e ./web/js/select-ui.js
cdc55061a41b7325aad552357d8359b42d881589c39b438cdc2a48bf0f690db9 ./web/js/settings-ui.js cdc55061a41b7325aad552357d8359b42d881589c39b438cdc2a48bf0f690db9 ./web/js/settings-ui.js
f00c3f603eb314f755a0c46424681b579e0b890453e2550007c719273815147c ./web/js/settings.js f00c3f603eb314f755a0c46424681b579e0b890453e2550007c719273815147c ./web/js/settings.js
3815fea7ae1694568c2f249bc8460e0563ced6bde89214f74f21b41fe8cad9a6 ./web/lang-init.js 3815fea7ae1694568c2f249bc8460e0563ced6bde89214f74f21b41fe8cad9a6 ./web/lang-init.js
6b653e4fe2d4db4ffae6ff8f39b8ce57a10a990dc0000ee4aae51d949afadd52 ./web/manifest.webmanifest 6b653e4fe2d4db4ffae6ff8f39b8ce57a10a990dc0000ee4aae51d949afadd52 ./web/manifest.webmanifest
4b3c94a1988e8e18bd0c39e5ebf73ce6ef31ddea5bb352ec40b1ae7b3609f5e9 ./web/styles.css ac5762a8656fb61a6100bb16ddd0d6d0ee506532b6c05da8ec47595ec2ebbf44 ./web/styles.css
fefdf12cbbb0a5d8a2cb89275cae28455aa42aaf179542305cc8f5e0f4825898 ./web/sw.js fefdf12cbbb0a5d8a2cb89275cae28455aa42aaf179542305cc8f5e0f4825898 ./web/sw.js
d505d793ce7cc9485b45b78bba1c0d51887adc7451ab59a42702946e5b991382 ./web/theme-init.js d505d793ce7cc9485b45b78bba1c0d51887adc7451ab59a42702946e5b991382 ./web/theme-init.js
+1
View File
@@ -35,6 +35,7 @@ fn content_hash(bytes: &[u8]) -> String {
const APP_JS_MODULES: &[&str] = &[ const APP_JS_MODULES: &[&str] = &[
"core.js", "core.js",
"select-ui.js",
"forms.js", "forms.js",
"bootstrap.js", "bootstrap.js",
"dashboard.js", "dashboard.js",
+6 -5
View File
@@ -1,6 +1,6 @@
# GREE Controller API reference # 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) [← Main documentation](../README.md)
@@ -108,7 +108,8 @@ Common statuses:
| `401 Unauthorized` | Missing/incorrect token. | | `401 Unauthorized` | Missing/incorrect token. |
| `404 Not Found` | Resource ID does not exist. | | `404 Not Found` | Resource ID does not exist. |
| `409 Conflict` | Revision/concurrency conflict. | | `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. | | `500 Internal Server Error` | Unexpected server/storage error. |
## Endpoint index ## Endpoint index
@@ -232,7 +233,7 @@ Response:
{ {
"status": "ok", "status": "ok",
"name": "gree-controller", "name": "gree-controller",
"version": "0.14.4", "version": "0.14.8",
"uptime_seconds": 1234, "uptime_seconds": 1234,
"control_ready": true, "control_ready": true,
"time": "2026-08-30T06:54:00Z" "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": {"generated_at": "2026-09-04T08:00:00Z", "zones": [], "rules": []},
"control_plan_revision": 42, "control_plan_revision": 42,
"system": { "system": {
"version": "0.14.4", "version": "0.14.8",
"uptime_seconds": 1234, "uptime_seconds": 1234,
"auth_required": false, "auth_required": false,
"control_ready": true, "control_ready": true,
@@ -1683,7 +1684,7 @@ Cloud account/discovery/diagnostic endpoints:
Energy 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. - `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. Cloud settings and diagnostics are redacted: passwords, REST/MQTT tokens, Authorization values and device cipher keys are not returned.
+71 -16
View File
@@ -2,9 +2,9 @@
"openapi": "3.1.0", "openapi": "3.1.0",
"info": { "info": {
"title": "GREE Controller API", "title": "GREE Controller API",
"version": "0.14.4", "version": "0.14.8",
"summary": "Local HTTP/WebSocket API for GREE HVAC control", "summary": "Local HTTP/WebSocket API for GREE HVAC control",
"description": "Self-hosted API used by the GREE Controller Web UI and Home Assistant integration.\n\n### Authentication\nAdministrator endpoints accept `Authorization: Bearer <APP_TOKEN>` or `x-api-token: <APP_TOKEN>` when configured. Restricted Home Assistant control endpoints also accept generated integration tokens.\n\n### Local and GREE Cloud\nDevices use one API model and one command surface. `connection_type=local` is dispatched only to the existing LAN/UDP provider; `connection_type=gree_cloud` is dispatched only to the GREE Cloud provider. No automatic transport fallback is performed.\n\n### GREE Cloud security\nCloud passwords, REST/MQTT tokens, Authorization values and device cipher keys are never returned in normal API responses or Cloud diagnostics. MQTT uses TLS with certificate and hostname validation.\n\n### Energy\nGREE Cloud `ElcAll` and selected Home Assistant cumulative energy sensors are normalized to kWh and converted to consumption deltas before aggregation. Raw cumulative values are retained only as meter samples/baselines.\n\n### 0.14.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 <APP_TOKEN>` or `x-api-token: <APP_TOKEN>` when configured. Restricted Home Assistant control endpoints also accept generated integration tokens.\n\n### Local and GREE Cloud\nDevices use one API model and one command surface. `connection_type=local` is dispatched only to the existing LAN/UDP provider; `connection_type=gree_cloud` is dispatched only to the GREE Cloud provider. No automatic transport fallback is performed.\n\n### GREE Cloud security\nCloud passwords, REST/MQTT tokens, Authorization values and device cipher keys are never returned in normal API responses or Cloud diagnostics. MQTT uses TLS with certificate and hostname validation.\n\n### Energy\nGREE Cloud `ElcAll` and selected Home Assistant cumulative energy sensors are normalized to kWh and converted to consumption deltas before aggregation. Raw cumulative values are retained only as meter samples/baselines.\n\n### 0.14.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": { "license": {
"name": "MIT" "name": "MIT"
} }
@@ -782,6 +782,9 @@
}, },
"500": { "500": {
"$ref": "#/components/responses/InternalError" "$ref": "#/components/responses/InternalError"
},
"424": {
"$ref": "#/components/responses/FailedDependency"
} }
} }
} }
@@ -1118,6 +1121,9 @@
}, },
"500": { "500": {
"$ref": "#/components/responses/InternalError" "$ref": "#/components/responses/InternalError"
},
"424": {
"$ref": "#/components/responses/FailedDependency"
} }
} }
} }
@@ -1623,6 +1629,9 @@
}, },
"500": { "500": {
"$ref": "#/components/responses/InternalError" "$ref": "#/components/responses/InternalError"
},
"424": {
"$ref": "#/components/responses/FailedDependency"
} }
} }
} }
@@ -1675,6 +1684,9 @@
}, },
"500": { "500": {
"$ref": "#/components/responses/InternalError" "$ref": "#/components/responses/InternalError"
},
"424": {
"$ref": "#/components/responses/FailedDependency"
} }
} }
} }
@@ -1728,6 +1740,9 @@
}, },
"500": { "500": {
"$ref": "#/components/responses/InternalError" "$ref": "#/components/responses/InternalError"
},
"424": {
"$ref": "#/components/responses/FailedDependency"
} }
} }
} }
@@ -1781,6 +1796,9 @@
}, },
"500": { "500": {
"$ref": "#/components/responses/InternalError" "$ref": "#/components/responses/InternalError"
},
"424": {
"$ref": "#/components/responses/FailedDependency"
} }
} }
} }
@@ -3116,11 +3134,11 @@
"401": { "401": {
"$ref": "#/components/responses/Unauthorized" "$ref": "#/components/responses/Unauthorized"
}, },
"502": {
"$ref": "#/components/responses/BadGateway"
},
"500": { "500": {
"$ref": "#/components/responses/InternalError" "$ref": "#/components/responses/InternalError"
},
"424": {
"$ref": "#/components/responses/FailedDependency"
} }
} }
} }
@@ -3178,11 +3196,11 @@
"401": { "401": {
"$ref": "#/components/responses/Unauthorized" "$ref": "#/components/responses/Unauthorized"
}, },
"502": {
"$ref": "#/components/responses/BadGateway"
},
"500": { "500": {
"$ref": "#/components/responses/InternalError" "$ref": "#/components/responses/InternalError"
},
"424": {
"$ref": "#/components/responses/FailedDependency"
} }
} }
} }
@@ -3240,11 +3258,11 @@
"401": { "401": {
"$ref": "#/components/responses/Unauthorized" "$ref": "#/components/responses/Unauthorized"
}, },
"502": {
"$ref": "#/components/responses/BadGateway"
},
"500": { "500": {
"$ref": "#/components/responses/InternalError" "$ref": "#/components/responses/InternalError"
},
"424": {
"$ref": "#/components/responses/FailedDependency"
} }
} }
} }
@@ -3487,6 +3505,9 @@
}, },
"500": { "500": {
"$ref": "#/components/responses/InternalError" "$ref": "#/components/responses/InternalError"
},
"424": {
"$ref": "#/components/responses/FailedDependency"
} }
} }
} }
@@ -3632,6 +3653,9 @@
}, },
"500": { "500": {
"$ref": "#/components/responses/InternalError" "$ref": "#/components/responses/InternalError"
},
"424": {
"$ref": "#/components/responses/FailedDependency"
} }
} }
} }
@@ -3684,6 +3708,9 @@
}, },
"500": { "500": {
"$ref": "#/components/responses/InternalError" "$ref": "#/components/responses/InternalError"
},
"424": {
"$ref": "#/components/responses/FailedDependency"
} }
} }
} }
@@ -3737,6 +3764,9 @@
}, },
"500": { "500": {
"$ref": "#/components/responses/InternalError" "$ref": "#/components/responses/InternalError"
},
"424": {
"$ref": "#/components/responses/FailedDependency"
} }
} }
} }
@@ -3790,6 +3820,9 @@
}, },
"500": { "500": {
"$ref": "#/components/responses/InternalError" "$ref": "#/components/responses/InternalError"
},
"424": {
"$ref": "#/components/responses/FailedDependency"
} }
} }
} }
@@ -3857,6 +3890,9 @@
}, },
"500": { "500": {
"$ref": "#/components/responses/InternalError" "$ref": "#/components/responses/InternalError"
},
"424": {
"$ref": "#/components/responses/FailedDependency"
} }
} }
} }
@@ -4979,6 +5015,9 @@
}, },
"500": { "500": {
"$ref": "#/components/responses/InternalError" "$ref": "#/components/responses/InternalError"
},
"424": {
"$ref": "#/components/responses/FailedDependency"
} }
} }
} }
@@ -5036,6 +5075,9 @@
}, },
"409": { "409": {
"$ref": "#/components/responses/Conflict" "$ref": "#/components/responses/Conflict"
},
"424": {
"$ref": "#/components/responses/FailedDependency"
} }
} }
} }
@@ -5113,8 +5155,8 @@
"400": { "400": {
"$ref": "#/components/responses/BadRequest" "$ref": "#/components/responses/BadRequest"
}, },
"502": { "424": {
"$ref": "#/components/responses/BadGateway" "$ref": "#/components/responses/FailedDependency"
} }
} }
} }
@@ -5180,7 +5222,7 @@
"Energy" "Energy"
], ],
"summary": "List compatible Home Assistant energy sensors", "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", "operationId": "listHomeAssistantEnergySensors",
"security": [ "security": [
{ {
@@ -5207,6 +5249,9 @@
}, },
"500": { "500": {
"$ref": "#/components/responses/InternalError" "$ref": "#/components/responses/InternalError"
},
"424": {
"$ref": "#/components/responses/FailedDependency"
} }
} }
} }
@@ -5475,7 +5520,7 @@
}, },
"version": { "version": {
"type": "string", "type": "string",
"example": "0.14.4" "example": "0.14.8"
}, },
"uptime_seconds": { "uptime_seconds": {
"type": "integer", "type": "integer",
@@ -9135,7 +9180,7 @@
} }
}, },
"BadGateway": { "BadGateway": {
"description": "GREE/Home Assistant/integration communication failure", "description": "Local/LAN GREE device communication failure.",
"content": { "content": {
"application/json": { "application/json": {
"schema": { "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"
}
}
}
} }
} }
} }
@@ -1,7 +1,7 @@
{ {
"domain": "gree_controller", "domain": "gree_controller",
"name": "GREE Controller", "name": "GREE Controller",
"version": "0.14.4", "version": "0.14.8",
"config_flow": true, "config_flow": true,
"integration_type": "hub", "integration_type": "hub",
"iot_class": "local_polling", "iot_class": "local_polling",
@@ -1,5 +1,34 @@
# Changelog # 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 ## 0.14.4
- Removes the obsolete Home Assistant connection-test/default entity; HA connection testing uses only the configured URL and token. - Removes the obsolete Home Assistant connection-test/default entity; HA connection testing uses only the configured URL and token.
@@ -1,5 +1,5 @@
name: "GREE Controller" name: "GREE Controller"
version: "0.14.4" version: "0.14.8"
slug: "gree_controller" slug: "gree_controller"
description: "Local GREE HVAC controller with Web UI and Home Assistant integration" description: "Local GREE HVAC controller with Web UI and Home Assistant integration"
url: "https://git.linuxiarz.pl/gru/gree-controller-ha-addon/" url: "https://git.linuxiarz.pl/gru/gree-controller-ha-addon/"
+7
View File
@@ -297,6 +297,13 @@
"house.smartThermostat": "Smart thermostat", "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.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.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", "house.modeUpdated": "Home climate mode updated",
"zones.modePolicy": "Mode policy", "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.", "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.",
+7
View File
@@ -297,6 +297,13 @@
"house.smartThermostat": "Inteligentny termostat", "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.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.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", "house.modeUpdated": "Zmieniono tryb klimatu domu",
"zones.modePolicy": "Polityka trybu", "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.", "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.",
+18 -4
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3 #!/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. 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 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_BASE_URL = os.environ.get("GREE_API_URL", "http://127.0.0.1:8787")
DEFAULT_TOKEN = os.environ.get("GREE_API_TOKEN", "") 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]] = { SETTINGS_PATHS: dict[str, set[str]] = {
"/api/settings/application": {"simulator_enabled"}, "/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")) version = ((spec.get("info") or {}).get("version"))
if expected_version and version != expected_version: if expected_version and version != expected_version:
raise TestFailure(f"OpenAPI info.version={version!r}, expected {expected_version!r}") 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}" 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") 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("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("bootstrap snapshot contract", lambda: test_bootstrap_contract(client))
runner.run("protected API requires auth", lambda: test_protected_auth(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: def parse_args(argv: Optional[Iterable[str]] = None) -> argparse.Namespace:
parser = argparse.ArgumentParser( 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, formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""Examples: epilog="""Examples:
python3 scripts/api_dev_test.py python3 scripts/api_dev_test.py
+1 -1
View File
@@ -27,7 +27,7 @@ async fn export_configuration(
fn validate_configuration_header(export: &ConfigurationExport) -> Result<(), AppError> { fn validate_configuration_header(export: &ConfigurationExport) -> Result<(), AppError> {
if export.format_version != 3 { 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" { if export.settings.control_strategy != "setpoint" {
return Err(AppError::BadRequest( return Err(AppError::BadRequest(
+44 -5
View File
@@ -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( async fn home_assistant_snapshot(
State(state): State<AppState>, State(state): State<AppState>,
) -> Result<Json<Value>, AppError> { ) -> Result<Json<Value>, AppError> {
@@ -17,7 +47,7 @@ async fn test_home_assistant(
let settings = state.settings.read().await.clone(); let settings = state.settings.read().await.clone();
home_assistant::test_connection(&state.http, &settings.home_assistant) home_assistant::test_connection(&state.http, &settings.home_assistant)
.await .await
.map_err(|e| AppError::Device(e.to_string()))?; .map_err(map_home_assistant_integration_error)?;
Ok(Json(json!({"ok": true}))) Ok(Json(json!({"ok": true})))
} }
@@ -41,7 +71,7 @@ async fn inspect_home_assistant_entity(
Some(entity_id.as_str()), Some(entity_id.as_str()),
) )
.await .await
.map_err(|e| AppError::Device(e.to_string()))?; .map_err(map_home_assistant_integration_error)?;
let raw_state = payload let raw_state = payload
.get("state") .get("state")
.and_then(Value::as_str) .and_then(Value::as_str)
@@ -78,7 +108,7 @@ async fn test_notifications(
} }
notifications::test(&state, input) notifications::test(&state, input)
.await .await
.map_err(AppError::Device)?; .map_err(map_notification_test_error)?;
Ok(Json(json!({"ok": true}))) Ok(Json(json!({"ok": true})))
} }
@@ -86,9 +116,15 @@ async fn list_home_assistant_energy_sensors(
State(state): State<AppState>, State(state): State<AppState>,
) -> Result<Json<Value>, AppError> { ) -> Result<Json<Value>, AppError> {
let settings = state.settings.read().await.home_assistant.clone(); 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) let entities = home_assistant::list_entities(&state.http, &settings)
.await .await
.map_err(|error| AppError::Device(error.to_string()))?; .map_err(|error| AppError::Dependency(error.to_string()))?;
let sensors = entities let sensors = entities
.into_iter() .into_iter()
.filter_map(|entity| { .filter_map(|entity| {
@@ -112,5 +148,8 @@ async fn list_home_assistant_energy_sensors(
})) }))
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
Ok(Json(json!({"sensors": sensors}))) Ok(Json(json!({
"configured": true,
"sensors": sensors,
})))
} }
+2 -2
View File
@@ -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 settings = state.settings.read().await.home_assistant.clone();
let payload = home_assistant::read_entity(&state.http, &settings, Some(entity_id)) let payload = home_assistant::read_entity(&state.http, &settings, Some(entity_id))
.await .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(); let state_value = payload.get("state").and_then(Value::as_str).unwrap_or_default();
if matches!(state_value, "" | "unknown" | "unavailable") { return Ok(()); } 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 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 device_class = attrs.get("device_class").and_then(Value::as_str).unwrap_or_default();
let state_class = attrs.get("state_class").and_then(Value::as_str).unwrap_or_default(); let state_class = attrs.get("state_class").and_then(Value::as_str).unwrap_or_default();
+20
View File
@@ -1649,6 +1649,26 @@
</div> </div>
</dialog> </dialog>
<dialog id="outdoorHistoryDialog" class="dialog-wide outdoor-history-dialog">
<div class="dialog-form">
<div class="dialog-head">
<div><span class="eyebrow" data-i18n="history.24h">24 hours</span><h2 data-i18n="history.allOutdoor">Outdoor temperatures</h2></div>
<button type="button" data-close data-i18n-aria="actions.close" aria-label="Close">×</button>
</div>
<div class="outdoor-history-current"><small data-i18n="house.outdoor">Outdoor</small><strong id="outdoorHistoryCurrent">--</strong></div>
<div id="outdoorHistoryChartHost"></div>
<div class="outdoor-history-links" data-i18n-aria="house.outdoorHistoryMore" aria-label="More metrics and ranges in history">
<span data-i18n="house.outdoorHistoryMore">More in history</span>
<div>
<a href="/history/overview?hours=168" data-history-route data-history-hours="168" data-i18n="history.range7d">7 days</a>
<a href="/history/overview?hours=720" data-history-route data-history-hours="720" data-i18n="history.range30d">30 days</a>
<a href="/history/overview?hours=8760" data-history-route data-history-hours="8760" data-i18n="history.range1y">1 year</a>
<a href="/history/overview" data-history-route class="outdoor-history-all" data-i18n="house.outdoorHistoryFull">Full history</a>
</div>
</div>
</div>
</dialog>
<dialog id="tokenDialog" class="auth-dialog"> <dialog id="tokenDialog" class="auth-dialog">
<form method="dialog" id="tokenForm" class="dialog-form"> <form method="dialog" id="tokenForm" class="dialog-form">
<div class="brand large"> <div class="brand large">
+70 -5
View File
@@ -66,29 +66,36 @@ function resetHistoryRangeControl(host) {
if (range && toolbar && range.parentElement === host) toolbar.insertBefore(range, refresh); if (range && toolbar && range.parentElement === host) toolbar.insertBefore(range, refresh);
} }
function configureHistoryRangeControl(tab, host) { function configureHistoryRangeControl(tab, host, hasEnergyTargets = true) {
const range = $('#historyRangeControl'); const range = $('#historyRangeControl');
if (!range) return; if (!range) return;
const toolbar = range.closest('.chart-toolbar') || host?.closest('.chart-toolbar'); 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 label = range.querySelector('span');
const hint = $('#historyRangeHint'); const hint = $('#historyRangeHint');
const sixHours = range.querySelector('option[value="6"]'); const sixHours = range.querySelector('option[value="6"]');
const energy = tab === 'energy'; const energy = tab === 'energy';
const energyUnavailable = energy && !hasEnergyTargets;
const rangeLabel = tr(energy ? 'energy.period' : 'history.range'); const rangeLabel = tr(energy ? 'energy.period' : 'history.range');
if (label) label.textContent = rangeLabel; if (label) label.textContent = rangeLabel;
range.setAttribute('aria-label', rangeLabel); range.setAttribute('aria-label', rangeLabel);
if (hint) { hint.textContent = energy ? tr('energy.periodHint') : ''; hint.hidden = !energy; } if (hint) { hint.textContent = energy ? tr('energy.periodHint') : ''; hint.hidden = !energy; }
if (sixHours) { sixHours.hidden = energy; sixHours.disabled = energy; } if (sixHours) { sixHours.hidden = energy; sixHours.disabled = energy; }
if (energy && $('#historyHours')?.value === '6') $('#historyHours').value = '24'; 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); range.classList.toggle('energy-period-control', energy);
if (toolbar) toolbar.classList.toggle('energy-toolbar', energy); if (toolbar) toolbar.classList.toggle('energy-toolbar', energy);
if (energy && host) host.appendChild(range); if (energy && host && hasEnergyTargets) host.appendChild(range);
} }
function renderHistoryNavigation() { function renderHistoryNavigation() {
$$('#historyTabs [data-history-tab]').forEach(button => button.classList.toggle('active', button.dataset.historyTab === app.historyTab)); $$('#historyTabs [data-history-tab]').forEach(button => button.classList.toggle('active', button.dataset.historyTab === app.historyTab));
const energyPickerWasOpen = $('#historyEnergyTargetPicker')?.open === true; const energyPickerWasOpen = $('#historyEnergyTargetPicker')?.open === true;
const host = $('#historyContextControls'); if (!host) return; const host = $('#historyContextControls'); if (!host) return;
let hasEnergyTargets = true;
resetHistoryRangeControl(host); resetHistoryRangeControl(host);
host.classList.toggle('energy-context-controls', app.historyTab === 'energy'); host.classList.toggle('energy-context-controls', app.historyTab === 'energy');
const options = historyEntityOptions(); 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; const select = $('#historyDeviceSelect'); if ([...select.options].some(option => option.value === app.historyDevice)) select.value = app.historyDevice;
} else if (app.historyTab === 'energy') { } else if (app.historyTab === 'energy') {
const targets = normalizeEnergyHistoryTargets(); const targets = normalizeEnergyHistoryTargets();
hasEnergyTargets = targets.length > 0;
const targetOptions = targets.map(target => `<label class="history-energy-option"><input type="checkbox" data-history-energy-target="${esc(target.id)}" ${app.historyEnergyTargets.includes(target.id) ? 'checked' : ''}><span>${esc(target.label)}</span></label>`).join(''); const targetOptions = targets.map(target => `<label class="history-energy-option"><input type="checkbox" data-history-energy-target="${esc(target.id)}" ${app.historyEnergyTargets.includes(target.id) ? 'checked' : ''}><span>${esc(target.label)}</span></label>`).join('');
host.innerHTML = targets.length host.innerHTML = targets.length
? `<div class="history-energy-targets"><span class="history-control-label">${esc(tr('energy.targets'))}</span><details class="history-energy-picker" id="historyEnergyTargetPicker"><summary><span>${esc(energyTargetPickerSummary(targets))}</span><b>${app.historyEnergyTargets.length}/8</b></summary><div class="history-energy-options">${targetOptions}</div></details><small>${esc(tr('energy.multiselectHint'))}</small></div><label><span>${esc(tr('history.bucket'))}</span><select id="historyEnergyInterval"><option value="hourly">${esc(tr('energy.hourly'))}</option><option value="daily">${esc(tr('energy.daily'))}</option><option value="weekly">${esc(tr('energy.weekly'))}</option><option value="monthly">${esc(tr('energy.monthly'))}</option></select></label><label><span>${esc(tr('energy.compare'))}</span><select id="historyEnergyCompare"><option value="none">${esc(tr('energy.compareNone'))}</option><option value="previous_day">${esc(tr('energy.comparePreviousDay'))}</option><option value="previous_period">${esc(tr('energy.comparePreviousPeriod'))}</option><option value="previous_year">${esc(tr('energy.comparePreviousYear'))}</option></select></label>` ? `<div class="history-energy-targets"><span class="history-control-label">${esc(tr('energy.targets'))}</span><details class="history-energy-picker" id="historyEnergyTargetPicker"><summary><span>${esc(energyTargetPickerSummary(targets))}</span><b>${app.historyEnergyTargets.length}/8</b></summary><div class="history-energy-options">${targetOptions}</div></details><small>${esc(tr('energy.multiselectHint'))}</small></div><label><span>${esc(tr('history.bucket'))}</span><select id="historyEnergyInterval"><option value="hourly">${esc(tr('energy.hourly'))}</option><option value="daily">${esc(tr('energy.daily'))}</option><option value="weekly">${esc(tr('energy.weekly'))}</option><option value="monthly">${esc(tr('energy.monthly'))}</option></select></label><label><span>${esc(tr('energy.compare'))}</span><select id="historyEnergyCompare"><option value="none">${esc(tr('energy.compareNone'))}</option><option value="previous_day">${esc(tr('energy.comparePreviousDay'))}</option><option value="previous_period">${esc(tr('energy.comparePreviousPeriod'))}</option><option value="previous_year">${esc(tr('energy.comparePreviousYear'))}</option></select></label>`
: `<span class="history-context-hint">${esc(tr('energy.noData'))}</span>`; : '';
const intervalSelect = $('#historyEnergyInterval'); if (intervalSelect) intervalSelect.value = app.historyEnergyInterval; const intervalSelect = $('#historyEnergyInterval'); if (intervalSelect) intervalSelect.value = app.historyEnergyInterval;
const compareSelect = $('#historyEnergyCompare'); if (compareSelect) compareSelect.value = app.historyEnergyCompare; const compareSelect = $('#historyEnergyCompare'); if (compareSelect) compareSelect.value = app.historyEnergyCompare;
if (energyPickerWasOpen && $('#historyEnergyTargetPicker')) $('#historyEnergyTargetPicker').open = true; if (energyPickerWasOpen && $('#historyEnergyTargetPicker')) $('#historyEnergyTargetPicker').open = true;
@@ -121,7 +129,7 @@ function renderHistoryNavigation() {
} else { } else {
host.innerHTML = `<span class="history-context-hint">${esc(tr('history.overviewHint'))}</span>`; host.innerHTML = `<span class="history-context-hint">${esc(tr('history.overviewHint'))}</span>`;
} }
configureHistoryRangeControl(app.historyTab, host); configureHistoryRangeControl(app.historyTab, host, hasEnergyTargets);
} }
async function loadHistory() { async function loadHistory() {
@@ -164,6 +172,7 @@ async function loadHistory() {
const HISTORY_COLORS = ['--accent', '--info', '--warning', '--purple', '--danger', '--teal', '--orange', '--blue']; const HISTORY_COLORS = ['--accent', '--info', '--warning', '--purple', '--danger', '--teal', '--orange', '--blue'];
const MAX_CHART_ZOOM = 16; const MAX_CHART_ZOOM = 16;
const chartRuntime = new Map(); const chartRuntime = new Map();
const chartFullscreenState = new WeakMap();
function chartSeriesKey(item, index) { function chartSeriesKey(item, index) {
return item.key || `${index}:${item.label}`; return item.key || `${index}:${item.label}`;
@@ -251,6 +260,28 @@ function updateChartFullscreenButton(card) {
button.setAttribute('aria-label', button.title); 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) { function closeChartPreview(card) {
if (!card) return; if (!card) return;
card.classList.remove('chart-fullscreen-fallback'); card.classList.remove('chart-fullscreen-fallback');
@@ -258,9 +289,31 @@ function closeChartPreview(card) {
card.removeAttribute('aria-modal'); card.removeAttribute('aria-modal');
card.removeAttribute('aria-label'); card.removeAttribute('aria-label');
document.body.classList.remove('chart-fullscreen-open'); document.body.classList.remove('chart-fullscreen-open');
const state = restoreChartPreviewLayout(card);
updateChartFullscreenButton(card); updateChartFullscreenButton(card);
const canvas = card.querySelector('canvas[id]'); 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) { function toggleChartFullscreen(id) {
@@ -278,6 +331,18 @@ function toggleChartFullscreen(id) {
return; 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.classList.add('chart-fullscreen-fallback');
card.setAttribute('role', 'dialog'); card.setAttribute('role', 'dialog');
card.setAttribute('aria-modal', 'true'); card.setAttribute('aria-modal', 'true');
+40 -15
View File
@@ -261,11 +261,36 @@ function applyTranslations() {
if (app.currentView === 'history' && app.zones.length) loadHistory(); 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); const available = app.languages.some(item => item.code === language);
app.language = available ? language : DEFAULT_LANGUAGE; const nextLanguage = available ? language : DEFAULT_LANGUAGE;
setCookie('gree_controller_language', app.language); const previousLanguage = app.language;
applyTranslations(); 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() { function renderLanguageOptions() {
@@ -286,21 +311,21 @@ async function loadLanguages() {
const manifest = await response.json(); const manifest = await response.json();
const languages = Array.isArray(manifest.languages) ? manifest.languages : []; 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'); if (!languages.some(item => item.code === DEFAULT_LANGUAGE)) throw new Error('Default English language pack is missing');
app.languages = languages;
const loaded = await Promise.all(languages.map(async item => { app.translations = {};
const packResponse = await fetch(withBase(item.path || `/lang/${encodeURIComponent(item.code)}.json`)); app.locales = Object.fromEntries(languages.map(item => [item.code, item.locale || item.code]));
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.language = app.languages.some(item => item.code === preferredLanguage) app.language = app.languages.some(item => item.code === preferredLanguage)
? preferredLanguage ? preferredLanguage
: (manifest.default || DEFAULT_LANGUAGE); : (manifest.default || DEFAULT_LANGUAGE);
if (!app.languages.some(item => item.code === app.language)) app.language = 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(); renderLanguageOptions();
} catch (error) { } catch (error) {
console.error('Unable to load language packs:', error); console.error('Unable to load language packs:', error);
+1 -1
View File
@@ -49,7 +49,7 @@ function renderHouseClimate() {
if (powerOn) { powerOn.classList.remove('active'); powerOn.removeAttribute('aria-pressed'); } if (powerOn) { powerOn.classList.remove('active'); powerOn.removeAttribute('aria-pressed'); }
if (powerOff) { powerOff.classList.remove('active'); powerOff.removeAttribute('aria-pressed'); } if (powerOff) { powerOff.classList.remove('active'); powerOff.removeAttribute('aria-pressed'); }
node.innerHTML = `<div class="house-climate-head"><div><span class="eyebrow">${esc(tr('house.seasonMode'))}</span><h3>${esc(tr('house.smartThermostat'))}</h3><p>${esc(tr('house.setpointStrategy'))}</p></div><div class="outside-pill"><small>${esc(tr('house.outdoor'))}</small><strong>${esc(outdoor)}</strong></div></div> node.innerHTML = `<div class="house-climate-head"><div><span class="eyebrow">${esc(tr('house.seasonMode'))}</span><h3>${esc(tr('house.smartThermostat'))}</h3><p>${esc(tr('house.setpointStrategy'))}</p></div><button type="button" class="outside-pill" data-action="open-outdoor-history" title="${esc(tr('house.outdoorHistoryOpen'))}" aria-label="${esc(tr('house.outdoorHistoryOpen'))}"><small>${esc(tr('house.outdoor'))}</small><strong>${esc(outdoor)}</strong></button></div>
<div class="house-mode-row"> <div class="house-mode-row">
<button class="${mode === 'cool' ? 'active' : ''}" data-action="house-mode" data-value="cool" aria-pressed="${mode === 'cool'}">${esc(tr('mode.cool'))}</button> <button class="${mode === 'cool' ? 'active' : ''}" data-action="house-mode" data-value="cool" aria-pressed="${mode === 'cool'}">${esc(tr('mode.cool'))}</button>
<button class="${mode === 'heat' ? 'active' : ''}" data-action="house-mode" data-value="heat" aria-pressed="${mode === 'heat'}">${esc(tr('mode.heat'))}</button> <button class="${mode === 'heat' ? 'active' : ''}" data-action="house-mode" data-value="heat" aria-pressed="${mode === 'heat'}">${esc(tr('mode.heat'))}</button>
+14
View File
@@ -11,6 +11,19 @@ document.addEventListener('keydown', event => {
document.addEventListener('click', async event => { document.addEventListener('click', async event => {
const inlineTarget = event.target.closest('[data-temperature-kind]'); const inlineTarget = event.target.closest('[data-temperature-kind]');
if (inlineTarget && !event.target.closest('button')) { beginInlineTemperatureEdit(inlineTarget); return; } 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; const button = event.target.closest('button'); if (!button) return;
if (button.dataset.settingsTab) { setSettingsTab(button.dataset.settingsTab); return; } if (button.dataset.settingsTab) { setSettingsTab(button.dataset.settingsTab); return; }
if (button.dataset.debugFilter) { app.debugFilter = button.dataset.debugFilter; renderDebugOverlay(); return; } if (button.dataset.debugFilter) { app.debugFilter = button.dataset.debugFilter; renderDebugOverlay(); return; }
@@ -73,6 +86,7 @@ document.addEventListener('click', async event => {
return; return;
} }
const action = button.dataset.action; if (!action) return; const action = button.dataset.action; if (!action) return;
if (action === 'open-outdoor-history') { await openOutdoorHistory(); return; }
if (action === 'add-cloud-device') { if (action === 'add-cloud-device') {
const cloudId = button.dataset.cloudId; if (!cloudId) return; const cloudId = button.dataset.cloudId; if (!cloudId) return;
button.disabled = true; button.disabled = true;
+5 -5
View File
@@ -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 haEntities = [...new Set(nodes.map(node => node.config?.entity_id).filter(value => typeof value === 'string' && value && !value.startsWith('$')))];
const requirements = []; const requirements = [];
const missing = []; const missing = [];
const addCount = (kind, count, available, key) => { const addCount = (count, available, key) => {
if (!count) return; if (!count) return;
const label = tr(key, { count }); const label = tr(key, { count });
requirements.push({ label, ok: available >= count }); requirements.push({ label, ok: available >= count });
if (available < count) missing.push(label); if (available < count) missing.push(label);
}; };
addCount('zone', placeholders.zone.size, (app.zones || []).length, 'flow.templateRequiresZone'); addCount(placeholders.zone.size, (app.zones || []).length, 'flow.templateRequiresZone');
addCount('device', placeholders.device.size, (app.devices || []).length, 'flow.templateRequiresDevice'); addCount(placeholders.device.size, (app.devices || []).length, 'flow.templateRequiresDevice');
addCount('group', placeholders.group.size, (app.groups || []).length, 'flow.templateRequiresGroup'); addCount(placeholders.group.size, (app.groups || []).length, 'flow.templateRequiresGroup');
addCount('shared', placeholders.shared.size, (app.flowSharedInputs || []).length, 'flow.templateRequiresShared'); addCount(placeholders.shared.size, (app.flowSharedInputs || []).length, 'flow.templateRequiresShared');
if (hasHa) { if (hasHa) {
const ready = Boolean(app.settings?.home_assistant?.url && app.settings?.home_assistant?.token_configured); const ready = Boolean(app.settings?.home_assistant?.url && app.settings?.home_assistant?.token_configured);
requirements.push({ label: tr('flow.templateRequiresHa'), ok: ready }); requirements.push({ label: tr('flow.templateRequiresHa'), ok: ready });
+1
View File
@@ -273,6 +273,7 @@ function requestDialogClose(dialog) {
if (!dialog) return true; if (!dialog) return true;
const form = $('form', dialog); const form = $('form', dialog);
if (form && !confirmDiscardForm(form)) return false; if (form && !confirmDiscardForm(form)) return false;
closeChartPreview(dialog.querySelector?.('.history-chart-card.chart-fullscreen-fallback'));
dialog.close(); dialog.close();
return true; return true;
} }
+37
View File
@@ -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 = `<div class="panel outdoor-history-loading">${esc(tr('common.loading'))}</div>`;
$$('#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 = `<div class="empty"><strong>${esc(tr('history.noData'))}</strong><span>${esc(error.message)}</span></div>`;
toast(error.message, true);
}
}
function renderHistorySummary() { function renderHistorySummary() {
const host = $('#historySummary'); if (!host) return; const host = $('#historySummary'); if (!host) return;
if (app.historyTab === 'energy') { renderEnergyHistorySummary(); return; } if (app.historyTab === 'energy') { renderEnergyHistorySummary(); return; }
+30 -24
View File
@@ -738,18 +738,21 @@ async function openDeviceDetails(id) {
const sensorSelect = form.ha_energy_entity_id; const sensorSelect = form.ha_energy_entity_id;
sensorSelect.disabled = groupOwnsEnergy; sensorSelect.disabled = groupOwnsEnergy;
sensorSelect.innerHTML = '<option value="">—</option>'; sensorSelect.innerHTML = '<option value="">—</option>';
try { const haConfigured = Boolean(String(app.settings?.home_assistant?.url || '').trim() && app.settings?.home_assistant?.token_configured);
const response = await api('/api/integrations/home-assistant/energy-sensors'); if (haConfigured) {
for (const sensor of (response.sensors || [])) { try {
const option = document.createElement('option'); const response = await api('/api/integrations/home-assistant/energy-sensors');
option.value = sensor.entity_id; for (const sensor of (response.sensors || [])) {
option.textContent = `${sensor.name || sensor.entity_id} · ${sensor.unit || ''}`; const option = document.createElement('option');
option.dataset.unit = sensor.unit || ''; option.value = sensor.entity_id;
option.dataset.deviceClass = sensor.device_class || ''; option.textContent = `${sensor.name || sensor.entity_id} · ${sensor.unit || ''}`;
option.dataset.stateClass = sensor.state_class || ''; option.dataset.unit = sensor.unit || '';
sensorSelect.append(option); option.dataset.deviceClass = sensor.device_class || '';
} option.dataset.stateClass = sensor.state_class || '';
} catch (_) { } sensorSelect.append(option);
}
} catch (_) { }
}
const energyEntity = groupOwnsEnergy ? installation.ha_energy_entity_id : device.ha_energy_entity_id; 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 energyUnit = groupOwnsEnergy ? installation.ha_energy_unit : device.ha_energy_unit;
const energyDeviceClass = groupOwnsEnergy ? installation.ha_energy_device_class : device.ha_energy_device_class; 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 form = $('#deviceGroupForm'); if (!form) return;
const select = form.ha_energy_entity_id; const select = form.ha_energy_entity_id;
select.innerHTML = '<option value="">—</option>'; select.innerHTML = '<option value="">—</option>';
try { const haConfigured = Boolean(String(app.settings?.home_assistant?.url || '').trim() && app.settings?.home_assistant?.token_configured);
const response = await api('/api/integrations/home-assistant/energy-sensors'); if (haConfigured) {
for (const sensor of (response.sensors || [])) { try {
const option = document.createElement('option'); const response = await api('/api/integrations/home-assistant/energy-sensors');
option.value = sensor.entity_id; for (const sensor of (response.sensors || [])) {
option.textContent = `${sensor.name || sensor.entity_id} · ${sensor.unit || ''}`; const option = document.createElement('option');
option.dataset.unit = sensor.unit || ''; option.value = sensor.entity_id;
option.dataset.deviceClass = sensor.device_class || ''; option.textContent = `${sensor.name || sensor.entity_id} · ${sensor.unit || ''}`;
option.dataset.stateClass = sensor.state_class || ''; option.dataset.unit = sensor.unit || '';
select.append(option); option.dataset.deviceClass = sensor.device_class || '';
} option.dataset.stateClass = sensor.state_class || '';
} catch (_) { } select.append(option);
}
} catch (_) { }
}
if (group?.ha_energy_entity_id && ![...select.options].some(option => option.value === group.ha_energy_entity_id)) { if (group?.ha_energy_entity_id && ![...select.options].some(option => option.value === group.ha_energy_entity_id)) {
const option = document.createElement('option'); const option = document.createElement('option');
option.value = group.ha_energy_entity_id; option.value = group.ha_energy_entity_id;
+461
View File
@@ -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 <label> may synthesize a click on the original select.
// Redirect that trusted interaction to the custom popup instead of opening the OS picker.
select.addEventListener('click', event => {
if (!event.isTrusted) return;
event.preventDefault();
event.stopPropagation();
openCustomSelect(select);
});
select.addEventListener('change', () => refreshCustomSelect(select));
select.addEventListener('input', () => refreshCustomSelect(select));
refreshCustomSelect(select);
}
function cleanupCustomSelect(select) {
const state = customSelectRegistry.get(select);
if (!state) return;
if (state.open) closeCustomSelect(state);
state.menu.remove();
state.wrapper?.remove();
select.classList.remove('select-native-proxy');
if (state.originalSelectState.tabIndex == null) select.removeAttribute('tabindex');
else select.setAttribute('tabindex', state.originalSelectState.tabIndex);
if (state.originalSelectState.ariaHidden == null) select.removeAttribute('aria-hidden');
else select.setAttribute('aria-hidden', state.originalSelectState.ariaHidden);
if (state.toolbar && state.trigger) {
state.trigger.classList.remove('custom-select-toolbar', 'custom-select-open', 'custom-select-disabled');
const restoreAttribute = (name, value) => value == null ? state.trigger.removeAttribute(name) : state.trigger.setAttribute(name, value);
restoreAttribute('role', state.originalToolbarState?.role);
restoreAttribute('aria-haspopup', state.originalToolbarState?.ariaHaspopup);
restoreAttribute('aria-controls', state.originalToolbarState?.ariaControls);
restoreAttribute('aria-expanded', state.originalToolbarState?.ariaExpanded);
restoreAttribute('aria-disabled', state.originalToolbarState?.ariaDisabled);
}
customSelectRegistry.delete(select);
}
function refreshCustomSelects(root = document) {
if (root instanceof HTMLSelectElement) refreshCustomSelect(root);
root.querySelectorAll?.('select').forEach(select => refreshCustomSelect(select));
}
function enhanceCustomSelects(root = document) {
if (root instanceof HTMLSelectElement) enhanceCustomSelect(root);
root.querySelectorAll?.('select').forEach(enhanceCustomSelect);
}
function initCustomSelects() {
patchCustomSelectProperties();
enhanceCustomSelects(document);
if (customSelectObserver) return;
customSelectObserver = new MutationObserver(records => {
const toRefresh = new Set();
records.forEach(record => {
record.addedNodes.forEach(node => {
if (node.nodeType !== Node.ELEMENT_NODE) return;
enhanceCustomSelects(node);
const select = node.closest?.('select');
if (select) toRefresh.add(select);
});
record.removedNodes.forEach(node => {
if (node.nodeType !== Node.ELEMENT_NODE) return;
if (node instanceof HTMLSelectElement && !node.isConnected) cleanupCustomSelect(node);
node.querySelectorAll?.('select').forEach(select => {
// A DOM move is reported as a removal followed by an addition. Keep
// the existing custom-select state when the select is still attached
// to the document; otherwise every move would leave an old wrapper
// behind and create another one on the next enhancement pass.
if (!select.isConnected) cleanupCustomSelect(select);
});
});
const targetElement = record.target.nodeType === Node.ELEMENT_NODE ? record.target : record.target.parentElement;
const select = targetElement?.closest?.('select');
if (select) toRefresh.add(select);
});
toRefresh.forEach(select => {
if (!select.isConnected) {
cleanupCustomSelect(select);
return;
}
if (!customSelectRegistry.has(select)) enhanceCustomSelect(select);
refreshCustomSelect(select);
});
});
customSelectObserver.observe(document.documentElement, {
childList: true,
subtree: true,
characterData: true,
attributes: true,
attributeFilter: ['disabled', 'hidden', 'selected', 'label', 'value', 'aria-label', 'aria-labelledby'],
});
document.addEventListener('pointerdown', event => {
const state = customSelectOpenState;
if (!state?.open) return;
if (state.menu.contains(event.target) || state.trigger.contains(event.target)) return;
closeCustomSelect(state);
}, true);
document.addEventListener('reset', event => {
requestAnimationFrame(() => refreshCustomSelects(event.target));
}, true);
document.addEventListener('invalid', event => {
const select = event.target;
if (!(select instanceof HTMLSelectElement)) return;
const state = customSelectRegistry.get(select);
if (!state) return;
requestAnimationFrame(() => state.trigger.focus({ preventScroll: true }));
}, true);
window.addEventListener('resize', () => positionCustomSelectMenu(customSelectOpenState));
document.addEventListener('scroll', event => {
const state = customSelectOpenState;
if (!state?.open || state.menu.contains(event.target)) return;
closeCustomSelect(state);
}, true);
}
initCustomSelects();
+328 -79
View File
@@ -4,7 +4,6 @@
--canvas: #181818; --canvas: #181818;
--surface: #1c1c1c; --surface: #1c1c1c;
--surface-2: #222222; --surface-2: #222222;
--surface-3: #282828;
--surface-muted: #202020; --surface-muted: #202020;
--input-bg: #181818; --input-bg: #181818;
--nav-bg: #171717; --nav-bg: #171717;
@@ -33,11 +32,7 @@
--orange: #fb923c; --orange: #fb923c;
--backdrop: rgba(0, 0, 0, .68); --backdrop: rgba(0, 0, 0, .68);
--grid: rgba(255, 255, 255, .07); --grid: rgba(255, 255, 255, .07);
--scroll-thumb: #444444;
--scroll-thumb-hover: #575757;
--radius: 16px; --radius: 16px;
--radius-small: 10px;
--radius-medium: 14px;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
} }
@@ -47,7 +42,6 @@
--canvas: #f8faf9; --canvas: #f8faf9;
--surface: #ffffff; --surface: #ffffff;
--surface-2: #f5f6f6; --surface-2: #f5f6f6;
--surface-3: #eef0ef;
--surface-muted: #f6f7f7; --surface-muted: #f6f7f7;
--input-bg: #ffffff; --input-bg: #ffffff;
--nav-bg: #ffffff; --nav-bg: #ffffff;
@@ -76,8 +70,6 @@
--orange: #c56b21; --orange: #c56b21;
--backdrop: rgba(23, 23, 23, .32); --backdrop: rgba(23, 23, 23, .32);
--grid: rgba(24, 24, 27, .08); --grid: rgba(24, 24, 27, .08);
--scroll-thumb: #c8ccca;
--scroll-thumb-hover: #aeb3b0;
} }
* { * {
@@ -119,7 +111,6 @@ button:disabled {
top: 0; top: 0;
z-index: 20; z-index: 20;
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
align-items: center; align-items: center;
gap: 1rem; gap: 1rem;
min-height: 72px; min-height: 72px;
@@ -184,7 +175,6 @@ button:disabled {
button { button {
border: 0; border: 0;
border-radius: 13px;
padding: 11px 15px; padding: 11px 15px;
color: var(--text); color: var(--text);
background: var(--surface-2); background: var(--surface-2);
@@ -285,9 +275,7 @@ h3 {
} }
.lead { .lead {
max-width: 690px;
color: var(--muted); color: var(--muted);
line-height: 1.55;
} }
.hero { .hero {
@@ -350,7 +338,6 @@ h3 {
.metric { .metric {
padding: 18px; padding: 18px;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 18px;
background: var(--surface); background: var(--surface);
} }
@@ -392,7 +379,6 @@ h3 {
.panel, .panel,
.list-card { .list-card {
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface); background: var(--surface);
} }
@@ -840,7 +826,6 @@ h3 {
.legend { .legend {
display: flex; display: flex;
gap: 18px;
margin-top: 10px; margin-top: 10px;
color: var(--muted); color: var(--muted);
font-size: 12px; font-size: 12px;
@@ -885,6 +870,31 @@ select:focus {
outline-offset: 1px; outline-offset: 1px;
} }
select:not(.toolbar-picker-select) {
appearance: none;
-webkit-appearance: none;
padding-right: 38px;
background-image:
linear-gradient(45deg, transparent 50%, var(--muted) 50%),
linear-gradient(135deg, var(--muted) 50%, transparent 50%);
background-position:
calc(100% - 17px) 50%,
calc(100% - 12px) 50%;
background-size: 5px 5px, 5px 5px;
background-repeat: no-repeat;
cursor: pointer;
transition: border-color .15s ease, background-color .15s ease, outline-color .15s ease;
}
select:not(.toolbar-picker-select):hover:not(:disabled) {
border-color: color-mix(in srgb, var(--accent) 32%, var(--line));
background-color: color-mix(in srgb, var(--input-bg) 90%, var(--surface-muted));
}
select:not(.toolbar-picker-select):disabled {
cursor: not-allowed;
}
.check { .check {
display: flex; display: flex;
grid-auto-flow: column; grid-auto-flow: column;
@@ -1077,9 +1087,6 @@ select:focus {
} }
.log-row { .log-row {
display: grid;
grid-template-columns: 78px 150px 1fr;
gap: 12px;
padding: 11px 12px; padding: 11px 12px;
border-bottom: 1px solid var(--line); border-bottom: 1px solid var(--line);
font-size: 12px; font-size: 12px;
@@ -1550,11 +1557,27 @@ legend {
flex: 0 0 auto; flex: 0 0 auto;
min-width: 94px; min-width: 94px;
padding: 10px 13px; padding: 10px 13px;
border: 1px solid transparent;
border-radius: 14px; border-radius: 14px;
background: var(--surface-muted); background: var(--surface-muted);
color: var(--text);
text-align: right; text-align: right;
} }
button.outside-pill {
cursor: pointer;
}
button.outside-pill:hover {
border-color: color-mix(in srgb, var(--accent) 28%, var(--line));
background: color-mix(in srgb, var(--surface-muted) 86%, var(--accent-soft));
}
button.outside-pill:focus-visible {
outline: 2px solid color-mix(in srgb, var(--accent) 55%, transparent);
outline-offset: 2px;
}
.outside-pill small, .outside-pill small,
.outside-pill strong { .outside-pill strong {
display: block; display: block;
@@ -1570,6 +1593,92 @@ legend {
font-size: 18px; font-size: 18px;
} }
.outdoor-history-dialog {
width: min(920px, calc(100% - 24px));
}
.outdoor-history-current {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 16px;
padding: 12px 14px;
border: 1px solid var(--line);
border-radius: 14px;
background: var(--surface-muted);
}
.outdoor-history-current small {
color: var(--muted);
font-size: 11px;
font-weight: 700;
}
.outdoor-history-current strong {
font-size: 24px;
font-variant-numeric: tabular-nums;
}
.outdoor-history-dialog .history-chart-card {
margin: 0;
}
.outdoor-history-dialog .history-chart-card:not(.chart-fullscreen-fallback) .chart-wrap {
height: auto;
max-height: none;
}
.outdoor-history-dialog .history-chart-card:not(.chart-fullscreen-fallback) canvas {
height: 350px;
}
.outdoor-history-links {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px 14px;
flex-wrap: wrap;
padding: 11px 2px 0;
color: var(--muted);
font-size: 11px;
font-weight: 650;
}
.outdoor-history-links > div {
display: flex;
align-items: center;
gap: 7px;
flex-wrap: wrap;
}
.outdoor-history-links a {
display: inline-flex;
align-items: center;
min-height: 32px;
padding: 6px 10px;
border: 1px solid var(--line);
border-radius: 9px;
background: var(--surface-muted);
color: var(--text-soft);
text-decoration: none;
}
.outdoor-history-links a:hover {
border-color: color-mix(in srgb, var(--accent) 36%, var(--line));
background: var(--surface-2);
color: var(--text);
}
.outdoor-history-links .outdoor-history-all {
color: var(--accent);
}
.outdoor-history-loading {
padding: 28px;
color: var(--muted);
text-align: center;
}
.automation-plan-grid { .automation-plan-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 290px), 1fr)); grid-template-columns: repeat(auto-fit, minmax(min(100%, 290px), 1fr));
@@ -1908,7 +2017,6 @@ legend {
.chart-title-row { .chart-title-row {
display: flex; display: flex;
align-items: flex-start;
justify-content: space-between; justify-content: space-between;
gap: 12px; gap: 12px;
margin-bottom: 12px; margin-bottom: 12px;
@@ -2096,9 +2204,7 @@ legend {
} }
.history-context-controls { .history-context-controls {
display: flex;
flex: 1 1 260px; flex: 1 1 260px;
min-width: min(100%, 220px);
} }
.history-context-controls label { .history-context-controls label {
@@ -2883,7 +2989,6 @@ legend {
.settings-block-head { .settings-block-head {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
gap: 13px;
} }
.settings-block-head h3 { .settings-block-head h3 {
@@ -3298,7 +3403,6 @@ legend {
.device-capability-panel { .device-capability-panel {
display: grid; display: grid;
gap: 8px; gap: 8px;
padding: 12px 0 2px;
} }
.device-capability-panel>small { .device-capability-panel>small {
@@ -3311,22 +3415,12 @@ legend {
.device-capability-buttons { .device-capability-buttons {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 7px;
} }
.device-capability-buttons button { .device-capability-buttons button {
min-height: 34px;
padding: 7px 11px;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 11px;
background: var(--surface-muted);
color: var(--text);
} }
.device-capability-buttons button.active {
border-color: var(--accent);
color: var(--accent);
}
.device-capability-panel { .device-capability-panel {
justify-items: center; justify-items: center;
@@ -3544,7 +3638,6 @@ legend {
.group-custom-temperature-row { .group-custom-temperature-row {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
} }
@@ -4022,9 +4115,6 @@ body.simulation-standalone [data-view="simulation"] {
background: var(--warning-soft); background: var(--warning-soft);
} }
.quick-thermostat-control .thermostat-main {
margin: 8px 0 7px;
}
.zone-config-card .zone-enable-toggle { .zone-config-card .zone-enable-toggle {
min-height: 28px; min-height: 28px;
@@ -4529,7 +4619,6 @@ body.simulation-standalone [data-view="simulation"] {
} }
#controlPlan .plan-card-head .eyebrow { #controlPlan .plan-card-head .eyebrow {
font-size: 8px;
line-height: 1.25; line-height: 1.25;
} }
@@ -4542,10 +4631,6 @@ body.simulation-standalone [data-view="simulation"] {
white-space: nowrap; white-space: nowrap;
} }
#controlPlan .plan-card>p {
font-size: 10px;
line-height: 1.3;
}
#controlPlan .plan-temp { #controlPlan .plan-temp {
gap: 5px; gap: 5px;
@@ -4587,8 +4672,6 @@ body.simulation-standalone [data-view="simulation"] {
#controlPlan .plan-events li { #controlPlan .plan-events li {
grid-template-columns: 62px minmax(0, 1fr); grid-template-columns: 62px minmax(0, 1fr);
gap: 5px; gap: 5px;
font-size: 9px;
line-height: 1.3;
} }
#controlPlan .plan-events li span { #controlPlan .plan-events li span {
@@ -4626,14 +4709,12 @@ body.simulation-standalone [data-view="simulation"] {
#controlPlan .plan-rule-summary strong { #controlPlan .plan-rule-summary strong {
color: var(--text); color: var(--text);
font-size: 10px;
font-weight: 650; font-weight: 650;
line-height: 1.35; line-height: 1.35;
} }
#controlPlan .plan-rule-summary small { #controlPlan .plan-rule-summary small {
color: var(--muted); color: var(--muted);
font-size: 9px;
line-height: 1.35; line-height: 1.35;
} }
@@ -4684,9 +4765,6 @@ body.simulation-standalone [data-view="simulation"] {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
#controlPlan.automation-plan-grid {
grid-template-columns: repeat(auto-fit, minmax(min(100%, 180px), 1fr));
}
} }
[data-view="dashboard"] .dashboard-summary-card { [data-view="dashboard"] .dashboard-summary-card {
@@ -5510,10 +5588,6 @@ textarea[aria-invalid="true"] {
box-shadow: 0 0 0 1px color-mix(in srgb, var(--warning) 16%, transparent); box-shadow: 0 0 0 1px color-mix(in srgb, var(--warning) 16%, transparent);
} }
.zone-thermostat.lockout-waiting,
.zone-config-card.lockout-waiting {
background-image: linear-gradient(color-mix(in srgb, var(--warning-soft) 42%, transparent), color-mix(in srgb, var(--warning-soft) 42%, transparent));
}
.group-custom-temperature-row { .group-custom-temperature-row {
grid-template-columns: minmax(0, 1fr) auto auto auto; grid-template-columns: minmax(0, 1fr) auto auto auto;
@@ -5816,9 +5890,6 @@ textarea[aria-invalid="true"] {
max-width: 70%; max-width: 70%;
} }
body.flow-editor-open {
overflow: hidden;
}
.flow-editor { .flow-editor {
position: fixed; position: fixed;
@@ -6028,8 +6099,6 @@ body.flow-editor-open {
border: 1px solid var(--line); border: 1px solid var(--line);
background: var(--surface-2); background: var(--surface-2);
color: var(--text); color: var(--text);
border-radius: 10px;
padding: 9px 10px;
} }
.flow-palette-group button:hover { .flow-palette-group button:hover {
@@ -6151,9 +6220,6 @@ body.flow-editor-open {
.flow-editor .flow-port { .flow-editor .flow-port {
position: absolute; position: absolute;
top: 50%; top: 50%;
width: 14px;
height: 14px;
margin-top: -7px;
border-radius: 50%; border-radius: 50%;
border: 2px solid var(--surface); border: 2px solid var(--surface);
background: var(--muted-2); background: var(--muted-2);
@@ -6161,13 +6227,6 @@ body.flow-editor-open {
z-index: 4; z-index: 4;
} }
.flow-editor .flow-port-in {
left: -8px;
}
.flow-editor .flow-port-out {
right: -8px;
}
.flow-editor .flow-port:hover, .flow-editor .flow-port:hover,
.flow-editor .flow-port.armed { .flow-editor .flow-port.armed {
@@ -6364,16 +6423,13 @@ body.flow-editor-open {
} }
.flow-template-card { .flow-template-card {
display: grid;
gap: 7px; gap: 7px;
text-align: left; text-align: left;
align-content: start; align-content: start;
min-height: 118px;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 14px; border-radius: 14px;
background: var(--surface-2); background: var(--surface-2);
color: var(--text); color: var(--text);
padding: 14px;
} }
.flow-template-card:hover { .flow-template-card:hover {
@@ -6494,12 +6550,6 @@ body.flow-editor-open {
} }
} }
@media (max-width:760px) {
.flow-editor-actions {
max-width: none;
justify-content: flex-start;
}
}
.flow-device-options { .flow-device-options {
margin: 10px 0; margin: 10px 0;
@@ -7754,7 +7804,6 @@ button {
} }
.automation-tabs { .automation-tabs {
width: 100%;
margin-bottom: 12px; margin-bottom: 12px;
scroll-snap-type: x proximity; scroll-snap-type: x proximity;
} }
@@ -9064,7 +9113,6 @@ body.flow-editor-open {
} }
#controlPlan .plan-rules .plan-events li { #controlPlan .plan-rules .plan-events li {
grid-template-columns: 1fr;
gap: 3px; gap: 3px;
} }
} }
@@ -9643,3 +9691,204 @@ body.flow-editor-open {
.history-network-controls { grid-template-columns: 1fr; } .history-network-controls { grid-template-columns: 1fr; }
.history-jitter-toggle { width: 100%; } .history-jitter-toggle { width: 100%; }
} }
/* Unified custom selects -------------------------------------------------- */
.select-native-proxy {
position: absolute !important;
width: 1px !important;
min-width: 1px !important;
height: 1px !important;
min-height: 1px !important;
margin: 0 !important;
padding: 0 !important;
border: 0 !important;
opacity: 0 !important;
pointer-events: none !important;
clip: rect(0 0 0 0) !important;
clip-path: inset(50%) !important;
}
.custom-select {
position: relative;
width: 100%;
min-width: 0;
}
.custom-select[hidden] {
display: none;
}
.custom-select-trigger {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 10px;
width: 100%;
min-height: 45px;
padding: 10px 12px;
border: 1px solid var(--line);
border-radius: 12px;
outline: 0;
background: var(--input-bg);
color: var(--text);
text-align: left;
cursor: pointer;
transition: border-color .15s ease, background-color .15s ease, outline-color .15s ease;
}
.custom-select-trigger:hover:not(:disabled) {
border-color: color-mix(in srgb, var(--accent) 32%, var(--line));
background: color-mix(in srgb, var(--input-bg) 90%, var(--surface-muted));
}
.custom-select-trigger:focus-visible,
.custom-select.custom-select-open .custom-select-trigger {
border-color: var(--accent);
outline: 2px solid color-mix(in srgb, var(--accent) 18%, transparent);
outline-offset: 1px;
}
.custom-select-trigger:disabled {
cursor: not-allowed;
opacity: .5;
}
.custom-select-value {
min-width: 0;
overflow: hidden;
color: var(--text);
text-overflow: ellipsis;
white-space: nowrap;
}
.custom-select-chevron {
margin-top: -2px;
color: var(--muted);
font-size: 12px;
line-height: 1;
transition: transform .15s ease;
}
.custom-select-open .custom-select-chevron,
.custom-select-toolbar.custom-select-open .toolbar-picker-chevron {
transform: rotate(180deg);
}
.custom-select-menu {
position: fixed;
z-index: 10000;
margin: 0;
padding: 7px;
overflow: auto;
overscroll-behavior: contain;
border: 1px solid var(--line);
border-radius: 12px;
outline: 0;
background: var(--surface);
color: var(--text);
box-shadow: 0 14px 34px rgba(0, 0, 0, .22);
}
.custom-select-menu[popover] {
inset: auto;
}
.custom-select-menu.custom-select-menu-fallback-open {
display: block;
}
.custom-select-option {
display: grid;
grid-template-columns: minmax(0, 1fr) 20px;
align-items: center;
gap: 9px;
width: 100%;
min-height: 38px;
padding: 7px 8px;
border: 0;
border-radius: 8px;
outline: 0;
background: transparent;
color: var(--text-soft);
text-align: left;
cursor: pointer;
font-size: 11px;
font-weight: 600;
}
.custom-select-option:hover:not(:disabled),
.custom-select-option:focus-visible {
background: var(--surface-muted);
color: var(--text);
}
.custom-select-option[aria-selected="true"] {
background: color-mix(in srgb, var(--accent) 11%, var(--surface));
color: var(--text);
}
.custom-select-option b {
color: var(--accent);
font-size: 13px;
line-height: 1;
text-align: center;
}
.custom-select-option:disabled {
opacity: .45;
cursor: not-allowed;
}
.custom-select-group {
padding: 9px 8px 5px;
color: var(--muted);
font-size: 10px;
font-weight: 800;
letter-spacing: .05em;
text-transform: uppercase;
}
.custom-select-empty {
padding: 9px 8px;
color: var(--muted);
font-size: 11px;
text-align: center;
}
.toolbar-picker.custom-select-toolbar {
cursor: pointer;
}
.toolbar-picker.custom-select-toolbar.custom-select-open {
border-color: var(--accent);
outline: 2px solid color-mix(in srgb, var(--accent) 18%, transparent);
outline-offset: 1px;
}
.toolbar-picker.custom-select-toolbar.custom-select-disabled {
opacity: .5;
cursor: not-allowed;
}
.chart-toolbar .custom-select,
.custom-chart-add .custom-select {
flex: 1 1 140px;
min-width: 140px;
}
.flow-inspector .custom-select-trigger {
border-radius: 10px;
padding: 9px 10px;
}
.has-error > .custom-select .custom-select-trigger,
select.select-native-proxy[aria-invalid="true"] + .custom-select .custom-select-trigger {
border-color: color-mix(in srgb, var(--danger) 68%, var(--line));
outline: 2px solid color-mix(in srgb, var(--danger) 13%, transparent);
}
@media (max-width: 640px) {
.custom-select-menu {
max-width: calc(100vw - 16px);
}
}