diff --git a/BUILD_REPORT.md b/BUILD_REPORT.md index a8c7a3b..98f7c8d 100644 --- a/BUILD_REPORT.md +++ b/BUILD_REPORT.md @@ -1,3 +1,42 @@ +# GREE Controller v0.8.2 - build and validation report + +## 0.8.2 UI/i18n + Home Assistant sensor freshness + +Source baseline: v0.8.1. + +### Changes + +- Fixed the 0.8.x ownership translations: `zones.controlOwner`, owner labels and owner timing labels are now inside each language pack's `translations` object, so the Web UI receives translated values instead of raw translation keys. +- Simplified the Polish owner caption from `Właściciel sterowania` to `Sterowanie` and allowed the owner/timing line to wrap instead of truncating. +- Added `home_assistant.sensor_stale_after_seconds` with a serde-compatible 300 s default. The Home Assistant / Sensors page exposes it as minutes (1-1440 min); the backend clamps API/env values to 30-86400 s. +- The configured freshness limit is used by room HA sensors, the outdoor HA sensor and the HA connection test. Existing non-default `zone.sensor_stale_after_seconds` values remain per-zone API overrides; the historical hidden/default 300 s value inherits the global HA setting. +- Added `HA_SENSOR_STALE_AFTER_SECONDS` as an optional environment override. +- Quick Thermostat and Direct Device dashboard grids now use `align-items:start`, so a taller manual/ownership panel no longer visually stretches neighbouring cards in the same row. +- `build.rs` now rejects unexpected top-level language-pack keys, preventing translation keys from being accidentally stored outside `translations` again. +- Added a Rust regression test for global/per-zone stale-timeout resolution. +- Bumped controller and Home Assistant integration version to 0.8.2 and changed the service-worker cache key. + +### Validation executed in this environment + +- `node --check web/app.js` +- `node --check web/theme-init.js` +- `node --check web/sw.js` +- parsed all JSON files +- verified both language packs contain only `meta` + `translations` +- verified all 564 statically referenced UI translation keys exist in both English and Polish packs +- parsed `web/index.html` +- `python3 -m compileall -q home-assistant scripts make_zip.py` +- `bash -n scripts/*.sh` +- checked version consistency across Cargo.toml, Cargo.lock, README and Home Assistant manifest +- checked that no HA temperature read still uses a hardcoded `Some(300)` freshness limit +- reviewed the complete 0.8.1 -> 0.8.2 source diff for unintended file changes + +### Rust toolchain note + +This execution environment does not contain `cargo`, `rustc` or `rustfmt`, so the Rust regression test and full compilation cannot be executed here. The existing installer/updater still runs `cargo test --all-targets` before touching the running service and then performs the release build. If Rust compilation or tests fail on the target host, the running service is not replaced. + +--- + # GREE Controller v0.8.1 - build and validation report ## 0.8.1 Rust borrow-checker hotfix diff --git a/Cargo.lock b/Cargo.lock index dded5e7..56837b9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -633,7 +633,7 @@ dependencies = [ [[package]] name = "gree-controller" -version = "0.8.1" +version = "0.8.2" dependencies = [ "aes", "aes-gcm", diff --git a/Cargo.toml b/Cargo.toml index e8e6ab7..c00182a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "gree-controller" -version = "0.8.1" +version = "0.8.2" 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 9eeb0b5..e56fbbd 100644 --- a/FILE_MANIFEST.sha256 +++ b/FILE_MANIFEST.sha256 @@ -1,12 +1,12 @@ -d67af429e4da9ce08e9d2f2a8472849ffbd70d135b1c5da535a076026794d04c ./.env.example +b1347a7cbb561b78c52a6045b162bf8af46b841abdca3b1e25a01225eb34c189 ./.env.example a4ec3874a2e3ab1bad28fb40bb620f7b01f64d01ad9b699306bf70ada31227db ./.gitignore -4b11d1d60ebbdbfc91c6caef4907559d29a096e7a000f75a74b9db58ee56ff44 ./BUILD_REPORT.md -13e555cc191550f5d09e87dc0f041bbbae55c4fc70afe98e1398c44b99e1a3d0 ./Cargo.lock -c1cb0011d206c38dd84d7d3668c58998ecb3eb95afd7ae64a62d4b1ddd5bf614 ./Cargo.toml +f3b1bbc242d26f4079394b893b2ab3c106920a7d6822fd92f7788cd6a2e1ad16 ./BUILD_REPORT.md +029e59328b60468a5da40bd17aaae6a4a0082eab85b2033ad375d0c31baca967 ./Cargo.lock +a815c1e8134a4d3f9afee6ab53911a5be6b0ce746435722787e150ab219c45e0 ./Cargo.toml 19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE -14fa3424666b9805ee65b1744e9ac0cef449339adec17e451bd68182d8ea74d2 ./README.md -f2c8eb28fedb26bb8f5636e1805e7a782cc472b87d882f6752d520f16216a248 ./build.rs -3f774fbdc32de8f437d7cf9c1ceb2ae195c674d0f48b8891b25cf36d152ee2c4 ./docs/API.md +05b5ebed3bb136b648e5a1d97ca6b268cef898d4bf8ab8d92abfcf66bbf5242e ./README.md +91c826e2f8c974bdb7b15e01e9b20487f06ff887247ecd9600f1263c11a61b96 ./build.rs +4fe34356d313ab624b4bb0de09c271999262cbd363cc1ede15af89a93a82b3ea ./docs/API.md 234dd200e380a13ecd3e61b4ea455f6f08d64ce89382077dee80684acadb9703 ./docs/HOME_ASSISTANT_MIGRATION.md 7a88d6e76fda21e5d34ab351e26bc10dc1f8f7b3055505aefad1df7c56d65ae4 ./docs/LOCALIZATION.md 10a0722e1100fb4a05e3067daeb67dc47b0c0a096b43b1cbf2bf002967ce7d98 ./docs/LXC.md @@ -24,7 +24,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 -559f47833e772277016976da5e1b717468083203f6e15a83b6c66f6d52992fbc ./home-assistant/custom_components/gree_controller/manifest.json +d54d23f1f1dc633f26c493b138d061deb97a063b3d79dd91f85aaddf126bc427 ./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 @@ -33,8 +33,8 @@ c4fb75c246db651087900ebfc2291ff41ac87652cd6194fc0b776b0005c1cbcf ./home-assista 13f30e2dcdcedbd1b6c3f99c2335e0487108fd72c8e86922368b84f2fa2038ae ./home-assistant/custom_components/gree_controller/translations/pl.json 4513070521d3dda0efb0d974a86ba674494cfb2b66fe9e5cac5b1b0430dede97 ./home-assistant/generated/gree_controller_entities.example.json 253a0bc912786e67ea7fc92a64e4a510ad973bec343a88ccfb1f28fca3e8cf01 ./lang/README.md -2bb4ce4b0eff314bd20288a0d93b0e4dd38fe22a2367bf912434c9b7e658b88f ./lang/en.json -11e23a02a9c979860f086d50f71591084629ae198df08ab3065fdb3e2236d6ee ./lang/pl.json +9f5a96bc8837aa5ea303b8b0dc10fcb629e084f64dfbb8f3b427705897e5ecdb ./lang/en.json +e0099f407b5ba49a8fe21bfc0fc8f7c0a092806c42bcc28061e6306f23b0ea8d ./lang/pl.json 028e1f16e9fbaed57cadb88eff04e65b4bd67722c50b4d6b1fb525f5a2f39abf ./make_zip.py bb89bac237e750e9b1bf73761d7df97a6b81853091615878c03f13d7b6399aa7 ./scripts/README.md 5bc736c7bc76ca80aaa406bb171d2aa91baf4c3aa8695dce0e09b888b6ab3146 ./scripts/common.sh @@ -47,15 +47,15 @@ e00d211e3885e30d7fed1e43b44e6fdad40a67019060156c0641816a93e3365f ./scripts/netw 81345b6a0b51736bdbc98fd23199b62e4c721b4e7437e02dab7ea79b97dff29a ./scripts/service.sh b48fc84d79aab381226363ac8473f981bcba5e4911c4cc0011261182debf4250 ./scripts/smoke.sh b50782b3742dfbf8a319c60571c968e93fdf8547db747c759edcffae68cb98bf ./scripts/update.sh -723c2c18245079c44b24e66d738dd77a3e6b4d2ac934f2329b7cc7ae62f7a2e3 ./src/api.rs -32b1d0a7ac2060fd18fe7f1ca1d110ee45beaf67e81514d24d6bde3d36301387 ./src/config.rs +0bc3da5b1148aa54d2b234592d740bc262738d386f27b2f316465651ebedf891 ./src/api.rs +9040e8cb6647c76a875148b7591abcccfe4e2d4708ef462a1e5d25dd4ccac911 ./src/config.rs 5dfda2f4dc540c502885b0cd7017dc77768684588acf528f01d1fd88f1af4aec ./src/db.rs -3f70c6cf618c8dbf5744876a611e2fd8d9be6362c25e680dee55a23eaef93b08 ./src/engine.rs +83f1a0bd19bc00b9f13e5207e11b6e21ca7d2b385136330f273ca502b292cecd ./src/engine.rs 4b271b6fc365b1078c01d6178eb563841b2ecaed5d8639196f58e1312d2236fe ./src/error.rs -fbe5b18bfe1a8298c99850bfab54c2bf13c1824c0df40669ad52eff125aab964 ./src/home_assistant.rs +c6ff66da9ad08506f839ec56a869ead3abc824b5311e0ea9244517d11f7f4207 ./src/home_assistant.rs 190b0a33431539676e5dd7796698077f16c179d42eae4501ca96a91bf797cbf8 ./src/influxdb.rs c67212da6a2bd5c2933ece31586ca4048b484fda3105f23a6e06e881b079d129 ./src/main.rs -dcd73be37622f06de921175ab4f9be8b7d22f23994c32496b7b0ccd39db7560f ./src/models.rs +f74f5ac1940cd1c9cd8ee610c56f68b9fe17646c97736e951cd1c42408bd5b89 ./src/models.rs ea4bd7f20759101c2525600a1f342d2fc0946e2f0b3208b03cc5a415b6947c10 ./src/notifications.rs 7fc31fbf8841a073a1544b8c7a6390f1a15b56087486ca0596a8418340fa232a ./src/protocol/crypto.rs bc03d88e5476386747ff5e32f1788cdc1fcd4bef238ab66df2dada282c348c8f ./src/protocol/gree.rs @@ -63,10 +63,10 @@ a910bd9432a393740c0f6fab52bfcb551f0ea756718d66d290fd2610767cf07c ./src/protocol 6a1c0cab3eab80ecd254c5e486b1fba068523edb8d699c64054c17f67f4a31d0 ./src/queries.rs 2d69811db832c90ce06035ee29665205a04e4c514a21ecae9623991a9b14d825 ./src/state.rs b92a6cb158b494fe145b43c7641e65f6fafff47201d7d76edbec2cfd8b94835c ./systemd/gree-controller.service -591b0153e307d025281cb72c8355d84cf8caab0db4633aca9cbec08676f25d2a ./web/app.js +cc9f2b846fb66fcb540851bdbe4d751fd297f04dac3d6a076fe1597281059992 ./web/app.js e98bdd7204349cce1ec6f57283509697af0bbc72280622a6c3efa6fed242db4f ./web/favicon.svg -e97db3feed31236152da42726e50455dcac08620a0da81cc4208c325b986fde5 ./web/index.html +a4127eb010479bf50a940edbddd068ab3afb6e5386e4ddc383950112fe499b6a ./web/index.html fd26156e9f1d6713d3def564ad000553d9a16a24376059701db2ee762c99ee6c ./web/manifest.webmanifest -1bccea6bfdbd0d5fec82f777886ce4704823952012f6f5625c0d98bca74d2dc4 ./web/styles.css -f44697b9306c14224bed65b084cdcd2a453e876e1fc7e3db8d3d153a94c76ff3 ./web/sw.js +1404cd00f52605c347b86ef84d95c74c47f48f5fa82e763b0ae99c62e9800850 ./web/styles.css +06d7293a1e9b2022503e58d06a43b27dadb8cc10acfe3dbb3bec2325b33b2b04 ./web/sw.js d505d793ce7cc9485b45b78bba1c0d51887adc7451ab59a42702946e5b991382 ./web/theme-init.js diff --git a/README.md b/README.md index 0c4cc47..a55e989 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Standalone local GREE air-conditioner controller written in Rust. It runs on a regular Linux host or an LXC container and provides a mobile-first web interface without depending on the vendor cloud. -Current version: **0.8.1**. +Current version: **0.8.2**. ## Highlights @@ -47,7 +47,7 @@ See [`BUILD_REPORT.md`](BUILD_REPORT.md) for package validation details and [`do On Debian, Ubuntu or an LXC container: ```bash -unzip gree-controller-v0.8.1.zip +unzip gree-controller-v0.8.2.zip cd gree-controller chmod +x scripts/*.sh ./scripts/dev.sh @@ -479,6 +479,15 @@ Zones now use setpoint modulation, global seasonal house mode, cooling/heating C + +## 0.8.2 UI/i18n and Home Assistant sensor freshness + +- fixes ownership translations that were accidentally stored outside the language-pack `translations` object, +- adds a configurable Home Assistant sensor freshness limit in the Home Assistant / Sensors settings page (default 5 minutes), +- keeps legacy non-default per-zone stale limits as API-level overrides, +- prevents taller Quick Thermostat / Direct Device cards from stretching neighbouring dashboard cards, +- adds build-time validation for misplaced language-pack keys. + ## 0.8.0 Control ownership and thermostat safety - Adds an explicit zone control-ownership model (`control_owner`, command source, since/resume/reason) shared by the engine, API, Web UI and Home Assistant control plan. diff --git a/build.rs b/build.rs index 0a953c7..694a419 100644 --- a/build.rs +++ b/build.rs @@ -41,6 +41,14 @@ fn main() { .unwrap_or_else(|error| panic!("cannot read {}: {error}", path.display())); let document: Value = serde_json::from_str(&source) .unwrap_or_else(|error| panic!("{filename}: invalid JSON: {error}")); + let root_object = document.as_object().unwrap_or_else(|| panic!("{filename}: language pack root must be an object")); + let unexpected: Vec<&str> = root_object.keys() + .filter(|key| key.as_str() != "meta" && key.as_str() != "translations") + .map(|key| key.as_str()) + .collect(); + if !unexpected.is_empty() { + panic!("{filename}: unexpected top-level keys {:?}; translation keys must be inside 'translations'", unexpected); + } let meta = document.get("meta").unwrap_or_else(|| panic!("{filename}: missing meta object")); let code = required_string(meta, "code", filename); let name = required_string(meta, "name", filename); diff --git a/docs/API.md b/docs/API.md index af70bdd..0b4fcb8 100644 --- a/docs/API.md +++ b/docs/API.md @@ -198,6 +198,8 @@ For non-`custom` schedules, `setpoint` is only a compatibility value; the actual Runtime settings can specify `home_assistant.outdoor_entity_id` and `outdoor_assist_enabled`. Outdoor temperature never replaces the zone room temperature. It is only used to make active setpoints/fan speed slightly more assertive in extreme weather. +`home_assistant.sensor_stale_after_seconds` controls the maximum accepted age of Home Assistant temperature samples. The default is 300 seconds (5 minutes) and the backend clamps the value to 30-86400 seconds. The Web UI exposes this value in minutes under **Home Assistant / Sensors**. Existing zones keep compatibility with the historical `zone.sensor_stale_after_seconds` field: the old hidden/default value of 300 seconds inherits the global HA setting, while a non-default value supplied through the zone API remains a per-zone override. + For trusted local Home Assistant servers with self-signed/invalid HTTPS certificates, set: ```json diff --git a/home-assistant/custom_components/gree_controller/manifest.json b/home-assistant/custom_components/gree_controller/manifest.json index ece2ab4..110a21b 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.1", + "version": "0.8.2", "config_flow": true, "integration_type": "hub", "iot_class": "local_polling", diff --git a/lang/en.json b/lang/en.json index d81b2ce..2b85b2e 100644 --- a/lang/en.json +++ b/lang/en.json @@ -611,16 +611,18 @@ "zones.noMeasurement": "No measurement", "zones.localThermostatOffDescriptionTimed": "This zone is locally off. Automation takes control again in {time}.", "zones.localThermostatResuming": "Timer elapsed — automation is taking control…", - "zones.resumeNow": "Resume now" - }, - "zones.controlOwner": "Control owner", - "zones.ownerAutomation": "Automation", - "zones.ownerLocalThermostat": "Local thermostat", - "zones.ownerExternal": "Remote / external control", - "zones.ownerHaDirect": "HA — direct control", - "zones.ownerWebDirect": "Web — direct control", - "zones.ownerGlobalOff": "Globally off", - "zones.ownerSince": "since", - "zones.ownerResume": "until", - "zones.lockoutUntil": "lockout until" + "zones.resumeNow": "Resume now", + "zones.controlOwner": "Control", + "zones.ownerAutomation": "Automation", + "zones.ownerLocalThermostat": "Local thermostat", + "zones.ownerExternal": "Remote / external control", + "zones.ownerHaDirect": "HA — direct control", + "zones.ownerWebDirect": "Web — direct control", + "zones.ownerGlobalOff": "Globally off", + "zones.ownerSince": "since", + "zones.ownerResume": "until", + "zones.lockoutUntil": "lockout until", + "settings.haSensorStaleAfterMinutes": "Maximum HA sensor reading age (min)", + "settings.haSensorStaleAfterHint": "If the HA sensor is not updated within this time, the reading is treated as stale. The thermostat falls back to the GREE sensor when available." + } } diff --git a/lang/pl.json b/lang/pl.json index 62e9bd3..80945e4 100644 --- a/lang/pl.json +++ b/lang/pl.json @@ -611,16 +611,18 @@ "zones.noMeasurement": "Brak pomiaru", "zones.localThermostatOffDescriptionTimed": "Strefa jest lokalnie wyłączona. Automatyka przejmie sterowanie za {time}.", "zones.localThermostatResuming": "Czas minął — automatyka przejmuje sterowanie…", - "zones.resumeNow": "Wznów teraz" - }, - "zones.controlOwner": "Właściciel sterowania", - "zones.ownerAutomation": "Automatyka", - "zones.ownerLocalThermostat": "Termostat lokalny", - "zones.ownerExternal": "Pilot / sterowanie zewnętrzne", - "zones.ownerHaDirect": "HA — sterowanie bezpośrednie", - "zones.ownerWebDirect": "Web — sterowanie bezpośrednie", - "zones.ownerGlobalOff": "Globalnie wyłączone", - "zones.ownerSince": "od", - "zones.ownerResume": "do", - "zones.lockoutUntil": "blokada do" + "zones.resumeNow": "Wznów teraz", + "zones.controlOwner": "Sterowanie", + "zones.ownerAutomation": "Automatyka", + "zones.ownerLocalThermostat": "Termostat lokalny", + "zones.ownerExternal": "Pilot / sterowanie zewnętrzne", + "zones.ownerHaDirect": "HA — sterowanie bezpośrednie", + "zones.ownerWebDirect": "Web — sterowanie bezpośrednie", + "zones.ownerGlobalOff": "Globalnie wyłączone", + "zones.ownerSince": "od", + "zones.ownerResume": "do", + "zones.lockoutUntil": "blokada do", + "settings.haSensorStaleAfterMinutes": "Maksymalny wiek odczytu sensora HA (min)", + "settings.haSensorStaleAfterHint": "Jeśli sensor HA nie zaktualizuje się przez ten czas, odczyt zostanie uznany za nieaktualny. Termostat użyje czujnika GREE jako awaryjnego źródła, jeśli jest dostępny." + } } diff --git a/src/api.rs b/src/api.rs index 533a2d7..d3e8bf6 100644 --- a/src/api.rs +++ b/src/api.rs @@ -1897,6 +1897,7 @@ async fn update_settings(State(state): State, Json(mut input): Json } 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(300)) + 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}))) } @@ -2270,6 +2271,7 @@ fn public_settings(settings: &RuntimeSettings) -> Value { "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/config.rs b/src/config.rs index 9e1da37..9c61ce4 100644 --- a/src/config.rs +++ b/src/config.rs @@ -79,6 +79,7 @@ impl Config { token: env::var("HA_TOKEN").unwrap_or_default(), default_entity_id: env::var("HA_ENTITY_ID").unwrap_or_default(), outdoor_entity_id: env::var("HA_OUTDOOR_ENTITY_ID").unwrap_or_default(), + sensor_stale_after_seconds: env_u64("HA_SENSOR_STALE_AFTER_SECONDS").unwrap_or(300).clamp(30, 86_400), allow_invalid_tls: env::var("HA_ALLOW_INVALID_TLS") .map(|v| matches!(v.to_ascii_lowercase().as_str(), "1" | "true" | "yes" | "on")) .unwrap_or(false), @@ -105,6 +106,7 @@ impl Config { if let Some(value) = env_u8("GREE_CONTROLLER_NIGHT_MODE_MAX_FAN_SPEED") { settings.night_mode.max_fan_speed = value.clamp(1, 5); } if let Some(value) = env_bool("GREE_CONTROLLER_NIGHT_MODE_FORCE_QUIET") { settings.night_mode.force_quiet = value; } if let Some(value) = env_bool("GREE_CONTROLLER_NIGHT_MODE_NATIVE_SLEEP") { settings.night_mode.use_native_sleep = value; } + if let Some(value) = env_u64("HA_SENSOR_STALE_AFTER_SECONDS") { settings.home_assistant.sensor_stale_after_seconds = value.clamp(30, 86_400); } let influx_env_present = [ "GREE_CONTROLLER_INFLUX_ENABLED", "GREE_CONTROLLER_INFLUX_VERSION", "GREE_CONTROLLER_INFLUX_URL", @@ -147,6 +149,7 @@ fn env_bool(name: &str) -> Option { } fn env_u32(name: &str) -> Option { env::var(name).ok()?.parse().ok() } +fn env_u64(name: &str) -> Option { env::var(name).ok()?.parse().ok() } fn env_u8(name: &str) -> Option { env::var(name).ok()?.parse().ok() } fn first_env(names: &[&str]) -> Option { diff --git a/src/engine.rs b/src/engine.rs index aac4732..52a9e62 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -1158,7 +1158,7 @@ async fn control_zones(state: &AppState) -> Result<()> { 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(300)).await { + 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, @@ -1204,8 +1204,9 @@ async fn control_zones(state: &AppState) -> Result<()> { 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(zone.sensor_stale_after_seconds)).await + 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) }) @@ -1773,6 +1774,13 @@ 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) { @@ -2407,6 +2415,14 @@ 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 diff --git a/src/home_assistant.rs b/src/home_assistant.rs index c67d096..db3d2f2 100644 --- a/src/home_assistant.rs +++ b/src/home_assistant.rs @@ -93,6 +93,7 @@ mod tests { token: "token".into(), default_entity_id: "sensor.salon_temperature".into(), outdoor_entity_id: "sensor.zewnatrz_temperature".into(), + sensor_stale_after_seconds: 300, allow_invalid_tls: false, sensor_aliases, } diff --git a/src/models.rs b/src/models.rs index 96c752c..5665f1d 100644 --- a/src/models.rs +++ b/src/models.rs @@ -582,6 +582,9 @@ pub struct HomeAssistantSettings { /// 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, diff --git a/web/app.js b/web/app.js index 2041947..030aaf4 100644 --- a/web/app.js +++ b/web/app.js @@ -1065,6 +1065,7 @@ function renderHomeAssistantSettings() { form.ha_token.placeholder = app.settings.home_assistant?.token_configured ? tr('settings.haTokenSaved') : tr('settings.haLongLivedToken'); form.ha_entity_id.value = app.settings.home_assistant?.default_entity_id || ''; form.ha_outdoor_entity_id.value = app.settings.home_assistant?.outdoor_entity_id || ''; + form.ha_sensor_stale_after_minutes.value = String(Math.max(1, Math.round(Number(app.settings.home_assistant?.sensor_stale_after_seconds || 300) / 60))); form.ha_allow_invalid_tls.checked = !!app.settings.home_assistant?.allow_invalid_tls; form.outdoor_assist_enabled.checked = !!app.settings.outdoor_assist_enabled; renderSensorAliases(); @@ -2067,6 +2068,7 @@ function currentSettingsBody() { token: '', default_entity_id: ha.default_entity_id || '', outdoor_entity_id: ha.outdoor_entity_id || '', + sensor_stale_after_seconds: Number(ha.sensor_stale_after_seconds || 300), allow_invalid_tls: !!ha.allow_invalid_tls, sensor_aliases: {...(ha.sensor_aliases || {})}, }, @@ -2127,6 +2129,7 @@ function homeAssistantSettingsBodyFromForm(form) { token: raw.ha_token, default_entity_id: raw.ha_entity_id, outdoor_entity_id: raw.ha_outdoor_entity_id, + sensor_stale_after_seconds: Math.max(60, Math.min(86400, Math.round(Number(raw.ha_sensor_stale_after_minutes || 5) * 60))), allow_invalid_tls: form.ha_allow_invalid_tls.checked, sensor_aliases: {...(app.sensorAliases || {})}, }; diff --git a/web/index.html b/web/index.html index d4c6cc1..0310a88 100644 --- a/web/index.html +++ b/web/index.html @@ -187,6 +187,8 @@ + +

