const ENERGY_ANOMALY_MAX_DELTA_KWH: f64 = 100.0; fn cumulative_energy_delta( previous_kwh: Option, current_kwh: f64, ) -> (f64, &'static str, bool) { let Some(previous_kwh) = previous_kwh else { return (0.0, "baseline", false); }; let delta = current_kwh - previous_kwh; if delta.abs() < 0.000_000_1 { (0.0, "duplicate", false) } else if delta < 0.0 { (0.0, "reset", true) } else if delta > ENERGY_ANOMALY_MAX_DELTA_KWH { (0.0, "anomaly_large_jump", true) } else { (delta.max(0.0), "ok", false) } } fn normalize_energy_kwh(raw_value: f64, unit: &str) -> Result { if !raw_value.is_finite() || raw_value < 0.0 { return Err(AppError::BadRequest( "energy meter value must be a finite non-negative number".into(), )); } match unit.trim().to_ascii_lowercase().as_str() { "kwh" => Ok(raw_value), "wh" => Ok(raw_value / 1000.0), "0.1kwh" | "0.1 kwh" => Ok(raw_value * 0.1), other => Err(AppError::BadRequest(format!( "unsupported energy unit: {other}" ))), } } pub(crate) fn record_cumulative_energy_sample( state: &AppState, device_id: &str, source: &str, raw_value: f64, raw_unit: &str, current_power_kw: Option, ) -> Result { let normalized = normalize_energy_kwh(raw_value, raw_unit)?; let previous = state.db.last_energy_reading(device_id, source)?; let (consumption, quality, reset_detected) = cumulative_energy_delta( previous.as_ref().map(|row| row.normalized_meter_kwh), normalized, ); let reading = EnergyReading { id: 0, device_id: device_id.to_string(), timestamp: Utc::now(), source: source.to_string(), raw_meter_value: raw_value, raw_unit: raw_unit.to_string(), normalized_meter_kwh: normalized, consumption_kwh: consumption.max(0.0), current_power_kw: current_power_kw.filter(|value| value.is_finite() && *value >= 0.0), quality: quality.to_string(), reset_detected, }; state.db.add_energy_reading(&reading)?; state.broadcast( "energy.updated", json!({ "target_id": reading.device_id.clone(), "source": reading.source.clone(), "total_kwh": reading.normalized_meter_kwh, "timestamp": reading.timestamp, }), ); queue_influx_energy(state, reading.clone()); Ok(reading) } fn queue_influx_energy(state: &AppState, reading: EnergyReading) { let state = state.clone(); tokio::spawn(async move { let settings = state.settings.read().await.influxdb.clone(); if !settings.enabled { return; } if let Err(err) = influxdb::write_energy(&state.http, &settings, &reading).await { tracing::warn!(error=?err, device_id=%reading.device_id, source=%reading.source, "cannot write energy metric to InfluxDB"); } }); } async fn sample_home_assistant_energy_target( state: &AppState, target_id: &str, entity_id: &str, ) -> Result<(), AppError> { let settings = state.settings.read().await.home_assistant.clone(); let payload = home_assistant::read_entity(&state.http, &settings, Some(entity_id)) .await .map_err(|err| AppError::Dependency(err.to_string()))?; let state_value = payload .get("state") .and_then(Value::as_str) .unwrap_or_default(); if matches!(state_value, "" | "unknown" | "unavailable") { return Ok(()); } let raw_value: f64 = state_value .parse() .map_err(|_| AppError::Dependency("Home Assistant energy state is not numeric".into()))?; let attrs = payload .get("attributes") .and_then(Value::as_object) .cloned() .unwrap_or_default(); let device_class = attrs .get("device_class") .and_then(Value::as_str) .unwrap_or_default(); let state_class = attrs .get("state_class") .and_then(Value::as_str) .unwrap_or_default(); let unit = attrs .get("unit_of_measurement") .and_then(Value::as_str) .unwrap_or_default(); if device_class != "energy" || !matches!(state_class, "total" | "total_increasing") { return Err(AppError::BadRequest( "selected Home Assistant entity is not a cumulative energy sensor".into(), )); } if !matches!(unit.to_ascii_lowercase().as_str(), "wh" | "kwh") { return Err(AppError::BadRequest( "Home Assistant energy sensor must use Wh or kWh".into(), )); } let _ = record_cumulative_energy_sample(state, target_id, "home_assistant", raw_value, unit, None)?; Ok(()) } async fn sample_home_assistant_energy_device( state: &AppState, device: &Device, ) -> Result<(), AppError> { let Some(entity_id) = device .ha_energy_entity_id .as_deref() .filter(|value| !value.trim().is_empty()) else { return Ok(()); }; sample_home_assistant_energy_target(state, &device.id, entity_id).await } pub(crate) async fn home_assistant_energy_loop(state: AppState) { sleep(Duration::from_secs(10)).await; loop { let settings = state.settings.read().await.home_assistant.clone(); if !settings.url.trim().is_empty() && !settings.token.trim().is_empty() { if let Ok(devices) = state.db.list_devices() { for device in devices .into_iter() .filter(|device| device.enabled && device.ha_energy_entity_id.is_some()) { if let Err(err) = sample_home_assistant_energy_device(&state, &device).await { tracing::warn!(device=%device.id, error=?err, "Home Assistant energy sample failed"); } } } if let Ok(groups) = state.db.list_device_groups() { for group in groups.into_iter().filter(|group| { matches!( group.energy_source, EnergySourcePreference::HomeAssistant | EnergySourcePreference::Auto ) }) { let Some(entity_id) = group .ha_energy_entity_id .as_deref() .filter(|value| !value.trim().is_empty()) else { continue; }; let target_id = format!("group:{}", group.id); if let Err(err) = sample_home_assistant_energy_target(&state, &target_id, entity_id).await { tracing::warn!(group=%group.id, error=?err, "Home Assistant installation energy sample failed"); } } } } sleep(Duration::from_secs(60)).await; } } #[cfg(test)] mod energy_tests { use super::*; #[test] fn normalizes_wh_and_kwh() { assert!((normalize_energy_kwh(1500.0, "Wh").unwrap() - 1.5).abs() < 1e-9); assert!((normalize_energy_kwh(1.5, "kWh").unwrap() - 1.5).abs() < 1e-9); } #[test] fn cumulative_counter_becomes_non_negative_delta() { assert_eq!( cumulative_energy_delta(None, 152.1), (0.0, "baseline", false) ); let (delta, quality, reset) = cumulative_energy_delta(Some(152.1), 152.4); assert!((delta - 0.3).abs() < 1e-9); assert_eq!(quality, "ok"); assert!(!reset); assert_eq!( cumulative_energy_delta(Some(152.4), 152.4), (0.0, "duplicate", false) ); assert_eq!( cumulative_energy_delta(Some(153.0), 1.0), (0.0, "reset", true) ); assert_eq!( cumulative_energy_delta(Some(1.0), 150.0), (0.0, "anomaly_large_jump", true) ); } #[test] fn unit_changes_normalize_before_delta() { let wh = normalize_energy_kwh(152_400.0, "Wh").unwrap(); let kwh = normalize_energy_kwh(153.0, "kWh").unwrap(); let (delta, quality, reset) = cumulative_energy_delta(Some(wh), kwh); assert!((delta - 0.6).abs() < 1e-9); assert_eq!(quality, "ok"); assert!(!reset); } }