This commit is contained in:
Mateusz Gruszczyński
2026-09-18 12:26:48 +02:00
parent 8d96ad2d38
commit 00cbe975bb
21 changed files with 231 additions and 106 deletions
+30 -10
View File
@@ -2,13 +2,18 @@ fn normalize_local_discovery_mac(value: &str) -> String {
value.replace([':', '-'], "").trim().to_ascii_uppercase()
}
fn local_discovery_candidate(device: &Device, already_added: bool) -> LocalDiscoveryCandidate {
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,
@@ -24,11 +29,16 @@ fn device_from_local_discovery(candidate: LocalDiscoveryCandidate) -> Result<Dev
.ip
.parse::<IpAddr>()
.map_err(|_| AppError::BadRequest(format!("invalid IP address for {mac}")))?;
if !matches!(candidate.protocol_version, 1 | 2) {
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 };
@@ -151,7 +161,11 @@ async fn scan_discovery(
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));
candidates.push(local_discovery_candidate(
&device,
already_added,
protocol_version != 0,
));
}
state.log(
@@ -188,6 +202,7 @@ async fn add_discovered_devices(
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() {
@@ -195,13 +210,17 @@ async fn add_discovered_devices(
continue;
}
match state
.providers
.local()
.client()
.bind_exact(&device, device.protocol_version)
.await
{
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;
@@ -217,6 +236,7 @@ async fn add_discovered_devices(
json!({
"device_id": device.id,
"protocol_version": device.protocol_version,
"protocol_locked": protocol_locked,
}),
);
}
+6 -1
View File
@@ -124,8 +124,13 @@ pub struct LocalDiscoveryCandidate {
pub ip: String,
#[serde(default = "default_port")]
pub port: u16,
/// Detected protocol: 1 = AES-ECB, 2 = AES-GCM.
/// Discovery protocol hint: 0 = unknown/auto, 1 = AES-ECB, 2 = AES-GCM.
pub protocol_version: u8,
/// True only when the discovery request explicitly selected V1 or V2.
/// Auto discovery keeps this false so binding can verify/fallback and store
/// the protocol that actually succeeds.
#[serde(default)]
pub protocol_locked: bool,
#[serde(default)]
pub model: String,
#[serde(default)]
+1 -1
View File
@@ -4,7 +4,7 @@ impl GreeClient {
self.bind_with_versions(device, versions).await
}
/// Bind using exactly the protocol detected during discovery.
/// Bind using exactly the requested protocol. Used by explicit V1/V2 discovery.
pub async fn bind_exact(&self, device: &Device, protocol_version: u8) -> Result<BindResult> {
let versions = Self::exact_bind_versions(protocol_version)?;
self.bind_with_versions(device, versions).await
+38 -13
View File
@@ -74,40 +74,65 @@ impl GreeClient {
Ok(result)
}
fn discovery_protocol_hint(value: &Value, envelope_protocol: u8) -> u8 {
// A GCM envelope proves V2 support. A V1/plain discovery envelope does
// not prove V1 because newer modules also advertise themselves that way.
if envelope_protocol == 2 {
return 2;
}
let ver = value
.get("ver")
.and_then(Value::as_str)
.unwrap_or_default()
.trim();
let ver = ver
.strip_prefix('V')
.or_else(|| ver.strip_prefix('v'))
.unwrap_or(ver);
let major = ver
.split('.')
.next()
.and_then(|part| part.parse::<u16>().ok());
match major {
Some(1) => 1,
Some(value) if value >= 2 => 2,
_ => 0,
}
}
fn parse_discovery(
&self,
mut value: Value,
source: SocketAddr,
protocol_filter: u8,
) -> Result<Option<Device>> {
let mut detected_protocol = 1_u8;
// Discovery transport is not the same thing as the protocol used for
// bind/status/commands. In particular, AES-GCM capable modules can
// answer the common scan packet using a legacy/plain V1-style envelope.
// Keep the envelope only as a hint and resolve the device generation
// from the inner discovery payload after it has been decoded.
let mut envelope_protocol = 0_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 packet_protocol = if value.get("tag").and_then(Value::as_str).is_some() {
2
} else {
1
};
if protocol_filter != 0 && packet_protocol != protocol_filter {
return Ok(None);
}
let clear = if let Some(tag) = value.get("tag").and_then(Value::as_str) {
detected_protocol = 2;
envelope_protocol = 2;
decrypt_v2(GENERIC_GREE_V2_KEY, pack, tag)?
} else {
envelope_protocol = 1;
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() {
if protocol_filter == 2 {
return Ok(None);
}
value = pack_value.clone();
}
}
}
let detected_protocol = Self::discovery_protocol_hint(&value, envelope_protocol);
if protocol_filter != 0 && detected_protocol != protocol_filter {
return Ok(None);
}
+65 -7
View File
@@ -120,7 +120,7 @@ mod tests {
}
#[test]
fn discovery_bind_uses_detected_protocol_exactly() {
fn bind_version_selection_supports_exact_and_auto_fallback() {
assert_eq!(GreeClient::exact_bind_versions(1).unwrap(), &[1]);
assert_eq!(GreeClient::exact_bind_versions(2).unwrap(), &[2]);
assert!(GreeClient::exact_bind_versions(0).is_err());
@@ -130,7 +130,7 @@ mod tests {
}
#[test]
fn discovery_protocol_filter_rejects_other_envelope_before_decode() {
fn discovery_uses_inner_version_as_protocol_hint() {
let client = GreeClient::new(
"test-controller".into(),
None,
@@ -138,20 +138,78 @@ mod tests {
Arc::new(AtomicBool::new(false)),
);
let source: SocketAddr = "192.0.2.20:7000".parse().unwrap();
let v1 = json!({
"t": "pack",
"pack": {
"t": "dev",
"mac": "502CC699C117",
"name": "Legacy",
"ver": "V1.2.1"
}
});
let v2_inner = json!({
"t": "dev",
"mac": "9424B80C5AB9",
"name": "Modern",
"ver": "V3.4"
});
let v2 = json!({
"t": "pack",
// Reproduce the mixed-generation case: the device is V2-capable,
// but its discovery payload is carried in the legacy V1 envelope.
"pack": encrypt_v1(
GENERIC_GREE_V1_KEY,
&serde_json::to_vec(&v2_inner).unwrap(),
)
.unwrap()
});
let legacy = client
.parse_discovery(v1.clone(), source, 0)
.unwrap()
.expect("V1 discovery");
let modern = client
.parse_discovery(v2.clone(), source, 0)
.unwrap()
.expect("V2 discovery");
assert_eq!(legacy.protocol_version, 1);
assert_eq!(modern.protocol_version, 2);
assert!(client.parse_discovery(v1.clone(), source, 1).unwrap().is_some());
assert!(client.parse_discovery(v1, source, 2).unwrap().is_none());
assert!(client.parse_discovery(v2.clone(), source, 2).unwrap().is_some());
assert!(client.parse_discovery(v2, source, 1).unwrap().is_none());
}
#[test]
fn discovery_does_not_treat_plain_or_v1_envelope_as_definitive_v1() {
let client = GreeClient::new(
"test-controller".into(),
None,
None,
Arc::new(AtomicBool::new(false)),
);
let source: SocketAddr = "192.0.2.21:7000".parse().unwrap();
let unknown = json!({
"t": "pack",
"pack": {
"t": "dev",
"mac": "AABBCCDDEEFF",
"name": "Test"
"name": "Unknown"
}
});
assert!(client
.parse_discovery(v1.clone(), source, 1)
let candidate = client
.parse_discovery(unknown.clone(), source, 0)
.unwrap()
.is_some());
assert!(client.parse_discovery(v1, source, 2).unwrap().is_none());
.expect("auto discovery must retain unknown protocol");
assert_eq!(candidate.protocol_version, 0);
assert!(client
.parse_discovery(unknown.clone(), source, 1)
.unwrap()
.is_none());
assert!(client.parse_discovery(unknown, source, 2).unwrap().is_none());
}
}