Files
gree-controller/src/engine/polling.rs
T
2026-09-14 16:32:28 +02:00

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,
}
}
}