v0.8.14
This commit is contained in:
@@ -0,0 +1,186 @@
|
||||
pub async fn send_command(state: &AppState, device_id: &str, command: DeviceCommand) -> Result<Device, AppError> {
|
||||
let _device_guard = state.lock_device_operation(device_id).await;
|
||||
send_command_locked(state, device_id, command).await
|
||||
}
|
||||
|
||||
async fn send_command_locked(state: &AppState, device_id: &str, command: DeviceCommand) -> Result<Device, AppError> {
|
||||
send_command_locked_inner(state, device_id, command, true, true).await
|
||||
}
|
||||
|
||||
async fn send_command_locked_forced(state: &AppState, device_id: &str, command: DeviceCommand) -> Result<Device, AppError> {
|
||||
send_command_locked_inner(state, device_id, command, false, true).await
|
||||
}
|
||||
|
||||
async fn send_command_locked_inner(
|
||||
state: &AppState,
|
||||
device_id: &str,
|
||||
command: DeviceCommand,
|
||||
dedupe_against_cache: bool,
|
||||
track_controller_command: bool,
|
||||
) -> Result<Device, AppError> {
|
||||
validate_command(&command)?;
|
||||
let mut device = state.db.get_device(device_id)?
|
||||
.ok_or_else(|| AppError::NotFound(format!("device {device_id}")))?;
|
||||
if !device.enabled { return Err(AppError::BadRequest("device is disabled".into())); }
|
||||
|
||||
// Routine control avoids redundant frames. Explicit safety transitions (global/group OFF,
|
||||
// detach) may bypass cache de-duplication so stale state cannot leave a unit powered.
|
||||
let command = if dedupe_against_cache && device.online && device.communication_failures == 0 { command.changed_from(&device) } else { command };
|
||||
if command.is_empty() { return Ok(device); }
|
||||
let controller_command_baseline = device.clone();
|
||||
let suppress_beep = state.settings.read().await.suppress_device_beep;
|
||||
let response_started = Instant::now();
|
||||
|
||||
let mut applied_command = command.clone();
|
||||
let mut confirmed_state = false;
|
||||
let mut confirmed_requested_state = true;
|
||||
if device.simulated {
|
||||
applied_command.apply(&mut device);
|
||||
confirmed_state = true;
|
||||
device.online = true;
|
||||
device.response_time_ms = Some(0);
|
||||
device.last_seen = Some(Utc::now());
|
||||
device.last_error = None;
|
||||
state.db.save_device(&device)?;
|
||||
} else {
|
||||
if device.key.as_deref().unwrap_or_default().is_empty() {
|
||||
match state.gree.bind(&device).await {
|
||||
Ok(bound) => {
|
||||
device.key = Some(bound.key);
|
||||
device.protocol_version = bound.protocol_version;
|
||||
device.communication_failures = 0;
|
||||
state.db.save_device(&device)?;
|
||||
state.log("info", "device.bound", &format!("Bound {} using protocol V{}", device.name, device.protocol_version), json!({"device_id": device.id, "protocol_version": device.protocol_version}));
|
||||
}
|
||||
Err(err) => {
|
||||
register_device_failure(state, &mut device, &err.to_string())?;
|
||||
return Err(AppError::Device(err.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
match state.gree.command(&device, &command, suppress_beep).await {
|
||||
Ok(result) => applied_command = result,
|
||||
Err(first_err) => {
|
||||
// A lost command ACK does not mean the command was lost. Read the device
|
||||
// first and avoid sending the same frame (and another beep) when the requested
|
||||
// state is already present. Only rebind when the verification read also fails.
|
||||
let mut observed = device.clone();
|
||||
let retry_result = match state.gree.poll(&mut observed).await {
|
||||
Ok(()) if command.changed_from(&observed).is_empty() => {
|
||||
device = observed;
|
||||
confirmed_state = true;
|
||||
tracing::debug!(device=%device.id, "GREE command ACK was uncertain, but status confirms the requested state");
|
||||
Ok(command.clone())
|
||||
}
|
||||
Ok(()) => {
|
||||
device = observed;
|
||||
let remaining = command.changed_from(&device);
|
||||
if remaining.is_empty() { Ok(command.clone()) }
|
||||
else { state.gree.command(&device, &remaining, suppress_beep).await }
|
||||
}
|
||||
Err(_) => {
|
||||
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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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); }
|
||||
|
||||
// A command ACK confirms transport/acceptance, but several GREE firmwares keep
|
||||
// returning the pre-command status for a short settling window. Publishing that first
|
||||
// stale read makes Home Assistant visibly bounce ON -> OFF -> ON. Verify a few times
|
||||
// with bounded backoff and only publish a differing state after the settling window.
|
||||
if !confirmed_state {
|
||||
let verification_delays_ms = [0_u64, 150, 350, 650];
|
||||
let mut last_verification_error: Option<String> = None;
|
||||
for delay_ms in verification_delays_ms {
|
||||
if delay_ms > 0 { sleep(Duration::from_millis(delay_ms)).await; }
|
||||
let mut observed = device.clone();
|
||||
match state.gree.poll(&mut observed).await {
|
||||
Ok(()) => {
|
||||
let requested_matches = applied_command.changed_from(&observed).is_empty();
|
||||
device = observed;
|
||||
confirmed_state = true;
|
||||
confirmed_requested_state = requested_matches;
|
||||
last_verification_error = None;
|
||||
if requested_matches { break; }
|
||||
}
|
||||
Err(err) => {
|
||||
last_verification_error = Some(err.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if confirmed_state && !confirmed_requested_state {
|
||||
tracing::debug!(device=%device.id, command=?applied_command, "GREE command acknowledged but status still differs after settling window");
|
||||
} else if !confirmed_state {
|
||||
let error = last_verification_error.unwrap_or_else(|| "status verification failed".into());
|
||||
record_poll_failure(&mut device, &format!("command accepted but status verification failed: {error}"));
|
||||
state.log("warn", "device.command_unconfirmed", &format!("Command accepted by {}, but resulting state could not be verified", device.name), json!({
|
||||
"device_id": device.id, "error": error
|
||||
}));
|
||||
}
|
||||
}
|
||||
if confirmed_state {
|
||||
device.response_time_ms = Some(response_started.elapsed().as_millis().min(u64::MAX as u128) as u64);
|
||||
}
|
||||
state.db.save_device(&device)?;
|
||||
if !dedupe_against_cache && confirmed_state && !confirmed_requested_state {
|
||||
if track_controller_command && !command_manual_control_fields(&applied_command).is_empty() {
|
||||
remember_controller_command(state, device_id, &applied_command, &controller_command_baseline).await;
|
||||
}
|
||||
state.broadcast("device.updated", serde_json::to_value(&device).unwrap_or_default());
|
||||
return Err(AppError::Device("device did not confirm the requested forced state change".into()));
|
||||
}
|
||||
}
|
||||
|
||||
if track_controller_command && !command_manual_control_fields(&applied_command).is_empty() {
|
||||
// Keep a bounded settling history even after the requested state has already been
|
||||
// observed once. Several GREE modules can briefly publish an older snapshot again
|
||||
// and then return to the controller-requested state. Without this guard that normal
|
||||
// firmware bounce can be misclassified as a physical/pilot takeover.
|
||||
remember_controller_command(state, device_id, &applied_command, &controller_command_baseline).await;
|
||||
}
|
||||
|
||||
record_device_transition_timestamps(state, &controller_command_baseline, &device)?;
|
||||
|
||||
state.log("info", "device.command", &format!("Updated {}", device.name), json!({
|
||||
"device_id": device.id,
|
||||
"command": applied_command,
|
||||
"confirmed": confirmed_state,
|
||||
}));
|
||||
state.broadcast("device.updated", serde_json::to_value(&device).unwrap_or_default());
|
||||
Ok(device)
|
||||
}
|
||||
|
||||
fn record_device_transition_timestamps(state: &AppState, before: &Device, after: &Device) -> Result<(), AppError> {
|
||||
if before.power == after.power && before.mode == after.mode { return Ok(()); }
|
||||
let now = Utc::now();
|
||||
for mut zone in state.db.list_zones()?.into_iter().filter(|zone| zone.device_id == after.id) {
|
||||
if before.power != after.power { zone.last_power_change_at = Some(now); }
|
||||
if before.mode != after.mode { zone.last_mode_change_at = Some(now); }
|
||||
// Do not bump zone.updated_at here: an in-flight thermostat cycle uses that field
|
||||
// as its optimistic snapshot guard. The cycle mirrors these timestamps into its own
|
||||
// computed Zone after a successful automatic command.
|
||||
state.db.save_zone(&zone)?;
|
||||
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user