v0.7.13
This commit is contained in:
+28
-3
@@ -1,10 +1,17 @@
|
||||
# GREE Controller v0.7.12 - build and validation report
|
||||
# GREE Controller v0.7.13 - build and validation report
|
||||
|
||||
## Scope
|
||||
|
||||
Version 0.7.12 adds a timed hand-back for per-zone Quick Thermostat OFF while preserving the strict separation between local thermostat ownership and true physical/manual-device takeover. After 15 minutes the zone returns to the current group/schedule logic instead of restoring stale physical state.
|
||||
Version 0.7.13 hardens Quick Thermostat ownership against polling/firmware-settling races, disables Global when no global Heating/Cooling mode is selected, and adds live counters for UDP frames received from configured GREE units. The 0.7.12 timed hand-back behavior remains unchanged.
|
||||
|
||||
### Backend hand-back correction
|
||||
### 0.7.13 race protection and diagnostics
|
||||
|
||||
- Per-zone Quick Thermostat changes are serialized with polling using the same per-device operation lock, preventing a stale poll from restoring a false physical/pilot takeover after the user explicitly selects local thermostat control.
|
||||
- Recent controller-requested states and their pre-command baselines are kept for a bounded settling window, so delayed/out-of-order GREE status frames are not mistaken for remote-control changes.
|
||||
- The Quick Thermostats **Global** button is disabled while the global house mode is neither Heating nor Cooling.
|
||||
- Settings shows live total and per-device UDP receive counters, updated over the existing WebSocket event stream.
|
||||
|
||||
### Existing 0.7.12 hand-back correction
|
||||
|
||||
- Local thermostat ON/OFF now goes through one backend helper, so every new OFF action creates a fresh authoritative 15-minute deadline and ON always cancels it.
|
||||
- A physical/direct-device takeover temporarily suspends expiry of an existing local-OFF hand-back. Returning the unit to the previous OFF state re-arms the countdown from that return moment instead of continuing an old timer.
|
||||
@@ -464,3 +471,21 @@ Validation for this package is recorded after the final archive/manifest checks.
|
||||
- Version metadata is aligned at `0.7.12` and the PWA cache key is rotated.
|
||||
- This packaging environment still has no `cargo`, `rustc` or `rustfmt`; target/CI must run `cargo test --all-targets` and `cargo build --release` before deployment.
|
||||
|
||||
|
||||
## 2026-08-27 v0.7.13 quick-thermostat race protection and frame counters
|
||||
|
||||
- Quick Thermostat changes from Web/Home Assistant are serialized with the same per-device operation lock used by polling/manual-takeover detection.
|
||||
- Controller-originated climate changes retain a bounded settling history of requested and pre-command states so delayed/out-of-order GREE status frames do not falsely trigger physical/pilot takeover.
|
||||
- Dashboard Quick Thermostat **Global** is disabled whenever the global house mode is neither Heating nor Cooling.
|
||||
- Settings exposes live total and per-device UDP receive counters for configured GREE units via the existing WebSocket stream.
|
||||
- The Home Assistant zone `climate` entity remains the supported path for controlling one thermostat independently.
|
||||
|
||||
## Final validation for v0.7.13
|
||||
|
||||
- `node --check` passed for `web/app.js` and `web/sw.js`.
|
||||
- All JSON files parsed successfully; Polish and English language packs have identical 610-key sets.
|
||||
- Home Assistant Python modules and helper Python scripts pass `py_compile`; shell scripts pass `bash -n`.
|
||||
- `web/index.html` parses successfully and CSS brace balance passes.
|
||||
- Cargo package, Home Assistant manifest, README and PWA cache metadata are aligned at `0.7.13`.
|
||||
- The final SHA-256 file manifest and ZIP integrity are verified during packaging.
|
||||
- This packaging environment does not provide `cargo`, `rustc` or `rustfmt`; target/CI must run `cargo test --all-targets` and `cargo build --release` before deployment.
|
||||
|
||||
Generated
+1
-1
@@ -633,7 +633,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gree-controller"
|
||||
version = "0.7.12"
|
||||
version = "0.7.13"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "gree-controller"
|
||||
version = "0.7.12"
|
||||
version = "0.7.13"
|
||||
edition = "2021"
|
||||
authors = ["GREE Controller contributors"]
|
||||
description = "Standalone local GREE HVAC controller with Web UI, SQLite and Home Assistant sensor support"
|
||||
|
||||
+16
-15
@@ -1,10 +1,10 @@
|
||||
d67af429e4da9ce08e9d2f2a8472849ffbd70d135b1c5da535a076026794d04c ./.env.example
|
||||
a4ec3874a2e3ab1bad28fb40bb620f7b01f64d01ad9b699306bf70ada31227db ./.gitignore
|
||||
3e44651c91c8a91c8b5de05b65d264d6a3065c60baaf95238acc1d33ffd1e2a1 ./BUILD_REPORT.md
|
||||
7e3367a68ba90e5e5139fde8842cf5d81ac87a154aeda44a8331be946207da7d ./Cargo.lock
|
||||
e97cb5f6ccf91a32b2c97560140f1ea0314b27d61039fbd03564ceacfab7fe31 ./Cargo.toml
|
||||
f06554764445d6b205835f5dba8a03e31cb7eeb23712bfda6c9b7bb75a73bbd0 ./BUILD_REPORT.md
|
||||
e44ec284006409396980d5d462bbb0b2620735d2112155302fec1cf45a406813 ./Cargo.lock
|
||||
8ccdf12dfb6019cfe20a6bba3a6d06f01fe41ecf344c6943214ebdbd60f41180 ./Cargo.toml
|
||||
19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE
|
||||
a2d518d230dedd59352c70dd609f44474eb159ca1fb5df32ea2cec37b54ae91c ./README.md
|
||||
9de6fcac8991224d629cca6eb6cf302df35b0166ac1d54ab18e44aca83c5eaec ./README.md
|
||||
f2c8eb28fedb26bb8f5636e1805e7a782cc472b87d882f6752d520f16216a248 ./build.rs
|
||||
3f774fbdc32de8f437d7cf9c1ceb2ae195c674d0f48b8891b25cf36d152ee2c4 ./docs/API.md
|
||||
234dd200e380a13ecd3e61b4ea455f6f08d64ce89382077dee80684acadb9703 ./docs/HOME_ASSISTANT_MIGRATION.md
|
||||
@@ -13,6 +13,7 @@ f2c8eb28fedb26bb8f5636e1805e7a782cc472b87d882f6752d520f16216a248 ./build.rs
|
||||
2ef5cb04cc74be3aab77a9d117b9040f186c73a7dcf24375f1cca2d73e428449 ./docs/PROJECT_SPEC.md
|
||||
7748b1230029439a405139750b2e0607b8e463bfd56ad86982394105069697c5 ./docs/REVERSE_PROXY.md
|
||||
28f1403d84b01b37bebb72548aa39a75bb14bdbb611a43bfa761319957cd6f39 ./docs/SECURITY.md
|
||||
c3f3f12206c552d3fb919022d64b3fa60342fc1031081e893b7efd743961c402 ./future.md
|
||||
95e3c8c99ba439a86a2fb05cfd8ef00c75cb959a024a349a082da935b726f500 ./home-assistant/README.md
|
||||
f8e8559fe10fe523ac5bc9aac25c6e26e862f679d502e8f3c39f38a0a8e40911 ./home-assistant/custom_components/gree_controller/__init__.py
|
||||
6910589f27960a28d4de9735884a7e5376e455cd885f19ce2b55947fcd135114 ./home-assistant/custom_components/gree_controller/api.py
|
||||
@@ -23,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
|
||||
ef77626f30454babda4c8fc3c80196a40d500b07db99d3f0640c3460fbe7b8e9 ./home-assistant/custom_components/gree_controller/manifest.json
|
||||
fceb28e4bf59829536bbe1a106edc55c685f1a53b6ec5aff12c6c01636159d67 ./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
|
||||
@@ -32,8 +33,8 @@ ef77626f30454babda4c8fc3c80196a40d500b07db99d3f0640c3460fbe7b8e9 ./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
|
||||
f8401fc91cce6b11a62c1624c3c67f075f145870c4caa13a295701cb2253fa2a ./lang/en.json
|
||||
cedd112847c3053dd3e130f77d1ed033d7bce70dff159a37d0ec9b5165e9dd20 ./lang/pl.json
|
||||
cb206af3652be23991686e18d84e3855f5782686a4b61ee4526ebcc9113f5a10 ./lang/en.json
|
||||
d8fbf934dce8d5e295fcf992dd85cab787fd44cae7d36c70e0d943d56403d222 ./lang/pl.json
|
||||
028e1f16e9fbaed57cadb88eff04e65b4bd67722c50b4d6b1fb525f5a2f39abf ./make_zip.py
|
||||
bb89bac237e750e9b1bf73761d7df97a6b81853091615878c03f13d7b6399aa7 ./scripts/README.md
|
||||
5bc736c7bc76ca80aaa406bb171d2aa91baf4c3aa8695dce0e09b888b6ab3146 ./scripts/common.sh
|
||||
@@ -46,10 +47,10 @@ e00d211e3885e30d7fed1e43b44e6fdad40a67019060156c0641816a93e3365f ./scripts/netw
|
||||
81345b6a0b51736bdbc98fd23199b62e4c721b4e7437e02dab7ea79b97dff29a ./scripts/service.sh
|
||||
b48fc84d79aab381226363ac8473f981bcba5e4911c4cc0011261182debf4250 ./scripts/smoke.sh
|
||||
b50782b3742dfbf8a319c60571c968e93fdf8547db747c759edcffae68cb98bf ./scripts/update.sh
|
||||
25b97d7cdfe8c64bc20fe22d6dec1038c59740c4243d345986569d57e2418d55 ./src/api.rs
|
||||
496b8ff31ec187392ab19efc5834f4d2ad9d1bb27fed65b81a62e8cd7537c265 ./src/api.rs
|
||||
32b1d0a7ac2060fd18fe7f1ca1d110ee45beaf67e81514d24d6bde3d36301387 ./src/config.rs
|
||||
5dfda2f4dc540c502885b0cd7017dc77768684588acf528f01d1fd88f1af4aec ./src/db.rs
|
||||
af5d1d79920a2923171c12b94a2f524c3eeff69873279d19a23d72c0d4ced506 ./src/engine.rs
|
||||
9328e7b1dfdedaaa6cdb12eb6d26d5b6d2d41afa7fac25745e4ed819ff997ce3 ./src/engine.rs
|
||||
ae3b496749a3fd723b243d9bea92e5d76249f52814c80359ad9bac53abacb074 ./src/error.rs
|
||||
081f6909e95839c7da8c40b866af9de237e0540f3e65e1ce739ce70a6fa94846 ./src/home_assistant.rs
|
||||
190b0a33431539676e5dd7796698077f16c179d42eae4501ca96a91bf797cbf8 ./src/influxdb.rs
|
||||
@@ -57,15 +58,15 @@ ae3b496749a3fd723b243d9bea92e5d76249f52814c80359ad9bac53abacb074 ./src/error.rs
|
||||
7de365e80b73a16b9429911fa683e0425830be7041fc889b594332de81d72cd5 ./src/models.rs
|
||||
ea4bd7f20759101c2525600a1f342d2fc0946e2f0b3208b03cc5a415b6947c10 ./src/notifications.rs
|
||||
7fc31fbf8841a073a1544b8c7a6390f1a15b56087486ca0596a8418340fa232a ./src/protocol/crypto.rs
|
||||
a60bd8d23b78c74447546722a03ff11c75367468d3bab9f2f5f9cb56f8e09572 ./src/protocol/gree.rs
|
||||
bc03d88e5476386747ff5e32f1788cdc1fcd4bef238ab66df2dada282c348c8f ./src/protocol/gree.rs
|
||||
a910bd9432a393740c0f6fab52bfcb551f0ea756718d66d290fd2610767cf07c ./src/protocol/mod.rs
|
||||
6a1c0cab3eab80ecd254c5e486b1fba068523edb8d699c64054c17f67f4a31d0 ./src/queries.rs
|
||||
6081635986e1ce39a0e195c3976ae30a033454fe90ec80bcbdf05fc26bf7087f ./src/state.rs
|
||||
d035b2348e1e02718e2c9cdca0ef672860bc19d5c705dab7c225040bb5c5c788 ./src/state.rs
|
||||
b92a6cb158b494fe145b43c7641e65f6fafff47201d7d76edbec2cfd8b94835c ./systemd/gree-controller.service
|
||||
0ffb96f6d494b24632c0825b76c21a88d2d9444e35208c854a01495c36369ec6 ./web/app.js
|
||||
1d7674e86a019bbd86557fcc9e6b67a100a1038907b84c7bc1cf6e504f7e1261 ./web/app.js
|
||||
e98bdd7204349cce1ec6f57283509697af0bbc72280622a6c3efa6fed242db4f ./web/favicon.svg
|
||||
5e39be3a379e3036c03773d5f4cc22baed79e9688b574b438fe87b6f5828089a ./web/index.html
|
||||
d22d27b9a9355ab5f67b49c38dd6d86e6fcf5c16292c767b7e0dc70573cc2898 ./web/index.html
|
||||
fd26156e9f1d6713d3def564ad000553d9a16a24376059701db2ee762c99ee6c ./web/manifest.webmanifest
|
||||
59df58dc528fbbbb5b6097df7110e78d35951506cc056816c7b2be51f4d51965 ./web/styles.css
|
||||
6206538cd2f546463387af542515137c3ae3e100ff0d4843a6af73a05705283b ./web/sw.js
|
||||
1bccea6bfdbd0d5fec82f777886ce4704823952012f6f5625c0d98bca74d2dc4 ./web/styles.css
|
||||
a10004855fc92eb7572752a06bd2bfc89c1be1421fb9996af88527a94ceef2ad ./web/sw.js
|
||||
d505d793ce7cc9485b45b78bba1c0d51887adc7451ab59a42702946e5b991382 ./web/theme-init.js
|
||||
|
||||
@@ -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.7.12**.
|
||||
Current version: **0.7.13**.
|
||||
|
||||
## 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.7.12.zip
|
||||
unzip gree-controller-v0.7.13.zip
|
||||
cd gree-controller
|
||||
chmod +x scripts/*.sh
|
||||
./scripts/dev.sh
|
||||
@@ -478,6 +478,13 @@ Zones now use setpoint modulation, global seasonal house mode, cooling/heating C
|
||||
|
||||
|
||||
|
||||
## 0.7.13 Quick-thermostat race protection and GREE frame counters
|
||||
|
||||
- Quick thermostat actions from Web/Home Assistant are serialized with device polling, so a poll running at the same time cannot restore an older `device_manual_override` snapshot after the thermostat deliberately takes control back.
|
||||
- Recent controller-originated climate transitions keep a bounded settling history of both requested and pre-command values. Delayed or out-of-order GREE status frames can pass through those known states without being misclassified as physical/pilot takeover.
|
||||
- In Dashboard -> Quick thermostats, **Global** is disabled when the whole-house thermostat mode is **Do not control**. Heat/Cool remain available as explicit per-zone overrides.
|
||||
- Settings now shows live UDP receive counters, both total and per configured air conditioner, since controller startup. Counters are independent of the debug-frame overlay and update over WebSocket.
|
||||
|
||||
## 0.7.12 Timed quick-thermostat hand-back
|
||||
|
||||
- Switching a single Quick Thermostat **OFF** now starts a 15-minute local-OFF window instead of leaving the zone outside automation indefinitely.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"domain": "gree_controller",
|
||||
"name": "GREE Controller",
|
||||
"version": "0.7.12",
|
||||
"version": "0.7.13",
|
||||
"config_flow": true,
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
|
||||
@@ -252,6 +252,7 @@
|
||||
"zones.modePolicyHint": "If this zone should use the global Heating/Cooling mode selected in GREE Controller, leave “Follow global mode” unchanged. “Cooling only” or “Heating only” forces a fixed mode only for this zone.",
|
||||
"zones.followHouse": "Follow global mode",
|
||||
"zones.followHouseShort": "Global",
|
||||
"zones.globalModeUnavailable": "Select global Heating or Cooling first",
|
||||
"zones.profileTemperatures": "Comfort / sleep / away temperatures",
|
||||
"zones.minAdjust": "Minimum adjustment interval (s)",
|
||||
"zones.standbyOffset": "Standby setpoint offset °C",
|
||||
@@ -376,6 +377,10 @@
|
||||
"settings.compaction": "Compact old metrics",
|
||||
"settings.compactionHint": "SQLite keeps recent data locally and compacts older samples to the resolution used by charts.",
|
||||
"settings.greeCommands": "GREE commands",
|
||||
"settings.greeTraffic": "GREE traffic",
|
||||
"settings.greeTrafficHint": "Live counters of UDP frames received from configured air conditioners since controller startup.",
|
||||
"settings.receivedFramesTotal": "Received frames total",
|
||||
"settings.receivedFramesDevices": "Air conditioners",
|
||||
"settings.suppressBeep": "Try to suppress command beeps",
|
||||
"settings.suppressBeepHint": "Only changed properties are sent. Buzzer suppression is also requested when supported by the unit firmware.",
|
||||
"settings.influx": "Long-term InfluxDB history",
|
||||
|
||||
@@ -252,6 +252,7 @@
|
||||
"zones.modePolicyHint": "Jeśli strefa ma korzystać z globalnego trybu Grzanie/Chłodzenie ustawionego w GREE Controller, zostaw „Dziedzicz tryb globalny” i nie zmieniaj tej opcji. „Tylko chłodzenie” lub „Tylko grzanie” wymusza stały tryb tylko dla tej strefy.",
|
||||
"zones.followHouse": "Dziedzicz tryb globalny",
|
||||
"zones.followHouseShort": "Globalny",
|
||||
"zones.globalModeUnavailable": "Najpierw wybierz globalnie Grzanie albo Chłodzenie",
|
||||
"zones.profileTemperatures": "Temperatury komfort / sen / poza domem",
|
||||
"zones.minAdjust": "Minimalny odstęp regulacji (s)",
|
||||
"zones.standbyOffset": "Przesunięcie temperatury standby °C",
|
||||
@@ -376,6 +377,10 @@
|
||||
"settings.compaction": "Kompaktuj starsze metryki",
|
||||
"settings.compactionHint": "SQLite przechowuje świeże dane lokalnie, a starsze próbki redukuje do rozdzielczości używanej przez wykresy.",
|
||||
"settings.greeCommands": "Polecenia GREE",
|
||||
"settings.greeTraffic": "Ruch GREE",
|
||||
"settings.greeTrafficHint": "Liczniki ramek UDP odebranych od skonfigurowanych klimatyzatorów od uruchomienia kontrolera, aktualizowane na żywo.",
|
||||
"settings.receivedFramesTotal": "Odebrane ramki łącznie",
|
||||
"settings.receivedFramesDevices": "Klimatyzatory",
|
||||
"settings.suppressBeep": "Próbuj wyciszać dźwięk poleceń",
|
||||
"settings.suppressBeepHint": "Wysyłane są tylko zmienione właściwości. Jeśli firmware jednostki to obsługuje, wysyłane jest też polecenie wyciszenia buzzera.",
|
||||
"settings.influx": "Długoterminowa historia InfluxDB",
|
||||
|
||||
+16
@@ -208,6 +208,7 @@ async fn bootstrap(State(state): State<AppState>) -> Result<Json<Value>, AppErro
|
||||
|
||||
async fn build_bootstrap(state: &AppState) -> Result<Value, AppError> {
|
||||
let settings = state.settings.read().await.clone();
|
||||
let (received_frames_total, received_frames_by_device) = state.gree.received_frame_stats();
|
||||
Ok(json!({
|
||||
"devices": state.db.list_devices()?,
|
||||
"zones": state.db.list_zones()?,
|
||||
@@ -222,12 +223,15 @@ async fn build_bootstrap(state: &AppState) -> Result<Value, AppError> {
|
||||
"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),
|
||||
"gree_received_frames": received_frames_total,
|
||||
"gree_received_frames_by_device": received_frames_by_device,
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
async fn system_info(State(state): State<AppState>) -> Result<Json<Value>, 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(),
|
||||
@@ -238,6 +242,8 @@ async fn system_info(State(state): State<AppState>) -> Result<Json<Value>, AppEr
|
||||
"control_ready": state.initial_device_sync_complete.load(Ordering::Acquire),
|
||||
"bind": state.config.bind.to_string(),
|
||||
"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,
|
||||
})))
|
||||
}
|
||||
|
||||
@@ -607,6 +613,14 @@ async fn update_zone(State(state): State<AppState>, Path(id): Path<String>, Json
|
||||
}
|
||||
|
||||
async fn apply_zone_control_patch(state: &AppState, id: &str, patch: ZoneControlPatch) -> Result<Zone, AppError> {
|
||||
// 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 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()?;
|
||||
@@ -687,6 +701,8 @@ async fn apply_zone_control_patch(state: &AppState, id: &str, patch: ZoneControl
|
||||
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) {
|
||||
|
||||
+37
-37
@@ -149,6 +149,7 @@ async fn send_command_locked_inner(
|
||||
// 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();
|
||||
|
||||
@@ -264,21 +265,19 @@ async fn send_command_locked_inner(
|
||||
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).await;
|
||||
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 {
|
||||
if confirmed_state && confirmed_requested_state {
|
||||
// A verified full status snapshot supersedes any older unsettled expectation for
|
||||
// this device, so it must not mask a later real remote change.
|
||||
clear_pending_controller_command(state, device_id).await;
|
||||
} else if !command_manual_control_fields(&applied_command).is_empty() {
|
||||
remember_controller_command(state, device_id, &applied_command).await;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
state.log("info", "device.command", &format!("Updated {}", device.name), json!({
|
||||
@@ -476,42 +475,41 @@ fn command_manual_control_fields(command: &DeviceCommand) -> Vec<String> {
|
||||
fields
|
||||
}
|
||||
|
||||
fn merge_device_command(base: &mut DeviceCommand, update: &DeviceCommand) {
|
||||
if update.power.is_some() { base.power = update.power; }
|
||||
if update.mode.is_some() { base.mode = update.mode.clone(); }
|
||||
if update.target_temperature.is_some() { base.target_temperature = update.target_temperature; }
|
||||
if update.fan_speed.is_some() { base.fan_speed = update.fan_speed; }
|
||||
if update.swing_vertical.is_some() { base.swing_vertical = update.swing_vertical; }
|
||||
if update.swing_horizontal.is_some() { base.swing_horizontal = update.swing_horizontal; }
|
||||
if update.quiet.is_some() { base.quiet = update.quiet; }
|
||||
if update.turbo.is_some() { base.turbo = update.turbo; }
|
||||
if update.light.is_some() { base.light = update.light; }
|
||||
if update.air.is_some() { base.air = update.air; }
|
||||
if update.xfan.is_some() { base.xfan = update.xfan; }
|
||||
if update.health.is_some() { base.health = update.health; }
|
||||
if update.sleep.is_some() { base.sleep = update.sleep; }
|
||||
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) {
|
||||
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) {
|
||||
merge_device_command(&mut existing.command, command);
|
||||
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 {
|
||||
command: command.clone(),
|
||||
commands: vec![command.clone()],
|
||||
baselines: vec![baseline],
|
||||
expires_at,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async fn clear_pending_controller_command(state: &AppState, device_id: &str) {
|
||||
state.pending_controller_commands.lock().await.remove(device_id);
|
||||
}
|
||||
|
||||
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),
|
||||
@@ -542,14 +540,16 @@ async fn suppress_expected_controller_changes(
|
||||
}
|
||||
let Some(expected) = pending.get(&device.id).cloned() else { return fields; };
|
||||
let filtered = fields.into_iter()
|
||||
.filter(|field| !command_field_matches_device(&expected.command, field, device))
|
||||
.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();
|
||||
let expected_fields = command_manual_control_fields(&expected.command);
|
||||
if !expected_fields.is_empty()
|
||||
&& expected_fields.iter().all(|field| command_field_matches_device(&expected.command, field, device))
|
||||
{
|
||||
pending.remove(&device.id);
|
||||
}
|
||||
// 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
|
||||
}
|
||||
|
||||
|
||||
+36
-1
@@ -1,4 +1,4 @@
|
||||
use std::{collections::HashSet, net::{Ipv4Addr, SocketAddr, SocketAddrV4}, sync::{Arc, Mutex, atomic::{AtomicBool, Ordering}}, time::Duration};
|
||||
use std::{collections::{HashMap, HashSet}, net::{Ipv4Addr, SocketAddr, SocketAddrV4}, sync::{Arc, Mutex, atomic::{AtomicBool, AtomicU64, Ordering}}, time::Duration};
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use chrono::Utc;
|
||||
use serde_json::{json, Value};
|
||||
@@ -22,6 +22,8 @@ pub struct GreeClient {
|
||||
interface: Option<String>,
|
||||
debug_events: Option<broadcast::Sender<ApiEvent>>,
|
||||
debug_gree_frames: Arc<AtomicBool>,
|
||||
received_frames_total: Arc<AtomicU64>,
|
||||
received_frames_by_device: Arc<Mutex<HashMap<String, u64>>>,
|
||||
buzzer_unsupported: Arc<Mutex<HashSet<String>>>,
|
||||
quiet_unsupported: Arc<Mutex<HashSet<String>>>,
|
||||
sleep_unsupported: Arc<Mutex<HashSet<String>>>,
|
||||
@@ -39,12 +41,43 @@ impl GreeClient {
|
||||
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<String, u64>) {
|
||||
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; };
|
||||
@@ -323,6 +356,7 @@ impl GreeClient {
|
||||
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;
|
||||
}
|
||||
@@ -667,6 +701,7 @@ impl GreeClient {
|
||||
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; }
|
||||
|
||||
+6
-1
@@ -6,7 +6,12 @@ use crate::{config::Config, db::Db, models::{ApiEvent, DeviceCommand, RuntimeSet
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct PendingControllerCommand {
|
||||
pub command: DeviceCommand,
|
||||
/// Recent controller-requested values for climate fields. Keep a short history because
|
||||
/// some GREE modules expose intermediate/out-of-order status snapshots while settling.
|
||||
pub commands: Vec<DeviceCommand>,
|
||||
/// Matching pre-command values for the same fields. A brief rollback to one of these
|
||||
/// values is firmware settling, not necessarily a physical-remote takeover.
|
||||
pub baselines: Vec<DeviceCommand>,
|
||||
pub expires_at: Instant,
|
||||
}
|
||||
|
||||
|
||||
+23
-1
@@ -729,6 +729,8 @@ function zoneCard(zone, detailed = true) {
|
||||
const displayPreset = zone.manual_preset || zone.active_preset || 'comfort';
|
||||
const mode = zone.inherit_house_mode ? 'house' : zone.mode;
|
||||
const effectiveMode = zone.effective_mode || (zone.inherit_house_mode ? (app.settings?.house_mode || 'off') : zone.mode) || 'off';
|
||||
const globalModeAvailable = ['heat', 'cool'].includes(app.settings?.house_mode);
|
||||
const globalModeDisabled = globalModeAvailable ? '' : ` disabled aria-disabled="true" title="${esc(tr('zones.globalModeUnavailable'))}"`;
|
||||
const hasManualOverride = zone.manual_preset != null || zone.manual_setpoint != null;
|
||||
const deviceManualOverride = zone.device_manual_override === true;
|
||||
const localThermostatPower = zone.local_thermostat_power;
|
||||
@@ -783,7 +785,7 @@ function zoneCard(zone, detailed = true) {
|
||||
<div class="preset-row quick-control-row quick-control-row-4">
|
||||
${['auto','comfort','sleep','away'].map(preset=>`<button class="${manual===preset?'active':''}" data-action="zone-preset" data-id="${esc(zone.id)}" data-value="${preset}">${esc(preset==='sleep'?tr('zones.sleepNow'):zonePresetLabel(preset))}</button>`).join('')}
|
||||
</div>
|
||||
<div class="mode-row zone-mode-row quick-control-row quick-control-row-3"><button class="${mode==='house'?'active':''}" data-action="zone-mode" data-id="${esc(zone.id)}" data-value="house">${esc(tr('zones.followHouseShort'))}</button><button class="${mode==='heat'?'active':''}" data-action="zone-mode" data-id="${esc(zone.id)}" data-value="heat">${esc(modeLabel('heat'))}</button><button class="${mode==='cool'?'active':''}" data-action="zone-mode" data-id="${esc(zone.id)}" data-value="cool">${esc(modeLabel('cool'))}</button></div>
|
||||
<div class="mode-row zone-mode-row quick-control-row quick-control-row-3"><button class="${mode==='house'&&globalModeAvailable?'active':''}" data-action="zone-mode" data-id="${esc(zone.id)}" data-value="house"${globalModeDisabled}>${esc(tr('zones.followHouseShort'))}</button><button class="${mode==='heat'?'active':''}" data-action="zone-mode" data-id="${esc(zone.id)}" data-value="heat">${esc(modeLabel('heat'))}</button><button class="${mode==='cool'?'active':''}" data-action="zone-mode" data-id="${esc(zone.id)}" data-value="cool">${esc(modeLabel('cool'))}</button></div>
|
||||
<div class="zone-state-line"><span>${esc(zoneRuntimeStatusLabel(zone, effectiveMode))}</span><span>${esc(override)}</span></div>
|
||||
${manualTakeover}
|
||||
</article>`;
|
||||
@@ -1004,9 +1006,22 @@ function renderSettings() {
|
||||
updateNotificationFields();
|
||||
updateInfluxFields();
|
||||
renderLogRetention();
|
||||
renderGreeFrameStats();
|
||||
$('#systemInfo').innerHTML = `<h3>${esc(tr('settings.systemState'))}</h3><div>${esc(tr('settings.version'))}: <strong>${esc(app.system.version || '—')}</strong></div><div>${esc(tr('settings.uptime'))}: <strong>${esc(formatDuration(app.system.uptime_seconds || 0))}</strong></div><div>${esc(tr('settings.apiAuth'))}: <strong>${esc(app.system.auth_required ? tr('settings.enabled') : tr('settings.disabled'))}</strong></div>`;
|
||||
}
|
||||
|
||||
function renderGreeFrameStats() {
|
||||
const host = $('#greeFrameStats');
|
||||
if (!host) return;
|
||||
const total = Number(app.system?.gree_received_frames || 0);
|
||||
const byDevice = app.system?.gree_received_frames_by_device || {};
|
||||
const deviceRows = app.devices.filter(device => !device.simulated).map(device => {
|
||||
const count = Number(byDevice[device.id] || 0);
|
||||
return `<div class="gree-frame-stat"><small>${esc(device.name || device.id)}</small><strong>${count.toLocaleString(locale())}</strong></div>`;
|
||||
}).join('');
|
||||
host.innerHTML = `<div class="gree-frame-stat total"><small>${esc(tr('settings.receivedFramesTotal'))}</small><strong>${total.toLocaleString(locale())}</strong></div>${deviceRows || `<div class="gree-frame-stat"><small>${esc(tr('settings.receivedFramesDevices'))}</small><strong>0</strong></div>`}`;
|
||||
}
|
||||
|
||||
function renderNightSettings() {
|
||||
if (!app.settings) return;
|
||||
const form = $('#nightModeForm');
|
||||
@@ -1687,6 +1702,13 @@ function connectWebSocket() {
|
||||
else if (message.event === 'house.power_changed') { app.settings=app.settings||{}; app.settings.house_power_enabled=data.house_power_enabled !== false; renderHouseClimate(); renderGroups(); scheduleControlPlanLoad(); }
|
||||
else if (message.event === 'debug.settings') { app.settings = app.settings || {}; app.settings.debug = data; renderSettings(); renderDebugOverlay(); if(data.overlay_enabled) loadDebugBacklog(); }
|
||||
else if (message.event === 'outdoor.updated') { app.outdoorTemperature=Number.isFinite(Number(data.temperature))?Number(data.temperature):null; renderHouseClimate(); scheduleControlPlanLoad(); }
|
||||
else if (message.event === 'gree.frame_received') {
|
||||
app.system = app.system || {};
|
||||
app.system.gree_received_frames = Number(data.total || 0);
|
||||
app.system.gree_received_frames_by_device = {...(app.system.gree_received_frames_by_device || {})};
|
||||
if (data.device_id) app.system.gree_received_frames_by_device[data.device_id] = Number(data.device_count || 0);
|
||||
renderGreeFrameStats();
|
||||
}
|
||||
else if (message.event === 'gree.frame') { if(app.settings?.debug?.overlay_enabled) debugLine('GREE', `${data.direction || '?'} ${data.protocol_version || ''}`, data.device_name || data.device_id || data.target || '', message.timestamp, data.payload); }
|
||||
else if (message.event === 'api.request') { if(app.settings?.debug?.overlay_enabled) debugLine('HTTP', `${data.method || '?'} ${data.status || ''}`, `${data.path || ''} · ${data.duration_ms ?? '?'} ms`, message.timestamp); }
|
||||
else if (message.event === 'log.created') { if(app.settings?.debug?.overlay_enabled) debugLine('API', data.kind || data.level || 'log', data.message || '', message.timestamp, data.metadata); if(app.currentView === 'logs') loadLogs(); }
|
||||
|
||||
@@ -253,6 +253,11 @@
|
||||
<div class="settings-grid"><label class="check wide"><input type="checkbox" name="suppress_device_beep"> <span data-i18n="settings.suppressBeep">Try to suppress command beeps</span></label></div>
|
||||
</section>
|
||||
|
||||
<section class="panel settings-block">
|
||||
<div class="settings-block-head"><div><h3 data-i18n="settings.greeTraffic">GREE traffic</h3><p data-i18n="settings.greeTrafficHint">Live counters of UDP frames received from configured air conditioners since controller startup.</p></div></div>
|
||||
<div id="greeFrameStats" class="gree-frame-stats"></div>
|
||||
</section>
|
||||
|
||||
<section class="panel settings-block">
|
||||
<div class="settings-block-head"><div><h3 data-i18n="settings.influx">Long-term InfluxDB history</h3><p data-i18n="settings.influxHint">Optional archive for older history. InfluxDB 1.x and 2.x are supported.</p></div></div>
|
||||
<div class="settings-grid">
|
||||
|
||||
@@ -203,6 +203,12 @@ input:focus, select:focus { border-color: var(--accent); outline: 2px solid colo
|
||||
.backup-actions { align-items: center; }
|
||||
.system-panel { margin-top: 14px; color: var(--muted); line-height: 1.7; }
|
||||
.system-panel strong { color: var(--text); }
|
||||
.gree-frame-stats { display:grid; grid-template-columns:repeat(auto-fit,minmax(150px,1fr)); gap:10px; }
|
||||
.gree-frame-stat { display:flex; align-items:center; justify-content:space-between; gap:12px; min-width:0; padding:12px 14px; border:1px solid var(--line); border-radius:14px; background:var(--surface-2); }
|
||||
.gree-frame-stat small { overflow:hidden; color:var(--muted); text-overflow:ellipsis; white-space:nowrap; }
|
||||
.gree-frame-stat strong { color:var(--text); font-variant-numeric:tabular-nums; }
|
||||
.gree-frame-stat.total { border-color:var(--accent-border); background:var(--accent-soft); }
|
||||
.quick-thermostat-control .zone-mode-row button:disabled { opacity:.38; cursor:not-allowed; box-shadow:none; }
|
||||
.log-list { display: grid; gap: 2px; padding: 8px; }
|
||||
.log-row { display: grid; grid-template-columns: 78px 150px 1fr; gap: 12px; padding: 11px 12px; border-bottom: 1px solid var(--line); font-size: 12px; }
|
||||
.log-row:last-child { border-bottom: 0; }
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const CACHE = 'gree-controller-v0712-timed-local-resume';
|
||||
const CACHE = 'gree-controller-v0713-race-frame-counters';
|
||||
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')];
|
||||
|
||||
Reference in New Issue
Block a user