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.
-1
View File
@@ -124,7 +124,6 @@ impl Config {
home_assistant: HomeAssistantSettings {
url: env::var("HA_URL").unwrap_or_default(),
token: env::var("HA_TOKEN").unwrap_or_default(),
default_entity_id: env::var("HA_ENTITY_ID").unwrap_or_default(),
outdoor_entity_id: env::var("HA_OUTDOOR_ENTITY_ID").unwrap_or_default(),
sensor_stale_after_seconds: env_u64("HA_SENSOR_STALE_AFTER_SECONDS")
.unwrap_or(300)
+1 -1
View File
@@ -230,7 +230,7 @@ mod tests {
heat_comfort_setpoint: 21.0, heat_sleep_setpoint: 19.0, heat_away_setpoint: 17.0,
hysteresis: 0.6, separate_hysteresis: false, cool_hysteresis: 0.6, heat_hysteresis: 0.6,
min_on_seconds: 180, min_off_seconds: 180, min_adjust_seconds: 120, standby_offset_c: 2.0, smart_fan: true,
sensor_source: source.into(), ha_entity_id: Some("sensor.room_temperature".into()),
sensor_source: source.into(), ha_entity_id: Some("sensor.room_temperature".into()), ha_outdoor_entity_id: None,
external_sensor_weight: 0.4, max_sensor_difference: 3.0, sensor_stale_after_seconds: 300, device_temperature: None, external_temperature: None,
current_temperature: None, control_temperature_source: "device".into(), active_preset: "comfort".into(),
manual_preset: None, manual_setpoint: None, manual_override_until: None, local_thermostat_power: None, local_thermostat_resume_at: None, local_thermostat_restore_zone_enabled: None, temporary_quick_thermostat: None,
+64 -6
View File
@@ -86,6 +86,58 @@ async fn resolve_cycle_outdoor_temperature(
temperature
}
async fn read_cycle_zone_outdoor_sensors(
state: &AppState,
settings: &RuntimeSettings,
zones: &[Zone],
) -> HashMap<String, Option<f64>> {
futures_util::future::join_all(zones.iter().filter_map(|zone| {
let configured = zone
.ha_outdoor_entity_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())?;
let zone_id = zone.id.clone();
let resolved_entity =
home_assistant::resolve_entity_id(&settings.home_assistant, Some(configured));
let http = &state.http;
let ha_settings = &settings.home_assistant;
let poll_interval_seconds = settings.poll_interval_seconds;
Some(async move {
let Some(entity_id) = resolved_entity else {
return (zone_id, None);
};
match home_assistant::read_temperature(
http,
ha_settings,
Some(&entity_id),
Some(ha_settings.sensor_stale_after_seconds),
)
.await
{
Ok(value) => {
record_ha_history(
state,
&entity_id,
Some(&zone_id),
"outdoor",
value,
poll_interval_seconds,
);
(zone_id, Some(value))
}
Err(err) => {
tracing::debug!(zone_id=%zone_id, resolved_entity=%entity_id, error=?err, "zone outdoor Home Assistant sensor unavailable; using global outdoor fallback");
(zone_id, None)
}
}
})
}))
.await
.into_iter()
.collect()
}
async fn read_cycle_room_sensors(
state: &AppState,
settings: &RuntimeSettings,
@@ -479,13 +531,10 @@ async fn control_zones(state: &AppState) -> Result<()> {
.await?;
let device_snapshot = state.db.list_devices()?;
let outdoor_temperature =
let global_outdoor_temperature =
resolve_cycle_outdoor_temperature(state, &settings, &device_snapshot).await;
let outdoor_assist_temperature = if settings.outdoor_assist_enabled {
outdoor_temperature
} else {
None
};
let mut zone_outdoor_sensor_results =
read_cycle_zone_outdoor_sensors(state, &settings, &zone_snapshot).await;
let night_active = night_mode_active(&settings.night_mode, Local::now().time());
let mut room_sensor_results = read_cycle_room_sensors(state, &settings, &zone_snapshot).await;
@@ -498,6 +547,15 @@ async fn control_zones(state: &AppState) -> Result<()> {
continue;
};
let cycle_started_at = zone.updated_at.clone();
let outdoor_temperature = zone_outdoor_sensor_results
.remove(&zone.id)
.flatten()
.or(global_outdoor_temperature);
let outdoor_assist_temperature = if settings.outdoor_assist_enabled {
outdoor_temperature
} else {
None
};
if zone
.manual_override_until
.map(|until| until <= Utc::now())
+35 -10
View File
@@ -26,11 +26,7 @@ pub fn resolve_entity_id(
) -> Option<String> {
let requested = entity_override
.filter(|value| !value.trim().is_empty())
.map(str::trim)
.unwrap_or_else(|| settings.default_entity_id.trim());
if requested.is_empty() {
return None;
}
.map(str::trim)?;
// Aliases are presentation-only. Accepting an alias here is a defensive
// compatibility path for settings saved by older UI revisions or manual edits;
@@ -48,6 +44,39 @@ pub fn resolve_entity_id(
Some(requested.to_string())
}
pub async fn test_connection(
default_client: &reqwest::Client,
settings: &HomeAssistantSettings,
) -> Result<()> {
if settings.url.trim().is_empty() {
bail!("Home Assistant URL is not configured")
}
if settings.token.trim().is_empty() {
bail!("Home Assistant token is not configured")
}
let mut base = Url::parse(settings.url.trim()).context("invalid Home Assistant URL")?;
if !matches!(base.scheme(), "http" | "https") {
bail!("Home Assistant URL must use http or https")
}
base = base.join("api/").context("cannot build Home Assistant API URL")?;
let response = request_client(default_client, settings)?
.get(base)
.bearer_auth(settings.token.trim())
.header("Accept", "application/json")
.send()
.await
.context("Home Assistant request failed")?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
bail!(
"Home Assistant returned {status}: {}",
body.chars().take(200).collect::<String>()
)
}
Ok(())
}
pub async fn read_temperature(
default_client: &reqwest::Client,
settings: &HomeAssistantSettings,
@@ -136,7 +165,6 @@ mod tests {
HomeAssistantSettings {
url: "http://homeassistant.local:8123".into(),
token: "token".into(),
default_entity_id: "sensor.salon_temperature".into(),
outdoor_entity_id: "sensor.zewnatrz_temperature".into(),
sensor_stale_after_seconds: 300,
allow_invalid_tls: false,
@@ -156,10 +184,7 @@ mod tests {
resolve_entity_id(&settings, Some("Gabinet")).as_deref(),
Some("sensor.gabinet_temperature")
);
assert_eq!(
resolve_entity_id(&settings, None).as_deref(),
Some("sensor.salon_temperature")
);
assert_eq!(resolve_entity_id(&settings, None), None);
}
}
+1 -3
View File
@@ -13,9 +13,7 @@ pub struct HomeAssistantSettings {
pub url: String,
#[serde(default)]
pub token: String,
#[serde(default)]
pub default_entity_id: String,
/// Optional outdoor temperature sensor used only as an assist signal.
/// Global Home Assistant outdoor temperature sensor. Zones may override it individually.
#[serde(default)]
pub outdoor_entity_id: String,
/// Maximum accepted age of Home Assistant sensor samples.
-2
View File
@@ -128,7 +128,6 @@ pub struct HomeAssistantSettingsUpdate {
pub url: String,
#[serde(default)]
pub token: Option<String>,
pub default_entity_id: String,
pub outdoor_entity_id: String,
pub sensor_stale_after_seconds: u64,
pub allow_invalid_tls: bool,
@@ -141,7 +140,6 @@ pub struct HomeAssistantSettingsUpdate {
pub struct HomeAssistantSettingsView {
pub url: String,
pub token_configured: bool,
pub default_entity_id: String,
pub outdoor_entity_id: String,
pub sensor_stale_after_seconds: u64,
pub allow_invalid_tls: bool,
+5
View File
@@ -52,8 +52,13 @@ pub struct Zone {
pub smart_fan: bool,
#[serde(default = "default_sensor_source")]
pub sensor_source: String,
/// Per-zone Home Assistant room temperature sensor used by external/combined control.
#[serde(default)]
pub ha_entity_id: Option<String>,
/// Optional per-zone Home Assistant outdoor temperature sensor. When absent, the zone
/// uses `HomeAssistantSettings::outdoor_entity_id` (and then the existing GREE fallback).
#[serde(default)]
pub ha_outdoor_entity_id: Option<String>,
/// Weight of the optional room sensor when sensor_source is `combined`.
#[serde(default = "default_external_sensor_weight")]
pub external_sensor_weight: f64,