If the HA sensor is not updated within this time, the reading is treated as stale. The thermostat falls back to the GREE sensor when available.

Use only for a trusted local Home Assistant server, for example https://192.168.50.25.

diff --git a/web/styles.css b/web/styles.css index 0b44c77..77f178d 100644 --- a/web/styles.css +++ b/web/styles.css @@ -831,6 +831,7 @@ body.simulation-standalone [data-view="simulation"] { min-height:calc(100vh - 36 /* Quick thermostat: target control gets its own row, readings no longer collide with it. */ #dashboardZones { grid-template-columns:repeat(auto-fit,minmax(min(100%,280px),1fr)); } +#dashboardZones, #dashboardDevices { align-items:start; } .quick-thermostat-control { min-width:0; } .quick-thermostat-control .thermostat-main { grid-template-columns:minmax(0,1fr) minmax(0,1fr); @@ -851,6 +852,8 @@ body.simulation-standalone [data-view="simulation"] { min-height:calc(100vh - 36 .quick-thermostat-control .zone-enable-toggle { flex:0 0 auto; } .quick-thermostat-control .zone-state-line { gap:8px; font-size:10px; } .quick-thermostat-control .zone-state-line span { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } +.quick-thermostat-control .control-owner-line { align-items:flex-start; flex-wrap:wrap; } +.quick-thermostat-control .control-owner-line span { overflow:visible; text-overflow:clip; white-space:normal; } /* Smaller Node-RED/simulator-like plan blocks with explicit group context. */ #controlPlan.automation-plan-grid { grid-template-columns:repeat(auto-fit,minmax(min(100%,210px),1fr)); gap:8px; } diff --git a/web/sw.js b/web/sw.js index ca35de5..9807e85 100644 --- a/web/sw.js +++ b/web/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'gree-controller-v080-control-ownership'; +const CACHE = 'gree-controller-v082-ui-i18n-ha-freshness'; const SCOPE = new URL(self.registration.scope).pathname.replace(/\/$/, ''); const path = value => `${SCOPE}${value.startsWith('/') ? value : `/${value}`}` || '/'; const ASSETS = [path('/'), path('/styles.css'), path('/app.js'), path('/theme-init.js'), path('/favicon.svg'), path('/manifest.webmanifest'), path('/lang/index.json'), path('/lang/en.json')];