v0.14.0
This commit is contained in:
@@ -0,0 +1,418 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user