v0.14.15
This commit is contained in:
@@ -495,6 +495,274 @@ async fn history(
|
||||
}
|
||||
}
|
||||
|
||||
#[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, serde::Serialize)]
|
||||
struct PublicChartPoint {
|
||||
timestamp: chrono::DateTime<Utc>,
|
||||
value: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
struct PublicChartSeries {
|
||||
key: String,
|
||||
label: String,
|
||||
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(kind, field, "en").is_none() {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"unsupported custom chart series: {key}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn generate_public_chart_token() -> String {
|
||||
let mut bytes = [0u8; 32];
|
||||
let mut rng = OsRng;
|
||||
rng.fill_bytes(&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 lang = if input.lang.as_deref() == Some("pl") {
|
||||
"pl"
|
||||
} else {
|
||||
"en"
|
||||
};
|
||||
let default_title = if lang == "pl" {
|
||||
"Wykres niestandardowy"
|
||||
} else {
|
||||
"Custom chart"
|
||||
};
|
||||
let title = input
|
||||
.title
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or(default_title)
|
||||
.chars()
|
||||
.take(120)
|
||||
.collect::<String>();
|
||||
let share = PublicCustomChartShare {
|
||||
title,
|
||||
series: input.series,
|
||||
hours,
|
||||
lang: lang.to_string(),
|
||||
};
|
||||
|
||||
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(kind: &str, field: &str, lang: &str) -> Option<&'static str> {
|
||||
let pl = lang == "pl";
|
||||
match (kind, field) {
|
||||
("device", "indoor") => Some(if pl { "Temperatura wewnętrzna" } else { "Indoor temperature" }),
|
||||
("device", "outdoor") => Some(if pl { "Temperatura zewnętrzna GREE" } else { "GREE outdoor temperature" }),
|
||||
("device", "target") => Some(if pl { "Temperatura zadana urządzenia" } else { "Device target" }),
|
||||
("installation", "outdoor") => Some(if pl { "Wspólna temperatura zewnętrzna" } else { "Shared outdoor temperature" }),
|
||||
("zone", "control") => Some(if pl { "Temperatura sterująca" } else { "Control temperature" }),
|
||||
("zone", "gree") => Some(if pl { "Czujnik GREE" } else { "GREE sensor" }),
|
||||
("zone", "external") => Some(if pl { "Czujnik pomieszczenia" } else { "Room sensor" }),
|
||||
("zone", "target") => Some(if pl { "Temperatura docelowa" } else { "Comfort target" }),
|
||||
("zone", "device_target") => Some(if pl { "Nastawa urządzenia" } else { "Device setpoint" }),
|
||||
("zone", "outdoor") => Some(if pl { "Temperatura zewnętrzna" } else { "Outdoor temperature" }),
|
||||
("ha", "temperature") => Some(if pl { "Temperatura" } else { "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>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
if token.len() < 32 || token.len() > 128 || !token.starts_with("chart_") {
|
||||
return Err(AppError::NotFound("custom chart".into()));
|
||||
}
|
||||
let payload = state
|
||||
.db
|
||||
.get_public_chart_share(&hash_token(&token))?
|
||||
.ok_or_else(|| AppError::NotFound("custom chart".into()))?;
|
||||
let share: PublicCustomChartShare = serde_json::from_value(payload)?;
|
||||
validate_public_chart_spec(&share.series)?;
|
||||
|
||||
let hours = share.hours.clamp(1, 24 * 3650);
|
||||
let lang = if share.lang == "pl" { "pl" } else { "en" };
|
||||
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 field_label = public_chart_field_label(kind, field, lang)
|
||||
.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: format!("{} · {}", device.name, field_label),
|
||||
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: format!("{} · {}", group.name, field_label),
|
||||
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: format!("{} · {}", zone.name, field_label),
|
||||
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, field_label),
|
||||
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);
|
||||
}
|
||||
|
||||
let (hint, no_data_label) = if lang == "pl" {
|
||||
(format!("Ostatnie {hours} h"), "Brak danych")
|
||||
} else {
|
||||
(format!("Last {hours} h"), "No data")
|
||||
};
|
||||
|
||||
Ok(Json(json!({
|
||||
"title": share.title,
|
||||
"hint": hint,
|
||||
"no_data_label": no_data_label,
|
||||
"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())?))
|
||||
|
||||
Reference in New Issue
Block a user