This commit is contained in:
Mateusz Gruszczyński
2026-09-15 18:47:20 +02:00
parent 7ffaba6c7a
commit 3d69e74319
37 changed files with 426 additions and 318 deletions
+14 -2
View File
@@ -27,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.2".into()));
return Err(AppError::BadRequest("unsupported configuration export version; version 3 is required by GREE Controller 0.14.4".into()));
}
if export.settings.control_strategy != "setpoint" {
return Err(AppError::BadRequest(
@@ -157,6 +157,18 @@ fn validate_configuration_devices_and_zones(
"import contains an invalid zone sensor source".into(),
));
}
if matches!(zone.sensor_source.as_str(), "home_assistant" | "combined")
&& zone
.ha_entity_id
.as_deref()
.map(str::trim)
.unwrap_or("")
.is_empty()
{
return Err(AppError::BadRequest(
"import contains a zone that needs a per-zone Home Assistant room temperature entity_id".into(),
));
}
}
let mut installation_devices = std::collections::HashSet::new();
for group in &export.device_groups {
@@ -687,7 +699,7 @@ fn normalize_imported_runtime_settings(settings: &mut RuntimeSettings) -> Result
fn prepare_configuration_import(export: &mut ConfigurationExport) -> Result<(), AppError> {
normalize_imported_runtime_settings(&mut export.settings)?;
for zone in &mut export.zones {
canonicalize_zone_ha_entity(zone, &export.settings.home_assistant);
canonicalize_zone_ha_entities(zone, &export.settings.home_assistant);
}
Ok(())
}
+46 -35
View File
@@ -130,13 +130,17 @@ fn sensor_history_with_fallback(
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
if !matches!(zone.sensor_source.as_str(), "home_assistant" | "combined") {
continue;
}
let entity_id = zone
.ha_entity_id
.as_deref()
.filter(|value| !value.trim().is_empty())
else {
.map(str::trim)
.unwrap_or("");
if entity_id.is_empty() {
continue;
};
}
if existing.contains(entity_id) {
continue;
}
@@ -162,31 +166,37 @@ fn sensor_history_with_fallback(
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;
let global_outdoor_entity = outdoor_entity.trim();
for zone in state.db.list_zones()? {
let zone_override = zone
.ha_outdoor_entity_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty());
let entity_id = zone_override.unwrap_or(global_outdoor_entity);
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 mut added = false;
for row in rows {
if let Some(temperature) = row.outdoor_temperature {
values.push(HaReading {
id: row.id,
entity_id: entity_id.to_string(),
zone_id: zone_override.map(|_| zone.id.clone()),
kind: "outdoor".into(),
timestamp: row.timestamp,
temperature,
});
added = true;
}
}
if added {
existing.insert(entity_id.to_string());
}
}
values.sort_by(|left, right| left.timestamp.cmp(&right.timestamp));
if values.len() > limit as usize {
@@ -332,7 +342,13 @@ 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 {
@@ -391,13 +407,8 @@ async fn history(
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 ha_settings = state.settings.read().await.home_assistant.clone();
let outdoor_entity = ha_settings.outdoor_entity_id;
let (device_count, zone_count, ha_count) = state.db.history_counts()?;
match scope {
+4 -18
View File
@@ -11,28 +11,14 @@ async fn home_assistant_snapshot(
})))
}
#[derive(Debug, Deserialize)]
struct HaTestRequest {
entity_id: Option<String>,
}
async fn test_home_assistant(
State(state): State<AppState>,
Json(input): Json<HaTestRequest>,
) -> Result<Json<Value>, AppError> {
let settings = state.settings.read().await.clone();
let resolved_entity_id =
home_assistant::resolve_entity_id(&settings.home_assistant, input.entity_id.as_deref());
let temperature = home_assistant::read_temperature(
&state.http,
&settings.home_assistant,
resolved_entity_id.as_deref(),
Some(settings.home_assistant.sensor_stale_after_seconds),
)
.await
.map_err(|e| AppError::Device(e.to_string()))?;
Ok(Json(
json!({"ok": true, "temperature_c": temperature, "entity_id": resolved_entity_id}),
))
home_assistant::test_connection(&state.http, &settings.home_assistant)
.await
.map_err(|e| AppError::Device(e.to_string()))?;
Ok(Json(json!({"ok": true})))
}
#[derive(Debug, Deserialize)]
-1
View File
@@ -52,7 +52,6 @@ fn public_settings(settings: &RuntimeSettings) -> Value {
"url": settings.home_assistant.url,
"token": "",
"token_configured": !settings.home_assistant.token.trim().is_empty(),
"default_entity_id": settings.home_assistant.default_entity_id,
"outdoor_entity_id": settings.home_assistant.outdoor_entity_id,
"sensor_stale_after_seconds": settings.home_assistant.sensor_stale_after_seconds,
"allow_invalid_tls": settings.home_assistant.allow_invalid_tls,
+17 -13
View File
@@ -80,7 +80,6 @@ fn home_assistant_settings(settings: &RuntimeSettings) -> HomeAssistantSettingsV
HomeAssistantSettingsView {
url: settings.home_assistant.url.clone(),
token_configured: !settings.home_assistant.token.trim().is_empty(),
default_entity_id: settings.home_assistant.default_entity_id.clone(),
outdoor_entity_id: settings.home_assistant.outdoor_entity_id.clone(),
sensor_stale_after_seconds: settings.home_assistant.sensor_stale_after_seconds,
allow_invalid_tls: settings.home_assistant.allow_invalid_tls,
@@ -618,10 +617,6 @@ fn validate_flow_shared_inputs(
}
fn canonicalize_home_assistant_entities(settings: &mut HomeAssistantSettings) {
let default_entity = settings.default_entity_id.clone();
if let Some(entity_id) = home_assistant::resolve_entity_id(settings, Some(&default_entity)) {
settings.default_entity_id = entity_id;
}
let outdoor_entity = settings.outdoor_entity_id.clone();
if !outdoor_entity.trim().is_empty() {
if let Some(entity_id) = home_assistant::resolve_entity_id(settings, Some(&outdoor_entity))
@@ -645,11 +640,20 @@ fn validate_home_assistant_url(settings: &HomeAssistantSettings) -> Result<(), A
Ok(())
}
fn canonicalize_zone_ha_entity(zone: &mut Zone, settings: &HomeAssistantSettings) {
let Some(configured) = zone.ha_entity_id.clone() else {
return;
fn canonicalize_zone_ha_entities(zone: &mut Zone, settings: &HomeAssistantSettings) {
let room = zone.ha_entity_id.clone().unwrap_or_default();
zone.ha_entity_id = if room.trim().is_empty() {
None
} else {
home_assistant::resolve_entity_id(settings, Some(room.trim()))
};
let outdoor = zone.ha_outdoor_entity_id.clone().unwrap_or_default();
zone.ha_outdoor_entity_id = if outdoor.trim().is_empty() {
None
} else {
home_assistant::resolve_entity_id(settings, Some(outdoor.trim()))
};
zone.ha_entity_id = home_assistant::resolve_entity_id(settings, Some(&configured));
}
async fn canonicalize_saved_zone_entities(
@@ -673,9 +677,10 @@ async fn canonicalize_saved_zone_entities(
let Some(mut zone) = state.db.get_zone(&zone_id)? else {
continue;
};
let previous = zone.ha_entity_id.clone();
canonicalize_zone_ha_entity(&mut zone, settings);
if zone.ha_entity_id != previous {
let previous_room = zone.ha_entity_id.clone();
let previous_outdoor = zone.ha_outdoor_entity_id.clone();
canonicalize_zone_ha_entities(&mut zone, settings);
if zone.ha_entity_id != previous_room || zone.ha_outdoor_entity_id != previous_outdoor {
zone.updated_at = Utc::now();
state.db.save_zone(&zone)?;
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
@@ -691,7 +696,6 @@ fn apply_home_assistant_update(
let mut next = HomeAssistantSettings {
url: input.url,
token: current.token.clone(),
default_entity_id: input.default_entity_id,
outdoor_entity_id: input.outdoor_entity_id,
sensor_stale_after_seconds: input.sensor_stale_after_seconds.clamp(30, 86_400),
allow_invalid_tls: input.allow_invalid_tls,
+11 -6
View File
@@ -44,6 +44,8 @@ struct ZoneInput {
sensor_source: String,
#[serde(default)]
ha_entity_id: Option<String>,
#[serde(default)]
ha_outdoor_entity_id: Option<String>,
#[serde(default = "external_sensor_weight")]
external_sensor_weight: f64,
#[serde(default = "max_sensor_difference")]
@@ -170,11 +172,13 @@ impl ZoneInput {
&& self
.ha_entity_id
.as_deref()
.map(|value| value.trim())
.map(str::trim)
.unwrap_or("")
.is_empty()
{
return Err(AppError::BadRequest("a per-zone Home Assistant entity_id is required for external or combined temperature control".into()));
return Err(AppError::BadRequest(
"a per-zone Home Assistant room temperature entity_id is required for external or combined temperature control".into(),
));
}
Ok(())
}
@@ -205,6 +209,7 @@ impl ZoneInput {
smart_fan: self.smart_fan,
sensor_source: self.sensor_source,
ha_entity_id: self.ha_entity_id.filter(|v| !v.trim().is_empty()),
ha_outdoor_entity_id: self.ha_outdoor_entity_id.filter(|v| !v.trim().is_empty()),
external_sensor_weight: self.external_sensor_weight,
max_sensor_difference: self.max_sensor_difference,
sensor_stale_after_seconds: self.sensor_stale_after_seconds,
@@ -289,6 +294,7 @@ async fn create_zone(
) -> Result<(StatusCode, Json<Zone>), AppError> {
let _configuration_guard = state.lock_configuration_operation().await;
let _reference_guard = state.lock_automation_operation().await;
let settings = state.settings.read().await.clone();
input.validate()?;
if state.db.get_device(&input.device_id)?.is_none() {
return Err(AppError::BadRequest("zone device does not exist".into()));
@@ -299,8 +305,7 @@ async fn create_zone(
// the newly created zone without participating in the zone operation lock.
let _device_guard = state.lock_device_operation(&input.device_id).await;
let mut zone = input.into_zone(Uuid::new_v4().to_string(), Utc::now());
let settings = state.settings.read().await.clone();
canonicalize_zone_ha_entity(&mut zone, &settings.home_assistant);
canonicalize_zone_ha_entities(&mut zone, &settings.home_assistant);
state.db.save_zone(&zone)?;
state.broadcast("zone.created", serde_json::to_value(&zone)?);
state.wake_zone_control();
@@ -313,6 +318,7 @@ async fn update_zone(
) -> Result<Json<Zone>, AppError> {
let _configuration_guard = state.lock_configuration_operation().await;
let _reference_guard = state.lock_automation_operation().await;
let settings = state.settings.read().await.clone();
input.validate()?;
let _zone_guard = state.lock_zone_operation(&id).await;
let mut existing = state
@@ -409,8 +415,7 @@ async fn update_zone(
.await?;
zone.revision = existing.revision.saturating_add(1);
}
let settings = state.settings.read().await.clone();
canonicalize_zone_ha_entity(&mut zone, &settings.home_assistant);
canonicalize_zone_ha_entities(&mut zone, &settings.home_assistant);
let power_off_device = !device_changed && existing.enabled && !zone.enabled;
if power_off_device {
// Full configuration PUT and quick-control disable use the same ownership cleanup.