v0.14.0
This commit is contained in:
+204
-15
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user