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
+120 -20
View File
@@ -24,6 +24,7 @@ pub struct GreeClient {
debug_gree_frames: Arc<AtomicBool>,
buzzer_unsupported: Arc<Mutex<HashSet<String>>>,
quiet_unsupported: Arc<Mutex<HashSet<String>>>,
sleep_unsupported: Arc<Mutex<HashSet<String>>>,
}
impl GreeClient {
@@ -40,6 +41,7 @@ impl GreeClient {
debug_gree_frames,
buzzer_unsupported: Arc::new(Mutex::new(HashSet::new())),
quiet_unsupported: Arc::new(Mutex::new(HashSet::new())),
sleep_unsupported: Arc::new(Mutex::new(HashSet::new())),
}
}
@@ -252,6 +254,17 @@ impl GreeClient {
quiet: false,
turbo: false,
light: true,
air: false,
xfan: false,
health: false,
sleep: false,
supports_light: None,
supports_quiet: None,
supports_turbo: None,
supports_air: None,
supports_xfan: None,
supports_health: None,
supports_sleep: None,
current_temperature: None,
outdoor_temperature: None,
temperature_sensor_offset: None,
@@ -357,6 +370,10 @@ impl GreeClient {
Err(err) => tracing::trace!(device=%device.id, error=?err, "GREE outdoor temperature is not available"),
}
}
// Capability discovery is deliberately lazy. Existing installations start with
// unknown support flags and each optional property is probed at most until a
// definitive success/failure has been persisted with the device state.
self.probe_optional_features(device, &key).await;
device.online = true;
device.communication_failures = 0;
device.last_seen = Some(Utc::now());
@@ -370,6 +387,49 @@ impl GreeClient {
self.request(device, &inner, key, false, device.protocol_version).await
}
async fn probe_optional_features(&self, device: &mut Device, key: &str) {
let probes = [
("Lig", device.supports_light.is_none()),
("Quiet", device.supports_quiet.is_none()),
("Tur", device.supports_turbo.is_none()),
("Air", device.supports_air.is_none()),
("Blo", device.supports_xfan.is_none()),
("Health", device.supports_health.is_none()),
("SwhSlp", device.supports_sleep.is_none()),
];
for (property, needed) in probes {
if !needed { continue; }
match self.status_request(device, key, &[property]).await {
Ok(value) => {
let returned = value.get("cols").and_then(Value::as_array)
.map(|cols| cols.iter().any(|name| name.as_str() == Some(property)))
.unwrap_or(false);
if !returned || self.apply_status(device, &value).is_err() {
Self::set_feature_support(device, property, false);
}
}
Err(err) => {
Self::set_feature_support(device, property, false);
tracing::trace!(device=%device.id, property, error=?err, "optional GREE feature is not available");
}
}
}
}
fn set_feature_support(device: &mut Device, property: &str, supported: bool) {
let value = Some(supported);
match property {
"Lig" => device.supports_light = value,
"Quiet" => device.supports_quiet = value,
"Tur" => device.supports_turbo = value,
"Air" => device.supports_air = value,
"Blo" => device.supports_xfan = value,
"Health" => device.supports_health = value,
"SwhSlp" => device.supports_sleep = value,
_ => {}
}
}
fn apply_status(&self, device: &mut Device, response: &Value) -> Result<()> {
let response_cols = response.get("cols").and_then(Value::as_array)
.ok_or_else(|| anyhow!("status response has no cols"))?;
@@ -385,9 +445,13 @@ impl GreeClient {
"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,
"Tur" => device.turbo = value_as_i64(value) != 0,
"Lig" => device.light = 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); },
"TemSen" => {
let raw = value_as_f64(value);
// The room sensor is a useful discriminator because normal indoor
@@ -420,6 +484,10 @@ impl GreeClient {
self.quiet_unsupported.lock().map(|items| !items.contains(device_id)).unwrap_or(true)
}
pub fn sleep_command_supported(&self, device_id: &str) -> bool {
self.sleep_unsupported.lock().map(|items| !items.contains(device_id)).unwrap_or(true)
}
async fn request_command_with_buzzer_fallback(
&self,
device: &Device,
@@ -455,29 +523,56 @@ impl GreeClient {
if effective.quiet.is_some() && !self.quiet_command_supported(&device.id) {
effective.quiet = None;
}
if effective.sleep.is_some() && !self.sleep_command_supported(&device.id) {
effective.sleep = None;
}
if effective.is_empty() {
return Ok(effective);
}
match self.request_command_with_buzzer_fallback(device, key, &effective, suppress_beep).await {
Ok(_) => Ok(effective),
Err(first_err) if effective.quiet.is_some() => {
// Quiet is not implemented by every GREE indoor unit. If a combined thermostat
// command is rejected, retry the same setpoint/fan change without Quiet. Once
// that succeeds, remember the unit so future thermostat frames omit Quiet.
let mut fallback = effective.clone();
fallback.quiet = None;
if fallback.is_empty() { return Err(first_err); }
match self.request_command_with_buzzer_fallback(device, key, &fallback, suppress_beep).await {
Ok(_) => {
if let Ok(mut items) = self.quiet_unsupported.lock() { items.insert(device.id.clone()); }
tracing::warn!(device=%device.id, "GREE Quiet command is unsupported; thermostat will use Low fan without Quiet for this device");
Ok(fallback)
Err(first_err) => {
// Quiet and native Sleep are optional GREE features. A unit may report a
// broader status schema than it accepts in command frames, so preserve
// the actual thermostat change and retry without the optional property.
if effective.sleep.is_some() {
let mut fallback = effective.clone();
fallback.sleep = None;
if !fallback.is_empty() {
if self.request_command_with_buzzer_fallback(device, key, &fallback, suppress_beep).await.is_ok() {
if let Ok(mut items) = self.sleep_unsupported.lock() { items.insert(device.id.clone()); }
tracing::warn!(device=%device.id, "GREE native Sleep command is unsupported; night mode will continue without Sleep for this device");
return Ok(fallback);
}
}
Err(_) => Err(first_err),
}
if effective.quiet.is_some() {
let mut fallback = effective.clone();
fallback.quiet = None;
if !fallback.is_empty() {
if self.request_command_with_buzzer_fallback(device, key, &fallback, suppress_beep).await.is_ok() {
if let Ok(mut items) = self.quiet_unsupported.lock() { items.insert(device.id.clone()); }
tracing::warn!(device=%device.id, "GREE Quiet command is unsupported; thermostat will continue without Quiet for this device");
return Ok(fallback);
}
}
}
if effective.sleep.is_some() && effective.quiet.is_some() {
let mut fallback = effective.clone();
fallback.sleep = None;
fallback.quiet = None;
if !fallback.is_empty() {
if self.request_command_with_buzzer_fallback(device, key, &fallback, suppress_beep).await.is_ok() {
if let Ok(mut items) = self.sleep_unsupported.lock() { items.insert(device.id.clone()); }
if let Ok(mut items) = self.quiet_unsupported.lock() { items.insert(device.id.clone()); }
tracing::warn!(device=%device.id, "GREE Quiet/Sleep optional command fields are unsupported; using the core thermostat command");
return Ok(fallback);
}
}
}
Err(first_err)
}
Err(err) => Err(err),
}
}
@@ -498,6 +593,10 @@ impl GreeClient {
if let Some(v) = command.quiet { opt.push("Quiet"); values.push(json!(if v { 1 } else { 0 })); }
if let Some(v) = command.turbo { opt.push("Tur"); values.push(json!(if v { 1 } else { 0 })); }
if let Some(v) = command.light { opt.push("Lig"); values.push(json!(if v { 1 } else { 0 })); }
if let Some(v) = command.air { opt.push("Air"); values.push(json!(if v { 1 } else { 0 })); }
if let Some(v) = command.xfan { opt.push("Blo"); values.push(json!(if v { 1 } else { 0 })); }
if let Some(v) = command.health { opt.push("Health"); values.push(json!(if v { 1 } else { 0 })); }
if let Some(v) = command.sleep { opt.push("SwhSlp"); values.push(json!(if v { 1 } else { 0 })); }
if opt.is_empty() { bail!("empty device command") }
if suppress_beep {
opt.push("Buzzer_ON_OFF"); values.push(json!(1));
@@ -731,15 +830,16 @@ mod tests {
use super::*;
#[test]
fn thermostat_standby_setpoint_low_fan_and_quiet_share_one_frame() {
fn thermostat_standby_setpoint_low_fan_quiet_and_sleep_share_one_frame() {
let payload = GreeClient::command_payload(&DeviceCommand {
target_temperature: Some(19.0),
fan_speed: Some(1),
quiet: Some(true),
sleep: Some(true),
..DeviceCommand::default()
}, false).expect("thermostat command payload");
assert_eq!(payload.get("opt").cloned(), Some(serde_json::json!(["SetTem", "WdSpd", "Quiet"])));
assert_eq!(payload.get("p").cloned(), Some(serde_json::json!([19, 1, 1])));
assert_eq!(payload.get("opt").cloned(), Some(serde_json::json!(["SetTem", "WdSpd", "Quiet", "SwhSlp"])));
assert_eq!(payload.get("p").cloned(), Some(serde_json::json!([19, 1, 1, 1])));
}
}