v0.14.0
This commit is contained in:
@@ -488,3 +488,214 @@ async fn control_plan(State(state): State<AppState>) -> Result<Json<Value>, AppE
|
||||
let snapshot = engine::get_control_plan_snapshot(&state).await?;
|
||||
Ok(Json(serde_json::to_value(snapshot.plan.as_ref())?))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct EnergyHistoryQuery {
|
||||
device_id: String,
|
||||
interval: Option<String>,
|
||||
source: Option<String>,
|
||||
days: Option<i64>,
|
||||
limit: Option<u32>,
|
||||
}
|
||||
|
||||
async fn energy_history(
|
||||
State(state): State<AppState>,
|
||||
Query(query): Query<EnergyHistoryQuery>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
use chrono::{Datelike, NaiveDate, Timelike, Weekday};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
let device = state
|
||||
.db
|
||||
.get_device(&query.device_id)?
|
||||
.ok_or_else(|| AppError::NotFound(format!("device {}", query.device_id)))?;
|
||||
let interval_name = query.interval.as_deref().unwrap_or("daily");
|
||||
if !matches!(interval_name, "hourly" | "daily" | "weekly" | "monthly") {
|
||||
return Err(AppError::BadRequest(
|
||||
"energy interval must be hourly, daily, weekly or monthly".into(),
|
||||
));
|
||||
}
|
||||
let days = query.days.unwrap_or(31).clamp(1, 3650);
|
||||
let now = Utc::now();
|
||||
let since = now - ChronoDuration::days(days);
|
||||
let month_start_for_load = chrono::DateTime::<Utc>::from_naive_utc_and_offset(
|
||||
chrono::NaiveDate::from_ymd_opt(now.year(), now.month(), 1)
|
||||
.expect("valid current month")
|
||||
.and_hms_opt(0, 0, 0)
|
||||
.expect("valid midnight"),
|
||||
Utc,
|
||||
);
|
||||
let previous_month_date_for_load = month_start_for_load.date_naive() - ChronoDuration::days(1);
|
||||
let previous_month_start_for_load = chrono::DateTime::<Utc>::from_naive_utc_and_offset(
|
||||
chrono::NaiveDate::from_ymd_opt(
|
||||
previous_month_date_for_load.year(),
|
||||
previous_month_date_for_load.month(),
|
||||
1,
|
||||
)
|
||||
.expect("valid previous month")
|
||||
.and_hms_opt(0, 0, 0)
|
||||
.expect("valid midnight"),
|
||||
Utc,
|
||||
);
|
||||
let load_since = since.min(previous_month_start_for_load);
|
||||
let limit = query.limit.unwrap_or(100_000).clamp(1, 200_000);
|
||||
let influx = state.settings.read().await.influxdb.clone();
|
||||
let cutoff = now - ChronoDuration::days(influx.history_threshold_days as i64);
|
||||
let mut storage = "sqlite".to_string();
|
||||
let mut storage_warning: Option<String> = None;
|
||||
let mut samples = if influx.enabled && load_since < cutoff {
|
||||
match influxdb::query_energy(
|
||||
&state.http,
|
||||
&influx,
|
||||
&device.id,
|
||||
None,
|
||||
load_since,
|
||||
cutoff,
|
||||
3600,
|
||||
limit,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(mut archived) => {
|
||||
archived.extend(state.db.list_energy_readings(&device.id, cutoff, limit)?);
|
||||
storage = "influx+sqlite".into();
|
||||
archived
|
||||
}
|
||||
Err(err) => {
|
||||
storage = "sqlite_fallback".into();
|
||||
storage_warning = Some(err.to_string());
|
||||
state.log(
|
||||
"warn",
|
||||
"influx.query_error",
|
||||
"InfluxDB energy history query failed",
|
||||
json!({"device_id": device.id, "error": err.to_string()}),
|
||||
);
|
||||
state.db.list_energy_readings(&device.id, load_since, limit)?
|
||||
}
|
||||
}
|
||||
} else {
|
||||
state.db.list_energy_readings(&device.id, load_since, limit)?
|
||||
};
|
||||
samples.sort_by_key(|row| row.timestamp);
|
||||
|
||||
let requested_source = query.source.as_deref().unwrap_or("auto");
|
||||
let selected_source = match requested_source {
|
||||
"gree_cloud" => Some("gree_cloud"),
|
||||
"home_assistant" => Some("home_assistant"),
|
||||
"auto" => match device.energy_source {
|
||||
EnergySourcePreference::GreeCloud => Some("gree_cloud"),
|
||||
EnergySourcePreference::HomeAssistant => Some("home_assistant"),
|
||||
EnergySourcePreference::Auto => {
|
||||
if samples.iter().any(|row| row.source == "gree_cloud") {
|
||||
Some("gree_cloud")
|
||||
} else if samples.iter().any(|row| row.source == "home_assistant") {
|
||||
Some("home_assistant")
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
},
|
||||
_ => {
|
||||
return Err(AppError::BadRequest(
|
||||
"energy source must be auto, gree_cloud or home_assistant".into(),
|
||||
))
|
||||
}
|
||||
};
|
||||
if let Some(source) = selected_source {
|
||||
samples.retain(|row| row.source == source);
|
||||
} else {
|
||||
samples.clear();
|
||||
}
|
||||
|
||||
fn midnight(date: NaiveDate) -> chrono::DateTime<Utc> {
|
||||
chrono::DateTime::<Utc>::from_naive_utc_and_offset(
|
||||
date.and_hms_opt(0, 0, 0).expect("valid midnight"),
|
||||
Utc,
|
||||
)
|
||||
}
|
||||
fn bucket_start(
|
||||
timestamp: chrono::DateTime<Utc>,
|
||||
interval_name: &str,
|
||||
) -> chrono::DateTime<Utc> {
|
||||
let date = timestamp.date_naive();
|
||||
match interval_name {
|
||||
"hourly" => chrono::DateTime::<Utc>::from_naive_utc_and_offset(
|
||||
date.and_hms_opt(timestamp.hour(), 0, 0)
|
||||
.expect("valid hour"),
|
||||
Utc,
|
||||
),
|
||||
"weekly" => {
|
||||
let iso = date.iso_week();
|
||||
midnight(
|
||||
NaiveDate::from_isoywd_opt(iso.year(), iso.week(), Weekday::Mon)
|
||||
.expect("valid ISO week"),
|
||||
)
|
||||
}
|
||||
"monthly" => midnight(
|
||||
NaiveDate::from_ymd_opt(date.year(), date.month(), 1)
|
||||
.expect("valid month"),
|
||||
),
|
||||
_ => midnight(date),
|
||||
}
|
||||
}
|
||||
|
||||
let period_samples = samples
|
||||
.iter()
|
||||
.filter(|row| row.timestamp >= since)
|
||||
.collect::<Vec<_>>();
|
||||
let mut buckets: BTreeMap<chrono::DateTime<Utc>, f64> = BTreeMap::new();
|
||||
for sample in &period_samples {
|
||||
*buckets
|
||||
.entry(bucket_start(sample.timestamp, interval_name))
|
||||
.or_default() += sample.consumption_kwh.max(0.0);
|
||||
}
|
||||
let buckets = buckets
|
||||
.into_iter()
|
||||
.map(|(start, consumption_kwh)| {
|
||||
json!({"start": start, "consumption_kwh": consumption_kwh.max(0.0)})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let today_start = midnight(now.date_naive());
|
||||
let yesterday_start = today_start - ChronoDuration::days(1);
|
||||
let month_start = midnight(
|
||||
NaiveDate::from_ymd_opt(now.year(), now.month(), 1).expect("valid current month"),
|
||||
);
|
||||
let previous_month_date = month_start.date_naive() - ChronoDuration::days(1);
|
||||
let previous_month_start = midnight(
|
||||
NaiveDate::from_ymd_opt(previous_month_date.year(), previous_month_date.month(), 1)
|
||||
.expect("valid previous month"),
|
||||
);
|
||||
let sum_range = |start: chrono::DateTime<Utc>, stop: chrono::DateTime<Utc>| -> f64 {
|
||||
samples
|
||||
.iter()
|
||||
.filter(|row| row.timestamp >= start && row.timestamp < stop)
|
||||
.map(|row| row.consumption_kwh.max(0.0))
|
||||
.sum()
|
||||
};
|
||||
let period_total: f64 = period_samples
|
||||
.iter()
|
||||
.map(|row| row.consumption_kwh.max(0.0))
|
||||
.sum();
|
||||
let latest = samples.last().cloned();
|
||||
|
||||
Ok(Json(json!({
|
||||
"device_id": device.id,
|
||||
"source": selected_source.unwrap_or("none"),
|
||||
"configured_source": device.energy_source,
|
||||
"interval": interval_name,
|
||||
"unit": "kWh",
|
||||
"period_days": days,
|
||||
"storage": storage,
|
||||
"storage_warning": storage_warning,
|
||||
"buckets": buckets,
|
||||
"summary": {
|
||||
"today": sum_range(today_start, now + ChronoDuration::seconds(1)),
|
||||
"yesterday": sum_range(yesterday_start, today_start),
|
||||
"current_month": sum_range(month_start, now + ChronoDuration::seconds(1)),
|
||||
"previous_month": sum_range(previous_month_start, month_start),
|
||||
"period_total": period_total,
|
||||
},
|
||||
"latest": latest,
|
||||
})))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user