v0.9.0
This commit is contained in:
+92
-16
@@ -1,3 +1,35 @@
|
||||
fn compressor_action_id(kind: &str, mode: &str, target: f64) -> String {
|
||||
format!("{kind}:{mode}:{target:.1}")
|
||||
}
|
||||
|
||||
pub(crate) fn clear_compressor_pending(zone: &mut Zone, clear_cancelled: bool) {
|
||||
zone.lockout_until = None;
|
||||
zone.lockout_reason = None;
|
||||
zone.compressor_pending_action = None;
|
||||
zone.compressor_pending_since = None;
|
||||
zone.compressor_pending_until = None;
|
||||
if clear_cancelled { zone.compressor_cancelled_action = None; }
|
||||
}
|
||||
|
||||
fn queue_compressor_action(zone: &mut Zone, action: String, until: DateTime<Utc>, reason: &str) {
|
||||
let now = Utc::now();
|
||||
if zone.compressor_pending_action.as_deref() != Some(action.as_str()) {
|
||||
zone.compressor_pending_since = Some(now);
|
||||
}
|
||||
zone.compressor_pending_action = Some(action);
|
||||
zone.compressor_pending_until = Some(until.clone());
|
||||
zone.lockout_until = Some(until);
|
||||
zone.lockout_reason = Some(reason.to_string());
|
||||
}
|
||||
|
||||
fn compressor_action_is_cancelled(zone: &Zone, action: &str) -> bool {
|
||||
zone.compressor_cancelled_action.as_deref() == Some(action)
|
||||
}
|
||||
|
||||
pub(crate) fn rearm_compressor_queue(zone: &mut Zone) {
|
||||
clear_compressor_pending(zone, true);
|
||||
}
|
||||
|
||||
async fn control_zones(state: &AppState) -> Result<()> {
|
||||
let _cycle_guard = state.lock_zone_control_cycle().await;
|
||||
let schedules = state.db.list_schedules()?;
|
||||
@@ -173,6 +205,7 @@ async fn control_zones(state: &AppState) -> Result<()> {
|
||||
if temporary_restored_disabled.iter().any(|zone_id| zone_id == &zone.id) {
|
||||
ensure_device_off_after_temporary_disabled_restore(state, &zone, &device).await;
|
||||
}
|
||||
clear_compressor_pending(&mut zone, true);
|
||||
zone.demand = false;
|
||||
zone.demand_since = None;
|
||||
record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds);
|
||||
@@ -184,6 +217,7 @@ async fn control_zones(state: &AppState) -> Result<()> {
|
||||
// A technically disabled device is outside thermostat ownership. Do not create
|
||||
// repeated command errors while keeping any available external sensor data visible.
|
||||
if !device.enabled {
|
||||
clear_compressor_pending(&mut zone, true);
|
||||
zone.demand = false;
|
||||
zone.demand_since = None;
|
||||
zone.device_setpoint = None;
|
||||
@@ -197,6 +231,9 @@ async fn control_zones(state: &AppState) -> Result<()> {
|
||||
// automation control. Continue sensor/history updates, but reflect the unit's real state
|
||||
// instead of sending corrective frames that would fight the person holding the remote.
|
||||
if zone.device_manual_override {
|
||||
// Direct/manual ownership and the thermostat compressor queue are mutually
|
||||
// exclusive. Clean any stale persisted task before remaining passive.
|
||||
clear_compressor_pending(&mut zone, true);
|
||||
// Manual/remote takeover pauses commands, but it must not erase the thermostat's
|
||||
// selected profile/target. Keep the intended target visible and report the physical
|
||||
// unit target separately through device_setpoint. This makes Resume/Profile actions
|
||||
@@ -419,30 +456,44 @@ async fn control_zones(state: &AppState) -> Result<()> {
|
||||
device.sleep,
|
||||
);
|
||||
|
||||
// Compressor protection for automatic ownership. Direct/manual commands and global safety OFF
|
||||
// deliberately bypass this path, while the thermostat never performs an immediate Heat<->Cool swap.
|
||||
// Global compressor protection. Automatic thermostat/group/house requests are not
|
||||
// discarded while the protection window is active: they become a visible pending
|
||||
// task which can be cancelled from the thermostat UI. Safety OFF paths still bypass
|
||||
// protection. A cancelled task is not silently re-created until a new control intent
|
||||
// re-arms the queue (or a different mode/target produces a different task id).
|
||||
let now = Utc::now();
|
||||
if zone.lockout_until.map(|until| until <= now).unwrap_or(false) {
|
||||
if !settings.compressor_protection_enabled {
|
||||
clear_compressor_pending(&mut zone, true);
|
||||
} else if zone.lockout_until.map(|until| until <= now).unwrap_or(false) {
|
||||
zone.lockout_until = None;
|
||||
zone.lockout_reason = None;
|
||||
zone.compressor_pending_until = None;
|
||||
}
|
||||
if device.power && device.mode != effective_mode {
|
||||
let min_on = chrono::Duration::seconds(zone.min_on_seconds as i64);
|
||||
if zone.last_power_change_at.map(|at| now.signed_duration_since(at) < min_on).unwrap_or(false) {
|
||||
let until = zone.last_power_change_at.map(|at| at + min_on);
|
||||
zone.lockout_until = until;
|
||||
zone.lockout_reason = Some("minimum_on_before_mode_change".into());
|
||||
let protection = chrono::Duration::seconds(settings.compressor_protection_seconds as i64);
|
||||
|
||||
if settings.compressor_protection_enabled && device.power && device.mode != effective_mode {
|
||||
let action = compressor_action_id("mode_change", effective_mode, desired_device_target);
|
||||
if compressor_action_is_cancelled(&zone, &action) {
|
||||
clear_compressor_pending(&mut zone, false);
|
||||
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;
|
||||
}
|
||||
if let Some(last_change) = zone.last_power_change_at {
|
||||
if now.signed_duration_since(last_change) < protection {
|
||||
queue_compressor_action(&mut zone, action, last_change + protection, "minimum_on_before_mode_change");
|
||||
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;
|
||||
}
|
||||
}
|
||||
match send_zone_command_if_owned(state, &zone.id, &zone.device_id, DeviceCommand { power: Some(false), ..Default::default() }).await {
|
||||
Ok(Some(_)) => {
|
||||
zone.last_power_change_at = Some(now);
|
||||
zone.lockout_until = Some(now + chrono::Duration::seconds(zone.min_off_seconds as i64));
|
||||
zone.lockout_reason = Some("mode_change_off_delay".into());
|
||||
state.log("info", "zone.mode_change_lockout", &format!("Zone {} switched off before {} mode", zone.name, effective_mode), json!({"zone_id": zone.id, "resume_at": zone.lockout_until}));
|
||||
queue_compressor_action(&mut zone, action, now + protection, "mode_change_off_delay");
|
||||
state.log("info", "zone.mode_change_lockout", &format!("Zone {} switched off before {} mode", zone.name, effective_mode), json!({"zone_id": zone.id, "resume_at": zone.lockout_until, "compressor_protection_enabled": settings.compressor_protection_enabled}));
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(err) => state.log("error", "zone.mode_change_off_error", &err.to_string(), json!({"zone_id": zone.id})),
|
||||
@@ -453,18 +504,42 @@ async fn control_zones(state: &AppState) -> Result<()> {
|
||||
continue;
|
||||
}
|
||||
if !device.power {
|
||||
let min_off = chrono::Duration::seconds(zone.min_off_seconds as i64);
|
||||
if zone.last_power_change_at.map(|at| now.signed_duration_since(at) < min_off).unwrap_or(false) {
|
||||
zone.lockout_until = zone.last_power_change_at.map(|at| at + min_off);
|
||||
zone.lockout_reason = Some("minimum_off_before_start".into());
|
||||
let action = zone.compressor_pending_action.clone()
|
||||
.filter(|value| value.starts_with("mode_change:"))
|
||||
.unwrap_or_else(|| compressor_action_id("power_on", effective_mode, desired_device_target));
|
||||
if compressor_action_is_cancelled(&zone, &action) {
|
||||
clear_compressor_pending(&mut zone, false);
|
||||
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;
|
||||
}
|
||||
if settings.compressor_protection_enabled {
|
||||
if let Some(last_change) = zone.last_power_change_at {
|
||||
if now.signed_duration_since(last_change) < protection {
|
||||
queue_compressor_action(&mut zone, action, last_change + protection, "minimum_off_before_start");
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
// The safe window is open. Remove the pending marker before attempting the
|
||||
// command; a transport error will be retried by the normal thermostat cycle.
|
||||
zone.compressor_pending_action = None;
|
||||
zone.compressor_pending_since = None;
|
||||
zone.compressor_pending_until = None;
|
||||
zone.lockout_until = None;
|
||||
zone.lockout_reason = None;
|
||||
} else {
|
||||
// Reaching the intended powered/mode state retires both pending and cancelled
|
||||
// markers so a future independent request starts with a clean queue.
|
||||
clear_compressor_pending(&mut zone, true);
|
||||
}
|
||||
|
||||
let core_needs_command = !device.power
|
||||
|| device.mode != effective_mode
|
||||
|| (device.target_temperature - desired_device_target).abs() >= 0.5;
|
||||
// In normal standby, Low fan is a transition hint rather than a state that should
|
||||
// be reasserted forever. Some GREE firmwares accept the frame but later report Auto
|
||||
@@ -496,6 +571,7 @@ async fn control_zones(state: &AppState) -> Result<()> {
|
||||
if device.mode != updated_device.mode { zone.last_mode_change_at = Some(transition_at); }
|
||||
zone.device_setpoint = if updated_device.power { Some(updated_device.target_temperature) } else { None };
|
||||
zone.last_action_at = Some(transition_at);
|
||||
clear_compressor_pending(&mut zone, true);
|
||||
state.log("info", "zone.setpoint_modulation", &format!("Zone {} -> {:.1} C ({})", zone.name, desired_device_target, if zone.demand { "demand" } else { "standby" }), json!({
|
||||
"zone_id": zone.id,
|
||||
"room_temperature": temp,
|
||||
|
||||
Reference in New Issue
Block a user