Files
gree-controller/src/api/gree_cloud.rs
T
2026-09-17 12:55:29 +02:00

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()
}
}