This commit is contained in:
Mateusz Gruszczyński
2026-09-17 08:52:02 +02:00
parent ff4f2b60e7
commit b7846c3b9f
87 changed files with 3783 additions and 1418 deletions
Generated
+1 -1
View File
@@ -700,7 +700,7 @@ dependencies = [
[[package]] [[package]]
name = "gree-controller" name = "gree-controller"
version = "0.14.20" version = "0.14.21"
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.20" version = "0.14.21"
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"
+86 -87
View File
@@ -1,17 +1,16 @@
8c09c8a2203e7b42ba62cbf7a354e57cb70941a5ecc77949368770e65fe9c8f7 ./.dockerignore
de0793949cf01d27d903653226ecc9b5f72523d8711f20bcab022cf42c4c4ed0 ./.env.example de0793949cf01d27d903653226ecc9b5f72523d8711f20bcab022cf42c4c4ed0 ./.env.example
a4ec3874a2e3ab1bad28fb40bb620f7b01f64d01ad9b699306bf70ada31227db ./.gitignore a4ec3874a2e3ab1bad28fb40bb620f7b01f64d01ad9b699306bf70ada31227db ./.gitignore
1cef28b62ca6f6a01736fc67d9639c909274b2a244c9276ebaacb51ffc662a3a ./Cargo.lock 10eec62654d9740fe5c6cd51620d608b2aa98dac0e6417bb30f77bb660b9d046 ./Cargo.lock
6e563ec8c215326660c9e63ec7f079e5b892cfb58abd72cf55ac127f02ae3938 ./Cargo.toml adff4bf5593ca6558be86fb0cc04a3af0d7b4140a57d07100f6ebb31e092dc59 ./Cargo.toml
19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE 19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE
046205c6a991bca9b514e507a35cebb875d3152d09a7cb02dfb69b5ac16eb640 ./README.md ae3d4b2c7d90282541aca94934ed713087fb7d6451ef78302cd43ca07b794d59 ./README.md
6d6933c5acb76e933dc2a83e24bad0d737c126d88293fadb6c7ee5efaf07ee86 ./SOURCE_MANIFEST.sha256 1fe7189e322c49765f3076bb2b5661a461aa0bd0e8280c24066fe545cce757e3 ./SOURCE_MANIFEST.sha256
5302570299af2c2650bada12e123f598c5db8009949c75761385f73ed57343c5 ./build.rs 29e9821e5ed216ea3565dd6d8bc0b174ed3cf9fa72c4ccebbfa99e98db3ad8f9 ./build.rs
5a673f26300c50aa393690332aed3ff6c8266a119d6a24dd4908ca6b19aa12a0 ./docs/API.md b8c5b2a9da89b6e6c6e20b1eb20f5810f9fee52b3d141ca19a539ecd2397861c ./docs/API.md
f21d732eb1147f8f3167d2172cdf0b57e61edabdfecbaf97811d46c5bff7e47a ./docs/FLOW.md 8b548d02dbdf0563f2be617a1d677459bd1959bed93a1799a01bf61bb5aed405 ./docs/FLOW.md
8350a8466a1a9fec13cfc925ed8ecc3f441e4408c091aef2b820b53e1fa0e408 ./docs/openapi.json 47817a9f7398012c6b374d090c8671e6927bb86cd042df40bf5f2f56ccea14d1 ./docs/openapi.json
c65aa543d2e99f8f7d52736307b07ebd182597d80124661d53a16bb23f6f1118 ./ha-addon/.env.example c65aa543d2e99f8f7d52736307b07ebd182597d80124661d53a16bb23f6f1118 ./ha-addon/.env.example
0972874605e1956aece2672ec6d921c871e5564aa3e290f41f406e38f84ae13f ./ha-addon/Dockerfile 0993076ca45b6d31859680835204cdc111b3f9bdbf460d50b4efec56cd5e685b ./ha-addon/Dockerfile
adf7ff248d782a3b1cba82b8789c57b6849ddaaaf5fbc108af75e18b1534a885 ./ha-addon/README.md adf7ff248d782a3b1cba82b8789c57b6849ddaaaf5fbc108af75e18b1534a885 ./ha-addon/README.md
dd9291b12afade6fc20d66e80a5513251270e18e4c265260b2bbbe393a5a94c4 ./ha-addon/build.sh dd9291b12afade6fc20d66e80a5513251270e18e4c265260b2bbbe393a5a94c4 ./ha-addon/build.sh
082dfeb6326f3e1b9841ccf7f2954117ecdf0aa1a90b0aef1e6a1960dacdecbf ./ha-addon/home-assistant/README.md 082dfeb6326f3e1b9841ccf7f2954117ecdf0aa1a90b0aef1e6a1960dacdecbf ./ha-addon/home-assistant/README.md
@@ -23,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
e5ae5e7c1659ba7555801a35e8542f4b8b89d00e4b3d14eae2794fc731a226c5 ./ha-addon/home-assistant/custom_components/gree_controller/manifest.json 53c09329253fb0f0a11bd52f242e04d2e1475c1298bb9365c92f42209d1f4d77 ./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
@@ -31,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
a256854c7b0200b3458e9ebdbc56c3f61c0dd8c62cc61a442ff038a6bdf40af8 ./ha-addon/repository/gree-controller/CHANGELOG.md a6671a7ad90e0b1898d3ccbc630863e9b385f6737aa5cb44549b5911216494a5 ./ha-addon/repository/gree-controller/CHANGELOG.md
1b5599195329cc3e643c23aa1caadcec80f133e8e515eec7d92b9f3cbda5b80b ./ha-addon/repository/gree-controller/DOCS.md 1b5599195329cc3e643c23aa1caadcec80f133e8e515eec7d92b9f3cbda5b80b ./ha-addon/repository/gree-controller/DOCS.md
c3db302f38d26dc9f8caf37a1a8a07a1334cc07ae301d452f2940a5ec5915719 ./ha-addon/repository/gree-controller/README.md c3db302f38d26dc9f8caf37a1a8a07a1334cc07ae301d452f2940a5ec5915719 ./ha-addon/repository/gree-controller/README.md
756a47c16ff9e52daa5ae6c539c8eba6b3bdb31aa932ac677c1c721d24c9dc09 ./ha-addon/repository/gree-controller/config.yaml 1832ed5985bc96765aade0793d87afcdb2851db941c70c7b414c8af2c62c47d9 ./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
2272740b185e9520e948f74b8f45cf98b8b5fd28c93bdee760da626f91fe5c2a ./ha-addon/repository/gree-controller/translations/en.yaml 2272740b185e9520e948f74b8f45cf98b8b5fd28c93bdee760da626f91fe5c2a ./ha-addon/repository/gree-controller/translations/en.yaml
@@ -44,8 +43,8 @@ cea12bc74c066a588a5ef0ef7bad9d4906bdbcea99f2d275429b0eefc94b4b5f ./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
9477f8e45e8471867acf9af2b1554c9dc9490808c11507e4ce9cb9101c0ce2df ./lang/en.json 3f7ffc2b9492593297668cf52307ad12a4a763dde3d0f8341fdb4b7298b40748 ./lang/en.json
1270413ebdc2ace15ba62f365484207299f2de480bbb87d2b42959dc9ce6fd34 ./lang/pl.json 23bfb017e2530ab8fa6c62908211016e802e990a75944a9914681ded6e2079d6 ./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
@@ -93,104 +92,104 @@ c64b1c6deeb24f20af662bfce7fa3683703914987241107ce664fa2f6d1a9dcb ./scripts/READ
3fe88e64e43d380c56955f577992ea5b0252c05280f6e9dc3cd0bbcc0bf00896 ./scripts/generate_ha_migration.py 3fe88e64e43d380c56955f577992ea5b0252c05280f6e9dc3cd0bbcc0bf00896 ./scripts/generate_ha_migration.py
e4849261fd9ed1f01df96c0637c439c0c4eff8fa317b2918026167bba343af79 ./scripts/install-lxc.sh e4849261fd9ed1f01df96c0637c439c0c4eff8fa317b2918026167bba343af79 ./scripts/install-lxc.sh
bb7cd2c5b27c9dceec1d1d2846fbad9600df9c08e0ad07d13fcde97af533091c ./scripts/install.sh bb7cd2c5b27c9dceec1d1d2846fbad9600df9c08e0ad07d13fcde97af533091c ./scripts/install.sh
1a84c91a89bf7f60aa4816a7650640f315efadf39bf7b54dc20f1259b2e2d5ce ./scripts/live_realtime_test.js 50cc08e2aeff052a47c68212524f4584e9201cfc535d71803f276cdc223d5216 ./scripts/live_realtime_test.js
e00d211e3885e30d7fed1e43b44e6fdad40a67019060156c0641816a93e3365f ./scripts/network-debug.sh e00d211e3885e30d7fed1e43b44e6fdad40a67019060156c0641816a93e3365f ./scripts/network-debug.sh
01952aa92b217f8eae2493b88870e2dec595100cd15c4d561ff11ae2b936c46f ./scripts/regenerate-sha.sh 01952aa92b217f8eae2493b88870e2dec595100cd15c4d561ff11ae2b936c46f ./scripts/regenerate-sha.sh
81345b6a0b51736bdbc98fd23199b62e4c721b4e7437e02dab7ea79b97dff29a ./scripts/service.sh 81345b6a0b51736bdbc98fd23199b62e4c721b4e7437e02dab7ea79b97dff29a ./scripts/service.sh
bd703ef841f9763f13367aaa5764381b9c59ee87210e806c24617f3cc0739a9b ./scripts/smoke.sh bd703ef841f9763f13367aaa5764381b9c59ee87210e806c24617f3cc0739a9b ./scripts/smoke.sh
b50782b3742dfbf8a319c60571c968e93fdf8547db747c759edcffae68cb98bf ./scripts/update.sh b50782b3742dfbf8a319c60571c968e93fdf8547db747c759edcffae68cb98bf ./scripts/update.sh
a70ddd9d4c9fca073ff494db73ec13acc7cff1830c9675d0541518d814da3772 ./src/api.rs bd7e0455c6a1501f761f0d4c00ff74833eecbd75078af9168235f90a2d9485b3 ./src/api.rs
f6ed359370dacd691344402307843a0ad31bbeb46085f99e81cfa46a618ee87d ./src/api/assets.rs 0db0d81da4b8161004ddcbed2096812c285ecec9d944771670e6c8edf0775175 ./src/api/assets.rs
b24092f34a46626d64628b510eba82193f0b203fd097f6a621b91af5d91eb571 ./src/api/auth.rs b24092f34a46626d64628b510eba82193f0b203fd097f6a621b91af5d91eb571 ./src/api/auth.rs
9e4fc675306e111ed3db7af9822e2925d88109c31b9e309851665a975c0bd83c ./src/api/automations.rs 9e4fc675306e111ed3db7af9822e2925d88109c31b9e309851665a975c0bd83c ./src/api/automations.rs
af078ccc0bfcf0c7f2a4a8c1e7ee2f21dc1966ac209d17f17de12bb4d5276244 ./src/api/configuration.rs a4222a29cdc90e75dffd5fb5e35c11e3556924a6489b1b31272cd81d0b141821 ./src/api/configuration.rs
19bc141286a336ad67e99079496eb0ae5126f986ab28be62b5f97758e5f6969c ./src/api/debug_tokens.rs 19bc141286a336ad67e99079496eb0ae5126f986ab28be62b5f97758e5f6969c ./src/api/debug_tokens.rs
32ade46ec45d6faf7852242b9f5ba1be6eacbacbeb33d2deabfab59bf1610ce0 ./src/api/device_groups.rs ca21f6d5a407e2e3532899d53ba4e8682c2fe961a46d8cd870ddc459721513ef ./src/api/device_groups.rs
fcbb3da0538aab451f71810f829cc248d6bb67e586dc418d71a572980583823d ./src/api/devices.rs 2d487e274c777cbde081c5803311bec449097f45bd2d021117cfe10677b39efb ./src/api/devices.rs
4b5b784670ae761a6848b9a33fe3fb391bebd39d65e87936907428711e96ca44 ./src/api/events.rs 4b5b784670ae761a6848b9a33fe3fb391bebd39d65e87936907428711e96ca44 ./src/api/events.rs
d59b080b392b574ec94276c7b485b455d0d1abacc7de2ef29dcbfcf7d86dbdb0 ./src/api/flows.rs d59b080b392b574ec94276c7b485b455d0d1abacc7de2ef29dcbfcf7d86dbdb0 ./src/api/flows.rs
2cfee7029f065e81628a8942e3dad00a03c3a5cf4f8492d07f12b75d05c07359 ./src/api/gree_cloud.rs a17ab0b6d683d285441be15f2af33172d7caa9a57ec8b32e59107379631a377e ./src/api/gree_cloud.rs
b61b0b4f196c5e72e47cf06ca0c14df3cf8eedbdef0242a9d2af33623663489f ./src/api/groups.rs b61b0b4f196c5e72e47cf06ca0c14df3cf8eedbdef0242a9d2af33623663489f ./src/api/groups.rs
92de677265216adb3154f6558684aec44cebff6bf6c57044f37d20f7d5a4a10f ./src/api/history.rs eb88001591ef26601391198b8b87f08cf445fbdd855faa152e33c5554e23f6b7 ./src/api/history.rs
e8edfbb725d6de650442444b0a929ad2be0bd2c2d7d227d2b3fdd99d05c94f94 ./src/api/house.rs e8edfbb725d6de650442444b0a929ad2be0bd2c2d7d227d2b3fdd99d05c94f94 ./src/api/house.rs
c71540b2b68e805e3a79ac787db9127b87585968a205fc307e93bf68d5b028e0 ./src/api/integrations.rs 8495967c3df72110f591c744975984c78cafc8181b2d430e7fd5b9baf57eba95 ./src/api/integrations.rs
7658111f7d125db55a6e23f88157339b110b2cd1ed13ceabd8f860b42dba5101 ./src/api/middleware.rs 7658111f7d125db55a6e23f88157339b110b2cd1ed13ceabd8f860b42dba5101 ./src/api/middleware.rs
45e34a447ff384d99a3c42c489ca9fa75849c74d4d246caa91c63d927de17396 ./src/api/openapi.rs 45e34a447ff384d99a3c42c489ca9fa75849c74d4d246caa91c63d927de17396 ./src/api/openapi.rs
b777e7fbeccef699e11ffd16bb18633843c99afecacce2221280e54b09f6d2da ./src/api/public_settings.rs b777e7fbeccef699e11ffd16bb18633843c99afecacce2221280e54b09f6d2da ./src/api/public_settings.rs
5b29f2e5aa2c422c5829ef89c7b59cf753883aa7f60661c95ab0f95cf676fa3f ./src/api/schedules.rs 5b29f2e5aa2c422c5829ef89c7b59cf753883aa7f60661c95ab0f95cf676fa3f ./src/api/schedules.rs
6acf6417b077a0204a815e255c667928476014ce5f94c1559c9d4268c00342d5 ./src/api/settings.rs fd08162d0f9287700196028ad0ca2aec7c242238056b0a228ca5bdcb319ece4e ./src/api/settings.rs
0de92f241b5da2b7f13aaf9c24ef76eba8d26f8e27d87c9312bd644ab13d31bd ./src/api/system.rs bb57f4b28df75ba350c9706719650fff3a689afee0cb6979c1fcae79396ac0cf ./src/api/system.rs
94d3845def693aafe15fa6e8b390881c46c3c236b62f247ddfdc055d9226ed22 ./src/api/websocket.rs 94d3845def693aafe15fa6e8b390881c46c3c236b62f247ddfdc055d9226ed22 ./src/api/websocket.rs
ddd1bfc8625b66f6e5f3d24b3122ac86c25f89b0af527e3dfce9102551a8f142 ./src/api/zones.rs ddd1bfc8625b66f6e5f3d24b3122ac86c25f89b0af527e3dfce9102551a8f142 ./src/api/zones.rs
7b15febe046c79917a22664f50d9063fd6d0968521544ef31b854424f73b4408 ./src/config.rs 0b7d2ace2e8cb1dd0cb59aa6ca3e80cec14078e93530fd7b58081798585cd833 ./src/config.rs
f8394e0daca284070ac9ae0844d6b04fed7c30386f2d1ec409ecf07880bb0e6a ./src/db.rs de0ce7807aebe4c0e325fbef01855e7fc1cfd117529f53f04a416fda3997673a ./src/db.rs
fdc9d019097becc5c36a0560caa1619aa35d802c0d208902b3bfecbe1643e3e8 ./src/db/climate.rs 87f1614a80addde69b619421187526d7f86f893c0ac18998854f11675d6e7cca ./src/db/climate.rs
e5d2027d40a99e61c2c572499af617a92de9e554f8916f3ea6f6731d1d6b09b4 ./src/db/configuration.rs a8fe3636ac79df191e2f2c9d8c1eec1e5181e048ef8c9036cfa18f84aef1a462 ./src/db/configuration.rs
46213497ae26b2f799364a2e64e41df26c93a39eb4e7167b59c1e42b525f61f7 ./src/db/core_devices.rs 5143803ab470f287a587171f1aab96f60653e84cade523cfe104220079727ffc ./src/db/core_devices.rs
4c20289c08b0cb9c3c4324d9dbc6a47c7dd953aff6fd1746f04b6024043d3bc5 ./src/db/device_history.rs 4c20289c08b0cb9c3c4324d9dbc6a47c7dd953aff6fd1746f04b6024043d3bc5 ./src/db/device_history.rs
f99de2230082f17eafaf03f5361bb67419205a23d64f22480c996f9c32c55ac5 ./src/db/energy_history.rs 7bc79d9d33726fbdd33b485c8c2e839cedbb8fe29fee872611da978ed149d6a0 ./src/db/energy_history.rs
7e14cf46e6750e913f2120ccb0a75ad6884194ba973bf85fff9c05ecd919b5f0 ./src/db/events_tokens.rs 96aac6510ddda8c18fd7e29802a937b96e8a74badf504980846c80cd7933a4c6 ./src/db/events_tokens.rs
bb5048ee60053f39ce3c1203dc1f24c7047310538d2357f105a052c016d21ec4 ./src/db/flows.rs bb5048ee60053f39ce3c1203dc1f24c7047310538d2357f105a052c016d21ec4 ./src/db/flows.rs
5f4b567c6bc866d0674b3b0c66f4aeccf561774623f575b872ff25bf015b91c2 ./src/db/ha_history.rs 5f4b567c6bc866d0674b3b0c66f4aeccf561774623f575b872ff25bf015b91c2 ./src/db/ha_history.rs
7840c89515d08a23050e2634dd91bcdbf7aecc0a8a80bf5002f3eb660ca46f20 ./src/db/network_history.rs 4fc007c08304e96818ad1d78997a5b2458e31aa9c48ad68b5be2a007533924bb ./src/db/network_history.rs
d6b9b70a04c774173c97f7426487d5e2932d7713e12ae3fb7f64cf2b4e0b3c10 ./src/db/schedules_automations.rs d6b9b70a04c774173c97f7426487d5e2932d7713e12ae3fb7f64cf2b4e0b3c10 ./src/db/schedules_automations.rs
8b312e258f079d844466ad516134cdd065a017be78be41214b4239fc0f9e5b36 ./src/db/tests.rs 28ee42d08147b0f154af818065d7d014d74ee8ddd59fdef98115f5c5368056cc ./src/db/tests.rs
f90fbb14f29a20b799b3856bdaa22812a9f0d9dfa46693f1341c160dccda8a97 ./src/db/zone_history.rs f90fbb14f29a20b799b3856bdaa22812a9f0d9dfa46693f1341c160dccda8a97 ./src/db/zone_history.rs
4ed54df6321496a6afabf9443beb9fa8d3434c510aa01dade318bbaa44f0f0ac ./src/engine.rs 75f4f139cb268b2dfda73966b52624735380724e73b99930bffe36328620f111 ./src/engine.rs
ed5c9a0c61c6e597acb33f58593ebe02b562ddc68fa61271e1c1cd436c1003d3 ./src/engine/automations.rs ed5c9a0c61c6e597acb33f58593ebe02b562ddc68fa61271e1c1cd436c1003d3 ./src/engine/automations.rs
0bf7fe7d28b4749e53ec25d71effbb78bc8f70c716714f838a4a2d8f20627d63 ./src/engine/commands.rs 1dccb8779f87dfdbd4ccda3f02ec51273e7be2e34e85166861668685528eca79 ./src/engine/commands.rs
f7de7b9b756bef9d6cffbc6ffdb7504b3121476f188e6ff5c8077d5fc54be811 ./src/engine/connectivity.rs 357ef92c3cc42b243af4da31ac52bb360d7444ae423f58d7a13bdee79bf0a748 ./src/engine/connectivity.rs
925135985f640ba9ae0a6109d908187b659341a91451e91d27e533adfd610b76 ./src/engine/control_plan.rs ad53cd7be24deb759e03cbc6472522b69ebd36d8fac35398a9b19c899d3b17f2 ./src/engine/control_plan.rs
7b48a33343d519a2ff2d81e9d757eb56a0c1beacfc0b7b8ef61f49c2ad363f9a ./src/engine/deadlines.rs 7b48a33343d519a2ff2d81e9d757eb56a0c1beacfc0b7b8ef61f49c2ad363f9a ./src/engine/deadlines.rs
f5e3a8e85c622ea623360bdac87a098785748d8e4f4ba19ec2aa18c279b61c03 ./src/engine/energy.rs a3282d1e22fbdc89bed1d9e681e4865c9d8778f97805083c709fc62090a00d27 ./src/engine/energy.rs
e350aa63f3bb14ca887eaaa7555edbeae00fb584cbcaf552cc972f0c8918f5d8 ./src/engine/groups.rs e350aa63f3bb14ca887eaaa7555edbeae00fb584cbcaf552cc972f0c8918f5d8 ./src/engine/groups.rs
5d8b3c9f2243848502bba5c1e4940f8bf8c5ae4bffd891b43e2e9b4e7dc55b0c ./src/engine/history.rs 4ec11426ef860811348f2bf1b458bc5713fe8a7794fb4c8045dd637652e8e696 ./src/engine/history.rs
41b7045877771b7c352328998cb45ce2433e7f9631fad3eb1a882ab4897dc31f ./src/engine/local_thermostat.rs 41b7045877771b7c352328998cb45ce2433e7f9631fad3eb1a882ab4897dc31f ./src/engine/local_thermostat.rs
99527adab9fea1caede9ebb24faa6a63d5cef4810c64c06306b571cf5cbbb0da ./src/engine/ownership.rs f684e6755d023478882d1689833f307f3b4cd2c66abf45f8af8315cee94d36ef ./src/engine/ownership.rs
1a29b7fd8de77f0a5e5964ca05e41cc5ea78855c1ab961750d40461faec305e5 ./src/engine/polling.rs 56d64614d8ca85cec7098b1f5d2963ecdcbfb7d0a3d4f22e4424a653895b5a16 ./src/engine/polling.rs
d48ae1799e045fb212e73e630a5d26ed3c5e3359a3dac3ebd5638532efdd4285 ./src/engine/runtime.rs c73691c3637465ab0f79aecf7f5428d0317799a8ab5329ed564cf57963bbe86e ./src/engine/runtime.rs
c1e8de5392681ba1255e3e2d871a6ee860fb8ca987a0a8f973d9ded3977a68fb ./src/engine/schedules.rs 48a496c3935f886a90b90461c38a7aa31ebc40c6482d6feaeb2be47bc2387625 ./src/engine/schedules.rs
b0189f28d0f337dfd3ce0934da7a565e4a5535afc1c242214c26ed584b3b7840 ./src/engine/targets.rs b0189f28d0f337dfd3ce0934da7a565e4a5535afc1c242214c26ed584b3b7840 ./src/engine/targets.rs
682c8b3956c657b655746cbd19ee97ee2f4cf953bc9de67d6d70a1b14f3dd746 ./src/engine/temperature.rs 682c8b3956c657b655746cbd19ee97ee2f4cf953bc9de67d6d70a1b14f3dd746 ./src/engine/temperature.rs
833b72907375e75c3f42b3921ad70a8e298f5a93743bf0d0c19059aa272462d1 ./src/engine/temporary_thermostat.rs 833b72907375e75c3f42b3921ad70a8e298f5a93743bf0d0c19059aa272462d1 ./src/engine/temporary_thermostat.rs
3f77350e4c54e8e497d9da8ba0545124f5a8810908c9a1471d1eaa63c4441b0c ./src/engine/tests.rs 2e7349862beb5f844aea0f1c4cd96ab6e53d3500559d7b2562bd93da595d7c41 ./src/engine/tests.rs
c89ff36c6a67bf4fbf68c43e18aa0a556de2dc69263fd79db376363afc741a4f ./src/engine/zone_actions.rs c89ff36c6a67bf4fbf68c43e18aa0a556de2dc69263fd79db376363afc741a4f ./src/engine/zone_actions.rs
3a7fada1f6dd9ebab4026b353f41b21d8ca19342b02538c711dbb2dfb599cbe7 ./src/engine/zone_control.rs fb169bcc20f95abe9892367df6dd3a683584bdbc721792d6ffb9311b81ca30d9 ./src/engine/zone_control.rs
bfac95cec87f0df6ce562153cd3cfe6731359d703e6c592d2c2f7ba01ea62d99 ./src/error.rs bfac95cec87f0df6ce562153cd3cfe6731359d703e6c592d2c2f7ba01ea62d99 ./src/error.rs
a1bd54026f9c7e9f84da1a730136db81095e8ba1ceeb2596fc01479def76df83 ./src/home_assistant.rs c55d32f288fe95869fc288d47817b8e289fbe284be4219e81b1e7493ff5e3a57 ./src/home_assistant.rs
e78ca9aff23362971eb6450534ea21bdc15150456c378ef9b226cb93a8dbc612 ./src/influxdb.rs bcd14e4d1af3ca580f3b95bcd86164c40f762511e1b8e2501cb8a598a6d8adf4 ./src/influxdb.rs
da4068295f37c23222bb13bebac022d88ba7729604a49ad25438c55e45458a49 ./src/influxdb/codec.rs da4068295f37c23222bb13bebac022d88ba7729604a49ad25438c55e45458a49 ./src/influxdb/codec.rs
b96624fa34b218a6d5f028ba09579633f6f8a04c109f0e94801e8d89ceeeb4b5 ./src/influxdb/query.rs 2d36405912646b30b1a1a37a7a4e18c2104ab22e865b593316a609dda884aec2 ./src/influxdb/query.rs
8089b8eca47419bb6b3ee02560e5cc39c00479164bc76d11992679b2cef3258e ./src/influxdb/write.rs 67e42a92295480d2f1d364958f184654cfb5d4b77e7e1df47f588bdec18868a0 ./src/influxdb/write.rs
6479dd1e5d7f8eb2ffb945ca958124107c5585c0abdfb34555367db0c21145da ./src/main.rs 1982ce54737a8780d1404b105d53db59cd5bee3d1c426f9a881d26cef7059bf5 ./src/main.rs
23688a5cb68efded89471ce959328d27adff370d451bd9f378a283ab51437e76 ./src/models.rs 23688a5cb68efded89471ce959328d27adff370d451bd9f378a283ab51437e76 ./src/models.rs
920e028d96a14cca9f74a964bb296e68a109cfead314d3164c180201e606d151 ./src/models/automation.rs 920e028d96a14cca9f74a964bb296e68a109cfead314d3164c180201e606d151 ./src/models/automation.rs
fc5f2802a073a175a8985e56bcb87eb1ff481226d5b4eeabad73dbba1839fc45 ./src/models/control_plan.rs fc5f2802a073a175a8985e56bcb87eb1ff481226d5b4eeabad73dbba1839fc45 ./src/models/control_plan.rs
06357b352ff6494d83e72796b869573a8b8f36b835985a5900ac571c922b8da9 ./src/models/defaults.rs 06357b352ff6494d83e72796b869573a8b8f36b835985a5900ac571c922b8da9 ./src/models/defaults.rs
5e212a072bf74ef32a186b172404844b02a92363d216167f814056e1f2a9a7a8 ./src/models/device.rs f3439a154fad8a0111db26918160901dd04fe47dd56d529490e75deaf0a78b43 ./src/models/device.rs
aeab5b5acf35d38a54fd1d82e964fe56635ade69c0149ad423df317095d01555 ./src/models/flow.rs aeab5b5acf35d38a54fd1d82e964fe56635ade69c0149ad423df317095d01555 ./src/models/flow.rs
ba5d359e4d6c12d9f62c1b28bdfb40d312737930df5421814f5310f7183e96c1 ./src/models/history.rs 6e44df6bde2f73c282095c80ac17287e5bef77d9fc1d344e440d863dae5f5031 ./src/models/history.rs
1fee89f939018bf1f3686c15f25434f01ab96b3239164108db7f348a1f1f8d82 ./src/models/integrations.rs 1fee89f939018bf1f3686c15f25434f01ab96b3239164108db7f348a1f1f8d82 ./src/models/integrations.rs
f1359b68f1d041cd441f80429e77893eeb6f6194613b58fd3e7a212fb511db3e ./src/models/runtime.rs f1359b68f1d041cd441f80429e77893eeb6f6194613b58fd3e7a212fb511db3e ./src/models/runtime.rs
e3dfdd6dba63f6febd0b64da413b2fe7ca258a54abc2fdd6f2982c27425abfd6 ./src/models/settings_api.rs 41b85cff6fd234c8d6ea007b2c3c22df5fee5107dcd57c64dcc94a356ff70c12 ./src/models/settings_api.rs
c4de6101de85d9d7e6814b2504b87d9216561e3d9e274054f081f505fb152140 ./src/models/temporary_thermostat.rs c4de6101de85d9d7e6814b2504b87d9216561e3d9e274054f081f505fb152140 ./src/models/temporary_thermostat.rs
bc2818ce8d0dae1edfb1b98e5d1c00b193f03eeaa28e343d3532f6023288738c ./src/models/zone.rs bc2818ce8d0dae1edfb1b98e5d1c00b193f03eeaa28e343d3532f6023288738c ./src/models/zone.rs
62e696df8b86835df7ad127f03c257a78580769dc82bc66daa5041f71e8440a5 ./src/notifications.rs 4c1f676eaf0b6da45b4d4b090ae3ac862fb97c1f9e53284a1640767ae9591d8f ./src/notifications.rs
cca742bb2b500eb9269f69385fcf88053cd08aabf6a98b7669b8a7a2fd67df13 ./src/protocol/crypto.rs cca742bb2b500eb9269f69385fcf88053cd08aabf6a98b7669b8a7a2fd67df13 ./src/protocol/crypto.rs
cd0612021f8ed6c9e0823d63883c0790db09aa485583f3f3fc9f2579f2c28ea3 ./src/protocol/gree.rs cd0612021f8ed6c9e0823d63883c0790db09aa485583f3f3fc9f2579f2c28ea3 ./src/protocol/gree.rs
7907c33e6396c7e0d61705b735b21c84d6ed784012fb61022b872c6745540f1d ./src/protocol/gree/binding.rs 7907c33e6396c7e0d61705b735b21c84d6ed784012fb61022b872c6745540f1d ./src/protocol/gree/binding.rs
d71771855d5ac90fae4994debf53ed09472b216c17bb2155d80aa2de13c57e18 ./src/protocol/gree/commands.rs fcf5e67539c419f3e7e4533d49f41f963fa7779f099bd33c1b210fac32a78949 ./src/protocol/gree/commands.rs
4e419ba3b86ca9d154e8c04445cf005965175c54041093b2daad3666d4d852bd ./src/protocol/gree/core.rs 4e419ba3b86ca9d154e8c04445cf005965175c54041093b2daad3666d4d852bd ./src/protocol/gree/core.rs
05fa2e4ec133a61c23a3f4ab2ce792bbfab93feaea9f0ce0d4ccc67fc479833d ./src/protocol/gree/discovery.rs 05fa2e4ec133a61c23a3f4ab2ce792bbfab93feaea9f0ce0d4ccc67fc479833d ./src/protocol/gree/discovery.rs
0ab094f609edb99715402d8eeaa7e38cbfd2d271036aec787dcbb3be3e005720 ./src/protocol/gree/merge.rs 0ab094f609edb99715402d8eeaa7e38cbfd2d271036aec787dcbb3be3e005720 ./src/protocol/gree/merge.rs
c09c931fa8d512a8883fb14f3142be694af3c51290e023d25c7c972e156e4bd2 ./src/protocol/gree/network.rs ff33db623f8f1bf30015b4a00e1f247a99de713da3d297c672e42c0795ec845c ./src/protocol/gree/network.rs
0a901ff258a74072af3812c5417196da1a9b8daa8a2eff30c96d038558601739 ./src/protocol/gree/polling.rs 0a901ff258a74072af3812c5417196da1a9b8daa8a2eff30c96d038558601739 ./src/protocol/gree/polling.rs
50152bcad4a843ae04000b991a2365abb302fc1583ea69f09320e25740f3c38c ./src/protocol/gree/tests.rs e0555fac67d1dd0129ecfea6d1fd2fbeb1a73ac89ad95d1b74a7094e50e29ae4 ./src/protocol/gree/tests.rs
3c93e5b254a2a40a07854532cb5fc07e493ad65d9ba2cf28093cff4a4a4e451c ./src/protocol/gree/transport.rs 3c93e5b254a2a40a07854532cb5fc07e493ad65d9ba2cf28093cff4a4a4e451c ./src/protocol/gree/transport.rs
49dcca1e4a4ad858b312386e3b729f36da691916d8fe62ad682ddb86d40695b7 ./src/protocol/gree_cloud.rs 297983059c915057869fbb4f6bcfe752f3d5bd1e93c3a2651a1bf883c739ed29 ./src/protocol/gree_cloud.rs
d406a2c5a49fe059f3f3a00ea534a32f9bdd972c09cc051cbfc4317f7c91b38c ./src/protocol/gree_cloud_mqtt.rs b4b1bad9cad889dd69681b2afc3e6a09ef19e19b2a1f2900fd417c4b1f55ec2c ./src/protocol/gree_cloud_mqtt.rs
6258ee3888a5f9a4f8bb6cfbbac97f2621de07f9da6bcb2fa596d452f47f2e49 ./src/protocol/mod.rs 6258ee3888a5f9a4f8bb6cfbbac97f2621de07f9da6bcb2fa596d452f47f2e49 ./src/protocol/mod.rs
823da603035619894d8a0252c6a9782c44fdf8d539f09a0a0c120dc8e4b8e4f2 ./src/provider.rs a78d73571fbd983660449fcbd7857064a1325ca12adc07e61c2f50a3208adf37 ./src/provider.rs
830621fa75265da7288b8135704c0ce27e0687f87d55abf3cdbe40a6e2f3d2b1 ./src/queries.rs 830621fa75265da7288b8135704c0ce27e0687f87d55abf3cdbe40a6e2f3d2b1 ./src/queries.rs
7fac65a9dcbcecee09816f5447c7792dc94f736b1efb66ac85629db75f6280be ./src/queries/device_history.rs 7fac65a9dcbcecee09816f5447c7792dc94f736b1efb66ac85629db75f6280be ./src/queries/device_history.rs
580917b91441fe9b3276cf0cfc31536ba7954b6a86f4080347b93f9367a5b1ac ./src/queries/entities.rs 580917b91441fe9b3276cf0cfc31536ba7954b6a86f4080347b93f9367a5b1ac ./src/queries/entities.rs
@@ -198,32 +197,32 @@ d406a2c5a49fe059f3f3a00ea534a32f9bdd972c09cc051cbfc4317f7c91b38c ./src/protocol
7e12e8704faa7ac15a312bcc1d714ee1c6f27c1dce0972d4ccd6e5a40c69c884 ./src/queries/maintenance.rs 7e12e8704faa7ac15a312bcc1d714ee1c6f27c1dce0972d4ccd6e5a40c69c884 ./src/queries/maintenance.rs
7da8b73a1c8c30e2a12071062d36f5393938fde0b5ffebbe4af1891da40f3233 ./src/queries/schema.rs 7da8b73a1c8c30e2a12071062d36f5393938fde0b5ffebbe4af1891da40f3233 ./src/queries/schema.rs
f13f1be3d5789539eba3fc1b59c14835f8c681eca7634e118fa3763a53aeee2b ./src/queries/zone_history.rs f13f1be3d5789539eba3fc1b59c14835f8c681eca7634e118fa3763a53aeee2b ./src/queries/zone_history.rs
7e7037a059b585c5709367b9431a225789e7039796e3ed19fc132776ac234099 ./src/state.rs 8c34b70fb5f80e3249c2e0c63686e60ce1697cf44a7e6f11b3ad6f689ce17792 ./src/state.rs
b92a6cb158b494fe145b43c7641e65f6fafff47201d7d76edbec2cfd8b94835c ./systemd/gree-controller.service b92a6cb158b494fe145b43c7641e65f6fafff47201d7d76edbec2cfd8b94835c ./systemd/gree-controller.service
544a31cb2e5374227026afd6abd0d910381f3fca1638154ebd74e3c3d96a35d1 ./web/404.html 544a31cb2e5374227026afd6abd0d910381f3fca1638154ebd74e3c3d96a35d1 ./web/404.html
fbd8595c339d245453427c7cb9bca937bd2f60852ba25a15956295abda26878c ./web/custom-chart.html 8b8b5e9511a833d6dc0eb902048e8f48f26395faf55f19962c3e6f06ae0fb4e4 ./web/css/styles.css
e13dc8c936b39c9d1f808d6e0e2ed1e9171c4da62ce6ba526220e8aaf478f571 ./web/custom-chart.js 50e0ffd652fb4ea8ea4ed79a8e54e838709448886e3f6004cdeb35ffa4e13206 ./web/custom-chart.html
e98bdd7204349cce1ec6f57283509697af0bbc72280622a6c3efa6fed242db4f ./web/favicon.svg e98bdd7204349cce1ec6f57283509697af0bbc72280622a6c3efa6fed242db4f ./web/favicon.svg
217a9f99be2356bb15d5fa177bd08c7527eaeff827e572557464dd4e1dfa8fe4 ./web/index.html 784e2c28effb9872b582298292d3e2a6b8dd6140207342452193894d210e2f45 ./web/index.html
9ba3f6fa05b72aa989139b2a909982571b2a02055052e4c40104f1e81a9aa7eb ./web/js/README.md 9ba3f6fa05b72aa989139b2a909982571b2a02055052e4c40104f1e81a9aa7eb ./web/js-dynamic/README.md
27eff2821c40395369be15e197fa830535d2327fcb27c93a0e8e65a1c62de3ad ./web/js/bootstrap.js 27eff2821c40395369be15e197fa830535d2327fcb27c93a0e8e65a1c62de3ad ./web/js-dynamic/bootstrap.js
1612896d22fd61747175cd54174a283002ac9911f4164e593c1079f767790cda ./web/js/charts.js 5d57253a013fd1cb79e4b8701d4be6e394e3f2acbce73dfcd726a86e5878f1a1 ./web/js-dynamic/charts.js
5c46d271550f3db38ecbfc09e3a620f6f0e6a907a3d6d56feae9b08e894df196 ./web/js/core.js 1c7a779d6b793413e6f92e6bea910d035ffa22356aae965923a14ed84ab6bd8d ./web/js-dynamic/core.js
752b3c3b59558b42d867d4a15329238f0213f354b76f7011c10e31b4e6dbb311 ./web/js/dashboard.js 752b3c3b59558b42d867d4a15329238f0213f354b76f7011c10e31b4e6dbb311 ./web/js-dynamic/dashboard.js
0f975542ae1d9bd7216123dd3718be6e9a25dbe643eb899c67024d293940fae5 ./web/js/entities.js 0f975542ae1d9bd7216123dd3718be6e9a25dbe643eb899c67024d293940fae5 ./web/js-dynamic/entities.js
aaa874637d4a63876aa1248f995190316de80031b4bbcac1e8943238d2bafb53 ./web/js/events.js 7a4d02bb1ebfa3a5962c7a0b567338d460b99f30ec582fda3a7fec9f6fe54840 ./web/js-dynamic/events.js
473511ca11a0412a794b20430c077843072e9abe0d2510552ac83b226c381453 ./web/js/flows.js 1865ef1ed93f04fe323338ac44ef06c9b19ad6ec222d47085ce51cb4c03fe13d ./web/js-dynamic/flows.js
a3d9f77a40a2574a668855125c781c067289944c6f93179ff23e3dae150e7dac ./web/js/forms.js a3d9f77a40a2574a668855125c781c067289944c6f93179ff23e3dae150e7dac ./web/js-dynamic/forms.js
edde2969c9c1376f4df37224daa291256ebbbe713903d98e26b6aaa6ca467911 ./web/js/history.js 92d013bb5d64f64aa8c55380704540a5f72732bde7ccd16554965e401daf50ea ./web/js-dynamic/history.js
511c4e7cc4c48fbbdab64558a9d1c27e6759c9b6dca255d11f301b9708e955c7 ./web/js/main.js 511c4e7cc4c48fbbdab64558a9d1c27e6759c9b6dca255d11f301b9708e955c7 ./web/js-dynamic/main.js
6bb9386707dcf601111fea15a1a366d8798313b973ca1624878618f4c3095add ./web/js/navigation.js 86031d4f7f7d1b46412c8fbb09ff1ad1b2b4cd84eb949bc749b27663f1dc27fa ./web/js-dynamic/navigation.js
799613c6d465cb2f92ecb57cb082d0c33729a03d88ab4ca7fa12a3cbd7e38e75 ./web/js/realtime.js 799613c6d465cb2f92ecb57cb082d0c33729a03d88ab4ca7fa12a3cbd7e38e75 ./web/js-dynamic/realtime.js
9546ca5c5566994aff3c33d92295be46b65d51d874681764abe77470dc9c9c73 ./web/js/router.js 1b667f0a8bce824b27f56a8d2f44ff8feef9a011289591dc629ee288db70d137 ./web/js-dynamic/router.js
c2710fd9c1f2b31878c1e25ceb02d74ae30456c1bbb400e69fcf7619ecc72bc2 ./web/js/select-ui.js c2710fd9c1f2b31878c1e25ceb02d74ae30456c1bbb400e69fcf7619ecc72bc2 ./web/js-dynamic/select-ui.js
f7245f0e6cb5011196704f201675ebc6c19d902b8253ffcdbf9ead46cb1c6f86 ./web/js/settings-ui.js f7245f0e6cb5011196704f201675ebc6c19d902b8253ffcdbf9ead46cb1c6f86 ./web/js-dynamic/settings-ui.js
335317bd50dd411f8c5e6d00b22d30e53172484626f2ad9901e32cab469724ac ./web/js/settings.js 672e404470bc6331aa6d658523b221659ec64222af9cc9c17ced978f725790da ./web/js-dynamic/settings.js
3815fea7ae1694568c2f249bc8460e0563ced6bde89214f74f21b41fe8cad9a6 ./web/lang-init.js d5045e9a28ac73a34d91d4ff23314b54bc93c958b14f597aacbb087b261152f3 ./web/js/custom-chart.js
6ae536b18db756bf5eda9adb1b8a550d5a02bfc80f0b7da27fa146039f22e884 ./web/js/lang-init.js
fefdf12cbbb0a5d8a2cb89275cae28455aa42aaf179542305cc8f5e0f4825898 ./web/js/sw.js
d505d793ce7cc9485b45b78bba1c0d51887adc7451ab59a42702946e5b991382 ./web/js/theme-init.js
6b653e4fe2d4db4ffae6ff8f39b8ce57a10a990dc0000ee4aae51d949afadd52 ./web/manifest.webmanifest 6b653e4fe2d4db4ffae6ff8f39b8ce57a10a990dc0000ee4aae51d949afadd52 ./web/manifest.webmanifest
0efe0cdff18beff496017a984f6d189ea6463e14b517f2f5c5fc46923579ab15 ./web/styles.css
fefdf12cbbb0a5d8a2cb89275cae28455aa42aaf179542305cc8f5e0f4825898 ./web/sw.js
d505d793ce7cc9485b45b78bba1c0d51887adc7451ab59a42702946e5b991382 ./web/theme-init.js
+5 -4
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.20** **Version: 0.14.21**
Version 0.14.20 fixes the ARM cross-build bootstrap and improves Docker cache reuse: target Rust stdlib/toolchain setup is cached before source changes, dependency fetching is shared across architectures, and compiled target caches are separated per target architecture. Alpine/musl and Debian Trixie/glibc remain selected through `DISTRO` in the existing `build.sh` workflow. Version 0.14.21 includes code cleanup and small UX changes.
> [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.
@@ -470,7 +470,8 @@ curl -H 'Authorization: Bearer YOUR_TOKEN' \
```text ```text
src/ Rust backend, GREE protocol and thermostat engine src/ Rust backend, GREE protocol and thermostat engine
web/ Embedded Web UI / PWA web/ Embedded Web UI / PWA
web/js/ Frontend JS source modules bundled by build.rs web/js/ Standalone/static frontend scripts
web/js-dynamic/ Application JS modules bundled by build.rs
lang/ Runtime language packs lang/ Runtime language packs
ha-addon/ Home Assistant add-on, custom integration and release tooling ha-addon/ Home Assistant add-on, custom integration and release tooling
scripts/ Development, install, update, service and diagnostics scripts/ Development, install, update, service and diagnostics
@@ -479,7 +480,7 @@ docs/API.md Complete API documentation
.env.example Environment reference .env.example Environment reference
``` ```
The application is intentionally self-contained: static assets and language packs are embedded into the binary at build time; SQLite is the default data store; no external frontend build chain is required. `build.rs` concatenates the ordered files from `web/js/` into one generated application bundle and exposes it under a content-hashed URL. The application is intentionally self-contained: static assets and language packs are embedded into the binary at build time; SQLite is the default data store; no external frontend build chain is required. `build.rs` concatenates the ordered files from `web/js-dynamic/` into one generated application bundle and exposes it under a content-hashed URL.
## Validation before release ## Validation before release
+85 -86
View File
@@ -1,16 +1,15 @@
8c09c8a2203e7b42ba62cbf7a354e57cb70941a5ecc77949368770e65fe9c8f7 ./.dockerignore
de0793949cf01d27d903653226ecc9b5f72523d8711f20bcab022cf42c4c4ed0 ./.env.example de0793949cf01d27d903653226ecc9b5f72523d8711f20bcab022cf42c4c4ed0 ./.env.example
a4ec3874a2e3ab1bad28fb40bb620f7b01f64d01ad9b699306bf70ada31227db ./.gitignore a4ec3874a2e3ab1bad28fb40bb620f7b01f64d01ad9b699306bf70ada31227db ./.gitignore
1cef28b62ca6f6a01736fc67d9639c909274b2a244c9276ebaacb51ffc662a3a ./Cargo.lock 10eec62654d9740fe5c6cd51620d608b2aa98dac0e6417bb30f77bb660b9d046 ./Cargo.lock
6e563ec8c215326660c9e63ec7f079e5b892cfb58abd72cf55ac127f02ae3938 ./Cargo.toml adff4bf5593ca6558be86fb0cc04a3af0d7b4140a57d07100f6ebb31e092dc59 ./Cargo.toml
19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE 19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE
046205c6a991bca9b514e507a35cebb875d3152d09a7cb02dfb69b5ac16eb640 ./README.md ae3d4b2c7d90282541aca94934ed713087fb7d6451ef78302cd43ca07b794d59 ./README.md
5302570299af2c2650bada12e123f598c5db8009949c75761385f73ed57343c5 ./build.rs 29e9821e5ed216ea3565dd6d8bc0b174ed3cf9fa72c4ccebbfa99e98db3ad8f9 ./build.rs
5a673f26300c50aa393690332aed3ff6c8266a119d6a24dd4908ca6b19aa12a0 ./docs/API.md b8c5b2a9da89b6e6c6e20b1eb20f5810f9fee52b3d141ca19a539ecd2397861c ./docs/API.md
f21d732eb1147f8f3167d2172cdf0b57e61edabdfecbaf97811d46c5bff7e47a ./docs/FLOW.md 8b548d02dbdf0563f2be617a1d677459bd1959bed93a1799a01bf61bb5aed405 ./docs/FLOW.md
8350a8466a1a9fec13cfc925ed8ecc3f441e4408c091aef2b820b53e1fa0e408 ./docs/openapi.json 47817a9f7398012c6b374d090c8671e6927bb86cd042df40bf5f2f56ccea14d1 ./docs/openapi.json
c65aa543d2e99f8f7d52736307b07ebd182597d80124661d53a16bb23f6f1118 ./ha-addon/.env.example c65aa543d2e99f8f7d52736307b07ebd182597d80124661d53a16bb23f6f1118 ./ha-addon/.env.example
0972874605e1956aece2672ec6d921c871e5564aa3e290f41f406e38f84ae13f ./ha-addon/Dockerfile 0993076ca45b6d31859680835204cdc111b3f9bdbf460d50b4efec56cd5e685b ./ha-addon/Dockerfile
adf7ff248d782a3b1cba82b8789c57b6849ddaaaf5fbc108af75e18b1534a885 ./ha-addon/README.md adf7ff248d782a3b1cba82b8789c57b6849ddaaaf5fbc108af75e18b1534a885 ./ha-addon/README.md
dd9291b12afade6fc20d66e80a5513251270e18e4c265260b2bbbe393a5a94c4 ./ha-addon/build.sh dd9291b12afade6fc20d66e80a5513251270e18e4c265260b2bbbe393a5a94c4 ./ha-addon/build.sh
082dfeb6326f3e1b9841ccf7f2954117ecdf0aa1a90b0aef1e6a1960dacdecbf ./ha-addon/home-assistant/README.md 082dfeb6326f3e1b9841ccf7f2954117ecdf0aa1a90b0aef1e6a1960dacdecbf ./ha-addon/home-assistant/README.md
@@ -22,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
e5ae5e7c1659ba7555801a35e8542f4b8b89d00e4b3d14eae2794fc731a226c5 ./ha-addon/home-assistant/custom_components/gree_controller/manifest.json 53c09329253fb0f0a11bd52f242e04d2e1475c1298bb9365c92f42209d1f4d77 ./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 +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
a256854c7b0200b3458e9ebdbc56c3f61c0dd8c62cc61a442ff038a6bdf40af8 ./ha-addon/repository/gree-controller/CHANGELOG.md a6671a7ad90e0b1898d3ccbc630863e9b385f6737aa5cb44549b5911216494a5 ./ha-addon/repository/gree-controller/CHANGELOG.md
1b5599195329cc3e643c23aa1caadcec80f133e8e515eec7d92b9f3cbda5b80b ./ha-addon/repository/gree-controller/DOCS.md 1b5599195329cc3e643c23aa1caadcec80f133e8e515eec7d92b9f3cbda5b80b ./ha-addon/repository/gree-controller/DOCS.md
c3db302f38d26dc9f8caf37a1a8a07a1334cc07ae301d452f2940a5ec5915719 ./ha-addon/repository/gree-controller/README.md c3db302f38d26dc9f8caf37a1a8a07a1334cc07ae301d452f2940a5ec5915719 ./ha-addon/repository/gree-controller/README.md
756a47c16ff9e52daa5ae6c539c8eba6b3bdb31aa932ac677c1c721d24c9dc09 ./ha-addon/repository/gree-controller/config.yaml 1832ed5985bc96765aade0793d87afcdb2851db941c70c7b414c8af2c62c47d9 ./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
2272740b185e9520e948f74b8f45cf98b8b5fd28c93bdee760da626f91fe5c2a ./ha-addon/repository/gree-controller/translations/en.yaml 2272740b185e9520e948f74b8f45cf98b8b5fd28c93bdee760da626f91fe5c2a ./ha-addon/repository/gree-controller/translations/en.yaml
@@ -43,8 +42,8 @@ cea12bc74c066a588a5ef0ef7bad9d4906bdbcea99f2d275429b0eefc94b4b5f ./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
9477f8e45e8471867acf9af2b1554c9dc9490808c11507e4ce9cb9101c0ce2df ./lang/en.json 3f7ffc2b9492593297668cf52307ad12a4a763dde3d0f8341fdb4b7298b40748 ./lang/en.json
1270413ebdc2ace15ba62f365484207299f2de480bbb87d2b42959dc9ce6fd34 ./lang/pl.json 23bfb017e2530ab8fa6c62908211016e802e990a75944a9914681ded6e2079d6 ./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
@@ -92,104 +91,104 @@ c64b1c6deeb24f20af662bfce7fa3683703914987241107ce664fa2f6d1a9dcb ./scripts/READ
3fe88e64e43d380c56955f577992ea5b0252c05280f6e9dc3cd0bbcc0bf00896 ./scripts/generate_ha_migration.py 3fe88e64e43d380c56955f577992ea5b0252c05280f6e9dc3cd0bbcc0bf00896 ./scripts/generate_ha_migration.py
e4849261fd9ed1f01df96c0637c439c0c4eff8fa317b2918026167bba343af79 ./scripts/install-lxc.sh e4849261fd9ed1f01df96c0637c439c0c4eff8fa317b2918026167bba343af79 ./scripts/install-lxc.sh
bb7cd2c5b27c9dceec1d1d2846fbad9600df9c08e0ad07d13fcde97af533091c ./scripts/install.sh bb7cd2c5b27c9dceec1d1d2846fbad9600df9c08e0ad07d13fcde97af533091c ./scripts/install.sh
1a84c91a89bf7f60aa4816a7650640f315efadf39bf7b54dc20f1259b2e2d5ce ./scripts/live_realtime_test.js 50cc08e2aeff052a47c68212524f4584e9201cfc535d71803f276cdc223d5216 ./scripts/live_realtime_test.js
e00d211e3885e30d7fed1e43b44e6fdad40a67019060156c0641816a93e3365f ./scripts/network-debug.sh e00d211e3885e30d7fed1e43b44e6fdad40a67019060156c0641816a93e3365f ./scripts/network-debug.sh
01952aa92b217f8eae2493b88870e2dec595100cd15c4d561ff11ae2b936c46f ./scripts/regenerate-sha.sh 01952aa92b217f8eae2493b88870e2dec595100cd15c4d561ff11ae2b936c46f ./scripts/regenerate-sha.sh
81345b6a0b51736bdbc98fd23199b62e4c721b4e7437e02dab7ea79b97dff29a ./scripts/service.sh 81345b6a0b51736bdbc98fd23199b62e4c721b4e7437e02dab7ea79b97dff29a ./scripts/service.sh
bd703ef841f9763f13367aaa5764381b9c59ee87210e806c24617f3cc0739a9b ./scripts/smoke.sh bd703ef841f9763f13367aaa5764381b9c59ee87210e806c24617f3cc0739a9b ./scripts/smoke.sh
b50782b3742dfbf8a319c60571c968e93fdf8547db747c759edcffae68cb98bf ./scripts/update.sh b50782b3742dfbf8a319c60571c968e93fdf8547db747c759edcffae68cb98bf ./scripts/update.sh
a70ddd9d4c9fca073ff494db73ec13acc7cff1830c9675d0541518d814da3772 ./src/api.rs bd7e0455c6a1501f761f0d4c00ff74833eecbd75078af9168235f90a2d9485b3 ./src/api.rs
f6ed359370dacd691344402307843a0ad31bbeb46085f99e81cfa46a618ee87d ./src/api/assets.rs 0db0d81da4b8161004ddcbed2096812c285ecec9d944771670e6c8edf0775175 ./src/api/assets.rs
b24092f34a46626d64628b510eba82193f0b203fd097f6a621b91af5d91eb571 ./src/api/auth.rs b24092f34a46626d64628b510eba82193f0b203fd097f6a621b91af5d91eb571 ./src/api/auth.rs
9e4fc675306e111ed3db7af9822e2925d88109c31b9e309851665a975c0bd83c ./src/api/automations.rs 9e4fc675306e111ed3db7af9822e2925d88109c31b9e309851665a975c0bd83c ./src/api/automations.rs
af078ccc0bfcf0c7f2a4a8c1e7ee2f21dc1966ac209d17f17de12bb4d5276244 ./src/api/configuration.rs a4222a29cdc90e75dffd5fb5e35c11e3556924a6489b1b31272cd81d0b141821 ./src/api/configuration.rs
19bc141286a336ad67e99079496eb0ae5126f986ab28be62b5f97758e5f6969c ./src/api/debug_tokens.rs 19bc141286a336ad67e99079496eb0ae5126f986ab28be62b5f97758e5f6969c ./src/api/debug_tokens.rs
32ade46ec45d6faf7852242b9f5ba1be6eacbacbeb33d2deabfab59bf1610ce0 ./src/api/device_groups.rs ca21f6d5a407e2e3532899d53ba4e8682c2fe961a46d8cd870ddc459721513ef ./src/api/device_groups.rs
fcbb3da0538aab451f71810f829cc248d6bb67e586dc418d71a572980583823d ./src/api/devices.rs 2d487e274c777cbde081c5803311bec449097f45bd2d021117cfe10677b39efb ./src/api/devices.rs
4b5b784670ae761a6848b9a33fe3fb391bebd39d65e87936907428711e96ca44 ./src/api/events.rs 4b5b784670ae761a6848b9a33fe3fb391bebd39d65e87936907428711e96ca44 ./src/api/events.rs
d59b080b392b574ec94276c7b485b455d0d1abacc7de2ef29dcbfcf7d86dbdb0 ./src/api/flows.rs d59b080b392b574ec94276c7b485b455d0d1abacc7de2ef29dcbfcf7d86dbdb0 ./src/api/flows.rs
2cfee7029f065e81628a8942e3dad00a03c3a5cf4f8492d07f12b75d05c07359 ./src/api/gree_cloud.rs a17ab0b6d683d285441be15f2af33172d7caa9a57ec8b32e59107379631a377e ./src/api/gree_cloud.rs
b61b0b4f196c5e72e47cf06ca0c14df3cf8eedbdef0242a9d2af33623663489f ./src/api/groups.rs b61b0b4f196c5e72e47cf06ca0c14df3cf8eedbdef0242a9d2af33623663489f ./src/api/groups.rs
92de677265216adb3154f6558684aec44cebff6bf6c57044f37d20f7d5a4a10f ./src/api/history.rs eb88001591ef26601391198b8b87f08cf445fbdd855faa152e33c5554e23f6b7 ./src/api/history.rs
e8edfbb725d6de650442444b0a929ad2be0bd2c2d7d227d2b3fdd99d05c94f94 ./src/api/house.rs e8edfbb725d6de650442444b0a929ad2be0bd2c2d7d227d2b3fdd99d05c94f94 ./src/api/house.rs
c71540b2b68e805e3a79ac787db9127b87585968a205fc307e93bf68d5b028e0 ./src/api/integrations.rs 8495967c3df72110f591c744975984c78cafc8181b2d430e7fd5b9baf57eba95 ./src/api/integrations.rs
7658111f7d125db55a6e23f88157339b110b2cd1ed13ceabd8f860b42dba5101 ./src/api/middleware.rs 7658111f7d125db55a6e23f88157339b110b2cd1ed13ceabd8f860b42dba5101 ./src/api/middleware.rs
45e34a447ff384d99a3c42c489ca9fa75849c74d4d246caa91c63d927de17396 ./src/api/openapi.rs 45e34a447ff384d99a3c42c489ca9fa75849c74d4d246caa91c63d927de17396 ./src/api/openapi.rs
b777e7fbeccef699e11ffd16bb18633843c99afecacce2221280e54b09f6d2da ./src/api/public_settings.rs b777e7fbeccef699e11ffd16bb18633843c99afecacce2221280e54b09f6d2da ./src/api/public_settings.rs
5b29f2e5aa2c422c5829ef89c7b59cf753883aa7f60661c95ab0f95cf676fa3f ./src/api/schedules.rs 5b29f2e5aa2c422c5829ef89c7b59cf753883aa7f60661c95ab0f95cf676fa3f ./src/api/schedules.rs
6acf6417b077a0204a815e255c667928476014ce5f94c1559c9d4268c00342d5 ./src/api/settings.rs fd08162d0f9287700196028ad0ca2aec7c242238056b0a228ca5bdcb319ece4e ./src/api/settings.rs
0de92f241b5da2b7f13aaf9c24ef76eba8d26f8e27d87c9312bd644ab13d31bd ./src/api/system.rs bb57f4b28df75ba350c9706719650fff3a689afee0cb6979c1fcae79396ac0cf ./src/api/system.rs
94d3845def693aafe15fa6e8b390881c46c3c236b62f247ddfdc055d9226ed22 ./src/api/websocket.rs 94d3845def693aafe15fa6e8b390881c46c3c236b62f247ddfdc055d9226ed22 ./src/api/websocket.rs
ddd1bfc8625b66f6e5f3d24b3122ac86c25f89b0af527e3dfce9102551a8f142 ./src/api/zones.rs ddd1bfc8625b66f6e5f3d24b3122ac86c25f89b0af527e3dfce9102551a8f142 ./src/api/zones.rs
7b15febe046c79917a22664f50d9063fd6d0968521544ef31b854424f73b4408 ./src/config.rs 0b7d2ace2e8cb1dd0cb59aa6ca3e80cec14078e93530fd7b58081798585cd833 ./src/config.rs
f8394e0daca284070ac9ae0844d6b04fed7c30386f2d1ec409ecf07880bb0e6a ./src/db.rs de0ce7807aebe4c0e325fbef01855e7fc1cfd117529f53f04a416fda3997673a ./src/db.rs
fdc9d019097becc5c36a0560caa1619aa35d802c0d208902b3bfecbe1643e3e8 ./src/db/climate.rs 87f1614a80addde69b619421187526d7f86f893c0ac18998854f11675d6e7cca ./src/db/climate.rs
e5d2027d40a99e61c2c572499af617a92de9e554f8916f3ea6f6731d1d6b09b4 ./src/db/configuration.rs a8fe3636ac79df191e2f2c9d8c1eec1e5181e048ef8c9036cfa18f84aef1a462 ./src/db/configuration.rs
46213497ae26b2f799364a2e64e41df26c93a39eb4e7167b59c1e42b525f61f7 ./src/db/core_devices.rs 5143803ab470f287a587171f1aab96f60653e84cade523cfe104220079727ffc ./src/db/core_devices.rs
4c20289c08b0cb9c3c4324d9dbc6a47c7dd953aff6fd1746f04b6024043d3bc5 ./src/db/device_history.rs 4c20289c08b0cb9c3c4324d9dbc6a47c7dd953aff6fd1746f04b6024043d3bc5 ./src/db/device_history.rs
f99de2230082f17eafaf03f5361bb67419205a23d64f22480c996f9c32c55ac5 ./src/db/energy_history.rs 7bc79d9d33726fbdd33b485c8c2e839cedbb8fe29fee872611da978ed149d6a0 ./src/db/energy_history.rs
7e14cf46e6750e913f2120ccb0a75ad6884194ba973bf85fff9c05ecd919b5f0 ./src/db/events_tokens.rs 96aac6510ddda8c18fd7e29802a937b96e8a74badf504980846c80cd7933a4c6 ./src/db/events_tokens.rs
bb5048ee60053f39ce3c1203dc1f24c7047310538d2357f105a052c016d21ec4 ./src/db/flows.rs bb5048ee60053f39ce3c1203dc1f24c7047310538d2357f105a052c016d21ec4 ./src/db/flows.rs
5f4b567c6bc866d0674b3b0c66f4aeccf561774623f575b872ff25bf015b91c2 ./src/db/ha_history.rs 5f4b567c6bc866d0674b3b0c66f4aeccf561774623f575b872ff25bf015b91c2 ./src/db/ha_history.rs
7840c89515d08a23050e2634dd91bcdbf7aecc0a8a80bf5002f3eb660ca46f20 ./src/db/network_history.rs 4fc007c08304e96818ad1d78997a5b2458e31aa9c48ad68b5be2a007533924bb ./src/db/network_history.rs
d6b9b70a04c774173c97f7426487d5e2932d7713e12ae3fb7f64cf2b4e0b3c10 ./src/db/schedules_automations.rs d6b9b70a04c774173c97f7426487d5e2932d7713e12ae3fb7f64cf2b4e0b3c10 ./src/db/schedules_automations.rs
8b312e258f079d844466ad516134cdd065a017be78be41214b4239fc0f9e5b36 ./src/db/tests.rs 28ee42d08147b0f154af818065d7d014d74ee8ddd59fdef98115f5c5368056cc ./src/db/tests.rs
f90fbb14f29a20b799b3856bdaa22812a9f0d9dfa46693f1341c160dccda8a97 ./src/db/zone_history.rs f90fbb14f29a20b799b3856bdaa22812a9f0d9dfa46693f1341c160dccda8a97 ./src/db/zone_history.rs
4ed54df6321496a6afabf9443beb9fa8d3434c510aa01dade318bbaa44f0f0ac ./src/engine.rs 75f4f139cb268b2dfda73966b52624735380724e73b99930bffe36328620f111 ./src/engine.rs
ed5c9a0c61c6e597acb33f58593ebe02b562ddc68fa61271e1c1cd436c1003d3 ./src/engine/automations.rs ed5c9a0c61c6e597acb33f58593ebe02b562ddc68fa61271e1c1cd436c1003d3 ./src/engine/automations.rs
0bf7fe7d28b4749e53ec25d71effbb78bc8f70c716714f838a4a2d8f20627d63 ./src/engine/commands.rs 1dccb8779f87dfdbd4ccda3f02ec51273e7be2e34e85166861668685528eca79 ./src/engine/commands.rs
f7de7b9b756bef9d6cffbc6ffdb7504b3121476f188e6ff5c8077d5fc54be811 ./src/engine/connectivity.rs 357ef92c3cc42b243af4da31ac52bb360d7444ae423f58d7a13bdee79bf0a748 ./src/engine/connectivity.rs
925135985f640ba9ae0a6109d908187b659341a91451e91d27e533adfd610b76 ./src/engine/control_plan.rs ad53cd7be24deb759e03cbc6472522b69ebd36d8fac35398a9b19c899d3b17f2 ./src/engine/control_plan.rs
7b48a33343d519a2ff2d81e9d757eb56a0c1beacfc0b7b8ef61f49c2ad363f9a ./src/engine/deadlines.rs 7b48a33343d519a2ff2d81e9d757eb56a0c1beacfc0b7b8ef61f49c2ad363f9a ./src/engine/deadlines.rs
f5e3a8e85c622ea623360bdac87a098785748d8e4f4ba19ec2aa18c279b61c03 ./src/engine/energy.rs a3282d1e22fbdc89bed1d9e681e4865c9d8778f97805083c709fc62090a00d27 ./src/engine/energy.rs
e350aa63f3bb14ca887eaaa7555edbeae00fb584cbcaf552cc972f0c8918f5d8 ./src/engine/groups.rs e350aa63f3bb14ca887eaaa7555edbeae00fb584cbcaf552cc972f0c8918f5d8 ./src/engine/groups.rs
5d8b3c9f2243848502bba5c1e4940f8bf8c5ae4bffd891b43e2e9b4e7dc55b0c ./src/engine/history.rs 4ec11426ef860811348f2bf1b458bc5713fe8a7794fb4c8045dd637652e8e696 ./src/engine/history.rs
41b7045877771b7c352328998cb45ce2433e7f9631fad3eb1a882ab4897dc31f ./src/engine/local_thermostat.rs 41b7045877771b7c352328998cb45ce2433e7f9631fad3eb1a882ab4897dc31f ./src/engine/local_thermostat.rs
99527adab9fea1caede9ebb24faa6a63d5cef4810c64c06306b571cf5cbbb0da ./src/engine/ownership.rs f684e6755d023478882d1689833f307f3b4cd2c66abf45f8af8315cee94d36ef ./src/engine/ownership.rs
1a29b7fd8de77f0a5e5964ca05e41cc5ea78855c1ab961750d40461faec305e5 ./src/engine/polling.rs 56d64614d8ca85cec7098b1f5d2963ecdcbfb7d0a3d4f22e4424a653895b5a16 ./src/engine/polling.rs
d48ae1799e045fb212e73e630a5d26ed3c5e3359a3dac3ebd5638532efdd4285 ./src/engine/runtime.rs c73691c3637465ab0f79aecf7f5428d0317799a8ab5329ed564cf57963bbe86e ./src/engine/runtime.rs
c1e8de5392681ba1255e3e2d871a6ee860fb8ca987a0a8f973d9ded3977a68fb ./src/engine/schedules.rs 48a496c3935f886a90b90461c38a7aa31ebc40c6482d6feaeb2be47bc2387625 ./src/engine/schedules.rs
b0189f28d0f337dfd3ce0934da7a565e4a5535afc1c242214c26ed584b3b7840 ./src/engine/targets.rs b0189f28d0f337dfd3ce0934da7a565e4a5535afc1c242214c26ed584b3b7840 ./src/engine/targets.rs
682c8b3956c657b655746cbd19ee97ee2f4cf953bc9de67d6d70a1b14f3dd746 ./src/engine/temperature.rs 682c8b3956c657b655746cbd19ee97ee2f4cf953bc9de67d6d70a1b14f3dd746 ./src/engine/temperature.rs
833b72907375e75c3f42b3921ad70a8e298f5a93743bf0d0c19059aa272462d1 ./src/engine/temporary_thermostat.rs 833b72907375e75c3f42b3921ad70a8e298f5a93743bf0d0c19059aa272462d1 ./src/engine/temporary_thermostat.rs
3f77350e4c54e8e497d9da8ba0545124f5a8810908c9a1471d1eaa63c4441b0c ./src/engine/tests.rs 2e7349862beb5f844aea0f1c4cd96ab6e53d3500559d7b2562bd93da595d7c41 ./src/engine/tests.rs
c89ff36c6a67bf4fbf68c43e18aa0a556de2dc69263fd79db376363afc741a4f ./src/engine/zone_actions.rs c89ff36c6a67bf4fbf68c43e18aa0a556de2dc69263fd79db376363afc741a4f ./src/engine/zone_actions.rs
3a7fada1f6dd9ebab4026b353f41b21d8ca19342b02538c711dbb2dfb599cbe7 ./src/engine/zone_control.rs fb169bcc20f95abe9892367df6dd3a683584bdbc721792d6ffb9311b81ca30d9 ./src/engine/zone_control.rs
bfac95cec87f0df6ce562153cd3cfe6731359d703e6c592d2c2f7ba01ea62d99 ./src/error.rs bfac95cec87f0df6ce562153cd3cfe6731359d703e6c592d2c2f7ba01ea62d99 ./src/error.rs
a1bd54026f9c7e9f84da1a730136db81095e8ba1ceeb2596fc01479def76df83 ./src/home_assistant.rs c55d32f288fe95869fc288d47817b8e289fbe284be4219e81b1e7493ff5e3a57 ./src/home_assistant.rs
e78ca9aff23362971eb6450534ea21bdc15150456c378ef9b226cb93a8dbc612 ./src/influxdb.rs bcd14e4d1af3ca580f3b95bcd86164c40f762511e1b8e2501cb8a598a6d8adf4 ./src/influxdb.rs
da4068295f37c23222bb13bebac022d88ba7729604a49ad25438c55e45458a49 ./src/influxdb/codec.rs da4068295f37c23222bb13bebac022d88ba7729604a49ad25438c55e45458a49 ./src/influxdb/codec.rs
b96624fa34b218a6d5f028ba09579633f6f8a04c109f0e94801e8d89ceeeb4b5 ./src/influxdb/query.rs 2d36405912646b30b1a1a37a7a4e18c2104ab22e865b593316a609dda884aec2 ./src/influxdb/query.rs
8089b8eca47419bb6b3ee02560e5cc39c00479164bc76d11992679b2cef3258e ./src/influxdb/write.rs 67e42a92295480d2f1d364958f184654cfb5d4b77e7e1df47f588bdec18868a0 ./src/influxdb/write.rs
6479dd1e5d7f8eb2ffb945ca958124107c5585c0abdfb34555367db0c21145da ./src/main.rs 1982ce54737a8780d1404b105d53db59cd5bee3d1c426f9a881d26cef7059bf5 ./src/main.rs
23688a5cb68efded89471ce959328d27adff370d451bd9f378a283ab51437e76 ./src/models.rs 23688a5cb68efded89471ce959328d27adff370d451bd9f378a283ab51437e76 ./src/models.rs
920e028d96a14cca9f74a964bb296e68a109cfead314d3164c180201e606d151 ./src/models/automation.rs 920e028d96a14cca9f74a964bb296e68a109cfead314d3164c180201e606d151 ./src/models/automation.rs
fc5f2802a073a175a8985e56bcb87eb1ff481226d5b4eeabad73dbba1839fc45 ./src/models/control_plan.rs fc5f2802a073a175a8985e56bcb87eb1ff481226d5b4eeabad73dbba1839fc45 ./src/models/control_plan.rs
06357b352ff6494d83e72796b869573a8b8f36b835985a5900ac571c922b8da9 ./src/models/defaults.rs 06357b352ff6494d83e72796b869573a8b8f36b835985a5900ac571c922b8da9 ./src/models/defaults.rs
5e212a072bf74ef32a186b172404844b02a92363d216167f814056e1f2a9a7a8 ./src/models/device.rs f3439a154fad8a0111db26918160901dd04fe47dd56d529490e75deaf0a78b43 ./src/models/device.rs
aeab5b5acf35d38a54fd1d82e964fe56635ade69c0149ad423df317095d01555 ./src/models/flow.rs aeab5b5acf35d38a54fd1d82e964fe56635ade69c0149ad423df317095d01555 ./src/models/flow.rs
ba5d359e4d6c12d9f62c1b28bdfb40d312737930df5421814f5310f7183e96c1 ./src/models/history.rs 6e44df6bde2f73c282095c80ac17287e5bef77d9fc1d344e440d863dae5f5031 ./src/models/history.rs
1fee89f939018bf1f3686c15f25434f01ab96b3239164108db7f348a1f1f8d82 ./src/models/integrations.rs 1fee89f939018bf1f3686c15f25434f01ab96b3239164108db7f348a1f1f8d82 ./src/models/integrations.rs
f1359b68f1d041cd441f80429e77893eeb6f6194613b58fd3e7a212fb511db3e ./src/models/runtime.rs f1359b68f1d041cd441f80429e77893eeb6f6194613b58fd3e7a212fb511db3e ./src/models/runtime.rs
e3dfdd6dba63f6febd0b64da413b2fe7ca258a54abc2fdd6f2982c27425abfd6 ./src/models/settings_api.rs 41b85cff6fd234c8d6ea007b2c3c22df5fee5107dcd57c64dcc94a356ff70c12 ./src/models/settings_api.rs
c4de6101de85d9d7e6814b2504b87d9216561e3d9e274054f081f505fb152140 ./src/models/temporary_thermostat.rs c4de6101de85d9d7e6814b2504b87d9216561e3d9e274054f081f505fb152140 ./src/models/temporary_thermostat.rs
bc2818ce8d0dae1edfb1b98e5d1c00b193f03eeaa28e343d3532f6023288738c ./src/models/zone.rs bc2818ce8d0dae1edfb1b98e5d1c00b193f03eeaa28e343d3532f6023288738c ./src/models/zone.rs
62e696df8b86835df7ad127f03c257a78580769dc82bc66daa5041f71e8440a5 ./src/notifications.rs 4c1f676eaf0b6da45b4d4b090ae3ac862fb97c1f9e53284a1640767ae9591d8f ./src/notifications.rs
cca742bb2b500eb9269f69385fcf88053cd08aabf6a98b7669b8a7a2fd67df13 ./src/protocol/crypto.rs cca742bb2b500eb9269f69385fcf88053cd08aabf6a98b7669b8a7a2fd67df13 ./src/protocol/crypto.rs
cd0612021f8ed6c9e0823d63883c0790db09aa485583f3f3fc9f2579f2c28ea3 ./src/protocol/gree.rs cd0612021f8ed6c9e0823d63883c0790db09aa485583f3f3fc9f2579f2c28ea3 ./src/protocol/gree.rs
7907c33e6396c7e0d61705b735b21c84d6ed784012fb61022b872c6745540f1d ./src/protocol/gree/binding.rs 7907c33e6396c7e0d61705b735b21c84d6ed784012fb61022b872c6745540f1d ./src/protocol/gree/binding.rs
d71771855d5ac90fae4994debf53ed09472b216c17bb2155d80aa2de13c57e18 ./src/protocol/gree/commands.rs fcf5e67539c419f3e7e4533d49f41f963fa7779f099bd33c1b210fac32a78949 ./src/protocol/gree/commands.rs
4e419ba3b86ca9d154e8c04445cf005965175c54041093b2daad3666d4d852bd ./src/protocol/gree/core.rs 4e419ba3b86ca9d154e8c04445cf005965175c54041093b2daad3666d4d852bd ./src/protocol/gree/core.rs
05fa2e4ec133a61c23a3f4ab2ce792bbfab93feaea9f0ce0d4ccc67fc479833d ./src/protocol/gree/discovery.rs 05fa2e4ec133a61c23a3f4ab2ce792bbfab93feaea9f0ce0d4ccc67fc479833d ./src/protocol/gree/discovery.rs
0ab094f609edb99715402d8eeaa7e38cbfd2d271036aec787dcbb3be3e005720 ./src/protocol/gree/merge.rs 0ab094f609edb99715402d8eeaa7e38cbfd2d271036aec787dcbb3be3e005720 ./src/protocol/gree/merge.rs
c09c931fa8d512a8883fb14f3142be694af3c51290e023d25c7c972e156e4bd2 ./src/protocol/gree/network.rs ff33db623f8f1bf30015b4a00e1f247a99de713da3d297c672e42c0795ec845c ./src/protocol/gree/network.rs
0a901ff258a74072af3812c5417196da1a9b8daa8a2eff30c96d038558601739 ./src/protocol/gree/polling.rs 0a901ff258a74072af3812c5417196da1a9b8daa8a2eff30c96d038558601739 ./src/protocol/gree/polling.rs
50152bcad4a843ae04000b991a2365abb302fc1583ea69f09320e25740f3c38c ./src/protocol/gree/tests.rs e0555fac67d1dd0129ecfea6d1fd2fbeb1a73ac89ad95d1b74a7094e50e29ae4 ./src/protocol/gree/tests.rs
3c93e5b254a2a40a07854532cb5fc07e493ad65d9ba2cf28093cff4a4a4e451c ./src/protocol/gree/transport.rs 3c93e5b254a2a40a07854532cb5fc07e493ad65d9ba2cf28093cff4a4a4e451c ./src/protocol/gree/transport.rs
49dcca1e4a4ad858b312386e3b729f36da691916d8fe62ad682ddb86d40695b7 ./src/protocol/gree_cloud.rs 297983059c915057869fbb4f6bcfe752f3d5bd1e93c3a2651a1bf883c739ed29 ./src/protocol/gree_cloud.rs
d406a2c5a49fe059f3f3a00ea534a32f9bdd972c09cc051cbfc4317f7c91b38c ./src/protocol/gree_cloud_mqtt.rs b4b1bad9cad889dd69681b2afc3e6a09ef19e19b2a1f2900fd417c4b1f55ec2c ./src/protocol/gree_cloud_mqtt.rs
6258ee3888a5f9a4f8bb6cfbbac97f2621de07f9da6bcb2fa596d452f47f2e49 ./src/protocol/mod.rs 6258ee3888a5f9a4f8bb6cfbbac97f2621de07f9da6bcb2fa596d452f47f2e49 ./src/protocol/mod.rs
823da603035619894d8a0252c6a9782c44fdf8d539f09a0a0c120dc8e4b8e4f2 ./src/provider.rs a78d73571fbd983660449fcbd7857064a1325ca12adc07e61c2f50a3208adf37 ./src/provider.rs
830621fa75265da7288b8135704c0ce27e0687f87d55abf3cdbe40a6e2f3d2b1 ./src/queries.rs 830621fa75265da7288b8135704c0ce27e0687f87d55abf3cdbe40a6e2f3d2b1 ./src/queries.rs
7fac65a9dcbcecee09816f5447c7792dc94f736b1efb66ac85629db75f6280be ./src/queries/device_history.rs 7fac65a9dcbcecee09816f5447c7792dc94f736b1efb66ac85629db75f6280be ./src/queries/device_history.rs
580917b91441fe9b3276cf0cfc31536ba7954b6a86f4080347b93f9367a5b1ac ./src/queries/entities.rs 580917b91441fe9b3276cf0cfc31536ba7954b6a86f4080347b93f9367a5b1ac ./src/queries/entities.rs
@@ -197,32 +196,32 @@ d406a2c5a49fe059f3f3a00ea534a32f9bdd972c09cc051cbfc4317f7c91b38c ./src/protocol
7e12e8704faa7ac15a312bcc1d714ee1c6f27c1dce0972d4ccd6e5a40c69c884 ./src/queries/maintenance.rs 7e12e8704faa7ac15a312bcc1d714ee1c6f27c1dce0972d4ccd6e5a40c69c884 ./src/queries/maintenance.rs
7da8b73a1c8c30e2a12071062d36f5393938fde0b5ffebbe4af1891da40f3233 ./src/queries/schema.rs 7da8b73a1c8c30e2a12071062d36f5393938fde0b5ffebbe4af1891da40f3233 ./src/queries/schema.rs
f13f1be3d5789539eba3fc1b59c14835f8c681eca7634e118fa3763a53aeee2b ./src/queries/zone_history.rs f13f1be3d5789539eba3fc1b59c14835f8c681eca7634e118fa3763a53aeee2b ./src/queries/zone_history.rs
7e7037a059b585c5709367b9431a225789e7039796e3ed19fc132776ac234099 ./src/state.rs 8c34b70fb5f80e3249c2e0c63686e60ce1697cf44a7e6f11b3ad6f689ce17792 ./src/state.rs
b92a6cb158b494fe145b43c7641e65f6fafff47201d7d76edbec2cfd8b94835c ./systemd/gree-controller.service b92a6cb158b494fe145b43c7641e65f6fafff47201d7d76edbec2cfd8b94835c ./systemd/gree-controller.service
544a31cb2e5374227026afd6abd0d910381f3fca1638154ebd74e3c3d96a35d1 ./web/404.html 544a31cb2e5374227026afd6abd0d910381f3fca1638154ebd74e3c3d96a35d1 ./web/404.html
fbd8595c339d245453427c7cb9bca937bd2f60852ba25a15956295abda26878c ./web/custom-chart.html 8b8b5e9511a833d6dc0eb902048e8f48f26395faf55f19962c3e6f06ae0fb4e4 ./web/css/styles.css
e13dc8c936b39c9d1f808d6e0e2ed1e9171c4da62ce6ba526220e8aaf478f571 ./web/custom-chart.js 50e0ffd652fb4ea8ea4ed79a8e54e838709448886e3f6004cdeb35ffa4e13206 ./web/custom-chart.html
e98bdd7204349cce1ec6f57283509697af0bbc72280622a6c3efa6fed242db4f ./web/favicon.svg e98bdd7204349cce1ec6f57283509697af0bbc72280622a6c3efa6fed242db4f ./web/favicon.svg
217a9f99be2356bb15d5fa177bd08c7527eaeff827e572557464dd4e1dfa8fe4 ./web/index.html 784e2c28effb9872b582298292d3e2a6b8dd6140207342452193894d210e2f45 ./web/index.html
9ba3f6fa05b72aa989139b2a909982571b2a02055052e4c40104f1e81a9aa7eb ./web/js/README.md 9ba3f6fa05b72aa989139b2a909982571b2a02055052e4c40104f1e81a9aa7eb ./web/js-dynamic/README.md
27eff2821c40395369be15e197fa830535d2327fcb27c93a0e8e65a1c62de3ad ./web/js/bootstrap.js 27eff2821c40395369be15e197fa830535d2327fcb27c93a0e8e65a1c62de3ad ./web/js-dynamic/bootstrap.js
1612896d22fd61747175cd54174a283002ac9911f4164e593c1079f767790cda ./web/js/charts.js 5d57253a013fd1cb79e4b8701d4be6e394e3f2acbce73dfcd726a86e5878f1a1 ./web/js-dynamic/charts.js
5c46d271550f3db38ecbfc09e3a620f6f0e6a907a3d6d56feae9b08e894df196 ./web/js/core.js 1c7a779d6b793413e6f92e6bea910d035ffa22356aae965923a14ed84ab6bd8d ./web/js-dynamic/core.js
752b3c3b59558b42d867d4a15329238f0213f354b76f7011c10e31b4e6dbb311 ./web/js/dashboard.js 752b3c3b59558b42d867d4a15329238f0213f354b76f7011c10e31b4e6dbb311 ./web/js-dynamic/dashboard.js
0f975542ae1d9bd7216123dd3718be6e9a25dbe643eb899c67024d293940fae5 ./web/js/entities.js 0f975542ae1d9bd7216123dd3718be6e9a25dbe643eb899c67024d293940fae5 ./web/js-dynamic/entities.js
aaa874637d4a63876aa1248f995190316de80031b4bbcac1e8943238d2bafb53 ./web/js/events.js 7a4d02bb1ebfa3a5962c7a0b567338d460b99f30ec582fda3a7fec9f6fe54840 ./web/js-dynamic/events.js
473511ca11a0412a794b20430c077843072e9abe0d2510552ac83b226c381453 ./web/js/flows.js 1865ef1ed93f04fe323338ac44ef06c9b19ad6ec222d47085ce51cb4c03fe13d ./web/js-dynamic/flows.js
a3d9f77a40a2574a668855125c781c067289944c6f93179ff23e3dae150e7dac ./web/js/forms.js a3d9f77a40a2574a668855125c781c067289944c6f93179ff23e3dae150e7dac ./web/js-dynamic/forms.js
edde2969c9c1376f4df37224daa291256ebbbe713903d98e26b6aaa6ca467911 ./web/js/history.js 92d013bb5d64f64aa8c55380704540a5f72732bde7ccd16554965e401daf50ea ./web/js-dynamic/history.js
511c4e7cc4c48fbbdab64558a9d1c27e6759c9b6dca255d11f301b9708e955c7 ./web/js/main.js 511c4e7cc4c48fbbdab64558a9d1c27e6759c9b6dca255d11f301b9708e955c7 ./web/js-dynamic/main.js
6bb9386707dcf601111fea15a1a366d8798313b973ca1624878618f4c3095add ./web/js/navigation.js 86031d4f7f7d1b46412c8fbb09ff1ad1b2b4cd84eb949bc749b27663f1dc27fa ./web/js-dynamic/navigation.js
799613c6d465cb2f92ecb57cb082d0c33729a03d88ab4ca7fa12a3cbd7e38e75 ./web/js/realtime.js 799613c6d465cb2f92ecb57cb082d0c33729a03d88ab4ca7fa12a3cbd7e38e75 ./web/js-dynamic/realtime.js
9546ca5c5566994aff3c33d92295be46b65d51d874681764abe77470dc9c9c73 ./web/js/router.js 1b667f0a8bce824b27f56a8d2f44ff8feef9a011289591dc629ee288db70d137 ./web/js-dynamic/router.js
c2710fd9c1f2b31878c1e25ceb02d74ae30456c1bbb400e69fcf7619ecc72bc2 ./web/js/select-ui.js c2710fd9c1f2b31878c1e25ceb02d74ae30456c1bbb400e69fcf7619ecc72bc2 ./web/js-dynamic/select-ui.js
f7245f0e6cb5011196704f201675ebc6c19d902b8253ffcdbf9ead46cb1c6f86 ./web/js/settings-ui.js f7245f0e6cb5011196704f201675ebc6c19d902b8253ffcdbf9ead46cb1c6f86 ./web/js-dynamic/settings-ui.js
335317bd50dd411f8c5e6d00b22d30e53172484626f2ad9901e32cab469724ac ./web/js/settings.js 672e404470bc6331aa6d658523b221659ec64222af9cc9c17ced978f725790da ./web/js-dynamic/settings.js
3815fea7ae1694568c2f249bc8460e0563ced6bde89214f74f21b41fe8cad9a6 ./web/lang-init.js d5045e9a28ac73a34d91d4ff23314b54bc93c958b14f597aacbb087b261152f3 ./web/js/custom-chart.js
6ae536b18db756bf5eda9adb1b8a550d5a02bfc80f0b7da27fa146039f22e884 ./web/js/lang-init.js
fefdf12cbbb0a5d8a2cb89275cae28455aa42aaf179542305cc8f5e0f4825898 ./web/js/sw.js
d505d793ce7cc9485b45b78bba1c0d51887adc7451ab59a42702946e5b991382 ./web/js/theme-init.js
6b653e4fe2d4db4ffae6ff8f39b8ce57a10a990dc0000ee4aae51d949afadd52 ./web/manifest.webmanifest 6b653e4fe2d4db4ffae6ff8f39b8ce57a10a990dc0000ee4aae51d949afadd52 ./web/manifest.webmanifest
0efe0cdff18beff496017a984f6d189ea6463e14b517f2f5c5fc46923579ab15 ./web/styles.css
fefdf12cbbb0a5d8a2cb89275cae28455aa42aaf179542305cc8f5e0f4825898 ./web/sw.js
d505d793ce7cc9485b45b78bba1c0d51887adc7451ab59a42702946e5b991382 ./web/theme-init.js
+16 -11
View File
@@ -53,7 +53,7 @@ const APP_JS_MODULES: &[&str] = &[
]; ];
fn bundle_app_js(web_dir: &Path) -> String { fn bundle_app_js(web_dir: &Path) -> String {
let js_dir = web_dir.join("js"); let js_dir = web_dir.join("js-dynamic");
println!("cargo:rerun-if-changed={}", js_dir.display()); println!("cargo:rerun-if-changed={}", js_dir.display());
let mut bundle = String::new(); let mut bundle = String::new();
@@ -78,12 +78,12 @@ fn main() {
println!("cargo:rerun-if-changed={}", lang_dir.display()); println!("cargo:rerun-if-changed={}", lang_dir.display());
println!("cargo:rerun-if-changed={}", preset_dir.display()); println!("cargo:rerun-if-changed={}", preset_dir.display());
let theme_init_path = web_dir.join("theme-init.js"); let theme_init_path = web_dir.join("js/theme-init.js");
let lang_init_path = web_dir.join("lang-init.js"); let lang_init_path = web_dir.join("js/lang-init.js");
let styles_path = web_dir.join("styles.css"); let styles_path = web_dir.join("css/styles.css");
let index_path = web_dir.join("index.html"); let index_path = web_dir.join("index.html");
let not_found_path = web_dir.join("404.html"); let not_found_path = web_dir.join("404.html");
let sw_path = web_dir.join("sw.js"); let sw_path = web_dir.join("js/sw.js");
let manifest_path = web_dir.join("manifest.webmanifest"); let manifest_path = web_dir.join("manifest.webmanifest");
let favicon_path = web_dir.join("favicon.svg"); let favicon_path = web_dir.join("favicon.svg");
for path in [ for path in [
@@ -143,9 +143,10 @@ fn main() {
panic!("no language files found in {}", lang_dir.display()); panic!("no language files found in {}", lang_dir.display());
} }
let default_language = "en";
let mut assets = Vec::new(); let mut assets = Vec::new();
let mut manifest_languages = Vec::new(); let mut manifest_languages = Vec::new();
let mut has_english = false; let mut has_default_language = false;
for path in files { for path in files {
println!("cargo:rerun-if-changed={}", path.display()); println!("cargo:rerun-if-changed={}", path.display());
@@ -193,8 +194,8 @@ fn main() {
if !document.get("translations").is_some_and(Value::is_object) { if !document.get("translations").is_some_and(Value::is_object) {
panic!("{filename}: translations must be a JSON object"); panic!("{filename}: translations must be a JSON object");
} }
if code == "en" { if code == default_language {
has_english = true; has_default_language = true;
} }
let language_hash = content_hash(source.as_bytes()); let language_hash = content_hash(source.as_bytes());
@@ -208,8 +209,8 @@ fn main() {
assets.push((code.to_owned(), path)); assets.push((code.to_owned(), path));
} }
if !has_english { if !has_default_language {
panic!("lang/en.json is required as the default fallback language"); panic!("lang/{default_language}.json is required as the default fallback language");
} }
let mut preset_files: Vec<PathBuf> = fs::read_dir(&preset_dir) let mut preset_files: Vec<PathBuf> = fs::read_dir(&preset_dir)
@@ -286,7 +287,7 @@ fn main() {
} }
let manifest_json = serde_json::to_string(&json!({ let manifest_json = serde_json::to_string(&json!({
"default": "en", "default": default_language,
"languages": manifest_languages "languages": manifest_languages
})) }))
.expect("serialize language manifest"); .expect("serialize language manifest");
@@ -323,6 +324,10 @@ fn main() {
"pub const LANGUAGE_MANIFEST_JSON: &str = {:?};\n", "pub const LANGUAGE_MANIFEST_JSON: &str = {:?};\n",
manifest_json manifest_json
)); ));
generated.push_str(&format!(
"pub const DEFAULT_LANGUAGE_CODE: &str = {:?};\n",
default_language
));
generated.push_str("pub const LANGUAGE_ASSETS: &[(&str, &str)] = &[\n"); generated.push_str("pub const LANGUAGE_ASSETS: &[(&str, &str)] = &[\n");
for (code, path) in assets { for (code, path) in assets {
generated.push_str(&format!( generated.push_str(&format!(
+18 -3
View File
@@ -1,6 +1,6 @@
# GREE Controller API reference # GREE Controller API reference
HTTP and WebSocket API for GREE Controller **0.14.20**. HTTP and WebSocket API for GREE Controller **0.14.21**.
[← Main documentation](../README.md) [← Main documentation](../README.md)
@@ -196,6 +196,7 @@ Common statuses:
| GET/PUT | `/api/settings/gree` | GREE controller, polling, discovery and compressor settings. | | GET/PUT | `/api/settings/gree` | GREE controller, polling, discovery and compressor settings. |
| GET/PUT | `/api/settings/history` | Metric/event retention and compaction settings. | | GET/PUT | `/api/settings/history` | Metric/event retention and compaction settings. |
| GET/PUT | `/api/settings/influxdb` | InfluxDB history settings. | | GET/PUT | `/api/settings/influxdb` | InfluxDB history settings. |
| POST | `/api/integrations/influxdb/test` | Test the supplied InfluxDB connection settings without saving them. |
| GET/PUT | `/api/settings/notifications` | Notification provider and alert settings. | | GET/PUT | `/api/settings/notifications` | Notification provider and alert settings. |
| GET/PUT | `/api/settings/night` | Night mode settings. | | GET/PUT | `/api/settings/night` | Night mode settings. |
| GET/PUT | `/api/settings/home-assistant` | Home Assistant, aliases, shared Flow inputs and outdoor assist. | | GET/PUT | `/api/settings/home-assistant` | Home Assistant, aliases, shared Flow inputs and outdoor assist. |
@@ -239,7 +240,7 @@ Response:
{ {
"status": "ok", "status": "ok",
"name": "gree-controller", "name": "gree-controller",
"version": "0.14.20", "version": "0.14.21",
"uptime_seconds": 1234, "uptime_seconds": 1234,
"control_ready": true, "control_ready": true,
"time": "2026-08-30T06:54:00Z" "time": "2026-08-30T06:54:00Z"
@@ -302,7 +303,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.20", "version": "0.14.21",
"uptime_seconds": 1234, "uptime_seconds": 1234,
"auth_required": false, "auth_required": false,
"control_ready": true, "control_ready": true,
@@ -1147,6 +1148,20 @@ Retention values are clamped to `1..3650` days. Updating this section immediatel
`PUT` uses the same non-secret fields plus optional `password` and `token`. Omitting either field (or sending `null`) preserves the stored secret. Sending an explicit empty string clears it. `history_threshold_days` is clamped to `1..3650`; the complete InfluxDB configuration is validated before persistence. `PUT` uses the same non-secret fields plus optional `password` and `token`. Omitting either field (or sending `null`) preserves the stored secret. Sending an explicit empty string clears it. `history_threshold_days` is clamped to `1..3650`; the complete InfluxDB configuration is validated before persistence.
### `POST /api/integrations/influxdb/test`
Accepts the same body as the InfluxDB settings update, but does not persist it. Blank/omitted secrets reuse the currently stored password/token. A successful test returns:
```json
{
"ok": true,
"version": "2",
"response_time_ms": 18
}
```
InfluxDB 1.x tests a lightweight query against the configured database; InfluxDB 2.x tests a lightweight Flux query against the configured organization/bucket.
### `/api/settings/notifications` ### `/api/settings/notifications`
`GET` returns provider state without secret values: `GET` returns provider state without secret values:
+1 -1
View File
@@ -69,7 +69,7 @@ The editor ships 37 editable presets grouped into seven categories: Comfort, Ene
Shared Flow inputs show the Flows that reference them and link directly to those editors. Home Assistant-backed shared inputs can be tested live in the input modal. Shared inputs report the current raw value/attribute. Each Flow reference defines its own comparison without changing the shared source. Home Assistant temperature entities remain normal HA entities: room-temperature sensors are configured per zone, while outdoor temperature has one global `outdoor_entity_id` plus an optional per-zone `ha_outdoor_entity_id` override. Their aliases are shared with the rest of the UI, outdoor overrides are recorded in sensor metrics/history, and all configured entities are offered as suggestions in Home Assistant Flow blocks; selecting a zone sensor does not create a separate shared input. Shared Flow inputs show the Flows that reference them and link directly to those editors. Home Assistant-backed shared inputs can be tested live in the input modal. Shared inputs report the current raw value/attribute. Each Flow reference defines its own comparison without changing the shared source. Home Assistant temperature entities remain normal HA entities: room-temperature sensors are configured per zone, while outdoor temperature has one global `outdoor_entity_id` plus an optional per-zone `ha_outdoor_entity_id` override. Their aliases are shared with the rest of the UI, outdoor overrides are recorded in sensor metrics/history, and all configured entities are offered as suggestions in Home Assistant Flow blocks; selecting a zone sensor does not create a separate shared input.
Presets are data files, not JavaScript definitions. Every preset lives in `presets/<id>.json` and contains `id`, `category`, localized `name`, localized `description`, and the complete `flow` graph (`nodes` + `edges`). `build.rs` validates and embeds the directory, exposes `/presets/index.json` plus `/presets/<file>.json`, and the Flow editor loads that library dynamically. Device/zone/group placeholders such as `$zone1`, `$zone2`, `$device1` and `$group1` are resolved when a preset is applied. Adding a preset therefore does not require adding graph-building code to `web/js/flows.js`. Presets are data files, not JavaScript definitions. Every preset lives in `presets/<id>.json` and contains `id`, `category`, localized `name`, localized `description`, and the complete `flow` graph (`nodes` + `edges`). `build.rs` validates and embeds the directory, exposes `/presets/index.json` plus `/presets/<file>.json`, and the Flow editor loads that library dynamically. Device/zone/group placeholders such as `$zone1`, `$zone2`, `$device1` and `$group1` are resolved when a preset is applied. Adding a preset therefore does not require adding graph-building code to `web/js-dynamic/flows.js`.
The canvas supports multi-selection (Shift/Ctrl/Cmd), `Ctrl/Cmd+A`, Select all / Clear selection toolbar actions, and dragging the whole selected group while preserving relative positions. The canvas supports multi-selection (Shift/Ctrl/Cmd), `Ctrl/Cmd+A`, Select all / Clear selection toolbar actions, and dragging the whole selected group while preserving relative positions.
+73 -3
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -116,7 +116,7 @@ LABEL io.hass.version="${BUILD_VERSION}" \
org.opencontainers.image.title="GREE Controller" \ org.opencontainers.image.title="GREE Controller" \
org.opencontainers.image.version="${BUILD_VERSION}" \ org.opencontainers.image.version="${BUILD_VERSION}" \
org.opencontainers.image.architecture="${TARGETARCH}" org.opencontainers.image.architecture="${TARGETARCH}"
RUN apk add --no-cache ca-certificates tzdata jq iproute2 curl RUN apk add --no-cache bash ca-certificates tzdata jq iproute2 curl
COPY --from=builder-alpine /out/gree-controller /usr/local/bin/gree-controller COPY --from=builder-alpine /out/gree-controller /usr/local/bin/gree-controller
COPY ha-addon/run.sh /usr/local/bin/run.sh COPY ha-addon/run.sh /usr/local/bin/run.sh
RUN chmod 0755 /usr/local/bin/run.sh /usr/local/bin/gree-controller RUN chmod 0755 /usr/local/bin/run.sh /usr/local/bin/gree-controller
@@ -1,7 +1,7 @@
{ {
"domain": "gree_controller", "domain": "gree_controller",
"name": "GREE Controller", "name": "GREE Controller",
"version": "0.14.20", "version": "0.14.21",
"config_flow": true, "config_flow": true,
"integration_type": "hub", "integration_type": "hub",
"iot_class": "local_polling", "iot_class": "local_polling",
@@ -1,5 +1,9 @@
# Changelog # Changelog
## 0.14.21
- Code cleanup and small UX changes.
## 0.14.20 ## 0.14.20
- Installs the Rust target standard library in a cacheable layer before project sources are copied, so source-only changes do not redownload/reinstall it. - Installs the Rust target standard library in a cacheable layer before project sources are copied, so source-only changes do not redownload/reinstall it.
@@ -1,5 +1,5 @@
name: "GREE Controller" name: "GREE Controller"
version: "0.14.20" version: "0.14.21"
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/"
+29 -4
View File
@@ -82,7 +82,7 @@
"devices.modelFirmware": "Model / firmware", "devices.modelFirmware": "Model / firmware",
"devices.lastSeen": "Last response", "devices.lastSeen": "Last response",
"devices.communicationFailures": "Communication failures", "devices.communicationFailures": "Communication failures",
"devices.lastError": "Last error", "devices.lastError": "Error",
"devices.readStatus": "Read status", "devices.readStatus": "Read status",
"devices.technicalConfig": "Technical configuration", "devices.technicalConfig": "Technical configuration",
"devices.diagnostics": "Diagnostics", "devices.diagnostics": "Diagnostics",
@@ -1274,7 +1274,7 @@
"debug.emptyCloud": "No GREE Cloud API activity captured yet.", "debug.emptyCloud": "No GREE Cloud API activity captured yet.",
"debug.emptyMqtt": "No GREE Cloud MQTT activity captured yet.", "debug.emptyMqtt": "No GREE Cloud MQTT activity captured yet.",
"settings.cloudDevicesOnline": "Devices online", "settings.cloudDevicesOnline": "Devices online",
"settings.cloudResponseTime": "Last device response time", "settings.cloudResponseTime": "Device response time",
"settings.cloudLastDeviceResponse": "Last device response", "settings.cloudLastDeviceResponse": "Last device response",
"settings.cloudLastMqttMessage": "Last MQTT activity", "settings.cloudLastMqttMessage": "Last MQTT activity",
"settings.cloudConnectedSince": "MQTT connected since", "settings.cloudConnectedSince": "MQTT connected since",
@@ -1288,7 +1288,7 @@
"devices.cloudAuthenticationProblem": "GREE Cloud authentication error", "devices.cloudAuthenticationProblem": "GREE Cloud authentication error",
"devices.cloudTransportProblem": "GREE Cloud connection is unavailable", "devices.cloudTransportProblem": "GREE Cloud connection is unavailable",
"devices.cloudDeviceOffline": "GREE Cloud is connected, but the unit is not responding", "devices.cloudDeviceOffline": "GREE Cloud is connected, but the unit is not responding",
"settings.cloudRestResponseTime": "Last REST response time", "settings.cloudRestResponseTime": "REST response time",
"common.pending": "Pending", "common.pending": "Pending",
"devices.installations": "Split / multisplit installations", "devices.installations": "Split / multisplit installations",
"devices.installationsHint": "Group indoor units that share one outdoor unit. Energy and outdoor temperature can use one shared source.", "devices.installationsHint": "Group indoor units that share one outdoor unit. Energy and outdoor temperature can use one shared source.",
@@ -1358,6 +1358,31 @@
"flow.haEntitySearchLabel": "Search Home Assistant entities", "flow.haEntitySearchLabel": "Search Home Assistant entities",
"flow.haEntitySearchPlaceholder": "Type a name, entity_id or state…", "flow.haEntitySearchPlaceholder": "Type a name, entity_id or state…",
"flow.haEntitySelectedLabel": "Selected entity (entity_id)", "flow.haEntitySelectedLabel": "Selected entity (entity_id)",
"flow.haEntitySearchMatches": "{count} of {total} Home Assistant entities match" "flow.haEntitySearchMatches": "{count} of {total} Home Assistant entities match",
"settings.influxTest": "Test InfluxDB connection",
"settings.influxTesting": "Testing InfluxDB…",
"settings.influxTestSuccess": "Connection successful ({ms} ms)",
"publicChart.title": "Custom chart",
"publicChart.loading": "Loading…",
"publicChart.invalidLink": "Invalid chart link.",
"publicChart.loadFailed": "Unable to load chart: {error}",
"publicChart.rangeAria": "Chart range",
"publicChart.periodHint": "Period: {hours} h",
"publicChart.hoursShort": "{hours} h",
"publicChart.noData": "No data",
"publicChart.field.deviceIndoor": "Indoor temperature",
"publicChart.field.deviceOutdoor": "GREE outdoor temperature",
"publicChart.field.deviceTarget": "Device target",
"publicChart.field.sharedOutdoor": "Shared outdoor temperature",
"publicChart.field.zoneControl": "Control temperature",
"publicChart.field.greeSensor": "GREE sensor",
"publicChart.field.roomSensor": "Room sensor",
"publicChart.field.comfortTarget": "Comfort target",
"publicChart.field.deviceSetpoint": "Device setpoint",
"publicChart.field.outdoor": "Outdoor temperature",
"publicChart.field.temperature": "Temperature",
"flow.invalidPresetFilename": "Invalid preset filename.",
"flow.invalidPresetFile": "{filename}: invalid preset.",
"flow.presetNotFound": "Preset not found."
} }
} }
+29 -4
View File
@@ -82,7 +82,7 @@
"devices.modelFirmware": "Model / firmware", "devices.modelFirmware": "Model / firmware",
"devices.lastSeen": "Ostatnia odpowiedź", "devices.lastSeen": "Ostatnia odpowiedź",
"devices.communicationFailures": "Błędy komunikacji", "devices.communicationFailures": "Błędy komunikacji",
"devices.lastError": "Ostatni błąd", "devices.lastError": "Błąd",
"devices.readStatus": "Odczytaj status", "devices.readStatus": "Odczytaj status",
"devices.technicalConfig": "Konfiguracja techniczna", "devices.technicalConfig": "Konfiguracja techniczna",
"devices.diagnostics": "Diagnostyka", "devices.diagnostics": "Diagnostyka",
@@ -1274,7 +1274,7 @@
"debug.emptyCloud": "Brak przechwyconej aktywności API GREE Cloud.", "debug.emptyCloud": "Brak przechwyconej aktywności API GREE Cloud.",
"debug.emptyMqtt": "Brak przechwyconej aktywności MQTT GREE Cloud.", "debug.emptyMqtt": "Brak przechwyconej aktywności MQTT GREE Cloud.",
"settings.cloudDevicesOnline": "Urządzenia online", "settings.cloudDevicesOnline": "Urządzenia online",
"settings.cloudResponseTime": "Czas ostatniej odpowiedzi urządzenia", "settings.cloudResponseTime": "Czas odpowiedzi urządzenia",
"settings.cloudLastDeviceResponse": "Ostatnia odpowiedź urządzenia", "settings.cloudLastDeviceResponse": "Ostatnia odpowiedź urządzenia",
"settings.cloudLastMqttMessage": "Ostatnia aktywność MQTT", "settings.cloudLastMqttMessage": "Ostatnia aktywność MQTT",
"settings.cloudConnectedSince": "MQTT połączony od", "settings.cloudConnectedSince": "MQTT połączony od",
@@ -1288,7 +1288,7 @@
"devices.cloudAuthenticationProblem": "Błąd uwierzytelniania GREE Cloud", "devices.cloudAuthenticationProblem": "Błąd uwierzytelniania GREE Cloud",
"devices.cloudTransportProblem": "Połączenie z GREE Cloud jest niedostępne", "devices.cloudTransportProblem": "Połączenie z GREE Cloud jest niedostępne",
"devices.cloudDeviceOffline": "GREE Cloud jest połączony, ale urządzenie nie odpowiada", "devices.cloudDeviceOffline": "GREE Cloud jest połączony, ale urządzenie nie odpowiada",
"settings.cloudRestResponseTime": "Czas ostatniej odpowiedzi REST", "settings.cloudRestResponseTime": "Czas odpowiedzi REST",
"common.pending": "Oczekiwanie", "common.pending": "Oczekiwanie",
"devices.installations": "Instalacje split / multisplit", "devices.installations": "Instalacje split / multisplit",
"devices.installationsHint": "Grupuj jednostki wewnętrzne współdzielące jedną jednostkę zewnętrzną. Energia i temperatura zewnętrzna mogą korzystać z jednego wspólnego źródła.", "devices.installationsHint": "Grupuj jednostki wewnętrzne współdzielące jedną jednostkę zewnętrzną. Energia i temperatura zewnętrzna mogą korzystać z jednego wspólnego źródła.",
@@ -1358,6 +1358,31 @@
"flow.haEntitySearchLabel": "Szukaj encji Home Assistant", "flow.haEntitySearchLabel": "Szukaj encji Home Assistant",
"flow.haEntitySearchPlaceholder": "Wpisz nazwę, entity_id lub stan…", "flow.haEntitySearchPlaceholder": "Wpisz nazwę, entity_id lub stan…",
"flow.haEntitySelectedLabel": "Wybrana encja (entity_id)", "flow.haEntitySelectedLabel": "Wybrana encja (entity_id)",
"flow.haEntitySearchMatches": "Pasujących: {count} z {total} encji Home Assistant" "flow.haEntitySearchMatches": "Pasujących: {count} z {total} encji Home Assistant",
"settings.influxTest": "Testuj połączenie z InfluxDB",
"settings.influxTesting": "Testowanie InfluxDB…",
"settings.influxTestSuccess": "Połączenie działa ({ms} ms)",
"publicChart.title": "Wykres niestandardowy",
"publicChart.loading": "Wczytywanie…",
"publicChart.invalidLink": "Nieprawidłowy link do wykresu.",
"publicChart.loadFailed": "Nie udało się wczytać wykresu: {error}",
"publicChart.rangeAria": "Zakres wykresu",
"publicChart.periodHint": "Okres: {hours} h",
"publicChart.hoursShort": "{hours} h",
"publicChart.noData": "Brak danych",
"publicChart.field.deviceIndoor": "Temperatura wewnętrzna",
"publicChart.field.deviceOutdoor": "Temperatura zewnętrzna GREE",
"publicChart.field.deviceTarget": "Temperatura zadana urządzenia",
"publicChart.field.sharedOutdoor": "Wspólna temperatura zewnętrzna",
"publicChart.field.zoneControl": "Temperatura sterująca",
"publicChart.field.greeSensor": "Czujnik GREE",
"publicChart.field.roomSensor": "Czujnik pomieszczenia",
"publicChart.field.comfortTarget": "Temperatura docelowa",
"publicChart.field.deviceSetpoint": "Nastawa urządzenia",
"publicChart.field.outdoor": "Temperatura zewnętrzna",
"publicChart.field.temperature": "Temperatura",
"flow.invalidPresetFilename": "Nieprawidłowa nazwa pliku presetu.",
"flow.invalidPresetFile": "{filename}: nieprawidłowy preset.",
"flow.presetNotFound": "Nie znaleziono presetu."
} }
} }
+4 -4
View File
@@ -76,9 +76,9 @@ const renderNames = [
for (const name of renderNames) context[name] = hit(name); for (const name of renderNames) context[name] = hit(name);
vm.createContext(context); vm.createContext(context);
vm.runInContext(fs.readFileSync(path.join(root, 'web/js/dashboard.js'), 'utf8'), context, { filename: 'dashboard.js' }); vm.runInContext(fs.readFileSync(path.join(root, 'web/js-dynamic/dashboard.js'), 'utf8'), context, { filename: 'dashboard.js' });
vm.runInContext(fs.readFileSync(path.join(root, 'web/js/bootstrap.js'), 'utf8'), context, { filename: 'bootstrap.js' }); vm.runInContext(fs.readFileSync(path.join(root, 'web/js-dynamic/bootstrap.js'), 'utf8'), context, { filename: 'bootstrap.js' });
vm.runInContext(fs.readFileSync(path.join(root, 'web/js/realtime.js'), 'utf8'), context, { filename: 'realtime.js' }); vm.runInContext(fs.readFileSync(path.join(root, 'web/js-dynamic/realtime.js'), 'utf8'), context, { filename: 'realtime.js' });
// Source files define DOM-heavy renderers. Replace those implementations for this state-machine test. // Source files define DOM-heavy renderers. Replace those implementations for this state-machine test.
for (const name of renderNames) context[name] = hit(name); for (const name of renderNames) context[name] = hit(name);
context.updateDevice = device => { context.updateDevice = device => {
@@ -118,7 +118,7 @@ async function testBootstrapReloadQueue() {
setTimeout: (fn, delay) => { queuedTimers.push({ fn, delay }); return queuedTimers.length; }, setTimeout: (fn, delay) => { queuedTimers.push({ fn, delay }); return queuedTimers.length; },
}; };
vm.createContext(c); vm.createContext(c);
vm.runInContext(fs.readFileSync(path.join(root, 'web/js/bootstrap.js'), 'utf8'), c, { filename: 'bootstrap.js' }); vm.runInContext(fs.readFileSync(path.join(root, 'web/js-dynamic/bootstrap.js'), 'utf8'), c, { filename: 'bootstrap.js' });
const first = c.loadBootstrap(); const first = c.loadBootstrap();
assert.equal(c.app.loading, true); assert.equal(c.app.loading, true);
await c.loadBootstrap(); await c.loadBootstrap();
+25 -19
View File
@@ -4,16 +4,16 @@ use crate::{
home_assistant, influxdb, home_assistant, influxdb,
models::{ models::{
ApiTokenInfo, ApplicationSettings, Automation, ClimateGroup, ConfigurationExport, ApiTokenInfo, ApplicationSettings, Automation, ClimateGroup, ConfigurationExport,
ConnectionStatus, ConnectionType, DebugSettings, Device, DeviceCommand, DeviceGroup, DeviceGroupKind, DevicePatch, DiscoveryRequest, ConnectionStatus, ConnectionType, DebugSettings, Device, DeviceCommand, DeviceGroup,
EnergyReading, EnergySourcePreference, Flow, GreeSettings, DeviceGroupKind, DevicePatch, DiscoveryRequest, EnergyReading, EnergySourcePreference,
GreeCloudSettings, GreeCloudSettingsUpdate, GreeCloudSettingsView, Flow, GreeCloudSettings, GreeCloudSettingsUpdate, GreeCloudSettingsView, GreeSettings,
GroupControlPatch, HaReading, HistorySettings, HomeAssistantSettings, GroupControlPatch, HaReading, HistorySettings, HomeAssistantSettings,
HomeAssistantSettingsUpdate, HomeAssistantSettingsView, InfluxDbSettings, HomeAssistantSettingsUpdate, HomeAssistantSettingsView, InfluxDbSettings,
InfluxDbSettingsUpdate, InfluxDbSettingsView, ManualDeviceRequest, NightModeSettings, InfluxDbSettingsUpdate, InfluxDbSettingsView, ManualDeviceRequest, NetworkReading,
NotificationSettings, NotificationSettingsUpdate, NotificationSettingsView, NetworkReading, Reading, NightModeSettings, NotificationSettings, NotificationSettingsUpdate,
RuntimeSettings, Schedule, SettingsSnapshot, TemporaryQuickThermostat, NotificationSettingsView, Reading, RuntimeSettings, Schedule, SettingsSnapshot,
TemporaryQuickThermostatRequest, Zone, TemporaryQuickThermostat, TemporaryQuickThermostatRequest, Zone, ZoneControlPatch,
ZoneControlPatch, ZoneReading, ZoneReading,
}, },
notifications, notifications,
protocol::merge_discovered, protocol::merge_discovered,
@@ -50,14 +50,14 @@ mod openapi;
const INDEX_HTML: &str = include_str!("../web/index.html"); const INDEX_HTML: &str = include_str!("../web/index.html");
const CUSTOM_CHART_HTML: &str = include_str!("../web/custom-chart.html"); const CUSTOM_CHART_HTML: &str = include_str!("../web/custom-chart.html");
const CUSTOM_CHART_JS: &str = include_str!("../web/custom-chart.js"); const CUSTOM_CHART_JS: &str = include_str!("../web/js/custom-chart.js");
const NOT_FOUND_HTML: &str = include_str!("../web/404.html"); const NOT_FOUND_HTML: &str = include_str!("../web/404.html");
const APP_JS: &str = include_str!(concat!(env!("OUT_DIR"), "/app.bundle.js")); const APP_JS: &str = include_str!(concat!(env!("OUT_DIR"), "/app.bundle.js"));
const THEME_INIT_JS: &str = include_str!("../web/theme-init.js"); const THEME_INIT_JS: &str = include_str!("../web/js/theme-init.js");
const LANG_INIT_JS: &str = include_str!("../web/lang-init.js"); const LANG_INIT_JS: &str = include_str!("../web/js/lang-init.js");
const STYLES_CSS: &str = include_str!("../web/styles.css"); const STYLES_CSS: &str = include_str!("../web/css/styles.css");
const MANIFEST: &str = include_str!("../web/manifest.webmanifest"); const MANIFEST: &str = include_str!("../web/manifest.webmanifest");
const SERVICE_WORKER: &str = include_str!("../web/sw.js"); const SERVICE_WORKER: &str = include_str!("../web/js/sw.js");
const FAVICON: &str = include_str!("../web/favicon.svg"); const FAVICON: &str = include_str!("../web/favicon.svg");
include!(concat!(env!("OUT_DIR"), "/languages.rs")); include!(concat!(env!("OUT_DIR"), "/languages.rs"));
@@ -98,10 +98,15 @@ pub fn router(state: AppState) -> Router {
.route("/api/devices/:id/poll", post(poll_device)) .route("/api/devices/:id/poll", post(poll_device))
.route("/api/devices/:id/probe", post(probe_device)) .route("/api/devices/:id/probe", post(probe_device))
.route("/api/devices/:id/command", post(command_device)) .route("/api/devices/:id/command", post(command_device))
.route("/api/device-groups", get(list_device_groups).post(create_device_group)) .route(
"/api/device-groups",
get(list_device_groups).post(create_device_group),
)
.route( .route(
"/api/device-groups/:id", "/api/device-groups/:id",
get(get_device_group).put(update_device_group).delete(delete_device_group), get(get_device_group)
.put(update_device_group)
.delete(delete_device_group),
) )
.route("/api/zones", get(list_zones).post(create_zone)) .route("/api/zones", get(list_zones).post(create_zone))
.route( .route(
@@ -183,6 +188,10 @@ pub fn router(state: AppState) -> Router {
"/api/settings/influxdb", "/api/settings/influxdb",
get(get_influxdb_settings).put(update_influxdb_settings), get(get_influxdb_settings).put(update_influxdb_settings),
) )
.route(
"/api/integrations/influxdb/test",
post(test_influxdb_connection),
)
.route( .route(
"/api/settings/notifications", "/api/settings/notifications",
get(get_notification_settings).put(update_notification_settings), get(get_notification_settings).put(update_notification_settings),
@@ -209,10 +218,7 @@ pub fn router(state: AppState) -> Router {
"/api/access-tokens/:id", "/api/access-tokens/:id",
axum::routing::delete(delete_access_token), axum::routing::delete(delete_access_token),
) )
.route( .route("/api/integrations/gree-cloud/test", post(test_gree_cloud))
"/api/integrations/gree-cloud/test",
post(test_gree_cloud),
)
.route( .route(
"/api/integrations/gree-cloud/devices", "/api/integrations/gree-cloud/devices",
get(discover_gree_cloud_devices), get(discover_gree_cloud_devices),
+12 -5
View File
@@ -37,6 +37,10 @@ async fn custom_chart_page(State(state): State<AppState>, headers: HeaderMap) ->
"__GREE_THEME_INIT_ASSET__", "__GREE_THEME_INIT_ASSET__",
&format!("{base}{THEME_INIT_ASSET_PATH}"), &format!("{base}{THEME_INIT_ASSET_PATH}"),
) )
.replace(
"__GREE_LANG_INIT_ASSET__",
&format!("{base}{LANG_INIT_ASSET_PATH}"),
)
.replace( .replace(
"__GREE_STYLES_ASSET__", "__GREE_STYLES_ASSET__",
&format!("{base}{STYLES_CSS_ASSET_PATH}"), &format!("{base}{STYLES_CSS_ASSET_PATH}"),
@@ -50,10 +54,9 @@ async fn custom_chart_page(State(state): State<AppState>, headers: HeaderMap) ->
header::CONTENT_TYPE, header::CONTENT_TYPE,
HeaderValue::from_static("text/html; charset=utf-8"), HeaderValue::from_static("text/html; charset=utf-8"),
); );
response.headers_mut().insert( response
header::CACHE_CONTROL, .headers_mut()
HeaderValue::from_static("public, no-cache"), .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
);
response response
} }
@@ -183,7 +186,11 @@ async fn language_file(Path(file): Path<String>) -> Response {
.iter() .iter()
.find(|(language, _)| *language == code) .find(|(language, _)| *language == code)
{ {
return static_response(*body, "application/json; charset=utf-8", "public, max-age=31536000, immutable"); return static_response(
*body,
"application/json; charset=utf-8",
"public, max-age=31536000, immutable",
);
} }
let mut response = Response::new(Body::from("Language not found")); let mut response = Response::new(Body::from("Language not found"));
*response.status_mut() = StatusCode::NOT_FOUND; *response.status_mut() = StatusCode::NOT_FOUND;
+68 -20
View File
@@ -50,7 +50,11 @@ fn collect_configuration_ids(
let ids = ConfigurationIds { let ids = ConfigurationIds {
devices: export.devices.iter().map(|item| item.id.as_str()).collect(), devices: export.devices.iter().map(|item| item.id.as_str()).collect(),
zones: export.zones.iter().map(|item| item.id.as_str()).collect(), zones: export.zones.iter().map(|item| item.id.as_str()).collect(),
device_groups: export.device_groups.iter().map(|item| item.id.as_str()).collect(), device_groups: export
.device_groups
.iter()
.map(|item| item.id.as_str())
.collect(),
schedules: export schedules: export
.schedules .schedules
.iter() .iter()
@@ -190,21 +194,33 @@ fn validate_configuration_devices_and_zones(
} }
if !installation_devices.insert(device_id.as_str()) { if !installation_devices.insert(device_id.as_str()) {
return Err(AppError::BadRequest( return Err(AppError::BadRequest(
"import assigns one device to more than one split/multisplit installation".into(), "import assigns one device to more than one split/multisplit installation"
.into(),
)); ));
} }
} }
if group.energy_device_id.as_deref().is_some_and(|id| !group.device_ids.iter().any(|member| member == id)) { if group
.energy_device_id
.as_deref()
.is_some_and(|id| !group.device_ids.iter().any(|member| member == id))
{
return Err(AppError::BadRequest( return Err(AppError::BadRequest(
"import contains an installation energy source outside the installation".into(), "import contains an installation energy source outside the installation".into(),
)); ));
} }
if group.energy_source == EnergySourcePreference::GreeCloud && group.energy_device_id.is_none() { if group.energy_source == EnergySourcePreference::GreeCloud
&& group.energy_device_id.is_none()
{
return Err(AppError::BadRequest( return Err(AppError::BadRequest(
"import contains a GREE Cloud installation without an energy source device".into(), "import contains a GREE Cloud installation without an energy source device".into(),
)); ));
} }
if group.energy_source == EnergySourcePreference::HomeAssistant && group.ha_energy_entity_id.as_deref().map_or(true, |value| value.trim().is_empty()) { if group.energy_source == EnergySourcePreference::HomeAssistant
&& group
.ha_energy_entity_id
.as_deref()
.map_or(true, |value| value.trim().is_empty())
{
return Err(AppError::BadRequest( return Err(AppError::BadRequest(
"import contains a Home Assistant installation without an energy entity".into(), "import contains a Home Assistant installation without an energy entity".into(),
)); ));
@@ -213,23 +229,43 @@ fn validate_configuration_devices_and_zones(
let energy_device = export.devices.iter().find(|device| device.id == energy_device_id).ok_or_else(|| { let energy_device = export.devices.iter().find(|device| device.id == energy_device_id).ok_or_else(|| {
AppError::BadRequest("import contains an installation energy source referencing a missing device".into()) AppError::BadRequest("import contains an installation energy source referencing a missing device".into())
})?; })?;
if energy_device.connection_type != ConnectionType::GreeCloud || !energy_device.capabilities.energy_meter { if energy_device.connection_type != ConnectionType::GreeCloud
|| !energy_device.capabilities.energy_meter
{
return Err(AppError::BadRequest( return Err(AppError::BadRequest(
"import contains an installation energy source without a GREE Cloud energy meter".into(), "import contains an installation energy source without a GREE Cloud energy meter".into(),
)); ));
} }
} }
if group.ha_energy_entity_id.as_deref().is_some_and(|value| !value.trim().is_empty()) { if group
.ha_energy_entity_id
.as_deref()
.is_some_and(|value| !value.trim().is_empty())
{
if group.ha_energy_device_class.as_deref() != Some("energy") if group.ha_energy_device_class.as_deref() != Some("energy")
|| !matches!(group.ha_energy_state_class.as_deref(), Some("total" | "total_increasing")) || !matches!(
|| !matches!(group.ha_energy_unit.as_deref().map(str::to_ascii_lowercase).as_deref(), Some("wh" | "kwh")) group.ha_energy_state_class.as_deref(),
Some("total" | "total_increasing")
)
|| !matches!(
group
.ha_energy_unit
.as_deref()
.map(str::to_ascii_lowercase)
.as_deref(),
Some("wh" | "kwh")
)
{ {
return Err(AppError::BadRequest( return Err(AppError::BadRequest(
"import contains an invalid Home Assistant cumulative energy entity".into(), "import contains an invalid Home Assistant cumulative energy entity".into(),
)); ));
} }
} }
if group.outdoor_temperature_device_id.as_deref().is_some_and(|id| !group.device_ids.iter().any(|member| member == id)) { if group
.outdoor_temperature_device_id
.as_deref()
.is_some_and(|id| !group.device_ids.iter().any(|member| member == id))
{
return Err(AppError::BadRequest( return Err(AppError::BadRequest(
"import contains an installation outdoor-temperature source outside the installation".into(), "import contains an installation outdoor-temperature source outside the installation".into(),
)); ));
@@ -652,10 +688,14 @@ fn normalize_imported_runtime_settings(settings: &mut RuntimeSettings) -> Result
} }
settings.gree_cloud.polling_interval_seconds = settings.gree_cloud.polling_interval_seconds =
settings.gree_cloud.polling_interval_seconds.clamp(30, 3600); settings.gree_cloud.polling_interval_seconds.clamp(30, 3600);
settings.gree_cloud.connectivity_metrics_interval_seconds = settings.gree_cloud.connectivity_metrics_interval_seconds = settings
settings.gree_cloud.connectivity_metrics_interval_seconds.clamp(30, 3600); .gree_cloud
settings.gree_cloud.connectivity_metrics_sample_count = .connectivity_metrics_interval_seconds
settings.gree_cloud.connectivity_metrics_sample_count.clamp(1, 10); .clamp(30, 3600);
settings.gree_cloud.connectivity_metrics_sample_count = settings
.gree_cloud
.connectivity_metrics_sample_count
.clamp(1, 10);
if settings.gree_cloud.account_id.trim().is_empty() { if settings.gree_cloud.account_id.trim().is_empty() {
settings.gree_cloud.account_id = "default".into(); settings.gree_cloud.account_id = "default".into();
} }
@@ -710,8 +750,12 @@ async fn hydrate_imported_cloud_device_keys(
) -> Result<(), AppError> { ) -> Result<(), AppError> {
if export.settings.gree_cloud.password.trim().is_empty() { if export.settings.gree_cloud.password.trim().is_empty() {
let current = state.settings.read().await.gree_cloud.clone(); let current = state.settings.read().await.gree_cloud.clone();
if current.region.eq_ignore_ascii_case(&export.settings.gree_cloud.region) if current
&& current.username.eq_ignore_ascii_case(&export.settings.gree_cloud.username) .region
.eq_ignore_ascii_case(&export.settings.gree_cloud.region)
&& current
.username
.eq_ignore_ascii_case(&export.settings.gree_cloud.username)
&& !current.password.trim().is_empty() && !current.password.trim().is_empty()
{ {
export.settings.gree_cloud.password = current.password; export.settings.gree_cloud.password = current.password;
@@ -732,9 +776,11 @@ async fn hydrate_imported_cloud_device_keys(
&settings.username, &settings.username,
&settings.password, &settings.password,
) )
.map_err(|err| AppError::BadRequest(format!( .map_err(|err| {
"cannot restore GREE Cloud device secrets from account: {err}" AppError::BadRequest(format!(
)))?; "cannot restore GREE Cloud device secrets from account: {err}"
))
})?;
api.login().await.map_err(|err| { api.login().await.map_err(|err| {
AppError::BadRequest(format!( AppError::BadRequest(format!(
"cannot restore GREE Cloud device secrets: account login failed: {err}" "cannot restore GREE Cloud device secrets: account login failed: {err}"
@@ -767,7 +813,9 @@ async fn hydrate_imported_cloud_device_keys(
device.key = Some(found.key.clone()); device.key = Some(found.key.clone());
let normalized_cloud_mac = found.mac.replace([':', '-'], "").to_ascii_uppercase(); let normalized_cloud_mac = found.mac.replace([':', '-'], "").to_ascii_uppercase();
device.cloud_device_id = Some(normalized_cloud_mac.clone()); device.cloud_device_id = Some(normalized_cloud_mac.clone());
device.cloud_parent_mac = Some(crate::protocol::gree_cloud::parent_mac(&normalized_cloud_mac)); device.cloud_parent_mac = Some(crate::protocol::gree_cloud::parent_mac(
&normalized_cloud_mac,
));
device.cloud_account_id = Some(settings.account_id.clone()); device.cloud_account_id = Some(settings.account_id.clone());
if device.model.trim().is_empty() { if device.model.trim().is_empty() {
device.model = found.model.clone().unwrap_or_default(); device.model = found.model.clone().unwrap_or_default();
+107 -28
View File
@@ -22,7 +22,9 @@ struct DeviceGroupInput {
} }
fn normalize_optional(value: Option<String>) -> Option<String> { fn normalize_optional(value: Option<String>) -> Option<String> {
value.map(|item| item.trim().to_string()).filter(|item| !item.is_empty()) value
.map(|item| item.trim().to_string())
.filter(|item| !item.is_empty())
} }
fn validate_device_group_input( fn validate_device_group_input(
@@ -33,63 +35,118 @@ fn validate_device_group_input(
if input.name.trim().is_empty() { if input.name.trim().is_empty() {
return Err(AppError::BadRequest("installation name is required".into())); return Err(AppError::BadRequest("installation name is required".into()));
} }
let mut device_ids = input.device_ids.iter().map(|id| id.trim().to_string()).filter(|id| !id.is_empty()).collect::<Vec<_>>(); let mut device_ids = input
.device_ids
.iter()
.map(|id| id.trim().to_string())
.filter(|id| !id.is_empty())
.collect::<Vec<_>>();
device_ids.sort(); device_ids.sort();
device_ids.dedup(); device_ids.dedup();
if device_ids.is_empty() { if device_ids.is_empty() {
return Err(AppError::BadRequest("installation must contain at least one device".into())); return Err(AppError::BadRequest(
"installation must contain at least one device".into(),
));
} }
if input.kind == DeviceGroupKind::Split && device_ids.len() != 1 { if input.kind == DeviceGroupKind::Split && device_ids.len() != 1 {
return Err(AppError::BadRequest("split installation must contain exactly one device".into())); return Err(AppError::BadRequest(
"split installation must contain exactly one device".into(),
));
} }
for device_id in &device_ids { for device_id in &device_ids {
if state.db.get_device(device_id)?.is_none() { if state.db.get_device(device_id)?.is_none() {
return Err(AppError::BadRequest(format!("installation references missing device {device_id}"))); return Err(AppError::BadRequest(format!(
"installation references missing device {device_id}"
)));
} }
} }
for existing in state.db.list_device_groups()? { for existing in state.db.list_device_groups()? {
if editing_id == Some(existing.id.as_str()) { continue; } if editing_id == Some(existing.id.as_str()) {
if let Some(device_id) = device_ids.iter().find(|id| existing.device_ids.iter().any(|other| other == *id)) { continue;
return Err(AppError::BadRequest(format!("device {device_id} already belongs to installation '{}'", existing.name))); }
if let Some(device_id) = device_ids
.iter()
.find(|id| existing.device_ids.iter().any(|other| other == *id))
{
return Err(AppError::BadRequest(format!(
"device {device_id} already belongs to installation '{}'",
existing.name
)));
} }
} }
let energy_device_id = normalize_optional(input.energy_device_id.clone()); let energy_device_id = normalize_optional(input.energy_device_id.clone());
if let Some(ref id) = energy_device_id { if let Some(ref id) = energy_device_id {
if !device_ids.iter().any(|device_id| device_id == id) { if !device_ids.iter().any(|device_id| device_id == id) {
return Err(AppError::BadRequest("energy source device must belong to this installation".into())); return Err(AppError::BadRequest(
"energy source device must belong to this installation".into(),
));
} }
let device = state.db.get_device(id)?.ok_or_else(|| AppError::BadRequest("energy source device does not exist".into()))?; let device = state
if device.connection_type != ConnectionType::GreeCloud || !device.capabilities.energy_meter { .db
return Err(AppError::BadRequest("selected device does not expose GREE Cloud energy".into())); .get_device(id)?
.ok_or_else(|| AppError::BadRequest("energy source device does not exist".into()))?;
if device.connection_type != ConnectionType::GreeCloud || !device.capabilities.energy_meter
{
return Err(AppError::BadRequest(
"selected device does not expose GREE Cloud energy".into(),
));
} }
} }
if input.energy_source == EnergySourcePreference::GreeCloud && energy_device_id.is_none() { if input.energy_source == EnergySourcePreference::GreeCloud && energy_device_id.is_none() {
return Err(AppError::BadRequest("select a GREE Cloud energy source device".into())); return Err(AppError::BadRequest(
"select a GREE Cloud energy source device".into(),
));
} }
let entity = normalize_optional(input.ha_energy_entity_id.clone()); let entity = normalize_optional(input.ha_energy_entity_id.clone());
if input.energy_source == EnergySourcePreference::HomeAssistant && entity.is_none() { if input.energy_source == EnergySourcePreference::HomeAssistant && entity.is_none() {
return Err(AppError::BadRequest("select a Home Assistant cumulative energy sensor".into())); return Err(AppError::BadRequest(
"select a Home Assistant cumulative energy sensor".into(),
));
} }
if entity.is_some() { if entity.is_some() {
if input.ha_energy_device_class.as_deref() != Some("energy") { if input.ha_energy_device_class.as_deref() != Some("energy") {
return Err(AppError::BadRequest("Home Assistant energy sensor must have device_class=energy".into())); return Err(AppError::BadRequest(
"Home Assistant energy sensor must have device_class=energy".into(),
));
} }
if !matches!(input.ha_energy_state_class.as_deref(), Some("total" | "total_increasing")) { if !matches!(
return Err(AppError::BadRequest("Home Assistant energy sensor must have state_class=total or total_increasing".into())); input.ha_energy_state_class.as_deref(),
Some("total" | "total_increasing")
) {
return Err(AppError::BadRequest(
"Home Assistant energy sensor must have state_class=total or total_increasing"
.into(),
));
} }
if !matches!(input.ha_energy_unit.as_deref().map(str::to_ascii_lowercase).as_deref(), Some("wh" | "kwh")) { if !matches!(
return Err(AppError::BadRequest("Home Assistant energy sensor must use Wh or kWh".into())); input
.ha_energy_unit
.as_deref()
.map(str::to_ascii_lowercase)
.as_deref(),
Some("wh" | "kwh")
) {
return Err(AppError::BadRequest(
"Home Assistant energy sensor must use Wh or kWh".into(),
));
} }
} }
if let Some(id) = normalize_optional(input.outdoor_temperature_device_id.clone()) { if let Some(id) = normalize_optional(input.outdoor_temperature_device_id.clone()) {
if !device_ids.iter().any(|device_id| device_id == &id) { if !device_ids.iter().any(|device_id| device_id == &id) {
return Err(AppError::BadRequest("outdoor temperature source must belong to this installation".into())); return Err(AppError::BadRequest(
"outdoor temperature source must belong to this installation".into(),
));
} }
} }
Ok(device_ids) Ok(device_ids)
} }
fn device_group_from_input(id: String, existing: Option<DeviceGroup>, input: DeviceGroupInput, device_ids: Vec<String>) -> DeviceGroup { fn device_group_from_input(
id: String,
existing: Option<DeviceGroup>,
input: DeviceGroupInput,
device_ids: Vec<String>,
) -> DeviceGroup {
let now = Utc::now(); let now = Utc::now();
DeviceGroup { DeviceGroup {
id, id,
@@ -108,15 +165,27 @@ fn device_group_from_input(id: String, existing: Option<DeviceGroup>, input: Dev
} }
} }
async fn list_device_groups(State(state): State<AppState>) -> Result<Json<Vec<DeviceGroup>>, AppError> { async fn list_device_groups(
State(state): State<AppState>,
) -> Result<Json<Vec<DeviceGroup>>, AppError> {
Ok(Json(state.db.list_device_groups()?)) Ok(Json(state.db.list_device_groups()?))
} }
async fn get_device_group(State(state): State<AppState>, Path(id): Path<String>) -> Result<Json<DeviceGroup>, AppError> { async fn get_device_group(
state.db.get_device_group(&id)?.map(Json).ok_or_else(|| AppError::NotFound(format!("device group {id}"))) State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<Json<DeviceGroup>, AppError> {
state
.db
.get_device_group(&id)?
.map(Json)
.ok_or_else(|| AppError::NotFound(format!("device group {id}")))
} }
async fn create_device_group(State(state): State<AppState>, Json(input): Json<DeviceGroupInput>) -> Result<(StatusCode, Json<DeviceGroup>), AppError> { async fn create_device_group(
State(state): State<AppState>,
Json(input): Json<DeviceGroupInput>,
) -> Result<(StatusCode, Json<DeviceGroup>), AppError> {
let _guard = state.lock_configuration_operation().await; let _guard = state.lock_configuration_operation().await;
let device_ids = validate_device_group_input(&state, &input, None)?; let device_ids = validate_device_group_input(&state, &input, None)?;
let group = device_group_from_input(Uuid::new_v4().to_string(), None, input, device_ids); let group = device_group_from_input(Uuid::new_v4().to_string(), None, input, device_ids);
@@ -125,9 +194,16 @@ async fn create_device_group(State(state): State<AppState>, Json(input): Json<De
Ok((StatusCode::CREATED, Json(group))) Ok((StatusCode::CREATED, Json(group)))
} }
async fn update_device_group(State(state): State<AppState>, Path(id): Path<String>, Json(input): Json<DeviceGroupInput>) -> Result<Json<DeviceGroup>, AppError> { async fn update_device_group(
State(state): State<AppState>,
Path(id): Path<String>,
Json(input): Json<DeviceGroupInput>,
) -> Result<Json<DeviceGroup>, AppError> {
let _guard = state.lock_configuration_operation().await; let _guard = state.lock_configuration_operation().await;
let existing = state.db.get_device_group(&id)?.ok_or_else(|| AppError::NotFound(format!("device group {id}")))?; let existing = state
.db
.get_device_group(&id)?
.ok_or_else(|| AppError::NotFound(format!("device group {id}")))?;
let device_ids = validate_device_group_input(&state, &input, Some(&id))?; let device_ids = validate_device_group_input(&state, &input, Some(&id))?;
let group = device_group_from_input(id, Some(existing), input, device_ids); let group = device_group_from_input(id, Some(existing), input, device_ids);
state.db.save_device_group(&group)?; state.db.save_device_group(&group)?;
@@ -135,7 +211,10 @@ async fn update_device_group(State(state): State<AppState>, Path(id): Path<Strin
Ok(Json(group)) Ok(Json(group))
} }
async fn delete_device_group(State(state): State<AppState>, Path(id): Path<String>) -> Result<StatusCode, AppError> { async fn delete_device_group(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<StatusCode, AppError> {
let _guard = state.lock_configuration_operation().await; let _guard = state.lock_configuration_operation().await;
if !state.db.delete_device_group(&id)? { if !state.db.delete_device_group(&id)? {
return Err(AppError::NotFound(format!("device group {id}"))); return Err(AppError::NotFound(format!("device group {id}")));
+48 -14
View File
@@ -193,7 +193,10 @@ async fn patch_device(
.get_device(&id)? .get_device(&id)?
.ok_or_else(|| AppError::NotFound(format!("device {id}")))?; .ok_or_else(|| AppError::NotFound(format!("device {id}")))?;
if device.connection_type == ConnectionType::GreeCloud if device.connection_type == ConnectionType::GreeCloud
&& (patch.ip.is_some() || patch.port.is_some() || patch.protocol_version.is_some() || patch.key.is_some()) && (patch.ip.is_some()
|| patch.port.is_some()
|| patch.protocol_version.is_some()
|| patch.key.is_some())
{ {
return Err(AppError::BadRequest( return Err(AppError::BadRequest(
"IP, UDP port, local protocol and local key are not configurable for GREE Cloud devices".into(), "IP, UDP port, local protocol and local key are not configurable for GREE Cloud devices".into(),
@@ -232,19 +235,35 @@ async fn patch_device(
if let Some(v) = patch.enabled { if let Some(v) = patch.enabled {
device.enabled = v; device.enabled = v;
} }
if let Some(v) = patch.energy_source { device.energy_source = v; } if let Some(v) = patch.energy_source {
if let Some(v) = patch.ha_energy_entity_id { device.ha_energy_entity_id = v.filter(|x| !x.trim().is_empty()); } device.energy_source = v;
if let Some(v) = patch.ha_energy_unit { device.ha_energy_unit = v.filter(|x| !x.trim().is_empty()); } }
if let Some(v) = patch.ha_energy_device_class { device.ha_energy_device_class = v.filter(|x| !x.trim().is_empty()); } if let Some(v) = patch.ha_energy_entity_id {
if let Some(v) = patch.ha_energy_state_class { device.ha_energy_state_class = v.filter(|x| !x.trim().is_empty()); } device.ha_energy_entity_id = v.filter(|x| !x.trim().is_empty());
}
if let Some(v) = patch.ha_energy_unit {
device.ha_energy_unit = v.filter(|x| !x.trim().is_empty());
}
if let Some(v) = patch.ha_energy_device_class {
device.ha_energy_device_class = v.filter(|x| !x.trim().is_empty());
}
if let Some(v) = patch.ha_energy_state_class {
device.ha_energy_state_class = v.filter(|x| !x.trim().is_empty());
}
device.refresh_capabilities(); device.refresh_capabilities();
if device.energy_source == EnergySourcePreference::GreeCloud && !device.capabilities.energy_meter { if device.energy_source == EnergySourcePreference::GreeCloud
&& !device.capabilities.energy_meter
{
return Err(AppError::BadRequest( return Err(AppError::BadRequest(
"GREE Cloud energy is not available for this device".into(), "GREE Cloud energy is not available for this device".into(),
)); ));
} }
if device.energy_source == EnergySourcePreference::HomeAssistant if device.energy_source == EnergySourcePreference::HomeAssistant
&& device.ha_energy_entity_id.as_deref().unwrap_or_default().is_empty() && device
.ha_energy_entity_id
.as_deref()
.unwrap_or_default()
.is_empty()
{ {
return Err(AppError::BadRequest( return Err(AppError::BadRequest(
"select a Home Assistant cumulative energy sensor first".into(), "select a Home Assistant cumulative energy sensor first".into(),
@@ -256,13 +275,21 @@ async fn patch_device(
"Home Assistant energy sensor must have device_class=energy".into(), "Home Assistant energy sensor must have device_class=energy".into(),
)); ));
} }
if !matches!(device.ha_energy_state_class.as_deref(), Some("total" | "total_increasing")) { if !matches!(
device.ha_energy_state_class.as_deref(),
Some("total" | "total_increasing")
) {
return Err(AppError::BadRequest( return Err(AppError::BadRequest(
"Home Assistant energy sensor must have state_class=total or total_increasing".into(), "Home Assistant energy sensor must have state_class=total or total_increasing"
.into(),
)); ));
} }
if !matches!( if !matches!(
device.ha_energy_unit.as_deref().map(str::to_ascii_lowercase).as_deref(), device
.ha_energy_unit
.as_deref()
.map(str::to_ascii_lowercase)
.as_deref(),
Some("wh" | "kwh") Some("wh" | "kwh")
) { ) {
return Err(AppError::BadRequest( return Err(AppError::BadRequest(
@@ -331,7 +358,10 @@ async fn delete_device(
.iter() .iter()
.filter(|group| { .filter(|group| {
!group.zone_ids.is_empty() !group.zone_ids.is_empty()
&& group.zone_ids.iter().all(|zone_id| removed_zone_ids.contains(zone_id)) && group
.zone_ids
.iter()
.all(|zone_id| removed_zone_ids.contains(zone_id))
}) })
.map(|group| group.id.clone()) .map(|group| group.id.clone())
.collect(); .collect();
@@ -380,7 +410,9 @@ async fn bind_device(
.get_device(&id)? .get_device(&id)?
.ok_or_else(|| AppError::NotFound(format!("device {id}")))?; .ok_or_else(|| AppError::NotFound(format!("device {id}")))?;
if device.connection_type == ConnectionType::GreeCloud { if device.connection_type == ConnectionType::GreeCloud {
return Err(AppError::BadRequest("bind is only available for Local/LAN devices".into())); return Err(AppError::BadRequest(
"bind is only available for Local/LAN devices".into(),
));
} }
if device.simulated { if device.simulated {
return Ok(Json(device)); return Ok(Json(device));
@@ -426,7 +458,9 @@ async fn probe_device(
.get_device(&id)? .get_device(&id)?
.ok_or_else(|| AppError::NotFound(format!("device {id}")))?; .ok_or_else(|| AppError::NotFound(format!("device {id}")))?;
if device.connection_type == ConnectionType::GreeCloud { if device.connection_type == ConnectionType::GreeCloud {
return Err(AppError::BadRequest("UDP probe is only available for Local/LAN devices".into())); return Err(AppError::BadRequest(
"UDP probe is only available for Local/LAN devices".into(),
));
} }
let response_time_ms = state let response_time_ms = state
.providers .providers
+102 -65
View File
@@ -19,7 +19,9 @@ fn cloud_error_kind(error: &anyhow::Error) -> &'static str {
} }
} }
async fn cloud_api_from_settings(state: &AppState) -> Result<crate::protocol::gree_cloud::GreeCloudApi, AppError> { async fn cloud_api_from_settings(
state: &AppState,
) -> Result<crate::protocol::gree_cloud::GreeCloudApi, AppError> {
let settings = state.settings.read().await.gree_cloud.clone(); let settings = state.settings.read().await.gree_cloud.clone();
crate::protocol::gree_cloud::GreeCloudApi::for_region( crate::protocol::gree_cloud::GreeCloudApi::for_region(
state.http.clone(), state.http.clone(),
@@ -33,7 +35,10 @@ async fn cloud_api_from_settings(state: &AppState) -> Result<crate::protocol::gr
async fn test_gree_cloud(State(state): State<AppState>) -> Result<Json<Value>, AppError> { async fn test_gree_cloud(State(state): State<AppState>) -> Result<Json<Value>, AppError> {
let mut api = cloud_api_from_settings(&state).await?; let mut api = cloud_api_from_settings(&state).await?;
let started = Instant::now(); let started = Instant::now();
cloud_debug_event(&state, json!({"operation":"test_connection","phase":"sent"})); cloud_debug_event(
&state,
json!({"operation":"test_connection","phase":"sent"}),
);
let result = async { let result = async {
api.login().await?; api.login().await?;
let devices = api.get_all_devices().await?; let devices = api.get_all_devices().await?;
@@ -44,12 +49,15 @@ async fn test_gree_cloud(State(state): State<AppState>) -> Result<Json<Value>, A
match result { match result {
Ok(device_count) => { Ok(device_count) => {
let now = Utc::now(); let now = Utc::now();
cloud_debug_event(&state, json!({ cloud_debug_event(
"operation":"test_connection", &state,
"phase":"response", json!({
"duration_ms": started.elapsed().as_millis() as u64, "operation":"test_connection",
"device_count": device_count, "phase":"response",
})); "duration_ms": started.elapsed().as_millis() as u64,
"device_count": device_count,
}),
);
{ {
let _configuration_guard = state.lock_configuration_operation().await; let _configuration_guard = state.lock_configuration_operation().await;
let mut settings = state.settings.write().await; let mut settings = state.settings.write().await;
@@ -73,12 +81,15 @@ async fn test_gree_cloud(State(state): State<AppState>) -> Result<Json<Value>, A
} }
Err(error) => { Err(error) => {
let kind = cloud_error_kind(&error); let kind = cloud_error_kind(&error);
cloud_debug_event(&state, json!({ cloud_debug_event(
"operation":"test_connection", &state,
"phase":"error", json!({
"duration_ms": started.elapsed().as_millis() as u64, "operation":"test_connection",
"kind": kind, "phase":"error",
})); "duration_ms": started.elapsed().as_millis() as u64,
"kind": kind,
}),
);
tracing::warn!(kind, "GREE Cloud connection test failed"); tracing::warn!(kind, "GREE Cloud connection test failed");
state.log( state.log(
"warn", "warn",
@@ -107,38 +118,42 @@ async fn discover_gree_cloud_devices(
let mut api = cloud_api_from_settings(&state).await?; let mut api = cloud_api_from_settings(&state).await?;
let started = Instant::now(); let started = Instant::now();
cloud_debug_event(&state, json!({"operation":"discovery","phase":"sent"})); cloud_debug_event(&state, json!({"operation":"discovery","phase":"sent"}));
api.login() api.login().await.map_err(|err| {
.await cloud_debug_event(
.map_err(|err| { &state,
cloud_debug_event(&state, json!({ json!({
"operation":"discovery", "operation":"discovery",
"phase":"error", "phase":"error",
"stage":"login", "stage":"login",
"duration_ms": started.elapsed().as_millis() as u64, "duration_ms": started.elapsed().as_millis() as u64,
"kind": cloud_error_kind(&err), "kind": cloud_error_kind(&err),
})); }),
AppError::Dependency(format!("GREE Cloud login failed: {err}")) );
})?; AppError::Dependency(format!("GREE Cloud login failed: {err}"))
let devices = api })?;
.get_all_devices() let devices = api.get_all_devices().await.map_err(|err| {
.await cloud_debug_event(
.map_err(|err| { &state,
cloud_debug_event(&state, json!({ json!({
"operation":"discovery", "operation":"discovery",
"phase":"error", "phase":"error",
"stage":"devices", "stage":"devices",
"duration_ms": started.elapsed().as_millis() as u64, "duration_ms": started.elapsed().as_millis() as u64,
"kind": cloud_error_kind(&err), "kind": cloud_error_kind(&err),
})); }),
AppError::Dependency(format!("GREE Cloud discovery failed: {err}")) );
})?; AppError::Dependency(format!("GREE Cloud discovery failed: {err}"))
})?;
let rest_duration_ms = started.elapsed().as_millis().min(u64::MAX as u128) as u64; let rest_duration_ms = started.elapsed().as_millis().min(u64::MAX as u128) as u64;
cloud_debug_event(&state, json!({ cloud_debug_event(
"operation":"discovery", &state,
"phase":"response", json!({
"duration_ms": rest_duration_ms, "operation":"discovery",
"device_count": devices.len(), "phase":"response",
})); "duration_ms": rest_duration_ms,
"device_count": devices.len(),
}),
);
{ {
let _configuration_guard = state.lock_configuration_operation().await; let _configuration_guard = state.lock_configuration_operation().await;
let mut settings = state.settings.write().await; let mut settings = state.settings.write().await;
@@ -153,7 +168,10 @@ async fn discover_gree_cloud_devices(
let id = device.mac.replace([':', '-'], "").to_ascii_uppercase(); let id = device.mac.replace([':', '-'], "").to_ascii_uppercase();
let already_added = existing.iter().any(|saved| { let already_added = existing.iter().any(|saved| {
saved.connection_type == ConnectionType::GreeCloud saved.connection_type == ConnectionType::GreeCloud
&& saved.cloud_device_id.as_deref().is_some_and(|value| value.eq_ignore_ascii_case(&id)) && saved
.cloud_device_id
.as_deref()
.is_some_and(|value| value.eq_ignore_ascii_case(&id))
}); });
crate::protocol::gree_cloud::CloudDeviceView { crate::protocol::gree_cloud::CloudDeviceView {
parent_mac: crate::protocol::gree_cloud::parent_mac(&id), parent_mac: crate::protocol::gree_cloud::parent_mac(&id),
@@ -196,50 +214,63 @@ async fn add_gree_cloud_device(
// Re-discover server-side so the frontend never needs to submit/store the device cipher key. // Re-discover server-side so the frontend never needs to submit/store the device cipher key.
let mut api = cloud_api_from_settings(&state).await?; let mut api = cloud_api_from_settings(&state).await?;
let started = Instant::now(); let started = Instant::now();
cloud_debug_event(&state, json!({ cloud_debug_event(
"operation":"add_device_lookup", &state,
"phase":"sent", json!({
"cloud_device_id": cloud_id.clone(), "operation":"add_device_lookup",
})); "phase":"sent",
api.login() "cloud_device_id": cloud_id.clone(),
.await }),
.map_err(|err| { );
cloud_debug_event(&state, json!({ api.login().await.map_err(|err| {
cloud_debug_event(
&state,
json!({
"operation":"add_device_lookup", "operation":"add_device_lookup",
"phase":"error", "phase":"error",
"stage":"login", "stage":"login",
"duration_ms": started.elapsed().as_millis() as u64, "duration_ms": started.elapsed().as_millis() as u64,
"kind": cloud_error_kind(&err), "kind": cloud_error_kind(&err),
})); }),
AppError::Dependency(format!("GREE Cloud login failed: {err}")) );
})?; AppError::Dependency(format!("GREE Cloud login failed: {err}"))
})?;
let cloud_device = api let cloud_device = api
.get_all_devices() .get_all_devices()
.await .await
.map_err(|err| { .map_err(|err| {
cloud_debug_event(&state, json!({ cloud_debug_event(
"operation":"add_device_lookup", &state,
"phase":"error", json!({
"stage":"devices", "operation":"add_device_lookup",
"duration_ms": started.elapsed().as_millis() as u64, "phase":"error",
"kind": cloud_error_kind(&err), "stage":"devices",
})); "duration_ms": started.elapsed().as_millis() as u64,
"kind": cloud_error_kind(&err),
}),
);
AppError::Dependency(format!("GREE Cloud discovery failed: {err}")) AppError::Dependency(format!("GREE Cloud discovery failed: {err}"))
})? })?
.into_iter() .into_iter()
.find(|device| device.mac.eq_ignore_ascii_case(&cloud_id)) .find(|device| device.mac.eq_ignore_ascii_case(&cloud_id))
.ok_or_else(|| AppError::NotFound(format!("GREE Cloud device {cloud_id}")))?; .ok_or_else(|| AppError::NotFound(format!("GREE Cloud device {cloud_id}")))?;
cloud_debug_event(&state, json!({ cloud_debug_event(
"operation":"add_device_lookup", &state,
"phase":"response", json!({
"duration_ms": started.elapsed().as_millis() as u64, "operation":"add_device_lookup",
"cloud_device_id": cloud_id.clone(), "phase":"response",
})); "duration_ms": started.elapsed().as_millis() as u64,
"cloud_device_id": cloud_id.clone(),
}),
);
let _configuration_guard = state.lock_configuration_operation().await; let _configuration_guard = state.lock_configuration_operation().await;
let now = Utc::now(); let now = Utc::now();
let account_id = state.settings.read().await.gree_cloud.account_id.clone(); let account_id = state.settings.read().await.gree_cloud.account_id.clone();
let normalized_cloud_mac = cloud_device.mac.replace([':', '-'], "").to_ascii_uppercase(); let normalized_cloud_mac = cloud_device
.mac
.replace([':', '-'], "")
.to_ascii_uppercase();
let device = Device { let device = Device {
id: format!("gree-cloud-{}", normalized_cloud_mac.to_ascii_lowercase()), id: format!("gree-cloud-{}", normalized_cloud_mac.to_ascii_lowercase()),
mac: normalized_cloud_mac.clone(), mac: normalized_cloud_mac.clone(),
@@ -251,7 +282,9 @@ async fn add_gree_cloud_device(
connection_type: ConnectionType::GreeCloud, connection_type: ConnectionType::GreeCloud,
connection_status: ConnectionStatus::CloudDisconnected, connection_status: ConnectionStatus::CloudDisconnected,
cloud_device_id: Some(normalized_cloud_mac.clone()), cloud_device_id: Some(normalized_cloud_mac.clone()),
cloud_parent_mac: Some(crate::protocol::gree_cloud::parent_mac(&normalized_cloud_mac)), cloud_parent_mac: Some(crate::protocol::gree_cloud::parent_mac(
&normalized_cloud_mac,
)),
cloud_account_id: Some(account_id), cloud_account_id: Some(account_id),
ip: String::new(), ip: String::new(),
port: 0, port: 0,
@@ -383,7 +416,9 @@ async fn reconnect_gree_cloud(State(state): State<AppState>) -> Result<Json<Valu
"GREE Cloud MQTT reconnected", "GREE Cloud MQTT reconnected",
json!({"device_count": devices.iter().filter(|device| device.connection_type == ConnectionType::GreeCloud).count()}), json!({"device_count": devices.iter().filter(|device| device.connection_type == ConnectionType::GreeCloud).count()}),
); );
Ok(Json(json!({"ok": true, "mqtt_status": "connected", "last_successful_contact": now}))) Ok(Json(
json!({"ok": true, "mqtt_status": "connected", "last_successful_contact": now}),
))
} }
async fn cloud_device_diagnostics( async fn cloud_device_diagnostics(
@@ -395,7 +430,9 @@ async fn cloud_device_diagnostics(
.get_device(&id)? .get_device(&id)?
.ok_or_else(|| AppError::NotFound(format!("device {id}")))?; .ok_or_else(|| AppError::NotFound(format!("device {id}")))?;
if device.connection_type != ConnectionType::GreeCloud { if device.connection_type != ConnectionType::GreeCloud {
return Err(AppError::BadRequest("Cloud diagnostics are available only for GREE Cloud devices".into())); return Err(AppError::BadRequest(
"Cloud diagnostics are available only for GREE Cloud devices".into(),
));
} }
let diagnostics = state.providers.cloud().diagnostics(&device.id).await; let diagnostics = state.providers.cloud().diagnostics(&device.id).await;
Ok(Json(json!({ Ok(Json(json!({
+244 -152
View File
@@ -133,11 +133,7 @@ fn sensor_history_with_fallback(
if !matches!(zone.sensor_source.as_str(), "home_assistant" | "combined") { if !matches!(zone.sensor_source.as_str(), "home_assistant" | "combined") {
continue; continue;
} }
let entity_id = zone let entity_id = zone.ha_entity_id.as_deref().map(str::trim).unwrap_or("");
.ha_entity_id
.as_deref()
.map(str::trim)
.unwrap_or("");
if entity_id.is_empty() { if entity_id.is_empty() {
continue; continue;
} }
@@ -177,9 +173,10 @@ fn sensor_history_with_fallback(
if entity_id.is_empty() || existing.contains(entity_id) { if entity_id.is_empty() || existing.contains(entity_id) {
continue; continue;
} }
let rows = state let rows =
.db state
.list_zone_history(Some(&zone.id), since.clone(), bucket_seconds, limit)?; .db
.list_zone_history(Some(&zone.id), since.clone(), bucket_seconds, limit)?;
let mut added = false; let mut added = false;
for row in rows { for row in rows {
if let Some(temperature) = row.outdoor_temperature { if let Some(temperature) = row.outdoor_temperature {
@@ -342,13 +339,7 @@ async fn combined_sensor_history(
.db .db
.list_ha_history(entity_id, start, bucket_seconds, limit)?) .list_ha_history(entity_id, start, bucket_seconds, limit)?)
} else { } else {
sensor_history_with_fallback( sensor_history_with_fallback(state, start, bucket_seconds, limit, outdoor_entity)
state,
start,
bucket_seconds,
limit,
outdoor_entity,
)
} }
}; };
if !influx.enabled || since >= cutoff { if !influx.enabled || since >= cutoff {
@@ -526,6 +517,7 @@ struct PublicChartPoint {
struct PublicChartSeries { struct PublicChartSeries {
key: String, key: String,
label: String, label: String,
label_key: &'static str,
dashed: bool, dashed: bool,
points: Vec<PublicChartPoint>, points: Vec<PublicChartPoint>,
} }
@@ -537,7 +529,9 @@ fn validate_public_chart_spec(series: &[String]) -> Result<(), AppError> {
.iter() .iter()
.any(|item| item.is_empty() || item.len() > 256) .any(|item| item.is_empty() || item.len() > 256)
{ {
return Err(AppError::BadRequest("invalid custom chart definition".into())); return Err(AppError::BadRequest(
"invalid custom chart definition".into(),
));
} }
for key in series { for key in series {
@@ -545,7 +539,7 @@ fn validate_public_chart_spec(series: &[String]) -> Result<(), AppError> {
let kind = parts.next().unwrap_or_default(); let kind = parts.next().unwrap_or_default();
let id = parts.next().unwrap_or_default(); let id = parts.next().unwrap_or_default();
let field = parts.next().unwrap_or_default(); let field = parts.next().unwrap_or_default();
if id.trim().is_empty() || public_chart_field_label(kind, field, "en").is_none() { if id.trim().is_empty() || public_chart_field_label_key(kind, field).is_none() {
return Err(AppError::BadRequest(format!( return Err(AppError::BadRequest(format!(
"unsupported custom chart series: {key}" "unsupported custom chart series: {key}"
))); )));
@@ -568,22 +562,17 @@ async fn create_public_custom_chart(
validate_public_chart_spec(&input.series)?; validate_public_chart_spec(&input.series)?;
let hours = input.hours.unwrap_or(24).clamp(1, 24 * 3650); let hours = input.hours.unwrap_or(24).clamp(1, 24 * 3650);
let lang = if input.lang.as_deref() == Some("pl") { let requested_lang = input.lang.as_deref().unwrap_or_default().trim();
"pl" let lang = LANGUAGE_ASSETS
} else { .iter()
"en" .find(|(code, _)| *code == requested_lang)
}; .map(|(code, _)| (*code).to_string())
let default_title = if lang == "pl" { .unwrap_or_else(|| DEFAULT_LANGUAGE_CODE.to_string());
"Wykres niestandardowy"
} else {
"Custom chart"
};
let title = input let title = input
.title .title
.as_deref() .as_deref()
.map(str::trim) .map(str::trim)
.filter(|value| !value.is_empty()) .unwrap_or_default()
.unwrap_or(default_title)
.chars() .chars()
.take(120) .take(120)
.collect::<String>(); .collect::<String>();
@@ -591,7 +580,7 @@ async fn create_public_custom_chart(
title, title,
series: input.series, series: input.series,
hours, hours,
lang: lang.to_string(), lang,
}; };
let token = generate_public_chart_token(); let token = generate_public_chart_token();
@@ -605,20 +594,19 @@ async fn create_public_custom_chart(
}))) })))
} }
fn public_chart_field_label(kind: &str, field: &str, lang: &str) -> Option<&'static str> { fn public_chart_field_label_key(kind: &str, field: &str) -> Option<&'static str> {
let pl = lang == "pl";
match (kind, field) { match (kind, field) {
("device", "indoor") => Some(if pl { "Temperatura wewnętrzna" } else { "Indoor temperature" }), ("device", "indoor") => Some("publicChart.field.deviceIndoor"),
("device", "outdoor") => Some(if pl { "Temperatura zewnętrzna GREE" } else { "GREE outdoor temperature" }), ("device", "outdoor") => Some("publicChart.field.deviceOutdoor"),
("device", "target") => Some(if pl { "Temperatura zadana urządzenia" } else { "Device target" }), ("device", "target") => Some("publicChart.field.deviceTarget"),
("installation", "outdoor") => Some(if pl { "Wspólna temperatura zewnętrzna" } else { "Shared outdoor temperature" }), ("installation", "outdoor") => Some("publicChart.field.sharedOutdoor"),
("zone", "control") => Some(if pl { "Temperatura sterująca" } else { "Control temperature" }), ("zone", "control") => Some("publicChart.field.zoneControl"),
("zone", "gree") => Some(if pl { "Czujnik GREE" } else { "GREE sensor" }), ("zone", "gree") => Some("publicChart.field.greeSensor"),
("zone", "external") => Some(if pl { "Czujnik pomieszczenia" } else { "Room sensor" }), ("zone", "external") => Some("publicChart.field.roomSensor"),
("zone", "target") => Some(if pl { "Temperatura docelowa" } else { "Comfort target" }), ("zone", "target") => Some("publicChart.field.comfortTarget"),
("zone", "device_target") => Some(if pl { "Nastawa urządzenia" } else { "Device setpoint" }), ("zone", "device_target") => Some("publicChart.field.deviceSetpoint"),
("zone", "outdoor") => Some(if pl { "Temperatura zewnętrzna" } else { "Outdoor temperature" }), ("zone", "outdoor") => Some("publicChart.field.outdoor"),
("ha", "temperature") => Some(if pl { "Temperatura" } else { "Temperature" }), ("ha", "temperature") => Some("publicChart.field.temperature"),
_ => None, _ => None,
} }
} }
@@ -632,7 +620,10 @@ fn reading_points(rows: Vec<Reading>, field: &str) -> Vec<PublicChartPoint> {
"target" => Some(row.target_temperature), "target" => Some(row.target_temperature),
_ => None, _ => None,
}?; }?;
value.is_finite().then_some(PublicChartPoint { timestamp: row.timestamp, value }) value.is_finite().then_some(PublicChartPoint {
timestamp: row.timestamp,
value,
})
}) })
.collect() .collect()
} }
@@ -649,7 +640,10 @@ fn zone_reading_points(rows: Vec<ZoneReading>, field: &str) -> Vec<PublicChartPo
"outdoor" => row.outdoor_temperature, "outdoor" => row.outdoor_temperature,
_ => None, _ => None,
}?; }?;
value.is_finite().then_some(PublicChartPoint { timestamp: row.timestamp, value }) value.is_finite().then_some(PublicChartPoint {
timestamp: row.timestamp,
value,
})
}) })
.collect() .collect()
} }
@@ -670,7 +664,11 @@ async fn public_custom_chart(
validate_public_chart_spec(&share.series)?; validate_public_chart_spec(&share.series)?;
let hours = query.hours.unwrap_or(share.hours).clamp(1, 24 * 3650); let hours = query.hours.unwrap_or(share.hours).clamp(1, 24 * 3650);
let lang = if share.lang == "pl" { "pl" } else { "en" }; let lang = LANGUAGE_ASSETS
.iter()
.find(|(code, _)| *code == share.lang.as_str())
.map(|(code, _)| *code)
.unwrap_or(DEFAULT_LANGUAGE_CODE);
let since = Utc::now() - ChronoDuration::hours(hours); let since = Utc::now() - ChronoDuration::hours(hours);
let bucket_seconds = history_bucket_seconds(hours); let bucket_seconds = history_bucket_seconds(hours);
let limit = 20_000; let limit = 20_000;
@@ -683,43 +681,73 @@ async fn public_custom_chart(
let kind = parts.next().unwrap_or_default(); let kind = parts.next().unwrap_or_default();
let id = parts.next().unwrap_or_default(); let id = parts.next().unwrap_or_default();
let field = parts.next().unwrap_or_default(); let field = parts.next().unwrap_or_default();
let field_label = public_chart_field_label(kind, field, lang) let label_key = public_chart_field_label_key(kind, field).ok_or_else(|| {
.ok_or_else(|| AppError::BadRequest(format!("unsupported custom chart series: {key}")))?; AppError::BadRequest(format!("unsupported custom chart series: {key}"))
})?;
let item = match kind { let item = match kind {
"device" => { "device" => {
let device = state.db.get_device(id)? let device = state
.db
.get_device(id)?
.ok_or_else(|| AppError::NotFound(format!("device {id}")))?; .ok_or_else(|| AppError::NotFound(format!("device {id}")))?;
let (rows, _, _) = combined_device_history(&state, Some(id), since.clone(), bucket_seconds, limit).await?; let (rows, _, _) =
combined_device_history(&state, Some(id), since.clone(), bucket_seconds, limit)
.await?;
PublicChartSeries { PublicChartSeries {
key: key.clone(), key: key.clone(),
label: format!("{} · {}", device.name, field_label), label: device.name,
label_key,
dashed: field == "target", dashed: field == "target",
points: reading_points(rows, field), points: reading_points(rows, field),
} }
} }
"installation" => { "installation" => {
let group = state.db.get_device_group(id)? let group = state
.db
.get_device_group(id)?
.ok_or_else(|| AppError::NotFound(format!("device group {id}")))?; .ok_or_else(|| AppError::NotFound(format!("device group {id}")))?;
let representative = group.outdoor_temperature_device_id.as_deref() let representative = group
.filter(|device_id| group.device_ids.iter().any(|member| member.as_str() == *device_id)) .outdoor_temperature_device_id
.as_deref()
.filter(|device_id| {
group
.device_ids
.iter()
.any(|member| member.as_str() == *device_id)
})
.or_else(|| group.device_ids.first().map(String::as_str)) .or_else(|| group.device_ids.first().map(String::as_str))
.ok_or_else(|| AppError::BadRequest(format!("device group {id} has no devices")))?; .ok_or_else(|| {
let (rows, _, _) = combined_device_history(&state, Some(representative), since.clone(), bucket_seconds, limit).await?; AppError::BadRequest(format!("device group {id} has no devices"))
})?;
let (rows, _, _) = combined_device_history(
&state,
Some(representative),
since.clone(),
bucket_seconds,
limit,
)
.await?;
PublicChartSeries { PublicChartSeries {
key: key.clone(), key: key.clone(),
label: format!("{} · {}", group.name, field_label), label: group.name,
label_key,
dashed: false, dashed: false,
points: reading_points(rows, "outdoor"), points: reading_points(rows, "outdoor"),
} }
} }
"zone" => { "zone" => {
let zone = state.db.get_zone(id)? let zone = state
.db
.get_zone(id)?
.ok_or_else(|| AppError::NotFound(format!("zone {id}")))?; .ok_or_else(|| AppError::NotFound(format!("zone {id}")))?;
let (rows, _, _) = combined_zone_history(&state, Some(id), since.clone(), bucket_seconds, limit).await?; let (rows, _, _) =
combined_zone_history(&state, Some(id), since.clone(), bucket_seconds, limit)
.await?;
PublicChartSeries { PublicChartSeries {
key: key.clone(), key: key.clone(),
label: format!("{} · {}", zone.name, field_label), label: zone.name,
label_key,
dashed: matches!(field, "target" | "device_target" | "outdoor"), dashed: matches!(field, "target" | "device_target" | "outdoor"),
points: zone_reading_points(rows, field), points: zone_reading_points(rows, field),
} }
@@ -732,36 +760,40 @@ async fn public_custom_chart(
bucket_seconds, bucket_seconds,
limit, limit,
&outdoor_entity, &outdoor_entity,
).await?; )
let alias = ha_settings.sensor_aliases.get(id) .await?;
let alias = ha_settings
.sensor_aliases
.get(id)
.map(String::as_str) .map(String::as_str)
.filter(|value| !value.trim().is_empty()) .filter(|value| !value.trim().is_empty())
.unwrap_or(id); .unwrap_or(id);
PublicChartSeries { PublicChartSeries {
key: key.clone(), key: key.clone(),
label: format!("HA · {} · {}", alias, field_label), label: format!("HA · {}", alias),
label_key,
dashed: true, dashed: true,
points: rows.into_iter() points: rows
.into_iter()
.filter(|row| row.entity_id == id && row.temperature.is_finite()) .filter(|row| row.entity_id == id && row.temperature.is_finite())
.map(|row| PublicChartPoint { timestamp: row.timestamp, value: row.temperature }) .map(|row| PublicChartPoint {
timestamp: row.timestamp,
value: row.temperature,
})
.collect(), .collect(),
} }
} }
_ => return Err(AppError::BadRequest(format!("unsupported custom chart series: {key}"))), _ => {
return Err(AppError::BadRequest(format!(
"unsupported custom chart series: {key}"
)))
}
}; };
series.push(item); series.push(item);
} }
let (hint, no_data_label) = if lang == "pl" {
(format!("Ostatnie {hours} h"), "Brak danych")
} else {
(format!("Last {hours} h"), "No data")
};
Ok(Json(json!({ Ok(Json(json!({
"title": share.title, "title": share.title,
"hint": hint,
"no_data_label": no_data_label,
"lang": lang, "lang": lang,
"hours": hours, "hours": hours,
"bucket_seconds": bucket_seconds, "bucket_seconds": bucket_seconds,
@@ -814,7 +846,12 @@ async fn energy_history(
"previous_day" => Some(1), "previous_day" => Some(1),
"previous_period" => Some(days), "previous_period" => Some(days),
"previous_year" => Some(365), "previous_year" => Some(365),
_ => return Err(AppError::BadRequest("energy compare must be none, previous_day, previous_period or previous_year".into())), _ => {
return Err(AppError::BadRequest(
"energy compare must be none, previous_day, previous_period or previous_year"
.into(),
))
}
}; };
let requested_source = query.source.as_deref().unwrap_or("auto"); let requested_source = query.source.as_deref().unwrap_or("auto");
@@ -824,66 +861,75 @@ async fn energy_history(
)); ));
} }
let (target_type, public_target_id, target_name, member_device_ids, configured_source, storage_id, preferred_source) = let (
if let Some(group_id) = target_id.strip_prefix("group:") { target_type,
let group = state public_target_id,
.db target_name,
.get_device_group(group_id)? member_device_ids,
.ok_or_else(|| AppError::NotFound(format!("device group {group_id}")))?; configured_source,
let auto_source = if group.energy_device_id.is_some() { storage_id,
Some("gree_cloud") preferred_source,
} else if group.ha_energy_entity_id.is_some() { ) = if let Some(group_id) = target_id.strip_prefix("group:") {
Some("home_assistant") let group = state
} else { .db
None .get_device_group(group_id)?
}; .ok_or_else(|| AppError::NotFound(format!("device group {group_id}")))?;
let selected = match requested_source { let auto_source = if group.energy_device_id.is_some() {
"gree_cloud" => Some("gree_cloud"), Some("gree_cloud")
"home_assistant" => Some("home_assistant"), } else if group.ha_energy_entity_id.is_some() {
_ => match group.energy_source { Some("home_assistant")
EnergySourcePreference::GreeCloud => Some("gree_cloud"),
EnergySourcePreference::HomeAssistant => Some("home_assistant"),
EnergySourcePreference::Auto => auto_source,
},
};
let storage_id = match selected {
Some("gree_cloud") => group.energy_device_id.clone().ok_or_else(|| AppError::BadRequest("installation has no GREE Cloud energy source device".into()))?,
_ => format!("group:{}", group.id),
};
(
"group",
format!("group:{}", group.id),
group.name,
group.device_ids,
group.energy_source,
storage_id,
selected,
)
} else { } else {
let device = state None
.db
.get_device(target_id)?
.ok_or_else(|| AppError::NotFound(format!("device {target_id}")))?;
let selected = match requested_source {
"gree_cloud" => Some("gree_cloud"),
"home_assistant" => Some("home_assistant"),
"auto" => match device.energy_source {
EnergySourcePreference::GreeCloud => Some("gree_cloud"),
EnergySourcePreference::HomeAssistant => Some("home_assistant"),
EnergySourcePreference::Auto => None,
},
_ => None,
};
(
"device",
device.id.clone(),
device.name,
vec![device.id.clone()],
device.energy_source,
device.id,
selected,
)
}; };
let selected = match requested_source {
"gree_cloud" => Some("gree_cloud"),
"home_assistant" => Some("home_assistant"),
_ => match group.energy_source {
EnergySourcePreference::GreeCloud => Some("gree_cloud"),
EnergySourcePreference::HomeAssistant => Some("home_assistant"),
EnergySourcePreference::Auto => auto_source,
},
};
let storage_id = match selected {
Some("gree_cloud") => group.energy_device_id.clone().ok_or_else(|| {
AppError::BadRequest("installation has no GREE Cloud energy source device".into())
})?,
_ => format!("group:{}", group.id),
};
(
"group",
format!("group:{}", group.id),
group.name,
group.device_ids,
group.energy_source,
storage_id,
selected,
)
} else {
let device = state
.db
.get_device(target_id)?
.ok_or_else(|| AppError::NotFound(format!("device {target_id}")))?;
let selected = match requested_source {
"gree_cloud" => Some("gree_cloud"),
"home_assistant" => Some("home_assistant"),
"auto" => match device.energy_source {
EnergySourcePreference::GreeCloud => Some("gree_cloud"),
EnergySourcePreference::HomeAssistant => Some("home_assistant"),
EnergySourcePreference::Auto => None,
},
_ => None,
};
(
"device",
device.id.clone(),
device.name,
vec![device.id.clone()],
device.energy_source,
device.id,
selected,
)
};
let now = Utc::now(); let now = Utc::now();
let since = now - ChronoDuration::days(days); let since = now - ChronoDuration::days(days);
@@ -943,11 +989,15 @@ async fn energy_history(
"InfluxDB energy history query failed", "InfluxDB energy history query failed",
json!({"target_id": public_target_id, "storage_id": storage_id, "error": err.to_string()}), json!({"target_id": public_target_id, "storage_id": storage_id, "error": err.to_string()}),
); );
state.db.list_energy_readings(&storage_id, load_since, limit)? state
.db
.list_energy_readings(&storage_id, load_since, limit)?
} }
} }
} else { } else {
state.db.list_energy_readings(&storage_id, load_since, limit)? state
.db
.list_energy_readings(&storage_id, load_since, limit)?
}; };
samples.sort_by_key(|row| row.timestamp); samples.sort_by_key(|row| row.timestamp);
@@ -976,18 +1026,27 @@ async fn energy_history(
Utc, Utc,
) )
} }
fn bucket_start(timestamp: chrono::DateTime<Utc>, interval_name: &str) -> chrono::DateTime<Utc> { fn bucket_start(
timestamp: chrono::DateTime<Utc>,
interval_name: &str,
) -> chrono::DateTime<Utc> {
let date = timestamp.date_naive(); let date = timestamp.date_naive();
match interval_name { match interval_name {
"hourly" => chrono::DateTime::<Utc>::from_naive_utc_and_offset( "hourly" => chrono::DateTime::<Utc>::from_naive_utc_and_offset(
date.and_hms_opt(timestamp.hour(), 0, 0).expect("valid hour"), date.and_hms_opt(timestamp.hour(), 0, 0)
.expect("valid hour"),
Utc, Utc,
), ),
"weekly" => { "weekly" => {
let iso = date.iso_week(); let iso = date.iso_week();
midnight(NaiveDate::from_isoywd_opt(iso.year(), iso.week(), Weekday::Mon).expect("valid ISO week")) midnight(
NaiveDate::from_isoywd_opt(iso.year(), iso.week(), Weekday::Mon)
.expect("valid ISO week"),
)
} }
"monthly" => midnight(NaiveDate::from_ymd_opt(date.year(), date.month(), 1).expect("valid month")), "monthly" => midnight(
NaiveDate::from_ymd_opt(date.year(), date.month(), 1).expect("valid month"),
),
_ => midnight(date), _ => midnight(date),
} }
} }
@@ -999,18 +1058,26 @@ async fn energy_history(
let mut buckets: BTreeMap<chrono::DateTime<Utc>, f64> = BTreeMap::new(); let mut buckets: BTreeMap<chrono::DateTime<Utc>, f64> = BTreeMap::new();
for sample in rows { for sample in rows {
let shifted = sample.timestamp + ChronoDuration::days(shift_days); let shifted = sample.timestamp + ChronoDuration::days(shift_days);
*buckets.entry(bucket_start(shifted, interval_name)).or_default() += sample.consumption_kwh.max(0.0); *buckets
.entry(bucket_start(shifted, interval_name))
.or_default() += sample.consumption_kwh.max(0.0);
} }
buckets.into_iter().map(|(start, consumption_kwh)| json!({"start": start, "consumption_kwh": consumption_kwh.max(0.0)})).collect() buckets.into_iter().map(|(start, consumption_kwh)| json!({"start": start, "consumption_kwh": consumption_kwh.max(0.0)})).collect()
} }
let period_samples = samples.iter().filter(|row| row.timestamp >= since && row.timestamp <= now).collect::<Vec<_>>(); let period_samples = samples
.iter()
.filter(|row| row.timestamp >= since && row.timestamp <= now)
.collect::<Vec<_>>();
let buckets = bucket_rows(period_samples.iter().copied(), interval_name, 0); let buckets = bucket_rows(period_samples.iter().copied(), interval_name, 0);
let comparison = if let Some(shift) = compare_shift_days { let comparison = if let Some(shift) = compare_shift_days {
let compare_end = now - ChronoDuration::days(shift); let compare_end = now - ChronoDuration::days(shift);
let compare_start = since - ChronoDuration::days(shift); let compare_start = since - ChronoDuration::days(shift);
let rows = samples.iter().filter(|row| row.timestamp >= compare_start && row.timestamp <= compare_end).collect::<Vec<_>>(); let rows = samples
.iter()
.filter(|row| row.timestamp >= compare_start && row.timestamp <= compare_end)
.collect::<Vec<_>>();
let total: f64 = rows.iter().map(|row| row.consumption_kwh.max(0.0)).sum(); let total: f64 = rows.iter().map(|row| row.consumption_kwh.max(0.0)).sum();
Some(json!({ Some(json!({
"kind": compare_name, "kind": compare_name,
@@ -1026,13 +1093,24 @@ async fn energy_history(
let today_start = midnight(now.date_naive()); let today_start = midnight(now.date_naive());
let yesterday_start = today_start - ChronoDuration::days(1); let yesterday_start = today_start - ChronoDuration::days(1);
let month_start = midnight(NaiveDate::from_ymd_opt(now.year(), now.month(), 1).expect("valid current month")); let month_start =
midnight(NaiveDate::from_ymd_opt(now.year(), now.month(), 1).expect("valid current month"));
let previous_month_date = month_start.date_naive() - ChronoDuration::days(1); let previous_month_date = month_start.date_naive() - ChronoDuration::days(1);
let previous_month_start = midnight(NaiveDate::from_ymd_opt(previous_month_date.year(), previous_month_date.month(), 1).expect("valid previous month")); let previous_month_start = midnight(
NaiveDate::from_ymd_opt(previous_month_date.year(), previous_month_date.month(), 1)
.expect("valid previous month"),
);
let sum_range = |start: chrono::DateTime<Utc>, stop: chrono::DateTime<Utc>| -> f64 { let sum_range = |start: chrono::DateTime<Utc>, stop: chrono::DateTime<Utc>| -> f64 {
samples.iter().filter(|row| row.timestamp >= start && row.timestamp < stop).map(|row| row.consumption_kwh.max(0.0)).sum() samples
.iter()
.filter(|row| row.timestamp >= start && row.timestamp < stop)
.map(|row| row.consumption_kwh.max(0.0))
.sum()
}; };
let period_total: f64 = period_samples.iter().map(|row| row.consumption_kwh.max(0.0)).sum(); let period_total: f64 = period_samples
.iter()
.map(|row| row.consumption_kwh.max(0.0))
.sum();
let latest = samples.last().cloned(); let latest = samples.last().cloned();
Ok(Json(json!({ Ok(Json(json!({
@@ -1079,7 +1157,9 @@ async fn combined_network_history(
let cutoff = Utc::now() - ChronoDuration::days(influx.history_threshold_days.max(1) as i64); let cutoff = Utc::now() - ChronoDuration::days(influx.history_threshold_days.max(1) as i64);
if !influx.enabled || since >= cutoff { if !influx.enabled || since >= cutoff {
return Ok(( return Ok((
state.db.list_network_history(target_id, since, bucket_seconds, limit)?, state
.db
.list_network_history(target_id, since, bucket_seconds, limit)?,
"sqlite".into(), "sqlite".into(),
None, None,
)); ));
@@ -1105,17 +1185,27 @@ async fn combined_network_history(
"InfluxDB connectivity history query failed", "InfluxDB connectivity history query failed",
json!({"target_id": target_id, "error": err.to_string()}), json!({"target_id": target_id, "error": err.to_string()}),
); );
state.db.list_network_history(target_id, since, bucket_seconds, limit)? state
.db
.list_network_history(target_id, since, bucket_seconds, limit)?
} }
}; };
if warning.is_none() { if warning.is_none() {
values.extend(state.db.list_network_history(target_id, cutoff, bucket_seconds, limit)?); values.extend(
state
.db
.list_network_history(target_id, cutoff, bucket_seconds, limit)?,
);
} }
values.sort_by_key(|row| row.timestamp); values.sort_by_key(|row| row.timestamp);
trim_history(&mut values, limit); trim_history(&mut values, limit);
Ok(( Ok((
values, values,
if warning.is_some() { "sqlite_fallback".into() } else { "influx+sqlite".into() }, if warning.is_some() {
"sqlite_fallback".into()
} else {
"influx+sqlite".into()
},
warning, warning,
)) ))
} }
@@ -1149,10 +1239,12 @@ async fn network_history(
} }
let has_rest = readings.iter().any(|row| row.target_id == "cloud:rest"); let has_rest = readings.iter().any(|row| row.target_id == "cloud:rest");
let has_mqtt = readings.iter().any(|row| row.target_id == "cloud:mqtt"); let has_mqtt = readings.iter().any(|row| row.target_id == "cloud:mqtt");
if (settings.gree_cloud.enabled && settings.gree_cloud.connectivity_metrics_enabled) || has_rest { if (settings.gree_cloud.enabled && settings.gree_cloud.connectivity_metrics_enabled) || has_rest
{
targets.push(json!({"id":"cloud:rest","name":"GREE Cloud REST","kind":"cloud_service","source":"cloud_rest"})); targets.push(json!({"id":"cloud:rest","name":"GREE Cloud REST","kind":"cloud_service","source":"cloud_rest"}));
} }
if (settings.gree_cloud.enabled && settings.gree_cloud.connectivity_metrics_enabled) || has_mqtt { if (settings.gree_cloud.enabled && settings.gree_cloud.connectivity_metrics_enabled) || has_mqtt
{
targets.push(json!({"id":"cloud:mqtt","name":"GREE Cloud MQTT","kind":"cloud_service","source":"cloud_mqtt"})); targets.push(json!({"id":"cloud:mqtt","name":"GREE Cloud MQTT","kind":"cloud_service","source":"cloud_mqtt"}));
} }
Ok(Json(json!({ Ok(Json(json!({
+7 -7
View File
@@ -29,9 +29,7 @@ fn map_notification_test_error(message: String) -> AppError {
} }
} }
async fn home_assistant_snapshot( async fn home_assistant_snapshot(State(state): State<AppState>) -> Result<Json<Value>, AppError> {
State(state): State<AppState>,
) -> Result<Json<Value>, AppError> {
let control_plan = engine::get_control_plan_snapshot(&state).await?; let control_plan = engine::get_control_plan_snapshot(&state).await?;
let groups = list_home_assistant_groups(State(state.clone())).await?.0; let groups = list_home_assistant_groups(State(state.clone())).await?.0;
Ok(Json(json!({ Ok(Json(json!({
@@ -42,9 +40,7 @@ async fn home_assistant_snapshot(
}))) })))
} }
async fn test_home_assistant( async fn test_home_assistant(State(state): State<AppState>) -> Result<Json<Value>, AppError> {
State(state): State<AppState>,
) -> Result<Json<Value>, AppError> {
let settings = state.settings.read().await.clone(); let settings = state.settings.read().await.clone();
let sample = home_assistant::test_connection(&state.http, &settings.home_assistant) let sample = home_assistant::test_connection(&state.http, &settings.home_assistant)
.await .await
@@ -92,7 +88,11 @@ async fn list_home_assistant_entities(
a.get("entity_id") a.get("entity_id")
.and_then(Value::as_str) .and_then(Value::as_str)
.unwrap_or_default() .unwrap_or_default()
.cmp(b.get("entity_id").and_then(Value::as_str).unwrap_or_default()) .cmp(
b.get("entity_id")
.and_then(Value::as_str)
.unwrap_or_default(),
)
}); });
Ok(Json(json!({ Ok(Json(json!({
"configured": true, "configured": true,
+24 -6
View File
@@ -28,7 +28,9 @@ fn gree_cloud_settings(settings: &RuntimeSettings) -> GreeCloudSettingsView {
password_configured: !settings.gree_cloud.password.is_empty(), password_configured: !settings.gree_cloud.password.is_empty(),
polling_interval_seconds: settings.gree_cloud.polling_interval_seconds, polling_interval_seconds: settings.gree_cloud.polling_interval_seconds,
connectivity_metrics_enabled: settings.gree_cloud.connectivity_metrics_enabled, connectivity_metrics_enabled: settings.gree_cloud.connectivity_metrics_enabled,
connectivity_metrics_interval_seconds: settings.gree_cloud.connectivity_metrics_interval_seconds, connectivity_metrics_interval_seconds: settings
.gree_cloud
.connectivity_metrics_interval_seconds,
connectivity_metrics_sample_count: settings.gree_cloud.connectivity_metrics_sample_count, connectivity_metrics_sample_count: settings.gree_cloud.connectivity_metrics_sample_count,
installation_id: settings.gree_cloud.installation_id.clone(), installation_id: settings.gree_cloud.installation_id.clone(),
account_id: settings.gree_cloud.account_id.clone(), account_id: settings.gree_cloud.account_id.clone(),
@@ -243,10 +245,7 @@ async fn update_gree_settings(
Ok(Json(payload)) Ok(Json(payload))
} }
async fn get_gree_cloud_settings(State(state): State<AppState>) -> Json<GreeCloudSettingsView> {
async fn get_gree_cloud_settings(
State(state): State<AppState>,
) -> Json<GreeCloudSettingsView> {
Json(gree_cloud_settings(&*state.settings.read().await)) Json(gree_cloud_settings(&*state.settings.read().await))
} }
@@ -266,7 +265,8 @@ fn apply_gree_cloud_update(
next.username = input.username.trim().to_string(); next.username = input.username.trim().to_string();
next.polling_interval_seconds = input.polling_interval_seconds.clamp(30, 3600); next.polling_interval_seconds = input.polling_interval_seconds.clamp(30, 3600);
next.connectivity_metrics_enabled = input.connectivity_metrics_enabled; next.connectivity_metrics_enabled = input.connectivity_metrics_enabled;
next.connectivity_metrics_interval_seconds = input.connectivity_metrics_interval_seconds.clamp(30, 3600); next.connectivity_metrics_interval_seconds =
input.connectivity_metrics_interval_seconds.clamp(30, 3600);
next.connectivity_metrics_sample_count = input.connectivity_metrics_sample_count.clamp(1, 10); next.connectivity_metrics_sample_count = input.connectivity_metrics_sample_count.clamp(1, 10);
if let Some(password) = input.password { if let Some(password) = input.password {
next.password = password; next.password = password;
@@ -365,6 +365,24 @@ async fn get_influxdb_settings(State(state): State<AppState>) -> Json<InfluxDbSe
Json(influxdb_settings(&*state.settings.read().await)) Json(influxdb_settings(&*state.settings.read().await))
} }
async fn test_influxdb_connection(
State(state): State<AppState>,
Json(mut input): Json<InfluxDbSettingsUpdate>,
) -> Result<Json<Value>, AppError> {
input.enabled = true;
let current = state.settings.read().await.influxdb.clone();
let settings = apply_influxdb_update(&current, input)?;
let started = Instant::now();
influxdb::test_connection(&state.http, &settings)
.await
.map_err(|err| AppError::BadRequest(err.to_string()))?;
Ok(Json(json!({
"ok": true,
"version": settings.version,
"response_time_ms": started.elapsed().as_millis(),
})))
}
fn apply_influxdb_update( fn apply_influxdb_update(
current: &InfluxDbSettings, current: &InfluxDbSettings,
input: InfluxDbSettingsUpdate, input: InfluxDbSettingsUpdate,
+9 -10
View File
@@ -38,7 +38,9 @@ fn device_group_energy_snapshot(
EnergySourcePreference::GreeCloud => Some("gree_cloud"), EnergySourcePreference::GreeCloud => Some("gree_cloud"),
EnergySourcePreference::HomeAssistant => Some("home_assistant"), EnergySourcePreference::HomeAssistant => Some("home_assistant"),
EnergySourcePreference::Auto if group.energy_device_id.is_some() => Some("gree_cloud"), EnergySourcePreference::Auto if group.energy_device_id.is_some() => Some("gree_cloud"),
EnergySourcePreference::Auto if group.ha_energy_entity_id.is_some() => Some("home_assistant"), EnergySourcePreference::Auto if group.ha_energy_entity_id.is_some() => {
Some("home_assistant")
}
EnergySourcePreference::Auto => None, EnergySourcePreference::Auto => None,
}; };
@@ -108,10 +110,7 @@ struct BootstrapResponse {
system: SystemInfoResponse, system: SystemInfoResponse,
} }
async fn health( async fn health(State(state): State<AppState>, request: Request) -> Result<Json<Value>, AppError> {
State(state): State<AppState>,
request: Request,
) -> Result<Json<Value>, AppError> {
if home_assistant::supervisor_token_detected() { if home_assistant::supervisor_token_detected() {
let trusted_supervisor = request let trusted_supervisor = request
.extensions() .extensions()
@@ -176,11 +175,13 @@ async fn build_bootstrap(state: &AppState) -> Result<BootstrapResponse, AppError
} }
fn build_system_info(state: &AppState, devices: &[Device]) -> SystemInfoResponse { fn build_system_info(state: &AppState, devices: &[Device]) -> SystemInfoResponse {
let (received_frames_total, received_frames_by_device) = state.providers.local().client().received_frame_stats(); let (received_frames_total, received_frames_by_device) =
state.providers.local().client().received_frame_stats();
SystemInfoResponse { SystemInfoResponse {
version: env!("CARGO_PKG_VERSION"), version: env!("CARGO_PKG_VERSION"),
uptime_seconds: state.started.elapsed().as_secs(), uptime_seconds: state.started.elapsed().as_secs(),
auth_required: !state.config.app_token.trim().is_empty() || home_assistant::supervisor_token_detected(), auth_required: !state.config.app_token.trim().is_empty()
|| home_assistant::supervisor_token_detected(),
control_ready: state.initial_device_sync_complete.load(Ordering::Acquire), control_ready: state.initial_device_sync_complete.load(Ordering::Acquire),
database: state.config.database.display().to_string(), database: state.config.database.display().to_string(),
device_count: devices.len(), device_count: devices.len(),
@@ -203,9 +204,7 @@ fn build_system_info(state: &AppState, devices: &[Device]) -> SystemInfoResponse
} }
} }
async fn system_info( async fn system_info(State(state): State<AppState>) -> Result<Json<SystemInfoResponse>, AppError> {
State(state): State<AppState>,
) -> Result<Json<SystemInfoResponse>, AppError> {
let devices = state.db.list_devices()?; let devices = state.db.list_devices()?;
Ok(Json(build_system_info(&state, &devices))) Ok(Json(build_system_info(&state, &devices)))
} }
+13 -4
View File
@@ -21,7 +21,11 @@ pub struct Config {
pub app_token: String, pub app_token: String,
#[arg(long, env = "GREE_CONTROLLER_BASE_PATH", default_value = "")] #[arg(long, env = "GREE_CONTROLLER_BASE_PATH", default_value = "")]
pub base_path: String, pub base_path: String,
#[arg(long, env = "GREE_CONTROLLER_PUBLIC_CHART_BASE_URL", default_value = "")] #[arg(
long,
env = "GREE_CONTROLLER_PUBLIC_CHART_BASE_URL",
default_value = ""
)]
pub public_chart_base_url: String, pub public_chart_base_url: String,
#[arg(long, env = "GREE_CONTROLLER_SIMULATE", default_value_t = false)] #[arg(long, env = "GREE_CONTROLLER_SIMULATE", default_value_t = false)]
pub simulate: bool, pub simulate: bool,
@@ -62,7 +66,8 @@ impl Config {
dotenvy::dotenv().ok(); dotenvy::dotenv().ok();
let mut config = Self::parse(); let mut config = Self::parse();
config.base_path = normalize_base_path(&config.base_path)?; config.base_path = normalize_base_path(&config.base_path)?;
config.public_chart_base_url = normalize_public_chart_base_url(&config.public_chart_base_url)?; config.public_chart_base_url =
normalize_public_chart_base_url(&config.public_chart_base_url)?;
if let Some(parent) = config.database.parent() { if let Some(parent) = config.database.parent() {
std::fs::create_dir_all(parent).with_context(|| { std::fs::create_dir_all(parent).with_context(|| {
format!("cannot create database directory {}", parent.display()) format!("cannot create database directory {}", parent.display())
@@ -80,8 +85,12 @@ impl Config {
discovery_timeout_ms: self.discovery_timeout_ms.clamp(300, 30_000), discovery_timeout_ms: self.discovery_timeout_ms.clamp(300, 30_000),
discovery_broadcast: self.discovery_broadcast.clone(), discovery_broadcast: self.discovery_broadcast.clone(),
ping_metrics_enabled: env_bool("GREE_CONTROLLER_PING_METRICS_ENABLED").unwrap_or(true), ping_metrics_enabled: env_bool("GREE_CONTROLLER_PING_METRICS_ENABLED").unwrap_or(true),
ping_interval_seconds: env_u64("GREE_CONTROLLER_PING_INTERVAL_SECONDS").unwrap_or(60).clamp(10, 3600), ping_interval_seconds: env_u64("GREE_CONTROLLER_PING_INTERVAL_SECONDS")
ping_sample_count: env_u32("GREE_CONTROLLER_PING_SAMPLE_COUNT").unwrap_or(3).clamp(1, 10), .unwrap_or(60)
.clamp(10, 3600),
ping_sample_count: env_u32("GREE_CONTROLLER_PING_SAMPLE_COUNT")
.unwrap_or(3)
.clamp(1, 10),
house_mode: env::var("GREE_CONTROLLER_HOUSE_MODE").unwrap_or_else(|_| "cool".into()), house_mode: env::var("GREE_CONTROLLER_HOUSE_MODE").unwrap_or_else(|_| "cool".into()),
control_strategy: "setpoint".into(), control_strategy: "setpoint".into(),
outdoor_assist_enabled: env_bool("GREE_CONTROLLER_OUTDOOR_ASSIST_ENABLED") outdoor_assist_enabled: env_bool("GREE_CONTROLLER_OUTDOOR_ASSIST_ENABLED")
+3 -2
View File
@@ -1,7 +1,8 @@
use crate::{ use crate::{
models::{ models::{
ApiTokenInfo, Automation, ClimateGroup, ConfigurationExport, ConnectionType, Device, DeviceGroup, EnergySourcePreference, EventLog, Flow, ApiTokenInfo, Automation, ClimateGroup, ConfigurationExport, ConnectionType, Device,
EnergyReading, HaReading, NetworkReading, Reading, RuntimeSettings, Schedule, Zone, ZoneReading, DeviceGroup, EnergyReading, EnergySourcePreference, EventLog, Flow, HaReading,
NetworkReading, Reading, RuntimeSettings, Schedule, Zone, ZoneReading,
}, },
queries, queries,
}; };
+4 -1
View File
@@ -68,7 +68,10 @@ impl Db {
} }
pub fn device_group_for_device(&self, device_id: &str) -> Result<Option<DeviceGroup>> { pub fn device_group_for_device(&self, device_id: &str) -> Result<Option<DeviceGroup>> {
Ok(self.list_device_groups()?.into_iter().find(|group| group.device_ids.iter().any(|id| id == device_id))) Ok(self
.list_device_groups()?
.into_iter()
.find(|group| group.device_ids.iter().any(|id| id == device_id)))
} }
pub fn delete_device_group(&self, id: &str) -> Result<bool> { pub fn delete_device_group(&self, id: &str) -> Result<bool> {
+9 -2
View File
@@ -22,7 +22,10 @@ impl Db {
let payload = Self::device_to_storage_json(device)?; let payload = Self::device_to_storage_json(device)?;
let index_mac = match device.connection_type { let index_mac = match device.connection_type {
ConnectionType::Local => device.mac.clone(), ConnectionType::Local => device.mac.clone(),
ConnectionType::GreeCloud => format!("cloud:{}", device.cloud_device_id.as_deref().unwrap_or(&device.mac)), ConnectionType::GreeCloud => format!(
"cloud:{}",
device.cloud_device_id.as_deref().unwrap_or(&device.mac)
),
}; };
tx.execute( tx.execute(
queries::UPSERT_DEVICE, queries::UPSERT_DEVICE,
@@ -55,7 +58,11 @@ impl Db {
let payload = Self::to_json(device_group)?; let payload = Self::to_json(device_group)?;
tx.execute( tx.execute(
queries::UPSERT_DEVICE_GROUP, queries::UPSERT_DEVICE_GROUP,
params![device_group.id, payload, device_group.updated_at.to_rfc3339()], params![
device_group.id,
payload,
device_group.updated_at.to_rfc3339()
],
)?; )?;
} }
for schedule in &export.schedules { for schedule in &export.schedules {
+10 -3
View File
@@ -45,7 +45,10 @@ impl Db {
let payload = Self::device_to_storage_json(device)?; let payload = Self::device_to_storage_json(device)?;
let index_mac = match device.connection_type { let index_mac = match device.connection_type {
ConnectionType::Local => device.mac.clone(), ConnectionType::Local => device.mac.clone(),
ConnectionType::GreeCloud => format!("cloud:{}", device.cloud_device_id.as_deref().unwrap_or(&device.mac)), ConnectionType::GreeCloud => format!(
"cloud:{}",
device.cloud_device_id.as_deref().unwrap_or(&device.mac)
),
}; };
let conn = self.lock()?; let conn = self.lock()?;
conn.execute( conn.execute(
@@ -90,7 +93,9 @@ impl Db {
pub fn delete_device(&self, id: &str) -> Result<bool> { pub fn delete_device(&self, id: &str) -> Result<bool> {
for mut group in self.list_device_groups()? { for mut group in self.list_device_groups()? {
if !group.device_ids.iter().any(|device_id| device_id == id) { continue; } if !group.device_ids.iter().any(|device_id| device_id == id) {
continue;
}
group.device_ids.retain(|device_id| device_id != id); group.device_ids.retain(|device_id| device_id != id);
if group.energy_device_id.as_deref() == Some(id) { if group.energy_device_id.as_deref() == Some(id) {
group.energy_device_id = None; group.energy_device_id = None;
@@ -98,7 +103,9 @@ impl Db {
group.energy_source = EnergySourcePreference::Auto; group.energy_source = EnergySourcePreference::Auto;
} }
} }
if group.outdoor_temperature_device_id.as_deref() == Some(id) { group.outdoor_temperature_device_id = None; } if group.outdoor_temperature_device_id.as_deref() == Some(id) {
group.outdoor_temperature_device_id = None;
}
if group.device_ids.is_empty() { if group.device_ids.is_empty() {
self.delete_device_group(&group.id)?; self.delete_device_group(&group.id)?;
} else { } else {
+18 -5
View File
@@ -19,7 +19,11 @@ impl Db {
Ok(conn.last_insert_rowid()) Ok(conn.last_insert_rowid())
} }
pub fn last_energy_reading(&self, device_id: &str, source: &str) -> Result<Option<EnergyReading>> { pub fn last_energy_reading(
&self,
device_id: &str,
source: &str,
) -> Result<Option<EnergyReading>> {
let conn = self.lock()?; let conn = self.lock()?;
conn.query_row( conn.query_row(
"SELECT id,device_id,timestamp,source,raw_meter_value,raw_unit,normalized_meter_kwh,consumption_kwh,current_power_kw,quality,reset_detected FROM energy_readings WHERE device_id=?1 AND source=?2 ORDER BY timestamp DESC,id DESC LIMIT 1", "SELECT id,device_id,timestamp,source,raw_meter_value,raw_unit,normalized_meter_kwh,consumption_kwh,current_power_kw,quality,reset_detected FROM energy_readings WHERE device_id=?1 AND source=?2 ORDER BY timestamp DESC,id DESC LIMIT 1",
@@ -39,10 +43,15 @@ impl Db {
"SELECT id,device_id,timestamp,source,raw_meter_value,raw_unit,normalized_meter_kwh,consumption_kwh,current_power_kw,quality,reset_detected FROM energy_readings WHERE device_id=?1 AND timestamp>=?2 ORDER BY timestamp ASC,id ASC LIMIT ?3", "SELECT id,device_id,timestamp,source,raw_meter_value,raw_unit,normalized_meter_kwh,consumption_kwh,current_power_kw,quality,reset_detected FROM energy_readings WHERE device_id=?1 AND timestamp>=?2 ORDER BY timestamp ASC,id ASC LIMIT ?3",
)?; )?;
let rows = stmt.query_map( let rows = stmt.query_map(
params![device_id, since.to_rfc3339(), limit.clamp(1, 100_000) as i64], params![
device_id,
since.to_rfc3339(),
limit.clamp(1, 100_000) as i64
],
Self::map_energy_reading, Self::map_energy_reading,
)?; )?;
rows.collect::<rusqlite::Result<Vec<_>>>().map_err(Into::into) rows.collect::<rusqlite::Result<Vec<_>>>()
.map_err(Into::into)
} }
pub fn energy_before(&self, before: DateTime<Utc>, limit: u32) -> Result<Vec<EnergyReading>> { pub fn energy_before(&self, before: DateTime<Utc>, limit: u32) -> Result<Vec<EnergyReading>> {
@@ -54,7 +63,8 @@ impl Db {
params![before.to_rfc3339(), limit.clamp(1, 5000) as i64], params![before.to_rfc3339(), limit.clamp(1, 5000) as i64],
Self::map_energy_reading, Self::map_energy_reading,
)?; )?;
rows.collect::<rusqlite::Result<Vec<_>>>().map_err(Into::into) rows.collect::<rusqlite::Result<Vec<_>>>()
.map_err(Into::into)
} }
pub fn delete_energy_batch(&self, rows: &[EnergyReading]) -> Result<u64> { pub fn delete_energy_batch(&self, rows: &[EnergyReading]) -> Result<u64> {
@@ -71,7 +81,10 @@ impl Db {
pub fn prune_energy_readings(&self, retention_days: i64) -> Result<u64> { pub fn prune_energy_readings(&self, retention_days: i64) -> Result<u64> {
let before = Utc::now() - Duration::days(retention_days.max(1)); let before = Utc::now() - Duration::days(retention_days.max(1));
let conn = self.lock()?; let conn = self.lock()?;
Ok(conn.execute("DELETE FROM energy_readings WHERE timestamp < ?1", [before.to_rfc3339()])? as u64) Ok(conn.execute(
"DELETE FROM energy_readings WHERE timestamp < ?1",
[before.to_rfc3339()],
)? as u64)
} }
fn map_energy_reading(row: &rusqlite::Row<'_>) -> rusqlite::Result<EnergyReading> { fn map_energy_reading(row: &rusqlite::Row<'_>) -> rusqlite::Result<EnergyReading> {
+3 -1
View File
@@ -126,7 +126,9 @@ impl Db {
pub fn get_public_chart_share(&self, token_hash: &str) -> Result<Option<Value>> { pub fn get_public_chart_share(&self, token_hash: &str) -> Result<Option<Value>> {
let conn = self.lock()?; let conn = self.lock()?;
let payload: Option<String> = conn let payload: Option<String> = conn
.query_row(queries::GET_PUBLIC_CHART_SHARE, [token_hash], |row| row.get(0)) .query_row(queries::GET_PUBLIC_CHART_SHARE, [token_hash], |row| {
row.get(0)
})
.optional()?; .optional()?;
payload payload
.map(|value| serde_json::from_str(&value)) .map(|value| serde_json::from_str(&value))
+19 -6
View File
@@ -53,14 +53,18 @@ LIMIT ?3
params![target_id, since.to_rfc3339(), bucket_seconds, limit], params![target_id, since.to_rfc3339(), bucket_seconds, limit],
Self::map_network_reading, Self::map_network_reading,
)?; )?;
for row in rows { out.push(row?); } for row in rows {
out.push(row?);
}
} else { } else {
let mut stmt = conn.prepare(sql_all)?; let mut stmt = conn.prepare(sql_all)?;
let rows = stmt.query_map( let rows = stmt.query_map(
params![since.to_rfc3339(), bucket_seconds, limit], params![since.to_rfc3339(), bucket_seconds, limit],
Self::map_network_reading, Self::map_network_reading,
)?; )?;
for row in rows { out.push(row?); } for row in rows {
out.push(row?);
}
} }
Ok(out) Ok(out)
} }
@@ -74,7 +78,8 @@ LIMIT ?3
params![before.to_rfc3339(), limit.clamp(1, 5000) as i64], params![before.to_rfc3339(), limit.clamp(1, 5000) as i64],
Self::map_network_reading, Self::map_network_reading,
)?; )?;
rows.collect::<rusqlite::Result<Vec<_>>>().map_err(Into::into) rows.collect::<rusqlite::Result<Vec<_>>>()
.map_err(Into::into)
} }
pub fn delete_network_batch(&self, rows: &[NetworkReading]) -> Result<u64> { pub fn delete_network_batch(&self, rows: &[NetworkReading]) -> Result<u64> {
@@ -91,7 +96,10 @@ LIMIT ?3
pub fn prune_network_readings(&self, retention_days: i64) -> Result<u64> { pub fn prune_network_readings(&self, retention_days: i64) -> Result<u64> {
let before = Utc::now() - Duration::days(retention_days.max(1)); let before = Utc::now() - Duration::days(retention_days.max(1));
let conn = self.lock()?; let conn = self.lock()?;
Ok(conn.execute("DELETE FROM network_readings WHERE timestamp < ?1", [before.to_rfc3339()])? as u64) Ok(conn.execute(
"DELETE FROM network_readings WHERE timestamp < ?1",
[before.to_rfc3339()],
)? as u64)
} }
pub fn compact_network_history(&self, retention_days: i64) -> Result<u64> { pub fn compact_network_history(&self, retention_days: i64) -> Result<u64> {
@@ -116,8 +124,13 @@ DELETE FROM network_readings WHERE id IN (
(600_i64, one_day, seven_days), (600_i64, one_day, seven_days),
(1800_i64, seven_days, retention), (1800_i64, seven_days, retention),
] { ] {
if older_than <= newer_than { continue; } if older_than <= newer_than {
changed += conn.execute(sql, params![bucket, older_than.to_rfc3339(), newer_than.to_rfc3339()])? as u64; continue;
}
changed += conn.execute(
sql,
params![bucket, older_than.to_rfc3339(), newer_than.to_rfc3339()],
)? as u64;
} }
conn.execute_batch("PRAGMA optimize;")?; conn.execute_batch("PRAGMA optimize;")?;
Ok(changed) Ok(changed)
+12 -5
View File
@@ -86,12 +86,16 @@ mod tests {
"hours": 24, "hours": 24,
"lang": "en" "lang": "en"
}); });
db.save_public_chart_share("chart-hash", &chart_share).unwrap(); db.save_public_chart_share("chart-hash", &chart_share)
.unwrap();
assert_eq!( assert_eq!(
db.get_public_chart_share("chart-hash").unwrap(), db.get_public_chart_share("chart-hash").unwrap(),
Some(chart_share) Some(chart_share)
); );
assert!(db.get_public_chart_share("missing-chart").unwrap().is_none()); assert!(db
.get_public_chart_share("missing-chart")
.unwrap()
.is_none());
let now = Utc::now(); let now = Utc::now();
db.add_reading(&Reading { db.add_reading(&Reading {
@@ -162,8 +166,11 @@ mod tests {
db.save_device(&cloud).unwrap(); db.save_device(&cloud).unwrap();
let devices = db.list_devices().unwrap(); let devices = db.list_devices().unwrap();
assert_eq!(devices.len(), 2); assert_eq!(devices.len(), 2);
assert!(devices.iter().any(|item| item.connection_type == ConnectionType::Local)); assert!(devices
assert!(devices.iter().any(|item| item.connection_type == ConnectionType::GreeCloud)); .iter()
.any(|item| item.connection_type == ConnectionType::Local));
assert!(devices
.iter()
.any(|item| item.connection_type == ConnectionType::GreeCloud));
} }
} }
+5 -3
View File
@@ -2,9 +2,11 @@ use crate::{
error::AppError, error::AppError,
home_assistant, influxdb, home_assistant, influxdb,
models::{ models::{
Automation, AutomationPlanRule, ClimateGroup, ConnectionStatus, ConnectionType, ControlPlan, ControlPlanEvent, Device, DeviceGroup, EnergyReading, EnergySourcePreference, Automation, AutomationPlanRule, ClimateGroup, ConnectionStatus, ConnectionType,
DeviceCommand, GroupControlPatch, HaReading, NetworkReading, NightModeSettings, Reading, RuntimeSettings, ControlPlan, ControlPlanEvent, Device, DeviceCommand, DeviceGroup, EnergyReading,
Schedule, TemporaryQuickThermostat, Zone, ZoneControlPlan, ZoneReading, EnergySourcePreference, GroupControlPatch, HaReading, NetworkReading, NightModeSettings,
Reading, RuntimeSettings, Schedule, TemporaryQuickThermostat, Zone, ZoneControlPlan,
ZoneReading,
}, },
state::{AppState, ControlPlanSnapshot, PendingControllerCommand}, state::{AppState, ControlPlanSnapshot, PendingControllerCommand},
}; };
+37 -14
View File
@@ -81,7 +81,12 @@ async fn send_command_locked_inner(
} }
} }
} }
match state.providers.local().command(&device, &command, suppress_beep).await { match state
.providers
.local()
.command(&device, &command, suppress_beep)
.await
{
Ok(result) => applied_command = result, Ok(result) => applied_command = result,
Err(first_err) => { Err(first_err) => {
// A lost command ACK does not mean the command was lost. Read the device // A lost command ACK does not mean the command was lost. Read the device
@@ -101,7 +106,11 @@ async fn send_command_locked_inner(
if remaining.is_empty() { if remaining.is_empty() {
Ok(command.clone()) Ok(command.clone())
} else { } else {
state.providers.local().command(&device, &remaining, suppress_beep).await state
.providers
.local()
.command(&device, &remaining, suppress_beep)
.await
} }
} }
Err(_) => match state.providers.local().bind(&device).await { Err(_) => match state.providers.local().bind(&device).await {
@@ -109,7 +118,11 @@ async fn send_command_locked_inner(
device.key = Some(bound.key); device.key = Some(bound.key);
device.protocol_version = bound.protocol_version; device.protocol_version = bound.protocol_version;
state.db.save_device(&device)?; state.db.save_device(&device)?;
state.providers.local().command(&device, &command, suppress_beep).await state
.providers
.local()
.command(&device, &command, suppress_beep)
.await
} }
Err(_) => Err(first_err), Err(_) => Err(first_err),
}, },
@@ -277,7 +290,10 @@ async fn send_cloud_command_locked_inner(
} }
device.updated_at = Utc::now(); device.updated_at = Utc::now();
state.db.save_device(&device)?; state.db.save_device(&device)?;
state.broadcast("device.updated", serde_json::to_value(&device).unwrap_or_default()); state.broadcast(
"device.updated",
serde_json::to_value(&device).unwrap_or_default(),
);
let mut transport_device = baseline.clone(); let mut transport_device = baseline.clone();
let applied = match state let applied = match state
@@ -298,7 +314,10 @@ async fn send_cloud_command_locked_inner(
restored.pending_command = false; restored.pending_command = false;
register_cloud_failure(&mut restored, &err.to_string()); register_cloud_failure(&mut restored, &err.to_string());
state.db.save_device(&restored)?; state.db.save_device(&restored)?;
state.broadcast("device.updated", serde_json::to_value(&restored).unwrap_or_default()); state.broadcast(
"device.updated",
serde_json::to_value(&restored).unwrap_or_default(),
);
state.log( state.log(
"warn", "warn",
"gree_cloud.command_rejected", "gree_cloud.command_rejected",
@@ -315,12 +334,8 @@ async fn send_cloud_command_locked_inner(
// MQTT push is the primary confirmation path and one delayed recovery read is scheduled. // MQTT push is the primary confirmation path and one delayed recovery read is scheduled.
let mut accepted = transport_device; let mut accepted = transport_device;
accepted.pending_command = true; accepted.pending_command = true;
accepted.response_time_ms = Some( accepted.response_time_ms =
response_started Some(response_started.elapsed().as_millis().min(u64::MAX as u128) as u64);
.elapsed()
.as_millis()
.min(u64::MAX as u128) as u64,
);
accepted.updated_at = Utc::now(); accepted.updated_at = Utc::now();
accepted.refresh_capabilities(); accepted.refresh_capabilities();
state.db.save_device(&accepted)?; state.db.save_device(&accepted)?;
@@ -351,7 +366,8 @@ async fn send_cloud_command_locked_inner(
let recovery_device_id = accepted.id.clone(); let recovery_device_id = accepted.id.clone();
tokio::spawn(async move { tokio::spawn(async move {
sleep(Duration::from_millis(1500)).await; sleep(Duration::from_millis(1500)).await;
let Some(_cloud_poll_guard) = recovery_state.try_begin_cloud_poll(&recovery_device_id) else { let Some(_cloud_poll_guard) = recovery_state.try_begin_cloud_poll(&recovery_device_id)
else {
tracing::debug!(device=%recovery_device_id, "skipping duplicate GREE Cloud recovery poll"); tracing::debug!(device=%recovery_device_id, "skipping duplicate GREE Cloud recovery poll");
return; return;
}; };
@@ -415,9 +431,16 @@ fn register_cloud_failure(device: &mut Device, error: &str) {
fn cloud_connection_status(error: &str) -> ConnectionStatus { fn cloud_connection_status(error: &str) -> ConnectionStatus {
let error = error.to_ascii_lowercase(); let error = error.to_ascii_lowercase();
if error.contains("authentication") || error.contains("not authorized") || error.contains("invalid username") { if error.contains("authentication")
|| error.contains("not authorized")
|| error.contains("invalid username")
{
ConnectionStatus::AuthenticationError ConnectionStatus::AuthenticationError
} else if error.contains("mqtt") || error.contains("connect") || error.contains("tls") || error.contains("network") { } else if error.contains("mqtt")
|| error.contains("connect")
|| error.contains("tls")
|| error.contains("network")
{
ConnectionStatus::CloudDisconnected ConnectionStatus::CloudDisconnected
} else { } else {
ConnectionStatus::Offline ConnectionStatus::Offline
+20 -8
View File
@@ -16,7 +16,11 @@ fn build_network_reading(
Some(successful.iter().sum::<f64>() / successful.len() as f64) Some(successful.iter().sum::<f64>() / successful.len() as f64)
}; };
let jitter_ms = if successful.len() < 2 { let jitter_ms = if successful.len() < 2 {
if successful.is_empty() { None } else { Some(0.0) } if successful.is_empty() {
None
} else {
Some(0.0)
}
} else { } else {
let diffs = successful let diffs = successful
.windows(2) .windows(2)
@@ -46,7 +50,9 @@ fn build_network_reading(
fn save_network_reading(state: &AppState, reading: NetworkReading) { fn save_network_reading(state: &AppState, reading: NetworkReading) {
match state.db.add_network_reading(&reading) { match state.db.add_network_reading(&reading) {
Ok(_) => queue_influx_network(state, reading), Ok(_) => queue_influx_network(state, reading),
Err(err) => tracing::warn!(error=?err, target_id=%reading.target_id, "cannot save connectivity metric"), Err(err) => {
tracing::warn!(error=?err, target_id=%reading.target_id, "cannot save connectivity metric")
}
} }
} }
@@ -77,9 +83,7 @@ async fn run_local_connectivity_cycle(state: &AppState, sample_count: u32) -> Re
.list_devices()? .list_devices()?
.into_iter() .into_iter()
.filter(|device| { .filter(|device| {
device.enabled device.enabled && !device.simulated && device.connection_type == ConnectionType::Local
&& !device.simulated
&& device.connection_type == ConnectionType::Local
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
futures_util::future::join_all( futures_util::future::join_all(
@@ -91,7 +95,10 @@ async fn run_local_connectivity_cycle(state: &AppState, sample_count: u32) -> Re
Ok(()) Ok(())
} }
async fn cloud_rest_probe(state: &AppState, cloud: &crate::models::GreeCloudSettings) -> Result<u64> { async fn cloud_rest_probe(
state: &AppState,
cloud: &crate::models::GreeCloudSettings,
) -> Result<u64> {
let mut api = crate::protocol::gree_cloud::GreeCloudApi::for_region( let mut api = crate::protocol::gree_cloud::GreeCloudApi::for_region(
state.http.clone(), state.http.clone(),
&cloud.region, &cloud.region,
@@ -149,10 +156,15 @@ async fn local_connectivity_loop(state: AppState) {
loop { loop {
let settings = state.settings.read().await.clone(); let settings = state.settings.read().await.clone();
if settings.ping_metrics_enabled { if settings.ping_metrics_enabled {
if let Err(err) = run_local_connectivity_cycle(&state, settings.ping_sample_count.clamp(1, 10)).await { if let Err(err) =
run_local_connectivity_cycle(&state, settings.ping_sample_count.clamp(1, 10)).await
{
tracing::warn!(error=?err, "local connectivity metrics cycle failed"); tracing::warn!(error=?err, "local connectivity metrics cycle failed");
} }
sleep(Duration::from_secs(settings.ping_interval_seconds.clamp(10, 3600))).await; sleep(Duration::from_secs(
settings.ping_interval_seconds.clamp(10, 3600),
))
.await;
} else { } else {
sleep(Duration::from_secs(10)).await; sleep(Duration::from_secs(10)).await;
} }
+2 -2
View File
@@ -442,8 +442,8 @@ fn next_schedule_events(
let candidate = base + chrono::Duration::minutes(minute); let candidate = base + chrono::Duration::minutes(minute);
let minute_of_day = candidate.hour() * 60 + candidate.minute(); let minute_of_day = candidate.hour() * 60 + candidate.minute();
let previous_local = base + chrono::Duration::minutes(minute - 1); let previous_local = base + chrono::Duration::minutes(minute - 1);
let clock_discontinuity = candidate.naive_local() - previous_local.naive_local() let clock_discontinuity =
!= chrono::Duration::minutes(1); candidate.naive_local() - previous_local.naive_local() != chrono::Duration::minutes(1);
if !boundary_minutes.contains(&minute_of_day) && !clock_discontinuity { if !boundary_minutes.contains(&minute_of_day) && !clock_discontinuity {
continue; continue;
} }
+100 -25
View File
@@ -1,6 +1,9 @@
const ENERGY_ANOMALY_MAX_DELTA_KWH: f64 = 100.0; const ENERGY_ANOMALY_MAX_DELTA_KWH: f64 = 100.0;
fn cumulative_energy_delta(previous_kwh: Option<f64>, current_kwh: f64) -> (f64, &'static str, bool) { fn cumulative_energy_delta(
previous_kwh: Option<f64>,
current_kwh: f64,
) -> (f64, &'static str, bool) {
let Some(previous_kwh) = previous_kwh else { let Some(previous_kwh) = previous_kwh else {
return (0.0, "baseline", false); return (0.0, "baseline", false);
}; };
@@ -18,13 +21,17 @@ fn cumulative_energy_delta(previous_kwh: Option<f64>, current_kwh: f64) -> (f64,
fn normalize_energy_kwh(raw_value: f64, unit: &str) -> Result<f64, AppError> { fn normalize_energy_kwh(raw_value: f64, unit: &str) -> Result<f64, AppError> {
if !raw_value.is_finite() || raw_value < 0.0 { if !raw_value.is_finite() || raw_value < 0.0 {
return Err(AppError::BadRequest("energy meter value must be a finite non-negative number".into())); return Err(AppError::BadRequest(
"energy meter value must be a finite non-negative number".into(),
));
} }
match unit.trim().to_ascii_lowercase().as_str() { match unit.trim().to_ascii_lowercase().as_str() {
"kwh" => Ok(raw_value), "kwh" => Ok(raw_value),
"wh" => Ok(raw_value / 1000.0), "wh" => Ok(raw_value / 1000.0),
"0.1kwh" | "0.1 kwh" => Ok(raw_value * 0.1), "0.1kwh" | "0.1 kwh" => Ok(raw_value * 0.1),
other => Err(AppError::BadRequest(format!("unsupported energy unit: {other}"))), other => Err(AppError::BadRequest(format!(
"unsupported energy unit: {other}"
))),
} }
} }
@@ -73,37 +80,77 @@ fn queue_influx_energy(state: &AppState, reading: EnergyReading) {
let state = state.clone(); let state = state.clone();
tokio::spawn(async move { tokio::spawn(async move {
let settings = state.settings.read().await.influxdb.clone(); let settings = state.settings.read().await.influxdb.clone();
if !settings.enabled { return; } if !settings.enabled {
return;
}
if let Err(err) = influxdb::write_energy(&state.http, &settings, &reading).await { if let Err(err) = influxdb::write_energy(&state.http, &settings, &reading).await {
tracing::warn!(error=?err, device_id=%reading.device_id, source=%reading.source, "cannot write energy metric to InfluxDB"); tracing::warn!(error=?err, device_id=%reading.device_id, source=%reading.source, "cannot write energy metric to InfluxDB");
} }
}); });
} }
async fn sample_home_assistant_energy_target(state: &AppState, target_id: &str, entity_id: &str) -> Result<(), AppError> { async fn sample_home_assistant_energy_target(
state: &AppState,
target_id: &str,
entity_id: &str,
) -> Result<(), AppError> {
let settings = state.settings.read().await.home_assistant.clone(); let 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::Dependency(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
if matches!(state_value, "" | "unknown" | "unavailable") { return Ok(()); } .get("state")
let raw_value: f64 = state_value.parse().map_err(|_| AppError::Dependency("Home Assistant energy state is not numeric".into()))?; .and_then(Value::as_str)
let attrs = payload.get("attributes").and_then(Value::as_object).cloned().unwrap_or_default(); .unwrap_or_default();
let device_class = attrs.get("device_class").and_then(Value::as_str).unwrap_or_default(); if matches!(state_value, "" | "unknown" | "unavailable") {
let state_class = attrs.get("state_class").and_then(Value::as_str).unwrap_or_default(); return Ok(());
let unit = attrs.get("unit_of_measurement").and_then(Value::as_str).unwrap_or_default(); }
let raw_value: f64 = state_value
.parse()
.map_err(|_| AppError::Dependency("Home Assistant energy state is not numeric".into()))?;
let attrs = payload
.get("attributes")
.and_then(Value::as_object)
.cloned()
.unwrap_or_default();
let device_class = attrs
.get("device_class")
.and_then(Value::as_str)
.unwrap_or_default();
let state_class = attrs
.get("state_class")
.and_then(Value::as_str)
.unwrap_or_default();
let unit = attrs
.get("unit_of_measurement")
.and_then(Value::as_str)
.unwrap_or_default();
if device_class != "energy" || !matches!(state_class, "total" | "total_increasing") { if device_class != "energy" || !matches!(state_class, "total" | "total_increasing") {
return Err(AppError::BadRequest("selected Home Assistant entity is not a cumulative energy sensor".into())); return Err(AppError::BadRequest(
"selected Home Assistant entity is not a cumulative energy sensor".into(),
));
} }
if !matches!(unit.to_ascii_lowercase().as_str(), "wh" | "kwh") { if !matches!(unit.to_ascii_lowercase().as_str(), "wh" | "kwh") {
return Err(AppError::BadRequest("Home Assistant energy sensor must use Wh or kWh".into())); return Err(AppError::BadRequest(
"Home Assistant energy sensor must use Wh or kWh".into(),
));
} }
let _ = record_cumulative_energy_sample(state, target_id, "home_assistant", raw_value, unit, None)?; let _ =
record_cumulative_energy_sample(state, target_id, "home_assistant", raw_value, unit, None)?;
Ok(()) Ok(())
} }
async fn sample_home_assistant_energy_device(state: &AppState, device: &Device) -> Result<(), AppError> { async fn sample_home_assistant_energy_device(
let Some(entity_id) = device.ha_energy_entity_id.as_deref().filter(|value| !value.trim().is_empty()) else { return Ok(()); }; state: &AppState,
device: &Device,
) -> Result<(), AppError> {
let Some(entity_id) = device
.ha_energy_entity_id
.as_deref()
.filter(|value| !value.trim().is_empty())
else {
return Ok(());
};
sample_home_assistant_energy_target(state, &device.id, entity_id).await sample_home_assistant_energy_target(state, &device.id, entity_id).await
} }
@@ -113,17 +160,33 @@ pub(crate) async fn home_assistant_energy_loop(state: AppState) {
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() { if !settings.url.trim().is_empty() && !settings.token.trim().is_empty() {
if let Ok(devices) = state.db.list_devices() { if let Ok(devices) = state.db.list_devices() {
for device in devices.into_iter().filter(|device| device.enabled && device.ha_energy_entity_id.is_some()) { for device in devices
.into_iter()
.filter(|device| device.enabled && device.ha_energy_entity_id.is_some())
{
if let Err(err) = sample_home_assistant_energy_device(&state, &device).await { if let Err(err) = sample_home_assistant_energy_device(&state, &device).await {
tracing::warn!(device=%device.id, error=?err, "Home Assistant energy sample failed"); tracing::warn!(device=%device.id, error=?err, "Home Assistant energy sample failed");
} }
} }
} }
if let Ok(groups) = state.db.list_device_groups() { if let Ok(groups) = state.db.list_device_groups() {
for group in groups.into_iter().filter(|group| matches!(group.energy_source, EnergySourcePreference::HomeAssistant | EnergySourcePreference::Auto)) { for group in groups.into_iter().filter(|group| {
let Some(entity_id) = group.ha_energy_entity_id.as_deref().filter(|value| !value.trim().is_empty()) else { continue; }; matches!(
group.energy_source,
EnergySourcePreference::HomeAssistant | EnergySourcePreference::Auto
)
}) {
let Some(entity_id) = group
.ha_energy_entity_id
.as_deref()
.filter(|value| !value.trim().is_empty())
else {
continue;
};
let target_id = format!("group:{}", group.id); let target_id = format!("group:{}", group.id);
if let Err(err) = sample_home_assistant_energy_target(&state, &target_id, entity_id).await { if let Err(err) =
sample_home_assistant_energy_target(&state, &target_id, entity_id).await
{
tracing::warn!(group=%group.id, error=?err, "Home Assistant installation energy sample failed"); tracing::warn!(group=%group.id, error=?err, "Home Assistant installation energy sample failed");
} }
} }
@@ -145,14 +208,26 @@ mod energy_tests {
#[test] #[test]
fn cumulative_counter_becomes_non_negative_delta() { fn cumulative_counter_becomes_non_negative_delta() {
assert_eq!(cumulative_energy_delta(None, 152.1), (0.0, "baseline", false)); assert_eq!(
cumulative_energy_delta(None, 152.1),
(0.0, "baseline", false)
);
let (delta, quality, reset) = cumulative_energy_delta(Some(152.1), 152.4); let (delta, quality, reset) = cumulative_energy_delta(Some(152.1), 152.4);
assert!((delta - 0.3).abs() < 1e-9); assert!((delta - 0.3).abs() < 1e-9);
assert_eq!(quality, "ok"); assert_eq!(quality, "ok");
assert!(!reset); assert!(!reset);
assert_eq!(cumulative_energy_delta(Some(152.4), 152.4), (0.0, "duplicate", false)); assert_eq!(
assert_eq!(cumulative_energy_delta(Some(153.0), 1.0), (0.0, "reset", true)); cumulative_energy_delta(Some(152.4), 152.4),
assert_eq!(cumulative_energy_delta(Some(1.0), 150.0), (0.0, "anomaly_large_jump", true)); (0.0, "duplicate", false)
);
assert_eq!(
cumulative_energy_delta(Some(153.0), 1.0),
(0.0, "reset", true)
);
assert_eq!(
cumulative_energy_delta(Some(1.0), 150.0),
(0.0, "anomaly_large_jump", true)
);
} }
#[test] #[test]
+6 -1
View File
@@ -1,7 +1,12 @@
fn installation_outdoor_temperature(state: &AppState, device_id: &str) -> Option<f64> { fn installation_outdoor_temperature(state: &AppState, device_id: &str) -> Option<f64> {
let group = state.db.device_group_for_device(device_id).ok().flatten()?; let group = state.db.device_group_for_device(device_id).ok().flatten()?;
let source_id = group.outdoor_temperature_device_id.as_deref()?; let source_id = group.outdoor_temperature_device_id.as_deref()?;
state.db.get_device(source_id).ok().flatten()?.outdoor_temperature state
.db
.get_device(source_id)
.ok()
.flatten()?
.outdoor_temperature
} }
fn record_zone_history( fn record_zone_history(
+2 -1
View File
@@ -142,7 +142,8 @@ fn manual_override_matches_baseline(zone: &Zone, device: &Device) -> bool {
"mode" => device.mode == baseline.mode, "mode" => device.mode == baseline.mode,
"target_temperature" => { "target_temperature" => {
let step = device.capabilities.temperature_step.max(0.5); let step = device.capabilities.temperature_step.max(0.5);
(device.target_temperature / step).round() == (baseline.target_temperature / step).round() (device.target_temperature / step).round()
== (baseline.target_temperature / step).round()
} }
"fan_speed" => device.fan_speed == baseline.fan_speed, "fan_speed" => device.fan_speed == baseline.fan_speed,
"quiet" => device.quiet == baseline.quiet, "quiet" => device.quiet == baseline.quiet,
+246 -80
View File
@@ -10,8 +10,14 @@ fn device_runtime_change_affects_control_plan(before: &Device, after: &Device) -
|| before.communication_failures != after.communication_failures || before.communication_failures != after.communication_failures
} }
async fn lock_poll_zone_operations(state: &AppState, device_id: &str) -> Result<Vec<tokio::sync::OwnedMutexGuard<()>>, AppError> { async fn lock_poll_zone_operations(
let mut zone_ids: Vec<String> = state.db.list_zones()?.into_iter() state: &AppState,
device_id: &str,
) -> Result<Vec<tokio::sync::OwnedMutexGuard<()>>, AppError> {
let mut zone_ids: Vec<String> = state
.db
.list_zones()?
.into_iter()
.filter(|zone| zone.device_id == device_id) .filter(|zone| zone.device_id == device_id)
.map(|zone| zone.id) .map(|zone| zone.id)
.collect(); .collect();
@@ -35,7 +41,9 @@ pub async fn poll_one(state: &AppState, device_id: &str) -> Result<Device, AppEr
// Caller must hold the device lock and every current zone lock associated with this device. // Caller must hold the device lock and every current zone lock associated with this device.
async fn poll_one_locked(state: &AppState, device_id: &str) -> Result<Device, AppError> { async fn poll_one_locked(state: &AppState, device_id: &str) -> Result<Device, AppError> {
let mut device = state.db.get_device(device_id)? let mut device = state
.db
.get_device(device_id)?
.ok_or_else(|| AppError::NotFound(format!("device {device_id}")))?; .ok_or_else(|| AppError::NotFound(format!("device {device_id}")))?;
let before = device.clone(); let before = device.clone();
poll_device(state, &mut device).await; poll_device(state, &mut device).await;
@@ -68,13 +76,19 @@ pub(crate) async fn poll_all(state: &AppState) -> Result<()> {
// LAN always runs first and keeps its historical sequential locking/transport behavior. // LAN always runs first and keeps its historical sequential locking/transport behavior.
// Cloud work is detached afterwards so an Internet/broker timeout cannot delay UDP cycles. // Cloud work is detached afterwards so an Internet/broker timeout cannot delay UDP cycles.
for device in devices.iter().filter(|device| device.enabled && device.connection_type == ConnectionType::Local) { for device in devices
.iter()
.filter(|device| device.enabled && device.connection_type == ConnectionType::Local)
{
let _zone_guards = lock_poll_zone_operations(state, &device.id).await?; let _zone_guards = lock_poll_zone_operations(state, &device.id).await?;
let _device_guard = state.lock_device_operation(&device.id).await; let _device_guard = state.lock_device_operation(&device.id).await;
let _ = poll_one_locked(state, &device.id).await?; let _ = poll_one_locked(state, &device.id).await?;
} }
for device in devices.into_iter().filter(|device| device.enabled && device.connection_type == ConnectionType::GreeCloud) { for device in devices
.into_iter()
.filter(|device| device.enabled && device.connection_type == ConnectionType::GreeCloud)
{
// A failed Cloud read has no last_cloud_sync, so use updated_at (which is refreshed // A failed Cloud read has no last_cloud_sync, so use updated_at (which is refreshed
// on failures) as the retry baseline. Otherwise an offline unit would be considered // on failures) as the retry baseline. Otherwise an offline unit would be considered
// due on every fast LAN poll cycle and detached tasks would accumulate indefinitely. // due on every fast LAN poll cycle and detached tasks would accumulate indefinitely.
@@ -84,7 +98,9 @@ pub(crate) async fn poll_all(state: &AppState) -> Result<()> {
.signed_duration_since(retry_baseline) .signed_duration_since(retry_baseline)
.num_seconds() .num_seconds()
>= cloud_interval as i64; >= cloud_interval as i64;
if !due { continue; } if !due {
continue;
}
if startup { if startup {
// Persist a conservative startup state before the asynchronous Cloud read. This // Persist a conservative startup state before the asynchronous Cloud read. This
// prevents a stale pre-restart Online snapshot from driving thermostat commands. // prevents a stale pre-restart Online snapshot from driving thermostat commands.
@@ -115,7 +131,10 @@ pub(crate) async fn poll_all(state: &AppState) -> Result<()> {
/// Poll all enabled devices when the caller already holds the corresponding zone and device locks. /// Poll all enabled devices when the caller already holds the corresponding zone and device locks.
/// Used by configuration import so no command/poll can interleave with the replacement. /// Used by configuration import so no command/poll can interleave with the replacement.
pub(crate) async fn poll_all_locked(state: &AppState) -> Result<()> { pub(crate) async fn poll_all_locked(state: &AppState) -> Result<()> {
let device_ids: Vec<String> = state.db.list_devices()?.into_iter() let device_ids: Vec<String> = state
.db
.list_devices()?
.into_iter()
.filter(|device| device.enabled) .filter(|device| device.enabled)
.map(|device| device.id) .map(|device| device.id)
.collect(); .collect();
@@ -137,13 +156,24 @@ async fn poll_device(state: &AppState, device: &mut Device) {
return; return;
} }
}; };
match state.providers.cloud().poll(&cloud_settings, &all_devices, device).await { match state
.providers
.cloud()
.poll(&cloud_settings, &all_devices, device)
.await
{
Ok(()) => { Ok(()) => {
device.pending_command = false; device.pending_command = false;
device.response_time_ms = Some(response_started.elapsed().as_millis().min(u64::MAX as u128) as u64); device.response_time_ms =
Some(response_started.elapsed().as_millis().min(u64::MAX as u128) as u64);
device.refresh_capabilities(); device.refresh_capabilities();
if previous_failures > 0 { if previous_failures > 0 {
state.log("info", "gree_cloud.device_online", &format!("{} is online through GREE Cloud", device.name), json!({"device_id": device.id})); state.log(
"info",
"gree_cloud.device_online",
&format!("{} is online through GREE Cloud", device.name),
json!({"device_id": device.id}),
);
} }
} }
Err(err) => { Err(err) => {
@@ -196,7 +226,8 @@ async fn poll_device(state: &AppState, device: &mut Device) {
} }
if device.communication_failures == 0 && device.online { if device.communication_failures == 0 && device.online {
device.connection_status = ConnectionStatus::Online; device.connection_status = ConnectionStatus::Online;
device.response_time_ms = Some(response_started.elapsed().as_millis().min(u64::MAX as u128) as u64); device.response_time_ms =
Some(response_started.elapsed().as_millis().min(u64::MAX as u128) as u64);
} }
log_poll_health_transition(state, device, previous_failures).await; log_poll_health_transition(state, device, previous_failures).await;
} }
@@ -205,13 +236,14 @@ fn register_cloud_poll_failure(device: &mut Device, error: &str) {
device.communication_failures = device.communication_failures.saturating_add(1); device.communication_failures = device.communication_failures.saturating_add(1);
device.online = false; device.online = false;
let lower = error.to_ascii_lowercase(); let lower = error.to_ascii_lowercase();
device.connection_status = if lower.contains("authentication") || lower.contains("not authorized") { device.connection_status =
ConnectionStatus::AuthenticationError if lower.contains("authentication") || lower.contains("not authorized") {
} else if lower.contains("mqtt") || lower.contains("connect") || lower.contains("tls") { ConnectionStatus::AuthenticationError
ConnectionStatus::CloudDisconnected } else if lower.contains("mqtt") || lower.contains("connect") || lower.contains("tls") {
} else { ConnectionStatus::CloudDisconnected
ConnectionStatus::Offline } else {
}; ConnectionStatus::Offline
};
device.response_time_ms = None; device.response_time_ms = None;
if device.last_seen.is_none() { if device.last_seen.is_none() {
device.last_cloud_sync = None; device.last_cloud_sync = None;
@@ -230,7 +262,13 @@ fn cloud_poll_public_error(error: &str) -> String {
} }
async fn log_poll_health_transition(state: &AppState, device: &Device, previous_failures: u8) { async fn log_poll_health_transition(state: &AppState, device: &Device, previous_failures: u8) {
let threshold = state.settings.read().await.notifications.communication_failure_threshold.max(2); let threshold = state
.settings
.read()
.await
.notifications
.communication_failure_threshold
.max(2);
let current_failures = u32::from(device.communication_failures); let current_failures = u32::from(device.communication_failures);
let previous_failures = u32::from(previous_failures); let previous_failures = u32::from(previous_failures);
if current_failures >= threshold && previous_failures < threshold { if current_failures >= threshold && previous_failures < threshold {
@@ -238,23 +276,33 @@ async fn log_poll_health_transition(state: &AppState, device: &Device, previous_
"device_id": device.id, "consecutive_failures": device.communication_failures, "threshold": threshold "device_id": device.id, "consecutive_failures": device.communication_failures, "threshold": threshold
})); }));
} else if current_failures == 0 && previous_failures >= threshold { } else if current_failures == 0 && previous_failures >= threshold {
state.log("info", "device.recovered", &format!("{} is responding again", device.name), json!({"device_id": device.id})); state.log(
"info",
"device.recovered",
&format!("{} is responding again", device.name),
json!({"device_id": device.id}),
);
} }
} }
fn simulate_tick(device: &mut Device) { fn simulate_tick(device: &mut Device) {
let mut current = device.current_temperature.unwrap_or(25.0); let mut current = device.current_temperature.unwrap_or(25.0);
let minute_wave = ((Utc::now().timestamp() % 3600) as f64 / 3600.0 * std::f64::consts::TAU).sin(); let minute_wave =
((Utc::now().timestamp() % 3600) as f64 / 3600.0 * std::f64::consts::TAU).sin();
let ambient = 25.5 + minute_wave * 0.35; let ambient = 25.5 + minute_wave * 0.35;
if device.power { if device.power {
match device.mode.as_str() { match device.mode.as_str() {
"cool" => { "cool" => {
let floor = device.target_temperature - 0.2; let floor = device.target_temperature - 0.2;
if current > floor { current -= if device.turbo { 0.25 } else { 0.12 }; } if current > floor {
current -= if device.turbo { 0.25 } else { 0.12 };
}
} }
"heat" => { "heat" => {
let ceiling = device.target_temperature + 0.2; let ceiling = device.target_temperature + 0.2;
if current < ceiling { current += if device.turbo { 0.25 } else { 0.12 }; } if current < ceiling {
current += if device.turbo { 0.25 } else { 0.12 };
}
} }
"dry" => current -= 0.04, "dry" => current -= 0.04,
_ => current += (ambient - current) * 0.02, _ => current += (ambient - current) * 0.02,
@@ -265,7 +313,9 @@ fn simulate_tick(device: &mut Device) {
device.current_temperature = Some((current * 10.0).round() / 10.0); device.current_temperature = Some((current * 10.0).round() / 10.0);
device.outdoor_temperature = Some((30.0 + minute_wave * 1.2) * 10.0_f64.round() / 10.0); device.outdoor_temperature = Some((30.0 + minute_wave * 1.2) * 10.0_f64.round() / 10.0);
// Correct rounding for outdoor temperature without accumulating precision noise. // Correct rounding for outdoor temperature without accumulating precision noise.
device.outdoor_temperature = device.outdoor_temperature.map(|v| (v * 10.0).round() / 10.0); device.outdoor_temperature = device
.outdoor_temperature
.map(|v| (v * 10.0).round() / 10.0);
device.online = true; device.online = true;
device.response_time_ms = Some(0); device.response_time_ms = Some(0);
device.last_seen = Some(Utc::now()); device.last_seen = Some(Utc::now());
@@ -279,10 +329,15 @@ fn record_reading(state: &AppState, device: &Device) -> Result<()> {
device_id: device.id.clone(), device_id: device.id.clone(),
timestamp: Utc::now(), timestamp: Utc::now(),
indoor_temperature: device.current_temperature, indoor_temperature: device.current_temperature,
outdoor_temperature: installation_outdoor_temperature(state, &device.id).or(device.outdoor_temperature), outdoor_temperature: installation_outdoor_temperature(state, &device.id)
.or(device.outdoor_temperature),
target_temperature: device.target_temperature, target_temperature: device.target_temperature,
power: device.power, power: device.power,
source: if device.simulated { "simulator".into() } else { "gree".into() }, source: if device.simulated {
"simulator".into()
} else {
"gree".into()
},
}; };
state.db.add_reading(&reading)?; state.db.add_reading(&reading)?;
queue_influx_device(state, reading); queue_influx_device(state, reading);
@@ -292,30 +347,50 @@ fn record_reading(state: &AppState, device: &Device) -> Result<()> {
fn record_poll_failure(device: &mut Device, error: &str) { fn record_poll_failure(device: &mut Device, error: &str) {
device.communication_failures = device.communication_failures.saturating_add(1); device.communication_failures = device.communication_failures.saturating_add(1);
// A single dropped UDP response is not enough to declare an AC offline. // A single dropped UDP response is not enough to declare an AC offline.
if device.communication_failures >= 3 { device.online = false; device.connection_status = ConnectionStatus::Offline; } if device.communication_failures >= 3 {
device.online = false;
device.connection_status = ConnectionStatus::Offline;
}
device.last_error = Some(error.to_string()); device.last_error = Some(error.to_string());
device.updated_at = Utc::now(); device.updated_at = Utc::now();
} }
fn register_device_failure(state: &AppState, device: &mut Device, error: &str) -> Result<(), AppError> { fn register_device_failure(
state: &AppState,
device: &mut Device,
error: &str,
) -> Result<(), AppError> {
record_poll_failure(device, error); record_poll_failure(device, error);
state.db.save_device(device)?; state.db.save_device(device)?;
// Command failures change live communication health; publish the updated snapshot immediately. // Command failures change live communication health; publish the updated snapshot immediately.
state.broadcast("device.updated", serde_json::to_value(&*device)?); state.broadcast("device.updated", serde_json::to_value(&*device)?);
state.log("warn", "device.communication_error", &format!("{}: {error}", device.name), json!({ state.log(
"device_id": device.id, "warn",
"consecutive_failures": device.communication_failures, "device.communication_error",
"offline": !device.online, &format!("{}: {error}", device.name),
})); json!({
"device_id": device.id,
"consecutive_failures": device.communication_failures,
"offline": !device.online,
}),
);
Ok(()) Ok(())
} }
pub(crate) fn validate_command(command: &DeviceCommand) -> Result<(), AppError> { pub(crate) fn validate_command(command: &DeviceCommand) -> Result<(), AppError> {
if let Some(value) = command.target_temperature { if let Some(value) = command.target_temperature {
if !(8.0..=30.0).contains(&value) { return Err(AppError::BadRequest("target temperature must be between 8 and 30 C".into())); } if !(8.0..=30.0).contains(&value) {
return Err(AppError::BadRequest(
"target temperature must be between 8 and 30 C".into(),
));
}
} }
if let Some(value) = command.fan_speed { if let Some(value) = command.fan_speed {
if value > 5 { return Err(AppError::BadRequest("fan speed must be between 0 and 5".into())); } if value > 5 {
return Err(AppError::BadRequest(
"fan speed must be between 0 and 5".into(),
));
}
} }
if let Some(value) = &command.mode { if let Some(value) = &command.mode {
if !matches!(value.as_str(), "auto" | "cool" | "dry" | "fan" | "heat") { if !matches!(value.as_str(), "auto" | "cool" | "dry" | "fan" | "heat") {
@@ -331,12 +406,24 @@ fn poll_completed_successfully(device: &Device) -> bool {
fn command_manual_control_fields(command: &DeviceCommand) -> Vec<String> { fn command_manual_control_fields(command: &DeviceCommand) -> Vec<String> {
let mut fields = Vec::new(); let mut fields = Vec::new();
if command.power.is_some() { fields.push("power".to_string()); } if command.power.is_some() {
if command.mode.is_some() { fields.push("mode".to_string()); } fields.push("power".to_string());
if command.target_temperature.is_some() { fields.push("target_temperature".to_string()); } }
if command.fan_speed.is_some() { fields.push("fan_speed".to_string()); } if command.mode.is_some() {
if command.quiet.is_some() { fields.push("quiet".to_string()); } fields.push("mode".to_string());
if command.sleep.is_some() { fields.push("sleep".to_string()); } }
if command.target_temperature.is_some() {
fields.push("target_temperature".to_string());
}
if command.fan_speed.is_some() {
fields.push("fan_speed".to_string());
}
if command.quiet.is_some() {
fields.push("quiet".to_string());
}
if command.sleep.is_some() {
fields.push("sleep".to_string());
}
fields fields
} }
@@ -344,7 +431,9 @@ fn command_baseline_from_device(command: &DeviceCommand, device: &Device) -> Dev
DeviceCommand { DeviceCommand {
power: command.power.map(|_| device.power), power: command.power.map(|_| device.power),
mode: command.mode.as_ref().map(|_| device.mode.clone()), mode: command.mode.as_ref().map(|_| device.mode.clone()),
target_temperature: command.target_temperature.map(|_| device.target_temperature), target_temperature: command
.target_temperature
.map(|_| device.target_temperature),
fan_speed: command.fan_speed.map(|_| device.fan_speed), fan_speed: command.fan_speed.map(|_| device.fan_speed),
quiet: command.quiet.map(|_| device.quiet), quiet: command.quiet.map(|_| device.quiet),
sleep: command.sleep.map(|_| device.sleep), sleep: command.sleep.map(|_| device.sleep),
@@ -352,7 +441,12 @@ fn command_baseline_from_device(command: &DeviceCommand, device: &Device) -> Dev
} }
} }
async fn remember_controller_command(state: &AppState, device_id: &str, command: &DeviceCommand, baseline_device: &Device) { async fn remember_controller_command(
state: &AppState,
device_id: &str,
command: &DeviceCommand,
baseline_device: &Device,
) {
let poll_seconds = state.settings.read().await.poll_interval_seconds.max(2); let poll_seconds = state.settings.read().await.poll_interval_seconds.max(2);
let ttl = Duration::from_secs(poll_seconds.saturating_mul(2).saturating_add(5).min(120)); let ttl = Duration::from_secs(poll_seconds.saturating_mul(2).saturating_add(5).min(120));
let mut pending = state.pending_controller_commands.lock().await; let mut pending = state.pending_controller_commands.lock().await;
@@ -363,32 +457,64 @@ async fn remember_controller_command(state: &AppState, device_id: &str, command:
existing.baselines.push(baseline); existing.baselines.push(baseline);
// The history only spans one settling window; cap it defensively so a noisy device // The history only spans one settling window; cap it defensively so a noisy device
// cannot grow this allocation without bound. // cannot grow this allocation without bound.
if existing.commands.len() > 8 { existing.commands.remove(0); } if existing.commands.len() > 8 {
if existing.baselines.len() > 8 { existing.baselines.remove(0); } existing.commands.remove(0);
}
if existing.baselines.len() > 8 {
existing.baselines.remove(0);
}
existing.expires_at = expires_at; existing.expires_at = expires_at;
} else { } else {
pending.insert(device_id.to_string(), PendingControllerCommand { pending.insert(
commands: vec![command.clone()], device_id.to_string(),
baselines: vec![baseline], PendingControllerCommand {
expires_at, commands: vec![command.clone()],
}); baselines: vec![baseline],
expires_at,
},
);
} }
} }
fn command_field_matches_device(command: &DeviceCommand, field: &str, device: &Device) -> bool { fn command_field_matches_device(command: &DeviceCommand, field: &str, device: &Device) -> bool {
match field { match field {
"power" => command.power.map(|value| value == device.power).unwrap_or(false), "power" => command
"mode" => command.mode.as_deref().map(|value| value == device.mode.as_str()).unwrap_or(false), .power
"target_temperature" => command.target_temperature .map(|value| value == device.power)
.unwrap_or(false),
"mode" => command
.mode
.as_deref()
.map(|value| value == device.mode.as_str())
.unwrap_or(false),
"target_temperature" => command
.target_temperature
.map(|value| { .map(|value| {
let step = device.capabilities.temperature_step.max(0.5); let step = device.capabilities.temperature_step.max(0.5);
(value.clamp(device.capabilities.min_temperature, device.capabilities.max_temperature) / step).round() (value.clamp(
== (device.target_temperature.clamp(device.capabilities.min_temperature, device.capabilities.max_temperature) / step).round() device.capabilities.min_temperature,
device.capabilities.max_temperature,
) / step)
.round()
== (device.target_temperature.clamp(
device.capabilities.min_temperature,
device.capabilities.max_temperature,
) / step)
.round()
}) })
.unwrap_or(false), .unwrap_or(false),
"fan_speed" => command.fan_speed.map(|value| value.min(5) == device.fan_speed).unwrap_or(false), "fan_speed" => command
"quiet" => command.quiet.map(|value| value == device.quiet).unwrap_or(false), .fan_speed
"sleep" => command.sleep.map(|value| value == device.sleep).unwrap_or(false), .map(|value| value.min(5) == device.fan_speed)
.unwrap_or(false),
"quiet" => command
.quiet
.map(|value| value == device.quiet)
.unwrap_or(false),
"sleep" => command
.sleep
.map(|value| value == device.sleep)
.unwrap_or(false),
_ => false, _ => false,
} }
} }
@@ -418,7 +544,10 @@ async fn controller_settling_diagnostics(state: &AppState, device_id: &str) -> V
}; };
let now = Instant::now(); let now = Instant::now();
if now > expected.expires_at { if now > expected.expires_at {
let expired_by_ms = now.saturating_duration_since(expected.expires_at).as_millis().min(u64::MAX as u128) as u64; let expired_by_ms = now
.saturating_duration_since(expected.expires_at)
.as_millis()
.min(u64::MAX as u128) as u64;
return json!({ return json!({
"active": false, "active": false,
"reason": "expired", "reason": "expired",
@@ -427,7 +556,11 @@ async fn controller_settling_diagnostics(state: &AppState, device_id: &str) -> V
"baselines": expected.baselines, "baselines": expected.baselines,
}); });
} }
let remaining_ms = expected.expires_at.saturating_duration_since(now).as_millis().min(u64::MAX as u128) as u64; let remaining_ms = expected
.expires_at
.saturating_duration_since(now)
.as_millis()
.min(u64::MAX as u128) as u64;
json!({ json!({
"active": true, "active": true,
"remaining_ms": remaining_ms, "remaining_ms": remaining_ms,
@@ -441,19 +574,27 @@ async fn suppress_expected_controller_changes(
device: &Device, device: &Device,
fields: Vec<String>, fields: Vec<String>,
) -> Vec<String> { ) -> Vec<String> {
if fields.is_empty() { return fields; } if fields.is_empty() {
return fields;
}
let mut pending = state.pending_controller_commands.lock().await; let mut pending = state.pending_controller_commands.lock().await;
let expired = pending.get(&device.id) let expired = pending
.get(&device.id)
.map(|expected| Instant::now() > expected.expires_at) .map(|expected| Instant::now() > expected.expires_at)
.unwrap_or(false); .unwrap_or(false);
if expired { if expired {
pending.remove(&device.id); pending.remove(&device.id);
return fields; return fields;
} }
let Some(expected) = pending.get(&device.id).cloned() else { return fields; }; let Some(expected) = pending.get(&device.id).cloned() else {
let filtered = fields.into_iter() return fields;
};
let filtered = fields
.into_iter()
.filter(|field| { .filter(|field| {
let matches_recent_controller_state = expected.commands.iter() let matches_recent_controller_state = expected
.commands
.iter()
.chain(expected.baselines.iter()) .chain(expected.baselines.iter())
.any(|command| command_field_matches_device(command, field, device)); .any(|command| command_field_matches_device(command, field, device));
!matches_recent_controller_state !matches_recent_controller_state
@@ -467,8 +608,12 @@ async fn suppress_expected_controller_changes(
fn externally_changed_control_fields(before: &Device, after: &Device, zone: &Zone) -> Vec<String> { fn externally_changed_control_fields(before: &Device, after: &Device, zone: &Zone) -> Vec<String> {
let mut fields = Vec::new(); let mut fields = Vec::new();
if before.power != after.power { fields.push("power".to_string()); } if before.power != after.power {
if before.mode != after.mode { fields.push("mode".to_string()); } fields.push("power".to_string());
}
if before.mode != after.mode {
fields.push("mode".to_string());
}
let temperature_step = after.capabilities.temperature_step.max(0.5); let temperature_step = after.capabilities.temperature_step.max(0.5);
if (before.target_temperature / temperature_step).round() if (before.target_temperature / temperature_step).round()
!= (after.target_temperature / temperature_step).round() != (after.target_temperature / temperature_step).round()
@@ -478,15 +623,14 @@ fn externally_changed_control_fields(before: &Device, after: &Device, zone: &Zon
// Some GREE units accept the controller's standby Low fan hint and later report Auto // Some GREE units accept the controller's standby Low fan hint and later report Auto
// again without user interaction. Treat that one known normalization as firmware drift, // again without user interaction. Treat that one known normalization as firmware drift,
// not as a remote-control takeover. Other fan changes remain meaningful manual input. // not as a remote-control takeover. Other fan changes remain meaningful manual input.
let standby_low_to_auto = zone.smart_fan && !zone.demand && before.fan_speed == 1 && after.fan_speed == 0; let standby_low_to_auto =
zone.smart_fan && !zone.demand && before.fan_speed == 1 && after.fan_speed == 0;
if before.fan_speed != after.fan_speed && !standby_low_to_auto { if before.fan_speed != after.fan_speed && !standby_low_to_auto {
fields.push("fan_speed".to_string()); fields.push("fan_speed".to_string());
} }
fields fields
} }
pub(crate) async fn cloud_push_loop(state: AppState) { pub(crate) async fn cloud_push_loop(state: AppState) {
let mut receiver = state.providers.cloud().subscribe_push(); let mut receiver = state.providers.cloud().subscribe_push();
loop { loop {
@@ -502,27 +646,43 @@ pub(crate) async fn cloud_push_loop(state: AppState) {
let matching: Vec<String> = devices let matching: Vec<String> = devices
.into_iter() .into_iter()
.filter(|device| { .filter(|device| {
if device.connection_type != ConnectionType::GreeCloud { return false; } if device.connection_type != ConnectionType::GreeCloud {
if let Some(id) = event.cloud_device_id.as_deref() { return false;
return device.cloud_device_id.as_deref().is_some_and(|value| value.eq_ignore_ascii_case(id));
} }
device.cloud_parent_mac.as_deref().is_some_and(|value| value.eq_ignore_ascii_case(&event.parent_mac)) if let Some(id) = event.cloud_device_id.as_deref() {
return device
.cloud_device_id
.as_deref()
.is_some_and(|value| value.eq_ignore_ascii_case(id));
}
device
.cloud_parent_mac
.as_deref()
.is_some_and(|value| value.eq_ignore_ascii_case(&event.parent_mac))
}) })
.map(|device| device.id) .map(|device| device.id)
.collect(); .collect();
for device_id in matching { for device_id in matching {
let _guard = state.lock_device_operation(&device_id).await; let _guard = state.lock_device_operation(&device_id).await;
let Ok(Some(mut device)) = state.db.get_device(&device_id) else { continue; }; let Ok(Some(mut device)) = state.db.get_device(&device_id) else {
continue;
};
let before = device.clone(); let before = device.clone();
if !event.properties.is_empty() { if !event.properties.is_empty() {
if let Some(raw_energy) = event.properties.get("ElcAll").and_then(|value| { if let Some(raw_energy) = event.properties.get("ElcAll").and_then(|value| {
value.as_f64() value
.as_f64()
.or_else(|| value.as_i64().map(|v| v as f64)) .or_else(|| value.as_i64().map(|v| v as f64))
.or_else(|| value.as_u64().map(|v| v as f64)) .or_else(|| value.as_u64().map(|v| v as f64))
.or_else(|| value.as_str().and_then(|v| v.parse::<f64>().ok())) .or_else(|| value.as_str().and_then(|v| v.parse::<f64>().ok()))
}) { }) {
if let Err(err) = record_cumulative_energy_sample( if let Err(err) = record_cumulative_energy_sample(
&state, &device.id, "gree_cloud", raw_energy, "0.1kWh", None &state,
&device.id,
"gree_cloud",
raw_energy,
"0.1kWh",
None,
) { ) {
tracing::warn!(device=%device.id, error=?err, "cannot record GREE Cloud energy sample"); tracing::warn!(device=%device.id, error=?err, "cannot record GREE Cloud energy sample");
} }
@@ -535,7 +695,9 @@ pub(crate) async fn cloud_push_loop(state: AppState) {
device.online = true; device.online = true;
device.communication_failures = 0; device.communication_failures = 0;
device.last_error = None; device.last_error = None;
if let Some(version) = event.cipher_version { device.protocol_version = version; } if let Some(version) = event.cipher_version {
device.protocol_version = version;
}
device.refresh_capabilities(); device.refresh_capabilities();
} else if event.connected == Some(true) { } else if event.connected == Some(true) {
// A connect topic proves cloud presence but does not replace a status frame. // A connect topic proves cloud presence but does not replace a status frame.
@@ -550,7 +712,9 @@ pub(crate) async fn cloud_push_loop(state: AppState) {
} }
if !event.properties.is_empty() { if !event.properties.is_empty() {
let _ = record_reading(&state, &device); let _ = record_reading(&state, &device);
if let Err(err) = detect_external_device_control(&state, &before, &device).await { if let Err(err) =
detect_external_device_control(&state, &before, &device).await
{
tracing::warn!(device=%device_id, error=?err, "cannot process external GREE Cloud state change"); tracing::warn!(device=%device_id, error=?err, "cannot process external GREE Cloud state change");
} }
} }
@@ -561,7 +725,9 @@ pub(crate) async fn cloud_push_loop(state: AppState) {
); );
} }
} }
Err(broadcast::error::RecvError::Lagged(skipped)) => tracing::warn!(skipped, "GREE Cloud push state receiver lagged"), Err(broadcast::error::RecvError::Lagged(skipped)) => {
tracing::warn!(skipped, "GREE Cloud push state receiver lagged")
}
Err(broadcast::error::RecvError::Closed) => break, Err(broadcast::error::RecvError::Closed) => break,
} }
} }
+7 -2
View File
@@ -148,8 +148,13 @@ pub fn start(state: AppState) {
Ok(_) => {} Ok(_) => {}
Err(err) => tracing::warn!(error=?err, "cannot compact history"), Err(err) => tracing::warn!(error=?err, "cannot compact history"),
} }
match maintenance_state.db.compact_network_history(compaction_days) { match maintenance_state
Ok(count) if count > 0 => tracing::info!(count, "connectivity history samples compacted"), .db
.compact_network_history(compaction_days)
{
Ok(count) if count > 0 => {
tracing::info!(count, "connectivity history samples compacted")
}
Ok(_) => {} Ok(_) => {}
Err(err) => tracing::warn!(error=?err, "cannot compact connectivity history"), Err(err) => tracing::warn!(error=?err, "cannot compact connectivity history"),
} }
+2 -2
View File
@@ -53,8 +53,8 @@ pub fn next_schedule_boundary_utc(
let candidate = base + chrono::Duration::minutes(minute); let candidate = base + chrono::Duration::minutes(minute);
let minute_of_day = candidate.hour() * 60 + candidate.minute(); let minute_of_day = candidate.hour() * 60 + candidate.minute();
let previous_local = base + chrono::Duration::minutes(minute - 1); let previous_local = base + chrono::Duration::minutes(minute - 1);
let clock_discontinuity = candidate.naive_local() - previous_local.naive_local() let clock_discontinuity =
!= chrono::Duration::minutes(1); candidate.naive_local() - previous_local.naive_local() != chrono::Duration::minutes(1);
if !boundary_minutes.contains(&minute_of_day) && !clock_discontinuity { if !boundary_minutes.contains(&minute_of_day) && !clock_discontinuity {
continue; continue;
} }
+617 -133
View File
File diff suppressed because it is too large Load Diff
+6 -13
View File
@@ -77,7 +77,8 @@ async fn resolve_cycle_outdoor_temperature(
None None
}; };
let device_groups = state.db.list_device_groups().unwrap_or_default(); let device_groups = state.db.list_device_groups().unwrap_or_default();
let temperature = from_home_assistant.or_else(|| gree_outdoor_temperature(devices, &device_groups)); let temperature =
from_home_assistant.or_else(|| gree_outdoor_temperature(devices, &device_groups));
let mut current = state.outdoor_temperature.write().await; let mut current = state.outdoor_temperature.write().await;
if *current != temperature { if *current != temperature {
*current = temperature; *current = temperature;
@@ -237,14 +238,9 @@ fn refresh_zone_temperature(
} else { } else {
"ha.sensor_error" "ha.sensor_error"
}; };
let entity_id = resolved_entity let entity_id = resolved_entity.as_deref().or(zone.ha_entity_id.as_deref());
.as_deref() let message =
.or(zone.ha_entity_id.as_deref()); home_assistant_sensor_log_message(&err, entity_id, &zone.name);
let message = home_assistant_sensor_log_message(
&err,
entity_id,
&zone.name,
);
state.log( state.log(
"warn", "warn",
kind, kind,
@@ -1196,10 +1192,7 @@ async fn control_zones(state: &AppState) -> Result<()> {
outdoor_temperature, outdoor_temperature,
settings.poll_interval_seconds, settings.poll_interval_seconds,
); );
publish_persisted_zone_cycle( publish_persisted_zone_cycle(state, persist_zone_cycle(state, &zone, cycle_started_at)?)?;
state,
persist_zone_cycle(state, &zone, cycle_started_at)?,
)?;
} }
Ok(()) Ok(())
+6 -2
View File
@@ -129,7 +129,9 @@ pub async fn test_connection(
settings: &HomeAssistantSettings, settings: &HomeAssistantSettings,
) -> Result<Option<Value>> { ) -> Result<Option<Value>> {
let (mut base, token) = connection(settings)?; let (mut base, token) = connection(settings)?;
base = base.join("api/").context("cannot build Home Assistant API URL")?; base = base
.join("api/")
.context("cannot build Home Assistant API URL")?;
let response = request_client(default_client, settings)? let response = request_client(default_client, settings)?
.get(base) .get(base)
.bearer_auth(token.trim()) .bearer_auth(token.trim())
@@ -323,7 +325,9 @@ pub async fn list_entities(
settings: &HomeAssistantSettings, settings: &HomeAssistantSettings,
) -> Result<Vec<Value>> { ) -> Result<Vec<Value>> {
let (mut base, token) = connection(settings)?; let (mut base, token) = connection(settings)?;
base = base.join("api/states").context("cannot build Home Assistant API URL")?; base = base
.join("api/states")
.context("cannot build Home Assistant API URL")?;
let response = request_client(default_client, settings)? let response = request_client(default_client, settings)?
.get(base) .get(base)
.bearer_auth(token.trim()) .bearer_auth(token.trim())
+17 -1
View File
@@ -4,7 +4,9 @@ use reqwest::Client;
use serde_json::Value; use serde_json::Value;
use std::collections::HashMap; use std::collections::HashMap;
use crate::models::{EnergyReading, HaReading, InfluxDbSettings, NetworkReading, Reading, ZoneReading}; use crate::models::{
EnergyReading, HaReading, InfluxDbSettings, NetworkReading, Reading, ZoneReading,
};
const DEVICE_MEASUREMENT: &str = "gree_device"; const DEVICE_MEASUREMENT: &str = "gree_device";
const ZONE_MEASUREMENT: &str = "gree_zone"; const ZONE_MEASUREMENT: &str = "gree_zone";
@@ -16,3 +18,17 @@ const NETWORK_MEASUREMENT: &str = "gree_network";
include!("influxdb/write.rs"); include!("influxdb/write.rs");
include!("influxdb/query.rs"); include!("influxdb/query.rs");
include!("influxdb/codec.rs"); include!("influxdb/codec.rs");
pub async fn test_connection(client: &Client, settings: &InfluxDbSettings) -> Result<()> {
validate(settings)?;
if settings.version == "1" {
query_v1(client, settings, "SHOW MEASUREMENTS LIMIT 1").await?;
} else {
let query = format!(
"from(bucket: {}) |> range(start: -1m) |> limit(n: 1)",
flux_string(&settings.bucket)
);
query_v2(client, settings, &query).await?;
}
Ok(())
}
+52 -14
View File
@@ -489,7 +489,9 @@ pub async fn query_energy(
for item in series { for item in series {
let source = item.tags.get("source").cloned().unwrap_or_default(); let source = item.tags.get("source").cloned().unwrap_or_default();
for row in item.rows { for row in item.rows {
let Some(timestamp) = row_time(&row) else { continue; }; let Some(timestamp) = row_time(&row) else {
continue;
};
out.push(EnergyReading { out.push(EnergyReading {
id: 0, id: 0,
device_id: device_id.to_string(), device_id: device_id.to_string(),
@@ -526,7 +528,9 @@ pub async fn query_energy(
let rows = query_v2(client, settings, &query).await?; let rows = query_v2(client, settings, &query).await?;
let mut out = Vec::new(); let mut out = Vec::new();
for row in rows.into_iter().take(limit as usize) { for row in rows.into_iter().take(limit as usize) {
let Some(timestamp) = parse_flux_time(&row) else { continue; }; let Some(timestamp) = parse_flux_time(&row) else {
continue;
};
out.push(EnergyReading { out.push(EnergyReading {
id: 0, id: 0,
device_id: device_id.to_string(), device_id: device_id.to_string(),
@@ -566,10 +570,16 @@ pub async fn query_network(
let mut out = Vec::new(); let mut out = Vec::new();
for item in series { for item in series {
let id = item.tags.get("target_id").cloned().unwrap_or_default(); let id = item.tags.get("target_id").cloned().unwrap_or_default();
let kind = item.tags.get("target_kind").cloned().unwrap_or_else(|| "device".into()); let kind = item
.tags
.get("target_kind")
.cloned()
.unwrap_or_else(|| "device".into());
let source = item.tags.get("source").cloned().unwrap_or_default(); let source = item.tags.get("source").cloned().unwrap_or_default();
for row in item.rows { for row in item.rows {
let Some(timestamp) = row_time(&row) else { continue; }; let Some(timestamp) = row_time(&row) else {
continue;
};
out.push(NetworkReading { out.push(NetworkReading {
id: 0, id: 0,
target_id: id.clone(), target_id: id.clone(),
@@ -577,9 +587,17 @@ pub async fn query_network(
timestamp, timestamp,
latency_ms: row_num(&row, "latency_ms"), latency_ms: row_num(&row, "latency_ms"),
jitter_ms: row_num(&row, "jitter_ms"), jitter_ms: row_num(&row, "jitter_ms"),
packet_loss_pct: row_num(&row, "packet_loss_pct").unwrap_or(0.0).clamp(0.0, 100.0), packet_loss_pct: row_num(&row, "packet_loss_pct")
sample_count: row_num(&row, "sample_count").unwrap_or(0.0).round().max(0.0) as u32, .unwrap_or(0.0)
successful_samples: row_num(&row, "successful_samples").unwrap_or(0.0).round().max(0.0) as u32, .clamp(0.0, 100.0),
sample_count: row_num(&row, "sample_count")
.unwrap_or(0.0)
.round()
.max(0.0) as u32,
successful_samples: row_num(&row, "successful_samples")
.unwrap_or(0.0)
.round()
.max(0.0) as u32,
source: source.clone(), source: source.clone(),
}); });
} }
@@ -590,7 +608,12 @@ pub async fn query_network(
} }
let tags = target_id let tags = target_id
.map(|value| format!(" |> filter(fn: (r) => r.target_id == {})", flux_string(value))) .map(|value| {
format!(
" |> filter(fn: (r) => r.target_id == {})",
flux_string(value)
)
})
.unwrap_or_default(); .unwrap_or_default();
let query = flux_query( let query = flux_query(
settings, settings,
@@ -604,18 +627,33 @@ pub async fn query_network(
let rows = query_v2(client, settings, &query).await?; let rows = query_v2(client, settings, &query).await?;
let mut out = Vec::new(); let mut out = Vec::new();
for row in rows.into_iter().take(limit as usize) { for row in rows.into_iter().take(limit as usize) {
let Some(timestamp) = parse_flux_time(&row) else { continue; }; let Some(timestamp) = parse_flux_time(&row) else {
let Some(id) = row.get("target_id").filter(|v| !v.is_empty()) else { continue; }; continue;
};
let Some(id) = row.get("target_id").filter(|v| !v.is_empty()) else {
continue;
};
out.push(NetworkReading { out.push(NetworkReading {
id: 0, id: 0,
target_id: id.clone(), target_id: id.clone(),
target_kind: row.get("target_kind").cloned().unwrap_or_else(|| "device".into()), target_kind: row
.get("target_kind")
.cloned()
.unwrap_or_else(|| "device".into()),
timestamp, timestamp,
latency_ms: row_f64(&row, "latency_ms"), latency_ms: row_f64(&row, "latency_ms"),
jitter_ms: row_f64(&row, "jitter_ms"), jitter_ms: row_f64(&row, "jitter_ms"),
packet_loss_pct: row_f64(&row, "packet_loss_pct").unwrap_or(0.0).clamp(0.0, 100.0), packet_loss_pct: row_f64(&row, "packet_loss_pct")
sample_count: row_f64(&row, "sample_count").unwrap_or(0.0).round().max(0.0) as u32, .unwrap_or(0.0)
successful_samples: row_f64(&row, "successful_samples").unwrap_or(0.0).round().max(0.0) as u32, .clamp(0.0, 100.0),
sample_count: row_f64(&row, "sample_count")
.unwrap_or(0.0)
.round()
.max(0.0) as u32,
successful_samples: row_f64(&row, "successful_samples")
.unwrap_or(0.0)
.round()
.max(0.0) as u32,
source: row.get("source").cloned().unwrap_or_default(), source: row.get("source").cloned().unwrap_or_default(),
}); });
} }
+84 -20
View File
@@ -1,19 +1,32 @@
pub async fn write_network( pub async fn write_network(
client: &Client, client: &Client,
settings: &InfluxDbSettings, settings: &InfluxDbSettings,
reading: &NetworkReading, reading: &NetworkReading,
) -> Result<()> { ) -> Result<()> {
if !settings.enabled { return Ok(()); } if !settings.enabled {
return Ok(());
}
let mut fields = Vec::new(); let mut fields = Vec::new();
push_float(&mut fields, "latency_ms", reading.latency_ms); push_float(&mut fields, "latency_ms", reading.latency_ms);
push_float(&mut fields, "jitter_ms", reading.jitter_ms); push_float(&mut fields, "jitter_ms", reading.jitter_ms);
push_float(&mut fields, "packet_loss_pct", Some(reading.packet_loss_pct.clamp(0.0, 100.0))); push_float(
&mut fields,
"packet_loss_pct",
Some(reading.packet_loss_pct.clamp(0.0, 100.0)),
);
push_int(&mut fields, "sample_count", reading.sample_count as i64); push_int(&mut fields, "sample_count", reading.sample_count as i64);
push_int(&mut fields, "successful_samples", reading.successful_samples as i64); push_int(
&mut fields,
"successful_samples",
reading.successful_samples as i64,
);
let line = line_protocol( let line = line_protocol(
NETWORK_MEASUREMENT, NETWORK_MEASUREMENT,
&[("target_id", &reading.target_id), ("target_kind", &reading.target_kind), ("source", &reading.source)], &[
("target_id", &reading.target_id),
("target_kind", &reading.target_kind),
("source", &reading.source),
],
fields, fields,
reading.timestamp, reading.timestamp,
)?; )?;
@@ -25,18 +38,32 @@ pub async fn write_network_batch(
settings: &InfluxDbSettings, settings: &InfluxDbSettings,
readings: &[NetworkReading], readings: &[NetworkReading],
) -> Result<()> { ) -> Result<()> {
if !settings.enabled || readings.is_empty() { return Ok(()); } if !settings.enabled || readings.is_empty() {
return Ok(());
}
let mut lines = Vec::with_capacity(readings.len()); let mut lines = Vec::with_capacity(readings.len());
for reading in readings { for reading in readings {
let mut fields = Vec::new(); let mut fields = Vec::new();
push_float(&mut fields, "latency_ms", reading.latency_ms); push_float(&mut fields, "latency_ms", reading.latency_ms);
push_float(&mut fields, "jitter_ms", reading.jitter_ms); push_float(&mut fields, "jitter_ms", reading.jitter_ms);
push_float(&mut fields, "packet_loss_pct", Some(reading.packet_loss_pct.clamp(0.0, 100.0))); push_float(
&mut fields,
"packet_loss_pct",
Some(reading.packet_loss_pct.clamp(0.0, 100.0)),
);
push_int(&mut fields, "sample_count", reading.sample_count as i64); push_int(&mut fields, "sample_count", reading.sample_count as i64);
push_int(&mut fields, "successful_samples", reading.successful_samples as i64); push_int(
&mut fields,
"successful_samples",
reading.successful_samples as i64,
);
lines.push(line_protocol( lines.push(line_protocol(
NETWORK_MEASUREMENT, NETWORK_MEASUREMENT,
&[("target_id", &reading.target_id), ("target_kind", &reading.target_kind), ("source", &reading.source)], &[
("target_id", &reading.target_id),
("target_kind", &reading.target_kind),
("source", &reading.source),
],
fields, fields,
reading.timestamp, reading.timestamp,
)?); )?);
@@ -44,22 +71,40 @@ pub async fn write_network_batch(
write_lines(client, settings, lines.join("\n")).await write_lines(client, settings, lines.join("\n")).await
} }
pub async fn write_energy( pub async fn write_energy(
client: &Client, client: &Client,
settings: &InfluxDbSettings, settings: &InfluxDbSettings,
reading: &EnergyReading, reading: &EnergyReading,
) -> Result<()> { ) -> Result<()> {
if !settings.enabled { return Ok(()); } if !settings.enabled {
return Ok(());
}
let mut fields = Vec::new(); let mut fields = Vec::new();
push_float(&mut fields, "raw_meter_value", Some(reading.raw_meter_value)); push_float(
push_float(&mut fields, "normalized_meter_kwh", Some(reading.normalized_meter_kwh)); &mut fields,
push_float(&mut fields, "consumption_kwh", Some(reading.consumption_kwh.max(0.0))); "raw_meter_value",
Some(reading.raw_meter_value),
);
push_float(
&mut fields,
"normalized_meter_kwh",
Some(reading.normalized_meter_kwh),
);
push_float(
&mut fields,
"consumption_kwh",
Some(reading.consumption_kwh.max(0.0)),
);
push_float(&mut fields, "current_power_kw", reading.current_power_kw); push_float(&mut fields, "current_power_kw", reading.current_power_kw);
push_int(&mut fields, "reset_detected", reading.reset_detected as i64); push_int(&mut fields, "reset_detected", reading.reset_detected as i64);
let line = line_protocol( let line = line_protocol(
ENERGY_MEASUREMENT, ENERGY_MEASUREMENT,
&[("device_id", &reading.device_id), ("source", &reading.source), ("quality", &reading.quality), ("raw_unit", &reading.raw_unit)], &[
("device_id", &reading.device_id),
("source", &reading.source),
("quality", &reading.quality),
("raw_unit", &reading.raw_unit),
],
fields, fields,
reading.timestamp, reading.timestamp,
)?; )?;
@@ -71,18 +116,37 @@ pub async fn write_energy_batch(
settings: &InfluxDbSettings, settings: &InfluxDbSettings,
readings: &[EnergyReading], readings: &[EnergyReading],
) -> Result<()> { ) -> Result<()> {
if !settings.enabled || readings.is_empty() { return Ok(()); } if !settings.enabled || readings.is_empty() {
return Ok(());
}
let mut lines = Vec::with_capacity(readings.len()); let mut lines = Vec::with_capacity(readings.len());
for reading in readings { for reading in readings {
let mut fields = Vec::new(); let mut fields = Vec::new();
push_float(&mut fields, "raw_meter_value", Some(reading.raw_meter_value)); push_float(
push_float(&mut fields, "normalized_meter_kwh", Some(reading.normalized_meter_kwh)); &mut fields,
push_float(&mut fields, "consumption_kwh", Some(reading.consumption_kwh.max(0.0))); "raw_meter_value",
Some(reading.raw_meter_value),
);
push_float(
&mut fields,
"normalized_meter_kwh",
Some(reading.normalized_meter_kwh),
);
push_float(
&mut fields,
"consumption_kwh",
Some(reading.consumption_kwh.max(0.0)),
);
push_float(&mut fields, "current_power_kw", reading.current_power_kw); push_float(&mut fields, "current_power_kw", reading.current_power_kw);
push_int(&mut fields, "reset_detected", reading.reset_detected as i64); push_int(&mut fields, "reset_detected", reading.reset_detected as i64);
lines.push(line_protocol( lines.push(line_protocol(
ENERGY_MEASUREMENT, ENERGY_MEASUREMENT,
&[("device_id", &reading.device_id), ("source", &reading.source), ("quality", &reading.quality), ("raw_unit", &reading.raw_unit)], &[
("device_id", &reading.device_id),
("source", &reading.source),
("quality", &reading.quality),
("raw_unit", &reading.raw_unit),
],
fields, fields,
reading.timestamp, reading.timestamp,
)?); )?);
+8 -3
View File
@@ -49,7 +49,12 @@ async fn main() -> Result<()> {
runtime_settings.discovery_broadcast = config.discovery_broadcast.clone(); runtime_settings.discovery_broadcast = config.discovery_broadcast.clone();
} }
config.apply_runtime_env_overrides(&mut runtime_settings); config.apply_runtime_env_overrides(&mut runtime_settings);
if runtime_settings.gree_cloud.installation_id.trim().is_empty() { if runtime_settings
.gree_cloud
.installation_id
.trim()
.is_empty()
{
runtime_settings.gree_cloud.installation_id = uuid::Uuid::new_v4().to_string(); runtime_settings.gree_cloud.installation_id = uuid::Uuid::new_v4().to_string();
} }
if runtime_settings.gree_cloud.account_id.trim().is_empty() { if runtime_settings.gree_cloud.account_id.trim().is_empty() {
@@ -162,8 +167,8 @@ async fn main() -> Result<()> {
listener, listener,
app.into_make_service_with_connect_info::<std::net::SocketAddr>(), app.into_make_service_with_connect_info::<std::net::SocketAddr>(),
) )
.with_graceful_shutdown(wait_for_shutdown(shutdown_rx.clone())) .with_graceful_shutdown(wait_for_shutdown(shutdown_rx.clone()))
.into_future(); .into_future();
tokio::pin!(server); tokio::pin!(server);
let deadline_rx = shutdown_rx; let deadline_rx = shutdown_rx;
tokio::select! { tokio::select! {
+70 -25
View File
@@ -17,7 +17,6 @@ pub enum ConnectionStatus {
Unknown, Unknown,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeviceCapabilities { pub struct DeviceCapabilities {
#[serde(default = "default_true")] #[serde(default = "default_true")]
@@ -36,32 +35,59 @@ pub struct DeviceCapabilities {
pub vertical_swing: bool, pub vertical_swing: bool,
#[serde(default = "default_true")] #[serde(default = "default_true")]
pub horizontal_swing: bool, pub horizontal_swing: bool,
#[serde(default)] pub turbo: bool, #[serde(default)]
#[serde(default)] pub quiet: bool, pub turbo: bool,
#[serde(default)] pub sleep: bool, #[serde(default)]
#[serde(default)] pub light: bool, pub quiet: bool,
#[serde(default)] pub health: bool, #[serde(default)]
#[serde(default)] pub buzzer_control: bool, pub sleep: bool,
#[serde(default)] pub energy_meter: bool, #[serde(default)]
#[serde(default)] pub compressor_frequency: bool, pub light: bool,
#[serde(default)]
pub health: bool,
#[serde(default)]
pub buzzer_control: bool,
#[serde(default)]
pub energy_meter: bool,
#[serde(default)]
pub compressor_frequency: bool,
} }
impl Default for DeviceCapabilities { impl Default for DeviceCapabilities {
fn default() -> Self { fn default() -> Self {
Self { Self {
power: true, mode: true, fan_modes: default_fan_modes(), power: true,
min_temperature: 8.0, max_temperature: 30.0, temperature_step: 1.0, mode: true,
vertical_swing: true, horizontal_swing: true, turbo: false, quiet: false, fan_modes: default_fan_modes(),
sleep: false, light: false, health: false, buzzer_control: false, min_temperature: 8.0,
energy_meter: false, compressor_frequency: false, max_temperature: 30.0,
temperature_step: 1.0,
vertical_swing: true,
horizontal_swing: true,
turbo: false,
quiet: false,
sleep: false,
light: false,
health: false,
buzzer_control: false,
energy_meter: false,
compressor_frequency: false,
} }
} }
} }
fn default_fan_modes() -> Vec<u8> { vec![0, 1, 2, 3, 4, 5] } fn default_fan_modes() -> Vec<u8> {
fn default_min_temperature() -> f64 { 8.0 } vec![0, 1, 2, 3, 4, 5]
fn default_max_temperature() -> f64 { 30.0 } }
fn default_temperature_step() -> f64 { 1.0 } fn default_min_temperature() -> f64 {
8.0
}
fn default_max_temperature() -> f64 {
30.0
}
fn default_temperature_step() -> f64 {
1.0
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
@@ -296,8 +322,13 @@ impl Device {
communication_failures: 0, communication_failures: 0,
pending_command: false, pending_command: false,
capabilities: DeviceCapabilities { capabilities: DeviceCapabilities {
turbo: true, quiet: true, sleep: true, light: true, health: true, turbo: true,
buzzer_control: true, ..DeviceCapabilities::default() quiet: true,
sleep: true,
light: true,
health: true,
buzzer_control: true,
..DeviceCapabilities::default()
}, },
energy_source: EnergySourcePreference::Auto, energy_source: EnergySourcePreference::Auto,
ha_energy_entity_id: None, ha_energy_entity_id: None,
@@ -320,10 +351,16 @@ impl Device {
self.capabilities.horizontal_swing = true; self.capabilities.horizontal_swing = true;
self.capabilities.temperature_step = 1.0; self.capabilities.temperature_step = 1.0;
} }
self.capabilities.turbo = self.supports_turbo.unwrap_or(self.connection_type == ConnectionType::Local); self.capabilities.turbo = self
self.capabilities.quiet = self.supports_quiet.unwrap_or(self.connection_type == ConnectionType::Local); .supports_turbo
.unwrap_or(self.connection_type == ConnectionType::Local);
self.capabilities.quiet = self
.supports_quiet
.unwrap_or(self.connection_type == ConnectionType::Local);
self.capabilities.sleep = self.supports_sleep.unwrap_or(false); self.capabilities.sleep = self.supports_sleep.unwrap_or(false);
self.capabilities.light = self.supports_light.unwrap_or(self.connection_type == ConnectionType::Local); self.capabilities.light = self
.supports_light
.unwrap_or(self.connection_type == ConnectionType::Local);
self.capabilities.health = self.supports_health.unwrap_or(false); self.capabilities.health = self.supports_health.unwrap_or(false);
self.capabilities.buzzer_control = self.supports_buzzer_control.unwrap_or(false); self.capabilities.buzzer_control = self.supports_buzzer_control.unwrap_or(false);
self.capabilities.energy_meter = self.supports_energy_meter.unwrap_or(false); self.capabilities.energy_meter = self.supports_energy_meter.unwrap_or(false);
@@ -391,8 +428,16 @@ impl DeviceCommand {
.cloned(), .cloned(),
target_temperature: self.target_temperature.filter(|value| { target_temperature: self.target_temperature.filter(|value| {
let step = device.capabilities.temperature_step.max(0.5); let step = device.capabilities.temperature_step.max(0.5);
let requested = (value.clamp(device.capabilities.min_temperature, device.capabilities.max_temperature) / step).round(); let requested = (value.clamp(
let current = (device.target_temperature.clamp(device.capabilities.min_temperature, device.capabilities.max_temperature) / step).round(); device.capabilities.min_temperature,
device.capabilities.max_temperature,
) / step)
.round();
let current = (device.target_temperature.clamp(
device.capabilities.min_temperature,
device.capabilities.max_temperature,
) / step)
.round();
requested != current requested != current
}), }),
fan_speed: self fan_speed: self
-2
View File
@@ -40,7 +40,6 @@ pub struct HaReading {
pub temperature: f64, pub temperature: f64,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NetworkReading { pub struct NetworkReading {
pub id: i64, pub id: i64,
@@ -90,4 +89,3 @@ pub struct EnergyReading {
#[serde(default)] #[serde(default)]
pub reset_detected: bool, pub reset_detected: bool,
} }
-1
View File
@@ -168,4 +168,3 @@ pub struct SettingsSnapshot {
pub home_assistant: HomeAssistantSettingsView, pub home_assistant: HomeAssistantSettingsView,
pub debug: DebugSettings, pub debug: DebugSettings,
} }
+4 -12
View File
@@ -151,10 +151,7 @@ fn persist_notification_status(
}; };
let updated = with_notification_status(metadata, status, reason, provider); let updated = with_notification_status(metadata, status, reason, provider);
match state.db.update_event_metadata(event_id, &updated) { match state.db.update_event_metadata(event_id, &updated) {
Ok(true) => state.broadcast( Ok(true) => state.broadcast("log.updated", json!({"id": event_id, "metadata": updated})),
"log.updated",
json!({"id": event_id, "metadata": updated}),
),
Ok(false) => tracing::warn!(event_id, "cannot update missing event log metadata"), Ok(false) => tracing::warn!(event_id, "cannot update missing event log metadata"),
Err(err) => tracing::warn!(event_id, error=?err, "cannot update event log metadata"), Err(err) => tracing::warn!(event_id, error=?err, "cannot update event log metadata"),
} }
@@ -224,14 +221,9 @@ pub async fn dispatch(
_ => Err("unsupported notification provider".into()), _ => Err("unsupported notification provider".into()),
}; };
match result { match result {
Ok(()) => persist_notification_status( Ok(()) => {
&state, persist_notification_status(&state, event_id, &metadata, "sent", None, &cfg.provider)
event_id, }
&metadata,
"sent",
None,
&cfg.provider,
),
Err(err) => { Err(err) => {
persist_notification_status( persist_notification_status(
&state, &state,
+1 -5
View File
@@ -199,11 +199,7 @@ impl GreeClient {
} }
if let Some(v) = command.quiet { if let Some(v) = command.quiet {
opt.push("Quiet"); opt.push("Quiet");
values.push(json!(if v { values.push(json!(if v { quiet_on_value.clamp(1, 3) } else { 0 }));
quiet_on_value.clamp(1, 3)
} else {
0
}));
} }
if let Some(v) = command.turbo { if let Some(v) = command.turbo {
opt.push("Tur"); opt.push("Tur");
+47 -12
View File
@@ -11,12 +11,16 @@ fn local_ipv4_config_for_target(target: Ipv4Addr) -> Result<Option<LocalIpv4Conf
use std::{ffi::CStr, ptr}; use std::{ffi::CStr, ptr};
unsafe { unsafe {
let mut addrs: *mut libc::ifaddrs = ptr::null_mut(); let mut addrs: *mut libc::ifaddrs = ptr::null_mut();
if libc::getifaddrs(&mut addrs) != 0 { return Err(std::io::Error::last_os_error()).context("getifaddrs failed"); } if libc::getifaddrs(&mut addrs) != 0 {
return Err(std::io::Error::last_os_error()).context("getifaddrs failed");
}
let mut current = addrs; let mut current = addrs;
let mut best: Option<LocalIpv4Config> = None; let mut best: Option<LocalIpv4Config> = None;
while !current.is_null() { while !current.is_null() {
let ifa = &*current; let ifa = &*current;
if !ifa.ifa_name.is_null() && !ifa.ifa_addr.is_null() && !ifa.ifa_netmask.is_null() if !ifa.ifa_name.is_null()
&& !ifa.ifa_addr.is_null()
&& !ifa.ifa_netmask.is_null()
&& (*ifa.ifa_addr).sa_family as i32 == libc::AF_INET && (*ifa.ifa_addr).sa_family as i32 == libc::AF_INET
{ {
let interface = CStr::from_ptr(ifa.ifa_name).to_string_lossy().into_owned(); let interface = CStr::from_ptr(ifa.ifa_name).to_string_lossy().into_owned();
@@ -35,7 +39,11 @@ fn local_ipv4_config_for_target(target: Ipv4Addr) -> Result<Option<LocalIpv4Conf
broadcast: Ipv4Addr::from(ip_u32 | !mask_u32), broadcast: Ipv4Addr::from(ip_u32 | !mask_u32),
prefix_len, prefix_len,
}; };
if best.as_ref().map(|current| prefix_len > current.prefix_len).unwrap_or(true) { if best
.as_ref()
.map(|current| prefix_len > current.prefix_len)
.unwrap_or(true)
{
best = Some(candidate); best = Some(candidate);
} }
} }
@@ -48,7 +56,9 @@ fn local_ipv4_config_for_target(target: Ipv4Addr) -> Result<Option<LocalIpv4Conf
} }
#[cfg(not(target_os = "linux"))] #[cfg(not(target_os = "linux"))]
fn local_ipv4_config_for_target(_target: Ipv4Addr) -> Result<Option<LocalIpv4Config>> { Ok(None) } fn local_ipv4_config_for_target(_target: Ipv4Addr) -> Result<Option<LocalIpv4Config>> {
Ok(None)
}
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
fn interface_ipv4_config(selector: &str) -> Result<(Ipv4Addr, Ipv4Addr)> { fn interface_ipv4_config(selector: &str) -> Result<(Ipv4Addr, Ipv4Addr)> {
@@ -56,7 +66,9 @@ fn interface_ipv4_config(selector: &str) -> Result<(Ipv4Addr, Ipv4Addr)> {
let requested_ip = selector.parse::<Ipv4Addr>().ok(); let requested_ip = selector.parse::<Ipv4Addr>().ok();
unsafe { unsafe {
let mut addrs: *mut libc::ifaddrs = ptr::null_mut(); let mut addrs: *mut libc::ifaddrs = ptr::null_mut();
if libc::getifaddrs(&mut addrs) != 0 { return Err(std::io::Error::last_os_error()).context("getifaddrs failed"); } if libc::getifaddrs(&mut addrs) != 0 {
return Err(std::io::Error::last_os_error()).context("getifaddrs failed");
}
let mut current = addrs; let mut current = addrs;
let mut found = None; let mut found = None;
while !current.is_null() { while !current.is_null() {
@@ -73,7 +85,9 @@ fn interface_ipv4_config(selector: &str) -> Result<(Ipv4Addr, Ipv4Addr)> {
let mask_addr = &*(ifa.ifa_netmask as *const libc::sockaddr_in); let mask_addr = &*(ifa.ifa_netmask as *const libc::sockaddr_in);
let mask = Ipv4Addr::from(mask_addr.sin_addr.s_addr.to_ne_bytes()); let mask = Ipv4Addr::from(mask_addr.sin_addr.s_addr.to_ne_bytes());
Ipv4Addr::from(u32::from(ip) | !u32::from(mask)) Ipv4Addr::from(u32::from(ip) | !u32::from(mask))
} else { Ipv4Addr::BROADCAST }; } else {
Ipv4Addr::BROADCAST
};
found = Some((ip, broadcast)); found = Some((ip, broadcast));
break; break;
} }
@@ -90,10 +104,19 @@ fn interface_ipv4_config(interface: &str) -> Result<(Ipv4Addr, Ipv4Addr)> {
bail!("GREE interface binding is only supported on Linux (requested {interface})") bail!("GREE interface binding is only supported on Linux (requested {interface})")
} }
fn interface_ipv4(interface: &str) -> Result<Ipv4Addr> { interface_ipv4_config(interface).map(|(ip, _)| ip) } fn interface_ipv4(interface: &str) -> Result<Ipv4Addr> {
fn value_as_i64(value: &Value) -> Option<i64> { value.as_i64().or_else(|| value.as_str()?.trim().parse().ok()) } interface_ipv4_config(interface).map(|(ip, _)| ip)
}
fn value_as_i64(value: &Value) -> Option<i64> {
value
.as_i64()
.or_else(|| value.as_str()?.trim().parse().ok())
}
fn value_as_f64(value: &Value) -> Option<f64> { fn value_as_f64(value: &Value) -> Option<f64> {
value.as_f64().or_else(|| value.as_str()?.trim().parse().ok()).filter(|value| value.is_finite()) value
.as_f64()
.or_else(|| value.as_str()?.trim().parse().ok())
.filter(|value| value.is_finite())
} }
fn status_i64(name: &str, value: &Value) -> Result<i64> { fn status_i64(name: &str, value: &Value) -> Result<i64> {
value_as_i64(value).ok_or_else(|| anyhow!("invalid GREE integer value for {name}: {value}")) value_as_i64(value).ok_or_else(|| anyhow!("invalid GREE integer value for {name}: {value}"))
@@ -121,11 +144,23 @@ fn status_feature_flag(name: &str, value: &Value) -> Result<bool> {
} }
Ok(raw != 0) Ok(raw != 0)
} }
fn mode_name_checked(value: i64) -> Option<&'static str> { match value { 0 => Some("auto"), 1 => Some("cool"), 2 => Some("dry"), 3 => Some("fan"), 4 => Some("heat"), _ => None } } fn mode_name_checked(value: i64) -> Option<&'static str> {
match value {
0 => Some("auto"),
1 => Some("cool"),
2 => Some("dry"),
3 => Some("fan"),
4 => Some("heat"),
_ => None,
}
}
fn mode_value(value: &str) -> Result<i64> { fn mode_value(value: &str) -> Result<i64> {
match value.to_ascii_lowercase().as_str() { match value.to_ascii_lowercase().as_str() {
"auto" => Ok(0), "cool" => Ok(1), "dry" => Ok(2), "fan" => Ok(3), "heat" => Ok(4), "auto" => Ok(0),
"cool" => Ok(1),
"dry" => Ok(2),
"fan" => Ok(3),
"heat" => Ok(4),
_ => bail!("unsupported mode: {value}"), _ => bail!("unsupported mode: {value}"),
} }
} }
+10 -3
View File
@@ -67,7 +67,9 @@ mod tests {
"cols": ["Pow", "Quiet"], "cols": ["Pow", "Quiet"],
"dat": [1, 2] "dat": [1, 2]
}); });
client.apply_status(&mut device, &response).expect("Quiet=2 status"); client
.apply_status(&mut device, &response)
.expect("Quiet=2 status");
assert!(device.power); assert!(device.power);
assert!(device.quiet); assert!(device.quiet);
@@ -87,7 +89,10 @@ mod tests {
) )
.expect("quiet command payload"); .expect("quiet command payload");
assert_eq!(payload.get("opt").cloned(), Some(serde_json::json!(["Quiet"]))); assert_eq!(
payload.get("opt").cloned(),
Some(serde_json::json!(["Quiet"]))
);
assert_eq!(payload.get("p").cloned(), Some(serde_json::json!([2]))); assert_eq!(payload.get("p").cloned(), Some(serde_json::json!([2])));
} }
@@ -105,7 +110,9 @@ mod tests {
"cols": ["Pow", "Air"], "cols": ["Pow", "Air"],
"dat": [1, 3] "dat": [1, 3]
}); });
client.apply_status(&mut device, &response).expect("Air=3 status"); client
.apply_status(&mut device, &response)
.expect("Air=3 status");
assert!(device.power); assert!(device.power);
assert!(device.air); assert!(device.air);
+28 -14
View File
@@ -117,7 +117,11 @@ impl GreeCloudApi {
.await .await
.context("GREE Cloud login request failed")?; .context("GREE Cloud login request failed")?;
if data.get("r").and_then(Value::as_i64).is_some_and(|r| r != 200) { if data
.get("r")
.and_then(Value::as_i64)
.is_some_and(|r| r != 200)
{
let message = data let message = data
.get("msg") .get("msg")
.and_then(Value::as_str) .and_then(Value::as_str)
@@ -230,8 +234,14 @@ impl GreeCloudApi {
Ok(devices) Ok(devices)
} }
async fn request_now(&self, endpoint: &str, payload: Value, hash_props: &[&str]) -> Result<Value> { async fn request_now(
self.request_at(endpoint, payload, hash_props, Utc::now()).await &self,
endpoint: &str,
payload: Value,
hash_props: &[&str],
) -> Result<Value> {
self.request_at(endpoint, payload, hash_props, Utc::now())
.await
} }
async fn request_at( async fn request_at(
@@ -318,7 +328,11 @@ fn filter_duplicate_devices(devices: Vec<CloudDeviceInfo>) -> Vec<CloudDeviceInf
filtered.extend(preferred); filtered.extend(preferred);
} }
} }
filtered.sort_by(|a, b| a.name.to_ascii_lowercase().cmp(&b.name.to_ascii_lowercase())); filtered.sort_by(|a, b| {
a.name
.to_ascii_lowercase()
.cmp(&b.name.to_ascii_lowercase())
});
filtered filtered
} }
@@ -344,9 +358,12 @@ fn value_bool(value: &Value) -> bool {
.as_bool() .as_bool()
.or_else(|| value.as_i64().map(|v| v != 0)) .or_else(|| value.as_i64().map(|v| v != 0))
.or_else(|| { .or_else(|| {
value value.as_str().map(|v| {
.as_str() !matches!(
.map(|v| !matches!(v.trim().to_ascii_lowercase().as_str(), "0" | "false" | "off" | "")) v.trim().to_ascii_lowercase().as_str(),
"0" | "false" | "off" | ""
)
})
}) })
.unwrap_or(false) .unwrap_or(false)
} }
@@ -383,9 +400,9 @@ fn md5_hex(input: &str) -> String {
fn md5(input: &[u8]) -> [u8; 16] { fn md5(input: &[u8]) -> [u8; 16] {
const S: [u32; 64] = [ const S: [u32; 64] = [
7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 5, 9, 14, 20, 5, 9, 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 5, 9, 14, 20, 5, 9, 14, 20, 5,
14, 20, 5, 9, 14, 20, 5, 9, 14, 20, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 4, 9, 14, 20, 5, 9, 14, 20, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 6, 10,
11, 16, 23, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21,
]; ];
const K: [u32; 64] = [ const K: [u32; 64] = [
0xd76aa478, 0xe8c7b756, 0x242070db, 0xc1bdceee, 0xf57c0faf, 0x4787c62a, 0xa8304613, 0xd76aa478, 0xe8c7b756, 0x242070db, 0xc1bdceee, 0xf57c0faf, 0x4787c62a, 0xa8304613,
@@ -432,10 +449,7 @@ fn md5(input: &[u8]) -> [u8; 16] {
32..=47 => (b ^ c ^ d, (3 * i + 5) % 16), 32..=47 => (b ^ c ^ d, (3 * i + 5) % 16),
_ => (c ^ (b | (!d)), (7 * i) % 16), _ => (c ^ (b | (!d)), (7 * i) % 16),
}; };
let next = a let next = a.wrapping_add(f).wrapping_add(K[i]).wrapping_add(m[g]);
.wrapping_add(f)
.wrapping_add(K[i])
.wrapping_add(m[g]);
a = d; a = d;
d = c; d = c;
c = b; c = b;
+52 -25
View File
@@ -13,8 +13,8 @@ use tokio::{
sync::{broadcast, mpsc}, sync::{broadcast, mpsc},
time::{interval, timeout}, time::{interval, timeout},
}; };
use tokio_rustls::{rustls, TlsConnector};
use tokio_rustls::rustls::pki_types::ServerName; use tokio_rustls::rustls::pki_types::ServerName;
use tokio_rustls::{rustls, TlsConnector};
pub const MQTT_PORT: u16 = 1984; pub const MQTT_PORT: u16 = 1984;
pub const MQTT_KEEPALIVE_SECONDS: u16 = 60; pub const MQTT_KEEPALIVE_SECONDS: u16 = 60;
@@ -61,11 +61,18 @@ pub struct MqttDeviceEnvelope {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum MqttEvent { pub enum MqttEvent {
Message { topic: String, payload: Vec<u8> }, Message {
topic: String,
payload: Vec<u8>,
},
/// Broker-level traffic such as SUBACK/PUBACK/PINGRESP. This proves the MQTT /// Broker-level traffic such as SUBACK/PUBACK/PINGRESP. This proves the MQTT
/// session is alive without being mistaken for a response from the HVAC unit. /// session is alive without being mistaken for a response from the HVAC unit.
Traffic { kind: &'static str }, Traffic {
Disconnected { reason: String }, kind: &'static str,
},
Disconnected {
reason: String,
},
} }
#[derive(Debug)] #[derive(Debug)]
@@ -150,13 +157,7 @@ impl MqttConnection {
let connected = Arc::new(AtomicBool::new(true)); let connected = Arc::new(AtomicBool::new(true));
let packet_ids = Arc::new(AtomicU16::new(1)); let packet_ids = Arc::new(AtomicU16::new(1));
let last_rx_ms = Arc::new(AtomicU64::new(unix_millis())); let last_rx_ms = Arc::new(AtomicU64::new(unix_millis()));
spawn_writer( spawn_writer(writer, rx, connected.clone(), events.clone(), packet_ids);
writer,
rx,
connected.clone(),
events.clone(),
packet_ids,
);
spawn_reader( spawn_reader(
reader, reader,
tx.clone(), tx.clone(),
@@ -230,7 +231,9 @@ impl MqttConnection {
Ok(MqttEvent::Disconnected { reason }) => break Err(anyhow!("{}", reason)), Ok(MqttEvent::Disconnected { reason }) => break Err(anyhow!("{}", reason)),
Ok(_) => {} Ok(_) => {}
Err(broadcast::error::RecvError::Lagged(_)) => {} Err(broadcast::error::RecvError::Lagged(_)) => {}
Err(broadcast::error::RecvError::Closed) => break Err(anyhow!("GREE Cloud MQTT event stream closed")), Err(broadcast::error::RecvError::Closed) => {
break Err(anyhow!("GREE Cloud MQTT event stream closed"))
}
} }
} }
}) })
@@ -365,15 +368,21 @@ fn spawn_reader<R>(
Ok((header, payload)) => { Ok((header, payload)) => {
last_rx_ms.store(unix_millis(), Ordering::Release); last_rx_ms.store(unix_millis(), Ordering::Release);
match header >> 4 { match header >> 4 {
3 => { 3 => {
if let Err(err) = handle_publish(header, &payload, &tx, &events).await { if let Err(err) = handle_publish(header, &payload, &tx, &events).await {
tracing::warn!(error=?err, "invalid GREE Cloud MQTT PUBLISH"); tracing::warn!(error=?err, "invalid GREE Cloud MQTT PUBLISH");
}
} }
} 9 => {
9 => { let _ = events.send(MqttEvent::Traffic { kind: "SUBACK" }); } let _ = events.send(MqttEvent::Traffic { kind: "SUBACK" });
4 => { let _ = events.send(MqttEvent::Traffic { kind: "PUBACK" }); } }
13 => { let _ = events.send(MqttEvent::Traffic { kind: "PINGRESP" }); } 4 => {
_ => {} let _ = events.send(MqttEvent::Traffic { kind: "PUBACK" });
}
13 => {
let _ = events.send(MqttEvent::Traffic { kind: "PINGRESP" });
}
_ => {}
} }
} }
Err(err) => { Err(err) => {
@@ -415,7 +424,10 @@ async fn handle_publish(
} }
} }
let body = payload[offset..].to_vec(); let body = payload[offset..].to_vec();
let _ = events.send(MqttEvent::Message { topic, payload: body }); let _ = events.send(MqttEvent::Message {
topic,
payload: body,
});
Ok(()) Ok(())
} }
@@ -438,7 +450,11 @@ fn unix_millis() -> u64 {
fn next_packet_id(ids: &AtomicU16) -> u16 { fn next_packet_id(ids: &AtomicU16) -> u16 {
let id = ids.fetch_add(1, Ordering::Relaxed); let id = ids.fetch_add(1, Ordering::Relaxed);
if id == 0 { 1 } else { id } if id == 0 {
1
} else {
id
}
} }
fn mark_disconnected( fn mark_disconnected(
@@ -451,7 +467,12 @@ fn mark_disconnected(
} }
} }
fn encode_connect(client_id: &str, username: &str, password: &str, keepalive: u16) -> Result<Vec<u8>> { fn encode_connect(
client_id: &str,
username: &str,
password: &str,
keepalive: u16,
) -> Result<Vec<u8>> {
let mut body = Vec::new(); let mut body = Vec::new();
push_utf8(&mut body, "MQTT")?; push_utf8(&mut body, "MQTT")?;
body.push(4); // MQTT 3.1.1 body.push(4); // MQTT 3.1.1
@@ -548,7 +569,13 @@ mod tests {
#[test] #[test]
fn region_brokers_match_cloud_reference() { fn region_brokers_match_cloud_reference() {
assert_eq!(broker_for_region("Europe"), Some("mqtt-eu.gree.com")); assert_eq!(broker_for_region("Europe"), Some("mqtt-eu.gree.com"));
assert_eq!(broker_for_region("North American"), Some("mqtt-na.gree.com")); assert_eq!(
assert_eq!(broker_for_region("China Mainland"), Some("mqtt-cn.gree.com")); broker_for_region("North American"),
Some("mqtt-na.gree.com")
);
assert_eq!(
broker_for_region("China Mainland"),
Some("mqtt-cn.gree.com")
);
} }
} }
+518 -150
View File
@@ -1,10 +1,14 @@
use crate::{ use crate::{
models::{ApiEvent, ConnectionStatus, ConnectionType, Device, DeviceCommand, GreeCloudSettings}, models::{
ApiEvent, ConnectionStatus, ConnectionType, Device, DeviceCommand, GreeCloudSettings,
},
protocol::{ protocol::{
crypto::{decrypt_v1, decrypt_v2, encrypt_v1, encrypt_v2}, crypto::{decrypt_v1, decrypt_v2, encrypt_v1, encrypt_v2},
gree::{BindResult, GreeClient}, gree::{BindResult, GreeClient},
gree_cloud::{parent_mac, CloudCredentials, GreeCloudApi}, gree_cloud::{parent_mac, CloudCredentials, GreeCloudApi},
gree_cloud_mqtt::{broker_for_region, MqttConnection, MqttDeviceEnvelope, MqttEvent, MQTT_PORT}, gree_cloud_mqtt::{
broker_for_region, MqttConnection, MqttDeviceEnvelope, MqttEvent, MQTT_PORT,
},
}, },
}; };
use anyhow::{anyhow, bail, Context, Result}; use anyhow::{anyhow, bail, Context, Result};
@@ -33,10 +37,37 @@ const CLOUD_MAX_CONCURRENT_REQUESTS: usize = 8;
const CLOUD_QUEUE_WAIT_TIMEOUT: Duration = Duration::from_secs(2); const CLOUD_QUEUE_WAIT_TIMEOUT: Duration = Duration::from_secs(2);
const CLOUD_DEVICE_LOCK_TIMEOUT: Duration = Duration::from_secs(2); const CLOUD_DEVICE_LOCK_TIMEOUT: Duration = Duration::from_secs(2);
const KNOWN_CLOUD_PROPERTIES: &[&str] = &[ const KNOWN_CLOUD_PROPERTIES: &[&str] = &[
"Pow", "Mod", "Dwet", "DwatSen", "Dfltr", "DwatFul", "Dmod", "SetTem", "TemSen", "Pow",
"TemUn", "TemRec", "HalfTemEn", "SetDeciTem", "Add0.5", "WdSpd", "Air", "Blo", "Mod",
"Health", "SwhSlp", "SlpMod", "Lig", "SwingLfRig", "SwUpDn", "Quiet", "Tur", "StHt", "Dwet",
"SvSt", "HeatCoolType", "hid", "ElcAll", "CompressorFqy", "DwatSen",
"Dfltr",
"DwatFul",
"Dmod",
"SetTem",
"TemSen",
"TemUn",
"TemRec",
"HalfTemEn",
"SetDeciTem",
"Add0.5",
"WdSpd",
"Air",
"Blo",
"Health",
"SwhSlp",
"SlpMod",
"Lig",
"SwingLfRig",
"SwUpDn",
"Quiet",
"Tur",
"StHt",
"SvSt",
"HeatCoolType",
"hid",
"ElcAll",
"CompressorFqy",
]; ];
// Property set used by older GREE Wi-Fi modules before the dehumidifier, // Property set used by older GREE Wi-Fi modules before the dehumidifier,
// half-degree and cloud energy fields were added. Some V1.x modules silently // half-degree and cloud energy fields were added. Some V1.x modules silently
@@ -45,9 +76,26 @@ const KNOWN_CLOUD_PROPERTIES: &[&str] = &[
// This list matches the long-standing greeclimate status schema used by those // This list matches the long-standing greeclimate status schema used by those
// modules and intentionally excludes newer optional fields. // modules and intentionally excludes newer optional fields.
const LEGACY_CLOUD_PROPERTIES: &[&str] = &[ const LEGACY_CLOUD_PROPERTIES: &[&str] = &[
"Pow", "Mod", "SetTem", "TemSen", "TemUn", "TemRec", "WdSpd", "Air", "Blo", "Pow",
"Health", "SwhSlp", "SlpMod", "Lig", "SwingLfRig", "SwUpDn", "Quiet", "Tur", "Mod",
"StHt", "SvSt", "HeatCoolType", "SetTem",
"TemSen",
"TemUn",
"TemRec",
"WdSpd",
"Air",
"Blo",
"Health",
"SwhSlp",
"SlpMod",
"Lig",
"SwingLfRig",
"SwUpDn",
"Quiet",
"Tur",
"StHt",
"SvSt",
"HeatCoolType",
]; ];
#[derive(Clone)] #[derive(Clone)]
@@ -314,7 +362,8 @@ impl GreeCloudProvider {
let session = self.current_session().await?; let session = self.current_session().await?;
let parent = cloud_wire_parent(&session, device); let parent = cloud_wire_parent(&session, device);
session.mqtt.subscribe_device(&parent).await?; session.mqtt.subscribe_device(&parent).await?;
self.set_subscriptions(device, &session.broker, &parent).await; self.set_subscriptions(device, &session.broker, &parent)
.await;
let response = self.poll_status_compatible(device, &session).await?; let response = self.poll_status_compatible(device, &session).await?;
apply_cloud_capability_snapshot(device, &response.properties); apply_cloud_capability_snapshot(device, &response.properties);
@@ -327,7 +376,8 @@ impl GreeCloudProvider {
device.last_seen = Some(Utc::now()); device.last_seen = Some(Utc::now());
device.last_cloud_sync = Some(Utc::now()); device.last_cloud_sync = Some(Utc::now());
device.updated_at = Utc::now(); device.updated_at = Utc::now();
self.update_diagnostics_from_message(device, &response).await; self.update_diagnostics_from_message(device, &response)
.await;
Ok(()) Ok(())
} }
@@ -394,10 +444,16 @@ impl GreeCloudProvider {
let alternate_cipher = if current_cipher == 2 { 1 } else { 2 }; let alternate_cipher = if current_cipher == 2 { 1 } else { 2 };
let alternate_wire = alternate_wire_mac_case(&current_wire); let alternate_wire = alternate_wire_mac_case(&current_wire);
let (cipher, wire, profile) = match step { let (cipher, wire, profile) = match step {
1 => (alternate_cipher, current_wire.clone(), "legacy-cipher-probe"), 1 => (
alternate_cipher,
current_wire.clone(),
"legacy-cipher-probe",
),
2 => ( 2 => (
current_cipher, current_cipher,
alternate_wire.clone().unwrap_or_else(|| current_wire.clone()), alternate_wire
.clone()
.unwrap_or_else(|| current_wire.clone()),
"legacy-case-probe", "legacy-case-probe",
), ),
3 => ( 3 => (
@@ -424,17 +480,13 @@ impl GreeCloudProvider {
self.remember_status_compatibility(device, &wire, "legacy") self.remember_status_compatibility(device, &wire, "legacy")
.await; .await;
if wire != current_wire { if wire != current_wire {
self.set_subscriptions(device, &session.broker, &parent).await; self.set_subscriptions(device, &session.broker, &parent)
.await;
} }
Ok(response) Ok(response)
} }
async fn remember_status_compatibility( async fn remember_status_compatibility(&self, device: &Device, wire_mac: &str, profile: &str) {
&self,
device: &Device,
wire_mac: &str,
profile: &str,
) {
let stable = cloud_id(device); let stable = cloud_id(device);
if let Some(session) = self.inner.session.write().await.as_mut() { if let Some(session) = self.inner.session.write().await.as_mut() {
session.wire_macs.insert(stable, wire_mac.to_string()); session.wire_macs.insert(stable, wire_mac.to_string());
@@ -463,17 +515,24 @@ impl GreeCloudProvider {
let session = self.current_session().await?; let session = self.current_session().await?;
let parent = cloud_wire_parent(&session, device); let parent = cloud_wire_parent(&session, device);
session.mqtt.subscribe_device(&parent).await?; session.mqtt.subscribe_device(&parent).await?;
self.set_subscriptions(device, &session.broker, &parent).await; self.set_subscriptions(device, &session.broker, &parent)
.await;
let commands = self.build_command_sequence(device, command, suppress_beep).await?; let commands = self
.build_command_sequence(device, command, suppress_beep)
.await?;
let mut applied = DeviceCommand::default(); let mut applied = DeviceCommand::default();
for (opt, values, fragment) in commands { for (opt, values, fragment) in commands {
let payload = json!({"t":"cmd", "opt": opt, "p": values}); let payload = json!({"t":"cmd", "opt": opt, "p": values});
// greeclimate treats a 2s no-ACK as uncertain success. Preserve that semantic, // greeclimate treats a 2s no-ACK as uncertain success. Preserve that semantic,
// then let the engine's bounded status verification decide the final UI state. // then let the engine's bounded status verification decide the final UI state.
match self.request(device, &session, payload, CLOUD_COMMAND_TIMEOUT).await { match self
.request(device, &session, payload, CLOUD_COMMAND_TIMEOUT)
.await
{
Ok(response) => { Ok(response) => {
self.update_diagnostics_from_message(device, &response).await; self.update_diagnostics_from_message(device, &response)
.await;
fragment.apply(device); fragment.apply(device);
merge_command(&mut applied, &fragment); merge_command(&mut applied, &fragment);
} }
@@ -497,7 +556,14 @@ impl GreeCloudProvider {
let mut out = Vec::new(); let mut out = Vec::new();
if let Some(mode) = command.mode.as_deref() { if let Some(mode) = command.mode.as_deref() {
let value = mode_to_wire(mode)?; let value = mode_to_wire(mode)?;
out.push((vec!["Mod".into()], vec![json!(value)], DeviceCommand { mode: Some(mode.into()), ..Default::default() })); out.push((
vec!["Mod".into()],
vec![json!(value)],
DeviceCommand {
mode: Some(mode.into()),
..Default::default()
},
));
} }
if let Some(target) = command.target_temperature { if let Some(target) = command.target_temperature {
@@ -513,24 +579,121 @@ impl GreeCloudProvider {
opt.push("Add0.5".into()); opt.push("Add0.5".into());
values.push(json!(half)); values.push(json!(half));
} }
out.push((opt, values, DeviceCommand { target_temperature: Some(target), ..Default::default() })); out.push((
opt,
values,
DeviceCommand {
target_temperature: Some(target),
..Default::default()
},
));
} }
let mut simple = Vec::new(); let mut simple = Vec::new();
if let Some(value) = command.fan_speed { simple.push(("WdSpd", json!(value.min(5)), DeviceCommand { fan_speed: Some(value.min(5)), ..Default::default() })); } if let Some(value) = command.fan_speed {
if let Some(value) = command.swing_vertical { simple.push(("SwUpDn", json!(if value { 1 } else { 0 }), DeviceCommand { swing_vertical: Some(value), ..Default::default() })); } simple.push((
if let Some(value) = command.swing_horizontal { simple.push(("SwingLfRig", json!(if value { 1 } else { 0 }), DeviceCommand { swing_horizontal: Some(value), ..Default::default() })); } "WdSpd",
if let Some(value) = command.quiet { json!(value.min(5)),
let wire = if value { device.quiet_wire_value.unwrap_or(2).max(1) } else { 0 }; DeviceCommand {
simple.push(("Quiet", json!(wire), DeviceCommand { quiet: Some(value), ..Default::default() })); fan_speed: Some(value.min(5)),
..Default::default()
},
));
}
if let Some(value) = command.swing_vertical {
simple.push((
"SwUpDn",
json!(if value { 1 } else { 0 }),
DeviceCommand {
swing_vertical: Some(value),
..Default::default()
},
));
}
if let Some(value) = command.swing_horizontal {
simple.push((
"SwingLfRig",
json!(if value { 1 } else { 0 }),
DeviceCommand {
swing_horizontal: Some(value),
..Default::default()
},
));
}
if let Some(value) = command.quiet {
let wire = if value {
device.quiet_wire_value.unwrap_or(2).max(1)
} else {
0
};
simple.push((
"Quiet",
json!(wire),
DeviceCommand {
quiet: Some(value),
..Default::default()
},
));
}
if let Some(value) = command.turbo {
simple.push((
"Tur",
json!(u8::from(value)),
DeviceCommand {
turbo: Some(value),
..Default::default()
},
));
}
if let Some(value) = command.light {
simple.push((
"Lig",
json!(u8::from(value)),
DeviceCommand {
light: Some(value),
..Default::default()
},
));
}
if let Some(value) = command.air {
simple.push((
"Air",
json!(u8::from(value)),
DeviceCommand {
air: Some(value),
..Default::default()
},
));
}
if let Some(value) = command.xfan {
simple.push((
"Blo",
json!(u8::from(value)),
DeviceCommand {
xfan: Some(value),
..Default::default()
},
));
}
if let Some(value) = command.health {
simple.push((
"Health",
json!(u8::from(value)),
DeviceCommand {
health: Some(value),
..Default::default()
},
));
} }
if let Some(value) = command.turbo { simple.push(("Tur", json!(u8::from(value)), DeviceCommand { turbo: Some(value), ..Default::default() })); }
if let Some(value) = command.light { simple.push(("Lig", json!(u8::from(value)), DeviceCommand { light: Some(value), ..Default::default() })); }
if let Some(value) = command.air { simple.push(("Air", json!(u8::from(value)), DeviceCommand { air: Some(value), ..Default::default() })); }
if let Some(value) = command.xfan { simple.push(("Blo", json!(u8::from(value)), DeviceCommand { xfan: Some(value), ..Default::default() })); }
if let Some(value) = command.health { simple.push(("Health", json!(u8::from(value)), DeviceCommand { health: Some(value), ..Default::default() })); }
if let Some(value) = command.sleep { if let Some(value) = command.sleep {
simple.push(("SwhSlp", json!(u8::from(value)), DeviceCommand { sleep: Some(value), ..Default::default() })); simple.push((
"SwhSlp",
json!(u8::from(value)),
DeviceCommand {
sleep: Some(value),
..Default::default()
},
));
simple.push(("SlpMod", json!(u8::from(value)), DeviceCommand::default())); simple.push(("SlpMod", json!(u8::from(value)), DeviceCommand::default()));
} }
for (name, value, fragment) in simple { for (name, value, fragment) in simple {
@@ -538,7 +701,14 @@ impl GreeCloudProvider {
} }
if let Some(value) = command.power { if let Some(value) = command.power {
out.push((vec!["Pow".into()], vec![json!(u8::from(value))], DeviceCommand { power: Some(value), ..Default::default() })); out.push((
vec!["Pow".into()],
vec![json!(u8::from(value))],
DeviceCommand {
power: Some(value),
..Default::default()
},
));
} }
// greeclimate adds Buzzer_ON_OFF=1 to every state command when its shared // greeclimate adds Buzzer_ON_OFF=1 to every state command when its shared
@@ -614,13 +784,21 @@ impl GreeCloudProvider {
.ok_or_else(|| anyhow!("GREE Cloud MQTT is disconnected")) .ok_or_else(|| anyhow!("GREE Cloud MQTT is disconnected"))
} }
pub async fn probe_mqtt(&self, settings: &GreeCloudSettings, devices: &[Device]) -> Result<u64> { pub async fn probe_mqtt(
&self,
settings: &GreeCloudSettings,
devices: &[Device],
) -> Result<u64> {
self.ensure_connected(settings, devices).await?; self.ensure_connected(settings, devices).await?;
let session = self.current_session().await?; let session = self.current_session().await?;
session.mqtt.ping_round_trip().await session.mqtt.ping_round_trip().await
} }
pub async fn ensure_connected(&self, settings: &GreeCloudSettings, devices: &[Device]) -> Result<()> { pub async fn ensure_connected(
&self,
settings: &GreeCloudSettings,
devices: &[Device],
) -> Result<()> {
if !settings.enabled { if !settings.enabled {
bail!("GREE Cloud is disabled"); bail!("GREE Cloud is disabled");
} }
@@ -637,9 +815,12 @@ impl GreeCloudProvider {
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
.is_err() .is_err()
{ {
timeout(CLOUD_CONNECT_TIMEOUT + Duration::from_secs(5), connection_finished) timeout(
.await CLOUD_CONNECT_TIMEOUT + Duration::from_secs(5),
.context("timed out waiting for GREE Cloud connection attempt")?; connection_finished,
)
.await
.context("timed out waiting for GREE Cloud connection attempt")?;
return if self.is_connected().await { return if self.is_connected().await {
Ok(()) Ok(())
} else { } else {
@@ -824,7 +1005,10 @@ impl GreeCloudProvider {
"parents": parents.len(), "parents": parents.len(),
})); }));
let mut diagnostics = self.inner.diagnostics.write().await; let mut diagnostics = self.inner.diagnostics.write().await;
for device in devices.iter().filter(|d| d.connection_type == ConnectionType::GreeCloud) { for device in devices
.iter()
.filter(|d| d.connection_type == ConnectionType::GreeCloud)
{
let item = diagnostics.entry(device.id.clone()).or_default(); let item = diagnostics.entry(device.id.clone()).or_default();
item.mqtt_state = "connected".into(); item.mqtt_state = "connected".into();
item.last_connect = Some(now); item.last_connect = Some(now);
@@ -836,7 +1020,10 @@ impl GreeCloudProvider {
} }
} }
tracing::info!(broker=%broker, "GREE Cloud MQTT connected"); tracing::info!(broker=%broker, "GREE Cloud MQTT connected");
tracing::info!(count=parents.len(), "GREE Cloud MQTT subscriptions restored"); tracing::info!(
count = parents.len(),
"GREE Cloud MQTT subscriptions restored"
);
Ok(()) Ok(())
} }
@@ -847,16 +1034,8 @@ impl GreeCloudProvider {
inner_payload: Value, inner_payload: Value,
wait: Duration, wait: Duration,
) -> Result<ParsedCloudMessage> { ) -> Result<ParsedCloudMessage> {
self.request_with_options( self.request_with_options(device, session, inner_payload, wait, None, None, None)
device, .await
session,
inner_payload,
wait,
None,
None,
None,
)
.await
} }
async fn request_with_options( async fn request_with_options(
@@ -883,7 +1062,8 @@ impl GreeCloudProvider {
.map(String::as_str) .map(String::as_str)
.or(device.key.as_deref()) .or(device.key.as_deref())
.ok_or_else(|| anyhow!("GREE Cloud device key is missing"))?; .ok_or_else(|| anyhow!("GREE Cloud device key is missing"))?;
let cipher = cipher_override.unwrap_or_else(|| if device.protocol_version == 2 { 2 } else { 1 }); let cipher =
cipher_override.unwrap_or_else(|| if device.protocol_version == 2 { 2 } else { 1 });
let plaintext = serde_json::to_vec(&inner_payload)?; let plaintext = serde_json::to_vec(&inner_payload)?;
let (pack, tag) = if cipher == 2 { let (pack, tag) = if cipher == 2 {
let encrypted = encrypt_v2(key, &plaintext)?; let encrypted = encrypt_v2(key, &plaintext)?;
@@ -1139,7 +1319,10 @@ impl GreeCloudProvider {
// and must be ignored rather than reported as a payload error every few seconds. // and must be ignored rather than reported as a payload error every few seconds.
let registered = self.inner.registered.read().await.clone(); let registered = self.inner.registered.read().await.clone();
if registered.is_empty() { if registered.is_empty() {
tracing::debug!(topic, "ignoring GREE Cloud MQTT payload without registered devices"); tracing::debug!(
topic,
"ignoring GREE Cloud MQTT payload without registered devices"
);
return Ok(()); return Ok(());
} }
if topic.starts_with("connect/") { if topic.starts_with("connect/") {
@@ -1158,7 +1341,8 @@ impl GreeCloudProvider {
}); });
return Ok(()); return Ok(());
} }
let envelope: MqttDeviceEnvelope = serde_json::from_slice(payload).context("invalid GREE Cloud MQTT JSON")?; let envelope: MqttDeviceEnvelope =
serde_json::from_slice(payload).context("invalid GREE Cloud MQTT JSON")?;
if envelope.pack.is_empty() { if envelope.pack.is_empty() {
bail!("GREE Cloud MQTT payload has no encrypted pack"); bail!("GREE Cloud MQTT payload has no encrypted pack");
} }
@@ -1181,28 +1365,40 @@ impl GreeCloudProvider {
.filter(|(_, item)| item.parent_mac.eq_ignore_ascii_case(&parent_from_topic)) .filter(|(_, item)| item.parent_mac.eq_ignore_ascii_case(&parent_from_topic))
.map(|(id, item)| (id.clone(), item.clone())) .map(|(id, item)| (id.clone(), item.clone()))
.collect(); .collect();
candidates.sort_by_key(|(id, _)| if !normalized_tcid.is_empty() && id.eq_ignore_ascii_case(&normalized_tcid) { 0 } else { 1 }); candidates.sort_by_key(|(id, _)| {
if !normalized_tcid.is_empty() && id.eq_ignore_ascii_case(&normalized_tcid) {
0
} else {
1
}
});
if candidates.is_empty() { if candidates.is_empty() {
bail!("GREE Cloud MQTT message does not match a registered device"); bail!("GREE Cloud MQTT message does not match a registered device");
} }
let mut last_error = None; let mut last_error = None;
for (cloud_id, registered) in candidates { for (cloud_id, registered) in candidates {
let preferred = if envelope.tag.is_some() { 2 } else { registered.cipher_version }; let preferred = if envelope.tag.is_some() {
let (plaintext, cipher_version) = match decrypt_cloud_payload(&registered.key, &envelope, preferred) { 2
Ok(value) => (value, preferred), } else {
Err(first) => { registered.cipher_version
let alternate = if preferred == 2 { 1 } else { 2 }; };
match decrypt_cloud_payload(&registered.key, &envelope, alternate) { let (plaintext, cipher_version) =
Ok(value) => (value, alternate), match decrypt_cloud_payload(&registered.key, &envelope, preferred) {
Err(second) => { Ok(value) => (value, preferred),
last_error = Some(format!("{first}; alternate cipher: {second}")); Err(first) => {
continue; let alternate = if preferred == 2 { 1 } else { 2 };
match decrypt_cloud_payload(&registered.key, &envelope, alternate) {
Ok(value) => (value, alternate),
Err(second) => {
last_error = Some(format!("{first}; alternate cipher: {second}"));
continue;
}
} }
} }
} };
}; let parsed: Value =
let parsed: Value = serde_json::from_slice(&plaintext).context("invalid decrypted GREE Cloud JSON")?; serde_json::from_slice(&plaintext).context("invalid decrypted GREE Cloud JSON")?;
validate_cloud_response(&parsed)?; validate_cloud_response(&parsed)?;
let properties = properties_from_payload(&parsed)?; let properties = properties_from_payload(&parsed)?;
let message = ParsedCloudMessage { let message = ParsedCloudMessage {
@@ -1235,17 +1431,25 @@ impl GreeCloudProvider {
if !properties.is_empty() { if !properties.is_empty() {
item.last_status_timestamp = Some(now); item.last_status_timestamp = Some(now);
for (key, value) in &properties { for (key, value) in &properties {
item.raw_device_properties.insert(key.clone(), value.clone()); item.raw_device_properties
.insert(key.clone(), value.clone());
} }
item.parsed_device_properties = parsed_properties(&item.raw_device_properties); item.parsed_device_properties = parsed_properties(&item.raw_device_properties);
item.unknown_properties = item.raw_device_properties item.unknown_properties = item
.raw_device_properties
.iter() .iter()
.filter(|(key, _)| !KNOWN_CLOUD_PROPERTIES.contains(&key.as_str())) .filter(|(key, _)| !KNOWN_CLOUD_PROPERTIES.contains(&key.as_str()))
.map(|(key, value)| (key.clone(), value.clone())) .map(|(key, value)| (key.clone(), value.clone()))
.collect(); .collect();
item.energy_related_properties = item.raw_device_properties item.energy_related_properties = item
.raw_device_properties
.iter() .iter()
.filter(|(key, _)| matches!(key.as_str(), "ElcAll" | "ElcAllConsumption" | "CompressorFqy")) .filter(|(key, _)| {
matches!(
key.as_str(),
"ElcAll" | "ElcAllConsumption" | "CompressorFqy"
)
})
.map(|(key, value)| (key.clone(), value.clone())) .map(|(key, value)| (key.clone(), value.clone()))
.collect(); .collect();
} }
@@ -1262,7 +1466,10 @@ impl GreeCloudProvider {
})); }));
return Ok(()); return Ok(());
} }
bail!("cannot decrypt GREE Cloud MQTT payload: {}", last_error.unwrap_or_else(|| "unknown cipher error".into())) bail!(
"cannot decrypt GREE Cloud MQTT payload: {}",
last_error.unwrap_or_else(|| "unknown cipher error".into())
)
} }
async fn update_diagnostics_from_message(&self, device: &Device, message: &ParsedCloudMessage) { async fn update_diagnostics_from_message(&self, device: &Device, message: &ParsedCloudMessage) {
@@ -1274,17 +1481,25 @@ impl GreeCloudProvider {
item.last_status_timestamp = Some(Utc::now()); item.last_status_timestamp = Some(Utc::now());
item.selected_cipher_version = Some(message.cipher_version); item.selected_cipher_version = Some(message.cipher_version);
for (key, value) in &message.properties { for (key, value) in &message.properties {
item.raw_device_properties.insert(key.clone(), value.clone()); item.raw_device_properties
.insert(key.clone(), value.clone());
} }
item.parsed_device_properties = parsed_properties(&item.raw_device_properties); item.parsed_device_properties = parsed_properties(&item.raw_device_properties);
item.unknown_properties = item.raw_device_properties item.unknown_properties = item
.raw_device_properties
.iter() .iter()
.filter(|(key, _)| !KNOWN_CLOUD_PROPERTIES.contains(&key.as_str())) .filter(|(key, _)| !KNOWN_CLOUD_PROPERTIES.contains(&key.as_str()))
.map(|(key, value)| (key.clone(), value.clone())) .map(|(key, value)| (key.clone(), value.clone()))
.collect(); .collect();
item.energy_related_properties = item.raw_device_properties item.energy_related_properties = item
.raw_device_properties
.iter() .iter()
.filter(|(key, _)| matches!(key.as_str(), "ElcAll" | "ElcAllConsumption" | "CompressorFqy")) .filter(|(key, _)| {
matches!(
key.as_str(),
"ElcAll" | "ElcAllConsumption" | "CompressorFqy"
)
})
.map(|(key, value)| (key.clone(), value.clone())) .map(|(key, value)| (key.clone(), value.clone()))
.collect(); .collect();
item.last_error = None; item.last_error = None;
@@ -1367,7 +1582,10 @@ fn decrypt_cloud_payload(key: &str, envelope: &MqttDeviceEnvelope, cipher: u8) -
2 => decrypt_v2( 2 => decrypt_v2(
key, key,
&envelope.pack, &envelope.pack,
envelope.tag.as_deref().ok_or_else(|| anyhow!("GCM payload is missing tag"))?, envelope
.tag
.as_deref()
.ok_or_else(|| anyhow!("GCM payload is missing tag"))?,
), ),
_ => decrypt_v1(key, &envelope.pack), _ => decrypt_v1(key, &envelope.pack),
} }
@@ -1383,7 +1601,10 @@ fn validate_cloud_response(payload: &Value) -> Result<()> {
bail!("GREE Cloud response error {code}: {message}"); bail!("GREE Cloud response error {code}: {message}");
} }
} }
if matches!(payload.get("t").and_then(Value::as_str), Some("error" | "err")) { if matches!(
payload.get("t").and_then(Value::as_str),
Some("error" | "err")
) {
let message = payload let message = payload
.get("msg") .get("msg")
.or_else(|| payload.get("error")) .or_else(|| payload.get("error"))
@@ -1398,8 +1619,14 @@ fn properties_from_payload(payload: &Value) -> Result<BTreeMap<String, Value>> {
if payload.get("t").and_then(Value::as_str) != Some("dat") { if payload.get("t").and_then(Value::as_str) != Some("dat") {
return Ok(BTreeMap::new()); return Ok(BTreeMap::new());
} }
let cols = payload.get("cols").and_then(Value::as_array).ok_or_else(|| anyhow!("GREE Cloud dat payload has no cols"))?; let cols = payload
let dat = payload.get("dat").and_then(Value::as_array).ok_or_else(|| anyhow!("GREE Cloud dat payload has no dat"))?; .get("cols")
.and_then(Value::as_array)
.ok_or_else(|| anyhow!("GREE Cloud dat payload has no cols"))?;
let dat = payload
.get("dat")
.and_then(Value::as_array)
.ok_or_else(|| anyhow!("GREE Cloud dat payload has no dat"))?;
if cols.len() != dat.len() { if cols.len() != dat.len() {
bail!("GREE Cloud dat payload column/value count mismatch"); bail!("GREE Cloud dat payload column/value count mismatch");
} }
@@ -1436,18 +1663,47 @@ fn apply_cloud_capability_snapshot(device: &mut Device, props: &BTreeMap<String,
} }
pub fn apply_cloud_properties(device: &mut Device, props: &BTreeMap<String, Value>) { pub fn apply_cloud_properties(device: &mut Device, props: &BTreeMap<String, Value>) {
if let Some(value) = props.get("Pow").and_then(value_i64) { device.power = value != 0; } if let Some(value) = props.get("Pow").and_then(value_i64) {
if let Some(value) = props.get("Mod").and_then(value_i64) { if let Some(mode) = wire_to_mode(value) { device.mode = mode.into(); } } device.power = value != 0;
if let Some(value) = props.get("WdSpd").and_then(value_i64) { device.fan_speed = value.clamp(0, 5) as u8; } }
if let Some(value) = props.get("SwUpDn").and_then(value_i64) { device.swing_vertical = value != 0; } if let Some(value) = props.get("Mod").and_then(value_i64) {
if let Some(value) = props.get("SwingLfRig").and_then(value_i64) { device.swing_horizontal = value != 0; } if let Some(mode) = wire_to_mode(value) {
if let Some(value) = props.get("Quiet").and_then(value_i64) { device.quiet = value != 0; if value > 0 { device.quiet_wire_value = Some(value.clamp(1, 255) as u8); } } device.mode = mode.into();
if let Some(value) = props.get("Tur").and_then(value_i64) { device.turbo = value != 0; } }
if let Some(value) = props.get("Lig").and_then(value_i64) { device.light = value != 0; } }
if let Some(value) = props.get("Air").and_then(value_i64) { device.air = value != 0; } if let Some(value) = props.get("WdSpd").and_then(value_i64) {
if let Some(value) = props.get("Blo").and_then(value_i64) { device.xfan = value != 0; } device.fan_speed = value.clamp(0, 5) as u8;
if let Some(value) = props.get("Health").and_then(value_i64) { device.health = value != 0; } }
if let Some(value) = props.get("SwhSlp").and_then(value_i64) { device.sleep = value != 0; } if let Some(value) = props.get("SwUpDn").and_then(value_i64) {
device.swing_vertical = value != 0;
}
if let Some(value) = props.get("SwingLfRig").and_then(value_i64) {
device.swing_horizontal = value != 0;
}
if let Some(value) = props.get("Quiet").and_then(value_i64) {
device.quiet = value != 0;
if value > 0 {
device.quiet_wire_value = Some(value.clamp(1, 255) as u8);
}
}
if let Some(value) = props.get("Tur").and_then(value_i64) {
device.turbo = value != 0;
}
if let Some(value) = props.get("Lig").and_then(value_i64) {
device.light = value != 0;
}
if let Some(value) = props.get("Air").and_then(value_i64) {
device.air = value != 0;
}
if let Some(value) = props.get("Blo").and_then(value_i64) {
device.xfan = value != 0;
}
if let Some(value) = props.get("Health").and_then(value_i64) {
device.health = value != 0;
}
if let Some(value) = props.get("SwhSlp").and_then(value_i64) {
device.sleep = value != 0;
}
if let Some(raw) = props.get("TemSen").and_then(value_f64) { if let Some(raw) = props.get("TemSen").and_then(value_f64) {
if let Some((temperature, offset)) = decode_indoor_temperature(raw) { if let Some((temperature, offset)) = decode_indoor_temperature(raw) {
device.current_temperature = Some(temperature); device.current_temperature = Some(temperature);
@@ -1459,41 +1715,74 @@ pub fn apply_cloud_properties(device: &mut Device, props: &BTreeMap<String, Valu
.and_then(value_f64) .and_then(value_f64)
.or_else(|| props.get("TemsSenOut").and_then(value_f64)); .or_else(|| props.get("TemsSenOut").and_then(value_f64));
if let Some(raw) = outdoor_raw { if let Some(raw) = outdoor_raw {
if let Some(temperature) = decode_outdoor_temperature(raw, device.temperature_sensor_offset) { if let Some(temperature) = decode_outdoor_temperature(raw, device.temperature_sensor_offset)
{
device.outdoor_temperature = Some(temperature); device.outdoor_temperature = Some(temperature);
} }
} }
if let Some(half_enabled) = props.get("HalfTemEn").and_then(value_i64) { if let Some(half_enabled) = props.get("HalfTemEn").and_then(value_i64) {
device.capabilities.temperature_step = if half_enabled == 1 { 0.5 } else { 1.0 }; device.capabilities.temperature_step = if half_enabled == 1 { 0.5 } else { 1.0 };
} }
if props.contains_key("SwUpDn") { device.capabilities.vertical_swing = true; } if props.contains_key("SwUpDn") {
if props.contains_key("SwingLfRig") { device.capabilities.horizontal_swing = true; } device.capabilities.vertical_swing = true;
}
if props.contains_key("SwingLfRig") {
device.capabilities.horizontal_swing = true;
}
if let Some(value) = props.get("SetDeciTem").and_then(value_f64) { if let Some(value) = props.get("SetDeciTem").and_then(value_f64) {
device.target_temperature = value / 10.0; device.target_temperature = value / 10.0;
} else if let Some(value) = props.get("SetTem").and_then(value_f64) { } else if let Some(value) = props.get("SetTem").and_then(value_f64) {
let half = props.get("TemRec").and_then(value_f64).unwrap_or(0.0); let half = props.get("TemRec").and_then(value_f64).unwrap_or(0.0);
device.target_temperature = value + if half > 0.0 { 0.5 } else { 0.0 }; device.target_temperature = value + if half > 0.0 { 0.5 } else { 0.0 };
} }
if props.contains_key("Lig") { device.supports_light = Some(true); } if props.contains_key("Lig") {
if props.contains_key("Quiet") { device.supports_quiet = Some(true); } device.supports_light = Some(true);
if props.contains_key("Tur") { device.supports_turbo = Some(true); } }
if props.contains_key("Air") { device.supports_air = Some(true); } if props.contains_key("Quiet") {
if props.contains_key("Blo") { device.supports_xfan = Some(true); } device.supports_quiet = Some(true);
if props.contains_key("Health") { device.supports_health = Some(true); } }
if props.contains_key("SwhSlp") { device.supports_sleep = Some(true); } if props.contains_key("Tur") {
if props.contains_key("Buzzer_ON_OFF") || props.contains_key("BuzzerCtrl") { device.supports_buzzer_control = Some(true); } device.supports_turbo = Some(true);
if props.contains_key("ElcAll") { device.supports_energy_meter = Some(true); } }
if let Some(value) = props.get("ElcAll").and_then(value_f64) { device.total_energy_kwh = Some(value * 0.1); } if props.contains_key("Air") {
if let Some(value) = props.get("CompressorFqy").and_then(value_f64) { device.compressor_frequency_hz = Some(value); } device.supports_air = Some(true);
}
if props.contains_key("Blo") {
device.supports_xfan = Some(true);
}
if props.contains_key("Health") {
device.supports_health = Some(true);
}
if props.contains_key("SwhSlp") {
device.supports_sleep = Some(true);
}
if props.contains_key("Buzzer_ON_OFF") || props.contains_key("BuzzerCtrl") {
device.supports_buzzer_control = Some(true);
}
if props.contains_key("ElcAll") {
device.supports_energy_meter = Some(true);
}
if let Some(value) = props.get("ElcAll").and_then(value_f64) {
device.total_energy_kwh = Some(value * 0.1);
}
if let Some(value) = props.get("CompressorFqy").and_then(value_f64) {
device.compressor_frequency_hz = Some(value);
}
if let Some(hid) = props.get("hid").and_then(Value::as_str) { if let Some(hid) = props.get("hid").and_then(Value::as_str) {
if device.firmware.is_empty() { device.firmware = firmware_from_hid(hid).unwrap_or_default(); } if device.firmware.is_empty() {
device.firmware = firmware_from_hid(hid).unwrap_or_default();
}
} }
} }
fn parsed_properties(props: &BTreeMap<String, Value>) -> BTreeMap<String, Value> { fn parsed_properties(props: &BTreeMap<String, Value>) -> BTreeMap<String, Value> {
let mut parsed = BTreeMap::new(); let mut parsed = BTreeMap::new();
if let Some(value) = props.get("Pow").and_then(value_i64) { parsed.insert("power".into(), json!(value != 0)); } if let Some(value) = props.get("Pow").and_then(value_i64) {
if let Some(value) = props.get("Mod").and_then(value_i64).and_then(wire_to_mode) { parsed.insert("mode".into(), json!(value)); } parsed.insert("power".into(), json!(value != 0));
}
if let Some(value) = props.get("Mod").and_then(value_i64).and_then(wire_to_mode) {
parsed.insert("mode".into(), json!(value));
}
let mut sensor_offset = None; let mut sensor_offset = None;
if let Some(raw) = props.get("TemSen").and_then(value_f64) { if let Some(raw) = props.get("TemSen").and_then(value_f64) {
if let Some((temperature, offset)) = decode_indoor_temperature(raw) { if let Some((temperature, offset)) = decode_indoor_temperature(raw) {
@@ -1510,30 +1799,64 @@ fn parsed_properties(props: &BTreeMap<String, Value>) -> BTreeMap<String, Value>
parsed.insert("outdoor_temperature_c".into(), json!(temperature)); parsed.insert("outdoor_temperature_c".into(), json!(temperature));
} }
} }
if let Some(value) = props.get("ElcAll").and_then(value_f64) { parsed.insert("total_energy_kwh".into(), json!(value * 0.1)); } if let Some(value) = props.get("ElcAll").and_then(value_f64) {
if let Some(value) = props.get("CompressorFqy").and_then(value_f64) { parsed.insert("compressor_frequency_hz".into(), json!(value)); } parsed.insert("total_energy_kwh".into(), json!(value * 0.1));
}
if let Some(value) = props.get("CompressorFqy").and_then(value_f64) {
parsed.insert("compressor_frequency_hz".into(), json!(value));
}
parsed parsed
} }
fn merge_command(target: &mut DeviceCommand, source: &DeviceCommand) { fn merge_command(target: &mut DeviceCommand, source: &DeviceCommand) {
if source.power.is_some() { target.power = source.power; } if source.power.is_some() {
if source.mode.is_some() { target.mode = source.mode.clone(); } target.power = source.power;
if source.target_temperature.is_some() { target.target_temperature = source.target_temperature; } }
if source.fan_speed.is_some() { target.fan_speed = source.fan_speed; } if source.mode.is_some() {
if source.swing_vertical.is_some() { target.swing_vertical = source.swing_vertical; } target.mode = source.mode.clone();
if source.swing_horizontal.is_some() { target.swing_horizontal = source.swing_horizontal; } }
if source.quiet.is_some() { target.quiet = source.quiet; } if source.target_temperature.is_some() {
if source.turbo.is_some() { target.turbo = source.turbo; } target.target_temperature = source.target_temperature;
if source.light.is_some() { target.light = source.light; } }
if source.air.is_some() { target.air = source.air; } if source.fan_speed.is_some() {
if source.xfan.is_some() { target.xfan = source.xfan; } target.fan_speed = source.fan_speed;
if source.health.is_some() { target.health = source.health; } }
if source.sleep.is_some() { target.sleep = source.sleep; } if source.swing_vertical.is_some() {
target.swing_vertical = source.swing_vertical;
}
if source.swing_horizontal.is_some() {
target.swing_horizontal = source.swing_horizontal;
}
if source.quiet.is_some() {
target.quiet = source.quiet;
}
if source.turbo.is_some() {
target.turbo = source.turbo;
}
if source.light.is_some() {
target.light = source.light;
}
if source.air.is_some() {
target.air = source.air;
}
if source.xfan.is_some() {
target.xfan = source.xfan;
}
if source.health.is_some() {
target.health = source.health;
}
if source.sleep.is_some() {
target.sleep = source.sleep;
}
} }
fn mode_to_wire(mode: &str) -> Result<i64> { fn mode_to_wire(mode: &str) -> Result<i64> {
match mode { match mode {
"auto" => Ok(0), "cool" => Ok(1), "dry" => Ok(2), "fan" => Ok(3), "heat" => Ok(4), "auto" => Ok(0),
"cool" => Ok(1),
"dry" => Ok(2),
"fan" => Ok(3),
"heat" => Ok(4),
_ => bail!("unsupported GREE Cloud mode: {mode}"), _ => bail!("unsupported GREE Cloud mode: {mode}"),
} }
} }
@@ -1546,7 +1869,9 @@ fn decode_indoor_temperature(raw: f64) -> Option<(f64, bool)> {
// 64 for 24 C. Older/local-compatible firmware can also report the real value. // 64 for 24 C. Older/local-compatible firmware can also report the real value.
let offset = raw > 40.0; let offset = raw > 40.0;
let temperature = if offset { raw - 40.0 } else { raw }; let temperature = if offset { raw - 40.0 } else { raw };
(-40.0..=80.0).contains(&temperature).then_some((temperature, offset)) (-40.0..=80.0)
.contains(&temperature)
.then_some((temperature, offset))
} }
fn decode_outdoor_temperature(raw: f64, sensor_offset: Option<bool>) -> Option<f64> { fn decode_outdoor_temperature(raw: f64, sensor_offset: Option<bool>) -> Option<f64> {
@@ -1562,14 +1887,29 @@ fn decode_outdoor_temperature(raw: f64, sensor_offset: Option<bool>) -> Option<f
} }
fn wire_to_mode(mode: i64) -> Option<&'static str> { fn wire_to_mode(mode: i64) -> Option<&'static str> {
match mode { 0 => Some("auto"), 1 => Some("cool"), 2 => Some("dry"), 3 => Some("fan"), 4 => Some("heat"), _ => None } match mode {
0 => Some("auto"),
1 => Some("cool"),
2 => Some("dry"),
3 => Some("fan"),
4 => Some("heat"),
_ => None,
}
} }
fn value_i64(value: &Value) -> Option<i64> { fn value_i64(value: &Value) -> Option<i64> {
value.as_i64().or_else(|| value.as_u64().and_then(|v| i64::try_from(v).ok())).or_else(|| value.as_f64().map(|v| v as i64)).or_else(|| value.as_str().and_then(|v| v.parse().ok())) value
.as_i64()
.or_else(|| value.as_u64().and_then(|v| i64::try_from(v).ok()))
.or_else(|| value.as_f64().map(|v| v as i64))
.or_else(|| value.as_str().and_then(|v| v.parse().ok()))
} }
fn value_f64(value: &Value) -> Option<f64> { fn value_f64(value: &Value) -> Option<f64> {
value.as_f64().or_else(|| value.as_i64().map(|v| v as f64)).or_else(|| value.as_u64().map(|v| v as f64)).or_else(|| value.as_str().and_then(|v| v.parse().ok())) value
.as_f64()
.or_else(|| value.as_i64().map(|v| v as f64))
.or_else(|| value.as_u64().map(|v| v as f64))
.or_else(|| value.as_str().and_then(|v| v.parse().ok()))
} }
fn parent_mac_preserve_case(mac: &str) -> String { fn parent_mac_preserve_case(mac: &str) -> String {
let compact = mac.trim().replace([':', '-'], ""); let compact = mac.trim().replace([':', '-'], "");
@@ -1620,21 +1960,43 @@ fn is_legacy_cloud_firmware(firmware: &str) -> bool {
value.starts_with("V1.") || value.starts_with("1.") value.starts_with("V1.") || value.starts_with("1.")
} }
fn normalize_mac(value: &str) -> String { value.trim().replace([':', '-'], "").to_ascii_uppercase() } fn normalize_mac(value: &str) -> String {
fn cloud_id(device: &Device) -> String { normalize_mac(device.cloud_device_id.as_deref().unwrap_or(&device.mac)) } value.trim().replace([':', '-'], "").to_ascii_uppercase()
fn cloud_parent(device: &Device) -> String { device.cloud_parent_mac.clone().unwrap_or_else(|| parent_mac(&cloud_id(device))) } }
fn is_timeout_error(error: &anyhow::Error) -> bool { error.to_string().to_ascii_lowercase().contains("timed out") } fn cloud_id(device: &Device) -> String {
normalize_mac(device.cloud_device_id.as_deref().unwrap_or(&device.mac))
}
fn cloud_parent(device: &Device) -> String {
device
.cloud_parent_mac
.clone()
.unwrap_or_else(|| parent_mac(&cloud_id(device)))
}
fn is_timeout_error(error: &anyhow::Error) -> bool {
error.to_string().to_ascii_lowercase().contains("timed out")
}
fn sanitize_error(value: &str) -> String { fn sanitize_error(value: &str) -> String {
let lower = value.to_ascii_lowercase(); let lower = value.to_ascii_lowercase();
if lower.contains("password") || lower.contains("token") || lower.contains("authorization") { "GREE Cloud authentication/transport error".into() } else { value.chars().take(300).collect() } if lower.contains("password") || lower.contains("token") || lower.contains("authorization") {
"GREE Cloud authentication/transport error".into()
} else {
value.chars().take(300).collect()
}
} }
fn firmware_from_hid(hid: &str) -> Option<String> { fn firmware_from_hid(hid: &str) -> Option<String> {
let marker = hid.rfind('V')?; let marker = hid.rfind('V')?;
let value = hid.get(marker + 1..)?.strip_suffix(".bin").unwrap_or(&hid[marker + 1..]); let value = hid
.get(marker + 1..)?
.strip_suffix(".bin")
.unwrap_or(&hid[marker + 1..]);
(!value.is_empty()).then(|| value.to_string()) (!value.is_empty()).then(|| value.to_string())
} }
pub async fn cloud_reconnect_loop(provider: GreeCloudProvider, settings: Arc<RwLock<crate::models::RuntimeSettings>>, db: crate::db::Db) { pub async fn cloud_reconnect_loop(
provider: GreeCloudProvider,
settings: Arc<RwLock<crate::models::RuntimeSettings>>,
db: crate::db::Db,
) {
let mut attempt = 0_u32; let mut attempt = 0_u32;
loop { loop {
let cloud = settings.read().await.gree_cloud.clone(); let cloud = settings.read().await.gree_cloud.clone();
@@ -1650,9 +2012,16 @@ pub async fn cloud_reconnect_loop(provider: GreeCloudProvider, settings: Arc<RwL
} }
let devices = match db.list_devices() { let devices = match db.list_devices() {
Ok(items) => items, Ok(items) => items,
Err(err) => { tracing::warn!(error=?err, "cannot list devices for GREE Cloud reconnect"); sleep(Duration::from_secs(10)).await; continue; } Err(err) => {
tracing::warn!(error=?err, "cannot list devices for GREE Cloud reconnect");
sleep(Duration::from_secs(10)).await;
continue;
}
}; };
if !devices.iter().any(|d| d.enabled && d.connection_type == ConnectionType::GreeCloud) { if !devices
.iter()
.any(|d| d.enabled && d.connection_type == ConnectionType::GreeCloud)
{
sleep(Duration::from_secs(15)).await; sleep(Duration::from_secs(15)).await;
continue; continue;
} }
@@ -1738,15 +2107,12 @@ mod tests {
assert!(mode_to_wire("unsupported").is_err()); assert!(mode_to_wire("unsupported").is_err());
} }
#[test] #[test]
fn cloud_wire_parent_preserves_rest_mac_case_for_mqtt_topics() { fn cloud_wire_parent_preserves_rest_mac_case_for_mqtt_topics() {
let mut device = Device::simulated_default(); let mut device = Device::simulated_default();
device.connection_type = ConnectionType::GreeCloud; device.connection_type = ConnectionType::GreeCloud;
device.cloud_device_id = Some("9424B80C5DB0".into()); device.cloud_device_id = Some("9424B80C5DB0".into());
let wire = HashMap::from([ let wire = HashMap::from([("9424B80C5DB0".to_string(), "9424b80c5db0".to_string())]);
("9424B80C5DB0".to_string(), "9424b80c5db0".to_string()),
]);
assert_eq!(cloud_wire_parent_from_map(&wire, &device), "9424b80c5db0"); assert_eq!(cloud_wire_parent_from_map(&wire, &device), "9424b80c5db0");
} }
@@ -1778,7 +2144,6 @@ mod tests {
assert!(!LEGACY_CLOUD_PROPERTIES.contains(&"CompressorFqy")); assert!(!LEGACY_CLOUD_PROPERTIES.contains(&"CompressorFqy"));
} }
#[tokio::test] #[tokio::test]
async fn mqtt_payload_is_ignored_after_all_cloud_devices_are_removed() { async fn mqtt_payload_is_ignored_after_all_cloud_devices_are_removed() {
let provider = GreeCloudProvider::new(reqwest::Client::new()); let provider = GreeCloudProvider::new(reqwest::Client::new());
@@ -1914,7 +2279,10 @@ mod tests {
let sleep_sequence = provider let sleep_sequence = provider
.build_command_sequence( .build_command_sequence(
&device, &device,
&DeviceCommand { sleep: Some(true), ..Default::default() }, &DeviceCommand {
sleep: Some(true),
..Default::default()
},
false, false,
) )
.await .await
+2 -4
View File
@@ -222,10 +222,8 @@ impl AppState {
let kind = kind.to_string(); let kind = kind.to_string();
let message = message.to_string(); let message = message.to_string();
handle.spawn(async move { handle.spawn(async move {
crate::notifications::dispatch( crate::notifications::dispatch(state, event_id, level, kind, message, metadata)
state, event_id, level, kind, message, metadata, .await;
)
.await;
}); });
} }
} }
+295 -80
View File
@@ -1664,7 +1664,7 @@ button.outside-pill:focus-visible {
font-weight: 650; font-weight: 650;
} }
.outdoor-history-links > div { .outdoor-history-links>div {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 7px; gap: 7px;
@@ -2285,7 +2285,9 @@ button.outside-pill:focus-visible {
font-size: 12px; font-size: 12px;
} }
.custom-chart-edit-note strong { color: var(--text-soft); } .custom-chart-edit-note strong {
color: var(--text-soft);
}
.saved-chart-section { .saved-chart-section {
display: grid; display: grid;
@@ -2301,7 +2303,9 @@ button.outside-pill:focus-visible {
gap: 10px; gap: 10px;
} }
.saved-chart-heading small { color: var(--muted); } .saved-chart-heading small {
color: var(--muted);
}
.saved-chart-list { .saved-chart-list {
display: grid; display: grid;
@@ -2939,7 +2943,9 @@ button.outside-pill:focus-visible {
align-items: end; align-items: end;
} }
.history-network-controls label { min-width: 0; } .history-network-controls label {
min-width: 0;
}
.history-jitter-toggle { .history-jitter-toggle {
min-height: 45px; min-height: 45px;
@@ -4507,8 +4513,6 @@ body.simulation-standalone [data-view="simulation"] {
height: 100%; height: 100%;
} }
/* Dashboard thermostat cards keep their intrinsic height when one zone shows
a local/manual takeover panel. Other grids retain equal-height rows. */
#dashboardZones { #dashboardZones {
grid-auto-rows: auto; grid-auto-rows: auto;
align-items: start; align-items: start;
@@ -9029,13 +9033,25 @@ body.flow-editor-open {
} }
@keyframes cloud-command-pulse { @keyframes cloud-command-pulse {
from { opacity: .72; } from {
to { opacity: 1; } opacity: .72;
}
to {
opacity: 1;
}
} }
@keyframes cloud-command-dot { @keyframes cloud-command-dot {
from { transform: scale(.65); opacity: .45; } from {
to { transform: scale(1); opacity: 1; } transform: scale(.65);
opacity: .45;
}
to {
transform: scale(1);
opacity: 1;
}
} }
@@ -9367,7 +9383,7 @@ body.flow-editor-open {
content: none; content: none;
} }
} }
/* Notification delivery audit in Events. */
.log-row .kind .log-notification-badge { .log-row .kind .log-notification-badge {
margin-left: .45rem; margin-left: .45rem;
padding: 3px 7px; padding: 3px 7px;
@@ -9380,7 +9396,6 @@ body.flow-editor-open {
vertical-align: middle; vertical-align: middle;
} }
/* Keep the Flow footer compact before the full mobile layout kicks in. */
@media (min-width:761px) and (max-width:1200px) { @media (min-width:761px) and (max-width:1200px) {
.flow-natural-preview { .flow-natural-preview {
display: block; display: block;
@@ -9450,14 +9465,13 @@ body.flow-editor-open {
} }
} }
/* GREE Cloud runtime summary */
.cloud-runtime-summary { .cloud-runtime-summary {
display: grid; display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px; gap: 8px;
} }
.cloud-runtime-summary > div { .cloud-runtime-summary>div {
display: grid; display: grid;
gap: 4px; gap: 4px;
min-width: 0; min-width: 0;
@@ -9519,7 +9533,6 @@ body.flow-editor-open {
} }
} }
/* Shared alerts and split/multisplit installation configuration */
.inline-alert { .inline-alert {
display: grid; display: grid;
gap: 4px; gap: 4px;
@@ -9550,18 +9563,30 @@ body.flow-editor-open {
color: var(--danger); color: var(--danger);
} }
.inline-alert.error strong { color: var(--danger); } .inline-alert.error strong {
color: var(--danger);
}
.inline-alert.success { .inline-alert.success {
border-color: color-mix(in srgb, var(--accent) 42%, var(--line)); border-color: color-mix(in srgb, var(--accent) 42%, var(--line));
background: color-mix(in srgb, var(--accent) 10%, var(--surface)); background: color-mix(in srgb, var(--accent) 10%, var(--surface));
} }
.inline-alert.info { .inline-alert.info {
border-color: color-mix(in srgb, var(--info) 38%, var(--line)); border-color: color-mix(in srgb, var(--info) 38%, var(--line));
background: color-mix(in srgb, var(--info) 9%, var(--surface)); background: color-mix(in srgb, var(--info) 9%, var(--surface));
} }
.inline-alert.wide { grid-column: 1 / -1; }
.device-installation-summary { display: grid; gap: 8px; margin-bottom: 12px; } .inline-alert.wide {
grid-column: 1 / -1;
}
.device-installation-summary {
display: grid;
gap: 8px;
margin-bottom: 12px;
}
.installation-summary-card, .installation-summary-card,
.installation-list-row { .installation-list-row {
display: grid; display: grid;
@@ -9573,36 +9598,106 @@ body.flow-editor-open {
border-radius: 12px; border-radius: 12px;
background: var(--surface-muted); background: var(--surface-muted);
} }
.installation-summary-card > div,
.installation-list-row > button:first-child { display: grid; gap: 3px; } .installation-summary-card>div,
.installation-list-row>button:first-child {
display: grid;
gap: 3px;
}
.installation-summary-card strong, .installation-summary-card strong,
.installation-list-row strong { color: var(--text); } .installation-list-row strong {
color: var(--text);
}
.installation-summary-card small, .installation-summary-card small,
.installation-list-row small { color: var(--muted); } .installation-list-row small {
.installation-list { display: grid; gap: 8px; } color: var(--muted);
.installation-list-row > button:first-child { border: 0; background: transparent; padding: 0; text-align: left; } }
.installation-list-row > div { display: flex; align-items: center; justify-content: flex-end; gap: 8px; flex-wrap: wrap; }
.installation-form { border-top: 1px solid var(--line); padding-top: 14px; margin-top: 14px; } .installation-list {
.installation-device-choices { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 6px; margin-top: 6px; } display: grid;
.installation-device-choices .check { margin: 0; padding: 8px 9px; border: 1px solid var(--line); border-radius: 9px; background: var(--surface); } gap: 8px;
.installation-device-choices .check.disabled { opacity: .55; } }
.installation-list-row>button:first-child {
border: 0;
background: transparent;
padding: 0;
text-align: left;
}
.installation-list-row>div {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
flex-wrap: wrap;
}
.installation-form {
border-top: 1px solid var(--line);
padding-top: 14px;
margin-top: 14px;
}
.installation-device-choices {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 6px;
margin-top: 6px;
}
.installation-device-choices .check {
margin: 0;
padding: 8px 9px;
border: 1px solid var(--line);
border-radius: 9px;
background: var(--surface);
}
.installation-device-choices .check.disabled {
opacity: .55;
}
.installation-energy-metric b { .installation-energy-metric b {
color: var(--text); color: var(--text);
font-size: 18px; font-size: 18px;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
letter-spacing: -.01em; letter-spacing: -.01em;
} }
.history-context-controls.energy-context-controls { .history-context-controls.energy-context-controls {
display: grid; display: grid;
grid-template-columns: minmax(240px, 1.5fr) repeat(3, minmax(132px, .8fr)); grid-template-columns: minmax(240px, 1.5fr) repeat(3, minmax(132px, .8fr));
gap: 10px; gap: 10px;
align-items: start; align-items: start;
} }
.history-energy-targets { display: grid; gap: 7px; min-width: 0; color: var(--muted); font-size: 12px; font-weight: 650; }
.history-energy-targets {
display: grid;
gap: 7px;
min-width: 0;
color: var(--muted);
font-size: 12px;
font-weight: 650;
}
.history-energy-targets small, .history-energy-targets small,
.history-range-control small { display: block; color: var(--muted); font-size: 10px; font-weight: 550; line-height: 1.35; } .history-range-control small {
.history-energy-picker { position: relative; min-width: 0; } display: block;
.history-energy-picker > summary { color: var(--muted);
font-size: 10px;
font-weight: 550;
line-height: 1.35;
}
.history-energy-picker {
position: relative;
min-width: 0;
}
.history-energy-picker>summary {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) auto; grid-template-columns: minmax(0, 1fr) auto;
align-items: center; align-items: center;
@@ -9616,9 +9711,18 @@ body.flow-editor-open {
cursor: pointer; cursor: pointer;
list-style: none; list-style: none;
} }
.history-energy-picker > summary::-webkit-details-marker { display: none; }
.history-energy-picker > summary span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .history-energy-picker>summary::-webkit-details-marker {
.history-energy-picker > summary b { display: none;
}
.history-energy-picker>summary span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.history-energy-picker>summary b {
min-width: 34px; min-width: 34px;
padding: 3px 6px; padding: 3px 6px;
border-radius: 999px; border-radius: 999px;
@@ -9627,7 +9731,13 @@ body.flow-editor-open {
font-size: 10px; font-size: 10px;
text-align: center; text-align: center;
} }
.history-energy-picker[open] > summary { border-color: var(--accent); outline: 2px solid color-mix(in srgb, var(--accent) 18%, transparent); outline-offset: 1px; }
.history-energy-picker[open]>summary {
border-color: var(--accent);
outline: 2px solid color-mix(in srgb, var(--accent) 18%, transparent);
outline-offset: 1px;
}
.history-energy-options { .history-energy-options {
position: absolute; position: absolute;
z-index: 20; z-index: 20;
@@ -9642,6 +9752,7 @@ body.flow-editor-open {
background: var(--surface); background: var(--surface);
box-shadow: 0 14px 34px rgba(0, 0, 0, .22); box-shadow: 0 14px 34px rgba(0, 0, 0, .22);
} }
.history-energy-option { .history-energy-option {
display: grid; display: grid;
grid-template-columns: 20px minmax(0, 1fr); grid-template-columns: 20px minmax(0, 1fr);
@@ -9655,28 +9766,67 @@ body.flow-editor-open {
font-size: 11px; font-size: 11px;
font-weight: 600; font-weight: 600;
} }
.history-energy-option:hover { background: var(--surface-muted); }
.history-energy-option input { width: 17px; min-height: 17px; margin: 0; padding: 0; accent-color: var(--accent); } .history-energy-option:hover {
.history-energy-option span { overflow-wrap: anywhere; } background: var(--surface-muted);
.history-range-control.energy-period-control { min-width: 0; } }
.history-energy-option input {
width: 17px;
min-height: 17px;
margin: 0;
padding: 0;
accent-color: var(--accent);
}
.history-energy-option span {
overflow-wrap: anywhere;
}
.history-range-control.energy-period-control {
min-width: 0;
}
@media (max-width: 760px) { @media (max-width: 760px) {
.installation-summary-card, .installation-summary-card,
.installation-list-row { grid-template-columns: 1fr; } .installation-list-row {
.installation-list-row > div { justify-content: flex-start; } grid-template-columns: 1fr;
.installation-device-choices { grid-template-columns: 1fr; } }
.history-context-controls.energy-context-controls { grid-template-columns: 1fr; }
.history-toolbar-panel .chart-toolbar.energy-toolbar .history-refresh-button { margin-top: 0; } .installation-list-row>div {
.history-energy-options { width: min(420px, calc(100vw - 40px)); max-height: 46vh; } justify-content: flex-start;
}
.installation-device-choices {
grid-template-columns: 1fr;
}
.history-context-controls.energy-context-controls {
grid-template-columns: 1fr;
}
.history-toolbar-panel .chart-toolbar.energy-toolbar .history-refresh-button {
margin-top: 0;
}
.history-energy-options {
width: min(420px, calc(100vw - 40px));
max-height: 46vh;
}
} }
@media (max-width: 640px) { @media (max-width: 640px) {
.history-network-controls { grid-template-columns: 1fr; } .history-network-controls {
.history-jitter-toggle { width: 100%; } grid-template-columns: 1fr;
}
.history-jitter-toggle {
width: 100%;
}
} }
/* Unified custom selects -------------------------------------------------- */
.select-native-proxy { .select-native-proxy {
position: absolute !important; position: absolute !important;
width: 1px !important; width: 1px !important;
@@ -9865,8 +10015,8 @@ body.flow-editor-open {
padding: 9px 10px; padding: 9px 10px;
} }
.has-error > .custom-select .custom-select-trigger, .has-error>.custom-select .custom-select-trigger,
select.select-native-proxy[aria-invalid="true"] + .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)); border-color: color-mix(in srgb, var(--danger) 68%, var(--line));
outline: 2px solid color-mix(in srgb, var(--danger) 13%, transparent); outline: 2px solid color-mix(in srgb, var(--danger) 13%, transparent);
} }
@@ -9877,13 +10027,26 @@ select.select-native-proxy[aria-invalid="true"] + .custom-select .custom-select-
} }
} }
@media (max-width: 900px), (orientation: landscape) and (max-height: 700px) { @media (max-width: 900px),
.flow-fit-button-icon { display: inline-grid; place-items: center; } (orientation: landscape) and (max-height: 700px) {
.flow-fit-button-label { display: none; } .flow-fit-button-icon {
display: inline-grid;
place-items: center;
}
.flow-fit-button-label {
display: none;
}
} }
@media (orientation: landscape) and (max-height: 700px) and (min-width: 500px) { @media (orientation: landscape) and (max-height: 700px) and (min-width: 500px) {
.flow-fit-button-icon { display: none; } .flow-fit-button-icon {
.flow-fit-button-label { display: inline; } display: none;
}
.flow-fit-button-label {
display: inline;
}
} }
.ha-supervisor-status { .ha-supervisor-status {
@@ -9896,11 +10059,27 @@ select.select-native-proxy[aria-invalid="true"] + .custom-select .custom-select-
background: var(--surface-2); background: var(--surface-2);
} }
.ha-supervisor-status strong { font-size: .92rem; } .ha-supervisor-status strong {
.ha-supervisor-status span { color: var(--muted); font-size: .82rem; line-height: 1.45; } font-size: .92rem;
.ha-supervisor-status.success { border-left-color: var(--teal); } }
.ha-supervisor-status.warning { border-left-color: var(--accent); }
.ha-supervisor-status.error { border-left-color: var(--danger); } .ha-supervisor-status span {
color: var(--muted);
font-size: .82rem;
line-height: 1.45;
}
.ha-supervisor-status.success {
border-left-color: var(--teal);
}
.ha-supervisor-status.warning {
border-left-color: var(--accent);
}
.ha-supervisor-status.error {
border-left-color: var(--danger);
}
.ha-entity-picker { .ha-entity-picker {
display: grid; display: grid;
@@ -9908,8 +10087,13 @@ select.select-native-proxy[aria-invalid="true"] + .custom-select .custom-select-
min-width: 0; min-width: 0;
} }
.ha-entity-picker > label { margin: 0; } .ha-entity-picker>label {
.ha-entity-picker > .field-note { margin: 0; } margin: 0;
}
.ha-entity-picker>.field-note {
margin: 0;
}
.ha-entity-results { .ha-entity-results {
display: grid; display: grid;
@@ -9942,7 +10126,7 @@ select.select-native-proxy[aria-invalid="true"] + .custom-select .custom-select-
background: var(--surface-2); background: var(--surface-2);
} }
.ha-entity-option > span { .ha-entity-option>span {
display: grid; display: grid;
gap: 2px; gap: 2px;
min-width: 0; min-width: 0;
@@ -9962,15 +10146,26 @@ select.select-native-proxy[aria-invalid="true"] + .custom-select .custom-select-
font-size: .76rem; font-size: .76rem;
} }
.ha-entity-option small { flex: 0 0 auto; } .ha-entity-option small {
.ha-entity-empty { padding: 9px 10px; } flex: 0 0 auto;
}
/* Shared Flow HA entity picker: keep search and selected entity visually distinct. */ .ha-entity-empty {
.ha-entity-search-field input[type="search"] { width: 100%; } padding: 9px 10px;
.ha-entity-selected-field { margin-top: 2px; } }
.ha-entity-selected-field input { font-family: var(--mono, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace); }
.ha-entity-search-field input[type="search"] {
width: 100%;
}
.ha-entity-selected-field {
margin-top: 2px;
}
.ha-entity-selected-field input {
font-family: var(--mono, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace);
}
/* Standalone public custom chart */
.public-custom-chart-page { .public-custom-chart-page {
height: 100vh; height: 100vh;
height: 100dvh; height: 100dvh;
@@ -10003,7 +10198,9 @@ select.select-native-proxy[aria-invalid="true"] + .custom-select .custom-select-
align-items: end; align-items: end;
} }
.public-chart-heading { min-width: 0; } .public-chart-heading {
min-width: 0;
}
.public-chart-range-control { .public-chart-range-control {
display: grid; display: grid;
@@ -10013,7 +10210,9 @@ select.select-native-proxy[aria-invalid="true"] + .custom-select .custom-select-
font-size: 11px; font-size: 11px;
} }
.public-chart-range-control select { min-width: 126px; } .public-chart-range-control select {
min-width: 126px;
}
.public-custom-chart-wrap { .public-custom-chart-wrap {
flex: 1 1 0; flex: 1 1 0;
@@ -10037,7 +10236,9 @@ select.select-native-proxy[aria-invalid="true"] + .custom-select .custom-select-
scrollbar-width: none; scrollbar-width: none;
} }
.public-custom-chart-card .legend::-webkit-scrollbar { display: none; } .public-custom-chart-card .legend::-webkit-scrollbar {
display: none;
}
.public-chart-legend-item { .public-chart-legend-item {
cursor: default; cursor: default;
@@ -10056,9 +10257,23 @@ select.select-native-proxy[aria-invalid="true"] + .custom-select .custom-select-
} }
@media (max-width: 700px) { @media (max-width: 700px) {
.public-custom-chart-shell { padding: 8px; } .public-custom-chart-shell {
.public-custom-chart-card { padding: 12px; } padding: 8px;
.public-chart-title-row { align-items: center; } }
.public-chart-range-control select { min-width: 108px; }
.public-custom-chart-wrap { min-height: 140px; } .public-custom-chart-card {
padding: 12px;
}
.public-chart-title-row {
align-items: center;
}
.public-chart-range-control select {
min-width: 108px;
}
.public-custom-chart-wrap {
min-height: 140px;
}
} }
+4
View File
@@ -1,5 +1,6 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"> <meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
@@ -8,8 +9,10 @@
<link rel="icon" href="__GREE_BASE_PATH__/favicon.svg" type="image/svg+xml"> <link rel="icon" href="__GREE_BASE_PATH__/favicon.svg" type="image/svg+xml">
<link rel="stylesheet" href="__GREE_STYLES_ASSET__"> <link rel="stylesheet" href="__GREE_STYLES_ASSET__">
<script src="__GREE_THEME_INIT_ASSET__"></script> <script src="__GREE_THEME_INIT_ASSET__"></script>
<script src="__GREE_LANG_INIT_ASSET__"></script>
<title>GREE Controller · Custom chart</title> <title>GREE Controller · Custom chart</title>
</head> </head>
<body class="public-custom-chart-page"> <body class="public-custom-chart-page">
<main class="public-custom-chart-shell"> <main class="public-custom-chart-shell">
<section class="panel chart-panel chart-card public-custom-chart-card" aria-live="polite"> <section class="panel chart-panel chart-card public-custom-chart-card" aria-live="polite">
@@ -41,4 +44,5 @@
</main> </main>
<script src="__GREE_CUSTOM_CHART_ASSET__"></script> <script src="__GREE_CUSTOM_CHART_ASSET__"></script>
</body> </body>
</html> </html>
+280 -127
View File
@@ -31,7 +31,8 @@
</select> </select>
</div> </div>
<div class="toolbar-picker" data-i18n-title="controls.theme" title="Theme"> <div class="toolbar-picker" data-i18n-title="controls.theme" title="Theme">
<span class="toolbar-picker-value theme-picker-value" id="themeIcon" data-ui-icon="system" aria-hidden="true"></span> <span class="toolbar-picker-value theme-picker-value" id="themeIcon" data-ui-icon="system"
aria-hidden="true"></span>
<span class="toolbar-picker-chevron" data-ui-icon="chevron-down" aria-hidden="true"></span> <span class="toolbar-picker-chevron" data-ui-icon="chevron-down" aria-hidden="true"></span>
<select class="toolbar-picker-select" id="themeSelect" data-i18n-aria="controls.theme" aria-label="Theme"> <select class="toolbar-picker-select" id="themeSelect" data-i18n-aria="controls.theme" aria-label="Theme">
<option value="system" data-i18n="theme.system">System</option> <option value="system" data-i18n="theme.system">System</option>
@@ -146,7 +147,11 @@
<div><span class="eyebrow" data-i18n="devices.localCloud">Local / GREE Cloud</span> <div><span class="eyebrow" data-i18n="devices.localCloud">Local / GREE Cloud</span>
<h1 data-i18n="nav.devices">Devices</h1> <h1 data-i18n="nav.devices">Devices</h1>
</div> </div>
<div class="section-heading-actions"><button class="primary compact" id="discoverButton" data-i18n="devices.discoverLocal">Discover Local</button><button class="secondary" type="button" id="cloudDiscoverButton">GREE Cloud</button><button class="secondary" type="button" id="deviceGroupsButton" data-i18n="devices.installations">Split / multisplit installations</button><button class="secondary" data-open="deviceDialog" data-i18n="devices.addLocalManual">Add Local manually</button></div> <div class="section-heading-actions"><button class="primary compact" id="discoverButton"
data-i18n="devices.discoverLocal">Discover Local</button><button class="secondary" type="button"
id="cloudDiscoverButton">GREE Cloud</button><button class="secondary" type="button" id="deviceGroupsButton"
data-i18n="devices.installations">Split / multisplit installations</button><button class="secondary"
data-open="deviceDialog" data-i18n="devices.addLocalManual">Add Local manually</button></div>
</div> </div>
<p class="lead" data-i18n="devices.technicalDescription">Technical configuration and diagnostics only. Current <p class="lead" data-i18n="devices.technicalDescription">Technical configuration and diagnostics only. Current
operating settings are available in Manual control.</p> operating settings are available in Manual control.</p>
@@ -334,12 +339,13 @@
<div class="panel chart-panel history-toolbar-panel"> <div class="panel chart-panel history-toolbar-panel">
<div class="chart-toolbar"> <div class="chart-toolbar">
<div id="historyContextControls" class="history-context-controls"></div> <div id="historyContextControls" class="history-context-controls"></div>
<label class="history-range-control" id="historyRangeControl"><span data-i18n="history.range">Range</span><select id="historyHours" <label class="history-range-control" id="historyRangeControl"><span
data-i18n-aria="history.range" aria-label="History range"> data-i18n="history.range">Range</span><select id="historyHours" data-i18n-aria="history.range"
aria-label="History range">
<option value="6" data-i18n="history.6h">6 hours</option> <option value="6" data-i18n="history.6h">6 hours</option>
<option value="24" selected data-i18n="history.24h">24 hours</option> <option value="24" data-i18n="history.24h">24 hours</option>
<option value="168" data-i18n="history.7d">7 days</option> <option value="168" data-i18n="history.7d">7 days</option>
<option value="720" data-i18n="history.30d">30 days</option> <option value="720" selected data-i18n="history.30d">30 days</option>
<option value="2160" data-i18n="history.90d">90 days</option> <option value="2160" data-i18n="history.90d">90 days</option>
<option value="8760" data-i18n="history.1y">1 year</option> <option value="8760" data-i18n="history.1y">1 year</option>
</select><small id="historyRangeHint" hidden></small></label> </select><small id="historyRangeHint" hidden></small></label>
@@ -414,19 +420,21 @@
</div> </div>
</div> </div>
<div class="form-grid"> <div class="form-grid">
<div id="haSupervisorStatus" class="ha-supervisor-status wide" role="status" aria-live="polite" hidden></div> <div id="haSupervisorStatus" class="ha-supervisor-status wide" role="status" aria-live="polite" hidden>
</div>
<label class="wide" data-ha-manual-field><span>URL</span><input type="url" name="ha_url" <label class="wide" data-ha-manual-field><span>URL</span><input type="url" name="ha_url"
placeholder="http://homeassistant.local:8123"></label> placeholder="http://homeassistant.local:8123"></label>
<label class="wide" data-ha-manual-field><span data-i18n="settings.haLongLivedToken">Long-Lived Access Token</span><input <label class="wide" data-ha-manual-field><span data-i18n="settings.haLongLivedToken">Long-Lived Access
type="password" name="ha_token" autocomplete="new-password" data-i18n-placeholder="settings.haTokenKeep" Token</span><input type="password" name="ha_token" autocomplete="new-password"
placeholder="Leave empty to keep the saved token"></label> data-i18n-placeholder="settings.haTokenKeep" placeholder="Leave empty to keep the saved token"></label>
<label class="check wide" data-ha-manual-field><input type="checkbox" name="ha_allow_invalid_tls"> <span <label class="check wide" data-ha-manual-field><input type="checkbox" name="ha_allow_invalid_tls"> <span
data-i18n="settings.allowInvalidTls">Allow invalid/self-signed HTTPS certificate</span></label> data-i18n="settings.allowInvalidTls">Allow invalid/self-signed HTTPS certificate</span></label>
<p class="field-note wide warning-note" data-ha-manual-field data-i18n="settings.allowInvalidTlsHint">Use only for a trusted <p class="field-note wide warning-note" data-ha-manual-field data-i18n="settings.allowInvalidTlsHint">Use
only for a trusted
local Home Assistant server.</p> local Home Assistant server.</p>
<div class="form-actions wide"><button type="button" class="secondary" id="haUseSupervisor" <div class="form-actions wide"><button type="button" class="secondary" id="haUseSupervisor"
data-i18n="settings.haUseSupervisor" hidden>Use Supervisor automatically</button><button type="button" class="secondary" id="haTest" data-i18n="settings.haUseSupervisor" hidden>Use Supervisor automatically</button><button type="button"
data-i18n="settings.testHa">Test HA</button></div> class="secondary" id="haTest" data-i18n="settings.testHa">Test HA</button></div>
</div> </div>
</section> </section>
@@ -439,9 +447,11 @@
</div> </div>
</div> </div>
<div class="form-grid"> <div class="form-grid">
<label class="wide"><span data-i18n="settings.outdoorEntity">Global outdoor temperature entity_id</span><input <label class="wide"><span data-i18n="settings.outdoorEntity">Global outdoor temperature
name="ha_outdoor_entity_id" list="haEntitySuggestions" placeholder="sensor.outdoor_temperature"></label> entity_id</span><input name="ha_outdoor_entity_id" list="haEntitySuggestions"
<p class="field-note wide" data-i18n="settings.outdoorEntityHint">Global Home Assistant outdoor temperature sensor. Zones use it by default and may override it with another outdoor sensor in zone settings.</p> placeholder="sensor.outdoor_temperature"></label>
<p class="field-note wide" data-i18n="settings.outdoorEntityHint">Global Home Assistant outdoor temperature
sensor. Zones use it by default and may override it with another outdoor sensor in zone settings.</p>
<label class="wide"><span data-i18n="settings.haSensorStaleAfterMinutes">Maximum HA sensor reading age <label class="wide"><span data-i18n="settings.haSensorStaleAfterMinutes">Maximum HA sensor reading age
(min)</span><input type="number" name="ha_sensor_stale_after_minutes" min="1" max="1440" step="1" (min)</span><input type="number" name="ha_sensor_stale_after_minutes" min="1" max="1440" step="1"
value="5"></label> value="5"></label>
@@ -475,9 +485,10 @@
</div> </div>
<div class="sensor-alias-manager"> <div class="sensor-alias-manager">
<div id="sensorAliasList" class="sensor-alias-list"></div> <div id="sensorAliasList" class="sensor-alias-list"></div>
<div class="sensor-alias-add"><input id="sensorAliasEntity" list="haEntitySuggestions" placeholder="sensor.gabinet_temperature"><input <div class="sensor-alias-add"><input id="sensorAliasEntity" list="haEntitySuggestions"
id="sensorAliasName" data-i18n-placeholder="settings.aliasPlaceholder" placeholder="Gabinet"><button placeholder="sensor.gabinet_temperature"><input id="sensorAliasName"
type="button" class="secondary" id="addSensorAlias" data-i18n="actions.add">Add</button></div> data-i18n-placeholder="settings.aliasPlaceholder" placeholder="Gabinet"><button type="button"
class="secondary" id="addSensorAlias" data-i18n="actions.add">Add</button></div>
</div> </div>
</section> </section>
@@ -587,7 +598,8 @@
<label class="check"><input type="checkbox" name="notification_alert_automation"> <span <label class="check"><input type="checkbox" name="notification_alert_automation"> <span
data-i18n="notifications.alertAutomation">Automation errors and conflicts</span></label> data-i18n="notifications.alertAutomation">Automation errors and conflicts</span></label>
<label class="check"><input type="checkbox" name="notification_alert_sensor_discrepancy"> <span <label class="check"><input type="checkbox" name="notification_alert_sensor_discrepancy"> <span
data-i18n="notifications.alertSensorDiscrepancy">GREE vs Home Assistant temperature difference</span></label> data-i18n="notifications.alertSensorDiscrepancy">GREE vs Home Assistant temperature
difference</span></label>
<label class="check"><input type="checkbox" name="notification_alert_control_errors"> <span <label class="check"><input type="checkbox" name="notification_alert_control_errors"> <span
data-i18n="notifications.alertControlErrors">Thermostat and group control errors</span></label> data-i18n="notifications.alertControlErrors">Thermostat and group control errors</span></label>
<label class="check"><input type="checkbox" name="notification_alert_important_events"> <span <label class="check"><input type="checkbox" name="notification_alert_important_events"> <span
@@ -652,6 +664,9 @@
autocomplete="new-password" data-i18n-placeholder="settings.secretKeep" autocomplete="new-password" data-i18n-placeholder="settings.secretKeep"
placeholder="Leave empty to keep saved secret"></label> placeholder="Leave empty to keep saved secret"></label>
</div> </div>
<div class="form-actions wide"><button type="button" class="secondary" id="testInfluxDbConnection"
data-i18n="settings.influxTest">Test InfluxDB connection</button></div>
<div id="influxDbTestResult" class="inline-alert wide" role="status" aria-live="polite" hidden></div>
</div> </div>
</section> </section>
<section class="panel form-section"> <section class="panel form-section">
@@ -714,14 +729,19 @@
<div class="form-section-head"> <div class="form-section-head">
<div> <div>
<h3 data-i18n="settings.connectivityTitle">Connectivity metrics</h3> <h3 data-i18n="settings.connectivityTitle">Connectivity metrics</h3>
<p data-i18n="settings.connectivityHint">Periodic GREE UDP round-trip measurements for Local/LAN units. Results are stored in History.</p> <p data-i18n="settings.connectivityHint">Periodic GREE UDP round-trip measurements for Local/LAN units.
Results are stored in History.</p>
</div> </div>
</div> </div>
<div class="form-grid"> <div class="form-grid">
<label class="check wide"><input type="checkbox" name="ping_metrics_enabled"> <span data-i18n="settings.connectivityEnable">Enable local unit ping measurements</span></label> <label class="check wide"><input type="checkbox" name="ping_metrics_enabled"> <span
<label><span data-i18n="settings.connectivityInterval">Measurement interval (s)</span><input type="number" name="ping_interval_seconds" min="10" max="3600" value="60"></label> data-i18n="settings.connectivityEnable">Enable local unit ping measurements</span></label>
<label><span data-i18n="settings.connectivitySamples">Samples per measurement</span><input type="number" name="ping_sample_count" min="1" max="10" value="3"></label> <label><span data-i18n="settings.connectivityInterval">Measurement interval (s)</span><input type="number"
<p class="field-note wide" data-i18n="settings.connectivityLocalOnly">Only Local/LAN installations are probed. Cloud devices are not pinged directly.</p> name="ping_interval_seconds" min="10" max="3600" value="60"></label>
<label><span data-i18n="settings.connectivitySamples">Samples per measurement</span><input type="number"
name="ping_sample_count" min="1" max="10" value="3"></label>
<p class="field-note wide" data-i18n="settings.connectivityLocalOnly">Only Local/LAN installations are
probed. Cloud devices are not pinged directly.</p>
</div> </div>
</section> </section>
<section class="panel form-section"> <section class="panel form-section">
@@ -757,33 +777,70 @@
</div> </div>
<div class="settings-pane" data-settings-pane="cloud" hidden> <div class="settings-pane" data-settings-pane="cloud" hidden>
<section class="panel form-section"> <section class="panel form-section">
<div class="form-section-head"><div><h3 data-i18n="settings.cloudAccountTitle">GREE Cloud account</h3><p data-i18n="settings.cloudAccountHint">REST login and device discovery use the selected regional GREE service. The password is never returned by the API after it is saved.</p></div></div> <div class="form-section-head">
<div>
<h3 data-i18n="settings.cloudAccountTitle">GREE Cloud account</h3>
<p data-i18n="settings.cloudAccountHint">REST login and device discovery use the selected regional GREE
service. The password is never returned by the API after it is saved.</p>
</div>
</div>
<div class="form-grid"> <div class="form-grid">
<label class="check wide"><input type="checkbox" name="gree_cloud_enabled"> <span data-i18n="settings.cloudEnable">Enable GREE Cloud</span></label> <label class="check wide"><input type="checkbox" name="gree_cloud_enabled"> <span
data-i18n="settings.cloudEnable">Enable GREE Cloud</span></label>
<label><span data-i18n="settings.cloudRegion">Region</span><select name="gree_cloud_region"> <label><span data-i18n="settings.cloudRegion">Region</span><select name="gree_cloud_region">
<option>Australia</option><option>China Mainland</option><option>East South Asia</option><option selected>Europe</option><option>India</option><option>Latin American</option><option>Middle East</option><option>North American</option><option>Russia</option><option>South American</option> <option>Australia</option>
</select></label> <option>China Mainland</option>
<label><span data-i18n="settings.cloudPolling">Cloud fallback polling interval (s)</span><input type="number" name="gree_cloud_polling_interval_seconds" min="30" max="3600" value="60"></label> <option>East South Asia</option>
<label class="wide"><span data-i18n="settings.cloudLogin">Login / email</span><input name="gree_cloud_username" autocomplete="username"></label> <option selected>Europe</option>
<label class="wide"><span data-i18n="settings.cloudPassword">Password</span><input type="password" name="gree_cloud_password" autocomplete="new-password" data-i18n-placeholder="settings.cloudPasswordPlaceholder" placeholder="Leave empty to keep saved secret"></label> <option>India</option>
<option>Latin American</option>
<option>Middle East</option>
<option>North American</option>
<option>Russia</option>
<option>South American</option>
</select></label>
<label><span data-i18n="settings.cloudPolling">Cloud fallback polling interval (s)</span><input
type="number" name="gree_cloud_polling_interval_seconds" min="30" max="3600" value="60"></label>
<label class="wide"><span data-i18n="settings.cloudLogin">Login / email</span><input
name="gree_cloud_username" autocomplete="username"></label>
<label class="wide"><span data-i18n="settings.cloudPassword">Password</span><input type="password"
name="gree_cloud_password" autocomplete="new-password"
data-i18n-placeholder="settings.cloudPasswordPlaceholder"
placeholder="Leave empty to keep saved secret"></label>
<div class="wide cloud-runtime-summary" aria-live="polite" hidden> <div class="wide cloud-runtime-summary" aria-live="polite" hidden>
<div><span data-i18n="settings.cloudAccountStatus">Account status</span><strong id="greeCloudAccountStatus"></strong></div> <div><span data-i18n="settings.cloudAccountStatus">Account status</span><strong
<div><span data-i18n="settings.cloudMqttStatus">MQTT status</span><strong id="greeCloudMqttStatus"></strong></div> id="greeCloudAccountStatus"></strong></div>
<div><span data-i18n="settings.cloudDevicesOnline">Devices online</span><strong id="greeCloudDevicesOnline"></strong></div> <div><span data-i18n="settings.cloudMqttStatus">MQTT status</span><strong
<div><span data-i18n="settings.cloudRestResponseTime">Last REST response time</span><strong id="greeCloudRestResponseTime"></strong></div> id="greeCloudMqttStatus"></strong></div>
<div><span data-i18n="settings.cloudResponseTime">Last device response time</span><strong id="greeCloudResponseTime"></strong></div> <div><span data-i18n="settings.cloudDevicesOnline">Devices online</span><strong
<div><span data-i18n="settings.cloudLastDeviceResponse">Last device response</span><strong id="greeCloudLastDeviceResponse"></strong></div> id="greeCloudDevicesOnline"></strong></div>
<div><span data-i18n="settings.cloudLastMqttMessage">Last MQTT activity</span><strong id="greeCloudLastMqttMessage"></strong></div> <div><span data-i18n="settings.cloudRestResponseTime">REST response time</span><strong
<div><span data-i18n="settings.cloudConnectedSince">MQTT connected since</span><strong id="greeCloudConnectedSince"></strong></div> id="greeCloudRestResponseTime"></strong></div>
<div><span data-i18n="settings.cloudResponseTime">Device response time</span><strong
id="greeCloudResponseTime">—</strong></div>
<div><span data-i18n="settings.cloudLastDeviceResponse">Last device response</span><strong
id="greeCloudLastDeviceResponse">—</strong></div>
<div><span data-i18n="settings.cloudLastMqttMessage">Last MQTT activity</span><strong
id="greeCloudLastMqttMessage">—</strong></div>
<div><span data-i18n="settings.cloudConnectedSince">MQTT connected since</span><strong
id="greeCloudConnectedSince">—</strong></div>
<div><span data-i18n="settings.cloudBroker">Broker</span><strong id="greeCloudBroker"></strong></div> <div><span data-i18n="settings.cloudBroker">Broker</span><strong id="greeCloudBroker"></strong></div>
<div><span data-i18n="settings.cloudTraffic">Requests / responses / timeouts</span><strong id="greeCloudTraffic"></strong></div> <div><span data-i18n="settings.cloudTraffic">Requests / responses / timeouts</span><strong
id="greeCloudTraffic">—</strong></div>
</div> </div>
<div class="wide cloud-account-meta"> <div class="wide cloud-account-meta">
<small hidden><span data-i18n="settings.cloudLastContact">Last successful REST contact</span>: <strong id="greeCloudLastContact"></strong></small> <small hidden><span data-i18n="settings.cloudLastContact">Last successful REST contact</span>: <strong
<small><span data-i18n="settings.cloudInstallationId">Installation ID</span>: <code id="greeCloudInstanceId"></code></small> id="greeCloudLastContact"></strong></small>
<small><span data-i18n="settings.cloudInstallationId">Installation ID</span>: <code
id="greeCloudInstanceId">—</code></small>
</div> </div>
<p class="field-note wide" data-i18n="settings.cloudPushHint">MQTT push is primary. Polling is used only for initial state, recovery and periodic verification.</p> <p class="field-note wide" data-i18n="settings.cloudPushHint">MQTT push is primary. Polling is used only
<div class="form-actions wide"><button type="button" class="secondary" id="greeCloudTestButton" data-i18n="settings.cloudTest">Test connection</button><button type="button" class="secondary" id="greeCloudRefreshButton" data-i18n="settings.cloudRefreshDevices">Refresh devices</button><button type="button" class="secondary" id="greeCloudReconnectButton" data-i18n="settings.cloudReconnect">Reconnect</button></div> for initial state, recovery and periodic verification.</p>
<div class="form-actions wide"><button type="button" class="secondary" id="greeCloudTestButton"
data-i18n="settings.cloudTest">Test connection</button><button type="button" class="secondary"
id="greeCloudRefreshButton" data-i18n="settings.cloudRefreshDevices">Refresh devices</button><button
type="button" class="secondary" id="greeCloudReconnectButton"
data-i18n="settings.cloudReconnect">Reconnect</button></div>
<div id="greeCloudTestResult" class="inline-alert wide" hidden></div> <div id="greeCloudTestResult" class="inline-alert wide" hidden></div>
</div> </div>
</section> </section>
@@ -791,14 +848,19 @@
<div class="form-section-head"> <div class="form-section-head">
<div> <div>
<h3 data-i18n="settings.cloudConnectivityTitle">Cloud connectivity metrics</h3> <h3 data-i18n="settings.cloudConnectivityTitle">Cloud connectivity metrics</h3>
<p data-i18n="settings.cloudConnectivityHint">Optional. Disabled by default. Measures REST login response and MQTT PINGRESP round-trip.</p> <p data-i18n="settings.cloudConnectivityHint">Optional. Disabled by default. Measures REST login
response and MQTT PINGRESP round-trip.</p>
</div> </div>
</div> </div>
<div class="form-grid"> <div class="form-grid">
<label class="check wide"><input type="checkbox" name="gree_cloud_connectivity_metrics_enabled"> <span data-i18n="settings.cloudConnectivityEnable">Measure Cloud REST/MQTT connectivity</span></label> <label class="check wide"><input type="checkbox" name="gree_cloud_connectivity_metrics_enabled"> <span
<label><span data-i18n="settings.connectivityInterval">Measurement interval (s)</span><input type="number" name="gree_cloud_connectivity_metrics_interval_seconds" min="30" max="3600" value="300"></label> data-i18n="settings.cloudConnectivityEnable">Measure Cloud REST/MQTT connectivity</span></label>
<label><span data-i18n="settings.connectivitySamples">Samples per measurement</span><input type="number" name="gree_cloud_connectivity_metrics_sample_count" min="1" max="10" value="3"></label> <label><span data-i18n="settings.connectivityInterval">Measurement interval (s)</span><input type="number"
<p class="field-note wide" data-i18n="settings.cloudConnectivityHistoryHint">REST and MQTT measurements are stored in History → Pings together with Local/LAN measurements.</p> name="gree_cloud_connectivity_metrics_interval_seconds" min="30" max="3600" value="300"></label>
<label><span data-i18n="settings.connectivitySamples">Samples per measurement</span><input type="number"
name="gree_cloud_connectivity_metrics_sample_count" min="1" max="10" value="3"></label>
<p class="field-note wide" data-i18n="settings.cloudConnectivityHistoryHint">REST and MQTT measurements
are stored in History → Pings together with Local/LAN measurements.</p>
</div> </div>
</section> </section>
</div> </div>
@@ -853,9 +915,9 @@
type="button" data-action="edit-flow-name" data-i18n-title="flow.editName" type="button" data-action="edit-flow-name" data-i18n-title="flow.editName"
title="Edit Flow name"></button><button class="flow-name-edit" type="button" title="Edit Flow name"></button><button class="flow-name-edit" type="button"
data-action="edit-flow-name" data-i18n-title="flow.editName" data-i18n-aria="flow.editName" data-action="edit-flow-name" data-i18n-title="flow.editName" data-i18n-aria="flow.editName"
title="Edit Flow name" aria-label="Edit Flow name" data-ui-icon="edit"></button></div><input id="flowName" maxlength="100" title="Edit Flow name" aria-label="Edit Flow name" data-ui-icon="edit"></button></div><input
data-i18n-placeholder="flow.namePlaceholder" data-i18n-title="flow.nameAliasHint" id="flowName" maxlength="100" data-i18n-placeholder="flow.namePlaceholder"
placeholder="My automation"> data-i18n-title="flow.nameAliasHint" placeholder="My automation">
</div> </div>
</div> </div>
</div> </div>
@@ -931,7 +993,8 @@
<div class="flow-workspace-wrap"> <div class="flow-workspace-wrap">
<div class="flow-workspace-toolbar"> <div class="flow-workspace-toolbar">
<div class="flow-workspace-primary-actions"><button type="button" class="primary flow-add-block-button" <div class="flow-workspace-primary-actions"><button type="button" class="primary flow-add-block-button"
data-action="flow-add-block"><span data-ui-icon="plus" aria-hidden="true"></span><span data-i18n="flow.addBlock">Add data-action="flow-add-block"><span data-ui-icon="plus" aria-hidden="true"></span><span
data-i18n="flow.addBlock">Add
block</span></button><button type="button" class="secondary flow-selection-action" block</span></button><button type="button" class="secondary flow-selection-action"
data-action="flow-select-all" data-i18n="flow.selectAll">Zaznacz wszystko</button><button type="button" data-action="flow-select-all" data-i18n="flow.selectAll">Zaznacz wszystko</button><button type="button"
class="secondary flow-selection-action" data-action="flow-clear-selection" class="secondary flow-selection-action" data-action="flow-clear-selection"
@@ -941,10 +1004,11 @@
<button type="button" class="secondary icon-button" data-action="flow-zoom-out" <button type="button" class="secondary icon-button" data-action="flow-zoom-out"
data-i18n-title="flow.zoomOut" title="Zoom out" data-ui-icon="minus"></button><span id="flowZoomLabel" data-i18n-title="flow.zoomOut" title="Zoom out" data-ui-icon="minus"></button><span id="flowZoomLabel"
aria-live="polite">100%</span><button type="button" class="secondary icon-button" aria-live="polite">100%</span><button type="button" class="secondary icon-button"
data-action="flow-zoom-in" data-i18n-title="flow.zoomIn" title="Zoom in" data-ui-icon="plus"></button><button type="button" data-action="flow-zoom-in" data-i18n-title="flow.zoomIn" title="Zoom in"
class="secondary flow-fit-button" data-action="flow-fit" data-i18n-title="flow.fitView" title="Fit"><span data-ui-icon="plus"></button><button type="button" class="secondary flow-fit-button"
class="flow-fit-button-icon" data-ui-icon="fit" aria-hidden="true"></span><span data-action="flow-fit" data-i18n-title="flow.fitView" title="Fit"><span class="flow-fit-button-icon"
class="flow-fit-button-label" data-i18n="flow.fitView">Fit</span></button> data-ui-icon="fit" aria-hidden="true"></span><span class="flow-fit-button-label"
data-i18n="flow.fitView">Fit</span></button>
</div> </div>
</div> </div>
<div id="flowWorkspace" class="flow-workspace" tabindex="0"> <div id="flowWorkspace" class="flow-workspace" tabindex="0">
@@ -991,13 +1055,16 @@
<h2 data-i18n="flow.actions">Actions</h2><button type="button" data-close data-ui-icon="close"></button> <h2 data-i18n="flow.actions">Actions</h2><button type="button" data-close data-ui-icon="close"></button>
</div> </div>
<div class="menu-list"> <div class="menu-list">
<button type="button" data-action="flow-templates"><span <button type="button" data-action="flow-templates"><span data-i18n="flow.templates">Templates</span><span
data-i18n="flow.templates">Templates</span><span data-ui-icon="chevron-right" aria-hidden="true"></span></button> data-ui-icon="chevron-right" aria-hidden="true"></span></button>
<button type="button" data-action="import-flow"><span data-i18n="flow.import">Import</span><span data-ui-icon="chevron-right" aria-hidden="true"></span></button> <button type="button" data-action="import-flow"><span data-i18n="flow.import">Import</span><span
<button type="button" data-action="export-flow"><span data-i18n="flow.export">Export</span><span data-ui-icon="chevron-right" aria-hidden="true"></span></button> data-ui-icon="chevron-right" aria-hidden="true"></span></button>
<button type="button" data-action="flow-dry-run"><span <button type="button" data-action="export-flow"><span data-i18n="flow.export">Export</span><span
data-i18n="flow.dryRun">Dry-run</span><span data-ui-icon="chevron-right" aria-hidden="true"></span></button> data-ui-icon="chevron-right" aria-hidden="true"></span></button>
<button type="button" data-action="flow-logs"><span data-i18n="flow.logs">Logs</span><span data-ui-icon="chevron-right" aria-hidden="true"></span></button> <button type="button" data-action="flow-dry-run"><span data-i18n="flow.dryRun">Dry-run</span><span
data-ui-icon="chevron-right" aria-hidden="true"></span></button>
<button type="button" data-action="flow-logs"><span data-i18n="flow.logs">Logs</span><span
data-ui-icon="chevron-right" aria-hidden="true"></span></button>
</div> </div>
</dialog> </dialog>
@@ -1084,26 +1151,36 @@
<nav class="bottom-nav" data-i18n-aria="nav.navigation" aria-label="Navigation"> <nav class="bottom-nav" data-i18n-aria="nav.navigation" aria-label="Navigation">
<span class="nav-section-label desktop-nav-only" data-i18n="nav.sectionControl">Sterowanie</span> <span class="nav-section-label desktop-nav-only" data-i18n="nav.sectionControl">Sterowanie</span>
<button class="active" data-nav="dashboard"><span data-ui-icon="home" aria-hidden="true"></span><b data-i18n="nav.dashboard">Dashboard</b></button> <button class="active" data-nav="dashboard"><span data-ui-icon="home" aria-hidden="true"></span><b
<button data-nav="zones"><span data-ui-icon="zones" aria-hidden="true"></span><b data-i18n="nav.zones">Zones</b></button> data-i18n="nav.dashboard">Dashboard</b></button>
<button class="desktop-nav-only" data-nav="groups"><span data-ui-icon="groups" aria-hidden="true"></span><b data-i18n="nav.groups">Groups</b></button> <button data-nav="zones"><span data-ui-icon="zones" aria-hidden="true"></span><b
data-i18n="nav.zones">Zones</b></button>
<button class="desktop-nav-only" data-nav="groups"><span data-ui-icon="groups" aria-hidden="true"></span><b
data-i18n="nav.groups">Groups</b></button>
<span class="nav-section-label desktop-nav-only" data-i18n="nav.sectionAutomation">Automatyzacja</span> <span class="nav-section-label desktop-nav-only" data-i18n="nav.sectionAutomation">Automatyzacja</span>
<button data-nav="flows"><span data-ui-icon="flow" aria-hidden="true"></span><b data-i18n="nav.flows">Flow</b></button> <button data-nav="flows"><span data-ui-icon="flow" aria-hidden="true"></span><b
data-i18n="nav.flows">Flow</b></button>
<button class="desktop-nav-only" data-nav="schedules"><span data-ui-icon="schedule" aria-hidden="true"></span><b <button class="desktop-nav-only" data-nav="schedules"><span data-ui-icon="schedule" aria-hidden="true"></span><b
data-i18n="nav.schedules">Schedules</b></button> data-i18n="nav.schedules">Schedules</b></button>
<button class="desktop-nav-only" data-nav="automations"><span data-ui-icon="automation" aria-hidden="true"></span><b <button class="desktop-nav-only" data-nav="automations"><span data-ui-icon="automation" aria-hidden="true"></span><b
data-i18n="nav.automations">Automations</b></button> data-i18n="nav.automations">Automations</b></button>
<span class="nav-section-label desktop-nav-only" data-i18n="nav.sectionData">Dane i system</span> <span class="nav-section-label desktop-nav-only" data-i18n="nav.sectionData">Dane i system</span>
<button data-nav="history"><span data-ui-icon="history" aria-hidden="true"></span><b data-i18n="nav.history">History</b></button> <button data-nav="history"><span data-ui-icon="history" aria-hidden="true"></span><b
<button class="desktop-nav-only" data-nav="devices"><span data-ui-icon="devices" aria-hidden="true"></span><b data-i18n="nav.devices">Devices</b></button> data-i18n="nav.history">History</b></button>
<button class="desktop-nav-only" data-nav="devices"><span data-ui-icon="devices" aria-hidden="true"></span><b
data-i18n="nav.devices">Devices</b></button>
<button class="desktop-nav-only" data-nav="homeassistant"><span>HA</span><b data-i18n="nav.homeAssistantShort">Home <button class="desktop-nav-only" data-nav="homeassistant"><span>HA</span><b data-i18n="nav.homeAssistantShort">Home
Assistant</b></button> Assistant</b></button>
<button class="desktop-nav-only" data-nav="simulation"><span data-ui-icon="play" aria-hidden="true"></span><b <button class="desktop-nav-only" data-nav="simulation"><span data-ui-icon="play" aria-hidden="true"></span><b
data-i18n="nav.simulation">Simulator</b></button> data-i18n="nav.simulation">Simulator</b></button>
<button class="desktop-nav-only" data-nav="night"><span data-ui-icon="moon" aria-hidden="true"></span><b data-i18n="nav.nightMode">Night mode</b></button> <button class="desktop-nav-only" data-nav="night"><span data-ui-icon="moon" aria-hidden="true"></span><b
<button class="desktop-nav-only" data-nav="logs"><span data-ui-icon="events" aria-hidden="true"></span><b data-i18n="nav.logs">Events</b></button> data-i18n="nav.nightMode">Night mode</b></button>
<button class="desktop-nav-only" data-nav="settings"><span data-ui-icon="settings" aria-hidden="true"></span><b data-i18n="nav.settings">Settings</b></button> <button class="desktop-nav-only" data-nav="logs"><span data-ui-icon="events" aria-hidden="true"></span><b
<button class="mobile-nav-only" data-nav="more"><span data-ui-icon="more-horizontal" aria-hidden="true"></span><b data-i18n="nav.more">More</b></button> data-i18n="nav.logs">Events</b></button>
<button class="desktop-nav-only" data-nav="settings"><span data-ui-icon="settings" aria-hidden="true"></span><b
data-i18n="nav.settings">Settings</b></button>
<button class="mobile-nav-only" data-nav="more"><span data-ui-icon="more-horizontal" aria-hidden="true"></span><b
data-i18n="nav.more">More</b></button>
</nav> </nav>
<dialog id="moreDialog" class="sheet"> <dialog id="moreDialog" class="sheet">
@@ -1112,18 +1189,26 @@
</div> </div>
<div class="menu-list more-menu-list"> <div class="menu-list more-menu-list">
<span class="menu-section-label" data-i18n="nav.sectionControl">Sterowanie</span> <span class="menu-section-label" data-i18n="nav.sectionControl">Sterowanie</span>
<button data-go="devices"><span data-i18n="nav.devices">Devices</span><span data-ui-icon="chevron-right" aria-hidden="true"></span></button> <button data-go="devices"><span data-i18n="nav.devices">Devices</span><span data-ui-icon="chevron-right"
<button data-go="groups"><span data-i18n="nav.groups">Groups</span><span data-ui-icon="chevron-right" aria-hidden="true"></span></button> aria-hidden="true"></span></button>
<button data-go="groups"><span data-i18n="nav.groups">Groups</span><span data-ui-icon="chevron-right"
aria-hidden="true"></span></button>
<span class="menu-section-label" data-i18n="nav.sectionAutomation">Automatyzacja</span> <span class="menu-section-label" data-i18n="nav.sectionAutomation">Automatyzacja</span>
<button data-go="schedules"><span data-i18n="nav.schedules">Schedules</span><span data-ui-icon="chevron-right" aria-hidden="true"></span></button> <button data-go="schedules"><span data-i18n="nav.schedules">Schedules</span><span data-ui-icon="chevron-right"
<button data-go="automations"><span data-i18n="nav.automations">Automations</span><span data-ui-icon="chevron-right" aria-hidden="true"></span></button> aria-hidden="true"></span></button>
<button data-go="automations"><span data-i18n="nav.automations">Automations</span><span
data-ui-icon="chevron-right" aria-hidden="true"></span></button>
<span class="menu-section-label" data-i18n="nav.sectionData">Dane i system</span> <span class="menu-section-label" data-i18n="nav.sectionData">Dane i system</span>
<button data-go="simulation"><span data-i18n="nav.simulation">Simulator</span><span data-ui-icon="chevron-right" aria-hidden="true"></span></button> <button data-go="simulation"><span data-i18n="nav.simulation">Simulator</span><span data-ui-icon="chevron-right"
<button data-go="night"><span data-i18n="nav.nightMode">Night mode</span><span data-ui-icon="chevron-right" aria-hidden="true"></span></button> aria-hidden="true"></span></button>
<button data-go="night"><span data-i18n="nav.nightMode">Night mode</span><span data-ui-icon="chevron-right"
aria-hidden="true"></span></button>
<button data-go="homeassistant"><span data-i18n="nav.homeAssistant">Home Assistant / <button data-go="homeassistant"><span data-i18n="nav.homeAssistant">Home Assistant /
Sensors</span><span data-ui-icon="chevron-right" aria-hidden="true"></span></button> Sensors</span><span data-ui-icon="chevron-right" aria-hidden="true"></span></button>
<button data-go="settings"><span data-i18n="nav.settings">Settings</span><span data-ui-icon="chevron-right" aria-hidden="true"></span></button> <button data-go="settings"><span data-i18n="nav.settings">Settings</span><span data-ui-icon="chevron-right"
<button data-go="logs"><span data-i18n="nav.logsAndEvents">Events and logs</span><span data-ui-icon="chevron-right" aria-hidden="true"></span></button> aria-hidden="true"></span></button>
<button data-go="logs"><span data-i18n="nav.logsAndEvents">Events and logs</span><span
data-ui-icon="chevron-right" aria-hidden="true"></span></button>
</div> </div>
</dialog> </dialog>
@@ -1157,17 +1242,25 @@
<dialog id="cloudDiscoveryDialog" class="dialog-wide"> <dialog id="cloudDiscoveryDialog" class="dialog-wide">
<div class="dialog-form"> <div class="dialog-form">
<div class="dialog-head"><div><span class="eyebrow">GREE Cloud</span><h2 data-i18n="devices.cloudDiscoveryTitle">Devices on account</h2></div><button type="button" data-close data-ui-icon="close"></button></div> <div class="dialog-head">
<p class="field-note" data-i18n="devices.cloudDiscoveryHint">Devices are loaded from the saved GREE Cloud account. Nothing is added automatically; choose the units you want to register.</p> <div><span class="eyebrow">GREE Cloud</span>
<h2 data-i18n="devices.cloudDiscoveryTitle">Devices on account</h2>
</div><button type="button" data-close data-ui-icon="close"></button>
</div>
<p class="field-note" data-i18n="devices.cloudDiscoveryHint">Devices are loaded from the saved GREE Cloud account.
Nothing is added automatically; choose the units you want to register.</p>
<div id="cloudDiscoveryList" class="discovery-name-list"></div> <div id="cloudDiscoveryList" class="discovery-name-list"></div>
<div class="form-actions"><button type="button" class="secondary" id="cloudDiscoveryRefresh" data-i18n="actions.refresh">Refresh</button><button type="button" class="primary" data-close data-i18n="actions.done">Done</button></div> <div class="form-actions"><button type="button" class="secondary" id="cloudDiscoveryRefresh"
data-i18n="actions.refresh">Refresh</button><button type="button" class="primary" data-close
data-i18n="actions.done">Done</button></div>
</div> </div>
</dialog> </dialog>
<dialog id="discoverDialog"> <dialog id="discoverDialog">
<form method="dialog" id="discoverForm" class="dialog-form"> <form method="dialog" id="discoverForm" class="dialog-form">
<div class="dialog-head"> <div class="dialog-head">
<h2 data-i18n="discovery.title">Discover GREE devices</h2><button type="button" data-close data-ui-icon="close"></button> <h2 data-i18n="discovery.title">Discover GREE devices</h2><button type="button" data-close
data-ui-icon="close"></button>
</div> </div>
<p class="field-note" data-i18n="discovery.help">Auto searches both AES-ECB and AES-GCM devices. Multiple passes <p class="field-note" data-i18n="discovery.help">Auto searches both AES-ECB and AES-GCM devices. Multiple passes
improve discovery when several Wi-Fi modules answer the same broadcast.</p> improve discovery when several Wi-Fi modules answer the same broadcast.</p>
@@ -1188,7 +1281,8 @@
<dialog id="discoveryNamesDialog"> <dialog id="discoveryNamesDialog">
<form method="dialog" id="discoveryNamesForm" class="dialog-form"> <form method="dialog" id="discoveryNamesForm" class="dialog-form">
<div class="dialog-head"> <div class="dialog-head">
<h2 data-i18n="discovery.nameDevices">Name discovered devices</h2><button type="button" data-close data-ui-icon="close"></button> <h2 data-i18n="discovery.nameDevices">Name discovered devices</h2><button type="button" data-close
data-ui-icon="close"></button>
</div> </div>
<p class="field-note" data-i18n="discovery.nameDevicesHelp">Give each new unit a friendly room name. The technical <p class="field-note" data-i18n="discovery.nameDevicesHelp">Give each new unit a friendly room name. The technical
model and MAC remain available in diagnostics.</p> model and MAC remain available in diagnostics.</p>
@@ -1203,7 +1297,8 @@
<form method="dialog" id="renameDeviceForm" class="dialog-form"> <form method="dialog" id="renameDeviceForm" class="dialog-form">
<input type="hidden" name="id"> <input type="hidden" name="id">
<div class="dialog-head"> <div class="dialog-head">
<h2 data-i18n="devices.technicalConfig">Technical configuration</h2><button type="button" data-close data-ui-icon="close"></button> <h2 data-i18n="devices.technicalConfig">Technical configuration</h2><button type="button" data-close
data-ui-icon="close"></button>
</div> </div>
<label><span data-i18n="common.name">Name</span><input name="name" required maxlength="80"></label> <label><span data-i18n="common.name">Name</span><input name="name" required maxlength="80"></label>
<div class="two"><label><span data-i18n="devices.ipAddress">IP address</span><input name="ip" <div class="two"><label><span data-i18n="devices.ipAddress">IP address</span><input name="ip"
@@ -1228,56 +1323,101 @@
<form method="dialog" id="deviceDetailsForm" class="dialog-form"> <form method="dialog" id="deviceDetailsForm" class="dialog-form">
<input type="hidden" name="id"> <input type="hidden" name="id">
<div class="dialog-head"> <div class="dialog-head">
<div><span class="eyebrow" data-i18n="devices.cloudDetails">Cloud details</span><h2 id="deviceDetailsTitle">Device details</h2></div> <div><span class="eyebrow" data-i18n="devices.cloudDetails">Cloud details</span>
<h2 id="deviceDetailsTitle">Device details</h2>
</div>
<button type="button" data-close data-ui-icon="close"></button> <button type="button" data-close data-ui-icon="close"></button>
</div> </div>
<label><span data-i18n="common.name">Name</span><input name="name" required maxlength="80"></label> <label><span data-i18n="common.name">Name</span><input name="name" required maxlength="80"></label>
<div class="technical-device-grid" id="deviceDetailsMeta"></div> <div class="technical-device-grid" id="deviceDetailsMeta"></div>
<section class="form-section"> <section class="form-section">
<div class="form-section-head"><div><h3 data-i18n="energy.title">Energy</h3><p data-i18n="energy.sourceHint">Choose the cumulative energy source used for charts and period totals.</p></div></div> <div class="form-section-head">
<div>
<h3 data-i18n="energy.title">Energy</h3>
<p data-i18n="energy.sourceHint">Choose the cumulative energy source used for charts and period totals.</p>
</div>
</div>
<div class="form-grid"> <div class="form-grid">
<label><span data-i18n="energy.source">Energy source</span><select name="energy_source"> <label><span data-i18n="energy.source">Energy source</span><select name="energy_source">
<option value="auto" data-i18n="energy.auto">Auto</option> <option value="auto" data-i18n="energy.auto">Auto</option>
<option value="gree_cloud">GREE Cloud</option> <option value="gree_cloud">GREE Cloud</option>
<option value="home_assistant">Home Assistant</option> <option value="home_assistant">Home Assistant</option>
</select></label> </select></label>
<label class="wide"><span data-i18n="energy.haSensor">Home Assistant energy sensor</span><select name="ha_energy_entity_id"><option value=""></option></select></label> <label class="wide"><span data-i18n="energy.haSensor">Home Assistant energy sensor</span><select
name="ha_energy_entity_id">
<option value=""></option>
</select></label>
<p class="field-note wide" id="deviceEnergySensorMeta"></p> <p class="field-note wide" id="deviceEnergySensorMeta"></p>
<div class="inline-alert info wide" id="deviceEnergyGroupNote" hidden></div> <div class="inline-alert info wide" id="deviceEnergyGroupNote" hidden></div>
</div> </div>
</section> </section>
<div class="form-actions"><button type="button" class="secondary" data-close data-i18n="actions.cancel">Cancel</button><button class="primary" type="submit" data-i18n="actions.save">Save</button></div> <div class="form-actions"><button type="button" class="secondary" data-close
data-i18n="actions.cancel">Cancel</button><button class="primary" type="submit"
data-i18n="actions.save">Save</button></div>
</form> </form>
</dialog> </dialog>
<dialog id="deviceGroupsDialog" class="dialog-wide"> <dialog id="deviceGroupsDialog" class="dialog-wide">
<div class="dialog-form"> <div class="dialog-form">
<div class="dialog-head"><div><span class="eyebrow" data-i18n="devices.installation">Installation</span><h2 data-i18n="devices.installations">Split / multisplit installations</h2></div><button type="button" data-close data-ui-icon="close"></button></div> <div class="dialog-head">
<p class="field-note" data-i18n="devices.installationsHint">Group indoor units that share one outdoor unit. Energy and outdoor temperature can use one shared source.</p> <div><span class="eyebrow" data-i18n="devices.installation">Installation</span>
<h2 data-i18n="devices.installations">Split / multisplit installations</h2>
</div><button type="button" data-close data-ui-icon="close"></button>
</div>
<p class="field-note" data-i18n="devices.installationsHint">Group indoor units that share one outdoor unit. Energy
and outdoor temperature can use one shared source.</p>
<div id="deviceGroupsList" class="installation-list"></div> <div id="deviceGroupsList" class="installation-list"></div>
<form id="deviceGroupForm" class="installation-form"> <form id="deviceGroupForm" class="installation-form">
<input type="hidden" name="id"> <input type="hidden" name="id">
<div class="form-grid"> <div class="form-grid">
<label><span data-i18n="devices.installationName">Installation name</span><input name="name" maxlength="80" required></label> <label><span data-i18n="devices.installationName">Installation name</span><input name="name" maxlength="80"
<label><span data-i18n="devices.installationType">Installation type</span><select name="kind"><option value="split" data-i18n="devices.split">Split</option><option value="multisplit" data-i18n="devices.multisplit">Multisplit</option></select></label> required></label>
<div class="wide"><span class="field-label" data-i18n="devices.installationDevices">Indoor units</span><div id="deviceGroupDeviceChoices" class="installation-device-choices"></div></div> <label><span data-i18n="devices.installationType">Installation type</span><select name="kind">
<label><span data-i18n="energy.source">Energy source</span><select name="energy_source"><option value="auto" data-i18n="energy.auto">Auto</option><option value="gree_cloud">GREE Cloud</option><option value="home_assistant">Home Assistant</option></select></label> <option value="split" data-i18n="devices.split">Split</option>
<label><span data-i18n="devices.energySourceDevice">GREE Cloud energy source</span><select name="energy_device_id"><option value=""></option></select></label> <option value="multisplit" data-i18n="devices.multisplit">Multisplit</option>
<label class="wide"><span data-i18n="energy.haSensor">Home Assistant energy sensor</span><select name="ha_energy_entity_id"><option value=""></option></select></label> </select></label>
<label class="wide"><span data-i18n="devices.outdoorSourceDevice">Outdoor temperature source</span><select name="outdoor_temperature_device_id"><option value="" data-i18n="devices.noSharedOutdoor">No shared source</option></select></label> <div class="wide"><span class="field-label" data-i18n="devices.installationDevices">Indoor units</span>
<div id="deviceGroupDeviceChoices" class="installation-device-choices"></div>
</div>
<label><span data-i18n="energy.source">Energy source</span><select name="energy_source">
<option value="auto" data-i18n="energy.auto">Auto</option>
<option value="gree_cloud">GREE Cloud</option>
<option value="home_assistant">Home Assistant</option>
</select></label>
<label><span data-i18n="devices.energySourceDevice">GREE Cloud energy source</span><select
name="energy_device_id">
<option value=""></option>
</select></label>
<label class="wide"><span data-i18n="energy.haSensor">Home Assistant energy sensor</span><select
name="ha_energy_entity_id">
<option value=""></option>
</select></label>
<label class="wide"><span data-i18n="devices.outdoorSourceDevice">Outdoor temperature source</span><select
name="outdoor_temperature_device_id">
<option value="" data-i18n="devices.noSharedOutdoor">No shared source</option>
</select></label>
<p class="field-note wide" id="deviceGroupEnergySensorMeta"></p> <p class="field-note wide" id="deviceGroupEnergySensorMeta"></p>
</div> </div>
<div class="form-actions"><button type="button" class="secondary" id="deviceGroupNew" data-i18n="devices.newInstallation">New installation</button><button type="submit" class="primary" data-i18n="actions.save">Save</button></div> <div class="form-actions"><button type="button" class="secondary" id="deviceGroupNew"
data-i18n="devices.newInstallation">New installation</button><button type="submit" class="primary"
data-i18n="actions.save">Save</button></div>
</form> </form>
</div> </div>
</dialog> </dialog>
<dialog id="cloudDiagnosticsDialog" class="dialog-wide"> <dialog id="cloudDiagnosticsDialog" class="dialog-wide">
<div class="dialog-form"> <div class="dialog-form">
<div class="dialog-head"><div><span class="eyebrow">GREE Cloud</span><h2 data-i18n="devices.cloudDiagnostics">Cloud diagnostics</h2></div><button type="button" data-close data-ui-icon="close"></button></div> <div class="dialog-head">
<p class="field-note" data-i18n="devices.cloudDiagnosticsHint">Decrypted and sanitized protocol data. Passwords, tokens and cipher keys are never included.</p> <div><span class="eyebrow">GREE Cloud</span>
<h2 data-i18n="devices.cloudDiagnostics">Cloud diagnostics</h2>
</div><button type="button" data-close data-ui-icon="close"></button>
</div>
<p class="field-note" data-i18n="devices.cloudDiagnosticsHint">Decrypted and sanitized protocol data. Passwords,
tokens and cipher keys are never included.</p>
<pre id="cloudDiagnosticsPayload" class="mono cloud-diagnostics-payload"></pre> <pre id="cloudDiagnosticsPayload" class="mono cloud-diagnostics-payload"></pre>
<div class="form-actions"><button type="button" class="secondary" id="cloudDiagnosticsRefresh" data-i18n="actions.refresh">Refresh</button><button type="button" class="primary" data-close data-i18n="actions.done">Done</button></div> <div class="form-actions"><button type="button" class="secondary" id="cloudDiagnosticsRefresh"
data-i18n="actions.refresh">Refresh</button><button type="button" class="primary" data-close
data-i18n="actions.done">Done</button></div>
</div> </div>
</dialog> </dialog>
@@ -1359,17 +1499,20 @@
°C</span><input type="text" inputmode="decimal" step="0.1" min="0.1" max="5" name="heat_hysteresis" °C</span><input type="text" inputmode="decimal" step="0.1" min="0.1" max="5" name="heat_hysteresis"
value="0.6"></label></div> value="0.6"></label></div>
<div id="externalSensorFields"> <div id="externalSensorFields">
<label><span data-i18n="zones.roomSensorEntity">Room sensor entity_id</span><input name="ha_entity_id" list="haEntitySuggestions" <label><span data-i18n="zones.roomSensorEntity">Room sensor entity_id</span><input name="ha_entity_id"
placeholder="sensor.living_room_temperature"></label> list="haEntitySuggestions" placeholder="sensor.living_room_temperature"></label>
<div class="two"><label><span data-i18n="zones.roomSensorWeight">Room sensor weight %</span><input type="number" <div class="two"><label><span data-i18n="zones.roomSensorWeight">Room sensor weight %</span><input type="number"
step="5" min="0" max="100" name="external_sensor_weight_percent" value="40"></label><label><span step="5" min="0" max="100" name="external_sensor_weight_percent" value="40"></label><label><span
data-i18n="zones.maxDifference">Max. sensor difference °C</span><input type="text" inputmode="decimal" data-i18n="zones.maxDifference">Max. sensor difference °C</span><input type="text" inputmode="decimal"
step="0.1" min="0.1" max="20" name="max_sensor_difference" value="3.0"></label></div> step="0.1" min="0.1" max="20" name="max_sensor_difference" value="3.0"></label></div>
<p class="field-note" data-i18n="zones.sensorHelp">This room sensor is required when the temperature strategy uses Home Assistant. If it becomes unavailable, the controller falls back to the GREE sensor.</p> <p class="field-note" data-i18n="zones.sensorHelp">This room sensor is required when the temperature strategy
uses Home Assistant. If it becomes unavailable, the controller falls back to the GREE sensor.</p>
</div> </div>
<label><span data-i18n="zones.outdoorSensorEntity">Outdoor temperature entity_id override</span><input name="ha_outdoor_entity_id" list="haEntitySuggestions" <label><span data-i18n="zones.outdoorSensorEntity">Outdoor temperature entity_id override</span><input
placeholder="sensor.outdoor_temperature"></label> name="ha_outdoor_entity_id" list="haEntitySuggestions" placeholder="sensor.outdoor_temperature"></label>
<p class="field-note" data-i18n="zones.outdoorSensorHelp">Leave empty to use the global Outdoor temperature entity_id. Set another entity_id to use a different outdoor sensor only for this zone. It is used by outdoor-temperature assist and recorded in history/metrics.</p> <p class="field-note" data-i18n="zones.outdoorSensorHelp">Leave empty to use the global Outdoor temperature
entity_id. Set another entity_id to use a different outdoor sensor only for this zone. It is used by
outdoor-temperature assist and recorded in history/metrics.</p>
<div class="two"><label><span data-i18n="zones.minAdjust">Minimum adjustment interval (s)</span><input <div class="two"><label><span data-i18n="zones.minAdjust">Minimum adjustment interval (s)</span><input
type="number" min="15" name="min_adjust_seconds" value="120"></label><label><span type="number" min="15" name="min_adjust_seconds" value="120"></label><label><span
data-i18n="zones.standbyOffset">Standby setpoint offset °C</span><input type="text" inputmode="decimal" data-i18n="zones.standbyOffset">Standby setpoint offset °C</span><input type="text" inputmode="decimal"
@@ -1641,7 +1784,8 @@
<dialog id="generatedTokenDialog" class="auth-dialog"> <dialog id="generatedTokenDialog" class="auth-dialog">
<div class="dialog-form"> <div class="dialog-form">
<div class="dialog-head"> <div class="dialog-head">
<h2 data-i18n="settings.tokenCreated">Token created</h2><button type="button" data-close data-ui-icon="close"></button> <h2 data-i18n="settings.tokenCreated">Token created</h2><button type="button" data-close
data-ui-icon="close"></button>
</div> </div>
<p class="field-note" data-i18n="settings.tokenCreatedHint">Copy this token now. It will not be shown again.</p> <p class="field-note" data-i18n="settings.tokenCreatedHint">Copy this token now. It will not be shown again.</p>
<label><span data-i18n="auth.token">Access token</span><input id="generatedAccessToken" class="mono" <label><span data-i18n="auth.token">Access token</span><input id="generatedAccessToken" class="mono"
@@ -1655,18 +1799,27 @@
<dialog id="outdoorHistoryDialog" class="dialog-wide outdoor-history-dialog"> <dialog id="outdoorHistoryDialog" class="dialog-wide outdoor-history-dialog">
<div class="dialog-form"> <div class="dialog-form">
<div class="dialog-head"> <div class="dialog-head">
<div><span class="eyebrow" data-i18n="history.24h">24 hours</span><h2 data-i18n="history.allOutdoor">Outdoor temperatures</h2></div> <div><span class="eyebrow" data-i18n="history.24h">24 hours</span>
<button type="button" data-close data-i18n-aria="actions.close" aria-label="Close" data-ui-icon="close"></button> <h2 data-i18n="history.allOutdoor">Outdoor temperatures</h2>
</div>
<button type="button" data-close data-i18n-aria="actions.close" aria-label="Close"
data-ui-icon="close"></button>
</div> </div>
<div class="outdoor-history-current"><small data-i18n="house.outdoor">Outdoor</small><strong id="outdoorHistoryCurrent">--</strong></div> <div class="outdoor-history-current"><small data-i18n="house.outdoor">Outdoor</small><strong
id="outdoorHistoryCurrent">--</strong></div>
<div id="outdoorHistoryChartHost"></div> <div id="outdoorHistoryChartHost"></div>
<div class="outdoor-history-links" data-i18n-aria="house.outdoorHistoryMore" aria-label="More metrics and ranges in history"> <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> <span data-i18n="house.outdoorHistoryMore">More in history</span>
<div> <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=168" data-history-route data-history-hours="168"
<a href="/history/overview?hours=720" data-history-route data-history-hours="720" data-i18n="history.range30d">30 days</a> data-i18n="history.range7d">7 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?hours=720" data-history-route data-history-hours="720"
<a href="/history/overview" data-history-route class="outdoor-history-all" data-i18n="house.outdoorHistoryFull">Full history</a> 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> </div>
</div> </div>
@@ -1695,8 +1848,8 @@
data-i18n="debug.requests">Requests</button> data-i18n="debug.requests">Requests</button>
<button type="button" role="tab" aria-selected="false" data-debug-filter="gree" <button type="button" role="tab" aria-selected="false" data-debug-filter="gree"
data-i18n="debug.gree">GREE</button> data-i18n="debug.gree">GREE</button>
<button type="button" role="tab" aria-selected="false" data-debug-filter="cloud" <button type="button" role="tab" aria-selected="false" data-debug-filter="cloud" data-i18n="debug.cloud">Cloud
data-i18n="debug.cloud">Cloud API</button> API</button>
<button type="button" role="tab" aria-selected="false" data-debug-filter="mqtt" <button type="button" role="tab" aria-selected="false" data-debug-filter="mqtt"
data-i18n="debug.mqtt">MQTT</button> data-i18n="debug.mqtt">MQTT</button>
</div> </div>
@@ -46,7 +46,7 @@ function normalizeEnergyHistoryTargets() {
const ids = new Set(available.map(item => item.id)); const ids = new Set(available.map(item => item.id));
app.historyEnergyTargets = (app.historyEnergyTargets || []).filter(id => ids.has(id)); app.historyEnergyTargets = (app.historyEnergyTargets || []).filter(id => ids.has(id));
if (!app.historyEnergyTargets.length && app.historyEnergyDevice && ids.has(app.historyEnergyDevice)) app.historyEnergyTargets = [app.historyEnergyDevice]; if (!app.historyEnergyTargets.length && app.historyEnergyDevice && ids.has(app.historyEnergyDevice)) app.historyEnergyTargets = [app.historyEnergyDevice];
if (!app.historyEnergyTargets.length && available.length) app.historyEnergyTargets = [available[0].id]; if (!app.historyEnergyTargets.length && available.length) app.historyEnergyTargets = available.slice(0, 8).map(item => item.id);
app.historyEnergyTargets = app.historyEnergyTargets.slice(0, 8); app.historyEnergyTargets = app.historyEnergyTargets.slice(0, 8);
app.historyEnergyDevice = app.historyEnergyTargets[0] || ''; app.historyEnergyDevice = app.historyEnergyTargets[0] || '';
return available; return available;
+21 -17
View File
@@ -20,7 +20,7 @@ const setCookie = (name, value) => {
document.cookie = `${name}=${encodeURIComponent(value)}; Max-Age=31536000; Path=${APP_BASE || '/'}; SameSite=Lax`; document.cookie = `${name}=${encodeURIComponent(value)}; Max-Age=31536000; Path=${APP_BASE || '/'}; SameSite=Lax`;
}; };
const DEFAULT_LANGUAGE = 'en'; const DEFAULT_LANGUAGE = String(document.documentElement.lang || '').trim();
const preferredLanguage = getCookie('gree_controller_language') || DEFAULT_LANGUAGE; const preferredLanguage = getCookie('gree_controller_language') || DEFAULT_LANGUAGE;
const preferredTheme = ['system', 'light', 'dark'].includes(getCookie('gree_controller_theme')) ? getCookie('gree_controller_theme') : 'system'; const preferredTheme = ['system', 'light', 'dark'].includes(getCookie('gree_controller_theme')) ? getCookie('gree_controller_theme') : 'system';
@@ -28,9 +28,9 @@ const app = {
devices: [], zones: [], groups: [], deviceGroups: [], deviceGroupEnergy: {}, schedules: [], automations: [], flows: [], accessTokens: [], settings: null, system: {}, outdoorTemperature: null, devices: [], zones: [], groups: [], deviceGroups: [], deviceGroupEnergy: {}, schedules: [], automations: [], flows: [], accessTokens: [], settings: null, system: {}, outdoorTemperature: null,
token: localStorage.getItem('gree_controller_token') || '', ws: null, wsTimer: null, token: localStorage.getItem('gree_controller_token') || '', ws: null, wsTimer: null,
currentView: 'dashboard', loading: false, bootstrapReloadPending: false, language: preferredLanguage, theme: preferredTheme, connectionStatus: 'connecting', currentView: 'dashboard', loading: false, bootstrapReloadPending: false, language: preferredLanguage, theme: preferredTheme, connectionStatus: 'connecting',
languages: [], translations: {}, locales: {}, languages: [], translations: {}, locales: {}, defaultLanguage: DEFAULT_LANGUAGE,
historyTab: 'overview', historyData: { zones: [], devices: [], sensors: [] }, historyCounts: {}, historyTab: 'overview', historyData: { zones: [], devices: [], sensors: [] }, historyCounts: {},
historyZone: 'all', historyDevice: 'all', historySensor: 'all', historyNetworkTarget: 'all', historyNetworkShowJitter: true, historyNetwork: [], historyNetworkTargets: [], historyEnergyDevice: '', historyEnergyTargets: [], historyEnergyInterval: 'daily', historyEnergyCompare: 'none', historyEnergy: [], historyLoading: false, historyReloadPending: false, historyZone: 'all', historyDevice: 'all', historySensor: 'all', historyNetworkTarget: 'all', historyNetworkShowJitter: true, historyNetwork: [], historyNetworkTargets: [], historyEnergyDevice: '', historyEnergyTargets: [], historyEnergyHours: '720', historyEnergyInterval: 'daily', historyEnergyCompare: 'none', historyEnergy: [], historyLoading: false, historyReloadPending: false,
customChartSeries: [], savedCharts: [], customChartEditingId: null, customChartNameDraft: null, chartZooms: {}, chartHiddenSeries: {}, zoneControlSeq: {}, groupControlSeq: {}, zoneControlQueue: {}, groupControlQueue: {}, deviceControlQueue: {}, houseControlQueue: null, climateControlQueue: null, groupCustomDrafts: {}, zoneTemperatureTimers: {}, deviceTemperatureDrafts: {}, customChartSeries: [], savedCharts: [], customChartEditingId: null, customChartNameDraft: null, chartZooms: {}, chartHiddenSeries: {}, zoneControlSeq: {}, groupControlSeq: {}, zoneControlQueue: {}, groupControlQueue: {}, deviceControlQueue: {}, houseControlQueue: null, climateControlQueue: null, groupCustomDrafts: {}, zoneTemperatureTimers: {}, deviceTemperatureDrafts: {},
controlPlan: null, controlPlanRevision: null, controlPlanPushReady: false, controlPlanTimer: null, debugLines: [], debugBacklogLoaded: false, debugFilter: 'all', sensorAliases: {}, flowSharedInputs: [], flowSharedInputValueCache: {}, flowSharedInputValueRequests: {}, flowSharedInputValueTimer: null, controlPlan: null, controlPlanRevision: null, controlPlanPushReady: false, controlPlanTimer: null, debugLines: [], debugBacklogLoaded: false, debugFilter: 'all', sensorAliases: {}, flowSharedInputs: [], flowSharedInputValueCache: {}, flowSharedInputValueRequests: {}, flowSharedInputValueTimer: null,
haManualFallbackVisible: false, haSupervisorTestState: null, haEntityCatalog: [], haEntityCatalogConfigured: false, haEntityCatalogLoadedAt: 0, haEntityCatalogLoading: false, haManualFallbackVisible: false, haSupervisorTestState: null, haEntityCatalog: [], haEntityCatalogConfigured: false, haEntityCatalogLoadedAt: 0, haEntityCatalogLoading: false,
@@ -97,9 +97,9 @@ function hydrateUiIcons(root = document) {
node.innerHTML = uiIcon(name, className); node.innerHTML = uiIcon(name, className);
}); });
} }
const locale = () => app.locales[app.language] || app.locales[DEFAULT_LANGUAGE] || 'en-GB'; const locale = () => app.locales[app.language] || app.locales[app.defaultLanguage] || app.language || app.defaultLanguage || DEFAULT_LANGUAGE;
const tr = (key, params = {}) => { const tr = (key, params = {}) => {
const template = app.translations[app.language]?.[key] ?? app.translations[DEFAULT_LANGUAGE]?.[key] ?? key; const template = app.translations[app.language]?.[key] ?? app.translations[app.defaultLanguage]?.[key] ?? key;
return String(template).replace(/\{([a-zA-Z0-9_]+)\}/g, (_, name) => params[name] ?? `{${name}}`); return String(template).replace(/\{([a-zA-Z0-9_]+)\}/g, (_, name) => params[name] ?? `{${name}}`);
}; };
const fmtTemp = value => value !== null && value !== undefined && value !== '' && Number.isFinite(Number(value)) ? `${Number(value).toFixed(1)}°C` : '--'; const fmtTemp = value => value !== null && value !== undefined && value !== '' && Number.isFinite(Number(value)) ? `${Number(value).toFixed(1)}°C` : '--';
@@ -275,7 +275,7 @@ function updateConnectionIndicator(status) {
function updateToolbarControls() { function updateToolbarControls() {
const languageLabel = $('#languageLabel'); const languageLabel = $('#languageLabel');
if (languageLabel) languageLabel.textContent = String(app.language || DEFAULT_LANGUAGE).toUpperCase(); if (languageLabel) languageLabel.textContent = String(app.language || app.defaultLanguage || DEFAULT_LANGUAGE).toUpperCase();
const themeSelect = $('#themeSelect'); const themeSelect = $('#themeSelect');
const themeIcon = $('#themeIcon'); const themeIcon = $('#themeIcon');
const iconName = { system: 'system', light: 'sun', dark: 'moon' }[app.theme] || 'system'; const iconName = { system: 'system', light: 'sun', dark: 'moon' }[app.theme] || 'system';
@@ -328,7 +328,7 @@ async function loadLanguagePack(language) {
async function setLanguage(language) { async function setLanguage(language) {
const available = app.languages.some(item => item.code === language); const available = app.languages.some(item => item.code === language);
const nextLanguage = available ? language : DEFAULT_LANGUAGE; const nextLanguage = available ? language : (app.defaultLanguage || app.languages[0]?.code || DEFAULT_LANGUAGE);
const previousLanguage = app.language; const previousLanguage = app.language;
const select = $('#languageSelect'); const select = $('#languageSelect');
if (nextLanguage === previousLanguage && app.translations[nextLanguage]) return; if (nextLanguage === previousLanguage && app.translations[nextLanguage]) return;
@@ -364,29 +364,33 @@ async function loadLanguages() {
if (!response.ok) throw new Error(`Language index HTTP ${response.status}`); if (!response.ok) throw new Error(`Language index HTTP ${response.status}`);
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.length) throw new Error('Language manifest is empty');
app.languages = languages; app.languages = languages;
app.defaultLanguage = app.languages.some(item => item.code === manifest.default)
? manifest.default
: app.languages[0].code;
app.translations = {}; app.translations = {};
app.locales = Object.fromEntries(languages.map(item => [item.code, item.locale || item.code])); app.locales = Object.fromEntries(languages.map(item => [item.code, 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); : app.defaultLanguage;
if (!app.languages.some(item => item.code === app.language)) app.language = DEFAULT_LANGUAGE; if (!app.languages.some(item => item.code === app.language)) app.language = app.defaultLanguage;
try { try {
await loadLanguagePack(app.language); await loadLanguagePack(app.language);
} catch (error) { } catch (error) {
if (app.language === DEFAULT_LANGUAGE) throw error; if (app.language === app.defaultLanguage) throw error;
console.error(`Unable to load preferred language ${app.language}:`, error); console.error(`Unable to load preferred language ${app.language}:`, error);
app.language = DEFAULT_LANGUAGE; app.language = app.defaultLanguage;
await loadLanguagePack(DEFAULT_LANGUAGE); await loadLanguagePack(app.defaultLanguage);
} }
renderLanguageOptions(); renderLanguageOptions();
} catch (error) { } catch (error) {
console.error('Unable to load language packs:', error); console.error('Unable to load language packs:', error);
app.languages = [{ code: DEFAULT_LANGUAGE, name: 'English', native_name: 'English', locale: 'en-GB' }]; const fallbackLanguage = app.defaultLanguage || DEFAULT_LANGUAGE;
app.translations = { [DEFAULT_LANGUAGE]: {} }; app.languages = fallbackLanguage ? [{ code: fallbackLanguage, name: fallbackLanguage.toUpperCase(), native_name: fallbackLanguage.toUpperCase(), locale: fallbackLanguage }] : [];
app.locales = { [DEFAULT_LANGUAGE]: 'en-GB' }; app.translations = fallbackLanguage ? { [fallbackLanguage]: {} } : {};
app.language = DEFAULT_LANGUAGE; app.locales = fallbackLanguage ? { [fallbackLanguage]: fallbackLanguage } : {};
app.language = fallbackLanguage;
renderLanguageOptions(); renderLanguageOptions();
} }
} }
@@ -261,7 +261,7 @@ $('#cloudDiscoverButton')?.addEventListener('click', async event => {
finally { button.disabled = false; } finally { button.disabled = false; }
}); });
$('#historyRefresh').addEventListener('click', loadHistory); $('#historyRefresh').addEventListener('click', loadHistory);
$('#historyHours').addEventListener('change', () => { updateBrowserUrl(currentHistoryPath(), true); loadHistory(); }); $('#historyHours').addEventListener('change', () => { if (app.historyTab === 'energy') app.historyEnergyHours = $('#historyHours').value || '720'; updateBrowserUrl(currentHistoryPath(), true); loadHistory(); });
$('#logsRefresh').addEventListener('click', loadLogs); $('#logsRefresh').addEventListener('click', loadLogs);
$('#languageSelect').addEventListener('change', event => setLanguage(event.target.value)); $('#languageSelect').addEventListener('change', event => setLanguage(event.target.value));
$('#themeSelect')?.addEventListener('change', event => setTheme(event.target.value)); $('#themeSelect')?.addEventListener('change', event => setTheme(event.target.value));
+5 -5
View File
@@ -728,7 +728,7 @@ function markFlowPresetRecent(id) {
function flowPresetText(value) { function flowPresetText(value) {
if (typeof value === 'string') return value; if (typeof value === 'string') return value;
if (!value || typeof value !== 'object') return ''; if (!value || typeof value !== 'object') return '';
return value[app.language] || value.en || value.pl || Object.values(value).find(item => typeof item === 'string') || ''; return value[app.language] || value[app.defaultLanguage] || Object.values(value).find(item => typeof item === 'string') || '';
} }
async function loadFlowPresets({ force = false } = {}) { async function loadFlowPresets({ force = false } = {}) {
@@ -741,11 +741,11 @@ async function loadFlowPresets({ force = false } = {}) {
const entries = Array.isArray(manifest?.presets) ? manifest.presets : []; const entries = Array.isArray(manifest?.presets) ? manifest.presets : [];
const presets = await Promise.all(entries.map(async entry => { const presets = await Promise.all(entries.map(async entry => {
const filename = String(entry?.file || ''); const filename = String(entry?.file || '');
if (!filename || !/^[a-zA-Z0-9_-]+\.json$/.test(filename)) throw new Error('Invalid preset filename'); if (!filename || !/^[a-zA-Z0-9_-]+\.json$/.test(filename)) throw new Error(tr('flow.invalidPresetFilename'));
const response = await fetch(withBase(`/presets/${encodeURIComponent(filename)}`), { headers: { Accept: 'application/json' }, cache: 'no-store' }); const response = await fetch(withBase(`/presets/${encodeURIComponent(filename)}`), { headers: { Accept: 'application/json' }, cache: 'no-store' });
if (!response.ok) throw new Error(`${filename}: HTTP ${response.status}`); if (!response.ok) throw new Error(`${filename}: HTTP ${response.status}`);
const preset = await response.json(); const preset = await response.json();
if (!preset?.id || !preset?.flow || !Array.isArray(preset.flow.nodes) || !Array.isArray(preset.flow.edges)) throw new Error(`${filename}: invalid preset`); if (!preset?.id || !preset?.flow || !Array.isArray(preset.flow.nodes) || !Array.isArray(preset.flow.edges)) throw new Error(tr('flow.invalidPresetFile', { filename }));
return preset; return preset;
})); }));
app.flowPresets = presets; app.flowPresets = presets;
@@ -923,7 +923,7 @@ async function openFlowTemplates() {
renderFlowPresetBrowser(flowPresetActiveCategory); renderFlowPresetBrowser(flowPresetActiveCategory);
} catch (error) { } catch (error) {
if (tabs) tabs.innerHTML = ''; if (tabs) tabs.innerHTML = '';
host.innerHTML = `<div class="empty compact"><strong>${esc(tr('flow.templatesLoadFailed') || 'Unable to load presets')}</strong><span>${esc(error.message)}</span></div>`; host.innerHTML = `<div class="empty compact"><strong>${esc(tr('flow.templatesLoadFailed'))}</strong><span>${esc(error.message)}</span></div>`;
} }
} }
@@ -936,7 +936,7 @@ function previewFlowTemplate(key) {
function applyFlowTemplate(key) { function applyFlowTemplate(key) {
if (!app.flowDraft) return; if (!app.flowDraft) return;
const preset = (app.flowPresets || []).find(item => item.id === key); const preset = (app.flowPresets || []).find(item => item.id === key);
if (!preset) return toast(tr('flow.templatesLoadFailed') || 'Preset not found', true); if (!preset) return toast(tr('flow.presetNotFound'), true);
if (app.flowDraft.nodes.length && !confirm(tr('flow.templateReplaceConfirm'))) return; if (app.flowDraft.nodes.length && !confirm(tr('flow.templateReplaceConfirm'))) return;
const graph = materializeFlowPreset(preset); const graph = materializeFlowPreset(preset);
app.flowDraft.nodes = graph.nodes; app.flowDraft.edges = graph.edges; app.flowDraft.nodes = graph.nodes; app.flowDraft.edges = graph.edges;
@@ -392,7 +392,7 @@ async function handleHistoryAction(button) {
title: $('#customChartName')?.value.trim() || tr('history.customChart'), title: $('#customChartName')?.value.trim() || tr('history.customChart'),
series: [...app.customChartSeries], series: [...app.customChartSeries],
hours: Number($('#historyHours')?.value || 24), hours: Number($('#historyHours')?.value || 24),
lang: app.language === 'pl' ? 'pl' : 'en', lang: app.language,
}, },
}); });
const link = publicCustomChartUrl(share.path); const link = publicCustomChartUrl(share.path);
@@ -29,7 +29,10 @@ function showView(name, { push = true, scroll = true } = {}) {
} }
function showHistoryTab(tab, { push = true, load = true } = {}) { function showHistoryTab(tab, { push = true, load = true } = {}) {
app.historyTab = HISTORY_TABS.includes(tab) ? tab : 'overview'; const nextTab = HISTORY_TABS.includes(tab) ? tab : 'overview';
if (app.historyTab === 'energy' && $('#historyHours')) app.historyEnergyHours = $('#historyHours').value || app.historyEnergyHours || '720';
app.historyTab = nextTab;
if (nextTab === 'energy' && $('#historyHours')) $('#historyHours').value = app.historyEnergyHours || '720';
renderHistoryNavigation(); renderHistoryNavigation();
if (push) updateBrowserUrl(currentHistoryPath()); if (push) updateBrowserUrl(currentHistoryPath());
if (load) loadHistory(); if (load) loadHistory();
@@ -85,7 +85,13 @@ function applyRouteFromLocation() {
app.historyNetworkTarget = params.get('target') || 'all'; app.historyNetworkTarget = params.get('target') || 'all';
app.historySensor = params.get('sensor') || 'all'; app.historySensor = params.get('sensor') || 'all';
const hours = params.get('hours'); const hours = params.get('hours');
if (hours && ['6', '24', '168', '720', '2160', '8760'].includes(hours) && $('#historyHours')) $('#historyHours').value = hours; const validHours = hours && ['6', '24', '168', '720', '2160', '8760'].includes(hours) ? hours : null;
if (app.historyTab === 'energy') {
app.historyEnergyHours = validHours || app.historyEnergyHours || '720';
if ($('#historyHours')) $('#historyHours').value = app.historyEnergyHours;
} else if (validHours && $('#historyHours')) {
$('#historyHours').value = validHours;
}
if (app.historyTab === 'custom' && params.get('chart')) app.customChartSeries = decodeChartSpec(params.get('chart')); if (app.historyTab === 'custom' && params.get('chart')) app.customChartSeries = decodeChartSpec(params.get('chart'));
if (!showView('history', { push: false, scroll: false })) restoreCurrentRoute(previousView); if (!showView('history', { push: false, scroll: false })) restoreCurrentRoute(previousView);
return; return;
@@ -345,6 +345,38 @@ $('#haUseSupervisor')?.addEventListener('click', async event => {
} finally { button.disabled = false; button.textContent = idle; } } finally { button.disabled = false; button.textContent = idle; }
}); });
$('#testInfluxDbConnection')?.addEventListener('click', async event => {
const form = $('#settingsForm');
const button = event.currentTarget;
const resultBox = $('#influxDbTestResult');
if (!form) return;
const idle = button.textContent;
button.disabled = true;
button.textContent = tr('settings.influxTesting');
clearFormErrors(form);
if (resultBox) { resultBox.hidden = true; resultBox.classList.remove('success', 'error'); }
try {
const result = await api('/api/integrations/influxdb/test', { method: 'POST', body: influxDbSettingsBodyFromForm(form) });
const message = tr('settings.influxTestSuccess', { ms: result.response_time_ms ?? 0 });
if (resultBox) {
resultBox.hidden = false;
resultBox.classList.add('success');
resultBox.innerHTML = `<span>${esc(tr('status.connected'))}</span><strong>${esc(message)}</strong>`;
}
toast(message);
} catch (error) {
if (resultBox) {
resultBox.hidden = false;
resultBox.classList.add('error');
resultBox.innerHTML = `<span>${esc(tr('devices.lastError'))}</span><strong>${esc(error.message)}</strong>`;
}
toast(error.message, true);
} finally {
button.disabled = false;
button.textContent = idle;
}
});
$('#exportSettings').addEventListener('click', async () => { $('#exportSettings').addEventListener('click', async () => {
try { try {
const data = await api('/api/configuration/export'); const data = await api('/api/configuration/export');
@@ -509,7 +541,7 @@ $('#greeCloudTestButton')?.addEventListener('click', async event => {
if (resultBox) { if (resultBox) {
resultBox.hidden = false; resultBox.hidden = false;
resultBox.classList.add(result.ok ? 'success' : 'error'); resultBox.classList.add(result.ok ? 'success' : 'error');
const message = result.ok ? `Connected. ${Number(result.device_count || 0)} device(s) found.` : (result.message || result.status || 'Connection failed'); const message = result.ok ? tr('settings.cloudConnected', { count: Number(result.device_count || 0) }) : (result.message || result.status || tr('settings.cloudConnectionFailed'));
resultBox.innerHTML = `<span>${esc(result.ok ? tr('status.online') : tr('devices.lastError'))}</span><strong>${esc(message)}</strong>`; resultBox.innerHTML = `<span>${esc(result.ok ? tr('status.online') : tr('devices.lastError'))}</span><strong>${esc(message)}</strong>`;
} }
if (result.ok) { if (result.ok) {
+86 -35
View File
@@ -16,16 +16,55 @@ function basePathFromScript() {
const PUBLIC_BASE = basePathFromScript(); const PUBLIC_BASE = basePathFromScript();
const withBase = path => `${PUBLIC_BASE}${path.startsWith('/') ? path : `/${path}`}`; const withBase = path => `${PUBLIC_BASE}${path.startsWith('/') ? path : `/${path}`}`;
const PUBLIC_DEFAULT_LANGUAGE = String(document.documentElement.lang || '').trim();
const PUBLIC_RANGE_KEYS = ['history.6h', 'history.24h', 'history.7d', 'history.30d', 'history.90d', 'history.1y'];
let publicLanguageManifest = null;
let publicChartLanguage = PUBLIC_DEFAULT_LANGUAGE;
let publicChartTranslations = {};
let publicChartLocale = PUBLIC_DEFAULT_LANGUAGE;
function publicCookie(name) {
const row = document.cookie.split('; ').find(item => item.startsWith(`${name}=`));
return row ? decodeURIComponent(row.split('=').slice(1).join('=')) : '';
}
function publicTr(key, params = {}) {
const template = publicChartTranslations[key] ?? key;
return String(template).replace(/\{([a-zA-Z0-9_]+)\}/g, (_, name) => params[name] ?? `{${name}}`);
}
async function loadPublicLanguageManifest() {
if (publicLanguageManifest) return publicLanguageManifest;
const response = await fetch(withBase('/lang/index.json'), { headers: { Accept: 'application/json' }, cache: 'no-store' });
if (!response.ok) throw new Error(`language index HTTP ${response.status}`);
publicLanguageManifest = await response.json();
return publicLanguageManifest;
}
async function setPublicChartLanguage(language) {
const manifest = await loadPublicLanguageManifest();
const languages = Array.isArray(manifest.languages) ? manifest.languages : [];
const fallbackCode = manifest.default || PUBLIC_DEFAULT_LANGUAGE;
const item = languages.find(entry => entry.code === language)
|| languages.find(entry => entry.code === fallbackCode)
|| languages[0];
if (!item) throw new Error('language pack unavailable');
const response = await fetch(withBase(item.path || `/lang/${encodeURIComponent(item.code)}.json`), { headers: { Accept: 'application/json' } });
if (!response.ok) throw new Error(`language ${item.code} HTTP ${response.status}`);
const pack = await response.json();
publicChartLanguage = item.code;
publicChartTranslations = pack.translations || {};
publicChartLocale = pack.meta?.locale || item.locale || item.code;
document.documentElement.lang = item.code;
document.title = `GREE Controller · ${publicTr('publicChart.title')}`;
setPublicRangeLanguage();
}
function cssColor(name, fallback) { function cssColor(name, fallback) {
const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim(); const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
return value || fallback; return value || fallback;
} }
function localeFor(lang) {
return lang === 'pl' ? 'pl-PL' : 'en-GB';
}
function timeLabel(timestamp, hours, locale) { function timeLabel(timestamp, hours, locale) {
const options = Number(hours) > 48 const options = Number(hours) > 48
? { day: '2-digit', month: '2-digit', hour: '2-digit' } ? { day: '2-digit', month: '2-digit', hour: '2-digit' }
@@ -142,7 +181,7 @@ function drawChart(canvas, payload, locale) {
renderLegend(series); renderLegend(series);
const allPoints = series.flatMap(item => item.points.map(point => ({ ...point, value: Number(point.value) }))) const allPoints = series.flatMap(item => item.points.map(point => ({ ...point, value: Number(point.value) })))
.filter(point => Number.isFinite(point.value) && Number.isFinite(new Date(point.timestamp).getTime())); .filter(point => Number.isFinite(point.value) && Number.isFinite(new Date(point.timestamp).getTime()));
if (!allPoints.length) return drawEmpty(canvas, payload.no_data_label || 'No data'); if (!allPoints.length) return drawEmpty(canvas, payload.no_data_label || publicTr('publicChart.noData'));
const { ctx, width, height } = prepareCanvas(canvas); const { ctx, width, height } = prepareCanvas(canvas);
const text = cssColor('--muted', '#888'); const text = cssColor('--muted', '#888');
@@ -196,7 +235,6 @@ function drawChart(canvas, payload, locale) {
} }
let publicChartPayload = null; let publicChartPayload = null;
let publicChartLocale = 'en-GB';
let publicChartResizeFrame = 0; let publicChartResizeFrame = 0;
function publicChartToken() { function publicChartToken() {
@@ -208,17 +246,16 @@ function requestedPublicChartHours() {
return Number.isInteger(value) && value >= 1 && value <= 87600 ? value : null; return Number.isInteger(value) && value >= 1 && value <= 87600 ? value : null;
} }
function setPublicRangeLanguage(lang) { function setPublicRangeLanguage() {
const pl = lang === 'pl';
const label = $('#publicChartRangeLabel'); const label = $('#publicChartRangeLabel');
const select = $('#publicChartHours'); const select = $('#publicChartHours');
if (label) label.textContent = pl ? 'Zakres' : 'Range'; if (label) label.textContent = publicTr('history.range');
if (!select) return; if (!select) return;
const labels = pl [...select.options].forEach((option, index) => {
? ['6 godzin', '24 godziny', '7 dni', '30 dni', '90 dni', '1 rok'] const key = PUBLIC_RANGE_KEYS[index];
: ['6 hours', '24 hours', '7 days', '30 days', '90 days', '1 year']; if (key) option.textContent = publicTr(key);
[...select.options].forEach((option, index) => { if (labels[index]) option.textContent = labels[index]; }); });
select.setAttribute('aria-label', pl ? 'Zakres wykresu' : 'Chart range'); select.setAttribute('aria-label', publicTr('publicChart.rangeAria'));
} }
function syncPublicRangeControl(hours) { function syncPublicRangeControl(hours) {
@@ -228,7 +265,7 @@ function syncPublicRangeControl(hours) {
if (![...select.options].some(option => option.value === value)) { if (![...select.options].some(option => option.value === value)) {
const option = document.createElement('option'); const option = document.createElement('option');
option.value = value; option.value = value;
option.textContent = `${value} h`; option.textContent = publicTr('publicChart.hoursShort', { hours: value });
select.append(option); select.append(option);
} }
select.value = value; select.value = value;
@@ -255,15 +292,16 @@ async function loadPublicChart(hours = requestedPublicChartHours()) {
const range = $('#publicChartHours'); const range = $('#publicChartHours');
if (!token.startsWith('chart_')) { if (!token.startsWith('chart_')) {
title.textContent = 'Custom chart'; title.textContent = publicTr('publicChart.title');
hint.textContent = ''; hint.textContent = '';
error.hidden = false; error.hidden = false;
error.textContent = 'Invalid chart link.'; error.textContent = publicTr('publicChart.invalidLink');
drawEmpty(canvas, error.textContent); drawEmpty(canvas, error.textContent);
return; return;
} }
if (range) range.disabled = true; if (range) range.disabled = true;
if (hint) hint.textContent = publicTr('publicChart.loading');
error.hidden = true; error.hidden = true;
const response = await fetch(withBase(publicChartApiPath(token, hours)), { headers: { Accept: 'application/json' }, cache: 'no-store' }); const response = await fetch(withBase(publicChartApiPath(token, hours)), { headers: { Accept: 'application/json' }, cache: 'no-store' });
if (!response.ok) { if (!response.ok) {
@@ -272,17 +310,17 @@ async function loadPublicChart(hours = requestedPublicChartHours()) {
throw new Error(message); throw new Error(message);
} }
const payload = await response.json(); const payload = await response.json();
const lang = payload.lang === 'pl' ? 'pl' : 'en'; await setPublicChartLanguage(payload.lang || publicChartLanguage);
const locale = localeFor(lang); title.textContent = payload.title || publicTr('publicChart.title');
document.documentElement.lang = payload.lang || lang; hint.textContent = payload.hint || publicTr('publicChart.periodHint', { hours: payload.hours });
title.textContent = payload.title || (lang === 'pl' ? 'Wykres niestandardowy' : 'Custom chart');
hint.textContent = payload.hint || '';
canvas.setAttribute('aria-label', title.textContent); canvas.setAttribute('aria-label', title.textContent);
setPublicRangeLanguage(lang);
syncPublicRangeControl(payload.hours); syncPublicRangeControl(payload.hours);
publicChartPayload = payload; const localizedSeries = (payload.series || []).map(item => ({
publicChartLocale = locale; ...item,
drawChart(canvas, payload, locale); label: item.label_key ? `${item.label} · ${publicTr(item.label_key)}` : item.label,
}));
publicChartPayload = { ...payload, series: localizedSeries, no_data_label: payload.no_data_label || publicTr('publicChart.noData') };
drawChart(canvas, publicChartPayload, publicChartLocale);
if (range) range.disabled = false; if (range) range.disabled = false;
} }
@@ -297,7 +335,7 @@ $('#publicChartHours')?.addEventListener('change', async event => {
} catch (error) { } catch (error) {
const host = $('#publicChartError'); const host = $('#publicChartError');
host.hidden = false; host.hidden = false;
host.textContent = `${document.documentElement.lang === 'pl' ? 'Nie udało się wczytać wykresu' : 'Unable to load chart'}: ${error.message}`; host.textContent = publicTr('publicChart.loadFailed', { error: error.message });
event.currentTarget.disabled = false; event.currentTarget.disabled = false;
} }
}); });
@@ -307,11 +345,24 @@ window.addEventListener('resize', () => {
publicChartResizeFrame = requestAnimationFrame(redrawPublicChart); publicChartResizeFrame = requestAnimationFrame(redrawPublicChart);
}); });
loadPublicChart().catch(error => { async function initPublicChart() {
const host = $('#publicChartError'); try {
host.hidden = false; await setPublicChartLanguage(publicCookie('gree_controller_language') || PUBLIC_DEFAULT_LANGUAGE);
host.textContent = `Unable to load chart: ${error.message}`; } catch (error) {
$('#publicChartHint').textContent = ''; console.error('Public chart language initialization failed:', error);
$('#publicChartHours').disabled = false; }
drawEmpty($('#publicCustomChart'), host.textContent); try {
}); await loadPublicChart();
} catch (error) {
const host = $('#publicChartError');
host.hidden = false;
host.textContent = publicChartTranslations['publicChart.loadFailed']
? publicTr('publicChart.loadFailed', { error: error.message })
: error.message;
$('#publicChartHint').textContent = '';
$('#publicChartHours').disabled = false;
drawEmpty($('#publicCustomChart'), host.textContent);
}
}
initPublicChart();
+2 -2
View File
@@ -1,7 +1,7 @@
'use strict'; 'use strict';
(() => { (() => {
const row = document.cookie.split('; ').find(item => item.startsWith('gree_controller_language=')); const row = document.cookie.split('; ').find(item => item.startsWith('gree_controller_language='));
const language = row ? decodeURIComponent(row.split('=').slice(1).join('=')) : 'en'; const language = row ? decodeURIComponent(row.split('=').slice(1).join('=')) : document.documentElement.lang;
document.documentElement.lang = language || 'en'; if (language) document.documentElement.lang = language;
document.documentElement.classList.add('i18n-loading'); document.documentElement.classList.add('i18n-loading');
})(); })();
View File