v0.9.4-fix
This commit is contained in:
+13
-13
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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()),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
@@ -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()));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user