v0.14.0
This commit is contained in:
+125
-14
@@ -12,7 +12,9 @@ async fn discover(
|
||||
let protocol_version = request.protocol_version.unwrap_or(0).min(2);
|
||||
let passes = request.passes.unwrap_or(3).clamp(1, 10);
|
||||
let discovered = state
|
||||
.gree
|
||||
.providers
|
||||
.local()
|
||||
.client()
|
||||
.discover(
|
||||
&broadcast,
|
||||
Duration::from_millis(timeout_ms),
|
||||
@@ -38,7 +40,7 @@ async fn discover(
|
||||
// Bind right after discovery. GREE modules can have a short bind window;
|
||||
// bind() also refreshes it with a direct scan before the handshake.
|
||||
if !merged.simulated && merged.key.as_deref().unwrap_or_default().is_empty() {
|
||||
match state.gree.bind(&merged).await {
|
||||
match state.providers.local().client().bind(&merged).await {
|
||||
Ok(bound) => {
|
||||
merged.key = Some(bound.key);
|
||||
merged.protocol_version = bound.protocol_version;
|
||||
@@ -96,6 +98,11 @@ async fn add_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),
|
||||
@@ -126,6 +133,11 @@ async fn add_device(
|
||||
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,
|
||||
@@ -134,6 +146,13 @@ async fn add_device(
|
||||
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,
|
||||
};
|
||||
@@ -173,6 +192,13 @@ async fn patch_device(
|
||||
.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();
|
||||
@@ -206,6 +232,44 @@ async fn patch_device(
|
||||
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)?);
|
||||
@@ -227,13 +291,17 @@ async fn delete_device(
|
||||
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;
|
||||
if state.db.get_device(&id)?.is_none() {
|
||||
return Err(AppError::NotFound(format!("device {id}")));
|
||||
}
|
||||
if state.db.list_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)
|
||||
}) {
|
||||
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(),
|
||||
));
|
||||
@@ -251,8 +319,40 @@ async fn delete_device(
|
||||
for zone_id in &sorted_zone_ids {
|
||||
zone_guards.push(state.lock_zone_operation(zone_id).await);
|
||||
}
|
||||
ensure_zone_removal_safe(&state, &removed_zone_ids)?;
|
||||
ensure_device_stopped_for_detach(&state, &id, "device.deleted").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<String> = 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}")));
|
||||
}
|
||||
@@ -262,9 +362,10 @@ async fn delete_device(
|
||||
"info",
|
||||
"device.deleted",
|
||||
"Device deleted",
|
||||
json!({"device_id": id}),
|
||||
json!({"device_id": id, "connection_type": device.connection_type}),
|
||||
);
|
||||
state.broadcast("device.deleted", json!({"id": id}));
|
||||
state.wake_zone_control();
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
@@ -278,11 +379,15 @@ async fn bind_device(
|
||||
.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
|
||||
.gree
|
||||
.providers
|
||||
.local()
|
||||
.bind(&device)
|
||||
.await
|
||||
.map_err(|e| AppError::Device(e.to_string()))?;
|
||||
@@ -290,6 +395,7 @@ async fn bind_device(
|
||||
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();
|
||||
@@ -319,8 +425,13 @@ async fn probe_device(
|
||||
.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
|
||||
.gree
|
||||
.providers
|
||||
.local()
|
||||
.client()
|
||||
.probe(&device)
|
||||
.await
|
||||
.map_err(|err| AppError::Device(err.to_string()))?;
|
||||
|
||||
Reference in New Issue
Block a user