fn cloud_debug_event(state: &AppState, data: Value) { if state.debug_cloud_requests.load(Ordering::Relaxed) { state.broadcast_with_control_plan_invalidation("gree_cloud.request", data, false); } } fn cloud_error_kind(error: &anyhow::Error) -> &'static str { let text = format!("{error:#}").to_ascii_lowercase(); if text.contains("authentication failed") || text.contains("login failed") { "authentication_error" } else if text.contains("timeout") || text.contains("timed out") { "timeout" } else if text.contains("http 5") || text.contains("service unavailable") { "api_unavailable" } else if text.contains("connect") || text.contains("dns") || text.contains("network") { "network_error" } else { "api_error" } } async fn cloud_api_from_settings( state: &AppState, ) -> Result { let settings = state.settings.read().await.gree_cloud.clone(); crate::protocol::gree_cloud::GreeCloudApi::for_region( state.http.clone(), &settings.region, &settings.username, &settings.password, ) .map_err(|err| AppError::BadRequest(err.to_string())) } async fn test_gree_cloud(State(state): State) -> Result, AppError> { let mut api = cloud_api_from_settings(&state).await?; let started = Instant::now(); cloud_debug_event( &state, json!({"operation":"test_connection","phase":"sent"}), ); let result = async { api.login().await?; let devices = api.get_all_devices().await?; Ok::(devices.len()) } .await; match result { Ok(device_count) => { let now = Utc::now(); cloud_debug_event( &state, json!({ "operation":"test_connection", "phase":"response", "duration_ms": started.elapsed().as_millis() as u64, "device_count": device_count, }), ); { let _configuration_guard = state.lock_configuration_operation().await; let mut settings = state.settings.write().await; settings.gree_cloud.last_successful_contact = Some(now); settings.gree_cloud.last_rest_response_time_ms = Some(started.elapsed().as_millis().min(u64::MAX as u128) as u64); state.db.save_runtime_settings(&settings)?; } state.log( "info", "gree_cloud.login_success", "GREE Cloud connection test succeeded", json!({"device_count": device_count}), ); Ok(Json(json!({ "ok": true, "status": "connected", "device_count": device_count, "last_successful_contact": now, }))) } Err(error) => { let kind = cloud_error_kind(&error); cloud_debug_event( &state, json!({ "operation":"test_connection", "phase":"error", "duration_ms": started.elapsed().as_millis() as u64, "kind": kind, }), ); tracing::warn!(kind, "GREE Cloud connection test failed"); state.log( "warn", "gree_cloud.login_failure", "GREE Cloud connection test failed", json!({"kind": kind}), ); Ok(Json(json!({ "ok": false, "status": kind, "message": match kind { "authentication_error" => "Invalid GREE Cloud login/password or authorization was rejected", "timeout" => "GREE Cloud request timed out", "network_error" => "Cannot reach GREE Cloud", "api_unavailable" => "GREE Cloud API is temporarily unavailable", _ => "GREE Cloud returned an unexpected response", } }))) } } } async fn discover_gree_cloud_devices( State(state): State, ) -> Result, AppError> { let mut api = cloud_api_from_settings(&state).await?; let started = Instant::now(); cloud_debug_event(&state, json!({"operation":"discovery","phase":"sent"})); api.login().await.map_err(|err| { cloud_debug_event( &state, json!({ "operation":"discovery", "phase":"error", "stage":"login", "duration_ms": started.elapsed().as_millis() as u64, "kind": cloud_error_kind(&err), }), ); AppError::Dependency(format!("GREE Cloud login failed: {err}")) })?; let devices = api.get_all_devices().await.map_err(|err| { cloud_debug_event( &state, json!({ "operation":"discovery", "phase":"error", "stage":"devices", "duration_ms": started.elapsed().as_millis() as u64, "kind": cloud_error_kind(&err), }), ); AppError::Dependency(format!("GREE Cloud discovery failed: {err}")) })?; let rest_duration_ms = started.elapsed().as_millis().min(u64::MAX as u128) as u64; cloud_debug_event( &state, json!({ "operation":"discovery", "phase":"response", "duration_ms": rest_duration_ms, "device_count": devices.len(), }), ); { let _configuration_guard = state.lock_configuration_operation().await; let mut settings = state.settings.write().await; settings.gree_cloud.last_successful_contact = Some(Utc::now()); settings.gree_cloud.last_rest_response_time_ms = Some(rest_duration_ms); state.db.save_runtime_settings(&settings)?; } let existing = state.db.list_devices()?; let views = devices .into_iter() .map(|device| { let id = device.mac.replace([':', '-'], "").to_ascii_uppercase(); let already_added = existing.iter().any(|saved| { saved.connection_type == ConnectionType::GreeCloud && saved .cloud_device_id .as_deref() .is_some_and(|value| value.eq_ignore_ascii_case(&id)) }); crate::protocol::gree_cloud::CloudDeviceView { parent_mac: crate::protocol::gree_cloud::parent_mac(&id), id: id.clone(), name: device.name, mac: id, model: device.model, version: device.version, online: device.online, already_added, } }) .collect::>(); state.log( "info", "gree_cloud.discovery", &format!("GREE Cloud discovery found {} device(s)", views.len()), json!({"count": views.len()}), ); Ok(Json(json!({"count": views.len(), "devices": views}))) } async fn add_gree_cloud_device( State(state): State, Path(cloud_id): Path, ) -> Result<(StatusCode, Json), AppError> { let cloud_id = cloud_id.replace([':', '-'], "").to_ascii_uppercase(); if cloud_id.is_empty() { return Err(AppError::BadRequest("cloud device id is required".into())); } if state.db.list_devices()?.iter().any(|device| { device.connection_type == ConnectionType::GreeCloud && device.cloud_device_id.as_deref() == Some(cloud_id.as_str()) }) { return Err(AppError::Conflict( "this GREE Cloud device is already added".into(), )); } // Re-discover server-side so the frontend never needs to submit/store the device cipher key. let mut api = cloud_api_from_settings(&state).await?; let started = Instant::now(); cloud_debug_event( &state, json!({ "operation":"add_device_lookup", "phase":"sent", "cloud_device_id": cloud_id.clone(), }), ); api.login().await.map_err(|err| { cloud_debug_event( &state, json!({ "operation":"add_device_lookup", "phase":"error", "stage":"login", "duration_ms": started.elapsed().as_millis() as u64, "kind": cloud_error_kind(&err), }), ); AppError::Dependency(format!("GREE Cloud login failed: {err}")) })?; let cloud_device = api .get_all_devices() .await .map_err(|err| { cloud_debug_event( &state, json!({ "operation":"add_device_lookup", "phase":"error", "stage":"devices", "duration_ms": started.elapsed().as_millis() as u64, "kind": cloud_error_kind(&err), }), ); AppError::Dependency(format!("GREE Cloud discovery failed: {err}")) })? .into_iter() .find(|device| device.mac.eq_ignore_ascii_case(&cloud_id)) .ok_or_else(|| AppError::NotFound(format!("GREE Cloud device {cloud_id}")))?; cloud_debug_event( &state, json!({ "operation":"add_device_lookup", "phase":"response", "duration_ms": started.elapsed().as_millis() as u64, "cloud_device_id": cloud_id.clone(), }), ); let _configuration_guard = state.lock_configuration_operation().await; let now = Utc::now(); let account_id = state.settings.read().await.gree_cloud.account_id.clone(); let normalized_cloud_mac = cloud_device .mac .replace([':', '-'], "") .to_ascii_uppercase(); let device = Device { id: format!("gree-cloud-{}", normalized_cloud_mac.to_ascii_lowercase()), mac: normalized_cloud_mac.clone(), name: if cloud_device.name.trim().is_empty() { format!("GREE Cloud {}", &normalized_cloud_mac) } else { cloud_device.name.clone() }, connection_type: ConnectionType::GreeCloud, connection_status: ConnectionStatus::CloudDisconnected, cloud_device_id: Some(normalized_cloud_mac.clone()), cloud_parent_mac: Some(crate::protocol::gree_cloud::parent_mac( &normalized_cloud_mac, )), cloud_account_id: Some(account_id), ip: String::new(), port: 0, // The reference HA integration currently creates CloudDevice with cipher_version=1. protocol_version: 1, model: cloud_device.model.unwrap_or_default(), firmware: cloud_device.version.unwrap_or_default(), key: Some(cloud_device.key), cid: Some("gree-cloud".into()), enabled: true, simulated: false, power: false, mode: "cool".into(), target_temperature: 24.0, fan_speed: 0, swing_vertical: false, swing_horizontal: false, quiet: false, quiet_wire_value: None, turbo: false, light: false, 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: false, response_time_ms: None, last_seen: None, last_error: None, communication_failures: 0, pending_command: false, capabilities: crate::models::DeviceCapabilities { vertical_swing: false, horizontal_swing: false, ..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", "gree_cloud.device_added", &format!("Added GREE Cloud device {}", device.name), json!({"device_id": device.id, "cloud_device_id": device.cloud_device_id}), ); state.broadcast("device.created", serde_json::to_value(&device)?); Ok((StatusCode::CREATED, Json(device))) } async fn gree_cloud_status(State(state): State) -> Result, AppError> { let settings = state.settings.read().await.gree_cloud.clone(); let mqtt_connected = state.providers.cloud().is_connected().await; let cloud_devices = state .db .list_devices()? .into_iter() .filter(|device| device.connection_type == ConnectionType::GreeCloud) .collect::>(); let account_status = if !settings.enabled { "disabled" } else if settings.username.trim().is_empty() || settings.password.trim().is_empty() { "not_configured" } else if cloud_devices .iter() .any(|device| device.connection_status == ConnectionStatus::AuthenticationError) { "authentication_error" } else if mqtt_connected { "connected" } else { "cloud_disconnected" }; let runtime = state.providers.cloud().runtime_status().await; Ok(Json(json!({ "enabled": settings.enabled, "account_status": account_status, "mqtt_status": if mqtt_connected { "connected" } else { "disconnected" }, "last_successful_contact": settings.last_successful_contact, "last_rest_response_time_ms": settings.last_rest_response_time_ms, "device_count": cloud_devices.len(), "online_device_count": cloud_devices.iter().filter(|device| device.connection_status == ConnectionStatus::Online).count(), "runtime": runtime, }))) } async fn reconnect_gree_cloud(State(state): State) -> Result, AppError> { let settings = state.settings.read().await.gree_cloud.clone(); if !settings.enabled { return Err(AppError::BadRequest("GREE Cloud is disabled".into())); } let devices = state.db.list_devices()?; state.providers.cloud().shutdown().await; state .providers .cloud() .ensure_connected(&settings, &devices) .await .map_err(|error| AppError::Dependency(cloud_public_error_text(&error.to_string())))?; let now = Utc::now(); { let mut runtime = state.settings.write().await; runtime.gree_cloud.last_successful_contact = Some(now); state.db.save_runtime_settings(&runtime)?; } state.log( "info", "gree_cloud.reconnect", "GREE Cloud MQTT reconnected", json!({"device_count": devices.iter().filter(|device| device.connection_type == ConnectionType::GreeCloud).count()}), ); Ok(Json( json!({"ok": true, "mqtt_status": "connected", "last_successful_contact": now}), )) } async fn cloud_device_diagnostics( 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( "Cloud diagnostics are available only for GREE Cloud devices".into(), )); } let diagnostics = state.providers.cloud().diagnostics(&device.id).await; Ok(Json(json!({ "device_id": device.id, "cloud_device_id": device.cloud_device_id, "connection_status": device.connection_status, "last_sync": device.last_cloud_sync, "capabilities": device.capabilities, "provider": diagnostics, }))) } fn cloud_public_error_text(error: &str) -> String { let lower = error.to_ascii_lowercase(); if lower.contains("password") || lower.contains("token") || lower.contains("authorization") { "GREE Cloud authentication failed".into() } else { error.chars().take(300).collect() } }