This commit is contained in:
Mateusz Gruszczyński
2026-09-18 11:21:48 +02:00
parent 3ffb02595e
commit 8d96ad2d38
29 changed files with 758 additions and 256 deletions
+226 -51
View File
@@ -1,15 +1,130 @@
async fn discover(
State(state): State<AppState>,
Json(request): Json<DiscoveryRequest>,
) -> Result<Json<Value>, AppError> {
let _configuration_guard = state.lock_configuration_operation().await;
fn normalize_local_discovery_mac(value: &str) -> String {
value.replace([':', '-'], "").trim().to_ascii_uppercase()
}
fn local_discovery_candidate(device: &Device, already_added: bool) -> LocalDiscoveryCandidate {
LocalDiscoveryCandidate {
name: device.name.clone(),
mac: device.mac.clone(),
ip: device.ip.clone(),
port: device.port,
protocol_version: device.protocol_version,
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 !matches!(candidate.protocol_version, 1 | 2) {
return Err(AppError::BadRequest(format!(
"invalid protocol version 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).min(2);
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
@@ -23,52 +138,112 @@ async fn discover(
)
.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);
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));
}
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}),
))
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 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;
}
match state
.providers
.local()
.client()
.bind_exact(&device, device.protocol_version)
.await
{
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,
}),
);
}
}
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> {