162 lines
7.1 KiB
Rust
162 lines
7.1 KiB
Rust
impl GreeClient {
|
|
/// protocol_filter: 0=auto/both, 1=ECB only, 2=GCM only.
|
|
pub async fn discover(&self, broadcast: &str, duration: Duration, protocol_filter: u8, passes: u8) -> Result<Vec<Device>> {
|
|
let target = self.discovery_target(broadcast)?;
|
|
let target_hint = match target { SocketAddr::V4(addr) => Some(*addr.ip()), SocketAddr::V6(_) => None };
|
|
let socket = self.udp_socket(true, target_hint).await?;
|
|
let local = socket.local_addr()?;
|
|
let passes = passes.clamp(1, 10);
|
|
tracing::info!(
|
|
target = %target,
|
|
local = %local,
|
|
interface = %self.interface.as_deref().unwrap_or("auto"),
|
|
protocol = protocol_filter,
|
|
passes,
|
|
controller_id = %self.controller_id,
|
|
"Starting GREE discovery"
|
|
);
|
|
|
|
let deadline = Instant::now() + duration;
|
|
let interval = if passes > 1 { duration / passes as u32 } else { duration };
|
|
let mut next_scan = Instant::now();
|
|
let mut sent = 0_u8;
|
|
let mut result = Vec::new();
|
|
let mut seen = HashSet::new();
|
|
let mut buffer = vec![0_u8; 16 * 1024];
|
|
|
|
while Instant::now() < deadline {
|
|
if sent < passes && Instant::now() >= next_scan {
|
|
socket.send_to(br#"{"t":"scan"}"#, target).await?;
|
|
sent += 1;
|
|
next_scan = Instant::now() + interval.max(Duration::from_millis(250));
|
|
tracing::debug!(pass = sent, passes, target = %target, "Sent GREE discovery packet");
|
|
}
|
|
|
|
let remaining = deadline.saturating_duration_since(Instant::now());
|
|
let wait = remaining.min(Duration::from_millis(250));
|
|
match timeout(wait, socket.recv_from(&mut buffer)).await {
|
|
Ok(Ok((size, source))) => {
|
|
let Ok(value) = serde_json::from_slice::<Value>(&buffer[..size]) else { continue; };
|
|
match self.parse_discovery(value, source) {
|
|
Ok(Some(mut device)) => {
|
|
if protocol_filter != 0 && device.protocol_version != protocol_filter { continue; }
|
|
let key = device.mac.to_ascii_lowercase();
|
|
if seen.insert(key) {
|
|
device.last_seen = Some(Utc::now());
|
|
tracing::info!(ip=%device.ip, mac=%device.mac, protocol=device.protocol_version, model=%device.model, firmware=%device.firmware, "Discovered GREE device");
|
|
result.push(device);
|
|
}
|
|
}
|
|
Ok(None) => {}
|
|
Err(err) => tracing::debug!(source=%source, error=?err, "Ignoring undecodable discovery response"),
|
|
}
|
|
}
|
|
Ok(Err(err)) => return Err(err.into()),
|
|
Err(_) => continue,
|
|
}
|
|
}
|
|
Ok(result)
|
|
}
|
|
|
|
fn parse_discovery(&self, mut value: Value, source: SocketAddr) -> Result<Option<Device>> {
|
|
let mut detected_protocol = 1_u8;
|
|
if value.get("t").and_then(Value::as_str) == Some("pack") {
|
|
if let Some(pack_value) = value.get("pack") {
|
|
if let Some(pack) = pack_value.as_str() {
|
|
let clear = if let Some(tag) = value.get("tag").and_then(Value::as_str) {
|
|
detected_protocol = 2;
|
|
decrypt_v2(GENERIC_GREE_V2_KEY, pack, tag)?
|
|
} else {
|
|
decrypt_v1(GENERIC_GREE_V1_KEY, pack)?
|
|
};
|
|
value = serde_json::from_slice::<Value>(&clear).context("invalid decrypted discovery JSON")?;
|
|
} else if pack_value.is_object() {
|
|
value = pack_value.clone();
|
|
}
|
|
}
|
|
}
|
|
let kind = value.get("t").and_then(Value::as_str).unwrap_or_default().to_ascii_lowercase();
|
|
if kind != "dev" && kind != "scan" && value.get("mac").is_none() && value.get("cid").is_none() {
|
|
return Ok(None);
|
|
}
|
|
let mac = value.get("mac")
|
|
.or_else(|| value.get("cid"))
|
|
.and_then(Value::as_str)
|
|
.unwrap_or_default()
|
|
.replace([':', '-'], "").to_ascii_uppercase();
|
|
if mac.is_empty() { return Ok(None); }
|
|
|
|
let raw_model = value.get("model").or_else(|| value.get("series"))
|
|
.and_then(Value::as_str).unwrap_or_default().trim().to_string();
|
|
let model_type = value.get("ModelType")
|
|
.and_then(|v| v.as_str().map(str::to_string).or_else(|| v.as_i64().map(|n| n.to_string())))
|
|
.unwrap_or_default();
|
|
let model = if !model_type.is_empty() && (raw_model.is_empty() || raw_model.eq_ignore_ascii_case("gree")) {
|
|
format!("GREE {model_type}")
|
|
} else if raw_model.is_empty() {
|
|
"GREE".to_string()
|
|
} else {
|
|
raw_model
|
|
};
|
|
let ver = value.get("ver").and_then(Value::as_str).unwrap_or_default().trim();
|
|
let hid = value.get("hid").and_then(Value::as_str).unwrap_or_default().trim();
|
|
let firmware = match (ver.is_empty(), hid.is_empty()) {
|
|
(false, false) => format!("{ver} · {hid}"),
|
|
(false, true) => ver.to_string(),
|
|
(true, false) => hid.to_string(),
|
|
(true, true) => String::new(),
|
|
};
|
|
let suffix = mac.chars().rev().take(4).collect::<String>().chars().rev().collect::<String>().to_ascii_uppercase();
|
|
let name = value.get("name").and_then(Value::as_str)
|
|
.map(str::trim).filter(|v| !v.is_empty())
|
|
.map(str::to_string)
|
|
.unwrap_or_else(|| format!("{model} {suffix}"));
|
|
let now = Utc::now();
|
|
Ok(Some(Device {
|
|
id: format!("gree-{}", mac.to_ascii_lowercase()),
|
|
mac,
|
|
name,
|
|
ip: source.ip().to_string(),
|
|
port: if source.port() == 0 { 7000 } else { source.port() },
|
|
protocol_version: detected_protocol,
|
|
model,
|
|
firmware,
|
|
key: None,
|
|
cid: Some("app".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,
|
|
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,
|
|
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,
|
|
created_at: now,
|
|
updated_at: now,
|
|
}))
|
|
}
|
|
|
|
}
|