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.
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user