v0.9.7
This commit is contained in:
+10
-1
@@ -654,11 +654,18 @@ async fn update_zone_control(State(state): State<AppState>, Path(id): Path<Strin
|
||||
}
|
||||
if let Some(value) = patch.enabled { zone.enabled = value; }
|
||||
let device_override_cleared = if resume_device_automation { engine::reset_device_manual_override(&mut zone) } else { false };
|
||||
let house_mode = state.settings.read().await.house_mode.clone();
|
||||
engine::refresh_zone_runtime_target(&mut zone, &schedules, &house_mode);
|
||||
zone.updated_at = Utc::now();
|
||||
state.db.save_zone(&zone)?;
|
||||
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
|
||||
if was_enabled && !zone.enabled {
|
||||
power_off_zone_device(&state, &zone, "zone.quick_disabled").await;
|
||||
} else {
|
||||
// Do not force users to wait for the fixed zone interval after selecting a profile,
|
||||
// changing the target/mode or re-enabling a thermostat. The regulator still owns the
|
||||
// physical command and therefore keeps all group/master/manual-override safeguards.
|
||||
state.wake_zone_control();
|
||||
}
|
||||
state.log("info", "zone.quick_control", &format!("Quick control updated for {}", zone.name), json!({
|
||||
"zone_id": zone.id, "setpoint": zone.setpoint, "manual_setpoint": zone.manual_setpoint, "mode": zone.mode,
|
||||
@@ -913,6 +920,7 @@ async fn list_home_assistant_groups(State(state): State<AppState>) -> Result<Jso
|
||||
"device_id": zone.device_id,
|
||||
"device_name": zone.device_name,
|
||||
"enabled": zone.enabled,
|
||||
"effective_enabled": zone.effective_enabled,
|
||||
"mode": zone.mode,
|
||||
"configured_mode": zone.configured_mode,
|
||||
"inherit_house_mode": zone.inherit_house_mode,
|
||||
@@ -938,7 +946,7 @@ async fn list_home_assistant_groups(State(state): State<AppState>) -> Result<Jso
|
||||
"house_mode": settings.house_mode,
|
||||
"zone_count": members.len(),
|
||||
"enabled_zones": members.iter().filter(|zone| zone.enabled).count(),
|
||||
"active_zones": planned_members.iter().filter(|zone| zone.enabled).count(),
|
||||
"active_zones": planned_members.iter().filter(|zone| zone.effective_enabled).count(),
|
||||
"demanding_zones": planned_members.iter().filter(|zone| zone.demand).count(),
|
||||
"device_count": member_device_ids.len(),
|
||||
"online_devices": online_devices,
|
||||
@@ -1124,6 +1132,7 @@ async fn update_house_preset(State(state): State<AppState>, Json(input): Json<Ho
|
||||
}
|
||||
|
||||
let failed = command_all_enabled_devices_power(&state, true, "house_preset").await?;
|
||||
state.wake_zone_control();
|
||||
let devices = state.db.list_devices()?;
|
||||
state.log("info", "house.preset", &format!("House preset set to {}", input.preset), json!({
|
||||
"preset": input.preset,
|
||||
|
||||
+53
-7
@@ -47,7 +47,10 @@ pub fn start(state: AppState) {
|
||||
tracing::error!(error=?err, "automation cycle failed");
|
||||
}
|
||||
let seconds = control_state.settings.read().await.zone_interval_seconds.max(2);
|
||||
sleep(Duration::from_secs(seconds)).await;
|
||||
tokio::select! {
|
||||
_ = sleep(Duration::from_secs(seconds)) => {},
|
||||
_ = control_state.zone_control_wakeup.notified() => {},
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -728,6 +731,9 @@ pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControl
|
||||
}
|
||||
}
|
||||
|
||||
if desired_power && should_command_power {
|
||||
state.wake_zone_control();
|
||||
}
|
||||
state.log("info", source, &format!("Updated group {}", group.name), json!({
|
||||
"group_id": group.id, "power_enabled": group.power_enabled, "mode": patch.mode, "preset": patch.preset,
|
||||
"zones": zones.len(), "failed": failed.len(), "master_power_enabled": master_power_enabled,
|
||||
@@ -1023,8 +1029,19 @@ 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 {
|
||||
// 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
|
||||
// deterministic and avoids a standby device target (for example 25 C) masquerading
|
||||
// as the zone's Sleep/Comfort target.
|
||||
let target_mode = if effective_mode == "off" { zone.mode.as_str() } else { effective_mode };
|
||||
let active_schedule = active_schedule_for_zone(&zone, &schedules, Local::now());
|
||||
let (preset, target) = resolve_zone_target(&zone, active_schedule, target_mode);
|
||||
zone.active_preset = preset;
|
||||
zone.effective_setpoint = Some(target);
|
||||
// Keep effective_mode's existing meaning during takeover: it reflects the physical
|
||||
// unit, while effective_setpoint above remains the thermostat intent.
|
||||
zone.effective_mode = if device.power { device.mode.clone() } else { "off".into() };
|
||||
zone.effective_setpoint = if device.power { Some(device.target_temperature) } else { None };
|
||||
zone.device_setpoint = if device.power { Some(device.target_temperature) } else { None };
|
||||
zone.demand = false;
|
||||
zone.demand_since = None;
|
||||
@@ -1529,6 +1546,18 @@ fn resolve_zone_target(zone: &Zone, schedule: Option<&Schedule>, mode: &str) ->
|
||||
(preset, zone.manual_setpoint.unwrap_or(base_target))
|
||||
}
|
||||
|
||||
pub fn refresh_zone_runtime_target(zone: &mut Zone, schedules: &[Schedule], house_mode: &str) {
|
||||
let configured_mode = if zone.inherit_house_mode { house_mode } else { zone.mode.as_str() };
|
||||
zone.effective_mode = configured_mode.to_string();
|
||||
let target_mode = if configured_mode == "off" { zone.mode.as_str() } else { configured_mode };
|
||||
let schedule = active_schedule_for_zone(zone, schedules, Local::now());
|
||||
let (preset, target) = resolve_zone_target(zone, schedule, target_mode);
|
||||
zone.active_preset = preset;
|
||||
if !zone.device_manual_override {
|
||||
zone.effective_setpoint = Some(target);
|
||||
}
|
||||
}
|
||||
|
||||
fn active_schedule_for_zone<'a>(zone: &Zone, schedules: &'a [Schedule], now: DateTime<Local>) -> Option<&'a Schedule> {
|
||||
schedules.iter()
|
||||
.filter(|item| item.enabled && item.zone_id == zone.id && schedule_active(item, now))
|
||||
@@ -1677,20 +1706,23 @@ pub async fn build_control_plan(state: &AppState) -> Result<ControlPlan, AppErro
|
||||
event.label = format!("{}: {}", zone.name, event.label);
|
||||
house_events.push(event);
|
||||
}
|
||||
let effective_enabled = zone.enabled && (!blocked_by_group || zone.device_manual_override);
|
||||
zones_out.push(ZoneControlPlan {
|
||||
zone_id: zone.id.clone(),
|
||||
zone_name: zone.name.clone(),
|
||||
device_id: zone.device_id.clone(),
|
||||
device_name: device.map(|item| item.name.clone()).unwrap_or_else(|| zone.device_id.clone()),
|
||||
enabled: zone.enabled && (!blocked_by_group || zone.device_manual_override),
|
||||
enabled: zone.enabled,
|
||||
effective_enabled,
|
||||
mode: effective_mode.to_string(),
|
||||
configured_mode: zone.mode.clone(),
|
||||
inherit_house_mode: zone.inherit_house_mode,
|
||||
preset: if zone.active_preset.is_empty() { resolved_preset } else { zone.active_preset.clone() },
|
||||
preset: resolved_preset,
|
||||
preset_override: zone.manual_preset.clone(),
|
||||
current_temperature: zone.current_temperature,
|
||||
target_temperature: if zone.device_manual_override {
|
||||
device.filter(|item| item.power).map(|item| item.target_temperature).or(Some(resolved_target))
|
||||
} else if !zone.enabled || effective_mode == "off" {
|
||||
target_temperature: 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.
|
||||
Some(resolved_target)
|
||||
} else {
|
||||
zone.effective_setpoint.or(Some(resolved_target))
|
||||
@@ -2231,6 +2263,20 @@ mod tests {
|
||||
assert_eq!(target, 22.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_target_refresh_applies_manual_profile_immediately() {
|
||||
let mut zone = test_zone("device");
|
||||
zone.inherit_house_mode = false;
|
||||
zone.mode = "cool".into();
|
||||
zone.manual_preset = Some("sleep".into());
|
||||
zone.active_preset = "comfort".into();
|
||||
zone.effective_setpoint = Some(25.0);
|
||||
refresh_zone_runtime_target(&mut zone, &[], "cool");
|
||||
assert_eq!(zone.active_preset, "sleep");
|
||||
assert_eq!(zone.effective_setpoint, Some(24.5));
|
||||
assert_eq!(zone.effective_mode, "cool");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_zone_keeps_old_comfort_setpoint() {
|
||||
let mut zone = test_zone("device");
|
||||
|
||||
+2
-1
@@ -18,7 +18,7 @@ use db::Db;
|
||||
use models::Device;
|
||||
use protocol::GreeClient;
|
||||
use state::AppState;
|
||||
use tokio::{net::TcpListener, signal, sync::{broadcast, RwLock}};
|
||||
use tokio::{net::TcpListener, signal, sync::{broadcast, Notify, RwLock}};
|
||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||
|
||||
#[tokio::main]
|
||||
@@ -67,6 +67,7 @@ async fn main() -> Result<()> {
|
||||
outdoor_temperature: Arc::new(RwLock::new(None)),
|
||||
debug_gree_frames,
|
||||
initial_device_sync_complete: Arc::new(AtomicBool::new(false)),
|
||||
zone_control_wakeup: Arc::new(Notify::new()),
|
||||
device_operation_locks: Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())),
|
||||
started: Instant::now(),
|
||||
};
|
||||
|
||||
@@ -708,13 +708,19 @@ pub struct ZoneControlPlan {
|
||||
pub zone_name: String,
|
||||
pub device_id: String,
|
||||
pub device_name: String,
|
||||
/// Configured per-zone enable switch. Group/master gates are reported separately.
|
||||
pub enabled: bool,
|
||||
/// True when the configured zone is not currently blocked by a disabled climate group.
|
||||
pub effective_enabled: bool,
|
||||
/// Effective mode currently used by the controller.
|
||||
pub mode: String,
|
||||
/// Configured zone mode before house-mode inheritance is resolved.
|
||||
pub configured_mode: String,
|
||||
pub inherit_house_mode: bool,
|
||||
/// Profile resolved from a manual override or the active schedule.
|
||||
pub preset: String,
|
||||
/// Explicit per-zone profile override; None means Auto schedule.
|
||||
pub preset_override: Option<String>,
|
||||
pub current_temperature: Option<f64>,
|
||||
pub target_temperature: Option<f64>,
|
||||
pub device_setpoint: Option<f64>,
|
||||
|
||||
+7
-1
@@ -1,7 +1,7 @@
|
||||
use std::{collections::HashMap, sync::{Arc, atomic::AtomicBool}, time::Instant};
|
||||
use chrono::Utc;
|
||||
use serde_json::Value;
|
||||
use tokio::sync::{broadcast, Mutex, OwnedMutexGuard, RwLock};
|
||||
use tokio::sync::{broadcast, Mutex, Notify, OwnedMutexGuard, RwLock};
|
||||
use crate::{config::Config, db::Db, models::{ApiEvent, RuntimeSettings}, protocol::GreeClient};
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -17,6 +17,8 @@ pub struct AppState {
|
||||
/// Thermostat/automation control stays passive until every enabled device has had
|
||||
/// one startup poll, preventing stale persisted device state from causing restart commands.
|
||||
pub initial_device_sync_complete: Arc<AtomicBool>,
|
||||
/// Explicit thermostat changes wake the regulator instead of waiting for the next fixed interval.
|
||||
pub zone_control_wakeup: Arc<Notify>,
|
||||
pub(crate) device_operation_locks: Arc<Mutex<HashMap<String, Arc<Mutex<()>>>>>,
|
||||
pub started: Instant,
|
||||
}
|
||||
@@ -30,6 +32,10 @@ impl AppState {
|
||||
lock.lock_owned().await
|
||||
}
|
||||
|
||||
pub fn wake_zone_control(&self) {
|
||||
self.zone_control_wakeup.notify_one();
|
||||
}
|
||||
|
||||
pub fn broadcast(&self, event: impl Into<String>, data: Value) {
|
||||
let _ = self.events.send(ApiEvent {
|
||||
event: event.into(),
|
||||
|
||||
Reference in New Issue
Block a user