This commit is contained in:
Mateusz Gruszczyński
2026-08-24 00:23:47 +02:00
parent 6e3075fae5
commit 10bc9aa099
21 changed files with 1110 additions and 260 deletions
+178 -18
View File
@@ -21,7 +21,7 @@ use crate::{
engine,
error::AppError,
home_assistant,
models::{ApiTokenInfo, Automation, Device, DeviceCommand, DevicePatch, DiscoveryRequest, ManualDeviceRequest, RuntimeSettings, Schedule, Zone, ZoneControlPatch},
models::{ApiTokenInfo, Automation, Device, DeviceCommand, DevicePatch, DiscoveryRequest, ManualDeviceRequest, HaReading, Reading, RuntimeSettings, Schedule, Zone, ZoneControlPatch, ZoneReading},
protocol::merge_discovered,
state::AppState,
};
@@ -259,6 +259,7 @@ async fn add_device(State(state): State<AppState>, Json(input): Json<ManualDevic
light: true,
current_temperature: if input.simulated { Some(25.0) } else { None },
outdoor_temperature: None,
temperature_sensor_offset: None,
online: input.simulated,
last_seen: if input.simulated { Some(now) } else { None },
last_error: None,
@@ -413,7 +414,7 @@ impl ZoneInput {
sensor_source: self.sensor_source, ha_entity_id: self.ha_entity_id.filter(|v| !v.trim().is_empty()),
external_sensor_weight: self.external_sensor_weight, max_sensor_difference: self.max_sensor_difference,
device_temperature: None, external_temperature: None, current_temperature: None, control_temperature_source: "device".into(),
active_preset: "comfort".into(), manual_preset: None, manual_override_until: None,
active_preset: "comfort".into(), manual_preset: None, manual_setpoint: None, manual_override_until: None,
effective_mode: String::new(), effective_setpoint: None, device_setpoint: None,
demand: false, last_action_at: None,
created_at, updated_at: Utc::now(),
@@ -444,6 +445,7 @@ async fn update_zone(State(state): State<AppState>, Path(id): Path<String>, Json
zone.control_temperature_source = existing.control_temperature_source;
zone.active_preset = existing.active_preset;
zone.manual_preset = existing.manual_preset;
zone.manual_setpoint = existing.manual_setpoint;
zone.manual_override_until = existing.manual_override_until;
zone.effective_mode = existing.effective_mode;
zone.effective_setpoint = existing.effective_setpoint;
@@ -460,8 +462,10 @@ async fn update_zone_control(State(state): State<AppState>, Path(id): Path<Strin
if let Some(value) = patch.setpoint {
if !(8.0..=30.0).contains(&value) { return Err(AppError::BadRequest("zone setpoint must be between 8 and 30 C".into())); }
zone.setpoint = (value * 2.0).round() / 2.0;
zone.manual_preset = Some("custom".into());
let value = (value * 2.0).round() / 2.0;
zone.setpoint = value;
zone.manual_setpoint = Some(value);
zone.effective_setpoint = Some(value);
zone.manual_override_until = Some(engine::next_schedule_boundary_utc(&zone.id, &schedules, chrono::Local::now()));
}
if let Some(value) = patch.mode.as_deref() {
@@ -478,10 +482,12 @@ async fn update_zone_control(State(state): State<AppState>, Path(id): Path<Strin
match value {
"auto" => {
zone.manual_preset = None;
zone.manual_setpoint = None;
zone.manual_override_until = None;
}
"comfort" | "sleep" | "away" | "custom" => {
zone.manual_preset = Some(value.to_string());
zone.manual_setpoint = None;
zone.manual_override_until = Some(engine::next_schedule_boundary_utc(&zone.id, &schedules, chrono::Local::now()));
}
_ => return Err(AppError::BadRequest("unsupported zone preset".into())),
@@ -489,6 +495,7 @@ async fn update_zone_control(State(state): State<AppState>, Path(id): Path<Strin
}
if patch.clear_override.unwrap_or(false) {
zone.manual_preset = None;
zone.manual_setpoint = None;
zone.manual_override_until = None;
}
if let Some(value) = patch.enabled { zone.enabled = value; }
@@ -496,7 +503,7 @@ async fn update_zone_control(State(state): State<AppState>, Path(id): Path<Strin
state.db.save_zone(&zone)?;
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
state.log("info", "zone.quick_control", &format!("Quick control updated for {}", zone.name), json!({
"zone_id": zone.id, "setpoint": zone.setpoint, "mode": zone.mode,
"zone_id": zone.id, "setpoint": zone.setpoint, "manual_setpoint": zone.manual_setpoint, "mode": zone.mode,
"inherit_house_mode": zone.inherit_house_mode, "preset": zone.manual_preset,
"override_until": zone.manual_override_until, "enabled": zone.enabled
}));
@@ -531,9 +538,11 @@ async fn update_house_preset(State(state): State<AppState>, Json(input): Json<Ho
for zone in &mut zones {
if input.preset == "auto" {
zone.manual_preset = None;
zone.manual_setpoint = None;
zone.manual_override_until = None;
} else {
zone.manual_preset = Some(input.preset.clone());
zone.manual_setpoint = None;
zone.manual_override_until = Some(engine::next_schedule_boundary_utc(&zone.id, &schedules, chrono::Local::now()));
}
zone.updated_at = Utc::now();
@@ -732,23 +741,174 @@ async fn readings(State(state): State<AppState>, Query(query): Query<ReadingsQue
}
#[derive(Debug, Deserialize)]
struct HistoryQuery { zone_id: Option<String>, hours: Option<i64>, limit: Option<u32> }
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 * 31);
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 bucket_seconds = match hours {
struct HistoryQuery {
scope: Option<String>,
zone_id: Option<String>,
device_id: Option<String>,
entity_id: Option<String>,
hours: Option<i64>,
limit: Option<u32>,
}
fn history_bucket_seconds(hours: i64) -> i64 {
match hours {
1..=6 => 30,
7..=24 => 120,
25..=168 => 600,
_ => 1800,
};
let values = state.db.list_zone_history(zone_id, Utc::now() - ChronoDuration::hours(hours), bucket_seconds, query.limit.unwrap_or(12_000))?;
Ok(Json(json!({"readings": values, "bucket_seconds": bucket_seconds})))
}
}
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()
}
fn zone_history_with_fallback(
state: &AppState,
zone_id: Option<&str>,
since: chrono::DateTime<Utc>,
bucket_seconds: i64,
limit: u32,
) -> Result<Vec<ZoneReading>, AppError> {
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}")))?;
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)?;
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();
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)?;
values.extend(fallback_zone_rows(&zone, &device, rows));
}
values.sort_by(|left, right| left.timestamp.cmp(&right.timestamp));
if values.len() > limit as usize {
let keep_from = values.len() - limit as usize;
values.drain(0..keep_from);
}
Ok(values)
}
fn sensor_history_with_fallback(
state: &AppState,
since: chrono::DateTime<Utc>,
bucket_seconds: i64,
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();
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 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 });
added = true;
}
}
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 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 });
added = true;
}
}
if added { break; }
}
}
values.sort_by(|left, right| left.timestamp.cmp(&right.timestamp));
if values.len() > limit as usize {
let keep_from = values.len() - limit as usize;
values.drain(0..keep_from);
}
Ok(values)
}
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 * 31);
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 (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 = state.db.list_device_history(device_id, since.clone(), bucket_seconds, limit)?;
Ok(Json(json!({
"scope": "devices", "readings": readings, "bucket_seconds": bucket_seconds,
"counts": {"devices": device_count, "zones": zone_count, "ha": ha_count}
})))
}
"sensors" => {
let entity_id = query.entity_id.as_deref().filter(|value| !value.is_empty() && *value != "all");
let readings = if entity_id.is_some() { state.db.list_ha_history(entity_id, since.clone(), bucket_seconds, limit)? } else { sensor_history_with_fallback(&state, since.clone(), bucket_seconds, limit, &outdoor_entity)? };
Ok(Json(json!({
"scope": "sensors", "readings": readings, "bucket_seconds": bucket_seconds,
"counts": {"devices": device_count, "zones": zone_count, "ha": ha_count}
})))
}
"overview" => {
let zones = zone_history_with_fallback(&state, None, since.clone(), bucket_seconds, limit)?;
let devices = state.db.list_device_history(None, since.clone(), bucket_seconds, limit)?;
let sensors = sensor_history_with_fallback(&state, since.clone(), bucket_seconds, limit, &outdoor_entity)?;
Ok(Json(json!({
"scope": "overview", "bucket_seconds": bucket_seconds,
"zones": zones, "devices": devices, "sensors": sensors,
"counts": {"devices": device_count, "zones": zone_count, "ha": ha_count}
})))
}
"zones" | "zone" => {
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 = zone_history_with_fallback(&state, zone_id, since.clone(), bucket_seconds, limit)?;
Ok(Json(json!({
"scope": "zones", "readings": readings, "bucket_seconds": bucket_seconds,
"counts": {"devices": device_count, "zones": zone_count, "ha": ha_count}
})))
}
_ => Err(AppError::BadRequest("history scope must be overview, zones, devices or sensors".into())),
}
}
#[derive(Debug, Deserialize)]
+89 -3
View File
@@ -5,7 +5,7 @@ use rusqlite::{params, Connection, OptionalExtension};
use serde::{de::DeserializeOwned, Serialize};
use serde_json::Value;
use crate::{
models::{ApiTokenInfo, Automation, Device, EventLog, Reading, RuntimeSettings, Schedule, Zone, ZoneReading},
models::{ApiTokenInfo, Automation, Device, EventLog, HaReading, Reading, RuntimeSettings, Schedule, Zone, ZoneReading},
queries,
};
@@ -219,6 +219,23 @@ impl Db {
Ok(rows_out)
}
pub fn list_device_history(&self, device_id: Option<&str>, since: DateTime<Utc>, bucket_seconds: i64, limit: u32) -> Result<Vec<Reading>> {
let conn = self.lock()?;
let bucket_seconds = bucket_seconds.max(1);
let limit = limit.clamp(1, 20_000) as i64;
let mut rows_out = Vec::new();
if let Some(device_id) = device_id {
let mut stmt = conn.prepare(queries::LIST_DEVICE_HISTORY_BY_DEVICE_BUCKETED)?;
let rows = stmt.query_map(params![device_id, since.to_rfc3339(), bucket_seconds, limit], Self::map_reading)?;
for row in rows { rows_out.push(row?); }
} else {
let mut stmt = conn.prepare(queries::LIST_DEVICE_HISTORY_ALL_BUCKETED)?;
let rows = stmt.query_map(params![since.to_rfc3339(), bucket_seconds, limit], Self::map_reading)?;
for row in rows { rows_out.push(row?); }
}
Ok(rows_out)
}
fn map_reading(row: &rusqlite::Row<'_>) -> rusqlite::Result<Reading> {
let timestamp: String = row.get(2)?;
Ok(Reading {
@@ -240,7 +257,8 @@ impl Db {
let conn = self.lock()?;
let device = conn.execute(queries::PRUNE_READINGS, [before.to_rfc3339()])? as u64;
let zone = conn.execute(queries::PRUNE_ZONE_READINGS, [before.to_rfc3339()])? as u64;
Ok(device + zone)
let ha = conn.execute(queries::PRUNE_HA_READINGS, [before.to_rfc3339()])? as u64;
Ok(device + zone + ha)
}
pub fn add_zone_reading_if_due(&self, reading: &ZoneReading, min_interval_seconds: i64) -> Result<bool> {
@@ -311,6 +329,60 @@ impl Db {
})
}
pub fn add_ha_reading_if_due(&self, reading: &HaReading, min_interval_seconds: i64) -> Result<bool> {
let cutoff = reading.timestamp.clone() - Duration::seconds(min_interval_seconds.max(1));
let conn = self.lock()?;
let changed = conn.execute(
queries::INSERT_HA_READING_IF_DUE,
params![
reading.entity_id,
reading.zone_id,
reading.kind,
reading.timestamp.to_rfc3339(),
reading.temperature,
cutoff.to_rfc3339(),
],
)?;
Ok(changed > 0)
}
pub fn list_ha_history(&self, entity_id: Option<&str>, since: DateTime<Utc>, bucket_seconds: i64, limit: u32) -> Result<Vec<HaReading>> {
let conn = self.lock()?;
let bucket_seconds = bucket_seconds.max(1);
let limit = limit.clamp(1, 20_000) as i64;
let mut rows_out = Vec::new();
if let Some(entity_id) = entity_id {
let mut stmt = conn.prepare(queries::LIST_HA_HISTORY_BY_ENTITY_BUCKETED)?;
let rows = stmt.query_map(params![entity_id, since.to_rfc3339(), bucket_seconds, limit], Self::map_ha_reading)?;
for row in rows { rows_out.push(row?); }
} else {
let mut stmt = conn.prepare(queries::LIST_HA_HISTORY_ALL_BUCKETED)?;
let rows = stmt.query_map(params![since.to_rfc3339(), bucket_seconds, limit], Self::map_ha_reading)?;
for row in rows { rows_out.push(row?); }
}
Ok(rows_out)
}
fn map_ha_reading(row: &rusqlite::Row<'_>) -> rusqlite::Result<HaReading> {
let timestamp: String = row.get(4)?;
Ok(HaReading {
id: row.get(0)?,
entity_id: row.get(1)?,
zone_id: row.get(2)?,
kind: row.get(3)?,
timestamp: DateTime::parse_from_rfc3339(&timestamp)
.map(|value| value.with_timezone(&Utc))
.unwrap_or_else(|_| Utc::now()),
temperature: row.get(5)?,
})
}
pub fn history_counts(&self) -> Result<(u64, u64, u64)> {
let conn = self.lock()?;
let (device, zone, ha): (i64, i64, i64) = conn.query_row(queries::HISTORY_COUNTS, [], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))?;
Ok((device.max(0) as u64, zone.max(0) as u64, ha.max(0) as u64))
}
pub fn log_event(&self, level: &str, kind: &str, message: &str, metadata: &Value) -> Result<i64> {
let conn = self.lock()?;
conn.execute(
@@ -399,7 +471,7 @@ impl Db {
#[cfg(test)]
mod tests {
use super::*;
use crate::models::{ApiTokenInfo, Device};
use crate::models::{ApiTokenInfo, Device, HaReading, Reading};
#[test]
fn sqlite_round_trip() {
@@ -424,5 +496,19 @@ mod tests {
assert_eq!(db.list_api_tokens().unwrap().len(), 1);
assert!(db.delete_api_token(&access_token.id).unwrap());
assert!(!db.api_token_exists("test-hash").unwrap());
let now = Utc::now();
db.add_reading(&Reading {
id: 0, device_id: device.id.clone(), timestamp: now.clone(), indoor_temperature: Some(22.5),
outdoor_temperature: Some(31.0), target_temperature: 23.0, power: true, source: "gree".into(),
}).unwrap();
assert_eq!(db.list_device_history(Some(&device.id), now.clone() - Duration::minutes(1), 30, 100).unwrap().len(), 1);
db.add_ha_reading_if_due(&HaReading {
id: 0, entity_id: "sensor.room".into(), zone_id: Some("zone-room".into()), kind: "room".into(),
timestamp: now.clone(), temperature: 22.1,
}, 15).unwrap();
assert_eq!(db.list_ha_history(Some("sensor.room"), now.clone() - Duration::minutes(1), 30, 100).unwrap().len(), 1);
assert_eq!(db.history_counts().unwrap(), (1, 0, 1));
}
}
+86 -28
View File
@@ -6,7 +6,7 @@ use tokio::time::sleep;
use crate::{
error::AppError,
home_assistant,
models::{Automation, Device, DeviceCommand, Reading, Schedule, Zone, ZoneReading},
models::{Automation, Device, DeviceCommand, HaReading, Reading, Schedule, Zone, ZoneReading},
state::AppState,
};
@@ -251,13 +251,23 @@ async fn control_zones(state: &AppState) -> Result<()> {
// Outdoor temperature is deliberately optional. It never replaces the room sensor;
// it only makes the active setpoint/fan a little more assertive in extreme weather.
let outdoor_temperature = if settings.outdoor_assist_enabled && !settings.home_assistant.outdoor_entity_id.trim().is_empty() {
let outdoor_temperature = if !settings.home_assistant.outdoor_entity_id.trim().is_empty() {
match home_assistant::read_temperature(
&state.http,
&settings.home_assistant,
Some(settings.home_assistant.outdoor_entity_id.trim()),
).await {
Ok(value) => Some(value),
Ok(value) => {
record_ha_history(
state,
settings.home_assistant.outdoor_entity_id.trim(),
None,
"outdoor",
value,
settings.poll_interval_seconds,
);
Some(value)
}
Err(err) => {
tracing::debug!(error=?err, "outdoor Home Assistant sensor unavailable");
None
@@ -273,12 +283,14 @@ async fn control_zones(state: &AppState) -> Result<()> {
state.broadcast("outdoor.updated", json!({"temperature": outdoor_temperature}));
}
}
let outdoor_assist_temperature = if settings.outdoor_assist_enabled { outdoor_temperature } else { None };
for mut zone in state.db.list_zones()? {
if !zone.enabled { continue; }
if zone.manual_override_until.map(|until| until <= Utc::now()).unwrap_or(false) {
zone.manual_preset = None;
zone.manual_setpoint = None;
zone.manual_override_until = None;
}
@@ -300,7 +312,12 @@ async fn control_zones(state: &AppState) -> Result<()> {
let device_temperature = device.current_temperature;
let external_temperature = if matches!(zone.sensor_source.as_str(), "home_assistant" | "combined") {
match home_assistant::read_temperature(&state.http, &settings.home_assistant, zone.ha_entity_id.as_deref()).await {
Ok(value) => Some(value),
Ok(value) => {
if let Some(entity_id) = zone.ha_entity_id.as_deref().filter(|value| !value.trim().is_empty()) {
record_ha_history(state, entity_id, Some(&zone.id), "room", value, settings.poll_interval_seconds);
}
Some(value)
}
Err(err) => {
if !matches!(previous_source.as_str(), "device_fallback" | "device_discrepancy_fallback") {
state.log("warn", "ha.sensor_error", &err.to_string(), json!({"zone_id": zone.id, "entity_id": zone.ha_entity_id.as_deref()}));
@@ -341,7 +358,7 @@ async fn control_zones(state: &AppState) -> Result<()> {
Err(err) => state.log("error", "zone.action_error", &err.to_string(), json!({"zone_id": zone.id})),
}
}
record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds)?;
record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds);
state.db.save_zone(&zone)?;
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
continue;
@@ -353,7 +370,7 @@ async fn control_zones(state: &AppState) -> Result<()> {
zone.effective_setpoint = Some(target);
let Some(temp) = temperature else {
record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds)?;
record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds);
state.db.save_zone(&zone)?;
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
continue;
@@ -375,7 +392,7 @@ async fn control_zones(state: &AppState) -> Result<()> {
// Setpoint modulation: keep the indoor unit powered and let its own inverter/compressor
// stop naturally when we move the target to the satisfied side of room temperature.
let assist = outdoor_assist_offset(effective_mode, outdoor_temperature, temp, target);
let assist = outdoor_assist_offset(effective_mode, outdoor_assist_temperature, temp, target);
let active_target = match effective_mode {
"heat" => target + assist,
_ => target - assist,
@@ -388,7 +405,7 @@ async fn control_zones(state: &AppState) -> Result<()> {
zone.device_setpoint = Some(desired_device_target);
let desired_fan = if zone.smart_fan {
Some(smart_fan_speed(effective_mode, temp, target, outdoor_temperature, zone.demand))
Some(smart_fan_speed(effective_mode, temp, target, outdoor_assist_temperature, zone.demand))
} else {
None
};
@@ -424,26 +441,33 @@ async fn control_zones(state: &AppState) -> Result<()> {
}
}
record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds)?;
record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds);
state.db.save_zone(&zone)?;
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
}
Ok(())
}
fn record_zone_history(state: &AppState, zone: &Zone, outdoor_temperature: Option<f64>, poll_interval_seconds: u64) -> Result<()> {
let Some(device) = state.db.get_device(&zone.device_id)? else { return Ok(()); };
fn record_zone_history(state: &AppState, zone: &Zone, outdoor_temperature: Option<f64>, poll_interval_seconds: u64) {
let device = match state.db.get_device(&zone.device_id) {
Ok(Some(device)) => device,
Ok(None) => return,
Err(err) => {
tracing::warn!(error=?err, zone_id=%zone.id, "cannot load device for zone history");
return;
}
};
let reading = ZoneReading {
id: 0,
zone_id: zone.id.clone(),
device_id: zone.device_id.clone(),
timestamp: Utc::now(),
gree_temperature: zone.device_temperature,
gree_temperature: zone.device_temperature.or(device.current_temperature),
external_temperature: zone.external_temperature,
control_temperature: zone.current_temperature,
target_temperature: zone.effective_setpoint,
control_temperature: zone.current_temperature.or(zone.device_temperature).or(device.current_temperature),
target_temperature: zone.effective_setpoint.or(zone.manual_setpoint).or(Some(zone.setpoint)),
device_setpoint: zone.device_setpoint.or(Some(device.target_temperature)),
outdoor_temperature,
outdoor_temperature: outdoor_temperature.or(device.outdoor_temperature),
power: device.power,
mode: if zone.effective_mode.is_empty() { device.mode.clone() } else { zone.effective_mode.clone() },
fan_speed: device.fan_speed,
@@ -451,10 +475,32 @@ fn record_zone_history(state: &AppState, zone: &Zone, outdoor_temperature: Optio
control_source: zone.control_temperature_source.clone(),
active_preset: zone.active_preset.clone(),
};
// History is deliberately less frequent than the zone control loop to keep SQLite compact.
let interval = poll_interval_seconds.max(15) as i64;
state.db.add_zone_reading_if_due(&reading, interval)?;
Ok(())
if let Err(err) = state.db.add_zone_reading_if_due(&reading, interval) {
tracing::warn!(error=?err, zone_id=%zone.id, "cannot save zone history sample");
}
}
fn record_ha_history(
state: &AppState,
entity_id: &str,
zone_id: Option<&str>,
kind: &str,
temperature: f64,
poll_interval_seconds: u64,
) {
let reading = HaReading {
id: 0,
entity_id: entity_id.to_string(),
zone_id: zone_id.map(str::to_string),
kind: kind.to_string(),
timestamp: Utc::now(),
temperature,
};
let interval = poll_interval_seconds.max(15) as i64;
if let Err(err) = state.db.add_ha_reading_if_due(&reading, interval) {
tracing::warn!(error=?err, entity_id=%entity_id, "cannot save Home Assistant history sample");
}
}
fn select_zone_temperature(zone: &Zone, device_temperature: Option<f64>, external_temperature: Option<f64>) -> (Option<f64>, String, bool) {
@@ -534,21 +580,24 @@ fn profile_setpoint(zone: &Zone, preset: &str, mode: &str) -> f64 {
}
fn resolve_zone_target(zone: &Zone, schedule: Option<&Schedule>, mode: &str) -> (String, f64) {
if let Some(manual) = zone.manual_preset.as_deref() {
return if manual == "custom" {
let (preset, base_target) = if let Some(manual) = zone.manual_preset.as_deref() {
if manual == "custom" {
("custom".into(), zone.setpoint)
} else {
(manual.to_string(), profile_setpoint(zone, manual, mode))
};
}
if let Some(item) = schedule {
return if item.preset == "custom" {
}
} else if let Some(item) = schedule {
if item.preset == "custom" {
("custom".into(), item.setpoint)
} else {
(item.preset.clone(), profile_setpoint(zone, &item.preset, mode))
};
}
("comfort".into(), profile_setpoint(zone, "comfort", mode))
}
} else {
("comfort".into(), profile_setpoint(zone, "comfort", mode))
};
// Quick +/- temperature adjustments are independent from the selected preset.
// The UI can therefore stay in Auto/Sleep/Comfort while temporarily nudging the target.
(preset, zone.manual_setpoint.unwrap_or(base_target))
}
fn active_schedule_for_zone<'a>(zone: &Zone, schedules: &'a [Schedule], now: DateTime<Local>) -> Option<&'a Schedule> {
@@ -667,7 +716,7 @@ mod tests {
sensor_source: source.into(), ha_entity_id: Some("sensor.room_temperature".into()),
external_sensor_weight: 0.4, max_sensor_difference: 3.0, device_temperature: None, external_temperature: None,
current_temperature: None, control_temperature_source: "device".into(), active_preset: "comfort".into(),
manual_preset: None, manual_override_until: None, effective_mode: "heat".into(), effective_setpoint: Some(21.0), device_setpoint: None,
manual_preset: None, manual_setpoint: None, manual_override_until: None, effective_mode: "heat".into(), effective_setpoint: Some(21.0), device_setpoint: None,
demand: false, last_action_at: None, created_at: Utc::now(), updated_at: Utc::now(),
}
}
@@ -708,6 +757,15 @@ mod tests {
assert_eq!(profile_setpoint(&zone, "sleep", "heat"), 19.0);
}
#[test]
fn quick_setpoint_keeps_active_preset() {
let mut zone = test_zone("device");
zone.manual_setpoint = Some(22.5);
let (preset, target) = resolve_zone_target(&zone, None, "cool");
assert_eq!(preset, "comfort");
assert_eq!(target, 22.5);
}
#[test]
fn legacy_zone_keeps_old_comfort_setpoint() {
let mut zone = test_zone("device");
+18 -1
View File
@@ -71,6 +71,9 @@ pub struct Device {
pub current_temperature: Option<f64>,
#[serde(default)]
pub outdoor_temperature: Option<f64>,
/// Some GREE firmware reports TemSen/OutEnvTem with a +40 C wire offset.
#[serde(default)]
pub temperature_sensor_offset: Option<bool>,
#[serde(default)]
pub online: bool,
#[serde(default)]
@@ -110,6 +113,7 @@ impl Device {
light: true,
current_temperature: Some(26.0),
outdoor_temperature: Some(30.0),
temperature_sensor_offset: Some(false),
online: true,
last_seen: Some(now),
last_error: None,
@@ -228,9 +232,12 @@ pub struct Zone {
/// Effective profile currently used by the zone: comfort/sleep/away/custom.
#[serde(default = "default_active_preset")]
pub active_preset: String,
/// Optional user override. Cleared automatically at the next schedule boundary.
/// Optional user preset override. Cleared automatically at the next schedule boundary.
#[serde(default)]
pub manual_preset: Option<String>,
/// Optional quick-thermostat setpoint override. It does not change the active preset.
#[serde(default)]
pub manual_setpoint: Option<f64>,
#[serde(default)]
pub manual_override_until: Option<DateTime<Utc>>,
#[serde(default)]
@@ -343,6 +350,16 @@ pub struct ZoneReading {
pub active_preset: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HaReading {
pub id: i64,
pub entity_id: String,
pub zone_id: Option<String>,
pub kind: String,
pub timestamp: DateTime<Utc>,
pub temperature: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EventLog {
pub id: i64,
+26 -5
View File
@@ -217,6 +217,7 @@ impl GreeClient {
light: true,
current_temperature: None,
outdoor_temperature: None,
temperature_sensor_offset: None,
online: true,
last_seen: Some(now),
last_error: None,
@@ -302,14 +303,23 @@ impl GreeClient {
"TemRec","SvSt","TemSen","CoolSvTem","HeatSvTem","OutEnvTem"
];
let core_cols = ["Pow","Mod","SetTem","TemRec","TemUn","TemSen","WdSpd","Lig","SwingLfRig","SwUpDn","Quiet","Tur"];
let response = match self.status_request(device, key, &full_cols).await {
Ok(value) => value,
let (response, used_core_fallback) = match self.status_request(device, key, &full_cols).await {
Ok(value) => (value, false),
Err(first) => {
tracing::debug!(device=%device.id, error=?first, "Full GREE status request failed; retrying core properties");
self.status_request(device, key, &core_cols).await?
(self.status_request(device, key, &core_cols).await?, true)
}
};
self.apply_status(device, &response)?;
// Some firmware rejects a large mixed property list but still exposes OutEnvTem.
// Probe it separately after the core fallback so compatible units can contribute
// their outdoor sensor to history without making the main poll fail.
if used_core_fallback {
match self.status_request(device, key, &["OutEnvTem"]).await {
Ok(optional) => { let _ = self.apply_status(device, &optional); }
Err(err) => tracing::trace!(device=%device.id, error=?err, "GREE outdoor temperature is not available"),
}
}
device.online = true;
device.communication_failures = 0;
device.last_seen = Some(Utc::now());
@@ -343,11 +353,22 @@ impl GreeClient {
"Lig" => device.light = value_as_i64(value) != 0,
"TemSen" => {
let raw = value_as_f64(value);
device.current_temperature = Some(if raw > 40.0 { raw - 40.0 } else { raw });
// The room sensor is a useful discriminator because normal indoor
// temperatures cannot exceed 40 C in controller operation. Persist
// the detected wire format and reuse it for OutEnvTem, including
// sub-zero outdoor values encoded as (temperature + 40).
if raw != 0.0 {
let offset = raw > 40.0;
device.temperature_sensor_offset = Some(offset);
device.current_temperature = Some(if offset { raw - 40.0 } else { raw });
}
}
"OutEnvTem" => {
let raw = value_as_f64(value);
device.outdoor_temperature = Some(if raw > 40.0 { raw - 40.0 } else { raw });
if raw != 0.0 {
let offset = device.temperature_sensor_offset.unwrap_or(raw > 50.0);
device.outdoor_temperature = Some(if offset { raw - 40.0 } else { raw });
}
}
_ => {}
}
+72
View File
@@ -86,6 +86,19 @@ CREATE INDEX IF NOT EXISTS zone_readings_zone_time_idx
CREATE INDEX IF NOT EXISTS zone_readings_time_idx
ON zone_readings(timestamp DESC);
CREATE TABLE IF NOT EXISTS ha_readings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
entity_id TEXT NOT NULL,
zone_id TEXT,
kind TEXT NOT NULL,
timestamp TEXT NOT NULL,
temperature REAL NOT NULL
);
CREATE INDEX IF NOT EXISTS ha_readings_entity_time_idx
ON ha_readings(entity_id, timestamp DESC);
CREATE INDEX IF NOT EXISTS ha_readings_zone_time_idx
ON ha_readings(zone_id, timestamp DESC);
CREATE TABLE IF NOT EXISTS event_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp TEXT NOT NULL,
@@ -110,6 +123,8 @@ INSERT OR IGNORE INTO schema_migrations(version, applied_at)
VALUES (2, strftime('%Y-%m-%dT%H:%M:%fZ','now'));
INSERT OR IGNORE INTO schema_migrations(version, applied_at)
VALUES (3, strftime('%Y-%m-%dT%H:%M:%fZ','now'));
INSERT OR IGNORE INTO schema_migrations(version, applied_at)
VALUES (4, strftime('%Y-%m-%dT%H:%M:%fZ','now'));
"#;
pub const COUNT_DEVICES: &str = "SELECT COUNT(*) FROM devices";
@@ -198,6 +213,26 @@ ORDER BY timestamp ASC
LIMIT ?2
"#;
pub const LIST_DEVICE_HISTORY_BY_DEVICE_BUCKETED: &str = r#"
SELECT MIN(id),device_id,MIN(timestamp),AVG(indoor_temperature),AVG(outdoor_temperature),
AVG(target_temperature),MAX(power),MAX(source)
FROM readings
WHERE device_id=?1 AND timestamp>=?2
GROUP BY device_id, CAST(unixepoch(timestamp)/?3 AS INTEGER)
ORDER BY MIN(timestamp) ASC
LIMIT ?4
"#;
pub const LIST_DEVICE_HISTORY_ALL_BUCKETED: &str = r#"
SELECT MIN(id),device_id,MIN(timestamp),AVG(indoor_temperature),AVG(outdoor_temperature),
AVG(target_temperature),MAX(power),MAX(source)
FROM readings
WHERE timestamp>=?1
GROUP BY device_id, CAST(unixepoch(timestamp)/?2 AS INTEGER)
ORDER BY MIN(timestamp) ASC
LIMIT ?3
"#;
pub const PRUNE_READINGS: &str = "DELETE FROM readings WHERE timestamp < ?1";
pub const INSERT_ZONE_READING_IF_DUE: &str = r#"
@@ -243,6 +278,43 @@ pub const DELETE_ZONE_READINGS_BY_ZONE_ID: &str = "DELETE FROM zone_readings WHE
pub const DELETE_ZONE_READINGS_BY_DEVICE_ID: &str = "DELETE FROM zone_readings WHERE device_id=?1";
pub const PRUNE_ZONE_READINGS: &str = "DELETE FROM zone_readings WHERE timestamp < ?1";
pub const INSERT_HA_READING_IF_DUE: &str = r#"
INSERT INTO ha_readings(entity_id,zone_id,kind,timestamp,temperature)
SELECT ?1,?2,?3,?4,?5
WHERE NOT EXISTS (
SELECT 1 FROM ha_readings
WHERE entity_id=?1 AND COALESCE(zone_id,'')=COALESCE(?2,'') AND kind=?3 AND timestamp>=?6
LIMIT 1
)
"#;
pub const LIST_HA_HISTORY_BY_ENTITY_BUCKETED: &str = r#"
SELECT MIN(id),entity_id,MAX(zone_id),MAX(kind),MIN(timestamp),AVG(temperature)
FROM ha_readings
WHERE entity_id=?1 AND timestamp>=?2
GROUP BY entity_id,COALESCE(zone_id,''),kind,CAST(unixepoch(timestamp)/?3 AS INTEGER)
ORDER BY MIN(timestamp) ASC
LIMIT ?4
"#;
pub const LIST_HA_HISTORY_ALL_BUCKETED: &str = r#"
SELECT MIN(id),entity_id,MAX(zone_id),MAX(kind),MIN(timestamp),AVG(temperature)
FROM ha_readings
WHERE timestamp>=?1
GROUP BY entity_id,COALESCE(zone_id,''),kind,CAST(unixepoch(timestamp)/?2 AS INTEGER)
ORDER BY MIN(timestamp) ASC
LIMIT ?3
"#;
pub const PRUNE_HA_READINGS: &str = "DELETE FROM ha_readings WHERE timestamp < ?1";
pub const HISTORY_COUNTS: &str = r#"
SELECT
(SELECT COUNT(*) FROM readings),
(SELECT COUNT(*) FROM zone_readings),
(SELECT COUNT(*) FROM ha_readings)
"#;
pub const INSERT_EVENT: &str =
"INSERT INTO event_log(timestamp,level,kind,message,metadata) VALUES(?1,?2,?3,?4,?5)";
pub const LIST_EVENTS: &str =