This commit is contained in:
Mateusz Gruszczyński
2026-08-30 13:39:29 +02:00
parent 3e950ab5fa
commit 5c05eddb8f
83 changed files with 10130 additions and 9954 deletions
+10 -906
View File
@@ -29,910 +29,14 @@ pub struct GreeClient {
sleep_unsupported: Arc<Mutex<HashSet<String>>>,
}
impl GreeClient {
pub fn new(
controller_id: String,
interface: Option<String>,
debug_events: Option<broadcast::Sender<ApiEvent>>,
debug_gree_frames: Arc<AtomicBool>,
) -> Self {
Self {
controller_id,
interface,
debug_events,
debug_gree_frames,
received_frames_total: Arc::new(AtomicU64::new(0)),
received_frames_by_device: Arc::new(Mutex::new(HashMap::new())),
buzzer_unsupported: Arc::new(Mutex::new(HashSet::new())),
quiet_unsupported: Arc::new(Mutex::new(HashSet::new())),
sleep_unsupported: Arc::new(Mutex::new(HashSet::new())),
}
}
pub fn received_frame_stats(&self) -> (u64, HashMap<String, u64>) {
let total = self.received_frames_total.load(Ordering::Relaxed);
let by_device = self.received_frames_by_device.lock()
.map(|counts| counts.clone())
.unwrap_or_default();
(total, by_device)
}
fn record_received_frame(&self, device: &Device) {
let total = self.received_frames_total.fetch_add(1, Ordering::Relaxed).saturating_add(1);
let device_count = self.received_frames_by_device.lock().ok().map(|mut counts| {
let count = counts.entry(device.id.clone()).or_insert(0);
*count = (*count).saturating_add(1);
*count
}).unwrap_or(0);
if let Some(events) = &self.debug_events {
let _ = events.send(ApiEvent {
event: "gree.frame_received".into(),
timestamp: Utc::now(),
data: json!({
"device_id": device.id,
"device_name": device.name,
"total": total,
"device_count": device_count,
}),
});
}
}
fn debug_frame(&self, direction: &str, device: &Device, target: SocketAddr, protocol: u8, payload: &Value) {
if !self.debug_gree_frames.load(Ordering::Relaxed) { return; }
let Some(events) = &self.debug_events else { return; };
let mut safe = payload.clone();
if let Some(object) = safe.as_object_mut() {
if object.contains_key("key") { object.insert("key".into(), json!("***")); }
}
let _ = events.send(ApiEvent {
event: "gree.frame".into(),
timestamp: Utc::now(),
data: json!({
"direction": direction,
"device_id": device.id,
"device_name": device.name,
"target": target.to_string(),
"protocol_version": protocol,
"payload": safe,
}),
});
}
async fn udp_socket(&self, broadcast: bool, target_hint: Option<Ipv4Addr>) -> Result<UdpSocket> {
let socket = if let Some(interface) = self.interface.as_deref() {
let ip = interface_ipv4(interface)?;
UdpSocket::bind(SocketAddrV4::new(ip, 0)).await
.with_context(|| format!("cannot bind GREE UDP socket to {ip} from interface {interface}"))?
} else if let Some(target) = target_hint {
if let Some(config) = local_ipv4_config_for_target(target)? {
tracing::debug!(
target = %target,
interface = %config.interface,
local_ip = %config.ip,
"Automatically selected local interface for GREE UDP"
);
UdpSocket::bind(SocketAddrV4::new(config.ip, 0)).await
.with_context(|| format!("cannot bind GREE UDP socket to {} on {}", config.ip, config.interface))?
} else {
UdpSocket::bind("0.0.0.0:0").await?
}
} else {
UdpSocket::bind("0.0.0.0:0").await?
};
socket.set_broadcast(broadcast)?;
Ok(socket)
}
fn bind_scan_target(&self, target: SocketAddr) -> Result<SocketAddr> {
let SocketAddr::V4(target_v4) = target else { return Ok(target); };
let broadcast = if let Some(interface) = self.interface.as_deref() {
let (_, broadcast) = interface_ipv4_config(interface)?;
Some(broadcast)
} else {
local_ipv4_config_for_target(*target_v4.ip())?.map(|config| config.broadcast)
};
Ok(broadcast
.map(|ip| SocketAddr::V4(SocketAddrV4::new(ip, target_v4.port())))
.unwrap_or(target))
}
fn discovery_target(&self, configured: &str) -> Result<SocketAddr> {
let value = configured.trim();
if value.eq_ignore_ascii_case("auto") || value.to_ascii_lowercase().starts_with("auto:") {
let port = value.split_once(':')
.map(|(_, port)| port.parse::<u16>().context("invalid automatic discovery port"))
.transpose()?
.unwrap_or(7000);
let interface = self.interface.as_deref()
.ok_or_else(|| anyhow!("automatic discovery broadcast requires GREE_CONTROLLER_GREE_INTERFACE"))?;
let (_, broadcast) = interface_ipv4_config(interface)?;
return Ok(SocketAddr::V4(SocketAddrV4::new(broadcast, port)));
}
value.parse().context("invalid discovery broadcast address")
}
/// 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,
}))
}
pub async fn bind(&self, device: &Device) -> Result<BindResult> {
let versions: &[u8] = match device.protocol_version {
2 => &[2, 1],
_ => &[1, 2],
};
let mut errors = Vec::new();
for &version in versions {
match self.bind_attempt(device, version).await {
Ok(key) => return Ok(BindResult { key, protocol_version: version }),
Err(err) => {
tracing::warn!(device=%device.id, ip=%device.ip, protocol=version, error=?err, "GREE bind attempt failed");
errors.push(format!("V{version}: {err}"));
}
}
}
bail!("unable to bind device ({})", errors.join("; "))
}
/// GREE Wi-Fi modules use the 12-hex device id as a protocol identifier.
/// Older V1 modules (notably 502cc6...) can silently ignore bind/status
/// packets when tcid/mac casing differs from the lowercase value returned
/// by discovery. Keep the database/display representation independent from
/// the on-wire representation and always send canonical lowercase hex.
fn wire_mac(device: &Device) -> String {
device.mac.replace([':', '-'], "").to_ascii_lowercase()
}
async fn bind_attempt(&self, device: &Device, version: u8) -> Result<String> {
let target = self.device_target(device)?;
let target_hint = match target { SocketAddr::V4(addr) => Some(*addr.ip()), SocketAddr::V6(_) => None };
let socket = self.udp_socket(true, target_hint).await?;
// Binding is time-sensitive on older GREE Wi-Fi modules. Refresh the
// bind window with a subnet broadcast when the target is on a directly
// connected network. A unicast scan remains the fallback for routed
// deployments. Keep the same UDP socket for scan + bind.
let scan_target = self.bind_scan_target(target)?;
tracing::debug!(device=%device.id, target=%target, scan_target=%scan_target, local=%socket.local_addr()?, "Refreshing GREE bind window");
socket.send_to(br#"{"t":"scan"}"#, scan_target).await?;
let mut scan_buf = vec![0_u8; 16 * 1024];
let scan_deadline = Instant::now() + Duration::from_millis(1500);
while Instant::now() < scan_deadline {
let remaining = scan_deadline.saturating_duration_since(Instant::now());
match timeout(remaining, socket.recv_from(&mut scan_buf)).await {
Ok(Ok((_size, source))) if source.ip() == target.ip() => {
self.record_received_frame(device);
tracing::debug!(device=%device.id, source=%source, "Received scan response immediately before bind");
break;
}
Ok(Ok(_)) => continue,
Ok(Err(err)) => return Err(err.into()),
Err(_) => break,
}
}
let wire_mac = Self::wire_mac(device);
let inner = json!({"mac": wire_mac, "t": "bind", "uid": 0});
let generic_key = if version == 2 { GENERIC_GREE_V2_KEY } else { GENERIC_GREE_V1_KEY };
let response = self.request_on_socket(device, &inner, generic_key, true, version, &socket).await?;
let kind = response.get("t").and_then(Value::as_str).unwrap_or_default();
if !kind.eq_ignore_ascii_case("bindok") {
bail!("unexpected bind response type: {kind}")
}
let key = response.get("key").and_then(Value::as_str)
.ok_or_else(|| anyhow!("bind response does not contain device key"))?;
if key.is_empty() { bail!("device returned an empty key") }
Ok(key.to_string())
}
pub async fn poll(&self, device: &mut Device) -> Result<()> {
let key = device.key.clone().ok_or_else(|| anyhow!("device is not bound"))?;
let full_cols = [
"Pow","Mod","SetTem","WdSpd","Air","Blo","Health","SwhSlp","Lig",
"SwingLfRig","SwUpDn","Quiet","Tur","StHt","TemUn","HeatCoolType",
"TemRec","SvSt","TemSen","CoolSvTem","HeatSvTem","OutEnvTem"
];
let core_cols = ["Pow","Mod","SetTem","TemRec","TemUn","TemSen","WdSpd","Lig","SwingLfRig","SwUpDn","Quiet","Tur"];
let (response, used_core_fallback) = match self.status_request(device, &key, &full_cols).await {
Ok(value) => (value, false),
Err(first) => {
tracing::debug!(device=%device.id, error=?first, "Full GREE status request failed; retrying core properties");
(self.status_request(device, &key, &core_cols).await?, true)
}
};
self.apply_status(device, &response)?;
// Some firmware rejects a large mixed property list but still exposes OutEnvTem.
// Probe it separately after the core fallback so compatible units can contribute
// their outdoor sensor to history without making the main poll fail.
if used_core_fallback {
match self.status_request(device, &key, &["OutEnvTem"]).await {
Ok(optional) => { let _ = self.apply_status(device, &optional); }
Err(err) => tracing::trace!(device=%device.id, error=?err, "GREE outdoor temperature is not available"),
}
}
// Capability discovery is deliberately lazy. Existing installations start with
// unknown support flags and each optional property is probed at most until a
// definitive success/failure has been persisted with the device state.
self.probe_optional_features(device, &key).await;
device.online = true;
device.communication_failures = 0;
device.last_seen = Some(Utc::now());
device.last_error = None;
device.updated_at = Utc::now();
Ok(())
}
async fn status_request(&self, device: &Device, key: &str, cols: &[&str]) -> Result<Value> {
let inner = json!({"cols": cols, "mac": Self::wire_mac(device), "t": "status"});
self.request(device, &inner, key, false, device.protocol_version).await
}
async fn probe_optional_features(&self, device: &mut Device, key: &str) {
let probes = [
("Lig", device.supports_light.is_none()),
("Quiet", device.supports_quiet.is_none()),
("Tur", device.supports_turbo.is_none()),
("Air", device.supports_air.is_none()),
("Blo", device.supports_xfan.is_none()),
("Health", device.supports_health.is_none()),
("SwhSlp", device.supports_sleep.is_none()),
];
for (property, needed) in probes {
if !needed { continue; }
match self.status_request(device, key, &[property]).await {
Ok(value) => {
let returned = value.get("cols").and_then(Value::as_array)
.map(|cols| cols.iter().any(|name| name.as_str() == Some(property)))
.unwrap_or(false);
if !returned || self.apply_status(device, &value).is_err() {
Self::set_feature_support(device, property, false);
}
}
Err(err) => {
Self::set_feature_support(device, property, false);
tracing::trace!(device=%device.id, property, error=?err, "optional GREE feature is not available");
}
}
}
}
fn set_feature_support(device: &mut Device, property: &str, supported: bool) {
let value = Some(supported);
match property {
"Lig" => device.supports_light = value,
"Quiet" => device.supports_quiet = value,
"Tur" => device.supports_turbo = value,
"Air" => device.supports_air = value,
"Blo" => device.supports_xfan = value,
"Health" => device.supports_health = value,
"SwhSlp" => device.supports_sleep = value,
_ => {}
}
}
fn apply_status(&self, device: &mut Device, response: &Value) -> Result<()> {
let response_cols = response.get("cols").and_then(Value::as_array)
.ok_or_else(|| anyhow!("status response has no cols"))?;
let data = response.get("dat").and_then(Value::as_array)
.ok_or_else(|| anyhow!("status response has no dat"))?;
if data.len() < response_cols.len() {
bail!("status response contains fewer values than columns")
}
// Parse into a clone and commit only when every climate-relevant value is valid.
// This prevents null/text/malformed frames from being silently converted into OFF,
// AUTO or a zero setpoint while leaving the rest of the packet partially applied.
let mut next = device.clone();
let mut set_temp = None;
for (name, value) in response_cols.iter().zip(data.iter()) {
let Some(name) = name.as_str() else { continue; };
match name {
"Pow" => next.power = status_flag(name, value)?,
"Mod" => {
let raw = status_i64(name, value)?;
next.mode = mode_name_checked(raw).ok_or_else(|| anyhow!("invalid GREE mode value for {name}: {raw}"))?.into();
}
"SetTem" => {
let raw = status_f64(name, value)?;
if !(8.0..=30.0).contains(&raw) { bail!("invalid GREE setpoint for {name}: {raw}") }
set_temp = Some(raw.round());
}
"WdSpd" => {
let raw = status_i64(name, value)?;
if !(0..=5).contains(&raw) { bail!("invalid GREE fan value for {name}: {raw}") }
next.fan_speed = raw as u8;
}
"SwUpDn" => next.swing_vertical = status_i64(name, value)? != 0,
"SwingLfRig" => next.swing_horizontal = status_i64(name, value)? != 0,
"Quiet" => { next.quiet = status_flag(name, value)?; next.supports_quiet = Some(true); },
"Tur" => { next.turbo = status_flag(name, value)?; next.supports_turbo = Some(true); },
"Lig" => { next.light = status_flag(name, value)?; next.supports_light = Some(true); },
"Air" => { next.air = status_flag(name, value)?; next.supports_air = Some(true); },
"Blo" => { next.xfan = status_flag(name, value)?; next.supports_xfan = Some(true); },
"Health" => { next.health = status_flag(name, value)?; next.supports_health = Some(true); },
"SwhSlp" => { next.sleep = status_flag(name, value)?; next.supports_sleep = Some(true); },
"TemSen" => {
let raw = status_f64(name, value)?;
if raw != 0.0 {
let offset = raw > 40.0;
let temperature = if offset { raw - 40.0 } else { raw };
if !(-40.0..=80.0).contains(&temperature) { bail!("invalid GREE indoor temperature: {temperature}") }
next.temperature_sensor_offset = Some(offset);
next.current_temperature = Some(temperature);
}
}
"OutEnvTem" => {
let raw = status_f64(name, value)?;
if raw != 0.0 {
let offset = next.temperature_sensor_offset.unwrap_or(raw > 50.0);
let temperature = if offset { raw - 40.0 } else { raw };
if !(-60.0..=80.0).contains(&temperature) { bail!("invalid GREE outdoor temperature: {temperature}") }
next.outdoor_temperature = Some(temperature);
}
}
_ => {}
}
}
if let Some(base) = set_temp { next.target_temperature = base; }
*device = next;
Ok(())
}
pub fn quiet_command_supported(&self, device_id: &str) -> bool {
self.quiet_unsupported.lock().map(|items| !items.contains(device_id)).unwrap_or(true)
}
pub fn sleep_command_supported(&self, device_id: &str) -> bool {
self.sleep_unsupported.lock().map(|items| !items.contains(device_id)).unwrap_or(true)
}
async fn request_command_with_buzzer_fallback(
&self,
device: &Device,
key: &str,
command: &DeviceCommand,
suppress_beep: bool,
) -> Result<Value> {
let try_buzzer_suppression = suppress_beep
&& self.buzzer_unsupported.lock().map(|items| !items.contains(&device.id)).unwrap_or(true);
let inner = Self::command_payload(command, try_buzzer_suppression)?;
match self.request(device, &inner, key, false, device.protocol_version).await {
Ok(value) => Ok(value),
Err(first_err) if try_buzzer_suppression => {
// Some firmwares reject unknown buzzer properties instead of ignoring them.
// Retry the exact state change without buzzer fields and remember the fallback.
let fallback = Self::command_payload(command, false)?;
match self.request(device, &fallback, key, false, device.protocol_version).await {
Ok(value) => {
if let Ok(mut items) = self.buzzer_unsupported.lock() { items.insert(device.id.clone()); }
tracing::warn!(device=%device.id, "GREE buzzer suppression is unsupported; using normal command frames for this device");
Ok(value)
}
Err(_) => Err(first_err),
}
}
Err(err) => Err(err),
}
}
pub async fn command(&self, device: &Device, command: &DeviceCommand, suppress_beep: bool) -> Result<DeviceCommand> {
let key = device.key.as_deref().ok_or_else(|| anyhow!("device is not bound"))?;
let mut effective = command.clone();
if effective.quiet.is_some() && !self.quiet_command_supported(&device.id) {
effective.quiet = None;
}
if effective.sleep.is_some() && !self.sleep_command_supported(&device.id) {
effective.sleep = None;
}
if effective.is_empty() {
return Ok(effective);
}
match self.request_command_with_buzzer_fallback(device, key, &effective, suppress_beep).await {
Ok(_) => Ok(effective),
Err(first_err) => {
// Quiet and native Sleep are optional GREE features. A unit may report a
// broader status schema than it accepts in command frames, so preserve
// the actual thermostat change and retry without the optional property.
if effective.sleep.is_some() {
let mut fallback = effective.clone();
fallback.sleep = None;
if !fallback.is_empty() {
if self.request_command_with_buzzer_fallback(device, key, &fallback, suppress_beep).await.is_ok() {
if let Ok(mut items) = self.sleep_unsupported.lock() { items.insert(device.id.clone()); }
tracing::warn!(device=%device.id, "GREE native Sleep command is unsupported; night mode will continue without Sleep for this device");
return Ok(fallback);
}
}
}
if effective.quiet.is_some() {
let mut fallback = effective.clone();
fallback.quiet = None;
if !fallback.is_empty() {
if self.request_command_with_buzzer_fallback(device, key, &fallback, suppress_beep).await.is_ok() {
if let Ok(mut items) = self.quiet_unsupported.lock() { items.insert(device.id.clone()); }
tracing::warn!(device=%device.id, "GREE Quiet command is unsupported; thermostat will continue without Quiet for this device");
return Ok(fallback);
}
}
}
if effective.sleep.is_some() && effective.quiet.is_some() {
let mut fallback = effective.clone();
fallback.sleep = None;
fallback.quiet = None;
if !fallback.is_empty() {
if self.request_command_with_buzzer_fallback(device, key, &fallback, suppress_beep).await.is_ok() {
if let Ok(mut items) = self.sleep_unsupported.lock() { items.insert(device.id.clone()); }
if let Ok(mut items) = self.quiet_unsupported.lock() { items.insert(device.id.clone()); }
tracing::warn!(device=%device.id, "GREE Quiet/Sleep optional command fields are unsupported; using the core thermostat command");
return Ok(fallback);
}
}
}
Err(first_err)
}
}
}
fn command_payload(command: &DeviceCommand, suppress_beep: bool) -> Result<Value> {
let mut opt = Vec::<&str>::new();
let mut values = Vec::<Value>::new();
if let Some(v) = command.power { opt.push("Pow"); values.push(json!(if v { 1 } else { 0 })); }
if let Some(v) = &command.mode { opt.push("Mod"); values.push(json!(mode_value(v)?)); }
if let Some(v) = command.target_temperature {
// GREE's Celsius setpoint is whole-degree. TemRec is used by the
// Fahrenheit conversion path and should not be abused as a 0.5 C bit.
let whole = v.clamp(8.0, 30.0).round() as i64;
opt.push("SetTem"); values.push(json!(whole));
}
if let Some(v) = command.fan_speed { opt.push("WdSpd"); values.push(json!(v.min(5))); }
if let Some(v) = command.swing_vertical { opt.push("SwUpDn"); values.push(json!(if v { 1 } else { 0 })); }
if let Some(v) = command.swing_horizontal { opt.push("SwingLfRig"); values.push(json!(if v { 1 } else { 0 })); }
if let Some(v) = command.quiet { opt.push("Quiet"); values.push(json!(if v { 1 } else { 0 })); }
if let Some(v) = command.turbo { opt.push("Tur"); values.push(json!(if v { 1 } else { 0 })); }
if let Some(v) = command.light { opt.push("Lig"); values.push(json!(if v { 1 } else { 0 })); }
if let Some(v) = command.air { opt.push("Air"); values.push(json!(if v { 1 } else { 0 })); }
if let Some(v) = command.xfan { opt.push("Blo"); values.push(json!(if v { 1 } else { 0 })); }
if let Some(v) = command.health { opt.push("Health"); values.push(json!(if v { 1 } else { 0 })); }
if let Some(v) = command.sleep { opt.push("SwhSlp"); values.push(json!(if v { 1 } else { 0 })); }
if opt.is_empty() { bail!("empty device command") }
if suppress_beep {
opt.push("Buzzer_ON_OFF"); values.push(json!(1));
opt.push("BuzzerCtrl"); values.push(json!(0));
}
Ok(json!({"opt": opt, "p": values, "t": "cmd"}))
}
async fn request(&self, device: &Device, inner: &Value, key: &str, binding: bool, protocol_version: u8) -> Result<Value> {
let target = self.device_target(device)?;
let target_hint = match target { SocketAddr::V4(addr) => Some(*addr.ip()), SocketAddr::V6(_) => None };
let socket = self.udp_socket(false, target_hint).await?;
self.request_on_socket(device, inner, key, binding, protocol_version, &socket).await
}
async fn request_on_socket(&self, device: &Device, inner: &Value, key: &str, binding: bool, protocol_version: u8, socket: &UdpSocket) -> Result<Value> {
let target = self.device_target(device)?;
let version = if protocol_version == 2 { 2 } else { 1 };
let inner_bytes = serde_json::to_vec(inner)?;
let wire_mac = Self::wire_mac(device);
let mut outer = json!({
"cid": "app",
"i": if binding { 1 } else { 0 },
"t": "pack",
"tcid": wire_mac,
"uid": 0
});
if version == 2 {
let encrypted = encrypt_v2(key, &inner_bytes)?;
outer["pack"] = json!(encrypted.ciphertext);
outer["tag"] = json!(encrypted.tag);
} else {
outer["pack"] = json!(encrypt_v1(key, &inner_bytes)?);
}
let payload = serde_json::to_vec(&outer)?;
tracing::debug!(target=%target, local=%socket.local_addr()?, protocol=version, wire_mac=%wire_mac, interface=%self.interface.as_deref().unwrap_or("auto"), binding, "Sending GREE request");
self.debug_frame("tx", device, target, version, inner);
socket.send_to(&payload, target).await?;
let deadline = Instant::now() + Duration::from_secs(4);
let mut buffer = vec![0_u8; 16 * 1024];
let mut last_decode_error = None;
while Instant::now() < deadline {
let remaining = deadline.saturating_duration_since(Instant::now());
let received = timeout(remaining, socket.recv_from(&mut buffer)).await;
let (size, source) = match received {
Ok(Ok(value)) => value,
Ok(Err(err)) => return Err(err.into()),
Err(_) => break,
};
if source.ip() != target.ip() { continue; }
self.record_received_frame(device);
let response: Value = match serde_json::from_slice(&buffer[..size]) {
Ok(value) => value,
Err(err) => { last_decode_error = Some(anyhow!("invalid GREE JSON response: {err}")); continue; }
};
if let Some(pack) = response.get("pack").and_then(Value::as_object) {
let decoded = Value::Object(pack.clone());
if binding {
let response_type = decoded.get("t").and_then(Value::as_str).unwrap_or_default();
if !response_type.eq_ignore_ascii_case("bindok") {
tracing::debug!(source=%source, response_type=%response_type, "Ignoring non-bind packet while waiting for GREE bind response");
continue;
}
}
if let Some(err) = decoded.get("err").filter(|v| !v.is_null()) { bail!("GREE device error: {err}") }
self.debug_frame("rx", device, target, version, &decoded);
return Ok(decoded);
}
let Some(pack) = response.get("pack").and_then(Value::as_str) else { continue; };
let clear = if version == 2 {
let Some(tag) = response.get("tag").and_then(Value::as_str) else {
last_decode_error = Some(anyhow!("AES-GCM response is missing tag"));
continue;
};
match decrypt_v2(key, pack, tag) {
Ok(v) => v,
Err(err) => { last_decode_error = Some(err); continue; }
}
} else {
match decrypt_v1(key, pack) {
Ok(v) => v,
Err(err) => { last_decode_error = Some(err); continue; }
}
};
let decoded: Value = match serde_json::from_slice(&clear) {
Ok(value) => value,
Err(err) => { last_decode_error = Some(anyhow!("invalid decrypted GREE response: {err}")); continue; }
};
if binding {
let response_type = decoded.get("t").and_then(Value::as_str).unwrap_or_default();
if !response_type.eq_ignore_ascii_case("bindok") { continue; }
}
if let Some(err) = decoded.get("err").filter(|v| !v.is_null()) { bail!("GREE device error: {err}") }
self.debug_frame("rx", device, target, version, &decoded);
return Ok(decoded);
}
if let Some(err) = last_decode_error { return Err(err); }
bail!("GREE response timeout after 4 seconds")
}
fn device_target(&self, device: &Device) -> Result<SocketAddr> {
format!("{}:{}", device.ip, device.port).parse().context("invalid device address")
}
}
#[derive(Debug, Clone)]
struct LocalIpv4Config {
interface: String,
ip: Ipv4Addr,
broadcast: Ipv4Addr,
prefix_len: u32,
}
#[cfg(target_os = "linux")]
fn local_ipv4_config_for_target(target: Ipv4Addr) -> Result<Option<LocalIpv4Config>> {
use std::{ffi::CStr, ptr};
unsafe {
let mut addrs: *mut libc::ifaddrs = ptr::null_mut();
if libc::getifaddrs(&mut addrs) != 0 { return Err(std::io::Error::last_os_error()).context("getifaddrs failed"); }
let mut current = addrs;
let mut best: Option<LocalIpv4Config> = None;
while !current.is_null() {
let ifa = &*current;
if !ifa.ifa_name.is_null() && !ifa.ifa_addr.is_null() && !ifa.ifa_netmask.is_null()
&& (*ifa.ifa_addr).sa_family as i32 == libc::AF_INET
{
let interface = CStr::from_ptr(ifa.ifa_name).to_string_lossy().into_owned();
let addr = &*(ifa.ifa_addr as *const libc::sockaddr_in);
let mask_addr = &*(ifa.ifa_netmask as *const libc::sockaddr_in);
let ip = Ipv4Addr::from(addr.sin_addr.s_addr.to_ne_bytes());
let mask = Ipv4Addr::from(mask_addr.sin_addr.s_addr.to_ne_bytes());
let ip_u32 = u32::from(ip);
let mask_u32 = u32::from(mask);
let target_u32 = u32::from(target);
if !ip.is_loopback() && (ip_u32 & mask_u32) == (target_u32 & mask_u32) {
let prefix_len = mask_u32.count_ones();
let candidate = LocalIpv4Config {
interface,
ip,
broadcast: Ipv4Addr::from(ip_u32 | !mask_u32),
prefix_len,
};
if best.as_ref().map(|current| prefix_len > current.prefix_len).unwrap_or(true) {
best = Some(candidate);
}
}
}
current = ifa.ifa_next;
}
libc::freeifaddrs(addrs);
Ok(best)
}
}
#[cfg(not(target_os = "linux"))]
fn local_ipv4_config_for_target(_target: Ipv4Addr) -> Result<Option<LocalIpv4Config>> { Ok(None) }
#[cfg(target_os = "linux")]
fn interface_ipv4_config(interface: &str) -> Result<(Ipv4Addr, Ipv4Addr)> {
use std::{ffi::CStr, ptr};
unsafe {
let mut addrs: *mut libc::ifaddrs = ptr::null_mut();
if libc::getifaddrs(&mut addrs) != 0 { return Err(std::io::Error::last_os_error()).context("getifaddrs failed"); }
let mut current = addrs;
let mut found = None;
while !current.is_null() {
let ifa = &*current;
if !ifa.ifa_name.is_null() && !ifa.ifa_addr.is_null() {
let name = CStr::from_ptr(ifa.ifa_name).to_string_lossy();
if name == interface && (*ifa.ifa_addr).sa_family as i32 == libc::AF_INET {
let addr = &*(ifa.ifa_addr as *const libc::sockaddr_in);
let ip = Ipv4Addr::from(addr.sin_addr.s_addr.to_ne_bytes());
let broadcast = if !ifa.ifa_netmask.is_null() {
let mask_addr = &*(ifa.ifa_netmask as *const libc::sockaddr_in);
let mask = Ipv4Addr::from(mask_addr.sin_addr.s_addr.to_ne_bytes());
Ipv4Addr::from(u32::from(ip) | !u32::from(mask))
} else { Ipv4Addr::BROADCAST };
found = Some((ip, broadcast));
break;
}
}
current = ifa.ifa_next;
}
libc::freeifaddrs(addrs);
found.ok_or_else(|| anyhow!("interface {interface} has no IPv4 address"))
}
}
#[cfg(not(target_os = "linux"))]
fn interface_ipv4_config(interface: &str) -> Result<(Ipv4Addr, Ipv4Addr)> {
bail!("GREE interface binding is only supported on Linux (requested {interface})")
}
fn interface_ipv4(interface: &str) -> Result<Ipv4Addr> { interface_ipv4_config(interface).map(|(ip, _)| ip) }
fn value_as_i64(value: &Value) -> Option<i64> { value.as_i64().or_else(|| value.as_str()?.trim().parse().ok()) }
fn value_as_f64(value: &Value) -> Option<f64> {
value.as_f64().or_else(|| value.as_str()?.trim().parse().ok()).filter(|value| value.is_finite())
}
fn status_i64(name: &str, value: &Value) -> Result<i64> {
value_as_i64(value).ok_or_else(|| anyhow!("invalid GREE integer value for {name}: {value}"))
}
fn status_f64(name: &str, value: &Value) -> Result<f64> {
value_as_f64(value).ok_or_else(|| anyhow!("invalid GREE numeric value for {name}: {value}"))
}
fn status_flag(name: &str, value: &Value) -> Result<bool> {
match status_i64(name, value)? {
0 => Ok(false),
1 => Ok(true),
other => bail!("invalid GREE flag value for {name}: {other}"),
}
}
fn mode_name_checked(value: i64) -> Option<&'static str> { match value { 0 => Some("auto"), 1 => Some("cool"), 2 => Some("dry"), 3 => Some("fan"), 4 => Some("heat"), _ => None } }
fn mode_value(value: &str) -> Result<i64> {
match value.to_ascii_lowercase().as_str() {
"auto" => Ok(0), "cool" => Ok(1), "dry" => Ok(2), "fan" => Ok(3), "heat" => Ok(4),
_ => bail!("unsupported mode: {value}"),
}
}
pub fn merge_discovered(existing: Option<Device>, discovered: Device) -> Device {
if let Some(mut old) = existing {
old.ip = discovered.ip;
old.port = discovered.port;
if old.name.trim().is_empty() || old.name == "Klimatyzator GREE" || old.name == "GREE air conditioner" { old.name = discovered.name; }
if !discovered.model.is_empty() { old.model = discovered.model; }
if !discovered.firmware.is_empty() { old.firmware = discovered.firmware; }
if old.protocol_version != discovered.protocol_version {
old.protocol_version = discovered.protocol_version;
old.key = None;
}
old.online = true;
old.communication_failures = 0;
old.last_seen = Some(Utc::now());
old.last_error = None;
old.updated_at = Utc::now();
old
} else {
let mut new = discovered;
if new.id.is_empty() { new.id = Uuid::new_v4().to_string(); }
new
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn invalid_status_frame_does_not_partially_mutate_device() {
let client = GreeClient::new(
"test-controller".into(),
None,
None,
Arc::new(AtomicBool::new(false)),
);
let mut device = Device::simulated_default();
device.power = true;
device.mode = "heat".into();
device.target_temperature = 24.0;
let before = device.clone();
let response = json!({
"cols": ["Pow", "Mod", "SetTem"],
"dat": [0, null, "not-a-number"]
});
assert!(client.apply_status(&mut device, &response).is_err());
assert_eq!(device.power, before.power);
assert_eq!(device.mode, before.mode);
assert_eq!(device.target_temperature, before.target_temperature);
}
#[test]
fn thermostat_standby_setpoint_low_fan_quiet_and_sleep_share_one_frame() {
let payload = GreeClient::command_payload(&DeviceCommand {
target_temperature: Some(19.0),
fan_speed: Some(1),
quiet: Some(true),
sleep: Some(true),
..DeviceCommand::default()
}, false).expect("thermostat command payload");
assert_eq!(payload.get("opt").cloned(), Some(serde_json::json!(["SetTem", "WdSpd", "Quiet", "SwhSlp"])));
assert_eq!(payload.get("p").cloned(), Some(serde_json::json!([19, 1, 1, 1])));
}
}
// Functional source split intentionally keeps items in the existing module namespace.
include!("gree/core.rs");
include!("gree/discovery.rs");
include!("gree/binding.rs");
include!("gree/polling.rs");
include!("gree/commands.rs");
include!("gree/transport.rs");
include!("gree/network.rs");
include!("gree/merge.rs");
include!("gree/tests.rs");
+71
View File
@@ -0,0 +1,71 @@
impl GreeClient {
pub async fn bind(&self, device: &Device) -> Result<BindResult> {
let versions: &[u8] = match device.protocol_version {
2 => &[2, 1],
_ => &[1, 2],
};
let mut errors = Vec::new();
for &version in versions {
match self.bind_attempt(device, version).await {
Ok(key) => return Ok(BindResult { key, protocol_version: version }),
Err(err) => {
tracing::warn!(device=%device.id, ip=%device.ip, protocol=version, error=?err, "GREE bind attempt failed");
errors.push(format!("V{version}: {err}"));
}
}
}
bail!("unable to bind device ({})", errors.join("; "))
}
/// GREE Wi-Fi modules use the 12-hex device id as a protocol identifier.
/// Older V1 modules (notably 502cc6...) can silently ignore bind/status
/// packets when tcid/mac casing differs from the lowercase value returned
/// by discovery. Keep the database/display representation independent from
/// the on-wire representation and always send canonical lowercase hex.
fn wire_mac(device: &Device) -> String {
device.mac.replace([':', '-'], "").to_ascii_lowercase()
}
async fn bind_attempt(&self, device: &Device, version: u8) -> Result<String> {
let target = self.device_target(device)?;
let target_hint = match target { SocketAddr::V4(addr) => Some(*addr.ip()), SocketAddr::V6(_) => None };
let socket = self.udp_socket(true, target_hint).await?;
// Binding is time-sensitive on older GREE Wi-Fi modules. Refresh the
// bind window with a subnet broadcast when the target is on a directly
// connected network. A unicast scan remains the fallback for routed
// deployments. Keep the same UDP socket for scan + bind.
let scan_target = self.bind_scan_target(target)?;
tracing::debug!(device=%device.id, target=%target, scan_target=%scan_target, local=%socket.local_addr()?, "Refreshing GREE bind window");
socket.send_to(br#"{"t":"scan"}"#, scan_target).await?;
let mut scan_buf = vec![0_u8; 16 * 1024];
let scan_deadline = Instant::now() + Duration::from_millis(1500);
while Instant::now() < scan_deadline {
let remaining = scan_deadline.saturating_duration_since(Instant::now());
match timeout(remaining, socket.recv_from(&mut scan_buf)).await {
Ok(Ok((_size, source))) if source.ip() == target.ip() => {
self.record_received_frame(device);
tracing::debug!(device=%device.id, source=%source, "Received scan response immediately before bind");
break;
}
Ok(Ok(_)) => continue,
Ok(Err(err)) => return Err(err.into()),
Err(_) => break,
}
}
let wire_mac = Self::wire_mac(device);
let inner = json!({"mac": wire_mac, "t": "bind", "uid": 0});
let generic_key = if version == 2 { GENERIC_GREE_V2_KEY } else { GENERIC_GREE_V1_KEY };
let response = self.request_on_socket(device, &inner, generic_key, true, version, &socket).await?;
let kind = response.get("t").and_then(Value::as_str).unwrap_or_default();
if !kind.eq_ignore_ascii_case("bindok") {
bail!("unexpected bind response type: {kind}")
}
let key = response.get("key").and_then(Value::as_str)
.ok_or_else(|| anyhow!("bind response does not contain device key"))?;
if key.is_empty() { bail!("device returned an empty key") }
Ok(key.to_string())
}
}
+127
View File
@@ -0,0 +1,127 @@
impl GreeClient {
pub fn quiet_command_supported(&self, device_id: &str) -> bool {
self.quiet_unsupported.lock().map(|items| !items.contains(device_id)).unwrap_or(true)
}
pub fn sleep_command_supported(&self, device_id: &str) -> bool {
self.sleep_unsupported.lock().map(|items| !items.contains(device_id)).unwrap_or(true)
}
async fn request_command_with_buzzer_fallback(
&self,
device: &Device,
key: &str,
command: &DeviceCommand,
suppress_beep: bool,
) -> Result<Value> {
let try_buzzer_suppression = suppress_beep
&& self.buzzer_unsupported.lock().map(|items| !items.contains(&device.id)).unwrap_or(true);
let inner = Self::command_payload(command, try_buzzer_suppression)?;
match self.request(device, &inner, key, false, device.protocol_version).await {
Ok(value) => Ok(value),
Err(first_err) if try_buzzer_suppression => {
// Some firmwares reject unknown buzzer properties instead of ignoring them.
// Retry the exact state change without buzzer fields and remember the fallback.
let fallback = Self::command_payload(command, false)?;
match self.request(device, &fallback, key, false, device.protocol_version).await {
Ok(value) => {
if let Ok(mut items) = self.buzzer_unsupported.lock() { items.insert(device.id.clone()); }
tracing::warn!(device=%device.id, "GREE buzzer suppression is unsupported; using normal command frames for this device");
Ok(value)
}
Err(_) => Err(first_err),
}
}
Err(err) => Err(err),
}
}
pub async fn command(&self, device: &Device, command: &DeviceCommand, suppress_beep: bool) -> Result<DeviceCommand> {
let key = device.key.as_deref().ok_or_else(|| anyhow!("device is not bound"))?;
let mut effective = command.clone();
if effective.quiet.is_some() && !self.quiet_command_supported(&device.id) {
effective.quiet = None;
}
if effective.sleep.is_some() && !self.sleep_command_supported(&device.id) {
effective.sleep = None;
}
if effective.is_empty() {
return Ok(effective);
}
match self.request_command_with_buzzer_fallback(device, key, &effective, suppress_beep).await {
Ok(_) => Ok(effective),
Err(first_err) => {
// Quiet and native Sleep are optional GREE features. A unit may report a
// broader status schema than it accepts in command frames, so preserve
// the actual thermostat change and retry without the optional property.
if effective.sleep.is_some() {
let mut fallback = effective.clone();
fallback.sleep = None;
if !fallback.is_empty() {
if self.request_command_with_buzzer_fallback(device, key, &fallback, suppress_beep).await.is_ok() {
if let Ok(mut items) = self.sleep_unsupported.lock() { items.insert(device.id.clone()); }
tracing::warn!(device=%device.id, "GREE native Sleep command is unsupported; night mode will continue without Sleep for this device");
return Ok(fallback);
}
}
}
if effective.quiet.is_some() {
let mut fallback = effective.clone();
fallback.quiet = None;
if !fallback.is_empty() {
if self.request_command_with_buzzer_fallback(device, key, &fallback, suppress_beep).await.is_ok() {
if let Ok(mut items) = self.quiet_unsupported.lock() { items.insert(device.id.clone()); }
tracing::warn!(device=%device.id, "GREE Quiet command is unsupported; thermostat will continue without Quiet for this device");
return Ok(fallback);
}
}
}
if effective.sleep.is_some() && effective.quiet.is_some() {
let mut fallback = effective.clone();
fallback.sleep = None;
fallback.quiet = None;
if !fallback.is_empty() {
if self.request_command_with_buzzer_fallback(device, key, &fallback, suppress_beep).await.is_ok() {
if let Ok(mut items) = self.sleep_unsupported.lock() { items.insert(device.id.clone()); }
if let Ok(mut items) = self.quiet_unsupported.lock() { items.insert(device.id.clone()); }
tracing::warn!(device=%device.id, "GREE Quiet/Sleep optional command fields are unsupported; using the core thermostat command");
return Ok(fallback);
}
}
}
Err(first_err)
}
}
}
fn command_payload(command: &DeviceCommand, suppress_beep: bool) -> Result<Value> {
let mut opt = Vec::<&str>::new();
let mut values = Vec::<Value>::new();
if let Some(v) = command.power { opt.push("Pow"); values.push(json!(if v { 1 } else { 0 })); }
if let Some(v) = &command.mode { opt.push("Mod"); values.push(json!(mode_value(v)?)); }
if let Some(v) = command.target_temperature {
// GREE's Celsius setpoint is whole-degree. TemRec is used by the
// Fahrenheit conversion path and should not be abused as a 0.5 C bit.
let whole = v.clamp(8.0, 30.0).round() as i64;
opt.push("SetTem"); values.push(json!(whole));
}
if let Some(v) = command.fan_speed { opt.push("WdSpd"); values.push(json!(v.min(5))); }
if let Some(v) = command.swing_vertical { opt.push("SwUpDn"); values.push(json!(if v { 1 } else { 0 })); }
if let Some(v) = command.swing_horizontal { opt.push("SwingLfRig"); values.push(json!(if v { 1 } else { 0 })); }
if let Some(v) = command.quiet { opt.push("Quiet"); values.push(json!(if v { 1 } else { 0 })); }
if let Some(v) = command.turbo { opt.push("Tur"); values.push(json!(if v { 1 } else { 0 })); }
if let Some(v) = command.light { opt.push("Lig"); values.push(json!(if v { 1 } else { 0 })); }
if let Some(v) = command.air { opt.push("Air"); values.push(json!(if v { 1 } else { 0 })); }
if let Some(v) = command.xfan { opt.push("Blo"); values.push(json!(if v { 1 } else { 0 })); }
if let Some(v) = command.health { opt.push("Health"); values.push(json!(if v { 1 } else { 0 })); }
if let Some(v) = command.sleep { opt.push("SwhSlp"); values.push(json!(if v { 1 } else { 0 })); }
if opt.is_empty() { bail!("empty device command") }
if suppress_beep {
opt.push("Buzzer_ON_OFF"); values.push(json!(1));
opt.push("BuzzerCtrl"); values.push(json!(0));
}
Ok(json!({"opt": opt, "p": values, "t": "cmd"}))
}
}
+124
View File
@@ -0,0 +1,124 @@
impl GreeClient {
pub fn new(
controller_id: String,
interface: Option<String>,
debug_events: Option<broadcast::Sender<ApiEvent>>,
debug_gree_frames: Arc<AtomicBool>,
) -> Self {
Self {
controller_id,
interface,
debug_events,
debug_gree_frames,
received_frames_total: Arc::new(AtomicU64::new(0)),
received_frames_by_device: Arc::new(Mutex::new(HashMap::new())),
buzzer_unsupported: Arc::new(Mutex::new(HashSet::new())),
quiet_unsupported: Arc::new(Mutex::new(HashSet::new())),
sleep_unsupported: Arc::new(Mutex::new(HashSet::new())),
}
}
pub fn received_frame_stats(&self) -> (u64, HashMap<String, u64>) {
let total = self.received_frames_total.load(Ordering::Relaxed);
let by_device = self.received_frames_by_device.lock()
.map(|counts| counts.clone())
.unwrap_or_default();
(total, by_device)
}
fn record_received_frame(&self, device: &Device) {
let total = self.received_frames_total.fetch_add(1, Ordering::Relaxed).saturating_add(1);
let device_count = self.received_frames_by_device.lock().ok().map(|mut counts| {
let count = counts.entry(device.id.clone()).or_insert(0);
*count = (*count).saturating_add(1);
*count
}).unwrap_or(0);
if let Some(events) = &self.debug_events {
let _ = events.send(ApiEvent {
event: "gree.frame_received".into(),
timestamp: Utc::now(),
data: json!({
"device_id": device.id,
"device_name": device.name,
"total": total,
"device_count": device_count,
}),
});
}
}
fn debug_frame(&self, direction: &str, device: &Device, target: SocketAddr, protocol: u8, payload: &Value) {
if !self.debug_gree_frames.load(Ordering::Relaxed) { return; }
let Some(events) = &self.debug_events else { return; };
let mut safe = payload.clone();
if let Some(object) = safe.as_object_mut() {
if object.contains_key("key") { object.insert("key".into(), json!("***")); }
}
let _ = events.send(ApiEvent {
event: "gree.frame".into(),
timestamp: Utc::now(),
data: json!({
"direction": direction,
"device_id": device.id,
"device_name": device.name,
"target": target.to_string(),
"protocol_version": protocol,
"payload": safe,
}),
});
}
async fn udp_socket(&self, broadcast: bool, target_hint: Option<Ipv4Addr>) -> Result<UdpSocket> {
let socket = if let Some(interface) = self.interface.as_deref() {
let ip = interface_ipv4(interface)?;
UdpSocket::bind(SocketAddrV4::new(ip, 0)).await
.with_context(|| format!("cannot bind GREE UDP socket to {ip} from interface {interface}"))?
} else if let Some(target) = target_hint {
if let Some(config) = local_ipv4_config_for_target(target)? {
tracing::debug!(
target = %target,
interface = %config.interface,
local_ip = %config.ip,
"Automatically selected local interface for GREE UDP"
);
UdpSocket::bind(SocketAddrV4::new(config.ip, 0)).await
.with_context(|| format!("cannot bind GREE UDP socket to {} on {}", config.ip, config.interface))?
} else {
UdpSocket::bind("0.0.0.0:0").await?
}
} else {
UdpSocket::bind("0.0.0.0:0").await?
};
socket.set_broadcast(broadcast)?;
Ok(socket)
}
fn bind_scan_target(&self, target: SocketAddr) -> Result<SocketAddr> {
let SocketAddr::V4(target_v4) = target else { return Ok(target); };
let broadcast = if let Some(interface) = self.interface.as_deref() {
let (_, broadcast) = interface_ipv4_config(interface)?;
Some(broadcast)
} else {
local_ipv4_config_for_target(*target_v4.ip())?.map(|config| config.broadcast)
};
Ok(broadcast
.map(|ip| SocketAddr::V4(SocketAddrV4::new(ip, target_v4.port())))
.unwrap_or(target))
}
fn discovery_target(&self, configured: &str) -> Result<SocketAddr> {
let value = configured.trim();
if value.eq_ignore_ascii_case("auto") || value.to_ascii_lowercase().starts_with("auto:") {
let port = value.split_once(':')
.map(|(_, port)| port.parse::<u16>().context("invalid automatic discovery port"))
.transpose()?
.unwrap_or(7000);
let interface = self.interface.as_deref()
.ok_or_else(|| anyhow!("automatic discovery broadcast requires GREE_CONTROLLER_GREE_INTERFACE"))?;
let (_, broadcast) = interface_ipv4_config(interface)?;
return Ok(SocketAddr::V4(SocketAddrV4::new(broadcast, port)));
}
value.parse().context("invalid discovery broadcast address")
}
}
+161
View File
@@ -0,0 +1,161 @@
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,
}))
}
}
+23
View File
@@ -0,0 +1,23 @@
pub fn merge_discovered(existing: Option<Device>, discovered: Device) -> Device {
if let Some(mut old) = existing {
old.ip = discovered.ip;
old.port = discovered.port;
if old.name.trim().is_empty() || old.name == "Klimatyzator GREE" || old.name == "GREE air conditioner" { old.name = discovered.name; }
if !discovered.model.is_empty() { old.model = discovered.model; }
if !discovered.firmware.is_empty() { old.firmware = discovered.firmware; }
if old.protocol_version != discovered.protocol_version {
old.protocol_version = discovered.protocol_version;
old.key = None;
}
old.online = true;
old.communication_failures = 0;
old.last_seen = Some(Utc::now());
old.last_error = None;
old.updated_at = Utc::now();
old
} else {
let mut new = discovered;
if new.id.is_empty() { new.id = Uuid::new_v4().to_string(); }
new
}
}
+114
View File
@@ -0,0 +1,114 @@
#[derive(Debug, Clone)]
struct LocalIpv4Config {
interface: String,
ip: Ipv4Addr,
broadcast: Ipv4Addr,
prefix_len: u32,
}
#[cfg(target_os = "linux")]
fn local_ipv4_config_for_target(target: Ipv4Addr) -> Result<Option<LocalIpv4Config>> {
use std::{ffi::CStr, ptr};
unsafe {
let mut addrs: *mut libc::ifaddrs = ptr::null_mut();
if libc::getifaddrs(&mut addrs) != 0 { return Err(std::io::Error::last_os_error()).context("getifaddrs failed"); }
let mut current = addrs;
let mut best: Option<LocalIpv4Config> = None;
while !current.is_null() {
let ifa = &*current;
if !ifa.ifa_name.is_null() && !ifa.ifa_addr.is_null() && !ifa.ifa_netmask.is_null()
&& (*ifa.ifa_addr).sa_family as i32 == libc::AF_INET
{
let interface = CStr::from_ptr(ifa.ifa_name).to_string_lossy().into_owned();
let addr = &*(ifa.ifa_addr as *const libc::sockaddr_in);
let mask_addr = &*(ifa.ifa_netmask as *const libc::sockaddr_in);
let ip = Ipv4Addr::from(addr.sin_addr.s_addr.to_ne_bytes());
let mask = Ipv4Addr::from(mask_addr.sin_addr.s_addr.to_ne_bytes());
let ip_u32 = u32::from(ip);
let mask_u32 = u32::from(mask);
let target_u32 = u32::from(target);
if !ip.is_loopback() && (ip_u32 & mask_u32) == (target_u32 & mask_u32) {
let prefix_len = mask_u32.count_ones();
let candidate = LocalIpv4Config {
interface,
ip,
broadcast: Ipv4Addr::from(ip_u32 | !mask_u32),
prefix_len,
};
if best.as_ref().map(|current| prefix_len > current.prefix_len).unwrap_or(true) {
best = Some(candidate);
}
}
}
current = ifa.ifa_next;
}
libc::freeifaddrs(addrs);
Ok(best)
}
}
#[cfg(not(target_os = "linux"))]
fn local_ipv4_config_for_target(_target: Ipv4Addr) -> Result<Option<LocalIpv4Config>> { Ok(None) }
#[cfg(target_os = "linux")]
fn interface_ipv4_config(interface: &str) -> Result<(Ipv4Addr, Ipv4Addr)> {
use std::{ffi::CStr, ptr};
unsafe {
let mut addrs: *mut libc::ifaddrs = ptr::null_mut();
if libc::getifaddrs(&mut addrs) != 0 { return Err(std::io::Error::last_os_error()).context("getifaddrs failed"); }
let mut current = addrs;
let mut found = None;
while !current.is_null() {
let ifa = &*current;
if !ifa.ifa_name.is_null() && !ifa.ifa_addr.is_null() {
let name = CStr::from_ptr(ifa.ifa_name).to_string_lossy();
if name == interface && (*ifa.ifa_addr).sa_family as i32 == libc::AF_INET {
let addr = &*(ifa.ifa_addr as *const libc::sockaddr_in);
let ip = Ipv4Addr::from(addr.sin_addr.s_addr.to_ne_bytes());
let broadcast = if !ifa.ifa_netmask.is_null() {
let mask_addr = &*(ifa.ifa_netmask as *const libc::sockaddr_in);
let mask = Ipv4Addr::from(mask_addr.sin_addr.s_addr.to_ne_bytes());
Ipv4Addr::from(u32::from(ip) | !u32::from(mask))
} else { Ipv4Addr::BROADCAST };
found = Some((ip, broadcast));
break;
}
}
current = ifa.ifa_next;
}
libc::freeifaddrs(addrs);
found.ok_or_else(|| anyhow!("interface {interface} has no IPv4 address"))
}
}
#[cfg(not(target_os = "linux"))]
fn interface_ipv4_config(interface: &str) -> Result<(Ipv4Addr, Ipv4Addr)> {
bail!("GREE interface binding is only supported on Linux (requested {interface})")
}
fn interface_ipv4(interface: &str) -> Result<Ipv4Addr> { interface_ipv4_config(interface).map(|(ip, _)| ip) }
fn value_as_i64(value: &Value) -> Option<i64> { value.as_i64().or_else(|| value.as_str()?.trim().parse().ok()) }
fn value_as_f64(value: &Value) -> Option<f64> {
value.as_f64().or_else(|| value.as_str()?.trim().parse().ok()).filter(|value| value.is_finite())
}
fn status_i64(name: &str, value: &Value) -> Result<i64> {
value_as_i64(value).ok_or_else(|| anyhow!("invalid GREE integer value for {name}: {value}"))
}
fn status_f64(name: &str, value: &Value) -> Result<f64> {
value_as_f64(value).ok_or_else(|| anyhow!("invalid GREE numeric value for {name}: {value}"))
}
fn status_flag(name: &str, value: &Value) -> Result<bool> {
match status_i64(name, value)? {
0 => Ok(false),
1 => Ok(true),
other => bail!("invalid GREE flag value for {name}: {other}"),
}
}
fn mode_name_checked(value: i64) -> Option<&'static str> { match value { 0 => Some("auto"), 1 => Some("cool"), 2 => Some("dry"), 3 => Some("fan"), 4 => Some("heat"), _ => None } }
fn mode_value(value: &str) -> Result<i64> {
match value.to_ascii_lowercase().as_str() {
"auto" => Ok(0), "cool" => Ok(1), "dry" => Ok(2), "fan" => Ok(3), "heat" => Ok(4),
_ => bail!("unsupported mode: {value}"),
}
}
+155
View File
@@ -0,0 +1,155 @@
impl GreeClient {
pub async fn poll(&self, device: &mut Device) -> Result<()> {
let key = device.key.clone().ok_or_else(|| anyhow!("device is not bound"))?;
let full_cols = [
"Pow","Mod","SetTem","WdSpd","Air","Blo","Health","SwhSlp","Lig",
"SwingLfRig","SwUpDn","Quiet","Tur","StHt","TemUn","HeatCoolType",
"TemRec","SvSt","TemSen","CoolSvTem","HeatSvTem","OutEnvTem"
];
let core_cols = ["Pow","Mod","SetTem","TemRec","TemUn","TemSen","WdSpd","Lig","SwingLfRig","SwUpDn","Quiet","Tur"];
let (response, used_core_fallback) = match self.status_request(device, &key, &full_cols).await {
Ok(value) => (value, false),
Err(first) => {
tracing::debug!(device=%device.id, error=?first, "Full GREE status request failed; retrying core properties");
(self.status_request(device, &key, &core_cols).await?, true)
}
};
self.apply_status(device, &response)?;
// Some firmware rejects a large mixed property list but still exposes OutEnvTem.
// Probe it separately after the core fallback so compatible units can contribute
// their outdoor sensor to history without making the main poll fail.
if used_core_fallback {
match self.status_request(device, &key, &["OutEnvTem"]).await {
Ok(optional) => { let _ = self.apply_status(device, &optional); }
Err(err) => tracing::trace!(device=%device.id, error=?err, "GREE outdoor temperature is not available"),
}
}
// Capability discovery is deliberately lazy. Existing installations start with
// unknown support flags and each optional property is probed at most until a
// definitive success/failure has been persisted with the device state.
self.probe_optional_features(device, &key).await;
device.online = true;
device.communication_failures = 0;
device.last_seen = Some(Utc::now());
device.last_error = None;
device.updated_at = Utc::now();
Ok(())
}
async fn status_request(&self, device: &Device, key: &str, cols: &[&str]) -> Result<Value> {
let inner = json!({"cols": cols, "mac": Self::wire_mac(device), "t": "status"});
self.request(device, &inner, key, false, device.protocol_version).await
}
async fn probe_optional_features(&self, device: &mut Device, key: &str) {
let probes = [
("Lig", device.supports_light.is_none()),
("Quiet", device.supports_quiet.is_none()),
("Tur", device.supports_turbo.is_none()),
("Air", device.supports_air.is_none()),
("Blo", device.supports_xfan.is_none()),
("Health", device.supports_health.is_none()),
("SwhSlp", device.supports_sleep.is_none()),
];
for (property, needed) in probes {
if !needed { continue; }
match self.status_request(device, key, &[property]).await {
Ok(value) => {
let returned = value.get("cols").and_then(Value::as_array)
.map(|cols| cols.iter().any(|name| name.as_str() == Some(property)))
.unwrap_or(false);
if !returned || self.apply_status(device, &value).is_err() {
Self::set_feature_support(device, property, false);
}
}
Err(err) => {
Self::set_feature_support(device, property, false);
tracing::trace!(device=%device.id, property, error=?err, "optional GREE feature is not available");
}
}
}
}
fn set_feature_support(device: &mut Device, property: &str, supported: bool) {
let value = Some(supported);
match property {
"Lig" => device.supports_light = value,
"Quiet" => device.supports_quiet = value,
"Tur" => device.supports_turbo = value,
"Air" => device.supports_air = value,
"Blo" => device.supports_xfan = value,
"Health" => device.supports_health = value,
"SwhSlp" => device.supports_sleep = value,
_ => {}
}
}
fn apply_status(&self, device: &mut Device, response: &Value) -> Result<()> {
let response_cols = response.get("cols").and_then(Value::as_array)
.ok_or_else(|| anyhow!("status response has no cols"))?;
let data = response.get("dat").and_then(Value::as_array)
.ok_or_else(|| anyhow!("status response has no dat"))?;
if data.len() < response_cols.len() {
bail!("status response contains fewer values than columns")
}
// Parse into a clone and commit only when every climate-relevant value is valid.
// This prevents null/text/malformed frames from being silently converted into OFF,
// AUTO or a zero setpoint while leaving the rest of the packet partially applied.
let mut next = device.clone();
let mut set_temp = None;
for (name, value) in response_cols.iter().zip(data.iter()) {
let Some(name) = name.as_str() else { continue; };
match name {
"Pow" => next.power = status_flag(name, value)?,
"Mod" => {
let raw = status_i64(name, value)?;
next.mode = mode_name_checked(raw).ok_or_else(|| anyhow!("invalid GREE mode value for {name}: {raw}"))?.into();
}
"SetTem" => {
let raw = status_f64(name, value)?;
if !(8.0..=30.0).contains(&raw) { bail!("invalid GREE setpoint for {name}: {raw}") }
set_temp = Some(raw.round());
}
"WdSpd" => {
let raw = status_i64(name, value)?;
if !(0..=5).contains(&raw) { bail!("invalid GREE fan value for {name}: {raw}") }
next.fan_speed = raw as u8;
}
"SwUpDn" => next.swing_vertical = status_i64(name, value)? != 0,
"SwingLfRig" => next.swing_horizontal = status_i64(name, value)? != 0,
"Quiet" => { next.quiet = status_flag(name, value)?; next.supports_quiet = Some(true); },
"Tur" => { next.turbo = status_flag(name, value)?; next.supports_turbo = Some(true); },
"Lig" => { next.light = status_flag(name, value)?; next.supports_light = Some(true); },
"Air" => { next.air = status_flag(name, value)?; next.supports_air = Some(true); },
"Blo" => { next.xfan = status_flag(name, value)?; next.supports_xfan = Some(true); },
"Health" => { next.health = status_flag(name, value)?; next.supports_health = Some(true); },
"SwhSlp" => { next.sleep = status_flag(name, value)?; next.supports_sleep = Some(true); },
"TemSen" => {
let raw = status_f64(name, value)?;
if raw != 0.0 {
let offset = raw > 40.0;
let temperature = if offset { raw - 40.0 } else { raw };
if !(-40.0..=80.0).contains(&temperature) { bail!("invalid GREE indoor temperature: {temperature}") }
next.temperature_sensor_offset = Some(offset);
next.current_temperature = Some(temperature);
}
}
"OutEnvTem" => {
let raw = status_f64(name, value)?;
if raw != 0.0 {
let offset = next.temperature_sensor_offset.unwrap_or(raw > 50.0);
let temperature = if offset { raw - 40.0 } else { raw };
if !(-60.0..=80.0).contains(&temperature) { bail!("invalid GREE outdoor temperature: {temperature}") }
next.outdoor_temperature = Some(temperature);
}
}
_ => {}
}
}
if let Some(base) = set_temp { next.target_temperature = base; }
*device = next;
Ok(())
}
}
+42
View File
@@ -0,0 +1,42 @@
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn invalid_status_frame_does_not_partially_mutate_device() {
let client = GreeClient::new(
"test-controller".into(),
None,
None,
Arc::new(AtomicBool::new(false)),
);
let mut device = Device::simulated_default();
device.power = true;
device.mode = "heat".into();
device.target_temperature = 24.0;
let before = device.clone();
let response = json!({
"cols": ["Pow", "Mod", "SetTem"],
"dat": [0, null, "not-a-number"]
});
assert!(client.apply_status(&mut device, &response).is_err());
assert_eq!(device.power, before.power);
assert_eq!(device.mode, before.mode);
assert_eq!(device.target_temperature, before.target_temperature);
}
#[test]
fn thermostat_standby_setpoint_low_fan_quiet_and_sleep_share_one_frame() {
let payload = GreeClient::command_payload(&DeviceCommand {
target_temperature: Some(19.0),
fan_speed: Some(1),
quiet: Some(true),
sleep: Some(true),
..DeviceCommand::default()
}, false).expect("thermostat command payload");
assert_eq!(payload.get("opt").cloned(), Some(serde_json::json!(["SetTem", "WdSpd", "Quiet", "SwhSlp"])));
assert_eq!(payload.get("p").cloned(), Some(serde_json::json!([19, 1, 1, 1])));
}
}
+98
View File
@@ -0,0 +1,98 @@
impl GreeClient {
async fn request(&self, device: &Device, inner: &Value, key: &str, binding: bool, protocol_version: u8) -> Result<Value> {
let target = self.device_target(device)?;
let target_hint = match target { SocketAddr::V4(addr) => Some(*addr.ip()), SocketAddr::V6(_) => None };
let socket = self.udp_socket(false, target_hint).await?;
self.request_on_socket(device, inner, key, binding, protocol_version, &socket).await
}
async fn request_on_socket(&self, device: &Device, inner: &Value, key: &str, binding: bool, protocol_version: u8, socket: &UdpSocket) -> Result<Value> {
let target = self.device_target(device)?;
let version = if protocol_version == 2 { 2 } else { 1 };
let inner_bytes = serde_json::to_vec(inner)?;
let wire_mac = Self::wire_mac(device);
let mut outer = json!({
"cid": "app",
"i": if binding { 1 } else { 0 },
"t": "pack",
"tcid": wire_mac,
"uid": 0
});
if version == 2 {
let encrypted = encrypt_v2(key, &inner_bytes)?;
outer["pack"] = json!(encrypted.ciphertext);
outer["tag"] = json!(encrypted.tag);
} else {
outer["pack"] = json!(encrypt_v1(key, &inner_bytes)?);
}
let payload = serde_json::to_vec(&outer)?;
tracing::debug!(target=%target, local=%socket.local_addr()?, protocol=version, wire_mac=%wire_mac, interface=%self.interface.as_deref().unwrap_or("auto"), binding, "Sending GREE request");
self.debug_frame("tx", device, target, version, inner);
socket.send_to(&payload, target).await?;
let deadline = Instant::now() + Duration::from_secs(4);
let mut buffer = vec![0_u8; 16 * 1024];
let mut last_decode_error = None;
while Instant::now() < deadline {
let remaining = deadline.saturating_duration_since(Instant::now());
let received = timeout(remaining, socket.recv_from(&mut buffer)).await;
let (size, source) = match received {
Ok(Ok(value)) => value,
Ok(Err(err)) => return Err(err.into()),
Err(_) => break,
};
if source.ip() != target.ip() { continue; }
self.record_received_frame(device);
let response: Value = match serde_json::from_slice(&buffer[..size]) {
Ok(value) => value,
Err(err) => { last_decode_error = Some(anyhow!("invalid GREE JSON response: {err}")); continue; }
};
if let Some(pack) = response.get("pack").and_then(Value::as_object) {
let decoded = Value::Object(pack.clone());
if binding {
let response_type = decoded.get("t").and_then(Value::as_str).unwrap_or_default();
if !response_type.eq_ignore_ascii_case("bindok") {
tracing::debug!(source=%source, response_type=%response_type, "Ignoring non-bind packet while waiting for GREE bind response");
continue;
}
}
if let Some(err) = decoded.get("err").filter(|v| !v.is_null()) { bail!("GREE device error: {err}") }
self.debug_frame("rx", device, target, version, &decoded);
return Ok(decoded);
}
let Some(pack) = response.get("pack").and_then(Value::as_str) else { continue; };
let clear = if version == 2 {
let Some(tag) = response.get("tag").and_then(Value::as_str) else {
last_decode_error = Some(anyhow!("AES-GCM response is missing tag"));
continue;
};
match decrypt_v2(key, pack, tag) {
Ok(v) => v,
Err(err) => { last_decode_error = Some(err); continue; }
}
} else {
match decrypt_v1(key, pack) {
Ok(v) => v,
Err(err) => { last_decode_error = Some(err); continue; }
}
};
let decoded: Value = match serde_json::from_slice(&clear) {
Ok(value) => value,
Err(err) => { last_decode_error = Some(anyhow!("invalid decrypted GREE response: {err}")); continue; }
};
if binding {
let response_type = decoded.get("t").and_then(Value::as_str).unwrap_or_default();
if !response_type.eq_ignore_ascii_case("bindok") { continue; }
}
if let Some(err) = decoded.get("err").filter(|v| !v.is_null()) { bail!("GREE device error: {err}") }
self.debug_frame("rx", device, target, version, &decoded);
return Ok(decoded);
}
if let Some(err) = last_decode_error { return Err(err); }
bail!("GREE response timeout after 4 seconds")
}
fn device_target(&self, device: &Device) -> Result<SocketAddr> {
format!("{}:{}", device.ip, device.port).parse().context("invalid device address")
}
}