246 lines
8.7 KiB
Rust
246 lines
8.7 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,
|
|
connection_type: ConnectionType::Local,
|
|
connection_status: ConnectionStatus::Unknown,
|
|
cloud_device_id: None,
|
|
cloud_parent_mac: None,
|
|
cloud_account_id: None,
|
|
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,
|
|
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: 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,
|
|
}))
|
|
}
|
|
}
|