This commit is contained in:
Mateusz Gruszczyński
2026-08-23 22:05:24 +02:00
parent 1d3dcba1a9
commit 9c9b7b272b
25 changed files with 902 additions and 252 deletions
+76 -15
View File
@@ -21,7 +21,7 @@ use crate::{
engine,
error::AppError,
home_assistant,
models::{ApiTokenInfo, Automation, Device, DeviceCommand, DevicePatch, DiscoveryRequest, ManualDeviceRequest, RuntimeSettings, Schedule, Zone},
models::{ApiTokenInfo, Automation, Device, DeviceCommand, DevicePatch, DiscoveryRequest, ManualDeviceRequest, RuntimeSettings, Schedule, Zone, ZoneControlPatch},
protocol::merge_discovered,
state::AppState,
};
@@ -46,6 +46,7 @@ pub fn router(state: AppState) -> Router {
.route("/api/devices/:id/command", post(command_device))
.route("/api/zones", get(list_zones).post(create_zone))
.route("/api/zones/:id", get(get_zone).put(update_zone).delete(delete_zone))
.route("/api/zones/:id/control", post(update_zone_control))
.route("/api/schedules", get(list_schedules).post(create_schedule))
.route("/api/schedules/:id", get(get_schedule).put(update_schedule).delete(delete_schedule))
.route("/api/automations", get(list_automations).post(create_automation))
@@ -172,25 +173,47 @@ async fn system_info(State(state): State<AppState>) -> Result<Json<Value>, AppEr
"online_count": devices.iter().filter(|v| v.online).count(),
"simulator_count": devices.iter().filter(|v| v.simulated).count(),
"bind": state.config.bind.to_string(),
"gree_interface": if state.config.gree_interface.trim().is_empty() { "auto" } else { state.config.gree_interface.trim() },
})))
}
async fn discover(State(state): State<AppState>, Json(request): Json<DiscoveryRequest>) -> Result<Json<Value>, AppError> {
let settings = state.settings.read().await.clone();
let timeout_ms = request.timeout_ms.unwrap_or(settings.discovery_timeout_ms).clamp(300, 30_000);
let timeout_ms = request.timeout_ms.unwrap_or(settings.discovery_timeout_ms).clamp(500, 30_000);
let broadcast = request.broadcast.unwrap_or(settings.discovery_broadcast);
let discovered = state.gree.discover(&broadcast, Duration::from_millis(timeout_ms)).await
let protocol_version = request.protocol_version.unwrap_or(0).min(2);
let passes = request.passes.unwrap_or(3).clamp(1, 10);
let discovered = state.gree.discover(&broadcast, Duration::from_millis(timeout_ms), protocol_version, passes).await
.map_err(|e| AppError::Device(e.to_string()))?;
let mut saved = Vec::new();
let mut new_device_ids = Vec::new();
for item in discovered {
let existing = state.db.get_device_by_mac(&item.mac)?;
let merged = merge_discovered(existing, item);
let is_new = existing.is_none();
let mut merged = merge_discovered(existing, item);
// Bind right after discovery. GREE modules can have a short bind window;
// bind() also refreshes it with a direct scan before the handshake.
if !merged.simulated && merged.key.as_deref().unwrap_or_default().is_empty() {
match state.gree.bind(&merged).await {
Ok(bound) => {
merged.key = Some(bound.key);
merged.protocol_version = bound.protocol_version;
merged.communication_failures = 0;
merged.last_error = None;
}
Err(err) => {
merged.last_error = Some(format!("discovered, bind pending: {err}"));
state.log("warn", "device.bind_after_discovery", &format!("{}: {err}", merged.name), json!({"device_id": merged.id}));
}
}
}
state.db.save_device(&merged)?;
if is_new { new_device_ids.push(merged.id.clone()); }
saved.push(merged);
}
state.log("info", "discovery.complete", &format!("Discovery found {} device(s)", saved.len()), json!({"count": saved.len()}));
state.log("info", "discovery.complete", &format!("Discovery found {} device(s)", saved.len()), json!({"count": saved.len(), "protocol_version": protocol_version, "passes": passes, "new_devices": new_device_ids.len()}));
state.broadcast("devices.discovered", json!({"devices": saved}));
Ok(Json(json!({"count": saved.len(), "devices": saved})))
Ok(Json(json!({"count": saved.len(), "devices": saved, "new_device_ids": new_device_ids})))
}
async fn list_devices(State(state): State<AppState>) -> Result<Json<Vec<Device>>, AppError> {
@@ -213,11 +236,11 @@ async fn add_device(State(state): State<AppState>, Json(input): Json<ManualDevic
name: input.name.trim().to_string(),
ip: input.ip,
port: input.port,
protocol_version: input.protocol_version.clamp(1, 2),
protocol_version: input.protocol_version.min(2),
model: String::new(),
firmware: String::new(),
key: input.key.filter(|v| !v.trim().is_empty()),
cid: Some(state.settings.read().await.controller_id.clone()),
cid: Some("app".into()),
enabled: true,
simulated: input.simulated,
power: false,
@@ -234,6 +257,7 @@ async fn add_device(State(state): State<AppState>, Json(input): Json<ManualDevic
online: input.simulated,
last_seen: if input.simulated { Some(now) } else { None },
last_error: None,
communication_failures: 0,
created_at: now,
updated_at: now,
};
@@ -252,7 +276,7 @@ async fn patch_device(State(state): State<AppState>, Path(id): Path<String>, Jso
if let Some(v) = patch.name { if !v.trim().is_empty() { device.name = v.trim().to_string(); } }
if let Some(v) = patch.ip { v.parse::<IpAddr>().map_err(|_| AppError::BadRequest("invalid IP address".into()))?; device.ip = v; }
if let Some(v) = patch.port { device.port = v; }
if let Some(v) = patch.protocol_version { device.protocol_version = v.clamp(1, 2); }
if let Some(v) = patch.protocol_version { let v = v.min(2); if device.protocol_version != v { device.protocol_version = v; device.key = None; } }
if let Some(v) = patch.key { device.key = v.filter(|x| !x.trim().is_empty()); }
if let Some(v) = patch.enabled { device.enabled = v; }
device.updated_at = Utc::now();
@@ -271,8 +295,10 @@ async fn delete_device(State(state): State<AppState>, Path(id): Path<String>) ->
async fn bind_device(State(state): State<AppState>, Path(id): Path<String>) -> Result<Json<Device>, AppError> {
let mut device = state.db.get_device(&id)?.ok_or_else(|| AppError::NotFound(format!("device {id}")))?;
if device.simulated { return Ok(Json(device)); }
let key = state.gree.bind(&device).await.map_err(|e| AppError::Device(e.to_string()))?;
device.key = Some(key);
let bound = state.gree.bind(&device).await.map_err(|e| AppError::Device(e.to_string()))?;
device.key = Some(bound.key);
device.protocol_version = bound.protocol_version;
device.communication_failures = 0;
device.online = true;
device.last_seen = Some(Utc::now());
device.last_error = None;
@@ -327,7 +353,7 @@ fn device_source() -> String { "device".into() }
impl ZoneInput {
fn validate(&self) -> Result<(), AppError> {
if self.name.trim().is_empty() { return Err(AppError::BadRequest("zone name is required".into())); }
if !(8.0..=32.0).contains(&self.setpoint) { return Err(AppError::BadRequest("zone setpoint must be between 8 and 32 C".into())); }
if !(8.0..=30.0).contains(&self.setpoint) { return Err(AppError::BadRequest("zone setpoint must be between 8 and 30 C".into())); }
if !(0.1..=5.0).contains(&self.hysteresis) { return Err(AppError::BadRequest("hysteresis must be between 0.1 and 5 C".into())); }
if !matches!(self.mode.as_str(), "cool" | "heat") { return Err(AppError::BadRequest("zone mode must be cool or heat".into())); }
if !matches!(self.sensor_source.as_str(), "device" | "home_assistant" | "combined") { return Err(AppError::BadRequest("unsupported sensor source".into())); }
@@ -379,6 +405,38 @@ async fn update_zone(State(state): State<AppState>, Path(id): Path<String>, Json
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
Ok(Json(zone))
}
async fn update_zone_control(State(state): State<AppState>, Path(id): Path<String>, Json(patch): Json<ZoneControlPatch>) -> Result<Json<Zone>, AppError> {
let mut zone = state.db.get_zone(&id)?.ok_or_else(|| AppError::NotFound(format!("zone {id}")))?;
if let Some(value) = patch.setpoint {
if !(8.0..=30.0).contains(&value) { return Err(AppError::BadRequest("zone setpoint must be between 8 and 30 C".into())); }
zone.setpoint = (value * 2.0).round() / 2.0;
}
if let Some(value) = patch.mode.as_deref() {
if !matches!(value, "cool" | "heat") { return Err(AppError::BadRequest("zone mode must be cool or heat".into())); }
zone.mode = value.to_string();
}
if let Some(value) = patch.enabled { zone.enabled = value; }
zone.updated_at = Utc::now();
state.db.save_zone(&zone)?;
// Quick zone controls also update the paired climate unit immediately. Power is
// intentionally left unchanged; the zone engine still owns ON/OFF demand.
if patch.setpoint.is_some() || patch.mode.is_some() {
let command = DeviceCommand {
mode: patch.mode.as_ref().map(|_| zone.mode.clone()),
target_temperature: patch.setpoint.map(|_| zone.setpoint),
..Default::default()
};
if let Err(err) = engine::send_command(&state, &zone.device_id, command).await {
state.log("warn", "zone.quick_control_device_error", &format!("{}: {err}", zone.name), json!({"zone_id": zone.id, "device_id": zone.device_id}));
}
}
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
state.log("info", "zone.quick_control", &format!("Quick control updated for {}", zone.name), json!({"zone_id": zone.id, "setpoint": zone.setpoint, "mode": zone.mode, "enabled": zone.enabled}));
Ok(Json(zone))
}
async fn delete_zone(State(state): State<AppState>, Path(id): Path<String>) -> Result<StatusCode, AppError> {
if !state.db.delete_zone(&id)? { return Err(AppError::NotFound(format!("zone {id}"))); }
state.broadcast("zone.deleted", json!({"id": id}));
@@ -402,7 +460,7 @@ impl ScheduleInput {
if self.weekdays.is_empty() || self.weekdays.iter().any(|v| !(1..=7).contains(v)) { return Err(AppError::BadRequest("weekdays must contain numbers 1..7".into())); }
chrono::NaiveTime::parse_from_str(&self.start_time, "%H:%M").map_err(|_| AppError::BadRequest("invalid start time".into()))?;
chrono::NaiveTime::parse_from_str(&self.end_time, "%H:%M").map_err(|_| AppError::BadRequest("invalid end time".into()))?;
if !(8.0..=32.0).contains(&self.setpoint) { return Err(AppError::BadRequest("schedule setpoint must be between 8 and 32 C".into())); }
if !(8.0..=30.0).contains(&self.setpoint) { return Err(AppError::BadRequest("schedule setpoint must be between 8 and 30 C".into())); }
Ok(())
}
fn into_schedule(self, id: String, created_at: chrono::DateTime<Utc>) -> Schedule {
@@ -533,8 +591,11 @@ async fn update_settings(State(state): State<AppState>, Json(mut input): Json<Ru
input.poll_interval_seconds = input.poll_interval_seconds.clamp(2, 3600);
input.zone_interval_seconds = input.zone_interval_seconds.clamp(2, 3600);
input.discovery_timeout_ms = input.discovery_timeout_ms.clamp(300, 30_000);
input.discovery_broadcast.parse::<std::net::SocketAddr>()
.map_err(|_| AppError::BadRequest("invalid discovery broadcast address".into()))?;
if !(input.discovery_broadcast.eq_ignore_ascii_case("auto")
|| input.discovery_broadcast.to_ascii_lowercase().starts_with("auto:")) {
input.discovery_broadcast.parse::<std::net::SocketAddr>()
.map_err(|_| AppError::BadRequest("invalid discovery broadcast address".into()))?;
}
if input.controller_id.trim().is_empty() { input.controller_id = old.controller_id; }
if input.home_assistant.token.trim().is_empty() { input.home_assistant.token = old.home_assistant.token; }
if !input.home_assistant.url.trim().is_empty() {
+4 -2
View File
@@ -12,9 +12,9 @@ pub struct Config {
pub database: PathBuf,
#[arg(long, env = "GREE_CONTROLLER_APP_TOKEN", default_value = "")]
pub app_token: String,
#[arg(long, env = "GREE_CONTROLLER_SIMULATE", default_value_t = true)]
#[arg(long, env = "GREE_CONTROLLER_SIMULATE", default_value_t = false)]
pub simulate: bool,
#[arg(long, env = "GREE_CONTROLLER_AUTO_SEED", default_value_t = true)]
#[arg(long, env = "GREE_CONTROLLER_AUTO_SEED", default_value_t = false)]
pub auto_seed: bool,
#[arg(long, env = "GREE_CONTROLLER_POLL_INTERVAL_SECONDS", default_value_t = 15)]
pub poll_interval_seconds: u64,
@@ -24,6 +24,8 @@ pub struct Config {
pub discovery_timeout_ms: u64,
#[arg(long, env = "GREE_CONTROLLER_DISCOVERY_BROADCAST", default_value = "255.255.255.255:7000")]
pub discovery_broadcast: String,
#[arg(long, env = "GREE_CONTROLLER_GREE_INTERFACE", default_value = "")]
pub gree_interface: String,
#[arg(long, env = "GREE_CONTROLLER_ID", default_value = "gree-controller")]
pub controller_id: String,
}
+56 -19
View File
@@ -66,23 +66,39 @@ pub async fn send_command(state: &AppState, device_id: &str, command: DeviceComm
} else {
if device.key.as_deref().unwrap_or_default().is_empty() {
match state.gree.bind(&device).await {
Ok(key) => {
device.key = Some(key);
Ok(bound) => {
device.key = Some(bound.key);
device.protocol_version = bound.protocol_version;
device.communication_failures = 0;
state.db.save_device(&device)?;
state.log("info", "device.bound", &format!("Bound {}", device.name), json!({"device_id": device.id}));
state.log("info", "device.bound", &format!("Bound {} using protocol V{}", device.name, device.protocol_version), json!({"device_id": device.id, "protocol_version": device.protocol_version}));
}
Err(err) => {
mark_device_error(state, &mut device, &err.to_string())?;
register_device_failure(state, &mut device, &err.to_string())?;
return Err(AppError::Device(err.to_string()));
}
}
}
if let Err(err) = state.gree.command(&device, &command).await {
mark_device_error(state, &mut device, &err.to_string())?;
return Err(AppError::Device(err.to_string()));
if let Err(first_err) = state.gree.command(&device, &command).await {
// Retry once after a fresh bind. This covers stale keys and devices that
// switched between ECB/GCM after a firmware update.
let retry_result = match state.gree.bind(&device).await {
Ok(bound) => {
device.key = Some(bound.key);
device.protocol_version = bound.protocol_version;
state.db.save_device(&device)?;
state.gree.command(&device, &command).await
}
Err(_) => Err(first_err),
};
if let Err(err) = retry_result {
register_device_failure(state, &mut device, &err.to_string())?;
return Err(AppError::Device(err.to_string()));
}
}
command.apply(&mut device);
device.online = true;
device.communication_failures = 0;
device.last_seen = Some(Utc::now());
device.last_error = None;
state.db.save_device(&device)?;
@@ -124,19 +140,30 @@ async fn poll_device(state: &AppState, device: &mut Device) {
}
if device.key.as_deref().unwrap_or_default().is_empty() {
match state.gree.bind(device).await {
Ok(key) => device.key = Some(key),
Ok(bound) => {
device.key = Some(bound.key);
device.protocol_version = bound.protocol_version;
device.communication_failures = 0;
}
Err(err) => {
device.online = false;
device.last_error = Some(err.to_string());
device.updated_at = Utc::now();
record_poll_failure(device, &err.to_string());
return;
}
}
}
if let Err(err) = state.gree.poll(device).await {
device.online = false;
device.last_error = Some(err.to_string());
device.updated_at = Utc::now();
if let Err(first_err) = state.gree.poll(device).await {
// A stale key or wrong cipher should heal automatically during polling.
// Rebind once, then retry the status request before counting a failure.
match state.gree.bind(device).await {
Ok(bound) => {
device.key = Some(bound.key);
device.protocol_version = bound.protocol_version;
if let Err(err) = state.gree.poll(device).await {
record_poll_failure(device, &err.to_string());
}
}
Err(_) => record_poll_failure(device, &first_err.to_string()),
}
}
}
@@ -184,18 +211,28 @@ fn record_reading(state: &AppState, device: &Device) -> Result<()> {
Ok(())
}
fn mark_device_error(state: &AppState, device: &mut Device, error: &str) -> Result<(), AppError> {
device.online = false;
fn record_poll_failure(device: &mut Device, error: &str) {
device.communication_failures = device.communication_failures.saturating_add(1);
// A single dropped UDP response is not enough to declare an AC offline.
if device.communication_failures >= 3 { device.online = false; }
device.last_error = Some(error.to_string());
device.updated_at = Utc::now();
}
fn register_device_failure(state: &AppState, device: &mut Device, error: &str) -> Result<(), AppError> {
record_poll_failure(device, error);
state.db.save_device(device)?;
state.log("error", "device.error", &format!("{}: {error}", device.name), json!({"device_id": device.id}));
state.log("warn", "device.communication_error", &format!("{}: {error}", device.name), json!({
"device_id": device.id,
"consecutive_failures": device.communication_failures,
"offline": !device.online,
}));
Ok(())
}
fn validate_command(command: &DeviceCommand) -> Result<(), AppError> {
if let Some(value) = command.target_temperature {
if !(8.0..=32.0).contains(&value) { return Err(AppError::BadRequest("target temperature must be between 8 and 32 C".into())); }
if !(8.0..=30.0).contains(&value) { return Err(AppError::BadRequest("target temperature must be between 8 and 30 C".into())); }
}
if let Some(value) = command.fan_speed {
if value > 5 { return Err(AppError::BadRequest("fan speed must be between 0 and 5".into())); }
+13 -2
View File
@@ -25,7 +25,12 @@ async fn main() -> Result<()> {
init_tracing();
let db = Db::open(&config.database)?;
let runtime_settings = db.load_runtime_settings()?.unwrap_or_else(|| config.runtime_defaults());
let mut runtime_settings = db.load_runtime_settings()?.unwrap_or_else(|| config.runtime_defaults());
// Network deployment settings explicitly provided by the service environment are authoritative.
// This makes /etc/gree-controller.env useful even after runtime settings were persisted in SQLite.
if std::env::var_os("GREE_CONTROLLER_DISCOVERY_BROADCAST").is_some() {
runtime_settings.discovery_broadcast = config.discovery_broadcast.clone();
}
db.save_runtime_settings(&runtime_settings)?;
if config.simulate && config.auto_seed && db.count_devices()? == 0 {
@@ -47,7 +52,10 @@ async fn main() -> Result<()> {
db,
settings: Arc::new(RwLock::new(runtime_settings.clone())),
config: Arc::new(config.clone()),
gree: GreeClient::new(runtime_settings.controller_id.clone()),
gree: GreeClient::new(
runtime_settings.controller_id.clone(),
(!config.gree_interface.trim().is_empty()).then(|| config.gree_interface.trim().to_string()),
),
events,
http,
started: Instant::now(),
@@ -58,11 +66,14 @@ async fn main() -> Result<()> {
let listener = TcpListener::bind(config.bind).await
.with_context(|| format!("cannot bind HTTP server to {}", config.bind))?;
let gree_interface_log = if config.gree_interface.trim().is_empty() { "auto" } else { config.gree_interface.trim() };
tracing::info!(
address = %config.bind,
database = %config.database.display(),
simulator = config.simulate,
auth = !config.app_token.trim().is_empty(),
gree_interface = %gree_interface_log,
discovery_broadcast = %runtime_settings.discovery_broadcast,
"GREE Controller started"
);
+22 -2
View File
@@ -4,7 +4,7 @@ use serde_json::Value;
fn default_true() -> bool { true }
fn default_port() -> u16 { 7000 }
fn default_protocol() -> u8 { 1 }
fn default_protocol() -> u8 { 0 }
fn default_mode() -> String { "cool".into() }
fn default_fan() -> u8 { 0 }
fn default_target() -> f64 { 24.0 }
@@ -65,6 +65,8 @@ pub struct Device {
pub last_seen: Option<DateTime<Utc>>,
#[serde(default)]
pub last_error: Option<String>,
#[serde(default)]
pub communication_failures: u8,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
@@ -99,6 +101,7 @@ impl Device {
online: true,
last_seen: Some(now),
last_error: None,
communication_failures: 0,
created_at: now,
updated_at: now,
}
@@ -132,7 +135,7 @@ impl DeviceCommand {
pub fn apply(&self, device: &mut Device) {
if let Some(v) = self.power { device.power = v; }
if let Some(v) = &self.mode { device.mode = v.clone(); }
if let Some(v) = self.target_temperature { device.target_temperature = v.clamp(8.0, 32.0); }
if let Some(v) = self.target_temperature { device.target_temperature = v.clamp(8.0, 30.0); }
if let Some(v) = self.fan_speed { device.fan_speed = v.min(5); }
if let Some(v) = self.swing_vertical { device.swing_vertical = v; }
if let Some(v) = self.swing_horizontal { device.swing_horizontal = v; }
@@ -192,6 +195,17 @@ pub struct Zone {
fn default_sensor_source() -> String { "device".into() }
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ZoneControlPatch {
#[serde(default)]
pub setpoint: Option<f64>,
#[serde(default)]
pub mode: Option<String>,
#[serde(default)]
pub enabled: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Schedule {
pub id: String,
@@ -284,6 +298,12 @@ pub struct DiscoveryRequest {
pub timeout_ms: Option<u64>,
#[serde(default)]
pub broadcast: Option<String>,
/// 0 = auto (accept both), 1 = AES-ECB only, 2 = AES-GCM only.
#[serde(default)]
pub protocol_version: Option<u8>,
/// Number of scan broadcasts sent during one discovery operation.
#[serde(default)]
pub passes: Option<u8>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
+16 -17
View File
@@ -2,9 +2,14 @@ use aes::{Aes128, cipher::{BlockDecrypt, BlockEncrypt, KeyInit, generic_array::G
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";
/// Shared discovery/bind key used by the original AES-128-ECB protocol.
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_AAD: &[u8] = b"qualcomm-test";
pub fn normalize_key(key: &str) -> Result<[u8; 16]> {
let bytes = key.as_bytes();
@@ -57,37 +62,31 @@ pub fn decrypt_v1(key: &str, ciphertext_b64: &str) -> Result<Vec<u8>> {
#[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 nonce = Nonce::from_slice(&GCM_NONCE);
let mut buffer = plaintext.to_vec();
let tag = cipher.encrypt_in_place_detached(nonce, b"", &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),
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>> {
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 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 nonce = Nonce::from_slice(&GCM_NONCE);
let tag = GenericArray::from_slice(&tag_bytes);
cipher.decrypt_in_place_detached(nonce, b"", &mut data, tag)
cipher.decrypt_in_place_detached(nonce, GCM_AAD, &mut data, tag)
.map_err(|_| anyhow!("AES-GCM authentication failed"))?;
Ok(data)
}
@@ -99,14 +98,14 @@ mod tests {
#[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);
let encrypted = encrypt_v1(GENERIC_GREE_V1_KEY, value).unwrap();
assert_eq!(decrypt_v1(GENERIC_GREE_V1_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);
let encrypted = encrypt_v2(GENERIC_GREE_V2_KEY, value).unwrap();
assert_eq!(decrypt_v2(GENERIC_GREE_V2_KEY, &encrypted.ciphertext, &encrypted.tag).unwrap(), value);
}
}
+306 -84
View File
@@ -1,48 +1,108 @@
use std::{collections::HashSet, net::SocketAddr, sync::{Arc, atomic::{AtomicU64, Ordering}}, time::Duration};
use std::{collections::HashSet, net::{Ipv4Addr, SocketAddr, SocketAddrV4}, 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};
use super::crypto::{
decrypt_v1, decrypt_v2, encrypt_v1, encrypt_v2,
GENERIC_GREE_V1_KEY, GENERIC_GREE_V2_KEY,
};
#[derive(Debug, Clone)]
pub struct BindResult {
pub key: String,
pub protocol_version: u8,
}
#[derive(Clone)]
pub struct GreeClient {
controller_id: String,
sequence: Arc<AtomicU64>,
interface: Option<String>,
}
impl GreeClient {
pub fn new(controller_id: String) -> Self {
Self { controller_id, sequence: Arc::new(AtomicU64::new(1)) }
pub fn new(controller_id: String, interface: Option<String>) -> Self {
Self { controller_id, interface }
}
fn next_id(&self) -> u64 { self.sequence.fetch_add(1, Ordering::Relaxed) }
async fn udp_socket(&self, broadcast: bool) -> 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 {
UdpSocket::bind("0.0.0.0:0").await?
};
socket.set_broadcast(broadcast)?;
Ok(socket)
}
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?;
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 socket = self.udp_socket(true).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; 8192];
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());
match timeout(remaining.min(Duration::from_millis(450)), socket.recv_from(&mut buffer)).await {
let wait = remaining.min(Duration::from_millis(250));
match timeout(wait, 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 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()),
@@ -52,42 +112,71 @@ impl GreeClient {
Ok(result)
}
fn parse_discovery(&self, mut value: Value, source: SocketAddr) -> Option<Device> {
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.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; }
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();
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 None;
return Ok(None);
}
let mac = value.get("mac").or_else(|| value.get("cid"))?.as_str()?.replace(':', "");
if mac.is_empty() { return 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)
.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 });
.map(str::trim).filter(|v| !v.is_empty())
.map(str::to_string)
.unwrap_or_else(|| format!("{model} {suffix}"));
let now = Utc::now();
Some(Device {
Ok(Some(Device {
id: format!("gree-{}", mac.to_ascii_lowercase()),
mac,
name,
ip: source.ip().to_string(),
port: source.port(),
protocol_version,
port: if source.port() == 0 { 7000 } else { source.port() },
protocol_version: detected_protocol,
model,
firmware,
key: None,
cid: Some(self.controller_id.clone()),
cid: Some("app".into()),
enabled: true,
simulated: false,
power: false,
@@ -104,14 +193,47 @@ impl GreeClient {
online: true,
last_seen: Some(now),
last_error: None,
communication_failures: 0,
created_at: now,
updated_at: now,
})
}))
}
pub async fn bind(&self, device: &Device) -> Result<String> {
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("; "))
}
async fn bind_attempt(&self, device: &Device, version: u8) -> Result<String> {
let target = self.device_target(device)?;
let socket = self.udp_socket(false).await?;
// Some Wi-Fi modules only accept bind shortly after a scan. A direct scan
// refreshes that window and works across routed/VLAN deployments too.
socket.send_to(br#"{"t":"scan"}"#, target).await?;
let mut scan_buf = vec![0_u8; 16 * 1024];
let _ = timeout(Duration::from_millis(900), socket.recv_from(&mut scan_buf)).await;
let inner = json!({"mac": device.mac, "t": "bind", "uid": 0});
let response = self.request(device, &inner, GENERIC_GREE_KEY, true).await?;
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") }
@@ -120,23 +242,45 @@ impl GreeClient {
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 = [
let full_cols = [
"Pow","Mod","SetTem","WdSpd","Air","Blo","Health","SwhSlp","Lig",
"SwingLfRig","SwUpDn","Quiet","Tur","StHt","TemUn","HeatCoolType",
"TemRec","SvSt","TemSen","CoolSvTem","HeatSvTem"
"TemRec","SvSt","TemSen","CoolSvTem","HeatSvTem","OutEnvTem"
];
let core_cols = ["Pow","Mod","SetTem","TemRec","TemUn","TemSen","WdSpd","Lig","SwingLfRig","SwUpDn","Quiet","Tur"];
let response = match self.status_request(device, key, &full_cols).await {
Ok(value) => value,
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?
}
};
self.apply_status(device, &response)?;
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": device.mac, "t": "status"});
let response = self.request(device, &inner, key, false).await?;
self.request(device, &inner, key, false, device.protocol_version).await
}
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"))?;
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" => 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),
"SetTem" => set_temp = Some(value_as_f64(value)),
"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,
@@ -147,13 +291,16 @@ impl GreeClient {
let raw = value_as_f64(value);
device.current_temperature = Some(if raw > 40.0 { raw - 40.0 } else { raw });
}
"OutEnvTem" => {
let raw = value_as_f64(value);
device.outdoor_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();
if let Some(base) = set_temp {
device.target_temperature = base.clamp(8.0, 30.0);
}
Ok(())
}
@@ -163,7 +310,12 @@ impl GreeClient {
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.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 })); }
@@ -172,65 +324,131 @@ impl GreeClient {
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
self.request(device, &inner, key, false, device.protocol_version).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")?;
async fn request(&self, device: &Device, inner: &Value, key: &str, binding: bool, protocol_version: u8) -> Result<Value> {
let socket = self.udp_socket(false).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 mut outer = json!({
"cid": self.controller_id,
"i": self.next_id(),
"cid": "app",
"i": if binding { 1 } else { 0 },
"t": "pack",
"tcid": device.mac,
"uid": 0
});
if device.protocol_version >= 2 && !binding {
if version == 2 {
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?;
tracing::debug!(target=%target, local=%socket.local_addr()?, protocol=version, interface=%self.interface.as_deref().unwrap_or("auto"), binding, "Sending GREE request");
socket.send_to(&payload, target).await?;
let deadline = Instant::now() + Duration::from_secs(4);
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}")
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; }
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) {
return Ok(Value::Object(pack.clone()));
}
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}") }
return Ok(decoded);
}
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")
}
}
fn value_as_i64(value: &Value) -> i64 {
value.as_i64().or_else(|| value.as_str()?.parse().ok()).unwrap_or_default()
#[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"))
}
}
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" }
#[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) -> 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),
@@ -242,11 +460,15 @@ 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 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; }
old.protocol_version = discovered.protocol_version;
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();