diff --git a/FILE_MANIFEST.sha256 b/FILE_MANIFEST.sha256 index 794f3bd..a539e9e 100644 --- a/FILE_MANIFEST.sha256 +++ b/FILE_MANIFEST.sha256 @@ -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 diff --git a/README.md b/README.md index 3955dfe..86fd3b7 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/API.md b/docs/API.md index bde022b..b9a5ce2 100644 --- a/docs/API.md +++ b/docs/API.md @@ -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: diff --git a/lang/en.json b/lang/en.json index 012701f..36284d2 100644 --- a/lang/en.json +++ b/lang/en.json @@ -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", diff --git a/lang/pl.json b/lang/pl.json index 2d0634d..9fd7c9b 100644 --- a/lang/pl.json +++ b/lang/pl.json @@ -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", diff --git a/src/api/house.rs b/src/api/house.rs index b8de167..9dd3704 100644 --- a/src/api/house.rs +++ b/src/api/house.rs @@ -115,8 +115,9 @@ async fn update_house_power(State(state): State, Json(input): Json, Json(input): Json Result Result Result 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()), diff --git a/src/engine/local_thermostat.rs b/src/engine/local_thermostat.rs index 38c8301..df9b966 100644 --- a/src/engine/local_thermostat.rs +++ b/src/engine/local_thermostat.rs @@ -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() diff --git a/src/engine/ownership.rs b/src/engine/ownership.rs index b6beb5f..1c9dba5 100644 --- a/src/engine/ownership.rs +++ b/src/engine/ownership.rs @@ -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 { diff --git a/src/engine/targets.rs b/src/engine/targets.rs index 5e80715..b93ff80 100644 --- a/src/engine/targets.rs +++ b/src/engine/targets.rs @@ -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) -> 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) -> 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 { diff --git a/src/engine/tests.rs b/src/engine/tests.rs index 8e57701..65944df 100644 --- a/src/engine/tests.rs +++ b/src/engine/tests.rs @@ -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())); } } diff --git a/src/engine/zone_control.rs b/src/engine/zone_control.rs index ca67cc4..0680496 100644 --- a/src/engine/zone_control.rs +++ b/src/engine/zone_control.rs @@ -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); diff --git a/web/js/entities.js b/web/js/entities.js index 9378598..bfa8c43 100644 --- a/web/js/entities.js +++ b/web/js/entities.js @@ -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;