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>, AppError> { let mut zone_ids: Vec = 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 { // 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 { 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 = 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 { 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, ) -> Vec { 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 { 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 = 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::().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, } } }