Files
gree-controller/src/engine/energy.rs
T
2026-09-17 08:52:02 +02:00

243 lines
8.3 KiB
Rust

const ENERGY_ANOMALY_MAX_DELTA_KWH: f64 = 100.0;
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);
};
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<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(),
));
}
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<f64>,
) -> Result<EnergyReading, AppError> {
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);
}
}