This commit is contained in:
Mateusz Gruszczyński committed 2026-09-17 08:52:02 +02:00
1 parent ff4f2b60e7
commit b7846c3b9f
87 files changed
+3783 -1418

No files matched your search

+246 -80
View File
@@ -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,
}
}