This commit is contained in:
Mateusz Gruszczyński
2026-09-17 08:52:02 +02:00
parent ff4f2b60e7
commit b7846c3b9f
87 changed files with 3783 additions and 1418 deletions
+244 -152
View File
@@ -133,11 +133,7 @@ fn sensor_history_with_fallback(
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("");
let entity_id = zone.ha_entity_id.as_deref().map(str::trim).unwrap_or("");
if entity_id.is_empty() {
continue;
}
@@ -177,9 +173,10 @@ fn sensor_history_with_fallback(
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 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 {
@@ -342,13 +339,7 @@ async fn combined_sensor_history(
.db
.list_ha_history(entity_id, start, bucket_seconds, limit)?)
} else {
sensor_history_with_fallback(
state,
start,
bucket_seconds,
limit,
outdoor_entity,
)
sensor_history_with_fallback(state, start, bucket_seconds, limit, outdoor_entity)
}
};
if !influx.enabled || since >= cutoff {
@@ -526,6 +517,7 @@ struct PublicChartPoint {
struct PublicChartSeries {
key: String,
label: String,
label_key: &'static str,
dashed: bool,
points: Vec<PublicChartPoint>,
}
@@ -537,7 +529,9 @@ fn validate_public_chart_spec(series: &[String]) -> Result<(), AppError> {
.iter()
.any(|item| item.is_empty() || item.len() > 256)
{
return Err(AppError::BadRequest("invalid custom chart definition".into()));
return Err(AppError::BadRequest(
"invalid custom chart definition".into(),
));
}
for key in series {
@@ -545,7 +539,7 @@ fn validate_public_chart_spec(series: &[String]) -> Result<(), AppError> {
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() {
if id.trim().is_empty() || public_chart_field_label_key(kind, field).is_none() {
return Err(AppError::BadRequest(format!(
"unsupported custom chart series: {key}"
)));
@@ -568,22 +562,17 @@ async fn create_public_custom_chart(
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 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)
.filter(|value| !value.is_empty())
.unwrap_or(default_title)
.unwrap_or_default()
.chars()
.take(120)
.collect::<String>();
@@ -591,7 +580,7 @@ async fn create_public_custom_chart(
title,
series: input.series,
hours,
lang: lang.to_string(),
lang,
};
let token = generate_public_chart_token();
@@ -605,20 +594,19 @@ async fn create_public_custom_chart(
})))
}
fn public_chart_field_label(kind: &str, field: &str, lang: &str) -> Option<&'static str> {
let pl = lang == "pl";
fn public_chart_field_label_key(kind: &str, field: &str) -> Option<&'static str> {
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" }),
("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,
}
}
@@ -632,7 +620,10 @@ fn reading_points(rows: Vec<Reading>, field: &str) -> Vec<PublicChartPoint> {
"target" => Some(row.target_temperature),
_ => None,
}?;
value.is_finite().then_some(PublicChartPoint { timestamp: row.timestamp, value })
value.is_finite().then_some(PublicChartPoint {
timestamp: row.timestamp,
value,
})
})
.collect()
}
@@ -649,7 +640,10 @@ fn zone_reading_points(rows: Vec<ZoneReading>, field: &str) -> Vec<PublicChartPo
"outdoor" => row.outdoor_temperature,
_ => None,
}?;
value.is_finite().then_some(PublicChartPoint { timestamp: row.timestamp, value })
value.is_finite().then_some(PublicChartPoint {
timestamp: row.timestamp,
value,
})
})
.collect()
}
@@ -670,7 +664,11 @@ async fn public_custom_chart(
validate_public_chart_spec(&share.series)?;
let hours = query.hours.unwrap_or(share.hours).clamp(1, 24 * 3650);
let lang = if share.lang == "pl" { "pl" } else { "en" };
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;
@@ -683,43 +681,73 @@ async fn public_custom_chart(
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 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)?
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?;
let (rows, _, _) =
combined_device_history(&state, Some(id), since.clone(), bucket_seconds, limit)
.await?;
PublicChartSeries {
key: key.clone(),
label: format!("{} · {}", device.name, field_label),
label: device.name,
label_key,
dashed: field == "target",
points: reading_points(rows, field),
}
}
"installation" => {
let group = state.db.get_device_group(id)?
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))
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?;
.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),
label: group.name,
label_key,
dashed: false,
points: reading_points(rows, "outdoor"),
}
}
"zone" => {
let zone = state.db.get_zone(id)?
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?;
let (rows, _, _) =
combined_zone_history(&state, Some(id), since.clone(), bucket_seconds, limit)
.await?;
PublicChartSeries {
key: key.clone(),
label: format!("{} · {}", zone.name, field_label),
label: zone.name,
label_key,
dashed: matches!(field, "target" | "device_target" | "outdoor"),
points: zone_reading_points(rows, field),
}
@@ -732,36 +760,40 @@ async fn public_custom_chart(
bucket_seconds,
limit,
&outdoor_entity,
).await?;
let alias = ha_settings.sensor_aliases.get(id)
)
.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),
label: format!("HA · {}", alias),
label_key,
dashed: true,
points: rows.into_iter()
points: rows
.into_iter()
.filter(|row| row.entity_id == id && row.temperature.is_finite())
.map(|row| PublicChartPoint { timestamp: row.timestamp, value: row.temperature })
.map(|row| PublicChartPoint {
timestamp: row.timestamp,
value: row.temperature,
})
.collect(),
}
}
_ => return Err(AppError::BadRequest(format!("unsupported custom chart series: {key}"))),
_ => {
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,
@@ -814,7 +846,12 @@ async fn energy_history(
"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())),
_ => {
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");
@@ -824,66 +861,75 @@ async fn energy_history(
));
}
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,
)
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 {
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,
)
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);
@@ -943,11 +989,15 @@ async fn energy_history(
"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)?
state
.db
.list_energy_readings(&storage_id, load_since, limit)?
}
}
} else {
state.db.list_energy_readings(&storage_id, load_since, limit)?
state
.db
.list_energy_readings(&storage_id, load_since, limit)?
};
samples.sort_by_key(|row| row.timestamp);
@@ -976,18 +1026,27 @@ async fn energy_history(
Utc,
)
}
fn bucket_start(timestamp: chrono::DateTime<Utc>, interval_name: &str) -> chrono::DateTime<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"),
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"))
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")),
"monthly" => midnight(
NaiveDate::from_ymd_opt(date.year(), date.month(), 1).expect("valid month"),
),
_ => midnight(date),
}
}
@@ -999,18 +1058,26 @@ async fn energy_history(
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
.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 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 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,
@@ -1026,13 +1093,24 @@ async fn energy_history(
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 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 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()
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 period_total: f64 = period_samples
.iter()
.map(|row| row.consumption_kwh.max(0.0))
.sum();
let latest = samples.last().cloned();
Ok(Json(json!({
@@ -1079,7 +1157,9 @@ async fn combined_network_history(
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)?,
state
.db
.list_network_history(target_id, since, bucket_seconds, limit)?,
"sqlite".into(),
None,
));
@@ -1105,17 +1185,27 @@ async fn combined_network_history(
"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)?
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.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() },
if warning.is_some() {
"sqlite_fallback".into()
} else {
"influx+sqlite".into()
},
warning,
))
}
@@ -1149,10 +1239,12 @@ async fn network_history(
}
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 {
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 {
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!({