This commit is contained in:
Mateusz Gruszczyński
2026-09-14 23:10:17 +02:00
parent 4bb9c8621a
commit 0a2fb8c6c7
47 changed files with 1337 additions and 372 deletions
+70 -1
View File
@@ -1,6 +1,7 @@
struct ConfigurationIds<'a> {
devices: std::collections::HashSet<&'a str>,
zones: std::collections::HashSet<&'a str>,
device_groups: std::collections::HashSet<&'a str>,
schedules: std::collections::HashSet<&'a str>,
automations: std::collections::HashSet<&'a str>,
flows: std::collections::HashSet<&'a str>,
@@ -26,7 +27,7 @@ async fn export_configuration(
fn validate_configuration_header(export: &ConfigurationExport) -> Result<(), AppError> {
if export.format_version != 3 {
return Err(AppError::BadRequest("unsupported configuration export version; version 3 is required by GREE Controller 0.14.0".into()));
return Err(AppError::BadRequest("unsupported configuration export version; version 3 is required by GREE Controller 0.14.1".into()));
}
if export.settings.control_strategy != "setpoint" {
return Err(AppError::BadRequest(
@@ -49,6 +50,7 @@ fn collect_configuration_ids(
let ids = ConfigurationIds {
devices: export.devices.iter().map(|item| item.id.as_str()).collect(),
zones: export.zones.iter().map(|item| item.id.as_str()).collect(),
device_groups: export.device_groups.iter().map(|item| item.id.as_str()).collect(),
schedules: export
.schedules
.iter()
@@ -63,11 +65,13 @@ fn collect_configuration_ids(
};
let duplicate_or_empty = ids.devices.len() != export.devices.len()
|| ids.zones.len() != export.zones.len()
|| ids.device_groups.len() != export.device_groups.len()
|| ids.schedules.len() != export.schedules.len()
|| ids.automations.len() != export.automations.len()
|| ids.flows.len() != export.flows.len()
|| ids.devices.contains("")
|| ids.zones.contains("")
|| ids.device_groups.contains("")
|| ids.schedules.contains("")
|| ids.automations.contains("")
|| ids.flows.contains("");
@@ -154,6 +158,71 @@ fn validate_configuration_devices_and_zones(
));
}
}
let mut installation_devices = std::collections::HashSet::new();
for group in &export.device_groups {
if group.name.trim().is_empty() || group.device_ids.is_empty() {
return Err(AppError::BadRequest(
"import contains an empty split/multisplit installation".into(),
));
}
if group.kind == DeviceGroupKind::Split && group.device_ids.len() != 1 {
return Err(AppError::BadRequest(
"import contains a split installation with more than one unit".into(),
));
}
for device_id in &group.device_ids {
if !ids.devices.contains(device_id.as_str()) {
return Err(AppError::BadRequest(
"import contains an installation referencing a missing device".into(),
));
}
if !installation_devices.insert(device_id.as_str()) {
return Err(AppError::BadRequest(
"import assigns one device to more than one split/multisplit installation".into(),
));
}
}
if group.energy_device_id.as_deref().is_some_and(|id| !group.device_ids.iter().any(|member| member == id)) {
return Err(AppError::BadRequest(
"import contains an installation energy source outside the installation".into(),
));
}
if group.energy_source == EnergySourcePreference::GreeCloud && group.energy_device_id.is_none() {
return Err(AppError::BadRequest(
"import contains a GREE Cloud installation without an energy source device".into(),
));
}
if group.energy_source == EnergySourcePreference::HomeAssistant && group.ha_energy_entity_id.as_deref().map_or(true, |value| value.trim().is_empty()) {
return Err(AppError::BadRequest(
"import contains a Home Assistant installation without an energy entity".into(),
));
}
if let Some(energy_device_id) = group.energy_device_id.as_deref() {
let energy_device = export.devices.iter().find(|device| device.id == energy_device_id).ok_or_else(|| {
AppError::BadRequest("import contains an installation energy source referencing a missing device".into())
})?;
if energy_device.connection_type != ConnectionType::GreeCloud || !energy_device.capabilities.energy_meter {
return Err(AppError::BadRequest(
"import contains an installation energy source without a GREE Cloud energy meter".into(),
));
}
}
if group.ha_energy_entity_id.as_deref().is_some_and(|value| !value.trim().is_empty()) {
if group.ha_energy_device_class.as_deref() != Some("energy")
|| !matches!(group.ha_energy_state_class.as_deref(), Some("total" | "total_increasing"))
|| !matches!(group.ha_energy_unit.as_deref().map(str::to_ascii_lowercase).as_deref(), Some("wh" | "kwh"))
{
return Err(AppError::BadRequest(
"import contains an invalid Home Assistant cumulative energy entity".into(),
));
}
}
if group.outdoor_temperature_device_id.as_deref().is_some_and(|id| !group.device_ids.iter().any(|member| member == id)) {
return Err(AppError::BadRequest(
"import contains an installation outdoor-temperature source outside the installation".into(),
));
}
}
Ok(())
}
+145
View File
@@ -0,0 +1,145 @@
#[derive(Debug, Deserialize)]
struct DeviceGroupInput {
name: String,
#[serde(default)]
kind: DeviceGroupKind,
#[serde(default)]
device_ids: Vec<String>,
#[serde(default)]
energy_source: EnergySourcePreference,
#[serde(default)]
energy_device_id: Option<String>,
#[serde(default)]
ha_energy_entity_id: Option<String>,
#[serde(default)]
ha_energy_unit: Option<String>,
#[serde(default)]
ha_energy_device_class: Option<String>,
#[serde(default)]
ha_energy_state_class: Option<String>,
#[serde(default)]
outdoor_temperature_device_id: Option<String>,
}
fn normalize_optional(value: Option<String>) -> Option<String> {
value.map(|item| item.trim().to_string()).filter(|item| !item.is_empty())
}
fn validate_device_group_input(
state: &AppState,
input: &DeviceGroupInput,
editing_id: Option<&str>,
) -> Result<Vec<String>, AppError> {
if input.name.trim().is_empty() {
return Err(AppError::BadRequest("installation name is required".into()));
}
let mut device_ids = input.device_ids.iter().map(|id| id.trim().to_string()).filter(|id| !id.is_empty()).collect::<Vec<_>>();
device_ids.sort();
device_ids.dedup();
if device_ids.is_empty() {
return Err(AppError::BadRequest("installation must contain at least one device".into()));
}
if input.kind == DeviceGroupKind::Split && device_ids.len() != 1 {
return Err(AppError::BadRequest("split installation must contain exactly one device".into()));
}
for device_id in &device_ids {
if state.db.get_device(device_id)?.is_none() {
return Err(AppError::BadRequest(format!("installation references missing device {device_id}")));
}
}
for existing in state.db.list_device_groups()? {
if editing_id == Some(existing.id.as_str()) { continue; }
if let Some(device_id) = device_ids.iter().find(|id| existing.device_ids.iter().any(|other| other == *id)) {
return Err(AppError::BadRequest(format!("device {device_id} already belongs to installation '{}'", existing.name)));
}
}
let energy_device_id = normalize_optional(input.energy_device_id.clone());
if let Some(ref id) = energy_device_id {
if !device_ids.iter().any(|device_id| device_id == id) {
return Err(AppError::BadRequest("energy source device must belong to this installation".into()));
}
let device = state.db.get_device(id)?.ok_or_else(|| AppError::BadRequest("energy source device does not exist".into()))?;
if device.connection_type != ConnectionType::GreeCloud || !device.capabilities.energy_meter {
return Err(AppError::BadRequest("selected device does not expose GREE Cloud energy".into()));
}
}
if input.energy_source == EnergySourcePreference::GreeCloud && energy_device_id.is_none() {
return Err(AppError::BadRequest("select a GREE Cloud energy source device".into()));
}
let entity = normalize_optional(input.ha_energy_entity_id.clone());
if input.energy_source == EnergySourcePreference::HomeAssistant && entity.is_none() {
return Err(AppError::BadRequest("select a Home Assistant cumulative energy sensor".into()));
}
if entity.is_some() {
if input.ha_energy_device_class.as_deref() != Some("energy") {
return Err(AppError::BadRequest("Home Assistant energy sensor must have device_class=energy".into()));
}
if !matches!(input.ha_energy_state_class.as_deref(), Some("total" | "total_increasing")) {
return Err(AppError::BadRequest("Home Assistant energy sensor must have state_class=total or total_increasing".into()));
}
if !matches!(input.ha_energy_unit.as_deref().map(str::to_ascii_lowercase).as_deref(), Some("wh" | "kwh")) {
return Err(AppError::BadRequest("Home Assistant energy sensor must use Wh or kWh".into()));
}
}
if let Some(id) = normalize_optional(input.outdoor_temperature_device_id.clone()) {
if !device_ids.iter().any(|device_id| device_id == &id) {
return Err(AppError::BadRequest("outdoor temperature source must belong to this installation".into()));
}
}
Ok(device_ids)
}
fn device_group_from_input(id: String, existing: Option<DeviceGroup>, input: DeviceGroupInput, device_ids: Vec<String>) -> DeviceGroup {
let now = Utc::now();
DeviceGroup {
id,
name: input.name.trim().to_string(),
kind: input.kind,
device_ids,
energy_source: input.energy_source,
energy_device_id: normalize_optional(input.energy_device_id),
ha_energy_entity_id: normalize_optional(input.ha_energy_entity_id),
ha_energy_unit: normalize_optional(input.ha_energy_unit),
ha_energy_device_class: normalize_optional(input.ha_energy_device_class),
ha_energy_state_class: normalize_optional(input.ha_energy_state_class),
outdoor_temperature_device_id: normalize_optional(input.outdoor_temperature_device_id),
created_at: existing.as_ref().map(|item| item.created_at).unwrap_or(now),
updated_at: now,
}
}
async fn list_device_groups(State(state): State<AppState>) -> Result<Json<Vec<DeviceGroup>>, AppError> {
Ok(Json(state.db.list_device_groups()?))
}
async fn get_device_group(State(state): State<AppState>, Path(id): Path<String>) -> Result<Json<DeviceGroup>, AppError> {
state.db.get_device_group(&id)?.map(Json).ok_or_else(|| AppError::NotFound(format!("device group {id}")))
}
async fn create_device_group(State(state): State<AppState>, Json(input): Json<DeviceGroupInput>) -> Result<(StatusCode, Json<DeviceGroup>), AppError> {
let _guard = state.lock_configuration_operation().await;
let device_ids = validate_device_group_input(&state, &input, None)?;
let group = device_group_from_input(Uuid::new_v4().to_string(), None, input, device_ids);
state.db.save_device_group(&group)?;
state.broadcast("device_group.created", serde_json::to_value(&group)?);
Ok((StatusCode::CREATED, Json(group)))
}
async fn update_device_group(State(state): State<AppState>, Path(id): Path<String>, Json(input): Json<DeviceGroupInput>) -> Result<Json<DeviceGroup>, AppError> {
let _guard = state.lock_configuration_operation().await;
let existing = state.db.get_device_group(&id)?.ok_or_else(|| AppError::NotFound(format!("device group {id}")))?;
let device_ids = validate_device_group_input(&state, &input, Some(&id))?;
let group = device_group_from_input(id, Some(existing), input, device_ids);
state.db.save_device_group(&group)?;
state.broadcast("device_group.updated", serde_json::to_value(&group)?);
Ok(Json(group))
}
async fn delete_device_group(State(state): State<AppState>, Path(id): Path<String>) -> Result<StatusCode, AppError> {
let _guard = state.lock_configuration_operation().await;
if !state.db.delete_device_group(&id)? {
return Err(AppError::NotFound(format!("device group {id}")));
}
state.broadcast("device_group.deleted", json!({"id": id}));
Ok(StatusCode::NO_CONTENT)
}
+156 -80
View File
@@ -491,11 +491,16 @@ async fn control_plan(State(state): State<AppState>) -> Result<Json<Value>, AppE
#[derive(Debug, Deserialize)]
struct EnergyHistoryQuery {
device_id: String,
/// 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(
@@ -505,10 +510,12 @@ async fn energy_history(
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 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(
@@ -516,8 +523,86 @@ async fn energy_history(
));
}
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")
@@ -537,7 +622,10 @@ async fn energy_history(
.expect("valid midnight"),
Utc,
);
let load_since = since.min(previous_month_start_for_load);
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);
@@ -547,7 +635,7 @@ async fn energy_history(
match influxdb::query_energy(
&state.http,
&influx,
&device.id,
&storage_id,
None,
load_since,
cutoff,
@@ -557,7 +645,7 @@ async fn energy_history(
.await
{
Ok(mut archived) => {
archived.extend(state.db.list_energy_readings(&device.id, cutoff, limit)?);
archived.extend(state.db.list_energy_readings(&storage_id, cutoff, limit)?);
storage = "influx+sqlite".into();
archived
}
@@ -568,38 +656,28 @@ async fn energy_history(
"warn",
"influx.query_error",
"InfluxDB energy history query failed",
json!({"device_id": device.id, "error": err.to_string()}),
json!({"target_id": public_target_id, "storage_id": storage_id, "error": err.to_string()}),
);
state.db.list_energy_readings(&device.id, load_since, limit)?
state.db.list_energy_readings(&storage_id, load_since, limit)?
}
}
} else {
state.db.list_energy_readings(&device.id, load_since, limit)?
state.db.list_energy_readings(&storage_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(),
))
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);
@@ -613,82 +691,80 @@ 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),
}
}
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);
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 buckets = buckets
.into_iter()
.map(|(start, consumption_kwh)| {
json!({"start": start, "consumption_kwh": consumption_kwh.max(0.0)})
})
.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 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 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!({
"device_id": device.id,
"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": device.energy_source,
"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),
+2
View File
@@ -25,6 +25,7 @@ struct BootstrapResponse {
devices: Vec<Device>,
zones: Vec<Zone>,
groups: Vec<ClimateGroup>,
device_groups: Vec<DeviceGroup>,
schedules: Vec<Schedule>,
automations: Vec<Automation>,
flows: Vec<Flow>,
@@ -65,6 +66,7 @@ async fn build_bootstrap(state: &AppState) -> Result<BootstrapResponse, AppError
devices,
zones: state.db.list_zones()?,
groups: state.db.list_groups()?,
device_groups: state.db.list_device_groups()?,
schedules: state.db.list_schedules()?,
automations: state.db.list_automations()?,
flows: state.db.list_flows()?,