v0.12.0-preety_code
This commit is contained in:
+38
-13
@@ -1,5 +1,11 @@
|
||||
use aes::{Aes128, cipher::{BlockDecrypt, BlockEncrypt, KeyInit, generic_array::GenericArray}};
|
||||
use aes_gcm::{Aes128Gcm, Nonce, aead::{AeadInPlace, KeyInit as AeadKeyInit}};
|
||||
use aes::{
|
||||
cipher::{generic_array::GenericArray, BlockDecrypt, BlockEncrypt, KeyInit},
|
||||
Aes128,
|
||||
};
|
||||
use aes_gcm::{
|
||||
aead::{AeadInPlace, KeyInit as AeadKeyInit},
|
||||
Aes128Gcm, Nonce,
|
||||
};
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use base64::{
|
||||
alphabet,
|
||||
@@ -15,7 +21,9 @@ pub const GENERIC_GREE_V1_KEY: &str = "a3K8Bx%2r8Y7#xDh";
|
||||
/// Shared discovery/bind key used by AES-128-GCM capable Wi-Fi modules.
|
||||
pub const GENERIC_GREE_V2_KEY: &str = "{yxAHAY_Lm6pbC/<";
|
||||
/// GREE protocol v2 uses a fixed nonce and AAD, matching the EWPE/GREE LAN protocol.
|
||||
const GCM_NONCE: [u8; 12] = [0x54, 0x40, 0x78, 0x44, 0x49, 0x67, 0x5a, 0x51, 0x6c, 0x5e, 0x63, 0x13];
|
||||
const GCM_NONCE: [u8; 12] = [
|
||||
0x54, 0x40, 0x78, 0x44, 0x49, 0x67, 0x5a, 0x51, 0x6c, 0x5e, 0x63, 0x13,
|
||||
];
|
||||
const GCM_AAD: &[u8] = b"qualcomm-test";
|
||||
|
||||
// Some GREE Wi-Fi modules emit technically non-canonical Base64: padding may
|
||||
@@ -62,7 +70,9 @@ pub fn encrypt_v1(key: &str, plaintext: &[u8]) -> Result<String> {
|
||||
pub fn decrypt_v1(key: &str, ciphertext_b64: &str) -> Result<Vec<u8>> {
|
||||
let key = normalize_key(key)?;
|
||||
let cipher = Aes128::new_from_slice(&key).map_err(|_| anyhow!("invalid AES key"))?;
|
||||
let mut data = GREE_BASE64_DECODE.decode(ciphertext_b64).context("invalid base64 packet")?;
|
||||
let mut data = GREE_BASE64_DECODE
|
||||
.decode(ciphertext_b64)
|
||||
.context("invalid base64 packet")?;
|
||||
if data.is_empty() || data.len() % 16 != 0 {
|
||||
bail!("invalid AES-ECB ciphertext length")
|
||||
}
|
||||
@@ -94,10 +104,12 @@ pub struct V2Encrypted {
|
||||
|
||||
pub fn encrypt_v2(key: &str, plaintext: &[u8]) -> Result<V2Encrypted> {
|
||||
let key = normalize_key(key)?;
|
||||
let cipher = <Aes128Gcm as AeadKeyInit>::new_from_slice(&key).map_err(|_| anyhow!("invalid AES-GCM key"))?;
|
||||
let cipher = <Aes128Gcm as AeadKeyInit>::new_from_slice(&key)
|
||||
.map_err(|_| anyhow!("invalid AES-GCM key"))?;
|
||||
let nonce = Nonce::from_slice(&GCM_NONCE);
|
||||
let mut buffer = plaintext.to_vec();
|
||||
let tag = cipher.encrypt_in_place_detached(nonce, GCM_AAD, &mut buffer)
|
||||
let tag = cipher
|
||||
.encrypt_in_place_detached(nonce, GCM_AAD, &mut buffer)
|
||||
.map_err(|_| anyhow!("AES-GCM encryption failed"))?;
|
||||
Ok(V2Encrypted {
|
||||
ciphertext: STANDARD.encode(buffer),
|
||||
@@ -107,13 +119,21 @@ pub fn encrypt_v2(key: &str, plaintext: &[u8]) -> Result<V2Encrypted> {
|
||||
|
||||
pub fn decrypt_v2(key: &str, ciphertext_b64: &str, tag_b64: &str) -> Result<Vec<u8>> {
|
||||
let key = normalize_key(key)?;
|
||||
let cipher = <Aes128Gcm as AeadKeyInit>::new_from_slice(&key).map_err(|_| anyhow!("invalid AES-GCM key"))?;
|
||||
let tag_bytes = GREE_BASE64_DECODE.decode(tag_b64).context("invalid GCM tag")?;
|
||||
if tag_bytes.len() != 16 { bail!("invalid GCM tag length: {} bytes", tag_bytes.len()) }
|
||||
let mut data = GREE_BASE64_DECODE.decode(ciphertext_b64).context("invalid GCM ciphertext")?;
|
||||
let cipher = <Aes128Gcm as AeadKeyInit>::new_from_slice(&key)
|
||||
.map_err(|_| anyhow!("invalid AES-GCM key"))?;
|
||||
let tag_bytes = GREE_BASE64_DECODE
|
||||
.decode(tag_b64)
|
||||
.context("invalid GCM tag")?;
|
||||
if tag_bytes.len() != 16 {
|
||||
bail!("invalid GCM tag length: {} bytes", tag_bytes.len())
|
||||
}
|
||||
let mut data = GREE_BASE64_DECODE
|
||||
.decode(ciphertext_b64)
|
||||
.context("invalid GCM ciphertext")?;
|
||||
let nonce = Nonce::from_slice(&GCM_NONCE);
|
||||
let tag = GenericArray::from_slice(&tag_bytes);
|
||||
cipher.decrypt_in_place_detached(nonce, GCM_AAD, &mut data, tag)
|
||||
cipher
|
||||
.decrypt_in_place_detached(nonce, GCM_AAD, &mut data, tag)
|
||||
.map_err(|_| anyhow!("AES-GCM authentication failed"))?;
|
||||
// A few modules append 0xff filler bytes to decrypted JSON.
|
||||
data.retain(|byte| *byte != 0xff);
|
||||
@@ -135,7 +155,10 @@ mod tests {
|
||||
fn v2_round_trip() {
|
||||
let value = b"gree-gcm-test";
|
||||
let encrypted = encrypt_v2(GENERIC_GREE_V2_KEY, value).unwrap();
|
||||
assert_eq!(decrypt_v2(GENERIC_GREE_V2_KEY, &encrypted.ciphertext, &encrypted.tag).unwrap(), value);
|
||||
assert_eq!(
|
||||
decrypt_v2(GENERIC_GREE_V2_KEY, &encrypted.ciphertext, &encrypted.tag).unwrap(),
|
||||
value
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -143,7 +166,9 @@ mod tests {
|
||||
// 16 zero bytes canonically end with `A==`. `B==` carries the same
|
||||
// useful two bits but has non-zero unused trailing bits. Python's
|
||||
// base64.b64decode accepts it and real GREE modules emit this form.
|
||||
let decoded = GREE_BASE64_DECODE.decode("AAAAAAAAAAAAAAAAAAAAAB==").unwrap();
|
||||
let decoded = GREE_BASE64_DECODE
|
||||
.decode("AAAAAAAAAAAAAAAAAAAAAB==")
|
||||
.unwrap();
|
||||
assert_eq!(decoded, vec![0_u8; 16]);
|
||||
}
|
||||
}
|
||||
|
||||
+18
-8
@@ -1,14 +1,25 @@
|
||||
use std::{collections::{HashMap, HashSet}, net::{Ipv4Addr, SocketAddr, SocketAddrV4}, sync::{Arc, Mutex, atomic::{AtomicBool, AtomicU64, Ordering}}, time::Duration};
|
||||
use super::crypto::{
|
||||
decrypt_v1, decrypt_v2, encrypt_v1, encrypt_v2, GENERIC_GREE_V1_KEY, GENERIC_GREE_V2_KEY,
|
||||
};
|
||||
use crate::models::{ApiEvent, Device, DeviceCommand};
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use chrono::Utc;
|
||||
use serde_json::{json, Value};
|
||||
use tokio::{net::UdpSocket, sync::broadcast, time::{timeout, Instant}};
|
||||
use uuid::Uuid;
|
||||
use crate::models::{ApiEvent, Device, DeviceCommand};
|
||||
use super::crypto::{
|
||||
decrypt_v1, decrypt_v2, encrypt_v1, encrypt_v2,
|
||||
GENERIC_GREE_V1_KEY, GENERIC_GREE_V2_KEY,
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
net::{Ipv4Addr, SocketAddr, SocketAddrV4},
|
||||
sync::{
|
||||
atomic::{AtomicBool, AtomicU64, Ordering},
|
||||
Arc, Mutex,
|
||||
},
|
||||
time::Duration,
|
||||
};
|
||||
use tokio::{
|
||||
net::UdpSocket,
|
||||
sync::broadcast,
|
||||
time::{timeout, Instant},
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BindResult {
|
||||
@@ -29,7 +40,6 @@ pub struct GreeClient {
|
||||
sleep_unsupported: Arc<Mutex<HashSet<String>>>,
|
||||
}
|
||||
|
||||
|
||||
// Functional source split intentionally keeps items in the existing module namespace.
|
||||
include!("gree/core.rs");
|
||||
include!("gree/discovery.rs");
|
||||
|
||||
@@ -7,7 +7,12 @@ impl GreeClient {
|
||||
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 }),
|
||||
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}"));
|
||||
@@ -28,7 +33,10 @@ impl GreeClient {
|
||||
|
||||
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 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
|
||||
@@ -56,16 +64,28 @@ impl GreeClient {
|
||||
|
||||
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();
|
||||
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)
|
||||
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") }
|
||||
if key.is_empty() {
|
||||
bail!("device returned an empty key")
|
||||
}
|
||||
Ok(key.to_string())
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+137
-33
@@ -1,10 +1,16 @@
|
||||
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)
|
||||
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)
|
||||
self.sleep_unsupported
|
||||
.lock()
|
||||
.map(|items| !items.contains(device_id))
|
||||
.unwrap_or(true)
|
||||
}
|
||||
|
||||
async fn request_command_with_buzzer_fallback(
|
||||
@@ -15,17 +21,29 @@ impl GreeClient {
|
||||
suppress_beep: bool,
|
||||
) -> Result<Value> {
|
||||
let try_buzzer_suppression = suppress_beep
|
||||
&& self.buzzer_unsupported.lock().map(|items| !items.contains(&device.id)).unwrap_or(true);
|
||||
&& 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 {
|
||||
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 {
|
||||
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()); }
|
||||
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)
|
||||
}
|
||||
@@ -36,8 +54,16 @@ impl GreeClient {
|
||||
}
|
||||
}
|
||||
|
||||
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"))?;
|
||||
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;
|
||||
@@ -49,7 +75,10 @@ impl GreeClient {
|
||||
return Ok(effective);
|
||||
}
|
||||
|
||||
match self.request_command_with_buzzer_fallback(device, key, &effective, suppress_beep).await {
|
||||
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
|
||||
@@ -59,8 +88,19 @@ impl GreeClient {
|
||||
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()); }
|
||||
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);
|
||||
}
|
||||
@@ -70,8 +110,19 @@ impl GreeClient {
|
||||
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()); }
|
||||
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);
|
||||
}
|
||||
@@ -82,9 +133,22 @@ impl GreeClient {
|
||||
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()); }
|
||||
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);
|
||||
}
|
||||
@@ -98,30 +162,70 @@ impl GreeClient {
|
||||
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.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));
|
||||
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 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));
|
||||
opt.push("Buzzer_ON_OFF");
|
||||
values.push(json!(1));
|
||||
opt.push("BuzzerCtrl");
|
||||
values.push(json!(0));
|
||||
}
|
||||
Ok(json!({"opt": opt, "p": values, "t": "cmd"}))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+64
-22
@@ -20,19 +20,29 @@ impl GreeClient {
|
||||
|
||||
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()
|
||||
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);
|
||||
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(),
|
||||
@@ -47,12 +57,25 @@ impl GreeClient {
|
||||
}
|
||||
}
|
||||
|
||||
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; };
|
||||
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!("***")); }
|
||||
if object.contains_key("key") {
|
||||
object.insert("key".into(), json!("***"));
|
||||
}
|
||||
}
|
||||
let _ = events.send(ApiEvent {
|
||||
event: "gree.frame".into(),
|
||||
@@ -68,11 +91,18 @@ impl GreeClient {
|
||||
});
|
||||
}
|
||||
|
||||
async fn udp_socket(&self, broadcast: bool, target_hint: Option<Ipv4Addr>) -> Result<UdpSocket> {
|
||||
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}"))?
|
||||
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!(
|
||||
@@ -81,8 +111,14 @@ impl GreeClient {
|
||||
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))?
|
||||
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?
|
||||
}
|
||||
@@ -94,7 +130,9 @@ impl GreeClient {
|
||||
}
|
||||
|
||||
fn bind_scan_target(&self, target: SocketAddr) -> Result<SocketAddr> {
|
||||
let SocketAddr::V4(target_v4) = target else { return Ok(target); };
|
||||
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)
|
||||
@@ -109,16 +147,20 @@ impl GreeClient {
|
||||
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"))
|
||||
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 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")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
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>> {
|
||||
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 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);
|
||||
@@ -17,7 +26,11 @@ impl GreeClient {
|
||||
);
|
||||
|
||||
let deadline = Instant::now() + duration;
|
||||
let interval = if passes > 1 { duration / passes as u32 } else { 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();
|
||||
@@ -36,10 +49,14 @@ impl GreeClient {
|
||||
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; };
|
||||
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; }
|
||||
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());
|
||||
@@ -48,7 +65,9 @@ impl GreeClient {
|
||||
}
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(err) => tracing::debug!(source=%source, error=?err, "Ignoring undecodable discovery response"),
|
||||
Err(err) => {
|
||||
tracing::debug!(source=%source, error=?err, "Ignoring undecodable discovery response")
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Err(err)) => return Err(err.into()),
|
||||
@@ -69,46 +88,90 @@ impl GreeClient {
|
||||
} else {
|
||||
decrypt_v1(GENERIC_GREE_V1_KEY, pack)?
|
||||
};
|
||||
value = serde_json::from_slice::<Value>(&clear).context("invalid decrypted discovery JSON")?;
|
||||
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() {
|
||||
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")
|
||||
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); }
|
||||
.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())))
|
||||
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")) {
|
||||
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 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())
|
||||
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();
|
||||
@@ -117,7 +180,11 @@ impl GreeClient {
|
||||
mac,
|
||||
name,
|
||||
ip: source.ip().to_string(),
|
||||
port: if source.port() == 0 { 7000 } else { source.port() },
|
||||
port: if source.port() == 0 {
|
||||
7000
|
||||
} else {
|
||||
source.port()
|
||||
},
|
||||
protocol_version: detected_protocol,
|
||||
model,
|
||||
firmware,
|
||||
@@ -157,5 +224,4 @@ impl GreeClient {
|
||||
updated_at: now,
|
||||
}))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -2,9 +2,18 @@ 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.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;
|
||||
@@ -17,7 +26,9 @@ pub fn merge_discovered(existing: Option<Device>, discovered: Device) -> Device
|
||||
old
|
||||
} else {
|
||||
let mut new = discovered;
|
||||
if new.id.is_empty() { new.id = Uuid::new_v4().to_string(); }
|
||||
if new.id.is_empty() {
|
||||
new.id = Uuid::new_v4().to_string();
|
||||
}
|
||||
new
|
||||
}
|
||||
}
|
||||
|
||||
+120
-30
@@ -2,8 +2,13 @@ impl GreeClient {
|
||||
/// Measure a minimal GREE round-trip without mutating persisted/live device state.
|
||||
/// Diagnostics must not alter online/error counters, ownership, readings or capabilities.
|
||||
pub async fn probe(&self, device: &Device) -> Result<u64> {
|
||||
if device.simulated { return Ok(0); }
|
||||
let key = device.key.as_deref().filter(|value| !value.is_empty())
|
||||
if device.simulated {
|
||||
return Ok(0);
|
||||
}
|
||||
let key = device
|
||||
.key
|
||||
.as_deref()
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| anyhow!("device is not bound"))?;
|
||||
let started = Instant::now();
|
||||
let response = self.status_request(device, key, &["Pow"]).await?;
|
||||
@@ -13,14 +18,52 @@ 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 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"
|
||||
"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 {
|
||||
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");
|
||||
@@ -33,8 +76,12 @@ impl GreeClient {
|
||||
// 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"),
|
||||
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
|
||||
@@ -51,7 +98,8 @@ impl GreeClient {
|
||||
|
||||
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
|
||||
self.request(device, &inner, key, false, device.protocol_version)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn probe_optional_features(&self, device: &mut Device, key: &str) {
|
||||
@@ -65,10 +113,14 @@ impl GreeClient {
|
||||
("SwhSlp", device.supports_sleep.is_none()),
|
||||
];
|
||||
for (property, needed) in probes {
|
||||
if !needed { continue; }
|
||||
if !needed {
|
||||
continue;
|
||||
}
|
||||
match self.status_request(device, key, &[property]).await {
|
||||
Ok(value) => {
|
||||
let returned = value.get("cols").and_then(Value::as_array)
|
||||
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() {
|
||||
@@ -98,9 +150,13 @@ impl GreeClient {
|
||||
}
|
||||
|
||||
fn apply_status(&self, device: &mut Device, response: &Value) -> Result<()> {
|
||||
let response_cols = response.get("cols").and_then(Value::as_array)
|
||||
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)
|
||||
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")
|
||||
@@ -112,38 +168,69 @@ impl GreeClient {
|
||||
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; };
|
||||
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();
|
||||
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}") }
|
||||
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}") }
|
||||
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); },
|
||||
"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}") }
|
||||
if !(-40.0..=80.0).contains(&temperature) {
|
||||
bail!("invalid GREE indoor temperature: {temperature}")
|
||||
}
|
||||
next.temperature_sensor_offset = Some(offset);
|
||||
next.current_temperature = Some(temperature);
|
||||
}
|
||||
@@ -153,16 +240,19 @@ impl GreeClient {
|
||||
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}") }
|
||||
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; }
|
||||
if let Some(base) = set_temp {
|
||||
next.target_temperature = base;
|
||||
}
|
||||
*device = next;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -28,15 +28,25 @@ mod tests {
|
||||
|
||||
#[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");
|
||||
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])));
|
||||
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]))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,31 @@
|
||||
impl GreeClient {
|
||||
async fn request(&self, device: &Device, inner: &Value, key: &str, binding: bool, protocol_version: u8) -> Result<Value> {
|
||||
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 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
|
||||
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> {
|
||||
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)?;
|
||||
@@ -41,26 +60,36 @@ impl GreeClient {
|
||||
Ok(Err(err)) => return Err(err.into()),
|
||||
Err(_) => break,
|
||||
};
|
||||
if source.ip() != target.ip() { continue; }
|
||||
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; }
|
||||
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();
|
||||
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}") }
|
||||
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 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"));
|
||||
@@ -68,31 +97,48 @@ impl GreeClient {
|
||||
};
|
||||
match decrypt_v2(key, pack, tag) {
|
||||
Ok(v) => v,
|
||||
Err(err) => { last_decode_error = Some(err); continue; }
|
||||
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; }
|
||||
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; }
|
||||
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 !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}")
|
||||
}
|
||||
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); }
|
||||
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")
|
||||
format!("{}:{}", device.ip, device.port)
|
||||
.parse()
|
||||
.context("invalid device address")
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
pub mod crypto;
|
||||
pub mod gree;
|
||||
|
||||
pub use gree::{GreeClient, merge_discovered};
|
||||
pub use gree::{merge_discovered, GreeClient};
|
||||
|
||||
Reference in New Issue
Block a user