v0.8.14
This commit is contained in:
@@ -0,0 +1,328 @@
|
||||
pub async fn poll_one(state: &AppState, device_id: &str) -> Result<Device, AppError> {
|
||||
let _device_guard = state.lock_device_operation(device_id).await;
|
||||
poll_one_locked(state, device_id).await
|
||||
}
|
||||
|
||||
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("device.updated", serde_json::to_value(&device).unwrap_or_default());
|
||||
Ok(device)
|
||||
}
|
||||
|
||||
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 _device_guard = state.lock_device_operation(&device_id).await;
|
||||
let _ = poll_one_locked(state, &device_id).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn poll_device(state: &AppState, device: &mut Device) {
|
||||
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.gree.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.gree.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 {
|
||||
Ok(bound) => {
|
||||
device.key = Some(bound.key);
|
||||
device.protocol_version = bound.protocol_version;
|
||||
if let Err(err) = state.gree.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.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;
|
||||
}
|
||||
|
||||
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.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)?;
|
||||
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| value.clamp(8.0, 30.0).round() == device.target_temperature.clamp(8.0, 30.0).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()); }
|
||||
if before.target_temperature.round() != after.target_temperature.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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user