v0.14.21
This commit is contained in:
1 parent
ff4f2b60e7
commit
b7846c3b9f
87 files changed
+3783
-1418
No files matched your search
+246
-80
@@ -10,8 +10,14 @@ fn device_runtime_change_affects_control_plan(before: &Device, after: &Device) -
|
||||
|| 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()
|
||||
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();
|
||||
@@ -35,7 +41,9 @@ pub async fn poll_one(state: &AppState, device_id: &str) -> Result<Device, AppEr
|
||||
|
||||
// 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)?
|
||||
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;
|
||||
@@ -68,13 +76,19 @@ pub(crate) async fn poll_all(state: &AppState) -> Result<()> {
|
||||
|
||||
// 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) {
|
||||
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) {
|
||||
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.
|
||||
@@ -84,7 +98,9 @@ pub(crate) async fn poll_all(state: &AppState) -> Result<()> {
|
||||
.signed_duration_since(retry_baseline)
|
||||
.num_seconds()
|
||||
>= cloud_interval as i64;
|
||||
if !due { continue; }
|
||||
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.
|
||||
@@ -115,7 +131,10 @@ pub(crate) async fn poll_all(state: &AppState) -> Result<()> {
|
||||
/// 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()
|
||||
let device_ids: Vec<String> = state
|
||||
.db
|
||||
.list_devices()?
|
||||
.into_iter()
|
||||
.filter(|device| device.enabled)
|
||||
.map(|device| device.id)
|
||||
.collect();
|
||||
@@ -137,13 +156,24 @@ async fn poll_device(state: &AppState, device: &mut Device) {
|
||||
return;
|
||||
}
|
||||
};
|
||||
match state.providers.cloud().poll(&cloud_settings, &all_devices, device).await {
|
||||
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.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}));
|
||||
state.log(
|
||||
"info",
|
||||
"gree_cloud.device_online",
|
||||
&format!("{} is online through GREE Cloud", device.name),
|
||||
json!({"device_id": device.id}),
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
@@ -196,7 +226,8 @@ 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);
|
||||
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;
|
||||
}
|
||||
@@ -205,13 +236,14 @@ 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.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;
|
||||
@@ -230,7 +262,13 @@ fn cloud_poll_public_error(error: &str) -> String {
|
||||
}
|
||||
|
||||
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 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 {
|
||||
@@ -238,23 +276,33 @@ async fn log_poll_health_transition(state: &AppState, device: &Device, previous_
|
||||
"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}));
|
||||
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 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 }; }
|
||||
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 }; }
|
||||
if current < ceiling {
|
||||
current += if device.turbo { 0.25 } else { 0.12 };
|
||||
}
|
||||
}
|
||||
"dry" => current -= 0.04,
|
||||
_ => current += (ambient - current) * 0.02,
|
||||
@@ -265,7 +313,9 @@ fn simulate_tick(device: &mut Device) {
|
||||
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.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());
|
||||
@@ -279,10 +329,15 @@ fn record_reading(state: &AppState, device: &Device) -> Result<()> {
|
||||
device_id: device.id.clone(),
|
||||
timestamp: Utc::now(),
|
||||
indoor_temperature: device.current_temperature,
|
||||
outdoor_temperature: installation_outdoor_temperature(state, &device.id).or(device.outdoor_temperature),
|
||||
outdoor_temperature: installation_outdoor_temperature(state, &device.id)
|
||||
.or(device.outdoor_temperature),
|
||||
target_temperature: device.target_temperature,
|
||||
power: device.power,
|
||||
source: if device.simulated { "simulator".into() } else { "gree".into() },
|
||||
source: if device.simulated {
|
||||
"simulator".into()
|
||||
} else {
|
||||
"gree".into()
|
||||
},
|
||||
};
|
||||
state.db.add_reading(&reading)?;
|
||||
queue_influx_device(state, reading);
|
||||
@@ -292,30 +347,50 @@ 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; device.connection_status = ConnectionStatus::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> {
|
||||
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,
|
||||
}));
|
||||
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 !(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 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") {
|
||||
@@ -331,12 +406,24 @@ fn poll_completed_successfully(device: &Device) -> bool {
|
||||
|
||||
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()); }
|
||||
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
|
||||
}
|
||||
|
||||
@@ -344,7 +431,9 @@ fn command_baseline_from_device(command: &DeviceCommand, device: &Device) -> Dev
|
||||
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),
|
||||
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),
|
||||
@@ -352,7 +441,12 @@ fn command_baseline_from_device(command: &DeviceCommand, device: &Device) -> Dev
|
||||
}
|
||||
}
|
||||
|
||||
async fn remember_controller_command(state: &AppState, device_id: &str, command: &DeviceCommand, baseline_device: &Device) {
|
||||
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;
|
||||
@@ -363,32 +457,64 @@ async fn remember_controller_command(state: &AppState, device_id: &str, command:
|
||||
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); }
|
||||
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,
|
||||
});
|
||||
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
|
||||
"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()
|
||||
(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),
|
||||
"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,
|
||||
}
|
||||
}
|
||||
@@ -418,7 +544,10 @@ async fn controller_settling_diagnostics(state: &AppState, device_id: &str) -> V
|
||||
};
|
||||
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;
|
||||
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",
|
||||
@@ -427,7 +556,11 @@ async fn controller_settling_diagnostics(state: &AppState, device_id: &str) -> V
|
||||
"baselines": expected.baselines,
|
||||
});
|
||||
}
|
||||
let remaining_ms = expected.expires_at.saturating_duration_since(now).as_millis().min(u64::MAX as u128) as u64;
|
||||
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,
|
||||
@@ -441,19 +574,27 @@ async fn suppress_expected_controller_changes(
|
||||
device: &Device,
|
||||
fields: Vec<String>,
|
||||
) -> Vec<String> {
|
||||
if fields.is_empty() { return fields; }
|
||||
if fields.is_empty() {
|
||||
return fields;
|
||||
}
|
||||
let mut pending = state.pending_controller_commands.lock().await;
|
||||
let expired = pending.get(&device.id)
|
||||
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()
|
||||
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()
|
||||
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
|
||||
@@ -467,8 +608,12 @@ async fn suppress_expected_controller_changes(
|
||||
|
||||
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()); }
|
||||
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()
|
||||
@@ -478,15 +623,14 @@ fn externally_changed_control_fields(before: &Device, after: &Device, zone: &Zon
|
||||
// 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;
|
||||
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 {
|
||||
@@ -502,27 +646,43 @@ pub(crate) async fn cloud_push_loop(state: AppState) {
|
||||
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));
|
||||
if device.connection_type != ConnectionType::GreeCloud {
|
||||
return false;
|
||||
}
|
||||
device.cloud_parent_mac.as_deref().is_some_and(|value| value.eq_ignore_ascii_case(&event.parent_mac))
|
||||
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 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()
|
||||
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
|
||||
&state,
|
||||
&device.id,
|
||||
"gree_cloud",
|
||||
raw_energy,
|
||||
"0.1kWh",
|
||||
None,
|
||||
) {
|
||||
tracing::warn!(device=%device.id, error=?err, "cannot record GREE Cloud energy sample");
|
||||
}
|
||||
@@ -535,7 +695,9 @@ pub(crate) async fn cloud_push_loop(state: AppState) {
|
||||
device.online = true;
|
||||
device.communication_failures = 0;
|
||||
device.last_error = None;
|
||||
if let Some(version) = event.cipher_version { device.protocol_version = version; }
|
||||
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.
|
||||
@@ -550,7 +712,9 @@ pub(crate) async fn cloud_push_loop(state: AppState) {
|
||||
}
|
||||
if !event.properties.is_empty() {
|
||||
let _ = record_reading(&state, &device);
|
||||
if let Err(err) = detect_external_device_control(&state, &before, &device).await {
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -561,7 +725,9 @@ pub(crate) async fn cloud_push_loop(state: AppState) {
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(broadcast::error::RecvError::Lagged(skipped)) => tracing::warn!(skipped, "GREE Cloud push state receiver lagged"),
|
||||
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