This commit is contained in:
Mateusz Gruszczyński
2026-08-27 23:18:03 +02:00
parent 7465de4b55
commit 016aac0f8d
19 changed files with 147 additions and 52 deletions
+39
View File
@@ -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
Generated
+1 -1
View File
@@ -633,7 +633,7 @@ dependencies = [
[[package]]
name = "gree-controller"
version = "0.8.1"
version = "0.8.2"
dependencies = [
"aes",
"aes-gcm",
+1 -1
View File
@@ -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"
+19 -19
View File
@@ -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
+11 -2
View File
@@ -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.
+8
View File
@@ -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);
+2
View File
@@ -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
@@ -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",
+14 -12
View File
@@ -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."
}
}
+14 -12
View File
@@ -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."
}
}
+3 -1
View File
@@ -1897,6 +1897,7 @@ async fn update_settings(State(state): State<AppState>, Json(mut input): Json<Ru
}
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; }
@@ -2189,7 +2190,7 @@ struct HaTestRequest { entity_id: Option<String> }
async fn test_home_assistant(State(state): State<AppState>, Json(input): Json<HaTestRequest>) -> Result<Json<Value>, 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,
}
+3
View File
@@ -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<bool> {
}
fn env_u32(name: &str) -> Option<u32> { env::var(name).ok()?.parse().ok() }
fn env_u64(name: &str) -> Option<u64> { env::var(name).ok()?.parse().ok() }
fn env_u8(name: &str) -> Option<u8> { env::var(name).ok()?.parse().ok() }
fn first_env(names: &[&str]) -> Option<String> {
+18 -2
View File
@@ -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
+1
View File
@@ -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,
}
+3
View File
@@ -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,
+3
View File
@@ -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 || {})},
};
+2
View File
@@ -187,6 +187,8 @@
<label class="wide"><span data-i18n="settings.haLongLivedToken">Long-Lived Access Token</span><input type="password" name="ha_token" autocomplete="new-password" data-i18n-placeholder="settings.haTokenKeep" placeholder="Leave empty to keep the saved token"></label>
<label class="wide"><span data-i18n="settings.defaultEntity">Default room entity_id</span><input name="ha_entity_id" placeholder="sensor.living_room_temperature"></label>
<label class="wide"><span data-i18n="settings.outdoorEntity">Outdoor temperature entity_id</span><input name="ha_outdoor_entity_id" placeholder="sensor.outdoor_temperature"></label>
<label class="wide"><span data-i18n="settings.haSensorStaleAfterMinutes">Maximum HA sensor reading age (min)</span><input type="number" name="ha_sensor_stale_after_minutes" min="1" max="1440" step="1" value="5"></label>
<p class="field-note wide" data-i18n="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.</p>
<label class="check wide"><input type="checkbox" name="outdoor_assist_enabled"> <span data-i18n="settings.outdoorAssist">Use outdoor temperature as smart-control assist</span></label>
<label class="check wide"><input type="checkbox" name="ha_allow_invalid_tls"> <span data-i18n="settings.allowInvalidTls">Allow invalid/self-signed HTTPS certificate</span></label>
<p class="field-note wide warning-note" data-i18n="settings.allowInvalidTlsHint">Use only for a trusted local Home Assistant server, for example https://192.168.50.25.</p>
+3
View File
@@ -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; }
+1 -1
View File
@@ -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')];