fn normalize_local_discovery_mac(value: &str) -> String { value.replace([':', '-'], "").trim().to_ascii_uppercase() } fn local_discovery_candidate( device: &Device, already_added: bool, protocol_locked: bool, ) -> LocalDiscoveryCandidate { LocalDiscoveryCandidate { name: device.name.clone(), mac: device.mac.clone(), ip: device.ip.clone(), port: device.port, protocol_version: device.protocol_version, protocol_locked, model: device.model.clone(), firmware: device.firmware.clone(), already_added, } } fn device_from_local_discovery(candidate: LocalDiscoveryCandidate) -> Result { let mac = normalize_local_discovery_mac(&candidate.mac); if mac.is_empty() { return Err(AppError::BadRequest("discovered device MAC is required".into())); } candidate .ip .parse::() .map_err(|_| AppError::BadRequest(format!("invalid IP address for {mac}")))?; if candidate.protocol_version > 2 { return Err(AppError::BadRequest(format!( "invalid protocol version for {mac}" ))); } if candidate.protocol_locked && candidate.protocol_version == 0 { return Err(AppError::BadRequest(format!( "locked discovery protocol is missing for {mac}" ))); } let model = candidate.model.trim().to_string(); let fallback_model = if model.is_empty() { "GREE" } else { &model }; let suffix = mac .chars() .rev() .take(4) .collect::() .chars() .rev() .collect::(); let name = if candidate.name.trim().is_empty() { format!("{fallback_model} {suffix}") } else { candidate.name.trim().to_string() }; let now = Utc::now(); Ok(Device { id: format!("gree-{}", mac.to_ascii_lowercase()), mac, name, connection_type: ConnectionType::Local, connection_status: ConnectionStatus::Unknown, cloud_device_id: None, cloud_parent_mac: None, cloud_account_id: None, ip: candidate.ip, port: if candidate.port == 0 { 7000 } else { candidate.port }, protocol_version: candidate.protocol_version, model, firmware: candidate.firmware.trim().to_string(), key: None, cid: Some("app".into()), enabled: true, simulated: false, power: false, mode: "cool".into(), target_temperature: 24.0, fan_speed: 0, swing_vertical: 0, swing_horizontal: 0, quiet: false, quiet_wire_value: None, 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, supports_buzzer_control: None, supports_energy_meter: None, total_energy_kwh: None, compressor_frequency_hz: None, last_cloud_sync: None, current_temperature: None, outdoor_temperature: None, temperature_sensor_offset: None, online: true, response_time_ms: None, last_seen: Some(now), last_error: None, communication_failures: 0, pending_command: false, capabilities: crate::models::DeviceCapabilities::default(), energy_source: EnergySourcePreference::Auto, ha_energy_entity_id: None, ha_energy_unit: None, ha_energy_device_class: None, ha_energy_state_class: None, created_at: now, updated_at: now, }) } async fn run_local_discovery( state: &AppState, request: DiscoveryRequest, ) -> Result<(u8, u8, Vec), AppError> { 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); if protocol_version > 2 { return Err(AppError::BadRequest("protocol_version must be 0, 1 or 2".into())); } let passes = request.passes.unwrap_or(3).clamp(1, 10); let discovered = state .providers .local() .client() .discover( &broadcast, Duration::from_millis(timeout_ms), protocol_version, passes, ) .await .map_err(|e| AppError::Device(e.to_string()))?; Ok((protocol_version, passes, discovered)) } /// Scan for local GREE units without persisting or binding them. async fn scan_discovery( State(state): State, Json(request): Json, ) -> Result, AppError> { let (protocol_version, passes, discovered) = run_local_discovery(&state, request).await?; let mut candidates = Vec::with_capacity(discovered.len()); for device in discovered { let mac = normalize_local_discovery_mac(&device.mac); let already_added = state.db.get_device_by_mac(&mac)?.is_some(); candidates.push(local_discovery_candidate( &device, already_added, protocol_version != 0, )); } state.log( "info", "discovery.scan_complete", &format!("Discovery scan found {} device(s)", candidates.len()), json!({ "count": candidates.len(), "protocol_version": protocol_version, "passes": passes, "persisted": false, }), ); Ok(Json(json!({ "count": candidates.len(), "devices": candidates, }))) } async fn add_discovered_devices( State(state): State, Json(request): Json, ) -> Result, AppError> { let _configuration_guard = state.lock_configuration_operation().await; if request.devices.is_empty() { return Err(AppError::BadRequest("select at least one discovered device".into())); } if request.devices.len() > 64 { return Err(AppError::BadRequest("too many discovered devices selected".into())); } let selected_count = request.devices.len(); let mut added = Vec::new(); let mut skipped = Vec::new(); for candidate in request.devices { let protocol_locked = candidate.protocol_locked; let mut device = device_from_local_discovery(candidate)?; let _device_guard = state.lock_device_operation(&device.id).await; if state.db.get_device_by_mac(&device.mac)?.is_some() { skipped.push(device.mac.clone()); continue; } let client = state.providers.local().client(); let bind_result = if protocol_locked { client.bind_exact(&device, device.protocol_version).await } else { // Auto discovery only provides a protocol hint. Try that generation // first, fall back to the other one, and persist the protocol that // actually completes binding. client.bind(&device).await }; match bind_result { Ok(bound) => { device.key = Some(bound.key); device.protocol_version = bound.protocol_version; device.communication_failures = 0; device.last_error = None; } Err(err) => { device.last_error = Some(format!("added, bind pending: {err}")); state.log( "warn", "device.bind_after_discovery", &format!("{}: {err}", device.name), json!({ "device_id": device.id, "protocol_version": device.protocol_version, "protocol_locked": protocol_locked, }), ); } } state.db.save_device(&device)?; added.push(device); } state.log( "info", "discovery.devices_added", &format!("Added {} discovered device(s)", added.len()), json!({ "selected": selected_count, "added": added.len(), "skipped": skipped.len(), }), ); if !added.is_empty() { state.broadcast("devices.discovered", json!({"devices": added})); } Ok(Json(json!({ "count": added.len(), "devices": added, "skipped_macs": skipped, }))) } 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(), connection_type: ConnectionType::Local, connection_status: ConnectionStatus::Unknown, cloud_device_id: None, cloud_parent_mac: None, cloud_account_id: None, 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: 0, swing_horizontal: 0, quiet: false, quiet_wire_value: None, 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, supports_buzzer_control: None, supports_energy_meter: None, total_energy_kwh: None, compressor_frequency_hz: None, last_cloud_sync: 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, pending_command: false, capabilities: crate::models::DeviceCapabilities::default(), energy_source: crate::models::EnergySourcePreference::Auto, ha_energy_entity_id: None, ha_energy_unit: None, ha_energy_device_class: None, ha_energy_state_class: None, 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 device.connection_type == ConnectionType::GreeCloud && (patch.ip.is_some() || patch.port.is_some() || patch.protocol_version.is_some() || patch.key.is_some()) { return Err(AppError::BadRequest( "IP, UDP port, local protocol and local key are not configurable for GREE Cloud devices".into(), )); } 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; } if let Some(v) = patch.energy_source { device.energy_source = v; } if let Some(v) = patch.ha_energy_entity_id { device.ha_energy_entity_id = v.filter(|x| !x.trim().is_empty()); } if let Some(v) = patch.ha_energy_unit { device.ha_energy_unit = v.filter(|x| !x.trim().is_empty()); } if let Some(v) = patch.ha_energy_device_class { device.ha_energy_device_class = v.filter(|x| !x.trim().is_empty()); } if let Some(v) = patch.ha_energy_state_class { device.ha_energy_state_class = v.filter(|x| !x.trim().is_empty()); } device.refresh_capabilities(); if device.energy_source == EnergySourcePreference::GreeCloud && !device.capabilities.energy_meter { return Err(AppError::BadRequest( "GREE Cloud energy is not available for this device".into(), )); } if device.energy_source == EnergySourcePreference::HomeAssistant && device .ha_energy_entity_id .as_deref() .unwrap_or_default() .is_empty() { return Err(AppError::BadRequest( "select a Home Assistant cumulative energy sensor first".into(), )); } if device.ha_energy_entity_id.is_some() { if device.ha_energy_device_class.as_deref() != Some("energy") { return Err(AppError::BadRequest( "Home Assistant energy sensor must have device_class=energy".into(), )); } if !matches!( device.ha_energy_state_class.as_deref(), Some("total" | "total_increasing") ) { return Err(AppError::BadRequest( "Home Assistant energy sensor must have state_class=total or total_increasing" .into(), )); } if !matches!( device .ha_energy_unit .as_deref() .map(str::to_ascii_lowercase) .as_deref(), Some("wh" | "kwh") ) { return Err(AppError::BadRequest( "Home Assistant energy sensor must use Wh or kWh".into(), )); } } 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; let device = state .db .get_device(&id)? .ok_or_else(|| AppError::NotFound(format!("device {id}")))?; let automations = state.db.list_automations()?; if device.connection_type == ConnectionType::Local && 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); } if device.connection_type == ConnectionType::Local { ensure_zone_removal_safe(&state, &removed_zone_ids)?; ensure_device_stopped_for_detach(&state, &id, "device.deleted").await?; } else { // Cloud removal must remain possible even when the physical unit is offline. Remove // controller-only references that would otherwise block deletion, but never send an // OFF/status request and never depend on MQTT. Local keeps the historical safeguards. let groups = state.db.list_groups()?; let emptied_group_ids: std::collections::HashSet = groups .iter() .filter(|group| { !group.zone_ids.is_empty() && group .zone_ids .iter() .all(|zone_id| removed_zone_ids.contains(zone_id)) }) .map(|group| group.id.clone()) .collect(); for automation in automations.iter().filter(|item| { item.trigger_device_id.as_deref() == Some(id.as_str()) || (item.action_group_id.is_none() && item.action_device_id == id) || item .action_group_id .as_ref() .is_some_and(|group_id| emptied_group_ids.contains(group_id)) || item .action_zone_id .as_ref() .is_some_and(|zone_id| removed_zone_ids.contains(zone_id)) }) { if state.db.delete_automation(&automation.id)? { state.broadcast("automation.deleted", json!({"id": automation.id.clone()})); } } state.providers.cloud().unregister_device(&id).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, "connection_type": device.connection_type}), ); state.broadcast("device.deleted", json!({"id": id})); state.wake_zone_control(); 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.connection_type == ConnectionType::GreeCloud { return Err(AppError::BadRequest( "bind is only available for Local/LAN devices".into(), )); } if device.simulated { return Ok(Json(device)); } let bound = state .providers .local() .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.connection_status = ConnectionStatus::Online; device.last_seen = Some(Utc::now()); device.last_error = None; device.updated_at = Utc::now(); state.db.save_device(&device)?; state.broadcast("device.updated", serde_json::to_value(&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 probe_device( State(state): State, Path(id): Path, ) -> Result, AppError> { let device = state .db .get_device(&id)? .ok_or_else(|| AppError::NotFound(format!("device {id}")))?; if device.connection_type == ConnectionType::GreeCloud { return Err(AppError::BadRequest( "UDP probe is only available for Local/LAN devices".into(), )); } let response_time_ms = state .providers .local() .client() .probe(&device) .await .map_err(|err| AppError::Device(err.to_string()))?; Ok(Json(json!({ "device_id": device.id, "response_time_ms": response_time_ms, "ok": true }))) } #[derive(Debug, Deserialize)] struct ManualDeviceCommandRequest { #[serde(flatten)] command: DeviceCommand, #[serde(default)] manual_override: bool, } async fn command_device( State(state): State, Path(id): Path, Json(request): Json, ) -> Result, AppError> { Ok(Json( engine::send_manual_command( &state, &id, request.command, "device.manual_control", request.manual_override, ) .await?, )) } async fn command_home_assistant_device( State(state): State, Path(id): Path, Json(command): Json, ) -> Result, AppError> { Ok(Json( engine::send_manual_command( &state, &id, command, "home_assistant.device_manual_control", false, ) .await?, )) }