715 lines
24 KiB
Rust
715 lines
24 KiB
Rust
fn normalize_local_discovery_mac(value: &str) -> String {
|
|
value.replace([':', '-'], "").trim().to_ascii_uppercase()
|
|
}
|
|
|
|
fn local_discovery_candidate(
|
|
device: &Device,
|
|
already_added: bool,
|
|
protocol_locked: bool,
|
|
) -> LocalDiscoveryCandidate {
|
|
LocalDiscoveryCandidate {
|
|
name: device.name.clone(),
|
|
mac: device.mac.clone(),
|
|
ip: device.ip.clone(),
|
|
port: device.port,
|
|
protocol_version: device.protocol_version,
|
|
protocol_locked,
|
|
model: device.model.clone(),
|
|
firmware: device.firmware.clone(),
|
|
already_added,
|
|
}
|
|
}
|
|
|
|
fn device_from_local_discovery(candidate: LocalDiscoveryCandidate) -> Result<Device, AppError> {
|
|
let mac = normalize_local_discovery_mac(&candidate.mac);
|
|
if mac.is_empty() {
|
|
return Err(AppError::BadRequest("discovered device MAC is required".into()));
|
|
}
|
|
candidate
|
|
.ip
|
|
.parse::<IpAddr>()
|
|
.map_err(|_| AppError::BadRequest(format!("invalid IP address for {mac}")))?;
|
|
if candidate.protocol_version > 2 {
|
|
return Err(AppError::BadRequest(format!(
|
|
"invalid protocol version for {mac}"
|
|
)));
|
|
}
|
|
if candidate.protocol_locked && candidate.protocol_version == 0 {
|
|
return Err(AppError::BadRequest(format!(
|
|
"locked discovery protocol is missing for {mac}"
|
|
)));
|
|
}
|
|
|
|
let model = candidate.model.trim().to_string();
|
|
let fallback_model = if model.is_empty() { "GREE" } else { &model };
|
|
let suffix = mac
|
|
.chars()
|
|
.rev()
|
|
.take(4)
|
|
.collect::<String>()
|
|
.chars()
|
|
.rev()
|
|
.collect::<String>();
|
|
let name = if candidate.name.trim().is_empty() {
|
|
format!("{fallback_model} {suffix}")
|
|
} else {
|
|
candidate.name.trim().to_string()
|
|
};
|
|
let now = Utc::now();
|
|
|
|
Ok(Device {
|
|
id: format!("gree-{}", mac.to_ascii_lowercase()),
|
|
mac,
|
|
name,
|
|
connection_type: ConnectionType::Local,
|
|
connection_status: ConnectionStatus::Unknown,
|
|
cloud_device_id: None,
|
|
cloud_parent_mac: None,
|
|
cloud_account_id: None,
|
|
ip: candidate.ip,
|
|
port: if candidate.port == 0 { 7000 } else { candidate.port },
|
|
protocol_version: candidate.protocol_version,
|
|
model,
|
|
firmware: candidate.firmware.trim().to_string(),
|
|
key: None,
|
|
cid: Some("app".into()),
|
|
enabled: true,
|
|
simulated: false,
|
|
power: false,
|
|
mode: "cool".into(),
|
|
target_temperature: 24.0,
|
|
fan_speed: 0,
|
|
swing_vertical: 0,
|
|
swing_horizontal: 0,
|
|
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: None,
|
|
outdoor_temperature: None,
|
|
temperature_sensor_offset: None,
|
|
online: true,
|
|
response_time_ms: None,
|
|
last_seen: Some(now),
|
|
last_error: None,
|
|
communication_failures: 0,
|
|
pending_command: false,
|
|
capabilities: crate::models::DeviceCapabilities::default(),
|
|
energy_source: 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,
|
|
})
|
|
}
|
|
|
|
async fn run_local_discovery(
|
|
state: &AppState,
|
|
request: DiscoveryRequest,
|
|
) -> Result<(u8, u8, Vec<Device>), AppError> {
|
|
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);
|
|
if protocol_version > 2 {
|
|
return Err(AppError::BadRequest("protocol_version must be 0, 1 or 2".into()));
|
|
}
|
|
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()))?;
|
|
Ok((protocol_version, passes, discovered))
|
|
}
|
|
|
|
/// Scan for local GREE units without persisting or binding them.
|
|
async fn scan_discovery(
|
|
State(state): State<AppState>,
|
|
Json(request): Json<DiscoveryRequest>,
|
|
) -> Result<Json<Value>, AppError> {
|
|
let (protocol_version, passes, discovered) = run_local_discovery(&state, request).await?;
|
|
let mut candidates = Vec::with_capacity(discovered.len());
|
|
for device in discovered {
|
|
let mac = normalize_local_discovery_mac(&device.mac);
|
|
let already_added = state.db.get_device_by_mac(&mac)?.is_some();
|
|
candidates.push(local_discovery_candidate(
|
|
&device,
|
|
already_added,
|
|
protocol_version != 0,
|
|
));
|
|
}
|
|
|
|
state.log(
|
|
"info",
|
|
"discovery.scan_complete",
|
|
&format!("Discovery scan found {} device(s)", candidates.len()),
|
|
json!({
|
|
"count": candidates.len(),
|
|
"protocol_version": protocol_version,
|
|
"passes": passes,
|
|
"persisted": false,
|
|
}),
|
|
);
|
|
Ok(Json(json!({
|
|
"count": candidates.len(),
|
|
"devices": candidates,
|
|
})))
|
|
}
|
|
|
|
async fn add_discovered_devices(
|
|
State(state): State<AppState>,
|
|
Json(request): Json<AddDiscoveredDevicesRequest>,
|
|
) -> Result<Json<Value>, AppError> {
|
|
let _configuration_guard = state.lock_configuration_operation().await;
|
|
if request.devices.is_empty() {
|
|
return Err(AppError::BadRequest("select at least one discovered device".into()));
|
|
}
|
|
if request.devices.len() > 64 {
|
|
return Err(AppError::BadRequest("too many discovered devices selected".into()));
|
|
}
|
|
|
|
let selected_count = request.devices.len();
|
|
let mut added = Vec::new();
|
|
let mut skipped = Vec::new();
|
|
|
|
for candidate in request.devices {
|
|
let protocol_locked = candidate.protocol_locked;
|
|
let mut device = device_from_local_discovery(candidate)?;
|
|
let _device_guard = state.lock_device_operation(&device.id).await;
|
|
if state.db.get_device_by_mac(&device.mac)?.is_some() {
|
|
skipped.push(device.mac.clone());
|
|
continue;
|
|
}
|
|
|
|
let client = state.providers.local().client();
|
|
let bind_result = if protocol_locked {
|
|
client.bind_exact(&device, device.protocol_version).await
|
|
} else {
|
|
// Auto discovery only provides a protocol hint. Try that generation
|
|
// first, fall back to the other one, and persist the protocol that
|
|
// actually completes binding.
|
|
client.bind(&device).await
|
|
};
|
|
|
|
match bind_result {
|
|
Ok(bound) => {
|
|
device.key = Some(bound.key);
|
|
device.protocol_version = bound.protocol_version;
|
|
device.communication_failures = 0;
|
|
device.last_error = None;
|
|
}
|
|
Err(err) => {
|
|
device.last_error = Some(format!("added, bind pending: {err}"));
|
|
state.log(
|
|
"warn",
|
|
"device.bind_after_discovery",
|
|
&format!("{}: {err}", device.name),
|
|
json!({
|
|
"device_id": device.id,
|
|
"protocol_version": device.protocol_version,
|
|
"protocol_locked": protocol_locked,
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
|
|
state.db.save_device(&device)?;
|
|
added.push(device);
|
|
}
|
|
|
|
state.log(
|
|
"info",
|
|
"discovery.devices_added",
|
|
&format!("Added {} discovered device(s)", added.len()),
|
|
json!({
|
|
"selected": selected_count,
|
|
"added": added.len(),
|
|
"skipped": skipped.len(),
|
|
}),
|
|
);
|
|
if !added.is_empty() {
|
|
state.broadcast("devices.discovered", json!({"devices": added}));
|
|
}
|
|
Ok(Json(json!({
|
|
"count": added.len(),
|
|
"devices": added,
|
|
"skipped_macs": skipped,
|
|
})))
|
|
}
|
|
|
|
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: 0,
|
|
swing_horizontal: 0,
|
|
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?,
|
|
))
|
|
}
|