Files
gree-controller/src/api/devices.rs
T
2026-09-14 16:32:28 +02:00

486 lines
18 KiB
Rust

async fn discover(
State(state): State<AppState>,
Json(request): Json<DiscoveryRequest>,
) -> Result<Json<Value>, AppError> {
let _configuration_guard = state.lock_configuration_operation().await;
let settings = state.settings.read().await.clone();
let timeout_ms = request
.timeout_ms
.unwrap_or(settings.discovery_timeout_ms)
.clamp(500, 30_000);
let broadcast = request.broadcast.unwrap_or(settings.discovery_broadcast);
let protocol_version = request.protocol_version.unwrap_or(0).min(2);
let passes = request.passes.unwrap_or(3).clamp(1, 10);
let discovered = state
.providers
.local()
.client()
.discover(
&broadcast,
Duration::from_millis(timeout_ms),
protocol_version,
passes,
)
.await
.map_err(|e| AppError::Device(e.to_string()))?;
let mut saved = Vec::new();
let mut new_device_ids = Vec::new();
for item in discovered {
let existing = state.db.get_device_by_mac(&item.mac)?;
let is_new = existing.is_none();
let mut merged = merge_discovered(existing, item);
let _device_guard = state.lock_device_operation(&merged.id).await;
// A poll/command may have updated the same known device between discovery and
// acquiring its operation lock. Re-merge against the freshest persisted state.
if !is_new {
if let Some(current) = state.db.get_device(&merged.id)? {
merged = merge_discovered(Some(current), merged);
}
}
// 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.providers.local().client().bind(&merged).await {
Ok(bound) => {
merged.key = Some(bound.key);
merged.protocol_version = bound.protocol_version;
merged.communication_failures = 0;
merged.last_error = None;
}
Err(err) => {
merged.last_error = Some(format!("discovered, bind pending: {err}"));
state.log(
"warn",
"device.bind_after_discovery",
&format!("{}: {err}", merged.name),
json!({"device_id": merged.id}),
);
}
}
}
state.db.save_device(&merged)?;
if is_new {
new_device_ids.push(merged.id.clone());
}
saved.push(merged);
}
state.log("info", "discovery.complete", &format!("Discovery found {} device(s)", saved.len()), json!({"count": saved.len(), "protocol_version": protocol_version, "passes": passes, "new_devices": new_device_ids.len()}));
state.broadcast("devices.discovered", json!({"devices": saved}));
Ok(Json(
json!({"count": saved.len(), "devices": saved, "new_device_ids": new_device_ids}),
))
}
async fn list_devices(State(state): State<AppState>) -> Result<Json<Vec<Device>>, AppError> {
Ok(Json(state.db.list_devices()?))
}
async fn add_device(
State(state): State<AppState>,
Json(input): Json<ManualDeviceRequest>,
) -> Result<(StatusCode, Json<Device>), AppError> {
let _configuration_guard = state.lock_configuration_operation().await;
if input.name.trim().is_empty() || input.mac.trim().is_empty() || input.ip.trim().is_empty() {
return Err(AppError::BadRequest("name, mac and ip are required".into()));
}
input
.ip
.parse::<IpAddr>()
.map_err(|_| AppError::BadRequest("invalid IP address".into()))?;
if state.db.get_device_by_mac(&input.mac)?.is_some() {
return Err(AppError::BadRequest(
"a device with this MAC already exists".into(),
));
}
let now = Utc::now();
let normalized_mac = input.mac.replace([':', '-'], "").to_ascii_uppercase();
let device = 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),
model: String::new(),
firmware: String::new(),
key: input.key.filter(|v| !v.trim().is_empty()),
cid: Some("app".into()),
enabled: true,
simulated: input.simulated,
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: true,
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: if input.simulated { Some(25.0) } else { None },
outdoor_temperature: None,
temperature_sensor_offset: None,
online: input.simulated,
response_time_ms: if input.simulated { Some(0) } else { None },
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,
};
state.db.save_device(&device)?;
state.log(
"info",
"device.created",
&format!("Added {}", device.name),
json!({"device_id": device.id}),
);
state.broadcast("device.created", serde_json::to_value(&device)?);
Ok((StatusCode::CREATED, Json(device)))
}
async fn get_device(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<Json<Device>, AppError> {
state
.db
.get_device(&id)?
.map(Json)
.ok_or_else(|| AppError::NotFound(format!("device {id}")))
}
async fn patch_device(
State(state): State<AppState>,
Path(id): Path<String>,
Json(patch): Json<DevicePatch>,
) -> Result<Json<Device>, AppError> {
let _configuration_guard = state.lock_configuration_operation().await;
if patch.enabled == Some(false) {
engine::disable_device_safely(&state, &id).await?;
}
let _device_guard = state.lock_device_operation(&id).await;
let mut device = state
.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();
}
}
if let Some(v) = patch.ip {
v.parse::<IpAddr>()
.map_err(|_| AppError::BadRequest("invalid IP address".into()))?;
device.ip = v;
}
if let Some(v) = patch.port {
device.port = v;
}
if let Some(v) = patch.protocol_version {
let v = v.min(2);
if device.protocol_version != v {
device.protocol_version = v;
device.key = None;
device.supports_light = None;
device.supports_quiet = None;
device.supports_turbo = None;
device.supports_air = None;
device.supports_xfan = None;
device.supports_health = None;
device.supports_sleep = None;
}
}
if let Some(v) = patch.key {
device.key = v.filter(|x| !x.trim().is_empty());
}
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)?);
// Enabling or changing a thermostat device should be reflected by the arbiter without
// waiting for the periodic loop. The device lock above keeps the edit ordered against
// polling and an in-flight thermostat command.
state.wake_zone_control();
Ok(Json(device))
}
async fn delete_device(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<StatusCode, AppError> {
let _configuration_guard = state.lock_configuration_operation().await;
// Keep reference validation and the destructive DB operation in one serialized window.
// Lock order for cross-resource destructive operations: configuration -> automation -> house -> schedule -> cycle -> zones -> device.
let _automation_guard = state.lock_automation_operation().await;
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;
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(),
));
}
let removed_zone_ids: std::collections::HashSet<String> = state
.db
.list_zones()?
.into_iter()
.filter(|zone| zone.device_id == id)
.map(|zone| zone.id)
.collect();
let mut sorted_zone_ids: Vec<String> = removed_zone_ids.iter().cloned().collect();
sorted_zone_ids.sort();
let mut zone_guards = Vec::with_capacity(sorted_zone_ids.len());
for zone_id in &sorted_zone_ids {
zone_guards.push(state.lock_zone_operation(zone_id).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}")));
}
drop(zone_guards);
remove_zone_ids_from_groups_locked(&state, &removed_zone_ids).await?;
state.log(
"info",
"device.deleted",
"Device deleted",
json!({"device_id": id, "connection_type": device.connection_type}),
);
state.broadcast("device.deleted", json!({"id": id}));
state.wake_zone_control();
Ok(StatusCode::NO_CONTENT)
}
async fn bind_device(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<Json<Device>, AppError> {
let _configuration_guard = state.lock_configuration_operation().await;
let _device_guard = state.lock_device_operation(&id).await;
let mut device = state
.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
.providers
.local()
.bind(&device)
.await
.map_err(|e| AppError::Device(e.to_string()))?;
device.key = Some(bound.key);
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();
state.db.save_device(&device)?;
state.broadcast("device.updated", serde_json::to_value(&device)?);
state.log(
"info",
"device.bound",
&format!("Bound {}", device.name),
json!({"device_id": id}),
);
Ok(Json(device))
}
async fn poll_device(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<Json<Device>, AppError> {
Ok(Json(engine::poll_one(&state, &id).await?))
}
async fn probe_device(
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("UDP probe is only available for Local/LAN devices".into()));
}
let response_time_ms = state
.providers
.local()
.client()
.probe(&device)
.await
.map_err(|err| AppError::Device(err.to_string()))?;
Ok(Json(json!({
"device_id": device.id,
"response_time_ms": response_time_ms,
"ok": true
})))
}
#[derive(Debug, Deserialize)]
struct ManualDeviceCommandRequest {
#[serde(flatten)]
command: DeviceCommand,
#[serde(default)]
manual_override: bool,
}
async fn command_device(
State(state): State<AppState>,
Path(id): Path<String>,
Json(request): Json<ManualDeviceCommandRequest>,
) -> Result<Json<Device>, AppError> {
Ok(Json(
engine::send_manual_command(
&state,
&id,
request.command,
"device.manual_control",
request.manual_override,
)
.await?,
))
}
async fn command_home_assistant_device(
State(state): State<AppState>,
Path(id): Path<String>,
Json(command): Json<DeviceCommand>,
) -> Result<Json<Device>, AppError> {
Ok(Json(
engine::send_manual_command(
&state,
&id,
command,
"home_assistant.device_manual_control",
false,
)
.await?,
))
}