first commit

This commit is contained in:
Mateusz Gruszczyński
2026-08-23 21:34:07 +02:00
commit 1d3dcba1a9
62 changed files with 12456 additions and 0 deletions
+112
View File
@@ -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);
}
}
+259
View File
@@ -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
}
}
+4
View File
@@ -0,0 +1,4 @@
pub mod crypto;
pub mod gree;
pub use gree::{GreeClient, merge_discovered};