async fn discover(State(state): State, Json(request): Json) -> Result, AppError> { let _configuration_guard = state.lock_configuration_operation().await; let settings = state.settings.read().await.clone(); let timeout_ms = request.timeout_ms.unwrap_or(settings.discovery_timeout_ms).clamp(500, 30_000); let broadcast = request.broadcast.unwrap_or(settings.discovery_broadcast); let protocol_version = request.protocol_version.unwrap_or(0).min(2); let passes = request.passes.unwrap_or(3).clamp(1, 10); let discovered = state.gree.discover(&broadcast, Duration::from_millis(timeout_ms), protocol_version, passes).await .map_err(|e| AppError::Device(e.to_string()))?; let mut saved = Vec::new(); let mut new_device_ids = Vec::new(); for item in discovered { let existing = state.db.get_device_by_mac(&item.mac)?; let is_new = existing.is_none(); let mut merged = merge_discovered(existing, item); let _device_guard = state.lock_device_operation(&merged.id).await; // A poll/command may have updated the same known device between discovery and // acquiring its operation lock. Re-merge against the freshest persisted state. if !is_new { if let Some(current) = state.db.get_device(&merged.id)? { merged = merge_discovered(Some(current), merged); } } // Bind right after discovery. GREE modules can have a short bind window; // bind() also refreshes it with a direct scan before the handshake. if !merged.simulated && merged.key.as_deref().unwrap_or_default().is_empty() { match state.gree.bind(&merged).await { Ok(bound) => { merged.key = Some(bound.key); merged.protocol_version = bound.protocol_version; merged.communication_failures = 0; merged.last_error = None; } Err(err) => { merged.last_error = Some(format!("discovered, bind pending: {err}")); state.log("warn", "device.bind_after_discovery", &format!("{}: {err}", merged.name), json!({"device_id": merged.id})); } } } state.db.save_device(&merged)?; if is_new { new_device_ids.push(merged.id.clone()); } saved.push(merged); } state.log("info", "discovery.complete", &format!("Discovery found {} device(s)", saved.len()), json!({"count": saved.len(), "protocol_version": protocol_version, "passes": passes, "new_devices": new_device_ids.len()})); state.broadcast("devices.discovered", json!({"devices": saved})); Ok(Json(json!({"count": saved.len(), "devices": saved, "new_device_ids": new_device_ids}))) } async fn list_devices(State(state): State) -> Result>, AppError> { Ok(Json(state.db.list_devices()?)) } async fn add_device(State(state): State, Json(input): Json) -> Result<(StatusCode, Json), AppError> { let _configuration_guard = state.lock_configuration_operation().await; if input.name.trim().is_empty() || input.mac.trim().is_empty() || input.ip.trim().is_empty() { return Err(AppError::BadRequest("name, mac and ip are required".into())); } input.ip.parse::().map_err(|_| AppError::BadRequest("invalid IP address".into()))?; if state.db.get_device_by_mac(&input.mac)?.is_some() { return Err(AppError::BadRequest("a device with this MAC already exists".into())); } let now = Utc::now(); let normalized_mac = input.mac.replace([':', '-'], "").to_ascii_uppercase(); let device = Device { id: format!("gree-{}", normalized_mac.to_ascii_lowercase()), mac: normalized_mac, name: input.name.trim().to_string(), ip: input.ip, port: input.port, protocol_version: input.protocol_version.min(2), model: String::new(), firmware: String::new(), key: input.key.filter(|v| !v.trim().is_empty()), cid: Some("app".into()), enabled: true, simulated: input.simulated, power: false, mode: "cool".into(), target_temperature: 24.0, fan_speed: 0, swing_vertical: false, swing_horizontal: false, 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: if input.simulated { Some(25.0) } else { None }, outdoor_temperature: None, temperature_sensor_offset: None, online: input.simulated, response_time_ms: if input.simulated { Some(0) } else { None }, last_seen: if input.simulated { Some(now) } else { None }, last_error: None, communication_failures: 0, created_at: now, updated_at: now, }; state.db.save_device(&device)?; state.log("info", "device.created", &format!("Added {}", device.name), json!({"device_id": device.id})); state.broadcast("device.created", serde_json::to_value(&device)?); Ok((StatusCode::CREATED, Json(device))) } async fn get_device(State(state): State, Path(id): Path) -> Result, AppError> { state.db.get_device(&id)?.map(Json).ok_or_else(|| AppError::NotFound(format!("device {id}"))) } async fn patch_device(State(state): State, Path(id): Path, Json(patch): Json) -> Result, AppError> { let _configuration_guard = state.lock_configuration_operation().await; if patch.enabled == Some(false) { engine::disable_device_safely(&state, &id).await?; } let _device_guard = state.lock_device_operation(&id).await; let mut device = state.db.get_device(&id)?.ok_or_else(|| AppError::NotFound(format!("device {id}")))?; if let Some(v) = patch.name { if !v.trim().is_empty() { device.name = v.trim().to_string(); } } if let Some(v) = patch.ip { v.parse::().map_err(|_| AppError::BadRequest("invalid IP address".into()))?; device.ip = v; } if let Some(v) = patch.port { device.port = v; } if let Some(v) = patch.protocol_version { let v = v.min(2); if device.protocol_version != v { device.protocol_version = v; device.key = None; device.supports_light = None; device.supports_quiet = None; device.supports_turbo = None; device.supports_air = None; device.supports_xfan = None; device.supports_health = None; device.supports_sleep = None; } } if let Some(v) = patch.key { device.key = v.filter(|x| !x.trim().is_empty()); } if let Some(v) = patch.enabled { device.enabled = v; } device.updated_at = Utc::now(); state.db.save_device(&device)?; state.broadcast("device.updated", serde_json::to_value(&device)?); // Enabling or changing a thermostat device should be reflected by the arbiter without // waiting for the periodic loop. The device lock above keeps the edit ordered against // polling and an in-flight thermostat command. state.wake_zone_control(); Ok(Json(device)) } async fn delete_device(State(state): State, Path(id): Path) -> Result { let _configuration_guard = state.lock_configuration_operation().await; // Keep reference validation and the destructive DB operation in one serialized window. // Lock order for cross-resource destructive operations: configuration -> automation -> house -> schedule -> cycle -> zones -> device. let _automation_guard = state.lock_automation_operation().await; let _house_guard = state.lock_house_operation().await; let _schedule_guard = state.lock_schedule_operation().await; let _cycle_guard = state.lock_zone_control_cycle().await; if state.db.get_device(&id)?.is_none() { return Err(AppError::NotFound(format!("device {id}"))); } if state.db.list_automations()?.iter().any(|item| { item.trigger_device_id.as_deref() == Some(id.as_str()) || (item.action_group_id.is_none() && item.action_device_id == id) }) { return Err(AppError::BadRequest("device is used by an automation; remove or retarget that automation first".into())); } let removed_zone_ids: std::collections::HashSet = state.db.list_zones()?.into_iter() .filter(|zone| zone.device_id == id) .map(|zone| zone.id) .collect(); let mut sorted_zone_ids: Vec = removed_zone_ids.iter().cloned().collect(); sorted_zone_ids.sort(); let mut zone_guards = Vec::with_capacity(sorted_zone_ids.len()); for zone_id in &sorted_zone_ids { zone_guards.push(state.lock_zone_operation(zone_id).await); } ensure_zone_removal_safe(&state, &removed_zone_ids)?; ensure_device_stopped_for_detach(&state, &id, "device.deleted").await?; if !state.db.delete_device(&id)? { return Err(AppError::NotFound(format!("device {id}"))); } drop(zone_guards); remove_zone_ids_from_groups_locked(&state, &removed_zone_ids).await?; state.log("info", "device.deleted", "Device deleted", json!({"device_id": id})); state.broadcast("device.deleted", json!({"id": id})); Ok(StatusCode::NO_CONTENT) } async fn bind_device(State(state): State, Path(id): Path) -> Result, AppError> { let _configuration_guard = state.lock_configuration_operation().await; let _device_guard = state.lock_device_operation(&id).await; let mut device = state.db.get_device(&id)?.ok_or_else(|| AppError::NotFound(format!("device {id}")))?; if device.simulated { return Ok(Json(device)); } let bound = state.gree.bind(&device).await.map_err(|e| AppError::Device(e.to_string()))?; device.key = Some(bound.key); device.protocol_version = bound.protocol_version; device.communication_failures = 0; device.online = true; device.last_seen = Some(Utc::now()); device.last_error = None; device.updated_at = Utc::now(); state.db.save_device(&device)?; state.log("info", "device.bound", &format!("Bound {}", device.name), json!({"device_id": id})); Ok(Json(device)) } async fn poll_device(State(state): State, Path(id): Path) -> Result, AppError> { Ok(Json(engine::poll_one(&state, &id).await?)) } async fn command_device(State(state): State, Path(id): Path, Json(command): Json) -> Result, AppError> { Ok(Json(engine::send_manual_command(&state, &id, command, "device.manual_control").await?)) } async fn command_home_assistant_device(State(state): State, Path(id): Path, Json(command): Json) -> Result, AppError> { if state.db.list_zones()?.iter().any(|zone| zone.device_id == id && !zone.enabled) { return Err(AppError::BadRequest("device belongs to a disabled thermostat zone; use technical device control for manual operation".into())); } Ok(Json(engine::send_manual_command(&state, &id, command, "home_assistant.device_manual_control").await?)) }