This commit is contained in:
Mateusz Gruszczyński
2026-08-23 22:05:24 +02:00
parent 1d3dcba1a9
commit 9c9b7b272b
25 changed files with 902 additions and 252 deletions
+56 -19
View File
@@ -66,23 +66,39 @@ pub async fn send_command(state: &AppState, device_id: &str, command: DeviceComm
} else {
if device.key.as_deref().unwrap_or_default().is_empty() {
match state.gree.bind(&device).await {
Ok(key) => {
device.key = Some(key);
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 {}", device.name), json!({"device_id": device.id}));
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) => {
mark_device_error(state, &mut device, &err.to_string())?;
register_device_failure(state, &mut device, &err.to_string())?;
return Err(AppError::Device(err.to_string()));
}
}
}
if let Err(err) = state.gree.command(&device, &command).await {
mark_device_error(state, &mut device, &err.to_string())?;
return Err(AppError::Device(err.to_string()));
if let Err(first_err) = state.gree.command(&device, &command).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).await
}
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);
device.online = true;
device.communication_failures = 0;
device.last_seen = Some(Utc::now());
device.last_error = None;
state.db.save_device(&device)?;
@@ -124,19 +140,30 @@ async fn poll_device(state: &AppState, device: &mut Device) {
}
if device.key.as_deref().unwrap_or_default().is_empty() {
match state.gree.bind(device).await {
Ok(key) => device.key = Some(key),
Ok(bound) => {
device.key = Some(bound.key);
device.protocol_version = bound.protocol_version;
device.communication_failures = 0;
}
Err(err) => {
device.online = false;
device.last_error = Some(err.to_string());
device.updated_at = Utc::now();
record_poll_failure(device, &err.to_string());
return;
}
}
}
if let Err(err) = state.gree.poll(device).await {
device.online = false;
device.last_error = Some(err.to_string());
device.updated_at = Utc::now();
if let Err(first_err) = state.gree.poll(device).await {
// A stale key or wrong cipher should heal automatically during polling.
// Rebind once, then retry the status request before counting a failure.
match state.gree.bind(device).await {
Ok(bound) => {
device.key = Some(bound.key);
device.protocol_version = bound.protocol_version;
if let Err(err) = state.gree.poll(device).await {
record_poll_failure(device, &err.to_string());
}
}
Err(_) => record_poll_failure(device, &first_err.to_string()),
}
}
}
@@ -184,18 +211,28 @@ fn record_reading(state: &AppState, device: &Device) -> Result<()> {
Ok(())
}
fn mark_device_error(state: &AppState, device: &mut Device, error: &str) -> Result<(), AppError> {
device.online = false;
fn record_poll_failure(device: &mut Device, error: &str) {
device.communication_failures = device.communication_failures.saturating_add(1);
// A single dropped UDP response is not enough to declare an AC offline.
if device.communication_failures >= 3 { device.online = false; }
device.last_error = Some(error.to_string());
device.updated_at = Utc::now();
}
fn register_device_failure(state: &AppState, device: &mut Device, error: &str) -> Result<(), AppError> {
record_poll_failure(device, error);
state.db.save_device(device)?;
state.log("error", "device.error", &format!("{}: {error}", device.name), json!({"device_id": device.id}));
state.log("warn", "device.communication_error", &format!("{}: {error}", device.name), json!({
"device_id": device.id,
"consecutive_failures": device.communication_failures,
"offline": !device.online,
}));
Ok(())
}
fn validate_command(command: &DeviceCommand) -> Result<(), AppError> {
if let Some(value) = command.target_temperature {
if !(8.0..=32.0).contains(&value) { return Err(AppError::BadRequest("target temperature must be between 8 and 32 C".into())); }
if !(8.0..=30.0).contains(&value) { return Err(AppError::BadRequest("target temperature must be between 8 and 30 C".into())); }
}
if let Some(value) = command.fan_speed {
if value > 5 { return Err(AppError::BadRequest("fan speed must be between 0 and 5".into())); }