v0.14.21
This commit is contained in:
+100
-25
@@ -1,6 +1,9 @@
|
||||
const ENERGY_ANOMALY_MAX_DELTA_KWH: f64 = 100.0;
|
||||
|
||||
fn cumulative_energy_delta(previous_kwh: Option<f64>, current_kwh: f64) -> (f64, &'static str, bool) {
|
||||
fn cumulative_energy_delta(
|
||||
previous_kwh: Option<f64>,
|
||||
current_kwh: f64,
|
||||
) -> (f64, &'static str, bool) {
|
||||
let Some(previous_kwh) = previous_kwh else {
|
||||
return (0.0, "baseline", false);
|
||||
};
|
||||
@@ -18,13 +21,17 @@ fn cumulative_energy_delta(previous_kwh: Option<f64>, current_kwh: f64) -> (f64,
|
||||
|
||||
fn normalize_energy_kwh(raw_value: f64, unit: &str) -> Result<f64, AppError> {
|
||||
if !raw_value.is_finite() || raw_value < 0.0 {
|
||||
return Err(AppError::BadRequest("energy meter value must be a finite non-negative number".into()));
|
||||
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}"))),
|
||||
other => Err(AppError::BadRequest(format!(
|
||||
"unsupported energy unit: {other}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,37 +80,77 @@ 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 !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> {
|
||||
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();
|
||||
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()));
|
||||
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()));
|
||||
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)?;
|
||||
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(()); };
|
||||
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
|
||||
}
|
||||
|
||||
@@ -113,17 +160,33 @@ pub(crate) async fn home_assistant_energy_loop(state: AppState) {
|
||||
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()) {
|
||||
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; };
|
||||
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 {
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -145,14 +208,26 @@ mod energy_tests {
|
||||
|
||||
#[test]
|
||||
fn cumulative_counter_becomes_non_negative_delta() {
|
||||
assert_eq!(cumulative_energy_delta(None, 152.1), (0.0, "baseline", false));
|
||||
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));
|
||||
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]
|
||||
|
||||
Reference in New Issue
Block a user