v0.5.2
This commit is contained in:
+74
-20
@@ -102,8 +102,9 @@ pub async fn send_command(state: &AppState, device_id: &str, command: DeviceComm
|
||||
if command.is_empty() { return Ok(device); }
|
||||
let suppress_beep = state.settings.read().await.suppress_device_beep;
|
||||
|
||||
let mut applied_command = command.clone();
|
||||
if device.simulated {
|
||||
command.apply(&mut device);
|
||||
applied_command.apply(&mut device);
|
||||
device.online = true;
|
||||
device.last_seen = Some(Utc::now());
|
||||
device.last_error = None;
|
||||
@@ -124,24 +125,30 @@ pub async fn send_command(state: &AppState, device_id: &str, command: DeviceComm
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Err(first_err) = state.gree.command(&device, &command, suppress_beep).await {
|
||||
// Retry once after a fresh bind. This covers stale keys and devices that
|
||||
// switched between ECB/GCM after a firmware update.
|
||||
let retry_result = match state.gree.bind(&device).await {
|
||||
Ok(bound) => {
|
||||
device.key = Some(bound.key);
|
||||
device.protocol_version = bound.protocol_version;
|
||||
state.db.save_device(&device)?;
|
||||
state.gree.command(&device, &command, suppress_beep).await
|
||||
match state.gree.command(&device, &command, suppress_beep).await {
|
||||
Ok(result) => applied_command = result,
|
||||
Err(first_err) => {
|
||||
// Retry once after a fresh bind. This covers stale keys and devices that
|
||||
// switched between ECB/GCM after a firmware update.
|
||||
let retry_result = match state.gree.bind(&device).await {
|
||||
Ok(bound) => {
|
||||
device.key = Some(bound.key);
|
||||
device.protocol_version = bound.protocol_version;
|
||||
state.db.save_device(&device)?;
|
||||
state.gree.command(&device, &command, suppress_beep).await
|
||||
}
|
||||
Err(_) => Err(first_err),
|
||||
};
|
||||
match retry_result {
|
||||
Ok(result) => applied_command = result,
|
||||
Err(err) => {
|
||||
register_device_failure(state, &mut device, &err.to_string())?;
|
||||
return Err(AppError::Device(err.to_string()));
|
||||
}
|
||||
}
|
||||
Err(_) => Err(first_err),
|
||||
};
|
||||
if let Err(err) = retry_result {
|
||||
register_device_failure(state, &mut device, &err.to_string())?;
|
||||
return Err(AppError::Device(err.to_string()));
|
||||
}
|
||||
}
|
||||
command.apply(&mut device);
|
||||
applied_command.apply(&mut device);
|
||||
device.online = true;
|
||||
device.communication_failures = 0;
|
||||
device.last_seen = Some(Utc::now());
|
||||
@@ -151,7 +158,7 @@ pub async fn send_command(state: &AppState, device_id: &str, command: DeviceComm
|
||||
|
||||
state.log("info", "device.command", &format!("Updated {}", device.name), json!({
|
||||
"device_id": device.id,
|
||||
"command": command,
|
||||
"command": applied_command,
|
||||
}));
|
||||
state.broadcast("device.updated", serde_json::to_value(&device).unwrap_or_default());
|
||||
Ok(device)
|
||||
@@ -424,6 +431,7 @@ async fn control_zones(state: &AppState) -> Result<()> {
|
||||
};
|
||||
|
||||
let half = zone.hysteresis.max(0.1) / 2.0;
|
||||
let previous_demand = zone.demand;
|
||||
zone.demand = match effective_mode {
|
||||
"heat" => {
|
||||
if temp <= target - half { true }
|
||||
@@ -456,11 +464,23 @@ async fn control_zones(state: &AppState) -> Result<()> {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// When the room becomes satisfied, ask compatible units for Quiet in the same
|
||||
// frame as the standby setpoint and Low fan. When demand returns, disable Quiet
|
||||
// only on that transition so a user's manual Quiet choice is not constantly
|
||||
// overwritten while the zone is actively heating/cooling.
|
||||
let desired_quiet = smart_quiet_command(
|
||||
zone.smart_fan,
|
||||
state.gree.quiet_command_supported(&device.id),
|
||||
previous_demand,
|
||||
zone.demand,
|
||||
device.quiet,
|
||||
);
|
||||
|
||||
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_fan.map(|fan| fan != device.fan_speed).unwrap_or(false)
|
||||
|| desired_quiet.map(|quiet| quiet != device.quiet).unwrap_or(false);
|
||||
|
||||
let urgent_mode_change = !device.power || device.mode != effective_mode;
|
||||
if needs_command && (urgent_mode_change || adjustment_allowed(&zone)) {
|
||||
@@ -469,10 +489,11 @@ async fn control_zones(state: &AppState) -> Result<()> {
|
||||
mode: Some(effective_mode.to_string()),
|
||||
target_temperature: Some(desired_device_target),
|
||||
fan_speed: desired_fan,
|
||||
quiet: desired_quiet,
|
||||
..Default::default()
|
||||
};
|
||||
match send_command(state, &zone.device_id, command).await {
|
||||
Ok(_) => {
|
||||
Ok(updated_device) => {
|
||||
zone.last_action_at = Some(Utc::now());
|
||||
state.log("info", "zone.setpoint_modulation", &format!("Zone {} -> {:.1} C ({})", zone.name, desired_device_target, if zone.demand { "demand" } else { "standby" }), json!({
|
||||
"zone_id": zone.id,
|
||||
@@ -482,6 +503,8 @@ async fn control_zones(state: &AppState) -> Result<()> {
|
||||
"mode": effective_mode,
|
||||
"preset": zone.active_preset,
|
||||
"outdoor_temperature": outdoor_temperature,
|
||||
"fan_speed": updated_device.fan_speed,
|
||||
"quiet": updated_device.quiet,
|
||||
}));
|
||||
}
|
||||
Err(err) => state.log("error", "zone.action_error", &err.to_string(), json!({"zone_id": zone.id})),
|
||||
@@ -640,8 +663,24 @@ fn outdoor_assist_offset(mode: &str, outdoor: Option<f64>, room: f64, target: f6
|
||||
(weather * room_error.clamp(0.0, 2.0) * 0.5).clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
fn smart_quiet_command(
|
||||
smart_fan: bool,
|
||||
quiet_supported: bool,
|
||||
previous_demand: bool,
|
||||
demand: bool,
|
||||
device_quiet: bool,
|
||||
) -> Option<bool> {
|
||||
if !smart_fan || !quiet_supported { return None; }
|
||||
if !demand { return Some(true); }
|
||||
if !previous_demand && device_quiet { return Some(false); }
|
||||
None
|
||||
}
|
||||
|
||||
fn smart_fan_speed(mode: &str, room: f64, target: f64, outdoor: Option<f64>, demand: bool) -> u8 {
|
||||
if !demand { return 0; }
|
||||
// When the thermostat is satisfied, keep airflow quiet instead of leaving the
|
||||
// unit in Auto. The caller sends this together with the standby setpoint in
|
||||
// the same GREE command, so e.g. 21 C reached -> 19 C + Low fan for heating.
|
||||
if !demand { return 1; }
|
||||
let error = (room - target).abs();
|
||||
let extreme_weather = match (mode, outdoor) {
|
||||
("heat", Some(value)) => value <= 0.0,
|
||||
@@ -1023,6 +1062,21 @@ mod tests {
|
||||
assert_eq!(round_device_setpoint("heat", false, 19.5), 19.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smart_fan_uses_low_speed_when_zone_is_satisfied() {
|
||||
assert_eq!(smart_fan_speed("heat", 21.0, 21.0, None, false), 1);
|
||||
assert_eq!(smart_fan_speed("cool", 23.0, 23.0, None, false), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smart_quiet_follows_satisfied_transition_only_when_supported() {
|
||||
assert_eq!(smart_quiet_command(true, true, true, false, false), Some(true));
|
||||
assert_eq!(smart_quiet_command(true, true, false, true, true), Some(false));
|
||||
assert_eq!(smart_quiet_command(true, true, true, true, true), None);
|
||||
assert_eq!(smart_quiet_command(true, false, true, false, false), None);
|
||||
assert_eq!(smart_quiet_command(false, true, true, false, false), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outdoor_assist_is_bounded_and_direction_neutral() {
|
||||
let cool = outdoor_assist_offset("cool", Some(36.0), 27.0, 23.0);
|
||||
|
||||
+65
-5
@@ -23,6 +23,7 @@ pub struct GreeClient {
|
||||
debug_events: Option<broadcast::Sender<ApiEvent>>,
|
||||
debug_gree_frames: Arc<AtomicBool>,
|
||||
buzzer_unsupported: Arc<Mutex<HashSet<String>>>,
|
||||
quiet_unsupported: Arc<Mutex<HashSet<String>>>,
|
||||
}
|
||||
|
||||
impl GreeClient {
|
||||
@@ -38,6 +39,7 @@ impl GreeClient {
|
||||
debug_events,
|
||||
debug_gree_frames,
|
||||
buzzer_unsupported: Arc::new(Mutex::new(HashSet::new())),
|
||||
quiet_unsupported: Arc::new(Mutex::new(HashSet::new())),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -414,17 +416,25 @@ impl GreeClient {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn command(&self, device: &Device, command: &DeviceCommand, suppress_beep: bool) -> Result<Value> {
|
||||
let key = device.key.as_deref().ok_or_else(|| anyhow!("device is not bound"))?;
|
||||
pub fn quiet_command_supported(&self, device_id: &str) -> bool {
|
||||
self.quiet_unsupported.lock().map(|items| !items.contains(device_id)).unwrap_or(true)
|
||||
}
|
||||
|
||||
async fn request_command_with_buzzer_fallback(
|
||||
&self,
|
||||
device: &Device,
|
||||
key: &str,
|
||||
command: &DeviceCommand,
|
||||
suppress_beep: bool,
|
||||
) -> Result<Value> {
|
||||
let try_buzzer_suppression = suppress_beep
|
||||
&& self.buzzer_unsupported.lock().map(|items| !items.contains(&device.id)).unwrap_or(true);
|
||||
let inner = Self::command_payload(command, try_buzzer_suppression)?;
|
||||
match self.request(device, &inner, key, false, device.protocol_version).await {
|
||||
Ok(value) => Ok(value),
|
||||
Err(first_err) if try_buzzer_suppression => {
|
||||
// Some firmwares reject unknown command properties instead of ignoring them.
|
||||
// Retry the exact state change without buzzer fields; only remember the device
|
||||
// as incompatible after that fallback succeeds.
|
||||
// Some firmwares reject unknown buzzer properties instead of ignoring them.
|
||||
// Retry the exact state change without buzzer fields and remember the fallback.
|
||||
let fallback = Self::command_payload(command, false)?;
|
||||
match self.request(device, &fallback, key, false, device.protocol_version).await {
|
||||
Ok(value) => {
|
||||
@@ -439,6 +449,38 @@ impl GreeClient {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn command(&self, device: &Device, command: &DeviceCommand, suppress_beep: bool) -> Result<DeviceCommand> {
|
||||
let key = device.key.as_deref().ok_or_else(|| anyhow!("device is not bound"))?;
|
||||
let mut effective = command.clone();
|
||||
if effective.quiet.is_some() && !self.quiet_command_supported(&device.id) {
|
||||
effective.quiet = 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(_) => Err(first_err),
|
||||
}
|
||||
}
|
||||
Err(err) => Err(err),
|
||||
}
|
||||
}
|
||||
|
||||
fn command_payload(command: &DeviceCommand, suppress_beep: bool) -> Result<Value> {
|
||||
let mut opt = Vec::<&str>::new();
|
||||
let mut values = Vec::<Value>::new();
|
||||
@@ -683,3 +725,21 @@ pub fn merge_discovered(existing: Option<Device>, discovered: Device) -> Device
|
||||
new
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn thermostat_standby_setpoint_low_fan_and_quiet_share_one_frame() {
|
||||
let payload = GreeClient::command_payload(&DeviceCommand {
|
||||
target_temperature: Some(19.0),
|
||||
fan_speed: Some(1),
|
||||
quiet: 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])));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user