This commit is contained in:
Mateusz Gruszczyński
2026-08-25 23:59:04 +02:00
parent 9fa0a5399e
commit b5a4265148
11 changed files with 598 additions and 193 deletions
+86 -29
View File
@@ -436,48 +436,66 @@ impl GreeClient {
.ok_or_else(|| anyhow!("status response has no cols"))?;
let data = response.get("dat").and_then(Value::as_array)
.ok_or_else(|| anyhow!("status response has no dat"))?;
if data.len() < response_cols.len() {
bail!("status response contains fewer values than columns")
}
// Parse into a clone and commit only when every climate-relevant value is valid.
// This prevents null/text/malformed frames from being silently converted into OFF,
// AUTO or a zero setpoint while leaving the rest of the packet partially applied.
let mut next = device.clone();
let mut set_temp = None;
for (name, value) in response_cols.iter().zip(data.iter()) {
let Some(name) = name.as_str() else { continue; };
match name {
"Pow" => device.power = value_as_i64(value) != 0,
"Mod" => device.mode = mode_name(value_as_i64(value)).into(),
"SetTem" => set_temp = Some(value_as_f64(value)),
"WdSpd" => device.fan_speed = value_as_i64(value).clamp(0, 5) as u8,
"SwUpDn" => device.swing_vertical = value_as_i64(value) != 0,
"SwingLfRig" => device.swing_horizontal = value_as_i64(value) != 0,
"Quiet" => { device.quiet = value_as_i64(value) != 0; device.supports_quiet = Some(true); },
"Tur" => { device.turbo = value_as_i64(value) != 0; device.supports_turbo = Some(true); },
"Lig" => { device.light = value_as_i64(value) != 0; device.supports_light = Some(true); },
"Air" => { device.air = value_as_i64(value) != 0; device.supports_air = Some(true); },
"Blo" => { device.xfan = value_as_i64(value) != 0; device.supports_xfan = Some(true); },
"Health" => { device.health = value_as_i64(value) != 0; device.supports_health = Some(true); },
"SwhSlp" => { device.sleep = value_as_i64(value) != 0; device.supports_sleep = Some(true); },
"Pow" => next.power = status_flag(name, value)?,
"Mod" => {
let raw = status_i64(name, value)?;
next.mode = mode_name_checked(raw).ok_or_else(|| anyhow!("invalid GREE mode value for {name}: {raw}"))?.into();
}
"SetTem" => {
let raw = status_f64(name, value)?;
if !(8.0..=30.0).contains(&raw) { bail!("invalid GREE setpoint for {name}: {raw}") }
set_temp = Some(raw.round());
}
"WdSpd" => {
let raw = status_i64(name, value)?;
if !(0..=5).contains(&raw) { bail!("invalid GREE fan value for {name}: {raw}") }
next.fan_speed = raw as u8;
}
"SwUpDn" => next.swing_vertical = status_i64(name, value)? != 0,
"SwingLfRig" => next.swing_horizontal = status_i64(name, value)? != 0,
"Quiet" => { next.quiet = status_flag(name, value)?; next.supports_quiet = Some(true); },
"Tur" => { next.turbo = status_flag(name, value)?; next.supports_turbo = Some(true); },
"Lig" => { next.light = status_flag(name, value)?; next.supports_light = Some(true); },
"Air" => { next.air = status_flag(name, value)?; next.supports_air = Some(true); },
"Blo" => { next.xfan = status_flag(name, value)?; next.supports_xfan = Some(true); },
"Health" => { next.health = status_flag(name, value)?; next.supports_health = Some(true); },
"SwhSlp" => { next.sleep = status_flag(name, value)?; next.supports_sleep = Some(true); },
"TemSen" => {
let raw = value_as_f64(value);
// The room sensor is a useful discriminator because normal indoor
// temperatures cannot exceed 40 C in controller operation. Persist
// the detected wire format and reuse it for OutEnvTem, including
// sub-zero outdoor values encoded as (temperature + 40).
let raw = status_f64(name, value)?;
if raw != 0.0 {
let offset = raw > 40.0;
device.temperature_sensor_offset = Some(offset);
device.current_temperature = Some(if offset { raw - 40.0 } else { raw });
let temperature = if offset { raw - 40.0 } else { raw };
if !(-40.0..=80.0).contains(&temperature) { bail!("invalid GREE indoor temperature: {temperature}") }
next.temperature_sensor_offset = Some(offset);
next.current_temperature = Some(temperature);
}
}
"OutEnvTem" => {
let raw = value_as_f64(value);
let raw = status_f64(name, value)?;
if raw != 0.0 {
let offset = device.temperature_sensor_offset.unwrap_or(raw > 50.0);
device.outdoor_temperature = Some(if offset { raw - 40.0 } else { raw });
let offset = next.temperature_sensor_offset.unwrap_or(raw > 50.0);
let temperature = if offset { raw - 40.0 } else { raw };
if !(-60.0..=80.0).contains(&temperature) { bail!("invalid GREE outdoor temperature: {temperature}") }
next.outdoor_temperature = Some(temperature);
}
}
_ => {}
}
}
if let Some(base) = set_temp {
device.target_temperature = base.clamp(8.0, 30.0);
}
if let Some(base) = set_temp { next.target_temperature = base; }
*device = next;
Ok(())
}
@@ -792,9 +810,24 @@ fn interface_ipv4_config(interface: &str) -> Result<(Ipv4Addr, Ipv4Addr)> {
}
fn interface_ipv4(interface: &str) -> Result<Ipv4Addr> { interface_ipv4_config(interface).map(|(ip, _)| ip) }
fn value_as_i64(value: &Value) -> i64 { value.as_i64().or_else(|| value.as_str()?.parse().ok()).unwrap_or_default() }
fn value_as_f64(value: &Value) -> f64 { value.as_f64().or_else(|| value.as_str()?.parse().ok()).unwrap_or_default() }
fn mode_name(value: i64) -> &'static str { match value { 0 => "auto", 1 => "cool", 2 => "dry", 3 => "fan", 4 => "heat", _ => "auto" } }
fn value_as_i64(value: &Value) -> Option<i64> { value.as_i64().or_else(|| value.as_str()?.trim().parse().ok()) }
fn value_as_f64(value: &Value) -> Option<f64> {
value.as_f64().or_else(|| value.as_str()?.trim().parse().ok()).filter(|value| value.is_finite())
}
fn status_i64(name: &str, value: &Value) -> Result<i64> {
value_as_i64(value).ok_or_else(|| anyhow!("invalid GREE integer value for {name}: {value}"))
}
fn status_f64(name: &str, value: &Value) -> Result<f64> {
value_as_f64(value).ok_or_else(|| anyhow!("invalid GREE numeric value for {name}: {value}"))
}
fn status_flag(name: &str, value: &Value) -> Result<bool> {
match status_i64(name, value)? {
0 => Ok(false),
1 => Ok(true),
other => bail!("invalid GREE flag value for {name}: {other}"),
}
}
fn mode_name_checked(value: i64) -> Option<&'static str> { match value { 0 => Some("auto"), 1 => Some("cool"), 2 => Some("dry"), 3 => Some("fan"), 4 => Some("heat"), _ => None } }
fn mode_value(value: &str) -> Result<i64> {
match value.to_ascii_lowercase().as_str() {
"auto" => Ok(0), "cool" => Ok(1), "dry" => Ok(2), "fan" => Ok(3), "heat" => Ok(4),
@@ -830,6 +863,30 @@ pub fn merge_discovered(existing: Option<Device>, discovered: Device) -> Device
mod tests {
use super::*;
#[test]
fn invalid_status_frame_does_not_partially_mutate_device() {
let client = GreeClient::new(
"test-controller".into(),
None,
None,
Arc::new(AtomicBool::new(false)),
);
let mut device = Device::simulated_default();
device.power = true;
device.mode = "heat".into();
device.target_temperature = 24.0;
let before = device.clone();
let response = json!({
"cols": ["Pow", "Mod", "SetTem"],
"dat": [0, null, "not-a-number"]
});
assert!(client.apply_status(&mut device, &response).is_err());
assert_eq!(device.power, before.power);
assert_eq!(device.mode, before.mode);
assert_eq!(device.target_temperature, before.target_temperature);
}
#[test]
fn thermostat_standby_setpoint_low_fan_quiet_and_sleep_share_one_frame() {
let payload = GreeClient::command_payload(&DeviceCommand {