first commit
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
use aes::{Aes128, cipher::{BlockDecrypt, BlockEncrypt, KeyInit, generic_array::GenericArray}};
|
||||
use aes_gcm::{Aes128Gcm, Nonce, aead::{AeadInPlace, KeyInit as AeadKeyInit}};
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use base64::{engine::general_purpose::STANDARD, Engine};
|
||||
use rand::RngCore;
|
||||
|
||||
pub const GENERIC_GREE_KEY: &str = "a3K8Bx%2r8Y7cB!a";
|
||||
|
||||
pub fn normalize_key(key: &str) -> Result<[u8; 16]> {
|
||||
let bytes = key.as_bytes();
|
||||
if bytes.len() == 16 {
|
||||
let mut out = [0_u8; 16];
|
||||
out.copy_from_slice(bytes);
|
||||
return Ok(out);
|
||||
}
|
||||
if let Ok(decoded) = STANDARD.decode(key) {
|
||||
if decoded.len() == 16 {
|
||||
let mut out = [0_u8; 16];
|
||||
out.copy_from_slice(&decoded);
|
||||
return Ok(out);
|
||||
}
|
||||
}
|
||||
bail!("GREE key must contain 16 bytes or base64-encoded 16 bytes")
|
||||
}
|
||||
|
||||
pub fn encrypt_v1(key: &str, plaintext: &[u8]) -> Result<String> {
|
||||
let key = normalize_key(key)?;
|
||||
let cipher = Aes128::new_from_slice(&key).map_err(|_| anyhow!("invalid AES key"))?;
|
||||
let pad = 16 - (plaintext.len() % 16);
|
||||
let mut data = Vec::with_capacity(plaintext.len() + pad);
|
||||
data.extend_from_slice(plaintext);
|
||||
data.extend(std::iter::repeat(pad as u8).take(pad));
|
||||
for block in data.chunks_exact_mut(16) {
|
||||
cipher.encrypt_block(GenericArray::from_mut_slice(block));
|
||||
}
|
||||
Ok(STANDARD.encode(data))
|
||||
}
|
||||
|
||||
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 = STANDARD.decode(ciphertext_b64).context("invalid base64 packet")?;
|
||||
if data.is_empty() || data.len() % 16 != 0 {
|
||||
bail!("invalid AES-ECB ciphertext length")
|
||||
}
|
||||
for block in data.chunks_exact_mut(16) {
|
||||
cipher.decrypt_block(GenericArray::from_mut_slice(block));
|
||||
}
|
||||
let pad = *data.last().ok_or_else(|| anyhow!("empty plaintext"))? as usize;
|
||||
if pad == 0 || pad > 16 || data.len() < pad || data[data.len() - pad..].iter().any(|v| *v as usize != pad) {
|
||||
bail!("invalid PKCS#7 padding")
|
||||
}
|
||||
data.truncate(data.len() - pad);
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct V2Encrypted {
|
||||
pub ciphertext: String,
|
||||
pub nonce: String,
|
||||
pub tag: String,
|
||||
}
|
||||
|
||||
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 mut nonce_bytes = [0_u8; 12];
|
||||
rand::thread_rng().fill_bytes(&mut nonce_bytes);
|
||||
let nonce = Nonce::from_slice(&nonce_bytes);
|
||||
let mut buffer = plaintext.to_vec();
|
||||
let tag = cipher.encrypt_in_place_detached(nonce, b"", &mut buffer)
|
||||
.map_err(|_| anyhow!("AES-GCM encryption failed"))?;
|
||||
Ok(V2Encrypted {
|
||||
ciphertext: STANDARD.encode(buffer),
|
||||
nonce: STANDARD.encode(nonce_bytes),
|
||||
tag: STANDARD.encode(tag),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn decrypt_v2(key: &str, ciphertext_b64: &str, nonce_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 nonce_bytes = STANDARD.decode(nonce_b64).context("invalid GCM nonce")?;
|
||||
if nonce_bytes.len() != 12 { bail!("invalid GCM nonce length") }
|
||||
let tag_bytes = STANDARD.decode(tag_b64).context("invalid GCM tag")?;
|
||||
if tag_bytes.len() != 16 { bail!("invalid GCM tag length") }
|
||||
let mut data = STANDARD.decode(ciphertext_b64).context("invalid GCM ciphertext")?;
|
||||
let nonce = Nonce::from_slice(&nonce_bytes);
|
||||
let tag = GenericArray::from_slice(&tag_bytes);
|
||||
cipher.decrypt_in_place_detached(nonce, b"", &mut data, tag)
|
||||
.map_err(|_| anyhow!("AES-GCM authentication failed"))?;
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn v1_round_trip() {
|
||||
let value = br#"{"t":"status","mac":"112233445566"}"#;
|
||||
let encrypted = encrypt_v1(GENERIC_GREE_KEY, value).unwrap();
|
||||
assert_eq!(decrypt_v1(GENERIC_GREE_KEY, &encrypted).unwrap(), value);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v2_round_trip() {
|
||||
let value = b"gree-gcm-test";
|
||||
let encrypted = encrypt_v2(GENERIC_GREE_KEY, value).unwrap();
|
||||
assert_eq!(decrypt_v2(GENERIC_GREE_KEY, &encrypted.ciphertext, &encrypted.nonce, &encrypted.tag).unwrap(), value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
use std::{collections::HashSet, net::SocketAddr, sync::{Arc, atomic::{AtomicU64, Ordering}}, time::Duration};
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use chrono::Utc;
|
||||
use serde_json::{json, Value};
|
||||
use tokio::{net::UdpSocket, time::{timeout, Instant}};
|
||||
use uuid::Uuid;
|
||||
use crate::models::{Device, DeviceCommand};
|
||||
use super::crypto::{decrypt_v1, decrypt_v2, encrypt_v1, encrypt_v2, GENERIC_GREE_KEY};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct GreeClient {
|
||||
controller_id: String,
|
||||
sequence: Arc<AtomicU64>,
|
||||
}
|
||||
|
||||
impl GreeClient {
|
||||
pub fn new(controller_id: String) -> Self {
|
||||
Self { controller_id, sequence: Arc::new(AtomicU64::new(1)) }
|
||||
}
|
||||
|
||||
fn next_id(&self) -> u64 { self.sequence.fetch_add(1, Ordering::Relaxed) }
|
||||
|
||||
pub async fn discover(&self, broadcast: &str, duration: Duration) -> Result<Vec<Device>> {
|
||||
let target: SocketAddr = broadcast.parse().context("invalid discovery broadcast address")?;
|
||||
let socket = UdpSocket::bind("0.0.0.0:0").await?;
|
||||
socket.set_broadcast(true)?;
|
||||
socket.send_to(br#"{"t":"scan"}"#, target).await?;
|
||||
|
||||
let deadline = Instant::now() + duration;
|
||||
let mut result = Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
let mut buffer = vec![0_u8; 8192];
|
||||
|
||||
while Instant::now() < deadline {
|
||||
let remaining = deadline.saturating_duration_since(Instant::now());
|
||||
match timeout(remaining.min(Duration::from_millis(450)), socket.recv_from(&mut buffer)).await {
|
||||
Ok(Ok((size, source))) => {
|
||||
if let Ok(value) = serde_json::from_slice::<Value>(&buffer[..size]) {
|
||||
if let Some(mut device) = self.parse_discovery(value, source) {
|
||||
let key = device.mac.to_ascii_lowercase();
|
||||
if seen.insert(key) {
|
||||
device.last_seen = Some(Utc::now());
|
||||
result.push(device);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Err(err)) => return Err(err.into()),
|
||||
Err(_) => continue,
|
||||
}
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn parse_discovery(&self, mut value: Value, source: SocketAddr) -> Option<Device> {
|
||||
if value.get("t").and_then(Value::as_str) == Some("pack") {
|
||||
if let Some(pack) = value.get("pack").and_then(Value::as_str) {
|
||||
if let Ok(clear) = decrypt_v1(GENERIC_GREE_KEY, pack) {
|
||||
if let Ok(inner) = serde_json::from_slice::<Value>(&clear) { value = inner; }
|
||||
}
|
||||
}
|
||||
}
|
||||
let kind = value.get("t").and_then(Value::as_str).unwrap_or_default();
|
||||
if kind != "dev" && kind != "scan" && value.get("mac").is_none() && value.get("cid").is_none() {
|
||||
return None;
|
||||
}
|
||||
let mac = value.get("mac").or_else(|| value.get("cid"))?.as_str()?.replace(':', "");
|
||||
if mac.is_empty() { return None; }
|
||||
let name = value.get("name").and_then(Value::as_str)
|
||||
.filter(|v| !v.trim().is_empty())
|
||||
.unwrap_or("Klimatyzator GREE").to_string();
|
||||
let model = value.get("model").or_else(|| value.get("series"))
|
||||
.and_then(Value::as_str).unwrap_or_default().to_string();
|
||||
let firmware = value.get("ver").and_then(Value::as_str).unwrap_or_default().to_string();
|
||||
let protocol_version = value.get("protocol").and_then(Value::as_u64)
|
||||
.or_else(|| value.get("v").and_then(Value::as_u64))
|
||||
.map(|v| v as u8)
|
||||
.unwrap_or_else(|| if value.get("tag").is_some() || value.get("nonce").is_some() { 2 } else { 1 });
|
||||
let now = Utc::now();
|
||||
Some(Device {
|
||||
id: format!("gree-{}", mac.to_ascii_lowercase()),
|
||||
mac,
|
||||
name,
|
||||
ip: source.ip().to_string(),
|
||||
port: source.port(),
|
||||
protocol_version,
|
||||
model,
|
||||
firmware,
|
||||
key: None,
|
||||
cid: Some(self.controller_id.clone()),
|
||||
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,
|
||||
current_temperature: None,
|
||||
outdoor_temperature: None,
|
||||
online: true,
|
||||
last_seen: Some(now),
|
||||
last_error: None,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn bind(&self, device: &Device) -> Result<String> {
|
||||
let inner = json!({"mac": device.mac, "t": "bind", "uid": 0});
|
||||
let response = self.request(device, &inner, GENERIC_GREE_KEY, true).await?;
|
||||
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.as_deref().ok_or_else(|| anyhow!("device is not bound"))?;
|
||||
let cols = [
|
||||
"Pow","Mod","SetTem","WdSpd","Air","Blo","Health","SwhSlp","Lig",
|
||||
"SwingLfRig","SwUpDn","Quiet","Tur","StHt","TemUn","HeatCoolType",
|
||||
"TemRec","SvSt","TemSen","CoolSvTem","HeatSvTem"
|
||||
];
|
||||
let inner = json!({"cols": cols, "mac": device.mac, "t": "status"});
|
||||
let response = self.request(device, &inner, key, false).await?;
|
||||
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"))?;
|
||||
for (name, value) in response_cols.iter().zip(data.iter()) {
|
||||
let Some(name) = name.as_str() else { continue; };
|
||||
match name {
|
||||
"Pow" => device.power = value_as_i64(value) != 0,
|
||||
"Mod" => device.mode = mode_name(value_as_i64(value)).into(),
|
||||
"SetTem" => device.target_temperature = value_as_f64(value).clamp(8.0, 32.0),
|
||||
"WdSpd" => device.fan_speed = value_as_i64(value).clamp(0, 5) as u8,
|
||||
"SwUpDn" => device.swing_vertical = value_as_i64(value) != 0,
|
||||
"SwingLfRig" => device.swing_horizontal = value_as_i64(value) != 0,
|
||||
"Quiet" => device.quiet = value_as_i64(value) != 0,
|
||||
"Tur" => device.turbo = value_as_i64(value) != 0,
|
||||
"Lig" => device.light = value_as_i64(value) != 0,
|
||||
"TemSen" => {
|
||||
let raw = value_as_f64(value);
|
||||
device.current_temperature = Some(if raw > 40.0 { raw - 40.0 } else { raw });
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
device.online = true;
|
||||
device.last_seen = Some(Utc::now());
|
||||
device.last_error = None;
|
||||
device.updated_at = Utc::now();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn command(&self, device: &Device, command: &DeviceCommand) -> Result<Value> {
|
||||
let key = device.key.as_deref().ok_or_else(|| anyhow!("device is not bound"))?;
|
||||
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 { opt.push("SetTem"); values.push(json!(v.clamp(8.0, 32.0).round() as i64)); }
|
||||
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 opt.is_empty() { bail!("empty device command") }
|
||||
let inner = json!({"opt": opt, "p": values, "t": "cmd"});
|
||||
self.request(device, &inner, key, false).await
|
||||
}
|
||||
|
||||
async fn request(&self, device: &Device, inner: &Value, key: &str, binding: bool) -> Result<Value> {
|
||||
let target: SocketAddr = format!("{}:{}", device.ip, device.port).parse()
|
||||
.context("invalid device address")?;
|
||||
let inner_bytes = serde_json::to_vec(inner)?;
|
||||
let mut outer = json!({
|
||||
"cid": self.controller_id,
|
||||
"i": self.next_id(),
|
||||
"t": "pack",
|
||||
"tcid": device.mac,
|
||||
"uid": 0
|
||||
});
|
||||
if device.protocol_version >= 2 && !binding {
|
||||
let encrypted = encrypt_v2(key, &inner_bytes)?;
|
||||
outer["pack"] = json!(encrypted.ciphertext);
|
||||
outer["nonce"] = json!(encrypted.nonce);
|
||||
outer["tag"] = json!(encrypted.tag);
|
||||
} else {
|
||||
outer["pack"] = json!(encrypt_v1(key, &inner_bytes)?);
|
||||
}
|
||||
let payload = serde_json::to_vec(&outer)?;
|
||||
let socket = UdpSocket::bind("0.0.0.0:0").await?;
|
||||
socket.send_to(&payload, target).await?;
|
||||
let mut buffer = vec![0_u8; 16 * 1024];
|
||||
let (size, _) = timeout(Duration::from_secs(4), socket.recv_from(&mut buffer))
|
||||
.await.context("GREE response timeout")??;
|
||||
let response: Value = serde_json::from_slice(&buffer[..size]).context("invalid GREE JSON response")?;
|
||||
let pack = response.get("pack").and_then(Value::as_str)
|
||||
.ok_or_else(|| anyhow!("GREE response does not contain encrypted pack"))?;
|
||||
let clear = if let (Some(nonce), Some(tag)) = (
|
||||
response.get("nonce").and_then(Value::as_str),
|
||||
response.get("tag").and_then(Value::as_str),
|
||||
) {
|
||||
decrypt_v2(key, pack, nonce, tag)?
|
||||
} else {
|
||||
decrypt_v1(key, pack)?
|
||||
};
|
||||
let decoded: Value = serde_json::from_slice(&clear).context("invalid decrypted GREE response")?;
|
||||
if let Some(err) = decoded.get("err").filter(|v| !v.is_null()) {
|
||||
bail!("GREE device error: {err}")
|
||||
}
|
||||
Ok(decoded)
|
||||
}
|
||||
}
|
||||
|
||||
fn value_as_i64(value: &Value) -> i64 {
|
||||
value.as_i64().or_else(|| value.as_str()?.parse().ok()).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn value_as_f64(value: &Value) -> f64 {
|
||||
value.as_f64().or_else(|| value.as_str()?.parse().ok()).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn mode_name(value: i64) -> &'static str {
|
||||
match value { 0 => "auto", 1 => "cool", 2 => "dry", 3 => "fan", 4 => "heat", _ => "auto" }
|
||||
}
|
||||
|
||||
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 = discovered.name; }
|
||||
if !discovered.model.is_empty() { old.model = discovered.model; }
|
||||
if !discovered.firmware.is_empty() { old.firmware = discovered.firmware; }
|
||||
old.protocol_version = discovered.protocol_version;
|
||||
old.online = true;
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
pub mod crypto;
|
||||
pub mod gree;
|
||||
|
||||
pub use gree::{GreeClient, merge_discovered};
|
||||
Reference in New Issue
Block a user