This commit is contained in:
Mateusz Gruszczyński
2026-09-16 16:56:13 +02:00
parent 143ff0d272
commit 1862fed87f
27 changed files with 1368 additions and 178 deletions
+268
View File
@@ -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())?))