diff --git a/Cargo.lock b/Cargo.lock index 1bf5040..aaa0e67 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -700,7 +700,7 @@ dependencies = [ [[package]] name = "gree-controller" -version = "0.14.20" +version = "0.14.21" dependencies = [ "aes", "aes-gcm", diff --git a/Cargo.toml b/Cargo.toml index dce7779..08b746a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "gree-controller" -version = "0.14.20" +version = "0.14.21" edition = "2021" authors = ["GREE Controller contributors"] description = "Standalone local GREE HVAC controller with Web UI, SQLite and Home Assistant sensor support" diff --git a/FILE_MANIFEST.sha256 b/FILE_MANIFEST.sha256 index a49638b..9becc35 100644 --- a/FILE_MANIFEST.sha256 +++ b/FILE_MANIFEST.sha256 @@ -1,17 +1,16 @@ -8c09c8a2203e7b42ba62cbf7a354e57cb70941a5ecc77949368770e65fe9c8f7 ./.dockerignore de0793949cf01d27d903653226ecc9b5f72523d8711f20bcab022cf42c4c4ed0 ./.env.example a4ec3874a2e3ab1bad28fb40bb620f7b01f64d01ad9b699306bf70ada31227db ./.gitignore -1cef28b62ca6f6a01736fc67d9639c909274b2a244c9276ebaacb51ffc662a3a ./Cargo.lock -6e563ec8c215326660c9e63ec7f079e5b892cfb58abd72cf55ac127f02ae3938 ./Cargo.toml +10eec62654d9740fe5c6cd51620d608b2aa98dac0e6417bb30f77bb660b9d046 ./Cargo.lock +adff4bf5593ca6558be86fb0cc04a3af0d7b4140a57d07100f6ebb31e092dc59 ./Cargo.toml 19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE -046205c6a991bca9b514e507a35cebb875d3152d09a7cb02dfb69b5ac16eb640 ./README.md -6d6933c5acb76e933dc2a83e24bad0d737c126d88293fadb6c7ee5efaf07ee86 ./SOURCE_MANIFEST.sha256 -5302570299af2c2650bada12e123f598c5db8009949c75761385f73ed57343c5 ./build.rs -5a673f26300c50aa393690332aed3ff6c8266a119d6a24dd4908ca6b19aa12a0 ./docs/API.md -f21d732eb1147f8f3167d2172cdf0b57e61edabdfecbaf97811d46c5bff7e47a ./docs/FLOW.md -8350a8466a1a9fec13cfc925ed8ecc3f441e4408c091aef2b820b53e1fa0e408 ./docs/openapi.json +ae3d4b2c7d90282541aca94934ed713087fb7d6451ef78302cd43ca07b794d59 ./README.md +1fe7189e322c49765f3076bb2b5661a461aa0bd0e8280c24066fe545cce757e3 ./SOURCE_MANIFEST.sha256 +29e9821e5ed216ea3565dd6d8bc0b174ed3cf9fa72c4ccebbfa99e98db3ad8f9 ./build.rs +b8c5b2a9da89b6e6c6e20b1eb20f5810f9fee52b3d141ca19a539ecd2397861c ./docs/API.md +8b548d02dbdf0563f2be617a1d677459bd1959bed93a1799a01bf61bb5aed405 ./docs/FLOW.md +47817a9f7398012c6b374d090c8671e6927bb86cd042df40bf5f2f56ccea14d1 ./docs/openapi.json c65aa543d2e99f8f7d52736307b07ebd182597d80124661d53a16bb23f6f1118 ./ha-addon/.env.example -0972874605e1956aece2672ec6d921c871e5564aa3e290f41f406e38f84ae13f ./ha-addon/Dockerfile +0993076ca45b6d31859680835204cdc111b3f9bdbf460d50b4efec56cd5e685b ./ha-addon/Dockerfile adf7ff248d782a3b1cba82b8789c57b6849ddaaaf5fbc108af75e18b1534a885 ./ha-addon/README.md dd9291b12afade6fc20d66e80a5513251270e18e4c265260b2bbbe393a5a94c4 ./ha-addon/build.sh 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 5a96fe8f5c035c34f1339370270cd078056202d09e236dec75735be11de92a7d ./ha-addon/home-assistant/custom_components/gree_controller/entity_map.py 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 39c4309001b75abb56234f05662bc06e077054986876f1927937edbce528ec95 ./ha-addon/home-assistant/custom_components/gree_controller/select.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 13f30e2dcdcedbd1b6c3f99c2335e0487108fd72c8e86922368b84f2fa2038ae ./ha-addon/home-assistant/custom_components/gree_controller/translations/pl.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 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 0d0b42a7639b128946eec379175436fa1f150294f39d2dfd169077bfa87fd14d ./ha-addon/repository/gree-controller/topology-ha-vlan-gree-en.png 2272740b185e9520e948f74b8f45cf98b8b5fd28c93bdee760da626f91fe5c2a ./ha-addon/repository/gree-controller/translations/en.yaml @@ -44,8 +43,8 @@ cea12bc74c066a588a5ef0ef7bad9d4906bdbcea99f2d275429b0eefc94b4b5f ./ha-addon/run 081e27b6e43070d44f865335c30a42a5a29668eaa6a5ea5ab6967ac4447b8e06 ./ha-addon/sync-repository.sh f9e31de3d109ead32bf531c074d46a087df398d62488caee0b46b5e0a1c027f7 ./ha_addon.md 253a0bc912786e67ea7fc92a64e4a510ad973bec343a88ccfb1f28fca3e8cf01 ./lang/README.md -9477f8e45e8471867acf9af2b1554c9dc9490808c11507e4ce9cb9101c0ce2df ./lang/en.json -1270413ebdc2ace15ba62f365484207299f2de480bbb87d2b42959dc9ce6fd34 ./lang/pl.json +3f7ffc2b9492593297668cf52307ad12a4a763dde3d0f8341fdb4b7298b40748 ./lang/en.json +23bfb017e2530ab8fa6c62908211016e802e990a75944a9914681ded6e2079d6 ./lang/pl.json d8459024f04ca514bd8e9d6bd3af872fb942fd85a7cdd5583f4a9d28aab6faba ./make_zip.py b14233a8987e53bbbdd6770386ba10fa166ec25c1e098275c173c544b37846fc ./presets/bedroom_window_night.json 1960841119c0b673fb2f03621b32237552fd828045304cd221401214b997c910 ./presets/device_resilience.json @@ -93,104 +92,104 @@ c64b1c6deeb24f20af662bfce7fa3683703914987241107ce664fa2f6d1a9dcb ./scripts/READ 3fe88e64e43d380c56955f577992ea5b0252c05280f6e9dc3cd0bbcc0bf00896 ./scripts/generate_ha_migration.py e4849261fd9ed1f01df96c0637c439c0c4eff8fa317b2918026167bba343af79 ./scripts/install-lxc.sh bb7cd2c5b27c9dceec1d1d2846fbad9600df9c08e0ad07d13fcde97af533091c ./scripts/install.sh -1a84c91a89bf7f60aa4816a7650640f315efadf39bf7b54dc20f1259b2e2d5ce ./scripts/live_realtime_test.js +50cc08e2aeff052a47c68212524f4584e9201cfc535d71803f276cdc223d5216 ./scripts/live_realtime_test.js e00d211e3885e30d7fed1e43b44e6fdad40a67019060156c0641816a93e3365f ./scripts/network-debug.sh 01952aa92b217f8eae2493b88870e2dec595100cd15c4d561ff11ae2b936c46f ./scripts/regenerate-sha.sh 81345b6a0b51736bdbc98fd23199b62e4c721b4e7437e02dab7ea79b97dff29a ./scripts/service.sh bd703ef841f9763f13367aaa5764381b9c59ee87210e806c24617f3cc0739a9b ./scripts/smoke.sh b50782b3742dfbf8a319c60571c968e93fdf8547db747c759edcffae68cb98bf ./scripts/update.sh -a70ddd9d4c9fca073ff494db73ec13acc7cff1830c9675d0541518d814da3772 ./src/api.rs -f6ed359370dacd691344402307843a0ad31bbeb46085f99e81cfa46a618ee87d ./src/api/assets.rs +bd7e0455c6a1501f761f0d4c00ff74833eecbd75078af9168235f90a2d9485b3 ./src/api.rs +0db0d81da4b8161004ddcbed2096812c285ecec9d944771670e6c8edf0775175 ./src/api/assets.rs b24092f34a46626d64628b510eba82193f0b203fd097f6a621b91af5d91eb571 ./src/api/auth.rs 9e4fc675306e111ed3db7af9822e2925d88109c31b9e309851665a975c0bd83c ./src/api/automations.rs -af078ccc0bfcf0c7f2a4a8c1e7ee2f21dc1966ac209d17f17de12bb4d5276244 ./src/api/configuration.rs +a4222a29cdc90e75dffd5fb5e35c11e3556924a6489b1b31272cd81d0b141821 ./src/api/configuration.rs 19bc141286a336ad67e99079496eb0ae5126f986ab28be62b5f97758e5f6969c ./src/api/debug_tokens.rs -32ade46ec45d6faf7852242b9f5ba1be6eacbacbeb33d2deabfab59bf1610ce0 ./src/api/device_groups.rs -fcbb3da0538aab451f71810f829cc248d6bb67e586dc418d71a572980583823d ./src/api/devices.rs +ca21f6d5a407e2e3532899d53ba4e8682c2fe961a46d8cd870ddc459721513ef ./src/api/device_groups.rs +2d487e274c777cbde081c5803311bec449097f45bd2d021117cfe10677b39efb ./src/api/devices.rs 4b5b784670ae761a6848b9a33fe3fb391bebd39d65e87936907428711e96ca44 ./src/api/events.rs d59b080b392b574ec94276c7b485b455d0d1abacc7de2ef29dcbfcf7d86dbdb0 ./src/api/flows.rs -2cfee7029f065e81628a8942e3dad00a03c3a5cf4f8492d07f12b75d05c07359 ./src/api/gree_cloud.rs +a17ab0b6d683d285441be15f2af33172d7caa9a57ec8b32e59107379631a377e ./src/api/gree_cloud.rs b61b0b4f196c5e72e47cf06ca0c14df3cf8eedbdef0242a9d2af33623663489f ./src/api/groups.rs -92de677265216adb3154f6558684aec44cebff6bf6c57044f37d20f7d5a4a10f ./src/api/history.rs +eb88001591ef26601391198b8b87f08cf445fbdd855faa152e33c5554e23f6b7 ./src/api/history.rs e8edfbb725d6de650442444b0a929ad2be0bd2c2d7d227d2b3fdd99d05c94f94 ./src/api/house.rs -c71540b2b68e805e3a79ac787db9127b87585968a205fc307e93bf68d5b028e0 ./src/api/integrations.rs +8495967c3df72110f591c744975984c78cafc8181b2d430e7fd5b9baf57eba95 ./src/api/integrations.rs 7658111f7d125db55a6e23f88157339b110b2cd1ed13ceabd8f860b42dba5101 ./src/api/middleware.rs 45e34a447ff384d99a3c42c489ca9fa75849c74d4d246caa91c63d927de17396 ./src/api/openapi.rs b777e7fbeccef699e11ffd16bb18633843c99afecacce2221280e54b09f6d2da ./src/api/public_settings.rs 5b29f2e5aa2c422c5829ef89c7b59cf753883aa7f60661c95ab0f95cf676fa3f ./src/api/schedules.rs -6acf6417b077a0204a815e255c667928476014ce5f94c1559c9d4268c00342d5 ./src/api/settings.rs -0de92f241b5da2b7f13aaf9c24ef76eba8d26f8e27d87c9312bd644ab13d31bd ./src/api/system.rs +fd08162d0f9287700196028ad0ca2aec7c242238056b0a228ca5bdcb319ece4e ./src/api/settings.rs +bb57f4b28df75ba350c9706719650fff3a689afee0cb6979c1fcae79396ac0cf ./src/api/system.rs 94d3845def693aafe15fa6e8b390881c46c3c236b62f247ddfdc055d9226ed22 ./src/api/websocket.rs ddd1bfc8625b66f6e5f3d24b3122ac86c25f89b0af527e3dfce9102551a8f142 ./src/api/zones.rs -7b15febe046c79917a22664f50d9063fd6d0968521544ef31b854424f73b4408 ./src/config.rs -f8394e0daca284070ac9ae0844d6b04fed7c30386f2d1ec409ecf07880bb0e6a ./src/db.rs -fdc9d019097becc5c36a0560caa1619aa35d802c0d208902b3bfecbe1643e3e8 ./src/db/climate.rs -e5d2027d40a99e61c2c572499af617a92de9e554f8916f3ea6f6731d1d6b09b4 ./src/db/configuration.rs -46213497ae26b2f799364a2e64e41df26c93a39eb4e7167b59c1e42b525f61f7 ./src/db/core_devices.rs +0b7d2ace2e8cb1dd0cb59aa6ca3e80cec14078e93530fd7b58081798585cd833 ./src/config.rs +de0ce7807aebe4c0e325fbef01855e7fc1cfd117529f53f04a416fda3997673a ./src/db.rs +87f1614a80addde69b619421187526d7f86f893c0ac18998854f11675d6e7cca ./src/db/climate.rs +a8fe3636ac79df191e2f2c9d8c1eec1e5181e048ef8c9036cfa18f84aef1a462 ./src/db/configuration.rs +5143803ab470f287a587171f1aab96f60653e84cade523cfe104220079727ffc ./src/db/core_devices.rs 4c20289c08b0cb9c3c4324d9dbc6a47c7dd953aff6fd1746f04b6024043d3bc5 ./src/db/device_history.rs -f99de2230082f17eafaf03f5361bb67419205a23d64f22480c996f9c32c55ac5 ./src/db/energy_history.rs -7e14cf46e6750e913f2120ccb0a75ad6884194ba973bf85fff9c05ecd919b5f0 ./src/db/events_tokens.rs +7bc79d9d33726fbdd33b485c8c2e839cedbb8fe29fee872611da978ed149d6a0 ./src/db/energy_history.rs +96aac6510ddda8c18fd7e29802a937b96e8a74badf504980846c80cd7933a4c6 ./src/db/events_tokens.rs bb5048ee60053f39ce3c1203dc1f24c7047310538d2357f105a052c016d21ec4 ./src/db/flows.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 -8b312e258f079d844466ad516134cdd065a017be78be41214b4239fc0f9e5b36 ./src/db/tests.rs +28ee42d08147b0f154af818065d7d014d74ee8ddd59fdef98115f5c5368056cc ./src/db/tests.rs f90fbb14f29a20b799b3856bdaa22812a9f0d9dfa46693f1341c160dccda8a97 ./src/db/zone_history.rs -4ed54df6321496a6afabf9443beb9fa8d3434c510aa01dade318bbaa44f0f0ac ./src/engine.rs +75f4f139cb268b2dfda73966b52624735380724e73b99930bffe36328620f111 ./src/engine.rs ed5c9a0c61c6e597acb33f58593ebe02b562ddc68fa61271e1c1cd436c1003d3 ./src/engine/automations.rs -0bf7fe7d28b4749e53ec25d71effbb78bc8f70c716714f838a4a2d8f20627d63 ./src/engine/commands.rs -f7de7b9b756bef9d6cffbc6ffdb7504b3121476f188e6ff5c8077d5fc54be811 ./src/engine/connectivity.rs -925135985f640ba9ae0a6109d908187b659341a91451e91d27e533adfd610b76 ./src/engine/control_plan.rs +1dccb8779f87dfdbd4ccda3f02ec51273e7be2e34e85166861668685528eca79 ./src/engine/commands.rs +357ef92c3cc42b243af4da31ac52bb360d7444ae423f58d7a13bdee79bf0a748 ./src/engine/connectivity.rs +ad53cd7be24deb759e03cbc6472522b69ebd36d8fac35398a9b19c899d3b17f2 ./src/engine/control_plan.rs 7b48a33343d519a2ff2d81e9d757eb56a0c1beacfc0b7b8ef61f49c2ad363f9a ./src/engine/deadlines.rs -f5e3a8e85c622ea623360bdac87a098785748d8e4f4ba19ec2aa18c279b61c03 ./src/engine/energy.rs +a3282d1e22fbdc89bed1d9e681e4865c9d8778f97805083c709fc62090a00d27 ./src/engine/energy.rs e350aa63f3bb14ca887eaaa7555edbeae00fb584cbcaf552cc972f0c8918f5d8 ./src/engine/groups.rs -5d8b3c9f2243848502bba5c1e4940f8bf8c5ae4bffd891b43e2e9b4e7dc55b0c ./src/engine/history.rs +4ec11426ef860811348f2bf1b458bc5713fe8a7794fb4c8045dd637652e8e696 ./src/engine/history.rs 41b7045877771b7c352328998cb45ce2433e7f9631fad3eb1a882ab4897dc31f ./src/engine/local_thermostat.rs -99527adab9fea1caede9ebb24faa6a63d5cef4810c64c06306b571cf5cbbb0da ./src/engine/ownership.rs -1a29b7fd8de77f0a5e5964ca05e41cc5ea78855c1ab961750d40461faec305e5 ./src/engine/polling.rs -d48ae1799e045fb212e73e630a5d26ed3c5e3359a3dac3ebd5638532efdd4285 ./src/engine/runtime.rs -c1e8de5392681ba1255e3e2d871a6ee860fb8ca987a0a8f973d9ded3977a68fb ./src/engine/schedules.rs +f684e6755d023478882d1689833f307f3b4cd2c66abf45f8af8315cee94d36ef ./src/engine/ownership.rs +56d64614d8ca85cec7098b1f5d2963ecdcbfb7d0a3d4f22e4424a653895b5a16 ./src/engine/polling.rs +c73691c3637465ab0f79aecf7f5428d0317799a8ab5329ed564cf57963bbe86e ./src/engine/runtime.rs +48a496c3935f886a90b90461c38a7aa31ebc40c6482d6feaeb2be47bc2387625 ./src/engine/schedules.rs b0189f28d0f337dfd3ce0934da7a565e4a5535afc1c242214c26ed584b3b7840 ./src/engine/targets.rs 682c8b3956c657b655746cbd19ee97ee2f4cf953bc9de67d6d70a1b14f3dd746 ./src/engine/temperature.rs 833b72907375e75c3f42b3921ad70a8e298f5a93743bf0d0c19059aa272462d1 ./src/engine/temporary_thermostat.rs -3f77350e4c54e8e497d9da8ba0545124f5a8810908c9a1471d1eaa63c4441b0c ./src/engine/tests.rs +2e7349862beb5f844aea0f1c4cd96ab6e53d3500559d7b2562bd93da595d7c41 ./src/engine/tests.rs c89ff36c6a67bf4fbf68c43e18aa0a556de2dc69263fd79db376363afc741a4f ./src/engine/zone_actions.rs -3a7fada1f6dd9ebab4026b353f41b21d8ca19342b02538c711dbb2dfb599cbe7 ./src/engine/zone_control.rs +fb169bcc20f95abe9892367df6dd3a683584bdbc721792d6ffb9311b81ca30d9 ./src/engine/zone_control.rs bfac95cec87f0df6ce562153cd3cfe6731359d703e6c592d2c2f7ba01ea62d99 ./src/error.rs -a1bd54026f9c7e9f84da1a730136db81095e8ba1ceeb2596fc01479def76df83 ./src/home_assistant.rs -e78ca9aff23362971eb6450534ea21bdc15150456c378ef9b226cb93a8dbc612 ./src/influxdb.rs +c55d32f288fe95869fc288d47817b8e289fbe284be4219e81b1e7493ff5e3a57 ./src/home_assistant.rs +bcd14e4d1af3ca580f3b95bcd86164c40f762511e1b8e2501cb8a598a6d8adf4 ./src/influxdb.rs da4068295f37c23222bb13bebac022d88ba7729604a49ad25438c55e45458a49 ./src/influxdb/codec.rs -b96624fa34b218a6d5f028ba09579633f6f8a04c109f0e94801e8d89ceeeb4b5 ./src/influxdb/query.rs -8089b8eca47419bb6b3ee02560e5cc39c00479164bc76d11992679b2cef3258e ./src/influxdb/write.rs -6479dd1e5d7f8eb2ffb945ca958124107c5585c0abdfb34555367db0c21145da ./src/main.rs +2d36405912646b30b1a1a37a7a4e18c2104ab22e865b593316a609dda884aec2 ./src/influxdb/query.rs +67e42a92295480d2f1d364958f184654cfb5d4b77e7e1df47f588bdec18868a0 ./src/influxdb/write.rs +1982ce54737a8780d1404b105d53db59cd5bee3d1c426f9a881d26cef7059bf5 ./src/main.rs 23688a5cb68efded89471ce959328d27adff370d451bd9f378a283ab51437e76 ./src/models.rs 920e028d96a14cca9f74a964bb296e68a109cfead314d3164c180201e606d151 ./src/models/automation.rs fc5f2802a073a175a8985e56bcb87eb1ff481226d5b4eeabad73dbba1839fc45 ./src/models/control_plan.rs 06357b352ff6494d83e72796b869573a8b8f36b835985a5900ac571c922b8da9 ./src/models/defaults.rs -5e212a072bf74ef32a186b172404844b02a92363d216167f814056e1f2a9a7a8 ./src/models/device.rs +f3439a154fad8a0111db26918160901dd04fe47dd56d529490e75deaf0a78b43 ./src/models/device.rs aeab5b5acf35d38a54fd1d82e964fe56635ade69c0149ad423df317095d01555 ./src/models/flow.rs -ba5d359e4d6c12d9f62c1b28bdfb40d312737930df5421814f5310f7183e96c1 ./src/models/history.rs +6e44df6bde2f73c282095c80ac17287e5bef77d9fc1d344e440d863dae5f5031 ./src/models/history.rs 1fee89f939018bf1f3686c15f25434f01ab96b3239164108db7f348a1f1f8d82 ./src/models/integrations.rs f1359b68f1d041cd441f80429e77893eeb6f6194613b58fd3e7a212fb511db3e ./src/models/runtime.rs -e3dfdd6dba63f6febd0b64da413b2fe7ca258a54abc2fdd6f2982c27425abfd6 ./src/models/settings_api.rs +41b85cff6fd234c8d6ea007b2c3c22df5fee5107dcd57c64dcc94a356ff70c12 ./src/models/settings_api.rs c4de6101de85d9d7e6814b2504b87d9216561e3d9e274054f081f505fb152140 ./src/models/temporary_thermostat.rs bc2818ce8d0dae1edfb1b98e5d1c00b193f03eeaa28e343d3532f6023288738c ./src/models/zone.rs -62e696df8b86835df7ad127f03c257a78580769dc82bc66daa5041f71e8440a5 ./src/notifications.rs +4c1f676eaf0b6da45b4d4b090ae3ac862fb97c1f9e53284a1640767ae9591d8f ./src/notifications.rs cca742bb2b500eb9269f69385fcf88053cd08aabf6a98b7669b8a7a2fd67df13 ./src/protocol/crypto.rs cd0612021f8ed6c9e0823d63883c0790db09aa485583f3f3fc9f2579f2c28ea3 ./src/protocol/gree.rs 7907c33e6396c7e0d61705b735b21c84d6ed784012fb61022b872c6745540f1d ./src/protocol/gree/binding.rs -d71771855d5ac90fae4994debf53ed09472b216c17bb2155d80aa2de13c57e18 ./src/protocol/gree/commands.rs +fcf5e67539c419f3e7e4533d49f41f963fa7779f099bd33c1b210fac32a78949 ./src/protocol/gree/commands.rs 4e419ba3b86ca9d154e8c04445cf005965175c54041093b2daad3666d4d852bd ./src/protocol/gree/core.rs 05fa2e4ec133a61c23a3f4ab2ce792bbfab93feaea9f0ce0d4ccc67fc479833d ./src/protocol/gree/discovery.rs 0ab094f609edb99715402d8eeaa7e38cbfd2d271036aec787dcbb3be3e005720 ./src/protocol/gree/merge.rs -c09c931fa8d512a8883fb14f3142be694af3c51290e023d25c7c972e156e4bd2 ./src/protocol/gree/network.rs +ff33db623f8f1bf30015b4a00e1f247a99de713da3d297c672e42c0795ec845c ./src/protocol/gree/network.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 -49dcca1e4a4ad858b312386e3b729f36da691916d8fe62ad682ddb86d40695b7 ./src/protocol/gree_cloud.rs -d406a2c5a49fe059f3f3a00ea534a32f9bdd972c09cc051cbfc4317f7c91b38c ./src/protocol/gree_cloud_mqtt.rs +297983059c915057869fbb4f6bcfe752f3d5bd1e93c3a2651a1bf883c739ed29 ./src/protocol/gree_cloud.rs +b4b1bad9cad889dd69681b2afc3e6a09ef19e19b2a1f2900fd417c4b1f55ec2c ./src/protocol/gree_cloud_mqtt.rs 6258ee3888a5f9a4f8bb6cfbbac97f2621de07f9da6bcb2fa596d452f47f2e49 ./src/protocol/mod.rs -823da603035619894d8a0252c6a9782c44fdf8d539f09a0a0c120dc8e4b8e4f2 ./src/provider.rs +a78d73571fbd983660449fcbd7857064a1325ca12adc07e61c2f50a3208adf37 ./src/provider.rs 830621fa75265da7288b8135704c0ce27e0687f87d55abf3cdbe40a6e2f3d2b1 ./src/queries.rs 7fac65a9dcbcecee09816f5447c7792dc94f736b1efb66ac85629db75f6280be ./src/queries/device_history.rs 580917b91441fe9b3276cf0cfc31536ba7954b6a86f4080347b93f9367a5b1ac ./src/queries/entities.rs @@ -198,32 +197,32 @@ d406a2c5a49fe059f3f3a00ea534a32f9bdd972c09cc051cbfc4317f7c91b38c ./src/protocol 7e12e8704faa7ac15a312bcc1d714ee1c6f27c1dce0972d4ccd6e5a40c69c884 ./src/queries/maintenance.rs 7da8b73a1c8c30e2a12071062d36f5393938fde0b5ffebbe4af1891da40f3233 ./src/queries/schema.rs f13f1be3d5789539eba3fc1b59c14835f8c681eca7634e118fa3763a53aeee2b ./src/queries/zone_history.rs -7e7037a059b585c5709367b9431a225789e7039796e3ed19fc132776ac234099 ./src/state.rs +8c34b70fb5f80e3249c2e0c63686e60ce1697cf44a7e6f11b3ad6f689ce17792 ./src/state.rs b92a6cb158b494fe145b43c7641e65f6fafff47201d7d76edbec2cfd8b94835c ./systemd/gree-controller.service 544a31cb2e5374227026afd6abd0d910381f3fca1638154ebd74e3c3d96a35d1 ./web/404.html -fbd8595c339d245453427c7cb9bca937bd2f60852ba25a15956295abda26878c ./web/custom-chart.html -e13dc8c936b39c9d1f808d6e0e2ed1e9171c4da62ce6ba526220e8aaf478f571 ./web/custom-chart.js +8b8b5e9511a833d6dc0eb902048e8f48f26395faf55f19962c3e6f06ae0fb4e4 ./web/css/styles.css +50e0ffd652fb4ea8ea4ed79a8e54e838709448886e3f6004cdeb35ffa4e13206 ./web/custom-chart.html e98bdd7204349cce1ec6f57283509697af0bbc72280622a6c3efa6fed242db4f ./web/favicon.svg -217a9f99be2356bb15d5fa177bd08c7527eaeff827e572557464dd4e1dfa8fe4 ./web/index.html -9ba3f6fa05b72aa989139b2a909982571b2a02055052e4c40104f1e81a9aa7eb ./web/js/README.md -27eff2821c40395369be15e197fa830535d2327fcb27c93a0e8e65a1c62de3ad ./web/js/bootstrap.js -1612896d22fd61747175cd54174a283002ac9911f4164e593c1079f767790cda ./web/js/charts.js -5c46d271550f3db38ecbfc09e3a620f6f0e6a907a3d6d56feae9b08e894df196 ./web/js/core.js -752b3c3b59558b42d867d4a15329238f0213f354b76f7011c10e31b4e6dbb311 ./web/js/dashboard.js -0f975542ae1d9bd7216123dd3718be6e9a25dbe643eb899c67024d293940fae5 ./web/js/entities.js -aaa874637d4a63876aa1248f995190316de80031b4bbcac1e8943238d2bafb53 ./web/js/events.js -473511ca11a0412a794b20430c077843072e9abe0d2510552ac83b226c381453 ./web/js/flows.js -a3d9f77a40a2574a668855125c781c067289944c6f93179ff23e3dae150e7dac ./web/js/forms.js -edde2969c9c1376f4df37224daa291256ebbbe713903d98e26b6aaa6ca467911 ./web/js/history.js -511c4e7cc4c48fbbdab64558a9d1c27e6759c9b6dca255d11f301b9708e955c7 ./web/js/main.js -6bb9386707dcf601111fea15a1a366d8798313b973ca1624878618f4c3095add ./web/js/navigation.js -799613c6d465cb2f92ecb57cb082d0c33729a03d88ab4ca7fa12a3cbd7e38e75 ./web/js/realtime.js -9546ca5c5566994aff3c33d92295be46b65d51d874681764abe77470dc9c9c73 ./web/js/router.js -c2710fd9c1f2b31878c1e25ceb02d74ae30456c1bbb400e69fcf7619ecc72bc2 ./web/js/select-ui.js -f7245f0e6cb5011196704f201675ebc6c19d902b8253ffcdbf9ead46cb1c6f86 ./web/js/settings-ui.js -335317bd50dd411f8c5e6d00b22d30e53172484626f2ad9901e32cab469724ac ./web/js/settings.js -3815fea7ae1694568c2f249bc8460e0563ced6bde89214f74f21b41fe8cad9a6 ./web/lang-init.js +784e2c28effb9872b582298292d3e2a6b8dd6140207342452193894d210e2f45 ./web/index.html +9ba3f6fa05b72aa989139b2a909982571b2a02055052e4c40104f1e81a9aa7eb ./web/js-dynamic/README.md +27eff2821c40395369be15e197fa830535d2327fcb27c93a0e8e65a1c62de3ad ./web/js-dynamic/bootstrap.js +5d57253a013fd1cb79e4b8701d4be6e394e3f2acbce73dfcd726a86e5878f1a1 ./web/js-dynamic/charts.js +1c7a779d6b793413e6f92e6bea910d035ffa22356aae965923a14ed84ab6bd8d ./web/js-dynamic/core.js +752b3c3b59558b42d867d4a15329238f0213f354b76f7011c10e31b4e6dbb311 ./web/js-dynamic/dashboard.js +0f975542ae1d9bd7216123dd3718be6e9a25dbe643eb899c67024d293940fae5 ./web/js-dynamic/entities.js +7a4d02bb1ebfa3a5962c7a0b567338d460b99f30ec582fda3a7fec9f6fe54840 ./web/js-dynamic/events.js +1865ef1ed93f04fe323338ac44ef06c9b19ad6ec222d47085ce51cb4c03fe13d ./web/js-dynamic/flows.js +a3d9f77a40a2574a668855125c781c067289944c6f93179ff23e3dae150e7dac ./web/js-dynamic/forms.js +92d013bb5d64f64aa8c55380704540a5f72732bde7ccd16554965e401daf50ea ./web/js-dynamic/history.js +511c4e7cc4c48fbbdab64558a9d1c27e6759c9b6dca255d11f301b9708e955c7 ./web/js-dynamic/main.js +86031d4f7f7d1b46412c8fbb09ff1ad1b2b4cd84eb949bc749b27663f1dc27fa ./web/js-dynamic/navigation.js +799613c6d465cb2f92ecb57cb082d0c33729a03d88ab4ca7fa12a3cbd7e38e75 ./web/js-dynamic/realtime.js +1b667f0a8bce824b27f56a8d2f44ff8feef9a011289591dc629ee288db70d137 ./web/js-dynamic/router.js +c2710fd9c1f2b31878c1e25ceb02d74ae30456c1bbb400e69fcf7619ecc72bc2 ./web/js-dynamic/select-ui.js +f7245f0e6cb5011196704f201675ebc6c19d902b8253ffcdbf9ead46cb1c6f86 ./web/js-dynamic/settings-ui.js +672e404470bc6331aa6d658523b221659ec64222af9cc9c17ced978f725790da ./web/js-dynamic/settings.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 -0efe0cdff18beff496017a984f6d189ea6463e14b517f2f5c5fc46923579ab15 ./web/styles.css -fefdf12cbbb0a5d8a2cb89275cae28455aa42aaf179542305cc8f5e0f4825898 ./web/sw.js -d505d793ce7cc9485b45b78bba1c0d51887adc7451ab59a42702946e5b991382 ./web/theme-init.js diff --git a/README.md b/README.md index 29fe3d3..b209cdc 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,9 @@ Self-hosted controller for GREE-compatible air conditioners with a local Web UI, thermostat zones, schedules, Home Assistant integration, history, notifications and a documented HTTP/WebSocket API. -**Version: 0.14.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. @@ -470,7 +470,8 @@ curl -H 'Authorization: Bearer YOUR_TOKEN' \ ```text src/ Rust backend, GREE protocol and thermostat engine 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 ha-addon/ Home Assistant add-on, custom integration and release tooling scripts/ Development, install, update, service and diagnostics @@ -479,7 +480,7 @@ docs/API.md Complete API documentation .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 diff --git a/SOURCE_MANIFEST.sha256 b/SOURCE_MANIFEST.sha256 index 589fb49..704b495 100644 --- a/SOURCE_MANIFEST.sha256 +++ b/SOURCE_MANIFEST.sha256 @@ -1,16 +1,15 @@ -8c09c8a2203e7b42ba62cbf7a354e57cb70941a5ecc77949368770e65fe9c8f7 ./.dockerignore de0793949cf01d27d903653226ecc9b5f72523d8711f20bcab022cf42c4c4ed0 ./.env.example a4ec3874a2e3ab1bad28fb40bb620f7b01f64d01ad9b699306bf70ada31227db ./.gitignore -1cef28b62ca6f6a01736fc67d9639c909274b2a244c9276ebaacb51ffc662a3a ./Cargo.lock -6e563ec8c215326660c9e63ec7f079e5b892cfb58abd72cf55ac127f02ae3938 ./Cargo.toml +10eec62654d9740fe5c6cd51620d608b2aa98dac0e6417bb30f77bb660b9d046 ./Cargo.lock +adff4bf5593ca6558be86fb0cc04a3af0d7b4140a57d07100f6ebb31e092dc59 ./Cargo.toml 19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE -046205c6a991bca9b514e507a35cebb875d3152d09a7cb02dfb69b5ac16eb640 ./README.md -5302570299af2c2650bada12e123f598c5db8009949c75761385f73ed57343c5 ./build.rs -5a673f26300c50aa393690332aed3ff6c8266a119d6a24dd4908ca6b19aa12a0 ./docs/API.md -f21d732eb1147f8f3167d2172cdf0b57e61edabdfecbaf97811d46c5bff7e47a ./docs/FLOW.md -8350a8466a1a9fec13cfc925ed8ecc3f441e4408c091aef2b820b53e1fa0e408 ./docs/openapi.json +ae3d4b2c7d90282541aca94934ed713087fb7d6451ef78302cd43ca07b794d59 ./README.md +29e9821e5ed216ea3565dd6d8bc0b174ed3cf9fa72c4ccebbfa99e98db3ad8f9 ./build.rs +b8c5b2a9da89b6e6c6e20b1eb20f5810f9fee52b3d141ca19a539ecd2397861c ./docs/API.md +8b548d02dbdf0563f2be617a1d677459bd1959bed93a1799a01bf61bb5aed405 ./docs/FLOW.md +47817a9f7398012c6b374d090c8671e6927bb86cd042df40bf5f2f56ccea14d1 ./docs/openapi.json c65aa543d2e99f8f7d52736307b07ebd182597d80124661d53a16bb23f6f1118 ./ha-addon/.env.example -0972874605e1956aece2672ec6d921c871e5564aa3e290f41f406e38f84ae13f ./ha-addon/Dockerfile +0993076ca45b6d31859680835204cdc111b3f9bdbf460d50b4efec56cd5e685b ./ha-addon/Dockerfile adf7ff248d782a3b1cba82b8789c57b6849ddaaaf5fbc108af75e18b1534a885 ./ha-addon/README.md dd9291b12afade6fc20d66e80a5513251270e18e4c265260b2bbbe393a5a94c4 ./ha-addon/build.sh 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 5a96fe8f5c035c34f1339370270cd078056202d09e236dec75735be11de92a7d ./ha-addon/home-assistant/custom_components/gree_controller/entity_map.py 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 39c4309001b75abb56234f05662bc06e077054986876f1927937edbce528ec95 ./ha-addon/home-assistant/custom_components/gree_controller/select.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 13f30e2dcdcedbd1b6c3f99c2335e0487108fd72c8e86922368b84f2fa2038ae ./ha-addon/home-assistant/custom_components/gree_controller/translations/pl.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 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 0d0b42a7639b128946eec379175436fa1f150294f39d2dfd169077bfa87fd14d ./ha-addon/repository/gree-controller/topology-ha-vlan-gree-en.png 2272740b185e9520e948f74b8f45cf98b8b5fd28c93bdee760da626f91fe5c2a ./ha-addon/repository/gree-controller/translations/en.yaml @@ -43,8 +42,8 @@ cea12bc74c066a588a5ef0ef7bad9d4906bdbcea99f2d275429b0eefc94b4b5f ./ha-addon/run 081e27b6e43070d44f865335c30a42a5a29668eaa6a5ea5ab6967ac4447b8e06 ./ha-addon/sync-repository.sh f9e31de3d109ead32bf531c074d46a087df398d62488caee0b46b5e0a1c027f7 ./ha_addon.md 253a0bc912786e67ea7fc92a64e4a510ad973bec343a88ccfb1f28fca3e8cf01 ./lang/README.md -9477f8e45e8471867acf9af2b1554c9dc9490808c11507e4ce9cb9101c0ce2df ./lang/en.json -1270413ebdc2ace15ba62f365484207299f2de480bbb87d2b42959dc9ce6fd34 ./lang/pl.json +3f7ffc2b9492593297668cf52307ad12a4a763dde3d0f8341fdb4b7298b40748 ./lang/en.json +23bfb017e2530ab8fa6c62908211016e802e990a75944a9914681ded6e2079d6 ./lang/pl.json d8459024f04ca514bd8e9d6bd3af872fb942fd85a7cdd5583f4a9d28aab6faba ./make_zip.py b14233a8987e53bbbdd6770386ba10fa166ec25c1e098275c173c544b37846fc ./presets/bedroom_window_night.json 1960841119c0b673fb2f03621b32237552fd828045304cd221401214b997c910 ./presets/device_resilience.json @@ -92,104 +91,104 @@ c64b1c6deeb24f20af662bfce7fa3683703914987241107ce664fa2f6d1a9dcb ./scripts/READ 3fe88e64e43d380c56955f577992ea5b0252c05280f6e9dc3cd0bbcc0bf00896 ./scripts/generate_ha_migration.py e4849261fd9ed1f01df96c0637c439c0c4eff8fa317b2918026167bba343af79 ./scripts/install-lxc.sh bb7cd2c5b27c9dceec1d1d2846fbad9600df9c08e0ad07d13fcde97af533091c ./scripts/install.sh -1a84c91a89bf7f60aa4816a7650640f315efadf39bf7b54dc20f1259b2e2d5ce ./scripts/live_realtime_test.js +50cc08e2aeff052a47c68212524f4584e9201cfc535d71803f276cdc223d5216 ./scripts/live_realtime_test.js e00d211e3885e30d7fed1e43b44e6fdad40a67019060156c0641816a93e3365f ./scripts/network-debug.sh 01952aa92b217f8eae2493b88870e2dec595100cd15c4d561ff11ae2b936c46f ./scripts/regenerate-sha.sh 81345b6a0b51736bdbc98fd23199b62e4c721b4e7437e02dab7ea79b97dff29a ./scripts/service.sh bd703ef841f9763f13367aaa5764381b9c59ee87210e806c24617f3cc0739a9b ./scripts/smoke.sh b50782b3742dfbf8a319c60571c968e93fdf8547db747c759edcffae68cb98bf ./scripts/update.sh -a70ddd9d4c9fca073ff494db73ec13acc7cff1830c9675d0541518d814da3772 ./src/api.rs -f6ed359370dacd691344402307843a0ad31bbeb46085f99e81cfa46a618ee87d ./src/api/assets.rs +bd7e0455c6a1501f761f0d4c00ff74833eecbd75078af9168235f90a2d9485b3 ./src/api.rs +0db0d81da4b8161004ddcbed2096812c285ecec9d944771670e6c8edf0775175 ./src/api/assets.rs b24092f34a46626d64628b510eba82193f0b203fd097f6a621b91af5d91eb571 ./src/api/auth.rs 9e4fc675306e111ed3db7af9822e2925d88109c31b9e309851665a975c0bd83c ./src/api/automations.rs -af078ccc0bfcf0c7f2a4a8c1e7ee2f21dc1966ac209d17f17de12bb4d5276244 ./src/api/configuration.rs +a4222a29cdc90e75dffd5fb5e35c11e3556924a6489b1b31272cd81d0b141821 ./src/api/configuration.rs 19bc141286a336ad67e99079496eb0ae5126f986ab28be62b5f97758e5f6969c ./src/api/debug_tokens.rs -32ade46ec45d6faf7852242b9f5ba1be6eacbacbeb33d2deabfab59bf1610ce0 ./src/api/device_groups.rs -fcbb3da0538aab451f71810f829cc248d6bb67e586dc418d71a572980583823d ./src/api/devices.rs +ca21f6d5a407e2e3532899d53ba4e8682c2fe961a46d8cd870ddc459721513ef ./src/api/device_groups.rs +2d487e274c777cbde081c5803311bec449097f45bd2d021117cfe10677b39efb ./src/api/devices.rs 4b5b784670ae761a6848b9a33fe3fb391bebd39d65e87936907428711e96ca44 ./src/api/events.rs d59b080b392b574ec94276c7b485b455d0d1abacc7de2ef29dcbfcf7d86dbdb0 ./src/api/flows.rs -2cfee7029f065e81628a8942e3dad00a03c3a5cf4f8492d07f12b75d05c07359 ./src/api/gree_cloud.rs +a17ab0b6d683d285441be15f2af33172d7caa9a57ec8b32e59107379631a377e ./src/api/gree_cloud.rs b61b0b4f196c5e72e47cf06ca0c14df3cf8eedbdef0242a9d2af33623663489f ./src/api/groups.rs -92de677265216adb3154f6558684aec44cebff6bf6c57044f37d20f7d5a4a10f ./src/api/history.rs +eb88001591ef26601391198b8b87f08cf445fbdd855faa152e33c5554e23f6b7 ./src/api/history.rs e8edfbb725d6de650442444b0a929ad2be0bd2c2d7d227d2b3fdd99d05c94f94 ./src/api/house.rs -c71540b2b68e805e3a79ac787db9127b87585968a205fc307e93bf68d5b028e0 ./src/api/integrations.rs +8495967c3df72110f591c744975984c78cafc8181b2d430e7fd5b9baf57eba95 ./src/api/integrations.rs 7658111f7d125db55a6e23f88157339b110b2cd1ed13ceabd8f860b42dba5101 ./src/api/middleware.rs 45e34a447ff384d99a3c42c489ca9fa75849c74d4d246caa91c63d927de17396 ./src/api/openapi.rs b777e7fbeccef699e11ffd16bb18633843c99afecacce2221280e54b09f6d2da ./src/api/public_settings.rs 5b29f2e5aa2c422c5829ef89c7b59cf753883aa7f60661c95ab0f95cf676fa3f ./src/api/schedules.rs -6acf6417b077a0204a815e255c667928476014ce5f94c1559c9d4268c00342d5 ./src/api/settings.rs -0de92f241b5da2b7f13aaf9c24ef76eba8d26f8e27d87c9312bd644ab13d31bd ./src/api/system.rs +fd08162d0f9287700196028ad0ca2aec7c242238056b0a228ca5bdcb319ece4e ./src/api/settings.rs +bb57f4b28df75ba350c9706719650fff3a689afee0cb6979c1fcae79396ac0cf ./src/api/system.rs 94d3845def693aafe15fa6e8b390881c46c3c236b62f247ddfdc055d9226ed22 ./src/api/websocket.rs ddd1bfc8625b66f6e5f3d24b3122ac86c25f89b0af527e3dfce9102551a8f142 ./src/api/zones.rs -7b15febe046c79917a22664f50d9063fd6d0968521544ef31b854424f73b4408 ./src/config.rs -f8394e0daca284070ac9ae0844d6b04fed7c30386f2d1ec409ecf07880bb0e6a ./src/db.rs -fdc9d019097becc5c36a0560caa1619aa35d802c0d208902b3bfecbe1643e3e8 ./src/db/climate.rs -e5d2027d40a99e61c2c572499af617a92de9e554f8916f3ea6f6731d1d6b09b4 ./src/db/configuration.rs -46213497ae26b2f799364a2e64e41df26c93a39eb4e7167b59c1e42b525f61f7 ./src/db/core_devices.rs +0b7d2ace2e8cb1dd0cb59aa6ca3e80cec14078e93530fd7b58081798585cd833 ./src/config.rs +de0ce7807aebe4c0e325fbef01855e7fc1cfd117529f53f04a416fda3997673a ./src/db.rs +87f1614a80addde69b619421187526d7f86f893c0ac18998854f11675d6e7cca ./src/db/climate.rs +a8fe3636ac79df191e2f2c9d8c1eec1e5181e048ef8c9036cfa18f84aef1a462 ./src/db/configuration.rs +5143803ab470f287a587171f1aab96f60653e84cade523cfe104220079727ffc ./src/db/core_devices.rs 4c20289c08b0cb9c3c4324d9dbc6a47c7dd953aff6fd1746f04b6024043d3bc5 ./src/db/device_history.rs -f99de2230082f17eafaf03f5361bb67419205a23d64f22480c996f9c32c55ac5 ./src/db/energy_history.rs -7e14cf46e6750e913f2120ccb0a75ad6884194ba973bf85fff9c05ecd919b5f0 ./src/db/events_tokens.rs +7bc79d9d33726fbdd33b485c8c2e839cedbb8fe29fee872611da978ed149d6a0 ./src/db/energy_history.rs +96aac6510ddda8c18fd7e29802a937b96e8a74badf504980846c80cd7933a4c6 ./src/db/events_tokens.rs bb5048ee60053f39ce3c1203dc1f24c7047310538d2357f105a052c016d21ec4 ./src/db/flows.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 -8b312e258f079d844466ad516134cdd065a017be78be41214b4239fc0f9e5b36 ./src/db/tests.rs +28ee42d08147b0f154af818065d7d014d74ee8ddd59fdef98115f5c5368056cc ./src/db/tests.rs f90fbb14f29a20b799b3856bdaa22812a9f0d9dfa46693f1341c160dccda8a97 ./src/db/zone_history.rs -4ed54df6321496a6afabf9443beb9fa8d3434c510aa01dade318bbaa44f0f0ac ./src/engine.rs +75f4f139cb268b2dfda73966b52624735380724e73b99930bffe36328620f111 ./src/engine.rs ed5c9a0c61c6e597acb33f58593ebe02b562ddc68fa61271e1c1cd436c1003d3 ./src/engine/automations.rs -0bf7fe7d28b4749e53ec25d71effbb78bc8f70c716714f838a4a2d8f20627d63 ./src/engine/commands.rs -f7de7b9b756bef9d6cffbc6ffdb7504b3121476f188e6ff5c8077d5fc54be811 ./src/engine/connectivity.rs -925135985f640ba9ae0a6109d908187b659341a91451e91d27e533adfd610b76 ./src/engine/control_plan.rs +1dccb8779f87dfdbd4ccda3f02ec51273e7be2e34e85166861668685528eca79 ./src/engine/commands.rs +357ef92c3cc42b243af4da31ac52bb360d7444ae423f58d7a13bdee79bf0a748 ./src/engine/connectivity.rs +ad53cd7be24deb759e03cbc6472522b69ebd36d8fac35398a9b19c899d3b17f2 ./src/engine/control_plan.rs 7b48a33343d519a2ff2d81e9d757eb56a0c1beacfc0b7b8ef61f49c2ad363f9a ./src/engine/deadlines.rs -f5e3a8e85c622ea623360bdac87a098785748d8e4f4ba19ec2aa18c279b61c03 ./src/engine/energy.rs +a3282d1e22fbdc89bed1d9e681e4865c9d8778f97805083c709fc62090a00d27 ./src/engine/energy.rs e350aa63f3bb14ca887eaaa7555edbeae00fb584cbcaf552cc972f0c8918f5d8 ./src/engine/groups.rs -5d8b3c9f2243848502bba5c1e4940f8bf8c5ae4bffd891b43e2e9b4e7dc55b0c ./src/engine/history.rs +4ec11426ef860811348f2bf1b458bc5713fe8a7794fb4c8045dd637652e8e696 ./src/engine/history.rs 41b7045877771b7c352328998cb45ce2433e7f9631fad3eb1a882ab4897dc31f ./src/engine/local_thermostat.rs -99527adab9fea1caede9ebb24faa6a63d5cef4810c64c06306b571cf5cbbb0da ./src/engine/ownership.rs -1a29b7fd8de77f0a5e5964ca05e41cc5ea78855c1ab961750d40461faec305e5 ./src/engine/polling.rs -d48ae1799e045fb212e73e630a5d26ed3c5e3359a3dac3ebd5638532efdd4285 ./src/engine/runtime.rs -c1e8de5392681ba1255e3e2d871a6ee860fb8ca987a0a8f973d9ded3977a68fb ./src/engine/schedules.rs +f684e6755d023478882d1689833f307f3b4cd2c66abf45f8af8315cee94d36ef ./src/engine/ownership.rs +56d64614d8ca85cec7098b1f5d2963ecdcbfb7d0a3d4f22e4424a653895b5a16 ./src/engine/polling.rs +c73691c3637465ab0f79aecf7f5428d0317799a8ab5329ed564cf57963bbe86e ./src/engine/runtime.rs +48a496c3935f886a90b90461c38a7aa31ebc40c6482d6feaeb2be47bc2387625 ./src/engine/schedules.rs b0189f28d0f337dfd3ce0934da7a565e4a5535afc1c242214c26ed584b3b7840 ./src/engine/targets.rs 682c8b3956c657b655746cbd19ee97ee2f4cf953bc9de67d6d70a1b14f3dd746 ./src/engine/temperature.rs 833b72907375e75c3f42b3921ad70a8e298f5a93743bf0d0c19059aa272462d1 ./src/engine/temporary_thermostat.rs -3f77350e4c54e8e497d9da8ba0545124f5a8810908c9a1471d1eaa63c4441b0c ./src/engine/tests.rs +2e7349862beb5f844aea0f1c4cd96ab6e53d3500559d7b2562bd93da595d7c41 ./src/engine/tests.rs c89ff36c6a67bf4fbf68c43e18aa0a556de2dc69263fd79db376363afc741a4f ./src/engine/zone_actions.rs -3a7fada1f6dd9ebab4026b353f41b21d8ca19342b02538c711dbb2dfb599cbe7 ./src/engine/zone_control.rs +fb169bcc20f95abe9892367df6dd3a683584bdbc721792d6ffb9311b81ca30d9 ./src/engine/zone_control.rs bfac95cec87f0df6ce562153cd3cfe6731359d703e6c592d2c2f7ba01ea62d99 ./src/error.rs -a1bd54026f9c7e9f84da1a730136db81095e8ba1ceeb2596fc01479def76df83 ./src/home_assistant.rs -e78ca9aff23362971eb6450534ea21bdc15150456c378ef9b226cb93a8dbc612 ./src/influxdb.rs +c55d32f288fe95869fc288d47817b8e289fbe284be4219e81b1e7493ff5e3a57 ./src/home_assistant.rs +bcd14e4d1af3ca580f3b95bcd86164c40f762511e1b8e2501cb8a598a6d8adf4 ./src/influxdb.rs da4068295f37c23222bb13bebac022d88ba7729604a49ad25438c55e45458a49 ./src/influxdb/codec.rs -b96624fa34b218a6d5f028ba09579633f6f8a04c109f0e94801e8d89ceeeb4b5 ./src/influxdb/query.rs -8089b8eca47419bb6b3ee02560e5cc39c00479164bc76d11992679b2cef3258e ./src/influxdb/write.rs -6479dd1e5d7f8eb2ffb945ca958124107c5585c0abdfb34555367db0c21145da ./src/main.rs +2d36405912646b30b1a1a37a7a4e18c2104ab22e865b593316a609dda884aec2 ./src/influxdb/query.rs +67e42a92295480d2f1d364958f184654cfb5d4b77e7e1df47f588bdec18868a0 ./src/influxdb/write.rs +1982ce54737a8780d1404b105d53db59cd5bee3d1c426f9a881d26cef7059bf5 ./src/main.rs 23688a5cb68efded89471ce959328d27adff370d451bd9f378a283ab51437e76 ./src/models.rs 920e028d96a14cca9f74a964bb296e68a109cfead314d3164c180201e606d151 ./src/models/automation.rs fc5f2802a073a175a8985e56bcb87eb1ff481226d5b4eeabad73dbba1839fc45 ./src/models/control_plan.rs 06357b352ff6494d83e72796b869573a8b8f36b835985a5900ac571c922b8da9 ./src/models/defaults.rs -5e212a072bf74ef32a186b172404844b02a92363d216167f814056e1f2a9a7a8 ./src/models/device.rs +f3439a154fad8a0111db26918160901dd04fe47dd56d529490e75deaf0a78b43 ./src/models/device.rs aeab5b5acf35d38a54fd1d82e964fe56635ade69c0149ad423df317095d01555 ./src/models/flow.rs -ba5d359e4d6c12d9f62c1b28bdfb40d312737930df5421814f5310f7183e96c1 ./src/models/history.rs +6e44df6bde2f73c282095c80ac17287e5bef77d9fc1d344e440d863dae5f5031 ./src/models/history.rs 1fee89f939018bf1f3686c15f25434f01ab96b3239164108db7f348a1f1f8d82 ./src/models/integrations.rs f1359b68f1d041cd441f80429e77893eeb6f6194613b58fd3e7a212fb511db3e ./src/models/runtime.rs -e3dfdd6dba63f6febd0b64da413b2fe7ca258a54abc2fdd6f2982c27425abfd6 ./src/models/settings_api.rs +41b85cff6fd234c8d6ea007b2c3c22df5fee5107dcd57c64dcc94a356ff70c12 ./src/models/settings_api.rs c4de6101de85d9d7e6814b2504b87d9216561e3d9e274054f081f505fb152140 ./src/models/temporary_thermostat.rs bc2818ce8d0dae1edfb1b98e5d1c00b193f03eeaa28e343d3532f6023288738c ./src/models/zone.rs -62e696df8b86835df7ad127f03c257a78580769dc82bc66daa5041f71e8440a5 ./src/notifications.rs +4c1f676eaf0b6da45b4d4b090ae3ac862fb97c1f9e53284a1640767ae9591d8f ./src/notifications.rs cca742bb2b500eb9269f69385fcf88053cd08aabf6a98b7669b8a7a2fd67df13 ./src/protocol/crypto.rs cd0612021f8ed6c9e0823d63883c0790db09aa485583f3f3fc9f2579f2c28ea3 ./src/protocol/gree.rs 7907c33e6396c7e0d61705b735b21c84d6ed784012fb61022b872c6745540f1d ./src/protocol/gree/binding.rs -d71771855d5ac90fae4994debf53ed09472b216c17bb2155d80aa2de13c57e18 ./src/protocol/gree/commands.rs +fcf5e67539c419f3e7e4533d49f41f963fa7779f099bd33c1b210fac32a78949 ./src/protocol/gree/commands.rs 4e419ba3b86ca9d154e8c04445cf005965175c54041093b2daad3666d4d852bd ./src/protocol/gree/core.rs 05fa2e4ec133a61c23a3f4ab2ce792bbfab93feaea9f0ce0d4ccc67fc479833d ./src/protocol/gree/discovery.rs 0ab094f609edb99715402d8eeaa7e38cbfd2d271036aec787dcbb3be3e005720 ./src/protocol/gree/merge.rs -c09c931fa8d512a8883fb14f3142be694af3c51290e023d25c7c972e156e4bd2 ./src/protocol/gree/network.rs +ff33db623f8f1bf30015b4a00e1f247a99de713da3d297c672e42c0795ec845c ./src/protocol/gree/network.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 -49dcca1e4a4ad858b312386e3b729f36da691916d8fe62ad682ddb86d40695b7 ./src/protocol/gree_cloud.rs -d406a2c5a49fe059f3f3a00ea534a32f9bdd972c09cc051cbfc4317f7c91b38c ./src/protocol/gree_cloud_mqtt.rs +297983059c915057869fbb4f6bcfe752f3d5bd1e93c3a2651a1bf883c739ed29 ./src/protocol/gree_cloud.rs +b4b1bad9cad889dd69681b2afc3e6a09ef19e19b2a1f2900fd417c4b1f55ec2c ./src/protocol/gree_cloud_mqtt.rs 6258ee3888a5f9a4f8bb6cfbbac97f2621de07f9da6bcb2fa596d452f47f2e49 ./src/protocol/mod.rs -823da603035619894d8a0252c6a9782c44fdf8d539f09a0a0c120dc8e4b8e4f2 ./src/provider.rs +a78d73571fbd983660449fcbd7857064a1325ca12adc07e61c2f50a3208adf37 ./src/provider.rs 830621fa75265da7288b8135704c0ce27e0687f87d55abf3cdbe40a6e2f3d2b1 ./src/queries.rs 7fac65a9dcbcecee09816f5447c7792dc94f736b1efb66ac85629db75f6280be ./src/queries/device_history.rs 580917b91441fe9b3276cf0cfc31536ba7954b6a86f4080347b93f9367a5b1ac ./src/queries/entities.rs @@ -197,32 +196,32 @@ d406a2c5a49fe059f3f3a00ea534a32f9bdd972c09cc051cbfc4317f7c91b38c ./src/protocol 7e12e8704faa7ac15a312bcc1d714ee1c6f27c1dce0972d4ccd6e5a40c69c884 ./src/queries/maintenance.rs 7da8b73a1c8c30e2a12071062d36f5393938fde0b5ffebbe4af1891da40f3233 ./src/queries/schema.rs f13f1be3d5789539eba3fc1b59c14835f8c681eca7634e118fa3763a53aeee2b ./src/queries/zone_history.rs -7e7037a059b585c5709367b9431a225789e7039796e3ed19fc132776ac234099 ./src/state.rs +8c34b70fb5f80e3249c2e0c63686e60ce1697cf44a7e6f11b3ad6f689ce17792 ./src/state.rs b92a6cb158b494fe145b43c7641e65f6fafff47201d7d76edbec2cfd8b94835c ./systemd/gree-controller.service 544a31cb2e5374227026afd6abd0d910381f3fca1638154ebd74e3c3d96a35d1 ./web/404.html -fbd8595c339d245453427c7cb9bca937bd2f60852ba25a15956295abda26878c ./web/custom-chart.html -e13dc8c936b39c9d1f808d6e0e2ed1e9171c4da62ce6ba526220e8aaf478f571 ./web/custom-chart.js +8b8b5e9511a833d6dc0eb902048e8f48f26395faf55f19962c3e6f06ae0fb4e4 ./web/css/styles.css +50e0ffd652fb4ea8ea4ed79a8e54e838709448886e3f6004cdeb35ffa4e13206 ./web/custom-chart.html e98bdd7204349cce1ec6f57283509697af0bbc72280622a6c3efa6fed242db4f ./web/favicon.svg -217a9f99be2356bb15d5fa177bd08c7527eaeff827e572557464dd4e1dfa8fe4 ./web/index.html -9ba3f6fa05b72aa989139b2a909982571b2a02055052e4c40104f1e81a9aa7eb ./web/js/README.md -27eff2821c40395369be15e197fa830535d2327fcb27c93a0e8e65a1c62de3ad ./web/js/bootstrap.js -1612896d22fd61747175cd54174a283002ac9911f4164e593c1079f767790cda ./web/js/charts.js -5c46d271550f3db38ecbfc09e3a620f6f0e6a907a3d6d56feae9b08e894df196 ./web/js/core.js -752b3c3b59558b42d867d4a15329238f0213f354b76f7011c10e31b4e6dbb311 ./web/js/dashboard.js -0f975542ae1d9bd7216123dd3718be6e9a25dbe643eb899c67024d293940fae5 ./web/js/entities.js -aaa874637d4a63876aa1248f995190316de80031b4bbcac1e8943238d2bafb53 ./web/js/events.js -473511ca11a0412a794b20430c077843072e9abe0d2510552ac83b226c381453 ./web/js/flows.js -a3d9f77a40a2574a668855125c781c067289944c6f93179ff23e3dae150e7dac ./web/js/forms.js -edde2969c9c1376f4df37224daa291256ebbbe713903d98e26b6aaa6ca467911 ./web/js/history.js -511c4e7cc4c48fbbdab64558a9d1c27e6759c9b6dca255d11f301b9708e955c7 ./web/js/main.js -6bb9386707dcf601111fea15a1a366d8798313b973ca1624878618f4c3095add ./web/js/navigation.js -799613c6d465cb2f92ecb57cb082d0c33729a03d88ab4ca7fa12a3cbd7e38e75 ./web/js/realtime.js -9546ca5c5566994aff3c33d92295be46b65d51d874681764abe77470dc9c9c73 ./web/js/router.js -c2710fd9c1f2b31878c1e25ceb02d74ae30456c1bbb400e69fcf7619ecc72bc2 ./web/js/select-ui.js -f7245f0e6cb5011196704f201675ebc6c19d902b8253ffcdbf9ead46cb1c6f86 ./web/js/settings-ui.js -335317bd50dd411f8c5e6d00b22d30e53172484626f2ad9901e32cab469724ac ./web/js/settings.js -3815fea7ae1694568c2f249bc8460e0563ced6bde89214f74f21b41fe8cad9a6 ./web/lang-init.js +784e2c28effb9872b582298292d3e2a6b8dd6140207342452193894d210e2f45 ./web/index.html +9ba3f6fa05b72aa989139b2a909982571b2a02055052e4c40104f1e81a9aa7eb ./web/js-dynamic/README.md +27eff2821c40395369be15e197fa830535d2327fcb27c93a0e8e65a1c62de3ad ./web/js-dynamic/bootstrap.js +5d57253a013fd1cb79e4b8701d4be6e394e3f2acbce73dfcd726a86e5878f1a1 ./web/js-dynamic/charts.js +1c7a779d6b793413e6f92e6bea910d035ffa22356aae965923a14ed84ab6bd8d ./web/js-dynamic/core.js +752b3c3b59558b42d867d4a15329238f0213f354b76f7011c10e31b4e6dbb311 ./web/js-dynamic/dashboard.js +0f975542ae1d9bd7216123dd3718be6e9a25dbe643eb899c67024d293940fae5 ./web/js-dynamic/entities.js +7a4d02bb1ebfa3a5962c7a0b567338d460b99f30ec582fda3a7fec9f6fe54840 ./web/js-dynamic/events.js +1865ef1ed93f04fe323338ac44ef06c9b19ad6ec222d47085ce51cb4c03fe13d ./web/js-dynamic/flows.js +a3d9f77a40a2574a668855125c781c067289944c6f93179ff23e3dae150e7dac ./web/js-dynamic/forms.js +92d013bb5d64f64aa8c55380704540a5f72732bde7ccd16554965e401daf50ea ./web/js-dynamic/history.js +511c4e7cc4c48fbbdab64558a9d1c27e6759c9b6dca255d11f301b9708e955c7 ./web/js-dynamic/main.js +86031d4f7f7d1b46412c8fbb09ff1ad1b2b4cd84eb949bc749b27663f1dc27fa ./web/js-dynamic/navigation.js +799613c6d465cb2f92ecb57cb082d0c33729a03d88ab4ca7fa12a3cbd7e38e75 ./web/js-dynamic/realtime.js +1b667f0a8bce824b27f56a8d2f44ff8feef9a011289591dc629ee288db70d137 ./web/js-dynamic/router.js +c2710fd9c1f2b31878c1e25ceb02d74ae30456c1bbb400e69fcf7619ecc72bc2 ./web/js-dynamic/select-ui.js +f7245f0e6cb5011196704f201675ebc6c19d902b8253ffcdbf9ead46cb1c6f86 ./web/js-dynamic/settings-ui.js +672e404470bc6331aa6d658523b221659ec64222af9cc9c17ced978f725790da ./web/js-dynamic/settings.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 -0efe0cdff18beff496017a984f6d189ea6463e14b517f2f5c5fc46923579ab15 ./web/styles.css -fefdf12cbbb0a5d8a2cb89275cae28455aa42aaf179542305cc8f5e0f4825898 ./web/sw.js -d505d793ce7cc9485b45b78bba1c0d51887adc7451ab59a42702946e5b991382 ./web/theme-init.js diff --git a/build.rs b/build.rs index 9980fe2..467e962 100644 --- a/build.rs +++ b/build.rs @@ -53,7 +53,7 @@ const APP_JS_MODULES: &[&str] = &[ ]; 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()); let mut bundle = String::new(); @@ -78,12 +78,12 @@ fn main() { println!("cargo:rerun-if-changed={}", lang_dir.display()); println!("cargo:rerun-if-changed={}", preset_dir.display()); - let theme_init_path = web_dir.join("theme-init.js"); - let lang_init_path = web_dir.join("lang-init.js"); - let styles_path = web_dir.join("styles.css"); + let theme_init_path = web_dir.join("js/theme-init.js"); + let lang_init_path = web_dir.join("js/lang-init.js"); + let styles_path = web_dir.join("css/styles.css"); let index_path = web_dir.join("index.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 favicon_path = web_dir.join("favicon.svg"); for path in [ @@ -143,9 +143,10 @@ fn main() { panic!("no language files found in {}", lang_dir.display()); } + let default_language = "en"; let mut assets = Vec::new(); let mut manifest_languages = Vec::new(); - let mut has_english = false; + let mut has_default_language = false; for path in files { println!("cargo:rerun-if-changed={}", path.display()); @@ -193,8 +194,8 @@ fn main() { if !document.get("translations").is_some_and(Value::is_object) { panic!("{filename}: translations must be a JSON object"); } - if code == "en" { - has_english = true; + if code == default_language { + has_default_language = true; } let language_hash = content_hash(source.as_bytes()); @@ -208,8 +209,8 @@ fn main() { assets.push((code.to_owned(), path)); } - if !has_english { - panic!("lang/en.json is required as the default fallback language"); + if !has_default_language { + panic!("lang/{default_language}.json is required as the default fallback language"); } let mut preset_files: Vec = fs::read_dir(&preset_dir) @@ -286,7 +287,7 @@ fn main() { } let manifest_json = serde_json::to_string(&json!({ - "default": "en", + "default": default_language, "languages": manifest_languages })) .expect("serialize language manifest"); @@ -323,6 +324,10 @@ fn main() { "pub const LANGUAGE_MANIFEST_JSON: &str = {:?};\n", 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"); for (code, path) in assets { generated.push_str(&format!( diff --git a/docs/API.md b/docs/API.md index 6f1ec6c..698b33f 100644 --- a/docs/API.md +++ b/docs/API.md @@ -1,6 +1,6 @@ # GREE Controller API reference -HTTP and WebSocket API for GREE Controller **0.14.20**. +HTTP and WebSocket API for GREE Controller **0.14.21**. [← 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/history` | Metric/event retention and compaction 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/night` | Night mode settings. | | GET/PUT | `/api/settings/home-assistant` | Home Assistant, aliases, shared Flow inputs and outdoor assist. | @@ -239,7 +240,7 @@ Response: { "status": "ok", "name": "gree-controller", - "version": "0.14.20", + "version": "0.14.21", "uptime_seconds": 1234, "control_ready": true, "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_revision": 42, "system": { - "version": "0.14.20", + "version": "0.14.21", "uptime_seconds": 1234, "auth_required": false, "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. +### `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` `GET` returns provider state without secret values: diff --git a/docs/FLOW.md b/docs/FLOW.md index be95f31..48b4226 100644 --- a/docs/FLOW.md +++ b/docs/FLOW.md @@ -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. -Presets are data files, not JavaScript definitions. Every preset lives in `presets/.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/.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/.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/.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. diff --git a/docs/openapi.json b/docs/openapi.json index 4d6ca45..e4028a5 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -2,9 +2,9 @@ "openapi": "3.1.0", "info": { "title": "GREE Controller API", - "version": "0.14.20", + "version": "0.14.21", "summary": "Local HTTP/WebSocket API for GREE HVAC control", - "description": "Self-hosted API used by the GREE Controller Web UI and Home Assistant integration.\n\n### Authentication\nAdministrator endpoints accept `Authorization: Bearer ` or `x-api-token: ` when configured. Restricted Home Assistant control endpoints also accept generated integration tokens.\n\n### Local and GREE Cloud\nDevices use one API model and one command surface. `connection_type=local` is dispatched only to the existing LAN/UDP provider; `connection_type=gree_cloud` is dispatched only to the GREE Cloud provider. No automatic transport fallback is performed.\n\n### GREE Cloud security\nCloud passwords, REST/MQTT tokens, Authorization values and device cipher keys are never returned in normal API responses or Cloud diagnostics. MQTT uses TLS with certificate and hostname validation.\n\n### Energy\nGREE Cloud `ElcAll` and selected Home Assistant cumulative energy sensors are normalized to kWh and converted to consumption deltas before aggregation. Raw cumulative values are retained only as meter samples/baselines.\n\n### 0.14.20\nFixes the ARM cross-build bootstrap by removing the invalid xx-cargo version probe, prepares the target Rust standard library in a cacheable pre-source layer, shares dependency fetching across target architectures, and isolates compiled target caches per architecture while preserving DISTRO-selected Alpine/musl and Debian Trixie/glibc runtimes.\n\n### 0.14.19\nHome Assistant OCI builds now compile Rust for amd64/arm64 on the native build platform through xx cross-compilation, keep Cargo registry/target caches between builds, and send a smaller Docker context while preserving the selected Alpine (musl) or Debian Trixie (glibc) runtime. The Web UI also deduplicates the initial GREE Cloud runtime-status GET across the HTTP and WebSocket bootstrap renders.\n\n### 0.14.18\nShared Web UI CSS now uses generic reusable class names for segmented tabs, configuration layouts, chart cards, enable toggles, diagram primitives and Flow block categories; behavior and visual styling are unchanged.\n\n### 0.14.17\nHistory navigation now reuses the same segmented tab component as Flow / Schedules / Automations, with redundant History-specific tab CSS removed.\n\n### 0.14.16\nCustom Charts now expose saved-chart editing in the Web UI, public chart pages can switch history range through an optional hours override, and the standalone chart layout fits the viewport without vertical page scrolling.\n\n### 0.14.15\nHome Assistant add-on Custom Chart links now resolve the primary HA host IPv4 through Supervisor, support an explicit public_chart_base_url override, and enforce the fixed 8787 ingress/application port contract at startup and build time. Standalone bind/port behavior remains configurable.\n\n### 0.14.14\nShared Flow input Home Assistant sources now expose a dedicated entity search box separate from the selected entity_id, making large live entity catalogs easy to filter by name, ID and state.\n\n### 0.14.13\nHome Assistant add-on authentication is now hidden behind an automatic Supervisor status, manual URL/token fallback is unlocked only after a failed HA test, connection tests return a live sample entity reading, and Shared Flow input HA sources include searchable live entity suggestions.\n\n### 0.14.12\nThe Home Assistant add-on now uses the Supervisor Core API proxy and runtime SUPERVISOR_TOKEN automatically; standalone Home Assistant URL/token configuration is unchanged.\n\n### 0.14.11\nWeb UI icons now use a shared inline SVG set instead of font-dependent Unicode glyphs across navigation, dialogs, Flow, charts, controls and event categories for consistent cross-platform rendering.\n\n\n### 0.14.10\nNight mode and Home Assistant / Sensors standalone settings now use the full available page width, matching the other Web UI sections.\n\n### 0.14.9\nPower controls use an embedded SVG icon instead of a font-dependent Unicode glyph. Home Assistant room-sensor stale/error events identify the affected entity and zone, and historical event rows can recover that context from metadata.\n\n### 0.14.8\nRemaining Home Assistant and notification diagnostic errors no longer use device-transport 502 responses. Missing/invalid integration configuration returns 400, configured external dependency failures return 424, and 502 is reserved for Local/LAN GREE device communication.\n\n### 0.14.7\nHome Assistant energy-sensor discovery now returns an empty optional result when HA is not configured, uses 424 for configured external-dependency failures, and custom select popovers use their rendered height when positioning above a field.\n\n\n### 0.14.6\nFixes dynamic custom-select duplication in History → Energy and hides Energy history controls when no energy source is configured.\n\n### 0.14.5\nWeb UI single-choice controls now use one consistent custom dropdown, language packs are loaded on demand, and the dashboard outdoor temperature opens a 24-hour history modal.\n\n### 0.14.4\nHome Assistant connection tests validate only the configured server/API token and require no entity. `outdoor_entity_id` is the global Home Assistant outdoor-temperature sensor; zones can override it with `ha_outdoor_entity_id`. Room-temperature Home Assistant sensors remain configured per zone with `ha_entity_id`. Global/per-zone outdoor sensors are integrated with aliases, metrics/history and Visual Flow entity suggestions.\n\n### 0.14.2\nAdds independent vertical/horizontal swing controls across manual device control, thermostat zones, legacy automations, Visual Flow and Home Assistant zone climates. Swing is treated as an auxiliary one-shot unit setting and does not replace thermostat ownership.\n\n### 0.14.1\nAdds split/multisplit installation groups with shared energy and outdoor-temperature sources, multi-target energy charts with period comparison, cleaner Cloud status/alerts and safe MQTT cleanup after the last Cloud device is removed.\n\n### 0.14.0\nAdds first-class GREE Cloud MQTT status/control, reconnect/resubscribe lifecycle, Cloud diagnostics, per-device capabilities, Cloud/HA energy sources and energy-history aggregation while preserving the LAN transport contract.", + "description": "Self-hosted API used by the GREE Controller Web UI and Home Assistant integration.\n\n### Authentication\nAdministrator endpoints accept `Authorization: Bearer ` or `x-api-token: ` when configured. Restricted Home Assistant control endpoints also accept generated integration tokens.\n\n### Local and GREE Cloud\nDevices use one API model and one command surface. `connection_type=local` is dispatched only to the existing LAN/UDP provider; `connection_type=gree_cloud` is dispatched only to the GREE Cloud provider. No automatic transport fallback is performed.\n\n### GREE Cloud security\nCloud passwords, REST/MQTT tokens, Authorization values and device cipher keys are never returned in normal API responses or Cloud diagnostics. MQTT uses TLS with certificate and hostname validation.\n\n### Energy\nGREE Cloud `ElcAll` and selected Home Assistant cumulative energy sensors are normalized to kWh and converted to consumption deltas before aggregation. Raw cumulative values are retained only as meter samples/baselines.\n\n### 0.14.21\nCode cleanup and small UX changes.\n\n### 0.14.20\nFixes the ARM cross-build bootstrap by removing the invalid xx-cargo version probe, prepares the target Rust standard library in a cacheable pre-source layer, shares dependency fetching across target architectures, and isolates compiled target caches per architecture while preserving DISTRO-selected Alpine/musl and Debian Trixie/glibc runtimes.\n\n### 0.14.19\nHome Assistant OCI builds now compile Rust for amd64/arm64 on the native build platform through xx cross-compilation, keep Cargo registry/target caches between builds, and send a smaller Docker context while preserving the selected Alpine (musl) or Debian Trixie (glibc) runtime. The Web UI also deduplicates the initial GREE Cloud runtime-status GET across the HTTP and WebSocket bootstrap renders.\n\n### 0.14.18\nShared Web UI CSS now uses generic reusable class names for segmented tabs, configuration layouts, chart cards, enable toggles, diagram primitives and Flow block categories; behavior and visual styling are unchanged.\n\n### 0.14.17\nHistory navigation now reuses the same segmented tab component as Flow / Schedules / Automations, with redundant History-specific tab CSS removed.\n\n### 0.14.16\nCustom Charts now expose saved-chart editing in the Web UI, public chart pages can switch history range through an optional hours override, and the standalone chart layout fits the viewport without vertical page scrolling.\n\n### 0.14.15\nHome Assistant add-on Custom Chart links now resolve the primary HA host IPv4 through Supervisor, support an explicit public_chart_base_url override, and enforce the fixed 8787 ingress/application port contract at startup and build time. Standalone bind/port behavior remains configurable.\n\n### 0.14.14\nShared Flow input Home Assistant sources now expose a dedicated entity search box separate from the selected entity_id, making large live entity catalogs easy to filter by name, ID and state.\n\n### 0.14.13\nHome Assistant add-on authentication is now hidden behind an automatic Supervisor status, manual URL/token fallback is unlocked only after a failed HA test, connection tests return a live sample entity reading, and Shared Flow input HA sources include searchable live entity suggestions.\n\n### 0.14.12\nThe Home Assistant add-on now uses the Supervisor Core API proxy and runtime SUPERVISOR_TOKEN automatically; standalone Home Assistant URL/token configuration is unchanged.\n\n### 0.14.11\nWeb UI icons now use a shared inline SVG set instead of font-dependent Unicode glyphs across navigation, dialogs, Flow, charts, controls and event categories for consistent cross-platform rendering.\n\n\n### 0.14.10\nNight mode and Home Assistant / Sensors standalone settings now use the full available page width, matching the other Web UI sections.\n\n### 0.14.9\nPower controls use an embedded SVG icon instead of a font-dependent Unicode glyph. Home Assistant room-sensor stale/error events identify the affected entity and zone, and historical event rows can recover that context from metadata.\n\n### 0.14.8\nRemaining Home Assistant and notification diagnostic errors no longer use device-transport 502 responses. Missing/invalid integration configuration returns 400, configured external dependency failures return 424, and 502 is reserved for Local/LAN GREE device communication.\n\n### 0.14.7\nHome Assistant energy-sensor discovery now returns an empty optional result when HA is not configured, uses 424 for configured external-dependency failures, and custom select popovers use their rendered height when positioning above a field.\n\n\n### 0.14.6\nFixes dynamic custom-select duplication in History → Energy and hides Energy history controls when no energy source is configured.\n\n### 0.14.5\nWeb UI single-choice controls now use one consistent custom dropdown, language packs are loaded on demand, and the dashboard outdoor temperature opens a 24-hour history modal.\n\n### 0.14.4\nHome Assistant connection tests validate only the configured server/API token and require no entity. `outdoor_entity_id` is the global Home Assistant outdoor-temperature sensor; zones can override it with `ha_outdoor_entity_id`. Room-temperature Home Assistant sensors remain configured per zone with `ha_entity_id`. Global/per-zone outdoor sensors are integrated with aliases, metrics/history and Visual Flow entity suggestions.\n\n### 0.14.2\nAdds independent vertical/horizontal swing controls across manual device control, thermostat zones, legacy automations, Visual Flow and Home Assistant zone climates. Swing is treated as an auxiliary one-shot unit setting and does not replace thermostat ownership.\n\n### 0.14.1\nAdds split/multisplit installation groups with shared energy and outdoor-temperature sources, multi-target energy charts with period comparison, cleaner Cloud status/alerts and safe MQTT cleanup after the last Cloud device is removed.\n\n### 0.14.0\nAdds first-class GREE Cloud MQTT status/control, reconnect/resubscribe lifecycle, Cloud diagnostics, per-device capabilities, Cloud/HA energy sources and energy-history aggregation while preserving the LAN transport contract.", "license": { "name": "MIT" } @@ -5775,6 +5775,76 @@ } } } + }, + "/api/integrations/influxdb/test": { + "post": { + "tags": [ + "Settings", + "Diagnostics" + ], + "summary": "Test InfluxDB connection", + "description": "Tests the supplied InfluxDB settings without persisting them. Omitted password/token values reuse the stored secret.", + "operationId": "testInfluxDBConnection", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InfluxDbSettingsUpdate" + } + } + } + }, + "security": [ + { + "BearerToken": [] + }, + { + "ApiTokenHeader": [] + } + ], + "responses": { + "200": { + "description": "Connection succeeded", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ok", + "version", + "response_time_ms" + ], + "properties": { + "ok": { + "type": "boolean", + "example": true + }, + "version": { + "type": "string", + "example": "2" + }, + "response_time_ms": { + "type": "integer", + "minimum": 0, + "example": 18 + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } } }, "components": { @@ -5830,7 +5900,7 @@ }, "version": { "type": "string", - "example": "0.14.20" + "example": "0.14.21" }, "uptime_seconds": { "type": "integer", diff --git a/ha-addon/Dockerfile b/ha-addon/Dockerfile index 7652bac..256a10b 100644 --- a/ha-addon/Dockerfile +++ b/ha-addon/Dockerfile @@ -116,7 +116,7 @@ LABEL io.hass.version="${BUILD_VERSION}" \ org.opencontainers.image.title="GREE Controller" \ org.opencontainers.image.version="${BUILD_VERSION}" \ 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 ha-addon/run.sh /usr/local/bin/run.sh RUN chmod 0755 /usr/local/bin/run.sh /usr/local/bin/gree-controller diff --git a/ha-addon/home-assistant/custom_components/gree_controller/manifest.json b/ha-addon/home-assistant/custom_components/gree_controller/manifest.json index 5275fbf..62edaa4 100644 --- a/ha-addon/home-assistant/custom_components/gree_controller/manifest.json +++ b/ha-addon/home-assistant/custom_components/gree_controller/manifest.json @@ -1,7 +1,7 @@ { "domain": "gree_controller", "name": "GREE Controller", - "version": "0.14.20", + "version": "0.14.21", "config_flow": true, "integration_type": "hub", "iot_class": "local_polling", diff --git a/ha-addon/repository/gree-controller/CHANGELOG.md b/ha-addon/repository/gree-controller/CHANGELOG.md index 50c8776..adb8117 100644 --- a/ha-addon/repository/gree-controller/CHANGELOG.md +++ b/ha-addon/repository/gree-controller/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 0.14.21 + +- Code cleanup and small UX changes. + ## 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. diff --git a/ha-addon/repository/gree-controller/config.yaml b/ha-addon/repository/gree-controller/config.yaml index d7e0e75..caf1cb8 100644 --- a/ha-addon/repository/gree-controller/config.yaml +++ b/ha-addon/repository/gree-controller/config.yaml @@ -1,5 +1,5 @@ name: "GREE Controller" -version: "0.14.20" +version: "0.14.21" slug: "gree_controller" description: "Local GREE HVAC controller with Web UI and Home Assistant integration" url: "https://git.linuxiarz.pl/gru/gree-controller-ha-addon/" diff --git a/lang/en.json b/lang/en.json index bd6ceb2..bc9647c 100644 --- a/lang/en.json +++ b/lang/en.json @@ -82,7 +82,7 @@ "devices.modelFirmware": "Model / firmware", "devices.lastSeen": "Last response", "devices.communicationFailures": "Communication failures", - "devices.lastError": "Last error", + "devices.lastError": "Error", "devices.readStatus": "Read status", "devices.technicalConfig": "Technical configuration", "devices.diagnostics": "Diagnostics", @@ -1274,7 +1274,7 @@ "debug.emptyCloud": "No GREE Cloud API activity captured yet.", "debug.emptyMqtt": "No GREE Cloud MQTT activity captured yet.", "settings.cloudDevicesOnline": "Devices online", - "settings.cloudResponseTime": "Last device response time", + "settings.cloudResponseTime": "Device response time", "settings.cloudLastDeviceResponse": "Last device response", "settings.cloudLastMqttMessage": "Last MQTT activity", "settings.cloudConnectedSince": "MQTT connected since", @@ -1288,7 +1288,7 @@ "devices.cloudAuthenticationProblem": "GREE Cloud authentication error", "devices.cloudTransportProblem": "GREE Cloud connection is unavailable", "devices.cloudDeviceOffline": "GREE Cloud is connected, but the unit is not responding", - "settings.cloudRestResponseTime": "Last REST response time", + "settings.cloudRestResponseTime": "REST response time", "common.pending": "Pending", "devices.installations": "Split / multisplit installations", "devices.installationsHint": "Group indoor units that share one outdoor unit. Energy and outdoor temperature can use one shared source.", @@ -1358,6 +1358,31 @@ "flow.haEntitySearchLabel": "Search Home Assistant entities", "flow.haEntitySearchPlaceholder": "Type a name, entity_id or state…", "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." } } diff --git a/lang/pl.json b/lang/pl.json index 999281c..500b73e 100644 --- a/lang/pl.json +++ b/lang/pl.json @@ -82,7 +82,7 @@ "devices.modelFirmware": "Model / firmware", "devices.lastSeen": "Ostatnia odpowiedź", "devices.communicationFailures": "Błędy komunikacji", - "devices.lastError": "Ostatni błąd", + "devices.lastError": "Błąd", "devices.readStatus": "Odczytaj status", "devices.technicalConfig": "Konfiguracja techniczna", "devices.diagnostics": "Diagnostyka", @@ -1274,7 +1274,7 @@ "debug.emptyCloud": "Brak przechwyconej aktywności API GREE Cloud.", "debug.emptyMqtt": "Brak przechwyconej aktywności MQTT GREE Cloud.", "settings.cloudDevicesOnline": "Urządzenia online", - "settings.cloudResponseTime": "Czas ostatniej odpowiedzi urządzenia", + "settings.cloudResponseTime": "Czas odpowiedzi urządzenia", "settings.cloudLastDeviceResponse": "Ostatnia odpowiedź urządzenia", "settings.cloudLastMqttMessage": "Ostatnia aktywność MQTT", "settings.cloudConnectedSince": "MQTT połączony od", @@ -1288,7 +1288,7 @@ "devices.cloudAuthenticationProblem": "Błąd uwierzytelniania GREE Cloud", "devices.cloudTransportProblem": "Połączenie z GREE Cloud jest niedostępne", "devices.cloudDeviceOffline": "GREE Cloud jest połączony, ale urządzenie nie odpowiada", - "settings.cloudRestResponseTime": "Czas ostatniej odpowiedzi REST", + "settings.cloudRestResponseTime": "Czas odpowiedzi REST", "common.pending": "Oczekiwanie", "devices.installations": "Instalacje split / multisplit", "devices.installationsHint": "Grupuj jednostki wewnętrzne współdzielące jedną jednostkę zewnętrzną. Energia i temperatura zewnętrzna mogą korzystać z jednego wspólnego źródła.", @@ -1358,6 +1358,31 @@ "flow.haEntitySearchLabel": "Szukaj encji Home Assistant", "flow.haEntitySearchPlaceholder": "Wpisz nazwę, entity_id lub stan…", "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." } } diff --git a/scripts/live_realtime_test.js b/scripts/live_realtime_test.js index c098766..e1b026d 100755 --- a/scripts/live_realtime_test.js +++ b/scripts/live_realtime_test.js @@ -76,9 +76,9 @@ const renderNames = [ for (const name of renderNames) context[name] = hit(name); 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/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/dashboard.js'), 'utf8'), context, { filename: 'dashboard.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-dynamic/realtime.js'), 'utf8'), context, { filename: 'realtime.js' }); // Source files define DOM-heavy renderers. Replace those implementations for this state-machine test. for (const name of renderNames) context[name] = hit(name); context.updateDevice = device => { @@ -118,7 +118,7 @@ async function testBootstrapReloadQueue() { setTimeout: (fn, delay) => { queuedTimers.push({ fn, delay }); return queuedTimers.length; }, }; 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(); assert.equal(c.app.loading, true); await c.loadBootstrap(); diff --git a/src/api.rs b/src/api.rs index ebf32e3..90ee360 100644 --- a/src/api.rs +++ b/src/api.rs @@ -4,16 +4,16 @@ use crate::{ home_assistant, influxdb, models::{ ApiTokenInfo, ApplicationSettings, Automation, ClimateGroup, ConfigurationExport, - ConnectionStatus, ConnectionType, DebugSettings, Device, DeviceCommand, DeviceGroup, DeviceGroupKind, DevicePatch, DiscoveryRequest, - EnergyReading, EnergySourcePreference, Flow, GreeSettings, - GreeCloudSettings, GreeCloudSettingsUpdate, GreeCloudSettingsView, + ConnectionStatus, ConnectionType, DebugSettings, Device, DeviceCommand, DeviceGroup, + DeviceGroupKind, DevicePatch, DiscoveryRequest, EnergyReading, EnergySourcePreference, + Flow, GreeCloudSettings, GreeCloudSettingsUpdate, GreeCloudSettingsView, GreeSettings, GroupControlPatch, HaReading, HistorySettings, HomeAssistantSettings, HomeAssistantSettingsUpdate, HomeAssistantSettingsView, InfluxDbSettings, - InfluxDbSettingsUpdate, InfluxDbSettingsView, ManualDeviceRequest, NightModeSettings, - NotificationSettings, NotificationSettingsUpdate, NotificationSettingsView, NetworkReading, Reading, - RuntimeSettings, Schedule, SettingsSnapshot, TemporaryQuickThermostat, - TemporaryQuickThermostatRequest, Zone, - ZoneControlPatch, ZoneReading, + InfluxDbSettingsUpdate, InfluxDbSettingsView, ManualDeviceRequest, NetworkReading, + NightModeSettings, NotificationSettings, NotificationSettingsUpdate, + NotificationSettingsView, Reading, RuntimeSettings, Schedule, SettingsSnapshot, + TemporaryQuickThermostat, TemporaryQuickThermostatRequest, Zone, ZoneControlPatch, + ZoneReading, }, notifications, protocol::merge_discovered, @@ -50,14 +50,14 @@ mod openapi; const INDEX_HTML: &str = include_str!("../web/index.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 APP_JS: &str = include_str!(concat!(env!("OUT_DIR"), "/app.bundle.js")); -const THEME_INIT_JS: &str = include_str!("../web/theme-init.js"); -const LANG_INIT_JS: &str = include_str!("../web/lang-init.js"); -const STYLES_CSS: &str = include_str!("../web/styles.css"); +const THEME_INIT_JS: &str = include_str!("../web/js/theme-init.js"); +const LANG_INIT_JS: &str = include_str!("../web/js/lang-init.js"); +const STYLES_CSS: &str = include_str!("../web/css/styles.css"); 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"); 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/probe", post(probe_device)) .route("/api/devices/:id/command", post(command_device)) - .route("/api/device-groups", get(list_device_groups).post(create_device_group)) + .route( + "/api/device-groups", + get(list_device_groups).post(create_device_group), + ) .route( "/api/device-groups/:id", - get(get_device_group).put(update_device_group).delete(delete_device_group), + get(get_device_group) + .put(update_device_group) + .delete(delete_device_group), ) .route("/api/zones", get(list_zones).post(create_zone)) .route( @@ -183,6 +188,10 @@ pub fn router(state: AppState) -> Router { "/api/settings/influxdb", get(get_influxdb_settings).put(update_influxdb_settings), ) + .route( + "/api/integrations/influxdb/test", + post(test_influxdb_connection), + ) .route( "/api/settings/notifications", get(get_notification_settings).put(update_notification_settings), @@ -209,10 +218,7 @@ pub fn router(state: AppState) -> Router { "/api/access-tokens/:id", axum::routing::delete(delete_access_token), ) - .route( - "/api/integrations/gree-cloud/test", - post(test_gree_cloud), - ) + .route("/api/integrations/gree-cloud/test", post(test_gree_cloud)) .route( "/api/integrations/gree-cloud/devices", get(discover_gree_cloud_devices), diff --git a/src/api/assets.rs b/src/api/assets.rs index 89abb15..63b6ed9 100644 --- a/src/api/assets.rs +++ b/src/api/assets.rs @@ -37,6 +37,10 @@ async fn custom_chart_page(State(state): State, headers: HeaderMap) -> "__GREE_THEME_INIT_ASSET__", &format!("{base}{THEME_INIT_ASSET_PATH}"), ) + .replace( + "__GREE_LANG_INIT_ASSET__", + &format!("{base}{LANG_INIT_ASSET_PATH}"), + ) .replace( "__GREE_STYLES_ASSET__", &format!("{base}{STYLES_CSS_ASSET_PATH}"), @@ -50,10 +54,9 @@ async fn custom_chart_page(State(state): State, headers: HeaderMap) -> header::CONTENT_TYPE, HeaderValue::from_static("text/html; charset=utf-8"), ); - response.headers_mut().insert( - header::CACHE_CONTROL, - HeaderValue::from_static("public, no-cache"), - ); + response + .headers_mut() + .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache")); response } @@ -183,7 +186,11 @@ async fn language_file(Path(file): Path) -> Response { .iter() .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")); *response.status_mut() = StatusCode::NOT_FOUND; diff --git a/src/api/configuration.rs b/src/api/configuration.rs index f06cdea..2b205f0 100644 --- a/src/api/configuration.rs +++ b/src/api/configuration.rs @@ -50,7 +50,11 @@ fn collect_configuration_ids( let ids = ConfigurationIds { devices: export.devices.iter().map(|item| item.id.as_str()).collect(), zones: export.zones.iter().map(|item| item.id.as_str()).collect(), - device_groups: export.device_groups.iter().map(|item| item.id.as_str()).collect(), + device_groups: export + .device_groups + .iter() + .map(|item| item.id.as_str()) + .collect(), schedules: export .schedules .iter() @@ -190,21 +194,33 @@ fn validate_configuration_devices_and_zones( } if !installation_devices.insert(device_id.as_str()) { 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( "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( "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( "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(|| { 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( "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") - || !matches!(group.ha_energy_state_class.as_deref(), Some("total" | "total_increasing")) - || !matches!(group.ha_energy_unit.as_deref().map(str::to_ascii_lowercase).as_deref(), Some("wh" | "kwh")) + || !matches!( + group.ha_energy_state_class.as_deref(), + Some("total" | "total_increasing") + ) + || !matches!( + group + .ha_energy_unit + .as_deref() + .map(str::to_ascii_lowercase) + .as_deref(), + Some("wh" | "kwh") + ) { return Err(AppError::BadRequest( "import contains an invalid Home Assistant cumulative energy entity".into(), )); } } - if group.outdoor_temperature_device_id.as_deref().is_some_and(|id| !group.device_ids.iter().any(|member| member == id)) { + if group + .outdoor_temperature_device_id + .as_deref() + .is_some_and(|id| !group.device_ids.iter().any(|member| member == id)) + { return Err(AppError::BadRequest( "import contains an installation outdoor-temperature source outside the installation".into(), )); @@ -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.clamp(30, 3600); - settings.gree_cloud.connectivity_metrics_interval_seconds = - settings.gree_cloud.connectivity_metrics_interval_seconds.clamp(30, 3600); - settings.gree_cloud.connectivity_metrics_sample_count = - settings.gree_cloud.connectivity_metrics_sample_count.clamp(1, 10); + settings.gree_cloud.connectivity_metrics_interval_seconds = settings + .gree_cloud + .connectivity_metrics_interval_seconds + .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() { settings.gree_cloud.account_id = "default".into(); } @@ -710,8 +750,12 @@ async fn hydrate_imported_cloud_device_keys( ) -> Result<(), AppError> { if export.settings.gree_cloud.password.trim().is_empty() { let current = state.settings.read().await.gree_cloud.clone(); - if current.region.eq_ignore_ascii_case(&export.settings.gree_cloud.region) - && current.username.eq_ignore_ascii_case(&export.settings.gree_cloud.username) + if current + .region + .eq_ignore_ascii_case(&export.settings.gree_cloud.region) + && current + .username + .eq_ignore_ascii_case(&export.settings.gree_cloud.username) && !current.password.trim().is_empty() { export.settings.gree_cloud.password = current.password; @@ -732,9 +776,11 @@ async fn hydrate_imported_cloud_device_keys( &settings.username, &settings.password, ) - .map_err(|err| AppError::BadRequest(format!( - "cannot restore GREE Cloud device secrets from account: {err}" - )))?; + .map_err(|err| { + AppError::BadRequest(format!( + "cannot restore GREE Cloud device secrets from account: {err}" + )) + })?; api.login().await.map_err(|err| { AppError::BadRequest(format!( "cannot restore GREE Cloud device secrets: account login failed: {err}" @@ -767,7 +813,9 @@ async fn hydrate_imported_cloud_device_keys( device.key = Some(found.key.clone()); let normalized_cloud_mac = found.mac.replace([':', '-'], "").to_ascii_uppercase(); device.cloud_device_id = Some(normalized_cloud_mac.clone()); - device.cloud_parent_mac = Some(crate::protocol::gree_cloud::parent_mac(&normalized_cloud_mac)); + device.cloud_parent_mac = Some(crate::protocol::gree_cloud::parent_mac( + &normalized_cloud_mac, + )); device.cloud_account_id = Some(settings.account_id.clone()); if device.model.trim().is_empty() { device.model = found.model.clone().unwrap_or_default(); diff --git a/src/api/device_groups.rs b/src/api/device_groups.rs index 5a75600..ef1ce34 100644 --- a/src/api/device_groups.rs +++ b/src/api/device_groups.rs @@ -22,7 +22,9 @@ struct DeviceGroupInput { } fn normalize_optional(value: Option) -> Option { - 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( @@ -33,63 +35,118 @@ fn validate_device_group_input( if input.name.trim().is_empty() { return Err(AppError::BadRequest("installation name is required".into())); } - let mut device_ids = input.device_ids.iter().map(|id| id.trim().to_string()).filter(|id| !id.is_empty()).collect::>(); + let mut device_ids = input + .device_ids + .iter() + .map(|id| id.trim().to_string()) + .filter(|id| !id.is_empty()) + .collect::>(); device_ids.sort(); device_ids.dedup(); if device_ids.is_empty() { - return Err(AppError::BadRequest("installation must contain at least one device".into())); + return Err(AppError::BadRequest( + "installation must contain at least one device".into(), + )); } if input.kind == DeviceGroupKind::Split && device_ids.len() != 1 { - return Err(AppError::BadRequest("split installation must contain exactly one device".into())); + return Err(AppError::BadRequest( + "split installation must contain exactly one device".into(), + )); } for device_id in &device_ids { if state.db.get_device(device_id)?.is_none() { - return Err(AppError::BadRequest(format!("installation references missing device {device_id}"))); + return Err(AppError::BadRequest(format!( + "installation references missing device {device_id}" + ))); } } for existing in state.db.list_device_groups()? { - if editing_id == Some(existing.id.as_str()) { continue; } - if let Some(device_id) = device_ids.iter().find(|id| existing.device_ids.iter().any(|other| other == *id)) { - return Err(AppError::BadRequest(format!("device {device_id} already belongs to installation '{}'", existing.name))); + if editing_id == Some(existing.id.as_str()) { + continue; + } + if let Some(device_id) = device_ids + .iter() + .find(|id| existing.device_ids.iter().any(|other| other == *id)) + { + return Err(AppError::BadRequest(format!( + "device {device_id} already belongs to installation '{}'", + existing.name + ))); } } let energy_device_id = normalize_optional(input.energy_device_id.clone()); if let Some(ref id) = energy_device_id { if !device_ids.iter().any(|device_id| device_id == id) { - return Err(AppError::BadRequest("energy source device must belong to this installation".into())); + return Err(AppError::BadRequest( + "energy source device must belong to this installation".into(), + )); } - let device = state.db.get_device(id)?.ok_or_else(|| AppError::BadRequest("energy source device does not exist".into()))?; - if device.connection_type != ConnectionType::GreeCloud || !device.capabilities.energy_meter { - return Err(AppError::BadRequest("selected device does not expose GREE Cloud energy".into())); + let device = state + .db + .get_device(id)? + .ok_or_else(|| AppError::BadRequest("energy source device does not exist".into()))?; + if device.connection_type != ConnectionType::GreeCloud || !device.capabilities.energy_meter + { + return Err(AppError::BadRequest( + "selected device does not expose GREE Cloud energy".into(), + )); } } if input.energy_source == EnergySourcePreference::GreeCloud && energy_device_id.is_none() { - return Err(AppError::BadRequest("select a GREE Cloud energy source device".into())); + return Err(AppError::BadRequest( + "select a GREE Cloud energy source device".into(), + )); } let entity = normalize_optional(input.ha_energy_entity_id.clone()); if input.energy_source == EnergySourcePreference::HomeAssistant && entity.is_none() { - return Err(AppError::BadRequest("select a Home Assistant cumulative energy sensor".into())); + return Err(AppError::BadRequest( + "select a Home Assistant cumulative energy sensor".into(), + )); } if entity.is_some() { if input.ha_energy_device_class.as_deref() != Some("energy") { - return Err(AppError::BadRequest("Home Assistant energy sensor must have device_class=energy".into())); + return Err(AppError::BadRequest( + "Home Assistant energy sensor must have device_class=energy".into(), + )); } - if !matches!(input.ha_energy_state_class.as_deref(), Some("total" | "total_increasing")) { - return Err(AppError::BadRequest("Home Assistant energy sensor must have state_class=total or total_increasing".into())); + if !matches!( + input.ha_energy_state_class.as_deref(), + Some("total" | "total_increasing") + ) { + return Err(AppError::BadRequest( + "Home Assistant energy sensor must have state_class=total or total_increasing" + .into(), + )); } - if !matches!(input.ha_energy_unit.as_deref().map(str::to_ascii_lowercase).as_deref(), Some("wh" | "kwh")) { - return Err(AppError::BadRequest("Home Assistant energy sensor must use Wh or kWh".into())); + if !matches!( + input + .ha_energy_unit + .as_deref() + .map(str::to_ascii_lowercase) + .as_deref(), + Some("wh" | "kwh") + ) { + return Err(AppError::BadRequest( + "Home Assistant energy sensor must use Wh or kWh".into(), + )); } } if let Some(id) = normalize_optional(input.outdoor_temperature_device_id.clone()) { if !device_ids.iter().any(|device_id| device_id == &id) { - return Err(AppError::BadRequest("outdoor temperature source must belong to this installation".into())); + return Err(AppError::BadRequest( + "outdoor temperature source must belong to this installation".into(), + )); } } Ok(device_ids) } -fn device_group_from_input(id: String, existing: Option, input: DeviceGroupInput, device_ids: Vec) -> DeviceGroup { +fn device_group_from_input( + id: String, + existing: Option, + input: DeviceGroupInput, + device_ids: Vec, +) -> DeviceGroup { let now = Utc::now(); DeviceGroup { id, @@ -108,15 +165,27 @@ fn device_group_from_input(id: String, existing: Option, input: Dev } } -async fn list_device_groups(State(state): State) -> Result>, AppError> { +async fn list_device_groups( + State(state): State, +) -> Result>, AppError> { Ok(Json(state.db.list_device_groups()?)) } -async fn get_device_group(State(state): State, Path(id): Path) -> Result, AppError> { - state.db.get_device_group(&id)?.map(Json).ok_or_else(|| AppError::NotFound(format!("device group {id}"))) +async fn get_device_group( + State(state): State, + Path(id): Path, +) -> Result, AppError> { + state + .db + .get_device_group(&id)? + .map(Json) + .ok_or_else(|| AppError::NotFound(format!("device group {id}"))) } -async fn create_device_group(State(state): State, Json(input): Json) -> Result<(StatusCode, Json), AppError> { +async fn create_device_group( + State(state): State, + Json(input): Json, +) -> Result<(StatusCode, Json), AppError> { let _guard = state.lock_configuration_operation().await; let device_ids = validate_device_group_input(&state, &input, None)?; let group = device_group_from_input(Uuid::new_v4().to_string(), None, input, device_ids); @@ -125,9 +194,16 @@ async fn create_device_group(State(state): State, Json(input): Json, Path(id): Path, Json(input): Json) -> Result, AppError> { +async fn update_device_group( + State(state): State, + Path(id): Path, + Json(input): Json, +) -> Result, AppError> { let _guard = state.lock_configuration_operation().await; - let existing = state.db.get_device_group(&id)?.ok_or_else(|| AppError::NotFound(format!("device group {id}")))?; + let existing = state + .db + .get_device_group(&id)? + .ok_or_else(|| AppError::NotFound(format!("device group {id}")))?; let device_ids = validate_device_group_input(&state, &input, Some(&id))?; let group = device_group_from_input(id, Some(existing), input, device_ids); state.db.save_device_group(&group)?; @@ -135,7 +211,10 @@ async fn update_device_group(State(state): State, Path(id): Path, Path(id): Path) -> Result { +async fn delete_device_group( + State(state): State, + Path(id): Path, +) -> Result { let _guard = state.lock_configuration_operation().await; if !state.db.delete_device_group(&id)? { return Err(AppError::NotFound(format!("device group {id}"))); diff --git a/src/api/devices.rs b/src/api/devices.rs index 7fae407..8a7606d 100644 --- a/src/api/devices.rs +++ b/src/api/devices.rs @@ -193,7 +193,10 @@ async fn patch_device( .get_device(&id)? .ok_or_else(|| AppError::NotFound(format!("device {id}")))?; if device.connection_type == ConnectionType::GreeCloud - && (patch.ip.is_some() || patch.port.is_some() || patch.protocol_version.is_some() || patch.key.is_some()) + && (patch.ip.is_some() + || patch.port.is_some() + || patch.protocol_version.is_some() + || patch.key.is_some()) { return Err(AppError::BadRequest( "IP, UDP port, local protocol and local key are not configurable for GREE Cloud devices".into(), @@ -232,19 +235,35 @@ async fn patch_device( if let Some(v) = patch.enabled { device.enabled = v; } - if let Some(v) = patch.energy_source { device.energy_source = v; } - if let Some(v) = patch.ha_energy_entity_id { device.ha_energy_entity_id = v.filter(|x| !x.trim().is_empty()); } - if let Some(v) = patch.ha_energy_unit { device.ha_energy_unit = v.filter(|x| !x.trim().is_empty()); } - if let Some(v) = patch.ha_energy_device_class { device.ha_energy_device_class = v.filter(|x| !x.trim().is_empty()); } - if let Some(v) = patch.ha_energy_state_class { device.ha_energy_state_class = v.filter(|x| !x.trim().is_empty()); } + if let Some(v) = patch.energy_source { + device.energy_source = v; + } + if let Some(v) = patch.ha_energy_entity_id { + device.ha_energy_entity_id = v.filter(|x| !x.trim().is_empty()); + } + if let Some(v) = patch.ha_energy_unit { + device.ha_energy_unit = v.filter(|x| !x.trim().is_empty()); + } + if let Some(v) = patch.ha_energy_device_class { + device.ha_energy_device_class = v.filter(|x| !x.trim().is_empty()); + } + if let Some(v) = patch.ha_energy_state_class { + device.ha_energy_state_class = v.filter(|x| !x.trim().is_empty()); + } device.refresh_capabilities(); - if device.energy_source == EnergySourcePreference::GreeCloud && !device.capabilities.energy_meter { + if device.energy_source == EnergySourcePreference::GreeCloud + && !device.capabilities.energy_meter + { return Err(AppError::BadRequest( "GREE Cloud energy is not available for this device".into(), )); } if device.energy_source == EnergySourcePreference::HomeAssistant - && device.ha_energy_entity_id.as_deref().unwrap_or_default().is_empty() + && device + .ha_energy_entity_id + .as_deref() + .unwrap_or_default() + .is_empty() { return Err(AppError::BadRequest( "select a Home Assistant cumulative energy sensor first".into(), @@ -256,13 +275,21 @@ async fn patch_device( "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( - "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!( - 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") ) { return Err(AppError::BadRequest( @@ -331,7 +358,10 @@ async fn delete_device( .iter() .filter(|group| { !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()) .collect(); @@ -380,7 +410,9 @@ async fn bind_device( .get_device(&id)? .ok_or_else(|| AppError::NotFound(format!("device {id}")))?; if device.connection_type == ConnectionType::GreeCloud { - return Err(AppError::BadRequest("bind is only available for Local/LAN devices".into())); + return Err(AppError::BadRequest( + "bind is only available for Local/LAN devices".into(), + )); } if device.simulated { return Ok(Json(device)); @@ -426,7 +458,9 @@ async fn probe_device( .get_device(&id)? .ok_or_else(|| AppError::NotFound(format!("device {id}")))?; if device.connection_type == ConnectionType::GreeCloud { - return Err(AppError::BadRequest("UDP probe is only available for Local/LAN devices".into())); + return Err(AppError::BadRequest( + "UDP probe is only available for Local/LAN devices".into(), + )); } let response_time_ms = state .providers diff --git a/src/api/gree_cloud.rs b/src/api/gree_cloud.rs index a20dfa9..4918dcb 100644 --- a/src/api/gree_cloud.rs +++ b/src/api/gree_cloud.rs @@ -19,7 +19,9 @@ fn cloud_error_kind(error: &anyhow::Error) -> &'static str { } } -async fn cloud_api_from_settings(state: &AppState) -> Result { +async fn cloud_api_from_settings( + state: &AppState, +) -> Result { let settings = state.settings.read().await.gree_cloud.clone(); crate::protocol::gree_cloud::GreeCloudApi::for_region( state.http.clone(), @@ -33,7 +35,10 @@ async fn cloud_api_from_settings(state: &AppState) -> Result) -> Result, AppError> { let mut api = cloud_api_from_settings(&state).await?; let started = Instant::now(); - cloud_debug_event(&state, json!({"operation":"test_connection","phase":"sent"})); + cloud_debug_event( + &state, + json!({"operation":"test_connection","phase":"sent"}), + ); let result = async { api.login().await?; let devices = api.get_all_devices().await?; @@ -44,12 +49,15 @@ async fn test_gree_cloud(State(state): State) -> Result, A match result { Ok(device_count) => { let now = Utc::now(); - cloud_debug_event(&state, json!({ - "operation":"test_connection", - "phase":"response", - "duration_ms": started.elapsed().as_millis() as u64, - "device_count": device_count, - })); + cloud_debug_event( + &state, + json!({ + "operation":"test_connection", + "phase":"response", + "duration_ms": started.elapsed().as_millis() as u64, + "device_count": device_count, + }), + ); { let _configuration_guard = state.lock_configuration_operation().await; let mut settings = state.settings.write().await; @@ -73,12 +81,15 @@ async fn test_gree_cloud(State(state): State) -> Result, A } Err(error) => { let kind = cloud_error_kind(&error); - cloud_debug_event(&state, json!({ - "operation":"test_connection", - "phase":"error", - "duration_ms": started.elapsed().as_millis() as u64, - "kind": kind, - })); + cloud_debug_event( + &state, + json!({ + "operation":"test_connection", + "phase":"error", + "duration_ms": started.elapsed().as_millis() as u64, + "kind": kind, + }), + ); tracing::warn!(kind, "GREE Cloud connection test failed"); state.log( "warn", @@ -107,38 +118,42 @@ async fn discover_gree_cloud_devices( let mut api = cloud_api_from_settings(&state).await?; let started = Instant::now(); cloud_debug_event(&state, json!({"operation":"discovery","phase":"sent"})); - api.login() - .await - .map_err(|err| { - cloud_debug_event(&state, json!({ + api.login().await.map_err(|err| { + cloud_debug_event( + &state, + json!({ "operation":"discovery", "phase":"error", "stage":"login", "duration_ms": started.elapsed().as_millis() as u64, "kind": cloud_error_kind(&err), - })); - AppError::Dependency(format!("GREE Cloud login failed: {err}")) - })?; - let devices = api - .get_all_devices() - .await - .map_err(|err| { - cloud_debug_event(&state, json!({ + }), + ); + AppError::Dependency(format!("GREE Cloud login failed: {err}")) + })?; + let devices = api.get_all_devices().await.map_err(|err| { + cloud_debug_event( + &state, + json!({ "operation":"discovery", "phase":"error", "stage":"devices", "duration_ms": started.elapsed().as_millis() as u64, "kind": cloud_error_kind(&err), - })); - AppError::Dependency(format!("GREE Cloud discovery failed: {err}")) - })?; + }), + ); + AppError::Dependency(format!("GREE Cloud discovery failed: {err}")) + })?; let rest_duration_ms = started.elapsed().as_millis().min(u64::MAX as u128) as u64; - cloud_debug_event(&state, json!({ - "operation":"discovery", - "phase":"response", - "duration_ms": rest_duration_ms, - "device_count": devices.len(), - })); + cloud_debug_event( + &state, + json!({ + "operation":"discovery", + "phase":"response", + "duration_ms": rest_duration_ms, + "device_count": devices.len(), + }), + ); { let _configuration_guard = state.lock_configuration_operation().await; let mut settings = state.settings.write().await; @@ -153,7 +168,10 @@ async fn discover_gree_cloud_devices( let id = device.mac.replace([':', '-'], "").to_ascii_uppercase(); let already_added = existing.iter().any(|saved| { saved.connection_type == ConnectionType::GreeCloud - && saved.cloud_device_id.as_deref().is_some_and(|value| value.eq_ignore_ascii_case(&id)) + && saved + .cloud_device_id + .as_deref() + .is_some_and(|value| value.eq_ignore_ascii_case(&id)) }); crate::protocol::gree_cloud::CloudDeviceView { parent_mac: crate::protocol::gree_cloud::parent_mac(&id), @@ -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. let mut api = cloud_api_from_settings(&state).await?; let started = Instant::now(); - cloud_debug_event(&state, json!({ - "operation":"add_device_lookup", - "phase":"sent", - "cloud_device_id": cloud_id.clone(), - })); - api.login() - .await - .map_err(|err| { - cloud_debug_event(&state, json!({ + cloud_debug_event( + &state, + json!({ + "operation":"add_device_lookup", + "phase":"sent", + "cloud_device_id": cloud_id.clone(), + }), + ); + api.login().await.map_err(|err| { + cloud_debug_event( + &state, + json!({ "operation":"add_device_lookup", "phase":"error", "stage":"login", "duration_ms": started.elapsed().as_millis() as u64, "kind": cloud_error_kind(&err), - })); - AppError::Dependency(format!("GREE Cloud login failed: {err}")) - })?; + }), + ); + AppError::Dependency(format!("GREE Cloud login failed: {err}")) + })?; let cloud_device = api .get_all_devices() .await .map_err(|err| { - cloud_debug_event(&state, json!({ - "operation":"add_device_lookup", - "phase":"error", - "stage":"devices", - "duration_ms": started.elapsed().as_millis() as u64, - "kind": cloud_error_kind(&err), - })); + cloud_debug_event( + &state, + json!({ + "operation":"add_device_lookup", + "phase":"error", + "stage":"devices", + "duration_ms": started.elapsed().as_millis() as u64, + "kind": cloud_error_kind(&err), + }), + ); AppError::Dependency(format!("GREE Cloud discovery failed: {err}")) })? .into_iter() .find(|device| device.mac.eq_ignore_ascii_case(&cloud_id)) .ok_or_else(|| AppError::NotFound(format!("GREE Cloud device {cloud_id}")))?; - cloud_debug_event(&state, json!({ - "operation":"add_device_lookup", - "phase":"response", - "duration_ms": started.elapsed().as_millis() as u64, - "cloud_device_id": cloud_id.clone(), - })); + cloud_debug_event( + &state, + json!({ + "operation":"add_device_lookup", + "phase":"response", + "duration_ms": started.elapsed().as_millis() as u64, + "cloud_device_id": cloud_id.clone(), + }), + ); let _configuration_guard = state.lock_configuration_operation().await; let now = Utc::now(); let account_id = state.settings.read().await.gree_cloud.account_id.clone(); - let normalized_cloud_mac = cloud_device.mac.replace([':', '-'], "").to_ascii_uppercase(); + let normalized_cloud_mac = cloud_device + .mac + .replace([':', '-'], "") + .to_ascii_uppercase(); let device = Device { id: format!("gree-cloud-{}", normalized_cloud_mac.to_ascii_lowercase()), mac: normalized_cloud_mac.clone(), @@ -251,7 +282,9 @@ async fn add_gree_cloud_device( connection_type: ConnectionType::GreeCloud, connection_status: ConnectionStatus::CloudDisconnected, cloud_device_id: Some(normalized_cloud_mac.clone()), - cloud_parent_mac: Some(crate::protocol::gree_cloud::parent_mac(&normalized_cloud_mac)), + cloud_parent_mac: Some(crate::protocol::gree_cloud::parent_mac( + &normalized_cloud_mac, + )), cloud_account_id: Some(account_id), ip: String::new(), port: 0, @@ -383,7 +416,9 @@ async fn reconnect_gree_cloud(State(state): State) -> Result= cutoff { @@ -526,6 +517,7 @@ struct PublicChartPoint { struct PublicChartSeries { key: String, label: String, + label_key: &'static str, dashed: bool, points: Vec, } @@ -537,7 +529,9 @@ fn validate_public_chart_spec(series: &[String]) -> Result<(), AppError> { .iter() .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 { @@ -545,7 +539,7 @@ fn validate_public_chart_spec(series: &[String]) -> Result<(), AppError> { let kind = parts.next().unwrap_or_default(); let id = 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!( "unsupported custom chart series: {key}" ))); @@ -568,22 +562,17 @@ async fn create_public_custom_chart( validate_public_chart_spec(&input.series)?; let hours = input.hours.unwrap_or(24).clamp(1, 24 * 3650); - let lang = if input.lang.as_deref() == Some("pl") { - "pl" - } else { - "en" - }; - let default_title = if lang == "pl" { - "Wykres niestandardowy" - } else { - "Custom chart" - }; + let requested_lang = input.lang.as_deref().unwrap_or_default().trim(); + let lang = LANGUAGE_ASSETS + .iter() + .find(|(code, _)| *code == requested_lang) + .map(|(code, _)| (*code).to_string()) + .unwrap_or_else(|| DEFAULT_LANGUAGE_CODE.to_string()); let title = input .title .as_deref() .map(str::trim) - .filter(|value| !value.is_empty()) - .unwrap_or(default_title) + .unwrap_or_default() .chars() .take(120) .collect::(); @@ -591,7 +580,7 @@ async fn create_public_custom_chart( title, series: input.series, hours, - lang: lang.to_string(), + lang, }; 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> { - let pl = lang == "pl"; +fn public_chart_field_label_key(kind: &str, field: &str) -> Option<&'static str> { match (kind, field) { - ("device", "indoor") => Some(if pl { "Temperatura wewnętrzna" } else { "Indoor temperature" }), - ("device", "outdoor") => Some(if pl { "Temperatura zewnętrzna GREE" } else { "GREE outdoor temperature" }), - ("device", "target") => Some(if pl { "Temperatura zadana urządzenia" } else { "Device target" }), - ("installation", "outdoor") => Some(if pl { "Wspólna temperatura zewnętrzna" } else { "Shared outdoor temperature" }), - ("zone", "control") => Some(if pl { "Temperatura sterująca" } else { "Control temperature" }), - ("zone", "gree") => Some(if pl { "Czujnik GREE" } else { "GREE sensor" }), - ("zone", "external") => Some(if pl { "Czujnik pomieszczenia" } else { "Room sensor" }), - ("zone", "target") => Some(if pl { "Temperatura docelowa" } else { "Comfort target" }), - ("zone", "device_target") => Some(if pl { "Nastawa urządzenia" } else { "Device setpoint" }), - ("zone", "outdoor") => Some(if pl { "Temperatura zewnętrzna" } else { "Outdoor temperature" }), - ("ha", "temperature") => Some(if pl { "Temperatura" } else { "Temperature" }), + ("device", "indoor") => Some("publicChart.field.deviceIndoor"), + ("device", "outdoor") => Some("publicChart.field.deviceOutdoor"), + ("device", "target") => Some("publicChart.field.deviceTarget"), + ("installation", "outdoor") => Some("publicChart.field.sharedOutdoor"), + ("zone", "control") => Some("publicChart.field.zoneControl"), + ("zone", "gree") => Some("publicChart.field.greeSensor"), + ("zone", "external") => Some("publicChart.field.roomSensor"), + ("zone", "target") => Some("publicChart.field.comfortTarget"), + ("zone", "device_target") => Some("publicChart.field.deviceSetpoint"), + ("zone", "outdoor") => Some("publicChart.field.outdoor"), + ("ha", "temperature") => Some("publicChart.field.temperature"), _ => None, } } @@ -632,7 +620,10 @@ fn reading_points(rows: Vec, field: &str) -> Vec { "target" => Some(row.target_temperature), _ => None, }?; - value.is_finite().then_some(PublicChartPoint { timestamp: row.timestamp, value }) + value.is_finite().then_some(PublicChartPoint { + timestamp: row.timestamp, + value, + }) }) .collect() } @@ -649,7 +640,10 @@ fn zone_reading_points(rows: Vec, field: &str) -> Vec row.outdoor_temperature, _ => None, }?; - value.is_finite().then_some(PublicChartPoint { timestamp: row.timestamp, value }) + value.is_finite().then_some(PublicChartPoint { + timestamp: row.timestamp, + value, + }) }) .collect() } @@ -670,7 +664,11 @@ async fn public_custom_chart( validate_public_chart_spec(&share.series)?; 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 bucket_seconds = history_bucket_seconds(hours); let limit = 20_000; @@ -683,43 +681,73 @@ async fn public_custom_chart( let kind = parts.next().unwrap_or_default(); let id = parts.next().unwrap_or_default(); let field = parts.next().unwrap_or_default(); - let field_label = public_chart_field_label(kind, field, lang) - .ok_or_else(|| AppError::BadRequest(format!("unsupported custom chart series: {key}")))?; + let label_key = public_chart_field_label_key(kind, field).ok_or_else(|| { + AppError::BadRequest(format!("unsupported custom chart series: {key}")) + })?; let item = match kind { "device" => { - let device = state.db.get_device(id)? + let device = state + .db + .get_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 { key: key.clone(), - label: format!("{} · {}", device.name, field_label), + label: device.name, + label_key, dashed: field == "target", points: reading_points(rows, field), } } "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}")))?; - let representative = group.outdoor_temperature_device_id.as_deref() - .filter(|device_id| group.device_ids.iter().any(|member| member.as_str() == *device_id)) + let representative = group + .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)) - .ok_or_else(|| AppError::BadRequest(format!("device group {id} has no devices")))?; - let (rows, _, _) = combined_device_history(&state, Some(representative), since.clone(), bucket_seconds, limit).await?; + .ok_or_else(|| { + AppError::BadRequest(format!("device group {id} has no devices")) + })?; + let (rows, _, _) = combined_device_history( + &state, + Some(representative), + since.clone(), + bucket_seconds, + limit, + ) + .await?; PublicChartSeries { key: key.clone(), - label: format!("{} · {}", group.name, field_label), + label: group.name, + label_key, dashed: false, points: reading_points(rows, "outdoor"), } } "zone" => { - let zone = state.db.get_zone(id)? + let zone = state + .db + .get_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 { key: key.clone(), - label: format!("{} · {}", zone.name, field_label), + label: zone.name, + label_key, dashed: matches!(field, "target" | "device_target" | "outdoor"), points: zone_reading_points(rows, field), } @@ -732,36 +760,40 @@ async fn public_custom_chart( bucket_seconds, limit, &outdoor_entity, - ).await?; - let alias = ha_settings.sensor_aliases.get(id) + ) + .await?; + let alias = ha_settings + .sensor_aliases + .get(id) .map(String::as_str) .filter(|value| !value.trim().is_empty()) .unwrap_or(id); PublicChartSeries { key: key.clone(), - label: format!("HA · {} · {}", alias, field_label), + label: format!("HA · {}", alias), + label_key, dashed: true, - points: rows.into_iter() + points: rows + .into_iter() .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(), } } - _ => return Err(AppError::BadRequest(format!("unsupported custom chart series: {key}"))), + _ => { + return Err(AppError::BadRequest(format!( + "unsupported custom chart series: {key}" + ))) + } }; 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!({ "title": share.title, - "hint": hint, - "no_data_label": no_data_label, "lang": lang, "hours": hours, "bucket_seconds": bucket_seconds, @@ -814,7 +846,12 @@ async fn energy_history( "previous_day" => Some(1), "previous_period" => Some(days), "previous_year" => Some(365), - _ => return Err(AppError::BadRequest("energy compare must be none, previous_day, previous_period or previous_year".into())), + _ => { + 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"); @@ -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) = - if let Some(group_id) = target_id.strip_prefix("group:") { - let group = state - .db - .get_device_group(group_id)? - .ok_or_else(|| AppError::NotFound(format!("device group {group_id}")))?; - let auto_source = if group.energy_device_id.is_some() { - Some("gree_cloud") - } else if group.ha_energy_entity_id.is_some() { - Some("home_assistant") - } else { - None - }; - let selected = match requested_source { - "gree_cloud" => Some("gree_cloud"), - "home_assistant" => Some("home_assistant"), - _ => match group.energy_source { - EnergySourcePreference::GreeCloud => Some("gree_cloud"), - EnergySourcePreference::HomeAssistant => Some("home_assistant"), - EnergySourcePreference::Auto => auto_source, - }, - }; - let storage_id = match selected { - Some("gree_cloud") => group.energy_device_id.clone().ok_or_else(|| AppError::BadRequest("installation has no GREE Cloud energy source device".into()))?, - _ => format!("group:{}", group.id), - }; - ( - "group", - format!("group:{}", group.id), - group.name, - group.device_ids, - group.energy_source, - storage_id, - selected, - ) + let ( + target_type, + public_target_id, + target_name, + member_device_ids, + configured_source, + storage_id, + preferred_source, + ) = if let Some(group_id) = target_id.strip_prefix("group:") { + let group = state + .db + .get_device_group(group_id)? + .ok_or_else(|| AppError::NotFound(format!("device group {group_id}")))?; + let auto_source = if group.energy_device_id.is_some() { + Some("gree_cloud") + } else if group.ha_energy_entity_id.is_some() { + Some("home_assistant") } else { - 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, - ) + None }; + let selected = match requested_source { + "gree_cloud" => Some("gree_cloud"), + "home_assistant" => Some("home_assistant"), + _ => match group.energy_source { + EnergySourcePreference::GreeCloud => Some("gree_cloud"), + EnergySourcePreference::HomeAssistant => Some("home_assistant"), + EnergySourcePreference::Auto => auto_source, + }, + }; + let storage_id = match selected { + Some("gree_cloud") => group.energy_device_id.clone().ok_or_else(|| { + AppError::BadRequest("installation has no GREE Cloud energy source device".into()) + })?, + _ => format!("group:{}", group.id), + }; + ( + "group", + format!("group:{}", group.id), + group.name, + group.device_ids, + group.energy_source, + storage_id, + selected, + ) + } else { + let device = state + .db + .get_device(target_id)? + .ok_or_else(|| AppError::NotFound(format!("device {target_id}")))?; + let selected = match requested_source { + "gree_cloud" => Some("gree_cloud"), + "home_assistant" => Some("home_assistant"), + "auto" => match device.energy_source { + EnergySourcePreference::GreeCloud => Some("gree_cloud"), + EnergySourcePreference::HomeAssistant => Some("home_assistant"), + EnergySourcePreference::Auto => None, + }, + _ => None, + }; + ( + "device", + device.id.clone(), + device.name, + vec![device.id.clone()], + device.energy_source, + device.id, + selected, + ) + }; let now = Utc::now(); let since = now - ChronoDuration::days(days); @@ -943,11 +989,15 @@ async fn energy_history( "InfluxDB energy history query failed", 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 { - 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); @@ -976,18 +1026,27 @@ async fn energy_history( Utc, ) } - fn bucket_start(timestamp: chrono::DateTime, interval_name: &str) -> chrono::DateTime { + fn bucket_start( + timestamp: chrono::DateTime, + interval_name: &str, + ) -> chrono::DateTime { let date = timestamp.date_naive(); match interval_name { "hourly" => chrono::DateTime::::from_naive_utc_and_offset( - date.and_hms_opt(timestamp.hour(), 0, 0).expect("valid hour"), + date.and_hms_opt(timestamp.hour(), 0, 0) + .expect("valid hour"), Utc, ), "weekly" => { let iso = date.iso_week(); - midnight(NaiveDate::from_isoywd_opt(iso.year(), iso.week(), Weekday::Mon).expect("valid ISO week")) + midnight( + NaiveDate::from_isoywd_opt(iso.year(), iso.week(), Weekday::Mon) + .expect("valid ISO week"), + ) } - "monthly" => midnight(NaiveDate::from_ymd_opt(date.year(), date.month(), 1).expect("valid month")), + "monthly" => midnight( + NaiveDate::from_ymd_opt(date.year(), date.month(), 1).expect("valid month"), + ), _ => midnight(date), } } @@ -999,18 +1058,26 @@ async fn energy_history( let mut buckets: BTreeMap, f64> = BTreeMap::new(); for sample in rows { let shifted = sample.timestamp + ChronoDuration::days(shift_days); - *buckets.entry(bucket_start(shifted, interval_name)).or_default() += sample.consumption_kwh.max(0.0); + *buckets + .entry(bucket_start(shifted, interval_name)) + .or_default() += sample.consumption_kwh.max(0.0); } buckets.into_iter().map(|(start, consumption_kwh)| json!({"start": start, "consumption_kwh": consumption_kwh.max(0.0)})).collect() } - let period_samples = samples.iter().filter(|row| row.timestamp >= since && row.timestamp <= now).collect::>(); + let period_samples = samples + .iter() + .filter(|row| row.timestamp >= since && row.timestamp <= now) + .collect::>(); let buckets = bucket_rows(period_samples.iter().copied(), interval_name, 0); let comparison = if let Some(shift) = compare_shift_days { let compare_end = now - ChronoDuration::days(shift); let compare_start = since - ChronoDuration::days(shift); - let rows = samples.iter().filter(|row| row.timestamp >= compare_start && row.timestamp <= compare_end).collect::>(); + let rows = samples + .iter() + .filter(|row| row.timestamp >= compare_start && row.timestamp <= compare_end) + .collect::>(); let total: f64 = rows.iter().map(|row| row.consumption_kwh.max(0.0)).sum(); Some(json!({ "kind": compare_name, @@ -1026,13 +1093,24 @@ async fn energy_history( let today_start = midnight(now.date_naive()); let yesterday_start = today_start - ChronoDuration::days(1); - let month_start = midnight(NaiveDate::from_ymd_opt(now.year(), now.month(), 1).expect("valid current month")); + let month_start = + midnight(NaiveDate::from_ymd_opt(now.year(), now.month(), 1).expect("valid current month")); let previous_month_date = month_start.date_naive() - ChronoDuration::days(1); - let previous_month_start = midnight(NaiveDate::from_ymd_opt(previous_month_date.year(), previous_month_date.month(), 1).expect("valid previous month")); + let previous_month_start = midnight( + NaiveDate::from_ymd_opt(previous_month_date.year(), previous_month_date.month(), 1) + .expect("valid previous month"), + ); let sum_range = |start: chrono::DateTime, stop: chrono::DateTime| -> f64 { - samples.iter().filter(|row| row.timestamp >= start && row.timestamp < stop).map(|row| row.consumption_kwh.max(0.0)).sum() + samples + .iter() + .filter(|row| row.timestamp >= start && row.timestamp < stop) + .map(|row| row.consumption_kwh.max(0.0)) + .sum() }; - let period_total: f64 = period_samples.iter().map(|row| row.consumption_kwh.max(0.0)).sum(); + let period_total: f64 = period_samples + .iter() + .map(|row| row.consumption_kwh.max(0.0)) + .sum(); let latest = samples.last().cloned(); Ok(Json(json!({ @@ -1079,7 +1157,9 @@ async fn combined_network_history( let cutoff = Utc::now() - ChronoDuration::days(influx.history_threshold_days.max(1) as i64); if !influx.enabled || since >= cutoff { 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(), None, )); @@ -1105,17 +1185,27 @@ async fn combined_network_history( "InfluxDB connectivity history query failed", 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() { - 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); trim_history(&mut values, limit); Ok(( values, - if warning.is_some() { "sqlite_fallback".into() } else { "influx+sqlite".into() }, + if warning.is_some() { + "sqlite_fallback".into() + } else { + "influx+sqlite".into() + }, warning, )) } @@ -1149,10 +1239,12 @@ async fn network_history( } let has_rest = readings.iter().any(|row| row.target_id == "cloud:rest"); 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"})); } - 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"})); } Ok(Json(json!({ diff --git a/src/api/integrations.rs b/src/api/integrations.rs index 03bd984..6916181 100644 --- a/src/api/integrations.rs +++ b/src/api/integrations.rs @@ -29,9 +29,7 @@ fn map_notification_test_error(message: String) -> AppError { } } -async fn home_assistant_snapshot( - State(state): State, -) -> Result, AppError> { +async fn home_assistant_snapshot(State(state): State) -> Result, AppError> { let control_plan = engine::get_control_plan_snapshot(&state).await?; let groups = list_home_assistant_groups(State(state.clone())).await?.0; Ok(Json(json!({ @@ -42,9 +40,7 @@ async fn home_assistant_snapshot( }))) } -async fn test_home_assistant( - State(state): State, -) -> Result, AppError> { +async fn test_home_assistant(State(state): State) -> Result, AppError> { let settings = state.settings.read().await.clone(); let sample = home_assistant::test_connection(&state.http, &settings.home_assistant) .await @@ -92,7 +88,11 @@ async fn list_home_assistant_entities( a.get("entity_id") .and_then(Value::as_str) .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!({ "configured": true, diff --git a/src/api/settings.rs b/src/api/settings.rs index f6a26e2..6b8be8f 100644 --- a/src/api/settings.rs +++ b/src/api/settings.rs @@ -28,7 +28,9 @@ fn gree_cloud_settings(settings: &RuntimeSettings) -> GreeCloudSettingsView { password_configured: !settings.gree_cloud.password.is_empty(), polling_interval_seconds: settings.gree_cloud.polling_interval_seconds, 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, installation_id: settings.gree_cloud.installation_id.clone(), account_id: settings.gree_cloud.account_id.clone(), @@ -243,10 +245,7 @@ async fn update_gree_settings( Ok(Json(payload)) } - -async fn get_gree_cloud_settings( - State(state): State, -) -> Json { +async fn get_gree_cloud_settings(State(state): State) -> Json { 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.polling_interval_seconds = input.polling_interval_seconds.clamp(30, 3600); 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); if let Some(password) = input.password { next.password = password; @@ -365,6 +365,24 @@ async fn get_influxdb_settings(State(state): State) -> Json, + Json(mut input): Json, +) -> Result, AppError> { + input.enabled = true; + let current = state.settings.read().await.influxdb.clone(); + let settings = apply_influxdb_update(¤t, 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( current: &InfluxDbSettings, input: InfluxDbSettingsUpdate, diff --git a/src/api/system.rs b/src/api/system.rs index 719c853..0e54fa0 100644 --- a/src/api/system.rs +++ b/src/api/system.rs @@ -38,7 +38,9 @@ fn device_group_energy_snapshot( EnergySourcePreference::GreeCloud => Some("gree_cloud"), EnergySourcePreference::HomeAssistant => Some("home_assistant"), 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, }; @@ -108,10 +110,7 @@ struct BootstrapResponse { system: SystemInfoResponse, } -async fn health( - State(state): State, - request: Request, -) -> Result, AppError> { +async fn health(State(state): State, request: Request) -> Result, AppError> { if home_assistant::supervisor_token_detected() { let trusted_supervisor = request .extensions() @@ -176,11 +175,13 @@ async fn build_bootstrap(state: &AppState) -> Result 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 { version: env!("CARGO_PKG_VERSION"), 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), database: state.config.database.display().to_string(), device_count: devices.len(), @@ -203,9 +204,7 @@ fn build_system_info(state: &AppState, devices: &[Device]) -> SystemInfoResponse } } -async fn system_info( - State(state): State, -) -> Result, AppError> { +async fn system_info(State(state): State) -> Result, AppError> { let devices = state.db.list_devices()?; Ok(Json(build_system_info(&state, &devices))) } diff --git a/src/config.rs b/src/config.rs index b70fbae..f1bf251 100644 --- a/src/config.rs +++ b/src/config.rs @@ -21,7 +21,11 @@ pub struct Config { pub app_token: String, #[arg(long, env = "GREE_CONTROLLER_BASE_PATH", default_value = "")] 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, #[arg(long, env = "GREE_CONTROLLER_SIMULATE", default_value_t = false)] pub simulate: bool, @@ -62,7 +66,8 @@ impl Config { dotenvy::dotenv().ok(); let mut config = Self::parse(); 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() { std::fs::create_dir_all(parent).with_context(|| { 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_broadcast: self.discovery_broadcast.clone(), 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_sample_count: env_u32("GREE_CONTROLLER_PING_SAMPLE_COUNT").unwrap_or(3).clamp(1, 10), + ping_interval_seconds: env_u64("GREE_CONTROLLER_PING_INTERVAL_SECONDS") + .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()), control_strategy: "setpoint".into(), outdoor_assist_enabled: env_bool("GREE_CONTROLLER_OUTDOOR_ASSIST_ENABLED") diff --git a/src/db.rs b/src/db.rs index dee4b73..778dbe9 100644 --- a/src/db.rs +++ b/src/db.rs @@ -1,7 +1,8 @@ use crate::{ models::{ - ApiTokenInfo, Automation, ClimateGroup, ConfigurationExport, ConnectionType, Device, DeviceGroup, EnergySourcePreference, EventLog, Flow, - EnergyReading, HaReading, NetworkReading, Reading, RuntimeSettings, Schedule, Zone, ZoneReading, + ApiTokenInfo, Automation, ClimateGroup, ConfigurationExport, ConnectionType, Device, + DeviceGroup, EnergyReading, EnergySourcePreference, EventLog, Flow, HaReading, + NetworkReading, Reading, RuntimeSettings, Schedule, Zone, ZoneReading, }, queries, }; diff --git a/src/db/climate.rs b/src/db/climate.rs index f6610fa..f3af28d 100644 --- a/src/db/climate.rs +++ b/src/db/climate.rs @@ -68,7 +68,10 @@ impl Db { } pub fn device_group_for_device(&self, device_id: &str) -> Result> { - Ok(self.list_device_groups()?.into_iter().find(|group| group.device_ids.iter().any(|id| id == device_id))) + 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 { diff --git a/src/db/configuration.rs b/src/db/configuration.rs index ed1fe97..bae7d6b 100644 --- a/src/db/configuration.rs +++ b/src/db/configuration.rs @@ -22,7 +22,10 @@ impl Db { let payload = Self::device_to_storage_json(device)?; let index_mac = match device.connection_type { ConnectionType::Local => device.mac.clone(), - ConnectionType::GreeCloud => format!("cloud:{}", device.cloud_device_id.as_deref().unwrap_or(&device.mac)), + ConnectionType::GreeCloud => format!( + "cloud:{}", + device.cloud_device_id.as_deref().unwrap_or(&device.mac) + ), }; tx.execute( queries::UPSERT_DEVICE, @@ -55,7 +58,11 @@ impl Db { let payload = Self::to_json(device_group)?; tx.execute( 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 { diff --git a/src/db/core_devices.rs b/src/db/core_devices.rs index d9bb60c..b0eb230 100644 --- a/src/db/core_devices.rs +++ b/src/db/core_devices.rs @@ -45,7 +45,10 @@ impl Db { let payload = Self::device_to_storage_json(device)?; let index_mac = match device.connection_type { ConnectionType::Local => device.mac.clone(), - ConnectionType::GreeCloud => format!("cloud:{}", device.cloud_device_id.as_deref().unwrap_or(&device.mac)), + ConnectionType::GreeCloud => format!( + "cloud:{}", + device.cloud_device_id.as_deref().unwrap_or(&device.mac) + ), }; let conn = self.lock()?; conn.execute( @@ -90,7 +93,9 @@ impl Db { pub fn delete_device(&self, id: &str) -> Result { for mut group in self.list_device_groups()? { - if !group.device_ids.iter().any(|device_id| device_id == id) { continue; } + if !group.device_ids.iter().any(|device_id| device_id == id) { + continue; + } group.device_ids.retain(|device_id| device_id != id); if group.energy_device_id.as_deref() == Some(id) { group.energy_device_id = None; @@ -98,7 +103,9 @@ impl Db { 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() { self.delete_device_group(&group.id)?; } else { diff --git a/src/db/energy_history.rs b/src/db/energy_history.rs index 264d58c..7c7e1de 100644 --- a/src/db/energy_history.rs +++ b/src/db/energy_history.rs @@ -19,7 +19,11 @@ impl Db { Ok(conn.last_insert_rowid()) } - pub fn last_energy_reading(&self, device_id: &str, source: &str) -> Result> { + pub fn last_energy_reading( + &self, + device_id: &str, + source: &str, + ) -> Result> { let conn = self.lock()?; conn.query_row( "SELECT id,device_id,timestamp,source,raw_meter_value,raw_unit,normalized_meter_kwh,consumption_kwh,current_power_kw,quality,reset_detected FROM energy_readings WHERE device_id=?1 AND source=?2 ORDER BY timestamp DESC,id DESC LIMIT 1", @@ -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", )?; 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, )?; - rows.collect::>>().map_err(Into::into) + rows.collect::>>() + .map_err(Into::into) } pub fn energy_before(&self, before: DateTime, limit: u32) -> Result> { @@ -54,7 +63,8 @@ impl Db { params![before.to_rfc3339(), limit.clamp(1, 5000) as i64], Self::map_energy_reading, )?; - rows.collect::>>().map_err(Into::into) + rows.collect::>>() + .map_err(Into::into) } pub fn delete_energy_batch(&self, rows: &[EnergyReading]) -> Result { @@ -71,7 +81,10 @@ impl Db { pub fn prune_energy_readings(&self, retention_days: i64) -> Result { let before = Utc::now() - Duration::days(retention_days.max(1)); let conn = self.lock()?; - Ok(conn.execute("DELETE FROM energy_readings WHERE timestamp < ?1", [before.to_rfc3339()])? as u64) + Ok(conn.execute( + "DELETE FROM energy_readings WHERE timestamp < ?1", + [before.to_rfc3339()], + )? as u64) } fn map_energy_reading(row: &rusqlite::Row<'_>) -> rusqlite::Result { diff --git a/src/db/events_tokens.rs b/src/db/events_tokens.rs index aaf4213..55e6497 100644 --- a/src/db/events_tokens.rs +++ b/src/db/events_tokens.rs @@ -126,7 +126,9 @@ impl Db { pub fn get_public_chart_share(&self, token_hash: &str) -> Result> { let conn = self.lock()?; let payload: Option = 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()?; payload .map(|value| serde_json::from_str(&value)) diff --git a/src/db/network_history.rs b/src/db/network_history.rs index 5804b7d..a013903 100644 --- a/src/db/network_history.rs +++ b/src/db/network_history.rs @@ -53,14 +53,18 @@ LIMIT ?3 params![target_id, since.to_rfc3339(), bucket_seconds, limit], Self::map_network_reading, )?; - for row in rows { out.push(row?); } + for row in rows { + out.push(row?); + } } else { let mut stmt = conn.prepare(sql_all)?; let rows = stmt.query_map( params![since.to_rfc3339(), bucket_seconds, limit], Self::map_network_reading, )?; - for row in rows { out.push(row?); } + for row in rows { + out.push(row?); + } } Ok(out) } @@ -74,7 +78,8 @@ LIMIT ?3 params![before.to_rfc3339(), limit.clamp(1, 5000) as i64], Self::map_network_reading, )?; - rows.collect::>>().map_err(Into::into) + rows.collect::>>() + .map_err(Into::into) } pub fn delete_network_batch(&self, rows: &[NetworkReading]) -> Result { @@ -91,7 +96,10 @@ LIMIT ?3 pub fn prune_network_readings(&self, retention_days: i64) -> Result { let before = Utc::now() - Duration::days(retention_days.max(1)); let conn = self.lock()?; - Ok(conn.execute("DELETE FROM 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 { @@ -116,8 +124,13 @@ DELETE FROM network_readings WHERE id IN ( (600_i64, one_day, seven_days), (1800_i64, seven_days, retention), ] { - if older_than <= newer_than { continue; } - changed += conn.execute(sql, params![bucket, older_than.to_rfc3339(), newer_than.to_rfc3339()])? as u64; + if older_than <= newer_than { + continue; + } + changed += conn.execute( + sql, + params![bucket, older_than.to_rfc3339(), newer_than.to_rfc3339()], + )? as u64; } conn.execute_batch("PRAGMA optimize;")?; Ok(changed) diff --git a/src/db/tests.rs b/src/db/tests.rs index 26144c0..acf466c 100644 --- a/src/db/tests.rs +++ b/src/db/tests.rs @@ -86,12 +86,16 @@ mod tests { "hours": 24, "lang": "en" }); - db.save_public_chart_share("chart-hash", &chart_share).unwrap(); + db.save_public_chart_share("chart-hash", &chart_share) + .unwrap(); assert_eq!( db.get_public_chart_share("chart-hash").unwrap(), 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(); db.add_reading(&Reading { @@ -162,8 +166,11 @@ mod tests { db.save_device(&cloud).unwrap(); let devices = db.list_devices().unwrap(); assert_eq!(devices.len(), 2); - assert!(devices.iter().any(|item| item.connection_type == ConnectionType::Local)); - assert!(devices.iter().any(|item| item.connection_type == ConnectionType::GreeCloud)); + assert!(devices + .iter() + .any(|item| item.connection_type == ConnectionType::Local)); + assert!(devices + .iter() + .any(|item| item.connection_type == ConnectionType::GreeCloud)); } - } diff --git a/src/engine.rs b/src/engine.rs index f2cdfc4..36fbd12 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -2,9 +2,11 @@ use crate::{ error::AppError, home_assistant, influxdb, models::{ - Automation, AutomationPlanRule, ClimateGroup, ConnectionStatus, ConnectionType, ControlPlan, ControlPlanEvent, Device, DeviceGroup, EnergyReading, EnergySourcePreference, - DeviceCommand, GroupControlPatch, HaReading, NetworkReading, NightModeSettings, Reading, RuntimeSettings, - Schedule, TemporaryQuickThermostat, Zone, ZoneControlPlan, ZoneReading, + Automation, AutomationPlanRule, ClimateGroup, ConnectionStatus, ConnectionType, + ControlPlan, ControlPlanEvent, Device, DeviceCommand, DeviceGroup, EnergyReading, + EnergySourcePreference, GroupControlPatch, HaReading, NetworkReading, NightModeSettings, + Reading, RuntimeSettings, Schedule, TemporaryQuickThermostat, Zone, ZoneControlPlan, + ZoneReading, }, state::{AppState, ControlPlanSnapshot, PendingControllerCommand}, }; diff --git a/src/engine/commands.rs b/src/engine/commands.rs index 200ce1b..bff71a2 100644 --- a/src/engine/commands.rs +++ b/src/engine/commands.rs @@ -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, Err(first_err) => { // 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() { Ok(command.clone()) } 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 { @@ -109,7 +118,11 @@ async fn send_command_locked_inner( device.key = Some(bound.key); device.protocol_version = bound.protocol_version; 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), }, @@ -277,7 +290,10 @@ async fn send_cloud_command_locked_inner( } device.updated_at = Utc::now(); 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 applied = match state @@ -298,7 +314,10 @@ async fn send_cloud_command_locked_inner( restored.pending_command = false; register_cloud_failure(&mut restored, &err.to_string()); state.db.save_device(&restored)?; - state.broadcast("device.updated", serde_json::to_value(&restored).unwrap_or_default()); + state.broadcast( + "device.updated", + serde_json::to_value(&restored).unwrap_or_default(), + ); state.log( "warn", "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. let mut accepted = transport_device; accepted.pending_command = true; - accepted.response_time_ms = Some( - response_started - .elapsed() - .as_millis() - .min(u64::MAX as u128) as u64, - ); + accepted.response_time_ms = + Some(response_started.elapsed().as_millis().min(u64::MAX as u128) as u64); accepted.updated_at = Utc::now(); accepted.refresh_capabilities(); state.db.save_device(&accepted)?; @@ -351,7 +366,8 @@ async fn send_cloud_command_locked_inner( let recovery_device_id = accepted.id.clone(); tokio::spawn(async move { sleep(Duration::from_millis(1500)).await; - let Some(_cloud_poll_guard) = recovery_state.try_begin_cloud_poll(&recovery_device_id) else { + let Some(_cloud_poll_guard) = recovery_state.try_begin_cloud_poll(&recovery_device_id) + else { tracing::debug!(device=%recovery_device_id, "skipping duplicate GREE Cloud recovery poll"); return; }; @@ -415,9 +431,16 @@ fn register_cloud_failure(device: &mut Device, error: &str) { fn cloud_connection_status(error: &str) -> ConnectionStatus { let error = error.to_ascii_lowercase(); - if error.contains("authentication") || error.contains("not authorized") || error.contains("invalid username") { + if error.contains("authentication") + || error.contains("not authorized") + || error.contains("invalid username") + { ConnectionStatus::AuthenticationError - } else if error.contains("mqtt") || error.contains("connect") || error.contains("tls") || error.contains("network") { + } else if error.contains("mqtt") + || error.contains("connect") + || error.contains("tls") + || error.contains("network") + { ConnectionStatus::CloudDisconnected } else { ConnectionStatus::Offline diff --git a/src/engine/connectivity.rs b/src/engine/connectivity.rs index 7634b9b..ee33d84 100644 --- a/src/engine/connectivity.rs +++ b/src/engine/connectivity.rs @@ -16,7 +16,11 @@ fn build_network_reading( Some(successful.iter().sum::() / successful.len() as f64) }; 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 { let diffs = successful .windows(2) @@ -46,7 +50,9 @@ fn build_network_reading( fn save_network_reading(state: &AppState, reading: NetworkReading) { match state.db.add_network_reading(&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()? .into_iter() .filter(|device| { - device.enabled - && !device.simulated - && device.connection_type == ConnectionType::Local + device.enabled && !device.simulated && device.connection_type == ConnectionType::Local }) .collect::>(); futures_util::future::join_all( @@ -91,7 +95,10 @@ async fn run_local_connectivity_cycle(state: &AppState, sample_count: u32) -> Re Ok(()) } -async fn cloud_rest_probe(state: &AppState, cloud: &crate::models::GreeCloudSettings) -> Result { +async fn cloud_rest_probe( + state: &AppState, + cloud: &crate::models::GreeCloudSettings, +) -> Result { let mut api = crate::protocol::gree_cloud::GreeCloudApi::for_region( state.http.clone(), &cloud.region, @@ -149,10 +156,15 @@ async fn local_connectivity_loop(state: AppState) { loop { let settings = state.settings.read().await.clone(); 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"); } - 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 { sleep(Duration::from_secs(10)).await; } diff --git a/src/engine/control_plan.rs b/src/engine/control_plan.rs index 280aa5f..a4c2ef1 100644 --- a/src/engine/control_plan.rs +++ b/src/engine/control_plan.rs @@ -442,8 +442,8 @@ fn next_schedule_events( let candidate = base + chrono::Duration::minutes(minute); let minute_of_day = candidate.hour() * 60 + candidate.minute(); let previous_local = base + chrono::Duration::minutes(minute - 1); - let clock_discontinuity = candidate.naive_local() - previous_local.naive_local() - != chrono::Duration::minutes(1); + let clock_discontinuity = + candidate.naive_local() - previous_local.naive_local() != chrono::Duration::minutes(1); if !boundary_minutes.contains(&minute_of_day) && !clock_discontinuity { continue; } diff --git a/src/engine/energy.rs b/src/engine/energy.rs index 337ffcf..1084d6c 100644 --- a/src/engine/energy.rs +++ b/src/engine/energy.rs @@ -1,6 +1,9 @@ const ENERGY_ANOMALY_MAX_DELTA_KWH: f64 = 100.0; -fn cumulative_energy_delta(previous_kwh: Option, current_kwh: f64) -> (f64, &'static str, bool) { +fn cumulative_energy_delta( + previous_kwh: Option, + current_kwh: f64, +) -> (f64, &'static str, bool) { let Some(previous_kwh) = previous_kwh else { return (0.0, "baseline", false); }; @@ -18,13 +21,17 @@ fn cumulative_energy_delta(previous_kwh: Option, current_kwh: f64) -> (f64, fn normalize_energy_kwh(raw_value: f64, unit: &str) -> Result { if !raw_value.is_finite() || raw_value < 0.0 { - return Err(AppError::BadRequest("energy meter value must be a finite non-negative number".into())); + return Err(AppError::BadRequest( + "energy meter value must be a finite non-negative number".into(), + )); } match unit.trim().to_ascii_lowercase().as_str() { "kwh" => Ok(raw_value), "wh" => Ok(raw_value / 1000.0), "0.1kwh" | "0.1 kwh" => Ok(raw_value * 0.1), - other => Err(AppError::BadRequest(format!("unsupported energy unit: {other}"))), + 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(); tokio::spawn(async move { 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 { 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 payload = home_assistant::read_entity(&state.http, &settings, Some(entity_id)) .await .map_err(|err| AppError::Dependency(err.to_string()))?; - let state_value = payload.get("state").and_then(Value::as_str).unwrap_or_default(); - if matches!(state_value, "" | "unknown" | "unavailable") { return Ok(()); } - let raw_value: f64 = state_value.parse().map_err(|_| AppError::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(); + let state_value = payload + .get("state") + .and_then(Value::as_str) + .unwrap_or_default(); + if matches!(state_value, "" | "unknown" | "unavailable") { + return Ok(()); + } + let raw_value: f64 = state_value + .parse() + .map_err(|_| AppError::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") { - 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") { - 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(()) } -async fn sample_home_assistant_energy_device(state: &AppState, device: &Device) -> Result<(), AppError> { - let Some(entity_id) = device.ha_energy_entity_id.as_deref().filter(|value| !value.trim().is_empty()) else { return Ok(()); }; +async fn sample_home_assistant_energy_device( + state: &AppState, + device: &Device, +) -> Result<(), AppError> { + let Some(entity_id) = device + .ha_energy_entity_id + .as_deref() + .filter(|value| !value.trim().is_empty()) + else { + return Ok(()); + }; sample_home_assistant_energy_target(state, &device.id, entity_id).await } @@ -113,17 +160,33 @@ pub(crate) async fn home_assistant_energy_loop(state: AppState) { let settings = state.settings.read().await.home_assistant.clone(); if !settings.url.trim().is_empty() && !settings.token.trim().is_empty() { if let Ok(devices) = state.db.list_devices() { - for device in devices.into_iter().filter(|device| device.enabled && device.ha_energy_entity_id.is_some()) { + for device in devices + .into_iter() + .filter(|device| device.enabled && device.ha_energy_entity_id.is_some()) + { if let Err(err) = sample_home_assistant_energy_device(&state, &device).await { tracing::warn!(device=%device.id, error=?err, "Home Assistant energy sample failed"); } } } if let Ok(groups) = state.db.list_device_groups() { - for group in groups.into_iter().filter(|group| matches!(group.energy_source, EnergySourcePreference::HomeAssistant | EnergySourcePreference::Auto)) { - let Some(entity_id) = group.ha_energy_entity_id.as_deref().filter(|value| !value.trim().is_empty()) else { continue; }; + for group in groups.into_iter().filter(|group| { + matches!( + group.energy_source, + EnergySourcePreference::HomeAssistant | EnergySourcePreference::Auto + ) + }) { + let Some(entity_id) = group + .ha_energy_entity_id + .as_deref() + .filter(|value| !value.trim().is_empty()) + else { + continue; + }; let target_id = format!("group:{}", group.id); - if let Err(err) = sample_home_assistant_energy_target(&state, &target_id, entity_id).await { + 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"); } } @@ -145,14 +208,26 @@ mod energy_tests { #[test] 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); assert!((delta - 0.3).abs() < 1e-9); assert_eq!(quality, "ok"); assert!(!reset); - assert_eq!(cumulative_energy_delta(Some(152.4), 152.4), (0.0, "duplicate", false)); - assert_eq!(cumulative_energy_delta(Some(153.0), 1.0), (0.0, "reset", true)); - assert_eq!(cumulative_energy_delta(Some(1.0), 150.0), (0.0, "anomaly_large_jump", true)); + assert_eq!( + cumulative_energy_delta(Some(152.4), 152.4), + (0.0, "duplicate", false) + ); + assert_eq!( + cumulative_energy_delta(Some(153.0), 1.0), + (0.0, "reset", true) + ); + assert_eq!( + cumulative_energy_delta(Some(1.0), 150.0), + (0.0, "anomaly_large_jump", true) + ); } #[test] diff --git a/src/engine/history.rs b/src/engine/history.rs index 3e8aaf5..47961e5 100644 --- a/src/engine/history.rs +++ b/src/engine/history.rs @@ -1,7 +1,12 @@ fn installation_outdoor_temperature(state: &AppState, device_id: &str) -> Option { let group = state.db.device_group_for_device(device_id).ok().flatten()?; let source_id = group.outdoor_temperature_device_id.as_deref()?; - state.db.get_device(source_id).ok().flatten()?.outdoor_temperature + state + .db + .get_device(source_id) + .ok() + .flatten()? + .outdoor_temperature } fn record_zone_history( diff --git a/src/engine/ownership.rs b/src/engine/ownership.rs index 32de600..f3018c0 100644 --- a/src/engine/ownership.rs +++ b/src/engine/ownership.rs @@ -142,7 +142,8 @@ fn manual_override_matches_baseline(zone: &Zone, device: &Device) -> bool { "mode" => device.mode == baseline.mode, "target_temperature" => { 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, "quiet" => device.quiet == baseline.quiet, diff --git a/src/engine/polling.rs b/src/engine/polling.rs index 777f1e1..3928aba 100644 --- a/src/engine/polling.rs +++ b/src/engine/polling.rs @@ -10,8 +10,14 @@ fn device_runtime_change_affects_control_plan(before: &Device, after: &Device) - || before.communication_failures != after.communication_failures } -async fn lock_poll_zone_operations(state: &AppState, device_id: &str) -> Result>, AppError> { - let mut zone_ids: Vec = state.db.list_zones()?.into_iter() +async fn lock_poll_zone_operations( + state: &AppState, + device_id: &str, +) -> Result>, AppError> { + let mut zone_ids: Vec = state + .db + .list_zones()? + .into_iter() .filter(|zone| zone.device_id == device_id) .map(|zone| zone.id) .collect(); @@ -35,7 +41,9 @@ pub async fn poll_one(state: &AppState, device_id: &str) -> Result Result { - 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}")))?; let before = device.clone(); 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. // 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 _device_guard = state.lock_device_operation(&device.id).await; let _ = poll_one_locked(state, &device.id).await?; } - for device in devices.into_iter().filter(|device| device.enabled && device.connection_type == ConnectionType::GreeCloud) { + for device in devices + .into_iter() + .filter(|device| device.enabled && device.connection_type == ConnectionType::GreeCloud) + { // A failed Cloud read has no last_cloud_sync, so use updated_at (which is refreshed // on failures) as the retry baseline. Otherwise an offline unit would be considered // due on every fast LAN poll cycle and detached tasks would accumulate indefinitely. @@ -84,7 +98,9 @@ pub(crate) async fn poll_all(state: &AppState) -> Result<()> { .signed_duration_since(retry_baseline) .num_seconds() >= cloud_interval as i64; - if !due { continue; } + if !due { + continue; + } if startup { // Persist a conservative startup state before the asynchronous Cloud read. This // prevents a stale pre-restart Online snapshot from driving thermostat commands. @@ -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. /// Used by configuration import so no command/poll can interleave with the replacement. pub(crate) async fn poll_all_locked(state: &AppState) -> Result<()> { - let device_ids: Vec = state.db.list_devices()?.into_iter() + let device_ids: Vec = state + .db + .list_devices()? + .into_iter() .filter(|device| device.enabled) .map(|device| device.id) .collect(); @@ -137,13 +156,24 @@ async fn poll_device(state: &AppState, device: &mut Device) { return; } }; - match state.providers.cloud().poll(&cloud_settings, &all_devices, device).await { + match state + .providers + .cloud() + .poll(&cloud_settings, &all_devices, device) + .await + { Ok(()) => { device.pending_command = false; - device.response_time_ms = Some(response_started.elapsed().as_millis().min(u64::MAX as u128) as u64); + device.response_time_ms = + Some(response_started.elapsed().as_millis().min(u64::MAX as u128) as u64); device.refresh_capabilities(); if previous_failures > 0 { - state.log("info", "gree_cloud.device_online", &format!("{} is online through GREE Cloud", device.name), json!({"device_id": device.id})); + state.log( + "info", + "gree_cloud.device_online", + &format!("{} is online through GREE Cloud", device.name), + json!({"device_id": device.id}), + ); } } Err(err) => { @@ -196,7 +226,8 @@ async fn poll_device(state: &AppState, device: &mut Device) { } if device.communication_failures == 0 && device.online { device.connection_status = ConnectionStatus::Online; - device.response_time_ms = Some(response_started.elapsed().as_millis().min(u64::MAX as u128) as u64); + 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; } @@ -205,13 +236,14 @@ fn register_cloud_poll_failure(device: &mut Device, error: &str) { device.communication_failures = device.communication_failures.saturating_add(1); device.online = false; let lower = error.to_ascii_lowercase(); - device.connection_status = if lower.contains("authentication") || lower.contains("not authorized") { - ConnectionStatus::AuthenticationError - } else if lower.contains("mqtt") || lower.contains("connect") || lower.contains("tls") { - ConnectionStatus::CloudDisconnected - } else { - ConnectionStatus::Offline - }; + device.connection_status = + if lower.contains("authentication") || lower.contains("not authorized") { + ConnectionStatus::AuthenticationError + } else if lower.contains("mqtt") || lower.contains("connect") || lower.contains("tls") { + ConnectionStatus::CloudDisconnected + } else { + ConnectionStatus::Offline + }; device.response_time_ms = None; if device.last_seen.is_none() { device.last_cloud_sync = None; @@ -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) { - 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 previous_failures = u32::from(previous_failures); 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 })); } 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) { 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; if device.power { match device.mode.as_str() { "cool" => { 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" => { 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, _ => 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.outdoor_temperature = Some((30.0 + minute_wave * 1.2) * 10.0_f64.round() / 10.0); // 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.response_time_ms = Some(0); device.last_seen = Some(Utc::now()); @@ -279,10 +329,15 @@ fn record_reading(state: &AppState, device: &Device) -> Result<()> { device_id: device.id.clone(), timestamp: Utc::now(), 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, 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)?; 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) { device.communication_failures = device.communication_failures.saturating_add(1); // A single dropped UDP response is not enough to declare an AC offline. - if device.communication_failures >= 3 { device.online = false; 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.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); state.db.save_device(device)?; // Command failures change live communication health; publish the updated snapshot immediately. state.broadcast("device.updated", serde_json::to_value(&*device)?); - state.log("warn", "device.communication_error", &format!("{}: {error}", device.name), json!({ - "device_id": device.id, - "consecutive_failures": device.communication_failures, - "offline": !device.online, - })); + state.log( + "warn", + "device.communication_error", + &format!("{}: {error}", device.name), + json!({ + "device_id": device.id, + "consecutive_failures": device.communication_failures, + "offline": !device.online, + }), + ); Ok(()) } pub(crate) fn validate_command(command: &DeviceCommand) -> Result<(), AppError> { 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 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 !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 { let mut fields = Vec::new(); - if command.power.is_some() { fields.push("power".to_string()); } - if command.mode.is_some() { fields.push("mode".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()); } + if command.power.is_some() { + fields.push("power".to_string()); + } + if command.mode.is_some() { + fields.push("mode".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 } @@ -344,7 +431,9 @@ fn command_baseline_from_device(command: &DeviceCommand, device: &Device) -> Dev DeviceCommand { power: command.power.map(|_| device.power), 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), quiet: command.quiet.map(|_| device.quiet), 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 ttl = Duration::from_secs(poll_seconds.saturating_mul(2).saturating_add(5).min(120)); 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); // The history only spans one settling window; cap it defensively so a noisy device // cannot grow this allocation without bound. - if existing.commands.len() > 8 { existing.commands.remove(0); } - if existing.baselines.len() > 8 { existing.baselines.remove(0); } + if existing.commands.len() > 8 { + existing.commands.remove(0); + } + if existing.baselines.len() > 8 { + existing.baselines.remove(0); + } existing.expires_at = expires_at; } else { - pending.insert(device_id.to_string(), PendingControllerCommand { - commands: vec![command.clone()], - baselines: vec![baseline], - expires_at, - }); + pending.insert( + device_id.to_string(), + PendingControllerCommand { + commands: vec![command.clone()], + baselines: vec![baseline], + expires_at, + }, + ); } } fn command_field_matches_device(command: &DeviceCommand, field: &str, device: &Device) -> bool { match field { - "power" => command.power.map(|value| value == device.power).unwrap_or(false), - "mode" => command.mode.as_deref().map(|value| value == device.mode.as_str()).unwrap_or(false), - "target_temperature" => command.target_temperature + "power" => command + .power + .map(|value| value == device.power) + .unwrap_or(false), + "mode" => command + .mode + .as_deref() + .map(|value| value == device.mode.as_str()) + .unwrap_or(false), + "target_temperature" => command + .target_temperature .map(|value| { let step = device.capabilities.temperature_step.max(0.5); - (value.clamp(device.capabilities.min_temperature, device.capabilities.max_temperature) / step).round() - == (device.target_temperature.clamp(device.capabilities.min_temperature, device.capabilities.max_temperature) / step).round() + (value.clamp( + device.capabilities.min_temperature, + device.capabilities.max_temperature, + ) / step) + .round() + == (device.target_temperature.clamp( + device.capabilities.min_temperature, + device.capabilities.max_temperature, + ) / step) + .round() }) .unwrap_or(false), - "fan_speed" => command.fan_speed.map(|value| value.min(5) == device.fan_speed).unwrap_or(false), - "quiet" => command.quiet.map(|value| value == device.quiet).unwrap_or(false), - "sleep" => command.sleep.map(|value| value == device.sleep).unwrap_or(false), + "fan_speed" => command + .fan_speed + .map(|value| value.min(5) == device.fan_speed) + .unwrap_or(false), + "quiet" => command + .quiet + .map(|value| value == device.quiet) + .unwrap_or(false), + "sleep" => command + .sleep + .map(|value| value == device.sleep) + .unwrap_or(false), _ => false, } } @@ -418,7 +544,10 @@ async fn controller_settling_diagnostics(state: &AppState, device_id: &str) -> V }; let now = Instant::now(); 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!({ "active": false, "reason": "expired", @@ -427,7 +556,11 @@ async fn controller_settling_diagnostics(state: &AppState, device_id: &str) -> V "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!({ "active": true, "remaining_ms": remaining_ms, @@ -441,19 +574,27 @@ async fn suppress_expected_controller_changes( device: &Device, fields: Vec, ) -> Vec { - if fields.is_empty() { return fields; } + if fields.is_empty() { + return fields; + } 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) .unwrap_or(false); if expired { pending.remove(&device.id); return fields; } - let Some(expected) = pending.get(&device.id).cloned() else { return fields; }; - let filtered = fields.into_iter() + let Some(expected) = pending.get(&device.id).cloned() else { + return fields; + }; + let filtered = fields + .into_iter() .filter(|field| { - let matches_recent_controller_state = expected.commands.iter() + let matches_recent_controller_state = expected + .commands + .iter() .chain(expected.baselines.iter()) .any(|command| command_field_matches_device(command, field, device)); !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 { let mut fields = Vec::new(); - if before.power != after.power { fields.push("power".to_string()); } - if before.mode != after.mode { fields.push("mode".to_string()); } + if before.power != after.power { + 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); if (before.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 // 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. - 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 { fields.push("fan_speed".to_string()); } fields } - - pub(crate) async fn cloud_push_loop(state: AppState) { let mut receiver = state.providers.cloud().subscribe_push(); loop { @@ -502,27 +646,43 @@ pub(crate) async fn cloud_push_loop(state: AppState) { let matching: Vec = devices .into_iter() .filter(|device| { - if device.connection_type != ConnectionType::GreeCloud { return false; } - if let Some(id) = event.cloud_device_id.as_deref() { - return device.cloud_device_id.as_deref().is_some_and(|value| value.eq_ignore_ascii_case(id)); + if device.connection_type != ConnectionType::GreeCloud { + return false; } - 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) .collect(); for device_id in matching { let _guard = state.lock_device_operation(&device_id).await; - let Ok(Some(mut device)) = state.db.get_device(&device_id) else { continue; }; + let Ok(Some(mut device)) = state.db.get_device(&device_id) else { + continue; + }; let before = device.clone(); if !event.properties.is_empty() { if let Some(raw_energy) = event.properties.get("ElcAll").and_then(|value| { - value.as_f64() + value + .as_f64() .or_else(|| value.as_i64().map(|v| v as f64)) .or_else(|| value.as_u64().map(|v| v as f64)) .or_else(|| value.as_str().and_then(|v| v.parse::().ok())) }) { if let Err(err) = record_cumulative_energy_sample( - &state, &device.id, "gree_cloud", raw_energy, "0.1kWh", None + &state, + &device.id, + "gree_cloud", + raw_energy, + "0.1kWh", + None, ) { 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.communication_failures = 0; 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(); } else if event.connected == Some(true) { // 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() { 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"); } } @@ -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, } } diff --git a/src/engine/runtime.rs b/src/engine/runtime.rs index a8bdff9..24c0f4d 100644 --- a/src/engine/runtime.rs +++ b/src/engine/runtime.rs @@ -148,8 +148,13 @@ pub fn start(state: AppState) { Ok(_) => {} Err(err) => tracing::warn!(error=?err, "cannot compact history"), } - match maintenance_state.db.compact_network_history(compaction_days) { - Ok(count) if count > 0 => tracing::info!(count, "connectivity history samples compacted"), + match maintenance_state + .db + .compact_network_history(compaction_days) + { + Ok(count) if count > 0 => { + tracing::info!(count, "connectivity history samples compacted") + } Ok(_) => {} Err(err) => tracing::warn!(error=?err, "cannot compact connectivity history"), } diff --git a/src/engine/schedules.rs b/src/engine/schedules.rs index 699e49e..6c27024 100644 --- a/src/engine/schedules.rs +++ b/src/engine/schedules.rs @@ -53,8 +53,8 @@ pub fn next_schedule_boundary_utc( let candidate = base + chrono::Duration::minutes(minute); let minute_of_day = candidate.hour() * 60 + candidate.minute(); let previous_local = base + chrono::Duration::minutes(minute - 1); - let clock_discontinuity = candidate.naive_local() - previous_local.naive_local() - != chrono::Duration::minutes(1); + let clock_discontinuity = + candidate.naive_local() - previous_local.naive_local() != chrono::Duration::minutes(1); if !boundary_minutes.contains(&minute_of_day) && !clock_discontinuity { continue; } diff --git a/src/engine/tests.rs b/src/engine/tests.rs index 65532de..d5c821f 100644 --- a/src/engine/tests.rs +++ b/src/engine/tests.rs @@ -69,29 +69,61 @@ mod tests { #[test] fn overnight_schedule_works() { - let now = Utc.with_ymd_and_hms(2025, 1, 7, 1, 0, 0).unwrap().with_timezone(&Local); // Tuesday + let now = Utc + .with_ymd_and_hms(2025, 1, 7, 1, 0, 0) + .unwrap() + .with_timezone(&Local); // Tuesday let item = Schedule { - id: "1".into(), zone_id: "z".into(), name: "night".into(), enabled: true, - weekdays: vec![1], start_time: "22:00".into(), end_time: "06:00".into(), preset: "custom".into(), setpoint: 20.0, - created_at: Utc::now(), updated_at: Utc::now(), flow_id: None, flow_node_id: None, + id: "1".into(), + zone_id: "z".into(), + name: "night".into(), + enabled: true, + weekdays: vec![1], + start_time: "22:00".into(), + end_time: "06:00".into(), + preset: "custom".into(), + setpoint: 20.0, + created_at: Utc::now(), + updated_at: Utc::now(), + flow_id: None, + flow_node_id: None, }; assert!(schedule_active(&item, now)); } fn test_schedule(id: &str, weekdays: Vec, start: &str, end: &str) -> Schedule { Schedule { - id: id.into(), zone_id: "z".into(), name: id.into(), enabled: true, - weekdays, start_time: start.into(), end_time: end.into(), preset: "comfort".into(), setpoint: 21.0, - created_at: Utc::now(), updated_at: Utc::now(), flow_id: None, flow_node_id: None, + id: id.into(), + zone_id: "z".into(), + name: id.into(), + enabled: true, + weekdays, + start_time: start.into(), + end_time: end.into(), + preset: "comfort".into(), + setpoint: 21.0, + created_at: Utc::now(), + updated_at: Utc::now(), + flow_id: None, + flow_node_id: None, } } #[test] fn equal_schedule_times_mean_a_full_day() { let item = test_schedule("full", vec![1], "06:00", "06:00"); - let monday_noon = Local.with_ymd_and_hms(2025, 1, 6, 12, 0, 0).single().unwrap(); - let tuesday_early = Local.with_ymd_and_hms(2025, 1, 7, 5, 59, 0).single().unwrap(); - let tuesday_after = Local.with_ymd_and_hms(2025, 1, 7, 6, 1, 0).single().unwrap(); + let monday_noon = Local + .with_ymd_and_hms(2025, 1, 6, 12, 0, 0) + .single() + .unwrap(); + let tuesday_early = Local + .with_ymd_and_hms(2025, 1, 7, 5, 59, 0) + .single() + .unwrap(); + let tuesday_after = Local + .with_ymd_and_hms(2025, 1, 7, 6, 1, 0) + .single() + .unwrap(); assert!(schedule_active(&item, monday_noon)); assert!(schedule_active(&item, tuesday_early)); assert!(!schedule_active(&item, tuesday_after)); @@ -99,8 +131,8 @@ mod tests { #[test] fn schedule_overlap_detection_handles_overnight_ranges() { - let daytime = test_schedule("day", vec![1,2,3,4,5,6,7], "06:30", "22:30"); - let night = test_schedule("night", vec![1,2,3,4,5,6,7], "22:30", "06:30"); + let daytime = test_schedule("day", vec![1, 2, 3, 4, 5, 6, 7], "06:30", "22:30"); + let night = test_schedule("night", vec![1, 2, 3, 4, 5, 6, 7], "22:30", "06:30"); let conflict = test_schedule("conflict", vec![1], "22:00", "23:00"); assert!(!schedules_overlap(&daytime, &night)); assert!(schedules_overlap(&night, &conflict)); @@ -109,8 +141,13 @@ mod tests { #[test] fn next_schedule_boundary_scans_the_whole_week() { let friday = test_schedule("friday", vec![5], "12:00", "13:00"); - let monday = Local.with_ymd_and_hms(2025, 1, 6, 10, 0, 30).single().unwrap(); - let boundary = next_schedule_boundary_utc("z", &[friday], monday).unwrap().with_timezone(&Local); + let monday = Local + .with_ymd_and_hms(2025, 1, 6, 10, 0, 30) + .single() + .unwrap(); + let boundary = next_schedule_boundary_utc("z", &[friday], monday) + .unwrap() + .with_timezone(&Local); assert_eq!(boundary.weekday(), Weekday::Fri); assert_eq!(boundary.hour(), 12); assert_eq!(boundary.minute(), 0); @@ -119,25 +156,33 @@ mod tests { #[test] fn full_week_schedule_has_no_manual_override_boundary() { - let always = test_schedule("always", vec![1,2,3,4,5,6,7], "00:00", "00:00"); - let now = Local.with_ymd_and_hms(2025, 1, 6, 10, 0, 30).single().unwrap(); + let always = test_schedule("always", vec![1, 2, 3, 4, 5, 6, 7], "00:00", "00:00"); + let now = Local + .with_ymd_and_hms(2025, 1, 6, 10, 0, 30) + .single() + .unwrap(); assert!(next_schedule_boundary_utc("z", &[always], now).is_none()); } #[test] fn workday_weekend_handoff_has_no_overlaps() { let schedules = vec![ - test_schedule("morning", vec![1,2,3,4,5], "06:30", "08:00"), - test_schedule("away", vec![1,2,3,4,5], "08:00", "16:00"), - test_schedule("evening", vec![1,2,3,4,5], "16:00", "22:30"), - test_schedule("sleep", vec![1,2,3,4,5], "22:30", "06:30"), - test_schedule("weekend", vec![6,7], "08:00", "23:00"), + test_schedule("morning", vec![1, 2, 3, 4, 5], "06:30", "08:00"), + test_schedule("away", vec![1, 2, 3, 4, 5], "08:00", "16:00"), + test_schedule("evening", vec![1, 2, 3, 4, 5], "16:00", "22:30"), + test_schedule("sleep", vec![1, 2, 3, 4, 5], "22:30", "06:30"), + test_schedule("weekend", vec![6, 7], "08:00", "23:00"), test_schedule("saturday-sleep", vec![6], "23:00", "08:00"), test_schedule("sunday-sleep", vec![7], "23:00", "06:30"), ]; for (index, item) in schedules.iter().enumerate() { for other in schedules.iter().skip(index + 1) { - assert!(!schedules_overlap(item, other), "{} overlaps {}", item.name, other.name); + assert!( + !schedules_overlap(item, other), + "{} overlaps {}", + item.name, + other.name + ); } } } @@ -146,14 +191,32 @@ mod tests { fn flow_cron_validation_and_matching_cover_ranges_steps_and_sunday_alias() { assert!(cron_expression_valid("*/5 * * * *")); assert!(cron_expression_valid("0,15,30,45 6-18 * * 1-5")); - for invalid in ["* * * *", "*/0 * * * *", "61 * * * *", "* 24 * * *", "* * 0 * *", "* * * 13 *", "* * * * 8", "*/100 * * * *"] { - assert!(!cron_expression_valid(invalid), "accepted invalid cron: {invalid}"); + for invalid in [ + "* * * *", + "*/0 * * * *", + "61 * * * *", + "* 24 * * *", + "* * 0 * *", + "* * * 13 *", + "* * * * 8", + "*/100 * * * *", + ] { + assert!( + !cron_expression_valid(invalid), + "accepted invalid cron: {invalid}" + ); } - let monday = Local.with_ymd_and_hms(2026, 9, 7, 10, 15, 0).single().unwrap(); + let monday = Local + .with_ymd_and_hms(2026, 9, 7, 10, 15, 0) + .single() + .unwrap(); assert!(cron_matches("15 10 * * 1", &monday)); assert!(cron_matches("15 10 * * 1-7", &monday)); assert!(!cron_matches("16 10 * * 1", &monday)); - let sunday = Local.with_ymd_and_hms(2026, 9, 6, 8, 0, 0).single().unwrap(); + let sunday = Local + .with_ymd_and_hms(2026, 9, 6, 8, 0, 0) + .single() + .unwrap(); assert!(cron_matches("0 8 * * 0", &sunday)); assert!(cron_matches("0 8 * * 7", &sunday)); assert!(cron_matches("0 8 * * 1-7", &sunday)); @@ -164,22 +227,81 @@ mod tests { let mut state = crate::models::FlowRuntimeNodeState::default(); let start = Utc.with_ymd_and_hms(2026, 9, 2, 12, 0, 0).unwrap(); assert!(!flow_timed_gate_update(&mut state, true, 30, start)); - assert!(!flow_timed_gate_update(&mut state, true, 30, start + chrono::Duration::seconds(29))); - assert!(flow_timed_gate_update(&mut state, true, 30, start + chrono::Duration::seconds(30))); - assert!(!flow_timed_gate_update(&mut state, false, 30, start + chrono::Duration::seconds(31))); + assert!(!flow_timed_gate_update( + &mut state, + true, + 30, + start + chrono::Duration::seconds(29) + )); + assert!(flow_timed_gate_update( + &mut state, + true, + 30, + start + chrono::Duration::seconds(30) + )); + assert!(!flow_timed_gate_update( + &mut state, + false, + 30, + start + chrono::Duration::seconds(31) + )); assert!(state.since.is_none()); - assert!(!flow_timed_gate_update(&mut state, true, 30, start + chrono::Duration::seconds(32))); + assert!(!flow_timed_gate_update( + &mut state, + true, + 30, + start + chrono::Duration::seconds(32) + )); } #[test] fn flow_state_duration_respects_minimum_maximum_and_reset() { let mut state = crate::models::FlowRuntimeNodeState::default(); let start = Utc.with_ymd_and_hms(2026, 9, 2, 12, 0, 0).unwrap(); - assert_eq!(flow_state_duration_update(&mut state, true, 10, Some(30), start), (false, 0)); - assert_eq!(flow_state_duration_update(&mut state, true, 10, Some(30), start + chrono::Duration::seconds(10)), (true, 10)); - assert_eq!(flow_state_duration_update(&mut state, true, 10, Some(30), start + chrono::Duration::seconds(30)), (true, 30)); - assert_eq!(flow_state_duration_update(&mut state, true, 10, Some(30), start + chrono::Duration::seconds(31)), (false, 31)); - assert_eq!(flow_state_duration_update(&mut state, false, 10, Some(30), start + chrono::Duration::seconds(32)), (false, 0)); + assert_eq!( + flow_state_duration_update(&mut state, true, 10, Some(30), start), + (false, 0) + ); + assert_eq!( + flow_state_duration_update( + &mut state, + true, + 10, + Some(30), + start + chrono::Duration::seconds(10) + ), + (true, 10) + ); + assert_eq!( + flow_state_duration_update( + &mut state, + true, + 10, + Some(30), + start + chrono::Duration::seconds(30) + ), + (true, 30) + ); + assert_eq!( + flow_state_duration_update( + &mut state, + true, + 10, + Some(30), + start + chrono::Duration::seconds(31) + ), + (false, 31) + ); + assert_eq!( + flow_state_duration_update( + &mut state, + false, + 10, + Some(30), + start + chrono::Duration::seconds(32) + ), + (false, 0) + ); assert!(state.since.is_none()); } @@ -196,33 +318,94 @@ mod tests { fn flow_rate_limit_prunes_rolling_window_and_blocks_at_limit() { let mut state = crate::models::FlowRuntimeNodeState::default(); let start = Utc.with_ymd_and_hms(2026, 9, 2, 12, 0, 0).unwrap(); - state.samples.push(crate::models::FlowRuntimeSample { at: start, value: 1.0 }); - state.samples.push(crate::models::FlowRuntimeSample { at: start + chrono::Duration::seconds(10), value: 1.0 }); - assert_eq!(flow_rate_limit_status(&mut state, 2, 60, start + chrono::Duration::seconds(20)), (false, 2)); - assert_eq!(flow_rate_limit_status(&mut state, 2, 60, start + chrono::Duration::seconds(61)), (true, 1)); + state.samples.push(crate::models::FlowRuntimeSample { + at: start, + value: 1.0, + }); + state.samples.push(crate::models::FlowRuntimeSample { + at: start + chrono::Duration::seconds(10), + value: 1.0, + }); + assert_eq!( + flow_rate_limit_status(&mut state, 2, 60, start + chrono::Duration::seconds(20)), + (false, 2) + ); + assert_eq!( + flow_rate_limit_status(&mut state, 2, 60, start + chrono::Duration::seconds(61)), + (true, 1) + ); } #[test] fn flow_rolling_statistics_compute_mean_median_and_prune_window() { let mut state = crate::models::FlowRuntimeNodeState::default(); let start = Utc.with_ymd_and_hms(2026, 9, 2, 12, 0, 0).unwrap(); - assert_eq!(flow_rolling_stat_update(&mut state, 10.0, 60, "mean", start), Some(10.0)); - assert_eq!(flow_rolling_stat_update(&mut state, 20.0, 60, "mean", start + chrono::Duration::seconds(10)), Some(15.0)); - assert_eq!(flow_rolling_stat_update(&mut state, 30.0, 60, "median", start + chrono::Duration::seconds(20)), Some(20.0)); - assert_eq!(flow_rolling_stat_update(&mut state, 40.0, 60, "median", start + chrono::Duration::seconds(30)), Some(25.0)); - assert_eq!(flow_rolling_stat_update(&mut state, 100.0, 60, "mean", start + chrono::Duration::seconds(100)), Some(100.0)); + assert_eq!( + flow_rolling_stat_update(&mut state, 10.0, 60, "mean", start), + Some(10.0) + ); + assert_eq!( + flow_rolling_stat_update( + &mut state, + 20.0, + 60, + "mean", + start + chrono::Duration::seconds(10) + ), + Some(15.0) + ); + assert_eq!( + flow_rolling_stat_update( + &mut state, + 30.0, + 60, + "median", + start + chrono::Duration::seconds(20) + ), + Some(20.0) + ); + assert_eq!( + flow_rolling_stat_update( + &mut state, + 40.0, + 60, + "median", + start + chrono::Duration::seconds(30) + ), + Some(25.0) + ); + assert_eq!( + flow_rolling_stat_update( + &mut state, + 100.0, + 60, + "mean", + start + chrono::Duration::seconds(100) + ), + Some(100.0) + ); assert_eq!(state.samples.len(), 1); } #[test] fn flow_oscillation_requires_direction_changes_not_only_large_span() { let start = Utc.with_ymd_and_hms(2026, 9, 2, 12, 0, 0).unwrap(); - let bouncing = [20.0, 22.0, 19.0, 23.0, 20.0].into_iter().enumerate().map(|(index, value)| crate::models::FlowRuntimeSample { - at: start + chrono::Duration::seconds(index as i64 * 10), value, - }).collect::>(); - let monotonic = [20.0, 21.0, 22.0, 23.0, 24.0].into_iter().enumerate().map(|(index, value)| crate::models::FlowRuntimeSample { - at: start + chrono::Duration::seconds(index as i64 * 10), value, - }).collect::>(); + let bouncing = [20.0, 22.0, 19.0, 23.0, 20.0] + .into_iter() + .enumerate() + .map(|(index, value)| crate::models::FlowRuntimeSample { + at: start + chrono::Duration::seconds(index as i64 * 10), + value, + }) + .collect::>(); + let monotonic = [20.0, 21.0, 22.0, 23.0, 24.0] + .into_iter() + .enumerate() + .map(|(index, value)| crate::models::FlowRuntimeSample { + at: start + chrono::Duration::seconds(index as i64 * 10), + value, + }) + .collect::>(); let (span, changes) = flow_oscillation_metrics(&bouncing).unwrap(); assert!(span >= 4.0); assert!(changes >= 2); @@ -232,35 +415,117 @@ mod tests { #[test] fn time_automation_fires_only_once_in_the_same_minute() { - let now = Local.with_ymd_and_hms(2025, 1, 6, 10, 15, 40).single().unwrap(); + let now = Local + .with_ymd_and_hms(2025, 1, 6, 10, 15, 40) + .single() + .unwrap(); let mut item = Automation { - id: "a".into(), name: "at time".into(), enabled: true, trigger_kind: "time".into(), - trigger_device_id: None, threshold: None, at_time: Some("10:15".into()), - action_device_id: "d".into(), action_group_id: None, action_preset: None, - action: DeviceCommand { power: Some(true), ..Default::default() }, cooldown_seconds: 30, - last_fired_at: None, action_zone_id: None, action_zone_preset: None, action_ha_domain: None, action_ha_service: None, action_ha_entity_id: None, action_ha_data: Value::Null, flow_conditions: vec![], flow_id: None, flow_node_id: None, flow_runtime: Default::default(), created_at: Utc::now(), updated_at: Utc::now(), + id: "a".into(), + name: "at time".into(), + enabled: true, + trigger_kind: "time".into(), + trigger_device_id: None, + threshold: None, + at_time: Some("10:15".into()), + action_device_id: "d".into(), + action_group_id: None, + action_preset: None, + action: DeviceCommand { + power: Some(true), + ..Default::default() + }, + cooldown_seconds: 30, + last_fired_at: None, + action_zone_id: None, + action_zone_preset: None, + action_ha_domain: None, + action_ha_service: None, + action_ha_entity_id: None, + action_ha_data: Value::Null, + flow_conditions: vec![], + flow_id: None, + flow_node_id: None, + flow_runtime: Default::default(), + created_at: Utc::now(), + updated_at: Utc::now(), }; assert!(time_automation_due(&item, now.clone())); - item.last_fired_at = Some((now.clone() - chrono::Duration::seconds(35)).with_timezone(&Utc)); + item.last_fired_at = + Some((now.clone() - chrono::Duration::seconds(35)).with_timezone(&Utc)); assert!(!time_automation_due(&item, now)); } fn test_zone(source: &str) -> Zone { Zone { - id: "z".into(), name: "Room".into(), device_id: "d".into(), enabled: true, - mode: "heat".into(), inherit_house_mode: true, setpoint: 21.0, profile_version: 1, - cool_comfort_setpoint: 23.0, cool_sleep_setpoint: 24.5, cool_away_setpoint: 27.0, - heat_comfort_setpoint: 21.0, heat_sleep_setpoint: 19.0, heat_away_setpoint: 17.0, - hysteresis: 0.6, separate_hysteresis: false, cool_hysteresis: 0.6, heat_hysteresis: 0.6, - min_on_seconds: 180, min_off_seconds: 180, min_adjust_seconds: 120, standby_offset_c: 2.0, smart_fan: true, - sensor_source: source.into(), ha_entity_id: Some("sensor.room_temperature".into()), ha_outdoor_entity_id: None, - external_sensor_weight: 0.4, max_sensor_difference: 3.0, sensor_stale_after_seconds: 300, device_temperature: None, external_temperature: None, - current_temperature: None, control_temperature_source: "device".into(), active_preset: "comfort".into(), - manual_preset: None, manual_setpoint: None, manual_override_until: None, local_thermostat_power: None, local_thermostat_resume_at: None, local_thermostat_restore_zone_enabled: None, temporary_quick_thermostat: None, - device_manual_override: false, device_manual_override_since: None, device_manual_override_until: None, device_manual_override_fields: Vec::new(), device_manual_override_baseline: None, - revision: 1, control_owner: "automation".into(), control_source: "automation".into(), control_since: Some(Utc::now()), control_resume_at: None, control_reason: "test".into(), last_power_change_at: None, last_mode_change_at: None, lockout_until: None, lockout_reason: None, compressor_pending_action: None, compressor_pending_since: None, compressor_pending_until: None, compressor_cancelled_action: None, - effective_mode: "heat".into(), effective_setpoint: Some(21.0), device_setpoint: None, - demand: false, demand_since: None, target_alerted_at: None, last_action_at: None, created_at: Utc::now(), updated_at: Utc::now(), + id: "z".into(), + name: "Room".into(), + device_id: "d".into(), + enabled: true, + mode: "heat".into(), + inherit_house_mode: true, + setpoint: 21.0, + profile_version: 1, + cool_comfort_setpoint: 23.0, + cool_sleep_setpoint: 24.5, + cool_away_setpoint: 27.0, + heat_comfort_setpoint: 21.0, + heat_sleep_setpoint: 19.0, + heat_away_setpoint: 17.0, + hysteresis: 0.6, + separate_hysteresis: false, + cool_hysteresis: 0.6, + heat_hysteresis: 0.6, + min_on_seconds: 180, + min_off_seconds: 180, + min_adjust_seconds: 120, + standby_offset_c: 2.0, + smart_fan: true, + sensor_source: source.into(), + ha_entity_id: Some("sensor.room_temperature".into()), + ha_outdoor_entity_id: None, + external_sensor_weight: 0.4, + max_sensor_difference: 3.0, + sensor_stale_after_seconds: 300, + device_temperature: None, + external_temperature: None, + current_temperature: None, + control_temperature_source: "device".into(), + active_preset: "comfort".into(), + manual_preset: None, + manual_setpoint: None, + manual_override_until: None, + local_thermostat_power: None, + local_thermostat_resume_at: None, + local_thermostat_restore_zone_enabled: None, + temporary_quick_thermostat: None, + device_manual_override: false, + device_manual_override_since: None, + device_manual_override_until: None, + device_manual_override_fields: Vec::new(), + device_manual_override_baseline: None, + revision: 1, + control_owner: "automation".into(), + control_source: "automation".into(), + control_since: Some(Utc::now()), + control_resume_at: None, + control_reason: "test".into(), + last_power_change_at: None, + last_mode_change_at: None, + lockout_until: None, + lockout_reason: None, + compressor_pending_action: None, + compressor_pending_since: None, + compressor_pending_until: None, + compressor_cancelled_action: None, + effective_mode: "heat".into(), + effective_setpoint: Some(21.0), + device_setpoint: None, + demand: false, + demand_since: None, + target_alerted_at: None, + last_action_at: None, + created_at: Utc::now(), + updated_at: Utc::now(), } } @@ -288,15 +553,24 @@ mod tests { diagnostics_only.device_temperature = Some(24.0); diagnostics_only.external_temperature = Some(23.0); diagnostics_only.last_action_at = Some(Utc::now()); - assert!(!zone_runtime_change_affects_control_plan(&zone, &diagnostics_only)); + assert!(!zone_runtime_change_affects_control_plan( + &zone, + &diagnostics_only + )); let mut temperature_changed = zone.clone(); temperature_changed.current_temperature = Some(23.5); - assert!(zone_runtime_change_affects_control_plan(&zone, &temperature_changed)); + assert!(zone_runtime_change_affects_control_plan( + &zone, + &temperature_changed + )); let mut demand_changed = zone.clone(); demand_changed.demand = !zone.demand; - assert!(zone_runtime_change_affects_control_plan(&zone, &demand_changed)); + assert!(zone_runtime_change_affects_control_plan( + &zone, + &demand_changed + )); } #[test] @@ -307,15 +581,23 @@ mod tests { heartbeat.updated_at = Utc::now() + chrono::Duration::seconds(15); heartbeat.response_time_ms = Some(42); heartbeat.current_temperature = Some(25.1); - assert!(!device_runtime_change_affects_control_plan(&device, &heartbeat)); + assert!(!device_runtime_change_affects_control_plan( + &device, &heartbeat + )); let mut power_changed = device.clone(); power_changed.power = !device.power; - assert!(device_runtime_change_affects_control_plan(&device, &power_changed)); + assert!(device_runtime_change_affects_control_plan( + &device, + &power_changed + )); let mut health_changed = device.clone(); health_changed.communication_failures = 1; - assert!(device_runtime_change_affects_control_plan(&device, &health_changed)); + assert!(device_runtime_change_affects_control_plan( + &device, + &health_changed + )); } #[test] @@ -360,22 +642,77 @@ mod tests { }; assert!(command_field_matches_device(&command, "power", &device)); assert!(command_field_matches_device(&command, "mode", &device)); - assert!(command_field_matches_device(&command, "target_temperature", &device)); + assert!(command_field_matches_device( + &command, + "target_temperature", + &device + )); device.target_temperature = 25.0; - assert!(!command_field_matches_device(&command, "target_temperature", &device)); + assert!(!command_field_matches_device( + &command, + "target_temperature", + &device + )); } #[test] fn conflicting_thermostat_output_fields_are_detected_for_direct_automation() { - assert!(!automation_action_conflicts_with_thermostat(&DeviceCommand { power: Some(true), ..Default::default() })); - assert!(!automation_action_conflicts_with_thermostat(&DeviceCommand { target_temperature: Some(22.0), ..Default::default() })); - assert!(automation_action_conflicts_with_thermostat(&DeviceCommand { fan_speed: Some(3), ..Default::default() })); - assert!(automation_action_conflicts_with_thermostat(&DeviceCommand { quiet: Some(true), ..Default::default() })); - assert!(automation_action_conflicts_with_thermostat(&DeviceCommand { mode: Some("fan".into()), ..Default::default() })); - assert!(automation_action_conflicts_with_thermostat(&DeviceCommand { mode: Some("dry".into()), ..Default::default() })); - assert!(!automation_action_conflicts_with_thermostat(&DeviceCommand { mode: Some("heat".into()), ..Default::default() })); - assert!(!automation_action_conflicts_with_thermostat(&DeviceCommand { light: Some(false), turbo: Some(true), ..Default::default() })); - assert!(!automation_action_conflicts_with_thermostat(&DeviceCommand { swing_vertical: Some(true), swing_horizontal: Some(false), ..Default::default() })); + assert!(!automation_action_conflicts_with_thermostat( + &DeviceCommand { + power: Some(true), + ..Default::default() + } + )); + assert!(!automation_action_conflicts_with_thermostat( + &DeviceCommand { + target_temperature: Some(22.0), + ..Default::default() + } + )); + assert!(automation_action_conflicts_with_thermostat( + &DeviceCommand { + fan_speed: Some(3), + ..Default::default() + } + )); + assert!(automation_action_conflicts_with_thermostat( + &DeviceCommand { + quiet: Some(true), + ..Default::default() + } + )); + assert!(automation_action_conflicts_with_thermostat( + &DeviceCommand { + mode: Some("fan".into()), + ..Default::default() + } + )); + assert!(automation_action_conflicts_with_thermostat( + &DeviceCommand { + mode: Some("dry".into()), + ..Default::default() + } + )); + assert!(!automation_action_conflicts_with_thermostat( + &DeviceCommand { + mode: Some("heat".into()), + ..Default::default() + } + )); + assert!(!automation_action_conflicts_with_thermostat( + &DeviceCommand { + light: Some(false), + turbo: Some(true), + ..Default::default() + } + )); + assert!(!automation_action_conflicts_with_thermostat( + &DeviceCommand { + swing_vertical: Some(true), + swing_horizontal: Some(false), + ..Default::default() + } + )); } #[test] @@ -402,7 +739,10 @@ mod tests { let now = Utc::now(); let mut zone = test_zone("device"); zone.temporary_quick_thermostat = Some(temporary_session(now)); - assert!(device_blocked_by_temporary_thermostat(&zone.device_id, std::slice::from_ref(&zone))); + assert!(device_blocked_by_temporary_thermostat( + &zone.device_id, + std::slice::from_ref(&zone) + )); } #[test] @@ -469,7 +809,10 @@ mod tests { zone.temporary_quick_thermostat = Some(session); assert!(!temporary_quick_thermostat_is_active(&zone, now)); - assert_eq!(temporary_quick_thermostat_wakeup_at(&zone, now), Some(now + chrono::Duration::hours(1))); + assert_eq!( + temporary_quick_thermostat_wakeup_at(&zone, now), + Some(now + chrono::Duration::hours(1)) + ); } #[test] @@ -565,8 +908,22 @@ mod tests { session.condition_started_at = Some(now.clone() - chrono::Duration::seconds(3599)); zone.temporary_quick_thermostat = Some(session); - assert!(evaluate_temporary_quick_thermostat_condition(&mut zone, now.clone(), Some(now.clone()), 10).is_none()); - assert_eq!(evaluate_temporary_quick_thermostat_condition(&mut zone, now + chrono::Duration::seconds(2), Some(now + chrono::Duration::seconds(2)), 10), Some("temperature_stable".into())); + assert!(evaluate_temporary_quick_thermostat_condition( + &mut zone, + now.clone(), + Some(now.clone()), + 10 + ) + .is_none()); + assert_eq!( + evaluate_temporary_quick_thermostat_condition( + &mut zone, + now + chrono::Duration::seconds(2), + Some(now + chrono::Duration::seconds(2)), + 10 + ), + Some("temperature_stable".into()) + ); } #[test] @@ -578,8 +935,15 @@ mod tests { session.condition_started_at = Some(now.clone() - chrono::Duration::minutes(30)); zone.temporary_quick_thermostat = Some(session); - assert!(evaluate_temporary_quick_thermostat_condition(&mut zone, now, Some(now), 10).is_none()); - assert!(zone.temporary_quick_thermostat.as_ref().unwrap().condition_started_at.is_none()); + assert!( + evaluate_temporary_quick_thermostat_condition(&mut zone, now, Some(now), 10).is_none() + ); + assert!(zone + .temporary_quick_thermostat + .as_ref() + .unwrap() + .condition_started_at + .is_none()); } #[test] @@ -592,8 +956,16 @@ mod tests { session.condition_last_observed_at = Some(now - chrono::Duration::minutes(10)); zone.temporary_quick_thermostat = Some(session); - assert!(evaluate_temporary_quick_thermostat_condition(&mut zone, now, Some(now), 30).is_none()); - assert_eq!(zone.temporary_quick_thermostat.as_ref().unwrap().condition_started_at, Some(now)); + assert!( + evaluate_temporary_quick_thermostat_condition(&mut zone, now, Some(now), 30).is_none() + ); + assert_eq!( + zone.temporary_quick_thermostat + .as_ref() + .unwrap() + .condition_started_at, + Some(now) + ); } #[test] @@ -607,8 +979,14 @@ mod tests { session.expires_at = Some(now.clone() + chrono::Duration::hours(3)); zone.temporary_quick_thermostat = Some(session); - assert!(!device_blocked_by_local_thermostat(&zone.device_id, std::slice::from_ref(&zone))); - assert_eq!(temporary_quick_thermostat_wakeup_at(&zone, now.clone()), Some(now + chrono::Duration::hours(1))); + assert!(!device_blocked_by_local_thermostat( + &zone.device_id, + std::slice::from_ref(&zone) + )); + assert_eq!( + temporary_quick_thermostat_wakeup_at(&zone, now.clone()), + Some(now + chrono::Duration::hours(1)) + ); } #[test] @@ -621,8 +999,15 @@ mod tests { session.activated_at = None; zone.temporary_quick_thermostat = Some(session); - assert!(evaluate_temporary_quick_thermostat_condition(&mut zone, now, Some(now), 10).is_none()); - assert!(zone.temporary_quick_thermostat.as_ref().unwrap().condition_started_at.is_none()); + assert!( + evaluate_temporary_quick_thermostat_condition(&mut zone, now, Some(now), 10).is_none() + ); + assert!(zone + .temporary_quick_thermostat + .as_ref() + .unwrap() + .condition_started_at + .is_none()); } #[test] @@ -631,7 +1016,7 @@ mod tests { zone.local_thermostat_power = Some(true); zone.manual_setpoint = Some(23.0); zone.temporary_quick_thermostat = Some(temporary_session(Utc::now())); - let mut schedule = test_schedule("night", vec![1,2,3,4,5,6,7], "00:00", "00:00"); + let mut schedule = test_schedule("night", vec![1, 2, 3, 4, 5, 6, 7], "00:00", "00:00"); schedule.preset = "custom".into(); schedule.setpoint = 19.0; @@ -695,13 +1080,19 @@ mod tests { let first = Utc::now(); set_local_thermostat_power(&mut zone, false, first.clone()); let first_deadline = zone.local_thermostat_resume_at.clone().unwrap(); - assert_eq!(first_deadline, first.clone() + chrono::Duration::minutes(15)); + assert_eq!( + first_deadline, + first.clone() + chrono::Duration::minutes(15) + ); let second = first + chrono::Duration::minutes(4); set_local_thermostat_power(&mut zone, true, second.clone()); assert!(zone.local_thermostat_resume_at.is_none()); set_local_thermostat_power(&mut zone, false, second.clone()); - assert_eq!(zone.local_thermostat_resume_at, Some(second + chrono::Duration::minutes(15))); + assert_eq!( + zone.local_thermostat_resume_at, + Some(second + chrono::Duration::minutes(15)) + ); assert!(zone.local_thermostat_resume_at.clone().unwrap() > first_deadline); } @@ -718,7 +1109,10 @@ mod tests { zone.device_manual_override = false; let returned = now + chrono::Duration::minutes(7); assert!(rearm_local_thermostat_resume(&mut zone, returned.clone())); - assert_eq!(zone.local_thermostat_resume_at, Some(returned + chrono::Duration::minutes(15))); + assert_eq!( + zone.local_thermostat_resume_at, + Some(returned + chrono::Duration::minutes(15)) + ); assert!(local_thermostat_handback_is_active(&zone)); } @@ -880,10 +1274,18 @@ mod tests { #[test] fn local_off_blocks_even_an_active_schedule_until_resume() { let mut zone = test_zone("device"); - let schedule = test_schedule("active", vec![1,2,3,4,5,6,7], "00:00", "00:00"); + let schedule = test_schedule("active", vec![1, 2, 3, 4, 5, 6, 7], "00:00", "00:00"); zone.local_thermostat_power = Some(false); - assert!(zone_has_thermostat_intent_source(&zone, Some(&schedule), Utc::now())); - assert!(!zone_has_active_thermostat_intent(&zone, Some(&schedule), Utc::now())); + assert!(zone_has_thermostat_intent_source( + &zone, + Some(&schedule), + Utc::now() + )); + assert!(!zone_has_active_thermostat_intent( + &zone, + Some(&schedule), + Utc::now() + )); } #[test] @@ -954,8 +1356,14 @@ mod tests { fn external_room_sensor_selects_lower_cooling_setpoint_only_when_used() { assert_eq!(external_room_sensor_cooling_assist("cool", "external"), 1.0); assert_eq!(external_room_sensor_cooling_assist("cool", "combined"), 1.0); - assert_eq!(external_room_sensor_cooling_assist("cool", "device_fallback"), 0.0); - assert_eq!(external_room_sensor_cooling_assist("cool", "device_discrepancy_fallback"), 0.0); + assert_eq!( + external_room_sensor_cooling_assist("cool", "device_fallback"), + 0.0 + ); + assert_eq!( + external_room_sensor_cooling_assist("cool", "device_discrepancy_fallback"), + 0.0 + ); assert_eq!(external_room_sensor_cooling_assist("heat", "external"), 0.0); } @@ -976,29 +1384,84 @@ mod tests { #[test] fn smart_quiet_follows_satisfied_transition_only_when_supported() { - assert_eq!(smart_quiet_command(true, true, true, false, false, false, false, true), Some(true)); - assert_eq!(smart_quiet_command(true, true, false, false, false, false, false, true), None); - assert_eq!(smart_quiet_command(true, true, false, false, true, false, false, true), None); - assert_eq!(smart_quiet_command(true, true, false, true, true, false, false, true), Some(false)); - assert_eq!(smart_quiet_command(true, true, true, true, true, false, false, true), None); - assert_eq!(smart_quiet_command(true, false, true, false, false, false, false, true), None); - assert_eq!(smart_quiet_command(false, true, true, false, false, false, false, true), None); + assert_eq!( + smart_quiet_command(true, true, true, false, false, false, false, true), + Some(true) + ); + assert_eq!( + smart_quiet_command(true, true, false, false, false, false, false, true), + None + ); + assert_eq!( + smart_quiet_command(true, true, false, false, true, false, false, true), + None + ); + assert_eq!( + smart_quiet_command(true, true, false, true, true, false, false, true), + Some(false) + ); + assert_eq!( + smart_quiet_command(true, true, true, true, true, false, false, true), + None + ); + assert_eq!( + smart_quiet_command(true, false, true, false, false, false, false, true), + None + ); + assert_eq!( + smart_quiet_command(false, true, true, false, false, false, false, true), + None + ); } #[test] fn night_mode_handles_midnight_and_limits_auto_fan() { - let settings = NightModeSettings { enabled: true, start_time: "22:00".into(), end_time: "06:00".into(), max_fan_speed: 1, force_quiet: true, use_native_sleep: true }; - assert!(night_mode_active(&settings, NaiveTime::from_hms_opt(23, 30, 0).unwrap())); - assert!(night_mode_active(&settings, NaiveTime::from_hms_opt(5, 59, 0).unwrap())); - assert!(!night_mode_active(&settings, NaiveTime::from_hms_opt(12, 0, 0).unwrap())); + let settings = NightModeSettings { + enabled: true, + start_time: "22:00".into(), + end_time: "06:00".into(), + max_fan_speed: 1, + force_quiet: true, + use_native_sleep: true, + }; + assert!(night_mode_active( + &settings, + NaiveTime::from_hms_opt(23, 30, 0).unwrap() + )); + assert!(night_mode_active( + &settings, + NaiveTime::from_hms_opt(5, 59, 0).unwrap() + )); + assert!(!night_mode_active( + &settings, + NaiveTime::from_hms_opt(12, 0, 0).unwrap() + )); assert_eq!(night_limited_fan_speed(0, 1), 1); assert_eq!(night_limited_fan_speed(3, 1), 1); - assert_eq!(smart_quiet_command(false, true, true, true, false, true, true, true), Some(true)); - assert_eq!(smart_quiet_command(false, true, true, true, true, true, false, true), Some(false)); - assert_eq!(smart_quiet_command(true, true, false, false, true, true, false, true), Some(false)); - assert_eq!(native_sleep_command(true, true, true, true, false), Some(true)); - assert_eq!(native_sleep_command(true, false, true, true, true), Some(false)); - assert_eq!(native_sleep_command(false, false, false, true, true), Some(false)); + assert_eq!( + smart_quiet_command(false, true, true, true, false, true, true, true), + Some(true) + ); + assert_eq!( + smart_quiet_command(false, true, true, true, true, true, false, true), + Some(false) + ); + assert_eq!( + smart_quiet_command(true, true, false, false, true, true, false, true), + Some(false) + ); + assert_eq!( + native_sleep_command(true, true, true, true, false), + Some(true) + ); + assert_eq!( + native_sleep_command(true, false, true, true, true), + Some(false) + ); + assert_eq!( + native_sleep_command(false, false, false, true, true), + Some(false) + ); assert_eq!(native_sleep_command(true, true, true, false, false), None); } @@ -1054,10 +1517,18 @@ mod tests { let owner = zone.control_owner.clone(); let source = zone.control_source.clone(); let until = Utc::now() + chrono::Duration::seconds(180); - queue_compressor_action(&mut zone, "global_power_on".into(), until, "minimum_off_before_global_start"); + queue_compressor_action( + &mut zone, + "global_power_on".into(), + until, + "minimum_off_before_global_start", + ); assert_eq!(zone.control_owner, owner); assert_eq!(zone.control_source, source); - assert_eq!(zone.compressor_pending_action.as_deref(), Some("global_power_on")); + assert_eq!( + zone.compressor_pending_action.as_deref(), + Some("global_power_on") + ); } #[test] @@ -1067,17 +1538,31 @@ mod tests { let action = compressor_action_id("power_on", "cool", 23.04); assert_eq!(action, "power_on:cool:23.0"); - queue_compressor_action(&mut zone, action.clone(), until.clone(), "minimum_off_before_start"); - assert_eq!(zone.compressor_pending_action.as_deref(), Some(action.as_str())); + queue_compressor_action( + &mut zone, + action.clone(), + until.clone(), + "minimum_off_before_start", + ); + assert_eq!( + zone.compressor_pending_action.as_deref(), + Some(action.as_str()) + ); assert!(zone.compressor_pending_since.is_some()); assert_eq!(zone.compressor_pending_until, Some(until.clone())); assert_eq!(zone.lockout_until, Some(until)); - assert_eq!(zone.lockout_reason.as_deref(), Some("minimum_off_before_start")); + assert_eq!( + zone.lockout_reason.as_deref(), + Some("minimum_off_before_start") + ); zone.compressor_cancelled_action = Some(action.clone()); clear_compressor_pending(&mut zone, false); assert!(zone.compressor_pending_action.is_none()); - assert_eq!(zone.compressor_cancelled_action.as_deref(), Some(action.as_str())); + assert_eq!( + zone.compressor_cancelled_action.as_deref(), + Some(action.as_str()) + ); rearm_compressor_queue(&mut zone); assert!(zone.compressor_cancelled_action.is_none()); @@ -1130,5 +1615,4 @@ mod tests { assert_eq!(zone.control_source, "house_power"); assert!(zone_has_active_thermostat_intent(&zone, None, Utc::now())); } - } diff --git a/src/engine/zone_control.rs b/src/engine/zone_control.rs index 2469592..a006be8 100644 --- a/src/engine/zone_control.rs +++ b/src/engine/zone_control.rs @@ -77,7 +77,8 @@ async fn resolve_cycle_outdoor_temperature( None }; 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; if *current != temperature { *current = temperature; @@ -237,14 +238,9 @@ fn refresh_zone_temperature( } else { "ha.sensor_error" }; - let entity_id = resolved_entity - .as_deref() - .or(zone.ha_entity_id.as_deref()); - let message = home_assistant_sensor_log_message( - &err, - entity_id, - &zone.name, - ); + let entity_id = resolved_entity.as_deref().or(zone.ha_entity_id.as_deref()); + let message = + home_assistant_sensor_log_message(&err, entity_id, &zone.name); state.log( "warn", kind, @@ -1196,10 +1192,7 @@ async fn control_zones(state: &AppState) -> Result<()> { outdoor_temperature, settings.poll_interval_seconds, ); - publish_persisted_zone_cycle( - state, - persist_zone_cycle(state, &zone, cycle_started_at)?, - )?; + publish_persisted_zone_cycle(state, persist_zone_cycle(state, &zone, cycle_started_at)?)?; } Ok(()) diff --git a/src/home_assistant.rs b/src/home_assistant.rs index f28e514..1dfc9a9 100644 --- a/src/home_assistant.rs +++ b/src/home_assistant.rs @@ -129,7 +129,9 @@ pub async fn test_connection( settings: &HomeAssistantSettings, ) -> Result> { 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)? .get(base) .bearer_auth(token.trim()) @@ -323,7 +325,9 @@ pub async fn list_entities( settings: &HomeAssistantSettings, ) -> Result> { 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)? .get(base) .bearer_auth(token.trim()) diff --git a/src/influxdb.rs b/src/influxdb.rs index 32ba37d..360f871 100644 --- a/src/influxdb.rs +++ b/src/influxdb.rs @@ -4,7 +4,9 @@ use reqwest::Client; use serde_json::Value; 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 ZONE_MEASUREMENT: &str = "gree_zone"; @@ -16,3 +18,17 @@ const NETWORK_MEASUREMENT: &str = "gree_network"; include!("influxdb/write.rs"); include!("influxdb/query.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(()) +} diff --git a/src/influxdb/query.rs b/src/influxdb/query.rs index 5df2f94..cefc058 100644 --- a/src/influxdb/query.rs +++ b/src/influxdb/query.rs @@ -489,7 +489,9 @@ pub async fn query_energy( for item in series { let source = item.tags.get("source").cloned().unwrap_or_default(); for row in item.rows { - let Some(timestamp) = row_time(&row) else { continue; }; + let Some(timestamp) = row_time(&row) else { + continue; + }; out.push(EnergyReading { id: 0, device_id: device_id.to_string(), @@ -526,7 +528,9 @@ pub async fn query_energy( let rows = query_v2(client, settings, &query).await?; let mut out = Vec::new(); for row in rows.into_iter().take(limit as usize) { - let Some(timestamp) = parse_flux_time(&row) else { continue; }; + let Some(timestamp) = parse_flux_time(&row) else { + continue; + }; out.push(EnergyReading { id: 0, device_id: device_id.to_string(), @@ -566,10 +570,16 @@ pub async fn query_network( let mut out = Vec::new(); for item in series { 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(); for row in item.rows { - let Some(timestamp) = row_time(&row) else { continue; }; + let Some(timestamp) = row_time(&row) else { + continue; + }; out.push(NetworkReading { id: 0, target_id: id.clone(), @@ -577,9 +587,17 @@ pub async fn query_network( timestamp, latency_ms: row_num(&row, "latency_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), - 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, + packet_loss_pct: row_num(&row, "packet_loss_pct") + .unwrap_or(0.0) + .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(), }); } @@ -590,7 +608,12 @@ pub async fn query_network( } 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(); let query = flux_query( settings, @@ -604,18 +627,33 @@ pub async fn query_network( let rows = query_v2(client, settings, &query).await?; let mut out = Vec::new(); for row in rows.into_iter().take(limit as usize) { - let Some(timestamp) = parse_flux_time(&row) else { continue; }; - let Some(id) = row.get("target_id").filter(|v| !v.is_empty()) else { continue; }; + let Some(timestamp) = parse_flux_time(&row) else { + continue; + }; + let Some(id) = row.get("target_id").filter(|v| !v.is_empty()) else { + continue; + }; out.push(NetworkReading { id: 0, 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, latency_ms: row_f64(&row, "latency_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), - 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, + packet_loss_pct: row_f64(&row, "packet_loss_pct") + .unwrap_or(0.0) + .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(), }); } diff --git a/src/influxdb/write.rs b/src/influxdb/write.rs index 366a41b..4f879bb 100644 --- a/src/influxdb/write.rs +++ b/src/influxdb/write.rs @@ -1,19 +1,32 @@ - pub async fn write_network( client: &Client, settings: &InfluxDbSettings, reading: &NetworkReading, ) -> Result<()> { - if !settings.enabled { return Ok(()); } + if !settings.enabled { + return Ok(()); + } let mut fields = Vec::new(); push_float(&mut fields, "latency_ms", reading.latency_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, "successful_samples", reading.successful_samples as i64); + push_int( + &mut fields, + "successful_samples", + reading.successful_samples as i64, + ); let line = line_protocol( 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, reading.timestamp, )?; @@ -25,18 +38,32 @@ pub async fn write_network_batch( settings: &InfluxDbSettings, readings: &[NetworkReading], ) -> 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()); for reading in readings { let mut fields = Vec::new(); push_float(&mut fields, "latency_ms", reading.latency_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, "successful_samples", reading.successful_samples as i64); + push_int( + &mut fields, + "successful_samples", + reading.successful_samples as i64, + ); lines.push(line_protocol( 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, reading.timestamp, )?); @@ -44,22 +71,40 @@ pub async fn write_network_batch( write_lines(client, settings, lines.join("\n")).await } - pub async fn write_energy( client: &Client, settings: &InfluxDbSettings, reading: &EnergyReading, ) -> Result<()> { - if !settings.enabled { return Ok(()); } + if !settings.enabled { + return Ok(()); + } let mut fields = Vec::new(); - push_float(&mut fields, "raw_meter_value", Some(reading.raw_meter_value)); - push_float(&mut fields, "normalized_meter_kwh", Some(reading.normalized_meter_kwh)); - push_float(&mut fields, "consumption_kwh", Some(reading.consumption_kwh.max(0.0))); + push_float( + &mut fields, + "raw_meter_value", + Some(reading.raw_meter_value), + ); + push_float( + &mut fields, + "normalized_meter_kwh", + Some(reading.normalized_meter_kwh), + ); + push_float( + &mut fields, + "consumption_kwh", + Some(reading.consumption_kwh.max(0.0)), + ); push_float(&mut fields, "current_power_kw", reading.current_power_kw); push_int(&mut fields, "reset_detected", reading.reset_detected as i64); let line = line_protocol( ENERGY_MEASUREMENT, - &[("device_id", &reading.device_id), ("source", &reading.source), ("quality", &reading.quality), ("raw_unit", &reading.raw_unit)], + &[ + ("device_id", &reading.device_id), + ("source", &reading.source), + ("quality", &reading.quality), + ("raw_unit", &reading.raw_unit), + ], fields, reading.timestamp, )?; @@ -71,18 +116,37 @@ pub async fn write_energy_batch( settings: &InfluxDbSettings, readings: &[EnergyReading], ) -> 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()); for reading in readings { let mut fields = Vec::new(); - push_float(&mut fields, "raw_meter_value", Some(reading.raw_meter_value)); - push_float(&mut fields, "normalized_meter_kwh", Some(reading.normalized_meter_kwh)); - push_float(&mut fields, "consumption_kwh", Some(reading.consumption_kwh.max(0.0))); + push_float( + &mut fields, + "raw_meter_value", + Some(reading.raw_meter_value), + ); + push_float( + &mut fields, + "normalized_meter_kwh", + Some(reading.normalized_meter_kwh), + ); + push_float( + &mut fields, + "consumption_kwh", + Some(reading.consumption_kwh.max(0.0)), + ); push_float(&mut fields, "current_power_kw", reading.current_power_kw); push_int(&mut fields, "reset_detected", reading.reset_detected as i64); lines.push(line_protocol( ENERGY_MEASUREMENT, - &[("device_id", &reading.device_id), ("source", &reading.source), ("quality", &reading.quality), ("raw_unit", &reading.raw_unit)], + &[ + ("device_id", &reading.device_id), + ("source", &reading.source), + ("quality", &reading.quality), + ("raw_unit", &reading.raw_unit), + ], fields, reading.timestamp, )?); diff --git a/src/main.rs b/src/main.rs index 2ce3ac7..2e458b9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -49,7 +49,12 @@ async fn main() -> Result<()> { runtime_settings.discovery_broadcast = config.discovery_broadcast.clone(); } config.apply_runtime_env_overrides(&mut runtime_settings); - if runtime_settings.gree_cloud.installation_id.trim().is_empty() { + if runtime_settings + .gree_cloud + .installation_id + .trim() + .is_empty() + { runtime_settings.gree_cloud.installation_id = uuid::Uuid::new_v4().to_string(); } if runtime_settings.gree_cloud.account_id.trim().is_empty() { @@ -162,8 +167,8 @@ async fn main() -> Result<()> { listener, app.into_make_service_with_connect_info::(), ) - .with_graceful_shutdown(wait_for_shutdown(shutdown_rx.clone())) - .into_future(); + .with_graceful_shutdown(wait_for_shutdown(shutdown_rx.clone())) + .into_future(); tokio::pin!(server); let deadline_rx = shutdown_rx; tokio::select! { diff --git a/src/models/device.rs b/src/models/device.rs index 3542cf2..fa4d7eb 100644 --- a/src/models/device.rs +++ b/src/models/device.rs @@ -17,7 +17,6 @@ pub enum ConnectionStatus { Unknown, } - #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DeviceCapabilities { #[serde(default = "default_true")] @@ -36,32 +35,59 @@ pub struct DeviceCapabilities { pub vertical_swing: bool, #[serde(default = "default_true")] pub horizontal_swing: bool, - #[serde(default)] pub turbo: bool, - #[serde(default)] pub quiet: bool, - #[serde(default)] pub sleep: bool, - #[serde(default)] pub light: bool, - #[serde(default)] pub health: bool, - #[serde(default)] pub buzzer_control: bool, - #[serde(default)] pub energy_meter: bool, - #[serde(default)] pub compressor_frequency: bool, + #[serde(default)] + pub turbo: bool, + #[serde(default)] + pub quiet: bool, + #[serde(default)] + pub sleep: bool, + #[serde(default)] + pub light: bool, + #[serde(default)] + pub health: bool, + #[serde(default)] + pub buzzer_control: bool, + #[serde(default)] + pub energy_meter: bool, + #[serde(default)] + pub compressor_frequency: bool, } impl Default for DeviceCapabilities { fn default() -> Self { Self { - power: true, mode: true, fan_modes: default_fan_modes(), - min_temperature: 8.0, max_temperature: 30.0, temperature_step: 1.0, - vertical_swing: true, horizontal_swing: true, turbo: false, quiet: false, - sleep: false, light: false, health: false, buzzer_control: false, - energy_meter: false, compressor_frequency: false, + power: true, + mode: true, + fan_modes: default_fan_modes(), + min_temperature: 8.0, + max_temperature: 30.0, + temperature_step: 1.0, + vertical_swing: true, + horizontal_swing: true, + turbo: false, + quiet: false, + sleep: false, + light: false, + health: false, + buzzer_control: false, + energy_meter: false, + compressor_frequency: false, } } } -fn default_fan_modes() -> Vec { vec![0, 1, 2, 3, 4, 5] } -fn default_min_temperature() -> f64 { 8.0 } -fn default_max_temperature() -> f64 { 30.0 } -fn default_temperature_step() -> f64 { 1.0 } +fn default_fan_modes() -> Vec { + vec![0, 1, 2, 3, 4, 5] +} +fn default_min_temperature() -> f64 { + 8.0 +} +fn default_max_temperature() -> f64 { + 30.0 +} +fn default_temperature_step() -> f64 { + 1.0 +} #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] #[serde(rename_all = "snake_case")] @@ -296,8 +322,13 @@ impl Device { communication_failures: 0, pending_command: false, capabilities: DeviceCapabilities { - turbo: true, quiet: true, sleep: true, light: true, health: true, - buzzer_control: true, ..DeviceCapabilities::default() + turbo: true, + quiet: true, + sleep: true, + light: true, + health: true, + buzzer_control: true, + ..DeviceCapabilities::default() }, energy_source: EnergySourcePreference::Auto, ha_energy_entity_id: None, @@ -320,10 +351,16 @@ impl Device { self.capabilities.horizontal_swing = true; self.capabilities.temperature_step = 1.0; } - self.capabilities.turbo = self.supports_turbo.unwrap_or(self.connection_type == ConnectionType::Local); - self.capabilities.quiet = self.supports_quiet.unwrap_or(self.connection_type == ConnectionType::Local); + self.capabilities.turbo = self + .supports_turbo + .unwrap_or(self.connection_type == ConnectionType::Local); + self.capabilities.quiet = self + .supports_quiet + .unwrap_or(self.connection_type == ConnectionType::Local); self.capabilities.sleep = self.supports_sleep.unwrap_or(false); - self.capabilities.light = self.supports_light.unwrap_or(self.connection_type == ConnectionType::Local); + self.capabilities.light = self + .supports_light + .unwrap_or(self.connection_type == ConnectionType::Local); self.capabilities.health = self.supports_health.unwrap_or(false); self.capabilities.buzzer_control = self.supports_buzzer_control.unwrap_or(false); self.capabilities.energy_meter = self.supports_energy_meter.unwrap_or(false); @@ -391,8 +428,16 @@ impl DeviceCommand { .cloned(), target_temperature: self.target_temperature.filter(|value| { let step = device.capabilities.temperature_step.max(0.5); - let requested = (value.clamp(device.capabilities.min_temperature, device.capabilities.max_temperature) / step).round(); - let current = (device.target_temperature.clamp(device.capabilities.min_temperature, device.capabilities.max_temperature) / step).round(); + let requested = (value.clamp( + device.capabilities.min_temperature, + device.capabilities.max_temperature, + ) / step) + .round(); + let current = (device.target_temperature.clamp( + device.capabilities.min_temperature, + device.capabilities.max_temperature, + ) / step) + .round(); requested != current }), fan_speed: self diff --git a/src/models/history.rs b/src/models/history.rs index 0f58675..383ae10 100644 --- a/src/models/history.rs +++ b/src/models/history.rs @@ -40,7 +40,6 @@ pub struct HaReading { pub temperature: f64, } - #[derive(Debug, Clone, Serialize, Deserialize)] pub struct NetworkReading { pub id: i64, @@ -90,4 +89,3 @@ pub struct EnergyReading { #[serde(default)] pub reset_detected: bool, } - diff --git a/src/models/settings_api.rs b/src/models/settings_api.rs index 79ad357..1b05883 100644 --- a/src/models/settings_api.rs +++ b/src/models/settings_api.rs @@ -168,4 +168,3 @@ pub struct SettingsSnapshot { pub home_assistant: HomeAssistantSettingsView, pub debug: DebugSettings, } - diff --git a/src/notifications.rs b/src/notifications.rs index b5cdc97..61b5b0a 100644 --- a/src/notifications.rs +++ b/src/notifications.rs @@ -151,10 +151,7 @@ fn persist_notification_status( }; let updated = with_notification_status(metadata, status, reason, provider); match state.db.update_event_metadata(event_id, &updated) { - Ok(true) => state.broadcast( - "log.updated", - json!({"id": event_id, "metadata": updated}), - ), + Ok(true) => state.broadcast("log.updated", json!({"id": event_id, "metadata": updated})), 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"), } @@ -224,14 +221,9 @@ pub async fn dispatch( _ => Err("unsupported notification provider".into()), }; match result { - Ok(()) => persist_notification_status( - &state, - event_id, - &metadata, - "sent", - None, - &cfg.provider, - ), + Ok(()) => { + persist_notification_status(&state, event_id, &metadata, "sent", None, &cfg.provider) + } Err(err) => { persist_notification_status( &state, diff --git a/src/protocol/gree/commands.rs b/src/protocol/gree/commands.rs index 7cc715d..7e5c644 100644 --- a/src/protocol/gree/commands.rs +++ b/src/protocol/gree/commands.rs @@ -199,11 +199,7 @@ impl GreeClient { } if let Some(v) = command.quiet { opt.push("Quiet"); - values.push(json!(if v { - quiet_on_value.clamp(1, 3) - } else { - 0 - })); + values.push(json!(if v { quiet_on_value.clamp(1, 3) } else { 0 })); } if let Some(v) = command.turbo { opt.push("Tur"); diff --git a/src/protocol/gree/network.rs b/src/protocol/gree/network.rs index 0b52f0f..0882b91 100644 --- a/src/protocol/gree/network.rs +++ b/src/protocol/gree/network.rs @@ -11,12 +11,16 @@ fn local_ipv4_config_for_target(target: Ipv4Addr) -> Result = None; while !current.is_null() { 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 { 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 current.prefix_len).unwrap_or(true) { + if best + .as_ref() + .map(|current| prefix_len > current.prefix_len) + .unwrap_or(true) + { best = Some(candidate); } } @@ -48,7 +56,9 @@ fn local_ipv4_config_for_target(target: Ipv4Addr) -> Result Result> { Ok(None) } +fn local_ipv4_config_for_target(_target: Ipv4Addr) -> Result> { + Ok(None) +} #[cfg(target_os = "linux")] 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::().ok(); unsafe { 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 found = None; 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 = Ipv4Addr::from(mask_addr.sin_addr.s_addr.to_ne_bytes()); Ipv4Addr::from(u32::from(ip) | !u32::from(mask)) - } else { Ipv4Addr::BROADCAST }; + } else { + Ipv4Addr::BROADCAST + }; found = Some((ip, broadcast)); 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})") } -fn interface_ipv4(interface: &str) -> Result { interface_ipv4_config(interface).map(|(ip, _)| ip) } -fn value_as_i64(value: &Value) -> Option { value.as_i64().or_else(|| value.as_str()?.trim().parse().ok()) } +fn interface_ipv4(interface: &str) -> Result { + interface_ipv4_config(interface).map(|(ip, _)| ip) +} +fn value_as_i64(value: &Value) -> Option { + value + .as_i64() + .or_else(|| value.as_str()?.trim().parse().ok()) +} fn value_as_f64(value: &Value) -> Option { - 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 { 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 { } 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 { 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}"), } } - diff --git a/src/protocol/gree/tests.rs b/src/protocol/gree/tests.rs index 2d6ae40..9098ba9 100644 --- a/src/protocol/gree/tests.rs +++ b/src/protocol/gree/tests.rs @@ -67,7 +67,9 @@ mod tests { "cols": ["Pow", "Quiet"], "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.quiet); @@ -87,7 +89,10 @@ mod tests { ) .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]))); } @@ -105,7 +110,9 @@ mod tests { "cols": ["Pow", "Air"], "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.air); diff --git a/src/protocol/gree_cloud.rs b/src/protocol/gree_cloud.rs index 174d6d7..364df0a 100644 --- a/src/protocol/gree_cloud.rs +++ b/src/protocol/gree_cloud.rs @@ -117,7 +117,11 @@ impl GreeCloudApi { .await .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 .get("msg") .and_then(Value::as_str) @@ -230,8 +234,14 @@ impl GreeCloudApi { Ok(devices) } - async fn request_now(&self, endpoint: &str, payload: Value, hash_props: &[&str]) -> Result { - self.request_at(endpoint, payload, hash_props, Utc::now()).await + async fn request_now( + &self, + endpoint: &str, + payload: Value, + hash_props: &[&str], + ) -> Result { + self.request_at(endpoint, payload, hash_props, Utc::now()) + .await } async fn request_at( @@ -318,7 +328,11 @@ fn filter_duplicate_devices(devices: Vec) -> Vec bool { .as_bool() .or_else(|| value.as_i64().map(|v| v != 0)) .or_else(|| { - value - .as_str() - .map(|v| !matches!(v.trim().to_ascii_lowercase().as_str(), "0" | "false" | "off" | "")) + value.as_str().map(|v| { + !matches!( + v.trim().to_ascii_lowercase().as_str(), + "0" | "false" | "off" | "" + ) + }) }) .unwrap_or(false) } @@ -383,9 +400,9 @@ fn md5_hex(input: &str) -> String { fn md5(input: &[u8]) -> [u8; 16] { const S: [u32; 64] = [ - 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 5, 9, 14, 20, 5, 9, - 14, 20, 5, 9, 14, 20, 5, 9, 14, 20, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 4, - 11, 16, 23, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21, + 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 5, 9, 14, 20, 5, 9, 14, 20, 5, + 9, 14, 20, 5, 9, 14, 20, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 6, 10, + 15, 21, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21, ]; const K: [u32; 64] = [ 0xd76aa478, 0xe8c7b756, 0x242070db, 0xc1bdceee, 0xf57c0faf, 0x4787c62a, 0xa8304613, @@ -432,10 +449,7 @@ fn md5(input: &[u8]) -> [u8; 16] { 32..=47 => (b ^ c ^ d, (3 * i + 5) % 16), _ => (c ^ (b | (!d)), (7 * i) % 16), }; - let next = a - .wrapping_add(f) - .wrapping_add(K[i]) - .wrapping_add(m[g]); + let next = a.wrapping_add(f).wrapping_add(K[i]).wrapping_add(m[g]); a = d; d = c; c = b; diff --git a/src/protocol/gree_cloud_mqtt.rs b/src/protocol/gree_cloud_mqtt.rs index e5bb585..d1cfce9 100644 --- a/src/protocol/gree_cloud_mqtt.rs +++ b/src/protocol/gree_cloud_mqtt.rs @@ -13,8 +13,8 @@ use tokio::{ sync::{broadcast, mpsc}, time::{interval, timeout}, }; -use tokio_rustls::{rustls, TlsConnector}; use tokio_rustls::rustls::pki_types::ServerName; +use tokio_rustls::{rustls, TlsConnector}; pub const MQTT_PORT: u16 = 1984; pub const MQTT_KEEPALIVE_SECONDS: u16 = 60; @@ -61,11 +61,18 @@ pub struct MqttDeviceEnvelope { #[derive(Debug, Clone)] pub enum MqttEvent { - Message { topic: String, payload: Vec }, + Message { + topic: String, + payload: Vec, + }, /// Broker-level traffic such as SUBACK/PUBACK/PINGRESP. This proves the MQTT /// session is alive without being mistaken for a response from the HVAC unit. - Traffic { kind: &'static str }, - Disconnected { reason: String }, + Traffic { + kind: &'static str, + }, + Disconnected { + reason: String, + }, } #[derive(Debug)] @@ -150,13 +157,7 @@ impl MqttConnection { let connected = Arc::new(AtomicBool::new(true)); let packet_ids = Arc::new(AtomicU16::new(1)); let last_rx_ms = Arc::new(AtomicU64::new(unix_millis())); - spawn_writer( - writer, - rx, - connected.clone(), - events.clone(), - packet_ids, - ); + spawn_writer(writer, rx, connected.clone(), events.clone(), packet_ids); spawn_reader( reader, tx.clone(), @@ -230,7 +231,9 @@ impl MqttConnection { Ok(MqttEvent::Disconnected { reason }) => break Err(anyhow!("{}", reason)), Ok(_) => {} 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( Ok((header, payload)) => { last_rx_ms.store(unix_millis(), Ordering::Release); match header >> 4 { - 3 => { - if let Err(err) = handle_publish(header, &payload, &tx, &events).await { - tracing::warn!(error=?err, "invalid GREE Cloud MQTT PUBLISH"); + 3 => { + if let Err(err) = handle_publish(header, &payload, &tx, &events).await { + tracing::warn!(error=?err, "invalid GREE Cloud MQTT PUBLISH"); + } } - } - 9 => { let _ = events.send(MqttEvent::Traffic { kind: "SUBACK" }); } - 4 => { let _ = events.send(MqttEvent::Traffic { kind: "PUBACK" }); } - 13 => { let _ = events.send(MqttEvent::Traffic { kind: "PINGRESP" }); } - _ => {} + 9 => { + let _ = events.send(MqttEvent::Traffic { kind: "SUBACK" }); + } + 4 => { + let _ = events.send(MqttEvent::Traffic { kind: "PUBACK" }); + } + 13 => { + let _ = events.send(MqttEvent::Traffic { kind: "PINGRESP" }); + } + _ => {} } } Err(err) => { @@ -415,7 +424,10 @@ async fn handle_publish( } } let body = payload[offset..].to_vec(); - let _ = events.send(MqttEvent::Message { topic, payload: body }); + let _ = events.send(MqttEvent::Message { + topic, + payload: body, + }); Ok(()) } @@ -438,7 +450,11 @@ fn unix_millis() -> u64 { fn next_packet_id(ids: &AtomicU16) -> u16 { let id = ids.fetch_add(1, Ordering::Relaxed); - if id == 0 { 1 } else { id } + if id == 0 { + 1 + } else { + id + } } fn mark_disconnected( @@ -451,7 +467,12 @@ fn mark_disconnected( } } -fn encode_connect(client_id: &str, username: &str, password: &str, keepalive: u16) -> Result> { +fn encode_connect( + client_id: &str, + username: &str, + password: &str, + keepalive: u16, +) -> Result> { let mut body = Vec::new(); push_utf8(&mut body, "MQTT")?; body.push(4); // MQTT 3.1.1 @@ -548,7 +569,13 @@ mod tests { #[test] fn region_brokers_match_cloud_reference() { assert_eq!(broker_for_region("Europe"), Some("mqtt-eu.gree.com")); - assert_eq!(broker_for_region("North American"), Some("mqtt-na.gree.com")); - assert_eq!(broker_for_region("China Mainland"), Some("mqtt-cn.gree.com")); + assert_eq!( + broker_for_region("North American"), + Some("mqtt-na.gree.com") + ); + assert_eq!( + broker_for_region("China Mainland"), + Some("mqtt-cn.gree.com") + ); } } diff --git a/src/provider.rs b/src/provider.rs index 387fa2c..4955fdc 100644 --- a/src/provider.rs +++ b/src/provider.rs @@ -1,10 +1,14 @@ use crate::{ - models::{ApiEvent, ConnectionStatus, ConnectionType, Device, DeviceCommand, GreeCloudSettings}, + models::{ + ApiEvent, ConnectionStatus, ConnectionType, Device, DeviceCommand, GreeCloudSettings, + }, protocol::{ crypto::{decrypt_v1, decrypt_v2, encrypt_v1, encrypt_v2}, gree::{BindResult, GreeClient}, gree_cloud::{parent_mac, CloudCredentials, GreeCloudApi}, - gree_cloud_mqtt::{broker_for_region, MqttConnection, MqttDeviceEnvelope, MqttEvent, MQTT_PORT}, + gree_cloud_mqtt::{ + broker_for_region, MqttConnection, MqttDeviceEnvelope, MqttEvent, MQTT_PORT, + }, }, }; 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_DEVICE_LOCK_TIMEOUT: Duration = Duration::from_secs(2); const KNOWN_CLOUD_PROPERTIES: &[&str] = &[ - "Pow", "Mod", "Dwet", "DwatSen", "Dfltr", "DwatFul", "Dmod", "SetTem", "TemSen", - "TemUn", "TemRec", "HalfTemEn", "SetDeciTem", "Add0.5", "WdSpd", "Air", "Blo", - "Health", "SwhSlp", "SlpMod", "Lig", "SwingLfRig", "SwUpDn", "Quiet", "Tur", "StHt", - "SvSt", "HeatCoolType", "hid", "ElcAll", "CompressorFqy", + "Pow", + "Mod", + "Dwet", + "DwatSen", + "Dfltr", + "DwatFul", + "Dmod", + "SetTem", + "TemSen", + "TemUn", + "TemRec", + "HalfTemEn", + "SetDeciTem", + "Add0.5", + "WdSpd", + "Air", + "Blo", + "Health", + "SwhSlp", + "SlpMod", + "Lig", + "SwingLfRig", + "SwUpDn", + "Quiet", + "Tur", + "StHt", + "SvSt", + "HeatCoolType", + "hid", + "ElcAll", + "CompressorFqy", ]; // Property set used by older GREE Wi-Fi modules before the dehumidifier, // half-degree and cloud energy fields were added. Some V1.x modules silently @@ -45,9 +76,26 @@ const KNOWN_CLOUD_PROPERTIES: &[&str] = &[ // This list matches the long-standing greeclimate status schema used by those // modules and intentionally excludes newer optional fields. const LEGACY_CLOUD_PROPERTIES: &[&str] = &[ - "Pow", "Mod", "SetTem", "TemSen", "TemUn", "TemRec", "WdSpd", "Air", "Blo", - "Health", "SwhSlp", "SlpMod", "Lig", "SwingLfRig", "SwUpDn", "Quiet", "Tur", - "StHt", "SvSt", "HeatCoolType", + "Pow", + "Mod", + "SetTem", + "TemSen", + "TemUn", + "TemRec", + "WdSpd", + "Air", + "Blo", + "Health", + "SwhSlp", + "SlpMod", + "Lig", + "SwingLfRig", + "SwUpDn", + "Quiet", + "Tur", + "StHt", + "SvSt", + "HeatCoolType", ]; #[derive(Clone)] @@ -314,7 +362,8 @@ impl GreeCloudProvider { let session = self.current_session().await?; let parent = cloud_wire_parent(&session, device); session.mqtt.subscribe_device(&parent).await?; - self.set_subscriptions(device, &session.broker, &parent).await; + self.set_subscriptions(device, &session.broker, &parent) + .await; let response = self.poll_status_compatible(device, &session).await?; apply_cloud_capability_snapshot(device, &response.properties); @@ -327,7 +376,8 @@ impl GreeCloudProvider { device.last_seen = Some(Utc::now()); device.last_cloud_sync = Some(Utc::now()); device.updated_at = Utc::now(); - self.update_diagnostics_from_message(device, &response).await; + self.update_diagnostics_from_message(device, &response) + .await; Ok(()) } @@ -394,10 +444,16 @@ impl GreeCloudProvider { let alternate_cipher = if current_cipher == 2 { 1 } else { 2 }; let alternate_wire = alternate_wire_mac_case(¤t_wire); let (cipher, wire, profile) = match step { - 1 => (alternate_cipher, current_wire.clone(), "legacy-cipher-probe"), + 1 => ( + alternate_cipher, + current_wire.clone(), + "legacy-cipher-probe", + ), 2 => ( current_cipher, - alternate_wire.clone().unwrap_or_else(|| current_wire.clone()), + alternate_wire + .clone() + .unwrap_or_else(|| current_wire.clone()), "legacy-case-probe", ), 3 => ( @@ -424,17 +480,13 @@ impl GreeCloudProvider { self.remember_status_compatibility(device, &wire, "legacy") .await; if wire != current_wire { - self.set_subscriptions(device, &session.broker, &parent).await; + self.set_subscriptions(device, &session.broker, &parent) + .await; } Ok(response) } - async fn remember_status_compatibility( - &self, - device: &Device, - wire_mac: &str, - profile: &str, - ) { + async fn remember_status_compatibility(&self, device: &Device, wire_mac: &str, profile: &str) { let stable = cloud_id(device); if let Some(session) = self.inner.session.write().await.as_mut() { session.wire_macs.insert(stable, wire_mac.to_string()); @@ -463,17 +515,24 @@ impl GreeCloudProvider { let session = self.current_session().await?; let parent = cloud_wire_parent(&session, device); session.mqtt.subscribe_device(&parent).await?; - self.set_subscriptions(device, &session.broker, &parent).await; + 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(); for (opt, values, fragment) in commands { let payload = json!({"t":"cmd", "opt": opt, "p": values}); // greeclimate treats a 2s no-ACK as uncertain success. Preserve that semantic, // then let the engine's bounded status verification decide the final UI state. - match self.request(device, &session, payload, CLOUD_COMMAND_TIMEOUT).await { + match self + .request(device, &session, payload, CLOUD_COMMAND_TIMEOUT) + .await + { Ok(response) => { - self.update_diagnostics_from_message(device, &response).await; + self.update_diagnostics_from_message(device, &response) + .await; fragment.apply(device); merge_command(&mut applied, &fragment); } @@ -497,7 +556,14 @@ impl GreeCloudProvider { let mut out = Vec::new(); if let Some(mode) = command.mode.as_deref() { let value = mode_to_wire(mode)?; - out.push((vec!["Mod".into()], vec![json!(value)], DeviceCommand { mode: Some(mode.into()), ..Default::default() })); + out.push(( + vec!["Mod".into()], + vec![json!(value)], + DeviceCommand { + mode: Some(mode.into()), + ..Default::default() + }, + )); } if let Some(target) = command.target_temperature { @@ -513,24 +579,121 @@ impl GreeCloudProvider { opt.push("Add0.5".into()); 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(); - if let Some(value) = command.fan_speed { simple.push(("WdSpd", json!(value.min(5)), DeviceCommand { fan_speed: Some(value.min(5)), ..Default::default() })); } - if let Some(value) = command.swing_vertical { simple.push(("SwUpDn", json!(if value { 1 } else { 0 }), DeviceCommand { swing_vertical: Some(value), ..Default::default() })); } - if let Some(value) = command.swing_horizontal { simple.push(("SwingLfRig", json!(if value { 1 } else { 0 }), DeviceCommand { swing_horizontal: Some(value), ..Default::default() })); } - if let Some(value) = command.quiet { - let wire = if value { device.quiet_wire_value.unwrap_or(2).max(1) } else { 0 }; - simple.push(("Quiet", json!(wire), DeviceCommand { quiet: Some(value), ..Default::default() })); + if let Some(value) = command.fan_speed { + simple.push(( + "WdSpd", + json!(value.min(5)), + DeviceCommand { + fan_speed: Some(value.min(5)), + ..Default::default() + }, + )); + } + if let Some(value) = command.swing_vertical { + simple.push(( + "SwUpDn", + json!(if value { 1 } else { 0 }), + DeviceCommand { + swing_vertical: Some(value), + ..Default::default() + }, + )); + } + if let Some(value) = command.swing_horizontal { + simple.push(( + "SwingLfRig", + json!(if value { 1 } else { 0 }), + DeviceCommand { + swing_horizontal: Some(value), + ..Default::default() + }, + )); + } + if let Some(value) = command.quiet { + let wire = if value { + device.quiet_wire_value.unwrap_or(2).max(1) + } else { + 0 + }; + simple.push(( + "Quiet", + json!(wire), + DeviceCommand { + quiet: Some(value), + ..Default::default() + }, + )); + } + if let Some(value) = command.turbo { + simple.push(( + "Tur", + json!(u8::from(value)), + DeviceCommand { + turbo: Some(value), + ..Default::default() + }, + )); + } + if let Some(value) = command.light { + simple.push(( + "Lig", + json!(u8::from(value)), + DeviceCommand { + light: Some(value), + ..Default::default() + }, + )); + } + if let Some(value) = command.air { + simple.push(( + "Air", + json!(u8::from(value)), + DeviceCommand { + air: Some(value), + ..Default::default() + }, + )); + } + if let Some(value) = command.xfan { + simple.push(( + "Blo", + json!(u8::from(value)), + DeviceCommand { + xfan: Some(value), + ..Default::default() + }, + )); + } + if let Some(value) = command.health { + simple.push(( + "Health", + json!(u8::from(value)), + DeviceCommand { + health: Some(value), + ..Default::default() + }, + )); } - if let Some(value) = command.turbo { simple.push(("Tur", json!(u8::from(value)), DeviceCommand { turbo: Some(value), ..Default::default() })); } - if let Some(value) = command.light { simple.push(("Lig", json!(u8::from(value)), DeviceCommand { light: Some(value), ..Default::default() })); } - if let Some(value) = command.air { simple.push(("Air", json!(u8::from(value)), DeviceCommand { air: Some(value), ..Default::default() })); } - if let Some(value) = command.xfan { simple.push(("Blo", json!(u8::from(value)), DeviceCommand { xfan: Some(value), ..Default::default() })); } - if let Some(value) = command.health { simple.push(("Health", json!(u8::from(value)), DeviceCommand { health: Some(value), ..Default::default() })); } if let Some(value) = command.sleep { - simple.push(("SwhSlp", json!(u8::from(value)), DeviceCommand { sleep: Some(value), ..Default::default() })); + simple.push(( + "SwhSlp", + json!(u8::from(value)), + DeviceCommand { + sleep: Some(value), + ..Default::default() + }, + )); simple.push(("SlpMod", json!(u8::from(value)), DeviceCommand::default())); } for (name, value, fragment) in simple { @@ -538,7 +701,14 @@ impl GreeCloudProvider { } 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 @@ -614,13 +784,21 @@ impl GreeCloudProvider { .ok_or_else(|| anyhow!("GREE Cloud MQTT is disconnected")) } - pub async fn probe_mqtt(&self, settings: &GreeCloudSettings, devices: &[Device]) -> Result { + pub async fn probe_mqtt( + &self, + settings: &GreeCloudSettings, + devices: &[Device], + ) -> Result { self.ensure_connected(settings, devices).await?; let session = self.current_session().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 { bail!("GREE Cloud is disabled"); } @@ -637,9 +815,12 @@ impl GreeCloudProvider { .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) .is_err() { - timeout(CLOUD_CONNECT_TIMEOUT + Duration::from_secs(5), connection_finished) - .await - .context("timed out waiting for GREE Cloud connection attempt")?; + timeout( + CLOUD_CONNECT_TIMEOUT + Duration::from_secs(5), + connection_finished, + ) + .await + .context("timed out waiting for GREE Cloud connection attempt")?; return if self.is_connected().await { Ok(()) } else { @@ -824,7 +1005,10 @@ impl GreeCloudProvider { "parents": parents.len(), })); 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(); item.mqtt_state = "connected".into(); item.last_connect = Some(now); @@ -836,7 +1020,10 @@ impl GreeCloudProvider { } } 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(()) } @@ -847,16 +1034,8 @@ impl GreeCloudProvider { inner_payload: Value, wait: Duration, ) -> Result { - self.request_with_options( - device, - session, - inner_payload, - wait, - None, - None, - None, - ) - .await + self.request_with_options(device, session, inner_payload, wait, None, None, None) + .await } async fn request_with_options( @@ -883,7 +1062,8 @@ impl GreeCloudProvider { .map(String::as_str) .or(device.key.as_deref()) .ok_or_else(|| anyhow!("GREE Cloud device key is missing"))?; - let cipher = cipher_override.unwrap_or_else(|| if device.protocol_version == 2 { 2 } else { 1 }); + let cipher = + cipher_override.unwrap_or_else(|| if device.protocol_version == 2 { 2 } else { 1 }); let plaintext = serde_json::to_vec(&inner_payload)?; let (pack, tag) = if cipher == 2 { let encrypted = encrypt_v2(key, &plaintext)?; @@ -1139,7 +1319,10 @@ impl GreeCloudProvider { // and must be ignored rather than reported as a payload error every few seconds. let registered = self.inner.registered.read().await.clone(); if registered.is_empty() { - tracing::debug!(topic, "ignoring GREE Cloud MQTT payload without registered devices"); + tracing::debug!( + topic, + "ignoring GREE Cloud MQTT payload without registered devices" + ); return Ok(()); } if topic.starts_with("connect/") { @@ -1158,7 +1341,8 @@ impl GreeCloudProvider { }); 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() { 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)) .map(|(id, item)| (id.clone(), item.clone())) .collect(); - candidates.sort_by_key(|(id, _)| if !normalized_tcid.is_empty() && id.eq_ignore_ascii_case(&normalized_tcid) { 0 } else { 1 }); + candidates.sort_by_key(|(id, _)| { + if !normalized_tcid.is_empty() && id.eq_ignore_ascii_case(&normalized_tcid) { + 0 + } else { + 1 + } + }); if candidates.is_empty() { bail!("GREE Cloud MQTT message does not match a registered device"); } let mut last_error = None; for (cloud_id, registered) in candidates { - let preferred = if envelope.tag.is_some() { 2 } else { registered.cipher_version }; - let (plaintext, cipher_version) = match decrypt_cloud_payload(®istered.key, &envelope, preferred) { - Ok(value) => (value, preferred), - Err(first) => { - let alternate = if preferred == 2 { 1 } else { 2 }; - match decrypt_cloud_payload(®istered.key, &envelope, alternate) { - Ok(value) => (value, alternate), - Err(second) => { - last_error = Some(format!("{first}; alternate cipher: {second}")); - continue; + let preferred = if envelope.tag.is_some() { + 2 + } else { + registered.cipher_version + }; + let (plaintext, cipher_version) = + match decrypt_cloud_payload(®istered.key, &envelope, preferred) { + Ok(value) => (value, preferred), + Err(first) => { + let alternate = if preferred == 2 { 1 } else { 2 }; + match decrypt_cloud_payload(®istered.key, &envelope, alternate) { + Ok(value) => (value, alternate), + Err(second) => { + last_error = Some(format!("{first}; alternate cipher: {second}")); + continue; + } } } - } - }; - let parsed: Value = serde_json::from_slice(&plaintext).context("invalid decrypted GREE Cloud JSON")?; + }; + let parsed: Value = + serde_json::from_slice(&plaintext).context("invalid decrypted GREE Cloud JSON")?; validate_cloud_response(&parsed)?; let properties = properties_from_payload(&parsed)?; let message = ParsedCloudMessage { @@ -1235,17 +1431,25 @@ impl GreeCloudProvider { if !properties.is_empty() { item.last_status_timestamp = Some(now); for (key, value) in &properties { - item.raw_device_properties.insert(key.clone(), value.clone()); + item.raw_device_properties + .insert(key.clone(), value.clone()); } item.parsed_device_properties = parsed_properties(&item.raw_device_properties); - item.unknown_properties = item.raw_device_properties + item.unknown_properties = item + .raw_device_properties .iter() .filter(|(key, _)| !KNOWN_CLOUD_PROPERTIES.contains(&key.as_str())) .map(|(key, value)| (key.clone(), value.clone())) .collect(); - item.energy_related_properties = item.raw_device_properties + item.energy_related_properties = item + .raw_device_properties .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())) .collect(); } @@ -1262,7 +1466,10 @@ impl GreeCloudProvider { })); 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) { @@ -1274,17 +1481,25 @@ impl GreeCloudProvider { item.last_status_timestamp = Some(Utc::now()); item.selected_cipher_version = Some(message.cipher_version); for (key, value) in &message.properties { - item.raw_device_properties.insert(key.clone(), value.clone()); + item.raw_device_properties + .insert(key.clone(), value.clone()); } item.parsed_device_properties = parsed_properties(&item.raw_device_properties); - item.unknown_properties = item.raw_device_properties + item.unknown_properties = item + .raw_device_properties .iter() .filter(|(key, _)| !KNOWN_CLOUD_PROPERTIES.contains(&key.as_str())) .map(|(key, value)| (key.clone(), value.clone())) .collect(); - item.energy_related_properties = item.raw_device_properties + item.energy_related_properties = item + .raw_device_properties .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())) .collect(); item.last_error = None; @@ -1367,7 +1582,10 @@ fn decrypt_cloud_payload(key: &str, envelope: &MqttDeviceEnvelope, cipher: u8) - 2 => decrypt_v2( key, &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), } @@ -1383,7 +1601,10 @@ fn validate_cloud_response(payload: &Value) -> Result<()> { 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 .get("msg") .or_else(|| payload.get("error")) @@ -1398,8 +1619,14 @@ fn properties_from_payload(payload: &Value) -> Result> { if payload.get("t").and_then(Value::as_str) != Some("dat") { return Ok(BTreeMap::new()); } - let cols = payload.get("cols").and_then(Value::as_array).ok_or_else(|| anyhow!("GREE Cloud dat payload has no cols"))?; - let dat = payload.get("dat").and_then(Value::as_array).ok_or_else(|| anyhow!("GREE Cloud dat payload has no dat"))?; + let cols = payload + .get("cols") + .and_then(Value::as_array) + .ok_or_else(|| anyhow!("GREE Cloud dat payload has no cols"))?; + let dat = payload + .get("dat") + .and_then(Value::as_array) + .ok_or_else(|| anyhow!("GREE Cloud dat payload has no dat"))?; if cols.len() != dat.len() { bail!("GREE Cloud dat payload column/value count mismatch"); } @@ -1436,18 +1663,47 @@ fn apply_cloud_capability_snapshot(device: &mut Device, props: &BTreeMap) { - if let Some(value) = props.get("Pow").and_then(value_i64) { device.power = value != 0; } - if let Some(value) = props.get("Mod").and_then(value_i64) { if let Some(mode) = wire_to_mode(value) { device.mode = mode.into(); } } - if let Some(value) = props.get("WdSpd").and_then(value_i64) { device.fan_speed = value.clamp(0, 5) as u8; } - if let Some(value) = props.get("SwUpDn").and_then(value_i64) { device.swing_vertical = value != 0; } - if let Some(value) = props.get("SwingLfRig").and_then(value_i64) { device.swing_horizontal = value != 0; } - if let Some(value) = props.get("Quiet").and_then(value_i64) { device.quiet = value != 0; if value > 0 { device.quiet_wire_value = Some(value.clamp(1, 255) as u8); } } - if let Some(value) = props.get("Tur").and_then(value_i64) { device.turbo = value != 0; } - if let Some(value) = props.get("Lig").and_then(value_i64) { device.light = value != 0; } - if let Some(value) = props.get("Air").and_then(value_i64) { device.air = value != 0; } - if let Some(value) = props.get("Blo").and_then(value_i64) { device.xfan = value != 0; } - if let Some(value) = props.get("Health").and_then(value_i64) { device.health = value != 0; } - if let Some(value) = props.get("SwhSlp").and_then(value_i64) { device.sleep = value != 0; } + if let Some(value) = props.get("Pow").and_then(value_i64) { + device.power = value != 0; + } + if let Some(value) = props.get("Mod").and_then(value_i64) { + if let Some(mode) = wire_to_mode(value) { + device.mode = mode.into(); + } + } + if let Some(value) = props.get("WdSpd").and_then(value_i64) { + device.fan_speed = value.clamp(0, 5) as u8; + } + if let Some(value) = props.get("SwUpDn").and_then(value_i64) { + device.swing_vertical = value != 0; + } + if let Some(value) = props.get("SwingLfRig").and_then(value_i64) { + device.swing_horizontal = value != 0; + } + if let Some(value) = props.get("Quiet").and_then(value_i64) { + device.quiet = value != 0; + if value > 0 { + device.quiet_wire_value = Some(value.clamp(1, 255) as u8); + } + } + if let Some(value) = props.get("Tur").and_then(value_i64) { + device.turbo = value != 0; + } + if let Some(value) = props.get("Lig").and_then(value_i64) { + device.light = value != 0; + } + if let Some(value) = props.get("Air").and_then(value_i64) { + device.air = value != 0; + } + if let Some(value) = props.get("Blo").and_then(value_i64) { + device.xfan = value != 0; + } + if let Some(value) = props.get("Health").and_then(value_i64) { + device.health = value != 0; + } + if let Some(value) = props.get("SwhSlp").and_then(value_i64) { + device.sleep = value != 0; + } if let Some(raw) = props.get("TemSen").and_then(value_f64) { if let Some((temperature, offset)) = decode_indoor_temperature(raw) { device.current_temperature = Some(temperature); @@ -1459,41 +1715,74 @@ pub fn apply_cloud_properties(device: &mut Device, props: &BTreeMap 0.0 { 0.5 } else { 0.0 }; } - if props.contains_key("Lig") { device.supports_light = Some(true); } - if props.contains_key("Quiet") { device.supports_quiet = Some(true); } - if props.contains_key("Tur") { device.supports_turbo = Some(true); } - if props.contains_key("Air") { device.supports_air = Some(true); } - if props.contains_key("Blo") { device.supports_xfan = Some(true); } - if props.contains_key("Health") { device.supports_health = Some(true); } - if props.contains_key("SwhSlp") { device.supports_sleep = Some(true); } - if props.contains_key("Buzzer_ON_OFF") || props.contains_key("BuzzerCtrl") { device.supports_buzzer_control = Some(true); } - if props.contains_key("ElcAll") { device.supports_energy_meter = Some(true); } - if let Some(value) = props.get("ElcAll").and_then(value_f64) { device.total_energy_kwh = Some(value * 0.1); } - if let Some(value) = props.get("CompressorFqy").and_then(value_f64) { device.compressor_frequency_hz = Some(value); } + if props.contains_key("Lig") { + device.supports_light = Some(true); + } + if props.contains_key("Quiet") { + device.supports_quiet = Some(true); + } + if props.contains_key("Tur") { + device.supports_turbo = Some(true); + } + if props.contains_key("Air") { + device.supports_air = Some(true); + } + if props.contains_key("Blo") { + device.supports_xfan = Some(true); + } + if props.contains_key("Health") { + device.supports_health = Some(true); + } + if props.contains_key("SwhSlp") { + device.supports_sleep = Some(true); + } + if props.contains_key("Buzzer_ON_OFF") || props.contains_key("BuzzerCtrl") { + device.supports_buzzer_control = Some(true); + } + if props.contains_key("ElcAll") { + device.supports_energy_meter = Some(true); + } + if let Some(value) = props.get("ElcAll").and_then(value_f64) { + device.total_energy_kwh = Some(value * 0.1); + } + if let Some(value) = props.get("CompressorFqy").and_then(value_f64) { + device.compressor_frequency_hz = Some(value); + } if let Some(hid) = props.get("hid").and_then(Value::as_str) { - if device.firmware.is_empty() { device.firmware = firmware_from_hid(hid).unwrap_or_default(); } + if device.firmware.is_empty() { + device.firmware = firmware_from_hid(hid).unwrap_or_default(); + } } } fn parsed_properties(props: &BTreeMap) -> BTreeMap { let mut parsed = BTreeMap::new(); - if let Some(value) = props.get("Pow").and_then(value_i64) { parsed.insert("power".into(), json!(value != 0)); } - if let Some(value) = props.get("Mod").and_then(value_i64).and_then(wire_to_mode) { parsed.insert("mode".into(), json!(value)); } + if let Some(value) = props.get("Pow").and_then(value_i64) { + parsed.insert("power".into(), json!(value != 0)); + } + if let Some(value) = props.get("Mod").and_then(value_i64).and_then(wire_to_mode) { + parsed.insert("mode".into(), json!(value)); + } let mut sensor_offset = None; if let Some(raw) = props.get("TemSen").and_then(value_f64) { if let Some((temperature, offset)) = decode_indoor_temperature(raw) { @@ -1510,30 +1799,64 @@ fn parsed_properties(props: &BTreeMap) -> BTreeMap parsed.insert("outdoor_temperature_c".into(), json!(temperature)); } } - if let Some(value) = props.get("ElcAll").and_then(value_f64) { parsed.insert("total_energy_kwh".into(), json!(value * 0.1)); } - if let Some(value) = props.get("CompressorFqy").and_then(value_f64) { parsed.insert("compressor_frequency_hz".into(), json!(value)); } + if let Some(value) = props.get("ElcAll").and_then(value_f64) { + parsed.insert("total_energy_kwh".into(), json!(value * 0.1)); + } + if let Some(value) = props.get("CompressorFqy").and_then(value_f64) { + parsed.insert("compressor_frequency_hz".into(), json!(value)); + } parsed } fn merge_command(target: &mut DeviceCommand, source: &DeviceCommand) { - if source.power.is_some() { target.power = source.power; } - if source.mode.is_some() { target.mode = source.mode.clone(); } - if source.target_temperature.is_some() { target.target_temperature = source.target_temperature; } - if source.fan_speed.is_some() { target.fan_speed = source.fan_speed; } - if source.swing_vertical.is_some() { target.swing_vertical = source.swing_vertical; } - if source.swing_horizontal.is_some() { target.swing_horizontal = source.swing_horizontal; } - if source.quiet.is_some() { target.quiet = source.quiet; } - if source.turbo.is_some() { target.turbo = source.turbo; } - if source.light.is_some() { target.light = source.light; } - if source.air.is_some() { target.air = source.air; } - if source.xfan.is_some() { target.xfan = source.xfan; } - if source.health.is_some() { target.health = source.health; } - if source.sleep.is_some() { target.sleep = source.sleep; } + if source.power.is_some() { + target.power = source.power; + } + if source.mode.is_some() { + target.mode = source.mode.clone(); + } + if source.target_temperature.is_some() { + target.target_temperature = source.target_temperature; + } + if source.fan_speed.is_some() { + target.fan_speed = source.fan_speed; + } + if source.swing_vertical.is_some() { + target.swing_vertical = source.swing_vertical; + } + if source.swing_horizontal.is_some() { + target.swing_horizontal = source.swing_horizontal; + } + if source.quiet.is_some() { + target.quiet = source.quiet; + } + if source.turbo.is_some() { + target.turbo = source.turbo; + } + if source.light.is_some() { + target.light = source.light; + } + if source.air.is_some() { + target.air = source.air; + } + if source.xfan.is_some() { + target.xfan = source.xfan; + } + if source.health.is_some() { + target.health = source.health; + } + if source.sleep.is_some() { + target.sleep = source.sleep; + } } fn mode_to_wire(mode: &str) -> Result { match mode { - "auto" => Ok(0), "cool" => Ok(1), "dry" => Ok(2), "fan" => Ok(3), "heat" => Ok(4), + "auto" => Ok(0), + "cool" => Ok(1), + "dry" => Ok(2), + "fan" => Ok(3), + "heat" => Ok(4), _ => 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. let offset = raw > 40.0; 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) -> Option { @@ -1562,14 +1887,29 @@ fn decode_outdoor_temperature(raw: f64, sensor_offset: Option) -> Option 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 { - 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 { - 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 { 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.") } -fn normalize_mac(value: &str) -> String { value.trim().replace([':', '-'], "").to_ascii_uppercase() } -fn cloud_id(device: &Device) -> String { normalize_mac(device.cloud_device_id.as_deref().unwrap_or(&device.mac)) } -fn cloud_parent(device: &Device) -> String { device.cloud_parent_mac.clone().unwrap_or_else(|| parent_mac(&cloud_id(device))) } -fn is_timeout_error(error: &anyhow::Error) -> bool { error.to_string().to_ascii_lowercase().contains("timed out") } +fn normalize_mac(value: &str) -> String { + value.trim().replace([':', '-'], "").to_ascii_uppercase() +} +fn cloud_id(device: &Device) -> String { + normalize_mac(device.cloud_device_id.as_deref().unwrap_or(&device.mac)) +} +fn cloud_parent(device: &Device) -> String { + device + .cloud_parent_mac + .clone() + .unwrap_or_else(|| parent_mac(&cloud_id(device))) +} +fn is_timeout_error(error: &anyhow::Error) -> bool { + error.to_string().to_ascii_lowercase().contains("timed out") +} fn sanitize_error(value: &str) -> String { let lower = value.to_ascii_lowercase(); - if lower.contains("password") || lower.contains("token") || lower.contains("authorization") { "GREE Cloud authentication/transport error".into() } else { value.chars().take(300).collect() } + if lower.contains("password") || lower.contains("token") || lower.contains("authorization") { + "GREE Cloud authentication/transport error".into() + } else { + value.chars().take(300).collect() + } } fn firmware_from_hid(hid: &str) -> Option { let marker = hid.rfind('V')?; - let value = hid.get(marker + 1..)?.strip_suffix(".bin").unwrap_or(&hid[marker + 1..]); + let value = hid + .get(marker + 1..)? + .strip_suffix(".bin") + .unwrap_or(&hid[marker + 1..]); (!value.is_empty()).then(|| value.to_string()) } -pub async fn cloud_reconnect_loop(provider: GreeCloudProvider, settings: Arc>, db: crate::db::Db) { +pub async fn cloud_reconnect_loop( + provider: GreeCloudProvider, + settings: Arc>, + db: crate::db::Db, +) { let mut attempt = 0_u32; loop { let cloud = settings.read().await.gree_cloud.clone(); @@ -1650,9 +2012,16 @@ pub async fn cloud_reconnect_loop(provider: GreeCloudProvider, settings: Arc 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; continue; } @@ -1738,15 +2107,12 @@ mod tests { assert!(mode_to_wire("unsupported").is_err()); } - #[test] fn cloud_wire_parent_preserves_rest_mac_case_for_mqtt_topics() { let mut device = Device::simulated_default(); device.connection_type = ConnectionType::GreeCloud; device.cloud_device_id = Some("9424B80C5DB0".into()); - let wire = HashMap::from([ - ("9424B80C5DB0".to_string(), "9424b80c5db0".to_string()), - ]); + let wire = HashMap::from([("9424B80C5DB0".to_string(), "9424b80c5db0".to_string())]); assert_eq!(cloud_wire_parent_from_map(&wire, &device), "9424b80c5db0"); } @@ -1778,7 +2144,6 @@ mod tests { assert!(!LEGACY_CLOUD_PROPERTIES.contains(&"CompressorFqy")); } - #[tokio::test] async fn mqtt_payload_is_ignored_after_all_cloud_devices_are_removed() { let provider = GreeCloudProvider::new(reqwest::Client::new()); @@ -1914,7 +2279,10 @@ mod tests { let sleep_sequence = provider .build_command_sequence( &device, - &DeviceCommand { sleep: Some(true), ..Default::default() }, + &DeviceCommand { + sleep: Some(true), + ..Default::default() + }, false, ) .await diff --git a/src/state.rs b/src/state.rs index 11c158c..81ad8a1 100644 --- a/src/state.rs +++ b/src/state.rs @@ -222,10 +222,8 @@ impl AppState { let kind = kind.to_string(); let message = message.to_string(); handle.spawn(async move { - crate::notifications::dispatch( - state, event_id, level, kind, message, metadata, - ) - .await; + crate::notifications::dispatch(state, event_id, level, kind, message, metadata) + .await; }); } } diff --git a/web/styles.css b/web/css/styles.css similarity index 97% rename from web/styles.css rename to web/css/styles.css index b6027d3..a041ccb 100644 --- a/web/styles.css +++ b/web/css/styles.css @@ -1664,7 +1664,7 @@ button.outside-pill:focus-visible { font-weight: 650; } -.outdoor-history-links > div { +.outdoor-history-links>div { display: flex; align-items: center; gap: 7px; @@ -2285,7 +2285,9 @@ button.outside-pill:focus-visible { font-size: 12px; } -.custom-chart-edit-note strong { color: var(--text-soft); } +.custom-chart-edit-note strong { + color: var(--text-soft); +} .saved-chart-section { display: grid; @@ -2301,7 +2303,9 @@ button.outside-pill:focus-visible { gap: 10px; } -.saved-chart-heading small { color: var(--muted); } +.saved-chart-heading small { + color: var(--muted); +} .saved-chart-list { display: grid; @@ -2939,7 +2943,9 @@ button.outside-pill:focus-visible { align-items: end; } -.history-network-controls label { min-width: 0; } +.history-network-controls label { + min-width: 0; +} .history-jitter-toggle { min-height: 45px; @@ -4507,8 +4513,6 @@ body.simulation-standalone [data-view="simulation"] { 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 { grid-auto-rows: auto; align-items: start; @@ -9029,13 +9033,25 @@ body.flow-editor-open { } @keyframes cloud-command-pulse { - from { opacity: .72; } - to { opacity: 1; } + from { + opacity: .72; + } + + to { + opacity: 1; + } } @keyframes cloud-command-dot { - from { transform: scale(.65); opacity: .45; } - to { transform: scale(1); opacity: 1; } + from { + transform: scale(.65); + opacity: .45; + } + + to { + transform: scale(1); + opacity: 1; + } } @@ -9367,7 +9383,7 @@ body.flow-editor-open { content: none; } } -/* Notification delivery audit in Events. */ + .log-row .kind .log-notification-badge { margin-left: .45rem; padding: 3px 7px; @@ -9380,7 +9396,6 @@ body.flow-editor-open { vertical-align: middle; } -/* Keep the Flow footer compact before the full mobile layout kicks in. */ @media (min-width:761px) and (max-width:1200px) { .flow-natural-preview { display: block; @@ -9450,14 +9465,13 @@ body.flow-editor-open { } } -/* GREE Cloud runtime summary */ .cloud-runtime-summary { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; } -.cloud-runtime-summary > div { +.cloud-runtime-summary>div { display: grid; gap: 4px; min-width: 0; @@ -9519,7 +9533,6 @@ body.flow-editor-open { } } -/* Shared alerts and split/multisplit installation configuration */ .inline-alert { display: grid; gap: 4px; @@ -9550,18 +9563,30 @@ body.flow-editor-open { color: var(--danger); } -.inline-alert.error strong { color: var(--danger); } +.inline-alert.error strong { + color: var(--danger); +} + .inline-alert.success { border-color: color-mix(in srgb, var(--accent) 42%, var(--line)); background: color-mix(in srgb, var(--accent) 10%, var(--surface)); } + .inline-alert.info { border-color: color-mix(in srgb, var(--info) 38%, var(--line)); background: color-mix(in srgb, var(--info) 9%, var(--surface)); } -.inline-alert.wide { grid-column: 1 / -1; } -.device-installation-summary { display: grid; gap: 8px; margin-bottom: 12px; } +.inline-alert.wide { + grid-column: 1 / -1; +} + +.device-installation-summary { + display: grid; + gap: 8px; + margin-bottom: 12px; +} + .installation-summary-card, .installation-list-row { display: grid; @@ -9573,36 +9598,106 @@ body.flow-editor-open { border-radius: 12px; 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-list-row strong { color: var(--text); } +.installation-list-row strong { + color: var(--text); +} + .installation-summary-card small, -.installation-list-row small { color: var(--muted); } -.installation-list { display: grid; gap: 8px; } -.installation-list-row > button:first-child { border: 0; background: transparent; padding: 0; text-align: left; } -.installation-list-row > div { display: flex; align-items: center; justify-content: flex-end; gap: 8px; flex-wrap: wrap; } -.installation-form { border-top: 1px solid var(--line); padding-top: 14px; margin-top: 14px; } -.installation-device-choices { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 6px; margin-top: 6px; } -.installation-device-choices .check { margin: 0; padding: 8px 9px; border: 1px solid var(--line); border-radius: 9px; background: var(--surface); } -.installation-device-choices .check.disabled { opacity: .55; } +.installation-list-row small { + color: var(--muted); +} + +.installation-list { + display: grid; + gap: 8px; +} + +.installation-list-row>button:first-child { + border: 0; + background: transparent; + padding: 0; + text-align: left; +} + +.installation-list-row>div { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + flex-wrap: wrap; +} + +.installation-form { + border-top: 1px solid var(--line); + padding-top: 14px; + margin-top: 14px; +} + +.installation-device-choices { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 6px; + margin-top: 6px; +} + +.installation-device-choices .check { + margin: 0; + padding: 8px 9px; + border: 1px solid var(--line); + border-radius: 9px; + background: var(--surface); +} + +.installation-device-choices .check.disabled { + opacity: .55; +} + .installation-energy-metric b { color: var(--text); font-size: 18px; font-variant-numeric: tabular-nums; letter-spacing: -.01em; } + .history-context-controls.energy-context-controls { display: grid; grid-template-columns: minmax(240px, 1.5fr) repeat(3, minmax(132px, .8fr)); gap: 10px; 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-range-control small { display: block; 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 { +.history-range-control small { + display: block; + 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; grid-template-columns: minmax(0, 1fr) auto; align-items: center; @@ -9616,9 +9711,18 @@ body.flow-editor-open { cursor: pointer; 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 b { + +.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 b { min-width: 34px; padding: 3px 6px; border-radius: 999px; @@ -9627,7 +9731,13 @@ body.flow-editor-open { font-size: 10px; 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 { position: absolute; z-index: 20; @@ -9642,6 +9752,7 @@ body.flow-editor-open { background: var(--surface); box-shadow: 0 14px 34px rgba(0, 0, 0, .22); } + .history-energy-option { display: grid; grid-template-columns: 20px minmax(0, 1fr); @@ -9655,28 +9766,67 @@ body.flow-editor-open { font-size: 11px; 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 span { overflow-wrap: anywhere; } -.history-range-control.energy-period-control { min-width: 0; } + +.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 span { + overflow-wrap: anywhere; +} + +.history-range-control.energy-period-control { + min-width: 0; +} @media (max-width: 760px) { + .installation-summary-card, - .installation-list-row { grid-template-columns: 1fr; } - .installation-list-row > div { justify-content: flex-start; } - .installation-device-choices { grid-template-columns: 1fr; } - .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; } + .installation-list-row { + grid-template-columns: 1fr; + } + + .installation-list-row>div { + 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) { - .history-network-controls { grid-template-columns: 1fr; } - .history-jitter-toggle { width: 100%; } + .history-network-controls { + grid-template-columns: 1fr; + } + + .history-jitter-toggle { + width: 100%; + } } -/* Unified custom selects -------------------------------------------------- */ .select-native-proxy { position: absolute !important; width: 1px !important; @@ -9865,8 +10015,8 @@ body.flow-editor-open { padding: 9px 10px; } -.has-error > .custom-select .custom-select-trigger, -select.select-native-proxy[aria-invalid="true"] + .custom-select .custom-select-trigger { +.has-error>.custom-select .custom-select-trigger, +select.select-native-proxy[aria-invalid="true"]+.custom-select .custom-select-trigger { border-color: color-mix(in srgb, var(--danger) 68%, var(--line)); outline: 2px solid color-mix(in srgb, var(--danger) 13%, transparent); } @@ -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) { - .flow-fit-button-icon { display: inline-grid; place-items: center; } - .flow-fit-button-label { display: none; } +@media (max-width: 900px), +(orientation: landscape) and (max-height: 700px) { + .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) { - .flow-fit-button-icon { display: none; } - .flow-fit-button-label { display: inline; } + .flow-fit-button-icon { + display: none; + } + + .flow-fit-button-label { + display: inline; + } } .ha-supervisor-status { @@ -9896,11 +10059,27 @@ select.select-native-proxy[aria-invalid="true"] + .custom-select .custom-select- background: var(--surface-2); } -.ha-supervisor-status strong { font-size: .92rem; } -.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-supervisor-status strong { + font-size: .92rem; +} + +.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 { display: grid; @@ -9908,8 +10087,13 @@ select.select-native-proxy[aria-invalid="true"] + .custom-select .custom-select- min-width: 0; } -.ha-entity-picker > label { margin: 0; } -.ha-entity-picker > .field-note { margin: 0; } +.ha-entity-picker>label { + margin: 0; +} + +.ha-entity-picker>.field-note { + margin: 0; +} .ha-entity-results { display: grid; @@ -9942,7 +10126,7 @@ select.select-native-proxy[aria-invalid="true"] + .custom-select .custom-select- background: var(--surface-2); } -.ha-entity-option > span { +.ha-entity-option>span { display: grid; gap: 2px; min-width: 0; @@ -9962,15 +10146,26 @@ select.select-native-proxy[aria-invalid="true"] + .custom-select .custom-select- font-size: .76rem; } -.ha-entity-option small { flex: 0 0 auto; } -.ha-entity-empty { padding: 9px 10px; } +.ha-entity-option small { + flex: 0 0 auto; +} -/* Shared Flow HA entity picker: keep search and selected entity visually distinct. */ -.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); } +.ha-entity-empty { + padding: 9px 10px; +} + +.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 { height: 100vh; height: 100dvh; @@ -10003,7 +10198,9 @@ select.select-native-proxy[aria-invalid="true"] + .custom-select .custom-select- align-items: end; } -.public-chart-heading { min-width: 0; } +.public-chart-heading { + min-width: 0; +} .public-chart-range-control { display: grid; @@ -10013,7 +10210,9 @@ select.select-native-proxy[aria-invalid="true"] + .custom-select .custom-select- font-size: 11px; } -.public-chart-range-control select { min-width: 126px; } +.public-chart-range-control select { + min-width: 126px; +} .public-custom-chart-wrap { flex: 1 1 0; @@ -10037,7 +10236,9 @@ select.select-native-proxy[aria-invalid="true"] + .custom-select .custom-select- 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 { cursor: default; @@ -10056,9 +10257,23 @@ select.select-native-proxy[aria-invalid="true"] + .custom-select .custom-select- } @media (max-width: 700px) { - .public-custom-chart-shell { padding: 8px; } - .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; } -} + .public-custom-chart-shell { + padding: 8px; + } + + .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; + } +} \ No newline at end of file diff --git a/web/custom-chart.html b/web/custom-chart.html index 401bc87..82d345e 100644 --- a/web/custom-chart.html +++ b/web/custom-chart.html @@ -1,5 +1,6 @@ + @@ -8,8 +9,10 @@ + GREE Controller · Custom chart +
@@ -41,4 +44,5 @@
- + + \ No newline at end of file diff --git a/web/index.html b/web/index.html index 47b300e..49543d9 100644 --- a/web/index.html +++ b/web/index.html @@ -31,7 +31,8 @@
- + + @@ -414,19 +420,21 @@
- + - + -

Use only for a trusted +

Use + only for a trusted local Home Assistant server.

+ data-i18n="settings.haUseSupervisor" hidden>Use Supervisor automatically
@@ -439,9 +447,11 @@
- -

Global Home Assistant outdoor temperature sensor. Zones use it by default and may override it with another outdoor sensor in zone settings.

+ +

Global Home Assistant outdoor temperature + sensor. Zones use it by default and may override it with another outdoor sensor in zone settings.

@@ -475,9 +485,10 @@
-
+
@@ -587,7 +598,8 @@ + data-i18n="notifications.alertSensorDiscrepancy">GREE vs Home Assistant temperature + difference +
+
@@ -714,14 +729,19 @@

Connectivity metrics

-

Periodic GREE UDP round-trip measurements for Local/LAN units. Results are stored in History.

+

Periodic GREE UDP round-trip measurements for Local/LAN units. + Results are stored in History.

- - - -

Only Local/LAN installations are probed. Cloud devices are not pinged directly.

+ + + +

Only Local/LAN installations are + probed. Cloud devices are not pinged directly.

@@ -757,33 +777,70 @@
@@ -853,9 +915,9 @@ type="button" data-action="edit-flow-name" data-i18n-title="flow.editName" title="Edit Flow name"> + title="Edit Flow name" aria-label="Edit Flow name" data-ui-icon="edit"> @@ -931,7 +993,8 @@
100% + data-action="flow-zoom-in" data-i18n-title="flow.zoomIn" title="Zoom in" + data-ui-icon="plus">
@@ -991,13 +1055,16 @@

Actions

@@ -1084,26 +1151,36 @@ @@ -1112,18 +1189,26 @@
@@ -1157,17 +1242,25 @@
-
GREE Cloud

Devices on account

-

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

+
+
GREE Cloud +

Devices on account

+
+
+

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

-
+
-

Discover GREE devices

+

Discover GREE devices

Auto searches both AES-ECB and AES-GCM devices. Multiple passes improve discovery when several Wi-Fi modules answer the same broadcast.

@@ -1188,7 +1281,8 @@
-

Name discovered devices

+

Name discovered devices

Give each new unit a friendly room name. The technical model and MAC remain available in diagnostics.

@@ -1203,7 +1297,8 @@
-

Technical configuration

+

Technical configuration

-
Installation

Split / multisplit installations

-

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

+
+
Installation +

Split / multisplit installations

+
+
+

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

- - -
Indoor units
- - - - + + +
Indoor units +
+
+ + + +

-
+
-
GREE Cloud

Cloud diagnostics

-

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

+
+
GREE Cloud +

Cloud diagnostics

+
+
+

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

-
+
@@ -1359,17 +1499,20 @@ °C
- +
-

This room sensor is required when the temperature strategy uses Home Assistant. If it becomes unavailable, the controller falls back to the GREE sensor.

+

This room sensor is required when the temperature strategy + uses Home Assistant. If it becomes unavailable, the controller falls back to the GREE sensor.

- -

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.

+ +

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.