v0.14.4
This commit is contained in:
@@ -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
@@ -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
@@ -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)]
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user