This commit is contained in:
Mateusz Gruszczyński
2026-08-24 16:12:31 +02:00
parent 83f744e2cb
commit 66a5d6e5e9
22 changed files with 686 additions and 103 deletions
+85 -15
View File
@@ -154,6 +154,8 @@ pub async fn send_command(state: &AppState, device_id: &str, command: DeviceComm
}
}
}
if command.quiet.is_some() && applied_command.quiet.is_none() { device.supports_quiet = Some(false); }
if command.sleep.is_some() && applied_command.sleep.is_none() { device.supports_sleep = Some(false); }
applied_command.apply(&mut device);
device.online = true;
device.communication_failures = 0;
@@ -309,18 +311,22 @@ async fn control_zones(state: &AppState) -> Result<()> {
let schedules = state.db.list_schedules()?;
let settings = state.settings.read().await.clone();
// Outdoor temperature is deliberately optional. It never replaces the room sensor;
// it only makes the active setpoint/fan a little more assertive in extreme weather.
let outdoor_temperature = if !settings.home_assistant.outdoor_entity_id.trim().is_empty() {
match home_assistant::read_temperature(
&state.http,
&settings.home_assistant,
Some(settings.home_assistant.outdoor_entity_id.trim()),
).await {
// Outdoor temperature is deliberately optional. Prefer the configured Home
// Assistant entity, but keep the dashboard/assist useful by falling back to the
// outdoor sensors reported by GREE units when HA is temporarily unavailable.
let device_snapshot = state.db.list_devices()?;
let configured_outdoor = settings.home_assistant.outdoor_entity_id.trim();
let resolved_outdoor = if configured_outdoor.is_empty() {
None
} else {
home_assistant::resolve_entity_id(&settings.home_assistant, Some(configured_outdoor))
};
let ha_outdoor_temperature = if let Some(entity_id) = resolved_outdoor.as_deref() {
match home_assistant::read_temperature(&state.http, &settings.home_assistant, Some(entity_id)).await {
Ok(value) => {
record_ha_history(
state,
settings.home_assistant.outdoor_entity_id.trim(),
entity_id,
None,
"outdoor",
value,
@@ -329,13 +335,14 @@ async fn control_zones(state: &AppState) -> Result<()> {
Some(value)
}
Err(err) => {
tracing::debug!(error=?err, "outdoor Home Assistant sensor unavailable");
tracing::debug!(configured_entity=%configured_outdoor, resolved_entity=%entity_id, error=?err, "outdoor Home Assistant sensor unavailable; trying GREE fallback");
None
}
}
} else {
None
};
let outdoor_temperature = ha_outdoor_temperature.or_else(|| gree_outdoor_temperature(&device_snapshot));
{
let mut current = state.outdoor_temperature.write().await;
if *current != outdoor_temperature {
@@ -372,16 +379,21 @@ async fn control_zones(state: &AppState) -> Result<()> {
let previous_source = zone.control_temperature_source.clone();
let device_temperature = device.current_temperature;
let external_temperature = if matches!(zone.sensor_source.as_str(), "home_assistant" | "combined") {
match home_assistant::read_temperature(&state.http, &settings.home_assistant, zone.ha_entity_id.as_deref()).await {
let resolved_entity = home_assistant::resolve_entity_id(&settings.home_assistant, zone.ha_entity_id.as_deref());
match home_assistant::read_temperature(&state.http, &settings.home_assistant, resolved_entity.as_deref()).await {
Ok(value) => {
if let Some(entity_id) = zone.ha_entity_id.as_deref().filter(|value| !value.trim().is_empty()) {
if let Some(entity_id) = resolved_entity.as_deref() {
record_ha_history(state, entity_id, Some(&zone.id), "room", value, settings.poll_interval_seconds);
}
Some(value)
}
Err(err) => {
if !matches!(previous_source.as_str(), "device_fallback" | "device_discrepancy_fallback") {
state.log("warn", "ha.sensor_error", &err.to_string(), json!({"zone_id": zone.id, "entity_id": zone.ha_entity_id.as_deref()}));
state.log("warn", "ha.sensor_error", &err.to_string(), json!({
"zone_id": zone.id,
"configured_entity_id": zone.ha_entity_id.as_deref(),
"resolved_entity_id": resolved_entity,
}));
}
None
}
@@ -497,12 +509,20 @@ async fn control_zones(state: &AppState) -> Result<()> {
night_active,
settings.night_mode.force_quiet,
);
let desired_sleep = native_sleep_command(
settings.night_mode.enabled,
night_active,
settings.night_mode.use_native_sleep,
device.supports_sleep == Some(true) && state.gree.sleep_command_supported(&device.id),
device.sleep,
);
let needs_command = !device.power
|| device.mode != effective_mode
|| (device.target_temperature - desired_device_target).abs() >= 0.5
|| desired_fan.map(|fan| fan != device.fan_speed).unwrap_or(false)
|| desired_quiet.map(|quiet| quiet != device.quiet).unwrap_or(false);
|| desired_quiet.map(|quiet| quiet != device.quiet).unwrap_or(false)
|| desired_sleep.map(|sleep| sleep != device.sleep).unwrap_or(false);
let urgent_mode_change = !device.power || device.mode != effective_mode;
if needs_command && (urgent_mode_change || adjustment_allowed(&zone)) {
@@ -512,6 +532,7 @@ async fn control_zones(state: &AppState) -> Result<()> {
target_temperature: Some(desired_device_target),
fan_speed: desired_fan,
quiet: desired_quiet,
sleep: desired_sleep,
..Default::default()
};
match send_command(state, &zone.device_id, command).await {
@@ -527,6 +548,7 @@ async fn control_zones(state: &AppState) -> Result<()> {
"outdoor_temperature": outdoor_temperature,
"fan_speed": updated_device.fan_speed,
"quiet": updated_device.quiet,
"sleep": updated_device.sleep,
"night_mode": night_active,
}));
}
@@ -633,6 +655,23 @@ fn queue_influx_ha(state: &AppState, reading: HaReading) {
});
}
fn gree_outdoor_temperature(devices: &[Device]) -> Option<f64> {
let mut values: Vec<f64> = devices.iter()
.filter(|device| device.enabled && device.online)
.filter_map(|device| device.outdoor_temperature)
.filter(|value| value.is_finite() && (-60.0..=70.0).contains(value))
.collect();
if values.is_empty() { return None; }
values.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let middle = values.len() / 2;
let value = if values.len() % 2 == 0 {
(values[middle - 1] + values[middle]) / 2.0
} else {
values[middle]
};
Some((value * 10.0).round() / 10.0)
}
fn select_zone_temperature(zone: &Zone, device_temperature: Option<f64>, external_temperature: Option<f64>) -> (Option<f64>, String, bool) {
match zone.sensor_source.as_str() {
"home_assistant" => match (external_temperature, device_temperature) {
@@ -707,6 +746,19 @@ fn smart_quiet_command(
None
}
fn native_sleep_command(
night_enabled: bool,
night_active: bool,
use_native_sleep: bool,
sleep_supported: bool,
device_sleep: bool,
) -> Option<bool> {
if !night_enabled || !use_native_sleep || !sleep_supported { return None; }
if night_active { return Some(true); }
if device_sleep { return Some(false); }
None
}
fn night_limited_fan_speed(requested: u8, max_fan: u8) -> u8 {
let max_fan = max_fan.clamp(1, 5);
if requested == 0 { 1 } else { requested.min(max_fan) }
@@ -854,6 +906,8 @@ pub async fn build_control_plan(state: &AppState) -> Result<ControlPlan, AppErro
device_name: device.map(|item| item.name.clone()).unwrap_or_else(|| zone.device_id.clone()),
enabled: zone.enabled,
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() },
current_temperature: zone.current_temperature,
target_temperature: if effective_mode == "off" { None } else { zone.effective_setpoint.or(target) },
@@ -1156,7 +1210,7 @@ mod tests {
#[test]
fn night_mode_handles_midnight_and_limits_auto_fan() {
let settings = NightModeSettings { enabled: true, start_time: "22:00".into(), end_time: "06:00".into(), max_fan_speed: 1, force_quiet: true };
let settings = NightModeSettings { enabled: true, start_time: "22:00".into(), end_time: "06:00".into(), max_fan_speed: 1, force_quiet: true, use_native_sleep: true };
assert!(night_mode_active(&settings, NaiveTime::from_hms_opt(23, 30, 0).unwrap()));
assert!(night_mode_active(&settings, NaiveTime::from_hms_opt(5, 59, 0).unwrap()));
assert!(!night_mode_active(&settings, NaiveTime::from_hms_opt(12, 0, 0).unwrap()));
@@ -1164,6 +1218,22 @@ mod tests {
assert_eq!(night_limited_fan_speed(3, 1), 1);
assert_eq!(smart_quiet_command(false, true, true, true, false, true, true, true), Some(true));
assert_eq!(smart_quiet_command(false, true, true, true, true, true, false, true), Some(false));
assert_eq!(native_sleep_command(true, true, true, true, false), Some(true));
assert_eq!(native_sleep_command(true, false, true, true, true), Some(false));
assert_eq!(native_sleep_command(true, true, true, false, false), None);
}
#[test]
fn gree_outdoor_fallback_uses_median_of_online_units() {
let mut a = Device::simulated_default();
a.outdoor_temperature = Some(10.0);
let mut b = Device::simulated_default();
b.id = "sim-b".into();
b.outdoor_temperature = Some(12.0);
let mut c = Device::simulated_default();
c.id = "sim-c".into();
c.outdoor_temperature = Some(40.0);
assert_eq!(gree_outdoor_temperature(&[a, b, c]), Some(12.0));
}
#[test]