diff --git a/Cargo.lock b/Cargo.lock index b4e644c..fac4dd8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -633,7 +633,7 @@ dependencies = [ [[package]] name = "gree-controller" -version = "0.8.13" +version = "0.8.14" dependencies = [ "aes", "aes-gcm", diff --git a/Cargo.toml b/Cargo.toml index 1adfdb0..7bcdbd3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "gree-controller" -version = "0.8.13" +version = "0.8.14" 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 985ade4..5bd4cd6 100644 --- a/FILE_MANIFEST.sha256 +++ b/FILE_MANIFEST.sha256 @@ -1,11 +1,12 @@ -f38d9bfbe2b378e02ab825b1f0bf6e7c6b8ac9a834c63bac58d2383524fbe8e1 ./.env.example +d67af429e4da9ce08e9d2f2a8472849ffbd70d135b1c5da535a076026794d04c ./.env.example a4ec3874a2e3ab1bad28fb40bb620f7b01f64d01ad9b699306bf70ada31227db ./.gitignore -7fdaf0c83e203b81eb3d6ae6461f61481f08998bba5f5a442722e943925f5c12 ./Cargo.lock -ba5be0946d041a905c54c102e00d933204f6eec153b6a56585d4ff7b3f10b876 ./Cargo.toml +67bc978959e4922de1c02cccd853dbd825735a738f23bdc261129c1a57a78c33 ./Cargo.lock +212f4f9307ed6e9b0b8f1db51e4208c8d625663f3974a016797f446caf7386bd ./Cargo.toml 19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE -73ff8cb22b2cfb35456e04225b59cefee3c71bd6ba0348d5ecb74e9d9f804c92 ./README.md +2937c57efa02f5a95840e1bae670fb989901b54dde8e89c80035e2ffc5888fda ./README.md 91c826e2f8c974bdb7b15e01e9b20487f06ff887247ecd9600f1263c11a61b96 ./build.rs -751f5083ced1ac84f1841492c389fc5afaa6bef242dc7379538896428df7bbed ./docs/API.md +1194a026091b3bfc12615a31461c065ad010c1962a390e77bb0e4a6bf722a971 ./docs/API.md +95e3c8c99ba439a86a2fb05cfd8ef00c75cb959a024a349a082da935b726f500 ./home-assistant/README.md f8e8559fe10fe523ac5bc9aac25c6e26e862f679d502e8f3c39f38a0a8e40911 ./home-assistant/custom_components/gree_controller/__init__.py 6910589f27960a28d4de9735884a7e5376e455cd885f19ce2b55947fcd135114 ./home-assistant/custom_components/gree_controller/api.py 71c8058f85fd9ce9f063b7a84dcf2956f23cdbbd02bd4bd99d2fbb9d177c72ad ./home-assistant/custom_components/gree_controller/climate.py @@ -15,7 +16,7 @@ ab08fbe40e9bb48ebcbfff98760aaa0c9434b61b705aaaadea0c09c255d99b0e ./home-assista 5a96fe8f5c035c34f1339370270cd078056202d09e236dec75735be11de92a7d ./home-assistant/custom_components/gree_controller/entity_map.py c4fb75c246db651087900ebfc2291ff41ac87652cd6194fc0b776b0005c1cbcf ./home-assistant/custom_components/gree_controller/icon.png c4fb75c246db651087900ebfc2291ff41ac87652cd6194fc0b776b0005c1cbcf ./home-assistant/custom_components/gree_controller/logo.png -ada3b5ff14063c4a88638dd6165405998fc248948a37d90a7fd34193ba10e2f0 ./home-assistant/custom_components/gree_controller/manifest.json +70b680a57ac75d6a1ccf10b38ce03b0f01a770cf260e992f63932c92dd7ef2bf ./home-assistant/custom_components/gree_controller/manifest.json 38d659d0273c0d1428679e44574a2666c605d1b3796d323b626baa40560788cf ./home-assistant/custom_components/gree_controller/number.py 39c4309001b75abb56234f05662bc06e077054986876f1927937edbce528ec95 ./home-assistant/custom_components/gree_controller/select.py 1cae12876eb81085910907cf1fcf340a18dcba4f8c967ef222c9496325057849 ./home-assistant/custom_components/gree_controller/sensor.py @@ -23,9 +24,11 @@ ada3b5ff14063c4a88638dd6165405998fc248948a37d90a7fd34193ba10e2f0 ./home-assista 6bddb7b4620021ecd2099a86a77ef5c7f2c2dcd3d07d5db4e7b4c4ce6d3e8c03 ./home-assistant/custom_components/gree_controller/translations/en.json 13f30e2dcdcedbd1b6c3f99c2335e0487108fd72c8e86922368b84f2fa2038ae ./home-assistant/custom_components/gree_controller/translations/pl.json 4513070521d3dda0efb0d974a86ba674494cfb2b66fe9e5cac5b1b0430dede97 ./home-assistant/generated/gree_controller_entities.example.json +253a0bc912786e67ea7fc92a64e4a510ad973bec343a88ccfb1f28fca3e8cf01 ./lang/README.md 0e8218afe7cddfa4519405213e67e5b84bd108c44e0b8bf2155bf94b81949399 ./lang/en.json 7ee310fa91a7be09fe4a8d4ab8ab94d651dbe0c72e512ec07517461b9381731f ./lang/pl.json -028e1f16e9fbaed57cadb88eff04e65b4bd67722c50b4d6b1fb525f5a2f39abf ./make_zip.py +d8459024f04ca514bd8e9d6bd3af872fb942fd85a7cdd5583f4a9d28aab6faba ./make_zip.py +bb89bac237e750e9b1bf73761d7df97a6b81853091615878c03f13d7b6399aa7 ./scripts/README.md 5bc736c7bc76ca80aaa406bb171d2aa91baf4c3aa8695dce0e09b888b6ab3146 ./scripts/common.sh 6403786610ee6d2f628193c25aee0dd058d62e904aa1a31d5f62fdaae0e94b4f ./scripts/configure-gree-network.sh dbb92ddc27b8724faf709983e4feecd3f188052b2cba39daba96d4bc16914df5 ./scripts/dev.sh @@ -36,20 +39,90 @@ e00d211e3885e30d7fed1e43b44e6fdad40a67019060156c0641816a93e3365f ./scripts/netw 81345b6a0b51736bdbc98fd23199b62e4c721b4e7437e02dab7ea79b97dff29a ./scripts/service.sh b48fc84d79aab381226363ac8473f981bcba5e4911c4cc0011261182debf4250 ./scripts/smoke.sh b50782b3742dfbf8a319c60571c968e93fdf8547db747c759edcffae68cb98bf ./scripts/update.sh -b8f000150bac599d6a8c57b64ab3d145f2e6154ecf5e49bcd3d6cfa5241c11bc ./src/api.rs +a57c2c473bcabc9973df075b4dd5dbfd2831d13650b5deba96f0a19807ac7f0b ./src/api.rs +d5c233a325231c56d2553d3ad23521bd12d47131dce183a8d2e7e7d817e3e7aa ./src/api/assets.rs +6c34b1294d76b0eba4c56bfd0bf69bdd502de0e40838104f6ddf2db4e922683d ./src/api/auth.rs +bc6dbb4b8fb6972ee9065f3caf36881e4316e70f2b7a7d04e7848839fd0801f3 ./src/api/automations.rs +05fdfca0e5d0eca7ace3267c4d95409ef12ead087bb9aad73d5a09b5cc7c3de1 ./src/api/debug_tokens.rs +ffa0a35a0c6badbbbaad88dfaedd46049a0bc4ce2f7e8b930dbdbffe5df4cffb ./src/api/devices.rs +1f13d0fffc9d0d0ff0c008645da57291d924a855de4b8fcb98756026d098e60d ./src/api/events.rs +28e6ae79a22ef87037dc6fb43d515878151abe9200a318903b3140fe06f9c2ca ./src/api/groups.rs +a13d4e217fe3ddaa73873ba6e0d1bc93750cf61764d867ff7372274ed21d9599 ./src/api/history.rs +f312f8a91d7ce1e60b189e684632edbd68e5496723f49d07aef885699a9f60f3 ./src/api/house.rs +0d7d598f7ed24315ed0547a60a72a06f20ebcf926489e1c454986f42d22cc305 ./src/api/integrations.rs +cf175af8ccde1861c82e58e387df0b0085d3699433a06898824d625457008656 ./src/api/middleware.rs +2abb8d155b832bd7bde575b12ef37fbbf09c246255d9abd2adae0d56016ce170 ./src/api/public_settings.rs +eed70af84e9ff9f0ec1b2005836e9ab9aa6a4e73c5c876ec161e5d5c96965b26 ./src/api/schedules.rs +a4d0d6c6ffb7944d336a2cf749a27ee5ed966fd94ab29464ce641873783ce756 ./src/api/settings.rs +74c156717b37e83d329f7c52ac622e02f690621544c4eb07c4502194db55e6e9 ./src/api/system.rs +681cccb8d09f4ad9c2e6467a4dbe2f9d991125775a5d2bfc9043daf940fecd29 ./src/api/websocket.rs +1f998bdac886fcbe55174ef6a916b9bb1219442b9fc313394eeea763daa8cc85 ./src/api/zones.rs 9040e8cb6647c76a875148b7591abcccfe4e2d4708ef462a1e5d25dd4ccac911 ./src/config.rs -5dfda2f4dc540c502885b0cd7017dc77768684588acf528f01d1fd88f1af4aec ./src/db.rs -15d07a2c0ad8ec6530897980787e6e9a94103bda849f07c233f6a37796d1f0b1 ./src/engine.rs +369455b5ffb125f1a80b767f62fd8c84a953adb69548dbd8adb653a6fbaaa45b ./src/db.rs +ae0f7635ba0523c1be980a28314d45445be1354444bcf74a6397da1d6d7c79df ./src/db/climate.rs +3de3fa4262be00029dde2344c4cfa41acd4acc0adec176f344761ff35ea730b6 ./src/db/configuration.rs +56b0efe84dbf6bd3ee2397b099220a68966311c187f4d5b53007a1693a5ef327 ./src/db/core_devices.rs +d8a2b323e864f2ec38ee15e3023be37a6aa05387f1ddbeedafe1f51556ad8c7d ./src/db/device_history.rs +f3bd91d0bdb7699f319b9341d928d3cc3b25f759653fcd2095a5c5f695d08fac ./src/db/events_tokens.rs +59912e70b688daf5c4d22679d643ed3b724c262d62466aeaa9ef74db87808178 ./src/db/ha_history.rs +7e2fbce70f40aef360534ba38c6700163db77cb7288b3bf58728fce72a126435 ./src/db/schedules_automations.rs +35cbb521a8ec456756496b8aee71f9dfebad528a2cf2653ba8b48f6035be0c01 ./src/db/tests.rs +a384ea4042d1d11692b5f8693df1f54850dcc4e80726780b44b2b096bf667b78 ./src/db/zone_history.rs +952ea5906d39ef917ad71edc345f76b29c07c63079644fd5699b12aa254cb35f ./src/engine.rs +2d9476fb5d3071b0f49c5844df93768f17f882b0bf05182763ab79628f50e63d ./src/engine/automations.rs +832fe59c59a0ab1a0d949b656b194eab7aae578fa1eb30c6d55753d97a508c3b ./src/engine/commands.rs +3967cc60a108c71bd7e5d6a99c7caf32ed8f6cbbb63282a5a5d3357e7c07f933 ./src/engine/control_plan.rs +47acda24f31ec6af5ac94b00f52e232f0fdd443106abb9eeca7cc28d26b994bd ./src/engine/deadlines.rs +445c1f7503c6f01adefa10b5b4414431aa630b739678de7fe6161f3d938b4de2 ./src/engine/groups.rs +18a7669318949e533f206d25cf7b63771d7f0f2c0b375a72ff78a2d921baaa61 ./src/engine/history.rs +dad8930c3d811f02cfeced3aa4b9a404fe273ba1b99539bb174b9430575f3e22 ./src/engine/local_thermostat.rs +92b8c4a386fb883f93eb774f3e94545cdf6e48cc674ad7ba40a4c3821c454980 ./src/engine/ownership.rs +88b88dc1e7778bd65d04d58172cfc01b473687264742fd50896cb5eb5f2080f1 ./src/engine/polling.rs +3d48608a065aa52a1fd8b9c59356a74d8216ecee3d47f8ff8ef7d3d47170c617 ./src/engine/runtime.rs +ffa72b93502eece187a96adb4ec94aec6048d4a6dc7af66e7c7af0931318787f ./src/engine/schedules.rs +e0d5347f73066404f9df9d211e059fd10cf054466ae35bdad6533a862b5a816a ./src/engine/targets.rs +cb274cf9247a3976b2ba321f903fde5fea0f6b96e5bbf0be28b893c17c553efd ./src/engine/temperature.rs +b453a8841494fd82d8fa4133dcd47dc59715a6a327375b5e13ffbaef431be700 ./src/engine/temporary_thermostat.rs +80fd12f3d0f9ea2836c82c0e495a10b832815e9662f071f3d78590c5dc091452 ./src/engine/tests.rs +c7e07dcf4419a4a64566620ba2006c5532a6d4576c90cb92855bca4c2deddcf6 ./src/engine/zone_actions.rs +05764fcbc4193266d980fa5d3194816d64037811be9af8023717a508789787e2 ./src/engine/zone_control.rs 4b271b6fc365b1078c01d6178eb563841b2ecaed5d8639196f58e1312d2236fe ./src/error.rs c6ff66da9ad08506f839ec56a869ead3abc824b5311e0ea9244517d11f7f4207 ./src/home_assistant.rs -190b0a33431539676e5dd7796698077f16c179d42eae4501ca96a91bf797cbf8 ./src/influxdb.rs +6f9ef85cd53ba030b477bc3c186f0f7e762674124210eabcb0686f2806eaada1 ./src/influxdb.rs +feb50ecd60b9a9fe9de1f7d8e1e26caab5435d59347468f6266a318f431bc3d3 ./src/influxdb/codec.rs +553b44ec0321e62480059bc2451fd34ec7aabe9fc87fc717daba0c4b55679d53 ./src/influxdb/query.rs +5e4d94f8a010df05bae9acab72c518080d2dba94d054df95b87e492305357222 ./src/influxdb/write.rs c67212da6a2bd5c2933ece31586ca4048b484fda3105f23a6e06e881b079d129 ./src/main.rs -ef841f3ab27da395465ef94256dba74b6f04a24f65b248d80eb560f542ee968e ./src/models.rs +e935e1fcf0d379b627df5d5055dbdad3dd55786fde7191376918b05afdde1727 ./src/models.rs +fca124ec30b5c9286f0f44cc18e8953bb2809f5009d4d521deaebfdeb00c1bd1 ./src/models/automation.rs +9a100f778d1f71697dca7ae4db9991a1b7b2da4f26e006ce0612f98c5406acec ./src/models/control_plan.rs +10b78ee6c225119fd86435928d5269fd9a9b482d3575f073157d5d20e0a17785 ./src/models/defaults.rs +7cdc99a6f0920baf4ac88fbe7e6f1859e8598a00f00b8c3d84f20bc3a4de35f9 ./src/models/device.rs +cfbaa853a5185c4be572588577b313a2d618837f7c07a3860d0e8d2cca04ee60 ./src/models/history.rs +668c9cf157679f8c9fe72846f743629c79e263b14827110834b4addbdf9d13ec ./src/models/integrations.rs +c19f243d709acb59db7bc4a91c02c538146fc5a7c014d90c53e8d8c62c4d6008 ./src/models/runtime.rs +0e0d47d88bb6b7b446fe9a89a69cf46dfe041f8f279eda2f5d8f91c296e19312 ./src/models/temporary_thermostat.rs +be6ef6a17eb41ca41cb21938af6456c5ee76e3278098303c9bd1202fcb2e01f4 ./src/models/zone.rs 9c735d7c475f21f2f388c21be784376f7184f0bf18af1886544ef51585a3b290 ./src/notifications.rs 7fc31fbf8841a073a1544b8c7a6390f1a15b56087486ca0596a8418340fa232a ./src/protocol/crypto.rs -bc03d88e5476386747ff5e32f1788cdc1fcd4bef238ab66df2dada282c348c8f ./src/protocol/gree.rs +f1f765331469f4219a551414833c19b6de413114c22fdd0468e1f417659553c9 ./src/protocol/gree.rs +c935e441933ba8709432cc3ac33ed868043a89188d0f48ea6fbfe34b1f8936a3 ./src/protocol/gree/binding.rs +563ded1758b135eb523f1fa0b1bf55fe5430ef55935b301fd6d79cbb980e1820 ./src/protocol/gree/commands.rs +5d4328199e2285016fd1941e909e5c09f7cdf9fd21ab6538c1bf8bc2f4af3ba3 ./src/protocol/gree/core.rs +287d0ecd9a3a9df2dd5b838463a3802e59b7c0c20a6bbd24682c8fe453b4e276 ./src/protocol/gree/discovery.rs +97284ad37767625cfc2b82a3de5c40f6eecddabe185bd0bcea7cf9b165e017f0 ./src/protocol/gree/merge.rs +a96061fbbcadac6c2df2e9708a48dcf819b951eb147f32c7d0d285ac5ae2491b ./src/protocol/gree/network.rs +45d0cd0ea40b9255bd811362547f5a667dac6bc7324074726130baf60aa41d2b ./src/protocol/gree/polling.rs +35fc903404045129e3046b7997e941b88f98ceb42485cccc872c2e4aa530c75b ./src/protocol/gree/tests.rs +55c2d615e76c9e4b08c4067ea734e81d81915144e637f3b51c19bea4fc879137 ./src/protocol/gree/transport.rs a910bd9432a393740c0f6fab52bfcb551f0ea756718d66d290fd2610767cf07c ./src/protocol/mod.rs -6a1c0cab3eab80ecd254c5e486b1fba068523edb8d699c64054c17f67f4a31d0 ./src/queries.rs +830621fa75265da7288b8135704c0ce27e0687f87d55abf3cdbe40a6e2f3d2b1 ./src/queries.rs +7fac65a9dcbcecee09816f5447c7792dc94f736b1efb66ac85629db75f6280be ./src/queries/device_history.rs +cd5d0befe9f51a7067201a6eaa8d8ed72182d359e42cbdc67c80aa12d722654b ./src/queries/entities.rs +2c5fba462b72158b04dbaaa53c8536263f8d72a1338a6c4ff06cb28a36f6ae27 ./src/queries/ha_history.rs +2b46a8cdc5efb5f930dd8ff7b9f6c0e720cbca096a51d9ae047711dd280fa3b8 ./src/queries/maintenance.rs +f688db6f440ee7b0129ccc22838de0ee0e0cf2b6376173b05f853ca2555c2c1e ./src/queries/schema.rs +f13f1be3d5789539eba3fc1b59c14835f8c681eca7634e118fa3763a53aeee2b ./src/queries/zone_history.rs 2d69811db832c90ce06035ee29665205a04e4c514a21ecae9623991a9b14d825 ./src/state.rs b92a6cb158b494fe145b43c7641e65f6fafff47201d7d76edbec2cfd8b94835c ./systemd/gree-controller.service 2e4e214a39f2ec4fab32d6cdcb480186c93a30ca7b8cac17fd11b196102324ae ./web/app.js diff --git a/README.md b/README.md index 797350b..31ddfd4 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ 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. -**Current release: 0.8.13** +**Current release: 0.8.14** > [Full API reference](docs/API.md) — authentication, every endpoint, request bodies, response models, WebSocket events and examples. diff --git a/docs/API.md b/docs/API.md index 01bd4d3..d92c475 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.8.13**. +HTTP and WebSocket API for GREE Controller **0.8.14**. [← Main documentation](../README.md) @@ -212,7 +212,7 @@ Response: { "status": "ok", "name": "gree-controller", - "version": "0.8.13", + "version": "0.8.14", "uptime_seconds": 1234, "control_ready": true, "time": "2026-08-30T06:54:00Z" @@ -236,7 +236,7 @@ Returns the initial Web UI snapshot: "settings": {}, "outdoor_temperature": null, "system": { - "version": "0.8.13", + "version": "0.8.14", "uptime_seconds": 1234, "auth_required": false, "control_ready": true, diff --git a/home-assistant/custom_components/gree_controller/manifest.json b/home-assistant/custom_components/gree_controller/manifest.json index 9f90cad..8cfaafe 100644 --- a/home-assistant/custom_components/gree_controller/manifest.json +++ b/home-assistant/custom_components/gree_controller/manifest.json @@ -1,7 +1,7 @@ { "domain": "gree_controller", "name": "GREE Controller", - "version": "0.8.13", + "version": "0.8.14", "config_flow": true, "integration_type": "hub", "iot_class": "local_polling", diff --git a/src/api.rs b/src/api.rs index 687fac6..bc14bc7 100644 --- a/src/api.rs +++ b/src/api.rs @@ -127,2707 +127,22 @@ pub fn router(state: AppState) -> Router { .with_state(state) } -async fn debug_api_requests(State(state): State, request: Request, next: Next) -> Response { - if !state.settings.read().await.debug.overlay_enabled { - return next.run(request).await; - } - let method = request.method().clone(); - let path = request.uri().path().to_string(); - let started = Instant::now(); - let response = next.run(request).await; - state.broadcast("api.request", json!({ - "method": method.as_str(), - "path": path, - "status": response.status().as_u16(), - "duration_ms": started.elapsed().as_millis(), - })); - response -} -async fn auth(State(state): State, request: Request, next: Next) -> Result { - let expected = state.config.app_token.trim(); - if expected.is_empty() { - return Ok(next.run(request).await); - } - let supplied = request_token(&request); - if supplied.as_deref() != Some(expected) { - return Err(AppError::Unauthorized); - } - Ok(next.run(request).await) -} - -async fn home_assistant_auth( - State(state): State, - request: Request, - next: Next, -) -> Result { - let supplied = request_token(&request).ok_or(AppError::Unauthorized)?; - let admin_token = state.config.app_token.trim(); - if !admin_token.is_empty() && supplied == admin_token { - return Ok(next.run(request).await); - } - if state.db.api_token_exists(&hash_token(&supplied))? { - return Ok(next.run(request).await); - } - Err(AppError::Unauthorized) -} - -fn request_token(request: &Request) -> Option { - request.headers().get(header::AUTHORIZATION) - .and_then(|value| value.to_str().ok()) - .and_then(|value| value.strip_prefix("Bearer ")) - .or_else(|| request.headers().get("x-api-token").and_then(|value| value.to_str().ok())) - .map(str::to_owned) -} - -fn hash_token(token: &str) -> String { - URL_SAFE_NO_PAD.encode(Sha256::digest(token.as_bytes())) -} - -fn generate_access_token() -> String { - let mut bytes = [0u8; 32]; - let mut rng = OsRng; - rng.fill_bytes(&mut bytes); - format!("gree_controller_{}", URL_SAFE_NO_PAD.encode(bytes)) -} - -async fn health(State(state): State) -> Json { - Json(json!({ - "status": "ok", - "name": "gree-controller", - "version": env!("CARGO_PKG_VERSION"), - "uptime_seconds": state.started.elapsed().as_secs(), - "control_ready": state.initial_device_sync_complete.load(Ordering::Acquire), - "time": Utc::now(), - })) -} - -async fn bootstrap(State(state): State) -> Result, AppError> { - Ok(Json(build_bootstrap(&state).await?)) -} - -async fn build_bootstrap(state: &AppState) -> Result { - let settings = state.settings.read().await.clone(); - let devices = state.db.list_devices()?; - let device_count = devices.len(); - let online_count = devices.iter().filter(|value| value.online).count(); - let simulator_count = devices.iter().filter(|value| value.simulated).count(); - let (received_frames_total, received_frames_by_device) = state.gree.received_frame_stats(); - Ok(json!({ - "devices": devices, - "zones": state.db.list_zones()?, - "groups": state.db.list_groups()?, - "schedules": state.db.list_schedules()?, - "automations": state.db.list_automations()?, - "access_tokens": state.db.list_api_tokens()?, - "settings": public_settings(&settings), - "outdoor_temperature": *state.outdoor_temperature.read().await, - "system": { - "version": env!("CARGO_PKG_VERSION"), - "uptime_seconds": state.started.elapsed().as_secs(), - "auth_required": !state.config.app_token.trim().is_empty(), - "control_ready": state.initial_device_sync_complete.load(Ordering::Acquire), - "database": state.config.database.display().to_string(), - "device_count": device_count, - "online_count": online_count, - "simulator_count": simulator_count, - "bind": state.config.bind.to_string(), - "base_path": if state.config.base_path.is_empty() { "/" } else { state.config.base_path.as_str() }, - "gree_interface": if state.config.gree_interface.trim().is_empty() { "auto" } else { state.config.gree_interface.trim() }, - "gree_received_frames": received_frames_total, - "gree_received_frames_by_device": received_frames_by_device, - } - })) -} - -async fn system_info(State(state): State) -> Result, AppError> { - let devices = state.db.list_devices()?; - let (received_frames_total, received_frames_by_device) = state.gree.received_frame_stats(); - Ok(Json(json!({ - "version": env!("CARGO_PKG_VERSION"), - "uptime_seconds": state.started.elapsed().as_secs(), - "database": state.config.database.display().to_string(), - "device_count": devices.len(), - "online_count": devices.iter().filter(|v| v.online).count(), - "simulator_count": devices.iter().filter(|v| v.simulated).count(), - "control_ready": state.initial_device_sync_complete.load(Ordering::Acquire), - "auth_required": !state.config.app_token.trim().is_empty(), - "bind": state.config.bind.to_string(), - "base_path": if state.config.base_path.is_empty() { "/" } else { state.config.base_path.as_str() }, - "gree_interface": if state.config.gree_interface.trim().is_empty() { "auto" } else { state.config.gree_interface.trim() }, - "gree_received_frames": received_frames_total, - "gree_received_frames_by_device": received_frames_by_device, - }))) -} - -async fn discover(State(state): State, Json(request): Json) -> Result, AppError> { - let settings = state.settings.read().await.clone(); - let timeout_ms = request.timeout_ms.unwrap_or(settings.discovery_timeout_ms).clamp(500, 30_000); - let broadcast = request.broadcast.unwrap_or(settings.discovery_broadcast); - let protocol_version = request.protocol_version.unwrap_or(0).min(2); - let passes = request.passes.unwrap_or(3).clamp(1, 10); - let discovered = state.gree.discover(&broadcast, Duration::from_millis(timeout_ms), protocol_version, passes).await - .map_err(|e| AppError::Device(e.to_string()))?; - let mut saved = Vec::new(); - let mut new_device_ids = Vec::new(); - for item in discovered { - let existing = state.db.get_device_by_mac(&item.mac)?; - let is_new = existing.is_none(); - let mut merged = merge_discovered(existing, item); - let _device_guard = state.lock_device_operation(&merged.id).await; - // A poll/command may have updated the same known device between discovery and - // acquiring its operation lock. Re-merge against the freshest persisted state. - if !is_new { - if let Some(current) = state.db.get_device(&merged.id)? { - merged = merge_discovered(Some(current), merged); - } - } - // Bind right after discovery. GREE modules can have a short bind window; - // bind() also refreshes it with a direct scan before the handshake. - if !merged.simulated && merged.key.as_deref().unwrap_or_default().is_empty() { - match state.gree.bind(&merged).await { - Ok(bound) => { - merged.key = Some(bound.key); - merged.protocol_version = bound.protocol_version; - merged.communication_failures = 0; - merged.last_error = None; - } - Err(err) => { - merged.last_error = Some(format!("discovered, bind pending: {err}")); - state.log("warn", "device.bind_after_discovery", &format!("{}: {err}", merged.name), json!({"device_id": merged.id})); - } - } - } - state.db.save_device(&merged)?; - if is_new { new_device_ids.push(merged.id.clone()); } - saved.push(merged); - } - state.log("info", "discovery.complete", &format!("Discovery found {} device(s)", saved.len()), json!({"count": saved.len(), "protocol_version": protocol_version, "passes": passes, "new_devices": new_device_ids.len()})); - state.broadcast("devices.discovered", json!({"devices": saved})); - Ok(Json(json!({"count": saved.len(), "devices": saved, "new_device_ids": new_device_ids}))) -} - -async fn list_devices(State(state): State) -> Result>, AppError> { - Ok(Json(state.db.list_devices()?)) -} - -async fn add_device(State(state): State, Json(input): Json) -> Result<(StatusCode, Json), AppError> { - if input.name.trim().is_empty() || input.mac.trim().is_empty() || input.ip.trim().is_empty() { - return Err(AppError::BadRequest("name, mac and ip are required".into())); - } - input.ip.parse::().map_err(|_| AppError::BadRequest("invalid IP address".into()))?; - if state.db.get_device_by_mac(&input.mac)?.is_some() { - return Err(AppError::BadRequest("a device with this MAC already exists".into())); - } - let now = Utc::now(); - let normalized_mac = input.mac.replace([':', '-'], "").to_ascii_uppercase(); - let device = Device { - id: format!("gree-{}", normalized_mac.to_ascii_lowercase()), - mac: normalized_mac, - name: input.name.trim().to_string(), - ip: input.ip, - port: input.port, - protocol_version: input.protocol_version.min(2), - model: String::new(), - firmware: String::new(), - key: input.key.filter(|v| !v.trim().is_empty()), - cid: Some("app".into()), - enabled: true, - simulated: input.simulated, - power: false, - mode: "cool".into(), - target_temperature: 24.0, - fan_speed: 0, - swing_vertical: false, - swing_horizontal: false, - quiet: false, - turbo: false, - light: true, - air: false, - xfan: false, - health: false, - sleep: false, - supports_light: None, - supports_quiet: None, - supports_turbo: None, - supports_air: None, - supports_xfan: None, - supports_health: None, - supports_sleep: None, - current_temperature: if input.simulated { Some(25.0) } else { None }, - outdoor_temperature: None, - temperature_sensor_offset: None, - online: input.simulated, - response_time_ms: if input.simulated { Some(0) } else { None }, - last_seen: if input.simulated { Some(now) } else { None }, - last_error: None, - communication_failures: 0, - created_at: now, - updated_at: now, - }; - state.db.save_device(&device)?; - state.log("info", "device.created", &format!("Added {}", device.name), json!({"device_id": device.id})); - state.broadcast("device.created", serde_json::to_value(&device)?); - Ok((StatusCode::CREATED, Json(device))) -} - -async fn get_device(State(state): State, Path(id): Path) -> Result, AppError> { - state.db.get_device(&id)?.map(Json).ok_or_else(|| AppError::NotFound(format!("device {id}"))) -} - -async fn patch_device(State(state): State, Path(id): Path, Json(patch): Json) -> Result, AppError> { - if patch.enabled == Some(false) { - engine::disable_device_safely(&state, &id).await?; - } - let _device_guard = state.lock_device_operation(&id).await; - let mut device = state.db.get_device(&id)?.ok_or_else(|| AppError::NotFound(format!("device {id}")))?; - if let Some(v) = patch.name { if !v.trim().is_empty() { device.name = v.trim().to_string(); } } - if let Some(v) = patch.ip { v.parse::().map_err(|_| AppError::BadRequest("invalid IP address".into()))?; device.ip = v; } - if let Some(v) = patch.port { device.port = v; } - if let Some(v) = patch.protocol_version { - let v = v.min(2); - if device.protocol_version != v { - device.protocol_version = v; - device.key = None; - device.supports_light = None; - device.supports_quiet = None; - device.supports_turbo = None; - device.supports_air = None; - device.supports_xfan = None; - device.supports_health = None; - device.supports_sleep = None; - } - } - if let Some(v) = patch.key { device.key = v.filter(|x| !x.trim().is_empty()); } - if let Some(v) = patch.enabled { device.enabled = v; } - device.updated_at = Utc::now(); - state.db.save_device(&device)?; - state.broadcast("device.updated", serde_json::to_value(&device)?); - Ok(Json(device)) -} - -async fn delete_device(State(state): State, Path(id): Path) -> Result { - if state.db.get_device(&id)?.is_none() { return Err(AppError::NotFound(format!("device {id}"))); } - if state.db.list_automations()?.iter().any(|item| { - item.trigger_device_id.as_deref() == Some(id.as_str()) - || (item.action_group_id.is_none() && item.action_device_id == id) - }) { - return Err(AppError::BadRequest("device is used by an automation; remove or retarget that automation first".into())); - } - let removed_zone_ids: std::collections::HashSet = state.db.list_zones()?.into_iter() - .filter(|zone| zone.device_id == id) - .map(|zone| zone.id) - .collect(); - ensure_zone_removal_safe(&state, &removed_zone_ids)?; - ensure_device_stopped_for_detach(&state, &id, "device.deleted").await?; - if !state.db.delete_device(&id)? { return Err(AppError::NotFound(format!("device {id}"))); } - remove_zone_ids_from_groups(&state, &removed_zone_ids)?; - state.log("info", "device.deleted", "Device deleted", json!({"device_id": id})); - state.broadcast("device.deleted", json!({"id": id})); - Ok(StatusCode::NO_CONTENT) -} - -async fn bind_device(State(state): State, Path(id): Path) -> Result, AppError> { - let _device_guard = state.lock_device_operation(&id).await; - let mut device = state.db.get_device(&id)?.ok_or_else(|| AppError::NotFound(format!("device {id}")))?; - if device.simulated { return Ok(Json(device)); } - let bound = state.gree.bind(&device).await.map_err(|e| AppError::Device(e.to_string()))?; - device.key = Some(bound.key); - device.protocol_version = bound.protocol_version; - device.communication_failures = 0; - device.online = true; - device.last_seen = Some(Utc::now()); - device.last_error = None; - device.updated_at = Utc::now(); - state.db.save_device(&device)?; - state.log("info", "device.bound", &format!("Bound {}", device.name), json!({"device_id": id})); - Ok(Json(device)) -} - -async fn poll_device(State(state): State, Path(id): Path) -> Result, AppError> { - Ok(Json(engine::poll_one(&state, &id).await?)) -} - -async fn command_device(State(state): State, Path(id): Path, Json(command): Json) -> Result, AppError> { - Ok(Json(engine::send_manual_command(&state, &id, command, "device.manual_control").await?)) -} - -async fn command_home_assistant_device(State(state): State, Path(id): Path, Json(command): Json) -> Result, AppError> { - if state.db.list_zones()?.iter().any(|zone| zone.device_id == id && !zone.enabled) { - return Err(AppError::BadRequest("device belongs to a disabled thermostat zone; use technical device control for manual operation".into())); - } - Ok(Json(engine::send_manual_command(&state, &id, command, "home_assistant.device_manual_control").await?)) -} - -#[derive(Debug, Deserialize)] -struct ZoneInput { - name: String, - device_id: String, - #[serde(default = "yes")] - enabled: bool, - #[serde(default = "cool")] - mode: String, - #[serde(default = "yes")] - inherit_house_mode: bool, - #[serde(default = "setpoint")] - setpoint: f64, - #[serde(default = "cool_comfort")] - cool_comfort_setpoint: f64, - #[serde(default = "cool_sleep")] - cool_sleep_setpoint: f64, - #[serde(default = "cool_away")] - cool_away_setpoint: f64, - #[serde(default = "heat_comfort")] - heat_comfort_setpoint: f64, - #[serde(default = "heat_sleep")] - heat_sleep_setpoint: f64, - #[serde(default = "heat_away")] - heat_away_setpoint: f64, - #[serde(default = "hysteresis")] - hysteresis: f64, - #[serde(default = "cycle")] - min_on_seconds: u64, - #[serde(default = "cycle")] - min_off_seconds: u64, - #[serde(default = "min_adjust")] - min_adjust_seconds: u64, - #[serde(default = "standby_offset")] - standby_offset_c: f64, - #[serde(default = "yes")] - smart_fan: bool, - #[serde(default = "device_source")] - sensor_source: String, - #[serde(default)] - ha_entity_id: Option, - #[serde(default = "external_sensor_weight")] - external_sensor_weight: f64, - #[serde(default = "max_sensor_difference")] - max_sensor_difference: f64, - #[serde(default = "sensor_stale_after")] - sensor_stale_after_seconds: u64, - #[serde(default)] - revision: Option, -} -fn yes() -> bool { true } -fn cool() -> String { "cool".into() } -fn setpoint() -> f64 { 24.0 } -fn cool_comfort() -> f64 { 23.0 } -fn cool_sleep() -> f64 { 24.5 } -fn cool_away() -> f64 { 27.0 } -fn heat_comfort() -> f64 { 21.0 } -fn heat_sleep() -> f64 { 19.0 } -fn heat_away() -> f64 { 17.0 } -fn hysteresis() -> f64 { 0.6 } -fn cycle() -> u64 { 180 } -fn min_adjust() -> u64 { 120 } -fn standby_offset() -> f64 { 2.0 } -fn external_sensor_weight() -> f64 { 0.4 } -fn max_sensor_difference() -> f64 { 3.0 } -fn sensor_stale_after() -> u64 { 300 } -fn device_source() -> String { "device".into() } - -impl ZoneInput { - fn validate(&self) -> Result<(), AppError> { - if self.name.trim().is_empty() { return Err(AppError::BadRequest("zone name is required".into())); } - for value in [self.setpoint, self.cool_comfort_setpoint, self.cool_sleep_setpoint, self.cool_away_setpoint, - self.heat_comfort_setpoint, self.heat_sleep_setpoint, self.heat_away_setpoint] { - if !(8.0..=30.0).contains(&value) { return Err(AppError::BadRequest("zone temperatures must be between 8 and 30 C".into())); } - } - if !(0.1..=5.0).contains(&self.hysteresis) { return Err(AppError::BadRequest("hysteresis must be between 0.1 and 5 C".into())); } - if !(0.5..=8.0).contains(&self.standby_offset_c) { return Err(AppError::BadRequest("standby offset must be between 0.5 and 8 C".into())); } - if !matches!(self.mode.as_str(), "cool" | "heat") { return Err(AppError::BadRequest("zone mode must be cool or heat".into())); } - if !matches!(self.sensor_source.as_str(), "device" | "home_assistant" | "combined") { return Err(AppError::BadRequest("unsupported sensor source".into())); } - if !(0.0..=1.0).contains(&self.external_sensor_weight) { return Err(AppError::BadRequest("external sensor weight must be between 0 and 1".into())); } - if !(0.1..=20.0).contains(&self.max_sensor_difference) { return Err(AppError::BadRequest("maximum sensor difference must be between 0.1 and 20 C".into())); } - if matches!(self.sensor_source.as_str(), "home_assistant" | "combined") && self.ha_entity_id.as_deref().map(|value| value.trim()).unwrap_or("").is_empty() { - return Err(AppError::BadRequest("a per-zone Home Assistant entity_id is required for external or combined temperature control".into())); - } - Ok(()) - } - fn into_zone(self, id: String, created_at: chrono::DateTime) -> Zone { - Zone { - id, name: self.name.trim().into(), device_id: self.device_id, enabled: self.enabled, - mode: self.mode, inherit_house_mode: self.inherit_house_mode, setpoint: self.setpoint, profile_version: 1, - cool_comfort_setpoint: self.cool_comfort_setpoint, cool_sleep_setpoint: self.cool_sleep_setpoint, - cool_away_setpoint: self.cool_away_setpoint, heat_comfort_setpoint: self.heat_comfort_setpoint, - heat_sleep_setpoint: self.heat_sleep_setpoint, heat_away_setpoint: self.heat_away_setpoint, - hysteresis: self.hysteresis, min_on_seconds: self.min_on_seconds, min_off_seconds: self.min_off_seconds, - min_adjust_seconds: self.min_adjust_seconds, standby_offset_c: self.standby_offset_c, smart_fan: self.smart_fan, - sensor_source: self.sensor_source, ha_entity_id: self.ha_entity_id.filter(|v| !v.trim().is_empty()), - external_sensor_weight: self.external_sensor_weight, max_sensor_difference: self.max_sensor_difference, sensor_stale_after_seconds: self.sensor_stale_after_seconds, - 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: "zone created".into(), - last_power_change_at: None, last_mode_change_at: None, lockout_until: None, lockout_reason: None, - effective_mode: String::new(), effective_setpoint: None, device_setpoint: None, - demand: false, demand_since: None, target_alerted_at: None, last_action_at: None, - created_at, updated_at: Utc::now(), - } - } -} - -fn validate_zone_device_assignment(state: &AppState, device_id: &str, current_zone_id: Option<&str>) -> Result<(), AppError> { - if state.db.list_zones()?.iter().any(|zone| zone.device_id == device_id && current_zone_id != Some(zone.id.as_str())) { - return Err(AppError::BadRequest("a device can belong to only one thermostat zone".into())); - } - Ok(()) -} - -async fn list_zones(State(state): State) -> Result>, AppError> { Ok(Json(state.db.list_zones()?)) } -async fn get_zone(State(state): State, Path(id): Path) -> Result, AppError> { - state.db.get_zone(&id)?.map(Json).ok_or_else(|| AppError::NotFound(format!("zone {id}"))) -} -async fn create_zone(State(state): State, Json(input): Json) -> Result<(StatusCode, Json), AppError> { - input.validate()?; - if state.db.get_device(&input.device_id)?.is_none() { return Err(AppError::BadRequest("zone device does not exist".into())); } - validate_zone_device_assignment(&state, &input.device_id, None)?; - let mut zone = input.into_zone(Uuid::new_v4().to_string(), Utc::now()); - let settings = state.settings.read().await.clone(); - canonicalize_zone_ha_entity(&mut zone, &settings); - state.db.save_zone(&zone)?; - state.broadcast("zone.created", serde_json::to_value(&zone)?); - Ok((StatusCode::CREATED, Json(zone))) -} -async fn update_zone(State(state): State, Path(id): Path, Json(input): Json) -> Result, AppError> { - input.validate()?; - let _zone_guard = state.lock_zone_operation(&id).await; - let mut existing = state.db.get_zone(&id)?.ok_or_else(|| AppError::NotFound(format!("zone {id}")))?; - if let Some(expected) = input.revision { - if expected != existing.revision { - return Err(AppError::Conflict(format!("zone {id} changed; expected revision {expected}, current revision {}", existing.revision))); - } - } - if state.db.get_device(&input.device_id)?.is_none() { return Err(AppError::BadRequest("zone device does not exist".into())); } - validate_zone_device_assignment(&state, &input.device_id, Some(&id))?; - let device_changed = existing.device_id != input.device_id; - // Serialize a normal zone edit with polling/manual-takeover detection for its device. - // Device reassignment uses ensure_device_stopped_for_detach below, which acquires the - // old device lock itself while this zone lock is held. - let _device_guard = if !device_changed { Some(state.lock_device_operation(&existing.device_id).await) } else { None }; - if !device_changed { - // Polling may have updated takeover/runtime state while we were waiting for the - // device lock. Re-read under both locks before building the replacement Zone. - existing = state.db.get_zone(&id)?.ok_or_else(|| AppError::NotFound(format!("zone {id}")))?; - if let Some(expected) = input.revision { - if expected != existing.revision { - return Err(AppError::Conflict(format!("zone {id} changed; expected revision {expected}, current revision {}", existing.revision))); - } - } - } - let mut zone = input.into_zone(id, existing.created_at); - if !device_changed { - zone.device_temperature = existing.device_temperature; - zone.external_temperature = existing.external_temperature; - zone.current_temperature = existing.current_temperature; - zone.control_temperature_source = existing.control_temperature_source; - zone.active_preset = existing.active_preset; - zone.manual_preset = existing.manual_preset; - zone.manual_setpoint = existing.manual_setpoint; - zone.manual_override_until = existing.manual_override_until; - zone.local_thermostat_power = existing.local_thermostat_power; - zone.local_thermostat_resume_at = existing.local_thermostat_resume_at; - zone.local_thermostat_restore_zone_enabled = existing.local_thermostat_restore_zone_enabled; - zone.temporary_quick_thermostat = existing.temporary_quick_thermostat; - zone.device_manual_override = existing.device_manual_override; - zone.device_manual_override_since = existing.device_manual_override_since; - zone.device_manual_override_until = existing.device_manual_override_until; - zone.device_manual_override_fields = existing.device_manual_override_fields; - zone.device_manual_override_baseline = existing.device_manual_override_baseline; - zone.revision = existing.revision.saturating_add(1); - zone.control_owner = existing.control_owner; - zone.control_source = existing.control_source; - zone.control_since = existing.control_since; - zone.control_resume_at = existing.control_resume_at; - zone.control_reason = existing.control_reason; - zone.last_power_change_at = existing.last_power_change_at; - zone.last_mode_change_at = existing.last_mode_change_at; - zone.lockout_until = existing.lockout_until; - zone.lockout_reason = existing.lockout_reason; - zone.effective_mode = existing.effective_mode; - zone.effective_setpoint = existing.effective_setpoint; - zone.device_setpoint = existing.device_setpoint; - zone.demand = existing.demand; - zone.demand_since = existing.demand_since; - zone.target_alerted_at = existing.target_alerted_at; - zone.last_action_at = existing.last_action_at; - } else { - // A new physical unit starts with a clean ownership/runtime state. Never transfer - // demand, sensor cache or remote-control takeover from the previous device. - ensure_device_stopped_for_detach(&state, &existing.device_id, "zone.device_reassigned").await?; - zone.revision = existing.revision.saturating_add(1); - } - let settings = state.settings.read().await.clone(); - canonicalize_zone_ha_entity(&mut zone, &settings); - let power_off_device = !device_changed && existing.enabled && !zone.enabled; - if power_off_device { - // Full configuration PUT and quick-control disable use the same ownership cleanup. - // No local/temporary/manual takeover survives a disabled thermostat zone (H6). - if engine::temporary_quick_thermostat_is_active(&zone, Utc::now()) { - engine::finish_temporary_quick_thermostat(&mut zone, &state.db.list_schedules()?, &settings.house_mode); - } else { - zone.temporary_quick_thermostat = None; - } - engine::reset_local_thermostat_override(&mut zone); - engine::reset_device_manual_override(&mut zone); - zone.enabled = false; - } - state.db.save_zone(&zone)?; - state.broadcast("zone.updated", serde_json::to_value(&zone)?); - drop(_device_guard); - if power_off_device { - power_off_zone_device(&state, &zone, "zone.disabled").await; - } - Ok(Json(zone)) -} - -async fn apply_zone_control_patch(state: &AppState, id: &str, patch: ZoneControlPatch, source: &str) -> Result { - // Serialize quick-thermostat changes with the same device lock used by GREE polling and - // manual-takeover detection. Without this, a poll that started just before a Web/HA - // thermostat action could save an older zone snapshot afterwards and resurrect a false - // "physical/pilot" takeover. - let _zone_guard = state.lock_zone_operation(id).await; - let device_id = state.db.get_zone(id)? - .ok_or_else(|| AppError::NotFound(format!("zone {id}")))? - .device_id; - let device_guard = state.lock_device_operation(&device_id).await; - let mut zone = state.db.get_zone(id)?.ok_or_else(|| AppError::NotFound(format!("zone {id}")))?; - let was_enabled = zone.enabled; - let schedules = state.db.list_schedules()?; - let resume_device_takeover = patch.clear_device_manual_override.unwrap_or(false); - let resume_local_thermostat = patch.clear_local_thermostat_override.unwrap_or(false); - let stop_temporary_quick_thermostat = patch.clear_temporary_quick_thermostat.unwrap_or(false); - if patch.temporary_quick_thermostat.is_some() && (patch.power.is_some() || stop_temporary_quick_thermostat || resume_local_thermostat) { - return Err(AppError::BadRequest("temporary thermostat cannot be combined with local power/clear operations in one request".into())); - } - // Direct/manual device takeover is higher priority than a temporary thermostat. Creating - // or editing a temporary session therefore never clears an active pilot/Devices takeover; - // the session waits/pauses instead. Other explicit thermostat actions still resume control. - let resume_device_automation = resume_device_takeover - || patch.power.is_some() || patch.setpoint.is_some() || patch.mode.is_some() - || patch.preset.is_some() || patch.enabled.is_some(); - if resume_device_automation || resume_local_thermostat || stop_temporary_quick_thermostat || patch.temporary_quick_thermostat.is_some() { - zone.control_source = if source.contains("home_assistant") { "home_assistant_thermostat".into() } else { "web_thermostat".into() }; - } - - if resume_local_thermostat { - engine::reset_local_thermostat_override(&mut zone); - } - if stop_temporary_quick_thermostat && zone.temporary_quick_thermostat.is_some() { - if engine::temporary_quick_thermostat_is_active(&zone, Utc::now()) { - engine::finish_temporary_quick_thermostat(&mut zone, &schedules, &state.settings.read().await.house_mode); - } else { - // Cancelling a delayed session before it starts must not erase unrelated - // quick preset/setpoint state that automation may be using in the meantime. - zone.temporary_quick_thermostat = None; - } - } - if let Some(request) = patch.temporary_quick_thermostat.as_ref() { - let now = Utc::now(); - let runtime = state.settings.read().await.clone(); - let existing_session = zone.temporary_quick_thermostat.clone(); - let editing_active = engine::temporary_quick_thermostat_is_active(&zone, now); - let requested_start_kind = request.start_kind.as_str(); - if !matches!(requested_start_kind, "now" | "delay" | "at") { - return Err(AppError::BadRequest("unsupported temporary thermostat start kind".into())); - } - - // Editing an already active session changes only target/finish rules. Its historical - // start and activated_at are preserved, so delay/at sessions cannot be accidentally - // rescheduled or rejected because their original start is now in the past. - let (start_kind, started_at, activated_at) = if editing_active { - let existing = existing_session.as_ref().expect("active temporary session must exist"); - (existing.start_kind.clone(), existing.started_at.clone(), existing.activated_at.clone()) - } else { - let started_at = match requested_start_kind { - "now" => now.clone(), - "delay" => { - let minutes = request.start_delay_minutes.ok_or_else(|| AppError::BadRequest("temporary thermostat start delay is required".into()))?; - if !(1..=43_200).contains(&minutes) { - return Err(AppError::BadRequest("temporary thermostat start delay must be between 1 minute and 30 days".into())); - } - now.clone() + ChronoDuration::minutes(minutes as i64) - } - "at" => { - let at = request.start_at.clone().ok_or_else(|| AppError::BadRequest("temporary thermostat start time is required".into()))?; - if at <= now { return Err(AppError::BadRequest("temporary thermostat start time must be in the future".into())); } - if at > now.clone() + ChronoDuration::days(30) { return Err(AppError::BadRequest("temporary thermostat start time cannot be more than 30 days away".into())); } - at - } - _ => unreachable!(), - }; - (requested_start_kind.to_string(), started_at, None) - }; - - if !editing_active && start_kind == "now" && !runtime.house_power_enabled { - return Err(AppError::BadRequest("temporary thermostat cannot start while whole-house automation is off; enable house power or schedule it for later".into())); - } - - let finish_kind = request.finish_kind.as_str(); - if !matches!(finish_kind, "duration" | "until" | "temperature_reached" | "temperature_stable" | "schedule_boundary") { - return Err(AppError::BadRequest("unsupported temporary thermostat finish kind".into())); - } - - let target = request.target_temperature.unwrap_or(zone.manual_setpoint.unwrap_or(zone.effective_setpoint.unwrap_or(zone.setpoint))); - if !(8.0..=30.0).contains(&target) { - return Err(AppError::BadRequest("temporary thermostat target must be between 8 and 30 C".into())); - } - let target = (target * 2.0).round() / 2.0; - let min_stable_tolerance = (zone.hysteresis.max(0.1) / 2.0 + 0.1).min(3.0); - let requested_tolerance = request.tolerance_c.unwrap_or(min_stable_tolerance.max(0.3)); - if !(0.1..=3.0).contains(&requested_tolerance) { - return Err(AppError::BadRequest("temporary thermostat tolerance must be between 0.1 and 3 C".into())); - } - let tolerance = if finish_kind == "temperature_stable" { requested_tolerance.max(min_stable_tolerance) } else { requested_tolerance }; - let temperature_operator = request.temperature_operator.as_deref().unwrap_or("within"); - if !matches!(temperature_operator, "within" | "at_or_below" | "at_or_above") { - return Err(AppError::BadRequest("unsupported temporary thermostat temperature operator".into())); - } - - let duration_seconds = if finish_kind == "duration" { - let minutes = request.duration_minutes.ok_or_else(|| AppError::BadRequest("temporary thermostat duration is required".into()))?; - if !(1..=14_400).contains(&minutes) { return Err(AppError::BadRequest("temporary thermostat duration must be between 1 minute and 10 days".into())); } - Some(minutes.saturating_mul(60)) - } else { None }; - let is_temperature_condition = matches!(finish_kind, "temperature_reached" | "temperature_stable"); - let hold_seconds = if finish_kind == "temperature_stable" { - let minutes = request.hold_minutes.ok_or_else(|| AppError::BadRequest("temperature hold time is required".into()))?; - if !(1..=1_440).contains(&minutes) { return Err(AppError::BadRequest("temperature hold time must be between 1 minute and 24 hours".into())); } - minutes.saturating_mul(60) - } else { 0 }; - let safety_duration_seconds = if is_temperature_condition { - request.max_duration_minutes.map(|minutes| { - if !(1..=14_400).contains(&minutes) { - return Err(AppError::BadRequest("temporary thermostat safety limit must be between 1 minute and 10 days".into())); - } - Ok(minutes.saturating_mul(60)) - }).transpose()? - } else { None }; - - let active_base = activated_at.clone().unwrap_or(now.clone()); - let expires_at = match finish_kind { - "duration" => if editing_active { duration_seconds.map(|seconds| active_base.clone() + ChronoDuration::seconds(seconds as i64)) } else { None }, - "until" => { - let until = request.until.clone().ok_or_else(|| AppError::BadRequest("temporary thermostat end time is required".into()))?; - let comparison_start = if editing_active { now.clone() } else { started_at.clone() }; - if until <= comparison_start { return Err(AppError::BadRequest("temporary thermostat end time must be in the future and after its start".into())); } - if until > comparison_start.clone() + ChronoDuration::days(30) { return Err(AppError::BadRequest("temporary thermostat end time cannot be more than 30 days after start".into())); } - Some(until) - } - "schedule_boundary" => { - let reference = if editing_active { chrono::Local::now() } else { started_at.clone().with_timezone(&chrono::Local) }; - Some(engine::next_schedule_boundary_utc(&zone.id, &schedules, reference) - .ok_or_else(|| AppError::BadRequest("this zone has no future schedule transition".into()))?) - } - _ => None, - }; - let safety_expires_at = if editing_active { - safety_duration_seconds.map(|seconds| active_base.clone() + ChronoDuration::seconds(seconds as i64)) - } else { None }; - - let starts_now = start_kind == "now"; - let immediate_activation = !editing_active && starts_now && runtime.house_power_enabled && !zone.device_manual_override; - let restore_zone_enabled = if editing_active { - existing_session.as_ref().and_then(|session| session.restore_zone_enabled) - } else if immediate_activation { - Some(zone.enabled) - } else { - // Delayed sessions capture this at actual takeover time (H12), not planning time. - None - }; - let configured_mode = if zone.inherit_house_mode { runtime.house_mode.as_str() } else { zone.mode.as_str() }; - let captured_mode = if configured_mode == "off" { zone.mode.clone() } else { configured_mode.to_string() }; - let active_mode = if editing_active { - existing_session.as_ref().and_then(|session| session.active_mode.clone()) - } else if immediate_activation { - Some(captured_mode.clone()) - } else { None }; - let condition_mode = active_mode.as_deref().unwrap_or(captured_mode.as_str()); - if is_temperature_condition { - if (condition_mode == "heat" && temperature_operator == "at_or_below") - || (condition_mode == "cool" && temperature_operator == "at_or_above") - { - return Err(AppError::BadRequest( - "temporary thermostat temperature condition conflicts with the active heating/cooling direction".into(), - )); - } - } - let state_value = if editing_active { - if zone.device_manual_override { "paused_manual" } else { "active" } - } else if starts_now && zone.device_manual_override { - "paused_manual" - } else { - "scheduled" - }; - let underlying_local_power = zone.local_thermostat_power; - let underlying_local_resume_at = zone.local_thermostat_resume_at; - let underlying_local_zone_enabled = zone.local_thermostat_restore_zone_enabled; - let underlying_manual_preset = zone.manual_preset.clone(); - let underlying_manual_setpoint = zone.manual_setpoint; - let underlying_manual_override_until = zone.manual_override_until; - - if immediate_activation { - zone.local_thermostat_power = Some(true); - zone.local_thermostat_resume_at = None; - zone.local_thermostat_restore_zone_enabled = None; - zone.enabled = true; - zone.manual_setpoint = Some(target); - zone.effective_setpoint = Some(target); - zone.manual_override_until = None; - } else if editing_active { - // Keep current ownership and update the live target without restarting the session. - zone.manual_setpoint = Some(target); - zone.effective_setpoint = Some(target); - zone.manual_override_until = None; - } - - zone.temporary_quick_thermostat = Some(TemporaryQuickThermostat { - start_kind, - finish_kind: finish_kind.into(), - started_at, - activated_at: if immediate_activation { Some(now.clone()) } else { activated_at.clone() }, - state: state_value.into(), - active_mode, - restore_zone_enabled, - restore_local_thermostat_power: if editing_active { - existing_session.as_ref().and_then(|session| session.restore_local_thermostat_power) - } else if immediate_activation { underlying_local_power } else { None }, - restore_local_thermostat_resume_at: if editing_active { - existing_session.as_ref().and_then(|session| session.restore_local_thermostat_resume_at) - } else if immediate_activation { underlying_local_resume_at } else { None }, - restore_local_thermostat_zone_enabled: if editing_active { - existing_session.as_ref().and_then(|session| session.restore_local_thermostat_zone_enabled) - } else if immediate_activation { underlying_local_zone_enabled } else { None }, - restore_manual_preset: if editing_active { - existing_session.as_ref().and_then(|session| session.restore_manual_preset.clone()) - } else if immediate_activation { underlying_manual_preset } else { None }, - restore_manual_setpoint: if editing_active { - existing_session.as_ref().and_then(|session| session.restore_manual_setpoint) - } else if immediate_activation { underlying_manual_setpoint } else { None }, - restore_manual_override_until: if editing_active { - existing_session.as_ref().and_then(|session| session.restore_manual_override_until) - } else if immediate_activation { underlying_manual_override_until } else { None }, - expires_at: if immediate_activation && finish_kind == "duration" { - duration_seconds.map(|seconds| now.clone() + ChronoDuration::seconds(seconds as i64)) - } else { expires_at }, - duration_seconds, - safety_duration_seconds, - temperature_target: Some(target), - temperature_operator: is_temperature_condition.then(|| temperature_operator.to_string()), - tolerance_c: tolerance, - hold_seconds, - condition_started_at: None, - condition_last_observed_at: None, - paused_at: if zone.device_manual_override && (editing_active || starts_now) { - existing_session.as_ref().and_then(|session| session.paused_at.clone()).or(Some(now.clone())) - } else { None }, - deferred_mode: existing_session.as_ref().and_then(|session| session.deferred_mode.clone()), - deferred_preset: existing_session.as_ref().and_then(|session| session.deferred_preset.clone()), - safety_expires_at: if immediate_activation && is_temperature_condition { - safety_duration_seconds.map(|seconds| now.clone() + ChronoDuration::seconds(seconds as i64)) - } else { safety_expires_at }, - }); - } - if let Some(power) = patch.power { - // The neighbouring quick-power control and the explicit Stop button must use the - // same temporary-session cleanup/restore semantics before local ownership changes. - if zone.temporary_quick_thermostat.is_some() { - if engine::temporary_quick_thermostat_is_active(&zone, Utc::now()) { - engine::finish_temporary_quick_thermostat(&mut zone, &schedules, &state.settings.read().await.house_mode); - } else { - zone.temporary_quick_thermostat = None; - } - } - engine::set_local_thermostat_power(&mut zone, power, Utc::now()); - if power { zone.enabled = true; } - // A manually started local thermostat keeps an already selected target/profile until - // it is switched off and the delayed hand-back completes, Auto is selected, or the - // user explicitly resumes automation. - if power && (zone.manual_preset.is_some() || zone.manual_setpoint.is_some()) { - zone.manual_override_until = None; - } - } - if let Some(value) = patch.setpoint { - if !(8.0..=30.0).contains(&value) { return Err(AppError::BadRequest("zone setpoint must be between 8 and 30 C".into())); } - let value = (value * 2.0).round() / 2.0; - zone.setpoint = value; - zone.manual_setpoint = Some(value); - zone.effective_setpoint = Some(value); - if zone.local_thermostat_power == Some(true) { - if let Some(session) = zone.temporary_quick_thermostat.as_mut() { - // +/- always edits the live temporary target, including duration/until - // sessions, so the modal and regulator cannot diverge (M14). - session.temperature_target = Some(value); - if matches!(session.finish_kind.as_str(), "temperature_reached" | "temperature_stable") { - session.condition_started_at = None; - session.condition_last_observed_at = None; - } - } - } - zone.manual_override_until = if zone.local_thermostat_power == Some(true) { - None - } else { - engine::next_schedule_boundary_utc(&zone.id, &schedules, chrono::Local::now()) - }; - } - if let Some(value) = patch.mode.as_deref() { - if !matches!(value, "house" | "auto" | "cool" | "heat") { - return Err(AppError::BadRequest("zone mode must be house, cool or heat".into())); - } - if engine::temporary_quick_thermostat_is_active(&zone, Utc::now()) { - if let Some(session) = zone.temporary_quick_thermostat.as_mut() { - session.deferred_mode = Some(value.to_string()); - } - } else { - match value { - "house" | "auto" => zone.inherit_house_mode = true, - "cool" | "heat" => { - zone.inherit_house_mode = false; - zone.mode = value.to_string(); - } - _ => unreachable!(), - } - } - } - if let Some(value) = patch.preset.as_deref() { - if !matches!(value, "auto" | "comfort" | "sleep" | "away" | "custom") { - return Err(AppError::BadRequest("unsupported zone preset".into())); - } - if engine::temporary_quick_thermostat_is_active(&zone, Utc::now()) { - if let Some(session) = zone.temporary_quick_thermostat.as_mut() { - session.deferred_preset = Some(value.to_string()); - } - } else { - match value { - "auto" => { - zone.manual_preset = None; - zone.manual_setpoint = None; - zone.manual_override_until = None; - } - "comfort" | "sleep" | "away" | "custom" => { - zone.manual_preset = Some(value.to_string()); - zone.manual_setpoint = None; - zone.manual_override_until = if zone.local_thermostat_power == Some(true) { - None - } else { - engine::next_schedule_boundary_utc(&zone.id, &schedules, chrono::Local::now()) - }; - } - _ => unreachable!(), - } - } - } - if patch.clear_override.unwrap_or(false) { - if engine::temporary_quick_thermostat_is_active(&zone, Utc::now()) { - if let Some(session) = zone.temporary_quick_thermostat.as_mut() { - session.deferred_preset = Some("auto".into()); - } - } else { - zone.manual_preset = None; - zone.manual_setpoint = None; - zone.manual_override_until = None; - } - } - if let Some(value) = patch.enabled { - if !value { - if engine::temporary_quick_thermostat_is_active(&zone, Utc::now()) { - engine::finish_temporary_quick_thermostat(&mut zone, &schedules, &state.settings.read().await.house_mode); - } else { - zone.temporary_quick_thermostat = None; - } - engine::reset_local_thermostat_override(&mut zone); - engine::reset_device_manual_override(&mut zone); - zone.enabled = false; - } else { - zone.enabled = true; - } - } - let device_override_cleared = if resume_device_automation { engine::reset_device_manual_override(&mut zone) } else { false }; - let runtime = state.settings.read().await.clone(); - let house_mode = runtime.house_mode.clone(); - let blocked_by_group = zone.local_thermostat_power != Some(true) - && state.db.list_groups()?.iter().any(|group| !group.power_enabled && group.zone_ids.iter().any(|zone_id| zone_id == &zone.id)); - engine::refresh_control_ownership(&mut zone, runtime.house_power_enabled, blocked_by_group); - engine::refresh_zone_runtime_target(&mut zone, &schedules, &house_mode); - zone.revision = zone.revision.saturating_add(1); - zone.updated_at = Utc::now(); - state.db.save_zone(&zone)?; - state.broadcast("zone.updated", serde_json::to_value(&zone)?); - // Release before any device command below; engine::send_command acquires this same lock. - drop(device_guard); - if was_enabled && !zone.enabled { - power_off_zone_device(state, &zone, "zone.quick_disabled").await; - } else if patch.power == Some(false) { - if let Err(err) = engine::send_command( - state, - &zone.device_id, - DeviceCommand { power: Some(false), ..Default::default() }, - ).await { - state.log("error", "zone.local_power_error", &err.to_string(), json!({ - "zone_id": zone.id, "device_id": zone.device_id, "power": false - })); - } - state.wake_zone_control(); - } else { - state.wake_zone_control(); - } - state.log("info", "zone.quick_control", &format!("Quick control updated for {}", zone.name), json!({ - "zone_id": zone.id, "setpoint": zone.setpoint, "manual_setpoint": zone.manual_setpoint, "mode": zone.mode, - "inherit_house_mode": zone.inherit_house_mode, "preset": zone.manual_preset, - "override_until": zone.manual_override_until, "enabled": zone.enabled, - "local_thermostat_power": zone.local_thermostat_power, - "local_thermostat_resume_at": zone.local_thermostat_resume_at, - "temporary_quick_thermostat": zone.temporary_quick_thermostat, - "device_manual_override_cleared": device_override_cleared - })); - Ok(zone) -} - -async fn update_zone_control(State(state): State, Path(id): Path, Json(patch): Json) -> Result, AppError> { - Ok(Json(apply_zone_control_patch(&state, &id, patch, "web.zone_thermostat").await?)) -} - - - -async fn ensure_device_stopped_for_detach(state: &AppState, device_id: &str, source: &str) -> Result<(), AppError> { - let Some(device) = state.db.get_device(device_id)? else { return Ok(()); }; - if !device.enabled { - return Err(AppError::BadRequest("cannot safely detach a technically disabled device; enable it so the controller can confirm it is powered off first".into())); - } - // Force one OFF transition even when the cached state already says OFF. A remote change - // may not have been polled yet and detaching must not leave a running unit without owner. - engine::force_power_off_device(state, device_id).await?; - state.log("info", "zone.detach_power_off", &format!("Powered off {} before detaching thermostat ownership", device.name), json!({ - "device_id": device.id, "source": source - })); - Ok(()) -} - -async fn power_off_zone_device(state: &AppState, zone: &Zone, source: &str) { - let Ok(Some(device)) = state.db.get_device(&zone.device_id) else { return; }; - if !device.enabled { return; } - if let Err(err) = engine::force_power_off_device(state, &device.id).await { - state.log("error", "zone.disable_power_error", &err.to_string(), json!({ - "zone_id": zone.id, - "device_id": device.id, - "device_name": device.name, - "source": source, - })); - } -} - -#[derive(Debug, Deserialize)] -struct GroupInput { - name: String, - #[serde(default)] - zone_ids: Vec, - #[serde(default)] - power_enabled: Option, -} - -fn normalize_group_zone_ids(zone_ids: Vec) -> Vec { - let mut values: Vec = zone_ids.into_iter() - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty()) - .collect(); - values.sort(); - values.dedup(); - values -} - -fn validate_group_input(state: &AppState, input: &GroupInput) -> Result, AppError> { - if input.name.trim().is_empty() { - return Err(AppError::BadRequest("group name is required".into())); - } - let zone_ids = normalize_group_zone_ids(input.zone_ids.clone()); - if zone_ids.is_empty() { - return Err(AppError::BadRequest("group must contain at least one zone".into())); - } - for zone_id in &zone_ids { - if state.db.get_zone(zone_id)?.is_none() { - return Err(AppError::BadRequest(format!("group references missing zone {zone_id}"))); - } - } - Ok(zone_ids) -} - -async fn list_groups(State(state): State) -> Result>, AppError> { - Ok(Json(state.db.list_groups()?)) -} - -async fn get_group(State(state): State, Path(id): Path) -> Result, AppError> { - state.db.get_group(&id)?.map(Json).ok_or_else(|| AppError::NotFound(format!("group {id}"))) -} - -async fn create_group(State(state): State, Json(input): Json) -> Result<(StatusCode, Json), AppError> { - let zone_ids = validate_group_input(&state, &input)?; - let now = Utc::now(); - let group = ClimateGroup { - id: Uuid::new_v4().to_string(), - name: input.name.trim().to_string(), - zone_ids, - power_enabled: input.power_enabled.unwrap_or(true), - created_at: now, - updated_at: now, - }; - state.db.save_group(&group)?; - state.broadcast("group.created", serde_json::to_value(&group)?); - state.wake_zone_control(); - Ok((StatusCode::CREATED, Json(group))) -} - -async fn update_group(State(state): State, Path(id): Path, Json(input): Json) -> Result, AppError> { - let existing = state.db.get_group(&id)?.ok_or_else(|| AppError::NotFound(format!("group {id}")))?; - let zone_ids = validate_group_input(&state, &input)?; - let group = ClimateGroup { - id, - name: input.name.trim().to_string(), - zone_ids, - power_enabled: input.power_enabled.unwrap_or(existing.power_enabled), - created_at: existing.created_at, - updated_at: Utc::now(), - }; - state.db.save_group(&group)?; - state.broadcast("group.updated", serde_json::to_value(&group)?); - state.wake_zone_control(); - Ok(Json(group)) -} - -async fn delete_group(State(state): State, Path(id): Path) -> Result { - if state.db.list_automations()?.iter().any(|item| item.action_group_id.as_deref() == Some(id.as_str())) { - return Err(AppError::BadRequest("group is used by an automation; remove or retarget that automation first".into())); - } - if !state.db.delete_group(&id)? { return Err(AppError::NotFound(format!("group {id}"))); } - state.broadcast("group.deleted", json!({"id": id})); - state.wake_zone_control(); - Ok(StatusCode::NO_CONTENT) -} - -fn ensure_zone_removal_safe(state: &AppState, zone_ids: &std::collections::HashSet) -> Result<(), AppError> { - if zone_ids.is_empty() { return Ok(()); } - let automated_groups: std::collections::HashSet = state.db.list_automations()?.into_iter() - .filter_map(|item| item.action_group_id) - .collect(); - for group in state.db.list_groups()? { - let remaining = group.zone_ids.iter().filter(|zone_id| !zone_ids.contains(*zone_id)).count(); - if remaining == 0 && group.zone_ids.iter().any(|zone_id| zone_ids.contains(zone_id)) && automated_groups.contains(&group.id) { - return Err(AppError::BadRequest(format!("cannot remove the last zone from group '{}' while an automation targets that group", group.name))); - } - } - Ok(()) -} - -fn remove_zone_ids_from_groups(state: &AppState, zone_ids: &std::collections::HashSet) -> Result<(), AppError> { - if zone_ids.is_empty() { return Ok(()); } - for mut group in state.db.list_groups()? { - let before = group.zone_ids.len(); - group.zone_ids.retain(|zone_id| !zone_ids.contains(zone_id)); - if group.zone_ids.len() == before { continue; } - if group.zone_ids.is_empty() { - state.db.delete_group(&group.id)?; - state.broadcast("group.deleted", json!({"id": group.id})); - continue; - } - group.updated_at = Utc::now(); - state.db.save_group(&group)?; - state.broadcast("group.updated", serde_json::to_value(&group)?); - } - Ok(()) -} - -async fn update_group_control(State(state): State, Path(id): Path, Json(patch): Json) -> Result, AppError> { - Ok(Json(engine::control_group(&state, &id, patch, "group.quick_control").await?)) -} - -fn home_assistant_group_mode(zones: &[&Zone]) -> String { - let mut value: Option<&str> = None; - for zone in zones { - let current = if zone.inherit_house_mode { "house" } else { zone.mode.as_str() }; - if !matches!(current, "house" | "cool" | "heat") { - return "mixed".into(); - } - if let Some(previous) = value { - if previous != current { return "mixed".into(); } - } else { - value = Some(current); - } - } - value.unwrap_or("mixed").to_string() -} - -fn home_assistant_group_preset(zones: &[&Zone]) -> String { - let mut value: Option<&str> = None; - for zone in zones { - let current = zone.manual_preset.as_deref().unwrap_or("auto"); - if !matches!(current, "auto" | "comfort" | "sleep" | "away") { - return "mixed".into(); - } - if let Some(previous) = value { - if previous != current { return "mixed".into(); } - } else { - value = Some(current); - } - } - value.unwrap_or("mixed").to_string() -} - -async fn list_home_assistant_groups(State(state): State) -> Result>, AppError> { - let groups = state.db.list_groups()?; - let zones = state.db.list_zones()?; - let devices = state.db.list_devices()?; - let plan = engine::build_control_plan(&state).await?; - let settings = state.settings.read().await.clone(); - let mut output = Vec::with_capacity(groups.len()); - - for group in groups { - let members = zones.iter() - .filter(|zone| group.zone_ids.iter().any(|zone_id| zone_id == &zone.id)) - .collect::>(); - let planned_members = plan.zones.iter() - .filter(|zone| group.zone_ids.iter().any(|zone_id| zone_id == &zone.zone_id)) - .collect::>(); - - let zone_names = members.iter().map(|zone| zone.name.clone()).collect::>(); - let member_device_ids = members.iter().map(|zone| zone.device_id.as_str()).collect::>(); - let online_devices = devices.iter() - .filter(|device| member_device_ids.contains(device.id.as_str()) && device.online) - .count(); - let current_temperatures = planned_members.iter() - .filter_map(|zone| zone.current_temperature) - .collect::>(); - let current_temperature = if current_temperatures.is_empty() { - None - } else { - Some(current_temperatures.iter().sum::() / current_temperatures.len() as f64) - }; - let mut next_events = Vec::new(); - for zone in &planned_members { - for event in &zone.next_events { - let mut event = event.clone(); - event.label = format!("{}: {}", zone.zone_name, event.label); - next_events.push(event); - } - } - next_events.sort_by_key(|event| event.at); - next_events.truncate(8); - let member_states = planned_members.iter().map(|zone| json!({ - "zone_id": zone.zone_id, - "zone_name": zone.zone_name, - "device_id": zone.device_id, - "device_name": zone.device_name, - "enabled": zone.enabled, - "effective_enabled": zone.effective_enabled, - "mode": zone.mode, - "configured_mode": zone.configured_mode, - "inherit_house_mode": zone.inherit_house_mode, - "preset": zone.preset, - "current_temperature": zone.current_temperature, - "target_temperature": zone.target_temperature, - "demand": zone.demand, - "control_source": zone.control_source, - "current_schedule": zone.current_schedule_name, - "local_thermostat_power": zone.local_thermostat_power, - "local_thermostat_resume_at": zone.local_thermostat_resume_at, - "device_manual_override": zone.device_manual_override, - "device_manual_override_until": zone.device_manual_override_until, - })).collect::>(); - - output.push(json!({ - "id": group.id, - "name": group.name, - "zone_ids": group.zone_ids, - "zone_names": zone_names, - "power_enabled": group.power_enabled, - "effective_power": settings.house_power_enabled && group.power_enabled, - "mode": home_assistant_group_mode(&members), - "preset": home_assistant_group_preset(&members), - "house_mode": settings.house_mode, - "zone_count": members.len(), - "enabled_zones": members.iter().filter(|zone| zone.enabled).count(), - "active_zones": planned_members.iter().filter(|zone| zone.effective_enabled).count(), - "demanding_zones": planned_members.iter().filter(|zone| zone.demand).count(), - "device_count": member_device_ids.len(), - "online_devices": online_devices, - "current_temperature": current_temperature, - "members": member_states, - "next_events": next_events, - })); - } - - Ok(Json(output)) -} - -async fn update_home_assistant_group_control( - State(state): State, - Path(id): Path, - Json(patch): Json, -) -> Result, AppError> { - Ok(Json(engine::control_group(&state, &id, patch, "home_assistant.group_control").await?)) -} - -#[derive(Debug, Deserialize)] -struct HouseControlPatch { mode: String } - -fn set_all_groups_power(state: &AppState, power: bool) -> Result<(), AppError> { - for mut group in state.db.list_groups()? { - if group.power_enabled == power { continue; } - group.power_enabled = power; - group.updated_at = Utc::now(); - state.db.save_group(&group)?; - state.broadcast("group.updated", serde_json::to_value(&group)?); - } - Ok(()) -} - -fn clear_all_local_thermostat_overrides(state: &AppState) -> Result { - let mut cleared = 0; - for mut zone in state.db.list_zones()? { - if zone.local_thermostat_power.is_none() && zone.local_thermostat_resume_at.is_none() && zone.temporary_quick_thermostat.is_none() { continue; } - let temporary_was_active = engine::temporary_quick_thermostat_is_active(&zone, Utc::now()); - let temporary_restore = zone.temporary_quick_thermostat.as_ref().and_then(|session| session.restore_zone_enabled); - zone.temporary_quick_thermostat = None; - engine::reset_local_thermostat_override(&mut zone); - if temporary_was_active { - if let Some(enabled) = temporary_restore { zone.enabled = enabled; } - } - zone.updated_at = Utc::now(); - state.db.save_zone(&zone)?; - state.broadcast("zone.updated", serde_json::to_value(&zone)?); - cleared += 1; - } - Ok(cleared) -} - -async fn command_all_enabled_devices_power(state: &AppState, power: bool, source: &str) -> Result, AppError> { - let mut failed = Vec::new(); - let enabled_zone_devices: std::collections::HashSet = if power { - state.db.list_zones()?.into_iter() - .filter(|zone| zone.enabled && !zone.device_manual_override && zone.local_thermostat_power != Some(false)) - .map(|zone| zone.device_id) - .collect() - } else { - std::collections::HashSet::new() - }; - for device in state.db.list_devices()? { - if !device.enabled { continue; } - // Whole-house ON only operates thermostat-managed, enabled zones. Devices with - // a disabled zone (or no zone at all) remain manual/technical Devices controls. - if power && !enabled_zone_devices.contains(&device.id) { continue; } - // Do not trust the pre-loop power snapshot for deciding whether to send. The engine - // reloads state under the per-device lock and turns an already-matching command into - // a no-op. This closes the polling/command race without extra UDP frames. - let result = if power { - engine::send_command(state, &device.id, DeviceCommand { power: Some(true), ..Default::default() }).await - } else { - engine::force_house_power_off_device(state, &device.id, source).await - }; - if let Err(err) = result { - state.log("error", "house.power_all_error", &err.to_string(), json!({ - "device_id": device.id, - "device_name": device.name, - "power": power, - "source": source, - })); - failed.push(json!({ - "device_id": device.id, - "device_name": device.name, - "error": err.to_string(), - })); - } - } - Ok(failed) -} - -async fn update_house_control(State(state): State, Json(input): Json) -> Result, AppError> { - if !matches!(input.mode.as_str(), "cool" | "heat" | "off") { - return Err(AppError::BadRequest("house mode must be cool, heat or off".into())); - } - let mode = input.mode; - let activate_all = mode != "off"; - let payload = { - let mut settings = state.settings.write().await; - settings.house_mode = mode.clone(); - // Choosing a real whole-house operating mode is an explicit request to run the - // house climate. It therefore clears a previous global power-off. "off" keeps - // its separate meaning: do not control, without changing master power. - if activate_all { settings.house_power_enabled = true; } - state.db.save_runtime_settings(&settings)?; - public_settings(&settings) - }; - state.broadcast("settings.updated", payload.clone()); - if activate_all { - set_all_groups_power(&state, true)?; - // Never send a bare power=true frame. Wake the thermostat arbiter so every unit - // starts only with a valid effective Heat/Cool mode and compressor lockout policy. - state.wake_zone_control(); - } - state.log("info", "house.mode", &format!("House mode set to {}", mode), json!({"mode": mode, "master_power_enabled": activate_all})); - Ok(Json(payload)) -} - -#[derive(Debug, Deserialize)] -struct HousePowerPatch { power: bool } - -async fn update_house_power(State(state): State, Json(input): Json) -> Result, AppError> { - // Whole-house power is independent from the thermostat mode. Publish/persist the master - // first so the regulator becomes passive before the one-shot OFF cascade starts. - { - let mut settings = state.settings.write().await; - if settings.house_power_enabled != input.power { - settings.house_power_enabled = input.power; - state.db.save_runtime_settings(&settings)?; - let payload = public_settings(&settings); - state.broadcast("settings.updated", payload); - } - } - - // Global power is a true cascade across group gates. OFF clears the current takeover - // markers once, then each enabled device is re-cleared atomically with its OFF command. - // A later pilot action is therefore not erased by subsequent controller cycles. - set_all_groups_power(&state, input.power)?; - if !input.power { - engine::clear_all_device_manual_overrides(&state, "house_power_off")?; - clear_all_local_thermostat_overrides(&state)?; - } - let failed = if input.power { - state.wake_zone_control(); - Vec::new() - } else { - command_all_enabled_devices_power(&state, false, "house_power").await? - }; - - let devices = state.db.list_devices()?; - let groups = state.db.list_groups()?; - let settings = state.settings.read().await; - let settings_payload = public_settings(&settings); - drop(settings); - state.log("info", "house.power_all", if input.power { "Whole-house automation enabled; thermostat arbiter resumed" } else { "Whole-house power disabled; all groups and enabled devices powered off" }, json!({ - "power": input.power, - "failed": failed.len(), - })); - Ok(Json(json!({ - "power": input.power, - "devices": devices, - "groups": groups, - "settings": settings_payload, - "failed": failed, - }))) -} - -#[derive(Debug, Deserialize)] -struct HousePresetPatch { preset: String } - -async fn update_house_preset(State(state): State, Json(input): Json) -> Result, AppError> { - if !matches!(input.preset.as_str(), "auto" | "comfort" | "sleep" | "away") { - return Err(AppError::BadRequest("house preset must be auto, comfort, sleep or away".into())); - } - - // A whole-house profile is also an explicit whole-house activation. This mirrors - // selecting cooling/heating and makes the separate master-power control intuitive. - let settings_payload = { - let mut settings = state.settings.write().await; - settings.house_power_enabled = true; - state.db.save_runtime_settings(&settings)?; - public_settings(&settings) - }; - state.broadcast("settings.updated", settings_payload.clone()); - set_all_groups_power(&state, true)?; - - let schedules = state.db.list_schedules()?; - let mut zones = state.db.list_zones()?; - for zone in &mut zones { - if engine::temporary_quick_thermostat_is_active(zone, Utc::now()) { - if let Some(session) = zone.temporary_quick_thermostat.as_mut() { - session.deferred_preset = Some(input.preset.clone()); - } - } else if input.preset == "auto" { - zone.manual_preset = None; - zone.manual_setpoint = None; - zone.manual_override_until = None; - } else { - zone.manual_preset = Some(input.preset.clone()); - zone.manual_setpoint = None; - zone.manual_override_until = engine::next_schedule_boundary_utc(&zone.id, &schedules, chrono::Local::now()); - } - zone.updated_at = Utc::now(); - state.db.save_zone(zone)?; - state.broadcast("zone.updated", serde_json::to_value(&*zone)?); - } - - // As with house mode/power ON, the central thermostat arbiter performs the physical - // start with a valid mode/target. This prevents unmanaged power-on while house mode=off. - let failed: Vec = Vec::new(); - state.wake_zone_control(); - let devices = state.db.list_devices()?; - state.log("info", "house.preset", &format!("House preset set to {}", input.preset), json!({ - "preset": input.preset, - "master_power_enabled": true, - "failed": failed.len(), - })); - Ok(Json(json!({ - "preset": input.preset, - "zones": zones, - "devices": devices, - "settings": settings_payload, - "failed": failed, - }))) -} - -#[derive(Debug, Deserialize)] -struct ScheduleTemplateRequest { template: String } - -async fn apply_schedule_template(State(state): State, Path(id): Path, Json(input): Json) -> Result, AppError> { - let zone = state.db.get_zone(&id)?.ok_or_else(|| AppError::NotFound(format!("zone {id}")))?; - let mut items: Vec = Vec::new(); - let mut add = |name: &str, days: Vec, start: &str, end: &str, preset: &str| { - items.push(Schedule { - id: Uuid::new_v4().to_string(), zone_id: id.clone(), name: name.into(), enabled: true, - weekdays: days, start_time: start.into(), end_time: end.into(), preset: preset.into(), - setpoint: zone.setpoint, created_at: Utc::now(), updated_at: Utc::now(), - }); - }; - let all = vec![1,2,3,4,5,6,7]; - match input.template.as_str() { - "family" => { - add("Comfort", all.clone(), "06:30", "22:30", "comfort"); - add("Sleep", all, "22:30", "06:30", "sleep"); - } - "child" => { - add("Comfort", all.clone(), "06:30", "20:30", "comfort"); - add("Sleep", all, "20:30", "06:30", "sleep"); - } - "bedroom" => { - add("Comfort", all.clone(), "06:30", "22:00", "comfort"); - add("Sleep", all, "22:00", "06:30", "sleep"); - } - "workday" => { - let weekdays = vec![1,2,3,4,5]; - let weekend = vec![6,7]; - add("Morning", weekdays.clone(), "06:30", "08:00", "comfort"); - add("Away", weekdays.clone(), "08:00", "16:00", "away"); - add("Evening", weekdays.clone(), "16:00", "22:30", "comfort"); - add("Sleep", weekdays, "22:30", "06:30", "sleep"); - add("Weekend", weekend, "08:00", "23:00", "comfort"); - // Saturday can sleep until the Sunday weekend block starts at 08:00. - add("Saturday sleep", vec![6], "23:00", "08:00", "sleep"); - // Sunday must hand over at 06:30 so it never overlaps Monday morning. - add("Sunday sleep", vec![7], "23:00", "06:30", "sleep"); - } - "always" => add("Comfort", all, "00:00", "00:00", "comfort"), - _ => return Err(AppError::BadRequest("unknown schedule template".into())), - } - validate_schedule_set(&items)?; - state.db.replace_schedules_for_zone(&id, &items)?; - refresh_zone_override_boundary(&state, &id)?; - state.broadcast("schedule.template_applied", json!({"zone_id": id, "template": input.template, "count": items.len()})); - Ok(Json(json!({"zone": zone, "schedules": items}))) -} - -async fn update_home_assistant_zone_control(State(state): State, Path(id): Path, Json(patch): Json) -> Result, AppError> { - Ok(Json(apply_zone_control_patch(&state, &id, patch, "home_assistant.zone_thermostat").await?)) -} - -async fn delete_zone(State(state): State, Path(id): Path) -> Result { - let zone = state.db.get_zone(&id)?.ok_or_else(|| AppError::NotFound(format!("zone {id}")))?; - let mut removed = std::collections::HashSet::new(); - removed.insert(id.clone()); - ensure_zone_removal_safe(&state, &removed)?; - ensure_device_stopped_for_detach(&state, &zone.device_id, "zone.deleted").await?; - if !state.db.delete_zone(&id)? { return Err(AppError::NotFound(format!("zone {id}"))); } - remove_zone_ids_from_groups(&state, &removed)?; - state.broadcast("zone.deleted", json!({"id": id})); - Ok(StatusCode::NO_CONTENT) -} - -#[derive(Debug, Deserialize)] -struct ScheduleInput { - zone_id: String, - name: String, - #[serde(default = "yes")] - enabled: bool, - weekdays: Vec, - start_time: String, - end_time: String, - #[serde(default = "schedule_preset")] - preset: String, - setpoint: f64, -} -fn schedule_preset() -> String { "custom".into() } -impl ScheduleInput { - fn validate(&self) -> Result<(), AppError> { - if self.name.trim().is_empty() { return Err(AppError::BadRequest("schedule name is required".into())); } - if self.weekdays.is_empty() || self.weekdays.iter().any(|v| !(1..=7).contains(v)) { return Err(AppError::BadRequest("weekdays must contain numbers 1..7".into())); } - chrono::NaiveTime::parse_from_str(&self.start_time, "%H:%M").map_err(|_| AppError::BadRequest("invalid start time".into()))?; - chrono::NaiveTime::parse_from_str(&self.end_time, "%H:%M").map_err(|_| AppError::BadRequest("invalid end time".into()))?; - if !matches!(self.preset.as_str(), "comfort" | "sleep" | "away" | "custom") { return Err(AppError::BadRequest("unsupported schedule preset".into())); } - if self.preset == "custom" && !(8.0..=30.0).contains(&self.setpoint) { return Err(AppError::BadRequest("schedule setpoint must be between 8 and 30 C".into())); } - Ok(()) - } - fn into_schedule(self, id: String, created_at: chrono::DateTime) -> Schedule { - Schedule { id, zone_id: self.zone_id, name: self.name.trim().into(), enabled: self.enabled, - weekdays: self.weekdays, start_time: self.start_time, end_time: self.end_time, - preset: self.preset, setpoint: self.setpoint, created_at, updated_at: Utc::now() } - } -} -fn validate_schedule_set(items: &[Schedule]) -> Result<(), AppError> { - for (index, item) in items.iter().enumerate() { - for other in items.iter().skip(index + 1) { - if engine::schedules_overlap(item, other) { - return Err(AppError::BadRequest(format!("schedule '{}' overlaps with '{}' for the same zone", item.name, other.name))); - } - } - } - Ok(()) -} - -fn validate_schedule_conflicts(state: &AppState, item: &Schedule, exclude_id: Option<&str>) -> Result<(), AppError> { - for existing in state.db.list_schedules()? { - if exclude_id == Some(existing.id.as_str()) { continue; } - if engine::schedules_overlap(item, &existing) { - return Err(AppError::BadRequest(format!("schedule overlaps with '{}'", existing.name))); - } - } - Ok(()) -} - -fn refresh_zone_override_boundary(state: &AppState, zone_id: &str) -> Result<(), AppError> { - let Some(mut zone) = state.db.get_zone(zone_id)? else { return Ok(()); }; - let has_temporary_schedule_boundary = zone.temporary_quick_thermostat.as_ref() - .map(|session| session.finish_kind == "schedule_boundary") - .unwrap_or(false); - if zone.manual_preset.is_none() && zone.manual_setpoint.is_none() && !zone.device_manual_override && !has_temporary_schedule_boundary { return Ok(()); } - let schedules = state.db.list_schedules()?; - let boundary = engine::next_schedule_boundary_utc(&zone.id, &schedules, chrono::Local::now()); - // An active Temporary Quick Thermostat explicitly owns its target until its own finish - // rule. Editing/applying schedules must not arm the generic quick-setpoint boundary and - // accidentally clear that target at the next schedule transition. - let temporary_owns_zone = engine::temporary_quick_thermostat_is_active(&zone, Utc::now()); - if (zone.manual_preset.is_some() || zone.manual_setpoint.is_some()) && !temporary_owns_zone { - zone.manual_override_until = boundary; - } - if zone.device_manual_override { zone.device_manual_override_until = boundary; zone.control_resume_at = boundary; } - if has_temporary_schedule_boundary { - let reference = zone.temporary_quick_thermostat.as_ref() - .filter(|session| session.activated_at.is_none()) - .map(|session| session.started_at.with_timezone(&chrono::Local)) - .unwrap_or_else(chrono::Local::now); - let refreshed = engine::next_schedule_boundary_utc(&zone.id, &schedules, reference).or(Some(Utc::now())); - if let Some(session) = zone.temporary_quick_thermostat.as_mut() { - session.expires_at = refreshed; - } - } - zone.revision = zone.revision.saturating_add(1); - zone.updated_at = Utc::now(); - state.db.save_zone(&zone)?; - state.broadcast("zone.updated", serde_json::to_value(&zone)?); - Ok(()) -} - -async fn list_schedules(State(state): State) -> Result>, AppError> { Ok(Json(state.db.list_schedules()?)) } -async fn get_schedule(State(state): State, Path(id): Path) -> Result, AppError> { - state.db.get_schedule(&id)?.map(Json).ok_or_else(|| AppError::NotFound(format!("schedule {id}"))) -} -async fn create_schedule(State(state): State, Json(input): Json) -> Result<(StatusCode, Json), AppError> { - input.validate()?; - if state.db.get_zone(&input.zone_id)?.is_none() { return Err(AppError::BadRequest("schedule zone does not exist".into())); } - let item = input.into_schedule(Uuid::new_v4().to_string(), Utc::now()); - validate_schedule_conflicts(&state, &item, None)?; - state.db.save_schedule(&item)?; - refresh_zone_override_boundary(&state, &item.zone_id)?; - state.broadcast("schedule.created", serde_json::to_value(&item)?); - state.wake_zone_control(); - Ok((StatusCode::CREATED, Json(item))) -} -async fn update_schedule(State(state): State, Path(id): Path, Json(input): Json) -> Result, AppError> { - input.validate()?; - let existing = state.db.get_schedule(&id)?.ok_or_else(|| AppError::NotFound(format!("schedule {id}")))?; - if state.db.get_zone(&input.zone_id)?.is_none() { return Err(AppError::BadRequest("schedule zone does not exist".into())); } - let old_zone_id = existing.zone_id.clone(); - let item = input.into_schedule(id.clone(), existing.created_at); - validate_schedule_conflicts(&state, &item, Some(&id))?; - state.db.save_schedule(&item)?; - refresh_zone_override_boundary(&state, &old_zone_id)?; - if item.zone_id != old_zone_id { refresh_zone_override_boundary(&state, &item.zone_id)?; } - state.broadcast("schedule.updated", serde_json::to_value(&item)?); - state.wake_zone_control(); - Ok(Json(item)) -} -async fn delete_schedule(State(state): State, Path(id): Path) -> Result { - let existing = state.db.get_schedule(&id)?.ok_or_else(|| AppError::NotFound(format!("schedule {id}")))?; - if !state.db.delete_schedule(&id)? { return Err(AppError::NotFound(format!("schedule {id}"))); } - refresh_zone_override_boundary(&state, &existing.zone_id)?; - state.broadcast("schedule.deleted", json!({"id": id})); - state.wake_zone_control(); - Ok(StatusCode::NO_CONTENT) -} - -#[derive(Debug, Deserialize)] -struct AutomationInput { - name: String, - #[serde(default = "yes")] - enabled: bool, - trigger_kind: String, - #[serde(default)] - trigger_device_id: Option, - #[serde(default)] - threshold: Option, - #[serde(default)] - at_time: Option, - #[serde(default)] - action_device_id: String, - #[serde(default)] - action_group_id: Option, - #[serde(default)] - action_preset: Option, - #[serde(default)] - action: DeviceCommand, - #[serde(default = "automation_cooldown")] - cooldown_seconds: u64, -} -fn automation_cooldown() -> u64 { 300 } -impl AutomationInput { - fn validate(&self) -> Result<(), AppError> { - if self.name.trim().is_empty() { return Err(AppError::BadRequest("automation name is required".into())); } - match self.trigger_kind.as_str() { - "temperature_above" | "temperature_below" => { - if self.trigger_device_id.as_deref().unwrap_or_default().is_empty() || self.threshold.is_none() { - return Err(AppError::BadRequest("temperature trigger needs device and threshold".into())); - } - } - "time" => { - let at = self.at_time.as_deref().ok_or_else(|| AppError::BadRequest("time trigger needs at_time".into()))?; - chrono::NaiveTime::parse_from_str(at, "%H:%M").map_err(|_| AppError::BadRequest("invalid automation time".into()))?; - } - _ => return Err(AppError::BadRequest("unsupported automation trigger".into())), - } - let action_group_id = self.action_group_id.as_deref().map(str::trim).filter(|value| !value.is_empty()); - if action_group_id.is_none() && self.action_device_id.trim().is_empty() { - return Err(AppError::BadRequest("automation action needs a device or group".into())); - } - if let Some(preset) = self.action_preset.as_deref().map(str::trim).filter(|value| !value.is_empty()) { - if action_group_id.is_none() { - return Err(AppError::BadRequest("automation preset actions require a group target".into())); - } - if !matches!(preset, "auto" | "comfort" | "sleep" | "away") { - return Err(AppError::BadRequest("unsupported group automation preset".into())); - } - } - if action_group_id.is_some() { - if let Some(mode) = self.action.mode.as_deref() { - if !matches!(mode, "auto" | "house" | "cool" | "heat") { - return Err(AppError::BadRequest("group automation mode must be house, cool or heat".into())); - } - } - if self.action.target_temperature.is_some() - || self.action.fan_speed.is_some() - || self.action.swing_vertical.is_some() - || self.action.swing_horizontal.is_some() - || self.action.quiet.is_some() - || self.action.turbo.is_some() - || self.action.light.is_some() - || self.action.air.is_some() - || self.action.xfan.is_some() - || self.action.health.is_some() - || self.action.sleep.is_some() - { - return Err(AppError::BadRequest("group automations support only power, heat/cool/house mode and a group preset".into())); - } - if self.action.power.is_none() && self.action.mode.is_none() && self.action_preset.as_deref().map(str::trim).filter(|v| !v.is_empty()).is_none() { - return Err(AppError::BadRequest("group automation action cannot be empty".into())); - } - } else { - engine::validate_command(&self.action)?; - if self.action.is_empty() { - return Err(AppError::BadRequest("automation action cannot be empty".into())); - } - } - Ok(()) - } - fn into_automation(self, id: String, created_at: chrono::DateTime, last_fired_at: Option>) -> Automation { - Automation { id, name: self.name.trim().into(), enabled: self.enabled, - trigger_kind: self.trigger_kind, trigger_device_id: self.trigger_device_id.map(|value| value.trim().to_string()).filter(|value| !value.is_empty()), - threshold: self.threshold, at_time: self.at_time, action_device_id: self.action_device_id.trim().to_string(), - action_group_id: self.action_group_id.map(|value| value.trim().to_string()).filter(|value| !value.is_empty()), - action_preset: self.action_preset.map(|value| value.trim().to_string()).filter(|value| !value.is_empty()), - action: self.action, cooldown_seconds: self.cooldown_seconds.max(30), last_fired_at, - created_at, updated_at: Utc::now() } - } -} -fn validate_automation_references(state: &AppState, input: &AutomationInput) -> Result<(), AppError> { - if matches!(input.trigger_kind.as_str(), "temperature_above" | "temperature_below") { - let trigger_id = input.trigger_device_id.as_deref().map(str::trim).unwrap_or_default(); - if state.db.get_device(trigger_id)?.is_none() { - return Err(AppError::BadRequest("automation trigger device does not exist".into())); - } - } - if let Some(group_id) = input.action_group_id.as_deref().map(str::trim).filter(|value| !value.is_empty()) { - if state.db.get_group(group_id)?.is_none() { - return Err(AppError::BadRequest("automation action group does not exist".into())); - } - } else if state.db.get_device(input.action_device_id.trim())?.is_none() { - return Err(AppError::BadRequest("automation action device does not exist".into())); - } - Ok(()) -} - -async fn list_automations(State(state): State) -> Result>, AppError> { Ok(Json(state.db.list_automations()?)) } -async fn get_automation(State(state): State, Path(id): Path) -> Result, AppError> { - state.db.get_automation(&id)?.map(Json).ok_or_else(|| AppError::NotFound(format!("automation {id}"))) -} -async fn create_automation(State(state): State, Json(input): Json) -> Result<(StatusCode, Json), AppError> { - input.validate()?; - validate_automation_references(&state, &input)?; - let item = input.into_automation(Uuid::new_v4().to_string(), Utc::now(), None); - state.db.save_automation(&item)?; - state.broadcast("automation.created", serde_json::to_value(&item)?); - Ok((StatusCode::CREATED, Json(item))) -} -async fn update_automation(State(state): State, Path(id): Path, Json(input): Json) -> Result, AppError> { - input.validate()?; - let existing = state.db.get_automation(&id)?.ok_or_else(|| AppError::NotFound(format!("automation {id}")))?; - validate_automation_references(&state, &input)?; - let item = input.into_automation(id, existing.created_at, existing.last_fired_at); - state.db.save_automation(&item)?; - state.broadcast("automation.updated", serde_json::to_value(&item)?); - Ok(Json(item)) -} -async fn delete_automation(State(state): State, Path(id): Path) -> Result { - if !state.db.delete_automation(&id)? { return Err(AppError::NotFound(format!("automation {id}"))); } - state.broadcast("automation.deleted", json!({"id": id})); - Ok(StatusCode::NO_CONTENT) -} - -#[derive(Debug, Deserialize)] -struct ReadingsQuery { device_id: Option, hours: Option, limit: Option } -async fn readings(State(state): State, Query(query): Query) -> Result, AppError> { - let hours = query.hours.unwrap_or(24).clamp(1, 24 * 3650); - let values = state.db.list_readings(query.device_id.as_deref(), Utc::now() - ChronoDuration::hours(hours), query.limit.unwrap_or(1500))?; - Ok(Json(json!({"readings": values}))) -} - -#[derive(Debug, Deserialize)] -struct HistoryQuery { - scope: Option, - zone_id: Option, - device_id: Option, - entity_id: Option, - hours: Option, - limit: Option, -} - -fn history_bucket_seconds(hours: i64) -> i64 { - match hours { - 1..=6 => 30, - 7..=24 => 120, - 25..=168 => 600, - 169..=720 => 1800, - 721..=2160 => 7200, - 2161..=8760 => 21600, - _ => 86400, - } -} - -fn fallback_zone_rows(zone: &Zone, device: &Device, readings: Vec) -> Vec { - readings.into_iter().map(|reading| ZoneReading { - id: reading.id, - zone_id: zone.id.clone(), - device_id: zone.device_id.clone(), - timestamp: reading.timestamp, - gree_temperature: reading.indoor_temperature, - external_temperature: None, - control_temperature: reading.indoor_temperature, - target_temperature: Some(reading.target_temperature), - device_setpoint: Some(reading.target_temperature), - outdoor_temperature: reading.outdoor_temperature, - power: reading.power, - mode: device.mode.clone(), - fan_speed: device.fan_speed, - demand: false, - control_source: "gree_history_fallback".into(), - active_preset: "history".into(), - }).collect() -} - -fn zone_history_with_fallback( - state: &AppState, - zone_id: Option<&str>, - since: chrono::DateTime, - bucket_seconds: i64, - limit: u32, -) -> Result, AppError> { - let mut values = state.db.list_zone_history(zone_id, since.clone(), bucket_seconds, limit)?; - if let Some(zone_id) = zone_id { - if values.is_empty() { - let zone = state.db.get_zone(zone_id)?.ok_or_else(|| AppError::NotFound(format!("zone {zone_id}")))?; - if let Some(device) = state.db.get_device(&zone.device_id)? { - let rows = state.db.list_device_history(Some(&zone.device_id), since.clone(), bucket_seconds, limit)?; - values = fallback_zone_rows(&zone, &device, rows); - } - } - return Ok(values); - } - - let existing: std::collections::HashSet = values.iter().map(|row| row.zone_id.clone()).collect(); - for zone in state.db.list_zones()? { - if existing.contains(&zone.id) { continue; } - let Some(device) = state.db.get_device(&zone.device_id)? else { continue; }; - let rows = state.db.list_device_history(Some(&zone.device_id), since.clone(), bucket_seconds, limit)?; - values.extend(fallback_zone_rows(&zone, &device, rows)); - } - values.sort_by(|left, right| left.timestamp.cmp(&right.timestamp)); - if values.len() > limit as usize { - let keep_from = values.len() - limit as usize; - values.drain(0..keep_from); - } - Ok(values) -} - -fn sensor_history_with_fallback( - state: &AppState, - since: chrono::DateTime, - bucket_seconds: i64, - limit: u32, - outdoor_entity: &str, -) -> Result, AppError> { - let mut values = state.db.list_ha_history(None, since.clone(), bucket_seconds, limit)?; - let mut existing: std::collections::HashSet = values.iter().map(|row| row.entity_id.clone()).collect(); - for zone in state.db.list_zones()? { - let Some(entity_id) = zone.ha_entity_id.as_deref().filter(|value| !value.trim().is_empty()) else { continue; }; - if existing.contains(entity_id) { continue; } - let rows = state.db.list_zone_history(Some(&zone.id), since.clone(), bucket_seconds, limit)?; - let mut added = false; - for row in rows { - if let Some(temperature) = row.external_temperature { - values.push(HaReading { id: row.id, entity_id: entity_id.to_string(), zone_id: Some(zone.id.clone()), kind: "room".into(), timestamp: row.timestamp, temperature }); - added = true; - } - } - if added { existing.insert(entity_id.to_string()); } - } - let outdoor_entity = outdoor_entity.trim(); - if !outdoor_entity.is_empty() && !existing.contains(outdoor_entity) { - for zone in state.db.list_zones()? { - let rows = state.db.list_zone_history(Some(&zone.id), since.clone(), bucket_seconds, limit)?; - let mut added = false; - for row in rows { - if let Some(temperature) = row.outdoor_temperature { - values.push(HaReading { id: row.id, entity_id: outdoor_entity.to_string(), zone_id: None, kind: "outdoor".into(), timestamp: row.timestamp, temperature }); - added = true; - } - } - if added { break; } - } - } - values.sort_by(|left, right| left.timestamp.cmp(&right.timestamp)); - if values.len() > limit as usize { - let keep_from = values.len() - limit as usize; - values.drain(0..keep_from); - } - Ok(values) -} - -async fn combined_device_history( - state: &AppState, - device_id: Option<&str>, - since: chrono::DateTime, - bucket_seconds: i64, - limit: u32, -) -> Result<(Vec, String, Option), AppError> { - let influx = state.settings.read().await.influxdb.clone(); - let cutoff = Utc::now() - ChronoDuration::days(influx.history_threshold_days.max(1) as i64); - if !influx.enabled || since >= cutoff { - return Ok((state.db.list_device_history(device_id, since, bucket_seconds, limit)?, "sqlite".into(), None)); - } - let mut warning = None; - let mut values = match influxdb::query_devices(&state.http, &influx, device_id, since, cutoff, bucket_seconds, limit).await { - Ok(rows) => rows, - Err(err) => { - warning = Some(err.to_string()); - state.log("warn", "influx.query_error", "InfluxDB device history query failed", json!({"error": err.to_string()})); - state.db.list_device_history(device_id, since, bucket_seconds, limit)? - } - }; - if warning.is_none() { - values.extend(state.db.list_device_history(device_id, cutoff, bucket_seconds, limit)?); - } - values.sort_by_key(|row| row.timestamp); - trim_history(&mut values, limit); - let source = if warning.is_some() { "sqlite_fallback" } else { "influx+sqlite" }; - Ok((values, source.into(), warning)) -} - -async fn combined_zone_history( - state: &AppState, - zone_id: Option<&str>, - since: chrono::DateTime, - bucket_seconds: i64, - limit: u32, -) -> Result<(Vec, String, Option), AppError> { - let influx = state.settings.read().await.influxdb.clone(); - let cutoff = Utc::now() - ChronoDuration::days(influx.history_threshold_days.max(1) as i64); - if !influx.enabled || since >= cutoff { - return Ok((zone_history_with_fallback(state, zone_id, since, bucket_seconds, limit)?, "sqlite".into(), None)); - } - let mut warning = None; - let mut values = match influxdb::query_zones(&state.http, &influx, zone_id, since, cutoff, bucket_seconds, limit).await { - Ok(rows) => rows, - Err(err) => { - warning = Some(err.to_string()); - state.log("warn", "influx.query_error", "InfluxDB zone history query failed", json!({"error": err.to_string()})); - zone_history_with_fallback(state, zone_id, since, bucket_seconds, limit)? - } - }; - if warning.is_none() { - values.extend(zone_history_with_fallback(state, zone_id, cutoff, bucket_seconds, limit)?); - } - values.sort_by_key(|row| row.timestamp); - trim_history(&mut values, limit); - let source = if warning.is_some() { "sqlite_fallback" } else { "influx+sqlite" }; - Ok((values, source.into(), warning)) -} - -async fn combined_sensor_history( - state: &AppState, - entity_id: Option<&str>, - since: chrono::DateTime, - bucket_seconds: i64, - limit: u32, - outdoor_entity: &str, -) -> Result<(Vec, String, Option), AppError> { - let influx = state.settings.read().await.influxdb.clone(); - let cutoff = Utc::now() - ChronoDuration::days(influx.history_threshold_days.max(1) as i64); - let local = |start| -> Result, AppError> { - if entity_id.is_some() { Ok(state.db.list_ha_history(entity_id, start, bucket_seconds, limit)?) } - else { sensor_history_with_fallback(state, start, bucket_seconds, limit, outdoor_entity) } - }; - if !influx.enabled || since >= cutoff { - return Ok((local(since)?, "sqlite".into(), None)); - } - let mut warning = None; - let mut values = match influxdb::query_ha(&state.http, &influx, entity_id, since, cutoff, bucket_seconds, limit).await { - Ok(rows) => rows, - Err(err) => { - warning = Some(err.to_string()); - state.log("warn", "influx.query_error", "InfluxDB HA history query failed", json!({"error": err.to_string()})); - local(since)? - } - }; - if warning.is_none() { - values.extend(local(cutoff)?); - } - values.sort_by_key(|row| row.timestamp); - trim_history(&mut values, limit); - let source = if warning.is_some() { "sqlite_fallback" } else { "influx+sqlite" }; - Ok((values, source.into(), warning)) -} - -fn trim_history(values: &mut Vec, limit: u32) { - if values.len() > limit as usize { - let keep_from = values.len() - limit as usize; - values.drain(0..keep_from); - } -} - -async fn history(State(state): State, Query(query): Query) -> Result, AppError> { - let hours = query.hours.unwrap_or(24).clamp(1, 24 * 3650); - let since = Utc::now() - ChronoDuration::hours(hours); - let bucket_seconds = history_bucket_seconds(hours); - let limit = query.limit.unwrap_or(12_000).clamp(1, 20_000); - let scope = query.scope.as_deref().unwrap_or("zones"); - let outdoor_entity = state.settings.read().await.home_assistant.outdoor_entity_id.clone(); - let (device_count, zone_count, ha_count) = state.db.history_counts()?; - - match scope { - "devices" => { - let device_id = query.device_id.as_deref().filter(|value| !value.is_empty() && *value != "all"); - let (readings, storage, warning) = combined_device_history(&state, device_id, since, bucket_seconds, limit).await?; - Ok(Json(json!({ - "scope": "devices", "readings": readings, "bucket_seconds": bucket_seconds, - "storage": storage, "storage_warning": warning, - "counts": {"devices": device_count, "zones": zone_count, "ha": ha_count} - }))) - } - "sensors" => { - let entity_id = query.entity_id.as_deref().filter(|value| !value.is_empty() && *value != "all"); - let (readings, storage, warning) = combined_sensor_history(&state, entity_id, since, bucket_seconds, limit, &outdoor_entity).await?; - Ok(Json(json!({ - "scope": "sensors", "readings": readings, "bucket_seconds": bucket_seconds, - "storage": storage, "storage_warning": warning, - "counts": {"devices": device_count, "zones": zone_count, "ha": ha_count} - }))) - } - "overview" => { - let (zones, zone_storage, zone_warning) = combined_zone_history(&state, None, since, bucket_seconds, limit).await?; - let (devices, device_storage, device_warning) = combined_device_history(&state, None, since, bucket_seconds, limit).await?; - let (sensors, sensor_storage, sensor_warning) = combined_sensor_history(&state, None, since, bucket_seconds, limit, &outdoor_entity).await?; - let storage_warning = [zone_warning, device_warning, sensor_warning] - .into_iter() - .flatten() - .collect::>(); - Ok(Json(json!({ - "scope": "overview", "bucket_seconds": bucket_seconds, - "zones": zones, "devices": devices, "sensors": sensors, - "storage": {"zones": zone_storage, "devices": device_storage, "sensors": sensor_storage}, - "storage_warning": storage_warning, - "counts": {"devices": device_count, "zones": zone_count, "ha": ha_count} - }))) - } - "zones" | "zone" => { - let zone_id = query.zone_id.as_deref().filter(|value| !value.is_empty() && *value != "all"); - if let Some(zone_id) = zone_id { - if state.db.get_zone(zone_id)?.is_none() { - return Err(AppError::NotFound(format!("zone {zone_id}"))); - } - } - let (readings, storage, warning) = combined_zone_history(&state, zone_id, since, bucket_seconds, limit).await?; - Ok(Json(json!({ - "scope": "zones", "readings": readings, "bucket_seconds": bucket_seconds, - "storage": storage, "storage_warning": warning, - "counts": {"devices": device_count, "zones": zone_count, "ha": ha_count} - }))) - } - _ => Err(AppError::BadRequest("history scope must be overview, zones, devices or sensors".into())), - } -} - -async fn control_plan(State(state): State) -> Result, AppError> { - Ok(Json(serde_json::to_value(engine::build_control_plan(&state).await?)?)) -} - -#[derive(Debug, Deserialize)] -struct EventsQuery { limit: Option } -async fn events(State(state): State, Query(query): Query) -> Result, AppError> { - Ok(Json(json!({"events": state.db.list_events(query.limit.unwrap_or(100))?}))) -} - -#[derive(Debug, Deserialize)] -struct EventRetentionInput { days: u32 } - -async fn get_event_retention(State(state): State) -> Json { - let days = state.settings.read().await.event_log_retention_days; - Json(json!({"days": days})) -} - -async fn update_event_retention(State(state): State, Json(input): Json) -> Result, AppError> { - let mut settings = state.settings.write().await; - settings.event_log_retention_days = input.days.clamp(1, 3650); - state.db.save_runtime_settings(&settings)?; - let days = settings.event_log_retention_days; - drop(settings); - let removed = state.db.prune_events(days as i64)?; - state.log("info", "events.retention_updated", "Event log retention updated", json!({"days": days, "removed": removed})); - let public = { let settings = state.settings.read().await; public_settings(&*settings) }; - state.broadcast("settings.updated", public); - Ok(Json(json!({"days": days, "removed": removed}))) -} - -async fn get_settings(State(state): State) -> Json { - let settings = state.settings.read().await; - Json(public_settings(&*settings)) -} - -async fn update_settings(State(state): State, Json(mut input): Json) -> Result, AppError> { - let old = state.settings.read().await.clone(); - if input.house_power_enabled != old.house_power_enabled || input.house_mode != old.house_mode { - return Err(AppError::BadRequest( - "house_power_enabled and house_mode must be changed through the House Control API".into(), - )); - } - input.poll_interval_seconds = input.poll_interval_seconds.clamp(2, 3600); - input.zone_interval_seconds = input.zone_interval_seconds.clamp(2, 3600); - input.discovery_timeout_ms = input.discovery_timeout_ms.clamp(300, 30_000); - if !matches!(input.house_mode.as_str(), "cool" | "heat" | "off") { return Err(AppError::BadRequest("house mode must be cool, heat or off".into())); } - if input.control_strategy != "setpoint" { input.control_strategy = "setpoint".into(); } - if !(input.discovery_broadcast.eq_ignore_ascii_case("auto") - || input.discovery_broadcast.to_ascii_lowercase().starts_with("auto:")) { - input.discovery_broadcast.parse::() - .map_err(|_| AppError::BadRequest("invalid discovery broadcast address".into()))?; - } - if input.controller_id.trim().is_empty() { input.controller_id = old.controller_id; } - if input.home_assistant.token.trim().is_empty() { input.home_assistant.token = old.home_assistant.token; } - input.home_assistant.sensor_stale_after_seconds = input.home_assistant.sensor_stale_after_seconds.clamp(30, 86_400); - if input.notifications.pushover_app_token.trim().is_empty() { input.notifications.pushover_app_token = old.notifications.pushover_app_token; } - if input.notifications.pushover_user_key.trim().is_empty() { input.notifications.pushover_user_key = old.notifications.pushover_user_key; } - if input.notifications.slack_webhook_url.trim().is_empty() { input.notifications.slack_webhook_url = old.notifications.slack_webhook_url; } - if input.notifications.discord_webhook_url.trim().is_empty() { input.notifications.discord_webhook_url = old.notifications.discord_webhook_url; } - input.notifications.cooldown_seconds = input.notifications.cooldown_seconds.clamp(30, 86_400); - input.notifications.communication_failure_threshold = input.notifications.communication_failure_threshold.clamp(2, 100); - input.notifications.target_timeout_minutes = input.notifications.target_timeout_minutes.clamp(5, 24 * 60); - if !matches!(input.notifications.mode.as_str(), "problems" | "important") { return Err(AppError::BadRequest("notification mode must be problems or important".into())); } - if !matches!(input.notifications.provider.as_str(), "pushover" | "slack" | "discord") { return Err(AppError::BadRequest("unsupported notification provider".into())); } - input.history_retention_days = input.history_retention_days.clamp(1, 3650); - input.event_log_retention_days = input.event_log_retention_days.clamp(1, 3650); - normalize_sensor_aliases(&mut input); - canonicalize_home_assistant_entities(&mut input); - validate_night_mode(&mut input)?; - input.influxdb.history_threshold_days = input.influxdb.history_threshold_days.clamp(1, 3650); - if input.influxdb.token.trim().is_empty() { input.influxdb.token = old.influxdb.token; } - if input.influxdb.password.trim().is_empty() { input.influxdb.password = old.influxdb.password; } - influxdb::validate(&input.influxdb).map_err(|err| AppError::BadRequest(err.to_string()))?; - if !input.home_assistant.url.trim().is_empty() { - let parsed = url::Url::parse(&input.home_assistant.url).map_err(|_| AppError::BadRequest("invalid Home Assistant URL".into()))?; - if !matches!(parsed.scheme(), "http" | "https") { return Err(AppError::BadRequest("Home Assistant URL must use http or https".into())); } - } - state.db.save_runtime_settings(&input)?; - canonicalize_saved_zone_entities(&state, &input)?; - state.debug_gree_frames.store(input.debug.gree_frames, Ordering::Relaxed); - *state.settings.write().await = input.clone(); - state.log("info", "settings.updated", "Settings updated", json!({})); - state.broadcast("settings.updated", public_settings(&input)); - Ok(Json(public_settings(&input))) -} - -fn normalize_sensor_aliases(settings: &mut RuntimeSettings) { - settings.home_assistant.sensor_aliases = settings.home_assistant.sensor_aliases - .iter() - .filter_map(|(entity, alias)| { - let entity = entity.trim(); - let alias = alias.trim(); - if entity.is_empty() || alias.is_empty() { return None; } - Some((entity.chars().take(160).collect::(), alias.chars().take(80).collect::())) - }) - .collect(); -} - -fn canonicalize_home_assistant_entities(settings: &mut RuntimeSettings) { - let default_entity = settings.home_assistant.default_entity_id.clone(); - if let Some(entity_id) = home_assistant::resolve_entity_id(&settings.home_assistant, Some(&default_entity)) { - settings.home_assistant.default_entity_id = entity_id; - } - let outdoor_entity = settings.home_assistant.outdoor_entity_id.clone(); - if !outdoor_entity.trim().is_empty() { - if let Some(entity_id) = home_assistant::resolve_entity_id(&settings.home_assistant, Some(&outdoor_entity)) { - settings.home_assistant.outdoor_entity_id = entity_id; - } - } -} - -fn canonicalize_zone_ha_entity(zone: &mut Zone, settings: &RuntimeSettings) { - let Some(configured) = zone.ha_entity_id.clone() else { return; }; - zone.ha_entity_id = home_assistant::resolve_entity_id(&settings.home_assistant, Some(&configured)); -} - -fn canonicalize_saved_zone_entities(state: &AppState, settings: &RuntimeSettings) -> Result<(), AppError> { - for mut zone in state.db.list_zones()? { - let previous = zone.ha_entity_id.clone(); - canonicalize_zone_ha_entity(&mut zone, settings); - if zone.ha_entity_id != previous { - zone.updated_at = Utc::now(); - state.db.save_zone(&zone)?; - state.broadcast("zone.updated", serde_json::to_value(&zone)?); - } - } - Ok(()) -} - -fn validate_night_mode(settings: &mut RuntimeSettings) -> Result<(), AppError> { - NaiveTime::parse_from_str(&settings.night_mode.start_time, "%H:%M") - .map_err(|_| AppError::BadRequest("night mode start time must use HH:MM".into()))?; - NaiveTime::parse_from_str(&settings.night_mode.end_time, "%H:%M") - .map_err(|_| AppError::BadRequest("night mode end time must use HH:MM".into()))?; - settings.night_mode.max_fan_speed = settings.night_mode.max_fan_speed.clamp(1, 5); - Ok(()) -} - -async fn export_settings(State(state): State) -> Result, AppError> { - let settings = state.settings.read().await.clone(); - let mut export = state.db.export_configuration(settings)?; - // Backups are configuration snapshots, not a way to resurrect transient ownership, - // timers or a stale physical device state after restore (K9). - sanitize_configuration_runtime(&mut export); - Ok(Json(export)) -} - -fn validate_configuration_export(export: &ConfigurationExport) -> Result<(), AppError> { - if export.format_version != 1 { return Err(AppError::BadRequest("unsupported configuration export version".into())); } - influxdb::validate(&export.settings.influxdb).map_err(|err| AppError::BadRequest(err.to_string()))?; - if !matches!(export.settings.house_mode.as_str(), "cool" | "heat" | "off") { - return Err(AppError::BadRequest("import contains an invalid house mode".into())); - } - - let devices: std::collections::HashSet<&str> = export.devices.iter().map(|item| item.id.as_str()).collect(); - let zones: std::collections::HashSet<&str> = export.zones.iter().map(|item| item.id.as_str()).collect(); - let schedules: std::collections::HashSet<&str> = export.schedules.iter().map(|item| item.id.as_str()).collect(); - let automations: std::collections::HashSet<&str> = export.automations.iter().map(|item| item.id.as_str()).collect(); - if devices.len() != export.devices.len() || zones.len() != export.zones.len() - || schedules.len() != export.schedules.len() || automations.len() != export.automations.len() - || devices.contains("") || zones.contains("") || schedules.contains("") || automations.contains("") - { - return Err(AppError::BadRequest("import contains duplicate or empty resource IDs".into())); - } - let device_macs: std::collections::HashSet<&str> = export.devices.iter().map(|item| item.mac.as_str()).collect(); - if device_macs.len() != export.devices.len() { - return Err(AppError::BadRequest("import contains duplicate device MAC addresses".into())); - } - if export.zones.iter().any(|item| !devices.contains(item.device_id.as_str())) { - return Err(AppError::BadRequest("import contains a zone referencing a missing device".into())); - } - let mut zone_devices = std::collections::HashSet::new(); - for zone in &export.zones { - if !zone_devices.insert(zone.device_id.as_str()) { - return Err(AppError::BadRequest("import assigns one device to more than one thermostat zone".into())); - } - if !matches!(zone.mode.as_str(), "cool" | "heat") { - return Err(AppError::BadRequest("import contains an invalid zone mode".into())); - } - if !matches!(zone.sensor_source.as_str(), "device" | "home_assistant" | "combined") { - return Err(AppError::BadRequest("import contains an invalid zone sensor source".into())); - } - } - - if export.schedules.iter().any(|item| !zones.contains(item.zone_id.as_str())) { - return Err(AppError::BadRequest("import contains a schedule referencing a missing zone".into())); - } - for item in &export.schedules { - if item.weekdays.is_empty() || item.weekdays.iter().any(|day| !(1..=7).contains(day)) { - return Err(AppError::BadRequest("import contains invalid schedule weekdays".into())); - } - NaiveTime::parse_from_str(&item.start_time, "%H:%M").map_err(|_| AppError::BadRequest("import contains an invalid schedule start time".into()))?; - NaiveTime::parse_from_str(&item.end_time, "%H:%M").map_err(|_| AppError::BadRequest("import contains an invalid schedule end time".into()))?; - if !matches!(item.preset.as_str(), "comfort" | "sleep" | "away" | "custom") { - return Err(AppError::BadRequest("import contains an invalid schedule preset".into())); - } - if item.preset == "custom" && !(8.0..=30.0).contains(&item.setpoint) { - return Err(AppError::BadRequest("import contains an invalid schedule setpoint".into())); - } - } - validate_schedule_set(&export.schedules)?; - - if export.groups.iter().any(|group| { - let members: std::collections::HashSet<&str> = group.zone_ids.iter().map(String::as_str).collect(); - group.id.trim().is_empty() || group.zone_ids.is_empty() || members.len() != group.zone_ids.len() - || group.zone_ids.iter().any(|zone_id| !zones.contains(zone_id.as_str())) - }) { - return Err(AppError::BadRequest("import contains an invalid group, duplicate members or a missing zone reference".into())); - } - let groups: std::collections::HashSet<&str> = export.groups.iter().map(|item| item.id.as_str()).collect(); - if groups.len() != export.groups.len() { - return Err(AppError::BadRequest("import contains duplicate group IDs".into())); - } - - for item in &export.automations { - match item.trigger_kind.as_str() { - "temperature_above" | "temperature_below" => { - let Some(trigger_id) = item.trigger_device_id.as_deref() else { - return Err(AppError::BadRequest("import contains a temperature automation without a trigger device".into())); - }; - if !devices.contains(trigger_id) || item.threshold.is_none() { - return Err(AppError::BadRequest("import contains an invalid temperature automation trigger".into())); - } - } - "time" => { - let at = item.at_time.as_deref().ok_or_else(|| AppError::BadRequest("import contains a time automation without at_time".into()))?; - NaiveTime::parse_from_str(at, "%H:%M").map_err(|_| AppError::BadRequest("import contains an invalid automation time".into()))?; - } - _ => return Err(AppError::BadRequest("import contains an unsupported automation trigger".into())), - } - - if let Some(group_id) = item.action_group_id.as_deref().filter(|value| !value.is_empty()) { - if !groups.contains(group_id) { - return Err(AppError::BadRequest("import contains an automation referencing a missing group".into())); - } - if let Some(mode) = item.action.mode.as_deref() { - if !matches!(mode, "auto" | "house" | "cool" | "heat") { - return Err(AppError::BadRequest("import contains an invalid group automation mode".into())); - } - } - if let Some(preset) = item.action_preset.as_deref() { - if !matches!(preset, "auto" | "comfort" | "sleep" | "away") { - return Err(AppError::BadRequest("import contains an invalid group automation preset".into())); - } - } - if item.action.target_temperature.is_some() || item.action.fan_speed.is_some() - || item.action.swing_vertical.is_some() || item.action.swing_horizontal.is_some() - || item.action.quiet.is_some() || item.action.turbo.is_some() || item.action.light.is_some() - || item.action.air.is_some() || item.action.xfan.is_some() || item.action.health.is_some() || item.action.sleep.is_some() - { - return Err(AppError::BadRequest("import contains unsupported fields in a group automation".into())); - } - if item.action.power.is_none() && item.action.mode.is_none() && item.action_preset.as_deref().filter(|v| !v.is_empty()).is_none() { - return Err(AppError::BadRequest("import contains an empty group automation action".into())); - } - } else { - if !devices.contains(item.action_device_id.as_str()) { - return Err(AppError::BadRequest("import contains an automation referencing a missing device".into())); - } - engine::validate_command(&item.action)?; - if item.action.is_empty() { - return Err(AppError::BadRequest("import contains an empty automation action".into())); - } - } - } - Ok(()) -} - -fn sanitize_configuration_runtime(export: &mut ConfigurationExport) { - let now = Utc::now(); - for device in &mut export.devices { - device.power = false; - device.mode = "cool".into(); - device.target_temperature = 23.0; - device.fan_speed = 0; - device.swing_vertical = false; - device.swing_horizontal = false; - device.quiet = false; - device.turbo = false; - device.light = false; - device.air = false; - device.xfan = false; - device.health = false; - device.sleep = false; - device.current_temperature = None; - device.outdoor_temperature = None; - device.online = false; - device.response_time_ms = None; - device.last_seen = None; - device.last_error = None; - device.communication_failures = 0; - device.updated_at = now; - } - for zone in &mut export.zones { - zone.device_temperature = None; - zone.external_temperature = None; - zone.current_temperature = None; - zone.control_temperature_source = "device".into(); - zone.active_preset = "comfort".into(); - zone.manual_preset = None; - zone.manual_setpoint = None; - zone.manual_override_until = None; - zone.local_thermostat_power = None; - zone.local_thermostat_resume_at = None; - zone.local_thermostat_restore_zone_enabled = None; - zone.temporary_quick_thermostat = None; - zone.device_manual_override = false; - zone.device_manual_override_since = None; - zone.device_manual_override_until = None; - zone.device_manual_override_fields.clear(); - zone.device_manual_override_baseline = None; - zone.control_owner = "automation".into(); - zone.control_source = "automation".into(); - zone.control_since = None; - zone.control_resume_at = None; - zone.control_reason = "Imported configuration; runtime ownership reset".into(); - zone.last_power_change_at = None; - zone.last_mode_change_at = None; - zone.lockout_until = None; - zone.lockout_reason = None; - zone.effective_mode.clear(); - zone.effective_setpoint = None; - zone.device_setpoint = None; - zone.demand = false; - zone.demand_since = None; - zone.target_alerted_at = None; - zone.last_action_at = None; - zone.revision = 0; - zone.updated_at = now; - } - for automation in &mut export.automations { - automation.last_fired_at = None; - automation.updated_at = now; - } -} - -async fn import_settings(State(state): State, Json(mut export): Json) -> Result, AppError> { - validate_configuration_export(&export)?; - export.settings.history_retention_days = export.settings.history_retention_days.clamp(1, 3650); - export.settings.event_log_retention_days = export.settings.event_log_retention_days.clamp(1, 3650); - normalize_sensor_aliases(&mut export.settings); - canonicalize_home_assistant_entities(&mut export.settings); - for zone in &mut export.zones { canonicalize_zone_ha_entity(zone, &export.settings); } - validate_night_mode(&mut export.settings)?; - export.settings.influxdb.history_threshold_days = export.settings.influxdb.history_threshold_days.clamp(1, 3650); - - // Before replacing ownership, safely stop every currently managed device whose zone is - // removed or rewired by the imported configuration. Otherwise an orphaned physical unit - // could keep running after its database owner disappears. - let imported_zone_map: std::collections::HashMap = export.zones.iter() - .map(|zone| (zone.id.clone(), zone.device_id.clone())) - .collect(); - let mut detach_devices = std::collections::HashSet::new(); - for current in state.db.list_zones()? { - if imported_zone_map.get(¤t.id).map(String::as_str) != Some(current.device_id.as_str()) { - detach_devices.insert(current.device_id); - } - } - for device_id in detach_devices { - ensure_device_stopped_for_detach(&state, &device_id, "configuration.import").await?; - } - - // Configuration import never restores ephemeral owners/timers or cached physical state. - // Imported devices are reconciled from a fresh poll and current house/group/zone gates. - sanitize_configuration_runtime(&mut export); - state.initial_device_sync_complete.store(false, Ordering::Release); - state.db.replace_configuration(&export)?; - state.debug_gree_frames.store(export.settings.debug.gree_frames, Ordering::Relaxed); - *state.settings.write().await = export.settings.clone(); - - let disabled_group_zones: std::collections::HashSet = export.groups.iter() - .filter(|group| !group.power_enabled) - .flat_map(|group| group.zone_ids.iter().cloned()) - .collect(); - let controllable_devices: std::collections::HashSet = export.zones.iter() - .filter(|zone| { - let effective_mode = if zone.inherit_house_mode { export.settings.house_mode.as_str() } else { zone.mode.as_str() }; - export.settings.house_power_enabled - && zone.enabled - && effective_mode != "off" - && !disabled_group_zones.contains(&zone.id) - }) - .map(|zone| zone.device_id.clone()) - .collect(); - for device in export.devices.iter().filter(|device| device.enabled && !controllable_devices.contains(&device.id)) { - if let Err(err) = engine::force_power_off_device(&state, &device.id).await { - state.log("error", "settings.import_reconcile_error", &err.to_string(), json!({"device_id": device.id})); - return Err(err); - } - } - // Rebuild live device snapshots before allowing the thermostat loop to make decisions. - // Network failures are represented in device health by poll_one rather than reviving - // imported cache values. - engine::poll_all(&state).await?; - state.initial_device_sync_complete.store(true, Ordering::Release); - state.wake_zone_control(); - state.log("info", "settings.imported", "Application configuration imported", json!({"format_version": export.format_version})); - state.broadcast("configuration.imported", json!({"at": Utc::now()})); - Ok(Json(json!({"ok": true}))) -} - -async fn get_debug(State(state): State) -> Json { - Json(state.settings.read().await.debug.clone()) -} - -async fn update_debug(State(state): State, Json(input): Json) -> Result, AppError> { - let mut settings = state.settings.write().await; - settings.debug = input.clone(); - state.db.save_runtime_settings(&settings)?; - state.debug_gree_frames.store(input.gree_frames, Ordering::Relaxed); - state.broadcast("debug.settings", serde_json::to_value(&input)?); - Ok(Json(input)) -} - -#[derive(Debug, Deserialize)] -struct CreateAccessTokenRequest { - name: Option, -} - -async fn list_access_tokens(State(state): State) -> Result>, AppError> { - Ok(Json(state.db.list_api_tokens()?)) -} - -async fn create_access_token( - State(state): State, - Json(input): Json, -) -> Result<(StatusCode, Json), AppError> { - let name = input.name.unwrap_or_else(|| "Home Assistant".into()).trim().to_string(); - if name.is_empty() || name.len() > 80 { - return Err(AppError::BadRequest("token name must contain 1 to 80 characters".into())); - } - - let secret = generate_access_token(); - let item = ApiTokenInfo { - id: Uuid::new_v4().to_string(), - name, - token_prefix: format!("{}...", secret.chars().take(24).collect::()), - created_at: Utc::now(), - }; - state.db.save_api_token(&item, &hash_token(&secret))?; - state.log( - "info", - "access_token.created", - "Created a Home Assistant access token", - json!({"token_id": item.id.clone(), "name": item.name.clone()}), - ); - Ok((StatusCode::CREATED, Json(json!({"token": secret, "item": item})))) -} - -async fn delete_access_token(State(state): State, Path(id): Path) -> Result { - if !state.db.delete_api_token(&id)? { - return Err(AppError::NotFound(format!("access token {id}"))); - } - state.log( - "info", - "access_token.revoked", - "Revoked a Home Assistant access token", - json!({"token_id": id}), - ); - Ok(StatusCode::NO_CONTENT) -} - -#[derive(Debug, Deserialize)] -struct HaTestRequest { entity_id: Option } -async fn test_home_assistant(State(state): State, Json(input): Json) -> Result, AppError> { - let settings = state.settings.read().await.clone(); - let resolved_entity_id = home_assistant::resolve_entity_id(&settings.home_assistant, input.entity_id.as_deref()); - let temperature = home_assistant::read_temperature(&state.http, &settings.home_assistant, resolved_entity_id.as_deref(), Some(settings.home_assistant.sensor_stale_after_seconds)) - .await.map_err(|e| AppError::Device(e.to_string()))?; - Ok(Json(json!({"ok": true, "temperature_c": temperature, "entity_id": resolved_entity_id}))) -} - -async fn test_notifications(State(state): State, Json(mut input): Json) -> Result, AppError> { - let old = state.settings.read().await.notifications.clone(); - if input.pushover_app_token.trim().is_empty() { input.pushover_app_token = old.pushover_app_token; } - if input.pushover_user_key.trim().is_empty() { input.pushover_user_key = old.pushover_user_key; } - if input.slack_webhook_url.trim().is_empty() { input.slack_webhook_url = old.slack_webhook_url; } - if input.discord_webhook_url.trim().is_empty() { input.discord_webhook_url = old.discord_webhook_url; } - notifications::test(&state, input).await.map_err(AppError::Device)?; - Ok(Json(json!({"ok": true}))) -} - -async fn security_headers(request: Request, next: Next) -> Response { - let is_api = request.uri().path().contains("/api/"); - let mut response = next.run(request).await; - let headers = response.headers_mut(); - headers.insert(header::HeaderName::from_static("x-content-type-options"), HeaderValue::from_static("nosniff")); - headers.insert(header::HeaderName::from_static("x-frame-options"), HeaderValue::from_static("SAMEORIGIN")); - headers.insert(header::HeaderName::from_static("referrer-policy"), HeaderValue::from_static("same-origin")); - headers.insert(header::HeaderName::from_static("content-security-policy"), HeaderValue::from_static("default-src 'self'; connect-src 'self' ws: wss:; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'self'; object-src 'none'")); - headers.insert(header::HeaderName::from_static("permissions-policy"), HeaderValue::from_static("camera=(), microphone=(), geolocation=()")); - if is_api { headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store")); } - response -} - -fn public_settings(settings: &RuntimeSettings) -> Value { - json!({ - "controller_id": settings.controller_id, - "simulator_enabled": settings.simulator_enabled, - "poll_interval_seconds": settings.poll_interval_seconds, - "zone_interval_seconds": settings.zone_interval_seconds, - "discovery_timeout_ms": settings.discovery_timeout_ms, - "discovery_broadcast": settings.discovery_broadcast, - "house_mode": settings.house_mode, - "house_power_enabled": settings.house_power_enabled, - "control_strategy": settings.control_strategy, - "outdoor_assist_enabled": settings.outdoor_assist_enabled, - "history_retention_days": settings.history_retention_days, - "history_compaction_enabled": settings.history_compaction_enabled, - "event_log_retention_days": settings.event_log_retention_days, - "suppress_device_beep": settings.suppress_device_beep, - "debug": settings.debug, - "night_mode": settings.night_mode, - "notifications": { - "enabled": settings.notifications.enabled, - "mode": settings.notifications.mode, - "provider": settings.notifications.provider, - "pushover_app_token": "", - "pushover_user_key": "", - "pushover_configured": !settings.notifications.pushover_app_token.trim().is_empty() && !settings.notifications.pushover_user_key.trim().is_empty(), - "slack_webhook_url": "", - "slack_configured": !settings.notifications.slack_webhook_url.trim().is_empty(), - "discord_webhook_url": "", - "discord_configured": !settings.notifications.discord_webhook_url.trim().is_empty(), - "cooldown_seconds": settings.notifications.cooldown_seconds, - "communication_failure_threshold": settings.notifications.communication_failure_threshold, - "target_timeout_minutes": settings.notifications.target_timeout_minutes, - "alert_types": &settings.notifications.alert_types, - }, - "influxdb": { - "enabled": settings.influxdb.enabled, - "version": settings.influxdb.version, - "url": settings.influxdb.url, - "database": settings.influxdb.database, - "username": settings.influxdb.username, - "password": "", - "password_configured": !settings.influxdb.password.trim().is_empty(), - "org": settings.influxdb.org, - "bucket": settings.influxdb.bucket, - "token": "", - "token_configured": !settings.influxdb.token.trim().is_empty(), - "history_threshold_days": settings.influxdb.history_threshold_days, - }, - "home_assistant": { - "url": settings.home_assistant.url, - "token": "", - "token_configured": !settings.home_assistant.token.trim().is_empty(), - "default_entity_id": settings.home_assistant.default_entity_id, - "outdoor_entity_id": settings.home_assistant.outdoor_entity_id, - "sensor_stale_after_seconds": settings.home_assistant.sensor_stale_after_seconds, - "allow_invalid_tls": settings.home_assistant.allow_invalid_tls, - "sensor_aliases": settings.home_assistant.sensor_aliases, - } - }) -} - -#[derive(Debug, Deserialize)] -struct WsQuery { token: Option } -async fn websocket(State(state): State, Query(query): Query, ws: WebSocketUpgrade) -> Result { - let expected = state.config.app_token.trim(); - if !expected.is_empty() && query.token.as_deref() != Some(expected) { return Err(AppError::Unauthorized); } - Ok(ws.on_upgrade(move |socket| websocket_loop(state, socket))) -} - -async fn websocket_loop(state: AppState, mut socket: WebSocket) { - let initial = match build_bootstrap(&state).await { - Ok(data) => json!({"event":"bootstrap","timestamp":Utc::now(),"data":data}), - Err(err) => json!({"event":"error","timestamp":Utc::now(),"data":{"message":err.to_string()}}), - }; - if socket.send(Message::Text(initial.to_string())).await.is_err() { return; } - let mut receiver = state.events.subscribe(); - loop { - tokio::select! { - event = receiver.recv() => { - match event { - Ok(event) => { - if let Ok(text) = serde_json::to_string(&event) { - if socket.send(Message::Text(text)).await.is_err() { break; } - } - } - Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue, - Err(_) => break, - } - } - message = socket.next() => { - match message { - Some(Ok(Message::Ping(value))) => { if socket.send(Message::Pong(value)).await.is_err() { break; } } - Some(Ok(Message::Text(text))) if text == "ping" => { if socket.send(Message::Text("pong".into())).await.is_err() { break; } } - Some(Ok(Message::Close(_))) | None | Some(Err(_)) => break, - _ => {} - } - } - } - } -} - -async fn index(State(state): State, headers: HeaderMap) -> Response { - let base = if !state.config.base_path.is_empty() { - state.config.base_path.clone() - } else { - forwarded_prefix(&headers).unwrap_or_default() - }; - let body = INDEX_HTML.replace("__GREE_BASE_PATH__", &base); - let mut response = Response::new(Body::from(body)); - response.headers_mut().insert(header::CONTENT_TYPE, HeaderValue::from_static("text/html; charset=utf-8")); - response.headers_mut().insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache")); - response -} - -fn forwarded_prefix(headers: &HeaderMap) -> Option { - let raw = headers.get("x-forwarded-prefix")?.to_str().ok()?.split(',').next()?.trim(); - if raw.is_empty() || raw == "/" { return Some(String::new()); } - if raw.contains('?') || raw.contains('#') || raw.split('/').any(|part| matches!(part, "." | "..")) { return None; } - Some(format!("/{}", raw.trim_matches('/'))) -} -async fn app_js() -> Response { static_response(APP_JS, "application/javascript; charset=utf-8", "no-cache") } -async fn theme_init_js() -> Response { static_response(THEME_INIT_JS, "application/javascript; charset=utf-8", "public, max-age=86400") } -async fn styles_css() -> Response { static_response(STYLES_CSS, "text/css; charset=utf-8", "no-cache") } -async fn manifest() -> Response { static_response(MANIFEST, "application/manifest+json", "public, max-age=3600") } -async fn service_worker() -> Response { static_response(SERVICE_WORKER, "application/javascript; charset=utf-8", "no-cache") } -async fn favicon() -> Response { static_response(FAVICON, "image/svg+xml", "public, max-age=86400") } -async fn language_index() -> Response { - static_response(LANGUAGE_MANIFEST_JSON, "application/json; charset=utf-8", "no-cache") -} -async fn language_file(Path(file): Path) -> Response { - let code = file.strip_suffix(".json").unwrap_or(&file); - if let Some((_, body)) = LANGUAGE_ASSETS.iter().find(|(language, _)| *language == code) { - return static_response(*body, "application/json; charset=utf-8", "no-cache"); - } - let mut response = Response::new(Body::from("Language not found")); - *response.status_mut() = StatusCode::NOT_FOUND; - response.headers_mut().insert(header::CONTENT_TYPE, HeaderValue::from_static("text/plain; charset=utf-8")); - response -} -fn static_response(body: &'static str, content_type: &'static str, cache: &'static str) -> Response { - let mut response = Response::new(Body::from(body)); - response.headers_mut().insert(header::CONTENT_TYPE, HeaderValue::from_static(content_type)); - response.headers_mut().insert(header::CACHE_CONTROL, HeaderValue::from_static(cache)); - response -} +// Functional source split intentionally keeps items in the existing module namespace. +include!("api/auth.rs"); +include!("api/system.rs"); +include!("api/devices.rs"); +include!("api/zones.rs"); +include!("api/groups.rs"); +include!("api/house.rs"); +include!("api/schedules.rs"); +include!("api/automations.rs"); +include!("api/history.rs"); +include!("api/events.rs"); +include!("api/settings.rs"); +include!("api/debug_tokens.rs"); +include!("api/integrations.rs"); +include!("api/middleware.rs"); +include!("api/public_settings.rs"); +include!("api/websocket.rs"); +include!("api/assets.rs"); diff --git a/src/api/assets.rs b/src/api/assets.rs new file mode 100644 index 0000000..800015a --- /dev/null +++ b/src/api/assets.rs @@ -0,0 +1,44 @@ +async fn index(State(state): State, headers: HeaderMap) -> Response { + let base = if !state.config.base_path.is_empty() { + state.config.base_path.clone() + } else { + forwarded_prefix(&headers).unwrap_or_default() + }; + let body = INDEX_HTML.replace("__GREE_BASE_PATH__", &base); + let mut response = Response::new(Body::from(body)); + response.headers_mut().insert(header::CONTENT_TYPE, HeaderValue::from_static("text/html; charset=utf-8")); + response.headers_mut().insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache")); + response +} + +fn forwarded_prefix(headers: &HeaderMap) -> Option { + let raw = headers.get("x-forwarded-prefix")?.to_str().ok()?.split(',').next()?.trim(); + if raw.is_empty() || raw == "/" { return Some(String::new()); } + if raw.contains('?') || raw.contains('#') || raw.split('/').any(|part| matches!(part, "." | "..")) { return None; } + Some(format!("/{}", raw.trim_matches('/'))) +} +async fn app_js() -> Response { static_response(APP_JS, "application/javascript; charset=utf-8", "no-cache") } +async fn theme_init_js() -> Response { static_response(THEME_INIT_JS, "application/javascript; charset=utf-8", "public, max-age=86400") } +async fn styles_css() -> Response { static_response(STYLES_CSS, "text/css; charset=utf-8", "no-cache") } +async fn manifest() -> Response { static_response(MANIFEST, "application/manifest+json", "public, max-age=3600") } +async fn service_worker() -> Response { static_response(SERVICE_WORKER, "application/javascript; charset=utf-8", "no-cache") } +async fn favicon() -> Response { static_response(FAVICON, "image/svg+xml", "public, max-age=86400") } +async fn language_index() -> Response { + static_response(LANGUAGE_MANIFEST_JSON, "application/json; charset=utf-8", "no-cache") +} +async fn language_file(Path(file): Path) -> Response { + let code = file.strip_suffix(".json").unwrap_or(&file); + if let Some((_, body)) = LANGUAGE_ASSETS.iter().find(|(language, _)| *language == code) { + return static_response(*body, "application/json; charset=utf-8", "no-cache"); + } + let mut response = Response::new(Body::from("Language not found")); + *response.status_mut() = StatusCode::NOT_FOUND; + response.headers_mut().insert(header::CONTENT_TYPE, HeaderValue::from_static("text/plain; charset=utf-8")); + response +} +fn static_response(body: &'static str, content_type: &'static str, cache: &'static str) -> Response { + let mut response = Response::new(Body::from(body)); + response.headers_mut().insert(header::CONTENT_TYPE, HeaderValue::from_static(content_type)); + response.headers_mut().insert(header::CACHE_CONTROL, HeaderValue::from_static(cache)); + response +} diff --git a/src/api/auth.rs b/src/api/auth.rs new file mode 100644 index 0000000..02493e6 --- /dev/null +++ b/src/api/auth.rs @@ -0,0 +1,64 @@ +async fn debug_api_requests(State(state): State, request: Request, next: Next) -> Response { + if !state.settings.read().await.debug.overlay_enabled { + return next.run(request).await; + } + let method = request.method().clone(); + let path = request.uri().path().to_string(); + let started = Instant::now(); + let response = next.run(request).await; + state.broadcast("api.request", json!({ + "method": method.as_str(), + "path": path, + "status": response.status().as_u16(), + "duration_ms": started.elapsed().as_millis(), + })); + response +} + +async fn auth(State(state): State, request: Request, next: Next) -> Result { + let expected = state.config.app_token.trim(); + if expected.is_empty() { + return Ok(next.run(request).await); + } + let supplied = request_token(&request); + if supplied.as_deref() != Some(expected) { + return Err(AppError::Unauthorized); + } + Ok(next.run(request).await) +} + +async fn home_assistant_auth( + State(state): State, + request: Request, + next: Next, +) -> Result { + let supplied = request_token(&request).ok_or(AppError::Unauthorized)?; + let admin_token = state.config.app_token.trim(); + if !admin_token.is_empty() && supplied == admin_token { + return Ok(next.run(request).await); + } + if state.db.api_token_exists(&hash_token(&supplied))? { + return Ok(next.run(request).await); + } + Err(AppError::Unauthorized) +} + +fn request_token(request: &Request) -> Option { + request.headers().get(header::AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.strip_prefix("Bearer ")) + .or_else(|| request.headers().get("x-api-token").and_then(|value| value.to_str().ok())) + .map(str::to_owned) +} + +fn hash_token(token: &str) -> String { + URL_SAFE_NO_PAD.encode(Sha256::digest(token.as_bytes())) +} + +fn generate_access_token() -> String { + let mut bytes = [0u8; 32]; + let mut rng = OsRng; + rng.fill_bytes(&mut bytes); + format!("gree_controller_{}", URL_SAFE_NO_PAD.encode(bytes)) +} + diff --git a/src/api/automations.rs b/src/api/automations.rs new file mode 100644 index 0000000..d1c8403 --- /dev/null +++ b/src/api/automations.rs @@ -0,0 +1,136 @@ +#[derive(Debug, Deserialize)] +struct AutomationInput { + name: String, + #[serde(default = "yes")] + enabled: bool, + trigger_kind: String, + #[serde(default)] + trigger_device_id: Option, + #[serde(default)] + threshold: Option, + #[serde(default)] + at_time: Option, + #[serde(default)] + action_device_id: String, + #[serde(default)] + action_group_id: Option, + #[serde(default)] + action_preset: Option, + #[serde(default)] + action: DeviceCommand, + #[serde(default = "automation_cooldown")] + cooldown_seconds: u64, +} +fn automation_cooldown() -> u64 { 300 } +impl AutomationInput { + fn validate(&self) -> Result<(), AppError> { + if self.name.trim().is_empty() { return Err(AppError::BadRequest("automation name is required".into())); } + match self.trigger_kind.as_str() { + "temperature_above" | "temperature_below" => { + if self.trigger_device_id.as_deref().unwrap_or_default().is_empty() || self.threshold.is_none() { + return Err(AppError::BadRequest("temperature trigger needs device and threshold".into())); + } + } + "time" => { + let at = self.at_time.as_deref().ok_or_else(|| AppError::BadRequest("time trigger needs at_time".into()))?; + chrono::NaiveTime::parse_from_str(at, "%H:%M").map_err(|_| AppError::BadRequest("invalid automation time".into()))?; + } + _ => return Err(AppError::BadRequest("unsupported automation trigger".into())), + } + let action_group_id = self.action_group_id.as_deref().map(str::trim).filter(|value| !value.is_empty()); + if action_group_id.is_none() && self.action_device_id.trim().is_empty() { + return Err(AppError::BadRequest("automation action needs a device or group".into())); + } + if let Some(preset) = self.action_preset.as_deref().map(str::trim).filter(|value| !value.is_empty()) { + if action_group_id.is_none() { + return Err(AppError::BadRequest("automation preset actions require a group target".into())); + } + if !matches!(preset, "auto" | "comfort" | "sleep" | "away") { + return Err(AppError::BadRequest("unsupported group automation preset".into())); + } + } + if action_group_id.is_some() { + if let Some(mode) = self.action.mode.as_deref() { + if !matches!(mode, "auto" | "house" | "cool" | "heat") { + return Err(AppError::BadRequest("group automation mode must be house, cool or heat".into())); + } + } + if self.action.target_temperature.is_some() + || self.action.fan_speed.is_some() + || self.action.swing_vertical.is_some() + || self.action.swing_horizontal.is_some() + || self.action.quiet.is_some() + || self.action.turbo.is_some() + || self.action.light.is_some() + || self.action.air.is_some() + || self.action.xfan.is_some() + || self.action.health.is_some() + || self.action.sleep.is_some() + { + return Err(AppError::BadRequest("group automations support only power, heat/cool/house mode and a group preset".into())); + } + if self.action.power.is_none() && self.action.mode.is_none() && self.action_preset.as_deref().map(str::trim).filter(|v| !v.is_empty()).is_none() { + return Err(AppError::BadRequest("group automation action cannot be empty".into())); + } + } else { + engine::validate_command(&self.action)?; + if self.action.is_empty() { + return Err(AppError::BadRequest("automation action cannot be empty".into())); + } + } + Ok(()) + } + fn into_automation(self, id: String, created_at: chrono::DateTime, last_fired_at: Option>) -> Automation { + Automation { id, name: self.name.trim().into(), enabled: self.enabled, + trigger_kind: self.trigger_kind, trigger_device_id: self.trigger_device_id.map(|value| value.trim().to_string()).filter(|value| !value.is_empty()), + threshold: self.threshold, at_time: self.at_time, action_device_id: self.action_device_id.trim().to_string(), + action_group_id: self.action_group_id.map(|value| value.trim().to_string()).filter(|value| !value.is_empty()), + action_preset: self.action_preset.map(|value| value.trim().to_string()).filter(|value| !value.is_empty()), + action: self.action, cooldown_seconds: self.cooldown_seconds.max(30), last_fired_at, + created_at, updated_at: Utc::now() } + } +} +fn validate_automation_references(state: &AppState, input: &AutomationInput) -> Result<(), AppError> { + if matches!(input.trigger_kind.as_str(), "temperature_above" | "temperature_below") { + let trigger_id = input.trigger_device_id.as_deref().map(str::trim).unwrap_or_default(); + if state.db.get_device(trigger_id)?.is_none() { + return Err(AppError::BadRequest("automation trigger device does not exist".into())); + } + } + if let Some(group_id) = input.action_group_id.as_deref().map(str::trim).filter(|value| !value.is_empty()) { + if state.db.get_group(group_id)?.is_none() { + return Err(AppError::BadRequest("automation action group does not exist".into())); + } + } else if state.db.get_device(input.action_device_id.trim())?.is_none() { + return Err(AppError::BadRequest("automation action device does not exist".into())); + } + Ok(()) +} + +async fn list_automations(State(state): State) -> Result>, AppError> { Ok(Json(state.db.list_automations()?)) } +async fn get_automation(State(state): State, Path(id): Path) -> Result, AppError> { + state.db.get_automation(&id)?.map(Json).ok_or_else(|| AppError::NotFound(format!("automation {id}"))) +} +async fn create_automation(State(state): State, Json(input): Json) -> Result<(StatusCode, Json), AppError> { + input.validate()?; + validate_automation_references(&state, &input)?; + let item = input.into_automation(Uuid::new_v4().to_string(), Utc::now(), None); + state.db.save_automation(&item)?; + state.broadcast("automation.created", serde_json::to_value(&item)?); + Ok((StatusCode::CREATED, Json(item))) +} +async fn update_automation(State(state): State, Path(id): Path, Json(input): Json) -> Result, AppError> { + input.validate()?; + let existing = state.db.get_automation(&id)?.ok_or_else(|| AppError::NotFound(format!("automation {id}")))?; + validate_automation_references(&state, &input)?; + let item = input.into_automation(id, existing.created_at, existing.last_fired_at); + state.db.save_automation(&item)?; + state.broadcast("automation.updated", serde_json::to_value(&item)?); + Ok(Json(item)) +} +async fn delete_automation(State(state): State, Path(id): Path) -> Result { + if !state.db.delete_automation(&id)? { return Err(AppError::NotFound(format!("automation {id}"))); } + state.broadcast("automation.deleted", json!({"id": id})); + Ok(StatusCode::NO_CONTENT) +} + diff --git a/src/api/debug_tokens.rs b/src/api/debug_tokens.rs new file mode 100644 index 0000000..7178b75 --- /dev/null +++ b/src/api/debug_tokens.rs @@ -0,0 +1,61 @@ +async fn get_debug(State(state): State) -> Json { + Json(state.settings.read().await.debug.clone()) +} + +async fn update_debug(State(state): State, Json(input): Json) -> Result, AppError> { + let mut settings = state.settings.write().await; + settings.debug = input.clone(); + state.db.save_runtime_settings(&settings)?; + state.debug_gree_frames.store(input.gree_frames, Ordering::Relaxed); + state.broadcast("debug.settings", serde_json::to_value(&input)?); + Ok(Json(input)) +} + +#[derive(Debug, Deserialize)] +struct CreateAccessTokenRequest { + name: Option, +} + +async fn list_access_tokens(State(state): State) -> Result>, AppError> { + Ok(Json(state.db.list_api_tokens()?)) +} + +async fn create_access_token( + State(state): State, + Json(input): Json, +) -> Result<(StatusCode, Json), AppError> { + let name = input.name.unwrap_or_else(|| "Home Assistant".into()).trim().to_string(); + if name.is_empty() || name.len() > 80 { + return Err(AppError::BadRequest("token name must contain 1 to 80 characters".into())); + } + + let secret = generate_access_token(); + let item = ApiTokenInfo { + id: Uuid::new_v4().to_string(), + name, + token_prefix: format!("{}...", secret.chars().take(24).collect::()), + created_at: Utc::now(), + }; + state.db.save_api_token(&item, &hash_token(&secret))?; + state.log( + "info", + "access_token.created", + "Created a Home Assistant access token", + json!({"token_id": item.id.clone(), "name": item.name.clone()}), + ); + Ok((StatusCode::CREATED, Json(json!({"token": secret, "item": item})))) +} + +async fn delete_access_token(State(state): State, Path(id): Path) -> Result { + if !state.db.delete_api_token(&id)? { + return Err(AppError::NotFound(format!("access token {id}"))); + } + state.log( + "info", + "access_token.revoked", + "Revoked a Home Assistant access token", + json!({"token_id": id}), + ); + Ok(StatusCode::NO_CONTENT) +} + diff --git a/src/api/devices.rs b/src/api/devices.rs new file mode 100644 index 0000000..9b8630c --- /dev/null +++ b/src/api/devices.rs @@ -0,0 +1,199 @@ +async fn discover(State(state): State, Json(request): Json) -> Result, AppError> { + let settings = state.settings.read().await.clone(); + let timeout_ms = request.timeout_ms.unwrap_or(settings.discovery_timeout_ms).clamp(500, 30_000); + let broadcast = request.broadcast.unwrap_or(settings.discovery_broadcast); + let protocol_version = request.protocol_version.unwrap_or(0).min(2); + let passes = request.passes.unwrap_or(3).clamp(1, 10); + let discovered = state.gree.discover(&broadcast, Duration::from_millis(timeout_ms), protocol_version, passes).await + .map_err(|e| AppError::Device(e.to_string()))?; + let mut saved = Vec::new(); + let mut new_device_ids = Vec::new(); + for item in discovered { + let existing = state.db.get_device_by_mac(&item.mac)?; + let is_new = existing.is_none(); + let mut merged = merge_discovered(existing, item); + let _device_guard = state.lock_device_operation(&merged.id).await; + // A poll/command may have updated the same known device between discovery and + // acquiring its operation lock. Re-merge against the freshest persisted state. + if !is_new { + if let Some(current) = state.db.get_device(&merged.id)? { + merged = merge_discovered(Some(current), merged); + } + } + // Bind right after discovery. GREE modules can have a short bind window; + // bind() also refreshes it with a direct scan before the handshake. + if !merged.simulated && merged.key.as_deref().unwrap_or_default().is_empty() { + match state.gree.bind(&merged).await { + Ok(bound) => { + merged.key = Some(bound.key); + merged.protocol_version = bound.protocol_version; + merged.communication_failures = 0; + merged.last_error = None; + } + Err(err) => { + merged.last_error = Some(format!("discovered, bind pending: {err}")); + state.log("warn", "device.bind_after_discovery", &format!("{}: {err}", merged.name), json!({"device_id": merged.id})); + } + } + } + state.db.save_device(&merged)?; + if is_new { new_device_ids.push(merged.id.clone()); } + saved.push(merged); + } + state.log("info", "discovery.complete", &format!("Discovery found {} device(s)", saved.len()), json!({"count": saved.len(), "protocol_version": protocol_version, "passes": passes, "new_devices": new_device_ids.len()})); + state.broadcast("devices.discovered", json!({"devices": saved})); + Ok(Json(json!({"count": saved.len(), "devices": saved, "new_device_ids": new_device_ids}))) +} + +async fn list_devices(State(state): State) -> Result>, AppError> { + Ok(Json(state.db.list_devices()?)) +} + +async fn add_device(State(state): State, Json(input): Json) -> Result<(StatusCode, Json), AppError> { + if input.name.trim().is_empty() || input.mac.trim().is_empty() || input.ip.trim().is_empty() { + return Err(AppError::BadRequest("name, mac and ip are required".into())); + } + input.ip.parse::().map_err(|_| AppError::BadRequest("invalid IP address".into()))?; + if state.db.get_device_by_mac(&input.mac)?.is_some() { + return Err(AppError::BadRequest("a device with this MAC already exists".into())); + } + let now = Utc::now(); + let normalized_mac = input.mac.replace([':', '-'], "").to_ascii_uppercase(); + let device = Device { + id: format!("gree-{}", normalized_mac.to_ascii_lowercase()), + mac: normalized_mac, + name: input.name.trim().to_string(), + ip: input.ip, + port: input.port, + protocol_version: input.protocol_version.min(2), + model: String::new(), + firmware: String::new(), + key: input.key.filter(|v| !v.trim().is_empty()), + cid: Some("app".into()), + enabled: true, + simulated: input.simulated, + power: false, + mode: "cool".into(), + target_temperature: 24.0, + fan_speed: 0, + swing_vertical: false, + swing_horizontal: false, + quiet: false, + turbo: false, + light: true, + air: false, + xfan: false, + health: false, + sleep: false, + supports_light: None, + supports_quiet: None, + supports_turbo: None, + supports_air: None, + supports_xfan: None, + supports_health: None, + supports_sleep: None, + current_temperature: if input.simulated { Some(25.0) } else { None }, + outdoor_temperature: None, + temperature_sensor_offset: None, + online: input.simulated, + response_time_ms: if input.simulated { Some(0) } else { None }, + last_seen: if input.simulated { Some(now) } else { None }, + last_error: None, + communication_failures: 0, + created_at: now, + updated_at: now, + }; + state.db.save_device(&device)?; + state.log("info", "device.created", &format!("Added {}", device.name), json!({"device_id": device.id})); + state.broadcast("device.created", serde_json::to_value(&device)?); + Ok((StatusCode::CREATED, Json(device))) +} + +async fn get_device(State(state): State, Path(id): Path) -> Result, AppError> { + state.db.get_device(&id)?.map(Json).ok_or_else(|| AppError::NotFound(format!("device {id}"))) +} + +async fn patch_device(State(state): State, Path(id): Path, Json(patch): Json) -> Result, AppError> { + if patch.enabled == Some(false) { + engine::disable_device_safely(&state, &id).await?; + } + let _device_guard = state.lock_device_operation(&id).await; + let mut device = state.db.get_device(&id)?.ok_or_else(|| AppError::NotFound(format!("device {id}")))?; + if let Some(v) = patch.name { if !v.trim().is_empty() { device.name = v.trim().to_string(); } } + if let Some(v) = patch.ip { v.parse::().map_err(|_| AppError::BadRequest("invalid IP address".into()))?; device.ip = v; } + if let Some(v) = patch.port { device.port = v; } + if let Some(v) = patch.protocol_version { + let v = v.min(2); + if device.protocol_version != v { + device.protocol_version = v; + device.key = None; + device.supports_light = None; + device.supports_quiet = None; + device.supports_turbo = None; + device.supports_air = None; + device.supports_xfan = None; + device.supports_health = None; + device.supports_sleep = None; + } + } + if let Some(v) = patch.key { device.key = v.filter(|x| !x.trim().is_empty()); } + if let Some(v) = patch.enabled { device.enabled = v; } + device.updated_at = Utc::now(); + state.db.save_device(&device)?; + state.broadcast("device.updated", serde_json::to_value(&device)?); + Ok(Json(device)) +} + +async fn delete_device(State(state): State, Path(id): Path) -> Result { + if state.db.get_device(&id)?.is_none() { return Err(AppError::NotFound(format!("device {id}"))); } + if state.db.list_automations()?.iter().any(|item| { + item.trigger_device_id.as_deref() == Some(id.as_str()) + || (item.action_group_id.is_none() && item.action_device_id == id) + }) { + return Err(AppError::BadRequest("device is used by an automation; remove or retarget that automation first".into())); + } + let removed_zone_ids: std::collections::HashSet = state.db.list_zones()?.into_iter() + .filter(|zone| zone.device_id == id) + .map(|zone| zone.id) + .collect(); + ensure_zone_removal_safe(&state, &removed_zone_ids)?; + ensure_device_stopped_for_detach(&state, &id, "device.deleted").await?; + if !state.db.delete_device(&id)? { return Err(AppError::NotFound(format!("device {id}"))); } + remove_zone_ids_from_groups(&state, &removed_zone_ids)?; + state.log("info", "device.deleted", "Device deleted", json!({"device_id": id})); + state.broadcast("device.deleted", json!({"id": id})); + Ok(StatusCode::NO_CONTENT) +} + +async fn bind_device(State(state): State, Path(id): Path) -> Result, AppError> { + let _device_guard = state.lock_device_operation(&id).await; + let mut device = state.db.get_device(&id)?.ok_or_else(|| AppError::NotFound(format!("device {id}")))?; + if device.simulated { return Ok(Json(device)); } + let bound = state.gree.bind(&device).await.map_err(|e| AppError::Device(e.to_string()))?; + device.key = Some(bound.key); + device.protocol_version = bound.protocol_version; + device.communication_failures = 0; + device.online = true; + device.last_seen = Some(Utc::now()); + device.last_error = None; + device.updated_at = Utc::now(); + state.db.save_device(&device)?; + state.log("info", "device.bound", &format!("Bound {}", device.name), json!({"device_id": id})); + Ok(Json(device)) +} + +async fn poll_device(State(state): State, Path(id): Path) -> Result, AppError> { + Ok(Json(engine::poll_one(&state, &id).await?)) +} + +async fn command_device(State(state): State, Path(id): Path, Json(command): Json) -> Result, AppError> { + Ok(Json(engine::send_manual_command(&state, &id, command, "device.manual_control").await?)) +} + +async fn command_home_assistant_device(State(state): State, Path(id): Path, Json(command): Json) -> Result, AppError> { + if state.db.list_zones()?.iter().any(|zone| zone.device_id == id && !zone.enabled) { + return Err(AppError::BadRequest("device belongs to a disabled thermostat zone; use technical device control for manual operation".into())); + } + Ok(Json(engine::send_manual_command(&state, &id, command, "home_assistant.device_manual_control").await?)) +} + diff --git a/src/api/events.rs b/src/api/events.rs new file mode 100644 index 0000000..5eadafd --- /dev/null +++ b/src/api/events.rs @@ -0,0 +1,27 @@ +#[derive(Debug, Deserialize)] +struct EventsQuery { limit: Option } +async fn events(State(state): State, Query(query): Query) -> Result, AppError> { + Ok(Json(json!({"events": state.db.list_events(query.limit.unwrap_or(100))?}))) +} + +#[derive(Debug, Deserialize)] +struct EventRetentionInput { days: u32 } + +async fn get_event_retention(State(state): State) -> Json { + let days = state.settings.read().await.event_log_retention_days; + Json(json!({"days": days})) +} + +async fn update_event_retention(State(state): State, Json(input): Json) -> Result, AppError> { + let mut settings = state.settings.write().await; + settings.event_log_retention_days = input.days.clamp(1, 3650); + state.db.save_runtime_settings(&settings)?; + let days = settings.event_log_retention_days; + drop(settings); + let removed = state.db.prune_events(days as i64)?; + state.log("info", "events.retention_updated", "Event log retention updated", json!({"days": days, "removed": removed})); + let public = { let settings = state.settings.read().await; public_settings(&*settings) }; + state.broadcast("settings.updated", public); + Ok(Json(json!({"days": days, "removed": removed}))) +} + diff --git a/src/api/groups.rs b/src/api/groups.rs new file mode 100644 index 0000000..3988c50 --- /dev/null +++ b/src/api/groups.rs @@ -0,0 +1,249 @@ +#[derive(Debug, Deserialize)] +struct GroupInput { + name: String, + #[serde(default)] + zone_ids: Vec, + #[serde(default)] + power_enabled: Option, +} + +fn normalize_group_zone_ids(zone_ids: Vec) -> Vec { + let mut values: Vec = zone_ids.into_iter() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .collect(); + values.sort(); + values.dedup(); + values +} + +fn validate_group_input(state: &AppState, input: &GroupInput) -> Result, AppError> { + if input.name.trim().is_empty() { + return Err(AppError::BadRequest("group name is required".into())); + } + let zone_ids = normalize_group_zone_ids(input.zone_ids.clone()); + if zone_ids.is_empty() { + return Err(AppError::BadRequest("group must contain at least one zone".into())); + } + for zone_id in &zone_ids { + if state.db.get_zone(zone_id)?.is_none() { + return Err(AppError::BadRequest(format!("group references missing zone {zone_id}"))); + } + } + Ok(zone_ids) +} + +async fn list_groups(State(state): State) -> Result>, AppError> { + Ok(Json(state.db.list_groups()?)) +} + +async fn get_group(State(state): State, Path(id): Path) -> Result, AppError> { + state.db.get_group(&id)?.map(Json).ok_or_else(|| AppError::NotFound(format!("group {id}"))) +} + +async fn create_group(State(state): State, Json(input): Json) -> Result<(StatusCode, Json), AppError> { + let zone_ids = validate_group_input(&state, &input)?; + let now = Utc::now(); + let group = ClimateGroup { + id: Uuid::new_v4().to_string(), + name: input.name.trim().to_string(), + zone_ids, + power_enabled: input.power_enabled.unwrap_or(true), + created_at: now, + updated_at: now, + }; + state.db.save_group(&group)?; + state.broadcast("group.created", serde_json::to_value(&group)?); + state.wake_zone_control(); + Ok((StatusCode::CREATED, Json(group))) +} + +async fn update_group(State(state): State, Path(id): Path, Json(input): Json) -> Result, AppError> { + let existing = state.db.get_group(&id)?.ok_or_else(|| AppError::NotFound(format!("group {id}")))?; + let zone_ids = validate_group_input(&state, &input)?; + let group = ClimateGroup { + id, + name: input.name.trim().to_string(), + zone_ids, + power_enabled: input.power_enabled.unwrap_or(existing.power_enabled), + created_at: existing.created_at, + updated_at: Utc::now(), + }; + state.db.save_group(&group)?; + state.broadcast("group.updated", serde_json::to_value(&group)?); + state.wake_zone_control(); + Ok(Json(group)) +} + +async fn delete_group(State(state): State, Path(id): Path) -> Result { + if state.db.list_automations()?.iter().any(|item| item.action_group_id.as_deref() == Some(id.as_str())) { + return Err(AppError::BadRequest("group is used by an automation; remove or retarget that automation first".into())); + } + if !state.db.delete_group(&id)? { return Err(AppError::NotFound(format!("group {id}"))); } + state.broadcast("group.deleted", json!({"id": id})); + state.wake_zone_control(); + Ok(StatusCode::NO_CONTENT) +} + +fn ensure_zone_removal_safe(state: &AppState, zone_ids: &std::collections::HashSet) -> Result<(), AppError> { + if zone_ids.is_empty() { return Ok(()); } + let automated_groups: std::collections::HashSet = state.db.list_automations()?.into_iter() + .filter_map(|item| item.action_group_id) + .collect(); + for group in state.db.list_groups()? { + let remaining = group.zone_ids.iter().filter(|zone_id| !zone_ids.contains(*zone_id)).count(); + if remaining == 0 && group.zone_ids.iter().any(|zone_id| zone_ids.contains(zone_id)) && automated_groups.contains(&group.id) { + return Err(AppError::BadRequest(format!("cannot remove the last zone from group '{}' while an automation targets that group", group.name))); + } + } + Ok(()) +} + +fn remove_zone_ids_from_groups(state: &AppState, zone_ids: &std::collections::HashSet) -> Result<(), AppError> { + if zone_ids.is_empty() { return Ok(()); } + for mut group in state.db.list_groups()? { + let before = group.zone_ids.len(); + group.zone_ids.retain(|zone_id| !zone_ids.contains(zone_id)); + if group.zone_ids.len() == before { continue; } + if group.zone_ids.is_empty() { + state.db.delete_group(&group.id)?; + state.broadcast("group.deleted", json!({"id": group.id})); + continue; + } + group.updated_at = Utc::now(); + state.db.save_group(&group)?; + state.broadcast("group.updated", serde_json::to_value(&group)?); + } + Ok(()) +} + +async fn update_group_control(State(state): State, Path(id): Path, Json(patch): Json) -> Result, AppError> { + Ok(Json(engine::control_group(&state, &id, patch, "group.quick_control").await?)) +} + +fn home_assistant_group_mode(zones: &[&Zone]) -> String { + let mut value: Option<&str> = None; + for zone in zones { + let current = if zone.inherit_house_mode { "house" } else { zone.mode.as_str() }; + if !matches!(current, "house" | "cool" | "heat") { + return "mixed".into(); + } + if let Some(previous) = value { + if previous != current { return "mixed".into(); } + } else { + value = Some(current); + } + } + value.unwrap_or("mixed").to_string() +} + +fn home_assistant_group_preset(zones: &[&Zone]) -> String { + let mut value: Option<&str> = None; + for zone in zones { + let current = zone.manual_preset.as_deref().unwrap_or("auto"); + if !matches!(current, "auto" | "comfort" | "sleep" | "away") { + return "mixed".into(); + } + if let Some(previous) = value { + if previous != current { return "mixed".into(); } + } else { + value = Some(current); + } + } + value.unwrap_or("mixed").to_string() +} + +async fn list_home_assistant_groups(State(state): State) -> Result>, AppError> { + let groups = state.db.list_groups()?; + let zones = state.db.list_zones()?; + let devices = state.db.list_devices()?; + let plan = engine::build_control_plan(&state).await?; + let settings = state.settings.read().await.clone(); + let mut output = Vec::with_capacity(groups.len()); + + for group in groups { + let members = zones.iter() + .filter(|zone| group.zone_ids.iter().any(|zone_id| zone_id == &zone.id)) + .collect::>(); + let planned_members = plan.zones.iter() + .filter(|zone| group.zone_ids.iter().any(|zone_id| zone_id == &zone.zone_id)) + .collect::>(); + + let zone_names = members.iter().map(|zone| zone.name.clone()).collect::>(); + let member_device_ids = members.iter().map(|zone| zone.device_id.as_str()).collect::>(); + let online_devices = devices.iter() + .filter(|device| member_device_ids.contains(device.id.as_str()) && device.online) + .count(); + let current_temperatures = planned_members.iter() + .filter_map(|zone| zone.current_temperature) + .collect::>(); + let current_temperature = if current_temperatures.is_empty() { + None + } else { + Some(current_temperatures.iter().sum::() / current_temperatures.len() as f64) + }; + let mut next_events = Vec::new(); + for zone in &planned_members { + for event in &zone.next_events { + let mut event = event.clone(); + event.label = format!("{}: {}", zone.zone_name, event.label); + next_events.push(event); + } + } + next_events.sort_by_key(|event| event.at); + next_events.truncate(8); + let member_states = planned_members.iter().map(|zone| json!({ + "zone_id": zone.zone_id, + "zone_name": zone.zone_name, + "device_id": zone.device_id, + "device_name": zone.device_name, + "enabled": zone.enabled, + "effective_enabled": zone.effective_enabled, + "mode": zone.mode, + "configured_mode": zone.configured_mode, + "inherit_house_mode": zone.inherit_house_mode, + "preset": zone.preset, + "current_temperature": zone.current_temperature, + "target_temperature": zone.target_temperature, + "demand": zone.demand, + "control_source": zone.control_source, + "current_schedule": zone.current_schedule_name, + "local_thermostat_power": zone.local_thermostat_power, + "local_thermostat_resume_at": zone.local_thermostat_resume_at, + "device_manual_override": zone.device_manual_override, + "device_manual_override_until": zone.device_manual_override_until, + })).collect::>(); + + output.push(json!({ + "id": group.id, + "name": group.name, + "zone_ids": group.zone_ids, + "zone_names": zone_names, + "power_enabled": group.power_enabled, + "effective_power": settings.house_power_enabled && group.power_enabled, + "mode": home_assistant_group_mode(&members), + "preset": home_assistant_group_preset(&members), + "house_mode": settings.house_mode, + "zone_count": members.len(), + "enabled_zones": members.iter().filter(|zone| zone.enabled).count(), + "active_zones": planned_members.iter().filter(|zone| zone.effective_enabled).count(), + "demanding_zones": planned_members.iter().filter(|zone| zone.demand).count(), + "device_count": member_device_ids.len(), + "online_devices": online_devices, + "current_temperature": current_temperature, + "members": member_states, + "next_events": next_events, + })); + } + + Ok(Json(output)) +} + +async fn update_home_assistant_group_control( + State(state): State, + Path(id): Path, + Json(patch): Json, +) -> Result, AppError> { + Ok(Json(engine::control_group(&state, &id, patch, "home_assistant.group_control").await?)) +} + diff --git a/src/api/history.rs b/src/api/history.rs new file mode 100644 index 0000000..2d63a82 --- /dev/null +++ b/src/api/history.rs @@ -0,0 +1,297 @@ +#[derive(Debug, Deserialize)] +struct ReadingsQuery { device_id: Option, hours: Option, limit: Option } +async fn readings(State(state): State, Query(query): Query) -> Result, AppError> { + let hours = query.hours.unwrap_or(24).clamp(1, 24 * 3650); + let values = state.db.list_readings(query.device_id.as_deref(), Utc::now() - ChronoDuration::hours(hours), query.limit.unwrap_or(1500))?; + Ok(Json(json!({"readings": values}))) +} + +#[derive(Debug, Deserialize)] +struct HistoryQuery { + scope: Option, + zone_id: Option, + device_id: Option, + entity_id: Option, + hours: Option, + limit: Option, +} + +fn history_bucket_seconds(hours: i64) -> i64 { + match hours { + 1..=6 => 30, + 7..=24 => 120, + 25..=168 => 600, + 169..=720 => 1800, + 721..=2160 => 7200, + 2161..=8760 => 21600, + _ => 86400, + } +} + +fn fallback_zone_rows(zone: &Zone, device: &Device, readings: Vec) -> Vec { + readings.into_iter().map(|reading| ZoneReading { + id: reading.id, + zone_id: zone.id.clone(), + device_id: zone.device_id.clone(), + timestamp: reading.timestamp, + gree_temperature: reading.indoor_temperature, + external_temperature: None, + control_temperature: reading.indoor_temperature, + target_temperature: Some(reading.target_temperature), + device_setpoint: Some(reading.target_temperature), + outdoor_temperature: reading.outdoor_temperature, + power: reading.power, + mode: device.mode.clone(), + fan_speed: device.fan_speed, + demand: false, + control_source: "gree_history_fallback".into(), + active_preset: "history".into(), + }).collect() +} + +fn zone_history_with_fallback( + state: &AppState, + zone_id: Option<&str>, + since: chrono::DateTime, + bucket_seconds: i64, + limit: u32, +) -> Result, AppError> { + let mut values = state.db.list_zone_history(zone_id, since.clone(), bucket_seconds, limit)?; + if let Some(zone_id) = zone_id { + if values.is_empty() { + let zone = state.db.get_zone(zone_id)?.ok_or_else(|| AppError::NotFound(format!("zone {zone_id}")))?; + if let Some(device) = state.db.get_device(&zone.device_id)? { + let rows = state.db.list_device_history(Some(&zone.device_id), since.clone(), bucket_seconds, limit)?; + values = fallback_zone_rows(&zone, &device, rows); + } + } + return Ok(values); + } + + let existing: std::collections::HashSet = values.iter().map(|row| row.zone_id.clone()).collect(); + for zone in state.db.list_zones()? { + if existing.contains(&zone.id) { continue; } + let Some(device) = state.db.get_device(&zone.device_id)? else { continue; }; + let rows = state.db.list_device_history(Some(&zone.device_id), since.clone(), bucket_seconds, limit)?; + values.extend(fallback_zone_rows(&zone, &device, rows)); + } + values.sort_by(|left, right| left.timestamp.cmp(&right.timestamp)); + if values.len() > limit as usize { + let keep_from = values.len() - limit as usize; + values.drain(0..keep_from); + } + Ok(values) +} + +fn sensor_history_with_fallback( + state: &AppState, + since: chrono::DateTime, + bucket_seconds: i64, + limit: u32, + outdoor_entity: &str, +) -> Result, AppError> { + let mut values = state.db.list_ha_history(None, since.clone(), bucket_seconds, limit)?; + let mut existing: std::collections::HashSet = values.iter().map(|row| row.entity_id.clone()).collect(); + for zone in state.db.list_zones()? { + let Some(entity_id) = zone.ha_entity_id.as_deref().filter(|value| !value.trim().is_empty()) else { continue; }; + if existing.contains(entity_id) { continue; } + let rows = state.db.list_zone_history(Some(&zone.id), since.clone(), bucket_seconds, limit)?; + let mut added = false; + for row in rows { + if let Some(temperature) = row.external_temperature { + values.push(HaReading { id: row.id, entity_id: entity_id.to_string(), zone_id: Some(zone.id.clone()), kind: "room".into(), timestamp: row.timestamp, temperature }); + added = true; + } + } + if added { existing.insert(entity_id.to_string()); } + } + let outdoor_entity = outdoor_entity.trim(); + if !outdoor_entity.is_empty() && !existing.contains(outdoor_entity) { + for zone in state.db.list_zones()? { + let rows = state.db.list_zone_history(Some(&zone.id), since.clone(), bucket_seconds, limit)?; + let mut added = false; + for row in rows { + if let Some(temperature) = row.outdoor_temperature { + values.push(HaReading { id: row.id, entity_id: outdoor_entity.to_string(), zone_id: None, kind: "outdoor".into(), timestamp: row.timestamp, temperature }); + added = true; + } + } + if added { break; } + } + } + values.sort_by(|left, right| left.timestamp.cmp(&right.timestamp)); + if values.len() > limit as usize { + let keep_from = values.len() - limit as usize; + values.drain(0..keep_from); + } + Ok(values) +} + +async fn combined_device_history( + state: &AppState, + device_id: Option<&str>, + since: chrono::DateTime, + bucket_seconds: i64, + limit: u32, +) -> Result<(Vec, String, Option), AppError> { + let influx = state.settings.read().await.influxdb.clone(); + let cutoff = Utc::now() - ChronoDuration::days(influx.history_threshold_days.max(1) as i64); + if !influx.enabled || since >= cutoff { + return Ok((state.db.list_device_history(device_id, since, bucket_seconds, limit)?, "sqlite".into(), None)); + } + let mut warning = None; + let mut values = match influxdb::query_devices(&state.http, &influx, device_id, since, cutoff, bucket_seconds, limit).await { + Ok(rows) => rows, + Err(err) => { + warning = Some(err.to_string()); + state.log("warn", "influx.query_error", "InfluxDB device history query failed", json!({"error": err.to_string()})); + state.db.list_device_history(device_id, since, bucket_seconds, limit)? + } + }; + if warning.is_none() { + values.extend(state.db.list_device_history(device_id, cutoff, bucket_seconds, limit)?); + } + values.sort_by_key(|row| row.timestamp); + trim_history(&mut values, limit); + let source = if warning.is_some() { "sqlite_fallback" } else { "influx+sqlite" }; + Ok((values, source.into(), warning)) +} + +async fn combined_zone_history( + state: &AppState, + zone_id: Option<&str>, + since: chrono::DateTime, + bucket_seconds: i64, + limit: u32, +) -> Result<(Vec, String, Option), AppError> { + let influx = state.settings.read().await.influxdb.clone(); + let cutoff = Utc::now() - ChronoDuration::days(influx.history_threshold_days.max(1) as i64); + if !influx.enabled || since >= cutoff { + return Ok((zone_history_with_fallback(state, zone_id, since, bucket_seconds, limit)?, "sqlite".into(), None)); + } + let mut warning = None; + let mut values = match influxdb::query_zones(&state.http, &influx, zone_id, since, cutoff, bucket_seconds, limit).await { + Ok(rows) => rows, + Err(err) => { + warning = Some(err.to_string()); + state.log("warn", "influx.query_error", "InfluxDB zone history query failed", json!({"error": err.to_string()})); + zone_history_with_fallback(state, zone_id, since, bucket_seconds, limit)? + } + }; + if warning.is_none() { + values.extend(zone_history_with_fallback(state, zone_id, cutoff, bucket_seconds, limit)?); + } + values.sort_by_key(|row| row.timestamp); + trim_history(&mut values, limit); + let source = if warning.is_some() { "sqlite_fallback" } else { "influx+sqlite" }; + Ok((values, source.into(), warning)) +} + +async fn combined_sensor_history( + state: &AppState, + entity_id: Option<&str>, + since: chrono::DateTime, + bucket_seconds: i64, + limit: u32, + outdoor_entity: &str, +) -> Result<(Vec, String, Option), AppError> { + let influx = state.settings.read().await.influxdb.clone(); + let cutoff = Utc::now() - ChronoDuration::days(influx.history_threshold_days.max(1) as i64); + let local = |start| -> Result, AppError> { + if entity_id.is_some() { Ok(state.db.list_ha_history(entity_id, start, bucket_seconds, limit)?) } + else { sensor_history_with_fallback(state, start, bucket_seconds, limit, outdoor_entity) } + }; + if !influx.enabled || since >= cutoff { + return Ok((local(since)?, "sqlite".into(), None)); + } + let mut warning = None; + let mut values = match influxdb::query_ha(&state.http, &influx, entity_id, since, cutoff, bucket_seconds, limit).await { + Ok(rows) => rows, + Err(err) => { + warning = Some(err.to_string()); + state.log("warn", "influx.query_error", "InfluxDB HA history query failed", json!({"error": err.to_string()})); + local(since)? + } + }; + if warning.is_none() { + values.extend(local(cutoff)?); + } + values.sort_by_key(|row| row.timestamp); + trim_history(&mut values, limit); + let source = if warning.is_some() { "sqlite_fallback" } else { "influx+sqlite" }; + Ok((values, source.into(), warning)) +} + +fn trim_history(values: &mut Vec, limit: u32) { + if values.len() > limit as usize { + let keep_from = values.len() - limit as usize; + values.drain(0..keep_from); + } +} + +async fn history(State(state): State, Query(query): Query) -> Result, AppError> { + let hours = query.hours.unwrap_or(24).clamp(1, 24 * 3650); + let since = Utc::now() - ChronoDuration::hours(hours); + let bucket_seconds = history_bucket_seconds(hours); + let limit = query.limit.unwrap_or(12_000).clamp(1, 20_000); + let scope = query.scope.as_deref().unwrap_or("zones"); + let outdoor_entity = state.settings.read().await.home_assistant.outdoor_entity_id.clone(); + let (device_count, zone_count, ha_count) = state.db.history_counts()?; + + match scope { + "devices" => { + let device_id = query.device_id.as_deref().filter(|value| !value.is_empty() && *value != "all"); + let (readings, storage, warning) = combined_device_history(&state, device_id, since, bucket_seconds, limit).await?; + Ok(Json(json!({ + "scope": "devices", "readings": readings, "bucket_seconds": bucket_seconds, + "storage": storage, "storage_warning": warning, + "counts": {"devices": device_count, "zones": zone_count, "ha": ha_count} + }))) + } + "sensors" => { + let entity_id = query.entity_id.as_deref().filter(|value| !value.is_empty() && *value != "all"); + let (readings, storage, warning) = combined_sensor_history(&state, entity_id, since, bucket_seconds, limit, &outdoor_entity).await?; + Ok(Json(json!({ + "scope": "sensors", "readings": readings, "bucket_seconds": bucket_seconds, + "storage": storage, "storage_warning": warning, + "counts": {"devices": device_count, "zones": zone_count, "ha": ha_count} + }))) + } + "overview" => { + let (zones, zone_storage, zone_warning) = combined_zone_history(&state, None, since, bucket_seconds, limit).await?; + let (devices, device_storage, device_warning) = combined_device_history(&state, None, since, bucket_seconds, limit).await?; + let (sensors, sensor_storage, sensor_warning) = combined_sensor_history(&state, None, since, bucket_seconds, limit, &outdoor_entity).await?; + let storage_warning = [zone_warning, device_warning, sensor_warning] + .into_iter() + .flatten() + .collect::>(); + Ok(Json(json!({ + "scope": "overview", "bucket_seconds": bucket_seconds, + "zones": zones, "devices": devices, "sensors": sensors, + "storage": {"zones": zone_storage, "devices": device_storage, "sensors": sensor_storage}, + "storage_warning": storage_warning, + "counts": {"devices": device_count, "zones": zone_count, "ha": ha_count} + }))) + } + "zones" | "zone" => { + let zone_id = query.zone_id.as_deref().filter(|value| !value.is_empty() && *value != "all"); + if let Some(zone_id) = zone_id { + if state.db.get_zone(zone_id)?.is_none() { + return Err(AppError::NotFound(format!("zone {zone_id}"))); + } + } + let (readings, storage, warning) = combined_zone_history(&state, zone_id, since, bucket_seconds, limit).await?; + Ok(Json(json!({ + "scope": "zones", "readings": readings, "bucket_seconds": bucket_seconds, + "storage": storage, "storage_warning": warning, + "counts": {"devices": device_count, "zones": zone_count, "ha": ha_count} + }))) + } + _ => Err(AppError::BadRequest("history scope must be overview, zones, devices or sensors".into())), + } +} + +async fn control_plan(State(state): State) -> Result, AppError> { + Ok(Json(serde_json::to_value(engine::build_control_plan(&state).await?)?)) +} + diff --git a/src/api/house.rs b/src/api/house.rs new file mode 100644 index 0000000..534bca4 --- /dev/null +++ b/src/api/house.rs @@ -0,0 +1,274 @@ +#[derive(Debug, Deserialize)] +struct HouseControlPatch { mode: String } + +fn set_all_groups_power(state: &AppState, power: bool) -> Result<(), AppError> { + for mut group in state.db.list_groups()? { + if group.power_enabled == power { continue; } + group.power_enabled = power; + group.updated_at = Utc::now(); + state.db.save_group(&group)?; + state.broadcast("group.updated", serde_json::to_value(&group)?); + } + Ok(()) +} + +fn clear_all_local_thermostat_overrides(state: &AppState) -> Result { + let mut cleared = 0; + for mut zone in state.db.list_zones()? { + if zone.local_thermostat_power.is_none() && zone.local_thermostat_resume_at.is_none() && zone.temporary_quick_thermostat.is_none() { continue; } + let temporary_was_active = engine::temporary_quick_thermostat_is_active(&zone, Utc::now()); + let temporary_restore = zone.temporary_quick_thermostat.as_ref().and_then(|session| session.restore_zone_enabled); + zone.temporary_quick_thermostat = None; + engine::reset_local_thermostat_override(&mut zone); + if temporary_was_active { + if let Some(enabled) = temporary_restore { zone.enabled = enabled; } + } + zone.updated_at = Utc::now(); + state.db.save_zone(&zone)?; + state.broadcast("zone.updated", serde_json::to_value(&zone)?); + cleared += 1; + } + Ok(cleared) +} + +async fn command_all_enabled_devices_power(state: &AppState, power: bool, source: &str) -> Result, AppError> { + let mut failed = Vec::new(); + let enabled_zone_devices: std::collections::HashSet = if power { + state.db.list_zones()?.into_iter() + .filter(|zone| zone.enabled && !zone.device_manual_override && zone.local_thermostat_power != Some(false)) + .map(|zone| zone.device_id) + .collect() + } else { + std::collections::HashSet::new() + }; + for device in state.db.list_devices()? { + if !device.enabled { continue; } + // Whole-house ON only operates thermostat-managed, enabled zones. Devices with + // a disabled zone (or no zone at all) remain manual/technical Devices controls. + if power && !enabled_zone_devices.contains(&device.id) { continue; } + // Do not trust the pre-loop power snapshot for deciding whether to send. The engine + // reloads state under the per-device lock and turns an already-matching command into + // a no-op. This closes the polling/command race without extra UDP frames. + let result = if power { + engine::send_command(state, &device.id, DeviceCommand { power: Some(true), ..Default::default() }).await + } else { + engine::force_house_power_off_device(state, &device.id, source).await + }; + if let Err(err) = result { + state.log("error", "house.power_all_error", &err.to_string(), json!({ + "device_id": device.id, + "device_name": device.name, + "power": power, + "source": source, + })); + failed.push(json!({ + "device_id": device.id, + "device_name": device.name, + "error": err.to_string(), + })); + } + } + Ok(failed) +} + +async fn update_house_control(State(state): State, Json(input): Json) -> Result, AppError> { + if !matches!(input.mode.as_str(), "cool" | "heat" | "off") { + return Err(AppError::BadRequest("house mode must be cool, heat or off".into())); + } + let mode = input.mode; + let activate_all = mode != "off"; + let payload = { + let mut settings = state.settings.write().await; + settings.house_mode = mode.clone(); + // Choosing a real whole-house operating mode is an explicit request to run the + // house climate. It therefore clears a previous global power-off. "off" keeps + // its separate meaning: do not control, without changing master power. + if activate_all { settings.house_power_enabled = true; } + state.db.save_runtime_settings(&settings)?; + public_settings(&settings) + }; + state.broadcast("settings.updated", payload.clone()); + if activate_all { + set_all_groups_power(&state, true)?; + // Never send a bare power=true frame. Wake the thermostat arbiter so every unit + // starts only with a valid effective Heat/Cool mode and compressor lockout policy. + state.wake_zone_control(); + } + state.log("info", "house.mode", &format!("House mode set to {}", mode), json!({"mode": mode, "master_power_enabled": activate_all})); + Ok(Json(payload)) +} + +#[derive(Debug, Deserialize)] +struct HousePowerPatch { power: bool } + +async fn update_house_power(State(state): State, Json(input): Json) -> Result, AppError> { + // Whole-house power is independent from the thermostat mode. Publish/persist the master + // first so the regulator becomes passive before the one-shot OFF cascade starts. + { + let mut settings = state.settings.write().await; + if settings.house_power_enabled != input.power { + settings.house_power_enabled = input.power; + state.db.save_runtime_settings(&settings)?; + let payload = public_settings(&settings); + state.broadcast("settings.updated", payload); + } + } + + // Global power is a true cascade across group gates. OFF clears the current takeover + // markers once, then each enabled device is re-cleared atomically with its OFF command. + // A later pilot action is therefore not erased by subsequent controller cycles. + set_all_groups_power(&state, input.power)?; + if !input.power { + engine::clear_all_device_manual_overrides(&state, "house_power_off")?; + clear_all_local_thermostat_overrides(&state)?; + } + let failed = if input.power { + state.wake_zone_control(); + Vec::new() + } else { + command_all_enabled_devices_power(&state, false, "house_power").await? + }; + + let devices = state.db.list_devices()?; + let groups = state.db.list_groups()?; + let settings = state.settings.read().await; + let settings_payload = public_settings(&settings); + drop(settings); + state.log("info", "house.power_all", if input.power { "Whole-house automation enabled; thermostat arbiter resumed" } else { "Whole-house power disabled; all groups and enabled devices powered off" }, json!({ + "power": input.power, + "failed": failed.len(), + })); + Ok(Json(json!({ + "power": input.power, + "devices": devices, + "groups": groups, + "settings": settings_payload, + "failed": failed, + }))) +} + +#[derive(Debug, Deserialize)] +struct HousePresetPatch { preset: String } + +async fn update_house_preset(State(state): State, Json(input): Json) -> Result, AppError> { + if !matches!(input.preset.as_str(), "auto" | "comfort" | "sleep" | "away") { + return Err(AppError::BadRequest("house preset must be auto, comfort, sleep or away".into())); + } + + // A whole-house profile is also an explicit whole-house activation. This mirrors + // selecting cooling/heating and makes the separate master-power control intuitive. + let settings_payload = { + let mut settings = state.settings.write().await; + settings.house_power_enabled = true; + state.db.save_runtime_settings(&settings)?; + public_settings(&settings) + }; + state.broadcast("settings.updated", settings_payload.clone()); + set_all_groups_power(&state, true)?; + + let schedules = state.db.list_schedules()?; + let mut zones = state.db.list_zones()?; + for zone in &mut zones { + if engine::temporary_quick_thermostat_is_active(zone, Utc::now()) { + if let Some(session) = zone.temporary_quick_thermostat.as_mut() { + session.deferred_preset = Some(input.preset.clone()); + } + } else if input.preset == "auto" { + zone.manual_preset = None; + zone.manual_setpoint = None; + zone.manual_override_until = None; + } else { + zone.manual_preset = Some(input.preset.clone()); + zone.manual_setpoint = None; + zone.manual_override_until = engine::next_schedule_boundary_utc(&zone.id, &schedules, chrono::Local::now()); + } + zone.updated_at = Utc::now(); + state.db.save_zone(zone)?; + state.broadcast("zone.updated", serde_json::to_value(&*zone)?); + } + + // As with house mode/power ON, the central thermostat arbiter performs the physical + // start with a valid mode/target. This prevents unmanaged power-on while house mode=off. + let failed: Vec = Vec::new(); + state.wake_zone_control(); + let devices = state.db.list_devices()?; + state.log("info", "house.preset", &format!("House preset set to {}", input.preset), json!({ + "preset": input.preset, + "master_power_enabled": true, + "failed": failed.len(), + })); + Ok(Json(json!({ + "preset": input.preset, + "zones": zones, + "devices": devices, + "settings": settings_payload, + "failed": failed, + }))) +} + +#[derive(Debug, Deserialize)] +struct ScheduleTemplateRequest { template: String } + +async fn apply_schedule_template(State(state): State, Path(id): Path, Json(input): Json) -> Result, AppError> { + let zone = state.db.get_zone(&id)?.ok_or_else(|| AppError::NotFound(format!("zone {id}")))?; + let mut items: Vec = Vec::new(); + let mut add = |name: &str, days: Vec, start: &str, end: &str, preset: &str| { + items.push(Schedule { + id: Uuid::new_v4().to_string(), zone_id: id.clone(), name: name.into(), enabled: true, + weekdays: days, start_time: start.into(), end_time: end.into(), preset: preset.into(), + setpoint: zone.setpoint, created_at: Utc::now(), updated_at: Utc::now(), + }); + }; + let all = vec![1,2,3,4,5,6,7]; + match input.template.as_str() { + "family" => { + add("Comfort", all.clone(), "06:30", "22:30", "comfort"); + add("Sleep", all, "22:30", "06:30", "sleep"); + } + "child" => { + add("Comfort", all.clone(), "06:30", "20:30", "comfort"); + add("Sleep", all, "20:30", "06:30", "sleep"); + } + "bedroom" => { + add("Comfort", all.clone(), "06:30", "22:00", "comfort"); + add("Sleep", all, "22:00", "06:30", "sleep"); + } + "workday" => { + let weekdays = vec![1,2,3,4,5]; + let weekend = vec![6,7]; + add("Morning", weekdays.clone(), "06:30", "08:00", "comfort"); + add("Away", weekdays.clone(), "08:00", "16:00", "away"); + add("Evening", weekdays.clone(), "16:00", "22:30", "comfort"); + add("Sleep", weekdays, "22:30", "06:30", "sleep"); + add("Weekend", weekend, "08:00", "23:00", "comfort"); + // Saturday can sleep until the Sunday weekend block starts at 08:00. + add("Saturday sleep", vec![6], "23:00", "08:00", "sleep"); + // Sunday must hand over at 06:30 so it never overlaps Monday morning. + add("Sunday sleep", vec![7], "23:00", "06:30", "sleep"); + } + "always" => add("Comfort", all, "00:00", "00:00", "comfort"), + _ => return Err(AppError::BadRequest("unknown schedule template".into())), + } + validate_schedule_set(&items)?; + state.db.replace_schedules_for_zone(&id, &items)?; + refresh_zone_override_boundary(&state, &id)?; + state.broadcast("schedule.template_applied", json!({"zone_id": id, "template": input.template, "count": items.len()})); + Ok(Json(json!({"zone": zone, "schedules": items}))) +} + +async fn update_home_assistant_zone_control(State(state): State, Path(id): Path, Json(patch): Json) -> Result, AppError> { + Ok(Json(apply_zone_control_patch(&state, &id, patch, "home_assistant.zone_thermostat").await?)) +} + +async fn delete_zone(State(state): State, Path(id): Path) -> Result { + let zone = state.db.get_zone(&id)?.ok_or_else(|| AppError::NotFound(format!("zone {id}")))?; + let mut removed = std::collections::HashSet::new(); + removed.insert(id.clone()); + ensure_zone_removal_safe(&state, &removed)?; + ensure_device_stopped_for_detach(&state, &zone.device_id, "zone.deleted").await?; + if !state.db.delete_zone(&id)? { return Err(AppError::NotFound(format!("zone {id}"))); } + remove_zone_ids_from_groups(&state, &removed)?; + state.broadcast("zone.deleted", json!({"id": id})); + Ok(StatusCode::NO_CONTENT) +} + diff --git a/src/api/integrations.rs b/src/api/integrations.rs new file mode 100644 index 0000000..62c5cb4 --- /dev/null +++ b/src/api/integrations.rs @@ -0,0 +1,20 @@ +#[derive(Debug, Deserialize)] +struct HaTestRequest { entity_id: Option } +async fn test_home_assistant(State(state): State, Json(input): Json) -> Result, AppError> { + let settings = state.settings.read().await.clone(); + let resolved_entity_id = home_assistant::resolve_entity_id(&settings.home_assistant, input.entity_id.as_deref()); + let temperature = home_assistant::read_temperature(&state.http, &settings.home_assistant, resolved_entity_id.as_deref(), Some(settings.home_assistant.sensor_stale_after_seconds)) + .await.map_err(|e| AppError::Device(e.to_string()))?; + Ok(Json(json!({"ok": true, "temperature_c": temperature, "entity_id": resolved_entity_id}))) +} + +async fn test_notifications(State(state): State, Json(mut input): Json) -> Result, AppError> { + let old = state.settings.read().await.notifications.clone(); + if input.pushover_app_token.trim().is_empty() { input.pushover_app_token = old.pushover_app_token; } + if input.pushover_user_key.trim().is_empty() { input.pushover_user_key = old.pushover_user_key; } + if input.slack_webhook_url.trim().is_empty() { input.slack_webhook_url = old.slack_webhook_url; } + if input.discord_webhook_url.trim().is_empty() { input.discord_webhook_url = old.discord_webhook_url; } + notifications::test(&state, input).await.map_err(AppError::Device)?; + Ok(Json(json!({"ok": true}))) +} + diff --git a/src/api/middleware.rs b/src/api/middleware.rs new file mode 100644 index 0000000..0d73c23 --- /dev/null +++ b/src/api/middleware.rs @@ -0,0 +1,13 @@ +async fn security_headers(request: Request, next: Next) -> Response { + let is_api = request.uri().path().contains("/api/"); + let mut response = next.run(request).await; + let headers = response.headers_mut(); + headers.insert(header::HeaderName::from_static("x-content-type-options"), HeaderValue::from_static("nosniff")); + headers.insert(header::HeaderName::from_static("x-frame-options"), HeaderValue::from_static("SAMEORIGIN")); + headers.insert(header::HeaderName::from_static("referrer-policy"), HeaderValue::from_static("same-origin")); + headers.insert(header::HeaderName::from_static("content-security-policy"), HeaderValue::from_static("default-src 'self'; connect-src 'self' ws: wss:; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'self'; object-src 'none'")); + headers.insert(header::HeaderName::from_static("permissions-policy"), HeaderValue::from_static("camera=(), microphone=(), geolocation=()")); + if is_api { headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store")); } + response +} + diff --git a/src/api/public_settings.rs b/src/api/public_settings.rs new file mode 100644 index 0000000..b6ae275 --- /dev/null +++ b/src/api/public_settings.rs @@ -0,0 +1,61 @@ +fn public_settings(settings: &RuntimeSettings) -> Value { + json!({ + "controller_id": settings.controller_id, + "simulator_enabled": settings.simulator_enabled, + "poll_interval_seconds": settings.poll_interval_seconds, + "zone_interval_seconds": settings.zone_interval_seconds, + "discovery_timeout_ms": settings.discovery_timeout_ms, + "discovery_broadcast": settings.discovery_broadcast, + "house_mode": settings.house_mode, + "house_power_enabled": settings.house_power_enabled, + "control_strategy": settings.control_strategy, + "outdoor_assist_enabled": settings.outdoor_assist_enabled, + "history_retention_days": settings.history_retention_days, + "history_compaction_enabled": settings.history_compaction_enabled, + "event_log_retention_days": settings.event_log_retention_days, + "suppress_device_beep": settings.suppress_device_beep, + "debug": settings.debug, + "night_mode": settings.night_mode, + "notifications": { + "enabled": settings.notifications.enabled, + "mode": settings.notifications.mode, + "provider": settings.notifications.provider, + "pushover_app_token": "", + "pushover_user_key": "", + "pushover_configured": !settings.notifications.pushover_app_token.trim().is_empty() && !settings.notifications.pushover_user_key.trim().is_empty(), + "slack_webhook_url": "", + "slack_configured": !settings.notifications.slack_webhook_url.trim().is_empty(), + "discord_webhook_url": "", + "discord_configured": !settings.notifications.discord_webhook_url.trim().is_empty(), + "cooldown_seconds": settings.notifications.cooldown_seconds, + "communication_failure_threshold": settings.notifications.communication_failure_threshold, + "target_timeout_minutes": settings.notifications.target_timeout_minutes, + "alert_types": &settings.notifications.alert_types, + }, + "influxdb": { + "enabled": settings.influxdb.enabled, + "version": settings.influxdb.version, + "url": settings.influxdb.url, + "database": settings.influxdb.database, + "username": settings.influxdb.username, + "password": "", + "password_configured": !settings.influxdb.password.trim().is_empty(), + "org": settings.influxdb.org, + "bucket": settings.influxdb.bucket, + "token": "", + "token_configured": !settings.influxdb.token.trim().is_empty(), + "history_threshold_days": settings.influxdb.history_threshold_days, + }, + "home_assistant": { + "url": settings.home_assistant.url, + "token": "", + "token_configured": !settings.home_assistant.token.trim().is_empty(), + "default_entity_id": settings.home_assistant.default_entity_id, + "outdoor_entity_id": settings.home_assistant.outdoor_entity_id, + "sensor_stale_after_seconds": settings.home_assistant.sensor_stale_after_seconds, + "allow_invalid_tls": settings.home_assistant.allow_invalid_tls, + "sensor_aliases": settings.home_assistant.sensor_aliases, + } + }) +} + diff --git a/src/api/schedules.rs b/src/api/schedules.rs new file mode 100644 index 0000000..2207e8d --- /dev/null +++ b/src/api/schedules.rs @@ -0,0 +1,122 @@ +#[derive(Debug, Deserialize)] +struct ScheduleInput { + zone_id: String, + name: String, + #[serde(default = "yes")] + enabled: bool, + weekdays: Vec, + start_time: String, + end_time: String, + #[serde(default = "schedule_preset")] + preset: String, + setpoint: f64, +} +fn schedule_preset() -> String { "custom".into() } +impl ScheduleInput { + fn validate(&self) -> Result<(), AppError> { + if self.name.trim().is_empty() { return Err(AppError::BadRequest("schedule name is required".into())); } + if self.weekdays.is_empty() || self.weekdays.iter().any(|v| !(1..=7).contains(v)) { return Err(AppError::BadRequest("weekdays must contain numbers 1..7".into())); } + chrono::NaiveTime::parse_from_str(&self.start_time, "%H:%M").map_err(|_| AppError::BadRequest("invalid start time".into()))?; + chrono::NaiveTime::parse_from_str(&self.end_time, "%H:%M").map_err(|_| AppError::BadRequest("invalid end time".into()))?; + if !matches!(self.preset.as_str(), "comfort" | "sleep" | "away" | "custom") { return Err(AppError::BadRequest("unsupported schedule preset".into())); } + if self.preset == "custom" && !(8.0..=30.0).contains(&self.setpoint) { return Err(AppError::BadRequest("schedule setpoint must be between 8 and 30 C".into())); } + Ok(()) + } + fn into_schedule(self, id: String, created_at: chrono::DateTime) -> Schedule { + Schedule { id, zone_id: self.zone_id, name: self.name.trim().into(), enabled: self.enabled, + weekdays: self.weekdays, start_time: self.start_time, end_time: self.end_time, + preset: self.preset, setpoint: self.setpoint, created_at, updated_at: Utc::now() } + } +} +fn validate_schedule_set(items: &[Schedule]) -> Result<(), AppError> { + for (index, item) in items.iter().enumerate() { + for other in items.iter().skip(index + 1) { + if engine::schedules_overlap(item, other) { + return Err(AppError::BadRequest(format!("schedule '{}' overlaps with '{}' for the same zone", item.name, other.name))); + } + } + } + Ok(()) +} + +fn validate_schedule_conflicts(state: &AppState, item: &Schedule, exclude_id: Option<&str>) -> Result<(), AppError> { + for existing in state.db.list_schedules()? { + if exclude_id == Some(existing.id.as_str()) { continue; } + if engine::schedules_overlap(item, &existing) { + return Err(AppError::BadRequest(format!("schedule overlaps with '{}'", existing.name))); + } + } + Ok(()) +} + +fn refresh_zone_override_boundary(state: &AppState, zone_id: &str) -> Result<(), AppError> { + let Some(mut zone) = state.db.get_zone(zone_id)? else { return Ok(()); }; + let has_temporary_schedule_boundary = zone.temporary_quick_thermostat.as_ref() + .map(|session| session.finish_kind == "schedule_boundary") + .unwrap_or(false); + if zone.manual_preset.is_none() && zone.manual_setpoint.is_none() && !zone.device_manual_override && !has_temporary_schedule_boundary { return Ok(()); } + let schedules = state.db.list_schedules()?; + let boundary = engine::next_schedule_boundary_utc(&zone.id, &schedules, chrono::Local::now()); + // An active Temporary Quick Thermostat explicitly owns its target until its own finish + // rule. Editing/applying schedules must not arm the generic quick-setpoint boundary and + // accidentally clear that target at the next schedule transition. + let temporary_owns_zone = engine::temporary_quick_thermostat_is_active(&zone, Utc::now()); + if (zone.manual_preset.is_some() || zone.manual_setpoint.is_some()) && !temporary_owns_zone { + zone.manual_override_until = boundary; + } + if zone.device_manual_override { zone.device_manual_override_until = boundary; zone.control_resume_at = boundary; } + if has_temporary_schedule_boundary { + let reference = zone.temporary_quick_thermostat.as_ref() + .filter(|session| session.activated_at.is_none()) + .map(|session| session.started_at.with_timezone(&chrono::Local)) + .unwrap_or_else(chrono::Local::now); + let refreshed = engine::next_schedule_boundary_utc(&zone.id, &schedules, reference).or(Some(Utc::now())); + if let Some(session) = zone.temporary_quick_thermostat.as_mut() { + session.expires_at = refreshed; + } + } + zone.revision = zone.revision.saturating_add(1); + zone.updated_at = Utc::now(); + state.db.save_zone(&zone)?; + state.broadcast("zone.updated", serde_json::to_value(&zone)?); + Ok(()) +} + +async fn list_schedules(State(state): State) -> Result>, AppError> { Ok(Json(state.db.list_schedules()?)) } +async fn get_schedule(State(state): State, Path(id): Path) -> Result, AppError> { + state.db.get_schedule(&id)?.map(Json).ok_or_else(|| AppError::NotFound(format!("schedule {id}"))) +} +async fn create_schedule(State(state): State, Json(input): Json) -> Result<(StatusCode, Json), AppError> { + input.validate()?; + if state.db.get_zone(&input.zone_id)?.is_none() { return Err(AppError::BadRequest("schedule zone does not exist".into())); } + let item = input.into_schedule(Uuid::new_v4().to_string(), Utc::now()); + validate_schedule_conflicts(&state, &item, None)?; + state.db.save_schedule(&item)?; + refresh_zone_override_boundary(&state, &item.zone_id)?; + state.broadcast("schedule.created", serde_json::to_value(&item)?); + state.wake_zone_control(); + Ok((StatusCode::CREATED, Json(item))) +} +async fn update_schedule(State(state): State, Path(id): Path, Json(input): Json) -> Result, AppError> { + input.validate()?; + let existing = state.db.get_schedule(&id)?.ok_or_else(|| AppError::NotFound(format!("schedule {id}")))?; + if state.db.get_zone(&input.zone_id)?.is_none() { return Err(AppError::BadRequest("schedule zone does not exist".into())); } + let old_zone_id = existing.zone_id.clone(); + let item = input.into_schedule(id.clone(), existing.created_at); + validate_schedule_conflicts(&state, &item, Some(&id))?; + state.db.save_schedule(&item)?; + refresh_zone_override_boundary(&state, &old_zone_id)?; + if item.zone_id != old_zone_id { refresh_zone_override_boundary(&state, &item.zone_id)?; } + state.broadcast("schedule.updated", serde_json::to_value(&item)?); + state.wake_zone_control(); + Ok(Json(item)) +} +async fn delete_schedule(State(state): State, Path(id): Path) -> Result { + let existing = state.db.get_schedule(&id)?.ok_or_else(|| AppError::NotFound(format!("schedule {id}")))?; + if !state.db.delete_schedule(&id)? { return Err(AppError::NotFound(format!("schedule {id}"))); } + refresh_zone_override_boundary(&state, &existing.zone_id)?; + state.broadcast("schedule.deleted", json!({"id": id})); + state.wake_zone_control(); + Ok(StatusCode::NO_CONTENT) +} + diff --git a/src/api/settings.rs b/src/api/settings.rs new file mode 100644 index 0000000..0eddf9b --- /dev/null +++ b/src/api/settings.rs @@ -0,0 +1,371 @@ +async fn get_settings(State(state): State) -> Json { + let settings = state.settings.read().await; + Json(public_settings(&*settings)) +} + +async fn update_settings(State(state): State, Json(mut input): Json) -> Result, AppError> { + let old = state.settings.read().await.clone(); + if input.house_power_enabled != old.house_power_enabled || input.house_mode != old.house_mode { + return Err(AppError::BadRequest( + "house_power_enabled and house_mode must be changed through the House Control API".into(), + )); + } + input.poll_interval_seconds = input.poll_interval_seconds.clamp(2, 3600); + input.zone_interval_seconds = input.zone_interval_seconds.clamp(2, 3600); + input.discovery_timeout_ms = input.discovery_timeout_ms.clamp(300, 30_000); + if !matches!(input.house_mode.as_str(), "cool" | "heat" | "off") { return Err(AppError::BadRequest("house mode must be cool, heat or off".into())); } + if input.control_strategy != "setpoint" { input.control_strategy = "setpoint".into(); } + if !(input.discovery_broadcast.eq_ignore_ascii_case("auto") + || input.discovery_broadcast.to_ascii_lowercase().starts_with("auto:")) { + input.discovery_broadcast.parse::() + .map_err(|_| AppError::BadRequest("invalid discovery broadcast address".into()))?; + } + if input.controller_id.trim().is_empty() { input.controller_id = old.controller_id; } + if input.home_assistant.token.trim().is_empty() { input.home_assistant.token = old.home_assistant.token; } + input.home_assistant.sensor_stale_after_seconds = input.home_assistant.sensor_stale_after_seconds.clamp(30, 86_400); + if input.notifications.pushover_app_token.trim().is_empty() { input.notifications.pushover_app_token = old.notifications.pushover_app_token; } + if input.notifications.pushover_user_key.trim().is_empty() { input.notifications.pushover_user_key = old.notifications.pushover_user_key; } + if input.notifications.slack_webhook_url.trim().is_empty() { input.notifications.slack_webhook_url = old.notifications.slack_webhook_url; } + if input.notifications.discord_webhook_url.trim().is_empty() { input.notifications.discord_webhook_url = old.notifications.discord_webhook_url; } + input.notifications.cooldown_seconds = input.notifications.cooldown_seconds.clamp(30, 86_400); + input.notifications.communication_failure_threshold = input.notifications.communication_failure_threshold.clamp(2, 100); + input.notifications.target_timeout_minutes = input.notifications.target_timeout_minutes.clamp(5, 24 * 60); + if !matches!(input.notifications.mode.as_str(), "problems" | "important") { return Err(AppError::BadRequest("notification mode must be problems or important".into())); } + if !matches!(input.notifications.provider.as_str(), "pushover" | "slack" | "discord") { return Err(AppError::BadRequest("unsupported notification provider".into())); } + input.history_retention_days = input.history_retention_days.clamp(1, 3650); + input.event_log_retention_days = input.event_log_retention_days.clamp(1, 3650); + normalize_sensor_aliases(&mut input); + canonicalize_home_assistant_entities(&mut input); + validate_night_mode(&mut input)?; + input.influxdb.history_threshold_days = input.influxdb.history_threshold_days.clamp(1, 3650); + if input.influxdb.token.trim().is_empty() { input.influxdb.token = old.influxdb.token; } + if input.influxdb.password.trim().is_empty() { input.influxdb.password = old.influxdb.password; } + influxdb::validate(&input.influxdb).map_err(|err| AppError::BadRequest(err.to_string()))?; + if !input.home_assistant.url.trim().is_empty() { + let parsed = url::Url::parse(&input.home_assistant.url).map_err(|_| AppError::BadRequest("invalid Home Assistant URL".into()))?; + if !matches!(parsed.scheme(), "http" | "https") { return Err(AppError::BadRequest("Home Assistant URL must use http or https".into())); } + } + state.db.save_runtime_settings(&input)?; + canonicalize_saved_zone_entities(&state, &input)?; + state.debug_gree_frames.store(input.debug.gree_frames, Ordering::Relaxed); + *state.settings.write().await = input.clone(); + state.log("info", "settings.updated", "Settings updated", json!({})); + state.broadcast("settings.updated", public_settings(&input)); + Ok(Json(public_settings(&input))) +} + +fn normalize_sensor_aliases(settings: &mut RuntimeSettings) { + settings.home_assistant.sensor_aliases = settings.home_assistant.sensor_aliases + .iter() + .filter_map(|(entity, alias)| { + let entity = entity.trim(); + let alias = alias.trim(); + if entity.is_empty() || alias.is_empty() { return None; } + Some((entity.chars().take(160).collect::(), alias.chars().take(80).collect::())) + }) + .collect(); +} + +fn canonicalize_home_assistant_entities(settings: &mut RuntimeSettings) { + let default_entity = settings.home_assistant.default_entity_id.clone(); + if let Some(entity_id) = home_assistant::resolve_entity_id(&settings.home_assistant, Some(&default_entity)) { + settings.home_assistant.default_entity_id = entity_id; + } + let outdoor_entity = settings.home_assistant.outdoor_entity_id.clone(); + if !outdoor_entity.trim().is_empty() { + if let Some(entity_id) = home_assistant::resolve_entity_id(&settings.home_assistant, Some(&outdoor_entity)) { + settings.home_assistant.outdoor_entity_id = entity_id; + } + } +} + +fn canonicalize_zone_ha_entity(zone: &mut Zone, settings: &RuntimeSettings) { + let Some(configured) = zone.ha_entity_id.clone() else { return; }; + zone.ha_entity_id = home_assistant::resolve_entity_id(&settings.home_assistant, Some(&configured)); +} + +fn canonicalize_saved_zone_entities(state: &AppState, settings: &RuntimeSettings) -> Result<(), AppError> { + for mut zone in state.db.list_zones()? { + let previous = zone.ha_entity_id.clone(); + canonicalize_zone_ha_entity(&mut zone, settings); + if zone.ha_entity_id != previous { + zone.updated_at = Utc::now(); + state.db.save_zone(&zone)?; + state.broadcast("zone.updated", serde_json::to_value(&zone)?); + } + } + Ok(()) +} + +fn validate_night_mode(settings: &mut RuntimeSettings) -> Result<(), AppError> { + NaiveTime::parse_from_str(&settings.night_mode.start_time, "%H:%M") + .map_err(|_| AppError::BadRequest("night mode start time must use HH:MM".into()))?; + NaiveTime::parse_from_str(&settings.night_mode.end_time, "%H:%M") + .map_err(|_| AppError::BadRequest("night mode end time must use HH:MM".into()))?; + settings.night_mode.max_fan_speed = settings.night_mode.max_fan_speed.clamp(1, 5); + Ok(()) +} + +async fn export_settings(State(state): State) -> Result, AppError> { + let settings = state.settings.read().await.clone(); + let mut export = state.db.export_configuration(settings)?; + // Backups are configuration snapshots, not a way to resurrect transient ownership, + // timers or a stale physical device state after restore (K9). + sanitize_configuration_runtime(&mut export); + Ok(Json(export)) +} + +fn validate_configuration_export(export: &ConfigurationExport) -> Result<(), AppError> { + if export.format_version != 1 { return Err(AppError::BadRequest("unsupported configuration export version".into())); } + influxdb::validate(&export.settings.influxdb).map_err(|err| AppError::BadRequest(err.to_string()))?; + if !matches!(export.settings.house_mode.as_str(), "cool" | "heat" | "off") { + return Err(AppError::BadRequest("import contains an invalid house mode".into())); + } + + let devices: std::collections::HashSet<&str> = export.devices.iter().map(|item| item.id.as_str()).collect(); + let zones: std::collections::HashSet<&str> = export.zones.iter().map(|item| item.id.as_str()).collect(); + let schedules: std::collections::HashSet<&str> = export.schedules.iter().map(|item| item.id.as_str()).collect(); + let automations: std::collections::HashSet<&str> = export.automations.iter().map(|item| item.id.as_str()).collect(); + if devices.len() != export.devices.len() || zones.len() != export.zones.len() + || schedules.len() != export.schedules.len() || automations.len() != export.automations.len() + || devices.contains("") || zones.contains("") || schedules.contains("") || automations.contains("") + { + return Err(AppError::BadRequest("import contains duplicate or empty resource IDs".into())); + } + let device_macs: std::collections::HashSet<&str> = export.devices.iter().map(|item| item.mac.as_str()).collect(); + if device_macs.len() != export.devices.len() { + return Err(AppError::BadRequest("import contains duplicate device MAC addresses".into())); + } + if export.zones.iter().any(|item| !devices.contains(item.device_id.as_str())) { + return Err(AppError::BadRequest("import contains a zone referencing a missing device".into())); + } + let mut zone_devices = std::collections::HashSet::new(); + for zone in &export.zones { + if !zone_devices.insert(zone.device_id.as_str()) { + return Err(AppError::BadRequest("import assigns one device to more than one thermostat zone".into())); + } + if !matches!(zone.mode.as_str(), "cool" | "heat") { + return Err(AppError::BadRequest("import contains an invalid zone mode".into())); + } + if !matches!(zone.sensor_source.as_str(), "device" | "home_assistant" | "combined") { + return Err(AppError::BadRequest("import contains an invalid zone sensor source".into())); + } + } + + if export.schedules.iter().any(|item| !zones.contains(item.zone_id.as_str())) { + return Err(AppError::BadRequest("import contains a schedule referencing a missing zone".into())); + } + for item in &export.schedules { + if item.weekdays.is_empty() || item.weekdays.iter().any(|day| !(1..=7).contains(day)) { + return Err(AppError::BadRequest("import contains invalid schedule weekdays".into())); + } + NaiveTime::parse_from_str(&item.start_time, "%H:%M").map_err(|_| AppError::BadRequest("import contains an invalid schedule start time".into()))?; + NaiveTime::parse_from_str(&item.end_time, "%H:%M").map_err(|_| AppError::BadRequest("import contains an invalid schedule end time".into()))?; + if !matches!(item.preset.as_str(), "comfort" | "sleep" | "away" | "custom") { + return Err(AppError::BadRequest("import contains an invalid schedule preset".into())); + } + if item.preset == "custom" && !(8.0..=30.0).contains(&item.setpoint) { + return Err(AppError::BadRequest("import contains an invalid schedule setpoint".into())); + } + } + validate_schedule_set(&export.schedules)?; + + if export.groups.iter().any(|group| { + let members: std::collections::HashSet<&str> = group.zone_ids.iter().map(String::as_str).collect(); + group.id.trim().is_empty() || group.zone_ids.is_empty() || members.len() != group.zone_ids.len() + || group.zone_ids.iter().any(|zone_id| !zones.contains(zone_id.as_str())) + }) { + return Err(AppError::BadRequest("import contains an invalid group, duplicate members or a missing zone reference".into())); + } + let groups: std::collections::HashSet<&str> = export.groups.iter().map(|item| item.id.as_str()).collect(); + if groups.len() != export.groups.len() { + return Err(AppError::BadRequest("import contains duplicate group IDs".into())); + } + + for item in &export.automations { + match item.trigger_kind.as_str() { + "temperature_above" | "temperature_below" => { + let Some(trigger_id) = item.trigger_device_id.as_deref() else { + return Err(AppError::BadRequest("import contains a temperature automation without a trigger device".into())); + }; + if !devices.contains(trigger_id) || item.threshold.is_none() { + return Err(AppError::BadRequest("import contains an invalid temperature automation trigger".into())); + } + } + "time" => { + let at = item.at_time.as_deref().ok_or_else(|| AppError::BadRequest("import contains a time automation without at_time".into()))?; + NaiveTime::parse_from_str(at, "%H:%M").map_err(|_| AppError::BadRequest("import contains an invalid automation time".into()))?; + } + _ => return Err(AppError::BadRequest("import contains an unsupported automation trigger".into())), + } + + if let Some(group_id) = item.action_group_id.as_deref().filter(|value| !value.is_empty()) { + if !groups.contains(group_id) { + return Err(AppError::BadRequest("import contains an automation referencing a missing group".into())); + } + if let Some(mode) = item.action.mode.as_deref() { + if !matches!(mode, "auto" | "house" | "cool" | "heat") { + return Err(AppError::BadRequest("import contains an invalid group automation mode".into())); + } + } + if let Some(preset) = item.action_preset.as_deref() { + if !matches!(preset, "auto" | "comfort" | "sleep" | "away") { + return Err(AppError::BadRequest("import contains an invalid group automation preset".into())); + } + } + if item.action.target_temperature.is_some() || item.action.fan_speed.is_some() + || item.action.swing_vertical.is_some() || item.action.swing_horizontal.is_some() + || item.action.quiet.is_some() || item.action.turbo.is_some() || item.action.light.is_some() + || item.action.air.is_some() || item.action.xfan.is_some() || item.action.health.is_some() || item.action.sleep.is_some() + { + return Err(AppError::BadRequest("import contains unsupported fields in a group automation".into())); + } + if item.action.power.is_none() && item.action.mode.is_none() && item.action_preset.as_deref().filter(|v| !v.is_empty()).is_none() { + return Err(AppError::BadRequest("import contains an empty group automation action".into())); + } + } else { + if !devices.contains(item.action_device_id.as_str()) { + return Err(AppError::BadRequest("import contains an automation referencing a missing device".into())); + } + engine::validate_command(&item.action)?; + if item.action.is_empty() { + return Err(AppError::BadRequest("import contains an empty automation action".into())); + } + } + } + Ok(()) +} + +fn sanitize_configuration_runtime(export: &mut ConfigurationExport) { + let now = Utc::now(); + for device in &mut export.devices { + device.power = false; + device.mode = "cool".into(); + device.target_temperature = 23.0; + device.fan_speed = 0; + device.swing_vertical = false; + device.swing_horizontal = false; + device.quiet = false; + device.turbo = false; + device.light = false; + device.air = false; + device.xfan = false; + device.health = false; + device.sleep = false; + device.current_temperature = None; + device.outdoor_temperature = None; + device.online = false; + device.response_time_ms = None; + device.last_seen = None; + device.last_error = None; + device.communication_failures = 0; + device.updated_at = now; + } + for zone in &mut export.zones { + zone.device_temperature = None; + zone.external_temperature = None; + zone.current_temperature = None; + zone.control_temperature_source = "device".into(); + zone.active_preset = "comfort".into(); + zone.manual_preset = None; + zone.manual_setpoint = None; + zone.manual_override_until = None; + zone.local_thermostat_power = None; + zone.local_thermostat_resume_at = None; + zone.local_thermostat_restore_zone_enabled = None; + zone.temporary_quick_thermostat = None; + zone.device_manual_override = false; + zone.device_manual_override_since = None; + zone.device_manual_override_until = None; + zone.device_manual_override_fields.clear(); + zone.device_manual_override_baseline = None; + zone.control_owner = "automation".into(); + zone.control_source = "automation".into(); + zone.control_since = None; + zone.control_resume_at = None; + zone.control_reason = "Imported configuration; runtime ownership reset".into(); + zone.last_power_change_at = None; + zone.last_mode_change_at = None; + zone.lockout_until = None; + zone.lockout_reason = None; + zone.effective_mode.clear(); + zone.effective_setpoint = None; + zone.device_setpoint = None; + zone.demand = false; + zone.demand_since = None; + zone.target_alerted_at = None; + zone.last_action_at = None; + zone.revision = 0; + zone.updated_at = now; + } + for automation in &mut export.automations { + automation.last_fired_at = None; + automation.updated_at = now; + } +} + +async fn import_settings(State(state): State, Json(mut export): Json) -> Result, AppError> { + validate_configuration_export(&export)?; + export.settings.history_retention_days = export.settings.history_retention_days.clamp(1, 3650); + export.settings.event_log_retention_days = export.settings.event_log_retention_days.clamp(1, 3650); + normalize_sensor_aliases(&mut export.settings); + canonicalize_home_assistant_entities(&mut export.settings); + for zone in &mut export.zones { canonicalize_zone_ha_entity(zone, &export.settings); } + validate_night_mode(&mut export.settings)?; + export.settings.influxdb.history_threshold_days = export.settings.influxdb.history_threshold_days.clamp(1, 3650); + + // Before replacing ownership, safely stop every currently managed device whose zone is + // removed or rewired by the imported configuration. Otherwise an orphaned physical unit + // could keep running after its database owner disappears. + let imported_zone_map: std::collections::HashMap = export.zones.iter() + .map(|zone| (zone.id.clone(), zone.device_id.clone())) + .collect(); + let mut detach_devices = std::collections::HashSet::new(); + for current in state.db.list_zones()? { + if imported_zone_map.get(¤t.id).map(String::as_str) != Some(current.device_id.as_str()) { + detach_devices.insert(current.device_id); + } + } + for device_id in detach_devices { + ensure_device_stopped_for_detach(&state, &device_id, "configuration.import").await?; + } + + // Configuration import never restores ephemeral owners/timers or cached physical state. + // Imported devices are reconciled from a fresh poll and current house/group/zone gates. + sanitize_configuration_runtime(&mut export); + state.initial_device_sync_complete.store(false, Ordering::Release); + state.db.replace_configuration(&export)?; + state.debug_gree_frames.store(export.settings.debug.gree_frames, Ordering::Relaxed); + *state.settings.write().await = export.settings.clone(); + + let disabled_group_zones: std::collections::HashSet = export.groups.iter() + .filter(|group| !group.power_enabled) + .flat_map(|group| group.zone_ids.iter().cloned()) + .collect(); + let controllable_devices: std::collections::HashSet = export.zones.iter() + .filter(|zone| { + let effective_mode = if zone.inherit_house_mode { export.settings.house_mode.as_str() } else { zone.mode.as_str() }; + export.settings.house_power_enabled + && zone.enabled + && effective_mode != "off" + && !disabled_group_zones.contains(&zone.id) + }) + .map(|zone| zone.device_id.clone()) + .collect(); + for device in export.devices.iter().filter(|device| device.enabled && !controllable_devices.contains(&device.id)) { + if let Err(err) = engine::force_power_off_device(&state, &device.id).await { + state.log("error", "settings.import_reconcile_error", &err.to_string(), json!({"device_id": device.id})); + return Err(err); + } + } + // Rebuild live device snapshots before allowing the thermostat loop to make decisions. + // Network failures are represented in device health by poll_one rather than reviving + // imported cache values. + engine::poll_all(&state).await?; + state.initial_device_sync_complete.store(true, Ordering::Release); + state.wake_zone_control(); + state.log("info", "settings.imported", "Application configuration imported", json!({"format_version": export.format_version})); + state.broadcast("configuration.imported", json!({"at": Utc::now()})); + Ok(Json(json!({"ok": true}))) +} + diff --git a/src/api/system.rs b/src/api/system.rs new file mode 100644 index 0000000..f6cd1cc --- /dev/null +++ b/src/api/system.rs @@ -0,0 +1,69 @@ +async fn health(State(state): State) -> Json { + Json(json!({ + "status": "ok", + "name": "gree-controller", + "version": env!("CARGO_PKG_VERSION"), + "uptime_seconds": state.started.elapsed().as_secs(), + "control_ready": state.initial_device_sync_complete.load(Ordering::Acquire), + "time": Utc::now(), + })) +} + +async fn bootstrap(State(state): State) -> Result, AppError> { + Ok(Json(build_bootstrap(&state).await?)) +} + +async fn build_bootstrap(state: &AppState) -> Result { + let settings = state.settings.read().await.clone(); + let devices = state.db.list_devices()?; + let device_count = devices.len(); + let online_count = devices.iter().filter(|value| value.online).count(); + let simulator_count = devices.iter().filter(|value| value.simulated).count(); + let (received_frames_total, received_frames_by_device) = state.gree.received_frame_stats(); + Ok(json!({ + "devices": devices, + "zones": state.db.list_zones()?, + "groups": state.db.list_groups()?, + "schedules": state.db.list_schedules()?, + "automations": state.db.list_automations()?, + "access_tokens": state.db.list_api_tokens()?, + "settings": public_settings(&settings), + "outdoor_temperature": *state.outdoor_temperature.read().await, + "system": { + "version": env!("CARGO_PKG_VERSION"), + "uptime_seconds": state.started.elapsed().as_secs(), + "auth_required": !state.config.app_token.trim().is_empty(), + "control_ready": state.initial_device_sync_complete.load(Ordering::Acquire), + "database": state.config.database.display().to_string(), + "device_count": device_count, + "online_count": online_count, + "simulator_count": simulator_count, + "bind": state.config.bind.to_string(), + "base_path": if state.config.base_path.is_empty() { "/" } else { state.config.base_path.as_str() }, + "gree_interface": if state.config.gree_interface.trim().is_empty() { "auto" } else { state.config.gree_interface.trim() }, + "gree_received_frames": received_frames_total, + "gree_received_frames_by_device": received_frames_by_device, + } + })) +} + +async fn system_info(State(state): State) -> Result, AppError> { + let devices = state.db.list_devices()?; + let (received_frames_total, received_frames_by_device) = state.gree.received_frame_stats(); + Ok(Json(json!({ + "version": env!("CARGO_PKG_VERSION"), + "uptime_seconds": state.started.elapsed().as_secs(), + "database": state.config.database.display().to_string(), + "device_count": devices.len(), + "online_count": devices.iter().filter(|v| v.online).count(), + "simulator_count": devices.iter().filter(|v| v.simulated).count(), + "control_ready": state.initial_device_sync_complete.load(Ordering::Acquire), + "auth_required": !state.config.app_token.trim().is_empty(), + "bind": state.config.bind.to_string(), + "base_path": if state.config.base_path.is_empty() { "/" } else { state.config.base_path.as_str() }, + "gree_interface": if state.config.gree_interface.trim().is_empty() { "auto" } else { state.config.gree_interface.trim() }, + "gree_received_frames": received_frames_total, + "gree_received_frames_by_device": received_frames_by_device, + }))) +} + diff --git a/src/api/websocket.rs b/src/api/websocket.rs new file mode 100644 index 0000000..ad561ad --- /dev/null +++ b/src/api/websocket.rs @@ -0,0 +1,40 @@ +#[derive(Debug, Deserialize)] +struct WsQuery { token: Option } +async fn websocket(State(state): State, Query(query): Query, ws: WebSocketUpgrade) -> Result { + let expected = state.config.app_token.trim(); + if !expected.is_empty() && query.token.as_deref() != Some(expected) { return Err(AppError::Unauthorized); } + Ok(ws.on_upgrade(move |socket| websocket_loop(state, socket))) +} + +async fn websocket_loop(state: AppState, mut socket: WebSocket) { + let initial = match build_bootstrap(&state).await { + Ok(data) => json!({"event":"bootstrap","timestamp":Utc::now(),"data":data}), + Err(err) => json!({"event":"error","timestamp":Utc::now(),"data":{"message":err.to_string()}}), + }; + if socket.send(Message::Text(initial.to_string())).await.is_err() { return; } + let mut receiver = state.events.subscribe(); + loop { + tokio::select! { + event = receiver.recv() => { + match event { + Ok(event) => { + if let Ok(text) = serde_json::to_string(&event) { + if socket.send(Message::Text(text)).await.is_err() { break; } + } + } + Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue, + Err(_) => break, + } + } + message = socket.next() => { + match message { + Some(Ok(Message::Ping(value))) => { if socket.send(Message::Pong(value)).await.is_err() { break; } } + Some(Ok(Message::Text(text))) if text == "ping" => { if socket.send(Message::Text("pong".into())).await.is_err() { break; } } + Some(Ok(Message::Close(_))) | None | Some(Err(_)) => break, + _ => {} + } + } + } + } +} + diff --git a/src/api/zones.rs b/src/api/zones.rs new file mode 100644 index 0000000..8779746 --- /dev/null +++ b/src/api/zones.rs @@ -0,0 +1,657 @@ +#[derive(Debug, Deserialize)] +struct ZoneInput { + name: String, + device_id: String, + #[serde(default = "yes")] + enabled: bool, + #[serde(default = "cool")] + mode: String, + #[serde(default = "yes")] + inherit_house_mode: bool, + #[serde(default = "setpoint")] + setpoint: f64, + #[serde(default = "cool_comfort")] + cool_comfort_setpoint: f64, + #[serde(default = "cool_sleep")] + cool_sleep_setpoint: f64, + #[serde(default = "cool_away")] + cool_away_setpoint: f64, + #[serde(default = "heat_comfort")] + heat_comfort_setpoint: f64, + #[serde(default = "heat_sleep")] + heat_sleep_setpoint: f64, + #[serde(default = "heat_away")] + heat_away_setpoint: f64, + #[serde(default = "hysteresis")] + hysteresis: f64, + #[serde(default = "cycle")] + min_on_seconds: u64, + #[serde(default = "cycle")] + min_off_seconds: u64, + #[serde(default = "min_adjust")] + min_adjust_seconds: u64, + #[serde(default = "standby_offset")] + standby_offset_c: f64, + #[serde(default = "yes")] + smart_fan: bool, + #[serde(default = "device_source")] + sensor_source: String, + #[serde(default)] + ha_entity_id: Option, + #[serde(default = "external_sensor_weight")] + external_sensor_weight: f64, + #[serde(default = "max_sensor_difference")] + max_sensor_difference: f64, + #[serde(default = "sensor_stale_after")] + sensor_stale_after_seconds: u64, + #[serde(default)] + revision: Option, +} +fn yes() -> bool { true } +fn cool() -> String { "cool".into() } +fn setpoint() -> f64 { 24.0 } +fn cool_comfort() -> f64 { 23.0 } +fn cool_sleep() -> f64 { 24.5 } +fn cool_away() -> f64 { 27.0 } +fn heat_comfort() -> f64 { 21.0 } +fn heat_sleep() -> f64 { 19.0 } +fn heat_away() -> f64 { 17.0 } +fn hysteresis() -> f64 { 0.6 } +fn cycle() -> u64 { 180 } +fn min_adjust() -> u64 { 120 } +fn standby_offset() -> f64 { 2.0 } +fn external_sensor_weight() -> f64 { 0.4 } +fn max_sensor_difference() -> f64 { 3.0 } +fn sensor_stale_after() -> u64 { 300 } +fn device_source() -> String { "device".into() } + +impl ZoneInput { + fn validate(&self) -> Result<(), AppError> { + if self.name.trim().is_empty() { return Err(AppError::BadRequest("zone name is required".into())); } + for value in [self.setpoint, self.cool_comfort_setpoint, self.cool_sleep_setpoint, self.cool_away_setpoint, + self.heat_comfort_setpoint, self.heat_sleep_setpoint, self.heat_away_setpoint] { + if !(8.0..=30.0).contains(&value) { return Err(AppError::BadRequest("zone temperatures must be between 8 and 30 C".into())); } + } + if !(0.1..=5.0).contains(&self.hysteresis) { return Err(AppError::BadRequest("hysteresis must be between 0.1 and 5 C".into())); } + if !(0.5..=8.0).contains(&self.standby_offset_c) { return Err(AppError::BadRequest("standby offset must be between 0.5 and 8 C".into())); } + if !matches!(self.mode.as_str(), "cool" | "heat") { return Err(AppError::BadRequest("zone mode must be cool or heat".into())); } + if !matches!(self.sensor_source.as_str(), "device" | "home_assistant" | "combined") { return Err(AppError::BadRequest("unsupported sensor source".into())); } + if !(0.0..=1.0).contains(&self.external_sensor_weight) { return Err(AppError::BadRequest("external sensor weight must be between 0 and 1".into())); } + if !(0.1..=20.0).contains(&self.max_sensor_difference) { return Err(AppError::BadRequest("maximum sensor difference must be between 0.1 and 20 C".into())); } + if matches!(self.sensor_source.as_str(), "home_assistant" | "combined") && self.ha_entity_id.as_deref().map(|value| value.trim()).unwrap_or("").is_empty() { + return Err(AppError::BadRequest("a per-zone Home Assistant entity_id is required for external or combined temperature control".into())); + } + Ok(()) + } + fn into_zone(self, id: String, created_at: chrono::DateTime) -> Zone { + Zone { + id, name: self.name.trim().into(), device_id: self.device_id, enabled: self.enabled, + mode: self.mode, inherit_house_mode: self.inherit_house_mode, setpoint: self.setpoint, profile_version: 1, + cool_comfort_setpoint: self.cool_comfort_setpoint, cool_sleep_setpoint: self.cool_sleep_setpoint, + cool_away_setpoint: self.cool_away_setpoint, heat_comfort_setpoint: self.heat_comfort_setpoint, + heat_sleep_setpoint: self.heat_sleep_setpoint, heat_away_setpoint: self.heat_away_setpoint, + hysteresis: self.hysteresis, min_on_seconds: self.min_on_seconds, min_off_seconds: self.min_off_seconds, + min_adjust_seconds: self.min_adjust_seconds, standby_offset_c: self.standby_offset_c, smart_fan: self.smart_fan, + sensor_source: self.sensor_source, ha_entity_id: self.ha_entity_id.filter(|v| !v.trim().is_empty()), + external_sensor_weight: self.external_sensor_weight, max_sensor_difference: self.max_sensor_difference, sensor_stale_after_seconds: self.sensor_stale_after_seconds, + 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: "zone created".into(), + last_power_change_at: None, last_mode_change_at: None, lockout_until: None, lockout_reason: None, + effective_mode: String::new(), effective_setpoint: None, device_setpoint: None, + demand: false, demand_since: None, target_alerted_at: None, last_action_at: None, + created_at, updated_at: Utc::now(), + } + } +} + +fn validate_zone_device_assignment(state: &AppState, device_id: &str, current_zone_id: Option<&str>) -> Result<(), AppError> { + if state.db.list_zones()?.iter().any(|zone| zone.device_id == device_id && current_zone_id != Some(zone.id.as_str())) { + return Err(AppError::BadRequest("a device can belong to only one thermostat zone".into())); + } + Ok(()) +} + +async fn list_zones(State(state): State) -> Result>, AppError> { Ok(Json(state.db.list_zones()?)) } +async fn get_zone(State(state): State, Path(id): Path) -> Result, AppError> { + state.db.get_zone(&id)?.map(Json).ok_or_else(|| AppError::NotFound(format!("zone {id}"))) +} +async fn create_zone(State(state): State, Json(input): Json) -> Result<(StatusCode, Json), AppError> { + input.validate()?; + if state.db.get_device(&input.device_id)?.is_none() { return Err(AppError::BadRequest("zone device does not exist".into())); } + validate_zone_device_assignment(&state, &input.device_id, None)?; + let mut zone = input.into_zone(Uuid::new_v4().to_string(), Utc::now()); + let settings = state.settings.read().await.clone(); + canonicalize_zone_ha_entity(&mut zone, &settings); + state.db.save_zone(&zone)?; + state.broadcast("zone.created", serde_json::to_value(&zone)?); + Ok((StatusCode::CREATED, Json(zone))) +} +async fn update_zone(State(state): State, Path(id): Path, Json(input): Json) -> Result, AppError> { + input.validate()?; + let _zone_guard = state.lock_zone_operation(&id).await; + let mut existing = state.db.get_zone(&id)?.ok_or_else(|| AppError::NotFound(format!("zone {id}")))?; + if let Some(expected) = input.revision { + if expected != existing.revision { + return Err(AppError::Conflict(format!("zone {id} changed; expected revision {expected}, current revision {}", existing.revision))); + } + } + if state.db.get_device(&input.device_id)?.is_none() { return Err(AppError::BadRequest("zone device does not exist".into())); } + validate_zone_device_assignment(&state, &input.device_id, Some(&id))?; + let device_changed = existing.device_id != input.device_id; + // Serialize a normal zone edit with polling/manual-takeover detection for its device. + // Device reassignment uses ensure_device_stopped_for_detach below, which acquires the + // old device lock itself while this zone lock is held. + let _device_guard = if !device_changed { Some(state.lock_device_operation(&existing.device_id).await) } else { None }; + if !device_changed { + // Polling may have updated takeover/runtime state while we were waiting for the + // device lock. Re-read under both locks before building the replacement Zone. + existing = state.db.get_zone(&id)?.ok_or_else(|| AppError::NotFound(format!("zone {id}")))?; + if let Some(expected) = input.revision { + if expected != existing.revision { + return Err(AppError::Conflict(format!("zone {id} changed; expected revision {expected}, current revision {}", existing.revision))); + } + } + } + let mut zone = input.into_zone(id, existing.created_at); + if !device_changed { + zone.device_temperature = existing.device_temperature; + zone.external_temperature = existing.external_temperature; + zone.current_temperature = existing.current_temperature; + zone.control_temperature_source = existing.control_temperature_source; + zone.active_preset = existing.active_preset; + zone.manual_preset = existing.manual_preset; + zone.manual_setpoint = existing.manual_setpoint; + zone.manual_override_until = existing.manual_override_until; + zone.local_thermostat_power = existing.local_thermostat_power; + zone.local_thermostat_resume_at = existing.local_thermostat_resume_at; + zone.local_thermostat_restore_zone_enabled = existing.local_thermostat_restore_zone_enabled; + zone.temporary_quick_thermostat = existing.temporary_quick_thermostat; + zone.device_manual_override = existing.device_manual_override; + zone.device_manual_override_since = existing.device_manual_override_since; + zone.device_manual_override_until = existing.device_manual_override_until; + zone.device_manual_override_fields = existing.device_manual_override_fields; + zone.device_manual_override_baseline = existing.device_manual_override_baseline; + zone.revision = existing.revision.saturating_add(1); + zone.control_owner = existing.control_owner; + zone.control_source = existing.control_source; + zone.control_since = existing.control_since; + zone.control_resume_at = existing.control_resume_at; + zone.control_reason = existing.control_reason; + zone.last_power_change_at = existing.last_power_change_at; + zone.last_mode_change_at = existing.last_mode_change_at; + zone.lockout_until = existing.lockout_until; + zone.lockout_reason = existing.lockout_reason; + zone.effective_mode = existing.effective_mode; + zone.effective_setpoint = existing.effective_setpoint; + zone.device_setpoint = existing.device_setpoint; + zone.demand = existing.demand; + zone.demand_since = existing.demand_since; + zone.target_alerted_at = existing.target_alerted_at; + zone.last_action_at = existing.last_action_at; + } else { + // A new physical unit starts with a clean ownership/runtime state. Never transfer + // demand, sensor cache or remote-control takeover from the previous device. + ensure_device_stopped_for_detach(&state, &existing.device_id, "zone.device_reassigned").await?; + zone.revision = existing.revision.saturating_add(1); + } + let settings = state.settings.read().await.clone(); + canonicalize_zone_ha_entity(&mut zone, &settings); + let power_off_device = !device_changed && existing.enabled && !zone.enabled; + if power_off_device { + // Full configuration PUT and quick-control disable use the same ownership cleanup. + // No local/temporary/manual takeover survives a disabled thermostat zone (H6). + if engine::temporary_quick_thermostat_is_active(&zone, Utc::now()) { + engine::finish_temporary_quick_thermostat(&mut zone, &state.db.list_schedules()?, &settings.house_mode); + } else { + zone.temporary_quick_thermostat = None; + } + engine::reset_local_thermostat_override(&mut zone); + engine::reset_device_manual_override(&mut zone); + zone.enabled = false; + } + state.db.save_zone(&zone)?; + state.broadcast("zone.updated", serde_json::to_value(&zone)?); + drop(_device_guard); + if power_off_device { + power_off_zone_device(&state, &zone, "zone.disabled").await; + } + Ok(Json(zone)) +} + +async fn apply_zone_control_patch(state: &AppState, id: &str, patch: ZoneControlPatch, source: &str) -> Result { + // Serialize quick-thermostat changes with the same device lock used by GREE polling and + // manual-takeover detection. Without this, a poll that started just before a Web/HA + // thermostat action could save an older zone snapshot afterwards and resurrect a false + // "physical/pilot" takeover. + let _zone_guard = state.lock_zone_operation(id).await; + let device_id = state.db.get_zone(id)? + .ok_or_else(|| AppError::NotFound(format!("zone {id}")))? + .device_id; + let device_guard = state.lock_device_operation(&device_id).await; + let mut zone = state.db.get_zone(id)?.ok_or_else(|| AppError::NotFound(format!("zone {id}")))?; + let was_enabled = zone.enabled; + let schedules = state.db.list_schedules()?; + let resume_device_takeover = patch.clear_device_manual_override.unwrap_or(false); + let resume_local_thermostat = patch.clear_local_thermostat_override.unwrap_or(false); + let stop_temporary_quick_thermostat = patch.clear_temporary_quick_thermostat.unwrap_or(false); + if patch.temporary_quick_thermostat.is_some() && (patch.power.is_some() || stop_temporary_quick_thermostat || resume_local_thermostat) { + return Err(AppError::BadRequest("temporary thermostat cannot be combined with local power/clear operations in one request".into())); + } + // Direct/manual device takeover is higher priority than a temporary thermostat. Creating + // or editing a temporary session therefore never clears an active pilot/Devices takeover; + // the session waits/pauses instead. Other explicit thermostat actions still resume control. + let resume_device_automation = resume_device_takeover + || patch.power.is_some() || patch.setpoint.is_some() || patch.mode.is_some() + || patch.preset.is_some() || patch.enabled.is_some(); + if resume_device_automation || resume_local_thermostat || stop_temporary_quick_thermostat || patch.temporary_quick_thermostat.is_some() { + zone.control_source = if source.contains("home_assistant") { "home_assistant_thermostat".into() } else { "web_thermostat".into() }; + } + + if resume_local_thermostat { + engine::reset_local_thermostat_override(&mut zone); + } + if stop_temporary_quick_thermostat && zone.temporary_quick_thermostat.is_some() { + if engine::temporary_quick_thermostat_is_active(&zone, Utc::now()) { + engine::finish_temporary_quick_thermostat(&mut zone, &schedules, &state.settings.read().await.house_mode); + } else { + // Cancelling a delayed session before it starts must not erase unrelated + // quick preset/setpoint state that automation may be using in the meantime. + zone.temporary_quick_thermostat = None; + } + } + if let Some(request) = patch.temporary_quick_thermostat.as_ref() { + let now = Utc::now(); + let runtime = state.settings.read().await.clone(); + let existing_session = zone.temporary_quick_thermostat.clone(); + let editing_active = engine::temporary_quick_thermostat_is_active(&zone, now); + let requested_start_kind = request.start_kind.as_str(); + if !matches!(requested_start_kind, "now" | "delay" | "at") { + return Err(AppError::BadRequest("unsupported temporary thermostat start kind".into())); + } + + // Editing an already active session changes only target/finish rules. Its historical + // start and activated_at are preserved, so delay/at sessions cannot be accidentally + // rescheduled or rejected because their original start is now in the past. + let (start_kind, started_at, activated_at) = if editing_active { + let existing = existing_session.as_ref().expect("active temporary session must exist"); + (existing.start_kind.clone(), existing.started_at.clone(), existing.activated_at.clone()) + } else { + let started_at = match requested_start_kind { + "now" => now.clone(), + "delay" => { + let minutes = request.start_delay_minutes.ok_or_else(|| AppError::BadRequest("temporary thermostat start delay is required".into()))?; + if !(1..=43_200).contains(&minutes) { + return Err(AppError::BadRequest("temporary thermostat start delay must be between 1 minute and 30 days".into())); + } + now.clone() + ChronoDuration::minutes(minutes as i64) + } + "at" => { + let at = request.start_at.clone().ok_or_else(|| AppError::BadRequest("temporary thermostat start time is required".into()))?; + if at <= now { return Err(AppError::BadRequest("temporary thermostat start time must be in the future".into())); } + if at > now.clone() + ChronoDuration::days(30) { return Err(AppError::BadRequest("temporary thermostat start time cannot be more than 30 days away".into())); } + at + } + _ => unreachable!(), + }; + (requested_start_kind.to_string(), started_at, None) + }; + + if !editing_active && start_kind == "now" && !runtime.house_power_enabled { + return Err(AppError::BadRequest("temporary thermostat cannot start while whole-house automation is off; enable house power or schedule it for later".into())); + } + + let finish_kind = request.finish_kind.as_str(); + if !matches!(finish_kind, "duration" | "until" | "temperature_reached" | "temperature_stable" | "schedule_boundary") { + return Err(AppError::BadRequest("unsupported temporary thermostat finish kind".into())); + } + + let target = request.target_temperature.unwrap_or(zone.manual_setpoint.unwrap_or(zone.effective_setpoint.unwrap_or(zone.setpoint))); + if !(8.0..=30.0).contains(&target) { + return Err(AppError::BadRequest("temporary thermostat target must be between 8 and 30 C".into())); + } + let target = (target * 2.0).round() / 2.0; + let min_stable_tolerance = (zone.hysteresis.max(0.1) / 2.0 + 0.1).min(3.0); + let requested_tolerance = request.tolerance_c.unwrap_or(min_stable_tolerance.max(0.3)); + if !(0.1..=3.0).contains(&requested_tolerance) { + return Err(AppError::BadRequest("temporary thermostat tolerance must be between 0.1 and 3 C".into())); + } + let tolerance = if finish_kind == "temperature_stable" { requested_tolerance.max(min_stable_tolerance) } else { requested_tolerance }; + let temperature_operator = request.temperature_operator.as_deref().unwrap_or("within"); + if !matches!(temperature_operator, "within" | "at_or_below" | "at_or_above") { + return Err(AppError::BadRequest("unsupported temporary thermostat temperature operator".into())); + } + + let duration_seconds = if finish_kind == "duration" { + let minutes = request.duration_minutes.ok_or_else(|| AppError::BadRequest("temporary thermostat duration is required".into()))?; + if !(1..=14_400).contains(&minutes) { return Err(AppError::BadRequest("temporary thermostat duration must be between 1 minute and 10 days".into())); } + Some(minutes.saturating_mul(60)) + } else { None }; + let is_temperature_condition = matches!(finish_kind, "temperature_reached" | "temperature_stable"); + let hold_seconds = if finish_kind == "temperature_stable" { + let minutes = request.hold_minutes.ok_or_else(|| AppError::BadRequest("temperature hold time is required".into()))?; + if !(1..=1_440).contains(&minutes) { return Err(AppError::BadRequest("temperature hold time must be between 1 minute and 24 hours".into())); } + minutes.saturating_mul(60) + } else { 0 }; + let safety_duration_seconds = if is_temperature_condition { + request.max_duration_minutes.map(|minutes| { + if !(1..=14_400).contains(&minutes) { + return Err(AppError::BadRequest("temporary thermostat safety limit must be between 1 minute and 10 days".into())); + } + Ok(minutes.saturating_mul(60)) + }).transpose()? + } else { None }; + + let active_base = activated_at.clone().unwrap_or(now.clone()); + let expires_at = match finish_kind { + "duration" => if editing_active { duration_seconds.map(|seconds| active_base.clone() + ChronoDuration::seconds(seconds as i64)) } else { None }, + "until" => { + let until = request.until.clone().ok_or_else(|| AppError::BadRequest("temporary thermostat end time is required".into()))?; + let comparison_start = if editing_active { now.clone() } else { started_at.clone() }; + if until <= comparison_start { return Err(AppError::BadRequest("temporary thermostat end time must be in the future and after its start".into())); } + if until > comparison_start.clone() + ChronoDuration::days(30) { return Err(AppError::BadRequest("temporary thermostat end time cannot be more than 30 days after start".into())); } + Some(until) + } + "schedule_boundary" => { + let reference = if editing_active { chrono::Local::now() } else { started_at.clone().with_timezone(&chrono::Local) }; + Some(engine::next_schedule_boundary_utc(&zone.id, &schedules, reference) + .ok_or_else(|| AppError::BadRequest("this zone has no future schedule transition".into()))?) + } + _ => None, + }; + let safety_expires_at = if editing_active { + safety_duration_seconds.map(|seconds| active_base.clone() + ChronoDuration::seconds(seconds as i64)) + } else { None }; + + let starts_now = start_kind == "now"; + let immediate_activation = !editing_active && starts_now && runtime.house_power_enabled && !zone.device_manual_override; + let restore_zone_enabled = if editing_active { + existing_session.as_ref().and_then(|session| session.restore_zone_enabled) + } else if immediate_activation { + Some(zone.enabled) + } else { + // Delayed sessions capture this at actual takeover time (H12), not planning time. + None + }; + let configured_mode = if zone.inherit_house_mode { runtime.house_mode.as_str() } else { zone.mode.as_str() }; + let captured_mode = if configured_mode == "off" { zone.mode.clone() } else { configured_mode.to_string() }; + let active_mode = if editing_active { + existing_session.as_ref().and_then(|session| session.active_mode.clone()) + } else if immediate_activation { + Some(captured_mode.clone()) + } else { None }; + let condition_mode = active_mode.as_deref().unwrap_or(captured_mode.as_str()); + if is_temperature_condition { + if (condition_mode == "heat" && temperature_operator == "at_or_below") + || (condition_mode == "cool" && temperature_operator == "at_or_above") + { + return Err(AppError::BadRequest( + "temporary thermostat temperature condition conflicts with the active heating/cooling direction".into(), + )); + } + } + let state_value = if editing_active { + if zone.device_manual_override { "paused_manual" } else { "active" } + } else if starts_now && zone.device_manual_override { + "paused_manual" + } else { + "scheduled" + }; + let underlying_local_power = zone.local_thermostat_power; + let underlying_local_resume_at = zone.local_thermostat_resume_at; + let underlying_local_zone_enabled = zone.local_thermostat_restore_zone_enabled; + let underlying_manual_preset = zone.manual_preset.clone(); + let underlying_manual_setpoint = zone.manual_setpoint; + let underlying_manual_override_until = zone.manual_override_until; + + if immediate_activation { + zone.local_thermostat_power = Some(true); + zone.local_thermostat_resume_at = None; + zone.local_thermostat_restore_zone_enabled = None; + zone.enabled = true; + zone.manual_setpoint = Some(target); + zone.effective_setpoint = Some(target); + zone.manual_override_until = None; + } else if editing_active { + // Keep current ownership and update the live target without restarting the session. + zone.manual_setpoint = Some(target); + zone.effective_setpoint = Some(target); + zone.manual_override_until = None; + } + + zone.temporary_quick_thermostat = Some(TemporaryQuickThermostat { + start_kind, + finish_kind: finish_kind.into(), + started_at, + activated_at: if immediate_activation { Some(now.clone()) } else { activated_at.clone() }, + state: state_value.into(), + active_mode, + restore_zone_enabled, + restore_local_thermostat_power: if editing_active { + existing_session.as_ref().and_then(|session| session.restore_local_thermostat_power) + } else if immediate_activation { underlying_local_power } else { None }, + restore_local_thermostat_resume_at: if editing_active { + existing_session.as_ref().and_then(|session| session.restore_local_thermostat_resume_at) + } else if immediate_activation { underlying_local_resume_at } else { None }, + restore_local_thermostat_zone_enabled: if editing_active { + existing_session.as_ref().and_then(|session| session.restore_local_thermostat_zone_enabled) + } else if immediate_activation { underlying_local_zone_enabled } else { None }, + restore_manual_preset: if editing_active { + existing_session.as_ref().and_then(|session| session.restore_manual_preset.clone()) + } else if immediate_activation { underlying_manual_preset } else { None }, + restore_manual_setpoint: if editing_active { + existing_session.as_ref().and_then(|session| session.restore_manual_setpoint) + } else if immediate_activation { underlying_manual_setpoint } else { None }, + restore_manual_override_until: if editing_active { + existing_session.as_ref().and_then(|session| session.restore_manual_override_until) + } else if immediate_activation { underlying_manual_override_until } else { None }, + expires_at: if immediate_activation && finish_kind == "duration" { + duration_seconds.map(|seconds| now.clone() + ChronoDuration::seconds(seconds as i64)) + } else { expires_at }, + duration_seconds, + safety_duration_seconds, + temperature_target: Some(target), + temperature_operator: is_temperature_condition.then(|| temperature_operator.to_string()), + tolerance_c: tolerance, + hold_seconds, + condition_started_at: None, + condition_last_observed_at: None, + paused_at: if zone.device_manual_override && (editing_active || starts_now) { + existing_session.as_ref().and_then(|session| session.paused_at.clone()).or(Some(now.clone())) + } else { None }, + deferred_mode: existing_session.as_ref().and_then(|session| session.deferred_mode.clone()), + deferred_preset: existing_session.as_ref().and_then(|session| session.deferred_preset.clone()), + safety_expires_at: if immediate_activation && is_temperature_condition { + safety_duration_seconds.map(|seconds| now.clone() + ChronoDuration::seconds(seconds as i64)) + } else { safety_expires_at }, + }); + } + if let Some(power) = patch.power { + // The neighbouring quick-power control and the explicit Stop button must use the + // same temporary-session cleanup/restore semantics before local ownership changes. + if zone.temporary_quick_thermostat.is_some() { + if engine::temporary_quick_thermostat_is_active(&zone, Utc::now()) { + engine::finish_temporary_quick_thermostat(&mut zone, &schedules, &state.settings.read().await.house_mode); + } else { + zone.temporary_quick_thermostat = None; + } + } + engine::set_local_thermostat_power(&mut zone, power, Utc::now()); + if power { zone.enabled = true; } + // A manually started local thermostat keeps an already selected target/profile until + // it is switched off and the delayed hand-back completes, Auto is selected, or the + // user explicitly resumes automation. + if power && (zone.manual_preset.is_some() || zone.manual_setpoint.is_some()) { + zone.manual_override_until = None; + } + } + if let Some(value) = patch.setpoint { + if !(8.0..=30.0).contains(&value) { return Err(AppError::BadRequest("zone setpoint must be between 8 and 30 C".into())); } + let value = (value * 2.0).round() / 2.0; + zone.setpoint = value; + zone.manual_setpoint = Some(value); + zone.effective_setpoint = Some(value); + if zone.local_thermostat_power == Some(true) { + if let Some(session) = zone.temporary_quick_thermostat.as_mut() { + // +/- always edits the live temporary target, including duration/until + // sessions, so the modal and regulator cannot diverge (M14). + session.temperature_target = Some(value); + if matches!(session.finish_kind.as_str(), "temperature_reached" | "temperature_stable") { + session.condition_started_at = None; + session.condition_last_observed_at = None; + } + } + } + zone.manual_override_until = if zone.local_thermostat_power == Some(true) { + None + } else { + engine::next_schedule_boundary_utc(&zone.id, &schedules, chrono::Local::now()) + }; + } + if let Some(value) = patch.mode.as_deref() { + if !matches!(value, "house" | "auto" | "cool" | "heat") { + return Err(AppError::BadRequest("zone mode must be house, cool or heat".into())); + } + if engine::temporary_quick_thermostat_is_active(&zone, Utc::now()) { + if let Some(session) = zone.temporary_quick_thermostat.as_mut() { + session.deferred_mode = Some(value.to_string()); + } + } else { + match value { + "house" | "auto" => zone.inherit_house_mode = true, + "cool" | "heat" => { + zone.inherit_house_mode = false; + zone.mode = value.to_string(); + } + _ => unreachable!(), + } + } + } + if let Some(value) = patch.preset.as_deref() { + if !matches!(value, "auto" | "comfort" | "sleep" | "away" | "custom") { + return Err(AppError::BadRequest("unsupported zone preset".into())); + } + if engine::temporary_quick_thermostat_is_active(&zone, Utc::now()) { + if let Some(session) = zone.temporary_quick_thermostat.as_mut() { + session.deferred_preset = Some(value.to_string()); + } + } else { + match value { + "auto" => { + zone.manual_preset = None; + zone.manual_setpoint = None; + zone.manual_override_until = None; + } + "comfort" | "sleep" | "away" | "custom" => { + zone.manual_preset = Some(value.to_string()); + zone.manual_setpoint = None; + zone.manual_override_until = if zone.local_thermostat_power == Some(true) { + None + } else { + engine::next_schedule_boundary_utc(&zone.id, &schedules, chrono::Local::now()) + }; + } + _ => unreachable!(), + } + } + } + if patch.clear_override.unwrap_or(false) { + if engine::temporary_quick_thermostat_is_active(&zone, Utc::now()) { + if let Some(session) = zone.temporary_quick_thermostat.as_mut() { + session.deferred_preset = Some("auto".into()); + } + } else { + zone.manual_preset = None; + zone.manual_setpoint = None; + zone.manual_override_until = None; + } + } + if let Some(value) = patch.enabled { + if !value { + if engine::temporary_quick_thermostat_is_active(&zone, Utc::now()) { + engine::finish_temporary_quick_thermostat(&mut zone, &schedules, &state.settings.read().await.house_mode); + } else { + zone.temporary_quick_thermostat = None; + } + engine::reset_local_thermostat_override(&mut zone); + engine::reset_device_manual_override(&mut zone); + zone.enabled = false; + } else { + zone.enabled = true; + } + } + let device_override_cleared = if resume_device_automation { engine::reset_device_manual_override(&mut zone) } else { false }; + let runtime = state.settings.read().await.clone(); + let house_mode = runtime.house_mode.clone(); + let blocked_by_group = zone.local_thermostat_power != Some(true) + && state.db.list_groups()?.iter().any(|group| !group.power_enabled && group.zone_ids.iter().any(|zone_id| zone_id == &zone.id)); + engine::refresh_control_ownership(&mut zone, runtime.house_power_enabled, blocked_by_group); + engine::refresh_zone_runtime_target(&mut zone, &schedules, &house_mode); + zone.revision = zone.revision.saturating_add(1); + zone.updated_at = Utc::now(); + state.db.save_zone(&zone)?; + state.broadcast("zone.updated", serde_json::to_value(&zone)?); + // Release before any device command below; engine::send_command acquires this same lock. + drop(device_guard); + if was_enabled && !zone.enabled { + power_off_zone_device(state, &zone, "zone.quick_disabled").await; + } else if patch.power == Some(false) { + if let Err(err) = engine::send_command( + state, + &zone.device_id, + DeviceCommand { power: Some(false), ..Default::default() }, + ).await { + state.log("error", "zone.local_power_error", &err.to_string(), json!({ + "zone_id": zone.id, "device_id": zone.device_id, "power": false + })); + } + state.wake_zone_control(); + } else { + state.wake_zone_control(); + } + state.log("info", "zone.quick_control", &format!("Quick control updated for {}", zone.name), json!({ + "zone_id": zone.id, "setpoint": zone.setpoint, "manual_setpoint": zone.manual_setpoint, "mode": zone.mode, + "inherit_house_mode": zone.inherit_house_mode, "preset": zone.manual_preset, + "override_until": zone.manual_override_until, "enabled": zone.enabled, + "local_thermostat_power": zone.local_thermostat_power, + "local_thermostat_resume_at": zone.local_thermostat_resume_at, + "temporary_quick_thermostat": zone.temporary_quick_thermostat, + "device_manual_override_cleared": device_override_cleared + })); + Ok(zone) +} + +async fn update_zone_control(State(state): State, Path(id): Path, Json(patch): Json) -> Result, AppError> { + Ok(Json(apply_zone_control_patch(&state, &id, patch, "web.zone_thermostat").await?)) +} + + + +async fn ensure_device_stopped_for_detach(state: &AppState, device_id: &str, source: &str) -> Result<(), AppError> { + let Some(device) = state.db.get_device(device_id)? else { return Ok(()); }; + if !device.enabled { + return Err(AppError::BadRequest("cannot safely detach a technically disabled device; enable it so the controller can confirm it is powered off first".into())); + } + // Force one OFF transition even when the cached state already says OFF. A remote change + // may not have been polled yet and detaching must not leave a running unit without owner. + engine::force_power_off_device(state, device_id).await?; + state.log("info", "zone.detach_power_off", &format!("Powered off {} before detaching thermostat ownership", device.name), json!({ + "device_id": device.id, "source": source + })); + Ok(()) +} + +async fn power_off_zone_device(state: &AppState, zone: &Zone, source: &str) { + let Ok(Some(device)) = state.db.get_device(&zone.device_id) else { return; }; + if !device.enabled { return; } + if let Err(err) = engine::force_power_off_device(state, &device.id).await { + state.log("error", "zone.disable_power_error", &err.to_string(), json!({ + "zone_id": zone.id, + "device_id": device.id, + "device_name": device.name, + "source": source, + })); + } +} + diff --git a/src/db.rs b/src/db.rs index a0b4c00..fb774b6 100644 --- a/src/db.rs +++ b/src/db.rs @@ -14,659 +14,14 @@ pub struct Db { conn: Arc>, } -impl Db { - pub fn open(path: &Path) -> Result { - let conn = Connection::open(path) - .with_context(|| format!("cannot open SQLite database {}", path.display()))?; - conn.busy_timeout(std::time::Duration::from_secs(5))?; - conn.execute_batch(queries::INIT_SCHEMA)?; - Ok(Self { conn: Arc::new(Mutex::new(conn)) }) - } - fn lock(&self) -> Result> { - self.conn.lock().map_err(|_| anyhow::anyhow!("database mutex poisoned")) - } - - fn from_json(payload: String) -> Result { - Ok(serde_json::from_str(&payload)?) - } - - fn to_json(value: &T) -> Result { - Ok(serde_json::to_string(value)?) - } - - pub fn count_devices(&self) -> Result { - let conn = self.lock()?; - let count: i64 = conn.query_row(queries::COUNT_DEVICES, [], |row| row.get(0))?; - Ok(count.max(0) as u64) - } - - pub fn save_device(&self, device: &Device) -> Result<()> { - let payload = Self::to_json(device)?; - let conn = self.lock()?; - conn.execute( - queries::UPSERT_DEVICE, - params![device.id, device.mac, device.name, device.ip, device.simulated as i64, payload, device.updated_at.to_rfc3339()], - )?; - Ok(()) - } - - pub fn list_devices(&self) -> Result> { - let conn = self.lock()?; - let mut stmt = conn.prepare(queries::LIST_DEVICES)?; - let payloads = stmt.query_map([], |row| row.get::<_, String>(0))? - .collect::, _>>()?; - payloads.into_iter().map(Self::from_json).collect() - } - - pub fn get_device(&self, id: &str) -> Result> { - let conn = self.lock()?; - let payload: Option = conn.query_row(queries::GET_DEVICE_BY_ID, [id], |row| row.get(0)).optional()?; - payload.map(Self::from_json).transpose() - } - - pub fn get_device_by_mac(&self, mac: &str) -> Result> { - let conn = self.lock()?; - let payload: Option = conn.query_row(queries::GET_DEVICE_BY_MAC, [mac], |row| row.get(0)).optional()?; - payload.map(Self::from_json).transpose() - } - - pub fn delete_device(&self, id: &str) -> Result { - let mut conn = self.lock()?; - let tx = conn.transaction()?; - tx.execute(queries::DELETE_DEVICE_READINGS, [id])?; - tx.execute(queries::DELETE_ZONE_READINGS_BY_DEVICE_ID, [id])?; - tx.execute(queries::DELETE_SCHEDULES_BY_DEVICE_ID, [id])?; - tx.execute(queries::DELETE_ZONES_BY_DEVICE_ID, [id])?; - let changed = tx.execute(queries::DELETE_DEVICE, [id])? > 0; - tx.commit()?; - Ok(changed) - } - - pub fn save_zone(&self, zone: &Zone) -> Result<()> { - let payload = Self::to_json(zone)?; - let conn = self.lock()?; - conn.execute( - queries::UPSERT_ZONE, - params![zone.id, payload, zone.updated_at.to_rfc3339()], - )?; - Ok(()) - } - - pub fn list_zones(&self) -> Result> { - self.list_payloads(queries::LIST_ZONES) - } - - pub fn get_zone(&self, id: &str) -> Result> { - self.get_payload(queries::GET_ZONE, id) - } - - pub fn delete_zone(&self, id: &str) -> Result { - let mut conn = self.lock()?; - let tx = conn.transaction()?; - tx.execute(queries::DELETE_SCHEDULES_BY_ZONE_ID, [id])?; - tx.execute(queries::DELETE_ZONE_READINGS_BY_ZONE_ID, [id])?; - let changed = tx.execute(queries::DELETE_ZONE, [id])? > 0; - tx.commit()?; - Ok(changed) - } - - pub fn save_group(&self, group: &ClimateGroup) -> Result<()> { - let payload = Self::to_json(group)?; - let conn = self.lock()?; - conn.execute( - queries::UPSERT_GROUP, - params![group.id, payload, group.updated_at.to_rfc3339()], - )?; - Ok(()) - } - - pub fn list_groups(&self) -> Result> { - self.list_payloads(queries::LIST_GROUPS) - } - - pub fn get_group(&self, id: &str) -> Result> { - self.get_payload(queries::GET_GROUP, id) - } - - pub fn delete_group(&self, id: &str) -> Result { - self.delete_by_id("groups", id) - } - - pub fn save_schedule(&self, schedule: &Schedule) -> Result<()> { - let payload = Self::to_json(schedule)?; - let conn = self.lock()?; - conn.execute( - queries::UPSERT_SCHEDULE, - params![schedule.id, schedule.zone_id, payload, schedule.updated_at.to_rfc3339()], - )?; - Ok(()) - } - - pub fn list_schedules(&self) -> Result> { - self.list_payloads(queries::LIST_SCHEDULES) - } - - pub fn get_schedule(&self, id: &str) -> Result> { - self.get_payload(queries::GET_SCHEDULE, id) - } - - pub fn delete_schedule(&self, id: &str) -> Result { - self.delete_by_id("schedules", id) - } - - pub fn replace_schedules_for_zone(&self, zone_id: &str, schedules: &[Schedule]) -> Result<()> { - let mut conn = self.lock()?; - let tx = conn.transaction()?; - tx.execute(queries::DELETE_SCHEDULES_BY_ZONE_ID, [zone_id])?; - for schedule in schedules { - let payload = Self::to_json(schedule)?; - tx.execute( - queries::UPSERT_SCHEDULE, - params![schedule.id, schedule.zone_id, payload, schedule.updated_at.to_rfc3339()], - )?; - } - tx.commit()?; - Ok(()) - } - - pub fn save_automation(&self, item: &Automation) -> Result<()> { - let payload = Self::to_json(item)?; - let conn = self.lock()?; - conn.execute( - queries::UPSERT_AUTOMATION, - params![item.id, payload, item.updated_at.to_rfc3339()], - )?; - Ok(()) - } - - pub fn list_automations(&self) -> Result> { - self.list_payloads(queries::LIST_AUTOMATIONS) - } - - pub fn get_automation(&self, id: &str) -> Result> { - self.get_payload(queries::GET_AUTOMATION, id) - } - - pub fn delete_automation(&self, id: &str) -> Result { - self.delete_by_id("automations", id) - } - - fn list_payloads(&self, sql: &str) -> Result> { - let conn = self.lock()?; - let mut stmt = conn.prepare(sql)?; - let payloads = stmt.query_map([], |row| row.get::<_, String>(0))? - .collect::, _>>()?; - payloads.into_iter().map(Self::from_json).collect() - } - - fn get_payload(&self, sql: &str, id: &str) -> Result> { - let conn = self.lock()?; - let payload: Option = conn.query_row(sql, [id], |row| row.get(0)).optional()?; - payload.map(Self::from_json).transpose() - } - - fn delete_by_id(&self, table: &str, id: &str) -> Result { - let sql = match table { - "schedules" => queries::DELETE_SCHEDULE, - "automations" => queries::DELETE_AUTOMATION, - "groups" => queries::DELETE_GROUP, - _ => anyhow::bail!("unsupported table"), - }; - let conn = self.lock()?; - Ok(conn.execute(sql, [id])? > 0) - } - - pub fn add_reading(&self, reading: &Reading) -> Result { - let conn = self.lock()?; - conn.execute( - queries::INSERT_READING, - params![reading.device_id, reading.timestamp.to_rfc3339(), reading.indoor_temperature, - reading.outdoor_temperature, reading.target_temperature, reading.power as i64, reading.source], - )?; - Ok(conn.last_insert_rowid()) - } - - pub fn list_readings(&self, device_id: Option<&str>, since: DateTime, limit: u32) -> Result> { - let conn = self.lock()?; - let limit = limit.clamp(1, 5000) as i64; - let mut rows_out = Vec::new(); - if let Some(device_id) = device_id { - let mut stmt = conn.prepare(queries::LIST_READINGS_BY_DEVICE)?; - let rows = stmt.query_map(params![device_id, since.to_rfc3339(), limit], Self::map_reading)?; - for row in rows { rows_out.push(row?); } - } else { - let mut stmt = conn.prepare(queries::LIST_READINGS_ALL)?; - let rows = stmt.query_map(params![since.to_rfc3339(), limit], Self::map_reading)?; - for row in rows { rows_out.push(row?); } - } - Ok(rows_out) - } - - pub fn list_device_history(&self, device_id: Option<&str>, since: DateTime, bucket_seconds: i64, limit: u32) -> Result> { - let conn = self.lock()?; - let bucket_seconds = bucket_seconds.max(1); - let limit = limit.clamp(1, 20_000) as i64; - let mut rows_out = Vec::new(); - if let Some(device_id) = device_id { - let mut stmt = conn.prepare(queries::LIST_DEVICE_HISTORY_BY_DEVICE_BUCKETED)?; - let rows = stmt.query_map(params![device_id, since.to_rfc3339(), bucket_seconds, limit], Self::map_reading)?; - for row in rows { rows_out.push(row?); } - } else { - let mut stmt = conn.prepare(queries::LIST_DEVICE_HISTORY_ALL_BUCKETED)?; - let rows = stmt.query_map(params![since.to_rfc3339(), bucket_seconds, limit], Self::map_reading)?; - for row in rows { rows_out.push(row?); } - } - Ok(rows_out) - } - - fn map_reading(row: &rusqlite::Row<'_>) -> rusqlite::Result { - let timestamp: String = row.get(2)?; - Ok(Reading { - id: row.get(0)?, - device_id: row.get(1)?, - timestamp: DateTime::parse_from_rfc3339(×tamp) - .map(|v| v.with_timezone(&Utc)) - .unwrap_or_else(|_| Utc::now()), - indoor_temperature: row.get(3)?, - outdoor_temperature: row.get(4)?, - target_temperature: row.get(5)?, - power: row.get::<_, i64>(6)? != 0, - source: row.get(7)?, - }) - } - - pub fn history_before(&self, before: DateTime, limit_per_family: u32) -> Result<(Vec, Vec, Vec)> { - let conn = self.lock()?; - let limit = limit_per_family.clamp(1, 5_000) as i64; - let before = before.to_rfc3339(); - - let devices = { - let mut stmt = conn.prepare(queries::LIST_DEVICE_HISTORY_BEFORE)?; - let rows = stmt.query_map(params![before.clone(), limit], Self::map_reading)?; - rows.collect::>>()? - }; - let zones = { - let mut stmt = conn.prepare(queries::LIST_ZONE_HISTORY_BEFORE)?; - let rows = stmt.query_map(params![before.clone(), limit], Self::map_zone_reading)?; - rows.collect::>>()? - }; - let ha = { - let mut stmt = conn.prepare(queries::LIST_HA_HISTORY_BEFORE)?; - let rows = stmt.query_map(params![before, limit], Self::map_ha_reading)?; - rows.collect::>>()? - }; - Ok((devices, zones, ha)) - } - - pub fn delete_history_batch(&self, devices: &[Reading], zones: &[ZoneReading], ha: &[HaReading]) -> Result { - let mut conn = self.lock()?; - let tx = conn.transaction()?; - let mut changed = 0_u64; - for row in devices { changed += tx.execute(queries::DELETE_READING_BY_ID, [row.id])? as u64; } - for row in zones { changed += tx.execute(queries::DELETE_ZONE_READING_BY_ID, [row.id])? as u64; } - for row in ha { changed += tx.execute(queries::DELETE_HA_READING_BY_ID, [row.id])? as u64; } - tx.commit()?; - Ok(changed) - } - - pub fn prune_readings(&self, retention_days: i64) -> Result { - let before = Utc::now() - Duration::days(retention_days.max(1)); - let conn = self.lock()?; - let device = conn.execute(queries::PRUNE_READINGS, [before.to_rfc3339()])? as u64; - let zone = conn.execute(queries::PRUNE_ZONE_READINGS, [before.to_rfc3339()])? as u64; - let ha = conn.execute(queries::PRUNE_HA_READINGS, [before.to_rfc3339()])? as u64; - Ok(device + zone + ha) - } - - /// Compact history to the same practical resolution used by charts. - /// 1-7 days: one sample / 10 minutes, 7+ days: one sample / 30 minutes. - pub fn compact_history(&self, retention_days: i64) -> Result { - let now = Utc::now(); - let one_day = now - Duration::days(1); - let seven_days = now - Duration::days(7); - let retention = now - Duration::days(retention_days.max(1)); - let conn = self.lock()?; - let mut changed = 0_u64; - for (bucket, older_than, newer_than) in [ - (600_i64, one_day, seven_days), - (1800_i64, seven_days, retention), - ] { - if older_than <= newer_than { continue; } - let args = params![bucket, older_than.to_rfc3339(), newer_than.to_rfc3339()]; - changed += conn.execute(queries::COMPACT_DEVICE_HISTORY, args)? as u64; - let args = params![bucket, older_than.to_rfc3339(), newer_than.to_rfc3339()]; - changed += conn.execute(queries::COMPACT_ZONE_HISTORY, args)? as u64; - let args = params![bucket, older_than.to_rfc3339(), newer_than.to_rfc3339()]; - changed += conn.execute(queries::COMPACT_HA_HISTORY, args)? as u64; - } - conn.execute_batch("PRAGMA optimize;")?; - Ok(changed) - } - - pub fn add_zone_reading_if_due(&self, reading: &ZoneReading, min_interval_seconds: i64) -> Result { - let cutoff = reading.timestamp.clone() - Duration::seconds(min_interval_seconds.max(1)); - let conn = self.lock()?; - let changed = conn.execute( - queries::INSERT_ZONE_READING_IF_DUE, - params![ - reading.zone_id, - reading.device_id, - reading.timestamp.to_rfc3339(), - reading.gree_temperature, - reading.external_temperature, - reading.control_temperature, - reading.target_temperature, - reading.device_setpoint, - reading.outdoor_temperature, - reading.power as i64, - reading.mode, - reading.fan_speed as i64, - reading.demand as i64, - reading.control_source, - reading.active_preset, - cutoff.to_rfc3339(), - ], - )?; - Ok(changed > 0) - } - - pub fn list_zone_history(&self, zone_id: Option<&str>, since: DateTime, bucket_seconds: i64, limit: u32) -> Result> { - let conn = self.lock()?; - let bucket_seconds = bucket_seconds.max(1); - let limit = limit.clamp(1, 20_000) as i64; - let mut rows_out = Vec::new(); - if let Some(zone_id) = zone_id { - let mut stmt = conn.prepare(queries::LIST_ZONE_HISTORY_BY_ZONE_BUCKETED)?; - let rows = stmt.query_map(params![zone_id, since.to_rfc3339(), bucket_seconds, limit], Self::map_zone_reading)?; - for row in rows { rows_out.push(row?); } - } else { - let mut stmt = conn.prepare(queries::LIST_ZONE_HISTORY_ALL_BUCKETED)?; - let rows = stmt.query_map(params![since.to_rfc3339(), bucket_seconds, limit], Self::map_zone_reading)?; - for row in rows { rows_out.push(row?); } - } - Ok(rows_out) - } - - fn map_zone_reading(row: &rusqlite::Row<'_>) -> rusqlite::Result { - let timestamp: String = row.get(3)?; - Ok(ZoneReading { - id: row.get(0)?, - zone_id: row.get(1)?, - device_id: row.get(2)?, - timestamp: DateTime::parse_from_rfc3339(×tamp) - .map(|v| v.with_timezone(&Utc)) - .unwrap_or_else(|_| Utc::now()), - gree_temperature: row.get(4)?, - external_temperature: row.get(5)?, - control_temperature: row.get(6)?, - target_temperature: row.get(7)?, - device_setpoint: row.get(8)?, - outdoor_temperature: row.get(9)?, - power: row.get::<_, i64>(10)? != 0, - mode: row.get(11)?, - fan_speed: row.get::<_, i64>(12)?.clamp(0, 255) as u8, - demand: row.get::<_, i64>(13)? != 0, - control_source: row.get(14)?, - active_preset: row.get(15)?, - }) - } - - pub fn add_ha_reading_if_due(&self, reading: &HaReading, min_interval_seconds: i64) -> Result { - let cutoff = reading.timestamp.clone() - Duration::seconds(min_interval_seconds.max(1)); - let conn = self.lock()?; - let changed = conn.execute( - queries::INSERT_HA_READING_IF_DUE, - params![ - reading.entity_id, - reading.zone_id, - reading.kind, - reading.timestamp.to_rfc3339(), - reading.temperature, - cutoff.to_rfc3339(), - ], - )?; - Ok(changed > 0) - } - - pub fn list_ha_history(&self, entity_id: Option<&str>, since: DateTime, bucket_seconds: i64, limit: u32) -> Result> { - let conn = self.lock()?; - let bucket_seconds = bucket_seconds.max(1); - let limit = limit.clamp(1, 20_000) as i64; - let mut rows_out = Vec::new(); - if let Some(entity_id) = entity_id { - let mut stmt = conn.prepare(queries::LIST_HA_HISTORY_BY_ENTITY_BUCKETED)?; - let rows = stmt.query_map(params![entity_id, since.to_rfc3339(), bucket_seconds, limit], Self::map_ha_reading)?; - for row in rows { rows_out.push(row?); } - } else { - let mut stmt = conn.prepare(queries::LIST_HA_HISTORY_ALL_BUCKETED)?; - let rows = stmt.query_map(params![since.to_rfc3339(), bucket_seconds, limit], Self::map_ha_reading)?; - for row in rows { rows_out.push(row?); } - } - Ok(rows_out) - } - - fn map_ha_reading(row: &rusqlite::Row<'_>) -> rusqlite::Result { - let timestamp: String = row.get(4)?; - Ok(HaReading { - id: row.get(0)?, - entity_id: row.get(1)?, - zone_id: row.get(2)?, - kind: row.get(3)?, - timestamp: DateTime::parse_from_rfc3339(×tamp) - .map(|value| value.with_timezone(&Utc)) - .unwrap_or_else(|_| Utc::now()), - temperature: row.get(5)?, - }) - } - - pub fn history_counts(&self) -> Result<(u64, u64, u64)> { - let conn = self.lock()?; - let (device, zone, ha): (i64, i64, i64) = conn.query_row(queries::HISTORY_COUNTS, [], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))?; - Ok((device.max(0) as u64, zone.max(0) as u64, ha.max(0) as u64)) - } - - pub fn log_event(&self, level: &str, kind: &str, message: &str, metadata: &Value) -> Result { - let conn = self.lock()?; - conn.execute( - queries::INSERT_EVENT, - params![Utc::now().to_rfc3339(), level, kind, message, serde_json::to_string(metadata)?], - )?; - Ok(conn.last_insert_rowid()) - } - - pub fn list_events(&self, limit: u32) -> Result> { - let conn = self.lock()?; - let mut stmt = conn.prepare(queries::LIST_EVENTS)?; - let rows = stmt.query_map([limit.clamp(1, 1000) as i64], |row| { - let ts: String = row.get(1)?; - let metadata: String = row.get(5)?; - Ok(EventLog { - id: row.get(0)?, - timestamp: DateTime::parse_from_rfc3339(&ts).map(|v| v.with_timezone(&Utc)).unwrap_or_else(|_| Utc::now()), - level: row.get(2)?, - kind: row.get(3)?, - message: row.get(4)?, - metadata: serde_json::from_str(&metadata).unwrap_or(Value::Null), - }) - })?; - rows.collect::, _>>().map_err(Into::into) - } - - pub fn prune_events(&self, retention_days: i64) -> Result { - let before = Utc::now() - Duration::days(retention_days.max(1)); - let conn = self.lock()?; - Ok(conn.execute(queries::PRUNE_EVENTS, [before.to_rfc3339()])? as u64) - } - - pub fn list_api_tokens(&self) -> Result> { - let conn = self.lock()?; - let mut stmt = conn.prepare(queries::LIST_API_TOKENS)?; - let rows = stmt.query_map([], |row| { - let created_at: String = row.get(3)?; - Ok(ApiTokenInfo { - id: row.get(0)?, - name: row.get(1)?, - token_prefix: row.get(2)?, - created_at: DateTime::parse_from_rfc3339(&created_at) - .map(|value| value.with_timezone(&Utc)) - .unwrap_or_else(|_| Utc::now()), - }) - })?; - rows.collect::, _>>().map_err(Into::into) - } - - pub fn save_api_token(&self, token: &ApiTokenInfo, token_hash: &str) -> Result<()> { - let conn = self.lock()?; - conn.execute( - queries::INSERT_API_TOKEN, - params![token.id, token.name, token_hash, token.token_prefix, token.created_at.to_rfc3339()], - )?; - Ok(()) - } - - pub fn api_token_exists(&self, token_hash: &str) -> Result { - let conn = self.lock()?; - let found: Option = conn.query_row( - queries::API_TOKEN_EXISTS, - [token_hash], - |row| row.get(0), - ).optional()?; - Ok(found.is_some()) - } - - pub fn delete_api_token(&self, id: &str) -> Result { - let conn = self.lock()?; - Ok(conn.execute(queries::DELETE_API_TOKEN, [id])? > 0) - } - - pub fn export_configuration(&self, settings: RuntimeSettings) -> Result { - Ok(ConfigurationExport { - format_version: 1, - exported_at: Utc::now(), - settings, - devices: self.list_devices()?, - zones: self.list_zones()?, - groups: self.list_groups()?, - schedules: self.list_schedules()?, - automations: self.list_automations()?, - }) - } - - pub fn replace_configuration(&self, export: &ConfigurationExport) -> Result<()> { - let mut conn = self.lock()?; - let tx = conn.transaction()?; - tx.execute_batch(queries::CLEAR_CONFIGURATION)?; - for device in &export.devices { - let payload = Self::to_json(device)?; - tx.execute(queries::UPSERT_DEVICE, params![device.id, device.mac, device.name, device.ip, device.simulated as i64, payload, device.updated_at.to_rfc3339()])?; - } - for zone in &export.zones { - let payload = Self::to_json(zone)?; - tx.execute(queries::UPSERT_ZONE, params![zone.id, payload, zone.updated_at.to_rfc3339()])?; - } - for group in &export.groups { - let payload = Self::to_json(group)?; - tx.execute(queries::UPSERT_GROUP, params![group.id, payload, group.updated_at.to_rfc3339()])?; - } - for schedule in &export.schedules { - let payload = Self::to_json(schedule)?; - tx.execute(queries::UPSERT_SCHEDULE, params![schedule.id, schedule.zone_id, payload, schedule.updated_at.to_rfc3339()])?; - } - for item in &export.automations { - let payload = Self::to_json(item)?; - tx.execute(queries::UPSERT_AUTOMATION, params![item.id, payload, item.updated_at.to_rfc3339()])?; - } - let settings_json = Self::to_json(&export.settings)?; - tx.execute(queries::UPSERT_RUNTIME_SETTINGS, params![settings_json, Utc::now().to_rfc3339()])?; - tx.commit()?; - Ok(()) - } - - pub fn load_runtime_settings(&self) -> Result> { - let conn = self.lock()?; - let value: Option = conn.query_row(queries::LOAD_RUNTIME_SETTINGS, [], |row| row.get(0)).optional()?; - value.map(Self::from_json).transpose() - } - - pub fn save_runtime_settings(&self, settings: &RuntimeSettings) -> Result<()> { - let value = Self::to_json(settings)?; - let conn = self.lock()?; - conn.execute( - queries::UPSERT_RUNTIME_SETTINGS, - params![value, Utc::now().to_rfc3339()], - )?; - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::models::{ApiTokenInfo, Device, HaReading, Reading}; - - #[test] - fn history_compaction_keeps_one_sample_per_old_bucket() { - let dir = tempfile::tempdir().unwrap(); - let db = Db::open(&dir.path().join("compact.db")).unwrap(); - let device = Device::simulated_default(); - db.save_device(&device).unwrap(); - let seconds = (Utc::now().timestamp() - 2 * 86_400) / 600 * 600; - let base = DateTime::::from_timestamp(seconds, 0).unwrap(); - for offset in [10_i64, 20_i64] { - db.add_reading(&Reading { - id: 0, device_id: device.id.clone(), timestamp: base + Duration::seconds(offset), - indoor_temperature: Some(22.0), outdoor_temperature: None, target_temperature: 23.0, - power: true, source: "gree".into(), - }).unwrap(); - } - assert_eq!(db.history_counts().unwrap().0, 2); - assert_eq!(db.compact_history(30).unwrap(), 1); - assert_eq!(db.history_counts().unwrap().0, 1); - } - - #[test] - fn sqlite_round_trip() { - let dir = tempfile::tempdir().unwrap(); - let db = Db::open(&dir.path().join("test.db")).unwrap(); - let device = Device::simulated_default(); - db.save_device(&device).unwrap(); - let loaded = db.get_device(&device.id).unwrap().unwrap(); - assert_eq!(loaded.mac, device.mac); - assert_eq!(db.list_devices().unwrap().len(), 1); - db.log_event("info", "test", "ok", &serde_json::json!({"a":1})).unwrap(); - assert_eq!(db.list_events(10).unwrap().len(), 1); - { - let conn = db.lock().unwrap(); - conn.execute(queries::INSERT_EVENT, rusqlite::params![(Utc::now() - Duration::days(40)).to_rfc3339(), "info", "old", "old", "{}"] ).unwrap(); - } - assert_eq!(db.prune_events(30).unwrap(), 1); - assert_eq!(db.list_events(10).unwrap().len(), 1); - - let access_token = ApiTokenInfo { - id: "token-1".into(), - name: "Home Assistant".into(), - token_prefix: "gree_controller_test...".into(), - created_at: Utc::now(), - }; - db.save_api_token(&access_token, "test-hash").unwrap(); - assert!(db.api_token_exists("test-hash").unwrap()); - assert_eq!(db.list_api_tokens().unwrap().len(), 1); - assert!(db.delete_api_token(&access_token.id).unwrap()); - assert!(!db.api_token_exists("test-hash").unwrap()); - - let now = Utc::now(); - db.add_reading(&Reading { - id: 0, device_id: device.id.clone(), timestamp: now.clone(), indoor_temperature: Some(22.5), - outdoor_temperature: Some(31.0), target_temperature: 23.0, power: true, source: "gree".into(), - }).unwrap(); - assert_eq!(db.list_device_history(Some(&device.id), now.clone() - Duration::minutes(1), 30, 100).unwrap().len(), 1); - - db.add_ha_reading_if_due(&HaReading { - id: 0, entity_id: "sensor.room".into(), zone_id: Some("zone-room".into()), kind: "room".into(), - timestamp: now.clone(), temperature: 22.1, - }, 15).unwrap(); - assert_eq!(db.list_ha_history(Some("sensor.room"), now.clone() - Duration::minutes(1), 30, 100).unwrap().len(), 1); - assert_eq!(db.history_counts().unwrap(), (1, 0, 1)); - } -} +// Functional source split intentionally keeps items in the existing module namespace. +include!("db/core_devices.rs"); +include!("db/climate.rs"); +include!("db/schedules_automations.rs"); +include!("db/device_history.rs"); +include!("db/zone_history.rs"); +include!("db/ha_history.rs"); +include!("db/events_tokens.rs"); +include!("db/configuration.rs"); +include!("db/tests.rs"); diff --git a/src/db/climate.rs b/src/db/climate.rs new file mode 100644 index 0000000..67897f6 --- /dev/null +++ b/src/db/climate.rs @@ -0,0 +1,52 @@ +impl Db { + pub fn save_zone(&self, zone: &Zone) -> Result<()> { + let payload = Self::to_json(zone)?; + let conn = self.lock()?; + conn.execute( + queries::UPSERT_ZONE, + params![zone.id, payload, zone.updated_at.to_rfc3339()], + )?; + Ok(()) + } + + pub fn list_zones(&self) -> Result> { + self.list_payloads(queries::LIST_ZONES) + } + + pub fn get_zone(&self, id: &str) -> Result> { + self.get_payload(queries::GET_ZONE, id) + } + + pub fn delete_zone(&self, id: &str) -> Result { + let mut conn = self.lock()?; + let tx = conn.transaction()?; + tx.execute(queries::DELETE_SCHEDULES_BY_ZONE_ID, [id])?; + tx.execute(queries::DELETE_ZONE_READINGS_BY_ZONE_ID, [id])?; + let changed = tx.execute(queries::DELETE_ZONE, [id])? > 0; + tx.commit()?; + Ok(changed) + } + + pub fn save_group(&self, group: &ClimateGroup) -> Result<()> { + let payload = Self::to_json(group)?; + let conn = self.lock()?; + conn.execute( + queries::UPSERT_GROUP, + params![group.id, payload, group.updated_at.to_rfc3339()], + )?; + Ok(()) + } + + pub fn list_groups(&self) -> Result> { + self.list_payloads(queries::LIST_GROUPS) + } + + pub fn get_group(&self, id: &str) -> Result> { + self.get_payload(queries::GET_GROUP, id) + } + + pub fn delete_group(&self, id: &str) -> Result { + self.delete_by_id("groups", id) + } + +} diff --git a/src/db/configuration.rs b/src/db/configuration.rs new file mode 100644 index 0000000..0ebfec5 --- /dev/null +++ b/src/db/configuration.rs @@ -0,0 +1,60 @@ +impl Db { + pub fn export_configuration(&self, settings: RuntimeSettings) -> Result { + Ok(ConfigurationExport { + format_version: 1, + exported_at: Utc::now(), + settings, + devices: self.list_devices()?, + zones: self.list_zones()?, + groups: self.list_groups()?, + schedules: self.list_schedules()?, + automations: self.list_automations()?, + }) + } + + pub fn replace_configuration(&self, export: &ConfigurationExport) -> Result<()> { + let mut conn = self.lock()?; + let tx = conn.transaction()?; + tx.execute_batch(queries::CLEAR_CONFIGURATION)?; + for device in &export.devices { + let payload = Self::to_json(device)?; + tx.execute(queries::UPSERT_DEVICE, params![device.id, device.mac, device.name, device.ip, device.simulated as i64, payload, device.updated_at.to_rfc3339()])?; + } + for zone in &export.zones { + let payload = Self::to_json(zone)?; + tx.execute(queries::UPSERT_ZONE, params![zone.id, payload, zone.updated_at.to_rfc3339()])?; + } + for group in &export.groups { + let payload = Self::to_json(group)?; + tx.execute(queries::UPSERT_GROUP, params![group.id, payload, group.updated_at.to_rfc3339()])?; + } + for schedule in &export.schedules { + let payload = Self::to_json(schedule)?; + tx.execute(queries::UPSERT_SCHEDULE, params![schedule.id, schedule.zone_id, payload, schedule.updated_at.to_rfc3339()])?; + } + for item in &export.automations { + let payload = Self::to_json(item)?; + tx.execute(queries::UPSERT_AUTOMATION, params![item.id, payload, item.updated_at.to_rfc3339()])?; + } + let settings_json = Self::to_json(&export.settings)?; + tx.execute(queries::UPSERT_RUNTIME_SETTINGS, params![settings_json, Utc::now().to_rfc3339()])?; + tx.commit()?; + Ok(()) + } + + pub fn load_runtime_settings(&self) -> Result> { + let conn = self.lock()?; + let value: Option = conn.query_row(queries::LOAD_RUNTIME_SETTINGS, [], |row| row.get(0)).optional()?; + value.map(Self::from_json).transpose() + } + + pub fn save_runtime_settings(&self, settings: &RuntimeSettings) -> Result<()> { + let value = Self::to_json(settings)?; + let conn = self.lock()?; + conn.execute( + queries::UPSERT_RUNTIME_SETTINGS, + params![value, Utc::now().to_rfc3339()], + )?; + Ok(()) + } +} diff --git a/src/db/core_devices.rs b/src/db/core_devices.rs new file mode 100644 index 0000000..2ed914b --- /dev/null +++ b/src/db/core_devices.rs @@ -0,0 +1,70 @@ +impl Db { + pub fn open(path: &Path) -> Result { + let conn = Connection::open(path) + .with_context(|| format!("cannot open SQLite database {}", path.display()))?; + conn.busy_timeout(std::time::Duration::from_secs(5))?; + conn.execute_batch(queries::INIT_SCHEMA)?; + Ok(Self { conn: Arc::new(Mutex::new(conn)) }) + } + + fn lock(&self) -> Result> { + self.conn.lock().map_err(|_| anyhow::anyhow!("database mutex poisoned")) + } + + fn from_json(payload: String) -> Result { + Ok(serde_json::from_str(&payload)?) + } + + fn to_json(value: &T) -> Result { + Ok(serde_json::to_string(value)?) + } + + pub fn count_devices(&self) -> Result { + let conn = self.lock()?; + let count: i64 = conn.query_row(queries::COUNT_DEVICES, [], |row| row.get(0))?; + Ok(count.max(0) as u64) + } + + pub fn save_device(&self, device: &Device) -> Result<()> { + let payload = Self::to_json(device)?; + let conn = self.lock()?; + conn.execute( + queries::UPSERT_DEVICE, + params![device.id, device.mac, device.name, device.ip, device.simulated as i64, payload, device.updated_at.to_rfc3339()], + )?; + Ok(()) + } + + pub fn list_devices(&self) -> Result> { + let conn = self.lock()?; + let mut stmt = conn.prepare(queries::LIST_DEVICES)?; + let payloads = stmt.query_map([], |row| row.get::<_, String>(0))? + .collect::, _>>()?; + payloads.into_iter().map(Self::from_json).collect() + } + + pub fn get_device(&self, id: &str) -> Result> { + let conn = self.lock()?; + let payload: Option = conn.query_row(queries::GET_DEVICE_BY_ID, [id], |row| row.get(0)).optional()?; + payload.map(Self::from_json).transpose() + } + + pub fn get_device_by_mac(&self, mac: &str) -> Result> { + let conn = self.lock()?; + let payload: Option = conn.query_row(queries::GET_DEVICE_BY_MAC, [mac], |row| row.get(0)).optional()?; + payload.map(Self::from_json).transpose() + } + + pub fn delete_device(&self, id: &str) -> Result { + let mut conn = self.lock()?; + let tx = conn.transaction()?; + tx.execute(queries::DELETE_DEVICE_READINGS, [id])?; + tx.execute(queries::DELETE_ZONE_READINGS_BY_DEVICE_ID, [id])?; + tx.execute(queries::DELETE_SCHEDULES_BY_DEVICE_ID, [id])?; + tx.execute(queries::DELETE_ZONES_BY_DEVICE_ID, [id])?; + let changed = tx.execute(queries::DELETE_DEVICE, [id])? > 0; + tx.commit()?; + Ok(changed) + } + +} diff --git a/src/db/device_history.rs b/src/db/device_history.rs new file mode 100644 index 0000000..384ff56 --- /dev/null +++ b/src/db/device_history.rs @@ -0,0 +1,129 @@ +impl Db { + pub fn add_reading(&self, reading: &Reading) -> Result { + let conn = self.lock()?; + conn.execute( + queries::INSERT_READING, + params![reading.device_id, reading.timestamp.to_rfc3339(), reading.indoor_temperature, + reading.outdoor_temperature, reading.target_temperature, reading.power as i64, reading.source], + )?; + Ok(conn.last_insert_rowid()) + } + + pub fn list_readings(&self, device_id: Option<&str>, since: DateTime, limit: u32) -> Result> { + let conn = self.lock()?; + let limit = limit.clamp(1, 5000) as i64; + let mut rows_out = Vec::new(); + if let Some(device_id) = device_id { + let mut stmt = conn.prepare(queries::LIST_READINGS_BY_DEVICE)?; + let rows = stmt.query_map(params![device_id, since.to_rfc3339(), limit], Self::map_reading)?; + for row in rows { rows_out.push(row?); } + } else { + let mut stmt = conn.prepare(queries::LIST_READINGS_ALL)?; + let rows = stmt.query_map(params![since.to_rfc3339(), limit], Self::map_reading)?; + for row in rows { rows_out.push(row?); } + } + Ok(rows_out) + } + + pub fn list_device_history(&self, device_id: Option<&str>, since: DateTime, bucket_seconds: i64, limit: u32) -> Result> { + let conn = self.lock()?; + let bucket_seconds = bucket_seconds.max(1); + let limit = limit.clamp(1, 20_000) as i64; + let mut rows_out = Vec::new(); + if let Some(device_id) = device_id { + let mut stmt = conn.prepare(queries::LIST_DEVICE_HISTORY_BY_DEVICE_BUCKETED)?; + let rows = stmt.query_map(params![device_id, since.to_rfc3339(), bucket_seconds, limit], Self::map_reading)?; + for row in rows { rows_out.push(row?); } + } else { + let mut stmt = conn.prepare(queries::LIST_DEVICE_HISTORY_ALL_BUCKETED)?; + let rows = stmt.query_map(params![since.to_rfc3339(), bucket_seconds, limit], Self::map_reading)?; + for row in rows { rows_out.push(row?); } + } + Ok(rows_out) + } + + fn map_reading(row: &rusqlite::Row<'_>) -> rusqlite::Result { + let timestamp: String = row.get(2)?; + Ok(Reading { + id: row.get(0)?, + device_id: row.get(1)?, + timestamp: DateTime::parse_from_rfc3339(×tamp) + .map(|v| v.with_timezone(&Utc)) + .unwrap_or_else(|_| Utc::now()), + indoor_temperature: row.get(3)?, + outdoor_temperature: row.get(4)?, + target_temperature: row.get(5)?, + power: row.get::<_, i64>(6)? != 0, + source: row.get(7)?, + }) + } + + pub fn history_before(&self, before: DateTime, limit_per_family: u32) -> Result<(Vec, Vec, Vec)> { + let conn = self.lock()?; + let limit = limit_per_family.clamp(1, 5_000) as i64; + let before = before.to_rfc3339(); + + let devices = { + let mut stmt = conn.prepare(queries::LIST_DEVICE_HISTORY_BEFORE)?; + let rows = stmt.query_map(params![before.clone(), limit], Self::map_reading)?; + rows.collect::>>()? + }; + let zones = { + let mut stmt = conn.prepare(queries::LIST_ZONE_HISTORY_BEFORE)?; + let rows = stmt.query_map(params![before.clone(), limit], Self::map_zone_reading)?; + rows.collect::>>()? + }; + let ha = { + let mut stmt = conn.prepare(queries::LIST_HA_HISTORY_BEFORE)?; + let rows = stmt.query_map(params![before, limit], Self::map_ha_reading)?; + rows.collect::>>()? + }; + Ok((devices, zones, ha)) + } + + pub fn delete_history_batch(&self, devices: &[Reading], zones: &[ZoneReading], ha: &[HaReading]) -> Result { + let mut conn = self.lock()?; + let tx = conn.transaction()?; + let mut changed = 0_u64; + for row in devices { changed += tx.execute(queries::DELETE_READING_BY_ID, [row.id])? as u64; } + for row in zones { changed += tx.execute(queries::DELETE_ZONE_READING_BY_ID, [row.id])? as u64; } + for row in ha { changed += tx.execute(queries::DELETE_HA_READING_BY_ID, [row.id])? as u64; } + tx.commit()?; + Ok(changed) + } + + pub fn prune_readings(&self, retention_days: i64) -> Result { + let before = Utc::now() - Duration::days(retention_days.max(1)); + let conn = self.lock()?; + let device = conn.execute(queries::PRUNE_READINGS, [before.to_rfc3339()])? as u64; + let zone = conn.execute(queries::PRUNE_ZONE_READINGS, [before.to_rfc3339()])? as u64; + let ha = conn.execute(queries::PRUNE_HA_READINGS, [before.to_rfc3339()])? as u64; + Ok(device + zone + ha) + } + + /// Compact history to the same practical resolution used by charts. + /// 1-7 days: one sample / 10 minutes, 7+ days: one sample / 30 minutes. + pub fn compact_history(&self, retention_days: i64) -> Result { + let now = Utc::now(); + let one_day = now - Duration::days(1); + let seven_days = now - Duration::days(7); + let retention = now - Duration::days(retention_days.max(1)); + let conn = self.lock()?; + let mut changed = 0_u64; + for (bucket, older_than, newer_than) in [ + (600_i64, one_day, seven_days), + (1800_i64, seven_days, retention), + ] { + if older_than <= newer_than { continue; } + let args = params![bucket, older_than.to_rfc3339(), newer_than.to_rfc3339()]; + changed += conn.execute(queries::COMPACT_DEVICE_HISTORY, args)? as u64; + let args = params![bucket, older_than.to_rfc3339(), newer_than.to_rfc3339()]; + changed += conn.execute(queries::COMPACT_ZONE_HISTORY, args)? as u64; + let args = params![bucket, older_than.to_rfc3339(), newer_than.to_rfc3339()]; + changed += conn.execute(queries::COMPACT_HA_HISTORY, args)? as u64; + } + conn.execute_batch("PRAGMA optimize;")?; + Ok(changed) + } + +} diff --git a/src/db/events_tokens.rs b/src/db/events_tokens.rs new file mode 100644 index 0000000..ecffcae --- /dev/null +++ b/src/db/events_tokens.rs @@ -0,0 +1,82 @@ +impl Db { + pub fn history_counts(&self) -> Result<(u64, u64, u64)> { + let conn = self.lock()?; + let (device, zone, ha): (i64, i64, i64) = conn.query_row(queries::HISTORY_COUNTS, [], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))?; + Ok((device.max(0) as u64, zone.max(0) as u64, ha.max(0) as u64)) + } + + pub fn log_event(&self, level: &str, kind: &str, message: &str, metadata: &Value) -> Result { + let conn = self.lock()?; + conn.execute( + queries::INSERT_EVENT, + params![Utc::now().to_rfc3339(), level, kind, message, serde_json::to_string(metadata)?], + )?; + Ok(conn.last_insert_rowid()) + } + + pub fn list_events(&self, limit: u32) -> Result> { + let conn = self.lock()?; + let mut stmt = conn.prepare(queries::LIST_EVENTS)?; + let rows = stmt.query_map([limit.clamp(1, 1000) as i64], |row| { + let ts: String = row.get(1)?; + let metadata: String = row.get(5)?; + Ok(EventLog { + id: row.get(0)?, + timestamp: DateTime::parse_from_rfc3339(&ts).map(|v| v.with_timezone(&Utc)).unwrap_or_else(|_| Utc::now()), + level: row.get(2)?, + kind: row.get(3)?, + message: row.get(4)?, + metadata: serde_json::from_str(&metadata).unwrap_or(Value::Null), + }) + })?; + rows.collect::, _>>().map_err(Into::into) + } + + pub fn prune_events(&self, retention_days: i64) -> Result { + let before = Utc::now() - Duration::days(retention_days.max(1)); + let conn = self.lock()?; + Ok(conn.execute(queries::PRUNE_EVENTS, [before.to_rfc3339()])? as u64) + } + + pub fn list_api_tokens(&self) -> Result> { + let conn = self.lock()?; + let mut stmt = conn.prepare(queries::LIST_API_TOKENS)?; + let rows = stmt.query_map([], |row| { + let created_at: String = row.get(3)?; + Ok(ApiTokenInfo { + id: row.get(0)?, + name: row.get(1)?, + token_prefix: row.get(2)?, + created_at: DateTime::parse_from_rfc3339(&created_at) + .map(|value| value.with_timezone(&Utc)) + .unwrap_or_else(|_| Utc::now()), + }) + })?; + rows.collect::, _>>().map_err(Into::into) + } + + pub fn save_api_token(&self, token: &ApiTokenInfo, token_hash: &str) -> Result<()> { + let conn = self.lock()?; + conn.execute( + queries::INSERT_API_TOKEN, + params![token.id, token.name, token_hash, token.token_prefix, token.created_at.to_rfc3339()], + )?; + Ok(()) + } + + pub fn api_token_exists(&self, token_hash: &str) -> Result { + let conn = self.lock()?; + let found: Option = conn.query_row( + queries::API_TOKEN_EXISTS, + [token_hash], + |row| row.get(0), + ).optional()?; + Ok(found.is_some()) + } + + pub fn delete_api_token(&self, id: &str) -> Result { + let conn = self.lock()?; + Ok(conn.execute(queries::DELETE_API_TOKEN, [id])? > 0) + } + +} diff --git a/src/db/ha_history.rs b/src/db/ha_history.rs new file mode 100644 index 0000000..0051f52 --- /dev/null +++ b/src/db/ha_history.rs @@ -0,0 +1,50 @@ +impl Db { + pub fn add_ha_reading_if_due(&self, reading: &HaReading, min_interval_seconds: i64) -> Result { + let cutoff = reading.timestamp.clone() - Duration::seconds(min_interval_seconds.max(1)); + let conn = self.lock()?; + let changed = conn.execute( + queries::INSERT_HA_READING_IF_DUE, + params![ + reading.entity_id, + reading.zone_id, + reading.kind, + reading.timestamp.to_rfc3339(), + reading.temperature, + cutoff.to_rfc3339(), + ], + )?; + Ok(changed > 0) + } + + pub fn list_ha_history(&self, entity_id: Option<&str>, since: DateTime, bucket_seconds: i64, limit: u32) -> Result> { + let conn = self.lock()?; + let bucket_seconds = bucket_seconds.max(1); + let limit = limit.clamp(1, 20_000) as i64; + let mut rows_out = Vec::new(); + if let Some(entity_id) = entity_id { + let mut stmt = conn.prepare(queries::LIST_HA_HISTORY_BY_ENTITY_BUCKETED)?; + let rows = stmt.query_map(params![entity_id, since.to_rfc3339(), bucket_seconds, limit], Self::map_ha_reading)?; + for row in rows { rows_out.push(row?); } + } else { + let mut stmt = conn.prepare(queries::LIST_HA_HISTORY_ALL_BUCKETED)?; + let rows = stmt.query_map(params![since.to_rfc3339(), bucket_seconds, limit], Self::map_ha_reading)?; + for row in rows { rows_out.push(row?); } + } + Ok(rows_out) + } + + fn map_ha_reading(row: &rusqlite::Row<'_>) -> rusqlite::Result { + let timestamp: String = row.get(4)?; + Ok(HaReading { + id: row.get(0)?, + entity_id: row.get(1)?, + zone_id: row.get(2)?, + kind: row.get(3)?, + timestamp: DateTime::parse_from_rfc3339(×tamp) + .map(|value| value.with_timezone(&Utc)) + .unwrap_or_else(|_| Utc::now()), + temperature: row.get(5)?, + }) + } + +} diff --git a/src/db/schedules_automations.rs b/src/db/schedules_automations.rs new file mode 100644 index 0000000..fb477d6 --- /dev/null +++ b/src/db/schedules_automations.rs @@ -0,0 +1,86 @@ +impl Db { + pub fn save_schedule(&self, schedule: &Schedule) -> Result<()> { + let payload = Self::to_json(schedule)?; + let conn = self.lock()?; + conn.execute( + queries::UPSERT_SCHEDULE, + params![schedule.id, schedule.zone_id, payload, schedule.updated_at.to_rfc3339()], + )?; + Ok(()) + } + + pub fn list_schedules(&self) -> Result> { + self.list_payloads(queries::LIST_SCHEDULES) + } + + pub fn get_schedule(&self, id: &str) -> Result> { + self.get_payload(queries::GET_SCHEDULE, id) + } + + pub fn delete_schedule(&self, id: &str) -> Result { + self.delete_by_id("schedules", id) + } + + pub fn replace_schedules_for_zone(&self, zone_id: &str, schedules: &[Schedule]) -> Result<()> { + let mut conn = self.lock()?; + let tx = conn.transaction()?; + tx.execute(queries::DELETE_SCHEDULES_BY_ZONE_ID, [zone_id])?; + for schedule in schedules { + let payload = Self::to_json(schedule)?; + tx.execute( + queries::UPSERT_SCHEDULE, + params![schedule.id, schedule.zone_id, payload, schedule.updated_at.to_rfc3339()], + )?; + } + tx.commit()?; + Ok(()) + } + + pub fn save_automation(&self, item: &Automation) -> Result<()> { + let payload = Self::to_json(item)?; + let conn = self.lock()?; + conn.execute( + queries::UPSERT_AUTOMATION, + params![item.id, payload, item.updated_at.to_rfc3339()], + )?; + Ok(()) + } + + pub fn list_automations(&self) -> Result> { + self.list_payloads(queries::LIST_AUTOMATIONS) + } + + pub fn get_automation(&self, id: &str) -> Result> { + self.get_payload(queries::GET_AUTOMATION, id) + } + + pub fn delete_automation(&self, id: &str) -> Result { + self.delete_by_id("automations", id) + } + + fn list_payloads(&self, sql: &str) -> Result> { + let conn = self.lock()?; + let mut stmt = conn.prepare(sql)?; + let payloads = stmt.query_map([], |row| row.get::<_, String>(0))? + .collect::, _>>()?; + payloads.into_iter().map(Self::from_json).collect() + } + + fn get_payload(&self, sql: &str, id: &str) -> Result> { + let conn = self.lock()?; + let payload: Option = conn.query_row(sql, [id], |row| row.get(0)).optional()?; + payload.map(Self::from_json).transpose() + } + + fn delete_by_id(&self, table: &str, id: &str) -> Result { + let sql = match table { + "schedules" => queries::DELETE_SCHEDULE, + "automations" => queries::DELETE_AUTOMATION, + "groups" => queries::DELETE_GROUP, + _ => anyhow::bail!("unsupported table"), + }; + let conn = self.lock()?; + Ok(conn.execute(sql, [id])? > 0) + } + +} diff --git a/src/db/tests.rs b/src/db/tests.rs new file mode 100644 index 0000000..82222c0 --- /dev/null +++ b/src/db/tests.rs @@ -0,0 +1,70 @@ +#[cfg(test)] +mod tests { + use super::*; + use crate::models::{ApiTokenInfo, Device, HaReading, Reading}; + + #[test] + fn history_compaction_keeps_one_sample_per_old_bucket() { + let dir = tempfile::tempdir().unwrap(); + let db = Db::open(&dir.path().join("compact.db")).unwrap(); + let device = Device::simulated_default(); + db.save_device(&device).unwrap(); + let seconds = (Utc::now().timestamp() - 2 * 86_400) / 600 * 600; + let base = DateTime::::from_timestamp(seconds, 0).unwrap(); + for offset in [10_i64, 20_i64] { + db.add_reading(&Reading { + id: 0, device_id: device.id.clone(), timestamp: base + Duration::seconds(offset), + indoor_temperature: Some(22.0), outdoor_temperature: None, target_temperature: 23.0, + power: true, source: "gree".into(), + }).unwrap(); + } + assert_eq!(db.history_counts().unwrap().0, 2); + assert_eq!(db.compact_history(30).unwrap(), 1); + assert_eq!(db.history_counts().unwrap().0, 1); + } + + #[test] + fn sqlite_round_trip() { + let dir = tempfile::tempdir().unwrap(); + let db = Db::open(&dir.path().join("test.db")).unwrap(); + let device = Device::simulated_default(); + db.save_device(&device).unwrap(); + let loaded = db.get_device(&device.id).unwrap().unwrap(); + assert_eq!(loaded.mac, device.mac); + assert_eq!(db.list_devices().unwrap().len(), 1); + db.log_event("info", "test", "ok", &serde_json::json!({"a":1})).unwrap(); + assert_eq!(db.list_events(10).unwrap().len(), 1); + { + let conn = db.lock().unwrap(); + conn.execute(queries::INSERT_EVENT, rusqlite::params![(Utc::now() - Duration::days(40)).to_rfc3339(), "info", "old", "old", "{}"] ).unwrap(); + } + assert_eq!(db.prune_events(30).unwrap(), 1); + assert_eq!(db.list_events(10).unwrap().len(), 1); + + let access_token = ApiTokenInfo { + id: "token-1".into(), + name: "Home Assistant".into(), + token_prefix: "gree_controller_test...".into(), + created_at: Utc::now(), + }; + db.save_api_token(&access_token, "test-hash").unwrap(); + assert!(db.api_token_exists("test-hash").unwrap()); + assert_eq!(db.list_api_tokens().unwrap().len(), 1); + assert!(db.delete_api_token(&access_token.id).unwrap()); + assert!(!db.api_token_exists("test-hash").unwrap()); + + let now = Utc::now(); + db.add_reading(&Reading { + id: 0, device_id: device.id.clone(), timestamp: now.clone(), indoor_temperature: Some(22.5), + outdoor_temperature: Some(31.0), target_temperature: 23.0, power: true, source: "gree".into(), + }).unwrap(); + assert_eq!(db.list_device_history(Some(&device.id), now.clone() - Duration::minutes(1), 30, 100).unwrap().len(), 1); + + db.add_ha_reading_if_due(&HaReading { + id: 0, entity_id: "sensor.room".into(), zone_id: Some("zone-room".into()), kind: "room".into(), + timestamp: now.clone(), temperature: 22.1, + }, 15).unwrap(); + assert_eq!(db.list_ha_history(Some("sensor.room"), now.clone() - Duration::minutes(1), 30, 100).unwrap().len(), 1); + assert_eq!(db.history_counts().unwrap(), (1, 0, 1)); + } +} diff --git a/src/db/zone_history.rs b/src/db/zone_history.rs new file mode 100644 index 0000000..0fcbe20 --- /dev/null +++ b/src/db/zone_history.rs @@ -0,0 +1,70 @@ +impl Db { + pub fn add_zone_reading_if_due(&self, reading: &ZoneReading, min_interval_seconds: i64) -> Result { + let cutoff = reading.timestamp.clone() - Duration::seconds(min_interval_seconds.max(1)); + let conn = self.lock()?; + let changed = conn.execute( + queries::INSERT_ZONE_READING_IF_DUE, + params![ + reading.zone_id, + reading.device_id, + reading.timestamp.to_rfc3339(), + reading.gree_temperature, + reading.external_temperature, + reading.control_temperature, + reading.target_temperature, + reading.device_setpoint, + reading.outdoor_temperature, + reading.power as i64, + reading.mode, + reading.fan_speed as i64, + reading.demand as i64, + reading.control_source, + reading.active_preset, + cutoff.to_rfc3339(), + ], + )?; + Ok(changed > 0) + } + + pub fn list_zone_history(&self, zone_id: Option<&str>, since: DateTime, bucket_seconds: i64, limit: u32) -> Result> { + let conn = self.lock()?; + let bucket_seconds = bucket_seconds.max(1); + let limit = limit.clamp(1, 20_000) as i64; + let mut rows_out = Vec::new(); + if let Some(zone_id) = zone_id { + let mut stmt = conn.prepare(queries::LIST_ZONE_HISTORY_BY_ZONE_BUCKETED)?; + let rows = stmt.query_map(params![zone_id, since.to_rfc3339(), bucket_seconds, limit], Self::map_zone_reading)?; + for row in rows { rows_out.push(row?); } + } else { + let mut stmt = conn.prepare(queries::LIST_ZONE_HISTORY_ALL_BUCKETED)?; + let rows = stmt.query_map(params![since.to_rfc3339(), bucket_seconds, limit], Self::map_zone_reading)?; + for row in rows { rows_out.push(row?); } + } + Ok(rows_out) + } + + fn map_zone_reading(row: &rusqlite::Row<'_>) -> rusqlite::Result { + let timestamp: String = row.get(3)?; + Ok(ZoneReading { + id: row.get(0)?, + zone_id: row.get(1)?, + device_id: row.get(2)?, + timestamp: DateTime::parse_from_rfc3339(×tamp) + .map(|v| v.with_timezone(&Utc)) + .unwrap_or_else(|_| Utc::now()), + gree_temperature: row.get(4)?, + external_temperature: row.get(5)?, + control_temperature: row.get(6)?, + target_temperature: row.get(7)?, + device_setpoint: row.get(8)?, + outdoor_temperature: row.get(9)?, + power: row.get::<_, i64>(10)? != 0, + mode: row.get(11)?, + fan_speed: row.get::<_, i64>(12)?.clamp(0, 255) as u8, + demand: row.get::<_, i64>(13)? != 0, + control_source: row.get(14)?, + active_preset: row.get(15)?, + }) + } + +} diff --git a/src/engine.rs b/src/engine.rs index 0d8b47a..42c7ded 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -11,3786 +11,22 @@ use crate::{ state::{AppState, PendingControllerCommand}, }; -fn reset_temporary_condition_observations_after_restart(state: &AppState) -> Result { - let mut changed = 0usize; - for mut zone in state.db.list_zones()? { - let Some(session) = zone.temporary_quick_thermostat.as_mut() else { continue; }; - if session.condition_started_at.is_none() && session.condition_last_observed_at.is_none() { continue; } - session.condition_started_at = None; - session.condition_last_observed_at = None; - zone.updated_at = Utc::now(); - state.db.save_zone(&zone)?; - changed += 1; - } - Ok(changed) -} -pub fn start(state: AppState) { - // A continuous temperature hold cannot span controller downtime. Preserve the session - // itself, but require fresh observations after every process restart (H11). - if let Err(err) = reset_temporary_condition_observations_after_restart(&state) { - tracing::warn!(error=?err, "cannot reset temporary thermostat observation continuity after restart"); - } - let poll_state = state.clone(); - tokio::spawn(async move { - sleep(Duration::from_millis(500)).await; - loop { - match poll_all(&poll_state).await { - Ok(()) => { - if !poll_state.initial_device_sync_complete.swap(true, Ordering::AcqRel) { - tracing::info!("initial device state synchronized; thermostat control enabled"); - } - } - Err(err) => tracing::error!(error=?err, "device poll cycle failed"), - } - let seconds = poll_state.settings.read().await.poll_interval_seconds.max(2); - sleep(Duration::from_secs(seconds)).await; - } - }); - - let control_state = state.clone(); - tokio::spawn(async move { - sleep(Duration::from_secs(2)).await; - loop { - // A restart must never make decisions from the persisted, potentially stale - // device snapshot. Wait for one full live poll before thermostat/schedule/automation - // ownership can emit commands. Manual API/remote control remains available. - if !control_state.initial_device_sync_complete.load(Ordering::Acquire) { - sleep(Duration::from_millis(250)).await; - continue; - } - if let Err(err) = control_zones(&control_state).await { - tracing::error!(error=?err, "zone cycle failed"); - } - if let Err(err) = run_automations(&control_state).await { - tracing::error!(error=?err, "automation cycle failed"); - } - let seconds = control_state.settings.read().await.zone_interval_seconds.max(2); - let normal_delay = Duration::from_secs(seconds); - let resume_delay = match next_zone_control_deadline_delay(&control_state) { - Ok(value) => value, - Err(err) => { - tracing::warn!(error=?err, "cannot calculate thermostat control deadline"); - None - } - }; - let sleep_for = resume_delay.map(|delay| delay.min(normal_delay)).unwrap_or(normal_delay); - tokio::select! { - _ = sleep(sleep_for) => {}, - _ = control_state.zone_control_wakeup.notified() => {}, - } - } - }); - - let maintenance_state = state; - tokio::spawn(async move { - sleep(Duration::from_secs(60)).await; - loop { - let settings = maintenance_state.settings.read().await.clone(); - // When InfluxDB is enabled, compact all locally retained legacy history before - // transferring old buckets. Without Influx, compact only the configured retention window. - let compaction_days = if settings.influxdb.enabled { 3650 } else { settings.history_retention_days.max(1) } as i64; - if settings.history_compaction_enabled { - match maintenance_state.db.compact_history(compaction_days) { - Ok(count) if count > 0 => tracing::info!(count, "history samples compacted"), - Ok(_) => {} - Err(err) => tracing::warn!(error=?err, "cannot compact history"), - } - } - if settings.influxdb.enabled { - match archive_old_history(&maintenance_state, settings.influxdb.history_threshold_days.max(1)).await { - Ok(count) if count > 0 => tracing::info!(count, "old local readings archived to InfluxDB and removed from SQLite"), - Ok(_) => {} - Err(err) => tracing::warn!(error=?err, "cannot archive old history to InfluxDB; SQLite copies were kept"), - } - } else { - let retention_days = settings.history_retention_days.max(1) as i64; - match maintenance_state.db.prune_readings(retention_days) { - Ok(count) if count > 0 => tracing::info!(count, retention_days, "old local readings pruned"), - Ok(_) => {} - Err(err) => tracing::warn!(error=?err, "cannot prune readings"), - } - } - let event_retention_days = settings.event_log_retention_days.max(1) as i64; - match maintenance_state.db.prune_events(event_retention_days) { - Ok(count) if count > 0 => tracing::info!(count, event_retention_days, "old event log rows pruned"), - Ok(_) => {} - Err(err) => tracing::warn!(error=?err, "cannot prune event log"), - } - sleep(Duration::from_secs(6 * 60 * 60)).await; - } - }); -} - -async fn archive_old_history(state: &AppState, threshold_days: u32) -> Result { - let cutoff = Utc::now() - chrono::Duration::days(threshold_days.max(1) as i64); - let settings = state.settings.read().await.influxdb.clone(); - let mut moved = 0_u64; - // Bound one maintenance pass so a very large legacy database never monopolizes the runtime. - // Successful batches are deleted from SQLite, so the next pass naturally continues forward. - for _ in 0..50 { - let (devices, zones, ha) = state.db.history_before(cutoff, 1_000)?; - if devices.is_empty() && zones.is_empty() && ha.is_empty() { break; } - influxdb::write_batch(&state.http, &settings, &devices, &zones, &ha).await?; - let deleted = state.db.delete_history_batch(&devices, &zones, &ha)?; - moved += deleted; - if deleted == 0 { break; } - } - Ok(moved) -} - -pub async fn send_command(state: &AppState, device_id: &str, command: DeviceCommand) -> Result { - let _device_guard = state.lock_device_operation(device_id).await; - send_command_locked(state, device_id, command).await -} - -async fn send_command_locked(state: &AppState, device_id: &str, command: DeviceCommand) -> Result { - send_command_locked_inner(state, device_id, command, true, true).await -} - -async fn send_command_locked_forced(state: &AppState, device_id: &str, command: DeviceCommand) -> Result { - send_command_locked_inner(state, device_id, command, false, true).await -} - -async fn send_command_locked_inner( - state: &AppState, - device_id: &str, - command: DeviceCommand, - dedupe_against_cache: bool, - track_controller_command: bool, -) -> Result { - validate_command(&command)?; - let mut device = state.db.get_device(device_id)? - .ok_or_else(|| AppError::NotFound(format!("device {device_id}")))?; - if !device.enabled { return Err(AppError::BadRequest("device is disabled".into())); } - - // Routine control avoids redundant frames. Explicit safety transitions (global/group OFF, - // detach) may bypass cache de-duplication so stale state cannot leave a unit powered. - let command = if dedupe_against_cache && device.online && device.communication_failures == 0 { command.changed_from(&device) } else { command }; - if command.is_empty() { return Ok(device); } - let controller_command_baseline = device.clone(); - let suppress_beep = state.settings.read().await.suppress_device_beep; - let response_started = Instant::now(); - - let mut applied_command = command.clone(); - let mut confirmed_state = false; - let mut confirmed_requested_state = true; - if device.simulated { - applied_command.apply(&mut device); - confirmed_state = true; - device.online = true; - device.response_time_ms = Some(0); - device.last_seen = Some(Utc::now()); - device.last_error = None; - state.db.save_device(&device)?; - } else { - if device.key.as_deref().unwrap_or_default().is_empty() { - match state.gree.bind(&device).await { - Ok(bound) => { - device.key = Some(bound.key); - device.protocol_version = bound.protocol_version; - device.communication_failures = 0; - state.db.save_device(&device)?; - state.log("info", "device.bound", &format!("Bound {} using protocol V{}", device.name, device.protocol_version), json!({"device_id": device.id, "protocol_version": device.protocol_version})); - } - Err(err) => { - register_device_failure(state, &mut device, &err.to_string())?; - return Err(AppError::Device(err.to_string())); - } - } - } - match state.gree.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 - // first and avoid sending the same frame (and another beep) when the requested - // state is already present. Only rebind when the verification read also fails. - let mut observed = device.clone(); - let retry_result = match state.gree.poll(&mut observed).await { - Ok(()) if command.changed_from(&observed).is_empty() => { - device = observed; - confirmed_state = true; - tracing::debug!(device=%device.id, "GREE command ACK was uncertain, but status confirms the requested state"); - Ok(command.clone()) - } - Ok(()) => { - device = observed; - let remaining = command.changed_from(&device); - if remaining.is_empty() { Ok(command.clone()) } - else { state.gree.command(&device, &remaining, suppress_beep).await } - } - Err(_) => { - match state.gree.bind(&device).await { - Ok(bound) => { - device.key = Some(bound.key); - device.protocol_version = bound.protocol_version; - state.db.save_device(&device)?; - state.gree.command(&device, &command, suppress_beep).await - } - Err(_) => Err(first_err), - } - } - }; - match retry_result { - Ok(result) => applied_command = result, - Err(err) => { - register_device_failure(state, &mut device, &err.to_string())?; - return Err(AppError::Device(err.to_string())); - } - } - } - } - if command.quiet.is_some() && applied_command.quiet.is_none() { device.supports_quiet = Some(false); } - if command.sleep.is_some() && applied_command.sleep.is_none() { device.supports_sleep = Some(false); } - - // A command ACK confirms transport/acceptance, but several GREE firmwares keep - // returning the pre-command status for a short settling window. Publishing that first - // stale read makes Home Assistant visibly bounce ON -> OFF -> ON. Verify a few times - // with bounded backoff and only publish a differing state after the settling window. - if !confirmed_state { - let verification_delays_ms = [0_u64, 150, 350, 650]; - let mut last_verification_error: Option = None; - for delay_ms in verification_delays_ms { - if delay_ms > 0 { sleep(Duration::from_millis(delay_ms)).await; } - let mut observed = device.clone(); - match state.gree.poll(&mut observed).await { - Ok(()) => { - let requested_matches = applied_command.changed_from(&observed).is_empty(); - device = observed; - confirmed_state = true; - confirmed_requested_state = requested_matches; - last_verification_error = None; - if requested_matches { break; } - } - Err(err) => { - last_verification_error = Some(err.to_string()); - } - } - } - - if confirmed_state && !confirmed_requested_state { - tracing::debug!(device=%device.id, command=?applied_command, "GREE command acknowledged but status still differs after settling window"); - } else if !confirmed_state { - let error = last_verification_error.unwrap_or_else(|| "status verification failed".into()); - record_poll_failure(&mut device, &format!("command accepted but status verification failed: {error}")); - state.log("warn", "device.command_unconfirmed", &format!("Command accepted by {}, but resulting state could not be verified", device.name), json!({ - "device_id": device.id, "error": error - })); - } - } - if confirmed_state { - device.response_time_ms = Some(response_started.elapsed().as_millis().min(u64::MAX as u128) as u64); - } - state.db.save_device(&device)?; - if !dedupe_against_cache && confirmed_state && !confirmed_requested_state { - if track_controller_command && !command_manual_control_fields(&applied_command).is_empty() { - remember_controller_command(state, device_id, &applied_command, &controller_command_baseline).await; - } - state.broadcast("device.updated", serde_json::to_value(&device).unwrap_or_default()); - return Err(AppError::Device("device did not confirm the requested forced state change".into())); - } - } - - if track_controller_command && !command_manual_control_fields(&applied_command).is_empty() { - // Keep a bounded settling history even after the requested state has already been - // observed once. Several GREE modules can briefly publish an older snapshot again - // and then return to the controller-requested state. Without this guard that normal - // firmware bounce can be misclassified as a physical/pilot takeover. - remember_controller_command(state, device_id, &applied_command, &controller_command_baseline).await; - } - - record_device_transition_timestamps(state, &controller_command_baseline, &device)?; - - state.log("info", "device.command", &format!("Updated {}", device.name), json!({ - "device_id": device.id, - "command": applied_command, - "confirmed": confirmed_state, - })); - state.broadcast("device.updated", serde_json::to_value(&device).unwrap_or_default()); - Ok(device) -} - -fn record_device_transition_timestamps(state: &AppState, before: &Device, after: &Device) -> Result<(), AppError> { - if before.power == after.power && before.mode == after.mode { return Ok(()); } - let now = Utc::now(); - for mut zone in state.db.list_zones()?.into_iter().filter(|zone| zone.device_id == after.id) { - if before.power != after.power { zone.last_power_change_at = Some(now); } - if before.mode != after.mode { zone.last_mode_change_at = Some(now); } - // Do not bump zone.updated_at here: an in-flight thermostat cycle uses that field - // as its optimistic snapshot guard. The cycle mirrors these timestamps into its own - // computed Zone after a successful automatic command. - state.db.save_zone(&zone)?; - state.broadcast("zone.updated", serde_json::to_value(&zone)?); - } - Ok(()) -} - -pub async fn poll_one(state: &AppState, device_id: &str) -> Result { - let _device_guard = state.lock_device_operation(device_id).await; - poll_one_locked(state, device_id).await -} - -async fn poll_one_locked(state: &AppState, device_id: &str) -> Result { - 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; - if poll_completed_successfully(&device) { - if state.initial_device_sync_complete.load(Ordering::Acquire) { - record_device_transition_timestamps(state, &before, &device)?; - } - detect_external_device_control(state, &before, &device).await?; - } - state.db.save_device(&device)?; - record_reading(state, &device)?; - state.broadcast("device.updated", serde_json::to_value(&device).unwrap_or_default()); - Ok(device) -} - -pub(crate) async fn poll_all(state: &AppState) -> Result<()> { - let device_ids: Vec = state.db.list_devices()?.into_iter() - .filter(|device| device.enabled) - .map(|device| device.id) - .collect(); - for device_id in device_ids { - let _device_guard = state.lock_device_operation(&device_id).await; - let _ = poll_one_locked(state, &device_id).await?; - } - Ok(()) -} - -async fn poll_device(state: &AppState, device: &mut Device) { - if device.simulated { - simulate_tick(device); - return; - } - let previous_failures = device.communication_failures; - let response_started = Instant::now(); - if device.key.as_deref().unwrap_or_default().is_empty() { - match state.gree.bind(device).await { - Ok(bound) => { - device.key = Some(bound.key); - device.protocol_version = bound.protocol_version; - device.communication_failures = 0; - } - Err(err) => { - record_poll_failure(device, &err.to_string()); - log_poll_health_transition(state, device, previous_failures).await; - return; - } - } - } - if let Err(first_err) = state.gree.poll(device).await { - // One lost UDP response is common on Wi-Fi and must not trigger a bind storm. - // Rebind only after at least one consecutive failed poll; a successful retry clears - // the counter in GreeClient::poll. - if previous_failures == 0 { - record_poll_failure(device, &first_err.to_string()); - } else { - match state.gree.bind(device).await { - Ok(bound) => { - device.key = Some(bound.key); - device.protocol_version = bound.protocol_version; - if let Err(err) = state.gree.poll(device).await { - record_poll_failure(device, &err.to_string()); - } - } - Err(_) => record_poll_failure(device, &first_err.to_string()), - } - } - } - if device.communication_failures == 0 && device.online { - 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; -} - -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 current_failures = u32::from(device.communication_failures); - let previous_failures = u32::from(previous_failures); - if current_failures >= threshold && previous_failures < threshold { - state.log("warn", "device.offline", &format!("{} did not respond {} times in a row", device.name, device.communication_failures), json!({ - "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})); - } -} - -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 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 }; } - } - "heat" => { - let ceiling = device.target_temperature + 0.2; - if current < ceiling { current += if device.turbo { 0.25 } else { 0.12 }; } - } - "dry" => current -= 0.04, - _ => current += (ambient - current) * 0.02, - } - } else { - current += (ambient - current) * 0.04; - } - 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.online = true; - device.response_time_ms = Some(0); - device.last_seen = Some(Utc::now()); - device.last_error = None; - device.updated_at = Utc::now(); -} - -fn record_reading(state: &AppState, device: &Device) -> Result<()> { - let reading = Reading { - id: 0, - device_id: device.id.clone(), - timestamp: Utc::now(), - indoor_temperature: device.current_temperature, - outdoor_temperature: device.outdoor_temperature, - target_temperature: device.target_temperature, - power: device.power, - source: if device.simulated { "simulator".into() } else { "gree".into() }, - }; - state.db.add_reading(&reading)?; - queue_influx_device(state, reading); - Ok(()) -} - -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.last_error = Some(error.to_string()); - device.updated_at = Utc::now(); -} - -fn register_device_failure(state: &AppState, device: &mut Device, error: &str) -> Result<(), AppError> { - record_poll_failure(device, error); - state.db.save_device(device)?; - 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 let Some(value) = command.fan_speed { - 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") { - return Err(AppError::BadRequest("unsupported HVAC mode".into())); - } - } - Ok(()) -} - -fn poll_completed_successfully(device: &Device) -> bool { - device.online && device.communication_failures == 0 && device.last_error.is_none() -} - -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()); } - fields -} - -fn command_baseline_from_device(command: &DeviceCommand, device: &Device) -> DeviceCommand { - 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), - fan_speed: command.fan_speed.map(|_| device.fan_speed), - quiet: command.quiet.map(|_| device.quiet), - sleep: command.sleep.map(|_| device.sleep), - ..Default::default() - } -} - -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; - let expires_at = Instant::now() + ttl; - let baseline = command_baseline_from_device(command, baseline_device); - if let Some(existing) = pending.get_mut(device_id) { - existing.commands.push(command.clone()); - 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); } - existing.expires_at = expires_at; - } else { - 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 - .map(|value| value.clamp(8.0, 30.0).round() == device.target_temperature.clamp(8.0, 30.0).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), - _ => false, - } -} - -fn device_control_snapshot(device: &Device) -> Value { - json!({ - "power": device.power, - "mode": device.mode.clone(), - "target_temperature": device.target_temperature, - "fan_speed": device.fan_speed, - "quiet": device.quiet, - "sleep": device.sleep, - "turbo": device.turbo, - "swing_vertical": device.swing_vertical, - "swing_horizontal": device.swing_horizontal, - "online": device.online, - "communication_failures": device.communication_failures, - "last_seen": device.last_seen.clone(), - "updated_at": device.updated_at.clone(), - }) -} - -async fn controller_settling_diagnostics(state: &AppState, device_id: &str) -> Value { - let pending = state.pending_controller_commands.lock().await; - let Some(expected) = pending.get(device_id).cloned() else { - return json!({ "active": false, "reason": "none" }); - }; - 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; - return json!({ - "active": false, - "reason": "expired", - "expired_by_ms": expired_by_ms, - "commands": expected.commands, - "baselines": expected.baselines, - }); - } - 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, - "commands": expected.commands, - "baselines": expected.baselines, - }) -} - -async fn suppress_expected_controller_changes( - state: &AppState, - device: &Device, - fields: Vec, -) -> Vec { - if fields.is_empty() { return fields; } - let mut pending = state.pending_controller_commands.lock().await; - 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() - .filter(|field| { - 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 - }) - .collect(); - // Do not clear the settling guard merely because one poll matched the requested state. - // A later status packet can still briefly roll back to the pre-command snapshot. The - // bounded TTL is what ends this ambiguity window. - filtered -} - -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.target_temperature.round() != after.target_temperature.round() { - fields.push("target_temperature".to_string()); - } - // 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; - if before.fan_speed != after.fan_speed && !standby_low_to_auto { - fields.push("fan_speed".to_string()); - } - fields -} - - -pub const LOCAL_THERMOSTAT_RESUME_DELAY_MINUTES: i64 = 15; - -/// Apply local quick-thermostat power ownership and keep the automatic hand-back -/// deadline in one backend-owned place. Every fresh OFF action receives a fresh -/// deadline; ON cancels any pending hand-back. -pub fn set_local_thermostat_power(zone: &mut Zone, power: bool, now: DateTime) -> bool { - let previous_restore = zone.local_thermostat_restore_zone_enabled; - // Ordinary Quick Thermostat has its own restore state. A temporary session never uses - // this field, so its lifecycle cannot be erased by the 15-minute local hand-back. - if power && zone.local_thermostat_power != Some(true) && zone.local_thermostat_restore_zone_enabled.is_none() { - zone.local_thermostat_restore_zone_enabled = Some(zone.enabled); - } - let resume_at = if power { - None - } else { - Some(now + chrono::Duration::minutes(LOCAL_THERMOSTAT_RESUME_DELAY_MINUTES)) - }; - let changed = zone.local_thermostat_power != Some(power) - || zone.local_thermostat_resume_at != resume_at - || zone.local_thermostat_restore_zone_enabled != previous_restore; - zone.local_thermostat_power = Some(power); - zone.local_thermostat_resume_at = resume_at; - changed -} - -/// Re-arm a local-OFF hand-back after a temporary direct/manual device takeover. -/// The countdown must start from the moment that manual control ends, not from the -/// older OFF action that happened before the takeover. -fn rearm_local_thermostat_resume(zone: &mut Zone, now: DateTime) -> bool { - if zone.local_thermostat_power != Some(false) { return false; } - set_local_thermostat_power(zone, false, now) -} - -fn local_thermostat_handback_is_active(zone: &Zone) -> bool { - zone.local_thermostat_power == Some(false) && !zone.device_manual_override -} - -/// Clear only the ordinary local Quick Thermostat. Temporary Quick Thermostat state is -/// deliberately untouched; the two ownership mechanisms have independent cleanup paths. -pub fn reset_local_thermostat_override(zone: &mut Zone) -> bool { - let temporary_active = zone.temporary_quick_thermostat.as_ref() - .map(|session| session.activated_at.is_some()) - .unwrap_or(false); - if temporary_active { - let Some(session) = zone.temporary_quick_thermostat.as_mut() else { return false; }; - let changed = session.restore_local_thermostat_power.is_some() - || session.restore_local_thermostat_resume_at.is_some() - || session.restore_local_thermostat_zone_enabled.is_some() - || session.restore_manual_preset.is_some() - || session.restore_manual_setpoint.is_some() - || session.restore_manual_override_until.is_some(); - session.restore_local_thermostat_power = None; - session.restore_local_thermostat_resume_at = None; - session.restore_local_thermostat_zone_enabled = None; - session.restore_manual_preset = None; - session.restore_manual_setpoint = None; - session.restore_manual_override_until = None; - return changed; - } - - let restore_zone_enabled = zone.local_thermostat_restore_zone_enabled; - let changed = zone.local_thermostat_power.is_some() - || zone.local_thermostat_resume_at.is_some() - || zone.local_thermostat_restore_zone_enabled.is_some() - || zone.manual_preset.is_some() - || zone.manual_setpoint.is_some() - || zone.manual_override_until.is_some(); - zone.local_thermostat_power = None; - zone.local_thermostat_resume_at = None; - zone.local_thermostat_restore_zone_enabled = None; - zone.manual_preset = None; - zone.manual_setpoint = None; - zone.manual_override_until = None; - if let Some(enabled) = restore_zone_enabled { - zone.enabled = enabled; - } - changed -} - -pub fn temporary_quick_thermostat_is_active(zone: &Zone, now: DateTime) -> bool { - zone.temporary_quick_thermostat.as_ref() - .and_then(|session| session.activated_at.as_ref()) - .map(|activated_at| activated_at <= &now) - .unwrap_or(false) -} - -fn temporary_quick_thermostat_hard_deadline(session: &TemporaryQuickThermostat) -> Option> { - if session.state == "paused_manual" { return None; } - match (session.expires_at, session.safety_expires_at) { - (Some(a), Some(b)) => Some(a.min(b)), - (Some(a), None) => Some(a), - (None, Some(b)) => Some(b), - (None, None) => None, - } -} - -fn temporary_quick_thermostat_next_deadline(session: &TemporaryQuickThermostat) -> Option> { - let hard = temporary_quick_thermostat_hard_deadline(session); - let hold = if session.finish_kind == "temperature_stable" && session.hold_seconds > 0 { - session.condition_started_at.map(|started| started + chrono::Duration::seconds(session.hold_seconds as i64)) - } else { - None - }; - match (hard, hold) { - (Some(a), Some(b)) => Some(a.min(b)), - (Some(a), None) => Some(a), - (None, Some(b)) => Some(b), - (None, None) => None, - } -} - -fn temporary_quick_thermostat_wakeup_at(zone: &Zone, now: DateTime) -> Option> { - let session = zone.temporary_quick_thermostat.as_ref()?; - if temporary_quick_thermostat_is_active(zone, now) { - return temporary_quick_thermostat_next_deadline(session); - } - // Once a due session is waiting for master/manual ownership, normal wakeups or an - // explicit state-change notification will retry it. Returning a past start would spin. - (session.started_at > now).then_some(session.started_at) -} - -/// Finish an active temporary session and apply climate changes that were deferred while -/// it owned the zone. Pending-session cancellation should simply remove the session instead. -pub fn finish_temporary_quick_thermostat(zone: &mut Zone, schedules: &[Schedule], house_mode: &str) -> bool { - let now = Utc::now(); - let was_active = temporary_quick_thermostat_is_active(zone, now); - let Some(session) = zone.temporary_quick_thermostat.take() else { return false; }; - if !was_active { return false; } - - zone.local_thermostat_power = session.restore_local_thermostat_power; - zone.local_thermostat_resume_at = session.restore_local_thermostat_resume_at; - zone.local_thermostat_restore_zone_enabled = session.restore_local_thermostat_zone_enabled; - zone.manual_preset = session.restore_manual_preset; - zone.manual_setpoint = session.restore_manual_setpoint; - zone.manual_override_until = session.restore_manual_override_until; - if let Some(enabled) = session.restore_zone_enabled { - zone.enabled = enabled; - } - - if let Some(mode) = session.deferred_mode.as_deref() { - match mode { - "house" | "auto" => zone.inherit_house_mode = true, - "cool" | "heat" => { - zone.inherit_house_mode = false; - zone.mode = mode.to_string(); - } - _ => {} - } - } - if let Some(preset) = session.deferred_preset.as_deref() { - if preset == "auto" { - zone.manual_preset = None; - zone.manual_setpoint = None; - zone.manual_override_until = None; - } else if matches!(preset, "comfort" | "sleep" | "away") { - zone.manual_preset = Some(preset.to_string()); - zone.manual_override_until = next_schedule_boundary_utc(&zone.id, schedules, Local::now()); - } - } - refresh_zone_runtime_target(zone, schedules, house_mode); - true -} - -async fn expire_temporary_quick_thermostats(state: &AppState, zones: &mut [Zone], schedules: &[Schedule], house_mode: &str) -> Result, AppError> { - let now = Utc::now(); - let mut restored_disabled_zones = Vec::new(); - for zone in zones.iter_mut() { - let zone_id = zone.id.clone(); - let _zone_guard = state.lock_zone_operation(&zone_id).await; - let Some(latest) = state.db.get_zone(&zone_id)? else { continue; }; - *zone = latest; - let active_under_manual = zone.device_manual_override - && zone.temporary_quick_thermostat.as_ref().and_then(|session| session.activated_at).is_some(); - if active_under_manual { - set_temporary_wait_state(state, zone, "paused_manual", now)?; - continue; - } - let Some((deadline, finish_kind, restore_zone_enabled)) = zone.temporary_quick_thermostat.as_ref() - .and_then(|session| temporary_quick_thermostat_hard_deadline(session) - .map(|deadline| (deadline, session.finish_kind.clone(), session.restore_zone_enabled))) - else { continue; }; - if deadline > now { continue; } - let was_activated = temporary_quick_thermostat_is_active(zone, now); - let restores_disabled = was_activated && restore_zone_enabled == Some(false); - if was_activated { - finish_temporary_quick_thermostat(zone, schedules, house_mode); - } else { - // A delayed session that expires before it acquires ownership must not clear - // unrelated local/manual/schedule state that was active while it was waiting. - zone.temporary_quick_thermostat = None; - } - zone.updated_at = now; - state.db.save_zone(zone)?; - state.broadcast("zone.updated", serde_json::to_value(&*zone)?); - state.log("info", "zone.temporary_quick_thermostat_finished", &format!("Temporary Quick Thermostat finished for {}", zone.name), json!({ - "zone_id": zone.id, "device_id": zone.device_id, "finish_kind": finish_kind, - "reason": if was_activated { "deadline" } else { "expired_before_activation" } - })); - if restores_disabled { restored_disabled_zones.push(zone.id.clone()); } - } - Ok(restored_disabled_zones) -} - -fn set_temporary_wait_state(state: &AppState, zone: &mut Zone, value: &str, now: DateTime) -> Result<(), AppError> { - let Some(session) = zone.temporary_quick_thermostat.as_mut() else { return Ok(()); }; - let mut changed = false; - if session.state != value { - session.state = value.to_string(); - changed = true; - } - if value == "paused_manual" && session.paused_at.is_none() { - session.paused_at = Some(now); - changed = true; - } - if !changed { return Ok(()); } - session.condition_started_at = None; - session.condition_last_observed_at = None; - zone.updated_at = now; - state.db.save_zone(zone)?; - state.broadcast("zone.updated", serde_json::to_value(&*zone)?); - Ok(()) -} - -async fn activate_due_temporary_quick_thermostats( - state: &AppState, - zones: &mut [Zone], - schedules: &[Schedule], - house_mode: &str, - house_power_enabled: bool, -) -> Result<(), AppError> { - let now = Utc::now(); - for zone in zones.iter_mut() { - let zone_id = zone.id.clone(); - let _zone_guard = state.lock_zone_operation(&zone_id).await; - let Some(latest) = state.db.get_zone(&zone_id)? else { continue; }; - *zone = latest; - let Some((session_state, started_at)) = zone.temporary_quick_thermostat.as_ref() - .map(|session| (session.state.clone(), session.started_at)) - else { continue; }; - if temporary_quick_thermostat_is_active(zone, now) { - if session_state != "active" && !zone.device_manual_override { - set_temporary_wait_state(state, zone, "active", now)?; - } - continue; - } - if started_at > now { continue; } - if !house_power_enabled { - set_temporary_wait_state(state, zone, "waiting_master", now)?; - continue; - } - if zone.device_manual_override { - set_temporary_wait_state(state, zone, "paused_manual", now)?; - continue; - } - - let (temperature_target, duration_seconds, safety_duration_seconds, finish_kind) = { - let session = zone.temporary_quick_thermostat.as_ref().expect("temporary session checked above"); - (session.temperature_target, session.duration_seconds, session.safety_duration_seconds, session.finish_kind.clone()) - }; - let target = temperature_target - .or(zone.manual_setpoint) - .or(zone.effective_setpoint) - .unwrap_or(zone.setpoint); - let restore_enabled = zone.enabled; - let restore_local_power = zone.local_thermostat_power; - let restore_local_resume_at = zone.local_thermostat_resume_at; - let restore_local_zone_enabled = zone.local_thermostat_restore_zone_enabled; - let restore_manual_preset = zone.manual_preset.clone(); - let restore_manual_setpoint = zone.manual_setpoint; - let restore_manual_override_until = zone.manual_override_until; - let configured_mode = if zone.inherit_house_mode { house_mode } else { zone.mode.as_str() }; - let active_mode = if configured_mode == "off" { zone.mode.clone() } else { configured_mode.to_string() }; - let schedule_boundary = if finish_kind == "schedule_boundary" { - next_schedule_boundary_utc(&zone.id, schedules, Local::now()) - } else { None }; - if finish_kind == "schedule_boundary" && schedule_boundary.is_none() { - zone.temporary_quick_thermostat = None; - zone.updated_at = now; - state.db.save_zone(zone)?; - state.broadcast("zone.updated", serde_json::to_value(&*zone)?); - state.log("warn", "zone.temporary_quick_thermostat_cancelled", &format!("Temporary Quick Thermostat cancelled for {} because no future schedule boundary exists", zone.name), json!({ - "zone_id": zone.id, "device_id": zone.device_id - })); - continue; - } - - // Temporary ownership is independent from the ordinary local hand-back state. - zone.local_thermostat_power = Some(true); - zone.local_thermostat_resume_at = None; - zone.local_thermostat_restore_zone_enabled = None; - zone.enabled = true; - zone.manual_setpoint = Some(target); - zone.effective_setpoint = Some(target); - zone.manual_override_until = None; - if let Some(session) = zone.temporary_quick_thermostat.as_mut() { - session.activated_at = Some(now); - session.state = "active".into(); - session.active_mode = Some(active_mode); - session.restore_zone_enabled = Some(restore_enabled); - session.restore_local_thermostat_power = restore_local_power; - session.restore_local_thermostat_resume_at = restore_local_resume_at; - session.restore_local_thermostat_zone_enabled = restore_local_zone_enabled; - session.restore_manual_preset = restore_manual_preset; - session.restore_manual_setpoint = restore_manual_setpoint; - session.restore_manual_override_until = restore_manual_override_until; - session.condition_started_at = None; - session.condition_last_observed_at = None; - session.paused_at = None; - if session.finish_kind == "duration" { - session.expires_at = duration_seconds.map(|seconds| now + chrono::Duration::seconds(seconds as i64)); - } else if session.finish_kind == "schedule_boundary" { - session.expires_at = schedule_boundary; - } - if matches!(session.finish_kind.as_str(), "temperature_reached" | "temperature_stable") { - session.safety_expires_at = safety_duration_seconds.map(|seconds| now + chrono::Duration::seconds(seconds as i64)); - } - } - zone.updated_at = now; - state.db.save_zone(zone)?; - state.broadcast("zone.updated", serde_json::to_value(&*zone)?); - state.log("info", "zone.temporary_quick_thermostat_started", &format!("Temporary Quick Thermostat started for {}", zone.name), json!({ - "zone_id": zone.id, "device_id": zone.device_id, "scheduled_start": true, "target_temperature": target - })); - } - Ok(()) -} - -async fn ensure_device_off_after_temporary_disabled_restore(state: &AppState, zone: &Zone, device: &Device) { - if zone.enabled || zone.device_manual_override || !device.enabled || !device.online || device.communication_failures > 0 || !device.power { return; } - let _device_guard = state.lock_device_operation(&zone.device_id).await; - let should_stop = state.db.get_zone(&zone.id).ok().flatten() - .map(|latest| !latest.enabled - && !latest.device_manual_override - && latest.temporary_quick_thermostat.is_none() - && latest.local_thermostat_power.is_none()) - .unwrap_or(false); - if !should_stop { return; } - if let Err(err) = send_command_locked(state, &zone.device_id, DeviceCommand { power: Some(false), ..Default::default() }).await { - state.log("error", "zone.temporary_quick_thermostat_poweroff_error", &err.to_string(), json!({ - "zone_id": zone.id, "device_id": zone.device_id - })); - } -} - -fn temporary_temperature_condition_met(zone: &Zone, session: &TemporaryQuickThermostat) -> bool { - let (Some(current), Some(target)) = (zone.current_temperature, session.temperature_target) else { return false; }; - let tolerance = session.tolerance_c.max(0.0); - match session.temperature_operator.as_deref().unwrap_or("within") { - "at_or_below" => current <= target + tolerance, - "at_or_above" => current >= target - tolerance, - _ => (current - target).abs() <= tolerance, - } -} - -/// Update a temperature-based temporary session only from a fresh sensor observation. -/// Cached samples and long controller gaps cannot count as continuous hold time. -fn evaluate_temporary_quick_thermostat_condition( - zone: &mut Zone, - now: DateTime, - sample_at: Option>, - max_gap_seconds: u64, -) -> Option { - if !temporary_quick_thermostat_is_active(zone, now) || zone.device_manual_override { return None; } - let is_condition = zone.temporary_quick_thermostat.as_ref() - .map(|session| matches!(session.finish_kind.as_str(), "temperature_reached" | "temperature_stable")) - .unwrap_or(false); - if !is_condition { return None; } - - let Some(sample_at) = sample_at else { - if let Some(session) = zone.temporary_quick_thermostat.as_mut() { - session.condition_started_at = None; - session.condition_last_observed_at = None; - } - return None; - }; - let last_observed = zone.temporary_quick_thermostat.as_ref().and_then(|session| session.condition_last_observed_at); - if last_observed.map(|last| sample_at <= last).unwrap_or(false) { return None; } - let gap_broken = last_observed - .map(|last| sample_at.signed_duration_since(last).num_seconds() > max_gap_seconds.max(1) as i64) - .unwrap_or(false); - let met = zone.temporary_quick_thermostat.as_ref() - .map(|session| temporary_temperature_condition_met(zone, session))?; - - let session = zone.temporary_quick_thermostat.as_mut()?; - session.condition_last_observed_at = Some(sample_at); - if gap_broken { session.condition_started_at = None; } - match session.finish_kind.as_str() { - "temperature_reached" => { - if met { return Some("temperature_reached".into()); } - session.condition_started_at = None; - } - "temperature_stable" => { - if !met { - session.condition_started_at = None; - return None; - } - let started = session.condition_started_at.get_or_insert(sample_at); - if session.hold_seconds == 0 || sample_at.signed_duration_since(*started).num_seconds() >= session.hold_seconds as i64 { - return Some("temperature_stable".into()); - } - } - _ => {} - } - None -} - -async fn expire_local_thermostat_overrides(state: &AppState, zones: &mut [Zone], schedules: &[Schedule], house_mode: &str) -> Result<(), AppError> { - let now = Utc::now(); - for zone in zones.iter_mut() { - let zone_id = zone.id.clone(); - let _zone_guard = state.lock_zone_operation(&zone_id).await; - let Some(latest) = state.db.get_zone(&zone_id)? else { continue; }; - *zone = latest; - // A direct device/pilot takeover has higher priority than the local-OFF hand-back. - // Do not let the old timer expire underneath someone who is actively controlling - // the unit. When that takeover ends and the device returns to OFF, the deadline is - // re-armed from that moment. - if !local_thermostat_handback_is_active(zone) { continue; } - if zone.local_thermostat_resume_at.is_none() { - // Upgrade safety for a persisted 0.7.10/0.7.11 local-OFF state: old releases - // had no hand-back deadline, so start one from the first cycle after upgrade. - set_local_thermostat_power(zone, false, now.clone()); - zone.updated_at = now.clone(); - state.db.save_zone(zone)?; - state.broadcast("zone.updated", serde_json::to_value(&*zone)?); - state.log("info", "zone.local_thermostat_resume_scheduled", &format!("Local thermostat hand-back scheduled for {}", zone.name), json!({ - "zone_id": zone.id, "device_id": zone.device_id, "delay_minutes": LOCAL_THERMOSTAT_RESUME_DELAY_MINUTES - })); - continue; - } - let expired = zone.local_thermostat_resume_at.as_ref().map(|at| at <= &now).unwrap_or(false); - if !expired { continue; } - reset_local_thermostat_override(zone); - refresh_zone_runtime_target(zone, schedules, house_mode); - zone.updated_at = now.clone(); - state.db.save_zone(zone)?; - state.broadcast("zone.updated", serde_json::to_value(&*zone)?); - state.log("info", "zone.local_thermostat_resumed", &format!("Local thermostat hand-back completed for {}", zone.name), json!({ - "zone_id": zone.id, "device_id": zone.device_id, "delay_minutes": LOCAL_THERMOSTAT_RESUME_DELAY_MINUTES - })); - } - Ok(()) -} - -fn next_time_automation_utc(item: &Automation, now: DateTime) -> Option> { - if !item.enabled || item.trigger_kind != "time" { return None; } - let expected = NaiveTime::parse_from_str(item.at_time.as_deref()?, "%H:%M").ok()?; - let minute_floor = now.with_second(0)?.with_nanosecond(0)?; - for offset in 0..=(24 * 60) { - let candidate = minute_floor + chrono::Duration::minutes(offset); - if candidate <= now { continue; } - if candidate.hour() == expected.hour() && candidate.minute() == expected.minute() { - return Some(candidate.with_timezone(&Utc)); - } - } - None -} - -fn next_zone_control_deadline_delay(state: &AppState) -> Result, AppError> { - let now = Utc::now(); - let local_now = now.with_timezone(&Local); - let zones = state.db.list_zones()?; - let schedules = state.db.list_schedules()?; - let automations = state.db.list_automations()?; - let mut deadlines: Vec> = Vec::new(); - - for zone in &zones { - if local_thermostat_handback_is_active(zone) { - if let Some(at) = zone.local_thermostat_resume_at.clone() { deadlines.push(at); } - } - if let Some(at) = temporary_quick_thermostat_wakeup_at(zone, now.clone()) { deadlines.push(at); } - if let Some(at) = next_schedule_boundary_utc(&zone.id, &schedules, local_now.clone()) { deadlines.push(at); } - } - for automation in &automations { - if let Some(at) = next_time_automation_utc(automation, local_now.clone()) { deadlines.push(at); } - } - - Ok(deadlines.into_iter() - .map(|at| (at - now.clone()).to_std().unwrap_or(Duration::ZERO)) - .min()) -} - -pub fn refresh_control_ownership(zone: &mut Zone, house_power_enabled: bool, blocked_by_group: bool) { - let now = Utc::now(); - let (owner, source, resume_at, reason) = if !house_power_enabled { - ("global_off", "global".to_string(), None, "Whole-house power is disabled".to_string()) - } else if zone.device_manual_override { - let source = match zone.control_source.as_str() { - "home_assistant_direct" | "web_direct" | "external" => zone.control_source.clone(), - _ => "external".into(), - }; - ("direct_manual", source, zone.device_manual_override_until, "Direct/manual device control has priority".to_string()) - } else if zone.local_thermostat_power.is_some() { - let source = match zone.control_source.as_str() { - "home_assistant_thermostat" | "web_thermostat" => zone.control_source.clone(), - _ => "local_thermostat".into(), - }; - let resume_at = zone.temporary_quick_thermostat.as_ref() - .and_then(temporary_quick_thermostat_next_deadline) - .or(zone.local_thermostat_resume_at.clone()); - let reason = if zone.local_thermostat_power == Some(false) { - "Local thermostat is explicitly off".into() - } else if zone.temporary_quick_thermostat.is_some() { - "Temporary Quick Thermostat owns the zone".into() - } else { - "Local thermostat owns the zone".into() - }; - ("local_thermostat", source, resume_at, reason) - } else if blocked_by_group { - ("automation", "group".to_string(), None, "Zone is blocked by a disabled group".to_string()) - } else { - ("automation", "automation".to_string(), zone.manual_override_until, "Automatic thermostat/schedule control".to_string()) - }; - if zone.control_owner != owner || zone.control_source != source { - zone.control_since = Some(now); - } else if zone.control_since.is_none() { - zone.control_since = Some(now); - } - zone.control_owner = owner.into(); - zone.control_source = source; - zone.control_resume_at = resume_at; - zone.control_reason = reason; -} - -fn normalized_direct_source(source: &str) -> &'static str { - if source.contains("home_assistant") { "home_assistant_direct" } - else if source == "device.manual_control" { "web_direct" } - else { "external" } -} - -pub fn reset_device_manual_override(zone: &mut Zone) -> bool { - let now = Utc::now(); - let changed = zone.device_manual_override - || zone.device_manual_override_since.is_some() - || zone.device_manual_override_until.is_some() - || !zone.device_manual_override_fields.is_empty() - || zone.device_manual_override_baseline.is_some(); - zone.device_manual_override = false; - zone.device_manual_override_since = None; - zone.device_manual_override_until = None; - zone.device_manual_override_fields.clear(); - zone.device_manual_override_baseline = None; - if let Some(session) = zone.temporary_quick_thermostat.as_mut() { - let pause = session.paused_at.take() - .map(|paused_at| now.signed_duration_since(paused_at)) - .filter(|pause| *pause > chrono::Duration::zero()); - if session.activated_at.is_some() { - if let Some(pause) = pause { - if matches!(session.finish_kind.as_str(), "duration" | "until") { - session.expires_at = session.expires_at.map(|at| at + pause); - } - if matches!(session.finish_kind.as_str(), "temperature_reached" | "temperature_stable") { - session.safety_expires_at = session.safety_expires_at.map(|at| at + pause); - } - } - session.state = "active".into(); - } else { - // A due session blocked by manual ownership has not started its work clock. - // Preserve the requested remaining `until` window by excluding manual wait time. - if session.finish_kind == "until" { - if let Some(pause) = pause { - session.expires_at = session.expires_at.map(|at| at + pause); - } - } - session.state = "scheduled".into(); - } - session.condition_started_at = None; - session.condition_last_observed_at = None; - } - if zone.control_owner == "direct_manual" { - zone.control_owner = "automation".into(); - zone.control_source = "automation".into(); - zone.control_since = Some(now); - zone.control_resume_at = None; - zone.control_reason = "Manual takeover cleared; automation may resume".into(); - } - changed -} - -fn manual_override_matches_baseline(zone: &Zone, device: &Device) -> bool { - let Some(baseline) = zone.device_manual_override_baseline.as_ref() else { return false; }; - if zone.device_manual_override_fields.is_empty() { return false; } - // If the unit was OFF before takeover, returning it to OFF is operationally the same - // controller state even if the remote retained a different mode/target internally. - // Those dormant values will be set explicitly if automation later powers the unit. - if !baseline.power { return !device.power; } - zone.device_manual_override_fields.iter().all(|field| match field.as_str() { - "power" => device.power == baseline.power, - "mode" => device.mode == baseline.mode, - "target_temperature" => device.target_temperature.round() == baseline.target_temperature.round(), - "fan_speed" => device.fan_speed == baseline.fan_speed, - "quiet" => device.quiet == baseline.quiet, - "sleep" => device.sleep == baseline.sleep, - _ => false, - }) -} - -fn persist_manual_override_clear(state: &AppState, zone: &mut Zone, source: &str, restored: bool) -> Result { - if !reset_device_manual_override(zone) { return Ok(false); } - let now = Utc::now(); - let local_resume_rearmed = restored && rearm_local_thermostat_resume(zone, now.clone()); - zone.updated_at = now; - state.db.save_zone(zone)?; - state.broadcast("zone.updated", serde_json::to_value(&*zone)?); - let (kind, message) = if restored { - ("zone.device_manual_override_restored", format!("Manual device control returned {} to its previous state", zone.name)) - } else { - ("zone.device_manual_override_cleared", format!("Manual device control ended for {}", zone.name)) - }; - state.log("info", kind, &message, json!({ - "zone_id": zone.id, "device_id": zone.device_id, "source": source, - "local_thermostat_resume_rearmed": local_resume_rearmed, - "local_thermostat_resume_at": zone.local_thermostat_resume_at, - })); - state.wake_zone_control(); - Ok(true) -} - -fn set_device_manual_override(state: &AppState, zone: &mut Zone, fields: Vec, source: &str, baseline: &Device) -> Result<(), AppError> { - if fields.is_empty() { return Ok(()); } - let now = Utc::now(); - if !zone.device_manual_override { - zone.device_manual_override_since = Some(now); - zone.device_manual_override_baseline = Some(baseline.into()); - zone.control_since = Some(now); - } - zone.device_manual_override = true; - if let Some(session) = zone.temporary_quick_thermostat.as_mut() { - // A future scheduled session has no ownership yet. Do not mark it paused until its - // requested start actually becomes due while manual control is still present. - if session.activated_at.is_some() || session.started_at <= now { - if session.paused_at.is_none() { session.paused_at = Some(now); } - session.state = "paused_manual".into(); - session.condition_started_at = None; - session.condition_last_observed_at = None; - } - } - zone.control_owner = "direct_manual".into(); - zone.control_source = normalized_direct_source(source).into(); - zone.control_reason = "Direct/manual device control has priority".into(); - zone.device_manual_override_until = if zone.enabled { - next_schedule_boundary_utc(&zone.id, &state.db.list_schedules()?, Local::now()) - } else { - None - }; - zone.control_resume_at = zone.device_manual_override_until; - for field in fields { - if !zone.device_manual_override_fields.iter().any(|existing| existing == &field) { - zone.device_manual_override_fields.push(field); - } - } - zone.demand = false; - zone.demand_since = None; - zone.updated_at = now; - state.db.save_zone(zone)?; - state.broadcast("zone.updated", serde_json::to_value(&*zone)?); - state.log("info", "zone.device_manual_override", &format!("Manual device control detected for {}", zone.name), json!({ - "zone_id": zone.id, - "device_id": zone.device_id, - "fields": zone.device_manual_override_fields, - "source": source, - "override_until": zone.device_manual_override_until, - })); - Ok(()) -} - -async fn detect_external_device_control(state: &AppState, before: &Device, after: &Device) -> Result<(), AppError> { - if before.id != after.id { return Ok(()); } - for mut zone in state.db.list_zones()?.into_iter().filter(|zone| zone.device_id == after.id) { - let raw_fields = externally_changed_control_fields(before, after, &zone); - let controller_settling = if raw_fields.is_empty() { - json!({ "active": false, "reason": "no_changed_control_fields" }) - } else { - controller_settling_diagnostics(state, &after.id).await - }; - let fields = suppress_expected_controller_changes( - state, - after, - raw_fields.clone(), - ).await; - if zone.device_manual_override && manual_override_matches_baseline(&zone, after) { - persist_manual_override_clear(state, &mut zone, "gree_poll", true)?; - continue; - } - if fields.is_empty() { continue; } - // A disabled zone is outside controller ownership. When its manually operated unit is - // switched off there is no takeover left to display or remember. - if !zone.enabled && !after.power { - persist_manual_override_clear(state, &mut zone, "gree_poll", false)?; - continue; - } - state.log("info", "device.remote_control_detected", &format!("External/pilot control detected for {}", zone.name), json!({ - "zone_id": zone.id.clone(), - "device_id": zone.device_id.clone(), - "raw_fields": raw_fields, - "detected_fields": fields.clone(), - "before": device_control_snapshot(before), - "after": device_control_snapshot(after), - "controller_settling": controller_settling, - "source": "gree_poll", - "zone_state": { - "enabled": zone.enabled, - "control_owner": zone.control_owner.clone(), - "control_source": zone.control_source.clone(), - "demand": zone.demand, - "local_thermostat_power": zone.local_thermostat_power, - "temporary_quick_thermostat": zone.temporary_quick_thermostat.clone(), - }, - })); - set_device_manual_override(state, &mut zone, fields, "gree_poll", before)?; - } - Ok(()) -} - -pub async fn send_manual_command(state: &AppState, device_id: &str, command: DeviceCommand, source: &str) -> Result { - // Keep zone -> device lock ordering consistent with Quick Thermostat/full-zone edits. - // A device belongs to at most one thermostat zone, but keep this generic for legacy data. - let zone_ids: Vec = state.db.list_zones()?.into_iter().filter(|zone| zone.device_id == device_id).map(|zone| zone.id).collect(); - let mut _zone_guards = Vec::new(); - for zone_id in &zone_ids { _zone_guards.push(state.lock_zone_operation(zone_id).await); } - // Keep the device lock until the zone takeover marker is persisted. Otherwise a poll - // could observe our own just-sent command before the controller records manual ownership. - let _device_guard = state.lock_device_operation(device_id).await; - let before = state.db.get_device(device_id)? - .ok_or_else(|| AppError::NotFound(format!("device {device_id}")))?; - let effective_command = if before.online && before.communication_failures == 0 { command.changed_from(&before) } else { command.clone() }; - let fields = command_manual_control_fields(&effective_command); - let updated = send_command_locked_inner(state, device_id, command, true, false).await?; - if !fields.is_empty() { - for mut zone in state.db.list_zones()?.into_iter().filter(|zone| zone.device_id == device_id) { - if zone.device_manual_override && manual_override_matches_baseline(&zone, &updated) { - persist_manual_override_clear(state, &mut zone, source, true)?; - continue; - } - if !zone.enabled && !updated.power { - persist_manual_override_clear(state, &mut zone, source, false)?; - continue; - } - set_device_manual_override(state, &mut zone, fields.clone(), source, &before)?; - } - } - Ok(updated) -} - -pub async fn force_house_power_off_device(state: &AppState, device_id: &str, source: &str) -> Result { - // Global OFF is a one-shot authority transition. Clear takeover and send OFF while - // polling for this unit is excluded; a later remote change happens after the lock and - // is therefore preserved as a new manual takeover. - let _device_guard = state.lock_device_operation(device_id).await; - for mut zone in state.db.list_zones()?.into_iter().filter(|zone| zone.device_id == device_id) { - if !reset_device_manual_override(&mut zone) { continue; } - zone.updated_at = Utc::now(); - state.db.save_zone(&zone)?; - state.broadcast("zone.updated", serde_json::to_value(&zone)?); - state.log("info", "zone.device_manual_override_cleared", &format!("Automation resumed for {}", zone.name), json!({ - "zone_id": zone.id, "device_id": zone.device_id, "source": source - })); - } - send_command_locked_forced(state, device_id, DeviceCommand { power: Some(false), ..Default::default() }).await -} - -pub async fn force_power_off_device(state: &AppState, device_id: &str) -> Result { - let _device_guard = state.lock_device_operation(device_id).await; - send_command_locked_forced(state, device_id, DeviceCommand { power: Some(false), ..Default::default() }).await -} - -/// Technical device disable is a safety transition, not just a database flag. The unit is -/// explicitly powered off while it is still commandable, then removed from controller polling. -pub async fn disable_device_safely(state: &AppState, device_id: &str) -> Result { - let _device_guard = state.lock_device_operation(device_id).await; - let mut device = state.db.get_device(device_id)? - .ok_or_else(|| AppError::NotFound(format!("device {device_id}")))?; - if !device.enabled { return Ok(device); } - device = send_command_locked_forced( - state, - device_id, - DeviceCommand { power: Some(false), ..Default::default() }, - ).await?; - device.enabled = false; - device.updated_at = Utc::now(); - state.db.save_device(&device)?; - state.broadcast("device.updated", serde_json::to_value(&device)?); - state.wake_zone_control(); - Ok(device) -} - -pub fn clear_all_device_manual_overrides(state: &AppState, source: &str) -> Result { - let mut cleared = 0usize; - for mut zone in state.db.list_zones()? { - if !reset_device_manual_override(&mut zone) { continue; } - zone.updated_at = Utc::now(); - state.db.save_zone(&zone)?; - state.broadcast("zone.updated", serde_json::to_value(&zone)?); - state.log("info", "zone.device_manual_override_cleared", &format!("Automation resumed for {}", zone.name), json!({ - "zone_id": zone.id, "device_id": zone.device_id, "source": source - })); - cleared += 1; - } - Ok(cleared) -} - -pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControlPatch, source: &str) -> Result { - if let Some(mode) = patch.mode.as_deref() { - if !matches!(mode, "house" | "auto" | "cool" | "heat") { - return Err(AppError::BadRequest("group mode must be house, cool or heat".into())); - } - } - if let Some(preset) = patch.preset.as_deref() { - if !matches!(preset, "auto" | "comfort" | "sleep" | "away") { - return Err(AppError::BadRequest("group preset must be auto, comfort, sleep or away".into())); - } - } - - let mut group = state.db.get_group(group_id)? - .ok_or_else(|| AppError::NotFound(format!("group {group_id}")))?; - let schedules = state.db.list_schedules()?; - let climate_change = patch.mode.is_some() || patch.preset.is_some(); - if let Some(power) = patch.power { - group.power_enabled = power; - } - group.updated_at = Utc::now(); - state.db.save_group(&group)?; - state.broadcast("group.updated", serde_json::to_value(&group)?); - - // Explicit group ON is a conscious request to run this group. Resume the global - // master without changing the gates of any other groups. This makes group ON work - // even after a previous whole-house OFF while preserving multi-group OFF priority. - if patch.power == Some(true) { - let mut settings = state.settings.write().await; - if !settings.house_power_enabled { - settings.house_power_enabled = true; - state.db.save_runtime_settings(&settings)?; - state.broadcast("house.power_changed", json!({"house_power_enabled": true})); - state.log("info", "house.power_resumed_by_group", &format!("Whole-house master resumed by group {}", group.name), json!({ - "group_id": group.id, "source": source - })); - } - } - - let mut zones = Vec::new(); - for zone_id in &group.zone_ids { - let _zone_guard = state.lock_zone_operation(zone_id).await; - let Some(zone_snapshot) = state.db.get_zone(zone_id)? else { continue; }; - let _device_guard = state.lock_device_operation(&zone_snapshot.device_id).await; - let Some(mut zone) = state.db.get_zone(zone_id)? else { continue; }; - let temporary_owns_zone = temporary_quick_thermostat_is_active(&zone, Utc::now()); - if temporary_owns_zone { - if let Some(session) = zone.temporary_quick_thermostat.as_mut() { - if let Some(mode) = patch.mode.as_deref() { session.deferred_mode = Some(mode.to_string()); } - if let Some(preset) = patch.preset.as_deref() { session.deferred_preset = Some(preset.to_string()); } - } - if climate_change { - state.log("info", "group.control_deferred_by_temporary_thermostat", &format!("Group climate change deferred for {} while Temporary Quick Thermostat owns the zone", zone.name), json!({ - "zone_id": zone.id, "group_id": group.id, "source": source - })); - } - } else { - if let Some(mode) = patch.mode.as_deref() { - match mode { - "house" | "auto" => zone.inherit_house_mode = true, - "cool" | "heat" => { - zone.inherit_house_mode = false; - zone.mode = mode.to_string(); - } - _ => {} - } - } - if let Some(preset) = patch.preset.as_deref() { - if preset == "auto" { - zone.manual_preset = None; - zone.manual_setpoint = None; - zone.manual_override_until = None; - } else { - zone.manual_preset = Some(preset.to_string()); - zone.manual_setpoint = None; - zone.manual_override_until = next_schedule_boundary_utc(&zone.id, &schedules, Local::now()); - } - } - } - zone.revision = zone.revision.saturating_add(1); - zone.updated_at = Utc::now(); - state.db.save_zone(&zone)?; - state.broadcast("zone.updated", serde_json::to_value(&zone)?); - zones.push(zone); - } - - let runtime = state.settings.read().await.clone(); - let master_power_enabled = runtime.house_power_enabled; - let should_command_power = patch.power.is_some(); - let desired_power = group.power_enabled; - // A zone may intentionally belong to more than one group. Power-off is authoritative: - // turning one group on must never briefly wake a member that is still blocked by another group. - let mut failed = Vec::new(); - if should_command_power && !desired_power { - let mut seen = std::collections::HashSet::new(); - for zone in &zones { - if !seen.insert(zone.device_id.clone()) { continue; } - // Group OFF is an immediate safety transition. Group ON never emits a bare - // power=true frame; the thermostat arbiter starts the unit with mode/target. - let Some(device) = state.db.get_device(&zone.device_id)? else { continue; }; - if !device.enabled { continue; } - match send_group_power_if_current(state, &group.id, &zone.id, &device.id, false).await { - Ok(_) => {} - Err(err) => { - state.log("error", "group.power_error", &err.to_string(), json!({ - "group_id": group.id, "device_id": device.id, "device_name": device.name, - "power": false, "source": source, - })); - failed.push(json!({"device_id": device.id, "device_name": device.name, "error": err.to_string()})); - } - } - } - } - - if desired_power && (should_command_power || climate_change) { - state.wake_zone_control(); - } - state.log("info", source, &format!("Updated group {}", group.name), json!({ - "group_id": group.id, "power_enabled": group.power_enabled, "mode": patch.mode, "preset": patch.preset, - "zones": zones.len(), "failed": failed.len(), "master_power_enabled": master_power_enabled, - })); - Ok(json!({ - "group": group, - "zones": zones, - "devices": state.db.list_devices()?, - "failed": failed, - "master_power_enabled": master_power_enabled, - })) -} - - -fn persist_zone_cycle(state: &AppState, computed: &Zone, cycle_started_at: DateTime) -> Result { - let Some(mut latest) = state.db.get_zone(&computed.id)? else { return Ok(computed.clone()); }; - if latest.updated_at <= cycle_started_at { - state.db.save_zone(computed)?; - return Ok(computed.clone()); - } - // Another actor changed this zone while the regulator was doing network I/O. Never - // write the old controller snapshot over fresh configuration or manual takeover state. - // Sensor observations are safe to carry forward only while the device assignment matches. - if latest.device_id == computed.device_id { - latest.device_temperature = computed.device_temperature; - latest.external_temperature = computed.external_temperature; - latest.current_temperature = computed.current_temperature; - latest.control_temperature_source = computed.control_temperature_source.clone(); - latest.updated_at = Utc::now(); - state.db.save_zone(&latest)?; - } - Ok(latest) -} - -async fn thermostat_ownership_is_current(state: &AppState, zone_id: &str, device_id: &str) -> Result { - if !state.settings.read().await.house_power_enabled { return Ok(false); } - let Some(zone) = state.db.get_zone(zone_id)? else { return Ok(false); }; - if zone.device_id != device_id || !zone.enabled || zone.device_manual_override || zone.local_thermostat_power == Some(false) { return Ok(false); } - if zone.local_thermostat_power == Some(true) { return Ok(true); } - let blocked = state.db.list_groups()?.iter().any(|group| { - !group.power_enabled && group.zone_ids.iter().any(|member| member == zone_id) - }); - Ok(!blocked) -} - -async fn group_off_ownership_is_current(state: &AppState, zone_id: &str, device_id: &str) -> Result { - if !state.settings.read().await.house_power_enabled { return Ok(false); } - let Some(zone) = state.db.get_zone(zone_id)? else { return Ok(false); }; - if zone.device_id != device_id || !zone.enabled || zone.device_manual_override || zone.local_thermostat_power.is_some() { return Ok(false); } - Ok(state.db.list_groups()?.iter().any(|group| { - !group.power_enabled && group.zone_ids.iter().any(|member| member == zone_id) - })) -} - -async fn send_zone_command_if_owned( - state: &AppState, - zone_id: &str, - device_id: &str, - command: DeviceCommand, - require_group_block: bool, -) -> Result, AppError> { - // Ownership must be checked after acquiring the same per-device lock used by polling. - // Otherwise polling could detect a remote takeover while this task is waiting for the lock, - // and a stale thermostat decision would still be sent immediately afterwards. - let _device_guard = state.lock_device_operation(device_id).await; - let owned = if require_group_block { - group_off_ownership_is_current(state, zone_id, device_id).await? - } else { - thermostat_ownership_is_current(state, zone_id, device_id).await? - }; - if !owned { return Ok(None); } - send_command_locked(state, device_id, command).await.map(Some) -} - -async fn send_group_power_if_current( - state: &AppState, - group_id: &str, - zone_id: &str, - device_id: &str, - desired_power: bool, -) -> Result, AppError> { - let _device_guard = state.lock_device_operation(device_id).await; - let Some(zone) = state.db.get_zone(zone_id)? else { return Ok(None); }; - if zone.device_id != device_id || !zone.enabled || zone.device_manual_override || zone.local_thermostat_power.is_some() { return Ok(None); } - let groups = state.db.list_groups()?; - let Some(group) = groups.iter().find(|group| group.id == group_id) else { return Ok(None); }; - if group.power_enabled != desired_power || !group.zone_ids.iter().any(|member| member == zone_id) { return Ok(None); } - if desired_power { - let settings = state.settings.read().await; - if !settings.house_power_enabled { return Ok(None); } - let effective_mode = if zone.inherit_house_mode { settings.house_mode.as_str() } else { zone.mode.as_str() }; - if effective_mode == "off" { return Ok(None); } - if groups.iter().any(|other| { - other.id != group_id && !other.power_enabled && other.zone_ids.iter().any(|member| member == zone_id) - }) { - return Ok(None); - } - } - let Some(device) = state.db.get_device(device_id)? else { return Ok(None); }; - if !device.enabled { return Ok(None); } - let command = DeviceCommand { power: Some(desired_power), ..Default::default() }; - if desired_power { - send_command_locked(state, device_id, command).await.map(Some) - } else { - // A deliberate group OFF is a one-shot safety transition. Send it even if the - // cached state already says OFF; the regulator itself will not keep repeating it. - send_command_locked_forced(state, device_id, command).await.map(Some) - } -} - -async fn send_automatic_device_command_if_owned( - state: &AppState, - device_id: &str, - command: DeviceCommand, -) -> Result, AppError> { - let _device_guard = state.lock_device_operation(device_id).await; - if !state.settings.read().await.house_power_enabled { return Ok(None); } - let zones = state.db.list_zones()?; - if device_blocked_by_disabled_zone(device_id, &zones) - || device_blocked_by_manual_override(device_id, &zones) - || device_blocked_by_local_thermostat(device_id, &zones) - || device_blocked_by_disabled_group(device_id, &zones, &state.db.list_groups()?) - { - return Ok(None); - } - send_command_locked(state, device_id, command).await.map(Some) -} - -async fn apply_automatic_device_action( - state: &AppState, - device_id: &str, - command: DeviceCommand, -) -> Result, AppError> { - let zones = state.db.list_zones()?; - let Some(zone_id) = zones.iter().find(|zone| zone.device_id == device_id).map(|zone| zone.id.clone()) else { - return send_automatic_device_command_if_owned(state, device_id, command).await; - }; - - let _zone_guard = state.lock_zone_operation(&zone_id).await; - let mut zone = state.db.get_zone(&zone_id)?.ok_or_else(|| AppError::NotFound(format!("zone {zone_id}")))?; - let settings = state.settings.read().await.clone(); - if !settings.house_power_enabled || zone.device_manual_override || zone.local_thermostat_power.is_some() { - return Ok(None); - } - let groups = state.db.list_groups()?; - if groups.iter().any(|group| !group.power_enabled && group.zone_ids.iter().any(|member| member == &zone.id)) { - return Ok(None); - } - // A power-on automation is an explicit domain transition and may re-enable a zone that - // a previous power automation disabled. Other actions still respect a disabled zone gate. - if !zone.enabled && command.power != Some(true) { return Ok(None); } - - let mut domain_changed = false; - if let Some(power) = command.power { - zone.enabled = power; - domain_changed = true; - } - if let Some(mode) = command.mode.as_deref() { - match mode { - "heat" | "cool" => { - zone.mode = mode.to_string(); - zone.inherit_house_mode = false; - domain_changed = true; - } - "auto" => { - zone.inherit_house_mode = true; - domain_changed = true; - } - _ => return Err(AppError::BadRequest( - "device automation for a thermostat-managed unit supports only heat, cool or auto mode".into(), - )), - } - } - if let Some(target) = command.target_temperature { - zone.manual_setpoint = Some((target.clamp(8.0, 30.0) * 2.0).round() / 2.0); - zone.manual_override_until = next_schedule_boundary_utc(&zone.id, &state.db.list_schedules()?, Local::now()); - domain_changed = true; - } - - if domain_changed { - zone.revision = zone.revision.saturating_add(1); - zone.updated_at = Utc::now(); - zone.control_source = "automation.device".into(); - state.db.save_zone(&zone)?; - state.broadcast("zone.updated", serde_json::to_value(&zone)?); - state.wake_zone_control(); - } - - if command.power == Some(false) { - return force_power_off_device(state, device_id).await.map(Some); - } - - // Climate fields above are durable zone state. Only non-climate device capabilities remain - // a one-shot command; the thermostat can no longer undo power/mode/target next cycle. - let residual = DeviceCommand { - power: None, - mode: None, - target_temperature: None, - fan_speed: command.fan_speed, - swing_vertical: command.swing_vertical, - swing_horizontal: command.swing_horizontal, - quiet: command.quiet, - turbo: command.turbo, - light: command.light, - air: command.air, - xfan: command.xfan, - health: command.health, - sleep: command.sleep, - }; - if residual.is_empty() { - return state.db.get_device(device_id)?.map(Some).ok_or_else(|| AppError::NotFound(format!("device {device_id}"))); - } - send_automatic_device_command_if_owned(state, device_id, residual).await -} - -async fn control_zones(state: &AppState) -> Result<()> { - let schedules = state.db.list_schedules()?; - let groups = state.db.list_groups()?; - let settings = state.settings.read().await.clone(); - let mut zone_snapshot = state.db.list_zones()?; - // Local quick-thermostat OFF is intentionally temporary. Expire the ownership marker - // before the house-power early return so the hand-back still happens while the master - // is off; no physical state is restored here, only automation ownership. - expire_local_thermostat_overrides(state, &mut zone_snapshot, &schedules, &settings.house_mode).await?; - let temporary_restored_disabled = expire_temporary_quick_thermostats(state, &mut zone_snapshot, &schedules, &settings.house_mode).await?; - activate_due_temporary_quick_thermostats(state, &mut zone_snapshot, &schedules, &settings.house_mode, settings.house_power_enabled).await?; - - // Outdoor temperature is deliberately optional. Prefer the configured Home - // Assistant entity, but keep the dashboard/assist useful by falling back to the - // outdoor sensors reported by GREE units when HA is temporarily unavailable. - let device_snapshot = state.db.list_devices()?; - let configured_outdoor = settings.home_assistant.outdoor_entity_id.trim(); - let resolved_outdoor = if configured_outdoor.is_empty() { - None - } else { - home_assistant::resolve_entity_id(&settings.home_assistant, Some(configured_outdoor)) - }; - let ha_outdoor_temperature = if let Some(entity_id) = resolved_outdoor.as_deref() { - match home_assistant::read_temperature(&state.http, &settings.home_assistant, Some(entity_id), Some(settings.home_assistant.sensor_stale_after_seconds)).await { - Ok(value) => { - record_ha_history( - state, - entity_id, - None, - "outdoor", - value, - settings.poll_interval_seconds, - ); - Some(value) - } - Err(err) => { - tracing::debug!(configured_entity=%configured_outdoor, resolved_entity=%entity_id, error=?err, "outdoor Home Assistant sensor unavailable; trying GREE fallback"); - None - } - } - } else { - None - }; - let outdoor_temperature = ha_outdoor_temperature.or_else(|| gree_outdoor_temperature(&device_snapshot)); - { - let mut current = state.outdoor_temperature.write().await; - if *current != outdoor_temperature { - *current = outdoor_temperature; - state.broadcast("outdoor.updated", json!({"temperature": outdoor_temperature})); - } - } - let outdoor_assist_temperature = if settings.outdoor_assist_enabled { outdoor_temperature } else { None }; - let night_active = night_mode_active(&settings.night_mode, Local::now().time()); - - if !settings.house_power_enabled { - // Whole-house OFF is a one-shot action performed by the API endpoint. While the - // master remains off the regulator stays passive. A later physical/remote change - // is therefore detected as manual takeover and is not erased or forced OFF again. - return Ok(()); - } - - // Read all per-zone Home Assistant sensors concurrently. A down HA instance should cost - // one request timeout per cycle, not one timeout multiplied by the number of zones. - let room_sensor_reads = futures_util::future::join_all(zone_snapshot.iter().filter_map(|zone| { - if !matches!(zone.sensor_source.as_str(), "home_assistant" | "combined") { return None; } - let zone_id = zone.id.clone(); - let resolved_entity = home_assistant::resolve_entity_id(&settings.home_assistant, zone.ha_entity_id.as_deref()); - let http = &state.http; - let ha_settings = &settings.home_assistant; - let stale_after_seconds = effective_sensor_stale_after_seconds(zone.sensor_stale_after_seconds, ha_settings.sensor_stale_after_seconds); - Some(async move { - let result = home_assistant::read_temperature(http, ha_settings, resolved_entity.as_deref(), Some(stale_after_seconds)).await - .map_err(|err| err.to_string()); - (zone_id, resolved_entity, result) - }) - })).await; - let mut room_sensor_results: HashMap, Result)> = room_sensor_reads.into_iter() - .map(|(zone_id, entity_id, result)| (zone_id, (entity_id, result))) - .collect(); - - for mut zone in zone_snapshot { - let cycle_started_at = zone.updated_at; - if zone.manual_override_until.map(|until| until <= Utc::now()).unwrap_or(false) { - zone.manual_preset = None; - zone.manual_setpoint = None; - zone.manual_override_until = None; - } - if zone.device_manual_override_until.map(|until| until <= Utc::now()).unwrap_or(false) { - reset_device_manual_override(&mut zone); - state.log("info", "zone.device_manual_override_expired", &format!("Manual device control expired for {} at schedule transition", zone.name), json!({ - "zone_id": zone.id, "device_id": zone.device_id - })); - } - - let Some(device) = state.db.get_device(&zone.device_id)? else { - state.log("error", "zone.device_missing", &format!("Zone {} has no device", zone.name), json!({"zone_id": zone.id})); - continue; - }; - - // House "off" means the smart thermostat does not control inherited zones. - // A zone explicitly switched to heat/cool remains independent and may still run. - // Local Quick Thermostat is an explicit per-zone request. If the inherited house - // climate mode is "off" (no automatic climate control), use the zone's last local - // heat/cool mode while local ownership is ON. The separate whole-house master power - // remains authoritative and is checked before this loop. - let effective_mode_owned = effective_zone_mode(&zone, &settings.house_mode); - zone.effective_mode = effective_mode_owned.clone(); - let ownership_blocked_by_group = zone.local_thermostat_power != Some(true) - && groups.iter().any(|group| !group.power_enabled && group.zone_ids.iter().any(|zone_id| zone_id == &zone.id)); - refresh_control_ownership(&mut zone, settings.house_power_enabled, ownership_blocked_by_group); - let effective_mode = effective_mode_owned.as_str(); - - let previous_source = zone.control_temperature_source.clone(); - // Never feed the thermostat a cached GREE temperature after any communication - // failure. External HA sensors may still keep a zone operational when configured. - let device_temperature = if device.enabled && device.online && device.communication_failures == 0 { - device.current_temperature - } else { - None - }; - let external_temperature = if matches!(zone.sensor_source.as_str(), "home_assistant" | "combined") { - match room_sensor_results.remove(&zone.id) { - Some((resolved_entity, Ok(value))) => { - if let Some(entity_id) = resolved_entity.as_deref() { - record_ha_history(state, entity_id, Some(&zone.id), "room", value, settings.poll_interval_seconds); - } - Some(value) - } - Some((resolved_entity, Err(err))) => { - if !matches!(previous_source.as_str(), "device_fallback" | "device_discrepancy_fallback") { - let notification_kind = if err.contains("Home Assistant sensor is stale:") { - "ha.sensor_stale" - } else { - "ha.sensor_error" - }; - state.log("warn", notification_kind, &err, json!({ - "zone_id": zone.id, - "configured_entity_id": zone.ha_entity_id.as_deref(), - "resolved_entity_id": resolved_entity, - })); - } - None - } - None => None, - } - } else { - None - }; - - let (temperature, control_source, discrepancy) = select_zone_temperature(&zone, device_temperature, external_temperature); - zone.device_temperature = device_temperature; - zone.external_temperature = external_temperature; - zone.current_temperature = temperature; - zone.control_temperature_source = control_source; - zone.updated_at = Utc::now(); - - // A disabled thermostat zone is completely outside normal controller ownership. - // Keep its sensors fresh, but do not let group state, schedules or thermostat - // modulation touch the unit. Manual control from the technical Devices view may - // therefore remain active until the zone is explicitly enabled again. - if !zone.enabled { - if temporary_restored_disabled.iter().any(|zone_id| zone_id == &zone.id) { - ensure_device_off_after_temporary_disabled_restore(state, &zone, &device).await; - } - zone.demand = false; - zone.demand_since = None; - record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds); - let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?; - state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?); - continue; - } - - // A technically disabled device is outside thermostat ownership. Do not create - // repeated command errors while keeping any available external sensor data visible. - if !device.enabled { - zone.demand = false; - zone.demand_since = None; - zone.device_setpoint = None; - record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds); - let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?; - state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?); - continue; - } - - // A physical/manual takeover has higher priority than thermostat, schedule, group and - // automation control. Continue sensor/history updates, but reflect the unit's real state - // instead of sending corrective frames that would fight the person holding the remote. - if zone.device_manual_override { - // Manual/remote takeover pauses commands, but it must not erase the thermostat's - // selected profile/target. Keep the intended target visible and report the physical - // unit target separately through device_setpoint. This makes Resume/Profile actions - // deterministic and avoids a standby device target (for example 25 C) masquerading - // as the zone's Sleep/Comfort target. - let temporary_active = temporary_quick_thermostat_is_active(&zone, zone.updated_at.clone()); - let pause_started_at = zone.updated_at; - if temporary_active { - if let Some(session) = zone.temporary_quick_thermostat.as_mut() { - if session.paused_at.is_none() { session.paused_at = Some(pause_started_at); } - session.state = "paused_manual".into(); - session.condition_started_at = None; - session.condition_last_observed_at = None; - } - } - let target_mode = if effective_mode == "off" { zone.mode.as_str() } else { effective_mode }; - let active_schedule = active_schedule_for_zone(&zone, &schedules, Local::now()); - let (preset, target) = resolve_zone_target(&zone, active_schedule, target_mode); - zone.active_preset = preset; - zone.effective_setpoint = Some(target); - // Keep effective_mode's existing meaning during takeover: it reflects the physical - // unit, while effective_setpoint above remains the thermostat intent. - zone.effective_mode = if device.power { device.mode.clone() } else { "off".into() }; - zone.device_setpoint = if device.power { Some(device.target_temperature) } else { None }; - zone.demand = false; - zone.demand_since = None; - zone.target_alerted_at = None; - record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds); - let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?; - state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?); - continue; - } - - // Temperature completion belongs to the temporary thermostat only while it truly owns - // the zone. A manual/device takeover above therefore pauses the hold instead of silently - // consuming it. GREE samples use last_seen; HA/combined samples were freshly read in this - // control cycle. A long gap resets continuous-hold evidence after restart/stale sensors. - let condition_sample_at = match zone.control_temperature_source.as_str() { - "home_assistant" | "combined" => Some(zone.updated_at.clone()), - _ => device.last_seen.clone(), - }; - let max_condition_gap_seconds = settings.poll_interval_seconds - .max(settings.zone_interval_seconds) - .saturating_mul(2) - .saturating_add(5); - let condition_now = zone.updated_at.clone(); - if let Some(reason) = evaluate_temporary_quick_thermostat_condition( - &mut zone, - condition_now, - condition_sample_at, - max_condition_gap_seconds, - ) { - let finish_kind = zone.temporary_quick_thermostat.as_ref().map(|item| item.finish_kind.clone()).unwrap_or_default(); - finish_temporary_quick_thermostat(&mut zone, &schedules, &settings.house_mode); - record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds); - let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?; - state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?); - ensure_device_off_after_temporary_disabled_restore(state, &persisted_zone, &device).await; - state.log("info", "zone.temporary_quick_thermostat_finished", &format!("Temporary Quick Thermostat finished for {}", zone.name), json!({ - "zone_id": zone.id, "device_id": zone.device_id, "finish_kind": finish_kind, "reason": reason - })); - state.wake_zone_control(); - continue; - } - - if zone.local_thermostat_power == Some(false) { - zone.effective_mode = "off".into(); - zone.demand = false; - zone.demand_since = None; - zone.device_setpoint = None; - if device.online && device.communication_failures == 0 && device.power { - let _device_guard = state.lock_device_operation(&zone.device_id).await; - let latest = state.db.get_zone(&zone.id)?; - if latest.as_ref().map(|item| item.local_thermostat_power == Some(false) && !item.device_manual_override).unwrap_or(false) { - if let Err(err) = send_command_locked( - state, - &zone.device_id, - DeviceCommand { power: Some(false), ..Default::default() }, - ).await { - state.log("error", "zone.local_power_error", &err.to_string(), json!({"zone_id": zone.id, "device_id": zone.device_id})); - } - } - } - record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds); - let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?; - state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?); - continue; - } - - let blocked_by_group = ownership_blocked_by_group; - if blocked_by_group { - zone.effective_mode = "off".into(); - zone.demand = false; - zone.demand_since = None; - zone.device_setpoint = None; - if device.online && device.communication_failures == 0 && device.power { - match send_zone_command_if_owned( - state, - &zone.id, - &zone.device_id, - DeviceCommand { power: Some(false), ..Default::default() }, - true, - ).await { - Ok(_) => {} - Err(err) => state.log("error", "group.power_error", &err.to_string(), json!({"zone_id": zone.id, "device_id": zone.device_id})), - } - } - record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds); - let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?; - state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?); - continue; - } - - if discrepancy && previous_source != "device_discrepancy_fallback" { - state.log("warn", "zone.sensor_discrepancy", &format!("Zone {} sensors differ by more than {:.1} C; using GREE sensor", zone.name, zone.max_sensor_difference), json!({ - "zone_id": zone.id, - "device_temperature": zone.device_temperature, - "external_temperature": zone.external_temperature, - "max_difference": zone.max_sensor_difference, - "entity_id": zone.ha_entity_id.as_deref(), - })); - } - - // House "off" is a no-control state, not a power-off command. Keep polling and - // publishing the zone, but never overwrite manual device state while it follows - // the house mode. Explicit per-zone heat/cool bypasses this branch above. - if effective_mode == "off" { - zone.effective_setpoint = None; - zone.device_setpoint = None; - zone.demand = false; - zone.demand_since = None; - zone.target_alerted_at = None; - record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds); - let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?; - state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?); - continue; - } - - let active_schedule = active_schedule_for_zone(&zone, &schedules, Local::now()); - let (preset, target) = resolve_zone_target(&zone, active_schedule, effective_mode); - zone.active_preset = preset; - zone.effective_setpoint = Some(target); - - let Some(temp) = temperature else { - record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds); - let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?; - state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?); - continue; - }; - - let half = zone.hysteresis.max(0.1) / 2.0; - let previous_demand = zone.demand; - zone.demand = match effective_mode { - "heat" => { - if temp <= target - half { true } - else if temp >= target + half { false } - else { zone.demand } - } - _ => { - if temp >= target + half { true } - else if temp <= target - half { false } - else { zone.demand } - } - }; - if zone.demand && !previous_demand { - zone.demand_since = Some(Utc::now()); - zone.target_alerted_at = None; - } else if !zone.demand { - zone.demand_since = None; - zone.target_alerted_at = None; - } - if zone.demand && zone.target_alerted_at.is_none() { - let timeout_minutes = settings.notifications.target_timeout_minutes.max(5) as i64; - if let Some(since) = zone.demand_since { - if (Utc::now() - since).num_minutes() >= timeout_minutes { - state.log("warn", "zone.target_timeout", &format!("Zone {} has not reached {:.1} C within {} minutes", zone.name, target, timeout_minutes), json!({ - "zone_id": zone.id, "room_temperature": temp, "target_temperature": target, "minutes": timeout_minutes - })); - zone.target_alerted_at = Some(Utc::now()); - } - } - } - - // Setpoint modulation: keep the indoor unit powered and let its own inverter/compressor - // stop naturally when we move the target to the satisfied side of room temperature. - let outdoor_assist = outdoor_assist_offset(effective_mode, outdoor_assist_temperature, temp, target); - // When an independent room sensor is actually driving cooling, the indoor unit's - // own sensor can satisfy too early. Apply a half-degree pre-rounding bias: because - // GREE setpoints are sent as whole degrees, this selects the next lower whole-degree - // target (0.5-1.0 C below the room target). Do not stack it with outdoor assist and - // do not use it during device/fallback control. - let room_sensor_assist = external_room_sensor_cooling_assist(effective_mode, &zone.control_temperature_source); - let demand_assist = outdoor_assist.max(room_sensor_assist); - let active_target = match effective_mode { - "heat" => target + outdoor_assist, - _ => target - demand_assist, - }; - let standby_target = match effective_mode { - "heat" => target - zone.standby_offset_c.max(0.5), - _ => target + zone.standby_offset_c.max(0.5), - }; - let desired_device_target = round_device_setpoint(effective_mode, zone.demand, if zone.demand { active_target } else { standby_target }); - // Report only the last confirmed device state here. The desired target belongs to - // effective_setpoint/command planning until a device command succeeds. - zone.device_setpoint = if device.power { Some(device.target_temperature) } else { None }; - - let demand_changed = previous_demand != zone.demand; - let desired_fan = if night_active { - let max_fan = settings.night_mode.max_fan_speed.clamp(1, 5); - if zone.smart_fan { - Some(night_limited_fan_speed( - smart_fan_speed(effective_mode, temp, target, outdoor_assist_temperature, zone.demand), - max_fan, - )) - } else if device.fan_speed == 0 || device.fan_speed > max_fan { - Some(max_fan) - } else { - Some(device.fan_speed) - } - } else if zone.smart_fan { - Some(smart_fan_speed(effective_mode, temp, target, outdoor_assist_temperature, zone.demand)) - } else { - None - }; - // When the room becomes satisfied, ask compatible units for Quiet in the same - // frame as the standby setpoint and Low fan. When demand returns, disable Quiet - // on the normal smart-fan transition. When scheduled night mode owns Quiet, it - // explicitly enables it inside the window and releases it outside the window. - let desired_quiet = smart_quiet_command( - zone.smart_fan, - state.gree.quiet_command_supported(&device.id), - previous_demand, - zone.demand, - device.quiet, - settings.night_mode.enabled, - night_active, - settings.night_mode.force_quiet, - ); - let desired_sleep = native_sleep_command( - settings.night_mode.enabled, - night_active, - settings.night_mode.use_native_sleep, - device.supports_sleep == Some(true) && state.gree.sleep_command_supported(&device.id), - device.sleep, - ); - - // Compressor protection for automatic ownership. Direct/manual commands and global safety OFF - // deliberately bypass this path, while the thermostat never performs an immediate Heat<->Cool swap. - let now = Utc::now(); - if zone.lockout_until.map(|until| until <= now).unwrap_or(false) { - zone.lockout_until = None; - zone.lockout_reason = None; - } - if device.power && device.mode != effective_mode { - let min_on = chrono::Duration::seconds(zone.min_on_seconds as i64); - if zone.last_power_change_at.map(|at| now.signed_duration_since(at) < min_on).unwrap_or(false) { - let until = zone.last_power_change_at.map(|at| at + min_on); - zone.lockout_until = until; - zone.lockout_reason = Some("minimum_on_before_mode_change".into()); - record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds); - let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?; - state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?); - continue; - } - match send_zone_command_if_owned(state, &zone.id, &zone.device_id, DeviceCommand { power: Some(false), ..Default::default() }, false).await { - Ok(Some(_)) => { - zone.last_power_change_at = Some(now); - zone.lockout_until = Some(now + chrono::Duration::seconds(zone.min_off_seconds as i64)); - zone.lockout_reason = Some("mode_change_off_delay".into()); - state.log("info", "zone.mode_change_lockout", &format!("Zone {} switched off before {} mode", zone.name, effective_mode), json!({"zone_id": zone.id, "resume_at": zone.lockout_until})); - } - Ok(None) => {} - Err(err) => state.log("error", "zone.mode_change_off_error", &err.to_string(), json!({"zone_id": zone.id})), - } - record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds); - let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?; - state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?); - continue; - } - if !device.power { - let min_off = chrono::Duration::seconds(zone.min_off_seconds as i64); - if zone.last_power_change_at.map(|at| now.signed_duration_since(at) < min_off).unwrap_or(false) { - zone.lockout_until = zone.last_power_change_at.map(|at| at + min_off); - zone.lockout_reason = Some("minimum_off_before_start".into()); - record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds); - let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?; - state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?); - continue; - } - } - - let core_needs_command = !device.power - || (device.target_temperature - desired_device_target).abs() >= 0.5; - // In normal standby, Low fan is a transition hint rather than a state that should - // be reasserted forever. Some GREE firmwares accept the frame but later report Auto - // again; retrying every min_adjust_seconds only causes needless command beeps. - let fan_needs_command = desired_fan - .map(|fan| fan != device.fan_speed) - .unwrap_or(false) - && (zone.demand || demand_changed || core_needs_command || night_active); - let needs_command = core_needs_command - || fan_needs_command - || desired_quiet.map(|quiet| quiet != device.quiet).unwrap_or(false) - || desired_sleep.map(|sleep| sleep != device.sleep).unwrap_or(false); - - let urgent_start = !device.power; - if needs_command && (urgent_start || adjustment_allowed(&zone)) { - let command = DeviceCommand { - power: Some(true), - mode: Some(effective_mode.to_string()), - target_temperature: Some(desired_device_target), - fan_speed: if fan_needs_command { desired_fan } else { None }, - quiet: desired_quiet, - sleep: desired_sleep, - ..Default::default() - }; - match send_zone_command_if_owned(state, &zone.id, &zone.device_id, command, false).await { - Ok(Some(updated_device)) => { - let transition_at = Utc::now(); - if device.power != updated_device.power { zone.last_power_change_at = Some(transition_at); } - if device.mode != updated_device.mode { zone.last_mode_change_at = Some(transition_at); } - zone.device_setpoint = if updated_device.power { Some(updated_device.target_temperature) } else { None }; - zone.last_action_at = Some(transition_at); - state.log("info", "zone.setpoint_modulation", &format!("Zone {} -> {:.1} C ({})", zone.name, desired_device_target, if zone.demand { "demand" } else { "standby" }), json!({ - "zone_id": zone.id, - "room_temperature": temp, - "comfort_target": target, - "device_target": desired_device_target, - "mode": effective_mode, - "preset": zone.active_preset, - "outdoor_temperature": outdoor_temperature, - "fan_speed": updated_device.fan_speed, - "quiet": updated_device.quiet, - "sleep": updated_device.sleep, - "night_mode": night_active, - })); - } - Ok(None) => { - record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds); - let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?; - state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?); - continue; - } - Err(err) => state.log("error", "zone.action_error", &err.to_string(), json!({"zone_id": zone.id})), - } - } - - record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds); - let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?; - state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?); - } - - Ok(()) -} - -fn record_zone_history(state: &AppState, zone: &Zone, outdoor_temperature: Option, poll_interval_seconds: u64) { - let device = match state.db.get_device(&zone.device_id) { - Ok(Some(device)) => device, - Ok(None) => return, - Err(err) => { - tracing::warn!(error=?err, zone_id=%zone.id, "cannot load device for zone history"); - return; - } - }; - let reading = ZoneReading { - id: 0, - zone_id: zone.id.clone(), - device_id: zone.device_id.clone(), - timestamp: Utc::now(), - gree_temperature: zone.device_temperature.or(device.current_temperature), - external_temperature: zone.external_temperature, - control_temperature: zone.current_temperature.or(zone.device_temperature).or(device.current_temperature), - target_temperature: zone.effective_setpoint.or(zone.manual_setpoint).or(Some(zone.setpoint)), - device_setpoint: zone.device_setpoint.or(Some(device.target_temperature)), - outdoor_temperature: outdoor_temperature.or(device.outdoor_temperature), - power: device.power, - mode: if zone.effective_mode.is_empty() { device.mode.clone() } else { zone.effective_mode.clone() }, - fan_speed: device.fan_speed, - demand: zone.demand, - control_source: zone.control_temperature_source.clone(), - active_preset: zone.active_preset.clone(), - }; - let interval = poll_interval_seconds.max(15) as i64; - match state.db.add_zone_reading_if_due(&reading, interval) { - Ok(true) => queue_influx_zone(state, reading), - Ok(false) => {} - Err(err) => tracing::warn!(error=?err, zone_id=%zone.id, "cannot save zone history sample"), - } -} - -fn record_ha_history( - state: &AppState, - entity_id: &str, - zone_id: Option<&str>, - kind: &str, - temperature: f64, - poll_interval_seconds: u64, -) { - let reading = HaReading { - id: 0, - entity_id: entity_id.to_string(), - zone_id: zone_id.map(str::to_string), - kind: kind.to_string(), - timestamp: Utc::now(), - temperature, - }; - let interval = poll_interval_seconds.max(15) as i64; - match state.db.add_ha_reading_if_due(&reading, interval) { - Ok(true) => queue_influx_ha(state, reading), - Ok(false) => {} - Err(err) => tracing::warn!(error=?err, entity_id=%entity_id, "cannot save Home Assistant history sample"), - } -} - -fn queue_influx_device(state: &AppState, reading: Reading) { - let state = state.clone(); - tokio::spawn(async move { - let settings = state.settings.read().await.influxdb.clone(); - if !settings.enabled { return; } - if let Err(err) = influxdb::write_device(&state.http, &settings, &reading).await { - tracing::warn!(error=?err, device_id=%reading.device_id, "cannot write device metric to InfluxDB"); - } - }); -} - -fn queue_influx_zone(state: &AppState, reading: ZoneReading) { - let state = state.clone(); - tokio::spawn(async move { - let settings = state.settings.read().await.influxdb.clone(); - if !settings.enabled { return; } - if let Err(err) = influxdb::write_zone(&state.http, &settings, &reading).await { - tracing::warn!(error=?err, zone_id=%reading.zone_id, "cannot write zone metric to InfluxDB"); - } - }); -} - -fn queue_influx_ha(state: &AppState, reading: HaReading) { - let state = state.clone(); - tokio::spawn(async move { - let settings = state.settings.read().await.influxdb.clone(); - if !settings.enabled { return; } - if let Err(err) = influxdb::write_ha(&state.http, &settings, &reading).await { - tracing::warn!(error=?err, entity_id=%reading.entity_id, "cannot write HA metric to InfluxDB"); - } - }); -} - -fn gree_outdoor_temperature(devices: &[Device]) -> Option { - let mut values: Vec = devices.iter() - .filter(|device| device.enabled && device.online && device.communication_failures == 0) - .filter_map(|device| device.outdoor_temperature) - .filter(|value| value.is_finite() && (-60.0..=70.0).contains(value)) - .collect(); - if values.is_empty() { return None; } - values.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); - let middle = values.len() / 2; - let value = if values.len() % 2 == 0 { - (values[middle - 1] + values[middle]) / 2.0 - } else { - values[middle] - }; - Some((value * 10.0).round() / 10.0) -} - -fn select_zone_temperature(zone: &Zone, device_temperature: Option, external_temperature: Option) -> (Option, String, bool) { - match zone.sensor_source.as_str() { - "home_assistant" => match (external_temperature, device_temperature) { - (Some(value), _) => (Some(value), "external".into(), false), - (None, Some(value)) => (Some(value), "device_fallback".into(), false), - (None, None) => (None, "unavailable".into(), false), - }, - "combined" => match (device_temperature, external_temperature) { - (Some(device), Some(external)) => { - if (device - external).abs() > zone.max_sensor_difference.max(0.1) { - (Some(device), "device_discrepancy_fallback".into(), true) - } else { - let external_weight = zone.external_sensor_weight.clamp(0.0, 1.0); - let value = device * (1.0 - external_weight) + external * external_weight; - (Some((value * 10.0).round() / 10.0), "combined".into(), false) - } - } - (Some(value), None) => (Some(value), "device_fallback".into(), false), - (None, Some(value)) => (Some(value), "external".into(), false), - (None, None) => (None, "unavailable".into(), false), - }, - _ => match device_temperature { - Some(value) => (Some(value), "device".into(), false), - None => (None, "unavailable".into(), false), - }, - } -} - -fn adjustment_allowed(zone: &Zone) -> bool { - let Some(last) = zone.last_action_at else { return true; }; - (Utc::now() - last).num_seconds().max(0) as u64 >= zone.min_adjust_seconds.max(15) -} - -fn external_room_sensor_cooling_assist(mode: &str, control_source: &str) -> f64 { - if mode == "cool" && matches!(control_source, "external" | "combined") { 0.5 } else { 0.0 } -} - -fn effective_sensor_stale_after_seconds(zone_value: u64, global_value: u64) -> u64 { - let global = global_value.clamp(30, 86_400); - // 0 and the historical hidden default (300 s) mean "inherit the HA setting". - // A non-default value supplied through the existing zone API remains a per-zone override. - if zone_value == 0 || zone_value == 300 { global } else { zone_value.clamp(30, 86_400) } -} - -fn round_device_setpoint(mode: &str, demand: bool, value: f64) -> f64 { - let value = value.clamp(16.0, 30.0); - match (mode, demand) { - ("heat", true) => value.ceil(), - ("heat", false) => value.floor(), - (_, true) => value.floor(), - (_, false) => value.ceil(), - } -} - -fn outdoor_assist_offset(mode: &str, outdoor: Option, room: f64, target: f64) -> f64 { - let Some(outdoor) = outdoor else { return 0.0; }; - let room_error = (room - target).abs(); - let weather = match mode { - "heat" => ((5.0 - outdoor) / 15.0).clamp(0.0, 1.0), - _ => ((outdoor - 30.0) / 10.0).clamp(0.0, 1.0), - }; - (weather * room_error.clamp(0.0, 2.0) * 0.5).clamp(0.0, 1.0) -} - -fn smart_quiet_command( - smart_fan: bool, - quiet_supported: bool, - previous_demand: bool, - demand: bool, - device_quiet: bool, - night_enabled: bool, - night_active: bool, - night_force_quiet: bool, -) -> Option { - if !quiet_supported { return None; } - if night_enabled && night_force_quiet && night_active { - return if device_quiet { None } else { Some(true) }; - } - if night_enabled && night_force_quiet && !night_active && device_quiet { - // Explicitly release Quiet when the scheduled night window ends. - return Some(false); - } - if smart_fan { - // Smart Quiet follows demand transitions. Do not keep reasserting Quiet while a - // satisfied room remains in standby: some units report Quiet=false again even after - // accepting the command, which otherwise produces a beep every adjustment interval. - if previous_demand && !demand && !device_quiet { return Some(true); } - if !previous_demand && demand && device_quiet { return Some(false); } - return None; - } - // Without Smart Fan, Quiet can only have been requested by scheduled night mode, - // so release it after the night window ends. - if night_enabled && night_force_quiet && device_quiet { return Some(false); } - None -} - -fn native_sleep_command( - night_enabled: bool, - night_active: bool, - use_native_sleep: bool, - sleep_supported: bool, - device_sleep: bool, -) -> Option { - if !sleep_supported { return None; } - if night_enabled && use_native_sleep && night_active { - return if device_sleep { None } else { Some(true) }; - } - // If night mode ended or native Sleep was disabled in settings, remove a previously - // active device Sleep flag instead of leaving it latched indefinitely. - if device_sleep { return Some(false); } - None -} - -fn night_limited_fan_speed(requested: u8, max_fan: u8) -> u8 { - let max_fan = max_fan.clamp(1, 5); - if requested == 0 { 1 } else { requested.min(max_fan) } -} - -pub fn night_mode_active(settings: &NightModeSettings, time: NaiveTime) -> bool { - if !settings.enabled { return false; } - let Ok(start) = NaiveTime::parse_from_str(&settings.start_time, "%H:%M") else { return false; }; - let Ok(end) = NaiveTime::parse_from_str(&settings.end_time, "%H:%M") else { return false; }; - if start == end { return true; } - if start < end { time >= start && time < end } else { time >= start || time < end } -} - -fn smart_fan_speed(mode: &str, room: f64, target: f64, outdoor: Option, demand: bool) -> u8 { - // When the thermostat is satisfied, keep airflow quiet instead of leaving the - // unit in Auto. The caller sends this together with the standby setpoint in - // the same GREE command, so e.g. 21 C reached -> 19 C + Low fan for heating. - if !demand { return 1; } - let error = (room - target).abs(); - let extreme_weather = match (mode, outdoor) { - ("heat", Some(value)) => value <= 0.0, - (_, Some(value)) => value >= 32.0, - _ => false, - }; - if error >= 2.0 || extreme_weather { 3 } else if error >= 1.0 { 2 } else { 0 } -} - -fn profile_setpoint(zone: &Zone, preset: &str, mode: &str) -> f64 { - if zone.profile_version == 0 && preset == "comfort" { return zone.setpoint; } - match (mode, preset) { - ("heat", "sleep") => zone.heat_sleep_setpoint, - ("heat", "away") => zone.heat_away_setpoint, - ("heat", _) => zone.heat_comfort_setpoint, - (_, "sleep") => zone.cool_sleep_setpoint, - (_, "away") => zone.cool_away_setpoint, - (_, _) => zone.cool_comfort_setpoint, - } -} - -fn resolve_zone_target(zone: &Zone, schedule: Option<&Schedule>, mode: &str) -> (String, f64) { - let (preset, base_target) = if let Some(manual) = zone.manual_preset.as_deref() { - if manual == "custom" { - ("custom".into(), zone.setpoint) - } else { - (manual.to_string(), profile_setpoint(zone, manual, mode)) - } - } else if let Some(item) = schedule { - if item.preset == "custom" { - ("custom".into(), item.setpoint) - } else { - (item.preset.clone(), profile_setpoint(zone, &item.preset, mode)) - } - } else { - ("comfort".into(), profile_setpoint(zone, "comfort", mode)) - }; - // Quick +/- temperature adjustments are independent from the selected preset. - // The UI can therefore stay in Auto/Sleep/Comfort while temporarily nudging the target. - (preset, zone.manual_setpoint.unwrap_or(base_target)) -} - -pub fn refresh_zone_runtime_target(zone: &mut Zone, schedules: &[Schedule], house_mode: &str) { - let configured_mode = effective_zone_mode(zone, house_mode); - zone.effective_mode = configured_mode.clone(); - let target_mode = if configured_mode == "off" { zone.mode.as_str() } else { configured_mode.as_str() }; - let schedule = active_schedule_for_zone(zone, schedules, Local::now()); - let (preset, target) = resolve_zone_target(zone, schedule, target_mode); - zone.active_preset = preset; - if !zone.device_manual_override { - zone.effective_setpoint = Some(target); - } -} - -fn effective_zone_mode(zone: &Zone, house_mode: &str) -> String { - if temporary_quick_thermostat_is_active(zone, Utc::now()) { - if let Some(mode) = zone.temporary_quick_thermostat.as_ref().and_then(|session| session.active_mode.as_deref()) { - if matches!(mode, "cool" | "heat") { return mode.to_string(); } - } - } - let configured = if zone.inherit_house_mode { house_mode } else { zone.mode.as_str() }; - if zone.local_thermostat_power == Some(true) && configured == "off" { - zone.mode.clone() - } else { - configured.to_string() - } -} - -fn active_schedule_for_zone<'a>(zone: &Zone, schedules: &'a [Schedule], now: DateTime) -> Option<&'a Schedule> { - schedules.iter() - .filter(|item| item.enabled && item.zone_id == zone.id && schedule_active(item, now)) - // Overlaps are rejected by the API, but imported/legacy data may still contain one. - // Prefer the most recently edited entry instead of depending on database/name order. - .max_by_key(|item| item.updated_at) -} - -fn minute_floor(now: DateTime) -> DateTime { - now.with_second(0).and_then(|value| value.with_nanosecond(0)).unwrap_or(now) -} - -pub fn next_schedule_boundary_utc(zone_id: &str, schedules: &[Schedule], now: DateTime) -> Option> { - let current = schedules.iter() - .filter(|item| item.enabled && item.zone_id == zone_id && schedule_active(item, now)) - .max_by_key(|item| item.updated_at) - .map(|item| item.id.as_str()); - let base = minute_floor(now); - // Eight days cover a complete weekly schedule plus the next transition. - for minute in 1..=(8 * 24 * 60) { - let candidate = base + chrono::Duration::minutes(minute); - let next = schedules.iter() - .filter(|item| item.enabled && item.zone_id == zone_id && schedule_active(item, candidate)) - .max_by_key(|item| item.updated_at) - .map(|item| item.id.as_str()); - if next != current { - return Some(candidate.with_timezone(&Utc)); - } - } - // No schedule transition exists: keep a manual override until the user clears it. - None -} - -fn schedule_active(item: &Schedule, now: DateTime) -> bool { - let Ok(start) = NaiveTime::parse_from_str(&item.start_time, "%H:%M") else { return false; }; - let Ok(end) = NaiveTime::parse_from_str(&item.end_time, "%H:%M") else { return false; }; - let time = now.time(); - let today = now.weekday().number_from_monday(); - if start == end { - // Equal times mean a 24-hour block starting on each selected weekday. - if time >= start { - item.weekdays.contains(&today) - } else { - let previous = previous_weekday(now.weekday()).number_from_monday(); - item.weekdays.contains(&previous) - } - } else if start < end { - item.weekdays.contains(&today) && time >= start && time < end - } else if time >= start { - item.weekdays.contains(&today) - } else if time < end { - let previous = previous_weekday(now.weekday()).number_from_monday(); - item.weekdays.contains(&previous) - } else { - false - } -} - -fn schedule_week_mask(item: &Schedule) -> Option> { - let start = NaiveTime::parse_from_str(&item.start_time, "%H:%M").ok()?; - let end = NaiveTime::parse_from_str(&item.end_time, "%H:%M").ok()?; - let start_minute = (start.hour() * 60 + start.minute()) as usize; - let end_minute = (end.hour() * 60 + end.minute()) as usize; - let mut mask = vec![false; 7 * 24 * 60]; - for weekday in &item.weekdays { - if !(1..=7).contains(weekday) { return None; } - let day = (*weekday as usize) - 1; - let mark = |mask: &mut [bool], day: usize, from: usize, to: usize| { - let base = (day % 7) * 24 * 60; - for minute in from..to { mask[base + minute] = true; } - }; - if start_minute == end_minute { - mark(&mut mask, day, start_minute, 24 * 60); - mark(&mut mask, day + 1, 0, end_minute); - } else if start_minute < end_minute { - mark(&mut mask, day, start_minute, end_minute); - } else { - mark(&mut mask, day, start_minute, 24 * 60); - mark(&mut mask, day + 1, 0, end_minute); - } - } - Some(mask) -} - -pub(crate) fn schedules_overlap(a: &Schedule, b: &Schedule) -> bool { - if !a.enabled || !b.enabled || a.zone_id != b.zone_id { return false; } - let (Some(left), Some(right)) = (schedule_week_mask(a), schedule_week_mask(b)) else { return false; }; - left.iter().zip(right.iter()).any(|(a, b)| *a && *b) -} - -fn previous_weekday(day: Weekday) -> Weekday { - match day { - Weekday::Mon => Weekday::Sun, Weekday::Tue => Weekday::Mon, Weekday::Wed => Weekday::Tue, - Weekday::Thu => Weekday::Wed, Weekday::Fri => Weekday::Thu, Weekday::Sat => Weekday::Fri, - Weekday::Sun => Weekday::Sat, - } -} - -pub async fn build_control_plan(state: &AppState) -> Result { - let settings = state.settings.read().await.clone(); - let schedules = state.db.list_schedules()?; - let devices = state.db.list_devices()?; - let zones = state.db.list_zones()?; - let groups = state.db.list_groups()?; - let house_preset = zones.first().and_then(|first| { - let first_preset = first.manual_preset.as_deref().unwrap_or("auto"); - zones.iter().all(|zone| zone.manual_preset.as_deref().unwrap_or("auto") == first_preset) - .then(|| first_preset.to_string()) - }); - let house_power = settings.house_power_enabled; - let now = Local::now(); - let night_active = night_mode_active(&settings.night_mode, now.time()); - let mut zones_out = Vec::new(); - let mut house_events = next_night_mode_events(&settings.night_mode, now, 2); - - for mut zone in zones { - let device = devices.iter().find(|item| item.id == zone.device_id); - let configured_effective_mode_owned = effective_zone_mode(&zone, &settings.house_mode); - let blocked_by_group = zone.local_thermostat_power != Some(true) - && groups.iter().any(|group| !group.power_enabled && group.zone_ids.iter().any(|zone_id| zone_id == &zone.id)); - refresh_control_ownership(&mut zone, settings.house_power_enabled, blocked_by_group); - let configured_effective_mode = configured_effective_mode_owned.as_str(); - let manual_device_mode = device.map(|item| if item.power { item.mode.as_str() } else { "off" }); - let effective_mode = if zone.device_manual_override { - manual_device_mode.unwrap_or(configured_effective_mode) - } else if zone.local_thermostat_power == Some(false) || blocked_by_group { - "off" - } else { - configured_effective_mode - }; - - // Keep the thermostat target readable even while the zone/group/house control is off. - // Home Assistant climate entities otherwise expose target_temperature as unknown. - let target_mode = if configured_effective_mode == "off" { zone.mode.as_str() } else { configured_effective_mode }; - let active_for_target = active_schedule_for_zone(&zone, &schedules, now); - let (resolved_preset, resolved_target) = resolve_zone_target(&zone, active_for_target, target_mode); - let active = if effective_mode == "off" { None } else { active_for_target }; - let next_events = if effective_mode == "off" { - Vec::new() - } else { - next_schedule_events(&zone, &schedules, effective_mode, now, 8) - }; - for event in next_events.iter().take(2) { - let mut event = event.clone(); - event.label = format!("{}: {}", zone.name, event.label); - house_events.push(event); - } - let effective_enabled = zone.enabled - && zone.local_thermostat_power != Some(false) - && (!blocked_by_group || zone.device_manual_override); - zones_out.push(ZoneControlPlan { - zone_id: zone.id.clone(), - zone_name: zone.name.clone(), - device_id: zone.device_id.clone(), - device_name: device.map(|item| item.name.clone()).unwrap_or_else(|| zone.device_id.clone()), - enabled: zone.enabled, - effective_enabled, - mode: effective_mode.to_string(), - configured_mode: zone.mode.clone(), - inherit_house_mode: zone.inherit_house_mode, - preset: resolved_preset, - preset_override: zone.manual_preset.clone(), - current_temperature: zone.current_temperature, - target_temperature: if zone.device_manual_override || !zone.enabled || effective_mode == "off" { - // A remote/manual takeover may leave a physical standby target persisted in the - // zone runtime snapshot. Never publish that value as the thermostat target. - Some(resolved_target) - } else { - zone.effective_setpoint.or(Some(resolved_target)) - }, - device_setpoint: device.filter(|item| item.power).map(|item| item.target_temperature), - desired_power: settings.house_power_enabled && zone.enabled && effective_mode != "off" && !zone.device_manual_override && !blocked_by_group, - desired_mode: effective_mode.to_string(), - actual_power: device.map(|item| item.power), - actual_mode: device.map(|item| if item.power { item.mode.clone() } else { "off".into() }), - actual_setpoint: device.filter(|item| item.power).map(|item| item.target_temperature), - demand: settings.house_power_enabled && zone.enabled && effective_mode != "off" && !zone.device_manual_override && zone.demand, - control_source: zone.control_temperature_source.clone(), - manual_override_until: zone.manual_override_until, - local_thermostat_power: zone.local_thermostat_power, - local_thermostat_resume_at: zone.local_thermostat_resume_at, - device_manual_override: zone.device_manual_override, - device_manual_override_until: zone.device_manual_override_until, - control_owner: zone.control_owner.clone(), - control_command_source: zone.control_source.clone(), - control_since: zone.control_since, - resume_at: zone.control_resume_at, - control_reason: zone.control_reason.clone(), - blocked_reason: if !settings.house_power_enabled { Some("global_off".into()) } else if zone.device_manual_override { Some("manual_override".into()) } else if blocked_by_group { Some("group_off".into()) } else if zone.lockout_until.map(|until| until > Utc::now()).unwrap_or(false) { Some(zone.lockout_reason.clone().unwrap_or_else(|| "lockout".into())) } else if !zone.enabled { Some("zone_disabled".into()) } else if device.map(|d| !d.online || d.communication_failures > 0).unwrap_or(true) { Some("offline".into()) } else { None }, - lockout_until: zone.lockout_until, - current_schedule_id: active.map(|item| item.id.clone()), - current_schedule_name: active.map(|item| item.name.clone()), - next_events, - }); - } - let mut rules = Vec::new(); - for item in state.db.list_automations()? { - let action_group_name = item.action_group_id.as_deref() - .and_then(|id| groups.iter().find(|group| group.id == id)) - .map(|group| group.name.clone()); - let action_name = action_group_name.clone().unwrap_or_else(|| { - devices.iter().find(|device| device.id == item.action_device_id) - .map(|device| device.name.clone()) - .unwrap_or_else(|| item.action_device_id.clone()) - }); - let trigger_name = item.trigger_device_id.as_deref().and_then(|id| devices.iter().find(|device| device.id == id)).map(|device| device.name.clone()); - let next_ready_at = item.last_fired_at.map(|last| last + chrono::Duration::seconds(item.cooldown_seconds as i64)); - if item.enabled && item.trigger_kind == "time" { - if let Some(event) = next_time_automation_event(&item, &action_name, now) { - house_events.push(event); - } - } - rules.push(AutomationPlanRule { - id: item.id, - name: item.name, - enabled: item.enabled, - trigger_kind: item.trigger_kind, - trigger_device_id: item.trigger_device_id, - trigger_device_name: trigger_name, - threshold: item.threshold, - at_time: item.at_time, - action_device_id: item.action_device_id, - action_device_name: action_name, - action_group_id: item.action_group_id, - action_group_name, - action_preset: item.action_preset, - action: item.action, - last_fired_at: item.last_fired_at, - next_ready_at, - }); - } - - house_events.sort_by_key(|event| event.at); - house_events.truncate(12); - - Ok(ControlPlan { - generated_at: Utc::now(), - house_mode: settings.house_mode, - house_preset, - house_power, - outdoor_temperature: *state.outdoor_temperature.read().await, - control_strategy: settings.control_strategy, - night_mode_active: night_active, - night_mode_start: settings.night_mode.start_time, - night_mode_end: settings.night_mode.end_time, - night_mode_max_fan_speed: settings.night_mode.max_fan_speed.clamp(1, 5), - next_events: house_events, - zones: zones_out, - rules, - }) -} - - -fn next_night_mode_events(settings: &NightModeSettings, now: DateTime, limit: usize) -> Vec { - if !settings.enabled || limit == 0 { return Vec::new(); } - let Ok(start) = NaiveTime::parse_from_str(&settings.start_time, "%H:%M") else { return Vec::new(); }; - let Ok(end) = NaiveTime::parse_from_str(&settings.end_time, "%H:%M") else { return Vec::new(); }; - let mut events = Vec::new(); - let base = minute_floor(now); - for minute in 1..=(48 * 60) { - let candidate = base + chrono::Duration::minutes(minute); - let time = candidate.time(); - let (kind, label) = if time.hour() == start.hour() && time.minute() == start.minute() { - let quiet = if settings.force_quiet { " + Quiet" } else { "" }; - ("night_mode_start", format!("Night mode -> fan max {}{}", settings.max_fan_speed.clamp(1, 5), quiet)) - } else if time.hour() == end.hour() && time.minute() == end.minute() { - ("night_mode_end", "Night mode ends".to_string()) - } else { - continue; - }; - events.push(ControlPlanEvent { - at: candidate.with_timezone(&Utc), - kind: kind.into(), - label, - preset: None, - target_temperature: None, - }); - if events.len() >= limit { break; } - } - events -} - -fn next_time_automation_event(item: &Automation, action_name: &str, now: DateTime) -> Option { - let expected = NaiveTime::parse_from_str(item.at_time.as_deref()?, "%H:%M").ok()?; - let base = minute_floor(now.clone()); - if time_automation_due(item, now) { - return Some(ControlPlanEvent { - at: base.with_timezone(&Utc), - kind: "automation".into(), - label: format!("{} -> {}", item.name, action_name), - preset: None, - target_temperature: item.action.target_temperature, - }); - } - // A local day can last 25 hours at the end of daylight saving time. - for minute in 1..=(26 * 60) { - let candidate = base + chrono::Duration::minutes(minute); - if candidate.hour() == expected.hour() && candidate.minute() == expected.minute() { - return Some(ControlPlanEvent { - at: candidate.with_timezone(&Utc), - kind: "automation".into(), - label: format!("{} -> {}", item.name, action_name), - preset: None, - target_temperature: item.action.target_temperature, - }); - } - } - None -} - -fn next_schedule_events(zone: &Zone, schedules: &[Schedule], mode: &str, now: DateTime, limit: usize) -> Vec { - if mode == "off" { return Vec::new(); } - let mut events = Vec::new(); - let mut current = active_schedule_for_zone(zone, schedules, now).map(|item| item.id.as_str()); - let base = minute_floor(now); - for minute in 1..=(8 * 24 * 60) { - let candidate = base + chrono::Duration::minutes(minute); - let next = active_schedule_for_zone(zone, schedules, candidate); - let next_id = next.map(|item| item.id.as_str()); - if next_id == current { continue; } - current = next_id; - let (preset, target, label) = if let Some(item) = next { - let target = if item.preset == "custom" { item.setpoint } else { profile_setpoint(zone, &item.preset, mode) }; - (Some(item.preset.clone()), Some(target), format!("{} -> {} {:.1} C", item.name, item.preset, target)) - } else { - let target = profile_setpoint(zone, "comfort", mode); - (Some("comfort".into()), Some(target), format!("comfort {:.1} C", target)) - }; - events.push(ControlPlanEvent { - at: candidate.with_timezone(&Utc), - kind: "schedule_transition".into(), - label, - preset, - target_temperature: target, - }); - if events.len() >= limit { break; } - } - events -} - -async fn run_automations(state: &AppState) -> Result<()> { - if !state.settings.read().await.house_power_enabled { return Ok(()); } - let devices = state.db.list_devices()?; - let zones = state.db.list_zones()?; - let groups = state.db.list_groups()?; - let mut automations = state.db.list_automations()?; - // Stable arbitration for same-cycle conflicts: the oldest configured rule wins, then ID. - // This avoids database row order deciding the physical outcome (M2). - automations.sort_by(|a, b| a.created_at.cmp(&b.created_at).then_with(|| a.id.cmp(&b.id))); - let mut claimed_devices = std::collections::HashSet::::new(); - - for mut item in automations { - if !item.enabled || !automation_ready(&item) { continue; } - let should_fire = match item.trigger_kind.as_str() { - "temperature_above" => find_temperature(&devices, item.trigger_device_id.as_deref()) - .zip(item.threshold).map(|(t, threshold)| t > threshold).unwrap_or(false), - "temperature_below" => find_temperature(&devices, item.trigger_device_id.as_deref()) - .zip(item.threshold).map(|(t, threshold)| t < threshold).unwrap_or(false), - "time" => time_automation_due(&item, Local::now()), - _ => false, - }; - if !should_fire { continue; } - if item.action_group_id.is_none() - && device_blocked_by_disabled_zone(&item.action_device_id, &zones) - && item.action.power != Some(true) - { - state.log("info", "automation.blocked_by_zone", &format!("Automation {} suppressed by disabled zone", item.name), json!({ - "automation_id": item.id, "device_id": item.action_device_id - })); - continue; - } - if item.action_group_id.is_none() && device_blocked_by_manual_override(&item.action_device_id, &zones) { - state.log("info", "automation.blocked_by_manual_override", &format!("Automation {} suppressed by manual device control", item.name), json!({ - "automation_id": item.id, "device_id": item.action_device_id - })); - continue; - } - if item.action_group_id.is_none() && device_blocked_by_local_thermostat(&item.action_device_id, &zones) { - state.log("info", "automation.blocked_by_local_thermostat", &format!("Automation {} suppressed by local thermostat ownership", item.name), json!({ - "automation_id": item.id, "device_id": item.action_device_id - })); - continue; - } - if item.action_group_id.is_none() && device_blocked_by_disabled_group(&item.action_device_id, &zones, &groups) { - // Group power-off is authoritative for normal controller-owned zones. A manual - // takeover is filtered above and therefore remains higher priority than the group. - state.log("info", "automation.blocked_by_group", &format!("Automation {} suppressed by disabled group", item.name), json!({ - "automation_id": item.id, "device_id": item.action_device_id - })); - continue; - } - - let target_devices: Vec = if let Some(group_id) = item.action_group_id.as_deref() { - groups.iter().find(|group| group.id == group_id) - .map(|group| group.zone_ids.iter() - .filter_map(|zone_id| zones.iter().find(|zone| &zone.id == zone_id).map(|zone| zone.device_id.clone())) - .collect()) - .unwrap_or_default() - } else { - vec![item.action_device_id.clone()] - }; - if target_devices.iter().any(|device_id| claimed_devices.contains(device_id)) { - state.log("warn", "automation.conflict", &format!("Automation {} skipped because an older due automation already claimed the same target", item.name), json!({ - "automation_id": item.id, - "group_id": item.action_group_id, - "device_id": item.action_device_id, - "target_devices": target_devices, - })); - continue; - } - - let result: Result = if let Some(group_id) = item.action_group_id.as_deref() { - let group_mode = item.action.mode.as_deref().map(|mode| if mode == "auto" { "house".to_string() } else { mode.to_string() }); - control_group(state, group_id, GroupControlPatch { - power: item.action.power, - mode: group_mode, - preset: item.action_preset.clone(), - }, "automation.group").await.map(|_| true) - } else { - match apply_automatic_device_action(state, &item.action_device_id, item.action.clone()).await { - Ok(Some(_)) => Ok(true), - Ok(None) => { - state.log("info", "automation.blocked_by_fresh_ownership", &format!("Automation {} was suppressed after ownership changed", item.name), json!({ - "automation_id": item.id, "device_id": item.action_device_id - })); - Ok(false) - } - Err(err) => Err(err), - } - }; - match result { - Ok(true) => { - for device_id in target_devices { claimed_devices.insert(device_id); } - item.last_fired_at = Some(Utc::now()); - item.updated_at = Utc::now(); - state.db.save_automation(&item)?; - state.log("info", "automation.fired", &format!("Automation {} fired", item.name), json!({ - "automation_id": item.id, "group_id": item.action_group_id, "device_id": item.action_device_id - })); - } - Ok(false) => { - // Ownership suppression is not an execution. Do not consume cooldown (M3), - // so a still-valid trigger may run as soon as the higher-priority owner leaves. - } - Err(err) => { - // A failed action is still an execution attempt. Apply the configured cooldown - // so an offline/disabled target cannot be hammered on every automation cycle. - item.last_fired_at = Some(Utc::now()); - item.updated_at = Utc::now(); - state.db.save_automation(&item)?; - state.log("error", "automation.error", &err.to_string(), json!({"automation_id": item.id})); - } - } - } - Ok(()) -} - -fn device_blocked_by_disabled_zone(device_id: &str, zones: &[Zone]) -> bool { - zones.iter().any(|zone| zone.device_id == device_id && !zone.enabled) -} - -fn device_blocked_by_manual_override(device_id: &str, zones: &[Zone]) -> bool { - zones.iter().any(|zone| zone.device_id == device_id && zone.device_manual_override) -} - -fn device_blocked_by_local_thermostat(device_id: &str, zones: &[Zone]) -> bool { - zones.iter().any(|zone| zone.device_id == device_id && zone.local_thermostat_power.is_some()) -} - -fn device_blocked_by_disabled_group(device_id: &str, zones: &[Zone], groups: &[crate::models::ClimateGroup]) -> bool { - let zone_ids: std::collections::HashSet<&str> = zones.iter() - .filter(|zone| zone.device_id == device_id) - .map(|zone| zone.id.as_str()) - .collect(); - if zone_ids.is_empty() { return false; } - groups.iter().any(|group| !group.power_enabled && group.zone_ids.iter().any(|zone_id| zone_ids.contains(zone_id.as_str()))) -} - -fn find_temperature(devices: &[Device], device_id: Option<&str>) -> Option { - let id = device_id?; - // Never fire a temperature automation from stale cached data of an offline/disabled unit. - devices.iter().find(|d| d.id == id && d.enabled && d.online && d.communication_failures == 0)?.current_temperature -} - -fn automation_ready(item: &Automation) -> bool { - item.last_fired_at.map(|last| (Utc::now() - last).num_seconds().max(0) as u64 >= item.cooldown_seconds).unwrap_or(true) -} - -fn time_automation_due(item: &Automation, now: DateTime) -> bool { - let Some(expected) = item.at_time.as_deref() else { return false; }; - let Ok(value) = NaiveTime::parse_from_str(expected, "%H:%M") else { return false; }; - if now.hour() != value.hour() || now.minute() != value.minute() { return false; } - if let Some(last) = item.last_fired_at { - let local_last = last.with_timezone(&Local); - if local_last.date_naive() == now.date_naive() - && local_last.hour() == now.hour() - && local_last.minute() == now.minute() - { - return false; - } - } - true -} - -#[cfg(test)] -mod tests { - use super::*; - use chrono::TimeZone; - - #[test] - fn global_ha_sensor_stale_timeout_is_used_for_default_zone_value() { - assert_eq!(effective_sensor_stale_after_seconds(300, 600), 600); - assert_eq!(effective_sensor_stale_after_seconds(0, 900), 900); - assert_eq!(effective_sensor_stale_after_seconds(120, 600), 120); - assert_eq!(effective_sensor_stale_after_seconds(120_000, 600), 86_400); - } - - #[test] - fn overnight_schedule_works() { - 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(), - }; - 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(), - } - } - - #[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(); - assert!(schedule_active(&item, monday_noon)); - assert!(schedule_active(&item, tuesday_early)); - assert!(!schedule_active(&item, tuesday_after)); - } - - #[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 conflict = test_schedule("conflict", vec![1], "22:00", "23:00"); - assert!(!schedules_overlap(&daytime, &night)); - assert!(schedules_overlap(&night, &conflict)); - } - - #[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); - assert_eq!(boundary.weekday(), Weekday::Fri); - assert_eq!(boundary.hour(), 12); - assert_eq!(boundary.minute(), 0); - assert_eq!(boundary.second(), 0); - } - - #[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(); - 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("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); - } - } - } - - #[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 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, 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)); - 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, 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()), - 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, - 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(), - } - } - - #[test] - fn external_device_change_detects_manual_climate_controls() { - let zone = test_zone("device"); - let before = Device::simulated_default(); - let mut after = before.clone(); - after.power = !before.power; - after.target_temperature = before.target_temperature + 1.0; - after.fan_speed = 3; - let fields = externally_changed_control_fields(&before, &after, &zone); - assert!(fields.iter().any(|field| field == "power")); - assert!(fields.iter().any(|field| field == "target_temperature")); - assert!(fields.iter().any(|field| field == "fan_speed")); - } - - #[test] - fn controller_expected_climate_change_is_recognized() { - let mut device = Device::simulated_default(); - device.power = true; - device.mode = "cool".into(); - device.target_temperature = 22.0; - let command = DeviceCommand { - power: Some(true), - mode: Some("cool".into()), - target_temperature: Some(22.0), - ..Default::default() - }; - 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)); - device.target_temperature = 25.0; - assert!(!command_field_matches_device(&command, "target_temperature", &device)); - } - - #[test] - fn local_thermostat_ownership_blocks_direct_automation() { - let mut zone = test_zone("device"); - assert!(!device_blocked_by_local_thermostat( - &zone.device_id, - std::slice::from_ref(&zone), - )); - zone.local_thermostat_power = Some(true); - assert!(device_blocked_by_local_thermostat( - &zone.device_id, - std::slice::from_ref(&zone), - )); - zone.local_thermostat_power = Some(false); - assert!(device_blocked_by_local_thermostat( - &zone.device_id, - std::slice::from_ref(&zone), - )); - } - - #[test] - fn local_thermostat_resume_clears_only_local_quick_control_state() { - let mut zone = test_zone("device"); - zone.local_thermostat_power = Some(false); - zone.local_thermostat_resume_at = Some(Utc::now() + chrono::Duration::minutes(15)); - zone.manual_preset = Some("comfort".into()); - zone.manual_setpoint = Some(23.0); - zone.manual_override_until = Some(Utc::now() + chrono::Duration::hours(1)); - zone.device_manual_override = true; - - assert!(reset_local_thermostat_override(&mut zone)); - assert!(zone.local_thermostat_power.is_none()); - assert!(zone.local_thermostat_resume_at.is_none()); - assert!(zone.manual_preset.is_none()); - assert!(zone.manual_setpoint.is_none()); - assert!(zone.manual_override_until.is_none()); - assert!(zone.device_manual_override); - assert_eq!(LOCAL_THERMOSTAT_RESUME_DELAY_MINUTES, 15); - } - - fn temporary_session(now: DateTime) -> TemporaryQuickThermostat { - TemporaryQuickThermostat { - start_kind: "now".into(), - finish_kind: "temperature_stable".into(), - started_at: now.clone(), - activated_at: Some(now), - state: "active".into(), - active_mode: Some("heat".into()), - restore_zone_enabled: Some(true), - restore_local_thermostat_power: None, - restore_local_thermostat_resume_at: None, - restore_local_thermostat_zone_enabled: None, - restore_manual_preset: None, - restore_manual_setpoint: None, - restore_manual_override_until: None, - expires_at: None, - duration_seconds: None, - safety_duration_seconds: None, - temperature_target: Some(23.0), - temperature_operator: Some("within".into()), - tolerance_c: 0.3, - hold_seconds: 3600, - condition_started_at: None, - condition_last_observed_at: None, - paused_at: None, - deferred_mode: None, - deferred_preset: None, - safety_expires_at: None, - } - } - - #[test] - fn scheduled_temporary_session_is_not_activated_by_unrelated_local_quick_on() { - let now = Utc::now(); - let mut zone = test_zone("device"); - zone.local_thermostat_power = Some(true); - let mut session = temporary_session(now + chrono::Duration::hours(1)); - session.start_kind = "delay".into(); - session.activated_at = None; - session.state = "scheduled".into(); - 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))); - } - - #[test] - fn local_handback_cleanup_does_not_remove_pending_temporary_session() { - let now = Utc::now(); - let mut zone = test_zone("device"); - set_local_thermostat_power(&mut zone, false, now); - let mut session = temporary_session(now + chrono::Duration::minutes(30)); - session.start_kind = "delay".into(); - session.activated_at = None; - session.state = "scheduled".into(); - zone.temporary_quick_thermostat = Some(session); - - reset_local_thermostat_override(&mut zone); - assert!(zone.temporary_quick_thermostat.is_some()); - assert!(zone.local_thermostat_power.is_none()); - } - - #[test] - fn temporary_quick_thermostat_restores_previous_zone_enabled_state() { - let now = Utc::now(); - let mut zone = test_zone("device"); - zone.enabled = true; - zone.local_thermostat_power = Some(true); - let mut session = temporary_session(now); - session.restore_zone_enabled = Some(false); - zone.temporary_quick_thermostat = Some(session); - zone.manual_setpoint = Some(23.0); - - assert!(finish_temporary_quick_thermostat(&mut zone, &[], "heat")); - assert!(!zone.enabled); - assert!(zone.local_thermostat_power.is_none()); - assert!(zone.temporary_quick_thermostat.is_none()); - assert!(zone.manual_setpoint.is_none()); - } - - #[test] - fn temporary_quick_thermostat_returns_to_underlying_local_quick_state() { - let now = Utc::now(); - let mut zone = test_zone("device"); - zone.enabled = true; - zone.local_thermostat_power = Some(true); - zone.local_thermostat_restore_zone_enabled = None; - zone.manual_setpoint = Some(24.0); - let mut session = temporary_session(now); - session.restore_zone_enabled = Some(true); - session.restore_local_thermostat_power = Some(true); - session.restore_local_thermostat_zone_enabled = Some(false); - session.restore_manual_setpoint = Some(22.0); - zone.temporary_quick_thermostat = Some(session); - zone.manual_setpoint = Some(23.0); - - assert!(finish_temporary_quick_thermostat(&mut zone, &[], "heat")); - assert!(zone.enabled); - assert_eq!(zone.local_thermostat_power, Some(true)); - assert_eq!(zone.local_thermostat_restore_zone_enabled, Some(false)); - assert_eq!(zone.manual_setpoint, Some(22.0)); - } - - #[test] - fn temporary_session_without_activation_marker_is_never_active() { - let now = Utc::now(); - let mut zone = test_zone("device"); - zone.local_thermostat_power = Some(true); - let mut session = temporary_session(now); - session.activated_at = None; - session.state = "scheduled".into(); - zone.temporary_quick_thermostat = Some(session); - - assert!(!temporary_quick_thermostat_is_active(&zone, now)); - } - - #[test] - fn temporary_stable_condition_requires_continuous_hold_time() { - let now = Utc::now(); - let mut zone = test_zone("device"); - zone.current_temperature = Some(23.2); - let mut session = temporary_session(now.clone()); - 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())); - } - - #[test] - fn temporary_stable_condition_resets_when_temperature_leaves_range() { - let now = Utc::now(); - let mut zone = test_zone("device"); - zone.current_temperature = Some(24.0); - let mut session = temporary_session(now.clone()); - 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()); - } - - #[test] - fn temporary_stable_condition_resets_after_observation_gap() { - let now = Utc::now(); - let mut zone = test_zone("device"); - zone.current_temperature = Some(23.0); - let mut session = temporary_session(now); - session.condition_started_at = Some(now - chrono::Duration::hours(1)); - 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)); - } - - #[test] - fn delayed_temporary_session_does_not_block_automation_before_start() { - let now = Utc::now(); - let mut zone = test_zone("device"); - let mut session = temporary_session(now.clone() + chrono::Duration::hours(1)); - session.start_kind = "delay".into(); - session.activated_at = None; - session.state = "scheduled".into(); - 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))); - } - - #[test] - fn delayed_temperature_condition_cannot_finish_before_activation() { - let now = Utc::now(); - let mut zone = test_zone("device"); - zone.current_temperature = Some(23.0); - let mut session = temporary_session(now.clone() + chrono::Duration::hours(1)); - session.start_kind = "at".into(); - 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()); - } - - #[test] - fn temporary_setpoint_keeps_priority_over_active_schedule() { - let mut zone = test_zone("device"); - 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"); - schedule.preset = "custom".into(); - schedule.setpoint = 19.0; - - let (_preset, target) = resolve_zone_target(&zone, Some(&schedule), "cool"); - assert_eq!(target, 23.0); - } - - #[test] - fn local_quick_thermostat_can_run_when_inherited_house_mode_is_off() { - let mut zone = test_zone("device"); - zone.inherit_house_mode = true; - zone.mode = "cool".into(); - assert_eq!(effective_zone_mode(&zone, "off"), "off"); - zone.local_thermostat_power = Some(true); - assert_eq!(effective_zone_mode(&zone, "off"), "cool"); - } - - #[test] - fn local_thermostat_off_restarts_backend_handback_deadline() { - let mut zone = test_zone("device"); - 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)); - - 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!(zone.local_thermostat_resume_at.clone().unwrap() > first_deadline); - } - - #[test] - fn manual_device_takeover_suspends_local_handback_until_control_returns() { - let mut zone = test_zone("device"); - let now = Utc::now(); - set_local_thermostat_power(&mut zone, false, now.clone()); - assert!(local_thermostat_handback_is_active(&zone)); - - zone.device_manual_override = true; - assert!(!local_thermostat_handback_is_active(&zone)); - - 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!(local_thermostat_handback_is_active(&zone)); - } - - #[test] - fn rounded_gree_setpoint_does_not_create_manual_override() { - let zone = test_zone("device"); - let mut before = Device::simulated_default(); - before.target_temperature = 23.5; - let mut after = before.clone(); - after.target_temperature = 24.0; - assert!(externally_changed_control_fields(&before, &after, &zone).is_empty()); - } - - #[test] - fn standby_low_to_auto_fan_drift_is_not_manual_override() { - let mut zone = test_zone("device"); - zone.smart_fan = true; - zone.demand = false; - let mut before = Device::simulated_default(); - before.fan_speed = 1; - let mut after = before.clone(); - after.fan_speed = 0; - assert!(externally_changed_control_fields(&before, &after, &zone).is_empty()); - } - - #[test] - fn reset_device_manual_override_clears_takeover_state() { - let mut zone = test_zone("device"); - let device = Device::simulated_default(); - zone.device_manual_override = true; - zone.device_manual_override_since = Some(Utc::now()); - zone.device_manual_override_until = Some(Utc::now()); - zone.device_manual_override_fields = vec!["target_temperature".into()]; - zone.device_manual_override_baseline = Some((&device).into()); - assert!(reset_device_manual_override(&mut zone)); - assert!(!zone.device_manual_override); - assert!(zone.device_manual_override_since.is_none()); - assert!(zone.device_manual_override_until.is_none()); - assert!(zone.device_manual_override_fields.is_empty()); - assert!(zone.device_manual_override_baseline.is_none()); - } - - #[test] - fn restored_manual_device_state_matches_original_takeover_baseline() { - let mut zone = test_zone("device"); - let baseline = Device::simulated_default(); - zone.device_manual_override = true; - zone.device_manual_override_fields = vec!["power".into(), "target_temperature".into()]; - zone.device_manual_override_baseline = Some((&baseline).into()); - - let mut changed = baseline.clone(); - changed.power = !baseline.power; - changed.target_temperature = baseline.target_temperature + 2.0; - assert!(!manual_override_matches_baseline(&zone, &changed)); - - let mut returned_off = baseline.clone(); - returned_off.target_temperature = baseline.target_temperature + 3.0; - assert!(manual_override_matches_baseline(&zone, &returned_off)); - assert!(manual_override_matches_baseline(&zone, &baseline)); - } - - #[test] - fn device_command_drops_unchanged_fields() { - let device = Device::simulated_default(); - let command = DeviceCommand { - power: Some(false), - mode: Some("cool".into()), - target_temperature: Some(23.4), - fan_speed: Some(3), - light: Some(false), - ..DeviceCommand::default() - }; - let changed = command.changed_from(&device); - assert_eq!(changed.power, None); - assert_eq!(changed.mode, None); - assert_eq!(changed.target_temperature, None); - assert_eq!(changed.fan_speed, Some(3)); - assert_eq!(changed.light, Some(false)); - } - - #[test] - fn combined_temperature_prefers_room_sensor_weight() { - let zone = test_zone("combined"); - let (value, source, discrepancy) = select_zone_temperature(&zone, Some(22.0), Some(20.0)); - assert_eq!(value, Some(21.2)); - assert_eq!(source, "combined"); - assert!(!discrepancy); - } - - #[test] - fn combined_temperature_falls_back_on_large_discrepancy() { - let zone = test_zone("combined"); - let (value, source, discrepancy) = select_zone_temperature(&zone, Some(21.0), Some(27.0)); - assert_eq!(value, Some(21.0)); - assert_eq!(source, "device_discrepancy_fallback"); - assert!(discrepancy); - } - - #[test] - fn combined_temperature_falls_back_when_external_is_missing() { - let zone = test_zone("combined"); - let (value, source, discrepancy) = select_zone_temperature(&zone, Some(21.5), None); - assert_eq!(value, Some(21.5)); - assert_eq!(source, "device_fallback"); - assert!(!discrepancy); - } - - #[test] - fn seasonal_profiles_resolve_independently() { - let zone = test_zone("device"); - assert_eq!(profile_setpoint(&zone, "comfort", "cool"), 23.0); - assert_eq!(profile_setpoint(&zone, "sleep", "cool"), 24.5); - assert_eq!(profile_setpoint(&zone, "comfort", "heat"), 21.0); - assert_eq!(profile_setpoint(&zone, "sleep", "heat"), 19.0); - } - - #[test] - fn quick_setpoint_keeps_active_preset() { - let mut zone = test_zone("device"); - zone.manual_setpoint = Some(22.5); - let (preset, target) = resolve_zone_target(&zone, None, "cool"); - assert_eq!(preset, "comfort"); - assert_eq!(target, 22.5); - } - - #[test] - fn runtime_target_refresh_applies_manual_profile_immediately() { - let mut zone = test_zone("device"); - zone.inherit_house_mode = false; - zone.mode = "cool".into(); - zone.manual_preset = Some("sleep".into()); - zone.active_preset = "comfort".into(); - zone.effective_setpoint = Some(25.0); - refresh_zone_runtime_target(&mut zone, &[], "cool"); - assert_eq!(zone.active_preset, "sleep"); - assert_eq!(zone.effective_setpoint, Some(24.5)); - assert_eq!(zone.effective_mode, "cool"); - } - - #[test] - fn legacy_zone_keeps_old_comfort_setpoint() { - let mut zone = test_zone("device"); - zone.profile_version = 0; - zone.setpoint = 22.5; - assert_eq!(profile_setpoint(&zone, "comfort", "cool"), 22.5); - assert_eq!(profile_setpoint(&zone, "comfort", "heat"), 22.5); - } - - #[test] - fn device_setpoint_rounding_preserves_control_direction() { - assert_eq!(round_device_setpoint("cool", true, 23.5), 23.0); - assert_eq!(round_device_setpoint("cool", false, 25.5), 26.0); - assert_eq!(round_device_setpoint("heat", true, 21.5), 22.0); - assert_eq!(round_device_setpoint("heat", false, 19.5), 19.0); - } - - #[test] - fn external_room_sensor_selects_lower_cooling_setpoint_only_when_used() { - assert_eq!(external_room_sensor_cooling_assist("cool", "external"), 0.5); - assert_eq!(external_room_sensor_cooling_assist("cool", "combined"), 0.5); - 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); - } - - #[test] - fn smart_fan_uses_low_speed_when_zone_is_satisfied() { - assert_eq!(smart_fan_speed("heat", 21.0, 21.0, None, false), 1); - assert_eq!(smart_fan_speed("cool", 23.0, 23.0, None, false), 1); - } - - #[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); - } - - #[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())); - 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!(native_sleep_command(true, true, true, false, false), None); - } - - #[test] - fn gree_outdoor_fallback_uses_median_of_online_units() { - let mut a = Device::simulated_default(); - a.outdoor_temperature = Some(10.0); - let mut b = Device::simulated_default(); - b.id = "sim-b".into(); - b.outdoor_temperature = Some(12.0); - let mut c = Device::simulated_default(); - c.id = "sim-c".into(); - c.outdoor_temperature = Some(40.0); - assert_eq!(gree_outdoor_temperature(&[a, b, c]), Some(12.0)); - } - - #[test] - fn outdoor_assist_is_bounded_and_direction_neutral() { - let cool = outdoor_assist_offset("cool", Some(36.0), 27.0, 23.0); - let heat = outdoor_assist_offset("heat", Some(-5.0), 17.0, 21.0); - assert!(cool > 0.0 && cool <= 1.0); - assert!(heat > 0.0 && heat <= 1.0); - assert_eq!(outdoor_assist_offset("cool", None, 27.0, 23.0), 0.0); - } - -} +// Functional source split intentionally keeps items in the existing module namespace. +include!("engine/runtime.rs"); +include!("engine/commands.rs"); +include!("engine/polling.rs"); +include!("engine/local_thermostat.rs"); +include!("engine/temporary_thermostat.rs"); +include!("engine/deadlines.rs"); +include!("engine/ownership.rs"); +include!("engine/groups.rs"); +include!("engine/zone_actions.rs"); +include!("engine/zone_control.rs"); +include!("engine/history.rs"); +include!("engine/temperature.rs"); +include!("engine/targets.rs"); +include!("engine/schedules.rs"); +include!("engine/control_plan.rs"); +include!("engine/automations.rs"); +include!("engine/tests.rs"); diff --git a/src/engine/automations.rs b/src/engine/automations.rs new file mode 100644 index 0000000..2f9c0fa --- /dev/null +++ b/src/engine/automations.rs @@ -0,0 +1,164 @@ +async fn run_automations(state: &AppState) -> Result<()> { + if !state.settings.read().await.house_power_enabled { return Ok(()); } + let devices = state.db.list_devices()?; + let zones = state.db.list_zones()?; + let groups = state.db.list_groups()?; + let mut automations = state.db.list_automations()?; + // Stable arbitration for same-cycle conflicts: the oldest configured rule wins, then ID. + // This avoids database row order deciding the physical outcome (M2). + automations.sort_by(|a, b| a.created_at.cmp(&b.created_at).then_with(|| a.id.cmp(&b.id))); + let mut claimed_devices = std::collections::HashSet::::new(); + + for mut item in automations { + if !item.enabled || !automation_ready(&item) { continue; } + let should_fire = match item.trigger_kind.as_str() { + "temperature_above" => find_temperature(&devices, item.trigger_device_id.as_deref()) + .zip(item.threshold).map(|(t, threshold)| t > threshold).unwrap_or(false), + "temperature_below" => find_temperature(&devices, item.trigger_device_id.as_deref()) + .zip(item.threshold).map(|(t, threshold)| t < threshold).unwrap_or(false), + "time" => time_automation_due(&item, Local::now()), + _ => false, + }; + if !should_fire { continue; } + if item.action_group_id.is_none() + && device_blocked_by_disabled_zone(&item.action_device_id, &zones) + && item.action.power != Some(true) + { + state.log("info", "automation.blocked_by_zone", &format!("Automation {} suppressed by disabled zone", item.name), json!({ + "automation_id": item.id, "device_id": item.action_device_id + })); + continue; + } + if item.action_group_id.is_none() && device_blocked_by_manual_override(&item.action_device_id, &zones) { + state.log("info", "automation.blocked_by_manual_override", &format!("Automation {} suppressed by manual device control", item.name), json!({ + "automation_id": item.id, "device_id": item.action_device_id + })); + continue; + } + if item.action_group_id.is_none() && device_blocked_by_local_thermostat(&item.action_device_id, &zones) { + state.log("info", "automation.blocked_by_local_thermostat", &format!("Automation {} suppressed by local thermostat ownership", item.name), json!({ + "automation_id": item.id, "device_id": item.action_device_id + })); + continue; + } + if item.action_group_id.is_none() && device_blocked_by_disabled_group(&item.action_device_id, &zones, &groups) { + // Group power-off is authoritative for normal controller-owned zones. A manual + // takeover is filtered above and therefore remains higher priority than the group. + state.log("info", "automation.blocked_by_group", &format!("Automation {} suppressed by disabled group", item.name), json!({ + "automation_id": item.id, "device_id": item.action_device_id + })); + continue; + } + + let target_devices: Vec = if let Some(group_id) = item.action_group_id.as_deref() { + groups.iter().find(|group| group.id == group_id) + .map(|group| group.zone_ids.iter() + .filter_map(|zone_id| zones.iter().find(|zone| &zone.id == zone_id).map(|zone| zone.device_id.clone())) + .collect()) + .unwrap_or_default() + } else { + vec![item.action_device_id.clone()] + }; + if target_devices.iter().any(|device_id| claimed_devices.contains(device_id)) { + state.log("warn", "automation.conflict", &format!("Automation {} skipped because an older due automation already claimed the same target", item.name), json!({ + "automation_id": item.id, + "group_id": item.action_group_id, + "device_id": item.action_device_id, + "target_devices": target_devices, + })); + continue; + } + + let result: Result = if let Some(group_id) = item.action_group_id.as_deref() { + let group_mode = item.action.mode.as_deref().map(|mode| if mode == "auto" { "house".to_string() } else { mode.to_string() }); + control_group(state, group_id, GroupControlPatch { + power: item.action.power, + mode: group_mode, + preset: item.action_preset.clone(), + }, "automation.group").await.map(|_| true) + } else { + match apply_automatic_device_action(state, &item.action_device_id, item.action.clone()).await { + Ok(Some(_)) => Ok(true), + Ok(None) => { + state.log("info", "automation.blocked_by_fresh_ownership", &format!("Automation {} was suppressed after ownership changed", item.name), json!({ + "automation_id": item.id, "device_id": item.action_device_id + })); + Ok(false) + } + Err(err) => Err(err), + } + }; + match result { + Ok(true) => { + for device_id in target_devices { claimed_devices.insert(device_id); } + item.last_fired_at = Some(Utc::now()); + item.updated_at = Utc::now(); + state.db.save_automation(&item)?; + state.log("info", "automation.fired", &format!("Automation {} fired", item.name), json!({ + "automation_id": item.id, "group_id": item.action_group_id, "device_id": item.action_device_id + })); + } + Ok(false) => { + // Ownership suppression is not an execution. Do not consume cooldown (M3), + // so a still-valid trigger may run as soon as the higher-priority owner leaves. + } + Err(err) => { + // A failed action is still an execution attempt. Apply the configured cooldown + // so an offline/disabled target cannot be hammered on every automation cycle. + item.last_fired_at = Some(Utc::now()); + item.updated_at = Utc::now(); + state.db.save_automation(&item)?; + state.log("error", "automation.error", &err.to_string(), json!({"automation_id": item.id})); + } + } + } + Ok(()) +} + +fn device_blocked_by_disabled_zone(device_id: &str, zones: &[Zone]) -> bool { + zones.iter().any(|zone| zone.device_id == device_id && !zone.enabled) +} + +fn device_blocked_by_manual_override(device_id: &str, zones: &[Zone]) -> bool { + zones.iter().any(|zone| zone.device_id == device_id && zone.device_manual_override) +} + +fn device_blocked_by_local_thermostat(device_id: &str, zones: &[Zone]) -> bool { + zones.iter().any(|zone| zone.device_id == device_id && zone.local_thermostat_power.is_some()) +} + +fn device_blocked_by_disabled_group(device_id: &str, zones: &[Zone], groups: &[crate::models::ClimateGroup]) -> bool { + let zone_ids: std::collections::HashSet<&str> = zones.iter() + .filter(|zone| zone.device_id == device_id) + .map(|zone| zone.id.as_str()) + .collect(); + if zone_ids.is_empty() { return false; } + groups.iter().any(|group| !group.power_enabled && group.zone_ids.iter().any(|zone_id| zone_ids.contains(zone_id.as_str()))) +} + +fn find_temperature(devices: &[Device], device_id: Option<&str>) -> Option { + let id = device_id?; + // Never fire a temperature automation from stale cached data of an offline/disabled unit. + devices.iter().find(|d| d.id == id && d.enabled && d.online && d.communication_failures == 0)?.current_temperature +} + +fn automation_ready(item: &Automation) -> bool { + item.last_fired_at.map(|last| (Utc::now() - last).num_seconds().max(0) as u64 >= item.cooldown_seconds).unwrap_or(true) +} + +fn time_automation_due(item: &Automation, now: DateTime) -> bool { + let Some(expected) = item.at_time.as_deref() else { return false; }; + let Ok(value) = NaiveTime::parse_from_str(expected, "%H:%M") else { return false; }; + if now.hour() != value.hour() || now.minute() != value.minute() { return false; } + if let Some(last) = item.last_fired_at { + let local_last = last.with_timezone(&Local); + if local_last.date_naive() == now.date_naive() + && local_last.hour() == now.hour() + && local_last.minute() == now.minute() + { + return false; + } + } + true +} + diff --git a/src/engine/commands.rs b/src/engine/commands.rs new file mode 100644 index 0000000..6b39870 --- /dev/null +++ b/src/engine/commands.rs @@ -0,0 +1,186 @@ +pub async fn send_command(state: &AppState, device_id: &str, command: DeviceCommand) -> Result { + let _device_guard = state.lock_device_operation(device_id).await; + send_command_locked(state, device_id, command).await +} + +async fn send_command_locked(state: &AppState, device_id: &str, command: DeviceCommand) -> Result { + send_command_locked_inner(state, device_id, command, true, true).await +} + +async fn send_command_locked_forced(state: &AppState, device_id: &str, command: DeviceCommand) -> Result { + send_command_locked_inner(state, device_id, command, false, true).await +} + +async fn send_command_locked_inner( + state: &AppState, + device_id: &str, + command: DeviceCommand, + dedupe_against_cache: bool, + track_controller_command: bool, +) -> Result { + validate_command(&command)?; + let mut device = state.db.get_device(device_id)? + .ok_or_else(|| AppError::NotFound(format!("device {device_id}")))?; + if !device.enabled { return Err(AppError::BadRequest("device is disabled".into())); } + + // Routine control avoids redundant frames. Explicit safety transitions (global/group OFF, + // detach) may bypass cache de-duplication so stale state cannot leave a unit powered. + let command = if dedupe_against_cache && device.online && device.communication_failures == 0 { command.changed_from(&device) } else { command }; + if command.is_empty() { return Ok(device); } + let controller_command_baseline = device.clone(); + let suppress_beep = state.settings.read().await.suppress_device_beep; + let response_started = Instant::now(); + + let mut applied_command = command.clone(); + let mut confirmed_state = false; + let mut confirmed_requested_state = true; + if device.simulated { + applied_command.apply(&mut device); + confirmed_state = true; + device.online = true; + device.response_time_ms = Some(0); + device.last_seen = Some(Utc::now()); + device.last_error = None; + state.db.save_device(&device)?; + } else { + if device.key.as_deref().unwrap_or_default().is_empty() { + match state.gree.bind(&device).await { + Ok(bound) => { + device.key = Some(bound.key); + device.protocol_version = bound.protocol_version; + device.communication_failures = 0; + state.db.save_device(&device)?; + state.log("info", "device.bound", &format!("Bound {} using protocol V{}", device.name, device.protocol_version), json!({"device_id": device.id, "protocol_version": device.protocol_version})); + } + Err(err) => { + register_device_failure(state, &mut device, &err.to_string())?; + return Err(AppError::Device(err.to_string())); + } + } + } + match state.gree.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 + // first and avoid sending the same frame (and another beep) when the requested + // state is already present. Only rebind when the verification read also fails. + let mut observed = device.clone(); + let retry_result = match state.gree.poll(&mut observed).await { + Ok(()) if command.changed_from(&observed).is_empty() => { + device = observed; + confirmed_state = true; + tracing::debug!(device=%device.id, "GREE command ACK was uncertain, but status confirms the requested state"); + Ok(command.clone()) + } + Ok(()) => { + device = observed; + let remaining = command.changed_from(&device); + if remaining.is_empty() { Ok(command.clone()) } + else { state.gree.command(&device, &remaining, suppress_beep).await } + } + Err(_) => { + match state.gree.bind(&device).await { + Ok(bound) => { + device.key = Some(bound.key); + device.protocol_version = bound.protocol_version; + state.db.save_device(&device)?; + state.gree.command(&device, &command, suppress_beep).await + } + Err(_) => Err(first_err), + } + } + }; + match retry_result { + Ok(result) => applied_command = result, + Err(err) => { + register_device_failure(state, &mut device, &err.to_string())?; + return Err(AppError::Device(err.to_string())); + } + } + } + } + if command.quiet.is_some() && applied_command.quiet.is_none() { device.supports_quiet = Some(false); } + if command.sleep.is_some() && applied_command.sleep.is_none() { device.supports_sleep = Some(false); } + + // A command ACK confirms transport/acceptance, but several GREE firmwares keep + // returning the pre-command status for a short settling window. Publishing that first + // stale read makes Home Assistant visibly bounce ON -> OFF -> ON. Verify a few times + // with bounded backoff and only publish a differing state after the settling window. + if !confirmed_state { + let verification_delays_ms = [0_u64, 150, 350, 650]; + let mut last_verification_error: Option = None; + for delay_ms in verification_delays_ms { + if delay_ms > 0 { sleep(Duration::from_millis(delay_ms)).await; } + let mut observed = device.clone(); + match state.gree.poll(&mut observed).await { + Ok(()) => { + let requested_matches = applied_command.changed_from(&observed).is_empty(); + device = observed; + confirmed_state = true; + confirmed_requested_state = requested_matches; + last_verification_error = None; + if requested_matches { break; } + } + Err(err) => { + last_verification_error = Some(err.to_string()); + } + } + } + + if confirmed_state && !confirmed_requested_state { + tracing::debug!(device=%device.id, command=?applied_command, "GREE command acknowledged but status still differs after settling window"); + } else if !confirmed_state { + let error = last_verification_error.unwrap_or_else(|| "status verification failed".into()); + record_poll_failure(&mut device, &format!("command accepted but status verification failed: {error}")); + state.log("warn", "device.command_unconfirmed", &format!("Command accepted by {}, but resulting state could not be verified", device.name), json!({ + "device_id": device.id, "error": error + })); + } + } + if confirmed_state { + device.response_time_ms = Some(response_started.elapsed().as_millis().min(u64::MAX as u128) as u64); + } + state.db.save_device(&device)?; + if !dedupe_against_cache && confirmed_state && !confirmed_requested_state { + if track_controller_command && !command_manual_control_fields(&applied_command).is_empty() { + remember_controller_command(state, device_id, &applied_command, &controller_command_baseline).await; + } + state.broadcast("device.updated", serde_json::to_value(&device).unwrap_or_default()); + return Err(AppError::Device("device did not confirm the requested forced state change".into())); + } + } + + if track_controller_command && !command_manual_control_fields(&applied_command).is_empty() { + // Keep a bounded settling history even after the requested state has already been + // observed once. Several GREE modules can briefly publish an older snapshot again + // and then return to the controller-requested state. Without this guard that normal + // firmware bounce can be misclassified as a physical/pilot takeover. + remember_controller_command(state, device_id, &applied_command, &controller_command_baseline).await; + } + + record_device_transition_timestamps(state, &controller_command_baseline, &device)?; + + state.log("info", "device.command", &format!("Updated {}", device.name), json!({ + "device_id": device.id, + "command": applied_command, + "confirmed": confirmed_state, + })); + state.broadcast("device.updated", serde_json::to_value(&device).unwrap_or_default()); + Ok(device) +} + +fn record_device_transition_timestamps(state: &AppState, before: &Device, after: &Device) -> Result<(), AppError> { + if before.power == after.power && before.mode == after.mode { return Ok(()); } + let now = Utc::now(); + for mut zone in state.db.list_zones()?.into_iter().filter(|zone| zone.device_id == after.id) { + if before.power != after.power { zone.last_power_change_at = Some(now); } + if before.mode != after.mode { zone.last_mode_change_at = Some(now); } + // Do not bump zone.updated_at here: an in-flight thermostat cycle uses that field + // as its optimistic snapshot guard. The cycle mirrors these timestamps into its own + // computed Zone after a successful automatic command. + state.db.save_zone(&zone)?; + state.broadcast("zone.updated", serde_json::to_value(&zone)?); + } + Ok(()) +} + diff --git a/src/engine/control_plan.rs b/src/engine/control_plan.rs new file mode 100644 index 0000000..1e3f746 --- /dev/null +++ b/src/engine/control_plan.rs @@ -0,0 +1,242 @@ +pub async fn build_control_plan(state: &AppState) -> Result { + let settings = state.settings.read().await.clone(); + let schedules = state.db.list_schedules()?; + let devices = state.db.list_devices()?; + let zones = state.db.list_zones()?; + let groups = state.db.list_groups()?; + let house_preset = zones.first().and_then(|first| { + let first_preset = first.manual_preset.as_deref().unwrap_or("auto"); + zones.iter().all(|zone| zone.manual_preset.as_deref().unwrap_or("auto") == first_preset) + .then(|| first_preset.to_string()) + }); + let house_power = settings.house_power_enabled; + let now = Local::now(); + let night_active = night_mode_active(&settings.night_mode, now.time()); + let mut zones_out = Vec::new(); + let mut house_events = next_night_mode_events(&settings.night_mode, now, 2); + + for mut zone in zones { + let device = devices.iter().find(|item| item.id == zone.device_id); + let configured_effective_mode_owned = effective_zone_mode(&zone, &settings.house_mode); + let blocked_by_group = zone.local_thermostat_power != Some(true) + && groups.iter().any(|group| !group.power_enabled && group.zone_ids.iter().any(|zone_id| zone_id == &zone.id)); + refresh_control_ownership(&mut zone, settings.house_power_enabled, blocked_by_group); + let configured_effective_mode = configured_effective_mode_owned.as_str(); + let manual_device_mode = device.map(|item| if item.power { item.mode.as_str() } else { "off" }); + let effective_mode = if zone.device_manual_override { + manual_device_mode.unwrap_or(configured_effective_mode) + } else if zone.local_thermostat_power == Some(false) || blocked_by_group { + "off" + } else { + configured_effective_mode + }; + + // Keep the thermostat target readable even while the zone/group/house control is off. + // Home Assistant climate entities otherwise expose target_temperature as unknown. + let target_mode = if configured_effective_mode == "off" { zone.mode.as_str() } else { configured_effective_mode }; + let active_for_target = active_schedule_for_zone(&zone, &schedules, now); + let (resolved_preset, resolved_target) = resolve_zone_target(&zone, active_for_target, target_mode); + let active = if effective_mode == "off" { None } else { active_for_target }; + let next_events = if effective_mode == "off" { + Vec::new() + } else { + next_schedule_events(&zone, &schedules, effective_mode, now, 8) + }; + for event in next_events.iter().take(2) { + let mut event = event.clone(); + event.label = format!("{}: {}", zone.name, event.label); + house_events.push(event); + } + let effective_enabled = zone.enabled + && zone.local_thermostat_power != Some(false) + && (!blocked_by_group || zone.device_manual_override); + zones_out.push(ZoneControlPlan { + zone_id: zone.id.clone(), + zone_name: zone.name.clone(), + device_id: zone.device_id.clone(), + device_name: device.map(|item| item.name.clone()).unwrap_or_else(|| zone.device_id.clone()), + enabled: zone.enabled, + effective_enabled, + mode: effective_mode.to_string(), + configured_mode: zone.mode.clone(), + inherit_house_mode: zone.inherit_house_mode, + preset: resolved_preset, + preset_override: zone.manual_preset.clone(), + current_temperature: zone.current_temperature, + target_temperature: if zone.device_manual_override || !zone.enabled || effective_mode == "off" { + // A remote/manual takeover may leave a physical standby target persisted in the + // zone runtime snapshot. Never publish that value as the thermostat target. + Some(resolved_target) + } else { + zone.effective_setpoint.or(Some(resolved_target)) + }, + device_setpoint: device.filter(|item| item.power).map(|item| item.target_temperature), + desired_power: settings.house_power_enabled && zone.enabled && effective_mode != "off" && !zone.device_manual_override && !blocked_by_group, + desired_mode: effective_mode.to_string(), + actual_power: device.map(|item| item.power), + actual_mode: device.map(|item| if item.power { item.mode.clone() } else { "off".into() }), + actual_setpoint: device.filter(|item| item.power).map(|item| item.target_temperature), + demand: settings.house_power_enabled && zone.enabled && effective_mode != "off" && !zone.device_manual_override && zone.demand, + control_source: zone.control_temperature_source.clone(), + manual_override_until: zone.manual_override_until, + local_thermostat_power: zone.local_thermostat_power, + local_thermostat_resume_at: zone.local_thermostat_resume_at, + device_manual_override: zone.device_manual_override, + device_manual_override_until: zone.device_manual_override_until, + control_owner: zone.control_owner.clone(), + control_command_source: zone.control_source.clone(), + control_since: zone.control_since, + resume_at: zone.control_resume_at, + control_reason: zone.control_reason.clone(), + blocked_reason: if !settings.house_power_enabled { Some("global_off".into()) } else if zone.device_manual_override { Some("manual_override".into()) } else if blocked_by_group { Some("group_off".into()) } else if zone.lockout_until.map(|until| until > Utc::now()).unwrap_or(false) { Some(zone.lockout_reason.clone().unwrap_or_else(|| "lockout".into())) } else if !zone.enabled { Some("zone_disabled".into()) } else if device.map(|d| !d.online || d.communication_failures > 0).unwrap_or(true) { Some("offline".into()) } else { None }, + lockout_until: zone.lockout_until, + current_schedule_id: active.map(|item| item.id.clone()), + current_schedule_name: active.map(|item| item.name.clone()), + next_events, + }); + } + let mut rules = Vec::new(); + for item in state.db.list_automations()? { + let action_group_name = item.action_group_id.as_deref() + .and_then(|id| groups.iter().find(|group| group.id == id)) + .map(|group| group.name.clone()); + let action_name = action_group_name.clone().unwrap_or_else(|| { + devices.iter().find(|device| device.id == item.action_device_id) + .map(|device| device.name.clone()) + .unwrap_or_else(|| item.action_device_id.clone()) + }); + let trigger_name = item.trigger_device_id.as_deref().and_then(|id| devices.iter().find(|device| device.id == id)).map(|device| device.name.clone()); + let next_ready_at = item.last_fired_at.map(|last| last + chrono::Duration::seconds(item.cooldown_seconds as i64)); + if item.enabled && item.trigger_kind == "time" { + if let Some(event) = next_time_automation_event(&item, &action_name, now) { + house_events.push(event); + } + } + rules.push(AutomationPlanRule { + id: item.id, + name: item.name, + enabled: item.enabled, + trigger_kind: item.trigger_kind, + trigger_device_id: item.trigger_device_id, + trigger_device_name: trigger_name, + threshold: item.threshold, + at_time: item.at_time, + action_device_id: item.action_device_id, + action_device_name: action_name, + action_group_id: item.action_group_id, + action_group_name, + action_preset: item.action_preset, + action: item.action, + last_fired_at: item.last_fired_at, + next_ready_at, + }); + } + + house_events.sort_by_key(|event| event.at); + house_events.truncate(12); + + Ok(ControlPlan { + generated_at: Utc::now(), + house_mode: settings.house_mode, + house_preset, + house_power, + outdoor_temperature: *state.outdoor_temperature.read().await, + control_strategy: settings.control_strategy, + night_mode_active: night_active, + night_mode_start: settings.night_mode.start_time, + night_mode_end: settings.night_mode.end_time, + night_mode_max_fan_speed: settings.night_mode.max_fan_speed.clamp(1, 5), + next_events: house_events, + zones: zones_out, + rules, + }) +} + + +fn next_night_mode_events(settings: &NightModeSettings, now: DateTime, limit: usize) -> Vec { + if !settings.enabled || limit == 0 { return Vec::new(); } + let Ok(start) = NaiveTime::parse_from_str(&settings.start_time, "%H:%M") else { return Vec::new(); }; + let Ok(end) = NaiveTime::parse_from_str(&settings.end_time, "%H:%M") else { return Vec::new(); }; + let mut events = Vec::new(); + let base = minute_floor(now); + for minute in 1..=(48 * 60) { + let candidate = base + chrono::Duration::minutes(minute); + let time = candidate.time(); + let (kind, label) = if time.hour() == start.hour() && time.minute() == start.minute() { + let quiet = if settings.force_quiet { " + Quiet" } else { "" }; + ("night_mode_start", format!("Night mode -> fan max {}{}", settings.max_fan_speed.clamp(1, 5), quiet)) + } else if time.hour() == end.hour() && time.minute() == end.minute() { + ("night_mode_end", "Night mode ends".to_string()) + } else { + continue; + }; + events.push(ControlPlanEvent { + at: candidate.with_timezone(&Utc), + kind: kind.into(), + label, + preset: None, + target_temperature: None, + }); + if events.len() >= limit { break; } + } + events +} + +fn next_time_automation_event(item: &Automation, action_name: &str, now: DateTime) -> Option { + let expected = NaiveTime::parse_from_str(item.at_time.as_deref()?, "%H:%M").ok()?; + let base = minute_floor(now.clone()); + if time_automation_due(item, now) { + return Some(ControlPlanEvent { + at: base.with_timezone(&Utc), + kind: "automation".into(), + label: format!("{} -> {}", item.name, action_name), + preset: None, + target_temperature: item.action.target_temperature, + }); + } + // A local day can last 25 hours at the end of daylight saving time. + for minute in 1..=(26 * 60) { + let candidate = base + chrono::Duration::minutes(minute); + if candidate.hour() == expected.hour() && candidate.minute() == expected.minute() { + return Some(ControlPlanEvent { + at: candidate.with_timezone(&Utc), + kind: "automation".into(), + label: format!("{} -> {}", item.name, action_name), + preset: None, + target_temperature: item.action.target_temperature, + }); + } + } + None +} + +fn next_schedule_events(zone: &Zone, schedules: &[Schedule], mode: &str, now: DateTime, limit: usize) -> Vec { + if mode == "off" { return Vec::new(); } + let mut events = Vec::new(); + let mut current = active_schedule_for_zone(zone, schedules, now).map(|item| item.id.as_str()); + let base = minute_floor(now); + for minute in 1..=(8 * 24 * 60) { + let candidate = base + chrono::Duration::minutes(minute); + let next = active_schedule_for_zone(zone, schedules, candidate); + let next_id = next.map(|item| item.id.as_str()); + if next_id == current { continue; } + current = next_id; + let (preset, target, label) = if let Some(item) = next { + let target = if item.preset == "custom" { item.setpoint } else { profile_setpoint(zone, &item.preset, mode) }; + (Some(item.preset.clone()), Some(target), format!("{} -> {} {:.1} C", item.name, item.preset, target)) + } else { + let target = profile_setpoint(zone, "comfort", mode); + (Some("comfort".into()), Some(target), format!("comfort {:.1} C", target)) + }; + events.push(ControlPlanEvent { + at: candidate.with_timezone(&Utc), + kind: "schedule_transition".into(), + label, + preset, + target_temperature: target, + }); + if events.len() >= limit { break; } + } + events +} + diff --git a/src/engine/deadlines.rs b/src/engine/deadlines.rs new file mode 100644 index 0000000..60e39d1 --- /dev/null +++ b/src/engine/deadlines.rs @@ -0,0 +1,38 @@ +fn next_time_automation_utc(item: &Automation, now: DateTime) -> Option> { + if !item.enabled || item.trigger_kind != "time" { return None; } + let expected = NaiveTime::parse_from_str(item.at_time.as_deref()?, "%H:%M").ok()?; + let minute_floor = now.with_second(0)?.with_nanosecond(0)?; + for offset in 0..=(24 * 60) { + let candidate = minute_floor + chrono::Duration::minutes(offset); + if candidate <= now { continue; } + if candidate.hour() == expected.hour() && candidate.minute() == expected.minute() { + return Some(candidate.with_timezone(&Utc)); + } + } + None +} + +fn next_zone_control_deadline_delay(state: &AppState) -> Result, AppError> { + let now = Utc::now(); + let local_now = now.with_timezone(&Local); + let zones = state.db.list_zones()?; + let schedules = state.db.list_schedules()?; + let automations = state.db.list_automations()?; + let mut deadlines: Vec> = Vec::new(); + + for zone in &zones { + if local_thermostat_handback_is_active(zone) { + if let Some(at) = zone.local_thermostat_resume_at.clone() { deadlines.push(at); } + } + if let Some(at) = temporary_quick_thermostat_wakeup_at(zone, now.clone()) { deadlines.push(at); } + if let Some(at) = next_schedule_boundary_utc(&zone.id, &schedules, local_now.clone()) { deadlines.push(at); } + } + for automation in &automations { + if let Some(at) = next_time_automation_utc(automation, local_now.clone()) { deadlines.push(at); } + } + + Ok(deadlines.into_iter() + .map(|at| (at - now.clone()).to_std().unwrap_or(Duration::ZERO)) + .min()) +} + diff --git a/src/engine/groups.rs b/src/engine/groups.rs new file mode 100644 index 0000000..c991649 --- /dev/null +++ b/src/engine/groups.rs @@ -0,0 +1,130 @@ +pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControlPatch, source: &str) -> Result { + if let Some(mode) = patch.mode.as_deref() { + if !matches!(mode, "house" | "auto" | "cool" | "heat") { + return Err(AppError::BadRequest("group mode must be house, cool or heat".into())); + } + } + if let Some(preset) = patch.preset.as_deref() { + if !matches!(preset, "auto" | "comfort" | "sleep" | "away") { + return Err(AppError::BadRequest("group preset must be auto, comfort, sleep or away".into())); + } + } + + let mut group = state.db.get_group(group_id)? + .ok_or_else(|| AppError::NotFound(format!("group {group_id}")))?; + let schedules = state.db.list_schedules()?; + let climate_change = patch.mode.is_some() || patch.preset.is_some(); + if let Some(power) = patch.power { + group.power_enabled = power; + } + group.updated_at = Utc::now(); + state.db.save_group(&group)?; + state.broadcast("group.updated", serde_json::to_value(&group)?); + + // Explicit group ON is a conscious request to run this group. Resume the global + // master without changing the gates of any other groups. This makes group ON work + // even after a previous whole-house OFF while preserving multi-group OFF priority. + if patch.power == Some(true) { + let mut settings = state.settings.write().await; + if !settings.house_power_enabled { + settings.house_power_enabled = true; + state.db.save_runtime_settings(&settings)?; + state.broadcast("house.power_changed", json!({"house_power_enabled": true})); + state.log("info", "house.power_resumed_by_group", &format!("Whole-house master resumed by group {}", group.name), json!({ + "group_id": group.id, "source": source + })); + } + } + + let mut zones = Vec::new(); + for zone_id in &group.zone_ids { + let _zone_guard = state.lock_zone_operation(zone_id).await; + let Some(zone_snapshot) = state.db.get_zone(zone_id)? else { continue; }; + let _device_guard = state.lock_device_operation(&zone_snapshot.device_id).await; + let Some(mut zone) = state.db.get_zone(zone_id)? else { continue; }; + let temporary_owns_zone = temporary_quick_thermostat_is_active(&zone, Utc::now()); + if temporary_owns_zone { + if let Some(session) = zone.temporary_quick_thermostat.as_mut() { + if let Some(mode) = patch.mode.as_deref() { session.deferred_mode = Some(mode.to_string()); } + if let Some(preset) = patch.preset.as_deref() { session.deferred_preset = Some(preset.to_string()); } + } + if climate_change { + state.log("info", "group.control_deferred_by_temporary_thermostat", &format!("Group climate change deferred for {} while Temporary Quick Thermostat owns the zone", zone.name), json!({ + "zone_id": zone.id, "group_id": group.id, "source": source + })); + } + } else { + if let Some(mode) = patch.mode.as_deref() { + match mode { + "house" | "auto" => zone.inherit_house_mode = true, + "cool" | "heat" => { + zone.inherit_house_mode = false; + zone.mode = mode.to_string(); + } + _ => {} + } + } + if let Some(preset) = patch.preset.as_deref() { + if preset == "auto" { + zone.manual_preset = None; + zone.manual_setpoint = None; + zone.manual_override_until = None; + } else { + zone.manual_preset = Some(preset.to_string()); + zone.manual_setpoint = None; + zone.manual_override_until = next_schedule_boundary_utc(&zone.id, &schedules, Local::now()); + } + } + } + zone.revision = zone.revision.saturating_add(1); + zone.updated_at = Utc::now(); + state.db.save_zone(&zone)?; + state.broadcast("zone.updated", serde_json::to_value(&zone)?); + zones.push(zone); + } + + let runtime = state.settings.read().await.clone(); + let master_power_enabled = runtime.house_power_enabled; + let should_command_power = patch.power.is_some(); + let desired_power = group.power_enabled; + // A zone may intentionally belong to more than one group. Power-off is authoritative: + // turning one group on must never briefly wake a member that is still blocked by another group. + let mut failed = Vec::new(); + if should_command_power && !desired_power { + let mut seen = std::collections::HashSet::new(); + for zone in &zones { + if !seen.insert(zone.device_id.clone()) { continue; } + // Group OFF is an immediate safety transition. Group ON never emits a bare + // power=true frame; the thermostat arbiter starts the unit with mode/target. + let Some(device) = state.db.get_device(&zone.device_id)? else { continue; }; + if !device.enabled { continue; } + match send_group_power_if_current(state, &group.id, &zone.id, &device.id, false).await { + Ok(_) => {} + Err(err) => { + state.log("error", "group.power_error", &err.to_string(), json!({ + "group_id": group.id, "device_id": device.id, "device_name": device.name, + "power": false, "source": source, + })); + failed.push(json!({"device_id": device.id, "device_name": device.name, "error": err.to_string()})); + } + } + } + } + + if desired_power && (should_command_power || climate_change) { + state.wake_zone_control(); + } + state.log("info", source, &format!("Updated group {}", group.name), json!({ + "group_id": group.id, "power_enabled": group.power_enabled, "mode": patch.mode, "preset": patch.preset, + "zones": zones.len(), "failed": failed.len(), "master_power_enabled": master_power_enabled, + })); + Ok(json!({ + "group": group, + "zones": zones, + "devices": state.db.list_devices()?, + "failed": failed, + "master_power_enabled": master_power_enabled, + })) +} + + diff --git a/src/engine/history.rs b/src/engine/history.rs new file mode 100644 index 0000000..e941464 --- /dev/null +++ b/src/engine/history.rs @@ -0,0 +1,109 @@ +fn record_zone_history(state: &AppState, zone: &Zone, outdoor_temperature: Option, poll_interval_seconds: u64) { + let device = match state.db.get_device(&zone.device_id) { + Ok(Some(device)) => device, + Ok(None) => return, + Err(err) => { + tracing::warn!(error=?err, zone_id=%zone.id, "cannot load device for zone history"); + return; + } + }; + let reading = ZoneReading { + id: 0, + zone_id: zone.id.clone(), + device_id: zone.device_id.clone(), + timestamp: Utc::now(), + gree_temperature: zone.device_temperature.or(device.current_temperature), + external_temperature: zone.external_temperature, + control_temperature: zone.current_temperature.or(zone.device_temperature).or(device.current_temperature), + target_temperature: zone.effective_setpoint.or(zone.manual_setpoint).or(Some(zone.setpoint)), + device_setpoint: zone.device_setpoint.or(Some(device.target_temperature)), + outdoor_temperature: outdoor_temperature.or(device.outdoor_temperature), + power: device.power, + mode: if zone.effective_mode.is_empty() { device.mode.clone() } else { zone.effective_mode.clone() }, + fan_speed: device.fan_speed, + demand: zone.demand, + control_source: zone.control_temperature_source.clone(), + active_preset: zone.active_preset.clone(), + }; + let interval = poll_interval_seconds.max(15) as i64; + match state.db.add_zone_reading_if_due(&reading, interval) { + Ok(true) => queue_influx_zone(state, reading), + Ok(false) => {} + Err(err) => tracing::warn!(error=?err, zone_id=%zone.id, "cannot save zone history sample"), + } +} + +fn record_ha_history( + state: &AppState, + entity_id: &str, + zone_id: Option<&str>, + kind: &str, + temperature: f64, + poll_interval_seconds: u64, +) { + let reading = HaReading { + id: 0, + entity_id: entity_id.to_string(), + zone_id: zone_id.map(str::to_string), + kind: kind.to_string(), + timestamp: Utc::now(), + temperature, + }; + let interval = poll_interval_seconds.max(15) as i64; + match state.db.add_ha_reading_if_due(&reading, interval) { + Ok(true) => queue_influx_ha(state, reading), + Ok(false) => {} + Err(err) => tracing::warn!(error=?err, entity_id=%entity_id, "cannot save Home Assistant history sample"), + } +} + +fn queue_influx_device(state: &AppState, reading: Reading) { + let state = state.clone(); + tokio::spawn(async move { + let settings = state.settings.read().await.influxdb.clone(); + if !settings.enabled { return; } + if let Err(err) = influxdb::write_device(&state.http, &settings, &reading).await { + tracing::warn!(error=?err, device_id=%reading.device_id, "cannot write device metric to InfluxDB"); + } + }); +} + +fn queue_influx_zone(state: &AppState, reading: ZoneReading) { + let state = state.clone(); + tokio::spawn(async move { + let settings = state.settings.read().await.influxdb.clone(); + if !settings.enabled { return; } + if let Err(err) = influxdb::write_zone(&state.http, &settings, &reading).await { + tracing::warn!(error=?err, zone_id=%reading.zone_id, "cannot write zone metric to InfluxDB"); + } + }); +} + +fn queue_influx_ha(state: &AppState, reading: HaReading) { + let state = state.clone(); + tokio::spawn(async move { + let settings = state.settings.read().await.influxdb.clone(); + if !settings.enabled { return; } + if let Err(err) = influxdb::write_ha(&state.http, &settings, &reading).await { + tracing::warn!(error=?err, entity_id=%reading.entity_id, "cannot write HA metric to InfluxDB"); + } + }); +} + +fn gree_outdoor_temperature(devices: &[Device]) -> Option { + let mut values: Vec = devices.iter() + .filter(|device| device.enabled && device.online && device.communication_failures == 0) + .filter_map(|device| device.outdoor_temperature) + .filter(|value| value.is_finite() && (-60.0..=70.0).contains(value)) + .collect(); + if values.is_empty() { return None; } + values.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); + let middle = values.len() / 2; + let value = if values.len() % 2 == 0 { + (values[middle - 1] + values[middle]) / 2.0 + } else { + values[middle] + }; + Some((value * 10.0).round() / 10.0) +} + diff --git a/src/engine/local_thermostat.rs b/src/engine/local_thermostat.rs new file mode 100644 index 0000000..6494e3c --- /dev/null +++ b/src/engine/local_thermostat.rs @@ -0,0 +1,79 @@ +pub const LOCAL_THERMOSTAT_RESUME_DELAY_MINUTES: i64 = 15; + +/// Apply local quick-thermostat power ownership and keep the automatic hand-back +/// deadline in one backend-owned place. Every fresh OFF action receives a fresh +/// deadline; ON cancels any pending hand-back. +pub fn set_local_thermostat_power(zone: &mut Zone, power: bool, now: DateTime) -> bool { + let previous_restore = zone.local_thermostat_restore_zone_enabled; + // Ordinary Quick Thermostat has its own restore state. A temporary session never uses + // this field, so its lifecycle cannot be erased by the 15-minute local hand-back. + if power && zone.local_thermostat_power != Some(true) && zone.local_thermostat_restore_zone_enabled.is_none() { + zone.local_thermostat_restore_zone_enabled = Some(zone.enabled); + } + let resume_at = if power { + None + } else { + Some(now + chrono::Duration::minutes(LOCAL_THERMOSTAT_RESUME_DELAY_MINUTES)) + }; + let changed = zone.local_thermostat_power != Some(power) + || zone.local_thermostat_resume_at != resume_at + || zone.local_thermostat_restore_zone_enabled != previous_restore; + zone.local_thermostat_power = Some(power); + zone.local_thermostat_resume_at = resume_at; + changed +} + +/// Re-arm a local-OFF hand-back after a temporary direct/manual device takeover. +/// The countdown must start from the moment that manual control ends, not from the +/// older OFF action that happened before the takeover. +fn rearm_local_thermostat_resume(zone: &mut Zone, now: DateTime) -> bool { + if zone.local_thermostat_power != Some(false) { return false; } + set_local_thermostat_power(zone, false, now) +} + +fn local_thermostat_handback_is_active(zone: &Zone) -> bool { + zone.local_thermostat_power == Some(false) && !zone.device_manual_override +} + +/// Clear only the ordinary local Quick Thermostat. Temporary Quick Thermostat state is +/// deliberately untouched; the two ownership mechanisms have independent cleanup paths. +pub fn reset_local_thermostat_override(zone: &mut Zone) -> bool { + let temporary_active = zone.temporary_quick_thermostat.as_ref() + .map(|session| session.activated_at.is_some()) + .unwrap_or(false); + if temporary_active { + let Some(session) = zone.temporary_quick_thermostat.as_mut() else { return false; }; + let changed = session.restore_local_thermostat_power.is_some() + || session.restore_local_thermostat_resume_at.is_some() + || session.restore_local_thermostat_zone_enabled.is_some() + || session.restore_manual_preset.is_some() + || session.restore_manual_setpoint.is_some() + || session.restore_manual_override_until.is_some(); + session.restore_local_thermostat_power = None; + session.restore_local_thermostat_resume_at = None; + session.restore_local_thermostat_zone_enabled = None; + session.restore_manual_preset = None; + session.restore_manual_setpoint = None; + session.restore_manual_override_until = None; + return changed; + } + + let restore_zone_enabled = zone.local_thermostat_restore_zone_enabled; + let changed = zone.local_thermostat_power.is_some() + || zone.local_thermostat_resume_at.is_some() + || zone.local_thermostat_restore_zone_enabled.is_some() + || zone.manual_preset.is_some() + || zone.manual_setpoint.is_some() + || zone.manual_override_until.is_some(); + zone.local_thermostat_power = None; + zone.local_thermostat_resume_at = None; + zone.local_thermostat_restore_zone_enabled = None; + zone.manual_preset = None; + zone.manual_setpoint = None; + zone.manual_override_until = None; + if let Some(enabled) = restore_zone_enabled { + zone.enabled = enabled; + } + changed +} + diff --git a/src/engine/ownership.rs b/src/engine/ownership.rs new file mode 100644 index 0000000..cdca26f --- /dev/null +++ b/src/engine/ownership.rs @@ -0,0 +1,319 @@ +pub fn refresh_control_ownership(zone: &mut Zone, house_power_enabled: bool, blocked_by_group: bool) { + let now = Utc::now(); + let (owner, source, resume_at, reason) = if !house_power_enabled { + ("global_off", "global".to_string(), None, "Whole-house power is disabled".to_string()) + } else if zone.device_manual_override { + let source = match zone.control_source.as_str() { + "home_assistant_direct" | "web_direct" | "external" => zone.control_source.clone(), + _ => "external".into(), + }; + ("direct_manual", source, zone.device_manual_override_until, "Direct/manual device control has priority".to_string()) + } else if zone.local_thermostat_power.is_some() { + let source = match zone.control_source.as_str() { + "home_assistant_thermostat" | "web_thermostat" => zone.control_source.clone(), + _ => "local_thermostat".into(), + }; + let resume_at = zone.temporary_quick_thermostat.as_ref() + .and_then(temporary_quick_thermostat_next_deadline) + .or(zone.local_thermostat_resume_at.clone()); + let reason = if zone.local_thermostat_power == Some(false) { + "Local thermostat is explicitly off".into() + } else if zone.temporary_quick_thermostat.is_some() { + "Temporary Quick Thermostat owns the zone".into() + } else { + "Local thermostat owns the zone".into() + }; + ("local_thermostat", source, resume_at, reason) + } else if blocked_by_group { + ("automation", "group".to_string(), None, "Zone is blocked by a disabled group".to_string()) + } else { + ("automation", "automation".to_string(), zone.manual_override_until, "Automatic thermostat/schedule control".to_string()) + }; + if zone.control_owner != owner || zone.control_source != source { + zone.control_since = Some(now); + } else if zone.control_since.is_none() { + zone.control_since = Some(now); + } + zone.control_owner = owner.into(); + zone.control_source = source; + zone.control_resume_at = resume_at; + zone.control_reason = reason; +} + +fn normalized_direct_source(source: &str) -> &'static str { + if source.contains("home_assistant") { "home_assistant_direct" } + else if source == "device.manual_control" { "web_direct" } + else { "external" } +} + +pub fn reset_device_manual_override(zone: &mut Zone) -> bool { + let now = Utc::now(); + let changed = zone.device_manual_override + || zone.device_manual_override_since.is_some() + || zone.device_manual_override_until.is_some() + || !zone.device_manual_override_fields.is_empty() + || zone.device_manual_override_baseline.is_some(); + zone.device_manual_override = false; + zone.device_manual_override_since = None; + zone.device_manual_override_until = None; + zone.device_manual_override_fields.clear(); + zone.device_manual_override_baseline = None; + if let Some(session) = zone.temporary_quick_thermostat.as_mut() { + let pause = session.paused_at.take() + .map(|paused_at| now.signed_duration_since(paused_at)) + .filter(|pause| *pause > chrono::Duration::zero()); + if session.activated_at.is_some() { + if let Some(pause) = pause { + if matches!(session.finish_kind.as_str(), "duration" | "until") { + session.expires_at = session.expires_at.map(|at| at + pause); + } + if matches!(session.finish_kind.as_str(), "temperature_reached" | "temperature_stable") { + session.safety_expires_at = session.safety_expires_at.map(|at| at + pause); + } + } + session.state = "active".into(); + } else { + // A due session blocked by manual ownership has not started its work clock. + // Preserve the requested remaining `until` window by excluding manual wait time. + if session.finish_kind == "until" { + if let Some(pause) = pause { + session.expires_at = session.expires_at.map(|at| at + pause); + } + } + session.state = "scheduled".into(); + } + session.condition_started_at = None; + session.condition_last_observed_at = None; + } + if zone.control_owner == "direct_manual" { + zone.control_owner = "automation".into(); + zone.control_source = "automation".into(); + zone.control_since = Some(now); + zone.control_resume_at = None; + zone.control_reason = "Manual takeover cleared; automation may resume".into(); + } + changed +} + +fn manual_override_matches_baseline(zone: &Zone, device: &Device) -> bool { + let Some(baseline) = zone.device_manual_override_baseline.as_ref() else { return false; }; + if zone.device_manual_override_fields.is_empty() { return false; } + // If the unit was OFF before takeover, returning it to OFF is operationally the same + // controller state even if the remote retained a different mode/target internally. + // Those dormant values will be set explicitly if automation later powers the unit. + if !baseline.power { return !device.power; } + zone.device_manual_override_fields.iter().all(|field| match field.as_str() { + "power" => device.power == baseline.power, + "mode" => device.mode == baseline.mode, + "target_temperature" => device.target_temperature.round() == baseline.target_temperature.round(), + "fan_speed" => device.fan_speed == baseline.fan_speed, + "quiet" => device.quiet == baseline.quiet, + "sleep" => device.sleep == baseline.sleep, + _ => false, + }) +} + +fn persist_manual_override_clear(state: &AppState, zone: &mut Zone, source: &str, restored: bool) -> Result { + if !reset_device_manual_override(zone) { return Ok(false); } + let now = Utc::now(); + let local_resume_rearmed = restored && rearm_local_thermostat_resume(zone, now.clone()); + zone.updated_at = now; + state.db.save_zone(zone)?; + state.broadcast("zone.updated", serde_json::to_value(&*zone)?); + let (kind, message) = if restored { + ("zone.device_manual_override_restored", format!("Manual device control returned {} to its previous state", zone.name)) + } else { + ("zone.device_manual_override_cleared", format!("Manual device control ended for {}", zone.name)) + }; + state.log("info", kind, &message, json!({ + "zone_id": zone.id, "device_id": zone.device_id, "source": source, + "local_thermostat_resume_rearmed": local_resume_rearmed, + "local_thermostat_resume_at": zone.local_thermostat_resume_at, + })); + state.wake_zone_control(); + Ok(true) +} + +fn set_device_manual_override(state: &AppState, zone: &mut Zone, fields: Vec, source: &str, baseline: &Device) -> Result<(), AppError> { + if fields.is_empty() { return Ok(()); } + let now = Utc::now(); + if !zone.device_manual_override { + zone.device_manual_override_since = Some(now); + zone.device_manual_override_baseline = Some(baseline.into()); + zone.control_since = Some(now); + } + zone.device_manual_override = true; + if let Some(session) = zone.temporary_quick_thermostat.as_mut() { + // A future scheduled session has no ownership yet. Do not mark it paused until its + // requested start actually becomes due while manual control is still present. + if session.activated_at.is_some() || session.started_at <= now { + if session.paused_at.is_none() { session.paused_at = Some(now); } + session.state = "paused_manual".into(); + session.condition_started_at = None; + session.condition_last_observed_at = None; + } + } + zone.control_owner = "direct_manual".into(); + zone.control_source = normalized_direct_source(source).into(); + zone.control_reason = "Direct/manual device control has priority".into(); + zone.device_manual_override_until = if zone.enabled { + next_schedule_boundary_utc(&zone.id, &state.db.list_schedules()?, Local::now()) + } else { + None + }; + zone.control_resume_at = zone.device_manual_override_until; + for field in fields { + if !zone.device_manual_override_fields.iter().any(|existing| existing == &field) { + zone.device_manual_override_fields.push(field); + } + } + zone.demand = false; + zone.demand_since = None; + zone.updated_at = now; + state.db.save_zone(zone)?; + state.broadcast("zone.updated", serde_json::to_value(&*zone)?); + state.log("info", "zone.device_manual_override", &format!("Manual device control detected for {}", zone.name), json!({ + "zone_id": zone.id, + "device_id": zone.device_id, + "fields": zone.device_manual_override_fields, + "source": source, + "override_until": zone.device_manual_override_until, + })); + Ok(()) +} + +async fn detect_external_device_control(state: &AppState, before: &Device, after: &Device) -> Result<(), AppError> { + if before.id != after.id { return Ok(()); } + for mut zone in state.db.list_zones()?.into_iter().filter(|zone| zone.device_id == after.id) { + let raw_fields = externally_changed_control_fields(before, after, &zone); + let controller_settling = if raw_fields.is_empty() { + json!({ "active": false, "reason": "no_changed_control_fields" }) + } else { + controller_settling_diagnostics(state, &after.id).await + }; + let fields = suppress_expected_controller_changes( + state, + after, + raw_fields.clone(), + ).await; + if zone.device_manual_override && manual_override_matches_baseline(&zone, after) { + persist_manual_override_clear(state, &mut zone, "gree_poll", true)?; + continue; + } + if fields.is_empty() { continue; } + // A disabled zone is outside controller ownership. When its manually operated unit is + // switched off there is no takeover left to display or remember. + if !zone.enabled && !after.power { + persist_manual_override_clear(state, &mut zone, "gree_poll", false)?; + continue; + } + state.log("info", "device.remote_control_detected", &format!("External/pilot control detected for {}", zone.name), json!({ + "zone_id": zone.id.clone(), + "device_id": zone.device_id.clone(), + "raw_fields": raw_fields, + "detected_fields": fields.clone(), + "before": device_control_snapshot(before), + "after": device_control_snapshot(after), + "controller_settling": controller_settling, + "source": "gree_poll", + "zone_state": { + "enabled": zone.enabled, + "control_owner": zone.control_owner.clone(), + "control_source": zone.control_source.clone(), + "demand": zone.demand, + "local_thermostat_power": zone.local_thermostat_power, + "temporary_quick_thermostat": zone.temporary_quick_thermostat.clone(), + }, + })); + set_device_manual_override(state, &mut zone, fields, "gree_poll", before)?; + } + Ok(()) +} + +pub async fn send_manual_command(state: &AppState, device_id: &str, command: DeviceCommand, source: &str) -> Result { + // Keep zone -> device lock ordering consistent with Quick Thermostat/full-zone edits. + // A device belongs to at most one thermostat zone, but keep this generic for legacy data. + let zone_ids: Vec = state.db.list_zones()?.into_iter().filter(|zone| zone.device_id == device_id).map(|zone| zone.id).collect(); + let mut _zone_guards = Vec::new(); + for zone_id in &zone_ids { _zone_guards.push(state.lock_zone_operation(zone_id).await); } + // Keep the device lock until the zone takeover marker is persisted. Otherwise a poll + // could observe our own just-sent command before the controller records manual ownership. + let _device_guard = state.lock_device_operation(device_id).await; + let before = state.db.get_device(device_id)? + .ok_or_else(|| AppError::NotFound(format!("device {device_id}")))?; + let effective_command = if before.online && before.communication_failures == 0 { command.changed_from(&before) } else { command.clone() }; + let fields = command_manual_control_fields(&effective_command); + let updated = send_command_locked_inner(state, device_id, command, true, false).await?; + if !fields.is_empty() { + for mut zone in state.db.list_zones()?.into_iter().filter(|zone| zone.device_id == device_id) { + if zone.device_manual_override && manual_override_matches_baseline(&zone, &updated) { + persist_manual_override_clear(state, &mut zone, source, true)?; + continue; + } + if !zone.enabled && !updated.power { + persist_manual_override_clear(state, &mut zone, source, false)?; + continue; + } + set_device_manual_override(state, &mut zone, fields.clone(), source, &before)?; + } + } + Ok(updated) +} + +pub async fn force_house_power_off_device(state: &AppState, device_id: &str, source: &str) -> Result { + // Global OFF is a one-shot authority transition. Clear takeover and send OFF while + // polling for this unit is excluded; a later remote change happens after the lock and + // is therefore preserved as a new manual takeover. + let _device_guard = state.lock_device_operation(device_id).await; + for mut zone in state.db.list_zones()?.into_iter().filter(|zone| zone.device_id == device_id) { + if !reset_device_manual_override(&mut zone) { continue; } + zone.updated_at = Utc::now(); + state.db.save_zone(&zone)?; + state.broadcast("zone.updated", serde_json::to_value(&zone)?); + state.log("info", "zone.device_manual_override_cleared", &format!("Automation resumed for {}", zone.name), json!({ + "zone_id": zone.id, "device_id": zone.device_id, "source": source + })); + } + send_command_locked_forced(state, device_id, DeviceCommand { power: Some(false), ..Default::default() }).await +} + +pub async fn force_power_off_device(state: &AppState, device_id: &str) -> Result { + let _device_guard = state.lock_device_operation(device_id).await; + send_command_locked_forced(state, device_id, DeviceCommand { power: Some(false), ..Default::default() }).await +} + +/// Technical device disable is a safety transition, not just a database flag. The unit is +/// explicitly powered off while it is still commandable, then removed from controller polling. +pub async fn disable_device_safely(state: &AppState, device_id: &str) -> Result { + let _device_guard = state.lock_device_operation(device_id).await; + let mut device = state.db.get_device(device_id)? + .ok_or_else(|| AppError::NotFound(format!("device {device_id}")))?; + if !device.enabled { return Ok(device); } + device = send_command_locked_forced( + state, + device_id, + DeviceCommand { power: Some(false), ..Default::default() }, + ).await?; + device.enabled = false; + device.updated_at = Utc::now(); + state.db.save_device(&device)?; + state.broadcast("device.updated", serde_json::to_value(&device)?); + state.wake_zone_control(); + Ok(device) +} + +pub fn clear_all_device_manual_overrides(state: &AppState, source: &str) -> Result { + let mut cleared = 0usize; + for mut zone in state.db.list_zones()? { + if !reset_device_manual_override(&mut zone) { continue; } + zone.updated_at = Utc::now(); + state.db.save_zone(&zone)?; + state.broadcast("zone.updated", serde_json::to_value(&zone)?); + state.log("info", "zone.device_manual_override_cleared", &format!("Automation resumed for {}", zone.name), json!({ + "zone_id": zone.id, "device_id": zone.device_id, "source": source + })); + cleared += 1; + } + Ok(cleared) +} + diff --git a/src/engine/polling.rs b/src/engine/polling.rs new file mode 100644 index 0000000..e1dc37b --- /dev/null +++ b/src/engine/polling.rs @@ -0,0 +1,328 @@ +pub async fn poll_one(state: &AppState, device_id: &str) -> Result { + let _device_guard = state.lock_device_operation(device_id).await; + poll_one_locked(state, device_id).await +} + +async fn poll_one_locked(state: &AppState, device_id: &str) -> Result { + 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; + if poll_completed_successfully(&device) { + if state.initial_device_sync_complete.load(Ordering::Acquire) { + record_device_transition_timestamps(state, &before, &device)?; + } + detect_external_device_control(state, &before, &device).await?; + } + state.db.save_device(&device)?; + record_reading(state, &device)?; + state.broadcast("device.updated", serde_json::to_value(&device).unwrap_or_default()); + Ok(device) +} + +pub(crate) async fn poll_all(state: &AppState) -> Result<()> { + let device_ids: Vec = state.db.list_devices()?.into_iter() + .filter(|device| device.enabled) + .map(|device| device.id) + .collect(); + for device_id in device_ids { + let _device_guard = state.lock_device_operation(&device_id).await; + let _ = poll_one_locked(state, &device_id).await?; + } + Ok(()) +} + +async fn poll_device(state: &AppState, device: &mut Device) { + if device.simulated { + simulate_tick(device); + return; + } + let previous_failures = device.communication_failures; + let response_started = Instant::now(); + if device.key.as_deref().unwrap_or_default().is_empty() { + match state.gree.bind(device).await { + Ok(bound) => { + device.key = Some(bound.key); + device.protocol_version = bound.protocol_version; + device.communication_failures = 0; + } + Err(err) => { + record_poll_failure(device, &err.to_string()); + log_poll_health_transition(state, device, previous_failures).await; + return; + } + } + } + if let Err(first_err) = state.gree.poll(device).await { + // One lost UDP response is common on Wi-Fi and must not trigger a bind storm. + // Rebind only after at least one consecutive failed poll; a successful retry clears + // the counter in GreeClient::poll. + if previous_failures == 0 { + record_poll_failure(device, &first_err.to_string()); + } else { + match state.gree.bind(device).await { + Ok(bound) => { + device.key = Some(bound.key); + device.protocol_version = bound.protocol_version; + if let Err(err) = state.gree.poll(device).await { + record_poll_failure(device, &err.to_string()); + } + } + Err(_) => record_poll_failure(device, &first_err.to_string()), + } + } + } + if device.communication_failures == 0 && device.online { + 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; +} + +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 current_failures = u32::from(device.communication_failures); + let previous_failures = u32::from(previous_failures); + if current_failures >= threshold && previous_failures < threshold { + state.log("warn", "device.offline", &format!("{} did not respond {} times in a row", device.name, device.communication_failures), json!({ + "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})); + } +} + +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 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 }; } + } + "heat" => { + let ceiling = device.target_temperature + 0.2; + if current < ceiling { current += if device.turbo { 0.25 } else { 0.12 }; } + } + "dry" => current -= 0.04, + _ => current += (ambient - current) * 0.02, + } + } else { + current += (ambient - current) * 0.04; + } + 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.online = true; + device.response_time_ms = Some(0); + device.last_seen = Some(Utc::now()); + device.last_error = None; + device.updated_at = Utc::now(); +} + +fn record_reading(state: &AppState, device: &Device) -> Result<()> { + let reading = Reading { + id: 0, + device_id: device.id.clone(), + timestamp: Utc::now(), + indoor_temperature: device.current_temperature, + outdoor_temperature: device.outdoor_temperature, + target_temperature: device.target_temperature, + power: device.power, + source: if device.simulated { "simulator".into() } else { "gree".into() }, + }; + state.db.add_reading(&reading)?; + queue_influx_device(state, reading); + Ok(()) +} + +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.last_error = Some(error.to_string()); + device.updated_at = Utc::now(); +} + +fn register_device_failure(state: &AppState, device: &mut Device, error: &str) -> Result<(), AppError> { + record_poll_failure(device, error); + state.db.save_device(device)?; + 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 let Some(value) = command.fan_speed { + 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") { + return Err(AppError::BadRequest("unsupported HVAC mode".into())); + } + } + Ok(()) +} + +fn poll_completed_successfully(device: &Device) -> bool { + device.online && device.communication_failures == 0 && device.last_error.is_none() +} + +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()); } + fields +} + +fn command_baseline_from_device(command: &DeviceCommand, device: &Device) -> DeviceCommand { + 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), + fan_speed: command.fan_speed.map(|_| device.fan_speed), + quiet: command.quiet.map(|_| device.quiet), + sleep: command.sleep.map(|_| device.sleep), + ..Default::default() + } +} + +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; + let expires_at = Instant::now() + ttl; + let baseline = command_baseline_from_device(command, baseline_device); + if let Some(existing) = pending.get_mut(device_id) { + existing.commands.push(command.clone()); + 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); } + existing.expires_at = expires_at; + } else { + 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 + .map(|value| value.clamp(8.0, 30.0).round() == device.target_temperature.clamp(8.0, 30.0).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), + _ => false, + } +} + +fn device_control_snapshot(device: &Device) -> Value { + json!({ + "power": device.power, + "mode": device.mode.clone(), + "target_temperature": device.target_temperature, + "fan_speed": device.fan_speed, + "quiet": device.quiet, + "sleep": device.sleep, + "turbo": device.turbo, + "swing_vertical": device.swing_vertical, + "swing_horizontal": device.swing_horizontal, + "online": device.online, + "communication_failures": device.communication_failures, + "last_seen": device.last_seen.clone(), + "updated_at": device.updated_at.clone(), + }) +} + +async fn controller_settling_diagnostics(state: &AppState, device_id: &str) -> Value { + let pending = state.pending_controller_commands.lock().await; + let Some(expected) = pending.get(device_id).cloned() else { + return json!({ "active": false, "reason": "none" }); + }; + 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; + return json!({ + "active": false, + "reason": "expired", + "expired_by_ms": expired_by_ms, + "commands": expected.commands, + "baselines": expected.baselines, + }); + } + 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, + "commands": expected.commands, + "baselines": expected.baselines, + }) +} + +async fn suppress_expected_controller_changes( + state: &AppState, + device: &Device, + fields: Vec, +) -> Vec { + if fields.is_empty() { return fields; } + let mut pending = state.pending_controller_commands.lock().await; + 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() + .filter(|field| { + 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 + }) + .collect(); + // Do not clear the settling guard merely because one poll matched the requested state. + // A later status packet can still briefly roll back to the pre-command snapshot. The + // bounded TTL is what ends this ambiguity window. + filtered +} + +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.target_temperature.round() != after.target_temperature.round() { + fields.push("target_temperature".to_string()); + } + // 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; + if before.fan_speed != after.fan_speed && !standby_low_to_auto { + fields.push("fan_speed".to_string()); + } + fields +} + + diff --git a/src/engine/runtime.rs b/src/engine/runtime.rs new file mode 100644 index 0000000..8fa229a --- /dev/null +++ b/src/engine/runtime.rs @@ -0,0 +1,128 @@ +fn reset_temporary_condition_observations_after_restart(state: &AppState) -> Result { + let mut changed = 0usize; + for mut zone in state.db.list_zones()? { + let Some(session) = zone.temporary_quick_thermostat.as_mut() else { continue; }; + if session.condition_started_at.is_none() && session.condition_last_observed_at.is_none() { continue; } + session.condition_started_at = None; + session.condition_last_observed_at = None; + zone.updated_at = Utc::now(); + state.db.save_zone(&zone)?; + changed += 1; + } + Ok(changed) +} + +pub fn start(state: AppState) { + // A continuous temperature hold cannot span controller downtime. Preserve the session + // itself, but require fresh observations after every process restart (H11). + if let Err(err) = reset_temporary_condition_observations_after_restart(&state) { + tracing::warn!(error=?err, "cannot reset temporary thermostat observation continuity after restart"); + } + let poll_state = state.clone(); + tokio::spawn(async move { + sleep(Duration::from_millis(500)).await; + loop { + match poll_all(&poll_state).await { + Ok(()) => { + if !poll_state.initial_device_sync_complete.swap(true, Ordering::AcqRel) { + tracing::info!("initial device state synchronized; thermostat control enabled"); + } + } + Err(err) => tracing::error!(error=?err, "device poll cycle failed"), + } + let seconds = poll_state.settings.read().await.poll_interval_seconds.max(2); + sleep(Duration::from_secs(seconds)).await; + } + }); + + let control_state = state.clone(); + tokio::spawn(async move { + sleep(Duration::from_secs(2)).await; + loop { + // A restart must never make decisions from the persisted, potentially stale + // device snapshot. Wait for one full live poll before thermostat/schedule/automation + // ownership can emit commands. Manual API/remote control remains available. + if !control_state.initial_device_sync_complete.load(Ordering::Acquire) { + sleep(Duration::from_millis(250)).await; + continue; + } + if let Err(err) = control_zones(&control_state).await { + tracing::error!(error=?err, "zone cycle failed"); + } + if let Err(err) = run_automations(&control_state).await { + tracing::error!(error=?err, "automation cycle failed"); + } + let seconds = control_state.settings.read().await.zone_interval_seconds.max(2); + let normal_delay = Duration::from_secs(seconds); + let resume_delay = match next_zone_control_deadline_delay(&control_state) { + Ok(value) => value, + Err(err) => { + tracing::warn!(error=?err, "cannot calculate thermostat control deadline"); + None + } + }; + let sleep_for = resume_delay.map(|delay| delay.min(normal_delay)).unwrap_or(normal_delay); + tokio::select! { + _ = sleep(sleep_for) => {}, + _ = control_state.zone_control_wakeup.notified() => {}, + } + } + }); + + let maintenance_state = state; + tokio::spawn(async move { + sleep(Duration::from_secs(60)).await; + loop { + let settings = maintenance_state.settings.read().await.clone(); + // When InfluxDB is enabled, compact all locally retained legacy history before + // transferring old buckets. Without Influx, compact only the configured retention window. + let compaction_days = if settings.influxdb.enabled { 3650 } else { settings.history_retention_days.max(1) } as i64; + if settings.history_compaction_enabled { + match maintenance_state.db.compact_history(compaction_days) { + Ok(count) if count > 0 => tracing::info!(count, "history samples compacted"), + Ok(_) => {} + Err(err) => tracing::warn!(error=?err, "cannot compact history"), + } + } + if settings.influxdb.enabled { + match archive_old_history(&maintenance_state, settings.influxdb.history_threshold_days.max(1)).await { + Ok(count) if count > 0 => tracing::info!(count, "old local readings archived to InfluxDB and removed from SQLite"), + Ok(_) => {} + Err(err) => tracing::warn!(error=?err, "cannot archive old history to InfluxDB; SQLite copies were kept"), + } + } else { + let retention_days = settings.history_retention_days.max(1) as i64; + match maintenance_state.db.prune_readings(retention_days) { + Ok(count) if count > 0 => tracing::info!(count, retention_days, "old local readings pruned"), + Ok(_) => {} + Err(err) => tracing::warn!(error=?err, "cannot prune readings"), + } + } + let event_retention_days = settings.event_log_retention_days.max(1) as i64; + match maintenance_state.db.prune_events(event_retention_days) { + Ok(count) if count > 0 => tracing::info!(count, event_retention_days, "old event log rows pruned"), + Ok(_) => {} + Err(err) => tracing::warn!(error=?err, "cannot prune event log"), + } + sleep(Duration::from_secs(6 * 60 * 60)).await; + } + }); +} + +async fn archive_old_history(state: &AppState, threshold_days: u32) -> Result { + let cutoff = Utc::now() - chrono::Duration::days(threshold_days.max(1) as i64); + let settings = state.settings.read().await.influxdb.clone(); + let mut moved = 0_u64; + // Bound one maintenance pass so a very large legacy database never monopolizes the runtime. + // Successful batches are deleted from SQLite, so the next pass naturally continues forward. + for _ in 0..50 { + let (devices, zones, ha) = state.db.history_before(cutoff, 1_000)?; + if devices.is_empty() && zones.is_empty() && ha.is_empty() { break; } + influxdb::write_batch(&state.http, &settings, &devices, &zones, &ha).await?; + let deleted = state.db.delete_history_batch(&devices, &zones, &ha)?; + moved += deleted; + if deleted == 0 { break; } + } + Ok(moved) +} + diff --git a/src/engine/schedules.rs b/src/engine/schedules.rs new file mode 100644 index 0000000..880989b --- /dev/null +++ b/src/engine/schedules.rs @@ -0,0 +1,98 @@ +fn active_schedule_for_zone<'a>(zone: &Zone, schedules: &'a [Schedule], now: DateTime) -> Option<&'a Schedule> { + schedules.iter() + .filter(|item| item.enabled && item.zone_id == zone.id && schedule_active(item, now)) + // Overlaps are rejected by the API, but imported/legacy data may still contain one. + // Prefer the most recently edited entry instead of depending on database/name order. + .max_by_key(|item| item.updated_at) +} + +fn minute_floor(now: DateTime) -> DateTime { + now.with_second(0).and_then(|value| value.with_nanosecond(0)).unwrap_or(now) +} + +pub fn next_schedule_boundary_utc(zone_id: &str, schedules: &[Schedule], now: DateTime) -> Option> { + let current = schedules.iter() + .filter(|item| item.enabled && item.zone_id == zone_id && schedule_active(item, now)) + .max_by_key(|item| item.updated_at) + .map(|item| item.id.as_str()); + let base = minute_floor(now); + // Eight days cover a complete weekly schedule plus the next transition. + for minute in 1..=(8 * 24 * 60) { + let candidate = base + chrono::Duration::minutes(minute); + let next = schedules.iter() + .filter(|item| item.enabled && item.zone_id == zone_id && schedule_active(item, candidate)) + .max_by_key(|item| item.updated_at) + .map(|item| item.id.as_str()); + if next != current { + return Some(candidate.with_timezone(&Utc)); + } + } + // No schedule transition exists: keep a manual override until the user clears it. + None +} + +fn schedule_active(item: &Schedule, now: DateTime) -> bool { + let Ok(start) = NaiveTime::parse_from_str(&item.start_time, "%H:%M") else { return false; }; + let Ok(end) = NaiveTime::parse_from_str(&item.end_time, "%H:%M") else { return false; }; + let time = now.time(); + let today = now.weekday().number_from_monday(); + if start == end { + // Equal times mean a 24-hour block starting on each selected weekday. + if time >= start { + item.weekdays.contains(&today) + } else { + let previous = previous_weekday(now.weekday()).number_from_monday(); + item.weekdays.contains(&previous) + } + } else if start < end { + item.weekdays.contains(&today) && time >= start && time < end + } else if time >= start { + item.weekdays.contains(&today) + } else if time < end { + let previous = previous_weekday(now.weekday()).number_from_monday(); + item.weekdays.contains(&previous) + } else { + false + } +} + +fn schedule_week_mask(item: &Schedule) -> Option> { + let start = NaiveTime::parse_from_str(&item.start_time, "%H:%M").ok()?; + let end = NaiveTime::parse_from_str(&item.end_time, "%H:%M").ok()?; + let start_minute = (start.hour() * 60 + start.minute()) as usize; + let end_minute = (end.hour() * 60 + end.minute()) as usize; + let mut mask = vec![false; 7 * 24 * 60]; + for weekday in &item.weekdays { + if !(1..=7).contains(weekday) { return None; } + let day = (*weekday as usize) - 1; + let mark = |mask: &mut [bool], day: usize, from: usize, to: usize| { + let base = (day % 7) * 24 * 60; + for minute in from..to { mask[base + minute] = true; } + }; + if start_minute == end_minute { + mark(&mut mask, day, start_minute, 24 * 60); + mark(&mut mask, day + 1, 0, end_minute); + } else if start_minute < end_minute { + mark(&mut mask, day, start_minute, end_minute); + } else { + mark(&mut mask, day, start_minute, 24 * 60); + mark(&mut mask, day + 1, 0, end_minute); + } + } + Some(mask) +} + +pub(crate) fn schedules_overlap(a: &Schedule, b: &Schedule) -> bool { + if !a.enabled || !b.enabled || a.zone_id != b.zone_id { return false; } + let (Some(left), Some(right)) = (schedule_week_mask(a), schedule_week_mask(b)) else { return false; }; + left.iter().zip(right.iter()).any(|(a, b)| *a && *b) +} + +fn previous_weekday(day: Weekday) -> Weekday { + match day { + Weekday::Mon => Weekday::Sun, Weekday::Tue => Weekday::Mon, Weekday::Wed => Weekday::Tue, + Weekday::Thu => Weekday::Wed, Weekday::Fri => Weekday::Thu, Weekday::Sat => Weekday::Fri, + Weekday::Sun => Weekday::Sat, + } +} + diff --git a/src/engine/targets.rs b/src/engine/targets.rs new file mode 100644 index 0000000..f2b71f7 --- /dev/null +++ b/src/engine/targets.rs @@ -0,0 +1,59 @@ +fn profile_setpoint(zone: &Zone, preset: &str, mode: &str) -> f64 { + if zone.profile_version == 0 && preset == "comfort" { return zone.setpoint; } + match (mode, preset) { + ("heat", "sleep") => zone.heat_sleep_setpoint, + ("heat", "away") => zone.heat_away_setpoint, + ("heat", _) => zone.heat_comfort_setpoint, + (_, "sleep") => zone.cool_sleep_setpoint, + (_, "away") => zone.cool_away_setpoint, + (_, _) => zone.cool_comfort_setpoint, + } +} + +fn resolve_zone_target(zone: &Zone, schedule: Option<&Schedule>, mode: &str) -> (String, f64) { + let (preset, base_target) = if let Some(manual) = zone.manual_preset.as_deref() { + if manual == "custom" { + ("custom".into(), zone.setpoint) + } else { + (manual.to_string(), profile_setpoint(zone, manual, mode)) + } + } else if let Some(item) = schedule { + if item.preset == "custom" { + ("custom".into(), item.setpoint) + } else { + (item.preset.clone(), profile_setpoint(zone, &item.preset, mode)) + } + } else { + ("comfort".into(), profile_setpoint(zone, "comfort", mode)) + }; + // Quick +/- temperature adjustments are independent from the selected preset. + // The UI can therefore stay in Auto/Sleep/Comfort while temporarily nudging the target. + (preset, zone.manual_setpoint.unwrap_or(base_target)) +} + +pub fn refresh_zone_runtime_target(zone: &mut Zone, schedules: &[Schedule], house_mode: &str) { + let configured_mode = effective_zone_mode(zone, house_mode); + zone.effective_mode = configured_mode.clone(); + let target_mode = if configured_mode == "off" { zone.mode.as_str() } else { configured_mode.as_str() }; + let schedule = active_schedule_for_zone(zone, schedules, Local::now()); + let (preset, target) = resolve_zone_target(zone, schedule, target_mode); + zone.active_preset = preset; + if !zone.device_manual_override { + zone.effective_setpoint = Some(target); + } +} + +fn effective_zone_mode(zone: &Zone, house_mode: &str) -> String { + if temporary_quick_thermostat_is_active(zone, Utc::now()) { + if let Some(mode) = zone.temporary_quick_thermostat.as_ref().and_then(|session| session.active_mode.as_deref()) { + if matches!(mode, "cool" | "heat") { return mode.to_string(); } + } + } + let configured = if zone.inherit_house_mode { house_mode } else { zone.mode.as_str() }; + if zone.local_thermostat_power == Some(true) && configured == "off" { + zone.mode.clone() + } else { + configured.to_string() + } +} + diff --git a/src/engine/temperature.rs b/src/engine/temperature.rs new file mode 100644 index 0000000..400617a --- /dev/null +++ b/src/engine/temperature.rs @@ -0,0 +1,140 @@ +fn select_zone_temperature(zone: &Zone, device_temperature: Option, external_temperature: Option) -> (Option, String, bool) { + match zone.sensor_source.as_str() { + "home_assistant" => match (external_temperature, device_temperature) { + (Some(value), _) => (Some(value), "external".into(), false), + (None, Some(value)) => (Some(value), "device_fallback".into(), false), + (None, None) => (None, "unavailable".into(), false), + }, + "combined" => match (device_temperature, external_temperature) { + (Some(device), Some(external)) => { + if (device - external).abs() > zone.max_sensor_difference.max(0.1) { + (Some(device), "device_discrepancy_fallback".into(), true) + } else { + let external_weight = zone.external_sensor_weight.clamp(0.0, 1.0); + let value = device * (1.0 - external_weight) + external * external_weight; + (Some((value * 10.0).round() / 10.0), "combined".into(), false) + } + } + (Some(value), None) => (Some(value), "device_fallback".into(), false), + (None, Some(value)) => (Some(value), "external".into(), false), + (None, None) => (None, "unavailable".into(), false), + }, + _ => match device_temperature { + Some(value) => (Some(value), "device".into(), false), + None => (None, "unavailable".into(), false), + }, + } +} + +fn adjustment_allowed(zone: &Zone) -> bool { + let Some(last) = zone.last_action_at else { return true; }; + (Utc::now() - last).num_seconds().max(0) as u64 >= zone.min_adjust_seconds.max(15) +} + +fn external_room_sensor_cooling_assist(mode: &str, control_source: &str) -> f64 { + if mode == "cool" && matches!(control_source, "external" | "combined") { 0.5 } else { 0.0 } +} + +fn effective_sensor_stale_after_seconds(zone_value: u64, global_value: u64) -> u64 { + let global = global_value.clamp(30, 86_400); + // 0 and the historical hidden default (300 s) mean "inherit the HA setting". + // A non-default value supplied through the existing zone API remains a per-zone override. + if zone_value == 0 || zone_value == 300 { global } else { zone_value.clamp(30, 86_400) } +} + +fn round_device_setpoint(mode: &str, demand: bool, value: f64) -> f64 { + let value = value.clamp(16.0, 30.0); + match (mode, demand) { + ("heat", true) => value.ceil(), + ("heat", false) => value.floor(), + (_, true) => value.floor(), + (_, false) => value.ceil(), + } +} + +fn outdoor_assist_offset(mode: &str, outdoor: Option, room: f64, target: f64) -> f64 { + let Some(outdoor) = outdoor else { return 0.0; }; + let room_error = (room - target).abs(); + let weather = match mode { + "heat" => ((5.0 - outdoor) / 15.0).clamp(0.0, 1.0), + _ => ((outdoor - 30.0) / 10.0).clamp(0.0, 1.0), + }; + (weather * room_error.clamp(0.0, 2.0) * 0.5).clamp(0.0, 1.0) +} + +fn smart_quiet_command( + smart_fan: bool, + quiet_supported: bool, + previous_demand: bool, + demand: bool, + device_quiet: bool, + night_enabled: bool, + night_active: bool, + night_force_quiet: bool, +) -> Option { + if !quiet_supported { return None; } + if night_enabled && night_force_quiet && night_active { + return if device_quiet { None } else { Some(true) }; + } + if night_enabled && night_force_quiet && !night_active && device_quiet { + // Explicitly release Quiet when the scheduled night window ends. + return Some(false); + } + if smart_fan { + // Smart Quiet follows demand transitions. Do not keep reasserting Quiet while a + // satisfied room remains in standby: some units report Quiet=false again even after + // accepting the command, which otherwise produces a beep every adjustment interval. + if previous_demand && !demand && !device_quiet { return Some(true); } + if !previous_demand && demand && device_quiet { return Some(false); } + return None; + } + // Without Smart Fan, Quiet can only have been requested by scheduled night mode, + // so release it after the night window ends. + if night_enabled && night_force_quiet && device_quiet { return Some(false); } + None +} + +fn native_sleep_command( + night_enabled: bool, + night_active: bool, + use_native_sleep: bool, + sleep_supported: bool, + device_sleep: bool, +) -> Option { + if !sleep_supported { return None; } + if night_enabled && use_native_sleep && night_active { + return if device_sleep { None } else { Some(true) }; + } + // If night mode ended or native Sleep was disabled in settings, remove a previously + // active device Sleep flag instead of leaving it latched indefinitely. + if device_sleep { return Some(false); } + None +} + +fn night_limited_fan_speed(requested: u8, max_fan: u8) -> u8 { + let max_fan = max_fan.clamp(1, 5); + if requested == 0 { 1 } else { requested.min(max_fan) } +} + +pub fn night_mode_active(settings: &NightModeSettings, time: NaiveTime) -> bool { + if !settings.enabled { return false; } + let Ok(start) = NaiveTime::parse_from_str(&settings.start_time, "%H:%M") else { return false; }; + let Ok(end) = NaiveTime::parse_from_str(&settings.end_time, "%H:%M") else { return false; }; + if start == end { return true; } + if start < end { time >= start && time < end } else { time >= start || time < end } +} + +fn smart_fan_speed(mode: &str, room: f64, target: f64, outdoor: Option, demand: bool) -> u8 { + // When the thermostat is satisfied, keep airflow quiet instead of leaving the + // unit in Auto. The caller sends this together with the standby setpoint in + // the same GREE command, so e.g. 21 C reached -> 19 C + Low fan for heating. + if !demand { return 1; } + let error = (room - target).abs(); + let extreme_weather = match (mode, outdoor) { + ("heat", Some(value)) => value <= 0.0, + (_, Some(value)) => value >= 32.0, + _ => false, + }; + if error >= 2.0 || extreme_weather { 3 } else if error >= 1.0 { 2 } else { 0 } +} + diff --git a/src/engine/temporary_thermostat.rs b/src/engine/temporary_thermostat.rs new file mode 100644 index 0000000..6c4cfe7 --- /dev/null +++ b/src/engine/temporary_thermostat.rs @@ -0,0 +1,365 @@ +pub fn temporary_quick_thermostat_is_active(zone: &Zone, now: DateTime) -> bool { + zone.temporary_quick_thermostat.as_ref() + .and_then(|session| session.activated_at.as_ref()) + .map(|activated_at| activated_at <= &now) + .unwrap_or(false) +} + +fn temporary_quick_thermostat_hard_deadline(session: &TemporaryQuickThermostat) -> Option> { + if session.state == "paused_manual" { return None; } + match (session.expires_at, session.safety_expires_at) { + (Some(a), Some(b)) => Some(a.min(b)), + (Some(a), None) => Some(a), + (None, Some(b)) => Some(b), + (None, None) => None, + } +} + +fn temporary_quick_thermostat_next_deadline(session: &TemporaryQuickThermostat) -> Option> { + let hard = temporary_quick_thermostat_hard_deadline(session); + let hold = if session.finish_kind == "temperature_stable" && session.hold_seconds > 0 { + session.condition_started_at.map(|started| started + chrono::Duration::seconds(session.hold_seconds as i64)) + } else { + None + }; + match (hard, hold) { + (Some(a), Some(b)) => Some(a.min(b)), + (Some(a), None) => Some(a), + (None, Some(b)) => Some(b), + (None, None) => None, + } +} + +fn temporary_quick_thermostat_wakeup_at(zone: &Zone, now: DateTime) -> Option> { + let session = zone.temporary_quick_thermostat.as_ref()?; + if temporary_quick_thermostat_is_active(zone, now) { + return temporary_quick_thermostat_next_deadline(session); + } + // Once a due session is waiting for master/manual ownership, normal wakeups or an + // explicit state-change notification will retry it. Returning a past start would spin. + (session.started_at > now).then_some(session.started_at) +} + +/// Finish an active temporary session and apply climate changes that were deferred while +/// it owned the zone. Pending-session cancellation should simply remove the session instead. +pub fn finish_temporary_quick_thermostat(zone: &mut Zone, schedules: &[Schedule], house_mode: &str) -> bool { + let now = Utc::now(); + let was_active = temporary_quick_thermostat_is_active(zone, now); + let Some(session) = zone.temporary_quick_thermostat.take() else { return false; }; + if !was_active { return false; } + + zone.local_thermostat_power = session.restore_local_thermostat_power; + zone.local_thermostat_resume_at = session.restore_local_thermostat_resume_at; + zone.local_thermostat_restore_zone_enabled = session.restore_local_thermostat_zone_enabled; + zone.manual_preset = session.restore_manual_preset; + zone.manual_setpoint = session.restore_manual_setpoint; + zone.manual_override_until = session.restore_manual_override_until; + if let Some(enabled) = session.restore_zone_enabled { + zone.enabled = enabled; + } + + if let Some(mode) = session.deferred_mode.as_deref() { + match mode { + "house" | "auto" => zone.inherit_house_mode = true, + "cool" | "heat" => { + zone.inherit_house_mode = false; + zone.mode = mode.to_string(); + } + _ => {} + } + } + if let Some(preset) = session.deferred_preset.as_deref() { + if preset == "auto" { + zone.manual_preset = None; + zone.manual_setpoint = None; + zone.manual_override_until = None; + } else if matches!(preset, "comfort" | "sleep" | "away") { + zone.manual_preset = Some(preset.to_string()); + zone.manual_override_until = next_schedule_boundary_utc(&zone.id, schedules, Local::now()); + } + } + refresh_zone_runtime_target(zone, schedules, house_mode); + true +} + +async fn expire_temporary_quick_thermostats(state: &AppState, zones: &mut [Zone], schedules: &[Schedule], house_mode: &str) -> Result, AppError> { + let now = Utc::now(); + let mut restored_disabled_zones = Vec::new(); + for zone in zones.iter_mut() { + let zone_id = zone.id.clone(); + let _zone_guard = state.lock_zone_operation(&zone_id).await; + let Some(latest) = state.db.get_zone(&zone_id)? else { continue; }; + *zone = latest; + let active_under_manual = zone.device_manual_override + && zone.temporary_quick_thermostat.as_ref().and_then(|session| session.activated_at).is_some(); + if active_under_manual { + set_temporary_wait_state(state, zone, "paused_manual", now)?; + continue; + } + let Some((deadline, finish_kind, restore_zone_enabled)) = zone.temporary_quick_thermostat.as_ref() + .and_then(|session| temporary_quick_thermostat_hard_deadline(session) + .map(|deadline| (deadline, session.finish_kind.clone(), session.restore_zone_enabled))) + else { continue; }; + if deadline > now { continue; } + let was_activated = temporary_quick_thermostat_is_active(zone, now); + let restores_disabled = was_activated && restore_zone_enabled == Some(false); + if was_activated { + finish_temporary_quick_thermostat(zone, schedules, house_mode); + } else { + // A delayed session that expires before it acquires ownership must not clear + // unrelated local/manual/schedule state that was active while it was waiting. + zone.temporary_quick_thermostat = None; + } + zone.updated_at = now; + state.db.save_zone(zone)?; + state.broadcast("zone.updated", serde_json::to_value(&*zone)?); + state.log("info", "zone.temporary_quick_thermostat_finished", &format!("Temporary Quick Thermostat finished for {}", zone.name), json!({ + "zone_id": zone.id, "device_id": zone.device_id, "finish_kind": finish_kind, + "reason": if was_activated { "deadline" } else { "expired_before_activation" } + })); + if restores_disabled { restored_disabled_zones.push(zone.id.clone()); } + } + Ok(restored_disabled_zones) +} + +fn set_temporary_wait_state(state: &AppState, zone: &mut Zone, value: &str, now: DateTime) -> Result<(), AppError> { + let Some(session) = zone.temporary_quick_thermostat.as_mut() else { return Ok(()); }; + let mut changed = false; + if session.state != value { + session.state = value.to_string(); + changed = true; + } + if value == "paused_manual" && session.paused_at.is_none() { + session.paused_at = Some(now); + changed = true; + } + if !changed { return Ok(()); } + session.condition_started_at = None; + session.condition_last_observed_at = None; + zone.updated_at = now; + state.db.save_zone(zone)?; + state.broadcast("zone.updated", serde_json::to_value(&*zone)?); + Ok(()) +} + +async fn activate_due_temporary_quick_thermostats( + state: &AppState, + zones: &mut [Zone], + schedules: &[Schedule], + house_mode: &str, + house_power_enabled: bool, +) -> Result<(), AppError> { + let now = Utc::now(); + for zone in zones.iter_mut() { + let zone_id = zone.id.clone(); + let _zone_guard = state.lock_zone_operation(&zone_id).await; + let Some(latest) = state.db.get_zone(&zone_id)? else { continue; }; + *zone = latest; + let Some((session_state, started_at)) = zone.temporary_quick_thermostat.as_ref() + .map(|session| (session.state.clone(), session.started_at)) + else { continue; }; + if temporary_quick_thermostat_is_active(zone, now) { + if session_state != "active" && !zone.device_manual_override { + set_temporary_wait_state(state, zone, "active", now)?; + } + continue; + } + if started_at > now { continue; } + if !house_power_enabled { + set_temporary_wait_state(state, zone, "waiting_master", now)?; + continue; + } + if zone.device_manual_override { + set_temporary_wait_state(state, zone, "paused_manual", now)?; + continue; + } + + let (temperature_target, duration_seconds, safety_duration_seconds, finish_kind) = { + let session = zone.temporary_quick_thermostat.as_ref().expect("temporary session checked above"); + (session.temperature_target, session.duration_seconds, session.safety_duration_seconds, session.finish_kind.clone()) + }; + let target = temperature_target + .or(zone.manual_setpoint) + .or(zone.effective_setpoint) + .unwrap_or(zone.setpoint); + let restore_enabled = zone.enabled; + let restore_local_power = zone.local_thermostat_power; + let restore_local_resume_at = zone.local_thermostat_resume_at; + let restore_local_zone_enabled = zone.local_thermostat_restore_zone_enabled; + let restore_manual_preset = zone.manual_preset.clone(); + let restore_manual_setpoint = zone.manual_setpoint; + let restore_manual_override_until = zone.manual_override_until; + let configured_mode = if zone.inherit_house_mode { house_mode } else { zone.mode.as_str() }; + let active_mode = if configured_mode == "off" { zone.mode.clone() } else { configured_mode.to_string() }; + let schedule_boundary = if finish_kind == "schedule_boundary" { + next_schedule_boundary_utc(&zone.id, schedules, Local::now()) + } else { None }; + if finish_kind == "schedule_boundary" && schedule_boundary.is_none() { + zone.temporary_quick_thermostat = None; + zone.updated_at = now; + state.db.save_zone(zone)?; + state.broadcast("zone.updated", serde_json::to_value(&*zone)?); + state.log("warn", "zone.temporary_quick_thermostat_cancelled", &format!("Temporary Quick Thermostat cancelled for {} because no future schedule boundary exists", zone.name), json!({ + "zone_id": zone.id, "device_id": zone.device_id + })); + continue; + } + + // Temporary ownership is independent from the ordinary local hand-back state. + zone.local_thermostat_power = Some(true); + zone.local_thermostat_resume_at = None; + zone.local_thermostat_restore_zone_enabled = None; + zone.enabled = true; + zone.manual_setpoint = Some(target); + zone.effective_setpoint = Some(target); + zone.manual_override_until = None; + if let Some(session) = zone.temporary_quick_thermostat.as_mut() { + session.activated_at = Some(now); + session.state = "active".into(); + session.active_mode = Some(active_mode); + session.restore_zone_enabled = Some(restore_enabled); + session.restore_local_thermostat_power = restore_local_power; + session.restore_local_thermostat_resume_at = restore_local_resume_at; + session.restore_local_thermostat_zone_enabled = restore_local_zone_enabled; + session.restore_manual_preset = restore_manual_preset; + session.restore_manual_setpoint = restore_manual_setpoint; + session.restore_manual_override_until = restore_manual_override_until; + session.condition_started_at = None; + session.condition_last_observed_at = None; + session.paused_at = None; + if session.finish_kind == "duration" { + session.expires_at = duration_seconds.map(|seconds| now + chrono::Duration::seconds(seconds as i64)); + } else if session.finish_kind == "schedule_boundary" { + session.expires_at = schedule_boundary; + } + if matches!(session.finish_kind.as_str(), "temperature_reached" | "temperature_stable") { + session.safety_expires_at = safety_duration_seconds.map(|seconds| now + chrono::Duration::seconds(seconds as i64)); + } + } + zone.updated_at = now; + state.db.save_zone(zone)?; + state.broadcast("zone.updated", serde_json::to_value(&*zone)?); + state.log("info", "zone.temporary_quick_thermostat_started", &format!("Temporary Quick Thermostat started for {}", zone.name), json!({ + "zone_id": zone.id, "device_id": zone.device_id, "scheduled_start": true, "target_temperature": target + })); + } + Ok(()) +} + +async fn ensure_device_off_after_temporary_disabled_restore(state: &AppState, zone: &Zone, device: &Device) { + if zone.enabled || zone.device_manual_override || !device.enabled || !device.online || device.communication_failures > 0 || !device.power { return; } + let _device_guard = state.lock_device_operation(&zone.device_id).await; + let should_stop = state.db.get_zone(&zone.id).ok().flatten() + .map(|latest| !latest.enabled + && !latest.device_manual_override + && latest.temporary_quick_thermostat.is_none() + && latest.local_thermostat_power.is_none()) + .unwrap_or(false); + if !should_stop { return; } + if let Err(err) = send_command_locked(state, &zone.device_id, DeviceCommand { power: Some(false), ..Default::default() }).await { + state.log("error", "zone.temporary_quick_thermostat_poweroff_error", &err.to_string(), json!({ + "zone_id": zone.id, "device_id": zone.device_id + })); + } +} + +fn temporary_temperature_condition_met(zone: &Zone, session: &TemporaryQuickThermostat) -> bool { + let (Some(current), Some(target)) = (zone.current_temperature, session.temperature_target) else { return false; }; + let tolerance = session.tolerance_c.max(0.0); + match session.temperature_operator.as_deref().unwrap_or("within") { + "at_or_below" => current <= target + tolerance, + "at_or_above" => current >= target - tolerance, + _ => (current - target).abs() <= tolerance, + } +} + +/// Update a temperature-based temporary session only from a fresh sensor observation. +/// Cached samples and long controller gaps cannot count as continuous hold time. +fn evaluate_temporary_quick_thermostat_condition( + zone: &mut Zone, + now: DateTime, + sample_at: Option>, + max_gap_seconds: u64, +) -> Option { + if !temporary_quick_thermostat_is_active(zone, now) || zone.device_manual_override { return None; } + let is_condition = zone.temporary_quick_thermostat.as_ref() + .map(|session| matches!(session.finish_kind.as_str(), "temperature_reached" | "temperature_stable")) + .unwrap_or(false); + if !is_condition { return None; } + + let Some(sample_at) = sample_at else { + if let Some(session) = zone.temporary_quick_thermostat.as_mut() { + session.condition_started_at = None; + session.condition_last_observed_at = None; + } + return None; + }; + let last_observed = zone.temporary_quick_thermostat.as_ref().and_then(|session| session.condition_last_observed_at); + if last_observed.map(|last| sample_at <= last).unwrap_or(false) { return None; } + let gap_broken = last_observed + .map(|last| sample_at.signed_duration_since(last).num_seconds() > max_gap_seconds.max(1) as i64) + .unwrap_or(false); + let met = zone.temporary_quick_thermostat.as_ref() + .map(|session| temporary_temperature_condition_met(zone, session))?; + + let session = zone.temporary_quick_thermostat.as_mut()?; + session.condition_last_observed_at = Some(sample_at); + if gap_broken { session.condition_started_at = None; } + match session.finish_kind.as_str() { + "temperature_reached" => { + if met { return Some("temperature_reached".into()); } + session.condition_started_at = None; + } + "temperature_stable" => { + if !met { + session.condition_started_at = None; + return None; + } + let started = session.condition_started_at.get_or_insert(sample_at); + if session.hold_seconds == 0 || sample_at.signed_duration_since(*started).num_seconds() >= session.hold_seconds as i64 { + return Some("temperature_stable".into()); + } + } + _ => {} + } + None +} + +async fn expire_local_thermostat_overrides(state: &AppState, zones: &mut [Zone], schedules: &[Schedule], house_mode: &str) -> Result<(), AppError> { + let now = Utc::now(); + for zone in zones.iter_mut() { + let zone_id = zone.id.clone(); + let _zone_guard = state.lock_zone_operation(&zone_id).await; + let Some(latest) = state.db.get_zone(&zone_id)? else { continue; }; + *zone = latest; + // A direct device/pilot takeover has higher priority than the local-OFF hand-back. + // Do not let the old timer expire underneath someone who is actively controlling + // the unit. When that takeover ends and the device returns to OFF, the deadline is + // re-armed from that moment. + if !local_thermostat_handback_is_active(zone) { continue; } + if zone.local_thermostat_resume_at.is_none() { + // Upgrade safety for a persisted 0.7.10/0.7.11 local-OFF state: old releases + // had no hand-back deadline, so start one from the first cycle after upgrade. + set_local_thermostat_power(zone, false, now.clone()); + zone.updated_at = now.clone(); + state.db.save_zone(zone)?; + state.broadcast("zone.updated", serde_json::to_value(&*zone)?); + state.log("info", "zone.local_thermostat_resume_scheduled", &format!("Local thermostat hand-back scheduled for {}", zone.name), json!({ + "zone_id": zone.id, "device_id": zone.device_id, "delay_minutes": LOCAL_THERMOSTAT_RESUME_DELAY_MINUTES + })); + continue; + } + let expired = zone.local_thermostat_resume_at.as_ref().map(|at| at <= &now).unwrap_or(false); + if !expired { continue; } + reset_local_thermostat_override(zone); + refresh_zone_runtime_target(zone, schedules, house_mode); + zone.updated_at = now.clone(); + state.db.save_zone(zone)?; + state.broadcast("zone.updated", serde_json::to_value(&*zone)?); + state.log("info", "zone.local_thermostat_resumed", &format!("Local thermostat hand-back completed for {}", zone.name), json!({ + "zone_id": zone.id, "device_id": zone.device_id, "delay_minutes": LOCAL_THERMOSTAT_RESUME_DELAY_MINUTES + })); + } + Ok(()) +} + diff --git a/src/engine/tests.rs b/src/engine/tests.rs new file mode 100644 index 0000000..aea0a55 --- /dev/null +++ b/src/engine/tests.rs @@ -0,0 +1,654 @@ +#[cfg(test)] +mod tests { + use super::*; + use chrono::TimeZone; + + #[test] + fn global_ha_sensor_stale_timeout_is_used_for_default_zone_value() { + assert_eq!(effective_sensor_stale_after_seconds(300, 600), 600); + assert_eq!(effective_sensor_stale_after_seconds(0, 900), 900); + assert_eq!(effective_sensor_stale_after_seconds(120, 600), 120); + assert_eq!(effective_sensor_stale_after_seconds(120_000, 600), 86_400); + } + + #[test] + fn overnight_schedule_works() { + 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(), + }; + 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(), + } + } + + #[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(); + assert!(schedule_active(&item, monday_noon)); + assert!(schedule_active(&item, tuesday_early)); + assert!(!schedule_active(&item, tuesday_after)); + } + + #[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 conflict = test_schedule("conflict", vec![1], "22:00", "23:00"); + assert!(!schedules_overlap(&daytime, &night)); + assert!(schedules_overlap(&night, &conflict)); + } + + #[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); + assert_eq!(boundary.weekday(), Weekday::Fri); + assert_eq!(boundary.hour(), 12); + assert_eq!(boundary.minute(), 0); + assert_eq!(boundary.second(), 0); + } + + #[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(); + 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("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); + } + } + } + + #[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 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, 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)); + 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, 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()), + 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, + 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(), + } + } + + #[test] + fn external_device_change_detects_manual_climate_controls() { + let zone = test_zone("device"); + let before = Device::simulated_default(); + let mut after = before.clone(); + after.power = !before.power; + after.target_temperature = before.target_temperature + 1.0; + after.fan_speed = 3; + let fields = externally_changed_control_fields(&before, &after, &zone); + assert!(fields.iter().any(|field| field == "power")); + assert!(fields.iter().any(|field| field == "target_temperature")); + assert!(fields.iter().any(|field| field == "fan_speed")); + } + + #[test] + fn controller_expected_climate_change_is_recognized() { + let mut device = Device::simulated_default(); + device.power = true; + device.mode = "cool".into(); + device.target_temperature = 22.0; + let command = DeviceCommand { + power: Some(true), + mode: Some("cool".into()), + target_temperature: Some(22.0), + ..Default::default() + }; + 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)); + device.target_temperature = 25.0; + assert!(!command_field_matches_device(&command, "target_temperature", &device)); + } + + #[test] + fn local_thermostat_ownership_blocks_direct_automation() { + let mut zone = test_zone("device"); + assert!(!device_blocked_by_local_thermostat( + &zone.device_id, + std::slice::from_ref(&zone), + )); + zone.local_thermostat_power = Some(true); + assert!(device_blocked_by_local_thermostat( + &zone.device_id, + std::slice::from_ref(&zone), + )); + zone.local_thermostat_power = Some(false); + assert!(device_blocked_by_local_thermostat( + &zone.device_id, + std::slice::from_ref(&zone), + )); + } + + #[test] + fn local_thermostat_resume_clears_only_local_quick_control_state() { + let mut zone = test_zone("device"); + zone.local_thermostat_power = Some(false); + zone.local_thermostat_resume_at = Some(Utc::now() + chrono::Duration::minutes(15)); + zone.manual_preset = Some("comfort".into()); + zone.manual_setpoint = Some(23.0); + zone.manual_override_until = Some(Utc::now() + chrono::Duration::hours(1)); + zone.device_manual_override = true; + + assert!(reset_local_thermostat_override(&mut zone)); + assert!(zone.local_thermostat_power.is_none()); + assert!(zone.local_thermostat_resume_at.is_none()); + assert!(zone.manual_preset.is_none()); + assert!(zone.manual_setpoint.is_none()); + assert!(zone.manual_override_until.is_none()); + assert!(zone.device_manual_override); + assert_eq!(LOCAL_THERMOSTAT_RESUME_DELAY_MINUTES, 15); + } + + fn temporary_session(now: DateTime) -> TemporaryQuickThermostat { + TemporaryQuickThermostat { + start_kind: "now".into(), + finish_kind: "temperature_stable".into(), + started_at: now.clone(), + activated_at: Some(now), + state: "active".into(), + active_mode: Some("heat".into()), + restore_zone_enabled: Some(true), + restore_local_thermostat_power: None, + restore_local_thermostat_resume_at: None, + restore_local_thermostat_zone_enabled: None, + restore_manual_preset: None, + restore_manual_setpoint: None, + restore_manual_override_until: None, + expires_at: None, + duration_seconds: None, + safety_duration_seconds: None, + temperature_target: Some(23.0), + temperature_operator: Some("within".into()), + tolerance_c: 0.3, + hold_seconds: 3600, + condition_started_at: None, + condition_last_observed_at: None, + paused_at: None, + deferred_mode: None, + deferred_preset: None, + safety_expires_at: None, + } + } + + #[test] + fn scheduled_temporary_session_is_not_activated_by_unrelated_local_quick_on() { + let now = Utc::now(); + let mut zone = test_zone("device"); + zone.local_thermostat_power = Some(true); + let mut session = temporary_session(now + chrono::Duration::hours(1)); + session.start_kind = "delay".into(); + session.activated_at = None; + session.state = "scheduled".into(); + 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))); + } + + #[test] + fn local_handback_cleanup_does_not_remove_pending_temporary_session() { + let now = Utc::now(); + let mut zone = test_zone("device"); + set_local_thermostat_power(&mut zone, false, now); + let mut session = temporary_session(now + chrono::Duration::minutes(30)); + session.start_kind = "delay".into(); + session.activated_at = None; + session.state = "scheduled".into(); + zone.temporary_quick_thermostat = Some(session); + + reset_local_thermostat_override(&mut zone); + assert!(zone.temporary_quick_thermostat.is_some()); + assert!(zone.local_thermostat_power.is_none()); + } + + #[test] + fn temporary_quick_thermostat_restores_previous_zone_enabled_state() { + let now = Utc::now(); + let mut zone = test_zone("device"); + zone.enabled = true; + zone.local_thermostat_power = Some(true); + let mut session = temporary_session(now); + session.restore_zone_enabled = Some(false); + zone.temporary_quick_thermostat = Some(session); + zone.manual_setpoint = Some(23.0); + + assert!(finish_temporary_quick_thermostat(&mut zone, &[], "heat")); + assert!(!zone.enabled); + assert!(zone.local_thermostat_power.is_none()); + assert!(zone.temporary_quick_thermostat.is_none()); + assert!(zone.manual_setpoint.is_none()); + } + + #[test] + fn temporary_quick_thermostat_returns_to_underlying_local_quick_state() { + let now = Utc::now(); + let mut zone = test_zone("device"); + zone.enabled = true; + zone.local_thermostat_power = Some(true); + zone.local_thermostat_restore_zone_enabled = None; + zone.manual_setpoint = Some(24.0); + let mut session = temporary_session(now); + session.restore_zone_enabled = Some(true); + session.restore_local_thermostat_power = Some(true); + session.restore_local_thermostat_zone_enabled = Some(false); + session.restore_manual_setpoint = Some(22.0); + zone.temporary_quick_thermostat = Some(session); + zone.manual_setpoint = Some(23.0); + + assert!(finish_temporary_quick_thermostat(&mut zone, &[], "heat")); + assert!(zone.enabled); + assert_eq!(zone.local_thermostat_power, Some(true)); + assert_eq!(zone.local_thermostat_restore_zone_enabled, Some(false)); + assert_eq!(zone.manual_setpoint, Some(22.0)); + } + + #[test] + fn temporary_session_without_activation_marker_is_never_active() { + let now = Utc::now(); + let mut zone = test_zone("device"); + zone.local_thermostat_power = Some(true); + let mut session = temporary_session(now); + session.activated_at = None; + session.state = "scheduled".into(); + zone.temporary_quick_thermostat = Some(session); + + assert!(!temporary_quick_thermostat_is_active(&zone, now)); + } + + #[test] + fn temporary_stable_condition_requires_continuous_hold_time() { + let now = Utc::now(); + let mut zone = test_zone("device"); + zone.current_temperature = Some(23.2); + let mut session = temporary_session(now.clone()); + 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())); + } + + #[test] + fn temporary_stable_condition_resets_when_temperature_leaves_range() { + let now = Utc::now(); + let mut zone = test_zone("device"); + zone.current_temperature = Some(24.0); + let mut session = temporary_session(now.clone()); + 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()); + } + + #[test] + fn temporary_stable_condition_resets_after_observation_gap() { + let now = Utc::now(); + let mut zone = test_zone("device"); + zone.current_temperature = Some(23.0); + let mut session = temporary_session(now); + session.condition_started_at = Some(now - chrono::Duration::hours(1)); + 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)); + } + + #[test] + fn delayed_temporary_session_does_not_block_automation_before_start() { + let now = Utc::now(); + let mut zone = test_zone("device"); + let mut session = temporary_session(now.clone() + chrono::Duration::hours(1)); + session.start_kind = "delay".into(); + session.activated_at = None; + session.state = "scheduled".into(); + 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))); + } + + #[test] + fn delayed_temperature_condition_cannot_finish_before_activation() { + let now = Utc::now(); + let mut zone = test_zone("device"); + zone.current_temperature = Some(23.0); + let mut session = temporary_session(now.clone() + chrono::Duration::hours(1)); + session.start_kind = "at".into(); + 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()); + } + + #[test] + fn temporary_setpoint_keeps_priority_over_active_schedule() { + let mut zone = test_zone("device"); + 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"); + schedule.preset = "custom".into(); + schedule.setpoint = 19.0; + + let (_preset, target) = resolve_zone_target(&zone, Some(&schedule), "cool"); + assert_eq!(target, 23.0); + } + + #[test] + fn local_quick_thermostat_can_run_when_inherited_house_mode_is_off() { + let mut zone = test_zone("device"); + zone.inherit_house_mode = true; + zone.mode = "cool".into(); + assert_eq!(effective_zone_mode(&zone, "off"), "off"); + zone.local_thermostat_power = Some(true); + assert_eq!(effective_zone_mode(&zone, "off"), "cool"); + } + + #[test] + fn local_thermostat_off_restarts_backend_handback_deadline() { + let mut zone = test_zone("device"); + 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)); + + 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!(zone.local_thermostat_resume_at.clone().unwrap() > first_deadline); + } + + #[test] + fn manual_device_takeover_suspends_local_handback_until_control_returns() { + let mut zone = test_zone("device"); + let now = Utc::now(); + set_local_thermostat_power(&mut zone, false, now.clone()); + assert!(local_thermostat_handback_is_active(&zone)); + + zone.device_manual_override = true; + assert!(!local_thermostat_handback_is_active(&zone)); + + 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!(local_thermostat_handback_is_active(&zone)); + } + + #[test] + fn rounded_gree_setpoint_does_not_create_manual_override() { + let zone = test_zone("device"); + let mut before = Device::simulated_default(); + before.target_temperature = 23.5; + let mut after = before.clone(); + after.target_temperature = 24.0; + assert!(externally_changed_control_fields(&before, &after, &zone).is_empty()); + } + + #[test] + fn standby_low_to_auto_fan_drift_is_not_manual_override() { + let mut zone = test_zone("device"); + zone.smart_fan = true; + zone.demand = false; + let mut before = Device::simulated_default(); + before.fan_speed = 1; + let mut after = before.clone(); + after.fan_speed = 0; + assert!(externally_changed_control_fields(&before, &after, &zone).is_empty()); + } + + #[test] + fn reset_device_manual_override_clears_takeover_state() { + let mut zone = test_zone("device"); + let device = Device::simulated_default(); + zone.device_manual_override = true; + zone.device_manual_override_since = Some(Utc::now()); + zone.device_manual_override_until = Some(Utc::now()); + zone.device_manual_override_fields = vec!["target_temperature".into()]; + zone.device_manual_override_baseline = Some((&device).into()); + assert!(reset_device_manual_override(&mut zone)); + assert!(!zone.device_manual_override); + assert!(zone.device_manual_override_since.is_none()); + assert!(zone.device_manual_override_until.is_none()); + assert!(zone.device_manual_override_fields.is_empty()); + assert!(zone.device_manual_override_baseline.is_none()); + } + + #[test] + fn restored_manual_device_state_matches_original_takeover_baseline() { + let mut zone = test_zone("device"); + let baseline = Device::simulated_default(); + zone.device_manual_override = true; + zone.device_manual_override_fields = vec!["power".into(), "target_temperature".into()]; + zone.device_manual_override_baseline = Some((&baseline).into()); + + let mut changed = baseline.clone(); + changed.power = !baseline.power; + changed.target_temperature = baseline.target_temperature + 2.0; + assert!(!manual_override_matches_baseline(&zone, &changed)); + + let mut returned_off = baseline.clone(); + returned_off.target_temperature = baseline.target_temperature + 3.0; + assert!(manual_override_matches_baseline(&zone, &returned_off)); + assert!(manual_override_matches_baseline(&zone, &baseline)); + } + + #[test] + fn device_command_drops_unchanged_fields() { + let device = Device::simulated_default(); + let command = DeviceCommand { + power: Some(false), + mode: Some("cool".into()), + target_temperature: Some(23.4), + fan_speed: Some(3), + light: Some(false), + ..DeviceCommand::default() + }; + let changed = command.changed_from(&device); + assert_eq!(changed.power, None); + assert_eq!(changed.mode, None); + assert_eq!(changed.target_temperature, None); + assert_eq!(changed.fan_speed, Some(3)); + assert_eq!(changed.light, Some(false)); + } + + #[test] + fn combined_temperature_prefers_room_sensor_weight() { + let zone = test_zone("combined"); + let (value, source, discrepancy) = select_zone_temperature(&zone, Some(22.0), Some(20.0)); + assert_eq!(value, Some(21.2)); + assert_eq!(source, "combined"); + assert!(!discrepancy); + } + + #[test] + fn combined_temperature_falls_back_on_large_discrepancy() { + let zone = test_zone("combined"); + let (value, source, discrepancy) = select_zone_temperature(&zone, Some(21.0), Some(27.0)); + assert_eq!(value, Some(21.0)); + assert_eq!(source, "device_discrepancy_fallback"); + assert!(discrepancy); + } + + #[test] + fn combined_temperature_falls_back_when_external_is_missing() { + let zone = test_zone("combined"); + let (value, source, discrepancy) = select_zone_temperature(&zone, Some(21.5), None); + assert_eq!(value, Some(21.5)); + assert_eq!(source, "device_fallback"); + assert!(!discrepancy); + } + + #[test] + fn seasonal_profiles_resolve_independently() { + let zone = test_zone("device"); + assert_eq!(profile_setpoint(&zone, "comfort", "cool"), 23.0); + assert_eq!(profile_setpoint(&zone, "sleep", "cool"), 24.5); + assert_eq!(profile_setpoint(&zone, "comfort", "heat"), 21.0); + assert_eq!(profile_setpoint(&zone, "sleep", "heat"), 19.0); + } + + #[test] + fn quick_setpoint_keeps_active_preset() { + let mut zone = test_zone("device"); + zone.manual_setpoint = Some(22.5); + let (preset, target) = resolve_zone_target(&zone, None, "cool"); + assert_eq!(preset, "comfort"); + assert_eq!(target, 22.5); + } + + #[test] + fn runtime_target_refresh_applies_manual_profile_immediately() { + let mut zone = test_zone("device"); + zone.inherit_house_mode = false; + zone.mode = "cool".into(); + zone.manual_preset = Some("sleep".into()); + zone.active_preset = "comfort".into(); + zone.effective_setpoint = Some(25.0); + refresh_zone_runtime_target(&mut zone, &[], "cool"); + assert_eq!(zone.active_preset, "sleep"); + assert_eq!(zone.effective_setpoint, Some(24.5)); + assert_eq!(zone.effective_mode, "cool"); + } + + #[test] + fn legacy_zone_keeps_old_comfort_setpoint() { + let mut zone = test_zone("device"); + zone.profile_version = 0; + zone.setpoint = 22.5; + assert_eq!(profile_setpoint(&zone, "comfort", "cool"), 22.5); + assert_eq!(profile_setpoint(&zone, "comfort", "heat"), 22.5); + } + + #[test] + fn device_setpoint_rounding_preserves_control_direction() { + assert_eq!(round_device_setpoint("cool", true, 23.5), 23.0); + assert_eq!(round_device_setpoint("cool", false, 25.5), 26.0); + assert_eq!(round_device_setpoint("heat", true, 21.5), 22.0); + assert_eq!(round_device_setpoint("heat", false, 19.5), 19.0); + } + + #[test] + fn external_room_sensor_selects_lower_cooling_setpoint_only_when_used() { + assert_eq!(external_room_sensor_cooling_assist("cool", "external"), 0.5); + assert_eq!(external_room_sensor_cooling_assist("cool", "combined"), 0.5); + 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); + } + + #[test] + fn smart_fan_uses_low_speed_when_zone_is_satisfied() { + assert_eq!(smart_fan_speed("heat", 21.0, 21.0, None, false), 1); + assert_eq!(smart_fan_speed("cool", 23.0, 23.0, None, false), 1); + } + + #[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); + } + + #[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())); + 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!(native_sleep_command(true, true, true, false, false), None); + } + + #[test] + fn gree_outdoor_fallback_uses_median_of_online_units() { + let mut a = Device::simulated_default(); + a.outdoor_temperature = Some(10.0); + let mut b = Device::simulated_default(); + b.id = "sim-b".into(); + b.outdoor_temperature = Some(12.0); + let mut c = Device::simulated_default(); + c.id = "sim-c".into(); + c.outdoor_temperature = Some(40.0); + assert_eq!(gree_outdoor_temperature(&[a, b, c]), Some(12.0)); + } + + #[test] + fn outdoor_assist_is_bounded_and_direction_neutral() { + let cool = outdoor_assist_offset("cool", Some(36.0), 27.0, 23.0); + let heat = outdoor_assist_offset("heat", Some(-5.0), 17.0, 21.0); + assert!(cool > 0.0 && cool <= 1.0); + assert!(heat > 0.0 && heat <= 1.0); + assert_eq!(outdoor_assist_offset("cool", None, 27.0, 23.0), 0.0); + } + +} diff --git a/src/engine/zone_actions.rs b/src/engine/zone_actions.rs new file mode 100644 index 0000000..439f6f2 --- /dev/null +++ b/src/engine/zone_actions.rs @@ -0,0 +1,201 @@ +fn persist_zone_cycle(state: &AppState, computed: &Zone, cycle_started_at: DateTime) -> Result { + let Some(mut latest) = state.db.get_zone(&computed.id)? else { return Ok(computed.clone()); }; + if latest.updated_at <= cycle_started_at { + state.db.save_zone(computed)?; + return Ok(computed.clone()); + } + // Another actor changed this zone while the regulator was doing network I/O. Never + // write the old controller snapshot over fresh configuration or manual takeover state. + // Sensor observations are safe to carry forward only while the device assignment matches. + if latest.device_id == computed.device_id { + latest.device_temperature = computed.device_temperature; + latest.external_temperature = computed.external_temperature; + latest.current_temperature = computed.current_temperature; + latest.control_temperature_source = computed.control_temperature_source.clone(); + latest.updated_at = Utc::now(); + state.db.save_zone(&latest)?; + } + Ok(latest) +} + +async fn thermostat_ownership_is_current(state: &AppState, zone_id: &str, device_id: &str) -> Result { + if !state.settings.read().await.house_power_enabled { return Ok(false); } + let Some(zone) = state.db.get_zone(zone_id)? else { return Ok(false); }; + if zone.device_id != device_id || !zone.enabled || zone.device_manual_override || zone.local_thermostat_power == Some(false) { return Ok(false); } + if zone.local_thermostat_power == Some(true) { return Ok(true); } + let blocked = state.db.list_groups()?.iter().any(|group| { + !group.power_enabled && group.zone_ids.iter().any(|member| member == zone_id) + }); + Ok(!blocked) +} + +async fn group_off_ownership_is_current(state: &AppState, zone_id: &str, device_id: &str) -> Result { + if !state.settings.read().await.house_power_enabled { return Ok(false); } + let Some(zone) = state.db.get_zone(zone_id)? else { return Ok(false); }; + if zone.device_id != device_id || !zone.enabled || zone.device_manual_override || zone.local_thermostat_power.is_some() { return Ok(false); } + Ok(state.db.list_groups()?.iter().any(|group| { + !group.power_enabled && group.zone_ids.iter().any(|member| member == zone_id) + })) +} + +async fn send_zone_command_if_owned( + state: &AppState, + zone_id: &str, + device_id: &str, + command: DeviceCommand, + require_group_block: bool, +) -> Result, AppError> { + // Ownership must be checked after acquiring the same per-device lock used by polling. + // Otherwise polling could detect a remote takeover while this task is waiting for the lock, + // and a stale thermostat decision would still be sent immediately afterwards. + let _device_guard = state.lock_device_operation(device_id).await; + let owned = if require_group_block { + group_off_ownership_is_current(state, zone_id, device_id).await? + } else { + thermostat_ownership_is_current(state, zone_id, device_id).await? + }; + if !owned { return Ok(None); } + send_command_locked(state, device_id, command).await.map(Some) +} + +async fn send_group_power_if_current( + state: &AppState, + group_id: &str, + zone_id: &str, + device_id: &str, + desired_power: bool, +) -> Result, AppError> { + let _device_guard = state.lock_device_operation(device_id).await; + let Some(zone) = state.db.get_zone(zone_id)? else { return Ok(None); }; + if zone.device_id != device_id || !zone.enabled || zone.device_manual_override || zone.local_thermostat_power.is_some() { return Ok(None); } + let groups = state.db.list_groups()?; + let Some(group) = groups.iter().find(|group| group.id == group_id) else { return Ok(None); }; + if group.power_enabled != desired_power || !group.zone_ids.iter().any(|member| member == zone_id) { return Ok(None); } + if desired_power { + let settings = state.settings.read().await; + if !settings.house_power_enabled { return Ok(None); } + let effective_mode = if zone.inherit_house_mode { settings.house_mode.as_str() } else { zone.mode.as_str() }; + if effective_mode == "off" { return Ok(None); } + if groups.iter().any(|other| { + other.id != group_id && !other.power_enabled && other.zone_ids.iter().any(|member| member == zone_id) + }) { + return Ok(None); + } + } + let Some(device) = state.db.get_device(device_id)? else { return Ok(None); }; + if !device.enabled { return Ok(None); } + let command = DeviceCommand { power: Some(desired_power), ..Default::default() }; + if desired_power { + send_command_locked(state, device_id, command).await.map(Some) + } else { + // A deliberate group OFF is a one-shot safety transition. Send it even if the + // cached state already says OFF; the regulator itself will not keep repeating it. + send_command_locked_forced(state, device_id, command).await.map(Some) + } +} + +async fn send_automatic_device_command_if_owned( + state: &AppState, + device_id: &str, + command: DeviceCommand, +) -> Result, AppError> { + let _device_guard = state.lock_device_operation(device_id).await; + if !state.settings.read().await.house_power_enabled { return Ok(None); } + let zones = state.db.list_zones()?; + if device_blocked_by_disabled_zone(device_id, &zones) + || device_blocked_by_manual_override(device_id, &zones) + || device_blocked_by_local_thermostat(device_id, &zones) + || device_blocked_by_disabled_group(device_id, &zones, &state.db.list_groups()?) + { + return Ok(None); + } + send_command_locked(state, device_id, command).await.map(Some) +} + +async fn apply_automatic_device_action( + state: &AppState, + device_id: &str, + command: DeviceCommand, +) -> Result, AppError> { + let zones = state.db.list_zones()?; + let Some(zone_id) = zones.iter().find(|zone| zone.device_id == device_id).map(|zone| zone.id.clone()) else { + return send_automatic_device_command_if_owned(state, device_id, command).await; + }; + + let _zone_guard = state.lock_zone_operation(&zone_id).await; + let mut zone = state.db.get_zone(&zone_id)?.ok_or_else(|| AppError::NotFound(format!("zone {zone_id}")))?; + let settings = state.settings.read().await.clone(); + if !settings.house_power_enabled || zone.device_manual_override || zone.local_thermostat_power.is_some() { + return Ok(None); + } + let groups = state.db.list_groups()?; + if groups.iter().any(|group| !group.power_enabled && group.zone_ids.iter().any(|member| member == &zone.id)) { + return Ok(None); + } + // A power-on automation is an explicit domain transition and may re-enable a zone that + // a previous power automation disabled. Other actions still respect a disabled zone gate. + if !zone.enabled && command.power != Some(true) { return Ok(None); } + + let mut domain_changed = false; + if let Some(power) = command.power { + zone.enabled = power; + domain_changed = true; + } + if let Some(mode) = command.mode.as_deref() { + match mode { + "heat" | "cool" => { + zone.mode = mode.to_string(); + zone.inherit_house_mode = false; + domain_changed = true; + } + "auto" => { + zone.inherit_house_mode = true; + domain_changed = true; + } + _ => return Err(AppError::BadRequest( + "device automation for a thermostat-managed unit supports only heat, cool or auto mode".into(), + )), + } + } + if let Some(target) = command.target_temperature { + zone.manual_setpoint = Some((target.clamp(8.0, 30.0) * 2.0).round() / 2.0); + zone.manual_override_until = next_schedule_boundary_utc(&zone.id, &state.db.list_schedules()?, Local::now()); + domain_changed = true; + } + + if domain_changed { + zone.revision = zone.revision.saturating_add(1); + zone.updated_at = Utc::now(); + zone.control_source = "automation.device".into(); + state.db.save_zone(&zone)?; + state.broadcast("zone.updated", serde_json::to_value(&zone)?); + state.wake_zone_control(); + } + + if command.power == Some(false) { + return force_power_off_device(state, device_id).await.map(Some); + } + + // Climate fields above are durable zone state. Only non-climate device capabilities remain + // a one-shot command; the thermostat can no longer undo power/mode/target next cycle. + let residual = DeviceCommand { + power: None, + mode: None, + target_temperature: None, + fan_speed: command.fan_speed, + swing_vertical: command.swing_vertical, + swing_horizontal: command.swing_horizontal, + quiet: command.quiet, + turbo: command.turbo, + light: command.light, + air: command.air, + xfan: command.xfan, + health: command.health, + sleep: command.sleep, + }; + if residual.is_empty() { + return state.db.get_device(device_id)?.map(Some).ok_or_else(|| AppError::NotFound(format!("device {device_id}"))); + } + send_automatic_device_command_if_owned(state, device_id, residual).await +} + diff --git a/src/engine/zone_control.rs b/src/engine/zone_control.rs new file mode 100644 index 0000000..d3dbc51 --- /dev/null +++ b/src/engine/zone_control.rs @@ -0,0 +1,543 @@ +async fn control_zones(state: &AppState) -> Result<()> { + let schedules = state.db.list_schedules()?; + let groups = state.db.list_groups()?; + let settings = state.settings.read().await.clone(); + let mut zone_snapshot = state.db.list_zones()?; + // Local quick-thermostat OFF is intentionally temporary. Expire the ownership marker + // before the house-power early return so the hand-back still happens while the master + // is off; no physical state is restored here, only automation ownership. + expire_local_thermostat_overrides(state, &mut zone_snapshot, &schedules, &settings.house_mode).await?; + let temporary_restored_disabled = expire_temporary_quick_thermostats(state, &mut zone_snapshot, &schedules, &settings.house_mode).await?; + activate_due_temporary_quick_thermostats(state, &mut zone_snapshot, &schedules, &settings.house_mode, settings.house_power_enabled).await?; + + // Outdoor temperature is deliberately optional. Prefer the configured Home + // Assistant entity, but keep the dashboard/assist useful by falling back to the + // outdoor sensors reported by GREE units when HA is temporarily unavailable. + let device_snapshot = state.db.list_devices()?; + let configured_outdoor = settings.home_assistant.outdoor_entity_id.trim(); + let resolved_outdoor = if configured_outdoor.is_empty() { + None + } else { + home_assistant::resolve_entity_id(&settings.home_assistant, Some(configured_outdoor)) + }; + let ha_outdoor_temperature = if let Some(entity_id) = resolved_outdoor.as_deref() { + match home_assistant::read_temperature(&state.http, &settings.home_assistant, Some(entity_id), Some(settings.home_assistant.sensor_stale_after_seconds)).await { + Ok(value) => { + record_ha_history( + state, + entity_id, + None, + "outdoor", + value, + settings.poll_interval_seconds, + ); + Some(value) + } + Err(err) => { + tracing::debug!(configured_entity=%configured_outdoor, resolved_entity=%entity_id, error=?err, "outdoor Home Assistant sensor unavailable; trying GREE fallback"); + None + } + } + } else { + None + }; + let outdoor_temperature = ha_outdoor_temperature.or_else(|| gree_outdoor_temperature(&device_snapshot)); + { + let mut current = state.outdoor_temperature.write().await; + if *current != outdoor_temperature { + *current = outdoor_temperature; + state.broadcast("outdoor.updated", json!({"temperature": outdoor_temperature})); + } + } + let outdoor_assist_temperature = if settings.outdoor_assist_enabled { outdoor_temperature } else { None }; + let night_active = night_mode_active(&settings.night_mode, Local::now().time()); + + if !settings.house_power_enabled { + // Whole-house OFF is a one-shot action performed by the API endpoint. While the + // master remains off the regulator stays passive. A later physical/remote change + // is therefore detected as manual takeover and is not erased or forced OFF again. + return Ok(()); + } + + // Read all per-zone Home Assistant sensors concurrently. A down HA instance should cost + // one request timeout per cycle, not one timeout multiplied by the number of zones. + let room_sensor_reads = futures_util::future::join_all(zone_snapshot.iter().filter_map(|zone| { + if !matches!(zone.sensor_source.as_str(), "home_assistant" | "combined") { return None; } + let zone_id = zone.id.clone(); + let resolved_entity = home_assistant::resolve_entity_id(&settings.home_assistant, zone.ha_entity_id.as_deref()); + let http = &state.http; + let ha_settings = &settings.home_assistant; + let stale_after_seconds = effective_sensor_stale_after_seconds(zone.sensor_stale_after_seconds, ha_settings.sensor_stale_after_seconds); + Some(async move { + let result = home_assistant::read_temperature(http, ha_settings, resolved_entity.as_deref(), Some(stale_after_seconds)).await + .map_err(|err| err.to_string()); + (zone_id, resolved_entity, result) + }) + })).await; + let mut room_sensor_results: HashMap, Result)> = room_sensor_reads.into_iter() + .map(|(zone_id, entity_id, result)| (zone_id, (entity_id, result))) + .collect(); + + for mut zone in zone_snapshot { + let cycle_started_at = zone.updated_at; + if zone.manual_override_until.map(|until| until <= Utc::now()).unwrap_or(false) { + zone.manual_preset = None; + zone.manual_setpoint = None; + zone.manual_override_until = None; + } + if zone.device_manual_override_until.map(|until| until <= Utc::now()).unwrap_or(false) { + reset_device_manual_override(&mut zone); + state.log("info", "zone.device_manual_override_expired", &format!("Manual device control expired for {} at schedule transition", zone.name), json!({ + "zone_id": zone.id, "device_id": zone.device_id + })); + } + + let Some(device) = state.db.get_device(&zone.device_id)? else { + state.log("error", "zone.device_missing", &format!("Zone {} has no device", zone.name), json!({"zone_id": zone.id})); + continue; + }; + + // House "off" means the smart thermostat does not control inherited zones. + // A zone explicitly switched to heat/cool remains independent and may still run. + // Local Quick Thermostat is an explicit per-zone request. If the inherited house + // climate mode is "off" (no automatic climate control), use the zone's last local + // heat/cool mode while local ownership is ON. The separate whole-house master power + // remains authoritative and is checked before this loop. + let effective_mode_owned = effective_zone_mode(&zone, &settings.house_mode); + zone.effective_mode = effective_mode_owned.clone(); + let ownership_blocked_by_group = zone.local_thermostat_power != Some(true) + && groups.iter().any(|group| !group.power_enabled && group.zone_ids.iter().any(|zone_id| zone_id == &zone.id)); + refresh_control_ownership(&mut zone, settings.house_power_enabled, ownership_blocked_by_group); + let effective_mode = effective_mode_owned.as_str(); + + let previous_source = zone.control_temperature_source.clone(); + // Never feed the thermostat a cached GREE temperature after any communication + // failure. External HA sensors may still keep a zone operational when configured. + let device_temperature = if device.enabled && device.online && device.communication_failures == 0 { + device.current_temperature + } else { + None + }; + let external_temperature = if matches!(zone.sensor_source.as_str(), "home_assistant" | "combined") { + match room_sensor_results.remove(&zone.id) { + Some((resolved_entity, Ok(value))) => { + if let Some(entity_id) = resolved_entity.as_deref() { + record_ha_history(state, entity_id, Some(&zone.id), "room", value, settings.poll_interval_seconds); + } + Some(value) + } + Some((resolved_entity, Err(err))) => { + if !matches!(previous_source.as_str(), "device_fallback" | "device_discrepancy_fallback") { + let notification_kind = if err.contains("Home Assistant sensor is stale:") { + "ha.sensor_stale" + } else { + "ha.sensor_error" + }; + state.log("warn", notification_kind, &err, json!({ + "zone_id": zone.id, + "configured_entity_id": zone.ha_entity_id.as_deref(), + "resolved_entity_id": resolved_entity, + })); + } + None + } + None => None, + } + } else { + None + }; + + let (temperature, control_source, discrepancy) = select_zone_temperature(&zone, device_temperature, external_temperature); + zone.device_temperature = device_temperature; + zone.external_temperature = external_temperature; + zone.current_temperature = temperature; + zone.control_temperature_source = control_source; + zone.updated_at = Utc::now(); + + // A disabled thermostat zone is completely outside normal controller ownership. + // Keep its sensors fresh, but do not let group state, schedules or thermostat + // modulation touch the unit. Manual control from the technical Devices view may + // therefore remain active until the zone is explicitly enabled again. + if !zone.enabled { + if temporary_restored_disabled.iter().any(|zone_id| zone_id == &zone.id) { + ensure_device_off_after_temporary_disabled_restore(state, &zone, &device).await; + } + zone.demand = false; + zone.demand_since = None; + record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds); + let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?; + state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?); + continue; + } + + // A technically disabled device is outside thermostat ownership. Do not create + // repeated command errors while keeping any available external sensor data visible. + if !device.enabled { + zone.demand = false; + zone.demand_since = None; + zone.device_setpoint = None; + record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds); + let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?; + state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?); + continue; + } + + // A physical/manual takeover has higher priority than thermostat, schedule, group and + // automation control. Continue sensor/history updates, but reflect the unit's real state + // instead of sending corrective frames that would fight the person holding the remote. + if zone.device_manual_override { + // Manual/remote takeover pauses commands, but it must not erase the thermostat's + // selected profile/target. Keep the intended target visible and report the physical + // unit target separately through device_setpoint. This makes Resume/Profile actions + // deterministic and avoids a standby device target (for example 25 C) masquerading + // as the zone's Sleep/Comfort target. + let temporary_active = temporary_quick_thermostat_is_active(&zone, zone.updated_at.clone()); + let pause_started_at = zone.updated_at; + if temporary_active { + if let Some(session) = zone.temporary_quick_thermostat.as_mut() { + if session.paused_at.is_none() { session.paused_at = Some(pause_started_at); } + session.state = "paused_manual".into(); + session.condition_started_at = None; + session.condition_last_observed_at = None; + } + } + let target_mode = if effective_mode == "off" { zone.mode.as_str() } else { effective_mode }; + let active_schedule = active_schedule_for_zone(&zone, &schedules, Local::now()); + let (preset, target) = resolve_zone_target(&zone, active_schedule, target_mode); + zone.active_preset = preset; + zone.effective_setpoint = Some(target); + // Keep effective_mode's existing meaning during takeover: it reflects the physical + // unit, while effective_setpoint above remains the thermostat intent. + zone.effective_mode = if device.power { device.mode.clone() } else { "off".into() }; + zone.device_setpoint = if device.power { Some(device.target_temperature) } else { None }; + zone.demand = false; + zone.demand_since = None; + zone.target_alerted_at = None; + record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds); + let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?; + state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?); + continue; + } + + // Temperature completion belongs to the temporary thermostat only while it truly owns + // the zone. A manual/device takeover above therefore pauses the hold instead of silently + // consuming it. GREE samples use last_seen; HA/combined samples were freshly read in this + // control cycle. A long gap resets continuous-hold evidence after restart/stale sensors. + let condition_sample_at = match zone.control_temperature_source.as_str() { + "home_assistant" | "combined" => Some(zone.updated_at.clone()), + _ => device.last_seen.clone(), + }; + let max_condition_gap_seconds = settings.poll_interval_seconds + .max(settings.zone_interval_seconds) + .saturating_mul(2) + .saturating_add(5); + let condition_now = zone.updated_at.clone(); + if let Some(reason) = evaluate_temporary_quick_thermostat_condition( + &mut zone, + condition_now, + condition_sample_at, + max_condition_gap_seconds, + ) { + let finish_kind = zone.temporary_quick_thermostat.as_ref().map(|item| item.finish_kind.clone()).unwrap_or_default(); + finish_temporary_quick_thermostat(&mut zone, &schedules, &settings.house_mode); + record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds); + let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?; + state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?); + ensure_device_off_after_temporary_disabled_restore(state, &persisted_zone, &device).await; + state.log("info", "zone.temporary_quick_thermostat_finished", &format!("Temporary Quick Thermostat finished for {}", zone.name), json!({ + "zone_id": zone.id, "device_id": zone.device_id, "finish_kind": finish_kind, "reason": reason + })); + state.wake_zone_control(); + continue; + } + + if zone.local_thermostat_power == Some(false) { + zone.effective_mode = "off".into(); + zone.demand = false; + zone.demand_since = None; + zone.device_setpoint = None; + if device.online && device.communication_failures == 0 && device.power { + let _device_guard = state.lock_device_operation(&zone.device_id).await; + let latest = state.db.get_zone(&zone.id)?; + if latest.as_ref().map(|item| item.local_thermostat_power == Some(false) && !item.device_manual_override).unwrap_or(false) { + if let Err(err) = send_command_locked( + state, + &zone.device_id, + DeviceCommand { power: Some(false), ..Default::default() }, + ).await { + state.log("error", "zone.local_power_error", &err.to_string(), json!({"zone_id": zone.id, "device_id": zone.device_id})); + } + } + } + record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds); + let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?; + state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?); + continue; + } + + let blocked_by_group = ownership_blocked_by_group; + if blocked_by_group { + zone.effective_mode = "off".into(); + zone.demand = false; + zone.demand_since = None; + zone.device_setpoint = None; + if device.online && device.communication_failures == 0 && device.power { + match send_zone_command_if_owned( + state, + &zone.id, + &zone.device_id, + DeviceCommand { power: Some(false), ..Default::default() }, + true, + ).await { + Ok(_) => {} + Err(err) => state.log("error", "group.power_error", &err.to_string(), json!({"zone_id": zone.id, "device_id": zone.device_id})), + } + } + record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds); + let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?; + state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?); + continue; + } + + if discrepancy && previous_source != "device_discrepancy_fallback" { + state.log("warn", "zone.sensor_discrepancy", &format!("Zone {} sensors differ by more than {:.1} C; using GREE sensor", zone.name, zone.max_sensor_difference), json!({ + "zone_id": zone.id, + "device_temperature": zone.device_temperature, + "external_temperature": zone.external_temperature, + "max_difference": zone.max_sensor_difference, + "entity_id": zone.ha_entity_id.as_deref(), + })); + } + + // House "off" is a no-control state, not a power-off command. Keep polling and + // publishing the zone, but never overwrite manual device state while it follows + // the house mode. Explicit per-zone heat/cool bypasses this branch above. + if effective_mode == "off" { + zone.effective_setpoint = None; + zone.device_setpoint = None; + zone.demand = false; + zone.demand_since = None; + zone.target_alerted_at = None; + record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds); + let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?; + state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?); + continue; + } + + let active_schedule = active_schedule_for_zone(&zone, &schedules, Local::now()); + let (preset, target) = resolve_zone_target(&zone, active_schedule, effective_mode); + zone.active_preset = preset; + zone.effective_setpoint = Some(target); + + let Some(temp) = temperature else { + record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds); + let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?; + state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?); + continue; + }; + + let half = zone.hysteresis.max(0.1) / 2.0; + let previous_demand = zone.demand; + zone.demand = match effective_mode { + "heat" => { + if temp <= target - half { true } + else if temp >= target + half { false } + else { zone.demand } + } + _ => { + if temp >= target + half { true } + else if temp <= target - half { false } + else { zone.demand } + } + }; + if zone.demand && !previous_demand { + zone.demand_since = Some(Utc::now()); + zone.target_alerted_at = None; + } else if !zone.demand { + zone.demand_since = None; + zone.target_alerted_at = None; + } + if zone.demand && zone.target_alerted_at.is_none() { + let timeout_minutes = settings.notifications.target_timeout_minutes.max(5) as i64; + if let Some(since) = zone.demand_since { + if (Utc::now() - since).num_minutes() >= timeout_minutes { + state.log("warn", "zone.target_timeout", &format!("Zone {} has not reached {:.1} C within {} minutes", zone.name, target, timeout_minutes), json!({ + "zone_id": zone.id, "room_temperature": temp, "target_temperature": target, "minutes": timeout_minutes + })); + zone.target_alerted_at = Some(Utc::now()); + } + } + } + + // Setpoint modulation: keep the indoor unit powered and let its own inverter/compressor + // stop naturally when we move the target to the satisfied side of room temperature. + let outdoor_assist = outdoor_assist_offset(effective_mode, outdoor_assist_temperature, temp, target); + // When an independent room sensor is actually driving cooling, the indoor unit's + // own sensor can satisfy too early. Apply a half-degree pre-rounding bias: because + // GREE setpoints are sent as whole degrees, this selects the next lower whole-degree + // target (0.5-1.0 C below the room target). Do not stack it with outdoor assist and + // do not use it during device/fallback control. + let room_sensor_assist = external_room_sensor_cooling_assist(effective_mode, &zone.control_temperature_source); + let demand_assist = outdoor_assist.max(room_sensor_assist); + let active_target = match effective_mode { + "heat" => target + outdoor_assist, + _ => target - demand_assist, + }; + let standby_target = match effective_mode { + "heat" => target - zone.standby_offset_c.max(0.5), + _ => target + zone.standby_offset_c.max(0.5), + }; + let desired_device_target = round_device_setpoint(effective_mode, zone.demand, if zone.demand { active_target } else { standby_target }); + // Report only the last confirmed device state here. The desired target belongs to + // effective_setpoint/command planning until a device command succeeds. + zone.device_setpoint = if device.power { Some(device.target_temperature) } else { None }; + + let demand_changed = previous_demand != zone.demand; + let desired_fan = if night_active { + let max_fan = settings.night_mode.max_fan_speed.clamp(1, 5); + if zone.smart_fan { + Some(night_limited_fan_speed( + smart_fan_speed(effective_mode, temp, target, outdoor_assist_temperature, zone.demand), + max_fan, + )) + } else if device.fan_speed == 0 || device.fan_speed > max_fan { + Some(max_fan) + } else { + Some(device.fan_speed) + } + } else if zone.smart_fan { + Some(smart_fan_speed(effective_mode, temp, target, outdoor_assist_temperature, zone.demand)) + } else { + None + }; + // When the room becomes satisfied, ask compatible units for Quiet in the same + // frame as the standby setpoint and Low fan. When demand returns, disable Quiet + // on the normal smart-fan transition. When scheduled night mode owns Quiet, it + // explicitly enables it inside the window and releases it outside the window. + let desired_quiet = smart_quiet_command( + zone.smart_fan, + state.gree.quiet_command_supported(&device.id), + previous_demand, + zone.demand, + device.quiet, + settings.night_mode.enabled, + night_active, + settings.night_mode.force_quiet, + ); + let desired_sleep = native_sleep_command( + settings.night_mode.enabled, + night_active, + settings.night_mode.use_native_sleep, + device.supports_sleep == Some(true) && state.gree.sleep_command_supported(&device.id), + device.sleep, + ); + + // Compressor protection for automatic ownership. Direct/manual commands and global safety OFF + // deliberately bypass this path, while the thermostat never performs an immediate Heat<->Cool swap. + let now = Utc::now(); + if zone.lockout_until.map(|until| until <= now).unwrap_or(false) { + zone.lockout_until = None; + zone.lockout_reason = None; + } + if device.power && device.mode != effective_mode { + let min_on = chrono::Duration::seconds(zone.min_on_seconds as i64); + if zone.last_power_change_at.map(|at| now.signed_duration_since(at) < min_on).unwrap_or(false) { + let until = zone.last_power_change_at.map(|at| at + min_on); + zone.lockout_until = until; + zone.lockout_reason = Some("minimum_on_before_mode_change".into()); + record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds); + let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?; + state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?); + continue; + } + match send_zone_command_if_owned(state, &zone.id, &zone.device_id, DeviceCommand { power: Some(false), ..Default::default() }, false).await { + Ok(Some(_)) => { + zone.last_power_change_at = Some(now); + zone.lockout_until = Some(now + chrono::Duration::seconds(zone.min_off_seconds as i64)); + zone.lockout_reason = Some("mode_change_off_delay".into()); + state.log("info", "zone.mode_change_lockout", &format!("Zone {} switched off before {} mode", zone.name, effective_mode), json!({"zone_id": zone.id, "resume_at": zone.lockout_until})); + } + Ok(None) => {} + Err(err) => state.log("error", "zone.mode_change_off_error", &err.to_string(), json!({"zone_id": zone.id})), + } + record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds); + let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?; + state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?); + continue; + } + if !device.power { + let min_off = chrono::Duration::seconds(zone.min_off_seconds as i64); + if zone.last_power_change_at.map(|at| now.signed_duration_since(at) < min_off).unwrap_or(false) { + zone.lockout_until = zone.last_power_change_at.map(|at| at + min_off); + zone.lockout_reason = Some("minimum_off_before_start".into()); + record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds); + let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?; + state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?); + continue; + } + } + + let core_needs_command = !device.power + || (device.target_temperature - desired_device_target).abs() >= 0.5; + // In normal standby, Low fan is a transition hint rather than a state that should + // be reasserted forever. Some GREE firmwares accept the frame but later report Auto + // again; retrying every min_adjust_seconds only causes needless command beeps. + let fan_needs_command = desired_fan + .map(|fan| fan != device.fan_speed) + .unwrap_or(false) + && (zone.demand || demand_changed || core_needs_command || night_active); + let needs_command = core_needs_command + || fan_needs_command + || desired_quiet.map(|quiet| quiet != device.quiet).unwrap_or(false) + || desired_sleep.map(|sleep| sleep != device.sleep).unwrap_or(false); + + let urgent_start = !device.power; + if needs_command && (urgent_start || adjustment_allowed(&zone)) { + let command = DeviceCommand { + power: Some(true), + mode: Some(effective_mode.to_string()), + target_temperature: Some(desired_device_target), + fan_speed: if fan_needs_command { desired_fan } else { None }, + quiet: desired_quiet, + sleep: desired_sleep, + ..Default::default() + }; + match send_zone_command_if_owned(state, &zone.id, &zone.device_id, command, false).await { + Ok(Some(updated_device)) => { + let transition_at = Utc::now(); + if device.power != updated_device.power { zone.last_power_change_at = Some(transition_at); } + if device.mode != updated_device.mode { zone.last_mode_change_at = Some(transition_at); } + zone.device_setpoint = if updated_device.power { Some(updated_device.target_temperature) } else { None }; + zone.last_action_at = Some(transition_at); + state.log("info", "zone.setpoint_modulation", &format!("Zone {} -> {:.1} C ({})", zone.name, desired_device_target, if zone.demand { "demand" } else { "standby" }), json!({ + "zone_id": zone.id, + "room_temperature": temp, + "comfort_target": target, + "device_target": desired_device_target, + "mode": effective_mode, + "preset": zone.active_preset, + "outdoor_temperature": outdoor_temperature, + "fan_speed": updated_device.fan_speed, + "quiet": updated_device.quiet, + "sleep": updated_device.sleep, + "night_mode": night_active, + })); + } + Ok(None) => { + record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds); + let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?; + state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?); + continue; + } + Err(err) => state.log("error", "zone.action_error", &err.to_string(), json!({"zone_id": zone.id})), + } + } + + record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds); + let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?; + state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?); + } + + Ok(()) +} + diff --git a/src/influxdb.rs b/src/influxdb.rs index f0e6f6c..0fbe675 100644 --- a/src/influxdb.rs +++ b/src/influxdb.rs @@ -10,394 +10,8 @@ const DEVICE_MEASUREMENT: &str = "gree_device"; const ZONE_MEASUREMENT: &str = "gree_zone"; const HA_MEASUREMENT: &str = "gree_ha"; -pub fn validate(settings: &InfluxDbSettings) -> Result<()> { - if !settings.enabled { return Ok(()); } - if !matches!(settings.version.as_str(), "1" | "2") { bail!("InfluxDB version must be 1 or 2"); } - let parsed = url::Url::parse(settings.url.trim()).context("invalid InfluxDB URL")?; - if !matches!(parsed.scheme(), "http" | "https") { bail!("InfluxDB URL must use http or https"); } - if settings.version == "1" && settings.database.trim().is_empty() { bail!("InfluxDB 1.x database is required"); } - if settings.version == "2" { - if settings.org.trim().is_empty() { bail!("InfluxDB 2.x organization is required"); } - if settings.bucket.trim().is_empty() { bail!("InfluxDB 2.x bucket is required"); } - if settings.token.trim().is_empty() { bail!("InfluxDB 2.x token is required"); } - } - Ok(()) -} -pub async fn write_device(client: &Client, settings: &InfluxDbSettings, reading: &Reading) -> Result<()> { - if !settings.enabled { return Ok(()); } - let mut fields = Vec::new(); - push_float(&mut fields, "indoor_temperature", reading.indoor_temperature); - push_float(&mut fields, "outdoor_temperature", reading.outdoor_temperature); - push_float(&mut fields, "target_temperature", Some(reading.target_temperature)); - push_int(&mut fields, "power", reading.power as i64); - let line = line_protocol(DEVICE_MEASUREMENT, &[("device_id", &reading.device_id)], fields, reading.timestamp)?; - write_line(client, settings, line).await -} - -pub async fn write_zone(client: &Client, settings: &InfluxDbSettings, reading: &ZoneReading) -> Result<()> { - if !settings.enabled { return Ok(()); } - let mut fields = Vec::new(); - push_float(&mut fields, "gree_temperature", reading.gree_temperature); - push_float(&mut fields, "external_temperature", reading.external_temperature); - push_float(&mut fields, "control_temperature", reading.control_temperature); - push_float(&mut fields, "target_temperature", reading.target_temperature); - push_float(&mut fields, "device_setpoint", reading.device_setpoint); - push_float(&mut fields, "outdoor_temperature", reading.outdoor_temperature); - push_int(&mut fields, "power", reading.power as i64); - push_int(&mut fields, "fan_speed", reading.fan_speed as i64); - push_int(&mut fields, "demand", reading.demand as i64); - let line = line_protocol( - ZONE_MEASUREMENT, - &[("zone_id", &reading.zone_id), ("device_id", &reading.device_id)], - fields, - reading.timestamp, - )?; - write_line(client, settings, line).await -} - -pub async fn write_ha(client: &Client, settings: &InfluxDbSettings, reading: &HaReading) -> Result<()> { - if !settings.enabled { return Ok(()); } - let zone = reading.zone_id.as_deref().unwrap_or(""); - let mut fields = Vec::new(); - push_float(&mut fields, "temperature", Some(reading.temperature)); - let line = line_protocol( - HA_MEASUREMENT, - &[("entity_id", &reading.entity_id), ("zone_id", zone), ("kind", &reading.kind)], - fields, - reading.timestamp, - )?; - write_line(client, settings, line).await -} - -pub async fn write_batch( - client: &Client, - settings: &InfluxDbSettings, - devices: &[Reading], - zones: &[ZoneReading], - ha: &[HaReading], -) -> Result<()> { - if !settings.enabled { return Ok(()); } - let mut lines = Vec::with_capacity(devices.len() + zones.len() + ha.len()); - for reading in devices { - let mut fields = Vec::new(); - push_float(&mut fields, "indoor_temperature", reading.indoor_temperature); - push_float(&mut fields, "outdoor_temperature", reading.outdoor_temperature); - push_float(&mut fields, "target_temperature", Some(reading.target_temperature)); - push_int(&mut fields, "power", reading.power as i64); - lines.push(line_protocol(DEVICE_MEASUREMENT, &[("device_id", &reading.device_id)], fields, reading.timestamp)?); - } - for reading in zones { - let mut fields = Vec::new(); - push_float(&mut fields, "gree_temperature", reading.gree_temperature); - push_float(&mut fields, "external_temperature", reading.external_temperature); - push_float(&mut fields, "control_temperature", reading.control_temperature); - push_float(&mut fields, "target_temperature", reading.target_temperature); - push_float(&mut fields, "device_setpoint", reading.device_setpoint); - push_float(&mut fields, "outdoor_temperature", reading.outdoor_temperature); - push_int(&mut fields, "power", reading.power as i64); - push_int(&mut fields, "fan_speed", reading.fan_speed as i64); - push_int(&mut fields, "demand", reading.demand as i64); - lines.push(line_protocol(ZONE_MEASUREMENT, &[("zone_id", &reading.zone_id), ("device_id", &reading.device_id)], fields, reading.timestamp)?); - } - for reading in ha { - let mut fields = Vec::new(); - push_float(&mut fields, "temperature", Some(reading.temperature)); - lines.push(line_protocol(HA_MEASUREMENT, &[("entity_id", &reading.entity_id), ("zone_id", reading.zone_id.as_deref().unwrap_or("")), ("kind", &reading.kind)], fields, reading.timestamp)?); - } - if lines.is_empty() { return Ok(()); } - write_lines(client, settings, lines.join("\n")).await -} - -async fn write_line(client: &Client, settings: &InfluxDbSettings, line: String) -> Result<()> { - write_lines(client, settings, line).await -} - -async fn write_lines(client: &Client, settings: &InfluxDbSettings, body: String) -> Result<()> { - validate(settings)?; - let base = settings.url.trim_end_matches('/'); - let request = if settings.version == "1" { - let request = client.post(format!("{base}/write")) - .query(&[("db", settings.database.as_str()), ("precision", "ns")]) - .header(reqwest::header::CONTENT_TYPE, "text/plain; charset=utf-8") - .body(body.clone()); - if settings.username.trim().is_empty() { request } else { request.basic_auth(&settings.username, Some(&settings.password)) } - } else { - client.post(format!("{base}/api/v2/write")) - .query(&[("org", settings.org.as_str()), ("bucket", settings.bucket.as_str()), ("precision", "ns")]) - .bearer_auth(settings.token.trim()) - .header(reqwest::header::CONTENT_TYPE, "text/plain; charset=utf-8") - .body(body) - }; - let response = request.send().await.context("InfluxDB write request failed")?; - if !response.status().is_success() { - let status = response.status(); - let body = response.text().await.unwrap_or_default(); - bail!("InfluxDB write failed ({status}): {}", truncate(&body, 300)); - } - Ok(()) -} - -pub async fn query_devices( - client: &Client, - settings: &InfluxDbSettings, - device_id: Option<&str>, - start: DateTime, - stop: DateTime, - bucket_seconds: i64, - limit: u32, -) -> Result> { - if settings.version == "1" { - query_devices_v1(client, settings, device_id, start, stop, bucket_seconds, limit).await - } else { - let tags = if let Some(value) = device_id { format!(" |> filter(fn: (r) => r.device_id == {})", flux_string(value)) } else { String::new() }; - let query = flux_query(settings, DEVICE_MEASUREMENT, &tags, &["device_id"], start, stop, bucket_seconds); - 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("device_id").filter(|v| !v.is_empty()) else { continue; }; - out.push(Reading { - id: 0, - device_id: id.clone(), - timestamp, - indoor_temperature: row_f64(&row, "indoor_temperature"), - outdoor_temperature: row_f64(&row, "outdoor_temperature"), - target_temperature: row_f64(&row, "target_temperature").unwrap_or(0.0), - power: row_f64(&row, "power").unwrap_or(0.0) >= 0.5, - source: "influx".into(), - }); - } - out.sort_by_key(|row| row.timestamp); - Ok(out) - } -} - -pub async fn query_zones( - client: &Client, - settings: &InfluxDbSettings, - zone_id: Option<&str>, - start: DateTime, - stop: DateTime, - bucket_seconds: i64, - limit: u32, -) -> Result> { - if settings.version == "1" { - query_zones_v1(client, settings, zone_id, start, stop, bucket_seconds, limit).await - } else { - let tags = if let Some(value) = zone_id { format!(" |> filter(fn: (r) => r.zone_id == {})", flux_string(value)) } else { String::new() }; - let query = flux_query(settings, ZONE_MEASUREMENT, &tags, &["zone_id", "device_id"], start, stop, bucket_seconds); - 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(zone) = row.get("zone_id").filter(|v| !v.is_empty()) else { continue; }; - out.push(ZoneReading { - id: 0, - zone_id: zone.clone(), - device_id: row.get("device_id").cloned().unwrap_or_default(), - timestamp, - gree_temperature: row_f64(&row, "gree_temperature"), - external_temperature: row_f64(&row, "external_temperature"), - control_temperature: row_f64(&row, "control_temperature"), - target_temperature: row_f64(&row, "target_temperature"), - device_setpoint: row_f64(&row, "device_setpoint"), - outdoor_temperature: row_f64(&row, "outdoor_temperature"), - power: row_f64(&row, "power").unwrap_or(0.0) >= 0.5, - mode: "history".into(), - fan_speed: row_f64(&row, "fan_speed").unwrap_or(0.0).round().clamp(0.0, 5.0) as u8, - demand: row_f64(&row, "demand").unwrap_or(0.0) >= 0.5, - control_source: "influx".into(), - active_preset: "history".into(), - }); - } - out.sort_by_key(|row| row.timestamp); - Ok(out) - } -} - -pub async fn query_ha( - client: &Client, - settings: &InfluxDbSettings, - entity_id: Option<&str>, - start: DateTime, - stop: DateTime, - bucket_seconds: i64, - limit: u32, -) -> Result> { - if settings.version == "1" { - query_ha_v1(client, settings, entity_id, start, stop, bucket_seconds, limit).await - } else { - let tags = if let Some(value) = entity_id { format!(" |> filter(fn: (r) => r.entity_id == {})", flux_string(value)) } else { String::new() }; - let query = flux_query(settings, HA_MEASUREMENT, &tags, &["entity_id", "zone_id", "kind"], start, stop, bucket_seconds); - 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(entity) = row.get("entity_id").filter(|v| !v.is_empty()) else { continue; }; - let Some(temperature) = row_f64(&row, "temperature") else { continue; }; - out.push(HaReading { - id: 0, - entity_id: entity.clone(), - zone_id: row.get("zone_id").filter(|v| !v.is_empty()).cloned(), - kind: row.get("kind").cloned().unwrap_or_else(|| "room".into()), - timestamp, - temperature, - }); - } - out.sort_by_key(|row| row.timestamp); - Ok(out) - } -} - -async fn query_devices_v1(client: &Client, settings: &InfluxDbSettings, device_id: Option<&str>, start: DateTime, stop: DateTime, bucket: i64, limit: u32) -> Result> { - let filter = device_id.map(|id| format!(" AND \"device_id\"='{}'", influxql_string(id))).unwrap_or_default(); - let q = format!("SELECT mean(\"indoor_temperature\") AS \"indoor_temperature\",mean(\"outdoor_temperature\") AS \"outdoor_temperature\",mean(\"target_temperature\") AS \"target_temperature\",max(\"power\") AS \"power\" FROM \"{DEVICE_MEASUREMENT}\" WHERE time >= '{}' AND time < '{}'{} GROUP BY time({}s),\"device_id\" fill(none) LIMIT {}", start.to_rfc3339(), stop.to_rfc3339(), filter, bucket.max(1), limit); - let series = query_v1(client, settings, &q).await?; - let mut out = Vec::new(); - for item in series { - let device = item.tags.get("device_id").cloned().unwrap_or_default(); - for row in item.rows { - let Some(timestamp) = row_time(&row) else { continue; }; - out.push(Reading { id:0, device_id:device.clone(), timestamp, indoor_temperature:row_num(&row,"indoor_temperature"), outdoor_temperature:row_num(&row,"outdoor_temperature"), target_temperature:row_num(&row,"target_temperature").unwrap_or(0.0), power:row_num(&row,"power").unwrap_or(0.0)>=0.5, source:"influx".into() }); - } - } - out.sort_by_key(|row| row.timestamp); out.truncate(limit as usize); Ok(out) -} - -async fn query_zones_v1(client: &Client, settings: &InfluxDbSettings, zone_id: Option<&str>, start: DateTime, stop: DateTime, bucket: i64, limit: u32) -> Result> { - let filter = zone_id.map(|id| format!(" AND \"zone_id\"='{}'", influxql_string(id))).unwrap_or_default(); - let q = format!("SELECT mean(\"gree_temperature\") AS \"gree_temperature\",mean(\"external_temperature\") AS \"external_temperature\",mean(\"control_temperature\") AS \"control_temperature\",mean(\"target_temperature\") AS \"target_temperature\",mean(\"device_setpoint\") AS \"device_setpoint\",mean(\"outdoor_temperature\") AS \"outdoor_temperature\",max(\"power\") AS \"power\",mean(\"fan_speed\") AS \"fan_speed\",max(\"demand\") AS \"demand\" FROM \"{ZONE_MEASUREMENT}\" WHERE time >= '{}' AND time < '{}'{} GROUP BY time({}s),\"zone_id\",\"device_id\" fill(none) LIMIT {}", start.to_rfc3339(), stop.to_rfc3339(), filter, bucket.max(1), limit); - let series = query_v1(client, settings, &q).await?; - let mut out = Vec::new(); - for item in series { - let zone = item.tags.get("zone_id").cloned().unwrap_or_default(); - let device = item.tags.get("device_id").cloned().unwrap_or_default(); - for row in item.rows { - let Some(timestamp) = row_time(&row) else { continue; }; - out.push(ZoneReading { id:0, zone_id:zone.clone(), device_id:device.clone(), timestamp, gree_temperature:row_num(&row,"gree_temperature"), external_temperature:row_num(&row,"external_temperature"), control_temperature:row_num(&row,"control_temperature"), target_temperature:row_num(&row,"target_temperature"), device_setpoint:row_num(&row,"device_setpoint"), outdoor_temperature:row_num(&row,"outdoor_temperature"), power:row_num(&row,"power").unwrap_or(0.0)>=0.5, mode:"history".into(), fan_speed:row_num(&row,"fan_speed").unwrap_or(0.0).round().clamp(0.0,5.0) as u8, demand:row_num(&row,"demand").unwrap_or(0.0)>=0.5, control_source:"influx".into(), active_preset:"history".into() }); - } - } - out.sort_by_key(|row| row.timestamp); out.truncate(limit as usize); Ok(out) -} - -async fn query_ha_v1(client: &Client, settings: &InfluxDbSettings, entity_id: Option<&str>, start: DateTime, stop: DateTime, bucket: i64, limit: u32) -> Result> { - let filter = entity_id.map(|id| format!(" AND \"entity_id\"='{}'", influxql_string(id))).unwrap_or_default(); - let q = format!("SELECT mean(\"temperature\") AS \"temperature\" FROM \"{HA_MEASUREMENT}\" WHERE time >= '{}' AND time < '{}'{} GROUP BY time({}s),\"entity_id\",\"zone_id\",\"kind\" fill(none) LIMIT {}", start.to_rfc3339(), stop.to_rfc3339(), filter, bucket.max(1), limit); - let series = query_v1(client, settings, &q).await?; - let mut out = Vec::new(); - for item in series { - let entity = item.tags.get("entity_id").cloned().unwrap_or_default(); - let zone = item.tags.get("zone_id").filter(|v| !v.is_empty()).cloned(); - let kind = item.tags.get("kind").cloned().unwrap_or_else(|| "room".into()); - for row in item.rows { - let Some(timestamp) = row_time(&row) else { continue; }; - let Some(temperature) = row_num(&row,"temperature") else { continue; }; - out.push(HaReading { id:0, entity_id:entity.clone(), zone_id:zone.clone(), kind:kind.clone(), timestamp, temperature }); - } - } - out.sort_by_key(|row| row.timestamp); out.truncate(limit as usize); Ok(out) -} - -struct V1Series { tags: HashMap, rows: Vec> } - -async fn query_v1(client: &Client, settings: &InfluxDbSettings, q: &str) -> Result> { - validate(settings)?; - let base = settings.url.trim_end_matches('/'); - let request = client.get(format!("{base}/query")).query(&[("db", settings.database.as_str()), ("q", q)]); - let request = if settings.username.trim().is_empty() { request } else { request.basic_auth(&settings.username, Some(&settings.password)) }; - let response = request.send().await.context("InfluxDB 1.x query failed")?; - let status = response.status(); - let body: Value = response.json().await.context("invalid InfluxDB 1.x JSON response")?; - if !status.is_success() { bail!("InfluxDB 1.x query failed ({status}): {body}"); } - if let Some(error) = body.pointer("/results/0/error").and_then(Value::as_str) { bail!("InfluxDB 1.x query error: {error}"); } - let mut out = Vec::new(); - for series in body.pointer("/results/0/series").and_then(Value::as_array).into_iter().flatten() { - let columns: Vec = series.get("columns").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str).map(str::to_owned).collect(); - let tags = series.get("tags").and_then(Value::as_object).map(|map| map.iter().map(|(k,v)|(k.clone(),v.as_str().unwrap_or_default().to_string())).collect()).unwrap_or_default(); - let mut rows = Vec::new(); - for values in series.get("values").and_then(Value::as_array).into_iter().flatten() { - let Some(values) = values.as_array() else { continue; }; - rows.push(columns.iter().cloned().zip(values.iter().cloned()).collect()); - } - out.push(V1Series { tags, rows }); - } - Ok(out) -} - -async fn query_v2(client: &Client, settings: &InfluxDbSettings, query: &str) -> Result>> { - validate(settings)?; - let base = settings.url.trim_end_matches('/'); - let response = client.post(format!("{base}/api/v2/query")) - .query(&[("org", settings.org.as_str())]) - .bearer_auth(settings.token.trim()) - .header(reqwest::header::ACCEPT, "application/csv") - .header(reqwest::header::CONTENT_TYPE, "application/vnd.flux") - .body(query.to_string()) - .send().await.context("InfluxDB 2.x query failed")?; - let status = response.status(); - let body = response.text().await.context("cannot read InfluxDB 2.x response")?; - if !status.is_success() { bail!("InfluxDB 2.x query failed ({status}): {}", truncate(&body, 500)); } - let mut headers: Option> = None; - let mut rows = Vec::new(); - for line in body.lines().filter(|line| !line.starts_with('#') && !line.trim().is_empty()) { - let record = parse_csv_line(line); - if headers.is_none() { - headers = Some(record); - continue; - } - let row: HashMap = headers.as_ref().unwrap().iter().cloned().zip(record.into_iter()).collect(); - if row.get("_time").map(|value| !value.is_empty()).unwrap_or(false) { rows.push(row); } - } - Ok(rows) -} - -fn parse_csv_line(line: &str) -> Vec { - let mut out = Vec::new(); - let mut field = String::new(); - let mut chars = line.chars().peekable(); - let mut quoted = false; - while let Some(ch) = chars.next() { - match ch { - '"' if quoted && chars.peek() == Some(&'"') => { - field.push('"'); - chars.next(); - } - '"' => quoted = !quoted, - ',' if !quoted => { - out.push(std::mem::take(&mut field)); - } - _ => field.push(ch), - } - } - out.push(field); - out -} - -fn flux_query(settings: &InfluxDbSettings, measurement: &str, extra_filters: &str, group_tags: &[&str], start: DateTime, stop: DateTime, bucket_seconds: i64) -> String { - let tags = group_tags.iter().map(|tag| format!("\"{tag}\"")).collect::>().join(","); - format!( - "from(bucket: {}) |> range(start: time(v: {}), stop: time(v: {})) |> filter(fn: (r) => r._measurement == {}){} |> aggregateWindow(every: {}s, fn: mean, createEmpty: false) |> group(columns: [{}]) |> pivot(rowKey:[\"_time\"], columnKey:[\"_field\"], valueColumn:\"_value\") |> sort(columns:[\"_time\"])", - flux_string(&settings.bucket), flux_string(&start.to_rfc3339()), flux_string(&stop.to_rfc3339()), flux_string(measurement), extra_filters, bucket_seconds.max(1), tags - ) -} - -fn line_protocol(measurement: &str, tags: &[(&str, &str)], fields: Vec, timestamp: DateTime) -> Result { - if fields.is_empty() { bail!("InfluxDB measurement has no fields"); } - let tags = tags.iter().filter(|(_, value)| !value.is_empty()).map(|(key,value)| format!(",{}={}", escape_tag(key), escape_tag(value))).collect::(); - let nanos = timestamp.timestamp_nanos_opt().ok_or_else(|| anyhow!("timestamp outside nanosecond range"))?; - Ok(format!("{}{} {} {}", escape_measurement(measurement), tags, fields.join(","), nanos)) -} - -fn push_float(fields: &mut Vec, key: &str, value: Option) { if let Some(value) = value.filter(|v| v.is_finite()) { fields.push(format!("{}={value}", escape_field_key(key))); } } -fn push_int(fields: &mut Vec, key: &str, value: i64) { fields.push(format!("{}={value}i", escape_field_key(key))); } -fn escape_measurement(value: &str) -> String { value.replace('\\', "\\\\").replace(',', "\\,").replace(' ', "\\ ") } -fn escape_tag(value: &str) -> String { value.replace('\\', "\\\\").replace(',', "\\,").replace('=', "\\=").replace(' ', "\\ ") } -fn escape_field_key(value: &str) -> String { escape_tag(value) } -fn influxql_string(value: &str) -> String { value.replace('\\', "\\\\").replace('\'', "\\'") } -fn flux_string(value: &str) -> String { format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\"")) } -fn truncate(value: &str, max: usize) -> String { value.chars().take(max).collect() } -fn row_f64(row: &HashMap, key: &str) -> Option { row.get(key)?.parse().ok() } -fn parse_flux_time(row: &HashMap) -> Option> { DateTime::parse_from_rfc3339(row.get("_time")?).ok().map(|v| v.with_timezone(&Utc)) } -fn row_num(row: &HashMap, key: &str) -> Option { row.get(key)?.as_f64().or_else(|| row.get(key)?.as_i64().map(|v|v as f64)) } -fn row_time(row: &HashMap) -> Option> { DateTime::parse_from_rfc3339(row.get("time")?.as_str()?).ok().map(|v|v.with_timezone(&Utc)) } +// Functional source split intentionally keeps items in the existing module namespace. +include!("influxdb/write.rs"); +include!("influxdb/query.rs"); +include!("influxdb/codec.rs"); diff --git a/src/influxdb/codec.rs b/src/influxdb/codec.rs new file mode 100644 index 0000000..499722c --- /dev/null +++ b/src/influxdb/codec.rs @@ -0,0 +1,49 @@ +fn parse_csv_line(line: &str) -> Vec { + let mut out = Vec::new(); + let mut field = String::new(); + let mut chars = line.chars().peekable(); + let mut quoted = false; + while let Some(ch) = chars.next() { + match ch { + '"' if quoted && chars.peek() == Some(&'"') => { + field.push('"'); + chars.next(); + } + '"' => quoted = !quoted, + ',' if !quoted => { + out.push(std::mem::take(&mut field)); + } + _ => field.push(ch), + } + } + out.push(field); + out +} + +fn flux_query(settings: &InfluxDbSettings, measurement: &str, extra_filters: &str, group_tags: &[&str], start: DateTime, stop: DateTime, bucket_seconds: i64) -> String { + let tags = group_tags.iter().map(|tag| format!("\"{tag}\"")).collect::>().join(","); + format!( + "from(bucket: {}) |> range(start: time(v: {}), stop: time(v: {})) |> filter(fn: (r) => r._measurement == {}){} |> aggregateWindow(every: {}s, fn: mean, createEmpty: false) |> group(columns: [{}]) |> pivot(rowKey:[\"_time\"], columnKey:[\"_field\"], valueColumn:\"_value\") |> sort(columns:[\"_time\"])", + flux_string(&settings.bucket), flux_string(&start.to_rfc3339()), flux_string(&stop.to_rfc3339()), flux_string(measurement), extra_filters, bucket_seconds.max(1), tags + ) +} + +fn line_protocol(measurement: &str, tags: &[(&str, &str)], fields: Vec, timestamp: DateTime) -> Result { + if fields.is_empty() { bail!("InfluxDB measurement has no fields"); } + let tags = tags.iter().filter(|(_, value)| !value.is_empty()).map(|(key,value)| format!(",{}={}", escape_tag(key), escape_tag(value))).collect::(); + let nanos = timestamp.timestamp_nanos_opt().ok_or_else(|| anyhow!("timestamp outside nanosecond range"))?; + Ok(format!("{}{} {} {}", escape_measurement(measurement), tags, fields.join(","), nanos)) +} + +fn push_float(fields: &mut Vec, key: &str, value: Option) { if let Some(value) = value.filter(|v| v.is_finite()) { fields.push(format!("{}={value}", escape_field_key(key))); } } +fn push_int(fields: &mut Vec, key: &str, value: i64) { fields.push(format!("{}={value}i", escape_field_key(key))); } +fn escape_measurement(value: &str) -> String { value.replace('\\', "\\\\").replace(',', "\\,").replace(' ', "\\ ") } +fn escape_tag(value: &str) -> String { value.replace('\\', "\\\\").replace(',', "\\,").replace('=', "\\=").replace(' ', "\\ ") } +fn escape_field_key(value: &str) -> String { escape_tag(value) } +fn influxql_string(value: &str) -> String { value.replace('\\', "\\\\").replace('\'', "\\'") } +fn flux_string(value: &str) -> String { format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\"")) } +fn truncate(value: &str, max: usize) -> String { value.chars().take(max).collect() } +fn row_f64(row: &HashMap, key: &str) -> Option { row.get(key)?.parse().ok() } +fn parse_flux_time(row: &HashMap) -> Option> { DateTime::parse_from_rfc3339(row.get("_time")?).ok().map(|v| v.with_timezone(&Utc)) } +fn row_num(row: &HashMap, key: &str) -> Option { row.get(key)?.as_f64().or_else(|| row.get(key)?.as_i64().map(|v|v as f64)) } +fn row_time(row: &HashMap) -> Option> { DateTime::parse_from_rfc3339(row.get("time")?.as_str()?).ok().map(|v|v.with_timezone(&Utc)) } diff --git a/src/influxdb/query.rs b/src/influxdb/query.rs new file mode 100644 index 0000000..28f330f --- /dev/null +++ b/src/influxdb/query.rs @@ -0,0 +1,214 @@ +pub async fn query_devices( + client: &Client, + settings: &InfluxDbSettings, + device_id: Option<&str>, + start: DateTime, + stop: DateTime, + bucket_seconds: i64, + limit: u32, +) -> Result> { + if settings.version == "1" { + query_devices_v1(client, settings, device_id, start, stop, bucket_seconds, limit).await + } else { + let tags = if let Some(value) = device_id { format!(" |> filter(fn: (r) => r.device_id == {})", flux_string(value)) } else { String::new() }; + let query = flux_query(settings, DEVICE_MEASUREMENT, &tags, &["device_id"], start, stop, bucket_seconds); + 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("device_id").filter(|v| !v.is_empty()) else { continue; }; + out.push(Reading { + id: 0, + device_id: id.clone(), + timestamp, + indoor_temperature: row_f64(&row, "indoor_temperature"), + outdoor_temperature: row_f64(&row, "outdoor_temperature"), + target_temperature: row_f64(&row, "target_temperature").unwrap_or(0.0), + power: row_f64(&row, "power").unwrap_or(0.0) >= 0.5, + source: "influx".into(), + }); + } + out.sort_by_key(|row| row.timestamp); + Ok(out) + } +} + +pub async fn query_zones( + client: &Client, + settings: &InfluxDbSettings, + zone_id: Option<&str>, + start: DateTime, + stop: DateTime, + bucket_seconds: i64, + limit: u32, +) -> Result> { + if settings.version == "1" { + query_zones_v1(client, settings, zone_id, start, stop, bucket_seconds, limit).await + } else { + let tags = if let Some(value) = zone_id { format!(" |> filter(fn: (r) => r.zone_id == {})", flux_string(value)) } else { String::new() }; + let query = flux_query(settings, ZONE_MEASUREMENT, &tags, &["zone_id", "device_id"], start, stop, bucket_seconds); + 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(zone) = row.get("zone_id").filter(|v| !v.is_empty()) else { continue; }; + out.push(ZoneReading { + id: 0, + zone_id: zone.clone(), + device_id: row.get("device_id").cloned().unwrap_or_default(), + timestamp, + gree_temperature: row_f64(&row, "gree_temperature"), + external_temperature: row_f64(&row, "external_temperature"), + control_temperature: row_f64(&row, "control_temperature"), + target_temperature: row_f64(&row, "target_temperature"), + device_setpoint: row_f64(&row, "device_setpoint"), + outdoor_temperature: row_f64(&row, "outdoor_temperature"), + power: row_f64(&row, "power").unwrap_or(0.0) >= 0.5, + mode: "history".into(), + fan_speed: row_f64(&row, "fan_speed").unwrap_or(0.0).round().clamp(0.0, 5.0) as u8, + demand: row_f64(&row, "demand").unwrap_or(0.0) >= 0.5, + control_source: "influx".into(), + active_preset: "history".into(), + }); + } + out.sort_by_key(|row| row.timestamp); + Ok(out) + } +} + +pub async fn query_ha( + client: &Client, + settings: &InfluxDbSettings, + entity_id: Option<&str>, + start: DateTime, + stop: DateTime, + bucket_seconds: i64, + limit: u32, +) -> Result> { + if settings.version == "1" { + query_ha_v1(client, settings, entity_id, start, stop, bucket_seconds, limit).await + } else { + let tags = if let Some(value) = entity_id { format!(" |> filter(fn: (r) => r.entity_id == {})", flux_string(value)) } else { String::new() }; + let query = flux_query(settings, HA_MEASUREMENT, &tags, &["entity_id", "zone_id", "kind"], start, stop, bucket_seconds); + 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(entity) = row.get("entity_id").filter(|v| !v.is_empty()) else { continue; }; + let Some(temperature) = row_f64(&row, "temperature") else { continue; }; + out.push(HaReading { + id: 0, + entity_id: entity.clone(), + zone_id: row.get("zone_id").filter(|v| !v.is_empty()).cloned(), + kind: row.get("kind").cloned().unwrap_or_else(|| "room".into()), + timestamp, + temperature, + }); + } + out.sort_by_key(|row| row.timestamp); + Ok(out) + } +} + +async fn query_devices_v1(client: &Client, settings: &InfluxDbSettings, device_id: Option<&str>, start: DateTime, stop: DateTime, bucket: i64, limit: u32) -> Result> { + let filter = device_id.map(|id| format!(" AND \"device_id\"='{}'", influxql_string(id))).unwrap_or_default(); + let q = format!("SELECT mean(\"indoor_temperature\") AS \"indoor_temperature\",mean(\"outdoor_temperature\") AS \"outdoor_temperature\",mean(\"target_temperature\") AS \"target_temperature\",max(\"power\") AS \"power\" FROM \"{DEVICE_MEASUREMENT}\" WHERE time >= '{}' AND time < '{}'{} GROUP BY time({}s),\"device_id\" fill(none) LIMIT {}", start.to_rfc3339(), stop.to_rfc3339(), filter, bucket.max(1), limit); + let series = query_v1(client, settings, &q).await?; + let mut out = Vec::new(); + for item in series { + let device = item.tags.get("device_id").cloned().unwrap_or_default(); + for row in item.rows { + let Some(timestamp) = row_time(&row) else { continue; }; + out.push(Reading { id:0, device_id:device.clone(), timestamp, indoor_temperature:row_num(&row,"indoor_temperature"), outdoor_temperature:row_num(&row,"outdoor_temperature"), target_temperature:row_num(&row,"target_temperature").unwrap_or(0.0), power:row_num(&row,"power").unwrap_or(0.0)>=0.5, source:"influx".into() }); + } + } + out.sort_by_key(|row| row.timestamp); out.truncate(limit as usize); Ok(out) +} + +async fn query_zones_v1(client: &Client, settings: &InfluxDbSettings, zone_id: Option<&str>, start: DateTime, stop: DateTime, bucket: i64, limit: u32) -> Result> { + let filter = zone_id.map(|id| format!(" AND \"zone_id\"='{}'", influxql_string(id))).unwrap_or_default(); + let q = format!("SELECT mean(\"gree_temperature\") AS \"gree_temperature\",mean(\"external_temperature\") AS \"external_temperature\",mean(\"control_temperature\") AS \"control_temperature\",mean(\"target_temperature\") AS \"target_temperature\",mean(\"device_setpoint\") AS \"device_setpoint\",mean(\"outdoor_temperature\") AS \"outdoor_temperature\",max(\"power\") AS \"power\",mean(\"fan_speed\") AS \"fan_speed\",max(\"demand\") AS \"demand\" FROM \"{ZONE_MEASUREMENT}\" WHERE time >= '{}' AND time < '{}'{} GROUP BY time({}s),\"zone_id\",\"device_id\" fill(none) LIMIT {}", start.to_rfc3339(), stop.to_rfc3339(), filter, bucket.max(1), limit); + let series = query_v1(client, settings, &q).await?; + let mut out = Vec::new(); + for item in series { + let zone = item.tags.get("zone_id").cloned().unwrap_or_default(); + let device = item.tags.get("device_id").cloned().unwrap_or_default(); + for row in item.rows { + let Some(timestamp) = row_time(&row) else { continue; }; + out.push(ZoneReading { id:0, zone_id:zone.clone(), device_id:device.clone(), timestamp, gree_temperature:row_num(&row,"gree_temperature"), external_temperature:row_num(&row,"external_temperature"), control_temperature:row_num(&row,"control_temperature"), target_temperature:row_num(&row,"target_temperature"), device_setpoint:row_num(&row,"device_setpoint"), outdoor_temperature:row_num(&row,"outdoor_temperature"), power:row_num(&row,"power").unwrap_or(0.0)>=0.5, mode:"history".into(), fan_speed:row_num(&row,"fan_speed").unwrap_or(0.0).round().clamp(0.0,5.0) as u8, demand:row_num(&row,"demand").unwrap_or(0.0)>=0.5, control_source:"influx".into(), active_preset:"history".into() }); + } + } + out.sort_by_key(|row| row.timestamp); out.truncate(limit as usize); Ok(out) +} + +async fn query_ha_v1(client: &Client, settings: &InfluxDbSettings, entity_id: Option<&str>, start: DateTime, stop: DateTime, bucket: i64, limit: u32) -> Result> { + let filter = entity_id.map(|id| format!(" AND \"entity_id\"='{}'", influxql_string(id))).unwrap_or_default(); + let q = format!("SELECT mean(\"temperature\") AS \"temperature\" FROM \"{HA_MEASUREMENT}\" WHERE time >= '{}' AND time < '{}'{} GROUP BY time({}s),\"entity_id\",\"zone_id\",\"kind\" fill(none) LIMIT {}", start.to_rfc3339(), stop.to_rfc3339(), filter, bucket.max(1), limit); + let series = query_v1(client, settings, &q).await?; + let mut out = Vec::new(); + for item in series { + let entity = item.tags.get("entity_id").cloned().unwrap_or_default(); + let zone = item.tags.get("zone_id").filter(|v| !v.is_empty()).cloned(); + let kind = item.tags.get("kind").cloned().unwrap_or_else(|| "room".into()); + for row in item.rows { + let Some(timestamp) = row_time(&row) else { continue; }; + let Some(temperature) = row_num(&row,"temperature") else { continue; }; + out.push(HaReading { id:0, entity_id:entity.clone(), zone_id:zone.clone(), kind:kind.clone(), timestamp, temperature }); + } + } + out.sort_by_key(|row| row.timestamp); out.truncate(limit as usize); Ok(out) +} + +struct V1Series { tags: HashMap, rows: Vec> } + +async fn query_v1(client: &Client, settings: &InfluxDbSettings, q: &str) -> Result> { + validate(settings)?; + let base = settings.url.trim_end_matches('/'); + let request = client.get(format!("{base}/query")).query(&[("db", settings.database.as_str()), ("q", q)]); + let request = if settings.username.trim().is_empty() { request } else { request.basic_auth(&settings.username, Some(&settings.password)) }; + let response = request.send().await.context("InfluxDB 1.x query failed")?; + let status = response.status(); + let body: Value = response.json().await.context("invalid InfluxDB 1.x JSON response")?; + if !status.is_success() { bail!("InfluxDB 1.x query failed ({status}): {body}"); } + if let Some(error) = body.pointer("/results/0/error").and_then(Value::as_str) { bail!("InfluxDB 1.x query error: {error}"); } + let mut out = Vec::new(); + for series in body.pointer("/results/0/series").and_then(Value::as_array).into_iter().flatten() { + let columns: Vec = series.get("columns").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str).map(str::to_owned).collect(); + let tags = series.get("tags").and_then(Value::as_object).map(|map| map.iter().map(|(k,v)|(k.clone(),v.as_str().unwrap_or_default().to_string())).collect()).unwrap_or_default(); + let mut rows = Vec::new(); + for values in series.get("values").and_then(Value::as_array).into_iter().flatten() { + let Some(values) = values.as_array() else { continue; }; + rows.push(columns.iter().cloned().zip(values.iter().cloned()).collect()); + } + out.push(V1Series { tags, rows }); + } + Ok(out) +} + +async fn query_v2(client: &Client, settings: &InfluxDbSettings, query: &str) -> Result>> { + validate(settings)?; + let base = settings.url.trim_end_matches('/'); + let response = client.post(format!("{base}/api/v2/query")) + .query(&[("org", settings.org.as_str())]) + .bearer_auth(settings.token.trim()) + .header(reqwest::header::ACCEPT, "application/csv") + .header(reqwest::header::CONTENT_TYPE, "application/vnd.flux") + .body(query.to_string()) + .send().await.context("InfluxDB 2.x query failed")?; + let status = response.status(); + let body = response.text().await.context("cannot read InfluxDB 2.x response")?; + if !status.is_success() { bail!("InfluxDB 2.x query failed ({status}): {}", truncate(&body, 500)); } + let mut headers: Option> = None; + let mut rows = Vec::new(); + for line in body.lines().filter(|line| !line.starts_with('#') && !line.trim().is_empty()) { + let record = parse_csv_line(line); + if headers.is_none() { + headers = Some(record); + continue; + } + let row: HashMap = headers.as_ref().unwrap().iter().cloned().zip(record.into_iter()).collect(); + if row.get("_time").map(|value| !value.is_empty()).unwrap_or(false) { rows.push(row); } + } + Ok(rows) +} + diff --git a/src/influxdb/write.rs b/src/influxdb/write.rs new file mode 100644 index 0000000..75a0c46 --- /dev/null +++ b/src/influxdb/write.rs @@ -0,0 +1,128 @@ +pub fn validate(settings: &InfluxDbSettings) -> Result<()> { + if !settings.enabled { return Ok(()); } + if !matches!(settings.version.as_str(), "1" | "2") { bail!("InfluxDB version must be 1 or 2"); } + let parsed = url::Url::parse(settings.url.trim()).context("invalid InfluxDB URL")?; + if !matches!(parsed.scheme(), "http" | "https") { bail!("InfluxDB URL must use http or https"); } + if settings.version == "1" && settings.database.trim().is_empty() { bail!("InfluxDB 1.x database is required"); } + if settings.version == "2" { + if settings.org.trim().is_empty() { bail!("InfluxDB 2.x organization is required"); } + if settings.bucket.trim().is_empty() { bail!("InfluxDB 2.x bucket is required"); } + if settings.token.trim().is_empty() { bail!("InfluxDB 2.x token is required"); } + } + Ok(()) +} + +pub async fn write_device(client: &Client, settings: &InfluxDbSettings, reading: &Reading) -> Result<()> { + if !settings.enabled { return Ok(()); } + let mut fields = Vec::new(); + push_float(&mut fields, "indoor_temperature", reading.indoor_temperature); + push_float(&mut fields, "outdoor_temperature", reading.outdoor_temperature); + push_float(&mut fields, "target_temperature", Some(reading.target_temperature)); + push_int(&mut fields, "power", reading.power as i64); + let line = line_protocol(DEVICE_MEASUREMENT, &[("device_id", &reading.device_id)], fields, reading.timestamp)?; + write_line(client, settings, line).await +} + +pub async fn write_zone(client: &Client, settings: &InfluxDbSettings, reading: &ZoneReading) -> Result<()> { + if !settings.enabled { return Ok(()); } + let mut fields = Vec::new(); + push_float(&mut fields, "gree_temperature", reading.gree_temperature); + push_float(&mut fields, "external_temperature", reading.external_temperature); + push_float(&mut fields, "control_temperature", reading.control_temperature); + push_float(&mut fields, "target_temperature", reading.target_temperature); + push_float(&mut fields, "device_setpoint", reading.device_setpoint); + push_float(&mut fields, "outdoor_temperature", reading.outdoor_temperature); + push_int(&mut fields, "power", reading.power as i64); + push_int(&mut fields, "fan_speed", reading.fan_speed as i64); + push_int(&mut fields, "demand", reading.demand as i64); + let line = line_protocol( + ZONE_MEASUREMENT, + &[("zone_id", &reading.zone_id), ("device_id", &reading.device_id)], + fields, + reading.timestamp, + )?; + write_line(client, settings, line).await +} + +pub async fn write_ha(client: &Client, settings: &InfluxDbSettings, reading: &HaReading) -> Result<()> { + if !settings.enabled { return Ok(()); } + let zone = reading.zone_id.as_deref().unwrap_or(""); + let mut fields = Vec::new(); + push_float(&mut fields, "temperature", Some(reading.temperature)); + let line = line_protocol( + HA_MEASUREMENT, + &[("entity_id", &reading.entity_id), ("zone_id", zone), ("kind", &reading.kind)], + fields, + reading.timestamp, + )?; + write_line(client, settings, line).await +} + +pub async fn write_batch( + client: &Client, + settings: &InfluxDbSettings, + devices: &[Reading], + zones: &[ZoneReading], + ha: &[HaReading], +) -> Result<()> { + if !settings.enabled { return Ok(()); } + let mut lines = Vec::with_capacity(devices.len() + zones.len() + ha.len()); + for reading in devices { + let mut fields = Vec::new(); + push_float(&mut fields, "indoor_temperature", reading.indoor_temperature); + push_float(&mut fields, "outdoor_temperature", reading.outdoor_temperature); + push_float(&mut fields, "target_temperature", Some(reading.target_temperature)); + push_int(&mut fields, "power", reading.power as i64); + lines.push(line_protocol(DEVICE_MEASUREMENT, &[("device_id", &reading.device_id)], fields, reading.timestamp)?); + } + for reading in zones { + let mut fields = Vec::new(); + push_float(&mut fields, "gree_temperature", reading.gree_temperature); + push_float(&mut fields, "external_temperature", reading.external_temperature); + push_float(&mut fields, "control_temperature", reading.control_temperature); + push_float(&mut fields, "target_temperature", reading.target_temperature); + push_float(&mut fields, "device_setpoint", reading.device_setpoint); + push_float(&mut fields, "outdoor_temperature", reading.outdoor_temperature); + push_int(&mut fields, "power", reading.power as i64); + push_int(&mut fields, "fan_speed", reading.fan_speed as i64); + push_int(&mut fields, "demand", reading.demand as i64); + lines.push(line_protocol(ZONE_MEASUREMENT, &[("zone_id", &reading.zone_id), ("device_id", &reading.device_id)], fields, reading.timestamp)?); + } + for reading in ha { + let mut fields = Vec::new(); + push_float(&mut fields, "temperature", Some(reading.temperature)); + lines.push(line_protocol(HA_MEASUREMENT, &[("entity_id", &reading.entity_id), ("zone_id", reading.zone_id.as_deref().unwrap_or("")), ("kind", &reading.kind)], fields, reading.timestamp)?); + } + if lines.is_empty() { return Ok(()); } + write_lines(client, settings, lines.join("\n")).await +} + +async fn write_line(client: &Client, settings: &InfluxDbSettings, line: String) -> Result<()> { + write_lines(client, settings, line).await +} + +async fn write_lines(client: &Client, settings: &InfluxDbSettings, body: String) -> Result<()> { + validate(settings)?; + let base = settings.url.trim_end_matches('/'); + let request = if settings.version == "1" { + let request = client.post(format!("{base}/write")) + .query(&[("db", settings.database.as_str()), ("precision", "ns")]) + .header(reqwest::header::CONTENT_TYPE, "text/plain; charset=utf-8") + .body(body.clone()); + if settings.username.trim().is_empty() { request } else { request.basic_auth(&settings.username, Some(&settings.password)) } + } else { + client.post(format!("{base}/api/v2/write")) + .query(&[("org", settings.org.as_str()), ("bucket", settings.bucket.as_str()), ("precision", "ns")]) + .bearer_auth(settings.token.trim()) + .header(reqwest::header::CONTENT_TYPE, "text/plain; charset=utf-8") + .body(body) + }; + let response = request.send().await.context("InfluxDB write request failed")?; + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + bail!("InfluxDB write failed ({status}): {}", truncate(&body, 300)); + } + Ok(()) +} + diff --git a/src/models.rs b/src/models.rs index e6d5034..4c73ca1 100644 --- a/src/models.rs +++ b/src/models.rs @@ -3,1084 +3,13 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use serde_json::Value; -fn default_true() -> bool { true } -fn default_port() -> u16 { 7000 } -fn default_protocol() -> u8 { 0 } -fn default_mode() -> String { "cool".into() } -fn default_fan() -> u8 { 0 } -fn default_target() -> f64 { 24.0 } -fn default_hysteresis() -> f64 { 0.6 } -fn default_external_sensor_weight() -> f64 { 0.4 } -fn default_max_sensor_difference() -> f64 { 3.0 } -fn default_control_temperature_source() -> String { "device".into() } -fn default_min_cycle() -> u64 { 180 } -fn default_sensor_stale_after() -> u64 { 300 } -fn default_cooldown() -> u64 { 300 } -fn default_house_mode() -> String { "cool".into() } -fn default_control_strategy() -> String { "setpoint".into() } -fn default_standby_offset() -> f64 { 2.0 } -fn default_min_adjust() -> u64 { 120 } -fn default_schedule_preset() -> String { "custom".into() } -fn default_active_preset() -> String { "comfort".into() } -fn default_cool_comfort() -> f64 { 23.0 } -fn default_cool_sleep() -> f64 { 24.5 } -fn default_cool_away() -> f64 { 27.0 } -fn default_heat_comfort() -> f64 { 21.0 } -fn default_heat_sleep() -> f64 { 19.0 } -fn default_heat_away() -> f64 { 17.0 } -fn default_history_retention_days() -> u32 { 30 } -fn default_event_log_retention_days() -> u32 { 30 } -fn default_influx_version() -> String { "2".into() } -fn default_influx_database() -> String { "gree_controller".into() } -fn default_influx_threshold_days() -> u32 { 30 } -fn default_night_start() -> String { "22:00".into() } -fn default_night_end() -> String { "06:00".into() } -fn default_night_max_fan_speed() -> u8 { 1 } -fn default_group_power_enabled() -> bool { true } -fn default_temporary_tolerance() -> f64 { 0.3 } -fn default_temporary_start_kind() -> String { "now".into() } -fn default_temporary_state() -> String { "scheduled".into() } - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Device { - pub id: String, - pub mac: String, - pub name: String, - pub ip: String, - #[serde(default = "default_port")] - pub port: u16, - #[serde(default = "default_protocol")] - pub protocol_version: u8, - #[serde(default)] - pub model: String, - #[serde(default)] - pub firmware: String, - #[serde(default)] - pub key: Option, - #[serde(default)] - pub cid: Option, - #[serde(default = "default_true")] - pub enabled: bool, - #[serde(default)] - pub simulated: bool, - #[serde(default)] - pub power: bool, - #[serde(default = "default_mode")] - pub mode: String, - #[serde(default = "default_target")] - pub target_temperature: f64, - #[serde(default = "default_fan")] - pub fan_speed: u8, - #[serde(default)] - pub swing_vertical: bool, - #[serde(default)] - pub swing_horizontal: bool, - #[serde(default)] - pub quiet: bool, - #[serde(default)] - pub turbo: bool, - #[serde(default)] - pub light: bool, - /// Optional GREE feature states. Support is learned from status responses. - #[serde(default)] - pub air: bool, - #[serde(default)] - pub xfan: bool, - #[serde(default)] - pub health: bool, - #[serde(default)] - pub sleep: bool, - #[serde(default)] - pub supports_light: Option, - #[serde(default)] - pub supports_quiet: Option, - #[serde(default)] - pub supports_turbo: Option, - #[serde(default)] - pub supports_air: Option, - #[serde(default)] - pub supports_xfan: Option, - #[serde(default)] - pub supports_health: Option, - #[serde(default)] - pub supports_sleep: Option, - #[serde(default)] - pub current_temperature: Option, - #[serde(default)] - pub outdoor_temperature: Option, - /// Some GREE firmware reports TemSen/OutEnvTem with a +40 C wire offset. - #[serde(default)] - pub temperature_sensor_offset: Option, - #[serde(default)] - pub online: bool, - /// Round-trip time of the latest successful controller communication. - #[serde(default)] - pub response_time_ms: Option, - #[serde(default)] - pub last_seen: Option>, - #[serde(default)] - pub last_error: Option, - #[serde(default)] - pub communication_failures: u8, - pub created_at: DateTime, - pub updated_at: DateTime, -} - -impl Device { - pub fn simulated_default() -> Self { - let now = Utc::now(); - Self { - id: "sim-salon".into(), - mac: "SIM000000001".into(), - name: "Living Room (simulator)".into(), - ip: "127.0.0.1".into(), - port: 7000, - protocol_version: 1, - model: "GREE-SIM".into(), - firmware: "sim-1.0".into(), - key: None, - cid: Some("gree-controller".into()), - enabled: true, - simulated: true, - power: false, - mode: "cool".into(), - target_temperature: 23.0, - fan_speed: 0, - swing_vertical: false, - swing_horizontal: false, - quiet: false, - turbo: false, - light: true, - air: false, - xfan: false, - health: false, - sleep: false, - supports_light: Some(true), - supports_quiet: Some(true), - supports_turbo: Some(true), - supports_air: Some(true), - supports_xfan: Some(true), - supports_health: Some(true), - supports_sleep: Some(true), - current_temperature: Some(26.0), - outdoor_temperature: Some(30.0), - temperature_sensor_offset: Some(false), - online: true, - response_time_ms: Some(0), - last_seen: Some(now), - last_error: None, - communication_failures: 0, - created_at: now, - updated_at: now, - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct DevicePatch { - pub name: Option, - pub ip: Option, - pub port: Option, - pub protocol_version: Option, - pub key: Option>, - pub enabled: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct DeviceCommand { - pub power: Option, - pub mode: Option, - pub target_temperature: Option, - pub fan_speed: Option, - pub swing_vertical: Option, - pub swing_horizontal: Option, - pub quiet: Option, - pub turbo: Option, - pub light: Option, - pub air: Option, - pub xfan: Option, - pub health: Option, - pub sleep: Option, -} - -impl DeviceCommand { - pub fn is_empty(&self) -> bool { - self.power.is_none() && self.mode.is_none() && self.target_temperature.is_none() - && self.fan_speed.is_none() && self.swing_vertical.is_none() && self.swing_horizontal.is_none() - && self.quiet.is_none() && self.turbo.is_none() && self.light.is_none() - && self.air.is_none() && self.xfan.is_none() && self.health.is_none() && self.sleep.is_none() - } - - /// Return only fields that differ from the last known device state. - pub fn changed_from(&self, device: &Device) -> Self { - Self { - power: self.power.filter(|value| *value != device.power), - mode: self.mode.as_ref().filter(|value| value.as_str() != device.mode.as_str()).cloned(), - target_temperature: self.target_temperature.filter(|value| value.clamp(8.0, 30.0).round() != device.target_temperature.clamp(8.0, 30.0).round()), - fan_speed: self.fan_speed.filter(|value| (*value).min(5) != device.fan_speed), - swing_vertical: self.swing_vertical.filter(|value| *value != device.swing_vertical), - swing_horizontal: self.swing_horizontal.filter(|value| *value != device.swing_horizontal), - quiet: self.quiet.filter(|value| *value != device.quiet), - turbo: self.turbo.filter(|value| *value != device.turbo), - light: self.light.filter(|value| *value != device.light), - air: self.air.filter(|value| *value != device.air), - xfan: self.xfan.filter(|value| *value != device.xfan), - health: self.health.filter(|value| *value != device.health), - sleep: self.sleep.filter(|value| *value != device.sleep), - } - } - - pub fn apply(&self, device: &mut Device) { - if let Some(v) = self.power { device.power = v; } - if let Some(v) = &self.mode { device.mode = v.clone(); } - if let Some(v) = self.target_temperature { device.target_temperature = v.clamp(8.0, 30.0).round(); } - if let Some(v) = self.fan_speed { device.fan_speed = v.min(5); } - if let Some(v) = self.swing_vertical { device.swing_vertical = v; } - if let Some(v) = self.swing_horizontal { device.swing_horizontal = v; } - if let Some(v) = self.quiet { device.quiet = v; } - if let Some(v) = self.turbo { device.turbo = v; } - if let Some(v) = self.light { device.light = v; } - if let Some(v) = self.air { device.air = v; } - if let Some(v) = self.xfan { device.xfan = v; } - if let Some(v) = self.health { device.health = v; } - if let Some(v) = self.sleep { device.sleep = v; } - device.updated_at = Utc::now(); - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ManualDeviceBaseline { - pub power: bool, - pub mode: String, - pub target_temperature: f64, - pub fan_speed: u8, - pub quiet: bool, - pub sleep: bool, -} - -impl From<&Device> for ManualDeviceBaseline { - fn from(device: &Device) -> Self { - Self { - power: device.power, - mode: device.mode.clone(), - target_temperature: device.target_temperature, - fan_speed: device.fan_speed, - quiet: device.quiet, - sleep: device.sleep, - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct TemporaryQuickThermostat { - /// now | delay | at. `started_at` is the effective start instant and may be in the future. - #[serde(default = "default_temporary_start_kind")] - pub start_kind: String, - /// duration | until | temperature_reached | temperature_stable | schedule_boundary - pub finish_kind: String, - /// Effective start instant. Future values mean the temporary thermostat is scheduled - /// but does not yet own the zone or block normal schedules/automations. - pub started_at: DateTime, - /// Set when the controller actually activates ownership. Kept separate from started_at - /// so a delayed session can survive restarts without being mistaken for an active one. - #[serde(default)] - pub activated_at: Option>, - /// Explicit lifecycle state: scheduled | waiting_master | paused_manual | active. - #[serde(default = "default_temporary_state")] - pub state: String, - /// Heat/cool mode captured when ownership really starts. It keeps a temporary session - /// independent from later whole-house mode changes until hand-back. - #[serde(default)] - pub active_mode: Option, - /// Zone automation enabled-state from immediately before the actual takeover. A delayed - /// Quick Thermostat may run even when normal automation was disabled, then restore it. - #[serde(default)] - pub restore_zone_enabled: Option, - /// Ordinary local Quick Thermostat state hidden underneath this higher-priority session. - /// It is captured at actual takeover and restored on hand-back. - #[serde(default)] - pub restore_local_thermostat_power: Option, - #[serde(default)] - pub restore_local_thermostat_resume_at: Option>, - #[serde(default)] - pub restore_local_thermostat_zone_enabled: Option, - #[serde(default)] - pub restore_manual_preset: Option, - #[serde(default)] - pub restore_manual_setpoint: Option, - #[serde(default)] - pub restore_manual_override_until: Option>, - /// Hard end for duration/until/schedule-boundary modes. - #[serde(default)] - pub expires_at: Option>, - /// Relative durations are retained so delayed/manual-waiting sessions start their clocks - /// when ownership actually begins rather than at the originally requested wall-clock time. - #[serde(default)] - pub duration_seconds: Option, - #[serde(default)] - pub safety_duration_seconds: Option, - /// Temperature condition used by reached/stable modes. - #[serde(default)] - pub temperature_target: Option, - /// within | at_or_below | at_or_above - #[serde(default)] - pub temperature_operator: Option, - #[serde(default = "default_temporary_tolerance")] - pub tolerance_c: f64, - /// Continuous in-condition time required by temperature_stable. - #[serde(default)] - pub hold_seconds: u64, - /// Set only while fresh consecutive room samples continuously satisfy the condition. - #[serde(default)] - pub condition_started_at: Option>, - /// Timestamp of the last fresh sensor sample used by the condition evaluator. This - /// prevents cached samples and controller downtime from counting as continuous hold time. - #[serde(default)] - pub condition_last_observed_at: Option>, - /// Start of a higher-priority direct/manual pause. Active deadlines are shifted by this - /// pause when ownership returns so hidden manual time is never consumed by the session. - #[serde(default)] - pub paused_at: Option>, - /// Group/house climate changes received while this session owns the zone. They are - /// applied only after hand-back instead of partially overwriting the active session. - #[serde(default)] - pub deferred_mode: Option, - #[serde(default)] - pub deferred_preset: Option, - /// Optional fail-safe for temperature-based modes. - #[serde(default)] - pub safety_expires_at: Option>, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct TemporaryQuickThermostatRequest { - #[serde(default = "default_temporary_start_kind")] - pub start_kind: String, - #[serde(default)] - pub start_delay_minutes: Option, - #[serde(default)] - pub start_at: Option>, - pub finish_kind: String, - #[serde(default)] - pub duration_minutes: Option, - #[serde(default)] - pub until: Option>, - #[serde(default)] - pub target_temperature: Option, - #[serde(default)] - pub temperature_operator: Option, - #[serde(default)] - pub tolerance_c: Option, - #[serde(default)] - pub hold_minutes: Option, - #[serde(default)] - pub max_duration_minutes: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Zone { - pub id: String, - pub name: String, - pub device_id: String, - #[serde(default = "default_true")] - pub enabled: bool, - #[serde(default = "default_mode")] - pub mode: String, - /// When true, the zone follows the global house heating/cooling mode. - #[serde(default = "default_true")] - pub inherit_house_mode: bool, - /// Manual/custom target retained for quick thermostat overrides. - #[serde(default = "default_target")] - pub setpoint: f64, - /// 0 means a zone created before smart profiles; its legacy setpoint remains the comfort target until edited. - #[serde(default)] - pub profile_version: u8, - #[serde(default = "default_cool_comfort")] - pub cool_comfort_setpoint: f64, - #[serde(default = "default_cool_sleep")] - pub cool_sleep_setpoint: f64, - #[serde(default = "default_cool_away")] - pub cool_away_setpoint: f64, - #[serde(default = "default_heat_comfort")] - pub heat_comfort_setpoint: f64, - #[serde(default = "default_heat_sleep")] - pub heat_sleep_setpoint: f64, - #[serde(default = "default_heat_away")] - pub heat_away_setpoint: f64, - #[serde(default = "default_hysteresis")] - pub hysteresis: f64, - #[serde(default = "default_min_cycle")] - pub min_on_seconds: u64, - #[serde(default = "default_min_cycle")] - pub min_off_seconds: u64, - /// Minimum interval between automatic setpoint/fan adjustments. - #[serde(default = "default_min_adjust")] - pub min_adjust_seconds: u64, - /// Difference applied to the AC setpoint while the room is satisfied. - #[serde(default = "default_standby_offset")] - pub standby_offset_c: f64, - /// Let the controller adjust fan speed based on demand and outdoor conditions. - #[serde(default = "default_true")] - pub smart_fan: bool, - #[serde(default = "default_sensor_source")] - pub sensor_source: String, - #[serde(default)] - pub ha_entity_id: Option, - /// Weight of the optional room sensor when sensor_source is `combined`. - #[serde(default = "default_external_sensor_weight")] - pub external_sensor_weight: f64, - /// If GREE and external sensor differ more than this, the controller falls back to GREE. - #[serde(default = "default_max_sensor_difference")] - pub max_sensor_difference: f64, - /// Maximum accepted age of a Home Assistant room sensor sample. - #[serde(default = "default_sensor_stale_after")] - pub sensor_stale_after_seconds: u64, - /// Temperature reported by the GREE indoor sensor during the last zone cycle. - #[serde(default)] - pub device_temperature: Option, - /// Temperature reported by the per-zone external Home Assistant sensor. - #[serde(default)] - pub external_temperature: Option, - /// Temperature actually used by the zone controller. - #[serde(default)] - pub current_temperature: Option, - /// `device`, `external`, `combined`, `device_fallback`, or `device_discrepancy_fallback`. - #[serde(default = "default_control_temperature_source")] - pub control_temperature_source: String, - /// Effective profile currently used by the zone: comfort/sleep/away/custom. - #[serde(default = "default_active_preset")] - pub active_preset: String, - /// Optional user preset override. Cleared automatically at the next schedule boundary. - #[serde(default)] - pub manual_preset: Option, - /// Optional quick-thermostat setpoint override. It does not change the active preset. - #[serde(default)] - pub manual_setpoint: Option, - #[serde(default)] - pub manual_override_until: Option>, - /// Local quick-thermostat power override. None follows group/global power gates; - /// Some(true) runs this zone locally through the full thermostat; Some(false) keeps it locally off. - #[serde(default)] - pub local_thermostat_power: Option, - /// Automatic hand-back deadline after the local quick thermostat is switched OFF. - /// When reached, local ownership and quick profile/setpoint overrides are cleared and - /// the current group/schedule state is evaluated again. - #[serde(default)] - pub local_thermostat_resume_at: Option>, - /// Zone automation enabled-state from before an ordinary local Quick Thermostat takeover. - /// Kept separate from the temporary-session restore state. - #[serde(default)] - pub local_thermostat_restore_zone_enabled: Option, - /// Separate, user-defined temporary Quick Thermostat session. This is intentionally - /// independent from local_thermostat_resume_at, which belongs to the local-OFF - /// hand-back mechanism. - #[serde(default)] - pub temporary_quick_thermostat: Option, - /// True when the physical unit was changed outside the thermostat engine (for example by IR remote). - /// While active, normal zone/group/schedule automation observes the unit but does not overwrite it. - #[serde(default)] - pub device_manual_override: bool, - #[serde(default)] - pub device_manual_override_since: Option>, - #[serde(default)] - pub device_manual_override_until: Option>, - /// Climate-relevant fields changed during the current external/manual takeover. - #[serde(default)] - pub device_manual_override_fields: Vec, - /// Controller-observed climate state immediately before the takeover started. - /// It lets us drop a stale "resume automation" prompt when the user restores that state. - #[serde(default)] - pub device_manual_override_baseline: Option, - /// Monotonic configuration/control revision used for optimistic concurrency. - #[serde(default)] - pub revision: u64, - /// Normalized control ownership exposed consistently to API/Web/Home Assistant. - #[serde(default)] - pub control_owner: String, - #[serde(default)] - pub control_source: String, - #[serde(default)] - pub control_since: Option>, - #[serde(default)] - pub control_resume_at: Option>, - #[serde(default)] - pub control_reason: String, - /// Last physical power/mode transition timestamps used by compressor lockout. - #[serde(default)] - pub last_power_change_at: Option>, - #[serde(default)] - pub last_mode_change_at: Option>, - #[serde(default)] - pub lockout_until: Option>, - #[serde(default)] - pub lockout_reason: Option, - #[serde(default)] - pub effective_mode: String, - #[serde(default)] - pub effective_setpoint: Option, - #[serde(default)] - pub device_setpoint: Option, - #[serde(default)] - pub demand: bool, - #[serde(default)] - pub demand_since: Option>, - #[serde(default)] - pub target_alerted_at: Option>, - #[serde(default)] - pub last_action_at: Option>, - pub created_at: DateTime, - pub updated_at: DateTime, -} - -fn default_sensor_source() -> String { "device".into() } - - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ClimateGroup { - pub id: String, - pub name: String, - #[serde(default)] - pub zone_ids: Vec, - #[serde(default = "default_group_power_enabled")] - pub power_enabled: bool, - pub created_at: DateTime, - pub updated_at: DateTime, -} - -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct GroupControlPatch { - #[serde(default)] - pub power: Option, - /// house follows the global house mode; cool/heat set an explicit mode on every member zone. - #[serde(default)] - pub mode: Option, - /// auto clears temporary overrides; comfort/sleep/away apply a temporary preset to every member zone. - #[serde(default)] - pub preset: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct ZoneControlPatch { - #[serde(default)] - pub setpoint: Option, - /// Local quick-thermostat power. This is thermostat ownership, not direct device/pilot control. - #[serde(default)] - pub power: Option, - #[serde(default)] - pub mode: Option, - #[serde(default)] - pub enabled: Option, - /// `auto` clears the override; comfort/sleep/away/custom create a temporary override. - #[serde(default)] - pub preset: Option, - #[serde(default)] - pub clear_override: Option, - /// Explicitly hand control of a manually overridden physical unit back to the thermostat engine. - #[serde(default)] - pub clear_device_manual_override: Option, - /// Return a locally forced quick thermostat to normal group/schedule ownership. - #[serde(default)] - pub clear_local_thermostat_override: Option, - /// Start or replace a temporary Quick Thermostat session. - #[serde(default)] - pub temporary_quick_thermostat: Option, - /// Stop only the temporary Quick Thermostat session and return to automation. - #[serde(default)] - pub clear_temporary_quick_thermostat: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Schedule { - pub id: String, - pub zone_id: String, - pub name: String, - #[serde(default = "default_true")] - pub enabled: bool, - /// ISO weekday numbers, Monday=1, Sunday=7. - pub weekdays: Vec, - /// Local time HH:MM. - pub start_time: String, - /// Local time HH:MM. Ranges crossing midnight are supported. - pub end_time: String, - /// comfort/sleep/away/custom. Non-custom profiles resolve their target from the zone. - #[serde(default = "default_schedule_preset")] - pub preset: String, - pub setpoint: f64, - pub created_at: DateTime, - pub updated_at: DateTime, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Automation { - pub id: String, - pub name: String, - #[serde(default = "default_true")] - pub enabled: bool, - /// temperature_above, temperature_below, time - pub trigger_kind: String, - #[serde(default)] - pub trigger_device_id: Option, - #[serde(default)] - pub threshold: Option, - #[serde(default)] - pub at_time: Option, - /// Legacy/direct-device target. Empty when this automation targets a group. - #[serde(default)] - pub action_device_id: String, - /// Optional climate group target. When set, the action is applied to every member zone/device. - #[serde(default)] - pub action_group_id: Option, - /// Optional thermostat preset used only for group actions. - #[serde(default)] - pub action_preset: Option, - #[serde(default)] - pub action: DeviceCommand, - #[serde(default = "default_cooldown")] - pub cooldown_seconds: u64, - #[serde(default)] - pub last_fired_at: Option>, - pub created_at: DateTime, - pub updated_at: DateTime, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Reading { - pub id: i64, - pub device_id: String, - pub timestamp: DateTime, - pub indoor_temperature: Option, - pub outdoor_temperature: Option, - pub target_temperature: f64, - pub power: bool, - pub source: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ZoneReading { - pub id: i64, - pub zone_id: String, - pub device_id: String, - pub timestamp: DateTime, - pub gree_temperature: Option, - pub external_temperature: Option, - pub control_temperature: Option, - pub target_temperature: Option, - pub device_setpoint: Option, - pub outdoor_temperature: Option, - pub power: bool, - pub mode: String, - pub fan_speed: u8, - pub demand: bool, - pub control_source: String, - pub active_preset: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct HaReading { - pub id: i64, - pub entity_id: String, - pub zone_id: Option, - pub kind: String, - pub timestamp: DateTime, - pub temperature: f64, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct EventLog { - pub id: i64, - pub timestamp: DateTime, - pub level: String, - pub kind: String, - pub message: String, - pub metadata: Value, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct HomeAssistantSettings { - #[serde(default)] - pub url: String, - #[serde(default)] - pub token: String, - #[serde(default)] - pub default_entity_id: String, - /// Optional outdoor temperature sensor used only as an assist signal. - #[serde(default)] - pub outdoor_entity_id: String, - /// Maximum accepted age of Home Assistant sensor samples. - #[serde(default = "default_sensor_stale_after")] - pub sensor_stale_after_seconds: u64, - /// Accept self-signed/expired certificates for local Home Assistant HTTPS. - #[serde(default)] - pub allow_invalid_tls: bool, - /// Friendly labels used only by the controller UI/charts; entity_id remains the storage key. - #[serde(default)] - pub sensor_aliases: BTreeMap, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct InfluxDbSettings { - #[serde(default)] - pub enabled: bool, - /// InfluxDB API generation: `1` or `2`. - #[serde(default = "default_influx_version")] - pub version: String, - #[serde(default)] - pub url: String, - /// InfluxDB 1.x database name. - #[serde(default = "default_influx_database")] - pub database: String, - #[serde(default)] - pub username: String, - #[serde(default)] - pub password: String, - /// InfluxDB 2.x organization. - #[serde(default)] - pub org: String, - /// InfluxDB 2.x bucket. - #[serde(default = "default_influx_database")] - pub bucket: String, - #[serde(default)] - pub token: String, - /// Queries older than this age are read from InfluxDB when it is enabled. - #[serde(default = "default_influx_threshold_days")] - pub history_threshold_days: u32, -} - -impl Default for InfluxDbSettings { - fn default() -> Self { - Self { - enabled: false, - version: default_influx_version(), - url: String::new(), - database: default_influx_database(), - username: String::new(), - password: String::new(), - org: String::new(), - bucket: default_influx_database(), - token: String::new(), - history_threshold_days: default_influx_threshold_days(), - } - } -} - - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct NotificationAlertTypes { - /// Home Assistant sensor exceeded the configured freshness window. - #[serde(default = "default_true")] - pub stale_sensor: bool, - /// Other Home Assistant sensor errors (missing/unavailable/invalid value). - #[serde(default = "default_true")] - pub sensor_errors: bool, - /// Device connectivity, polling and communication problems. - #[serde(default = "default_true")] - pub communication: bool, - /// Zone failed to reach its target within the configured timeout. - #[serde(default = "default_true")] - pub target_timeout: bool, - /// Automation execution errors and conflicts. - #[serde(default = "default_true")] - pub automation: bool, - /// Thermostat/group/device control failures and sensor discrepancies. - #[serde(default = "default_true")] - pub control_errors: bool, - /// Informational state changes sent when notification mode is "important". - #[serde(default = "default_true")] - pub important_events: bool, - /// Any warning/error not matched by one of the categories above. - #[serde(default = "default_true")] - pub other: bool, -} - -impl Default for NotificationAlertTypes { - fn default() -> Self { - Self { - stale_sensor: true, sensor_errors: true, communication: true, target_timeout: true, - automation: true, control_errors: true, important_events: true, other: true, - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct NotificationSettings { - #[serde(default)] - pub enabled: bool, - /// problems = warnings/errors/anomalies, important = problems plus important state changes. - #[serde(default = "default_notification_mode")] - pub mode: String, - /// pushover, slack, discord - #[serde(default = "default_notification_provider")] - pub provider: String, - #[serde(default)] - pub pushover_app_token: String, - #[serde(default)] - pub pushover_user_key: String, - #[serde(default)] - pub slack_webhook_url: String, - #[serde(default)] - pub discord_webhook_url: String, - #[serde(default = "default_notification_cooldown")] - pub cooldown_seconds: u64, - #[serde(default = "default_notification_failure_threshold")] - pub communication_failure_threshold: u32, - #[serde(default = "default_notification_target_timeout")] - pub target_timeout_minutes: u32, - /// Fine-grained selection of which alert categories may be delivered. - #[serde(default)] - pub alert_types: NotificationAlertTypes, -} - -fn default_notification_mode() -> String { "problems".into() } -fn default_notification_provider() -> String { "pushover".into() } -fn default_notification_cooldown() -> u64 { 300 } -fn default_notification_failure_threshold() -> u32 { 3 } -fn default_notification_target_timeout() -> u32 { 60 } - -impl Default for NotificationSettings { - fn default() -> Self { - Self { - enabled: false, mode: default_notification_mode(), provider: default_notification_provider(), - pushover_app_token: String::new(), pushover_user_key: String::new(), - slack_webhook_url: String::new(), discord_webhook_url: String::new(), - cooldown_seconds: default_notification_cooldown(), - communication_failure_threshold: default_notification_failure_threshold(), - target_timeout_minutes: default_notification_target_timeout(), - alert_types: NotificationAlertTypes::default(), - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct DebugSettings { - #[serde(default)] - pub overlay_enabled: bool, - #[serde(default)] - pub gree_frames: bool, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct NightModeSettings { - #[serde(default)] - pub enabled: bool, - #[serde(default = "default_night_start")] - pub start_time: String, - #[serde(default = "default_night_end")] - pub end_time: String, - /// Maximum fan speed used by thermostat control during night hours (1=Low..5=High). - #[serde(default = "default_night_max_fan_speed")] - pub max_fan_speed: u8, - /// Ask compatible GREE units to keep Quiet enabled during the whole night window. - #[serde(default = "default_true")] - pub force_quiet: bool, - /// Use the unit's native Sleep function during the night window when it is supported. - #[serde(default = "default_true")] - pub use_native_sleep: bool, -} - -impl Default for NightModeSettings { - fn default() -> Self { - Self { - enabled: false, - start_time: default_night_start(), - end_time: default_night_end(), - max_fan_speed: default_night_max_fan_speed(), - force_quiet: true, - use_native_sleep: true, - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ConfigurationExport { - pub format_version: u32, - pub exported_at: DateTime, - pub settings: RuntimeSettings, - pub devices: Vec, - pub zones: Vec, - #[serde(default)] - pub groups: Vec, - pub schedules: Vec, - pub automations: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ControlPlanEvent { - pub at: DateTime, - pub kind: String, - pub label: String, - pub preset: Option, - pub target_temperature: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ZoneControlPlan { - pub zone_id: String, - pub zone_name: String, - pub device_id: String, - pub device_name: String, - /// Configured per-zone enable switch. Group/master gates are reported separately. - pub enabled: bool, - /// True when the configured zone is not currently blocked by a disabled climate group. - pub effective_enabled: bool, - /// Effective mode currently used by the controller. - pub mode: String, - /// Configured zone mode before house-mode inheritance is resolved. - pub configured_mode: String, - pub inherit_house_mode: bool, - /// Profile resolved from a manual override or the active schedule. - pub preset: String, - /// Explicit per-zone profile override; None means Auto schedule. - pub preset_override: Option, - pub current_temperature: Option, - pub target_temperature: Option, - pub device_setpoint: Option, - pub desired_power: bool, - pub desired_mode: String, - pub actual_power: Option, - pub actual_mode: Option, - pub actual_setpoint: Option, - pub demand: bool, - pub control_source: String, - pub manual_override_until: Option>, - pub local_thermostat_power: Option, - pub local_thermostat_resume_at: Option>, - pub device_manual_override: bool, - pub device_manual_override_until: Option>, - pub control_owner: String, - pub control_command_source: String, - pub control_since: Option>, - pub resume_at: Option>, - pub control_reason: String, - pub blocked_reason: Option, - pub lockout_until: Option>, - pub current_schedule_id: Option, - pub current_schedule_name: Option, - pub next_events: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AutomationPlanRule { - pub id: String, - pub name: String, - pub enabled: bool, - pub trigger_kind: String, - pub trigger_device_id: Option, - pub trigger_device_name: Option, - pub threshold: Option, - pub at_time: Option, - pub action_device_id: String, - pub action_device_name: String, - #[serde(default)] - pub action_group_id: Option, - #[serde(default)] - pub action_group_name: Option, - #[serde(default)] - pub action_preset: Option, - pub action: DeviceCommand, - pub last_fired_at: Option>, - pub next_ready_at: Option>, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ControlPlan { - pub generated_at: DateTime, - pub house_mode: String, - /// Uniform whole-house preset when every zone uses the same override; None for a mixed state. - pub house_preset: Option, - /// Whole-house master power state. - pub house_power: bool, - pub outdoor_temperature: Option, - pub control_strategy: String, - pub night_mode_active: bool, - pub night_mode_start: String, - pub night_mode_end: String, - pub night_mode_max_fan_speed: u8, - pub next_events: Vec, - pub zones: Vec, - pub rules: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct RuntimeSettings { - pub controller_id: String, - pub simulator_enabled: bool, - pub poll_interval_seconds: u64, - pub zone_interval_seconds: u64, - pub discovery_timeout_ms: u64, - pub discovery_broadcast: String, - /// Global seasonal mode. Zones follow this by default. Values: cool/heat/off; off pauses house-level thermostat control. - #[serde(default = "default_house_mode")] - pub house_mode: String, - /// Whole-house master power. False is authoritative and suppresses zone/automation restarts. - #[serde(default = "default_true")] - pub house_power_enabled: bool, - /// `setpoint` keeps units powered and modulates compressor demand by changing target temperature. - #[serde(default = "default_control_strategy")] - pub control_strategy: String, - #[serde(default = "default_true")] - pub outdoor_assist_enabled: bool, - /// Keep recent metrics locally; older history may live in InfluxDB. - #[serde(default = "default_history_retention_days")] - pub history_retention_days: u32, - #[serde(default = "default_true")] - pub history_compaction_enabled: bool, - /// Retention window for controller event/debug log rows. - #[serde(default = "default_event_log_retention_days")] - pub event_log_retention_days: u32, - /// Add protocol-specific buzzer suppression fields to command frames. - #[serde(default)] - pub suppress_device_beep: bool, - #[serde(default)] - pub influxdb: InfluxDbSettings, - #[serde(default)] - pub debug: DebugSettings, - #[serde(default)] - pub night_mode: NightModeSettings, - #[serde(default)] - pub notifications: NotificationSettings, - pub home_assistant: HomeAssistantSettings, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct DiscoveryRequest { - #[serde(default)] - pub timeout_ms: Option, - #[serde(default)] - pub broadcast: Option, - /// 0 = auto (accept both), 1 = AES-ECB only, 2 = AES-GCM only. - #[serde(default)] - pub protocol_version: Option, - /// Number of scan broadcasts sent during one discovery operation. - #[serde(default)] - pub passes: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ManualDeviceRequest { - pub name: String, - pub mac: String, - pub ip: String, - #[serde(default = "default_port")] - pub port: u16, - #[serde(default = "default_protocol")] - pub protocol_version: u8, - #[serde(default)] - pub key: Option, - #[serde(default)] - pub simulated: bool, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ApiTokenInfo { - pub id: String, - pub name: String, - pub token_prefix: String, - pub created_at: DateTime, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ApiEvent { - pub event: String, - pub timestamp: DateTime, - pub data: Value, -} +// Functional source split intentionally keeps items in the existing module namespace. +include!("models/defaults.rs"); +include!("models/device.rs"); +include!("models/temporary_thermostat.rs"); +include!("models/zone.rs"); +include!("models/automation.rs"); +include!("models/history.rs"); +include!("models/integrations.rs"); +include!("models/control_plan.rs"); +include!("models/runtime.rs"); diff --git a/src/models/automation.rs b/src/models/automation.rs new file mode 100644 index 0000000..35424d9 --- /dev/null +++ b/src/models/automation.rs @@ -0,0 +1,54 @@ +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Schedule { + pub id: String, + pub zone_id: String, + pub name: String, + #[serde(default = "default_true")] + pub enabled: bool, + /// ISO weekday numbers, Monday=1, Sunday=7. + pub weekdays: Vec, + /// Local time HH:MM. + pub start_time: String, + /// Local time HH:MM. Ranges crossing midnight are supported. + pub end_time: String, + /// comfort/sleep/away/custom. Non-custom profiles resolve their target from the zone. + #[serde(default = "default_schedule_preset")] + pub preset: String, + pub setpoint: f64, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Automation { + pub id: String, + pub name: String, + #[serde(default = "default_true")] + pub enabled: bool, + /// temperature_above, temperature_below, time + pub trigger_kind: String, + #[serde(default)] + pub trigger_device_id: Option, + #[serde(default)] + pub threshold: Option, + #[serde(default)] + pub at_time: Option, + /// Legacy/direct-device target. Empty when this automation targets a group. + #[serde(default)] + pub action_device_id: String, + /// Optional climate group target. When set, the action is applied to every member zone/device. + #[serde(default)] + pub action_group_id: Option, + /// Optional thermostat preset used only for group actions. + #[serde(default)] + pub action_preset: Option, + #[serde(default)] + pub action: DeviceCommand, + #[serde(default = "default_cooldown")] + pub cooldown_seconds: u64, + #[serde(default)] + pub last_fired_at: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, +} + diff --git a/src/models/control_plan.rs b/src/models/control_plan.rs new file mode 100644 index 0000000..26dacb2 --- /dev/null +++ b/src/models/control_plan.rs @@ -0,0 +1,110 @@ +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ConfigurationExport { + pub format_version: u32, + pub exported_at: DateTime, + pub settings: RuntimeSettings, + pub devices: Vec, + pub zones: Vec, + #[serde(default)] + pub groups: Vec, + pub schedules: Vec, + pub automations: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ControlPlanEvent { + pub at: DateTime, + pub kind: String, + pub label: String, + pub preset: Option, + pub target_temperature: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ZoneControlPlan { + pub zone_id: String, + pub zone_name: String, + pub device_id: String, + pub device_name: String, + /// Configured per-zone enable switch. Group/master gates are reported separately. + pub enabled: bool, + /// True when the configured zone is not currently blocked by a disabled climate group. + pub effective_enabled: bool, + /// Effective mode currently used by the controller. + pub mode: String, + /// Configured zone mode before house-mode inheritance is resolved. + pub configured_mode: String, + pub inherit_house_mode: bool, + /// Profile resolved from a manual override or the active schedule. + pub preset: String, + /// Explicit per-zone profile override; None means Auto schedule. + pub preset_override: Option, + pub current_temperature: Option, + pub target_temperature: Option, + pub device_setpoint: Option, + pub desired_power: bool, + pub desired_mode: String, + pub actual_power: Option, + pub actual_mode: Option, + pub actual_setpoint: Option, + pub demand: bool, + pub control_source: String, + pub manual_override_until: Option>, + pub local_thermostat_power: Option, + pub local_thermostat_resume_at: Option>, + pub device_manual_override: bool, + pub device_manual_override_until: Option>, + pub control_owner: String, + pub control_command_source: String, + pub control_since: Option>, + pub resume_at: Option>, + pub control_reason: String, + pub blocked_reason: Option, + pub lockout_until: Option>, + pub current_schedule_id: Option, + pub current_schedule_name: Option, + pub next_events: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AutomationPlanRule { + pub id: String, + pub name: String, + pub enabled: bool, + pub trigger_kind: String, + pub trigger_device_id: Option, + pub trigger_device_name: Option, + pub threshold: Option, + pub at_time: Option, + pub action_device_id: String, + pub action_device_name: String, + #[serde(default)] + pub action_group_id: Option, + #[serde(default)] + pub action_group_name: Option, + #[serde(default)] + pub action_preset: Option, + pub action: DeviceCommand, + pub last_fired_at: Option>, + pub next_ready_at: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ControlPlan { + pub generated_at: DateTime, + pub house_mode: String, + /// Uniform whole-house preset when every zone uses the same override; None for a mixed state. + pub house_preset: Option, + /// Whole-house master power state. + pub house_power: bool, + pub outdoor_temperature: Option, + pub control_strategy: String, + pub night_mode_active: bool, + pub night_mode_start: String, + pub night_mode_end: String, + pub night_mode_max_fan_speed: u8, + pub next_events: Vec, + pub zones: Vec, + pub rules: Vec, +} + diff --git a/src/models/defaults.rs b/src/models/defaults.rs new file mode 100644 index 0000000..0fa13ff --- /dev/null +++ b/src/models/defaults.rs @@ -0,0 +1,38 @@ +fn default_true() -> bool { true } +fn default_port() -> u16 { 7000 } +fn default_protocol() -> u8 { 0 } +fn default_mode() -> String { "cool".into() } +fn default_fan() -> u8 { 0 } +fn default_target() -> f64 { 24.0 } +fn default_hysteresis() -> f64 { 0.6 } +fn default_external_sensor_weight() -> f64 { 0.4 } +fn default_max_sensor_difference() -> f64 { 3.0 } +fn default_control_temperature_source() -> String { "device".into() } +fn default_min_cycle() -> u64 { 180 } +fn default_sensor_stale_after() -> u64 { 300 } +fn default_cooldown() -> u64 { 300 } +fn default_house_mode() -> String { "cool".into() } +fn default_control_strategy() -> String { "setpoint".into() } +fn default_standby_offset() -> f64 { 2.0 } +fn default_min_adjust() -> u64 { 120 } +fn default_schedule_preset() -> String { "custom".into() } +fn default_active_preset() -> String { "comfort".into() } +fn default_cool_comfort() -> f64 { 23.0 } +fn default_cool_sleep() -> f64 { 24.5 } +fn default_cool_away() -> f64 { 27.0 } +fn default_heat_comfort() -> f64 { 21.0 } +fn default_heat_sleep() -> f64 { 19.0 } +fn default_heat_away() -> f64 { 17.0 } +fn default_history_retention_days() -> u32 { 30 } +fn default_event_log_retention_days() -> u32 { 30 } +fn default_influx_version() -> String { "2".into() } +fn default_influx_database() -> String { "gree_controller".into() } +fn default_influx_threshold_days() -> u32 { 30 } +fn default_night_start() -> String { "22:00".into() } +fn default_night_end() -> String { "06:00".into() } +fn default_night_max_fan_speed() -> u8 { 1 } +fn default_group_power_enabled() -> bool { true } +fn default_temporary_tolerance() -> f64 { 0.3 } +fn default_temporary_start_kind() -> String { "now".into() } +fn default_temporary_state() -> String { "scheduled".into() } + diff --git a/src/models/device.rs b/src/models/device.rs new file mode 100644 index 0000000..d7fa679 --- /dev/null +++ b/src/models/device.rs @@ -0,0 +1,230 @@ +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Device { + pub id: String, + pub mac: String, + pub name: String, + pub ip: String, + #[serde(default = "default_port")] + pub port: u16, + #[serde(default = "default_protocol")] + pub protocol_version: u8, + #[serde(default)] + pub model: String, + #[serde(default)] + pub firmware: String, + #[serde(default)] + pub key: Option, + #[serde(default)] + pub cid: Option, + #[serde(default = "default_true")] + pub enabled: bool, + #[serde(default)] + pub simulated: bool, + #[serde(default)] + pub power: bool, + #[serde(default = "default_mode")] + pub mode: String, + #[serde(default = "default_target")] + pub target_temperature: f64, + #[serde(default = "default_fan")] + pub fan_speed: u8, + #[serde(default)] + pub swing_vertical: bool, + #[serde(default)] + pub swing_horizontal: bool, + #[serde(default)] + pub quiet: bool, + #[serde(default)] + pub turbo: bool, + #[serde(default)] + pub light: bool, + /// Optional GREE feature states. Support is learned from status responses. + #[serde(default)] + pub air: bool, + #[serde(default)] + pub xfan: bool, + #[serde(default)] + pub health: bool, + #[serde(default)] + pub sleep: bool, + #[serde(default)] + pub supports_light: Option, + #[serde(default)] + pub supports_quiet: Option, + #[serde(default)] + pub supports_turbo: Option, + #[serde(default)] + pub supports_air: Option, + #[serde(default)] + pub supports_xfan: Option, + #[serde(default)] + pub supports_health: Option, + #[serde(default)] + pub supports_sleep: Option, + #[serde(default)] + pub current_temperature: Option, + #[serde(default)] + pub outdoor_temperature: Option, + /// Some GREE firmware reports TemSen/OutEnvTem with a +40 C wire offset. + #[serde(default)] + pub temperature_sensor_offset: Option, + #[serde(default)] + pub online: bool, + /// Round-trip time of the latest successful controller communication. + #[serde(default)] + pub response_time_ms: Option, + #[serde(default)] + pub last_seen: Option>, + #[serde(default)] + pub last_error: Option, + #[serde(default)] + pub communication_failures: u8, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +impl Device { + pub fn simulated_default() -> Self { + let now = Utc::now(); + Self { + id: "sim-salon".into(), + mac: "SIM000000001".into(), + name: "Living Room (simulator)".into(), + ip: "127.0.0.1".into(), + port: 7000, + protocol_version: 1, + model: "GREE-SIM".into(), + firmware: "sim-1.0".into(), + key: None, + cid: Some("gree-controller".into()), + enabled: true, + simulated: true, + power: false, + mode: "cool".into(), + target_temperature: 23.0, + fan_speed: 0, + swing_vertical: false, + swing_horizontal: false, + quiet: false, + turbo: false, + light: true, + air: false, + xfan: false, + health: false, + sleep: false, + supports_light: Some(true), + supports_quiet: Some(true), + supports_turbo: Some(true), + supports_air: Some(true), + supports_xfan: Some(true), + supports_health: Some(true), + supports_sleep: Some(true), + current_temperature: Some(26.0), + outdoor_temperature: Some(30.0), + temperature_sensor_offset: Some(false), + online: true, + response_time_ms: Some(0), + last_seen: Some(now), + last_error: None, + communication_failures: 0, + created_at: now, + updated_at: now, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct DevicePatch { + pub name: Option, + pub ip: Option, + pub port: Option, + pub protocol_version: Option, + pub key: Option>, + pub enabled: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct DeviceCommand { + pub power: Option, + pub mode: Option, + pub target_temperature: Option, + pub fan_speed: Option, + pub swing_vertical: Option, + pub swing_horizontal: Option, + pub quiet: Option, + pub turbo: Option, + pub light: Option, + pub air: Option, + pub xfan: Option, + pub health: Option, + pub sleep: Option, +} + +impl DeviceCommand { + pub fn is_empty(&self) -> bool { + self.power.is_none() && self.mode.is_none() && self.target_temperature.is_none() + && self.fan_speed.is_none() && self.swing_vertical.is_none() && self.swing_horizontal.is_none() + && self.quiet.is_none() && self.turbo.is_none() && self.light.is_none() + && self.air.is_none() && self.xfan.is_none() && self.health.is_none() && self.sleep.is_none() + } + + /// Return only fields that differ from the last known device state. + pub fn changed_from(&self, device: &Device) -> Self { + Self { + power: self.power.filter(|value| *value != device.power), + mode: self.mode.as_ref().filter(|value| value.as_str() != device.mode.as_str()).cloned(), + target_temperature: self.target_temperature.filter(|value| value.clamp(8.0, 30.0).round() != device.target_temperature.clamp(8.0, 30.0).round()), + fan_speed: self.fan_speed.filter(|value| (*value).min(5) != device.fan_speed), + swing_vertical: self.swing_vertical.filter(|value| *value != device.swing_vertical), + swing_horizontal: self.swing_horizontal.filter(|value| *value != device.swing_horizontal), + quiet: self.quiet.filter(|value| *value != device.quiet), + turbo: self.turbo.filter(|value| *value != device.turbo), + light: self.light.filter(|value| *value != device.light), + air: self.air.filter(|value| *value != device.air), + xfan: self.xfan.filter(|value| *value != device.xfan), + health: self.health.filter(|value| *value != device.health), + sleep: self.sleep.filter(|value| *value != device.sleep), + } + } + + pub fn apply(&self, device: &mut Device) { + if let Some(v) = self.power { device.power = v; } + if let Some(v) = &self.mode { device.mode = v.clone(); } + if let Some(v) = self.target_temperature { device.target_temperature = v.clamp(8.0, 30.0).round(); } + if let Some(v) = self.fan_speed { device.fan_speed = v.min(5); } + if let Some(v) = self.swing_vertical { device.swing_vertical = v; } + if let Some(v) = self.swing_horizontal { device.swing_horizontal = v; } + if let Some(v) = self.quiet { device.quiet = v; } + if let Some(v) = self.turbo { device.turbo = v; } + if let Some(v) = self.light { device.light = v; } + if let Some(v) = self.air { device.air = v; } + if let Some(v) = self.xfan { device.xfan = v; } + if let Some(v) = self.health { device.health = v; } + if let Some(v) = self.sleep { device.sleep = v; } + device.updated_at = Utc::now(); + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ManualDeviceBaseline { + pub power: bool, + pub mode: String, + pub target_temperature: f64, + pub fan_speed: u8, + pub quiet: bool, + pub sleep: bool, +} + +impl From<&Device> for ManualDeviceBaseline { + fn from(device: &Device) -> Self { + Self { + power: device.power, + mode: device.mode.clone(), + target_temperature: device.target_temperature, + fan_speed: device.fan_speed, + quiet: device.quiet, + sleep: device.sleep, + } + } +} + diff --git a/src/models/history.rs b/src/models/history.rs new file mode 100644 index 0000000..06c0c40 --- /dev/null +++ b/src/models/history.rs @@ -0,0 +1,52 @@ +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Reading { + pub id: i64, + pub device_id: String, + pub timestamp: DateTime, + pub indoor_temperature: Option, + pub outdoor_temperature: Option, + pub target_temperature: f64, + pub power: bool, + pub source: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ZoneReading { + pub id: i64, + pub zone_id: String, + pub device_id: String, + pub timestamp: DateTime, + pub gree_temperature: Option, + pub external_temperature: Option, + pub control_temperature: Option, + pub target_temperature: Option, + pub device_setpoint: Option, + pub outdoor_temperature: Option, + pub power: bool, + pub mode: String, + pub fan_speed: u8, + pub demand: bool, + pub control_source: String, + pub active_preset: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HaReading { + pub id: i64, + pub entity_id: String, + pub zone_id: Option, + pub kind: String, + pub timestamp: DateTime, + pub temperature: f64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EventLog { + pub id: i64, + pub timestamp: DateTime, + pub level: String, + pub kind: String, + pub message: String, + pub metadata: Value, +} + diff --git a/src/models/integrations.rs b/src/models/integrations.rs new file mode 100644 index 0000000..629c8b4 --- /dev/null +++ b/src/models/integrations.rs @@ -0,0 +1,195 @@ +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HomeAssistantSettings { + #[serde(default)] + pub url: String, + #[serde(default)] + pub token: String, + #[serde(default)] + pub default_entity_id: String, + /// Optional outdoor temperature sensor used only as an assist signal. + #[serde(default)] + pub outdoor_entity_id: String, + /// Maximum accepted age of Home Assistant sensor samples. + #[serde(default = "default_sensor_stale_after")] + pub sensor_stale_after_seconds: u64, + /// Accept self-signed/expired certificates for local Home Assistant HTTPS. + #[serde(default)] + pub allow_invalid_tls: bool, + /// Friendly labels used only by the controller UI/charts; entity_id remains the storage key. + #[serde(default)] + pub sensor_aliases: BTreeMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InfluxDbSettings { + #[serde(default)] + pub enabled: bool, + /// InfluxDB API generation: `1` or `2`. + #[serde(default = "default_influx_version")] + pub version: String, + #[serde(default)] + pub url: String, + /// InfluxDB 1.x database name. + #[serde(default = "default_influx_database")] + pub database: String, + #[serde(default)] + pub username: String, + #[serde(default)] + pub password: String, + /// InfluxDB 2.x organization. + #[serde(default)] + pub org: String, + /// InfluxDB 2.x bucket. + #[serde(default = "default_influx_database")] + pub bucket: String, + #[serde(default)] + pub token: String, + /// Queries older than this age are read from InfluxDB when it is enabled. + #[serde(default = "default_influx_threshold_days")] + pub history_threshold_days: u32, +} + +impl Default for InfluxDbSettings { + fn default() -> Self { + Self { + enabled: false, + version: default_influx_version(), + url: String::new(), + database: default_influx_database(), + username: String::new(), + password: String::new(), + org: String::new(), + bucket: default_influx_database(), + token: String::new(), + history_threshold_days: default_influx_threshold_days(), + } + } +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NotificationAlertTypes { + /// Home Assistant sensor exceeded the configured freshness window. + #[serde(default = "default_true")] + pub stale_sensor: bool, + /// Other Home Assistant sensor errors (missing/unavailable/invalid value). + #[serde(default = "default_true")] + pub sensor_errors: bool, + /// Device connectivity, polling and communication problems. + #[serde(default = "default_true")] + pub communication: bool, + /// Zone failed to reach its target within the configured timeout. + #[serde(default = "default_true")] + pub target_timeout: bool, + /// Automation execution errors and conflicts. + #[serde(default = "default_true")] + pub automation: bool, + /// Thermostat/group/device control failures and sensor discrepancies. + #[serde(default = "default_true")] + pub control_errors: bool, + /// Informational state changes sent when notification mode is "important". + #[serde(default = "default_true")] + pub important_events: bool, + /// Any warning/error not matched by one of the categories above. + #[serde(default = "default_true")] + pub other: bool, +} + +impl Default for NotificationAlertTypes { + fn default() -> Self { + Self { + stale_sensor: true, sensor_errors: true, communication: true, target_timeout: true, + automation: true, control_errors: true, important_events: true, other: true, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NotificationSettings { + #[serde(default)] + pub enabled: bool, + /// problems = warnings/errors/anomalies, important = problems plus important state changes. + #[serde(default = "default_notification_mode")] + pub mode: String, + /// pushover, slack, discord + #[serde(default = "default_notification_provider")] + pub provider: String, + #[serde(default)] + pub pushover_app_token: String, + #[serde(default)] + pub pushover_user_key: String, + #[serde(default)] + pub slack_webhook_url: String, + #[serde(default)] + pub discord_webhook_url: String, + #[serde(default = "default_notification_cooldown")] + pub cooldown_seconds: u64, + #[serde(default = "default_notification_failure_threshold")] + pub communication_failure_threshold: u32, + #[serde(default = "default_notification_target_timeout")] + pub target_timeout_minutes: u32, + /// Fine-grained selection of which alert categories may be delivered. + #[serde(default)] + pub alert_types: NotificationAlertTypes, +} + +fn default_notification_mode() -> String { "problems".into() } +fn default_notification_provider() -> String { "pushover".into() } +fn default_notification_cooldown() -> u64 { 300 } +fn default_notification_failure_threshold() -> u32 { 3 } +fn default_notification_target_timeout() -> u32 { 60 } + +impl Default for NotificationSettings { + fn default() -> Self { + Self { + enabled: false, mode: default_notification_mode(), provider: default_notification_provider(), + pushover_app_token: String::new(), pushover_user_key: String::new(), + slack_webhook_url: String::new(), discord_webhook_url: String::new(), + cooldown_seconds: default_notification_cooldown(), + communication_failure_threshold: default_notification_failure_threshold(), + target_timeout_minutes: default_notification_target_timeout(), + alert_types: NotificationAlertTypes::default(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct DebugSettings { + #[serde(default)] + pub overlay_enabled: bool, + #[serde(default)] + pub gree_frames: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NightModeSettings { + #[serde(default)] + pub enabled: bool, + #[serde(default = "default_night_start")] + pub start_time: String, + #[serde(default = "default_night_end")] + pub end_time: String, + /// Maximum fan speed used by thermostat control during night hours (1=Low..5=High). + #[serde(default = "default_night_max_fan_speed")] + pub max_fan_speed: u8, + /// Ask compatible GREE units to keep Quiet enabled during the whole night window. + #[serde(default = "default_true")] + pub force_quiet: bool, + /// Use the unit's native Sleep function during the night window when it is supported. + #[serde(default = "default_true")] + pub use_native_sleep: bool, +} + +impl Default for NightModeSettings { + fn default() -> Self { + Self { + enabled: false, + start_time: default_night_start(), + end_time: default_night_end(), + max_fan_speed: default_night_max_fan_speed(), + force_quiet: true, + use_native_sleep: true, + } + } +} + diff --git a/src/models/runtime.rs b/src/models/runtime.rs new file mode 100644 index 0000000..7c02da9 --- /dev/null +++ b/src/models/runtime.rs @@ -0,0 +1,84 @@ +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RuntimeSettings { + pub controller_id: String, + pub simulator_enabled: bool, + pub poll_interval_seconds: u64, + pub zone_interval_seconds: u64, + pub discovery_timeout_ms: u64, + pub discovery_broadcast: String, + /// Global seasonal mode. Zones follow this by default. Values: cool/heat/off; off pauses house-level thermostat control. + #[serde(default = "default_house_mode")] + pub house_mode: String, + /// Whole-house master power. False is authoritative and suppresses zone/automation restarts. + #[serde(default = "default_true")] + pub house_power_enabled: bool, + /// `setpoint` keeps units powered and modulates compressor demand by changing target temperature. + #[serde(default = "default_control_strategy")] + pub control_strategy: String, + #[serde(default = "default_true")] + pub outdoor_assist_enabled: bool, + /// Keep recent metrics locally; older history may live in InfluxDB. + #[serde(default = "default_history_retention_days")] + pub history_retention_days: u32, + #[serde(default = "default_true")] + pub history_compaction_enabled: bool, + /// Retention window for controller event/debug log rows. + #[serde(default = "default_event_log_retention_days")] + pub event_log_retention_days: u32, + /// Add protocol-specific buzzer suppression fields to command frames. + #[serde(default)] + pub suppress_device_beep: bool, + #[serde(default)] + pub influxdb: InfluxDbSettings, + #[serde(default)] + pub debug: DebugSettings, + #[serde(default)] + pub night_mode: NightModeSettings, + #[serde(default)] + pub notifications: NotificationSettings, + pub home_assistant: HomeAssistantSettings, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DiscoveryRequest { + #[serde(default)] + pub timeout_ms: Option, + #[serde(default)] + pub broadcast: Option, + /// 0 = auto (accept both), 1 = AES-ECB only, 2 = AES-GCM only. + #[serde(default)] + pub protocol_version: Option, + /// Number of scan broadcasts sent during one discovery operation. + #[serde(default)] + pub passes: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ManualDeviceRequest { + pub name: String, + pub mac: String, + pub ip: String, + #[serde(default = "default_port")] + pub port: u16, + #[serde(default = "default_protocol")] + pub protocol_version: u8, + #[serde(default)] + pub key: Option, + #[serde(default)] + pub simulated: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ApiTokenInfo { + pub id: String, + pub name: String, + pub token_prefix: String, + pub created_at: DateTime, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ApiEvent { + pub event: String, + pub timestamp: DateTime, + pub data: Value, +} diff --git a/src/models/temporary_thermostat.rs b/src/models/temporary_thermostat.rs new file mode 100644 index 0000000..694a559 --- /dev/null +++ b/src/models/temporary_thermostat.rs @@ -0,0 +1,106 @@ +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TemporaryQuickThermostat { + /// now | delay | at. `started_at` is the effective start instant and may be in the future. + #[serde(default = "default_temporary_start_kind")] + pub start_kind: String, + /// duration | until | temperature_reached | temperature_stable | schedule_boundary + pub finish_kind: String, + /// Effective start instant. Future values mean the temporary thermostat is scheduled + /// but does not yet own the zone or block normal schedules/automations. + pub started_at: DateTime, + /// Set when the controller actually activates ownership. Kept separate from started_at + /// so a delayed session can survive restarts without being mistaken for an active one. + #[serde(default)] + pub activated_at: Option>, + /// Explicit lifecycle state: scheduled | waiting_master | paused_manual | active. + #[serde(default = "default_temporary_state")] + pub state: String, + /// Heat/cool mode captured when ownership really starts. It keeps a temporary session + /// independent from later whole-house mode changes until hand-back. + #[serde(default)] + pub active_mode: Option, + /// Zone automation enabled-state from immediately before the actual takeover. A delayed + /// Quick Thermostat may run even when normal automation was disabled, then restore it. + #[serde(default)] + pub restore_zone_enabled: Option, + /// Ordinary local Quick Thermostat state hidden underneath this higher-priority session. + /// It is captured at actual takeover and restored on hand-back. + #[serde(default)] + pub restore_local_thermostat_power: Option, + #[serde(default)] + pub restore_local_thermostat_resume_at: Option>, + #[serde(default)] + pub restore_local_thermostat_zone_enabled: Option, + #[serde(default)] + pub restore_manual_preset: Option, + #[serde(default)] + pub restore_manual_setpoint: Option, + #[serde(default)] + pub restore_manual_override_until: Option>, + /// Hard end for duration/until/schedule-boundary modes. + #[serde(default)] + pub expires_at: Option>, + /// Relative durations are retained so delayed/manual-waiting sessions start their clocks + /// when ownership actually begins rather than at the originally requested wall-clock time. + #[serde(default)] + pub duration_seconds: Option, + #[serde(default)] + pub safety_duration_seconds: Option, + /// Temperature condition used by reached/stable modes. + #[serde(default)] + pub temperature_target: Option, + /// within | at_or_below | at_or_above + #[serde(default)] + pub temperature_operator: Option, + #[serde(default = "default_temporary_tolerance")] + pub tolerance_c: f64, + /// Continuous in-condition time required by temperature_stable. + #[serde(default)] + pub hold_seconds: u64, + /// Set only while fresh consecutive room samples continuously satisfy the condition. + #[serde(default)] + pub condition_started_at: Option>, + /// Timestamp of the last fresh sensor sample used by the condition evaluator. This + /// prevents cached samples and controller downtime from counting as continuous hold time. + #[serde(default)] + pub condition_last_observed_at: Option>, + /// Start of a higher-priority direct/manual pause. Active deadlines are shifted by this + /// pause when ownership returns so hidden manual time is never consumed by the session. + #[serde(default)] + pub paused_at: Option>, + /// Group/house climate changes received while this session owns the zone. They are + /// applied only after hand-back instead of partially overwriting the active session. + #[serde(default)] + pub deferred_mode: Option, + #[serde(default)] + pub deferred_preset: Option, + /// Optional fail-safe for temperature-based modes. + #[serde(default)] + pub safety_expires_at: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TemporaryQuickThermostatRequest { + #[serde(default = "default_temporary_start_kind")] + pub start_kind: String, + #[serde(default)] + pub start_delay_minutes: Option, + #[serde(default)] + pub start_at: Option>, + pub finish_kind: String, + #[serde(default)] + pub duration_minutes: Option, + #[serde(default)] + pub until: Option>, + #[serde(default)] + pub target_temperature: Option, + #[serde(default)] + pub temperature_operator: Option, + #[serde(default)] + pub tolerance_c: Option, + #[serde(default)] + pub hold_minutes: Option, + #[serde(default)] + pub max_duration_minutes: Option, +} + diff --git a/src/models/zone.rs b/src/models/zone.rs new file mode 100644 index 0000000..dc0bd0b --- /dev/null +++ b/src/models/zone.rs @@ -0,0 +1,212 @@ +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Zone { + pub id: String, + pub name: String, + pub device_id: String, + #[serde(default = "default_true")] + pub enabled: bool, + #[serde(default = "default_mode")] + pub mode: String, + /// When true, the zone follows the global house heating/cooling mode. + #[serde(default = "default_true")] + pub inherit_house_mode: bool, + /// Manual/custom target retained for quick thermostat overrides. + #[serde(default = "default_target")] + pub setpoint: f64, + /// 0 means a zone created before smart profiles; its legacy setpoint remains the comfort target until edited. + #[serde(default)] + pub profile_version: u8, + #[serde(default = "default_cool_comfort")] + pub cool_comfort_setpoint: f64, + #[serde(default = "default_cool_sleep")] + pub cool_sleep_setpoint: f64, + #[serde(default = "default_cool_away")] + pub cool_away_setpoint: f64, + #[serde(default = "default_heat_comfort")] + pub heat_comfort_setpoint: f64, + #[serde(default = "default_heat_sleep")] + pub heat_sleep_setpoint: f64, + #[serde(default = "default_heat_away")] + pub heat_away_setpoint: f64, + #[serde(default = "default_hysteresis")] + pub hysteresis: f64, + #[serde(default = "default_min_cycle")] + pub min_on_seconds: u64, + #[serde(default = "default_min_cycle")] + pub min_off_seconds: u64, + /// Minimum interval between automatic setpoint/fan adjustments. + #[serde(default = "default_min_adjust")] + pub min_adjust_seconds: u64, + /// Difference applied to the AC setpoint while the room is satisfied. + #[serde(default = "default_standby_offset")] + pub standby_offset_c: f64, + /// Let the controller adjust fan speed based on demand and outdoor conditions. + #[serde(default = "default_true")] + pub smart_fan: bool, + #[serde(default = "default_sensor_source")] + pub sensor_source: String, + #[serde(default)] + pub ha_entity_id: Option, + /// Weight of the optional room sensor when sensor_source is `combined`. + #[serde(default = "default_external_sensor_weight")] + pub external_sensor_weight: f64, + /// If GREE and external sensor differ more than this, the controller falls back to GREE. + #[serde(default = "default_max_sensor_difference")] + pub max_sensor_difference: f64, + /// Maximum accepted age of a Home Assistant room sensor sample. + #[serde(default = "default_sensor_stale_after")] + pub sensor_stale_after_seconds: u64, + /// Temperature reported by the GREE indoor sensor during the last zone cycle. + #[serde(default)] + pub device_temperature: Option, + /// Temperature reported by the per-zone external Home Assistant sensor. + #[serde(default)] + pub external_temperature: Option, + /// Temperature actually used by the zone controller. + #[serde(default)] + pub current_temperature: Option, + /// `device`, `external`, `combined`, `device_fallback`, or `device_discrepancy_fallback`. + #[serde(default = "default_control_temperature_source")] + pub control_temperature_source: String, + /// Effective profile currently used by the zone: comfort/sleep/away/custom. + #[serde(default = "default_active_preset")] + pub active_preset: String, + /// Optional user preset override. Cleared automatically at the next schedule boundary. + #[serde(default)] + pub manual_preset: Option, + /// Optional quick-thermostat setpoint override. It does not change the active preset. + #[serde(default)] + pub manual_setpoint: Option, + #[serde(default)] + pub manual_override_until: Option>, + /// Local quick-thermostat power override. None follows group/global power gates; + /// Some(true) runs this zone locally through the full thermostat; Some(false) keeps it locally off. + #[serde(default)] + pub local_thermostat_power: Option, + /// Automatic hand-back deadline after the local quick thermostat is switched OFF. + /// When reached, local ownership and quick profile/setpoint overrides are cleared and + /// the current group/schedule state is evaluated again. + #[serde(default)] + pub local_thermostat_resume_at: Option>, + /// Zone automation enabled-state from before an ordinary local Quick Thermostat takeover. + /// Kept separate from the temporary-session restore state. + #[serde(default)] + pub local_thermostat_restore_zone_enabled: Option, + /// Separate, user-defined temporary Quick Thermostat session. This is intentionally + /// independent from local_thermostat_resume_at, which belongs to the local-OFF + /// hand-back mechanism. + #[serde(default)] + pub temporary_quick_thermostat: Option, + /// True when the physical unit was changed outside the thermostat engine (for example by IR remote). + /// While active, normal zone/group/schedule automation observes the unit but does not overwrite it. + #[serde(default)] + pub device_manual_override: bool, + #[serde(default)] + pub device_manual_override_since: Option>, + #[serde(default)] + pub device_manual_override_until: Option>, + /// Climate-relevant fields changed during the current external/manual takeover. + #[serde(default)] + pub device_manual_override_fields: Vec, + /// Controller-observed climate state immediately before the takeover started. + /// It lets us drop a stale "resume automation" prompt when the user restores that state. + #[serde(default)] + pub device_manual_override_baseline: Option, + /// Monotonic configuration/control revision used for optimistic concurrency. + #[serde(default)] + pub revision: u64, + /// Normalized control ownership exposed consistently to API/Web/Home Assistant. + #[serde(default)] + pub control_owner: String, + #[serde(default)] + pub control_source: String, + #[serde(default)] + pub control_since: Option>, + #[serde(default)] + pub control_resume_at: Option>, + #[serde(default)] + pub control_reason: String, + /// Last physical power/mode transition timestamps used by compressor lockout. + #[serde(default)] + pub last_power_change_at: Option>, + #[serde(default)] + pub last_mode_change_at: Option>, + #[serde(default)] + pub lockout_until: Option>, + #[serde(default)] + pub lockout_reason: Option, + #[serde(default)] + pub effective_mode: String, + #[serde(default)] + pub effective_setpoint: Option, + #[serde(default)] + pub device_setpoint: Option, + #[serde(default)] + pub demand: bool, + #[serde(default)] + pub demand_since: Option>, + #[serde(default)] + pub target_alerted_at: Option>, + #[serde(default)] + pub last_action_at: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +fn default_sensor_source() -> String { "device".into() } + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ClimateGroup { + pub id: String, + pub name: String, + #[serde(default)] + pub zone_ids: Vec, + #[serde(default = "default_group_power_enabled")] + pub power_enabled: bool, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct GroupControlPatch { + #[serde(default)] + pub power: Option, + /// house follows the global house mode; cool/heat set an explicit mode on every member zone. + #[serde(default)] + pub mode: Option, + /// auto clears temporary overrides; comfort/sleep/away apply a temporary preset to every member zone. + #[serde(default)] + pub preset: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct ZoneControlPatch { + #[serde(default)] + pub setpoint: Option, + /// Local quick-thermostat power. This is thermostat ownership, not direct device/pilot control. + #[serde(default)] + pub power: Option, + #[serde(default)] + pub mode: Option, + #[serde(default)] + pub enabled: Option, + /// `auto` clears the override; comfort/sleep/away/custom create a temporary override. + #[serde(default)] + pub preset: Option, + #[serde(default)] + pub clear_override: Option, + /// Explicitly hand control of a manually overridden physical unit back to the thermostat engine. + #[serde(default)] + pub clear_device_manual_override: Option, + /// Return a locally forced quick thermostat to normal group/schedule ownership. + #[serde(default)] + pub clear_local_thermostat_override: Option, + /// Start or replace a temporary Quick Thermostat session. + #[serde(default)] + pub temporary_quick_thermostat: Option, + /// Stop only the temporary Quick Thermostat session and return to automation. + #[serde(default)] + pub clear_temporary_quick_thermostat: Option, +} + diff --git a/src/protocol/gree.rs b/src/protocol/gree.rs index c0e91ba..ec14291 100644 --- a/src/protocol/gree.rs +++ b/src/protocol/gree.rs @@ -29,910 +29,14 @@ pub struct GreeClient { sleep_unsupported: Arc>>, } -impl GreeClient { - pub fn new( - controller_id: String, - interface: Option, - debug_events: Option>, - debug_gree_frames: Arc, - ) -> Self { - Self { - controller_id, - interface, - debug_events, - debug_gree_frames, - received_frames_total: Arc::new(AtomicU64::new(0)), - received_frames_by_device: Arc::new(Mutex::new(HashMap::new())), - buzzer_unsupported: Arc::new(Mutex::new(HashSet::new())), - quiet_unsupported: Arc::new(Mutex::new(HashSet::new())), - sleep_unsupported: Arc::new(Mutex::new(HashSet::new())), - } - } - pub fn received_frame_stats(&self) -> (u64, HashMap) { - let total = self.received_frames_total.load(Ordering::Relaxed); - let by_device = self.received_frames_by_device.lock() - .map(|counts| counts.clone()) - .unwrap_or_default(); - (total, by_device) - } - - fn record_received_frame(&self, device: &Device) { - let total = self.received_frames_total.fetch_add(1, Ordering::Relaxed).saturating_add(1); - let device_count = self.received_frames_by_device.lock().ok().map(|mut counts| { - let count = counts.entry(device.id.clone()).or_insert(0); - *count = (*count).saturating_add(1); - *count - }).unwrap_or(0); - if let Some(events) = &self.debug_events { - let _ = events.send(ApiEvent { - event: "gree.frame_received".into(), - timestamp: Utc::now(), - data: json!({ - "device_id": device.id, - "device_name": device.name, - "total": total, - "device_count": device_count, - }), - }); - } - } - - fn debug_frame(&self, direction: &str, device: &Device, target: SocketAddr, protocol: u8, payload: &Value) { - if !self.debug_gree_frames.load(Ordering::Relaxed) { return; } - let Some(events) = &self.debug_events else { return; }; - let mut safe = payload.clone(); - if let Some(object) = safe.as_object_mut() { - if object.contains_key("key") { object.insert("key".into(), json!("***")); } - } - let _ = events.send(ApiEvent { - event: "gree.frame".into(), - timestamp: Utc::now(), - data: json!({ - "direction": direction, - "device_id": device.id, - "device_name": device.name, - "target": target.to_string(), - "protocol_version": protocol, - "payload": safe, - }), - }); - } - - async fn udp_socket(&self, broadcast: bool, target_hint: Option) -> Result { - let socket = if let Some(interface) = self.interface.as_deref() { - let ip = interface_ipv4(interface)?; - UdpSocket::bind(SocketAddrV4::new(ip, 0)).await - .with_context(|| format!("cannot bind GREE UDP socket to {ip} from interface {interface}"))? - } else if let Some(target) = target_hint { - if let Some(config) = local_ipv4_config_for_target(target)? { - tracing::debug!( - target = %target, - interface = %config.interface, - local_ip = %config.ip, - "Automatically selected local interface for GREE UDP" - ); - UdpSocket::bind(SocketAddrV4::new(config.ip, 0)).await - .with_context(|| format!("cannot bind GREE UDP socket to {} on {}", config.ip, config.interface))? - } else { - UdpSocket::bind("0.0.0.0:0").await? - } - } else { - UdpSocket::bind("0.0.0.0:0").await? - }; - socket.set_broadcast(broadcast)?; - Ok(socket) - } - - fn bind_scan_target(&self, target: SocketAddr) -> Result { - let SocketAddr::V4(target_v4) = target else { return Ok(target); }; - let broadcast = if let Some(interface) = self.interface.as_deref() { - let (_, broadcast) = interface_ipv4_config(interface)?; - Some(broadcast) - } else { - local_ipv4_config_for_target(*target_v4.ip())?.map(|config| config.broadcast) - }; - Ok(broadcast - .map(|ip| SocketAddr::V4(SocketAddrV4::new(ip, target_v4.port()))) - .unwrap_or(target)) - } - - fn discovery_target(&self, configured: &str) -> Result { - let value = configured.trim(); - if value.eq_ignore_ascii_case("auto") || value.to_ascii_lowercase().starts_with("auto:") { - let port = value.split_once(':') - .map(|(_, port)| port.parse::().context("invalid automatic discovery port")) - .transpose()? - .unwrap_or(7000); - let interface = self.interface.as_deref() - .ok_or_else(|| anyhow!("automatic discovery broadcast requires GREE_CONTROLLER_GREE_INTERFACE"))?; - let (_, broadcast) = interface_ipv4_config(interface)?; - return Ok(SocketAddr::V4(SocketAddrV4::new(broadcast, port))); - } - value.parse().context("invalid discovery broadcast address") - } - - /// protocol_filter: 0=auto/both, 1=ECB only, 2=GCM only. - pub async fn discover(&self, broadcast: &str, duration: Duration, protocol_filter: u8, passes: u8) -> Result> { - let target = self.discovery_target(broadcast)?; - let target_hint = match target { SocketAddr::V4(addr) => Some(*addr.ip()), SocketAddr::V6(_) => None }; - let socket = self.udp_socket(true, target_hint).await?; - let local = socket.local_addr()?; - let passes = passes.clamp(1, 10); - tracing::info!( - target = %target, - local = %local, - interface = %self.interface.as_deref().unwrap_or("auto"), - protocol = protocol_filter, - passes, - controller_id = %self.controller_id, - "Starting GREE discovery" - ); - - let deadline = Instant::now() + duration; - let interval = if passes > 1 { duration / passes as u32 } else { duration }; - let mut next_scan = Instant::now(); - let mut sent = 0_u8; - let mut result = Vec::new(); - let mut seen = HashSet::new(); - let mut buffer = vec![0_u8; 16 * 1024]; - - while Instant::now() < deadline { - if sent < passes && Instant::now() >= next_scan { - socket.send_to(br#"{"t":"scan"}"#, target).await?; - sent += 1; - next_scan = Instant::now() + interval.max(Duration::from_millis(250)); - tracing::debug!(pass = sent, passes, target = %target, "Sent GREE discovery packet"); - } - - let remaining = deadline.saturating_duration_since(Instant::now()); - let wait = remaining.min(Duration::from_millis(250)); - match timeout(wait, socket.recv_from(&mut buffer)).await { - Ok(Ok((size, source))) => { - let Ok(value) = serde_json::from_slice::(&buffer[..size]) else { continue; }; - match self.parse_discovery(value, source) { - Ok(Some(mut device)) => { - if protocol_filter != 0 && device.protocol_version != protocol_filter { continue; } - let key = device.mac.to_ascii_lowercase(); - if seen.insert(key) { - device.last_seen = Some(Utc::now()); - tracing::info!(ip=%device.ip, mac=%device.mac, protocol=device.protocol_version, model=%device.model, firmware=%device.firmware, "Discovered GREE device"); - result.push(device); - } - } - Ok(None) => {} - Err(err) => tracing::debug!(source=%source, error=?err, "Ignoring undecodable discovery response"), - } - } - Ok(Err(err)) => return Err(err.into()), - Err(_) => continue, - } - } - Ok(result) - } - - fn parse_discovery(&self, mut value: Value, source: SocketAddr) -> Result> { - let mut detected_protocol = 1_u8; - if value.get("t").and_then(Value::as_str) == Some("pack") { - if let Some(pack_value) = value.get("pack") { - if let Some(pack) = pack_value.as_str() { - let clear = if let Some(tag) = value.get("tag").and_then(Value::as_str) { - detected_protocol = 2; - decrypt_v2(GENERIC_GREE_V2_KEY, pack, tag)? - } else { - decrypt_v1(GENERIC_GREE_V1_KEY, pack)? - }; - value = serde_json::from_slice::(&clear).context("invalid decrypted discovery JSON")?; - } else if pack_value.is_object() { - value = pack_value.clone(); - } - } - } - let kind = value.get("t").and_then(Value::as_str).unwrap_or_default().to_ascii_lowercase(); - if kind != "dev" && kind != "scan" && value.get("mac").is_none() && value.get("cid").is_none() { - return Ok(None); - } - let mac = value.get("mac") - .or_else(|| value.get("cid")) - .and_then(Value::as_str) - .unwrap_or_default() - .replace([':', '-'], "").to_ascii_uppercase(); - if mac.is_empty() { return Ok(None); } - - let raw_model = value.get("model").or_else(|| value.get("series")) - .and_then(Value::as_str).unwrap_or_default().trim().to_string(); - let model_type = value.get("ModelType") - .and_then(|v| v.as_str().map(str::to_string).or_else(|| v.as_i64().map(|n| n.to_string()))) - .unwrap_or_default(); - let model = if !model_type.is_empty() && (raw_model.is_empty() || raw_model.eq_ignore_ascii_case("gree")) { - format!("GREE {model_type}") - } else if raw_model.is_empty() { - "GREE".to_string() - } else { - raw_model - }; - let ver = value.get("ver").and_then(Value::as_str).unwrap_or_default().trim(); - let hid = value.get("hid").and_then(Value::as_str).unwrap_or_default().trim(); - let firmware = match (ver.is_empty(), hid.is_empty()) { - (false, false) => format!("{ver} · {hid}"), - (false, true) => ver.to_string(), - (true, false) => hid.to_string(), - (true, true) => String::new(), - }; - let suffix = mac.chars().rev().take(4).collect::().chars().rev().collect::().to_ascii_uppercase(); - let name = value.get("name").and_then(Value::as_str) - .map(str::trim).filter(|v| !v.is_empty()) - .map(str::to_string) - .unwrap_or_else(|| format!("{model} {suffix}")); - let now = Utc::now(); - Ok(Some(Device { - id: format!("gree-{}", mac.to_ascii_lowercase()), - mac, - name, - ip: source.ip().to_string(), - port: if source.port() == 0 { 7000 } else { source.port() }, - protocol_version: detected_protocol, - model, - firmware, - key: None, - cid: Some("app".into()), - enabled: true, - simulated: false, - power: false, - mode: "cool".into(), - target_temperature: 24.0, - fan_speed: 0, - swing_vertical: false, - swing_horizontal: false, - quiet: false, - turbo: false, - light: true, - air: false, - xfan: false, - health: false, - sleep: false, - supports_light: None, - supports_quiet: None, - supports_turbo: None, - supports_air: None, - supports_xfan: None, - supports_health: None, - supports_sleep: None, - current_temperature: None, - outdoor_temperature: None, - temperature_sensor_offset: None, - online: true, - response_time_ms: None, - last_seen: Some(now), - last_error: None, - communication_failures: 0, - created_at: now, - updated_at: now, - })) - } - - pub async fn bind(&self, device: &Device) -> Result { - let versions: &[u8] = match device.protocol_version { - 2 => &[2, 1], - _ => &[1, 2], - }; - let mut errors = Vec::new(); - for &version in versions { - match self.bind_attempt(device, version).await { - Ok(key) => return Ok(BindResult { key, protocol_version: version }), - Err(err) => { - tracing::warn!(device=%device.id, ip=%device.ip, protocol=version, error=?err, "GREE bind attempt failed"); - errors.push(format!("V{version}: {err}")); - } - } - } - bail!("unable to bind device ({})", errors.join("; ")) - } - - /// GREE Wi-Fi modules use the 12-hex device id as a protocol identifier. - /// Older V1 modules (notably 502cc6...) can silently ignore bind/status - /// packets when tcid/mac casing differs from the lowercase value returned - /// by discovery. Keep the database/display representation independent from - /// the on-wire representation and always send canonical lowercase hex. - fn wire_mac(device: &Device) -> String { - device.mac.replace([':', '-'], "").to_ascii_lowercase() - } - - async fn bind_attempt(&self, device: &Device, version: u8) -> Result { - let target = self.device_target(device)?; - let target_hint = match target { SocketAddr::V4(addr) => Some(*addr.ip()), SocketAddr::V6(_) => None }; - let socket = self.udp_socket(true, target_hint).await?; - - // Binding is time-sensitive on older GREE Wi-Fi modules. Refresh the - // bind window with a subnet broadcast when the target is on a directly - // connected network. A unicast scan remains the fallback for routed - // deployments. Keep the same UDP socket for scan + bind. - let scan_target = self.bind_scan_target(target)?; - tracing::debug!(device=%device.id, target=%target, scan_target=%scan_target, local=%socket.local_addr()?, "Refreshing GREE bind window"); - socket.send_to(br#"{"t":"scan"}"#, scan_target).await?; - let mut scan_buf = vec![0_u8; 16 * 1024]; - let scan_deadline = Instant::now() + Duration::from_millis(1500); - while Instant::now() < scan_deadline { - let remaining = scan_deadline.saturating_duration_since(Instant::now()); - match timeout(remaining, socket.recv_from(&mut scan_buf)).await { - Ok(Ok((_size, source))) if source.ip() == target.ip() => { - self.record_received_frame(device); - tracing::debug!(device=%device.id, source=%source, "Received scan response immediately before bind"); - break; - } - Ok(Ok(_)) => continue, - Ok(Err(err)) => return Err(err.into()), - Err(_) => break, - } - } - - let wire_mac = Self::wire_mac(device); - let inner = json!({"mac": wire_mac, "t": "bind", "uid": 0}); - let generic_key = if version == 2 { GENERIC_GREE_V2_KEY } else { GENERIC_GREE_V1_KEY }; - let response = self.request_on_socket(device, &inner, generic_key, true, version, &socket).await?; - let kind = response.get("t").and_then(Value::as_str).unwrap_or_default(); - if !kind.eq_ignore_ascii_case("bindok") { - bail!("unexpected bind response type: {kind}") - } - let key = response.get("key").and_then(Value::as_str) - .ok_or_else(|| anyhow!("bind response does not contain device key"))?; - if key.is_empty() { bail!("device returned an empty key") } - Ok(key.to_string()) - } - - pub async fn poll(&self, device: &mut Device) -> Result<()> { - let key = device.key.clone().ok_or_else(|| anyhow!("device is not bound"))?; - let full_cols = [ - "Pow","Mod","SetTem","WdSpd","Air","Blo","Health","SwhSlp","Lig", - "SwingLfRig","SwUpDn","Quiet","Tur","StHt","TemUn","HeatCoolType", - "TemRec","SvSt","TemSen","CoolSvTem","HeatSvTem","OutEnvTem" - ]; - let core_cols = ["Pow","Mod","SetTem","TemRec","TemUn","TemSen","WdSpd","Lig","SwingLfRig","SwUpDn","Quiet","Tur"]; - let (response, used_core_fallback) = match self.status_request(device, &key, &full_cols).await { - Ok(value) => (value, false), - Err(first) => { - tracing::debug!(device=%device.id, error=?first, "Full GREE status request failed; retrying core properties"); - (self.status_request(device, &key, &core_cols).await?, true) - } - }; - self.apply_status(device, &response)?; - // Some firmware rejects a large mixed property list but still exposes OutEnvTem. - // Probe it separately after the core fallback so compatible units can contribute - // their outdoor sensor to history without making the main poll fail. - if used_core_fallback { - match self.status_request(device, &key, &["OutEnvTem"]).await { - Ok(optional) => { let _ = self.apply_status(device, &optional); } - Err(err) => tracing::trace!(device=%device.id, error=?err, "GREE outdoor temperature is not available"), - } - } - // Capability discovery is deliberately lazy. Existing installations start with - // unknown support flags and each optional property is probed at most until a - // definitive success/failure has been persisted with the device state. - self.probe_optional_features(device, &key).await; - device.online = true; - device.communication_failures = 0; - device.last_seen = Some(Utc::now()); - device.last_error = None; - device.updated_at = Utc::now(); - Ok(()) - } - - async fn status_request(&self, device: &Device, key: &str, cols: &[&str]) -> Result { - let inner = json!({"cols": cols, "mac": Self::wire_mac(device), "t": "status"}); - self.request(device, &inner, key, false, device.protocol_version).await - } - - async fn probe_optional_features(&self, device: &mut Device, key: &str) { - let probes = [ - ("Lig", device.supports_light.is_none()), - ("Quiet", device.supports_quiet.is_none()), - ("Tur", device.supports_turbo.is_none()), - ("Air", device.supports_air.is_none()), - ("Blo", device.supports_xfan.is_none()), - ("Health", device.supports_health.is_none()), - ("SwhSlp", device.supports_sleep.is_none()), - ]; - for (property, needed) in probes { - if !needed { continue; } - match self.status_request(device, key, &[property]).await { - Ok(value) => { - let returned = value.get("cols").and_then(Value::as_array) - .map(|cols| cols.iter().any(|name| name.as_str() == Some(property))) - .unwrap_or(false); - if !returned || self.apply_status(device, &value).is_err() { - Self::set_feature_support(device, property, false); - } - } - Err(err) => { - Self::set_feature_support(device, property, false); - tracing::trace!(device=%device.id, property, error=?err, "optional GREE feature is not available"); - } - } - } - } - - fn set_feature_support(device: &mut Device, property: &str, supported: bool) { - let value = Some(supported); - match property { - "Lig" => device.supports_light = value, - "Quiet" => device.supports_quiet = value, - "Tur" => device.supports_turbo = value, - "Air" => device.supports_air = value, - "Blo" => device.supports_xfan = value, - "Health" => device.supports_health = value, - "SwhSlp" => device.supports_sleep = value, - _ => {} - } - } - - fn apply_status(&self, device: &mut Device, response: &Value) -> Result<()> { - let response_cols = response.get("cols").and_then(Value::as_array) - .ok_or_else(|| anyhow!("status response has no cols"))?; - let data = response.get("dat").and_then(Value::as_array) - .ok_or_else(|| anyhow!("status response has no dat"))?; - if data.len() < response_cols.len() { - bail!("status response contains fewer values than columns") - } - - // Parse into a clone and commit only when every climate-relevant value is valid. - // This prevents null/text/malformed frames from being silently converted into OFF, - // AUTO or a zero setpoint while leaving the rest of the packet partially applied. - let mut next = device.clone(); - let mut set_temp = None; - for (name, value) in response_cols.iter().zip(data.iter()) { - let Some(name) = name.as_str() else { continue; }; - match name { - "Pow" => next.power = status_flag(name, value)?, - "Mod" => { - let raw = status_i64(name, value)?; - next.mode = mode_name_checked(raw).ok_or_else(|| anyhow!("invalid GREE mode value for {name}: {raw}"))?.into(); - } - "SetTem" => { - let raw = status_f64(name, value)?; - if !(8.0..=30.0).contains(&raw) { bail!("invalid GREE setpoint for {name}: {raw}") } - set_temp = Some(raw.round()); - } - "WdSpd" => { - let raw = status_i64(name, value)?; - if !(0..=5).contains(&raw) { bail!("invalid GREE fan value for {name}: {raw}") } - next.fan_speed = raw as u8; - } - "SwUpDn" => next.swing_vertical = status_i64(name, value)? != 0, - "SwingLfRig" => next.swing_horizontal = status_i64(name, value)? != 0, - "Quiet" => { next.quiet = status_flag(name, value)?; next.supports_quiet = Some(true); }, - "Tur" => { next.turbo = status_flag(name, value)?; next.supports_turbo = Some(true); }, - "Lig" => { next.light = status_flag(name, value)?; next.supports_light = Some(true); }, - "Air" => { next.air = status_flag(name, value)?; next.supports_air = Some(true); }, - "Blo" => { next.xfan = status_flag(name, value)?; next.supports_xfan = Some(true); }, - "Health" => { next.health = status_flag(name, value)?; next.supports_health = Some(true); }, - "SwhSlp" => { next.sleep = status_flag(name, value)?; next.supports_sleep = Some(true); }, - "TemSen" => { - let raw = status_f64(name, value)?; - if raw != 0.0 { - let offset = raw > 40.0; - let temperature = if offset { raw - 40.0 } else { raw }; - if !(-40.0..=80.0).contains(&temperature) { bail!("invalid GREE indoor temperature: {temperature}") } - next.temperature_sensor_offset = Some(offset); - next.current_temperature = Some(temperature); - } - } - "OutEnvTem" => { - let raw = status_f64(name, value)?; - if raw != 0.0 { - let offset = next.temperature_sensor_offset.unwrap_or(raw > 50.0); - let temperature = if offset { raw - 40.0 } else { raw }; - if !(-60.0..=80.0).contains(&temperature) { bail!("invalid GREE outdoor temperature: {temperature}") } - next.outdoor_temperature = Some(temperature); - } - } - _ => {} - } - } - if let Some(base) = set_temp { next.target_temperature = base; } - *device = next; - Ok(()) - } - - pub fn quiet_command_supported(&self, device_id: &str) -> bool { - self.quiet_unsupported.lock().map(|items| !items.contains(device_id)).unwrap_or(true) - } - - pub fn sleep_command_supported(&self, device_id: &str) -> bool { - self.sleep_unsupported.lock().map(|items| !items.contains(device_id)).unwrap_or(true) - } - - async fn request_command_with_buzzer_fallback( - &self, - device: &Device, - key: &str, - command: &DeviceCommand, - suppress_beep: bool, - ) -> Result { - let try_buzzer_suppression = suppress_beep - && self.buzzer_unsupported.lock().map(|items| !items.contains(&device.id)).unwrap_or(true); - let inner = Self::command_payload(command, try_buzzer_suppression)?; - match self.request(device, &inner, key, false, device.protocol_version).await { - Ok(value) => Ok(value), - Err(first_err) if try_buzzer_suppression => { - // Some firmwares reject unknown buzzer properties instead of ignoring them. - // Retry the exact state change without buzzer fields and remember the fallback. - let fallback = Self::command_payload(command, false)?; - match self.request(device, &fallback, key, false, device.protocol_version).await { - Ok(value) => { - if let Ok(mut items) = self.buzzer_unsupported.lock() { items.insert(device.id.clone()); } - tracing::warn!(device=%device.id, "GREE buzzer suppression is unsupported; using normal command frames for this device"); - Ok(value) - } - Err(_) => Err(first_err), - } - } - Err(err) => Err(err), - } - } - - pub async fn command(&self, device: &Device, command: &DeviceCommand, suppress_beep: bool) -> Result { - let key = device.key.as_deref().ok_or_else(|| anyhow!("device is not bound"))?; - let mut effective = command.clone(); - if effective.quiet.is_some() && !self.quiet_command_supported(&device.id) { - effective.quiet = None; - } - if effective.sleep.is_some() && !self.sleep_command_supported(&device.id) { - effective.sleep = None; - } - if effective.is_empty() { - return Ok(effective); - } - - match self.request_command_with_buzzer_fallback(device, key, &effective, suppress_beep).await { - Ok(_) => Ok(effective), - Err(first_err) => { - // Quiet and native Sleep are optional GREE features. A unit may report a - // broader status schema than it accepts in command frames, so preserve - // the actual thermostat change and retry without the optional property. - if effective.sleep.is_some() { - let mut fallback = effective.clone(); - fallback.sleep = None; - if !fallback.is_empty() { - if self.request_command_with_buzzer_fallback(device, key, &fallback, suppress_beep).await.is_ok() { - if let Ok(mut items) = self.sleep_unsupported.lock() { items.insert(device.id.clone()); } - tracing::warn!(device=%device.id, "GREE native Sleep command is unsupported; night mode will continue without Sleep for this device"); - return Ok(fallback); - } - } - } - if effective.quiet.is_some() { - let mut fallback = effective.clone(); - fallback.quiet = None; - if !fallback.is_empty() { - if self.request_command_with_buzzer_fallback(device, key, &fallback, suppress_beep).await.is_ok() { - if let Ok(mut items) = self.quiet_unsupported.lock() { items.insert(device.id.clone()); } - tracing::warn!(device=%device.id, "GREE Quiet command is unsupported; thermostat will continue without Quiet for this device"); - return Ok(fallback); - } - } - } - if effective.sleep.is_some() && effective.quiet.is_some() { - let mut fallback = effective.clone(); - fallback.sleep = None; - fallback.quiet = None; - if !fallback.is_empty() { - if self.request_command_with_buzzer_fallback(device, key, &fallback, suppress_beep).await.is_ok() { - if let Ok(mut items) = self.sleep_unsupported.lock() { items.insert(device.id.clone()); } - if let Ok(mut items) = self.quiet_unsupported.lock() { items.insert(device.id.clone()); } - tracing::warn!(device=%device.id, "GREE Quiet/Sleep optional command fields are unsupported; using the core thermostat command"); - return Ok(fallback); - } - } - } - Err(first_err) - } - } - } - - fn command_payload(command: &DeviceCommand, suppress_beep: bool) -> Result { - let mut opt = Vec::<&str>::new(); - let mut values = Vec::::new(); - if let Some(v) = command.power { opt.push("Pow"); values.push(json!(if v { 1 } else { 0 })); } - if let Some(v) = &command.mode { opt.push("Mod"); values.push(json!(mode_value(v)?)); } - if let Some(v) = command.target_temperature { - // GREE's Celsius setpoint is whole-degree. TemRec is used by the - // Fahrenheit conversion path and should not be abused as a 0.5 C bit. - let whole = v.clamp(8.0, 30.0).round() as i64; - opt.push("SetTem"); values.push(json!(whole)); - } - if let Some(v) = command.fan_speed { opt.push("WdSpd"); values.push(json!(v.min(5))); } - if let Some(v) = command.swing_vertical { opt.push("SwUpDn"); values.push(json!(if v { 1 } else { 0 })); } - if let Some(v) = command.swing_horizontal { opt.push("SwingLfRig"); values.push(json!(if v { 1 } else { 0 })); } - if let Some(v) = command.quiet { opt.push("Quiet"); values.push(json!(if v { 1 } else { 0 })); } - if let Some(v) = command.turbo { opt.push("Tur"); values.push(json!(if v { 1 } else { 0 })); } - if let Some(v) = command.light { opt.push("Lig"); values.push(json!(if v { 1 } else { 0 })); } - if let Some(v) = command.air { opt.push("Air"); values.push(json!(if v { 1 } else { 0 })); } - if let Some(v) = command.xfan { opt.push("Blo"); values.push(json!(if v { 1 } else { 0 })); } - if let Some(v) = command.health { opt.push("Health"); values.push(json!(if v { 1 } else { 0 })); } - if let Some(v) = command.sleep { opt.push("SwhSlp"); values.push(json!(if v { 1 } else { 0 })); } - if opt.is_empty() { bail!("empty device command") } - if suppress_beep { - opt.push("Buzzer_ON_OFF"); values.push(json!(1)); - opt.push("BuzzerCtrl"); values.push(json!(0)); - } - Ok(json!({"opt": opt, "p": values, "t": "cmd"})) - } - - async fn request(&self, device: &Device, inner: &Value, key: &str, binding: bool, protocol_version: u8) -> Result { - let target = self.device_target(device)?; - let target_hint = match target { SocketAddr::V4(addr) => Some(*addr.ip()), SocketAddr::V6(_) => None }; - let socket = self.udp_socket(false, target_hint).await?; - self.request_on_socket(device, inner, key, binding, protocol_version, &socket).await - } - - async fn request_on_socket(&self, device: &Device, inner: &Value, key: &str, binding: bool, protocol_version: u8, socket: &UdpSocket) -> Result { - let target = self.device_target(device)?; - let version = if protocol_version == 2 { 2 } else { 1 }; - let inner_bytes = serde_json::to_vec(inner)?; - let wire_mac = Self::wire_mac(device); - let mut outer = json!({ - "cid": "app", - "i": if binding { 1 } else { 0 }, - "t": "pack", - "tcid": wire_mac, - "uid": 0 - }); - if version == 2 { - let encrypted = encrypt_v2(key, &inner_bytes)?; - outer["pack"] = json!(encrypted.ciphertext); - outer["tag"] = json!(encrypted.tag); - } else { - outer["pack"] = json!(encrypt_v1(key, &inner_bytes)?); - } - let payload = serde_json::to_vec(&outer)?; - tracing::debug!(target=%target, local=%socket.local_addr()?, protocol=version, wire_mac=%wire_mac, interface=%self.interface.as_deref().unwrap_or("auto"), binding, "Sending GREE request"); - self.debug_frame("tx", device, target, version, inner); - socket.send_to(&payload, target).await?; - - let deadline = Instant::now() + Duration::from_secs(4); - let mut buffer = vec![0_u8; 16 * 1024]; - let mut last_decode_error = None; - while Instant::now() < deadline { - let remaining = deadline.saturating_duration_since(Instant::now()); - let received = timeout(remaining, socket.recv_from(&mut buffer)).await; - let (size, source) = match received { - Ok(Ok(value)) => value, - Ok(Err(err)) => return Err(err.into()), - Err(_) => break, - }; - if source.ip() != target.ip() { continue; } - self.record_received_frame(device); - let response: Value = match serde_json::from_slice(&buffer[..size]) { - Ok(value) => value, - Err(err) => { last_decode_error = Some(anyhow!("invalid GREE JSON response: {err}")); continue; } - }; - if let Some(pack) = response.get("pack").and_then(Value::as_object) { - let decoded = Value::Object(pack.clone()); - if binding { - let response_type = decoded.get("t").and_then(Value::as_str).unwrap_or_default(); - if !response_type.eq_ignore_ascii_case("bindok") { - tracing::debug!(source=%source, response_type=%response_type, "Ignoring non-bind packet while waiting for GREE bind response"); - continue; - } - } - if let Some(err) = decoded.get("err").filter(|v| !v.is_null()) { bail!("GREE device error: {err}") } - self.debug_frame("rx", device, target, version, &decoded); - return Ok(decoded); - } - let Some(pack) = response.get("pack").and_then(Value::as_str) else { continue; }; - let clear = if version == 2 { - let Some(tag) = response.get("tag").and_then(Value::as_str) else { - last_decode_error = Some(anyhow!("AES-GCM response is missing tag")); - continue; - }; - match decrypt_v2(key, pack, tag) { - Ok(v) => v, - Err(err) => { last_decode_error = Some(err); continue; } - } - } else { - match decrypt_v1(key, pack) { - Ok(v) => v, - Err(err) => { last_decode_error = Some(err); continue; } - } - }; - let decoded: Value = match serde_json::from_slice(&clear) { - Ok(value) => value, - Err(err) => { last_decode_error = Some(anyhow!("invalid decrypted GREE response: {err}")); continue; } - }; - if binding { - let response_type = decoded.get("t").and_then(Value::as_str).unwrap_or_default(); - if !response_type.eq_ignore_ascii_case("bindok") { continue; } - } - if let Some(err) = decoded.get("err").filter(|v| !v.is_null()) { bail!("GREE device error: {err}") } - self.debug_frame("rx", device, target, version, &decoded); - return Ok(decoded); - } - if let Some(err) = last_decode_error { return Err(err); } - bail!("GREE response timeout after 4 seconds") - } - - fn device_target(&self, device: &Device) -> Result { - format!("{}:{}", device.ip, device.port).parse().context("invalid device address") - } -} - -#[derive(Debug, Clone)] -struct LocalIpv4Config { - interface: String, - ip: Ipv4Addr, - broadcast: Ipv4Addr, - prefix_len: u32, -} - -#[cfg(target_os = "linux")] -fn local_ipv4_config_for_target(target: Ipv4Addr) -> Result> { - use std::{ffi::CStr, ptr}; - 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"); } - let mut current = addrs; - let mut best: Option = None; - while !current.is_null() { - let ifa = &*current; - 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(); - let addr = &*(ifa.ifa_addr as *const libc::sockaddr_in); - let mask_addr = &*(ifa.ifa_netmask as *const libc::sockaddr_in); - let ip = Ipv4Addr::from(addr.sin_addr.s_addr.to_ne_bytes()); - let mask = Ipv4Addr::from(mask_addr.sin_addr.s_addr.to_ne_bytes()); - let ip_u32 = u32::from(ip); - let mask_u32 = u32::from(mask); - let target_u32 = u32::from(target); - if !ip.is_loopback() && (ip_u32 & mask_u32) == (target_u32 & mask_u32) { - let prefix_len = mask_u32.count_ones(); - let candidate = LocalIpv4Config { - interface, - ip, - broadcast: Ipv4Addr::from(ip_u32 | !mask_u32), - prefix_len, - }; - if best.as_ref().map(|current| prefix_len > current.prefix_len).unwrap_or(true) { - best = Some(candidate); - } - } - } - current = ifa.ifa_next; - } - libc::freeifaddrs(addrs); - Ok(best) - } -} - -#[cfg(not(target_os = "linux"))] -fn local_ipv4_config_for_target(_target: Ipv4Addr) -> Result> { Ok(None) } - -#[cfg(target_os = "linux")] -fn interface_ipv4_config(interface: &str) -> Result<(Ipv4Addr, Ipv4Addr)> { - use std::{ffi::CStr, ptr}; - 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"); } - let mut current = addrs; - let mut found = None; - while !current.is_null() { - let ifa = &*current; - if !ifa.ifa_name.is_null() && !ifa.ifa_addr.is_null() { - let name = CStr::from_ptr(ifa.ifa_name).to_string_lossy(); - if name == interface && (*ifa.ifa_addr).sa_family as i32 == libc::AF_INET { - let addr = &*(ifa.ifa_addr as *const libc::sockaddr_in); - let ip = Ipv4Addr::from(addr.sin_addr.s_addr.to_ne_bytes()); - let broadcast = if !ifa.ifa_netmask.is_null() { - 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 }; - found = Some((ip, broadcast)); - break; - } - } - current = ifa.ifa_next; - } - libc::freeifaddrs(addrs); - found.ok_or_else(|| anyhow!("interface {interface} has no IPv4 address")) - } -} - -#[cfg(not(target_os = "linux"))] -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 value_as_f64(value: &Value) -> Option { - 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}")) -} -fn status_f64(name: &str, value: &Value) -> Result { - value_as_f64(value).ok_or_else(|| anyhow!("invalid GREE numeric value for {name}: {value}")) -} -fn status_flag(name: &str, value: &Value) -> Result { - match status_i64(name, value)? { - 0 => Ok(false), - 1 => Ok(true), - other => bail!("invalid GREE flag value for {name}: {other}"), - } -} -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), - _ => bail!("unsupported mode: {value}"), - } -} - -pub fn merge_discovered(existing: Option, discovered: Device) -> Device { - if let Some(mut old) = existing { - old.ip = discovered.ip; - old.port = discovered.port; - if old.name.trim().is_empty() || old.name == "Klimatyzator GREE" || old.name == "GREE air conditioner" { old.name = discovered.name; } - if !discovered.model.is_empty() { old.model = discovered.model; } - if !discovered.firmware.is_empty() { old.firmware = discovered.firmware; } - if old.protocol_version != discovered.protocol_version { - old.protocol_version = discovered.protocol_version; - old.key = None; - } - old.online = true; - old.communication_failures = 0; - old.last_seen = Some(Utc::now()); - old.last_error = None; - old.updated_at = Utc::now(); - old - } else { - let mut new = discovered; - if new.id.is_empty() { new.id = Uuid::new_v4().to_string(); } - new - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn invalid_status_frame_does_not_partially_mutate_device() { - let client = GreeClient::new( - "test-controller".into(), - None, - None, - Arc::new(AtomicBool::new(false)), - ); - let mut device = Device::simulated_default(); - device.power = true; - device.mode = "heat".into(); - device.target_temperature = 24.0; - let before = device.clone(); - - let response = json!({ - "cols": ["Pow", "Mod", "SetTem"], - "dat": [0, null, "not-a-number"] - }); - assert!(client.apply_status(&mut device, &response).is_err()); - assert_eq!(device.power, before.power); - assert_eq!(device.mode, before.mode); - assert_eq!(device.target_temperature, before.target_temperature); - } - - #[test] - fn thermostat_standby_setpoint_low_fan_quiet_and_sleep_share_one_frame() { - let payload = GreeClient::command_payload(&DeviceCommand { - target_temperature: Some(19.0), - fan_speed: Some(1), - quiet: Some(true), - sleep: Some(true), - ..DeviceCommand::default() - }, false).expect("thermostat command payload"); - - assert_eq!(payload.get("opt").cloned(), Some(serde_json::json!(["SetTem", "WdSpd", "Quiet", "SwhSlp"]))); - assert_eq!(payload.get("p").cloned(), Some(serde_json::json!([19, 1, 1, 1]))); - } -} +// Functional source split intentionally keeps items in the existing module namespace. +include!("gree/core.rs"); +include!("gree/discovery.rs"); +include!("gree/binding.rs"); +include!("gree/polling.rs"); +include!("gree/commands.rs"); +include!("gree/transport.rs"); +include!("gree/network.rs"); +include!("gree/merge.rs"); +include!("gree/tests.rs"); diff --git a/src/protocol/gree/binding.rs b/src/protocol/gree/binding.rs new file mode 100644 index 0000000..d558738 --- /dev/null +++ b/src/protocol/gree/binding.rs @@ -0,0 +1,71 @@ +impl GreeClient { + pub async fn bind(&self, device: &Device) -> Result { + let versions: &[u8] = match device.protocol_version { + 2 => &[2, 1], + _ => &[1, 2], + }; + let mut errors = Vec::new(); + for &version in versions { + match self.bind_attempt(device, version).await { + Ok(key) => return Ok(BindResult { key, protocol_version: version }), + Err(err) => { + tracing::warn!(device=%device.id, ip=%device.ip, protocol=version, error=?err, "GREE bind attempt failed"); + errors.push(format!("V{version}: {err}")); + } + } + } + bail!("unable to bind device ({})", errors.join("; ")) + } + + /// GREE Wi-Fi modules use the 12-hex device id as a protocol identifier. + /// Older V1 modules (notably 502cc6...) can silently ignore bind/status + /// packets when tcid/mac casing differs from the lowercase value returned + /// by discovery. Keep the database/display representation independent from + /// the on-wire representation and always send canonical lowercase hex. + fn wire_mac(device: &Device) -> String { + device.mac.replace([':', '-'], "").to_ascii_lowercase() + } + + async fn bind_attempt(&self, device: &Device, version: u8) -> Result { + let target = self.device_target(device)?; + let target_hint = match target { SocketAddr::V4(addr) => Some(*addr.ip()), SocketAddr::V6(_) => None }; + let socket = self.udp_socket(true, target_hint).await?; + + // Binding is time-sensitive on older GREE Wi-Fi modules. Refresh the + // bind window with a subnet broadcast when the target is on a directly + // connected network. A unicast scan remains the fallback for routed + // deployments. Keep the same UDP socket for scan + bind. + let scan_target = self.bind_scan_target(target)?; + tracing::debug!(device=%device.id, target=%target, scan_target=%scan_target, local=%socket.local_addr()?, "Refreshing GREE bind window"); + socket.send_to(br#"{"t":"scan"}"#, scan_target).await?; + let mut scan_buf = vec![0_u8; 16 * 1024]; + let scan_deadline = Instant::now() + Duration::from_millis(1500); + while Instant::now() < scan_deadline { + let remaining = scan_deadline.saturating_duration_since(Instant::now()); + match timeout(remaining, socket.recv_from(&mut scan_buf)).await { + Ok(Ok((_size, source))) if source.ip() == target.ip() => { + self.record_received_frame(device); + tracing::debug!(device=%device.id, source=%source, "Received scan response immediately before bind"); + break; + } + Ok(Ok(_)) => continue, + Ok(Err(err)) => return Err(err.into()), + Err(_) => break, + } + } + + let wire_mac = Self::wire_mac(device); + let inner = json!({"mac": wire_mac, "t": "bind", "uid": 0}); + let generic_key = if version == 2 { GENERIC_GREE_V2_KEY } else { GENERIC_GREE_V1_KEY }; + let response = self.request_on_socket(device, &inner, generic_key, true, version, &socket).await?; + let kind = response.get("t").and_then(Value::as_str).unwrap_or_default(); + if !kind.eq_ignore_ascii_case("bindok") { + bail!("unexpected bind response type: {kind}") + } + let key = response.get("key").and_then(Value::as_str) + .ok_or_else(|| anyhow!("bind response does not contain device key"))?; + if key.is_empty() { bail!("device returned an empty key") } + Ok(key.to_string()) + } + +} diff --git a/src/protocol/gree/commands.rs b/src/protocol/gree/commands.rs new file mode 100644 index 0000000..6c66326 --- /dev/null +++ b/src/protocol/gree/commands.rs @@ -0,0 +1,127 @@ +impl GreeClient { + pub fn quiet_command_supported(&self, device_id: &str) -> bool { + self.quiet_unsupported.lock().map(|items| !items.contains(device_id)).unwrap_or(true) + } + + pub fn sleep_command_supported(&self, device_id: &str) -> bool { + self.sleep_unsupported.lock().map(|items| !items.contains(device_id)).unwrap_or(true) + } + + async fn request_command_with_buzzer_fallback( + &self, + device: &Device, + key: &str, + command: &DeviceCommand, + suppress_beep: bool, + ) -> Result { + let try_buzzer_suppression = suppress_beep + && self.buzzer_unsupported.lock().map(|items| !items.contains(&device.id)).unwrap_or(true); + let inner = Self::command_payload(command, try_buzzer_suppression)?; + match self.request(device, &inner, key, false, device.protocol_version).await { + Ok(value) => Ok(value), + Err(first_err) if try_buzzer_suppression => { + // Some firmwares reject unknown buzzer properties instead of ignoring them. + // Retry the exact state change without buzzer fields and remember the fallback. + let fallback = Self::command_payload(command, false)?; + match self.request(device, &fallback, key, false, device.protocol_version).await { + Ok(value) => { + if let Ok(mut items) = self.buzzer_unsupported.lock() { items.insert(device.id.clone()); } + tracing::warn!(device=%device.id, "GREE buzzer suppression is unsupported; using normal command frames for this device"); + Ok(value) + } + Err(_) => Err(first_err), + } + } + Err(err) => Err(err), + } + } + + pub async fn command(&self, device: &Device, command: &DeviceCommand, suppress_beep: bool) -> Result { + let key = device.key.as_deref().ok_or_else(|| anyhow!("device is not bound"))?; + let mut effective = command.clone(); + if effective.quiet.is_some() && !self.quiet_command_supported(&device.id) { + effective.quiet = None; + } + if effective.sleep.is_some() && !self.sleep_command_supported(&device.id) { + effective.sleep = None; + } + if effective.is_empty() { + return Ok(effective); + } + + match self.request_command_with_buzzer_fallback(device, key, &effective, suppress_beep).await { + Ok(_) => Ok(effective), + Err(first_err) => { + // Quiet and native Sleep are optional GREE features. A unit may report a + // broader status schema than it accepts in command frames, so preserve + // the actual thermostat change and retry without the optional property. + if effective.sleep.is_some() { + let mut fallback = effective.clone(); + fallback.sleep = None; + if !fallback.is_empty() { + if self.request_command_with_buzzer_fallback(device, key, &fallback, suppress_beep).await.is_ok() { + if let Ok(mut items) = self.sleep_unsupported.lock() { items.insert(device.id.clone()); } + tracing::warn!(device=%device.id, "GREE native Sleep command is unsupported; night mode will continue without Sleep for this device"); + return Ok(fallback); + } + } + } + if effective.quiet.is_some() { + let mut fallback = effective.clone(); + fallback.quiet = None; + if !fallback.is_empty() { + if self.request_command_with_buzzer_fallback(device, key, &fallback, suppress_beep).await.is_ok() { + if let Ok(mut items) = self.quiet_unsupported.lock() { items.insert(device.id.clone()); } + tracing::warn!(device=%device.id, "GREE Quiet command is unsupported; thermostat will continue without Quiet for this device"); + return Ok(fallback); + } + } + } + if effective.sleep.is_some() && effective.quiet.is_some() { + let mut fallback = effective.clone(); + fallback.sleep = None; + fallback.quiet = None; + if !fallback.is_empty() { + if self.request_command_with_buzzer_fallback(device, key, &fallback, suppress_beep).await.is_ok() { + if let Ok(mut items) = self.sleep_unsupported.lock() { items.insert(device.id.clone()); } + if let Ok(mut items) = self.quiet_unsupported.lock() { items.insert(device.id.clone()); } + tracing::warn!(device=%device.id, "GREE Quiet/Sleep optional command fields are unsupported; using the core thermostat command"); + return Ok(fallback); + } + } + } + Err(first_err) + } + } + } + + fn command_payload(command: &DeviceCommand, suppress_beep: bool) -> Result { + let mut opt = Vec::<&str>::new(); + let mut values = Vec::::new(); + if let Some(v) = command.power { opt.push("Pow"); values.push(json!(if v { 1 } else { 0 })); } + if let Some(v) = &command.mode { opt.push("Mod"); values.push(json!(mode_value(v)?)); } + if let Some(v) = command.target_temperature { + // GREE's Celsius setpoint is whole-degree. TemRec is used by the + // Fahrenheit conversion path and should not be abused as a 0.5 C bit. + let whole = v.clamp(8.0, 30.0).round() as i64; + opt.push("SetTem"); values.push(json!(whole)); + } + if let Some(v) = command.fan_speed { opt.push("WdSpd"); values.push(json!(v.min(5))); } + if let Some(v) = command.swing_vertical { opt.push("SwUpDn"); values.push(json!(if v { 1 } else { 0 })); } + if let Some(v) = command.swing_horizontal { opt.push("SwingLfRig"); values.push(json!(if v { 1 } else { 0 })); } + if let Some(v) = command.quiet { opt.push("Quiet"); values.push(json!(if v { 1 } else { 0 })); } + if let Some(v) = command.turbo { opt.push("Tur"); values.push(json!(if v { 1 } else { 0 })); } + if let Some(v) = command.light { opt.push("Lig"); values.push(json!(if v { 1 } else { 0 })); } + if let Some(v) = command.air { opt.push("Air"); values.push(json!(if v { 1 } else { 0 })); } + if let Some(v) = command.xfan { opt.push("Blo"); values.push(json!(if v { 1 } else { 0 })); } + if let Some(v) = command.health { opt.push("Health"); values.push(json!(if v { 1 } else { 0 })); } + if let Some(v) = command.sleep { opt.push("SwhSlp"); values.push(json!(if v { 1 } else { 0 })); } + if opt.is_empty() { bail!("empty device command") } + if suppress_beep { + opt.push("Buzzer_ON_OFF"); values.push(json!(1)); + opt.push("BuzzerCtrl"); values.push(json!(0)); + } + Ok(json!({"opt": opt, "p": values, "t": "cmd"})) + } + +} diff --git a/src/protocol/gree/core.rs b/src/protocol/gree/core.rs new file mode 100644 index 0000000..0a027fe --- /dev/null +++ b/src/protocol/gree/core.rs @@ -0,0 +1,124 @@ +impl GreeClient { + pub fn new( + controller_id: String, + interface: Option, + debug_events: Option>, + debug_gree_frames: Arc, + ) -> Self { + Self { + controller_id, + interface, + debug_events, + debug_gree_frames, + received_frames_total: Arc::new(AtomicU64::new(0)), + received_frames_by_device: Arc::new(Mutex::new(HashMap::new())), + buzzer_unsupported: Arc::new(Mutex::new(HashSet::new())), + quiet_unsupported: Arc::new(Mutex::new(HashSet::new())), + sleep_unsupported: Arc::new(Mutex::new(HashSet::new())), + } + } + + pub fn received_frame_stats(&self) -> (u64, HashMap) { + let total = self.received_frames_total.load(Ordering::Relaxed); + let by_device = self.received_frames_by_device.lock() + .map(|counts| counts.clone()) + .unwrap_or_default(); + (total, by_device) + } + + fn record_received_frame(&self, device: &Device) { + let total = self.received_frames_total.fetch_add(1, Ordering::Relaxed).saturating_add(1); + let device_count = self.received_frames_by_device.lock().ok().map(|mut counts| { + let count = counts.entry(device.id.clone()).or_insert(0); + *count = (*count).saturating_add(1); + *count + }).unwrap_or(0); + if let Some(events) = &self.debug_events { + let _ = events.send(ApiEvent { + event: "gree.frame_received".into(), + timestamp: Utc::now(), + data: json!({ + "device_id": device.id, + "device_name": device.name, + "total": total, + "device_count": device_count, + }), + }); + } + } + + fn debug_frame(&self, direction: &str, device: &Device, target: SocketAddr, protocol: u8, payload: &Value) { + if !self.debug_gree_frames.load(Ordering::Relaxed) { return; } + let Some(events) = &self.debug_events else { return; }; + let mut safe = payload.clone(); + if let Some(object) = safe.as_object_mut() { + if object.contains_key("key") { object.insert("key".into(), json!("***")); } + } + let _ = events.send(ApiEvent { + event: "gree.frame".into(), + timestamp: Utc::now(), + data: json!({ + "direction": direction, + "device_id": device.id, + "device_name": device.name, + "target": target.to_string(), + "protocol_version": protocol, + "payload": safe, + }), + }); + } + + async fn udp_socket(&self, broadcast: bool, target_hint: Option) -> Result { + let socket = if let Some(interface) = self.interface.as_deref() { + let ip = interface_ipv4(interface)?; + UdpSocket::bind(SocketAddrV4::new(ip, 0)).await + .with_context(|| format!("cannot bind GREE UDP socket to {ip} from interface {interface}"))? + } else if let Some(target) = target_hint { + if let Some(config) = local_ipv4_config_for_target(target)? { + tracing::debug!( + target = %target, + interface = %config.interface, + local_ip = %config.ip, + "Automatically selected local interface for GREE UDP" + ); + UdpSocket::bind(SocketAddrV4::new(config.ip, 0)).await + .with_context(|| format!("cannot bind GREE UDP socket to {} on {}", config.ip, config.interface))? + } else { + UdpSocket::bind("0.0.0.0:0").await? + } + } else { + UdpSocket::bind("0.0.0.0:0").await? + }; + socket.set_broadcast(broadcast)?; + Ok(socket) + } + + fn bind_scan_target(&self, target: SocketAddr) -> Result { + let SocketAddr::V4(target_v4) = target else { return Ok(target); }; + let broadcast = if let Some(interface) = self.interface.as_deref() { + let (_, broadcast) = interface_ipv4_config(interface)?; + Some(broadcast) + } else { + local_ipv4_config_for_target(*target_v4.ip())?.map(|config| config.broadcast) + }; + Ok(broadcast + .map(|ip| SocketAddr::V4(SocketAddrV4::new(ip, target_v4.port()))) + .unwrap_or(target)) + } + + fn discovery_target(&self, configured: &str) -> Result { + let value = configured.trim(); + if value.eq_ignore_ascii_case("auto") || value.to_ascii_lowercase().starts_with("auto:") { + let port = value.split_once(':') + .map(|(_, port)| port.parse::().context("invalid automatic discovery port")) + .transpose()? + .unwrap_or(7000); + let interface = self.interface.as_deref() + .ok_or_else(|| anyhow!("automatic discovery broadcast requires GREE_CONTROLLER_GREE_INTERFACE"))?; + let (_, broadcast) = interface_ipv4_config(interface)?; + return Ok(SocketAddr::V4(SocketAddrV4::new(broadcast, port))); + } + value.parse().context("invalid discovery broadcast address") + } + +} diff --git a/src/protocol/gree/discovery.rs b/src/protocol/gree/discovery.rs new file mode 100644 index 0000000..6b2efcf --- /dev/null +++ b/src/protocol/gree/discovery.rs @@ -0,0 +1,161 @@ +impl GreeClient { + /// protocol_filter: 0=auto/both, 1=ECB only, 2=GCM only. + pub async fn discover(&self, broadcast: &str, duration: Duration, protocol_filter: u8, passes: u8) -> Result> { + let target = self.discovery_target(broadcast)?; + let target_hint = match target { SocketAddr::V4(addr) => Some(*addr.ip()), SocketAddr::V6(_) => None }; + let socket = self.udp_socket(true, target_hint).await?; + let local = socket.local_addr()?; + let passes = passes.clamp(1, 10); + tracing::info!( + target = %target, + local = %local, + interface = %self.interface.as_deref().unwrap_or("auto"), + protocol = protocol_filter, + passes, + controller_id = %self.controller_id, + "Starting GREE discovery" + ); + + let deadline = Instant::now() + duration; + let interval = if passes > 1 { duration / passes as u32 } else { duration }; + let mut next_scan = Instant::now(); + let mut sent = 0_u8; + let mut result = Vec::new(); + let mut seen = HashSet::new(); + let mut buffer = vec![0_u8; 16 * 1024]; + + while Instant::now() < deadline { + if sent < passes && Instant::now() >= next_scan { + socket.send_to(br#"{"t":"scan"}"#, target).await?; + sent += 1; + next_scan = Instant::now() + interval.max(Duration::from_millis(250)); + tracing::debug!(pass = sent, passes, target = %target, "Sent GREE discovery packet"); + } + + let remaining = deadline.saturating_duration_since(Instant::now()); + let wait = remaining.min(Duration::from_millis(250)); + match timeout(wait, socket.recv_from(&mut buffer)).await { + Ok(Ok((size, source))) => { + let Ok(value) = serde_json::from_slice::(&buffer[..size]) else { continue; }; + match self.parse_discovery(value, source) { + Ok(Some(mut device)) => { + if protocol_filter != 0 && device.protocol_version != protocol_filter { continue; } + let key = device.mac.to_ascii_lowercase(); + if seen.insert(key) { + device.last_seen = Some(Utc::now()); + tracing::info!(ip=%device.ip, mac=%device.mac, protocol=device.protocol_version, model=%device.model, firmware=%device.firmware, "Discovered GREE device"); + result.push(device); + } + } + Ok(None) => {} + Err(err) => tracing::debug!(source=%source, error=?err, "Ignoring undecodable discovery response"), + } + } + Ok(Err(err)) => return Err(err.into()), + Err(_) => continue, + } + } + Ok(result) + } + + fn parse_discovery(&self, mut value: Value, source: SocketAddr) -> Result> { + let mut detected_protocol = 1_u8; + if value.get("t").and_then(Value::as_str) == Some("pack") { + if let Some(pack_value) = value.get("pack") { + if let Some(pack) = pack_value.as_str() { + let clear = if let Some(tag) = value.get("tag").and_then(Value::as_str) { + detected_protocol = 2; + decrypt_v2(GENERIC_GREE_V2_KEY, pack, tag)? + } else { + decrypt_v1(GENERIC_GREE_V1_KEY, pack)? + }; + value = serde_json::from_slice::(&clear).context("invalid decrypted discovery JSON")?; + } else if pack_value.is_object() { + value = pack_value.clone(); + } + } + } + let kind = value.get("t").and_then(Value::as_str).unwrap_or_default().to_ascii_lowercase(); + if kind != "dev" && kind != "scan" && value.get("mac").is_none() && value.get("cid").is_none() { + return Ok(None); + } + let mac = value.get("mac") + .or_else(|| value.get("cid")) + .and_then(Value::as_str) + .unwrap_or_default() + .replace([':', '-'], "").to_ascii_uppercase(); + if mac.is_empty() { return Ok(None); } + + let raw_model = value.get("model").or_else(|| value.get("series")) + .and_then(Value::as_str).unwrap_or_default().trim().to_string(); + let model_type = value.get("ModelType") + .and_then(|v| v.as_str().map(str::to_string).or_else(|| v.as_i64().map(|n| n.to_string()))) + .unwrap_or_default(); + let model = if !model_type.is_empty() && (raw_model.is_empty() || raw_model.eq_ignore_ascii_case("gree")) { + format!("GREE {model_type}") + } else if raw_model.is_empty() { + "GREE".to_string() + } else { + raw_model + }; + let ver = value.get("ver").and_then(Value::as_str).unwrap_or_default().trim(); + let hid = value.get("hid").and_then(Value::as_str).unwrap_or_default().trim(); + let firmware = match (ver.is_empty(), hid.is_empty()) { + (false, false) => format!("{ver} · {hid}"), + (false, true) => ver.to_string(), + (true, false) => hid.to_string(), + (true, true) => String::new(), + }; + let suffix = mac.chars().rev().take(4).collect::().chars().rev().collect::().to_ascii_uppercase(); + let name = value.get("name").and_then(Value::as_str) + .map(str::trim).filter(|v| !v.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| format!("{model} {suffix}")); + let now = Utc::now(); + Ok(Some(Device { + id: format!("gree-{}", mac.to_ascii_lowercase()), + mac, + name, + ip: source.ip().to_string(), + port: if source.port() == 0 { 7000 } else { source.port() }, + protocol_version: detected_protocol, + model, + firmware, + key: None, + cid: Some("app".into()), + enabled: true, + simulated: false, + power: false, + mode: "cool".into(), + target_temperature: 24.0, + fan_speed: 0, + swing_vertical: false, + swing_horizontal: false, + quiet: false, + turbo: false, + light: true, + air: false, + xfan: false, + health: false, + sleep: false, + supports_light: None, + supports_quiet: None, + supports_turbo: None, + supports_air: None, + supports_xfan: None, + supports_health: None, + supports_sleep: None, + current_temperature: None, + outdoor_temperature: None, + temperature_sensor_offset: None, + online: true, + response_time_ms: None, + last_seen: Some(now), + last_error: None, + communication_failures: 0, + created_at: now, + updated_at: now, + })) + } + +} diff --git a/src/protocol/gree/merge.rs b/src/protocol/gree/merge.rs new file mode 100644 index 0000000..ea5c663 --- /dev/null +++ b/src/protocol/gree/merge.rs @@ -0,0 +1,23 @@ +pub fn merge_discovered(existing: Option, discovered: Device) -> Device { + if let Some(mut old) = existing { + old.ip = discovered.ip; + old.port = discovered.port; + if old.name.trim().is_empty() || old.name == "Klimatyzator GREE" || old.name == "GREE air conditioner" { old.name = discovered.name; } + if !discovered.model.is_empty() { old.model = discovered.model; } + if !discovered.firmware.is_empty() { old.firmware = discovered.firmware; } + if old.protocol_version != discovered.protocol_version { + old.protocol_version = discovered.protocol_version; + old.key = None; + } + old.online = true; + old.communication_failures = 0; + old.last_seen = Some(Utc::now()); + old.last_error = None; + old.updated_at = Utc::now(); + old + } else { + let mut new = discovered; + if new.id.is_empty() { new.id = Uuid::new_v4().to_string(); } + new + } +} diff --git a/src/protocol/gree/network.rs b/src/protocol/gree/network.rs new file mode 100644 index 0000000..9ff4503 --- /dev/null +++ b/src/protocol/gree/network.rs @@ -0,0 +1,114 @@ +#[derive(Debug, Clone)] +struct LocalIpv4Config { + interface: String, + ip: Ipv4Addr, + broadcast: Ipv4Addr, + prefix_len: u32, +} + +#[cfg(target_os = "linux")] +fn local_ipv4_config_for_target(target: Ipv4Addr) -> Result> { + use std::{ffi::CStr, ptr}; + 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"); } + let mut current = addrs; + let mut best: Option = None; + while !current.is_null() { + let ifa = &*current; + 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(); + let addr = &*(ifa.ifa_addr as *const libc::sockaddr_in); + let mask_addr = &*(ifa.ifa_netmask as *const libc::sockaddr_in); + let ip = Ipv4Addr::from(addr.sin_addr.s_addr.to_ne_bytes()); + let mask = Ipv4Addr::from(mask_addr.sin_addr.s_addr.to_ne_bytes()); + let ip_u32 = u32::from(ip); + let mask_u32 = u32::from(mask); + let target_u32 = u32::from(target); + if !ip.is_loopback() && (ip_u32 & mask_u32) == (target_u32 & mask_u32) { + let prefix_len = mask_u32.count_ones(); + let candidate = LocalIpv4Config { + interface, + ip, + broadcast: Ipv4Addr::from(ip_u32 | !mask_u32), + prefix_len, + }; + if best.as_ref().map(|current| prefix_len > current.prefix_len).unwrap_or(true) { + best = Some(candidate); + } + } + } + current = ifa.ifa_next; + } + libc::freeifaddrs(addrs); + Ok(best) + } +} + +#[cfg(not(target_os = "linux"))] +fn local_ipv4_config_for_target(_target: Ipv4Addr) -> Result> { Ok(None) } + +#[cfg(target_os = "linux")] +fn interface_ipv4_config(interface: &str) -> Result<(Ipv4Addr, Ipv4Addr)> { + use std::{ffi::CStr, ptr}; + 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"); } + let mut current = addrs; + let mut found = None; + while !current.is_null() { + let ifa = &*current; + if !ifa.ifa_name.is_null() && !ifa.ifa_addr.is_null() { + let name = CStr::from_ptr(ifa.ifa_name).to_string_lossy(); + if name == interface && (*ifa.ifa_addr).sa_family as i32 == libc::AF_INET { + let addr = &*(ifa.ifa_addr as *const libc::sockaddr_in); + let ip = Ipv4Addr::from(addr.sin_addr.s_addr.to_ne_bytes()); + let broadcast = if !ifa.ifa_netmask.is_null() { + 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 }; + found = Some((ip, broadcast)); + break; + } + } + current = ifa.ifa_next; + } + libc::freeifaddrs(addrs); + found.ok_or_else(|| anyhow!("interface {interface} has no IPv4 address")) + } +} + +#[cfg(not(target_os = "linux"))] +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 value_as_f64(value: &Value) -> Option { + 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}")) +} +fn status_f64(name: &str, value: &Value) -> Result { + value_as_f64(value).ok_or_else(|| anyhow!("invalid GREE numeric value for {name}: {value}")) +} +fn status_flag(name: &str, value: &Value) -> Result { + match status_i64(name, value)? { + 0 => Ok(false), + 1 => Ok(true), + other => bail!("invalid GREE flag value for {name}: {other}"), + } +} +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), + _ => bail!("unsupported mode: {value}"), + } +} + diff --git a/src/protocol/gree/polling.rs b/src/protocol/gree/polling.rs new file mode 100644 index 0000000..8b71de8 --- /dev/null +++ b/src/protocol/gree/polling.rs @@ -0,0 +1,155 @@ +impl GreeClient { + pub async fn poll(&self, device: &mut Device) -> Result<()> { + let key = device.key.clone().ok_or_else(|| anyhow!("device is not bound"))?; + let full_cols = [ + "Pow","Mod","SetTem","WdSpd","Air","Blo","Health","SwhSlp","Lig", + "SwingLfRig","SwUpDn","Quiet","Tur","StHt","TemUn","HeatCoolType", + "TemRec","SvSt","TemSen","CoolSvTem","HeatSvTem","OutEnvTem" + ]; + let core_cols = ["Pow","Mod","SetTem","TemRec","TemUn","TemSen","WdSpd","Lig","SwingLfRig","SwUpDn","Quiet","Tur"]; + let (response, used_core_fallback) = match self.status_request(device, &key, &full_cols).await { + Ok(value) => (value, false), + Err(first) => { + tracing::debug!(device=%device.id, error=?first, "Full GREE status request failed; retrying core properties"); + (self.status_request(device, &key, &core_cols).await?, true) + } + }; + self.apply_status(device, &response)?; + // Some firmware rejects a large mixed property list but still exposes OutEnvTem. + // Probe it separately after the core fallback so compatible units can contribute + // their outdoor sensor to history without making the main poll fail. + if used_core_fallback { + match self.status_request(device, &key, &["OutEnvTem"]).await { + Ok(optional) => { let _ = self.apply_status(device, &optional); } + Err(err) => tracing::trace!(device=%device.id, error=?err, "GREE outdoor temperature is not available"), + } + } + // Capability discovery is deliberately lazy. Existing installations start with + // unknown support flags and each optional property is probed at most until a + // definitive success/failure has been persisted with the device state. + self.probe_optional_features(device, &key).await; + device.online = true; + device.communication_failures = 0; + device.last_seen = Some(Utc::now()); + device.last_error = None; + device.updated_at = Utc::now(); + Ok(()) + } + + async fn status_request(&self, device: &Device, key: &str, cols: &[&str]) -> Result { + let inner = json!({"cols": cols, "mac": Self::wire_mac(device), "t": "status"}); + self.request(device, &inner, key, false, device.protocol_version).await + } + + async fn probe_optional_features(&self, device: &mut Device, key: &str) { + let probes = [ + ("Lig", device.supports_light.is_none()), + ("Quiet", device.supports_quiet.is_none()), + ("Tur", device.supports_turbo.is_none()), + ("Air", device.supports_air.is_none()), + ("Blo", device.supports_xfan.is_none()), + ("Health", device.supports_health.is_none()), + ("SwhSlp", device.supports_sleep.is_none()), + ]; + for (property, needed) in probes { + if !needed { continue; } + match self.status_request(device, key, &[property]).await { + Ok(value) => { + let returned = value.get("cols").and_then(Value::as_array) + .map(|cols| cols.iter().any(|name| name.as_str() == Some(property))) + .unwrap_or(false); + if !returned || self.apply_status(device, &value).is_err() { + Self::set_feature_support(device, property, false); + } + } + Err(err) => { + Self::set_feature_support(device, property, false); + tracing::trace!(device=%device.id, property, error=?err, "optional GREE feature is not available"); + } + } + } + } + + fn set_feature_support(device: &mut Device, property: &str, supported: bool) { + let value = Some(supported); + match property { + "Lig" => device.supports_light = value, + "Quiet" => device.supports_quiet = value, + "Tur" => device.supports_turbo = value, + "Air" => device.supports_air = value, + "Blo" => device.supports_xfan = value, + "Health" => device.supports_health = value, + "SwhSlp" => device.supports_sleep = value, + _ => {} + } + } + + fn apply_status(&self, device: &mut Device, response: &Value) -> Result<()> { + let response_cols = response.get("cols").and_then(Value::as_array) + .ok_or_else(|| anyhow!("status response has no cols"))?; + let data = response.get("dat").and_then(Value::as_array) + .ok_or_else(|| anyhow!("status response has no dat"))?; + if data.len() < response_cols.len() { + bail!("status response contains fewer values than columns") + } + + // Parse into a clone and commit only when every climate-relevant value is valid. + // This prevents null/text/malformed frames from being silently converted into OFF, + // AUTO or a zero setpoint while leaving the rest of the packet partially applied. + let mut next = device.clone(); + let mut set_temp = None; + for (name, value) in response_cols.iter().zip(data.iter()) { + let Some(name) = name.as_str() else { continue; }; + match name { + "Pow" => next.power = status_flag(name, value)?, + "Mod" => { + let raw = status_i64(name, value)?; + next.mode = mode_name_checked(raw).ok_or_else(|| anyhow!("invalid GREE mode value for {name}: {raw}"))?.into(); + } + "SetTem" => { + let raw = status_f64(name, value)?; + if !(8.0..=30.0).contains(&raw) { bail!("invalid GREE setpoint for {name}: {raw}") } + set_temp = Some(raw.round()); + } + "WdSpd" => { + let raw = status_i64(name, value)?; + if !(0..=5).contains(&raw) { bail!("invalid GREE fan value for {name}: {raw}") } + next.fan_speed = raw as u8; + } + "SwUpDn" => next.swing_vertical = status_i64(name, value)? != 0, + "SwingLfRig" => next.swing_horizontal = status_i64(name, value)? != 0, + "Quiet" => { next.quiet = status_flag(name, value)?; next.supports_quiet = Some(true); }, + "Tur" => { next.turbo = status_flag(name, value)?; next.supports_turbo = Some(true); }, + "Lig" => { next.light = status_flag(name, value)?; next.supports_light = Some(true); }, + "Air" => { next.air = status_flag(name, value)?; next.supports_air = Some(true); }, + "Blo" => { next.xfan = status_flag(name, value)?; next.supports_xfan = Some(true); }, + "Health" => { next.health = status_flag(name, value)?; next.supports_health = Some(true); }, + "SwhSlp" => { next.sleep = status_flag(name, value)?; next.supports_sleep = Some(true); }, + "TemSen" => { + let raw = status_f64(name, value)?; + if raw != 0.0 { + let offset = raw > 40.0; + let temperature = if offset { raw - 40.0 } else { raw }; + if !(-40.0..=80.0).contains(&temperature) { bail!("invalid GREE indoor temperature: {temperature}") } + next.temperature_sensor_offset = Some(offset); + next.current_temperature = Some(temperature); + } + } + "OutEnvTem" => { + let raw = status_f64(name, value)?; + if raw != 0.0 { + let offset = next.temperature_sensor_offset.unwrap_or(raw > 50.0); + let temperature = if offset { raw - 40.0 } else { raw }; + if !(-60.0..=80.0).contains(&temperature) { bail!("invalid GREE outdoor temperature: {temperature}") } + next.outdoor_temperature = Some(temperature); + } + } + _ => {} + } + } + if let Some(base) = set_temp { next.target_temperature = base; } + *device = next; + Ok(()) + } + +} diff --git a/src/protocol/gree/tests.rs b/src/protocol/gree/tests.rs new file mode 100644 index 0000000..264d426 --- /dev/null +++ b/src/protocol/gree/tests.rs @@ -0,0 +1,42 @@ +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn invalid_status_frame_does_not_partially_mutate_device() { + let client = GreeClient::new( + "test-controller".into(), + None, + None, + Arc::new(AtomicBool::new(false)), + ); + let mut device = Device::simulated_default(); + device.power = true; + device.mode = "heat".into(); + device.target_temperature = 24.0; + let before = device.clone(); + + let response = json!({ + "cols": ["Pow", "Mod", "SetTem"], + "dat": [0, null, "not-a-number"] + }); + assert!(client.apply_status(&mut device, &response).is_err()); + assert_eq!(device.power, before.power); + assert_eq!(device.mode, before.mode); + assert_eq!(device.target_temperature, before.target_temperature); + } + + #[test] + fn thermostat_standby_setpoint_low_fan_quiet_and_sleep_share_one_frame() { + let payload = GreeClient::command_payload(&DeviceCommand { + target_temperature: Some(19.0), + fan_speed: Some(1), + quiet: Some(true), + sleep: Some(true), + ..DeviceCommand::default() + }, false).expect("thermostat command payload"); + + assert_eq!(payload.get("opt").cloned(), Some(serde_json::json!(["SetTem", "WdSpd", "Quiet", "SwhSlp"]))); + assert_eq!(payload.get("p").cloned(), Some(serde_json::json!([19, 1, 1, 1]))); + } +} diff --git a/src/protocol/gree/transport.rs b/src/protocol/gree/transport.rs new file mode 100644 index 0000000..bd18e4d --- /dev/null +++ b/src/protocol/gree/transport.rs @@ -0,0 +1,98 @@ +impl GreeClient { + async fn request(&self, device: &Device, inner: &Value, key: &str, binding: bool, protocol_version: u8) -> Result { + let target = self.device_target(device)?; + let target_hint = match target { SocketAddr::V4(addr) => Some(*addr.ip()), SocketAddr::V6(_) => None }; + let socket = self.udp_socket(false, target_hint).await?; + self.request_on_socket(device, inner, key, binding, protocol_version, &socket).await + } + + async fn request_on_socket(&self, device: &Device, inner: &Value, key: &str, binding: bool, protocol_version: u8, socket: &UdpSocket) -> Result { + let target = self.device_target(device)?; + let version = if protocol_version == 2 { 2 } else { 1 }; + let inner_bytes = serde_json::to_vec(inner)?; + let wire_mac = Self::wire_mac(device); + let mut outer = json!({ + "cid": "app", + "i": if binding { 1 } else { 0 }, + "t": "pack", + "tcid": wire_mac, + "uid": 0 + }); + if version == 2 { + let encrypted = encrypt_v2(key, &inner_bytes)?; + outer["pack"] = json!(encrypted.ciphertext); + outer["tag"] = json!(encrypted.tag); + } else { + outer["pack"] = json!(encrypt_v1(key, &inner_bytes)?); + } + let payload = serde_json::to_vec(&outer)?; + tracing::debug!(target=%target, local=%socket.local_addr()?, protocol=version, wire_mac=%wire_mac, interface=%self.interface.as_deref().unwrap_or("auto"), binding, "Sending GREE request"); + self.debug_frame("tx", device, target, version, inner); + socket.send_to(&payload, target).await?; + + let deadline = Instant::now() + Duration::from_secs(4); + let mut buffer = vec![0_u8; 16 * 1024]; + let mut last_decode_error = None; + while Instant::now() < deadline { + let remaining = deadline.saturating_duration_since(Instant::now()); + let received = timeout(remaining, socket.recv_from(&mut buffer)).await; + let (size, source) = match received { + Ok(Ok(value)) => value, + Ok(Err(err)) => return Err(err.into()), + Err(_) => break, + }; + if source.ip() != target.ip() { continue; } + self.record_received_frame(device); + let response: Value = match serde_json::from_slice(&buffer[..size]) { + Ok(value) => value, + Err(err) => { last_decode_error = Some(anyhow!("invalid GREE JSON response: {err}")); continue; } + }; + if let Some(pack) = response.get("pack").and_then(Value::as_object) { + let decoded = Value::Object(pack.clone()); + if binding { + let response_type = decoded.get("t").and_then(Value::as_str).unwrap_or_default(); + if !response_type.eq_ignore_ascii_case("bindok") { + tracing::debug!(source=%source, response_type=%response_type, "Ignoring non-bind packet while waiting for GREE bind response"); + continue; + } + } + if let Some(err) = decoded.get("err").filter(|v| !v.is_null()) { bail!("GREE device error: {err}") } + self.debug_frame("rx", device, target, version, &decoded); + return Ok(decoded); + } + let Some(pack) = response.get("pack").and_then(Value::as_str) else { continue; }; + let clear = if version == 2 { + let Some(tag) = response.get("tag").and_then(Value::as_str) else { + last_decode_error = Some(anyhow!("AES-GCM response is missing tag")); + continue; + }; + match decrypt_v2(key, pack, tag) { + Ok(v) => v, + Err(err) => { last_decode_error = Some(err); continue; } + } + } else { + match decrypt_v1(key, pack) { + Ok(v) => v, + Err(err) => { last_decode_error = Some(err); continue; } + } + }; + let decoded: Value = match serde_json::from_slice(&clear) { + Ok(value) => value, + Err(err) => { last_decode_error = Some(anyhow!("invalid decrypted GREE response: {err}")); continue; } + }; + if binding { + let response_type = decoded.get("t").and_then(Value::as_str).unwrap_or_default(); + if !response_type.eq_ignore_ascii_case("bindok") { continue; } + } + if let Some(err) = decoded.get("err").filter(|v| !v.is_null()) { bail!("GREE device error: {err}") } + self.debug_frame("rx", device, target, version, &decoded); + return Ok(decoded); + } + if let Some(err) = last_decode_error { return Err(err); } + bail!("GREE response timeout after 4 seconds") + } + + fn device_target(&self, device: &Device) -> Result { + format!("{}:{}", device.ip, device.port).parse().context("invalid device address") + } +} diff --git a/src/queries.rs b/src/queries.rs index cc5accc..4659ce0 100644 --- a/src/queries.rs +++ b/src/queries.rs @@ -4,419 +4,10 @@ //! transactions and domain behavior. New queries and schema migrations should //! be added here instead of embedding SQL strings in other Rust modules. -pub const INIT_SCHEMA: &str = r#" -PRAGMA journal_mode=WAL; -PRAGMA synchronous=NORMAL; -PRAGMA foreign_keys=ON; - -CREATE TABLE IF NOT EXISTS schema_migrations ( - version INTEGER PRIMARY KEY, - applied_at TEXT NOT NULL -); - -CREATE TABLE IF NOT EXISTS settings ( - key TEXT PRIMARY KEY, - value TEXT NOT NULL, - updated_at TEXT NOT NULL -); - -CREATE TABLE IF NOT EXISTS devices ( - id TEXT PRIMARY KEY, - mac TEXT NOT NULL UNIQUE, - name TEXT NOT NULL, - ip TEXT NOT NULL, - simulated INTEGER NOT NULL DEFAULT 0, - payload TEXT NOT NULL, - updated_at TEXT NOT NULL -); - -CREATE TABLE IF NOT EXISTS zones ( - id TEXT PRIMARY KEY, - payload TEXT NOT NULL, - updated_at TEXT NOT NULL -); - -CREATE TABLE IF NOT EXISTS climate_groups ( - id TEXT PRIMARY KEY, - payload TEXT NOT NULL, - updated_at TEXT NOT NULL -); - -CREATE TABLE IF NOT EXISTS schedules ( - id TEXT PRIMARY KEY, - zone_id TEXT NOT NULL, - payload TEXT NOT NULL, - updated_at TEXT NOT NULL -); -CREATE INDEX IF NOT EXISTS schedules_zone_idx ON schedules(zone_id); - -CREATE TABLE IF NOT EXISTS automations ( - id TEXT PRIMARY KEY, - payload TEXT NOT NULL, - updated_at TEXT NOT NULL -); - -CREATE TABLE IF NOT EXISTS readings ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - device_id TEXT NOT NULL, - timestamp TEXT NOT NULL, - indoor_temperature REAL, - outdoor_temperature REAL, - target_temperature REAL NOT NULL, - power INTEGER NOT NULL, - source TEXT NOT NULL -); -CREATE INDEX IF NOT EXISTS readings_device_time_idx - ON readings(device_id, timestamp DESC); - -CREATE TABLE IF NOT EXISTS zone_readings ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - zone_id TEXT NOT NULL, - device_id TEXT NOT NULL, - timestamp TEXT NOT NULL, - gree_temperature REAL, - external_temperature REAL, - control_temperature REAL, - target_temperature REAL, - device_setpoint REAL, - outdoor_temperature REAL, - power INTEGER NOT NULL, - mode TEXT NOT NULL, - fan_speed INTEGER NOT NULL, - demand INTEGER NOT NULL, - control_source TEXT NOT NULL, - active_preset TEXT NOT NULL -); -CREATE INDEX IF NOT EXISTS zone_readings_zone_time_idx - ON zone_readings(zone_id, timestamp DESC); -CREATE INDEX IF NOT EXISTS zone_readings_time_idx - ON zone_readings(timestamp DESC); - -CREATE TABLE IF NOT EXISTS ha_readings ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - entity_id TEXT NOT NULL, - zone_id TEXT, - kind TEXT NOT NULL, - timestamp TEXT NOT NULL, - temperature REAL NOT NULL -); -CREATE INDEX IF NOT EXISTS ha_readings_entity_time_idx - ON ha_readings(entity_id, timestamp DESC); -CREATE INDEX IF NOT EXISTS ha_readings_zone_time_idx - ON ha_readings(zone_id, timestamp DESC); - -CREATE TABLE IF NOT EXISTS event_log ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - timestamp TEXT NOT NULL, - level TEXT NOT NULL, - kind TEXT NOT NULL, - message TEXT NOT NULL, - metadata TEXT NOT NULL -); -CREATE INDEX IF NOT EXISTS event_log_time_idx ON event_log(timestamp DESC); - -CREATE TABLE IF NOT EXISTS api_tokens ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - token_hash TEXT NOT NULL UNIQUE, - token_prefix TEXT NOT NULL, - created_at TEXT NOT NULL -); - -INSERT OR IGNORE INTO schema_migrations(version, applied_at) - VALUES (1, strftime('%Y-%m-%dT%H:%M:%fZ','now')); -INSERT OR IGNORE INTO schema_migrations(version, applied_at) - VALUES (2, strftime('%Y-%m-%dT%H:%M:%fZ','now')); -INSERT OR IGNORE INTO schema_migrations(version, applied_at) - VALUES (3, strftime('%Y-%m-%dT%H:%M:%fZ','now')); -INSERT OR IGNORE INTO schema_migrations(version, applied_at) - VALUES (4, strftime('%Y-%m-%dT%H:%M:%fZ','now')); -INSERT OR IGNORE INTO schema_migrations(version, applied_at) - VALUES (5, strftime('%Y-%m-%dT%H:%M:%fZ','now')); -"#; - -pub const COUNT_DEVICES: &str = "SELECT COUNT(*) FROM devices"; - -pub const UPSERT_DEVICE: &str = r#" -INSERT INTO devices(id, mac, name, ip, simulated, payload, updated_at) -VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) -ON CONFLICT(id) DO UPDATE SET - mac=excluded.mac, - name=excluded.name, - ip=excluded.ip, - simulated=excluded.simulated, - payload=excluded.payload, - updated_at=excluded.updated_at -"#; - -pub const LIST_DEVICES: &str = "SELECT payload FROM devices ORDER BY name COLLATE NOCASE"; -pub const GET_DEVICE_BY_ID: &str = "SELECT payload FROM devices WHERE id=?1"; -pub const GET_DEVICE_BY_MAC: &str = "SELECT payload FROM devices WHERE lower(mac)=lower(?1)"; -pub const DELETE_DEVICE_READINGS: &str = "DELETE FROM readings WHERE device_id=?1"; -pub const DELETE_SCHEDULES_BY_DEVICE_ID: &str = - "DELETE FROM schedules WHERE zone_id IN (SELECT id FROM zones WHERE json_extract(payload, '$.device_id')=?1)"; -pub const DELETE_ZONES_BY_DEVICE_ID: &str = - "DELETE FROM zones WHERE json_extract(payload, '$.device_id')=?1"; -pub const DELETE_DEVICE: &str = "DELETE FROM devices WHERE id=?1"; - -pub const UPSERT_ZONE: &str = r#" -INSERT INTO zones(id,payload,updated_at) VALUES(?1,?2,?3) -ON CONFLICT(id) DO UPDATE SET - payload=excluded.payload, - updated_at=excluded.updated_at -"#; -pub const LIST_ZONES: &str = - "SELECT payload FROM zones ORDER BY json_extract(payload, '$.name') COLLATE NOCASE"; -pub const GET_ZONE: &str = "SELECT payload FROM zones WHERE id=?1"; -pub const DELETE_SCHEDULES_BY_ZONE_ID: &str = "DELETE FROM schedules WHERE zone_id=?1"; -pub const DELETE_ZONE: &str = "DELETE FROM zones WHERE id=?1"; - -pub const UPSERT_GROUP: &str = r#" -INSERT INTO climate_groups(id,payload,updated_at) VALUES(?1,?2,?3) -ON CONFLICT(id) DO UPDATE SET - payload=excluded.payload, - updated_at=excluded.updated_at -"#; -pub const LIST_GROUPS: &str = - "SELECT payload FROM climate_groups ORDER BY json_extract(payload, '$.name') COLLATE NOCASE"; -pub const GET_GROUP: &str = "SELECT payload FROM climate_groups WHERE id=?1"; -pub const DELETE_GROUP: &str = "DELETE FROM climate_groups WHERE id=?1"; - -pub const UPSERT_SCHEDULE: &str = r#" -INSERT INTO schedules(id,zone_id,payload,updated_at) VALUES(?1,?2,?3,?4) -ON CONFLICT(id) DO UPDATE SET - zone_id=excluded.zone_id, - payload=excluded.payload, - updated_at=excluded.updated_at -"#; -pub const LIST_SCHEDULES: &str = - "SELECT payload FROM schedules ORDER BY json_extract(payload, '$.name') COLLATE NOCASE"; -pub const GET_SCHEDULE: &str = "SELECT payload FROM schedules WHERE id=?1"; -pub const DELETE_SCHEDULE: &str = "DELETE FROM schedules WHERE id=?1"; - -pub const UPSERT_AUTOMATION: &str = r#" -INSERT INTO automations(id,payload,updated_at) VALUES(?1,?2,?3) -ON CONFLICT(id) DO UPDATE SET - payload=excluded.payload, - updated_at=excluded.updated_at -"#; -pub const LIST_AUTOMATIONS: &str = - "SELECT payload FROM automations ORDER BY json_extract(payload, '$.name') COLLATE NOCASE"; -pub const GET_AUTOMATION: &str = "SELECT payload FROM automations WHERE id=?1"; -pub const DELETE_AUTOMATION: &str = "DELETE FROM automations WHERE id=?1"; - -pub const INSERT_READING: &str = r#" -INSERT INTO readings( - device_id, - timestamp, - indoor_temperature, - outdoor_temperature, - target_temperature, - power, - source -) -VALUES(?1,?2,?3,?4,?5,?6,?7) -"#; - -pub const LIST_READINGS_BY_DEVICE: &str = r#" -SELECT id,device_id,timestamp,indoor_temperature,outdoor_temperature,target_temperature,power,source -FROM readings -WHERE device_id=?1 AND timestamp>=?2 -ORDER BY timestamp ASC -LIMIT ?3 -"#; - -pub const LIST_READINGS_ALL: &str = r#" -SELECT id,device_id,timestamp,indoor_temperature,outdoor_temperature,target_temperature,power,source -FROM readings -WHERE timestamp>=?1 -ORDER BY timestamp ASC -LIMIT ?2 -"#; - -pub const LIST_DEVICE_HISTORY_BY_DEVICE_BUCKETED: &str = r#" -SELECT MIN(id),device_id,MIN(timestamp),AVG(indoor_temperature),AVG(outdoor_temperature), - AVG(target_temperature),MAX(power),MAX(source) -FROM readings -WHERE device_id=?1 AND timestamp>=?2 -GROUP BY device_id, CAST(unixepoch(timestamp)/?3 AS INTEGER) -ORDER BY MIN(timestamp) ASC -LIMIT ?4 -"#; - -pub const LIST_DEVICE_HISTORY_ALL_BUCKETED: &str = r#" -SELECT MIN(id),device_id,MIN(timestamp),AVG(indoor_temperature),AVG(outdoor_temperature), - AVG(target_temperature),MAX(power),MAX(source) -FROM readings -WHERE timestamp>=?1 -GROUP BY device_id, CAST(unixepoch(timestamp)/?2 AS INTEGER) -ORDER BY MIN(timestamp) ASC -LIMIT ?3 -"#; - -pub const LIST_DEVICE_HISTORY_BEFORE: &str = r#" -SELECT id,device_id,timestamp,indoor_temperature,outdoor_temperature,target_temperature,power,source -FROM readings WHERE timestamp < ?1 ORDER BY timestamp ASC LIMIT ?2 -"#; -pub const DELETE_READING_BY_ID: &str = "DELETE FROM readings WHERE id=?1"; - -pub const PRUNE_READINGS: &str = "DELETE FROM readings WHERE timestamp < ?1"; - -pub const INSERT_ZONE_READING_IF_DUE: &str = r#" -INSERT INTO zone_readings( - zone_id, device_id, timestamp, gree_temperature, external_temperature, - control_temperature, target_temperature, device_setpoint, outdoor_temperature, - power, mode, fan_speed, demand, control_source, active_preset -) -SELECT ?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15 -WHERE NOT EXISTS ( - SELECT 1 FROM zone_readings WHERE zone_id=?1 AND timestamp>=?16 LIMIT 1 -) -"#; - - -pub const LIST_ZONE_HISTORY_BY_ZONE_BUCKETED: &str = r#" -SELECT MIN(id),zone_id,MAX(device_id),MIN(timestamp), - AVG(gree_temperature),AVG(external_temperature),AVG(control_temperature), - AVG(target_temperature),AVG(device_setpoint),AVG(outdoor_temperature), - MAX(power),MAX(mode),CAST(ROUND(AVG(fan_speed)) AS INTEGER),MAX(demand), - MAX(control_source),MAX(active_preset) -FROM zone_readings -WHERE zone_id=?1 AND timestamp>=?2 -GROUP BY zone_id, CAST(unixepoch(timestamp)/?3 AS INTEGER) -ORDER BY MIN(timestamp) ASC -LIMIT ?4 -"#; - -pub const LIST_ZONE_HISTORY_ALL_BUCKETED: &str = r#" -SELECT MIN(id),zone_id,MAX(device_id),MIN(timestamp), - AVG(gree_temperature),AVG(external_temperature),AVG(control_temperature), - AVG(target_temperature),AVG(device_setpoint),AVG(outdoor_temperature), - MAX(power),MAX(mode),CAST(ROUND(AVG(fan_speed)) AS INTEGER),MAX(demand), - MAX(control_source),MAX(active_preset) -FROM zone_readings -WHERE timestamp>=?1 -GROUP BY zone_id, CAST(unixepoch(timestamp)/?2 AS INTEGER) -ORDER BY MIN(timestamp) ASC -LIMIT ?3 -"#; - -pub const DELETE_ZONE_READINGS_BY_ZONE_ID: &str = "DELETE FROM zone_readings WHERE zone_id=?1"; -pub const DELETE_ZONE_READINGS_BY_DEVICE_ID: &str = "DELETE FROM zone_readings WHERE device_id=?1"; -pub const LIST_ZONE_HISTORY_BEFORE: &str = r#" -SELECT id,zone_id,device_id,timestamp,gree_temperature,external_temperature,control_temperature, - target_temperature,device_setpoint,outdoor_temperature,power,mode,fan_speed,demand,control_source,active_preset -FROM zone_readings WHERE timestamp < ?1 ORDER BY timestamp ASC LIMIT ?2 -"#; -pub const DELETE_ZONE_READING_BY_ID: &str = "DELETE FROM zone_readings WHERE id=?1"; - -pub const PRUNE_ZONE_READINGS: &str = "DELETE FROM zone_readings WHERE timestamp < ?1"; - -pub const INSERT_HA_READING_IF_DUE: &str = r#" -INSERT INTO ha_readings(entity_id,zone_id,kind,timestamp,temperature) -SELECT ?1,?2,?3,?4,?5 -WHERE NOT EXISTS ( - SELECT 1 FROM ha_readings - WHERE entity_id=?1 AND COALESCE(zone_id,'')=COALESCE(?2,'') AND kind=?3 AND timestamp>=?6 - LIMIT 1 -) -"#; - -pub const LIST_HA_HISTORY_BY_ENTITY_BUCKETED: &str = r#" -SELECT MIN(id),entity_id,MAX(zone_id),MAX(kind),MIN(timestamp),AVG(temperature) -FROM ha_readings -WHERE entity_id=?1 AND timestamp>=?2 -GROUP BY entity_id,COALESCE(zone_id,''),kind,CAST(unixepoch(timestamp)/?3 AS INTEGER) -ORDER BY MIN(timestamp) ASC -LIMIT ?4 -"#; - -pub const LIST_HA_HISTORY_ALL_BUCKETED: &str = r#" -SELECT MIN(id),entity_id,MAX(zone_id),MAX(kind),MIN(timestamp),AVG(temperature) -FROM ha_readings -WHERE timestamp>=?1 -GROUP BY entity_id,COALESCE(zone_id,''),kind,CAST(unixepoch(timestamp)/?2 AS INTEGER) -ORDER BY MIN(timestamp) ASC -LIMIT ?3 -"#; - -pub const LIST_HA_HISTORY_BEFORE: &str = r#" -SELECT id,entity_id,zone_id,kind,timestamp,temperature -FROM ha_readings WHERE timestamp < ?1 ORDER BY timestamp ASC LIMIT ?2 -"#; -pub const DELETE_HA_READING_BY_ID: &str = "DELETE FROM ha_readings WHERE id=?1"; - -pub const PRUNE_HA_READINGS: &str = "DELETE FROM ha_readings WHERE timestamp < ?1"; - - -// Tiered history compaction keeps only the resolution the charts can actually display. -pub const COMPACT_DEVICE_HISTORY: &str = r#" -DELETE FROM readings WHERE id IN ( - SELECT id FROM ( - SELECT id, ROW_NUMBER() OVER ( - PARTITION BY device_id, CAST(unixepoch(timestamp)/?1 AS INTEGER) - ORDER BY timestamp DESC, id DESC - ) AS rn - FROM readings WHERE timestamp < ?2 AND timestamp >= ?3 - ) WHERE rn > 1 -) -"#; - -pub const COMPACT_ZONE_HISTORY: &str = r#" -DELETE FROM zone_readings WHERE id IN ( - SELECT id FROM ( - SELECT id, ROW_NUMBER() OVER ( - PARTITION BY zone_id, CAST(unixepoch(timestamp)/?1 AS INTEGER) - ORDER BY timestamp DESC, id DESC - ) AS rn - FROM zone_readings WHERE timestamp < ?2 AND timestamp >= ?3 - ) WHERE rn > 1 -) -"#; - -pub const COMPACT_HA_HISTORY: &str = r#" -DELETE FROM ha_readings WHERE id IN ( - SELECT id FROM ( - SELECT id, ROW_NUMBER() OVER ( - PARTITION BY entity_id, COALESCE(zone_id,''), kind, CAST(unixepoch(timestamp)/?1 AS INTEGER) - ORDER BY timestamp DESC, id DESC - ) AS rn - FROM ha_readings WHERE timestamp < ?2 AND timestamp >= ?3 - ) WHERE rn > 1 -) -"#; - -pub const CLEAR_CONFIGURATION: &str = r#" -DELETE FROM schedules; -DELETE FROM automations; -DELETE FROM climate_groups; -DELETE FROM zones; -DELETE FROM devices; -"#; -pub const HISTORY_COUNTS: &str = r#" -SELECT - (SELECT COUNT(*) FROM readings), - (SELECT COUNT(*) FROM zone_readings), - (SELECT COUNT(*) FROM ha_readings) -"#; - -pub const INSERT_EVENT: &str = - "INSERT INTO event_log(timestamp,level,kind,message,metadata) VALUES(?1,?2,?3,?4,?5)"; -pub const LIST_EVENTS: &str = - "SELECT id,timestamp,level,kind,message,metadata FROM event_log ORDER BY id DESC LIMIT ?1"; -pub const PRUNE_EVENTS: &str = "DELETE FROM event_log WHERE timestamp < ?1"; - -pub const LIST_API_TOKENS: &str = - "SELECT id,name,token_prefix,created_at FROM api_tokens ORDER BY created_at DESC"; -pub const INSERT_API_TOKEN: &str = - "INSERT INTO api_tokens(id,name,token_hash,token_prefix,created_at) VALUES(?1,?2,?3,?4,?5)"; -pub const API_TOKEN_EXISTS: &str = "SELECT 1 FROM api_tokens WHERE token_hash=?1 LIMIT 1"; -pub const DELETE_API_TOKEN: &str = "DELETE FROM api_tokens WHERE id=?1"; - -pub const LOAD_RUNTIME_SETTINGS: &str = "SELECT value FROM settings WHERE key='runtime'"; -pub const UPSERT_RUNTIME_SETTINGS: &str = r#" -INSERT INTO settings(key,value,updated_at) VALUES('runtime',?1,?2) -ON CONFLICT(key) DO UPDATE SET - value=excluded.value, - updated_at=excluded.updated_at -"#; +// Functional source split intentionally keeps items in the existing module namespace. +include!("queries/schema.rs"); +include!("queries/entities.rs"); +include!("queries/device_history.rs"); +include!("queries/zone_history.rs"); +include!("queries/ha_history.rs"); +include!("queries/maintenance.rs"); diff --git a/src/queries/device_history.rs b/src/queries/device_history.rs new file mode 100644 index 0000000..b32974c --- /dev/null +++ b/src/queries/device_history.rs @@ -0,0 +1,57 @@ +pub const INSERT_READING: &str = r#" +INSERT INTO readings( + device_id, + timestamp, + indoor_temperature, + outdoor_temperature, + target_temperature, + power, + source +) +VALUES(?1,?2,?3,?4,?5,?6,?7) +"#; + +pub const LIST_READINGS_BY_DEVICE: &str = r#" +SELECT id,device_id,timestamp,indoor_temperature,outdoor_temperature,target_temperature,power,source +FROM readings +WHERE device_id=?1 AND timestamp>=?2 +ORDER BY timestamp ASC +LIMIT ?3 +"#; + +pub const LIST_READINGS_ALL: &str = r#" +SELECT id,device_id,timestamp,indoor_temperature,outdoor_temperature,target_temperature,power,source +FROM readings +WHERE timestamp>=?1 +ORDER BY timestamp ASC +LIMIT ?2 +"#; + +pub const LIST_DEVICE_HISTORY_BY_DEVICE_BUCKETED: &str = r#" +SELECT MIN(id),device_id,MIN(timestamp),AVG(indoor_temperature),AVG(outdoor_temperature), + AVG(target_temperature),MAX(power),MAX(source) +FROM readings +WHERE device_id=?1 AND timestamp>=?2 +GROUP BY device_id, CAST(unixepoch(timestamp)/?3 AS INTEGER) +ORDER BY MIN(timestamp) ASC +LIMIT ?4 +"#; + +pub const LIST_DEVICE_HISTORY_ALL_BUCKETED: &str = r#" +SELECT MIN(id),device_id,MIN(timestamp),AVG(indoor_temperature),AVG(outdoor_temperature), + AVG(target_temperature),MAX(power),MAX(source) +FROM readings +WHERE timestamp>=?1 +GROUP BY device_id, CAST(unixepoch(timestamp)/?2 AS INTEGER) +ORDER BY MIN(timestamp) ASC +LIMIT ?3 +"#; + +pub const LIST_DEVICE_HISTORY_BEFORE: &str = r#" +SELECT id,device_id,timestamp,indoor_temperature,outdoor_temperature,target_temperature,power,source +FROM readings WHERE timestamp < ?1 ORDER BY timestamp ASC LIMIT ?2 +"#; +pub const DELETE_READING_BY_ID: &str = "DELETE FROM readings WHERE id=?1"; + +pub const PRUNE_READINGS: &str = "DELETE FROM readings WHERE timestamp < ?1"; + diff --git a/src/queries/entities.rs b/src/queries/entities.rs new file mode 100644 index 0000000..e9f8e6d --- /dev/null +++ b/src/queries/entities.rs @@ -0,0 +1,70 @@ +pub const COUNT_DEVICES: &str = "SELECT COUNT(*) FROM devices"; + +pub const UPSERT_DEVICE: &str = r#" +INSERT INTO devices(id, mac, name, ip, simulated, payload, updated_at) +VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) +ON CONFLICT(id) DO UPDATE SET + mac=excluded.mac, + name=excluded.name, + ip=excluded.ip, + simulated=excluded.simulated, + payload=excluded.payload, + updated_at=excluded.updated_at +"#; + +pub const LIST_DEVICES: &str = "SELECT payload FROM devices ORDER BY name COLLATE NOCASE"; +pub const GET_DEVICE_BY_ID: &str = "SELECT payload FROM devices WHERE id=?1"; +pub const GET_DEVICE_BY_MAC: &str = "SELECT payload FROM devices WHERE lower(mac)=lower(?1)"; +pub const DELETE_DEVICE_READINGS: &str = "DELETE FROM readings WHERE device_id=?1"; +pub const DELETE_SCHEDULES_BY_DEVICE_ID: &str = + "DELETE FROM schedules WHERE zone_id IN (SELECT id FROM zones WHERE json_extract(payload, '$.device_id')=?1)"; +pub const DELETE_ZONES_BY_DEVICE_ID: &str = + "DELETE FROM zones WHERE json_extract(payload, '$.device_id')=?1"; +pub const DELETE_DEVICE: &str = "DELETE FROM devices WHERE id=?1"; + +pub const UPSERT_ZONE: &str = r#" +INSERT INTO zones(id,payload,updated_at) VALUES(?1,?2,?3) +ON CONFLICT(id) DO UPDATE SET + payload=excluded.payload, + updated_at=excluded.updated_at +"#; +pub const LIST_ZONES: &str = + "SELECT payload FROM zones ORDER BY json_extract(payload, '$.name') COLLATE NOCASE"; +pub const GET_ZONE: &str = "SELECT payload FROM zones WHERE id=?1"; +pub const DELETE_SCHEDULES_BY_ZONE_ID: &str = "DELETE FROM schedules WHERE zone_id=?1"; +pub const DELETE_ZONE: &str = "DELETE FROM zones WHERE id=?1"; + +pub const UPSERT_GROUP: &str = r#" +INSERT INTO climate_groups(id,payload,updated_at) VALUES(?1,?2,?3) +ON CONFLICT(id) DO UPDATE SET + payload=excluded.payload, + updated_at=excluded.updated_at +"#; +pub const LIST_GROUPS: &str = + "SELECT payload FROM climate_groups ORDER BY json_extract(payload, '$.name') COLLATE NOCASE"; +pub const GET_GROUP: &str = "SELECT payload FROM climate_groups WHERE id=?1"; +pub const DELETE_GROUP: &str = "DELETE FROM climate_groups WHERE id=?1"; + +pub const UPSERT_SCHEDULE: &str = r#" +INSERT INTO schedules(id,zone_id,payload,updated_at) VALUES(?1,?2,?3,?4) +ON CONFLICT(id) DO UPDATE SET + zone_id=excluded.zone_id, + payload=excluded.payload, + updated_at=excluded.updated_at +"#; +pub const LIST_SCHEDULES: &str = + "SELECT payload FROM schedules ORDER BY json_extract(payload, '$.name') COLLATE NOCASE"; +pub const GET_SCHEDULE: &str = "SELECT payload FROM schedules WHERE id=?1"; +pub const DELETE_SCHEDULE: &str = "DELETE FROM schedules WHERE id=?1"; + +pub const UPSERT_AUTOMATION: &str = r#" +INSERT INTO automations(id,payload,updated_at) VALUES(?1,?2,?3) +ON CONFLICT(id) DO UPDATE SET + payload=excluded.payload, + updated_at=excluded.updated_at +"#; +pub const LIST_AUTOMATIONS: &str = + "SELECT payload FROM automations ORDER BY json_extract(payload, '$.name') COLLATE NOCASE"; +pub const GET_AUTOMATION: &str = "SELECT payload FROM automations WHERE id=?1"; +pub const DELETE_AUTOMATION: &str = "DELETE FROM automations WHERE id=?1"; + diff --git a/src/queries/ha_history.rs b/src/queries/ha_history.rs new file mode 100644 index 0000000..9496ccd --- /dev/null +++ b/src/queries/ha_history.rs @@ -0,0 +1,38 @@ +pub const INSERT_HA_READING_IF_DUE: &str = r#" +INSERT INTO ha_readings(entity_id,zone_id,kind,timestamp,temperature) +SELECT ?1,?2,?3,?4,?5 +WHERE NOT EXISTS ( + SELECT 1 FROM ha_readings + WHERE entity_id=?1 AND COALESCE(zone_id,'')=COALESCE(?2,'') AND kind=?3 AND timestamp>=?6 + LIMIT 1 +) +"#; + +pub const LIST_HA_HISTORY_BY_ENTITY_BUCKETED: &str = r#" +SELECT MIN(id),entity_id,MAX(zone_id),MAX(kind),MIN(timestamp),AVG(temperature) +FROM ha_readings +WHERE entity_id=?1 AND timestamp>=?2 +GROUP BY entity_id,COALESCE(zone_id,''),kind,CAST(unixepoch(timestamp)/?3 AS INTEGER) +ORDER BY MIN(timestamp) ASC +LIMIT ?4 +"#; + +pub const LIST_HA_HISTORY_ALL_BUCKETED: &str = r#" +SELECT MIN(id),entity_id,MAX(zone_id),MAX(kind),MIN(timestamp),AVG(temperature) +FROM ha_readings +WHERE timestamp>=?1 +GROUP BY entity_id,COALESCE(zone_id,''),kind,CAST(unixepoch(timestamp)/?2 AS INTEGER) +ORDER BY MIN(timestamp) ASC +LIMIT ?3 +"#; + +pub const LIST_HA_HISTORY_BEFORE: &str = r#" +SELECT id,entity_id,zone_id,kind,timestamp,temperature +FROM ha_readings WHERE timestamp < ?1 ORDER BY timestamp ASC LIMIT ?2 +"#; +pub const DELETE_HA_READING_BY_ID: &str = "DELETE FROM ha_readings WHERE id=?1"; + +pub const PRUNE_HA_READINGS: &str = "DELETE FROM ha_readings WHERE timestamp < ?1"; + + +// Tiered history compaction keeps only the resolution the charts can actually display. diff --git a/src/queries/maintenance.rs b/src/queries/maintenance.rs new file mode 100644 index 0000000..cd06c68 --- /dev/null +++ b/src/queries/maintenance.rs @@ -0,0 +1,70 @@ +pub const COMPACT_DEVICE_HISTORY: &str = r#" +DELETE FROM readings WHERE id IN ( + SELECT id FROM ( + SELECT id, ROW_NUMBER() OVER ( + PARTITION BY device_id, CAST(unixepoch(timestamp)/?1 AS INTEGER) + ORDER BY timestamp DESC, id DESC + ) AS rn + FROM readings WHERE timestamp < ?2 AND timestamp >= ?3 + ) WHERE rn > 1 +) +"#; + +pub const COMPACT_ZONE_HISTORY: &str = r#" +DELETE FROM zone_readings WHERE id IN ( + SELECT id FROM ( + SELECT id, ROW_NUMBER() OVER ( + PARTITION BY zone_id, CAST(unixepoch(timestamp)/?1 AS INTEGER) + ORDER BY timestamp DESC, id DESC + ) AS rn + FROM zone_readings WHERE timestamp < ?2 AND timestamp >= ?3 + ) WHERE rn > 1 +) +"#; + +pub const COMPACT_HA_HISTORY: &str = r#" +DELETE FROM ha_readings WHERE id IN ( + SELECT id FROM ( + SELECT id, ROW_NUMBER() OVER ( + PARTITION BY entity_id, COALESCE(zone_id,''), kind, CAST(unixepoch(timestamp)/?1 AS INTEGER) + ORDER BY timestamp DESC, id DESC + ) AS rn + FROM ha_readings WHERE timestamp < ?2 AND timestamp >= ?3 + ) WHERE rn > 1 +) +"#; + +pub const CLEAR_CONFIGURATION: &str = r#" +DELETE FROM schedules; +DELETE FROM automations; +DELETE FROM climate_groups; +DELETE FROM zones; +DELETE FROM devices; +"#; +pub const HISTORY_COUNTS: &str = r#" +SELECT + (SELECT COUNT(*) FROM readings), + (SELECT COUNT(*) FROM zone_readings), + (SELECT COUNT(*) FROM ha_readings) +"#; + +pub const INSERT_EVENT: &str = + "INSERT INTO event_log(timestamp,level,kind,message,metadata) VALUES(?1,?2,?3,?4,?5)"; +pub const LIST_EVENTS: &str = + "SELECT id,timestamp,level,kind,message,metadata FROM event_log ORDER BY id DESC LIMIT ?1"; +pub const PRUNE_EVENTS: &str = "DELETE FROM event_log WHERE timestamp < ?1"; + +pub const LIST_API_TOKENS: &str = + "SELECT id,name,token_prefix,created_at FROM api_tokens ORDER BY created_at DESC"; +pub const INSERT_API_TOKEN: &str = + "INSERT INTO api_tokens(id,name,token_hash,token_prefix,created_at) VALUES(?1,?2,?3,?4,?5)"; +pub const API_TOKEN_EXISTS: &str = "SELECT 1 FROM api_tokens WHERE token_hash=?1 LIMIT 1"; +pub const DELETE_API_TOKEN: &str = "DELETE FROM api_tokens WHERE id=?1"; + +pub const LOAD_RUNTIME_SETTINGS: &str = "SELECT value FROM settings WHERE key='runtime'"; +pub const UPSERT_RUNTIME_SETTINGS: &str = r#" +INSERT INTO settings(key,value,updated_at) VALUES('runtime',?1,?2) +ON CONFLICT(key) DO UPDATE SET + value=excluded.value, + updated_at=excluded.updated_at +"#; diff --git a/src/queries/schema.rs b/src/queries/schema.rs new file mode 100644 index 0000000..d78fa4e --- /dev/null +++ b/src/queries/schema.rs @@ -0,0 +1,131 @@ +pub const INIT_SCHEMA: &str = r#" +PRAGMA journal_mode=WAL; +PRAGMA synchronous=NORMAL; +PRAGMA foreign_keys=ON; + +CREATE TABLE IF NOT EXISTS schema_migrations ( + version INTEGER PRIMARY KEY, + applied_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS devices ( + id TEXT PRIMARY KEY, + mac TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + ip TEXT NOT NULL, + simulated INTEGER NOT NULL DEFAULT 0, + payload TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS zones ( + id TEXT PRIMARY KEY, + payload TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS climate_groups ( + id TEXT PRIMARY KEY, + payload TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS schedules ( + id TEXT PRIMARY KEY, + zone_id TEXT NOT NULL, + payload TEXT NOT NULL, + updated_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS schedules_zone_idx ON schedules(zone_id); + +CREATE TABLE IF NOT EXISTS automations ( + id TEXT PRIMARY KEY, + payload TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS readings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + device_id TEXT NOT NULL, + timestamp TEXT NOT NULL, + indoor_temperature REAL, + outdoor_temperature REAL, + target_temperature REAL NOT NULL, + power INTEGER NOT NULL, + source TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS readings_device_time_idx + ON readings(device_id, timestamp DESC); + +CREATE TABLE IF NOT EXISTS zone_readings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + zone_id TEXT NOT NULL, + device_id TEXT NOT NULL, + timestamp TEXT NOT NULL, + gree_temperature REAL, + external_temperature REAL, + control_temperature REAL, + target_temperature REAL, + device_setpoint REAL, + outdoor_temperature REAL, + power INTEGER NOT NULL, + mode TEXT NOT NULL, + fan_speed INTEGER NOT NULL, + demand INTEGER NOT NULL, + control_source TEXT NOT NULL, + active_preset TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS zone_readings_zone_time_idx + ON zone_readings(zone_id, timestamp DESC); +CREATE INDEX IF NOT EXISTS zone_readings_time_idx + ON zone_readings(timestamp DESC); + +CREATE TABLE IF NOT EXISTS ha_readings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + entity_id TEXT NOT NULL, + zone_id TEXT, + kind TEXT NOT NULL, + timestamp TEXT NOT NULL, + temperature REAL NOT NULL +); +CREATE INDEX IF NOT EXISTS ha_readings_entity_time_idx + ON ha_readings(entity_id, timestamp DESC); +CREATE INDEX IF NOT EXISTS ha_readings_zone_time_idx + ON ha_readings(zone_id, timestamp DESC); + +CREATE TABLE IF NOT EXISTS event_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp TEXT NOT NULL, + level TEXT NOT NULL, + kind TEXT NOT NULL, + message TEXT NOT NULL, + metadata TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS event_log_time_idx ON event_log(timestamp DESC); + +CREATE TABLE IF NOT EXISTS api_tokens ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + token_hash TEXT NOT NULL UNIQUE, + token_prefix TEXT NOT NULL, + created_at TEXT NOT NULL +); + +INSERT OR IGNORE INTO schema_migrations(version, applied_at) + VALUES (1, strftime('%Y-%m-%dT%H:%M:%fZ','now')); +INSERT OR IGNORE INTO schema_migrations(version, applied_at) + VALUES (2, strftime('%Y-%m-%dT%H:%M:%fZ','now')); +INSERT OR IGNORE INTO schema_migrations(version, applied_at) + VALUES (3, strftime('%Y-%m-%dT%H:%M:%fZ','now')); +INSERT OR IGNORE INTO schema_migrations(version, applied_at) + VALUES (4, strftime('%Y-%m-%dT%H:%M:%fZ','now')); +INSERT OR IGNORE INTO schema_migrations(version, applied_at) + VALUES (5, strftime('%Y-%m-%dT%H:%M:%fZ','now')); +"#; + diff --git a/src/queries/zone_history.rs b/src/queries/zone_history.rs new file mode 100644 index 0000000..e138860 --- /dev/null +++ b/src/queries/zone_history.rs @@ -0,0 +1,50 @@ +pub const INSERT_ZONE_READING_IF_DUE: &str = r#" +INSERT INTO zone_readings( + zone_id, device_id, timestamp, gree_temperature, external_temperature, + control_temperature, target_temperature, device_setpoint, outdoor_temperature, + power, mode, fan_speed, demand, control_source, active_preset +) +SELECT ?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15 +WHERE NOT EXISTS ( + SELECT 1 FROM zone_readings WHERE zone_id=?1 AND timestamp>=?16 LIMIT 1 +) +"#; + + +pub const LIST_ZONE_HISTORY_BY_ZONE_BUCKETED: &str = r#" +SELECT MIN(id),zone_id,MAX(device_id),MIN(timestamp), + AVG(gree_temperature),AVG(external_temperature),AVG(control_temperature), + AVG(target_temperature),AVG(device_setpoint),AVG(outdoor_temperature), + MAX(power),MAX(mode),CAST(ROUND(AVG(fan_speed)) AS INTEGER),MAX(demand), + MAX(control_source),MAX(active_preset) +FROM zone_readings +WHERE zone_id=?1 AND timestamp>=?2 +GROUP BY zone_id, CAST(unixepoch(timestamp)/?3 AS INTEGER) +ORDER BY MIN(timestamp) ASC +LIMIT ?4 +"#; + +pub const LIST_ZONE_HISTORY_ALL_BUCKETED: &str = r#" +SELECT MIN(id),zone_id,MAX(device_id),MIN(timestamp), + AVG(gree_temperature),AVG(external_temperature),AVG(control_temperature), + AVG(target_temperature),AVG(device_setpoint),AVG(outdoor_temperature), + MAX(power),MAX(mode),CAST(ROUND(AVG(fan_speed)) AS INTEGER),MAX(demand), + MAX(control_source),MAX(active_preset) +FROM zone_readings +WHERE timestamp>=?1 +GROUP BY zone_id, CAST(unixepoch(timestamp)/?2 AS INTEGER) +ORDER BY MIN(timestamp) ASC +LIMIT ?3 +"#; + +pub const DELETE_ZONE_READINGS_BY_ZONE_ID: &str = "DELETE FROM zone_readings WHERE zone_id=?1"; +pub const DELETE_ZONE_READINGS_BY_DEVICE_ID: &str = "DELETE FROM zone_readings WHERE device_id=?1"; +pub const LIST_ZONE_HISTORY_BEFORE: &str = r#" +SELECT id,zone_id,device_id,timestamp,gree_temperature,external_temperature,control_temperature, + target_temperature,device_setpoint,outdoor_temperature,power,mode,fan_speed,demand,control_source,active_preset +FROM zone_readings WHERE timestamp < ?1 ORDER BY timestamp ASC LIMIT ?2 +"#; +pub const DELETE_ZONE_READING_BY_ID: &str = "DELETE FROM zone_readings WHERE id=?1"; + +pub const PRUNE_ZONE_READINGS: &str = "DELETE FROM zone_readings WHERE timestamp < ?1"; +