v0.12.0-preety_code

This commit is contained in:
Mateusz Gruszczyński
2026-09-03 15:33:37 +02:00
parent be67932b47
commit 07be4b85d9
87 changed files with 11691 additions and 3172 deletions
+262 -68
View File
@@ -1,8 +1,19 @@
#[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> {
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))?;
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})))
}
@@ -29,24 +40,27 @@ fn history_bucket_seconds(hours: i64) -> i64 {
}
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()
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(
@@ -56,23 +70,43 @@ fn zone_history_with_fallback(
bucket_seconds: i64,
limit: u32,
) -> Result<Vec<ZoneReading>, AppError> {
let mut values = state.db.list_zone_history(zone_id, since.clone(), bucket_seconds, limit)?;
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}")))?;
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)?;
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();
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)?;
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));
@@ -90,33 +124,68 @@ fn sensor_history_with_fallback(
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();
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()? {
let Some(entity_id) = zone.ha_entity_id.as_deref().filter(|value| !value.trim().is_empty()) else { continue; };
if existing.contains(entity_id) { continue; }
let rows = state.db.list_zone_history(Some(&zone.id), since.clone(), bucket_seconds, limit)?;
let Some(entity_id) = zone
.ha_entity_id
.as_deref()
.filter(|value| !value.trim().is_empty())
else {
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 });
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()); }
if added {
existing.insert(entity_id.to_string());
}
}
let outdoor_entity = outdoor_entity.trim();
if !outdoor_entity.is_empty() && !existing.contains(outdoor_entity) {
for zone in state.db.list_zones()? {
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 {
values.push(HaReading { id: row.id, entity_id: outdoor_entity.to_string(), zone_id: None, kind: "outdoor".into(), timestamp: row.timestamp, temperature });
values.push(HaReading {
id: row.id,
entity_id: outdoor_entity.to_string(),
zone_id: None,
kind: "outdoor".into(),
timestamp: row.timestamp,
temperature,
});
added = true;
}
}
if added { break; }
if added {
break;
}
}
}
values.sort_by(|left, right| left.timestamp.cmp(&right.timestamp));
@@ -137,23 +206,54 @@ async fn combined_device_history(
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));
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 {
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)?
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.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" };
let source = if warning.is_some() {
"sqlite_fallback"
} else {
"influx+sqlite"
};
Ok((values, source.into(), warning))
}
@@ -167,23 +267,52 @@ async fn combined_zone_history(
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));
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 {
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()}));
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.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" };
let source = if warning.is_some() {
"sqlite_fallback"
} else {
"influx+sqlite"
};
Ok((values, source.into(), warning))
}
@@ -198,18 +327,38 @@ async fn combined_sensor_history(
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 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 {
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()}));
state.log(
"warn",
"influx.query_error",
"InfluxDB HA history query failed",
json!({"error": err.to_string()}),
);
local(since)?
}
};
@@ -218,7 +367,11 @@ async fn combined_sensor_history(
}
values.sort_by_key(|row| row.timestamp);
trim_history(&mut values, limit);
let source = if warning.is_some() { "sqlite_fallback" } else { "influx+sqlite" };
let source = if warning.is_some() {
"sqlite_fallback"
} else {
"influx+sqlite"
};
Ok((values, source.into(), warning))
}
@@ -229,19 +382,32 @@ fn trim_history<T>(values: &mut Vec<T>, limit: u32) {
}
}
async fn history(State(state): State<AppState>, Query(query): Query<HistoryQuery>) -> Result<Json<Value>, AppError> {
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 outdoor_entity = state.settings.read().await.home_assistant.outdoor_entity_id.clone();
let outdoor_entity = state
.settings
.read()
.await
.home_assistant
.outdoor_entity_id
.clone();
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?;
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,
@@ -249,8 +415,19 @@ async fn history(State(state): State<AppState>, Query(query): Query<HistoryQuery
})))
}
"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?;
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,
@@ -258,9 +435,19 @@ async fn history(State(state): State<AppState>, Query(query): Query<HistoryQuery
})))
}
"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 (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()
@@ -274,24 +461,31 @@ async fn history(State(state): State<AppState>, Query(query): Query<HistoryQuery
})))
}
"zones" | "zone" => {
let zone_id = query.zone_id.as_deref().filter(|value| !value.is_empty() && *value != "all");
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?;
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())),
_ => Err(AppError::BadRequest(
"history scope must be overview, zones, devices or sensors".into(),
)),
}
}
async fn control_plan(State(state): State<AppState>) -> Result<Json<Value>, AppError> {
Ok(Json(serde_json::to_value(engine::build_control_plan(&state).await?)?))
Ok(Json(serde_json::to_value(
engine::build_control_plan(&state).await?,
)?))
}