456 lines
16 KiB
Rust
456 lines
16 KiB
Rust
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<crate::protocol::gree_cloud::GreeCloudApi, AppError> {
|
|
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<AppState>) -> Result<Json<Value>, 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::<usize, anyhow::Error>(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<AppState>,
|
|
) -> Result<Json<Value>, 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::<Vec<_>>();
|
|
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<AppState>,
|
|
Path(cloud_id): Path<String>,
|
|
) -> Result<(StatusCode, Json<Device>), 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<AppState>) -> Result<Json<Value>, 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::<Vec<_>>();
|
|
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<AppState>) -> Result<Json<Value>, 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<AppState>,
|
|
Path(id): Path<String>,
|
|
) -> Result<Json<Value>, 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()
|
|
}
|
|
}
|