v0.9.4-fix

This commit is contained in:
Mateusz Gruszczyński
2026-09-01 14:59:00 +02:00
parent 3c490b3064
commit 150cf6cb7f
13 changed files with 285 additions and 62 deletions
+13 -13
View File
@@ -1,11 +1,11 @@
b38e05f8a00969270280b0062ee129fccee39ae9c11cb614a6ad7b2b7ae2dfc7 ./.env.example
d67af429e4da9ce08e9d2f2a8472849ffbd70d135b1c5da535a076026794d04c ./.env.example
a4ec3874a2e3ab1bad28fb40bb620f7b01f64d01ad9b699306bf70ada31227db ./.gitignore
37ba4d5222b221d9a3d560246d968092fa4402288410eaba7e6bab87ebcc5aea ./Cargo.lock
a0976abed3b84a5ba0c726410c87d31017d42098fb7e053af986869ea0c9db74 ./Cargo.toml
19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE
4011536d5181ce7d8132c3b2ad50699a3ffa057f33b361aaa96fe71b6ae3efe3 ./README.md
a6391cc7fc014d06205c3fac708e0316e7239eaca47e0a2cb05a18275c58f192 ./README.md
ec0f495ee6a618bdec10b260ee7838f7a872d8c5b0aa91c546226c1ad818f601 ./build.rs
e183861962799504fe7dfc7f43ba5d75dc7f7f68c898689dd2c178f566af73e2 ./docs/API.md
e7f085a9b4bd75dd62609e3c9f62bdde532f5cb0675fda2cf5109e57fafef2d2 ./docs/API.md
a0893b2a56eb1523f1a72871842e9be2139a5fafba1f51ae942fc407a6e4ca34 ./home-assistant/README.md
f8e8559fe10fe523ac5bc9aac25c6e26e862f679d502e8f3c39f38a0a8e40911 ./home-assistant/custom_components/gree_controller/__init__.py
6910589f27960a28d4de9735884a7e5376e455cd885f19ce2b55947fcd135114 ./home-assistant/custom_components/gree_controller/api.py
@@ -25,8 +25,8 @@ cca65482e36d48035aca178121a378fe7d578d600acff267ae81a6399c0da653 ./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
669da4cb511c9d7f72017edf3f16718eb3b32fc832d8431a597a330f4bc8f5c0 ./lang/en.json
9bf8bccb7eaf99aba65f4b4b8776f2834f335fc0b007999d7e5c8d9d287067c8 ./lang/pl.json
fdae654fbb89505a65cc3e05c76907a3ded31dbcae019d4ccf1b1cc146990d71 ./lang/en.json
093022ed2886317c092c19a556ab8cfed86d0322d88f620dfbae515ac64e5ac0 ./lang/pl.json
d8459024f04ca514bd8e9d6bd3af872fb942fd85a7cdd5583f4a9d28aab6faba ./make_zip.py
bb89bac237e750e9b1bf73761d7df97a6b81853091615878c03f13d7b6399aa7 ./scripts/README.md
5bc736c7bc76ca80aaa406bb171d2aa91baf4c3aa8695dce0e09b888b6ab3146 ./scripts/common.sh
@@ -48,7 +48,7 @@ b50782b3742dfbf8a319c60571c968e93fdf8547db747c759edcffae68cb98bf ./scripts/upda
417940b467c6f00bbf01cc47eb4479880dee9b2f284a80e3662a6d433dc11b92 ./src/api/events.rs
d096000e94997cf9aaf9d3cc567214a78b8b2da7da57a76e2b3519247af6e592 ./src/api/groups.rs
a13d4e217fe3ddaa73873ba6e0d1bc93750cf61764d867ff7372274ed21d9599 ./src/api/history.rs
b2a730970a767c67727d5bc8f7090edb26d8332a47bc72a28b5a1195a4dbafe7 ./src/api/house.rs
7b5de0bb8b6559a59a98ba56c853b712ffcc7211900f02a4a9a089027878dde1 ./src/api/house.rs
0d7d598f7ed24315ed0547a60a72a06f20ebcf926489e1c454986f42d22cc305 ./src/api/integrations.rs
bb2a746ecdcc2da5fe54e18b455c7bd19453486dd5c008e71951a2c81d0e7d64 ./src/api/middleware.rs
2b3feefbafc1f813c00dfe20db147aa1f5067ec5b8455f041ac3793802911be3 ./src/api/public_settings.rs
@@ -71,21 +71,21 @@ a384ea4042d1d11692b5f8693df1f54850dcc4e80726780b44b2b096bf667b78 ./src/db/zone_
952ea5906d39ef917ad71edc345f76b29c07c63079644fd5699b12aa254cb35f ./src/engine.rs
384f7027ca48bd4c4048320bc6d4cacd631bee40e8630ea22e0d6e5f22ddc3a6 ./src/engine/automations.rs
77931af524e9402fc826050b5fcec422d01b02b625c8ce7f2004f1f6520c9b4d ./src/engine/commands.rs
ced02b351ba86c43bd3683480a33ff7bd95c8a8109216c85979bc1cb14db5f41 ./src/engine/control_plan.rs
e16c1a65057f6fbf11bb6f27493ed975f5c89309b31dec0fa3248b9237494ed3 ./src/engine/control_plan.rs
38f42c86c31048780ca414f2c328f363ef803a0edbc1e1ed85503909a10b69ae ./src/engine/deadlines.rs
511caa52f9d1a6c13c15731e6a394ca2790ba642845367da1f0b767c12b5be98 ./src/engine/groups.rs
18a7669318949e533f206d25cf7b63771d7f0f2c0b375a72ff78a2d921baaa61 ./src/engine/history.rs
f7f2570c5b4a0f655e7c68e44d3dcca478b546e31586e98227b0e8c21e73e8c4 ./src/engine/local_thermostat.rs
ea61ee04f7cda0c4835a87b1e28f1ff8134a8b977741ef6e8ccb8f284d1c9470 ./src/engine/ownership.rs
4972c199274647bff47122c367e51bae8dab24d7ec4d652288e227a5a64c2310 ./src/engine/local_thermostat.rs
ecab67072e756f5a371864282eb470a17de53016593e2e96540674066262c865 ./src/engine/ownership.rs
9c37adf514c96840fd6a5c715c579350fa86d6dcc0655a2aa97d1fd7c7e04299 ./src/engine/polling.rs
3d48608a065aa52a1fd8b9c59356a74d8216ecee3d47f8ff8ef7d3d47170c617 ./src/engine/runtime.rs
ffa72b93502eece187a96adb4ec94aec6048d4a6dc7af66e7c7af0931318787f ./src/engine/schedules.rs
2e000ede494bafb5309af3bbcdf1f10024bb01012ab3f9555ff0c8f1898bc9d7 ./src/engine/targets.rs
bcfebf99a03ee676fd9c1c5d395adf271898fcb0df0d39bce3a3b7067456522a ./src/engine/targets.rs
a9188b588b2617ce1547a45d46141f7e5f1bb0027a48675dd86b274f807d0875 ./src/engine/temperature.rs
c2fe371f9245ce299015f0b4e43f6625a207016abf73d2419acb6bd7437f045a ./src/engine/temporary_thermostat.rs
9e86ea344e0bf6a2232905a98e5addce5da6f845cfed5b04a5d2d20787490b15 ./src/engine/tests.rs
5631fd8420c4137f4586d98939d24a83701400ac07a3f2fe9da8b8d486df3e64 ./src/engine/tests.rs
ebf8b9ec5c5a84850d8cad4c4faea090e52625b5a0be56625dc84060ecb35186 ./src/engine/zone_actions.rs
51e0d547709da755622c88bea5e69912b519040d5111ba5c2d74d071b73b52a9 ./src/engine/zone_control.rs
d2c2f3678bd13ec2d92404993173a691803da215dbdb1edafcd96e1963c7004e ./src/engine/zone_control.rs
4b271b6fc365b1078c01d6178eb563841b2ecaed5d8639196f58e1312d2236fe ./src/error.rs
c6ff66da9ad08506f839ec56a869ead3abc824b5311e0ea9244517d11f7f4207 ./src/home_assistant.rs
6f9ef85cd53ba030b477bc3c186f0f7e762674124210eabcb0686f2806eaada1 ./src/influxdb.rs
@@ -133,7 +133,7 @@ b3d5cb473bf9771de78e14c7de730cb86c73f8cdbb6344da67ba7d6abac879ea ./web/js/boots
64a43e2142a69ebddf4160c0d0d3b573fa379c650431090fd15c9ba3b62bc4e7 ./web/js/charts.js
cf8d97770985b8cf920382725fa33d5f61497a639544bfc528af72b276de0b6a ./web/js/core.js
9767d5f2bd2844245245c0bd86dddd528975c1d1dd1d05b1449feda7a95214c4 ./web/js/dashboard.js
a41a5d768452872e9e88abeab8d97c0869a3285448165ec306f59341265b4b59 ./web/js/entities.js
c378f6234dbd4310a25ef6d65c12521726cd3fcce1c7069cd878b1d5af491011 ./web/js/entities.js
69a052771dfafbd4b778cbc9d1b7a7695427621ed66047282088a3a07e6e4c2e ./web/js/events.js
2fe6c804f86b0613a50a56c54f7a0cbee77273759edf3e6fec36ac77d35edbd7 ./web/js/forms.js
be07a341ba4b1f514a5e13553494194b22aa87108cb04de40635a0158921780b ./web/js/history.js
+2 -2
View File
@@ -177,7 +177,7 @@ The hardened systemd unit allows `AF_NETLINK`, which is required for interface d
A **Device** is the physical GREE unit. Device commands are direct/technical commands.
A **Zone** is the thermostat owner for a device. It decides demand from room temperature, setpoint, hysteresis, schedules, house rules, manual ownership and safety lockouts. Group control can temporarily apply shared settings. Turning a group OFF powers its member units down and releases group ownership; each thermostat can then be switched back on independently without an OFF group acting as a membership block.
A **Zone** is the thermostat owner for a device. It decides demand from room temperature, setpoint, hysteresis, schedules, house rules, manual ownership and safety lockouts. Group control can temporarily apply shared settings. Turning a group OFF powers its member units down and releases group ownership; each thermostat can then be switched back on independently without an OFF group acting as a membership block. After handing an OFF thermostat back to automation, an implicit Comfort target alone does not start it: the zone waits for an active schedule, manual preset/setpoint, Temporary Quick Thermostat, active group/local control or another explicit automatic power intent.
### Compressor protection
@@ -189,7 +189,7 @@ This distinction is important: direct device control intentionally behaves diffe
The zone state exposes who currently owns control (`control_owner`, `control_source`, timestamps and reason). Physical remote/direct device actions can create a manual-device takeover so normal schedules do not immediately overwrite the user. Explicit zone thermostat control hands ownership back to the thermostat engine when appropriate.
Global **Turn all off** is an immediate bulk thermostat action: it powers every enabled unit off and leaves every thermostat locally OFF with no hand-back timer, but it does not create a persistent global gate. A later local thermostat or manual group action can therefore re-enable only its own scope. **Turn all on** releases those local OFF markers and sends ON to all enabled units; compressor protection may queue protected starts. Explicit local/group/manual control has priority over house rules.
Global **Turn all off** is an immediate bulk thermostat action: it powers every enabled unit off and leaves every thermostat locally OFF with no hand-back timer, but it does not create a persistent blocking gate. A later **Resume automation** releases only that zone to automatic arbitration; when no schedule/override/temporary or other explicit intent is active, it stays OFF. **Turn all on** is different: it is an explicit whole-house ON intent, releases local OFF markers and sends ON to enabled units; compressor protection may queue protected starts. Explicit local/group/manual control still has priority.
### Profiles and schedules
+2 -2
View File
@@ -519,7 +519,7 @@ Semantics:
- `enabled`: zone automation enable state.
- `power:true`: local Quick Thermostat ownership — this zone can run through full thermostat logic regardless of whether group-level control is enabled for its climate group.
- `power:false`: turns this zone off and creates a fresh backend-owned local hand-back timer (currently 15 minutes).
- `clear_local_thermostat_override:true`: immediately return local Quick Thermostat ownership to normal group/schedule control.
- `clear_local_thermostat_override:true`: immediately return local Quick Thermostat ownership to normal automation. If there is no active schedule, manual preset/setpoint, Temporary Quick Thermostat or other explicit thermostat intent, the zone remains physically OFF instead of falling back to implicit Comfort.
- `clear_device_manual_override:true`: explicitly hand a physical/direct manual takeover back to the thermostat.
- `clear_override:true`: clear ordinary quick preset/setpoint override.
- `temporary_quick_thermostat`: start/replace a persisted temporary session.
@@ -700,7 +700,7 @@ Returns public runtime settings.
}
```
This endpoint is a **bulk thermostat power action without a persistent global gate**. `false` first stores every thermostat as an indefinite local OFF and then sends immediate OFF to every technically enabled unit, so the next normal demand cycle cannot turn the house back on. A later explicit local thermostat or manual group action may re-enable only its own scope. `true` releases local OFF markers for all thermostats and sends ON to all technically enabled units. Group enablement, profiles, schedules and house mode are preserved. Pending compressor-protection tasks from before the action are cleared. When compressor protection is enabled, protected global starts are queued until their safe deadline; OFF is never delayed.
This endpoint is a **bulk thermostat power action without a persistent blocking global gate**. `false` first stores every thermostat as an indefinite local OFF and then sends immediate OFF to every technically enabled unit, so the next normal demand cycle cannot turn the house back on. A later per-zone **Resume automation** clears that local OFF but does not invent a Comfort demand: without an active schedule/override/temporary or another explicit intent the zone stays OFF. `true` is itself an explicit whole-house ON intent, releases local OFF markers for all thermostats and sends ON to all technically enabled units. Group enablement, profiles, schedules and house mode are preserved. Pending compressor-protection tasks from before the action are cleared. When compressor protection is enabled, protected global starts are queued until their safe deadline; OFF is never delayed.
Response includes:
+4 -4
View File
@@ -250,7 +250,7 @@
"mode.off": "Off",
"house.seasonMode": "Home climate mode",
"house.smartThermostat": "Smart thermostat",
"house.setpointStrategy": "Cooling or heating controls zones automatically. “Do not control” leaves devices and manually activated zones untouched by the house thermostat.",
"house.setpointStrategy": "Heating or cooling selects the automation direction. After resume, a zone with no active schedule, manual target, temporary session or other explicit control stays OFF. “Do not control” leaves devices untouched.",
"house.outdoor": "Outdoor",
"house.modeUpdated": "Home climate mode updated",
"zones.modePolicy": "Mode policy",
@@ -607,15 +607,15 @@
"zones.localThermostatOn": "Local control · on",
"zones.localThermostatOff": "Local control · off",
"zones.localThermostatOnDescription": "This zone runs locally regardless of whether group-level control is enabled. Sensor, hysteresis, profile and GREE regulation remain active.",
"zones.localThermostatOffDescription": "This zone is locally off. Without an active countdown it remains outside group control until automation is resumed manually.",
"zones.localThermostatOffDescription": "This zone is locally off. After automation is resumed it stays OFF unless a schedule, manual target, temporary session or another explicit control source is active.",
"zones.unitPowerLocalHint": "Turns this zone's local thermostat on or off; this is not remote-style or direct device control.",
"zones.localPowerUpdated": "Local thermostat state updated",
"zones.turnThermostatOn": "Turn thermostat on",
"zones.turnThermostatOff": "Turn thermostat off",
"zones.waiting": "Waiting",
"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.localThermostatOffDescriptionTimed": "This zone is locally off. In {time}, automation will resume and start only if an explicit control source is active.",
"zones.localThermostatResuming": "Timer elapsed — automation is checking for an active control source…",
"zones.resumeNow": "Resume now",
"zones.controlOwner": "Control",
"zones.ownerAutomation": "Automation",
+4 -4
View File
@@ -250,7 +250,7 @@
"mode.off": "Wyłączone",
"house.seasonMode": "Tryb klimatu domu",
"house.smartThermostat": "Inteligentny termostat",
"house.setpointStrategy": "Chłodzenie lub grzanie steruje strefami automatycznie. „Nie steruj” pozostawia urządzenia i ręcznie uruchomione strefy bez ingerencji termostatu domu.",
"house.setpointStrategy": "Grzanie lub chłodzenie określa kierunek pracy automatyki. Po wznowieniu strefa bez aktywnego harmonogramu, ręcznego celu, trybu czasowego lub innego jawnego sterowania pozostaje OFF. „Nie steruj” nie ingeruje w urządzenia.",
"house.outdoor": "Na zewnątrz",
"house.modeUpdated": "Zmieniono tryb klimatu domu",
"zones.modePolicy": "Polityka trybu",
@@ -607,15 +607,15 @@
"zones.localThermostatOn": "Sterowanie lokalne · włączone",
"zones.localThermostatOff": "Sterowanie lokalne · wyłączone",
"zones.localThermostatOnDescription": "Strefa działa lokalnie niezależnie od tego, czy sterowanie grupowe jest włączone. Czujnik, histereza, profil i regulacja GREE pozostają aktywne.",
"zones.localThermostatOffDescription": "Strefa jest lokalnie wyłączona. Bez aktywnego licznika pozostaje poza sterowaniem grupy do ręcznego wznowienia automatyki.",
"zones.localThermostatOffDescription": "Strefa jest lokalnie wyłączona. Po wznowieniu automatyki pozostanie OFF, jeśli nie ma aktywnego harmonogramu, ręcznego celu, trybu czasowego ani innego jawnego sterowania.",
"zones.unitPowerLocalHint": "Włącza lub wyłącza lokalny termostat tej strefy; nie jest to sterowanie pilotem ani bezpośrednia komenda urządzenia.",
"zones.localPowerUpdated": "Zmieniono stan lokalnego termostatu",
"zones.turnThermostatOn": "Włącz termostat",
"zones.turnThermostatOff": "Wyłącz termostat",
"zones.waiting": "Oczekiwanie",
"zones.noMeasurement": "Brak pomiaru",
"zones.localThermostatOffDescriptionTimed": "Strefa jest lokalnie wyłączona. Automatyka przejmie sterowanie za {time}.",
"zones.localThermostatResuming": "Czas minął — automatyka przejmuje sterowanie…",
"zones.localThermostatOffDescriptionTimed": "Strefa jest lokalnie wyłączona. Za {time} automatyka zostanie wznowiona i uruchomi strefę tylko przy aktywnym źródle sterowania.",
"zones.localThermostatResuming": "Czas minął — automatyka sprawdza aktywne źródło sterowania…",
"zones.resumeNow": "Wznów teraz",
"zones.controlOwner": "Sterowanie",
"zones.ownerAutomation": "Automatyka",
+4 -3
View File
@@ -115,8 +115,9 @@ async fn update_house_power(State(state): State<AppState>, Json(input): Json<Hou
// Global power is a bulk thermostat action, not a persistent master gate. OFF stores every
// thermostat as an indefinite local OFF so the next regulator cycle cannot immediately
// resurrect demand. ON releases that OFF state. Later explicit local/group/manual actions
// remain independent and can re-enable only the selected scope.
// resurrect demand. ON releases that OFF state and records an explicit automatic house-power
// intent for otherwise-free zones. Later explicit local/group/manual actions remain
// independent and can take over only the selected scope.
{
let mut settings = state.settings.write().await;
if !settings.house_power_enabled {
@@ -138,7 +139,7 @@ async fn update_house_power(State(state): State<AppState>, Json(input): Json<Hou
let settings_payload = public_settings(&settings);
drop(settings);
state.log("info", "house.power_all", if input.power {
"Whole-house ON sent; local OFF state released for all thermostats"
"Whole-house ON sent; local OFF state released and house thermostat intent armed"
} else {
"Whole-house OFF sent; all thermostats left locally OFF until explicitly re-enabled"
}, json!({
+34 -17
View File
@@ -20,25 +20,36 @@ pub async fn build_control_plan(state: &AppState) -> Result<ControlPlan, AppErro
let configured_effective_mode_owned = effective_zone_mode(&zone, &settings.house_mode);
refresh_control_ownership(&mut zone, true);
let configured_effective_mode = configured_effective_mode_owned.as_str();
let target_mode = if configured_effective_mode == "off" { zone.mode.as_str() } else { configured_effective_mode };
let active_for_target = active_schedule_for_zone(&zone, &schedules, now);
let has_intent_source = zone_has_thermostat_intent_source(&zone, active_for_target, Utc::now());
let automation_idle = zone.enabled
&& !zone.device_manual_override
&& zone.local_thermostat_power != Some(false)
&& configured_effective_mode != "off"
&& !has_intent_source;
let automatic_active = zone.enabled
&& !zone.device_manual_override
&& zone.local_thermostat_power != Some(false)
&& configured_effective_mode != "off"
&& has_intent_source;
let manual_device_mode = device.map(|item| if item.power { item.mode.as_str() } else { "off" });
let effective_mode = if zone.device_manual_override {
manual_device_mode.unwrap_or(configured_effective_mode)
} else if zone.local_thermostat_power == Some(false) {
"off"
} else {
} else if automatic_active {
configured_effective_mode
} else {
"off"
};
// Keep the thermostat target readable even while the zone/group/house control is off.
// Home Assistant climate entities otherwise expose target_temperature as unknown.
let target_mode = if configured_effective_mode == "off" { zone.mode.as_str() } else { configured_effective_mode };
let active_for_target = active_schedule_for_zone(&zone, &schedules, now);
let (resolved_preset, resolved_target) = resolve_zone_target(&zone, active_for_target, target_mode);
let active = if effective_mode == "off" { None } else { active_for_target };
let next_events = if effective_mode == "off" {
let active = if automatic_active { active_for_target } else { None };
// Future schedule events remain visible while the zone is currently idle between
// windows; idle must not be confused with a disabled schedule system.
let next_events = if configured_effective_mode == "off" {
Vec::new()
} else {
next_schedule_events(&zone, &schedules, effective_mode, now, 8)
next_schedule_events(&zone, &schedules, configured_effective_mode, now, 8)
};
for event in next_events.iter().take(2) {
let mut event = event.clone();
@@ -57,23 +68,25 @@ pub async fn build_control_plan(state: &AppState) -> Result<ControlPlan, AppErro
mode: effective_mode.to_string(),
configured_mode: zone.mode.clone(),
inherit_house_mode: zone.inherit_house_mode,
preset: resolved_preset,
preset: if has_intent_source { resolved_preset } else { "auto".into() },
preset_override: zone.manual_preset.clone(),
current_temperature: zone.current_temperature,
target_temperature: if zone.device_manual_override || !zone.enabled || effective_mode == "off" {
target_temperature: if !has_intent_source {
None
} else if zone.device_manual_override || !zone.enabled || effective_mode == "off" {
// A remote/manual takeover may leave a physical standby target persisted in the
// zone runtime snapshot. Never publish that value as the thermostat target.
// zone runtime snapshot. Publish only the underlying explicit thermostat target.
Some(resolved_target)
} else {
zone.effective_setpoint.or(Some(resolved_target))
},
device_setpoint: device.filter(|item| item.power).map(|item| item.target_temperature),
desired_power: zone.enabled && effective_mode != "off" && !zone.device_manual_override,
desired_power: automatic_active,
desired_mode: effective_mode.to_string(),
actual_power: device.map(|item| item.power),
actual_mode: device.map(|item| if item.power { item.mode.clone() } else { "off".into() }),
actual_setpoint: device.filter(|item| item.power).map(|item| item.target_temperature),
demand: zone.enabled && effective_mode != "off" && !zone.device_manual_override && zone.demand,
demand: automatic_active && zone.demand,
control_source: zone.control_temperature_source.clone(),
manual_override_until: zone.manual_override_until,
local_thermostat_power: zone.local_thermostat_power,
@@ -84,8 +97,12 @@ pub async fn build_control_plan(state: &AppState) -> Result<ControlPlan, AppErro
control_command_source: zone.control_source.clone(),
control_since: zone.control_since,
resume_at: zone.control_resume_at,
control_reason: zone.control_reason.clone(),
blocked_reason: if zone.device_manual_override { Some("manual_override".into()) } else if zone.lockout_until.map(|until| until > Utc::now()).unwrap_or(false) { Some(zone.lockout_reason.clone().unwrap_or_else(|| "lockout".into())) } else if !zone.enabled { Some("zone_disabled".into()) } else if device.map(|d| !d.online || d.communication_failures > 0).unwrap_or(true) { Some("offline".into()) } else { None },
control_reason: if automation_idle {
"Automation idle: no active schedule or explicit thermostat request".into()
} else {
zone.control_reason.clone()
},
blocked_reason: if zone.device_manual_override { Some("manual_override".into()) } else if zone.lockout_until.map(|until| until > Utc::now()).unwrap_or(false) { Some(zone.lockout_reason.clone().unwrap_or_else(|| "lockout".into())) } else if !zone.enabled { Some("zone_disabled".into()) } else if device.map(|d| !d.online || d.communication_failures > 0).unwrap_or(true) { Some("offline".into()) } else if automation_idle { Some("no_active_thermostat_intent".into()) } else { None },
lockout_until: zone.lockout_until,
current_schedule_id: active.map(|item| item.id.clone()),
current_schedule_name: active.map(|item| item.name.clone()),
+22 -8
View File
@@ -1,15 +1,29 @@
pub const LOCAL_THERMOSTAT_RESUME_DELAY_MINUTES: i64 = 15;
/// Apply the whole-house bulk power state to a thermostat without creating a persistent
/// global gate. OFF leaves the zone explicitly/indefinitely off; ON releases that local OFF
/// so the zone can immediately return to its group/manual/automatic controller.
/// Apply the whole-house bulk power state without creating a blocking global gate. OFF leaves
/// the zone explicitly/indefinitely off. ON releases that local OFF and records a non-manual
/// whole-house thermostat intent so an explicit Global ON remains effective without stealing
/// local/group/direct ownership.
pub fn set_house_bulk_thermostat_power(zone: &mut Zone, power: bool) -> bool {
if power {
if zone.local_thermostat_power != Some(false) { return false; }
zone.local_thermostat_power = None;
zone.local_thermostat_resume_at = None;
zone.local_thermostat_restore_zone_enabled = None;
true
let mut changed = false;
if zone.local_thermostat_power == Some(false) {
zone.local_thermostat_power = None;
zone.local_thermostat_resume_at = None;
zone.local_thermostat_restore_zone_enabled = None;
changed = true;
}
// Whole-house ON is itself an explicit thermostat intent, but it is not manual/local
// ownership. Mark only otherwise-free zones so group/local/direct owners keep priority.
if !zone.device_manual_override
&& zone.local_thermostat_power.is_none()
&& !zone.control_source.starts_with("group:")
&& zone.control_source != "house_power"
{
zone.control_source = "house_power".into();
changed = true;
}
changed
} else {
let changed = zone.local_thermostat_power != Some(false)
|| zone.local_thermostat_resume_at.is_some()
+7 -1
View File
@@ -23,7 +23,13 @@ pub fn refresh_control_ownership(zone: &mut Zone, _house_power_enabled: bool) {
};
("local_thermostat", source, resume_at, reason)
} else {
let source = if zone.control_source.starts_with("group:") { zone.control_source.clone() } else { "automation".into() };
let source = if zone.control_source.starts_with("group:")
|| matches!(zone.control_source.as_str(), "automation.device" | "house_power")
{
zone.control_source.clone()
} else {
"automation".into()
};
("automation", source, zone.manual_override_until, "Automatic thermostat/schedule control".to_string())
};
if zone.control_owner != owner || zone.control_source != source {
+52 -4
View File
@@ -24,6 +24,8 @@ fn resolve_zone_target(zone: &Zone, schedule: Option<&Schedule>, mode: &str) ->
(item.preset.clone(), profile_setpoint(zone, &item.preset, mode))
}
} else {
// Comfort is only a fallback target for an explicit controller (local/group/etc.).
// The absence of a schedule must not by itself create thermostat demand.
("comfort".into(), profile_setpoint(zone, "comfort", mode))
};
// Quick +/- temperature adjustments are independent from the selected preset.
@@ -31,16 +33,62 @@ fn resolve_zone_target(zone: &Zone, schedule: Option<&Schedule>, mode: &str) ->
(preset, zone.manual_setpoint.unwrap_or(base_target))
}
/// Return whether some thermostat source currently asks this zone to participate in climate
/// control. Heat/Cool configuration plus the implicit Comfort fallback is intentionally not a
/// source on its own.
fn zone_has_thermostat_intent_source(zone: &Zone, schedule: Option<&Schedule>, now: DateTime<Utc>) -> bool {
zone.local_thermostat_power == Some(true)
|| temporary_quick_thermostat_is_active(zone, now)
|| zone.manual_preset.is_some()
|| zone.manual_setpoint.is_some()
|| zone.control_source.starts_with("group:")
|| matches!(zone.control_source.as_str(), "automation.device" | "house_power")
|| schedule.is_some()
}
/// Automatic thermostat arbitration may run only when an explicit source exists and no higher
/// priority OFF/manual gate blocks it. This keeps a resumed/global/group-OFF zone physically OFF
/// while it waits for the next schedule window or another explicit thermostat request.
fn zone_has_active_thermostat_intent(zone: &Zone, schedule: Option<&Schedule>, now: DateTime<Utc>) -> bool {
zone.enabled
&& !zone.device_manual_override
&& zone.local_thermostat_power != Some(false)
&& zone_has_thermostat_intent_source(zone, schedule, now)
}
pub fn refresh_zone_runtime_target(zone: &mut Zone, schedules: &[Schedule], house_mode: &str) {
let configured_mode = effective_zone_mode(zone, house_mode);
zone.effective_mode = configured_mode.clone();
let target_mode = if configured_mode == "off" { zone.mode.as_str() } else { configured_mode.as_str() };
let schedule = active_schedule_for_zone(zone, schedules, Local::now());
let has_intent_source = zone_has_thermostat_intent_source(zone, schedule, Utc::now());
// During a physical/manual takeover keep effective_mode owned by the real device state.
// We may still refresh an underlying explicit thermostat target for the Resume UI.
if zone.device_manual_override {
if has_intent_source {
let (preset, target) = resolve_zone_target(zone, schedule, target_mode);
zone.active_preset = preset;
zone.effective_setpoint = Some(target);
} else {
zone.effective_setpoint = None;
}
return;
}
if !zone.enabled || zone.local_thermostat_power == Some(false) || !has_intent_source {
zone.effective_mode = "off".into();
zone.effective_setpoint = None;
zone.device_setpoint = None;
zone.demand = false;
zone.demand_since = None;
zone.target_alerted_at = None;
return;
}
zone.effective_mode = configured_mode;
let (preset, target) = resolve_zone_target(zone, schedule, target_mode);
zone.active_preset = preset;
if !zone.device_manual_override {
zone.effective_setpoint = Some(target);
}
zone.effective_setpoint = Some(target);
}
fn effective_zone_mode(zone: &Zone, house_mode: &str) -> String {
+84 -2
View File
@@ -634,6 +634,81 @@ mod tests {
assert_eq!(target, 22.5);
}
#[test]
fn automatic_thermostat_without_active_intent_stays_idle() {
let zone = test_zone("device");
assert!(!zone_has_thermostat_intent_source(&zone, None, Utc::now()));
assert!(!zone_has_active_thermostat_intent(&zone, None, Utc::now()));
}
#[test]
fn explicit_thermostat_sources_activate_control_without_schedule() {
let mut zone = test_zone("device");
zone.manual_setpoint = Some(22.0);
assert!(zone_has_active_thermostat_intent(&zone, None, Utc::now()));
zone.manual_setpoint = None;
zone.manual_preset = Some("sleep".into());
assert!(zone_has_active_thermostat_intent(&zone, None, Utc::now()));
zone.manual_preset = None;
zone.local_thermostat_power = Some(true);
assert!(zone_has_active_thermostat_intent(&zone, None, Utc::now()));
zone.local_thermostat_power = None;
zone.control_source = "group:upstairs".into();
assert!(zone_has_active_thermostat_intent(&zone, None, Utc::now()));
zone.control_source = "automation.device".into();
refresh_control_ownership(&mut zone, true);
assert_eq!(zone.control_owner, "automation");
assert_eq!(zone.control_source, "automation.device");
assert!(zone_has_active_thermostat_intent(&zone, None, Utc::now()));
}
#[test]
fn local_off_blocks_even_an_active_schedule_until_resume() {
let mut zone = test_zone("device");
let schedule = test_schedule("active", vec![1,2,3,4,5,6,7], "00:00", "00:00");
zone.local_thermostat_power = Some(false);
assert!(zone_has_thermostat_intent_source(&zone, Some(&schedule), Utc::now()));
assert!(!zone_has_active_thermostat_intent(&zone, Some(&schedule), Utc::now()));
}
#[test]
fn runtime_target_refresh_has_no_implicit_comfort_activation() {
let mut zone = test_zone("device");
zone.inherit_house_mode = false;
zone.mode = "cool".into();
zone.effective_setpoint = Some(23.0);
refresh_zone_runtime_target(&mut zone, &[], "cool");
assert_eq!(zone.effective_mode, "off");
assert_eq!(zone.effective_setpoint, None);
}
#[test]
fn resuming_local_off_without_schedule_stays_off() {
let mut zone = test_zone("device");
zone.inherit_house_mode = false;
zone.mode = "cool".into();
zone.demand = true;
set_local_thermostat_power(&mut zone, false, Utc::now());
assert!(reset_local_thermostat_override(&mut zone));
refresh_control_ownership(&mut zone, true);
refresh_zone_runtime_target(&mut zone, &[], "cool");
assert!(zone.local_thermostat_power.is_none());
assert_eq!(zone.control_source, "automation");
assert_eq!(zone.effective_mode, "off");
assert_eq!(zone.effective_setpoint, None);
assert!(!zone.demand);
assert!(!zone_has_active_thermostat_intent(&zone, None, Utc::now()));
}
#[test]
fn runtime_target_refresh_applies_manual_profile_immediately() {
let mut zone = test_zone("device");
@@ -800,14 +875,21 @@ mod tests {
assert!(set_house_bulk_thermostat_power(&mut zone, true));
assert!(zone.local_thermostat_power.is_none());
assert!(zone.local_thermostat_resume_at.is_none());
assert_eq!(zone.control_source, "house_power");
assert!(zone_has_active_thermostat_intent(&zone, None, Utc::now()));
}
#[test]
fn whole_house_on_does_not_create_a_local_on_override() {
fn whole_house_on_creates_automatic_intent_without_local_ownership() {
let mut zone = test_zone("device");
zone.local_thermostat_power = None;
assert!(!set_house_bulk_thermostat_power(&mut zone, true));
assert!(set_house_bulk_thermostat_power(&mut zone, true));
assert!(zone.local_thermostat_power.is_none());
assert_eq!(zone.control_source, "house_power");
refresh_control_ownership(&mut zone, true);
assert_eq!(zone.control_owner, "automation");
assert_eq!(zone.control_source, "house_power");
assert!(zone_has_active_thermostat_intent(&zone, None, Utc::now()));
}
}
+54
View File
@@ -385,6 +385,60 @@ async fn control_zones(state: &AppState) -> Result<()> {
}
let active_schedule = active_schedule_for_zone(&zone, &schedules, Local::now());
// Heat/Cool configuration alone is not an instruction to run forever on the implicit
// Comfort profile. After local/global/group OFF is handed back to automation, a zone
// with no active schedule, quick/manual target, temporary session or active scoped
// controller stays physically OFF. This also makes gaps between schedule windows true
// OFF periods instead of silently falling back to Comfort and recreating demand.
if !zone_has_active_thermostat_intent(&zone, active_schedule, Utc::now()) {
clear_compressor_pending(&mut zone, true);
zone.effective_mode = "off".into();
zone.effective_setpoint = None;
zone.device_setpoint = None;
zone.demand = false;
zone.demand_since = None;
zone.target_alerted_at = None;
if zone.control_owner == "automation" {
zone.control_reason = "Automation idle: no active schedule or explicit thermostat request".into();
zone.control_resume_at = None;
}
if device.online && device.communication_failures == 0 && device.power {
match send_zone_command_if_owned(
state,
&zone.id,
&zone.device_id,
DeviceCommand { power: Some(false), ..Default::default() },
).await {
Ok(Some(updated_device)) => {
let transition_at = Utc::now();
if device.power != updated_device.power {
zone.last_power_change_at = Some(transition_at);
}
zone.last_action_at = Some(transition_at);
state.log("info", "zone.automation_idle_off", &format!("Zone {} remains OFF: no active thermostat intent", zone.name), json!({
"zone_id": zone.id,
"device_id": zone.device_id,
"active_schedule": false,
"manual_preset": zone.manual_preset,
"manual_setpoint": zone.manual_setpoint,
"control_source": zone.control_source,
}));
}
Ok(None) => {}
Err(err) => state.log("error", "zone.automation_idle_off_error", &err.to_string(), json!({
"zone_id": zone.id, "device_id": zone.device_id
})),
}
}
record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds);
let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?;
state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?);
continue;
}
let (preset, target) = resolve_zone_target(&zone, active_schedule, effective_mode);
zone.active_preset = preset;
zone.effective_setpoint = Some(target);
+3 -2
View File
@@ -237,11 +237,12 @@ function zoneCard(zone, detailed = true) {
? `${tr('zones.greeTemp')}: ${fmtTemp(zone.device_temperature ?? device?.current_temperature)}`
: `${tr('zones.greeTemp')}: ${fmtTemp(zone.device_temperature)} · ${tr('zones.externalTemp')}: ${fmtTemp(zone.external_temperature)} · ${tr('zones.usedSource')}: ${zoneControlSourceLabel(zone.control_temperature_source)}`;
const roomTemperature = zone.current_temperature ?? zone.device_temperature ?? device?.current_temperature;
const target = Number(zone.effective_setpoint ?? zone.setpoint);
const manual = zone.manual_preset || 'auto';
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 idleWithoutTarget = effectiveMode === 'off' && zone.effective_setpoint == null && zone.manual_setpoint == null && zone.manual_preset == null;
const target = idleWithoutTarget ? Number.NaN : Number(zone.effective_setpoint ?? zone.manual_setpoint ?? zone.setpoint);
const displayPreset = idleWithoutTarget ? 'auto' : (zone.manual_preset || zone.active_preset || 'comfort');
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;