This commit is contained in:
Mateusz Gruszczyński
2026-09-14 16:32:28 +02:00
parent c3fbc5ccc6
commit 021cbddba5
68 changed files with 7780 additions and 202 deletions
+204 -15
View File
@@ -56,14 +56,58 @@ async fn poll_one_locked(state: &AppState, device_id: &str) -> Result<Device, Ap
}
pub(crate) async fn poll_all(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 _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?;
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(())
}
@@ -82,6 +126,35 @@ pub(crate) async fn poll_all_locked(state: &AppState) -> Result<()> {
}
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;
@@ -89,7 +162,7 @@ async fn poll_device(state: &AppState, device: &mut Device) {
let previous_failures = device.communication_failures;
let response_started = Instant::now();
if device.key.as_deref().unwrap_or_default().is_empty() {
match state.gree.bind(device).await {
match state.providers.local().bind(device).await {
Ok(bound) => {
device.key = Some(bound.key);
device.protocol_version = bound.protocol_version;
@@ -102,18 +175,18 @@ async fn poll_device(state: &AppState, device: &mut Device) {
}
}
}
if let Err(first_err) = state.gree.poll(device).await {
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.gree.bind(device).await {
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.gree.poll(device).await {
if let Err(err) = state.providers.local().poll(device).await {
record_poll_failure(device, &err.to_string());
}
}
@@ -122,11 +195,40 @@ async fn poll_device(state: &AppState, device: &mut Device) {
}
}
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);
@@ -190,7 +292,7 @@ fn record_reading(state: &AppState, device: &Device) -> Result<()> {
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; }
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();
}
@@ -278,7 +380,11 @@ fn command_field_matches_device(command: &DeviceCommand, field: &str, device: &D
"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| value.clamp(8.0, 30.0).round() == device.target_temperature.clamp(8.0, 30.0).round())
.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),
@@ -363,7 +469,10 @@ fn externally_changed_control_fields(before: &Device, after: &Device, zone: &Zon
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()); }
if before.target_temperature.round() != after.target_temperature.round() {
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
@@ -377,3 +486,83 @@ fn externally_changed_control_fields(before: &Device, after: &Device, zone: &Zon
}
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,
}
}
}