569 lines
26 KiB
Rust
569 lines
26 KiB
Rust
fn device_runtime_change_affects_control_plan(before: &Device, after: &Device) -> bool {
|
|
// build_control_plan() consumes only these runtime device fields. Poll heartbeat data such
|
|
// as last_seen/response_time_ms remains available through device.updated but no longer
|
|
// forces an expensive control-plan rebuild.
|
|
before.name != after.name
|
|
|| before.power != after.power
|
|
|| before.mode != after.mode
|
|
|| before.target_temperature != after.target_temperature
|
|
|| before.online != after.online
|
|
|| before.communication_failures != after.communication_failures
|
|
}
|
|
|
|
async fn lock_poll_zone_operations(state: &AppState, device_id: &str) -> Result<Vec<tokio::sync::OwnedMutexGuard<()>>, AppError> {
|
|
let mut zone_ids: Vec<String> = state.db.list_zones()?.into_iter()
|
|
.filter(|zone| zone.device_id == device_id)
|
|
.map(|zone| zone.id)
|
|
.collect();
|
|
zone_ids.sort();
|
|
zone_ids.dedup();
|
|
let mut guards = Vec::with_capacity(zone_ids.len());
|
|
for zone_id in zone_ids {
|
|
guards.push(state.lock_zone_operation(&zone_id).await);
|
|
}
|
|
Ok(guards)
|
|
}
|
|
|
|
pub async fn poll_one(state: &AppState, device_id: &str) -> Result<Device, AppError> {
|
|
// Polling can update zone ownership when it detects physical/pilot control. Acquire
|
|
// the same zone -> device lock order used by thermostat/manual actions so those writes
|
|
// cannot race and overwrite a fresh override or hand-back state.
|
|
let _zone_guards = lock_poll_zone_operations(state, device_id).await?;
|
|
let _device_guard = state.lock_device_operation(device_id).await;
|
|
poll_one_locked(state, device_id).await
|
|
}
|
|
|
|
// Caller must hold the device lock and every current zone lock associated with this device.
|
|
async fn poll_one_locked(state: &AppState, device_id: &str) -> Result<Device, AppError> {
|
|
let mut device = state.db.get_device(device_id)?
|
|
.ok_or_else(|| AppError::NotFound(format!("device {device_id}")))?;
|
|
let before = device.clone();
|
|
poll_device(state, &mut device).await;
|
|
if poll_completed_successfully(&device) {
|
|
if state.initial_device_sync_complete.load(Ordering::Acquire) {
|
|
record_device_transition_timestamps(state, &before, &device)?;
|
|
}
|
|
detect_external_device_control(state, &before, &device).await?;
|
|
}
|
|
state.db.save_device(&device)?;
|
|
record_reading(state, &device)?;
|
|
state.broadcast_with_control_plan_invalidation(
|
|
"device.updated",
|
|
serde_json::to_value(&device).unwrap_or_default(),
|
|
device_runtime_change_affects_control_plan(&before, &device),
|
|
);
|
|
Ok(device)
|
|
}
|
|
|
|
pub(crate) async fn poll_all(state: &AppState) -> Result<()> {
|
|
let devices = state.db.list_devices()?;
|
|
let cloud_interval = state
|
|
.settings
|
|
.read()
|
|
.await
|
|
.gree_cloud
|
|
.polling_interval_seconds
|
|
.max(30);
|
|
let startup = !state.initial_device_sync_complete.load(Ordering::Acquire);
|
|
|
|
// LAN always runs first and keeps its historical sequential locking/transport behavior.
|
|
// Cloud work is detached afterwards so an Internet/broker timeout cannot delay UDP cycles.
|
|
for device in devices.iter().filter(|device| device.enabled && device.connection_type == ConnectionType::Local) {
|
|
let _zone_guards = lock_poll_zone_operations(state, &device.id).await?;
|
|
let _device_guard = state.lock_device_operation(&device.id).await;
|
|
let _ = poll_one_locked(state, &device.id).await?;
|
|
}
|
|
|
|
for device in devices.into_iter().filter(|device| device.enabled && device.connection_type == ConnectionType::GreeCloud) {
|
|
// A failed Cloud read has no last_cloud_sync, so use updated_at (which is refreshed
|
|
// on failures) as the retry baseline. Otherwise an offline unit would be considered
|
|
// due on every fast LAN poll cycle and detached tasks would accumulate indefinitely.
|
|
let retry_baseline = device.last_cloud_sync.unwrap_or(device.updated_at);
|
|
let due = startup
|
|
|| Utc::now()
|
|
.signed_duration_since(retry_baseline)
|
|
.num_seconds()
|
|
>= cloud_interval as i64;
|
|
if !due { continue; }
|
|
if startup {
|
|
// Persist a conservative startup state before the asynchronous Cloud read. This
|
|
// prevents a stale pre-restart Online snapshot from driving thermostat commands.
|
|
let mut pending = device.clone();
|
|
pending.online = false;
|
|
pending.connection_status = ConnectionStatus::Unknown;
|
|
pending.response_time_ms = None;
|
|
state.db.save_device(&pending)?;
|
|
}
|
|
// Do not enqueue another detached poll while one for this Cloud device is still
|
|
// running or waiting on its operation lock. This is deliberately outside Tokio's
|
|
// async lock graph so an offline device cannot create an ever-growing waiter queue.
|
|
let Some(cloud_poll_guard) = state.try_begin_cloud_poll(&device.id) else {
|
|
continue;
|
|
};
|
|
let state = state.clone();
|
|
let device_id = device.id.clone();
|
|
tokio::spawn(async move {
|
|
let _cloud_poll_guard = cloud_poll_guard;
|
|
if let Err(err) = poll_one(&state, &device_id).await {
|
|
tracing::warn!(device=%device_id, error=?err, "GREE Cloud fallback poll failed");
|
|
}
|
|
});
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Poll all enabled devices when the caller already holds the corresponding zone and device locks.
|
|
/// Used by configuration import so no command/poll can interleave with the replacement.
|
|
pub(crate) async fn poll_all_locked(state: &AppState) -> Result<()> {
|
|
let device_ids: Vec<String> = state.db.list_devices()?.into_iter()
|
|
.filter(|device| device.enabled)
|
|
.map(|device| device.id)
|
|
.collect();
|
|
for device_id in device_ids {
|
|
let _ = poll_one_locked(state, &device_id).await?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
async fn poll_device(state: &AppState, device: &mut Device) {
|
|
if device.connection_type == ConnectionType::GreeCloud {
|
|
let previous_failures = device.communication_failures;
|
|
let response_started = Instant::now();
|
|
let cloud_settings = state.settings.read().await.gree_cloud.clone();
|
|
let all_devices = match state.db.list_devices() {
|
|
Ok(items) => items,
|
|
Err(err) => {
|
|
register_cloud_poll_failure(device, &err.to_string());
|
|
return;
|
|
}
|
|
};
|
|
match state.providers.cloud().poll(&cloud_settings, &all_devices, device).await {
|
|
Ok(()) => {
|
|
device.pending_command = false;
|
|
device.response_time_ms = Some(response_started.elapsed().as_millis().min(u64::MAX as u128) as u64);
|
|
device.refresh_capabilities();
|
|
if previous_failures > 0 {
|
|
state.log("info", "gree_cloud.device_online", &format!("{} is online through GREE Cloud", device.name), json!({"device_id": device.id}));
|
|
}
|
|
}
|
|
Err(err) => {
|
|
register_cloud_poll_failure(device, &err.to_string());
|
|
if previous_failures == 0 {
|
|
state.log("warn", "gree_cloud.device_offline", &format!("{} Cloud status failed", device.name), json!({"device_id": device.id, "error": cloud_poll_public_error(&err.to_string())}));
|
|
}
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
if device.simulated {
|
|
simulate_tick(device);
|
|
return;
|
|
}
|
|
let previous_failures = device.communication_failures;
|
|
let response_started = Instant::now();
|
|
if device.key.as_deref().unwrap_or_default().is_empty() {
|
|
match state.providers.local().bind(device).await {
|
|
Ok(bound) => {
|
|
device.key = Some(bound.key);
|
|
device.protocol_version = bound.protocol_version;
|
|
device.communication_failures = 0;
|
|
}
|
|
Err(err) => {
|
|
record_poll_failure(device, &err.to_string());
|
|
log_poll_health_transition(state, device, previous_failures).await;
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
if let Err(first_err) = state.providers.local().poll(device).await {
|
|
// One lost UDP response is common on Wi-Fi and must not trigger a bind storm.
|
|
// Rebind only after at least one consecutive failed poll; a successful retry clears
|
|
// the counter in GreeClient::poll.
|
|
if previous_failures == 0 {
|
|
record_poll_failure(device, &first_err.to_string());
|
|
} else {
|
|
match state.providers.local().bind(device).await {
|
|
Ok(bound) => {
|
|
device.key = Some(bound.key);
|
|
device.protocol_version = bound.protocol_version;
|
|
if let Err(err) = state.providers.local().poll(device).await {
|
|
record_poll_failure(device, &err.to_string());
|
|
}
|
|
}
|
|
Err(_) => record_poll_failure(device, &first_err.to_string()),
|
|
}
|
|
}
|
|
}
|
|
if device.communication_failures == 0 && device.online {
|
|
device.connection_status = ConnectionStatus::Online;
|
|
device.response_time_ms = Some(response_started.elapsed().as_millis().min(u64::MAX as u128) as u64);
|
|
}
|
|
log_poll_health_transition(state, device, previous_failures).await;
|
|
}
|
|
|
|
fn register_cloud_poll_failure(device: &mut Device, error: &str) {
|
|
device.communication_failures = device.communication_failures.saturating_add(1);
|
|
device.online = false;
|
|
let lower = error.to_ascii_lowercase();
|
|
device.connection_status = if lower.contains("authentication") || lower.contains("not authorized") {
|
|
ConnectionStatus::AuthenticationError
|
|
} else if lower.contains("mqtt") || lower.contains("connect") || lower.contains("tls") {
|
|
ConnectionStatus::CloudDisconnected
|
|
} else {
|
|
ConnectionStatus::Offline
|
|
};
|
|
device.response_time_ms = None;
|
|
if device.last_seen.is_none() {
|
|
device.last_cloud_sync = None;
|
|
}
|
|
device.last_error = Some(cloud_poll_public_error(error));
|
|
device.updated_at = Utc::now();
|
|
}
|
|
|
|
fn cloud_poll_public_error(error: &str) -> String {
|
|
let lower = error.to_ascii_lowercase();
|
|
if lower.contains("password") || lower.contains("token") || lower.contains("authorization") {
|
|
"GREE Cloud authentication failed".into()
|
|
} else {
|
|
error.chars().take(300).collect()
|
|
}
|
|
}
|
|
|
|
async fn log_poll_health_transition(state: &AppState, device: &Device, previous_failures: u8) {
|
|
let threshold = state.settings.read().await.notifications.communication_failure_threshold.max(2);
|
|
let current_failures = u32::from(device.communication_failures);
|
|
let previous_failures = u32::from(previous_failures);
|
|
if current_failures >= threshold && previous_failures < threshold {
|
|
state.log("warn", "device.offline", &format!("{} did not respond {} times in a row", device.name, device.communication_failures), json!({
|
|
"device_id": device.id, "consecutive_failures": device.communication_failures, "threshold": threshold
|
|
}));
|
|
} else if current_failures == 0 && previous_failures >= threshold {
|
|
state.log("info", "device.recovered", &format!("{} is responding again", device.name), json!({"device_id": device.id}));
|
|
}
|
|
}
|
|
|
|
fn simulate_tick(device: &mut Device) {
|
|
let mut current = device.current_temperature.unwrap_or(25.0);
|
|
let minute_wave = ((Utc::now().timestamp() % 3600) as f64 / 3600.0 * std::f64::consts::TAU).sin();
|
|
let ambient = 25.5 + minute_wave * 0.35;
|
|
if device.power {
|
|
match device.mode.as_str() {
|
|
"cool" => {
|
|
let floor = device.target_temperature - 0.2;
|
|
if current > floor { current -= if device.turbo { 0.25 } else { 0.12 }; }
|
|
}
|
|
"heat" => {
|
|
let ceiling = device.target_temperature + 0.2;
|
|
if current < ceiling { current += if device.turbo { 0.25 } else { 0.12 }; }
|
|
}
|
|
"dry" => current -= 0.04,
|
|
_ => current += (ambient - current) * 0.02,
|
|
}
|
|
} else {
|
|
current += (ambient - current) * 0.04;
|
|
}
|
|
device.current_temperature = Some((current * 10.0).round() / 10.0);
|
|
device.outdoor_temperature = Some((30.0 + minute_wave * 1.2) * 10.0_f64.round() / 10.0);
|
|
// Correct rounding for outdoor temperature without accumulating precision noise.
|
|
device.outdoor_temperature = device.outdoor_temperature.map(|v| (v * 10.0).round() / 10.0);
|
|
device.online = true;
|
|
device.response_time_ms = Some(0);
|
|
device.last_seen = Some(Utc::now());
|
|
device.last_error = None;
|
|
device.updated_at = Utc::now();
|
|
}
|
|
|
|
fn record_reading(state: &AppState, device: &Device) -> Result<()> {
|
|
let reading = Reading {
|
|
id: 0,
|
|
device_id: device.id.clone(),
|
|
timestamp: Utc::now(),
|
|
indoor_temperature: device.current_temperature,
|
|
outdoor_temperature: device.outdoor_temperature,
|
|
target_temperature: device.target_temperature,
|
|
power: device.power,
|
|
source: if device.simulated { "simulator".into() } else { "gree".into() },
|
|
};
|
|
state.db.add_reading(&reading)?;
|
|
queue_influx_device(state, reading);
|
|
Ok(())
|
|
}
|
|
|
|
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.connection_status = ConnectionStatus::Offline; }
|
|
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)?;
|
|
// Command failures change live communication health; publish the updated snapshot immediately.
|
|
state.broadcast("device.updated", serde_json::to_value(&*device)?);
|
|
state.log("warn", "device.communication_error", &format!("{}: {error}", device.name), json!({
|
|
"device_id": device.id,
|
|
"consecutive_failures": device.communication_failures,
|
|
"offline": !device.online,
|
|
}));
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) fn validate_command(command: &DeviceCommand) -> Result<(), AppError> {
|
|
if let Some(value) = command.target_temperature {
|
|
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())); }
|
|
}
|
|
if let Some(value) = &command.mode {
|
|
if !matches!(value.as_str(), "auto" | "cool" | "dry" | "fan" | "heat") {
|
|
return Err(AppError::BadRequest("unsupported HVAC mode".into()));
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn poll_completed_successfully(device: &Device) -> bool {
|
|
device.online && device.communication_failures == 0 && device.last_error.is_none()
|
|
}
|
|
|
|
fn command_manual_control_fields(command: &DeviceCommand) -> Vec<String> {
|
|
let mut fields = Vec::new();
|
|
if command.power.is_some() { fields.push("power".to_string()); }
|
|
if command.mode.is_some() { fields.push("mode".to_string()); }
|
|
if command.target_temperature.is_some() { fields.push("target_temperature".to_string()); }
|
|
if command.fan_speed.is_some() { fields.push("fan_speed".to_string()); }
|
|
if command.quiet.is_some() { fields.push("quiet".to_string()); }
|
|
if command.sleep.is_some() { fields.push("sleep".to_string()); }
|
|
fields
|
|
}
|
|
|
|
fn command_baseline_from_device(command: &DeviceCommand, device: &Device) -> DeviceCommand {
|
|
DeviceCommand {
|
|
power: command.power.map(|_| device.power),
|
|
mode: command.mode.as_ref().map(|_| device.mode.clone()),
|
|
target_temperature: command.target_temperature.map(|_| device.target_temperature),
|
|
fan_speed: command.fan_speed.map(|_| device.fan_speed),
|
|
quiet: command.quiet.map(|_| device.quiet),
|
|
sleep: command.sleep.map(|_| device.sleep),
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
async fn remember_controller_command(state: &AppState, device_id: &str, command: &DeviceCommand, baseline_device: &Device) {
|
|
let poll_seconds = state.settings.read().await.poll_interval_seconds.max(2);
|
|
let ttl = Duration::from_secs(poll_seconds.saturating_mul(2).saturating_add(5).min(120));
|
|
let mut pending = state.pending_controller_commands.lock().await;
|
|
let expires_at = Instant::now() + ttl;
|
|
let baseline = command_baseline_from_device(command, baseline_device);
|
|
if let Some(existing) = pending.get_mut(device_id) {
|
|
existing.commands.push(command.clone());
|
|
existing.baselines.push(baseline);
|
|
// The history only spans one settling window; cap it defensively so a noisy device
|
|
// cannot grow this allocation without bound.
|
|
if existing.commands.len() > 8 { existing.commands.remove(0); }
|
|
if existing.baselines.len() > 8 { existing.baselines.remove(0); }
|
|
existing.expires_at = expires_at;
|
|
} else {
|
|
pending.insert(device_id.to_string(), PendingControllerCommand {
|
|
commands: vec![command.clone()],
|
|
baselines: vec![baseline],
|
|
expires_at,
|
|
});
|
|
}
|
|
}
|
|
|
|
fn command_field_matches_device(command: &DeviceCommand, field: &str, device: &Device) -> bool {
|
|
match field {
|
|
"power" => command.power.map(|value| value == device.power).unwrap_or(false),
|
|
"mode" => command.mode.as_deref().map(|value| value == device.mode.as_str()).unwrap_or(false),
|
|
"target_temperature" => command.target_temperature
|
|
.map(|value| {
|
|
let step = device.capabilities.temperature_step.max(0.5);
|
|
(value.clamp(device.capabilities.min_temperature, device.capabilities.max_temperature) / step).round()
|
|
== (device.target_temperature.clamp(device.capabilities.min_temperature, device.capabilities.max_temperature) / step).round()
|
|
})
|
|
.unwrap_or(false),
|
|
"fan_speed" => command.fan_speed.map(|value| value.min(5) == device.fan_speed).unwrap_or(false),
|
|
"quiet" => command.quiet.map(|value| value == device.quiet).unwrap_or(false),
|
|
"sleep" => command.sleep.map(|value| value == device.sleep).unwrap_or(false),
|
|
_ => false,
|
|
}
|
|
}
|
|
|
|
fn device_control_snapshot(device: &Device) -> Value {
|
|
json!({
|
|
"power": device.power,
|
|
"mode": device.mode.clone(),
|
|
"target_temperature": device.target_temperature,
|
|
"fan_speed": device.fan_speed,
|
|
"quiet": device.quiet,
|
|
"sleep": device.sleep,
|
|
"turbo": device.turbo,
|
|
"swing_vertical": device.swing_vertical,
|
|
"swing_horizontal": device.swing_horizontal,
|
|
"online": device.online,
|
|
"communication_failures": device.communication_failures,
|
|
"last_seen": device.last_seen.clone(),
|
|
"updated_at": device.updated_at.clone(),
|
|
})
|
|
}
|
|
|
|
async fn controller_settling_diagnostics(state: &AppState, device_id: &str) -> Value {
|
|
let pending = state.pending_controller_commands.lock().await;
|
|
let Some(expected) = pending.get(device_id).cloned() else {
|
|
return json!({ "active": false, "reason": "none" });
|
|
};
|
|
let now = Instant::now();
|
|
if now > expected.expires_at {
|
|
let expired_by_ms = now.saturating_duration_since(expected.expires_at).as_millis().min(u64::MAX as u128) as u64;
|
|
return json!({
|
|
"active": false,
|
|
"reason": "expired",
|
|
"expired_by_ms": expired_by_ms,
|
|
"commands": expected.commands,
|
|
"baselines": expected.baselines,
|
|
});
|
|
}
|
|
let remaining_ms = expected.expires_at.saturating_duration_since(now).as_millis().min(u64::MAX as u128) as u64;
|
|
json!({
|
|
"active": true,
|
|
"remaining_ms": remaining_ms,
|
|
"commands": expected.commands,
|
|
"baselines": expected.baselines,
|
|
})
|
|
}
|
|
|
|
async fn suppress_expected_controller_changes(
|
|
state: &AppState,
|
|
device: &Device,
|
|
fields: Vec<String>,
|
|
) -> Vec<String> {
|
|
if fields.is_empty() { return fields; }
|
|
let mut pending = state.pending_controller_commands.lock().await;
|
|
let expired = pending.get(&device.id)
|
|
.map(|expected| Instant::now() > expected.expires_at)
|
|
.unwrap_or(false);
|
|
if expired {
|
|
pending.remove(&device.id);
|
|
return fields;
|
|
}
|
|
let Some(expected) = pending.get(&device.id).cloned() else { return fields; };
|
|
let filtered = fields.into_iter()
|
|
.filter(|field| {
|
|
let matches_recent_controller_state = expected.commands.iter()
|
|
.chain(expected.baselines.iter())
|
|
.any(|command| command_field_matches_device(command, field, device));
|
|
!matches_recent_controller_state
|
|
})
|
|
.collect();
|
|
// Do not clear the settling guard merely because one poll matched the requested state.
|
|
// A later status packet can still briefly roll back to the pre-command snapshot. The
|
|
// bounded TTL is what ends this ambiguity window.
|
|
filtered
|
|
}
|
|
|
|
fn externally_changed_control_fields(before: &Device, after: &Device, zone: &Zone) -> Vec<String> {
|
|
let mut fields = Vec::new();
|
|
if before.power != after.power { fields.push("power".to_string()); }
|
|
if before.mode != after.mode { fields.push("mode".to_string()); }
|
|
let temperature_step = after.capabilities.temperature_step.max(0.5);
|
|
if (before.target_temperature / temperature_step).round()
|
|
!= (after.target_temperature / temperature_step).round()
|
|
{
|
|
fields.push("target_temperature".to_string());
|
|
}
|
|
// Some GREE units accept the controller's standby Low fan hint and later report Auto
|
|
// again without user interaction. Treat that one known normalization as firmware drift,
|
|
// not as a remote-control takeover. Other fan changes remain meaningful manual input.
|
|
let standby_low_to_auto = zone.smart_fan && !zone.demand && before.fan_speed == 1 && after.fan_speed == 0;
|
|
if before.fan_speed != after.fan_speed && !standby_low_to_auto {
|
|
fields.push("fan_speed".to_string());
|
|
}
|
|
fields
|
|
}
|
|
|
|
|
|
|
|
pub(crate) async fn cloud_push_loop(state: AppState) {
|
|
let mut receiver = state.providers.cloud().subscribe_push();
|
|
loop {
|
|
match receiver.recv().await {
|
|
Ok(event) => {
|
|
let devices = match state.db.list_devices() {
|
|
Ok(items) => items,
|
|
Err(err) => {
|
|
tracing::warn!(error=?err, "cannot load devices for GREE Cloud push update");
|
|
continue;
|
|
}
|
|
};
|
|
let matching: Vec<String> = devices
|
|
.into_iter()
|
|
.filter(|device| {
|
|
if device.connection_type != ConnectionType::GreeCloud { return false; }
|
|
if let Some(id) = event.cloud_device_id.as_deref() {
|
|
return device.cloud_device_id.as_deref().is_some_and(|value| value.eq_ignore_ascii_case(id));
|
|
}
|
|
device.cloud_parent_mac.as_deref().is_some_and(|value| value.eq_ignore_ascii_case(&event.parent_mac))
|
|
})
|
|
.map(|device| device.id)
|
|
.collect();
|
|
for device_id in matching {
|
|
let _guard = state.lock_device_operation(&device_id).await;
|
|
let Ok(Some(mut device)) = state.db.get_device(&device_id) else { continue; };
|
|
let before = device.clone();
|
|
if !event.properties.is_empty() {
|
|
if let Some(raw_energy) = event.properties.get("ElcAll").and_then(|value| {
|
|
value.as_f64()
|
|
.or_else(|| value.as_i64().map(|v| v as f64))
|
|
.or_else(|| value.as_u64().map(|v| v as f64))
|
|
.or_else(|| value.as_str().and_then(|v| v.parse::<f64>().ok()))
|
|
}) {
|
|
if let Err(err) = record_cumulative_energy_sample(
|
|
&state, &device.id, "gree_cloud", raw_energy, "0.1kWh", None
|
|
) {
|
|
tracing::warn!(device=%device.id, error=?err, "cannot record GREE Cloud energy sample");
|
|
}
|
|
}
|
|
crate::provider::apply_cloud_properties(&mut device, &event.properties);
|
|
device.pending_command = false;
|
|
device.last_cloud_sync = Some(Utc::now());
|
|
device.last_seen = Some(Utc::now());
|
|
device.connection_status = ConnectionStatus::Online;
|
|
device.online = true;
|
|
device.communication_failures = 0;
|
|
device.last_error = None;
|
|
if let Some(version) = event.cipher_version { device.protocol_version = version; }
|
|
device.refresh_capabilities();
|
|
} else if event.connected == Some(true) {
|
|
// A connect topic proves cloud presence but does not replace a status frame.
|
|
device.connection_status = ConnectionStatus::Online;
|
|
device.online = true;
|
|
device.last_seen = Some(Utc::now());
|
|
}
|
|
device.updated_at = Utc::now();
|
|
if let Err(err) = state.db.save_device(&device) {
|
|
tracing::warn!(device=%device_id, error=?err, "cannot persist GREE Cloud push state");
|
|
continue;
|
|
}
|
|
if !event.properties.is_empty() {
|
|
let _ = record_reading(&state, &device);
|
|
if let Err(err) = detect_external_device_control(&state, &before, &device).await {
|
|
tracing::warn!(device=%device_id, error=?err, "cannot process external GREE Cloud state change");
|
|
}
|
|
}
|
|
state.broadcast_with_control_plan_invalidation(
|
|
"device.updated",
|
|
serde_json::to_value(&device).unwrap_or_default(),
|
|
device_runtime_change_affects_control_plan(&before, &device),
|
|
);
|
|
}
|
|
}
|
|
Err(broadcast::error::RecvError::Lagged(skipped)) => tracing::warn!(skipped, "GREE Cloud push state receiver lagged"),
|
|
Err(broadcast::error::RecvError::Closed) => break,
|
|
}
|
|
}
|
|
}
|