This commit is contained in:
Mateusz Gruszczyński
2026-08-25 09:31:15 +02:00
parent 25e1a854e3
commit fd26d6877f
25 changed files with 460 additions and 99 deletions
+56
View File
@@ -52,6 +52,7 @@ pub fn router(state: AppState) -> Router {
.route("/api/zones/:id/control", post(update_zone_control))
.route("/api/zones/:id/schedule-template", post(apply_schedule_template))
.route("/api/house/control", post(update_house_control))
.route("/api/house/power", post(update_house_power))
.route("/api/house/preset", post(update_house_preset))
.route("/api/schedules", get(list_schedules).post(create_schedule))
.route("/api/schedules/:id", get(get_schedule).put(update_schedule).delete(delete_schedule))
@@ -76,6 +77,9 @@ pub fn router(state: AppState) -> Router {
.route("/api/integrations/home-assistant/devices", get(list_devices))
.route("/api/integrations/home-assistant/devices/:id/command", post(command_device))
.route("/api/integrations/home-assistant/control-plan", get(control_plan))
.route("/api/integrations/home-assistant/house/control", post(update_house_control))
.route("/api/integrations/home-assistant/house/preset", post(update_house_preset))
.route("/api/integrations/home-assistant/house/power", post(update_house_power))
.route("/api/integrations/home-assistant/zones/:id/control", post(update_zone_control))
.route_layer(middleware::from_fn_with_state(state.clone(), home_assistant_auth));
@@ -318,6 +322,7 @@ async fn add_device(State(state): State<AppState>, Json(input): Json<ManualDevic
outdoor_temperature: None,
temperature_sensor_offset: None,
online: input.simulated,
response_time_ms: if input.simulated { Some(0) } else { None },
last_seen: if input.simulated { Some(now) } else { None },
last_error: None,
communication_failures: 0,
@@ -602,6 +607,56 @@ async fn update_house_control(State(state): State<AppState>, Json(input): Json<H
Ok(Json(payload))
}
#[derive(Debug, Deserialize)]
struct HousePowerPatch { power: bool }
async fn update_house_power(State(state): State<AppState>, Json(input): Json<HousePowerPatch>) -> Result<Json<Value>, AppError> {
// Whole-house power is independent from the thermostat mode. Turning it off is
// authoritative, while house mode `off` remains a separate "do not control" state.
{
let mut settings = state.settings.write().await;
if settings.house_power_enabled != input.power {
settings.house_power_enabled = input.power;
state.db.save_runtime_settings(&settings)?;
let payload = public_settings(&settings);
state.broadcast("settings.updated", payload);
}
}
let mut failed = Vec::new();
for device in state.db.list_devices()? {
if !device.enabled || device.power == input.power { continue; }
let command = DeviceCommand { power: Some(input.power), ..Default::default() };
if let Err(err) = engine::send_command(&state, &device.id, command).await {
state.log("error", "house.power_all_error", &err.to_string(), json!({
"device_id": device.id,
"device_name": device.name,
"power": input.power,
}));
failed.push(json!({
"device_id": device.id,
"device_name": device.name,
"error": err.to_string(),
}));
}
}
let devices = state.db.list_devices()?;
let settings = state.settings.read().await;
let settings_payload = public_settings(&settings);
drop(settings);
state.log("info", "house.power_all", if input.power { "Whole-house power enabled; all enabled devices powered on" } else { "Whole-house power disabled; all enabled devices powered off" }, json!({
"power": input.power,
"failed": failed.len(),
}));
Ok(Json(json!({
"power": input.power,
"devices": devices,
"settings": settings_payload,
"failed": failed,
})))
}
#[derive(Debug, Deserialize)]
struct HousePresetPatch { preset: String }
@@ -1379,6 +1434,7 @@ fn public_settings(settings: &RuntimeSettings) -> Value {
"discovery_timeout_ms": settings.discovery_timeout_ms,
"discovery_broadcast": settings.discovery_broadcast,
"house_mode": settings.house_mode,
"house_power_enabled": settings.house_power_enabled,
"control_strategy": settings.control_strategy,
"outdoor_assist_enabled": settings.outdoor_assist_enabled,
"history_retention_days": settings.history_retention_days,
+1
View File
@@ -53,6 +53,7 @@ impl Config {
discovery_timeout_ms: self.discovery_timeout_ms.clamp(300, 30_000),
discovery_broadcast: self.discovery_broadcast.clone(),
house_mode: env::var("GREE_CONTROLLER_HOUSE_MODE").unwrap_or_else(|_| "cool".into()),
house_power_enabled: true,
control_strategy: "setpoint".into(),
outdoor_assist_enabled: env_bool("GREE_CONTROLLER_OUTDOOR_ASSIST_ENABLED").unwrap_or(true),
history_retention_days: env_u32("GREE_CONTROLLER_HISTORY_RETENTION_DAYS").unwrap_or(30).clamp(1, 3650),
+53 -21
View File
@@ -1,4 +1,4 @@
use std::time::Duration;
use std::time::{Duration, Instant};
use anyhow::Result;
use chrono::{DateTime, Datelike, Local, NaiveTime, Timelike, Utc, Weekday};
use serde_json::json;
@@ -107,11 +107,13 @@ pub async fn send_command(state: &AppState, device_id: &str, command: DeviceComm
let command = if device.online { command.changed_from(&device) } else { command };
if command.is_empty() { return Ok(device); }
let suppress_beep = state.settings.read().await.suppress_device_beep;
let response_started = Instant::now();
let mut applied_command = command.clone();
if device.simulated {
applied_command.apply(&mut device);
device.online = true;
device.response_time_ms = Some(0);
device.last_seen = Some(Utc::now());
device.last_error = None;
state.db.save_device(&device)?;
@@ -159,6 +161,7 @@ pub async fn send_command(state: &AppState, device_id: &str, command: DeviceComm
applied_command.apply(&mut device);
device.online = true;
device.communication_failures = 0;
device.response_time_ms = Some(response_started.elapsed().as_millis().min(u64::MAX as u128) as u64);
device.last_seen = Some(Utc::now());
device.last_error = None;
state.db.save_device(&device)?;
@@ -199,6 +202,7 @@ async fn poll_device(state: &AppState, device: &mut Device) {
return;
}
let previous_failures = device.communication_failures;
let response_started = Instant::now();
if device.key.as_deref().unwrap_or_default().is_empty() {
match state.gree.bind(device).await {
Ok(bound) => {
@@ -227,6 +231,9 @@ async fn poll_device(state: &AppState, device: &mut Device) {
Err(_) => record_poll_failure(device, &first_err.to_string()),
}
}
if device.communication_failures == 0 && device.online {
device.response_time_ms = Some(response_started.elapsed().as_millis().min(u64::MAX as u128) as u64);
}
log_poll_health_transition(state, device, previous_failures).await;
}
@@ -268,6 +275,7 @@ fn simulate_tick(device: &mut Device) {
// Correct rounding for outdoor temperature without accumulating precision noise.
device.outdoor_temperature = device.outdoor_temperature.map(|v| (v * 10.0).round() / 10.0);
device.online = true;
device.response_time_ms = Some(0);
device.last_seen = Some(Utc::now());
device.last_error = None;
device.updated_at = Utc::now();
@@ -369,6 +377,16 @@ async fn control_zones(state: &AppState) -> Result<()> {
let outdoor_assist_temperature = if settings.outdoor_assist_enabled { outdoor_temperature } else { None };
let night_active = night_mode_active(&settings.night_mode, Local::now().time());
if !settings.house_power_enabled {
for device in &device_snapshot {
if !device.enabled || !device.power { continue; }
if let Err(err) = send_command(state, &device.id, DeviceCommand { power: Some(false), ..Default::default() }).await {
state.log("error", "house.master_power_error", &err.to_string(), json!({"device_id": device.id}));
}
}
return Ok(());
}
for mut zone in state.db.list_zones()? {
if zone.manual_override_until.map(|until| until <= Utc::now()).unwrap_or(false) {
zone.manual_preset = None;
@@ -381,9 +399,9 @@ async fn control_zones(state: &AppState) -> Result<()> {
continue;
};
let effective_mode = if settings.house_mode == "off" {
"off"
} else if zone.inherit_house_mode {
// House "off" means the smart thermostat does not control inherited zones.
// A zone explicitly switched to heat/cool remains independent and may still run.
let effective_mode = if zone.inherit_house_mode {
settings.house_mode.as_str()
} else {
zone.mode.as_str()
@@ -444,18 +462,15 @@ async fn control_zones(state: &AppState) -> Result<()> {
continue;
}
// Global Off is the only normal path that intentionally powers units down.
// House "off" is a no-control state, not a power-off command. Keep polling and
// publishing the zone, but never overwrite manual device state while it follows
// the house mode. Explicit per-zone heat/cool bypasses this branch above.
if effective_mode == "off" {
zone.active_preset = "off".into();
zone.effective_setpoint = None;
zone.device_setpoint = None;
zone.demand = false;
if device.power {
match send_command(state, &zone.device_id, DeviceCommand { power: Some(false), ..Default::default() }).await {
Ok(_) => zone.last_action_at = Some(Utc::now()),
Err(err) => state.log("error", "zone.action_error", &err.to_string(), json!({"zone_id": zone.id})),
}
}
zone.demand_since = None;
zone.target_alerted_at = None;
record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds);
state.db.save_zone(&zone)?;
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
@@ -603,6 +618,7 @@ async fn control_zones(state: &AppState) -> Result<()> {
state.db.save_zone(&zone)?;
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
}
Ok(())
}
@@ -915,28 +931,41 @@ pub async fn build_control_plan(state: &AppState) -> Result<ControlPlan, AppErro
let settings = state.settings.read().await.clone();
let schedules = state.db.list_schedules()?;
let devices = state.db.list_devices()?;
let zones = state.db.list_zones()?;
let house_preset = zones.first().and_then(|first| {
let first_preset = first.manual_preset.as_deref().unwrap_or("auto");
zones.iter().all(|zone| zone.manual_preset.as_deref().unwrap_or("auto") == first_preset)
.then(|| first_preset.to_string())
});
let house_power = settings.house_power_enabled;
let now = Local::now();
let night_active = night_mode_active(&settings.night_mode, now.time());
let mut zones_out = Vec::new();
let mut house_events = next_night_mode_events(&settings.night_mode, now, 2);
for zone in state.db.list_zones()? {
for zone in zones {
let device = devices.iter().find(|item| item.id == zone.device_id);
let effective_mode = if settings.house_mode == "off" {
"off"
} else if zone.inherit_house_mode {
let effective_mode = if zone.inherit_house_mode {
settings.house_mode.as_str()
} else {
zone.mode.as_str()
};
let active = active_schedule_for_zone(&zone, &schedules, now);
let active = if effective_mode == "off" {
None
} else {
active_schedule_for_zone(&zone, &schedules, now)
};
let (preset, target) = if effective_mode == "off" {
("off".to_string(), None)
("manual".to_string(), None)
} else {
let (preset, target) = resolve_zone_target(&zone, active, effective_mode);
(preset, Some(target))
};
let next_events = next_schedule_events(&zone, &schedules, effective_mode, now, 8);
let next_events = if effective_mode == "off" {
Vec::new()
} else {
next_schedule_events(&zone, &schedules, effective_mode, now, 8)
};
for event in next_events.iter().take(2) {
let mut event = event.clone();
event.label = format!("{}: {}", zone.name, event.label);
@@ -951,11 +980,11 @@ 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: if effective_mode == "off" { "off".into() } else if zone.active_preset.is_empty() { preset } else { zone.active_preset.clone() },
preset: if effective_mode == "off" { "manual".into() } else if zone.active_preset.is_empty() { preset } else { zone.active_preset.clone() },
current_temperature: zone.current_temperature,
target_temperature: if effective_mode == "off" { None } else { zone.effective_setpoint.or(target) },
device_setpoint: zone.device_setpoint.or_else(|| device.map(|item| item.target_temperature)),
demand: zone.enabled && effective_mode != "off" && zone.demand,
demand: settings.house_power_enabled && zone.enabled && effective_mode != "off" && zone.demand,
control_source: zone.control_temperature_source.clone(),
manual_override_until: zone.manual_override_until,
current_schedule_id: active.map(|item| item.id.clone()),
@@ -996,6 +1025,8 @@ pub async fn build_control_plan(state: &AppState) -> Result<ControlPlan, AppErro
Ok(ControlPlan {
generated_at: Utc::now(),
house_mode: settings.house_mode,
house_preset,
house_power,
outdoor_temperature: *state.outdoor_temperature.read().await,
control_strategy: settings.control_strategy,
night_mode_active: night_active,
@@ -1084,6 +1115,7 @@ fn next_schedule_events(zone: &Zone, schedules: &[Schedule], mode: &str, now: Da
}
async fn run_automations(state: &AppState) -> Result<()> {
if !state.settings.read().await.house_power_enabled { return Ok(()); }
let devices = state.db.list_devices()?;
for mut item in state.db.list_automations()? {
if !item.enabled || !automation_ready(&item) { continue; }
+12 -1
View File
@@ -108,6 +108,9 @@ pub struct Device {
pub temperature_sensor_offset: Option<bool>,
#[serde(default)]
pub online: bool,
/// Round-trip time of the latest successful controller communication.
#[serde(default)]
pub response_time_ms: Option<u64>,
#[serde(default)]
pub last_seen: Option<DateTime<Utc>>,
#[serde(default)]
@@ -158,6 +161,7 @@ impl Device {
outdoor_temperature: Some(30.0),
temperature_sensor_offset: Some(false),
online: true,
response_time_ms: Some(0),
last_seen: Some(now),
last_error: None,
communication_failures: 0,
@@ -667,6 +671,10 @@ pub struct AutomationPlanRule {
pub struct ControlPlan {
pub generated_at: DateTime<Utc>,
pub house_mode: String,
/// Uniform whole-house preset when every zone uses the same override; None for a mixed state.
pub house_preset: Option<String>,
/// Whole-house master power state.
pub house_power: bool,
pub outdoor_temperature: Option<f64>,
pub control_strategy: String,
pub night_mode_active: bool,
@@ -686,9 +694,12 @@ pub struct RuntimeSettings {
pub zone_interval_seconds: u64,
pub discovery_timeout_ms: u64,
pub discovery_broadcast: String,
/// Global seasonal mode. Zones follow this by default. Values: cool/heat/off.
/// Global seasonal mode. Zones follow this by default. Values: cool/heat/off; off pauses house-level thermostat control.
#[serde(default = "default_house_mode")]
pub house_mode: String,
/// Whole-house master power. False is authoritative and suppresses zone/automation restarts.
#[serde(default = "default_true")]
pub house_power_enabled: bool,
/// `setpoint` keeps units powered and modulates compressor demand by changing target temperature.
#[serde(default = "default_control_strategy")]
pub control_strategy: String,
+1
View File
@@ -269,6 +269,7 @@ impl GreeClient {
outdoor_temperature: None,
temperature_sensor_offset: None,
online: true,
response_time_ms: None,
last_seen: Some(now),
last_error: None,
communication_failures: 0,