1257 lines
43 KiB
Rust
1257 lines
43 KiB
Rust
#[derive(Debug, Deserialize)]
|
|
struct ReadingsQuery {
|
|
device_id: Option<String>,
|
|
hours: Option<i64>,
|
|
limit: Option<u32>,
|
|
}
|
|
async fn readings(
|
|
State(state): State<AppState>,
|
|
Query(query): Query<ReadingsQuery>,
|
|
) -> Result<Json<Value>, AppError> {
|
|
let hours = query.hours.unwrap_or(24).clamp(1, 24 * 3650);
|
|
let values = state.db.list_readings(
|
|
query.device_id.as_deref(),
|
|
Utc::now() - ChronoDuration::hours(hours),
|
|
query.limit.unwrap_or(1500),
|
|
)?;
|
|
Ok(Json(json!({"readings": values})))
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct HistoryQuery {
|
|
scope: Option<String>,
|
|
zone_id: Option<String>,
|
|
device_id: Option<String>,
|
|
entity_id: Option<String>,
|
|
hours: Option<i64>,
|
|
limit: Option<u32>,
|
|
}
|
|
|
|
fn history_bucket_seconds(hours: i64) -> i64 {
|
|
match hours {
|
|
1..=6 => 30,
|
|
7..=24 => 120,
|
|
25..=168 => 600,
|
|
169..=720 => 1800,
|
|
721..=2160 => 7200,
|
|
2161..=8760 => 21600,
|
|
_ => 86400,
|
|
}
|
|
}
|
|
|
|
fn fallback_zone_rows(zone: &Zone, device: &Device, readings: Vec<Reading>) -> Vec<ZoneReading> {
|
|
readings
|
|
.into_iter()
|
|
.map(|reading| ZoneReading {
|
|
id: reading.id,
|
|
zone_id: zone.id.clone(),
|
|
device_id: zone.device_id.clone(),
|
|
timestamp: reading.timestamp,
|
|
gree_temperature: reading.indoor_temperature,
|
|
external_temperature: None,
|
|
control_temperature: reading.indoor_temperature,
|
|
target_temperature: Some(reading.target_temperature),
|
|
device_setpoint: Some(reading.target_temperature),
|
|
outdoor_temperature: reading.outdoor_temperature,
|
|
power: reading.power,
|
|
mode: device.mode.clone(),
|
|
fan_speed: device.fan_speed,
|
|
demand: false,
|
|
control_source: "gree_history_fallback".into(),
|
|
active_preset: "history".into(),
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn zone_history_with_fallback(
|
|
state: &AppState,
|
|
zone_id: Option<&str>,
|
|
since: chrono::DateTime<Utc>,
|
|
bucket_seconds: i64,
|
|
limit: u32,
|
|
) -> Result<Vec<ZoneReading>, AppError> {
|
|
let mut values = state
|
|
.db
|
|
.list_zone_history(zone_id, since.clone(), bucket_seconds, limit)?;
|
|
if let Some(zone_id) = zone_id {
|
|
if values.is_empty() {
|
|
let zone = state
|
|
.db
|
|
.get_zone(zone_id)?
|
|
.ok_or_else(|| AppError::NotFound(format!("zone {zone_id}")))?;
|
|
if let Some(device) = state.db.get_device(&zone.device_id)? {
|
|
let rows = state.db.list_device_history(
|
|
Some(&zone.device_id),
|
|
since.clone(),
|
|
bucket_seconds,
|
|
limit,
|
|
)?;
|
|
values = fallback_zone_rows(&zone, &device, rows);
|
|
}
|
|
}
|
|
return Ok(values);
|
|
}
|
|
|
|
let existing: std::collections::HashSet<String> =
|
|
values.iter().map(|row| row.zone_id.clone()).collect();
|
|
for zone in state.db.list_zones()? {
|
|
if existing.contains(&zone.id) {
|
|
continue;
|
|
}
|
|
let Some(device) = state.db.get_device(&zone.device_id)? else {
|
|
continue;
|
|
};
|
|
let rows = state.db.list_device_history(
|
|
Some(&zone.device_id),
|
|
since.clone(),
|
|
bucket_seconds,
|
|
limit,
|
|
)?;
|
|
values.extend(fallback_zone_rows(&zone, &device, rows));
|
|
}
|
|
values.sort_by(|left, right| left.timestamp.cmp(&right.timestamp));
|
|
if values.len() > limit as usize {
|
|
let keep_from = values.len() - limit as usize;
|
|
values.drain(0..keep_from);
|
|
}
|
|
Ok(values)
|
|
}
|
|
|
|
fn sensor_history_with_fallback(
|
|
state: &AppState,
|
|
since: chrono::DateTime<Utc>,
|
|
bucket_seconds: i64,
|
|
limit: u32,
|
|
outdoor_entity: &str,
|
|
) -> Result<Vec<HaReading>, AppError> {
|
|
let mut values = state
|
|
.db
|
|
.list_ha_history(None, since.clone(), bucket_seconds, limit)?;
|
|
let mut existing: std::collections::HashSet<String> =
|
|
values.iter().map(|row| row.entity_id.clone()).collect();
|
|
for zone in state.db.list_zones()? {
|
|
if !matches!(zone.sensor_source.as_str(), "home_assistant" | "combined") {
|
|
continue;
|
|
}
|
|
let entity_id = zone.ha_entity_id.as_deref().map(str::trim).unwrap_or("");
|
|
if entity_id.is_empty() {
|
|
continue;
|
|
}
|
|
if existing.contains(entity_id) {
|
|
continue;
|
|
}
|
|
let rows =
|
|
state
|
|
.db
|
|
.list_zone_history(Some(&zone.id), since.clone(), bucket_seconds, limit)?;
|
|
let mut added = false;
|
|
for row in rows {
|
|
if let Some(temperature) = row.external_temperature {
|
|
values.push(HaReading {
|
|
id: row.id,
|
|
entity_id: entity_id.to_string(),
|
|
zone_id: Some(zone.id.clone()),
|
|
kind: "room".into(),
|
|
timestamp: row.timestamp,
|
|
temperature,
|
|
});
|
|
added = true;
|
|
}
|
|
}
|
|
if added {
|
|
existing.insert(entity_id.to_string());
|
|
}
|
|
}
|
|
let global_outdoor_entity = outdoor_entity.trim();
|
|
for zone in state.db.list_zones()? {
|
|
let zone_override = zone
|
|
.ha_outdoor_entity_id
|
|
.as_deref()
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty());
|
|
let entity_id = zone_override.unwrap_or(global_outdoor_entity);
|
|
if entity_id.is_empty() || existing.contains(entity_id) {
|
|
continue;
|
|
}
|
|
let rows =
|
|
state
|
|
.db
|
|
.list_zone_history(Some(&zone.id), since.clone(), bucket_seconds, limit)?;
|
|
let mut added = false;
|
|
for row in rows {
|
|
if let Some(temperature) = row.outdoor_temperature {
|
|
values.push(HaReading {
|
|
id: row.id,
|
|
entity_id: entity_id.to_string(),
|
|
zone_id: zone_override.map(|_| zone.id.clone()),
|
|
kind: "outdoor".into(),
|
|
timestamp: row.timestamp,
|
|
temperature,
|
|
});
|
|
added = true;
|
|
}
|
|
}
|
|
if added {
|
|
existing.insert(entity_id.to_string());
|
|
}
|
|
}
|
|
values.sort_by(|left, right| left.timestamp.cmp(&right.timestamp));
|
|
if values.len() > limit as usize {
|
|
let keep_from = values.len() - limit as usize;
|
|
values.drain(0..keep_from);
|
|
}
|
|
Ok(values)
|
|
}
|
|
|
|
async fn combined_device_history(
|
|
state: &AppState,
|
|
device_id: Option<&str>,
|
|
since: chrono::DateTime<Utc>,
|
|
bucket_seconds: i64,
|
|
limit: u32,
|
|
) -> Result<(Vec<Reading>, String, Option<String>), AppError> {
|
|
let influx = state.settings.read().await.influxdb.clone();
|
|
let cutoff = Utc::now() - ChronoDuration::days(influx.history_threshold_days.max(1) as i64);
|
|
if !influx.enabled || since >= cutoff {
|
|
return Ok((
|
|
state
|
|
.db
|
|
.list_device_history(device_id, since, bucket_seconds, limit)?,
|
|
"sqlite".into(),
|
|
None,
|
|
));
|
|
}
|
|
let mut warning = None;
|
|
let mut values = match influxdb::query_devices(
|
|
&state.http,
|
|
&influx,
|
|
device_id,
|
|
since,
|
|
cutoff,
|
|
bucket_seconds,
|
|
limit,
|
|
)
|
|
.await
|
|
{
|
|
Ok(rows) => rows,
|
|
Err(err) => {
|
|
warning = Some(err.to_string());
|
|
state.log(
|
|
"warn",
|
|
"influx.query_error",
|
|
"InfluxDB device history query failed",
|
|
json!({"error": err.to_string()}),
|
|
);
|
|
state
|
|
.db
|
|
.list_device_history(device_id, since, bucket_seconds, limit)?
|
|
}
|
|
};
|
|
if warning.is_none() {
|
|
values.extend(
|
|
state
|
|
.db
|
|
.list_device_history(device_id, cutoff, bucket_seconds, limit)?,
|
|
);
|
|
}
|
|
values.sort_by_key(|row| row.timestamp);
|
|
trim_history(&mut values, limit);
|
|
let source = if warning.is_some() {
|
|
"sqlite_fallback"
|
|
} else {
|
|
"influx+sqlite"
|
|
};
|
|
Ok((values, source.into(), warning))
|
|
}
|
|
|
|
async fn combined_zone_history(
|
|
state: &AppState,
|
|
zone_id: Option<&str>,
|
|
since: chrono::DateTime<Utc>,
|
|
bucket_seconds: i64,
|
|
limit: u32,
|
|
) -> Result<(Vec<ZoneReading>, String, Option<String>), AppError> {
|
|
let influx = state.settings.read().await.influxdb.clone();
|
|
let cutoff = Utc::now() - ChronoDuration::days(influx.history_threshold_days.max(1) as i64);
|
|
if !influx.enabled || since >= cutoff {
|
|
return Ok((
|
|
zone_history_with_fallback(state, zone_id, since, bucket_seconds, limit)?,
|
|
"sqlite".into(),
|
|
None,
|
|
));
|
|
}
|
|
let mut warning = None;
|
|
let mut values = match influxdb::query_zones(
|
|
&state.http,
|
|
&influx,
|
|
zone_id,
|
|
since,
|
|
cutoff,
|
|
bucket_seconds,
|
|
limit,
|
|
)
|
|
.await
|
|
{
|
|
Ok(rows) => rows,
|
|
Err(err) => {
|
|
warning = Some(err.to_string());
|
|
state.log(
|
|
"warn",
|
|
"influx.query_error",
|
|
"InfluxDB zone history query failed",
|
|
json!({"error": err.to_string()}),
|
|
);
|
|
zone_history_with_fallback(state, zone_id, since, bucket_seconds, limit)?
|
|
}
|
|
};
|
|
if warning.is_none() {
|
|
values.extend(zone_history_with_fallback(
|
|
state,
|
|
zone_id,
|
|
cutoff,
|
|
bucket_seconds,
|
|
limit,
|
|
)?);
|
|
}
|
|
values.sort_by_key(|row| row.timestamp);
|
|
trim_history(&mut values, limit);
|
|
let source = if warning.is_some() {
|
|
"sqlite_fallback"
|
|
} else {
|
|
"influx+sqlite"
|
|
};
|
|
Ok((values, source.into(), warning))
|
|
}
|
|
|
|
async fn combined_sensor_history(
|
|
state: &AppState,
|
|
entity_id: Option<&str>,
|
|
since: chrono::DateTime<Utc>,
|
|
bucket_seconds: i64,
|
|
limit: u32,
|
|
outdoor_entity: &str,
|
|
) -> Result<(Vec<HaReading>, String, Option<String>), AppError> {
|
|
let influx = state.settings.read().await.influxdb.clone();
|
|
let cutoff = Utc::now() - ChronoDuration::days(influx.history_threshold_days.max(1) as i64);
|
|
let local = |start| -> Result<Vec<HaReading>, AppError> {
|
|
if entity_id.is_some() {
|
|
Ok(state
|
|
.db
|
|
.list_ha_history(entity_id, start, bucket_seconds, limit)?)
|
|
} else {
|
|
sensor_history_with_fallback(state, start, bucket_seconds, limit, outdoor_entity)
|
|
}
|
|
};
|
|
if !influx.enabled || since >= cutoff {
|
|
return Ok((local(since)?, "sqlite".into(), None));
|
|
}
|
|
let mut warning = None;
|
|
let mut values = match influxdb::query_ha(
|
|
&state.http,
|
|
&influx,
|
|
entity_id,
|
|
since,
|
|
cutoff,
|
|
bucket_seconds,
|
|
limit,
|
|
)
|
|
.await
|
|
{
|
|
Ok(rows) => rows,
|
|
Err(err) => {
|
|
warning = Some(err.to_string());
|
|
state.log(
|
|
"warn",
|
|
"influx.query_error",
|
|
"InfluxDB HA history query failed",
|
|
json!({"error": err.to_string()}),
|
|
);
|
|
local(since)?
|
|
}
|
|
};
|
|
if warning.is_none() {
|
|
values.extend(local(cutoff)?);
|
|
}
|
|
values.sort_by_key(|row| row.timestamp);
|
|
trim_history(&mut values, limit);
|
|
let source = if warning.is_some() {
|
|
"sqlite_fallback"
|
|
} else {
|
|
"influx+sqlite"
|
|
};
|
|
Ok((values, source.into(), warning))
|
|
}
|
|
|
|
fn trim_history<T>(values: &mut Vec<T>, limit: u32) {
|
|
if values.len() > limit as usize {
|
|
let keep_from = values.len() - limit as usize;
|
|
values.drain(0..keep_from);
|
|
}
|
|
}
|
|
|
|
async fn history(
|
|
State(state): State<AppState>,
|
|
Query(query): Query<HistoryQuery>,
|
|
) -> Result<Json<Value>, AppError> {
|
|
let hours = query.hours.unwrap_or(24).clamp(1, 24 * 3650);
|
|
let since = Utc::now() - ChronoDuration::hours(hours);
|
|
let bucket_seconds = history_bucket_seconds(hours);
|
|
let limit = query.limit.unwrap_or(12_000).clamp(1, 20_000);
|
|
let scope = query.scope.as_deref().unwrap_or("zones");
|
|
let ha_settings = state.settings.read().await.home_assistant.clone();
|
|
let outdoor_entity = ha_settings.outdoor_entity_id;
|
|
let (device_count, zone_count, ha_count) = state.db.history_counts()?;
|
|
|
|
match scope {
|
|
"devices" => {
|
|
let device_id = query
|
|
.device_id
|
|
.as_deref()
|
|
.filter(|value| !value.is_empty() && *value != "all");
|
|
let (readings, storage, warning) =
|
|
combined_device_history(&state, device_id, since, bucket_seconds, limit).await?;
|
|
Ok(Json(json!({
|
|
"scope": "devices", "readings": readings, "bucket_seconds": bucket_seconds,
|
|
"storage": storage, "storage_warning": warning,
|
|
"counts": {"devices": device_count, "zones": zone_count, "ha": ha_count}
|
|
})))
|
|
}
|
|
"sensors" => {
|
|
let entity_id = query
|
|
.entity_id
|
|
.as_deref()
|
|
.filter(|value| !value.is_empty() && *value != "all");
|
|
let (readings, storage, warning) = combined_sensor_history(
|
|
&state,
|
|
entity_id,
|
|
since,
|
|
bucket_seconds,
|
|
limit,
|
|
&outdoor_entity,
|
|
)
|
|
.await?;
|
|
Ok(Json(json!({
|
|
"scope": "sensors", "readings": readings, "bucket_seconds": bucket_seconds,
|
|
"storage": storage, "storage_warning": warning,
|
|
"counts": {"devices": device_count, "zones": zone_count, "ha": ha_count}
|
|
})))
|
|
}
|
|
"overview" => {
|
|
let (zones, zone_storage, zone_warning) =
|
|
combined_zone_history(&state, None, since, bucket_seconds, limit).await?;
|
|
let (devices, device_storage, device_warning) =
|
|
combined_device_history(&state, None, since, bucket_seconds, limit).await?;
|
|
let (sensors, sensor_storage, sensor_warning) = combined_sensor_history(
|
|
&state,
|
|
None,
|
|
since,
|
|
bucket_seconds,
|
|
limit,
|
|
&outdoor_entity,
|
|
)
|
|
.await?;
|
|
let storage_warning = [zone_warning, device_warning, sensor_warning]
|
|
.into_iter()
|
|
.flatten()
|
|
.collect::<Vec<_>>();
|
|
Ok(Json(json!({
|
|
"scope": "overview", "bucket_seconds": bucket_seconds,
|
|
"zones": zones, "devices": devices, "sensors": sensors,
|
|
"storage": {"zones": zone_storage, "devices": device_storage, "sensors": sensor_storage},
|
|
"storage_warning": storage_warning,
|
|
"counts": {"devices": device_count, "zones": zone_count, "ha": ha_count}
|
|
})))
|
|
}
|
|
"zones" | "zone" => {
|
|
let zone_id = query
|
|
.zone_id
|
|
.as_deref()
|
|
.filter(|value| !value.is_empty() && *value != "all");
|
|
if let Some(zone_id) = zone_id {
|
|
if state.db.get_zone(zone_id)?.is_none() {
|
|
return Err(AppError::NotFound(format!("zone {zone_id}")));
|
|
}
|
|
}
|
|
let (readings, storage, warning) =
|
|
combined_zone_history(&state, zone_id, since, bucket_seconds, limit).await?;
|
|
Ok(Json(json!({
|
|
"scope": "zones", "readings": readings, "bucket_seconds": bucket_seconds,
|
|
"storage": storage, "storage_warning": warning,
|
|
"counts": {"devices": device_count, "zones": zone_count, "ha": ha_count}
|
|
})))
|
|
}
|
|
_ => Err(AppError::BadRequest(
|
|
"history scope must be overview, zones, devices or sensors".into(),
|
|
)),
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct CreatePublicCustomChartRequest {
|
|
title: Option<String>,
|
|
series: Vec<String>,
|
|
hours: Option<i64>,
|
|
lang: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, serde::Serialize, serde::Deserialize)]
|
|
struct PublicCustomChartShare {
|
|
title: String,
|
|
series: Vec<String>,
|
|
hours: i64,
|
|
lang: String,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct PublicCustomChartQuery {
|
|
hours: Option<i64>,
|
|
}
|
|
|
|
#[derive(Debug, serde::Serialize)]
|
|
struct PublicChartPoint {
|
|
timestamp: chrono::DateTime<Utc>,
|
|
value: f64,
|
|
}
|
|
|
|
#[derive(Debug, serde::Serialize)]
|
|
struct PublicChartSeries {
|
|
key: String,
|
|
label: String,
|
|
label_key: &'static str,
|
|
dashed: bool,
|
|
points: Vec<PublicChartPoint>,
|
|
}
|
|
|
|
fn validate_public_chart_spec(series: &[String]) -> Result<(), AppError> {
|
|
if series.is_empty()
|
|
|| series.len() > 16
|
|
|| series
|
|
.iter()
|
|
.any(|item| item.is_empty() || item.len() > 256)
|
|
{
|
|
return Err(AppError::BadRequest(
|
|
"invalid custom chart definition".into(),
|
|
));
|
|
}
|
|
|
|
for key in series {
|
|
let mut parts = key.splitn(3, '|');
|
|
let kind = parts.next().unwrap_or_default();
|
|
let id = parts.next().unwrap_or_default();
|
|
let field = parts.next().unwrap_or_default();
|
|
if id.trim().is_empty() || public_chart_field_label_key(kind, field).is_none() {
|
|
return Err(AppError::BadRequest(format!(
|
|
"unsupported custom chart series: {key}"
|
|
)));
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn generate_public_chart_token() -> String {
|
|
let mut bytes = [0u8; 32];
|
|
rand::fill(&mut bytes);
|
|
format!("chart_{}", URL_SAFE_NO_PAD.encode(bytes))
|
|
}
|
|
|
|
async fn create_public_custom_chart(
|
|
State(state): State<AppState>,
|
|
Json(input): Json<CreatePublicCustomChartRequest>,
|
|
) -> Result<Json<Value>, AppError> {
|
|
validate_public_chart_spec(&input.series)?;
|
|
|
|
let hours = input.hours.unwrap_or(24).clamp(1, 24 * 3650);
|
|
let requested_lang = input.lang.as_deref().unwrap_or_default().trim();
|
|
let lang = LANGUAGE_ASSETS
|
|
.iter()
|
|
.find(|(code, _)| *code == requested_lang)
|
|
.map(|(code, _)| (*code).to_string())
|
|
.unwrap_or_else(|| DEFAULT_LANGUAGE_CODE.to_string());
|
|
let title = input
|
|
.title
|
|
.as_deref()
|
|
.map(str::trim)
|
|
.unwrap_or_default()
|
|
.chars()
|
|
.take(120)
|
|
.collect::<String>();
|
|
let share = PublicCustomChartShare {
|
|
title,
|
|
series: input.series,
|
|
hours,
|
|
lang,
|
|
};
|
|
|
|
let token = generate_public_chart_token();
|
|
let token_hash = hash_token(&token);
|
|
state
|
|
.db
|
|
.save_public_chart_share(&token_hash, &serde_json::to_value(&share)?)?;
|
|
|
|
Ok(Json(json!({
|
|
"path": format!("/charts/custom/{token}")
|
|
})))
|
|
}
|
|
|
|
fn public_chart_field_label_key(kind: &str, field: &str) -> Option<&'static str> {
|
|
match (kind, field) {
|
|
("device", "indoor") => Some("publicChart.field.deviceIndoor"),
|
|
("device", "outdoor") => Some("publicChart.field.deviceOutdoor"),
|
|
("device", "target") => Some("publicChart.field.deviceTarget"),
|
|
("installation", "outdoor") => Some("publicChart.field.sharedOutdoor"),
|
|
("zone", "control") => Some("publicChart.field.zoneControl"),
|
|
("zone", "gree") => Some("publicChart.field.greeSensor"),
|
|
("zone", "external") => Some("publicChart.field.roomSensor"),
|
|
("zone", "target") => Some("publicChart.field.comfortTarget"),
|
|
("zone", "device_target") => Some("publicChart.field.deviceSetpoint"),
|
|
("zone", "outdoor") => Some("publicChart.field.outdoor"),
|
|
("ha", "temperature") => Some("publicChart.field.temperature"),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
fn reading_points(rows: Vec<Reading>, field: &str) -> Vec<PublicChartPoint> {
|
|
rows.into_iter()
|
|
.filter_map(|row| {
|
|
let value = match field {
|
|
"indoor" => row.indoor_temperature,
|
|
"outdoor" => row.outdoor_temperature,
|
|
"target" => Some(row.target_temperature),
|
|
_ => None,
|
|
}?;
|
|
value.is_finite().then_some(PublicChartPoint {
|
|
timestamp: row.timestamp,
|
|
value,
|
|
})
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn zone_reading_points(rows: Vec<ZoneReading>, field: &str) -> Vec<PublicChartPoint> {
|
|
rows.into_iter()
|
|
.filter_map(|row| {
|
|
let value = match field {
|
|
"control" => row.control_temperature,
|
|
"gree" => row.gree_temperature,
|
|
"external" => row.external_temperature,
|
|
"target" => row.target_temperature,
|
|
"device_target" => row.device_setpoint,
|
|
"outdoor" => row.outdoor_temperature,
|
|
_ => None,
|
|
}?;
|
|
value.is_finite().then_some(PublicChartPoint {
|
|
timestamp: row.timestamp,
|
|
value,
|
|
})
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
async fn public_custom_chart(
|
|
State(state): State<AppState>,
|
|
Path(token): Path<String>,
|
|
Query(query): Query<PublicCustomChartQuery>,
|
|
) -> Result<Json<Value>, AppError> {
|
|
if token.len() < 32 || token.len() > 128 || !token.starts_with("chart_") {
|
|
return Err(AppError::NotFound("chart id not found".into()));
|
|
}
|
|
let payload = state
|
|
.db
|
|
.get_public_chart_share(&hash_token(&token))?
|
|
.ok_or_else(|| AppError::NotFound("chart id not found".into()))?;
|
|
let share: PublicCustomChartShare = serde_json::from_value(payload)?;
|
|
validate_public_chart_spec(&share.series)?;
|
|
|
|
let hours = query.hours.unwrap_or(share.hours).clamp(1, 24 * 3650);
|
|
let lang = LANGUAGE_ASSETS
|
|
.iter()
|
|
.find(|(code, _)| *code == share.lang.as_str())
|
|
.map(|(code, _)| *code)
|
|
.unwrap_or(DEFAULT_LANGUAGE_CODE);
|
|
let since = Utc::now() - ChronoDuration::hours(hours);
|
|
let bucket_seconds = history_bucket_seconds(hours);
|
|
let limit = 20_000;
|
|
let ha_settings = state.settings.read().await.home_assistant.clone();
|
|
let outdoor_entity = ha_settings.outdoor_entity_id.clone();
|
|
let mut series = Vec::with_capacity(share.series.len());
|
|
|
|
for key in share.series {
|
|
let mut parts = key.splitn(3, '|');
|
|
let kind = parts.next().unwrap_or_default();
|
|
let id = parts.next().unwrap_or_default();
|
|
let field = parts.next().unwrap_or_default();
|
|
let label_key = public_chart_field_label_key(kind, field).ok_or_else(|| {
|
|
AppError::BadRequest(format!("unsupported custom chart series: {key}"))
|
|
})?;
|
|
|
|
let item = match kind {
|
|
"device" => {
|
|
let device = state
|
|
.db
|
|
.get_device(id)?
|
|
.ok_or_else(|| AppError::NotFound(format!("device {id}")))?;
|
|
let (rows, _, _) =
|
|
combined_device_history(&state, Some(id), since.clone(), bucket_seconds, limit)
|
|
.await?;
|
|
PublicChartSeries {
|
|
key: key.clone(),
|
|
label: device.name,
|
|
label_key,
|
|
dashed: field == "target",
|
|
points: reading_points(rows, field),
|
|
}
|
|
}
|
|
"installation" => {
|
|
let group = state
|
|
.db
|
|
.get_device_group(id)?
|
|
.ok_or_else(|| AppError::NotFound(format!("device group {id}")))?;
|
|
let representative = group
|
|
.outdoor_temperature_device_id
|
|
.as_deref()
|
|
.filter(|device_id| {
|
|
group
|
|
.device_ids
|
|
.iter()
|
|
.any(|member| member.as_str() == *device_id)
|
|
})
|
|
.or_else(|| group.device_ids.first().map(String::as_str))
|
|
.ok_or_else(|| {
|
|
AppError::BadRequest(format!("device group {id} has no devices"))
|
|
})?;
|
|
let (rows, _, _) = combined_device_history(
|
|
&state,
|
|
Some(representative),
|
|
since.clone(),
|
|
bucket_seconds,
|
|
limit,
|
|
)
|
|
.await?;
|
|
PublicChartSeries {
|
|
key: key.clone(),
|
|
label: group.name,
|
|
label_key,
|
|
dashed: false,
|
|
points: reading_points(rows, "outdoor"),
|
|
}
|
|
}
|
|
"zone" => {
|
|
let zone = state
|
|
.db
|
|
.get_zone(id)?
|
|
.ok_or_else(|| AppError::NotFound(format!("zone {id}")))?;
|
|
let (rows, _, _) =
|
|
combined_zone_history(&state, Some(id), since.clone(), bucket_seconds, limit)
|
|
.await?;
|
|
PublicChartSeries {
|
|
key: key.clone(),
|
|
label: zone.name,
|
|
label_key,
|
|
dashed: matches!(field, "target" | "device_target" | "outdoor"),
|
|
points: zone_reading_points(rows, field),
|
|
}
|
|
}
|
|
"ha" => {
|
|
let (rows, _, _) = combined_sensor_history(
|
|
&state,
|
|
Some(id),
|
|
since.clone(),
|
|
bucket_seconds,
|
|
limit,
|
|
&outdoor_entity,
|
|
)
|
|
.await?;
|
|
let alias = ha_settings
|
|
.sensor_aliases
|
|
.get(id)
|
|
.map(String::as_str)
|
|
.filter(|value| !value.trim().is_empty())
|
|
.unwrap_or(id);
|
|
PublicChartSeries {
|
|
key: key.clone(),
|
|
label: format!("HA · {}", alias),
|
|
label_key,
|
|
dashed: true,
|
|
points: rows
|
|
.into_iter()
|
|
.filter(|row| row.entity_id == id && row.temperature.is_finite())
|
|
.map(|row| PublicChartPoint {
|
|
timestamp: row.timestamp,
|
|
value: row.temperature,
|
|
})
|
|
.collect(),
|
|
}
|
|
}
|
|
_ => {
|
|
return Err(AppError::BadRequest(format!(
|
|
"unsupported custom chart series: {key}"
|
|
)))
|
|
}
|
|
};
|
|
series.push(item);
|
|
}
|
|
|
|
Ok(Json(json!({
|
|
"title": share.title,
|
|
"lang": lang,
|
|
"hours": hours,
|
|
"bucket_seconds": bucket_seconds,
|
|
"series": series,
|
|
})))
|
|
}
|
|
|
|
async fn control_plan(State(state): State<AppState>) -> Result<Json<Value>, AppError> {
|
|
let snapshot = engine::get_control_plan_snapshot(&state).await?;
|
|
Ok(Json(serde_json::to_value(snapshot.plan.as_ref())?))
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct EnergyHistoryQuery {
|
|
/// Backward-compatible single-device selector.
|
|
device_id: Option<String>,
|
|
/// Device id or `group:<installation-id>`.
|
|
target_id: Option<String>,
|
|
interval: Option<String>,
|
|
source: Option<String>,
|
|
days: Option<i64>,
|
|
limit: Option<u32>,
|
|
/// none, previous_day, previous_period or previous_year.
|
|
compare: Option<String>,
|
|
}
|
|
|
|
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 target_id = query
|
|
.target_id
|
|
.as_deref()
|
|
.or(query.device_id.as_deref())
|
|
.filter(|value| !value.trim().is_empty())
|
|
.ok_or_else(|| AppError::BadRequest("energy target_id is required".into()))?;
|
|
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 compare_name = query.compare.as_deref().unwrap_or("none");
|
|
let compare_shift_days = match compare_name {
|
|
"none" => None,
|
|
"previous_day" => Some(1),
|
|
"previous_period" => Some(days),
|
|
"previous_year" => Some(365),
|
|
_ => {
|
|
return Err(AppError::BadRequest(
|
|
"energy compare must be none, previous_day, previous_period or previous_year"
|
|
.into(),
|
|
))
|
|
}
|
|
};
|
|
|
|
let requested_source = query.source.as_deref().unwrap_or("auto");
|
|
if !matches!(requested_source, "auto" | "gree_cloud" | "home_assistant") {
|
|
return Err(AppError::BadRequest(
|
|
"energy source must be auto, gree_cloud or home_assistant".into(),
|
|
));
|
|
}
|
|
|
|
let (
|
|
target_type,
|
|
public_target_id,
|
|
target_name,
|
|
member_device_ids,
|
|
configured_source,
|
|
storage_id,
|
|
preferred_source,
|
|
) = if let Some(group_id) = target_id.strip_prefix("group:") {
|
|
let group = state
|
|
.db
|
|
.get_device_group(group_id)?
|
|
.ok_or_else(|| AppError::NotFound(format!("device group {group_id}")))?;
|
|
let auto_source = if group.energy_device_id.is_some() {
|
|
Some("gree_cloud")
|
|
} else if group.ha_energy_entity_id.is_some() {
|
|
Some("home_assistant")
|
|
} else {
|
|
None
|
|
};
|
|
let selected = match requested_source {
|
|
"gree_cloud" => Some("gree_cloud"),
|
|
"home_assistant" => Some("home_assistant"),
|
|
_ => match group.energy_source {
|
|
EnergySourcePreference::GreeCloud => Some("gree_cloud"),
|
|
EnergySourcePreference::HomeAssistant => Some("home_assistant"),
|
|
EnergySourcePreference::Auto => auto_source,
|
|
},
|
|
};
|
|
let storage_id = match selected {
|
|
Some("gree_cloud") => group.energy_device_id.clone().ok_or_else(|| {
|
|
AppError::BadRequest("installation has no GREE Cloud energy source device".into())
|
|
})?,
|
|
_ => format!("group:{}", group.id),
|
|
};
|
|
(
|
|
"group",
|
|
format!("group:{}", group.id),
|
|
group.name,
|
|
group.device_ids,
|
|
group.energy_source,
|
|
storage_id,
|
|
selected,
|
|
)
|
|
} else {
|
|
let device = state
|
|
.db
|
|
.get_device(target_id)?
|
|
.ok_or_else(|| AppError::NotFound(format!("device {target_id}")))?;
|
|
let selected = 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 => None,
|
|
},
|
|
_ => None,
|
|
};
|
|
(
|
|
"device",
|
|
device.id.clone(),
|
|
device.name,
|
|
vec![device.id.clone()],
|
|
device.energy_source,
|
|
device.id,
|
|
selected,
|
|
)
|
|
};
|
|
|
|
let now = Utc::now();
|
|
let since = now - ChronoDuration::days(days);
|
|
let comparison_since = compare_shift_days.map(|shift| since - ChronoDuration::days(shift));
|
|
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 mut load_since = since.min(previous_month_start_for_load);
|
|
if let Some(compare_since) = comparison_since {
|
|
load_since = load_since.min(compare_since);
|
|
}
|
|
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,
|
|
&storage_id,
|
|
None,
|
|
load_since,
|
|
cutoff,
|
|
3600,
|
|
limit,
|
|
)
|
|
.await
|
|
{
|
|
Ok(mut archived) => {
|
|
archived.extend(state.db.list_energy_readings(&storage_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!({"target_id": public_target_id, "storage_id": storage_id, "error": err.to_string()}),
|
|
);
|
|
state
|
|
.db
|
|
.list_energy_readings(&storage_id, load_since, limit)?
|
|
}
|
|
}
|
|
} else {
|
|
state
|
|
.db
|
|
.list_energy_readings(&storage_id, load_since, limit)?
|
|
};
|
|
samples.sort_by_key(|row| row.timestamp);
|
|
|
|
let selected_source = if let Some(source) = preferred_source {
|
|
Some(source)
|
|
} else if requested_source == "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
|
|
}
|
|
} else {
|
|
Some(requested_source)
|
|
};
|
|
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),
|
|
}
|
|
}
|
|
fn bucket_rows<'a>(
|
|
rows: impl Iterator<Item = &'a EnergyReading>,
|
|
interval_name: &str,
|
|
shift_days: i64,
|
|
) -> Vec<Value> {
|
|
let mut buckets: BTreeMap<chrono::DateTime<Utc>, f64> = BTreeMap::new();
|
|
for sample in rows {
|
|
let shifted = sample.timestamp + ChronoDuration::days(shift_days);
|
|
*buckets
|
|
.entry(bucket_start(shifted, interval_name))
|
|
.or_default() += sample.consumption_kwh.max(0.0);
|
|
}
|
|
buckets.into_iter().map(|(start, consumption_kwh)| json!({"start": start, "consumption_kwh": consumption_kwh.max(0.0)})).collect()
|
|
}
|
|
|
|
let period_samples = samples
|
|
.iter()
|
|
.filter(|row| row.timestamp >= since && row.timestamp <= now)
|
|
.collect::<Vec<_>>();
|
|
let buckets = bucket_rows(period_samples.iter().copied(), interval_name, 0);
|
|
|
|
let comparison = if let Some(shift) = compare_shift_days {
|
|
let compare_end = now - ChronoDuration::days(shift);
|
|
let compare_start = since - ChronoDuration::days(shift);
|
|
let rows = samples
|
|
.iter()
|
|
.filter(|row| row.timestamp >= compare_start && row.timestamp <= compare_end)
|
|
.collect::<Vec<_>>();
|
|
let total: f64 = rows.iter().map(|row| row.consumption_kwh.max(0.0)).sum();
|
|
Some(json!({
|
|
"kind": compare_name,
|
|
"shift_days": shift,
|
|
"period_start": compare_start,
|
|
"period_end": compare_end,
|
|
"period_total": total,
|
|
"buckets": bucket_rows(rows.iter().copied(), interval_name, shift),
|
|
}))
|
|
} else {
|
|
None
|
|
};
|
|
|
|
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!({
|
|
"target_id": public_target_id,
|
|
"target_type": target_type,
|
|
"target_name": target_name,
|
|
"member_device_ids": member_device_ids,
|
|
"device_id": if target_type == "device" { Some(public_target_id.clone()) } else { None::<String> },
|
|
"source": selected_source.unwrap_or("none"),
|
|
"configured_source": configured_source,
|
|
"interval": interval_name,
|
|
"unit": "kWh",
|
|
"period_days": days,
|
|
"storage": storage,
|
|
"storage_warning": storage_warning,
|
|
"buckets": buckets,
|
|
"comparison": comparison,
|
|
"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,
|
|
})))
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct NetworkHistoryQuery {
|
|
target_id: Option<String>,
|
|
hours: Option<i64>,
|
|
limit: Option<u32>,
|
|
}
|
|
|
|
async fn combined_network_history(
|
|
state: &AppState,
|
|
target_id: Option<&str>,
|
|
since: chrono::DateTime<Utc>,
|
|
bucket_seconds: i64,
|
|
limit: u32,
|
|
) -> Result<(Vec<NetworkReading>, String, Option<String>), AppError> {
|
|
let influx = state.settings.read().await.influxdb.clone();
|
|
let cutoff = Utc::now() - ChronoDuration::days(influx.history_threshold_days.max(1) as i64);
|
|
if !influx.enabled || since >= cutoff {
|
|
return Ok((
|
|
state
|
|
.db
|
|
.list_network_history(target_id, since, bucket_seconds, limit)?,
|
|
"sqlite".into(),
|
|
None,
|
|
));
|
|
}
|
|
let mut warning = None;
|
|
let mut values = match influxdb::query_network(
|
|
&state.http,
|
|
&influx,
|
|
target_id,
|
|
since,
|
|
cutoff,
|
|
bucket_seconds,
|
|
limit,
|
|
)
|
|
.await
|
|
{
|
|
Ok(rows) => rows,
|
|
Err(err) => {
|
|
warning = Some(err.to_string());
|
|
state.log(
|
|
"warn",
|
|
"influx.query_error",
|
|
"InfluxDB connectivity history query failed",
|
|
json!({"target_id": target_id, "error": err.to_string()}),
|
|
);
|
|
state
|
|
.db
|
|
.list_network_history(target_id, since, bucket_seconds, limit)?
|
|
}
|
|
};
|
|
if warning.is_none() {
|
|
values.extend(
|
|
state
|
|
.db
|
|
.list_network_history(target_id, cutoff, bucket_seconds, limit)?,
|
|
);
|
|
}
|
|
values.sort_by_key(|row| row.timestamp);
|
|
trim_history(&mut values, limit);
|
|
Ok((
|
|
values,
|
|
if warning.is_some() {
|
|
"sqlite_fallback".into()
|
|
} else {
|
|
"influx+sqlite".into()
|
|
},
|
|
warning,
|
|
))
|
|
}
|
|
|
|
async fn network_history(
|
|
State(state): State<AppState>,
|
|
Query(query): Query<NetworkHistoryQuery>,
|
|
) -> Result<Json<Value>, AppError> {
|
|
let hours = query.hours.unwrap_or(24).clamp(1, 24 * 3650);
|
|
let since = Utc::now() - ChronoDuration::hours(hours);
|
|
let bucket_seconds = history_bucket_seconds(hours);
|
|
let limit = query.limit.unwrap_or(20_000).clamp(1, 50_000);
|
|
let target_id = query
|
|
.target_id
|
|
.as_deref()
|
|
.filter(|value| !value.is_empty() && *value != "all");
|
|
let (readings, storage, warning) =
|
|
combined_network_history(&state, target_id, since, bucket_seconds, limit).await?;
|
|
|
|
let settings = state.settings.read().await.clone();
|
|
let mut targets = Vec::<Value>::new();
|
|
for device in state.db.list_devices()? {
|
|
if device.enabled && device.connection_type == ConnectionType::Local && !device.simulated {
|
|
targets.push(json!({
|
|
"id": device.id,
|
|
"name": device.name,
|
|
"kind": "device",
|
|
"source": "local_udp",
|
|
}));
|
|
}
|
|
}
|
|
let has_rest = readings.iter().any(|row| row.target_id == "cloud:rest");
|
|
let has_mqtt = readings.iter().any(|row| row.target_id == "cloud:mqtt");
|
|
if (settings.gree_cloud.enabled && settings.gree_cloud.connectivity_metrics_enabled) || has_rest
|
|
{
|
|
targets.push(json!({"id":"cloud:rest","name":"GREE Cloud REST","kind":"cloud_service","source":"cloud_rest"}));
|
|
}
|
|
if (settings.gree_cloud.enabled && settings.gree_cloud.connectivity_metrics_enabled) || has_mqtt
|
|
{
|
|
targets.push(json!({"id":"cloud:mqtt","name":"GREE Cloud MQTT","kind":"cloud_service","source":"cloud_mqtt"}));
|
|
}
|
|
Ok(Json(json!({
|
|
"readings": readings,
|
|
"targets": targets,
|
|
"bucket_seconds": bucket_seconds,
|
|
"storage": storage,
|
|
"storage_warning": warning,
|
|
})))
|
|
}
|