v0.12.0
This commit is contained in:
+404
-430
@@ -1,93 +1,330 @@
|
||||
async fn get_settings(State(state): State<AppState>) -> Json<Value> {
|
||||
let settings = state.settings.read().await;
|
||||
Json(public_settings(&*settings))
|
||||
fn application_settings(settings: &RuntimeSettings) -> ApplicationSettings {
|
||||
ApplicationSettings { simulator_enabled: settings.simulator_enabled }
|
||||
}
|
||||
|
||||
async fn update_settings(State(state): State<AppState>, Json(mut input): Json<RuntimeSettings>) -> Result<Json<Value>, AppError> {
|
||||
let _configuration_guard = state.lock_configuration_operation().await;
|
||||
let _house_guard = state.lock_house_operation().await;
|
||||
let _cycle_guard = state.lock_zone_control_cycle().await;
|
||||
let old = state.settings.read().await.clone();
|
||||
if input.house_mode != old.house_mode {
|
||||
return Err(AppError::BadRequest(
|
||||
"house_mode must be changed through the House Control API".into(),
|
||||
));
|
||||
fn gree_settings(settings: &RuntimeSettings) -> GreeSettings {
|
||||
GreeSettings {
|
||||
controller_id: settings.controller_id.clone(),
|
||||
poll_interval_seconds: settings.poll_interval_seconds,
|
||||
zone_interval_seconds: settings.zone_interval_seconds,
|
||||
discovery_timeout_ms: settings.discovery_timeout_ms,
|
||||
discovery_broadcast: settings.discovery_broadcast.clone(),
|
||||
suppress_device_beep: settings.suppress_device_beep,
|
||||
compressor_protection_enabled: settings.compressor_protection_enabled,
|
||||
compressor_protection_seconds: settings.compressor_protection_seconds,
|
||||
}
|
||||
}
|
||||
|
||||
fn history_settings(settings: &RuntimeSettings) -> HistorySettings {
|
||||
HistorySettings {
|
||||
retention_days: settings.history_retention_days,
|
||||
compaction_enabled: settings.history_compaction_enabled,
|
||||
event_retention_days: settings.event_log_retention_days,
|
||||
}
|
||||
}
|
||||
|
||||
fn influxdb_settings(settings: &RuntimeSettings) -> InfluxDbSettingsView {
|
||||
InfluxDbSettingsView {
|
||||
enabled: settings.influxdb.enabled,
|
||||
version: settings.influxdb.version.clone(),
|
||||
url: settings.influxdb.url.clone(),
|
||||
database: settings.influxdb.database.clone(),
|
||||
username: settings.influxdb.username.clone(),
|
||||
password_configured: !settings.influxdb.password.trim().is_empty(),
|
||||
org: settings.influxdb.org.clone(),
|
||||
bucket: settings.influxdb.bucket.clone(),
|
||||
token_configured: !settings.influxdb.token.trim().is_empty(),
|
||||
history_threshold_days: settings.influxdb.history_threshold_days,
|
||||
}
|
||||
}
|
||||
|
||||
fn notification_settings(settings: &RuntimeSettings) -> NotificationSettingsView {
|
||||
NotificationSettingsView {
|
||||
enabled: settings.notifications.enabled,
|
||||
mode: settings.notifications.mode.clone(),
|
||||
provider: settings.notifications.provider.clone(),
|
||||
pushover_configured: !settings.notifications.pushover_app_token.trim().is_empty()
|
||||
&& !settings.notifications.pushover_user_key.trim().is_empty(),
|
||||
slack_configured: !settings.notifications.slack_webhook_url.trim().is_empty(),
|
||||
discord_configured: !settings.notifications.discord_webhook_url.trim().is_empty(),
|
||||
cooldown_seconds: settings.notifications.cooldown_seconds,
|
||||
communication_failure_threshold: settings.notifications.communication_failure_threshold,
|
||||
target_timeout_minutes: settings.notifications.target_timeout_minutes,
|
||||
alert_types: settings.notifications.alert_types.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn home_assistant_settings(settings: &RuntimeSettings) -> HomeAssistantSettingsView {
|
||||
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,
|
||||
sensor_aliases: settings.home_assistant.sensor_aliases.clone(),
|
||||
flow_inputs: settings.home_assistant.flow_inputs.clone(),
|
||||
outdoor_assist_enabled: settings.outdoor_assist_enabled,
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_application_settings(State(state): State<AppState>) -> Json<ApplicationSettings> {
|
||||
Json(application_settings(&state.settings.read().await))
|
||||
}
|
||||
|
||||
async fn update_application_settings(
|
||||
State(state): State<AppState>,
|
||||
Json(input): Json<ApplicationSettings>,
|
||||
) -> Result<Json<ApplicationSettings>, AppError> {
|
||||
let _configuration_guard = state.lock_configuration_operation().await;
|
||||
let payload = {
|
||||
let mut settings = state.settings.write().await;
|
||||
settings.simulator_enabled = input.simulator_enabled;
|
||||
state.db.save_runtime_settings(&settings)?;
|
||||
application_settings(&settings)
|
||||
};
|
||||
state.log("info", "settings.application.updated", "Application settings updated", json!({"simulator_enabled": payload.simulator_enabled}));
|
||||
state.broadcast("settings.application.updated", serde_json::to_value(&payload)?);
|
||||
Ok(Json(payload))
|
||||
}
|
||||
|
||||
async fn get_gree_settings(State(state): State<AppState>) -> Json<GreeSettings> {
|
||||
Json(gree_settings(&state.settings.read().await))
|
||||
}
|
||||
|
||||
fn normalize_gree_settings(mut input: GreeSettings) -> Result<GreeSettings, AppError> {
|
||||
input.controller_id = input.controller_id.trim().to_string();
|
||||
if input.controller_id.is_empty() {
|
||||
return Err(AppError::BadRequest("controller_id cannot be empty".into()));
|
||||
}
|
||||
// Compatibility-only field: global ON/OFF no longer uses a persistent master automation gate.
|
||||
input.house_power_enabled = true;
|
||||
input.poll_interval_seconds = input.poll_interval_seconds.clamp(2, 3600);
|
||||
input.zone_interval_seconds = input.zone_interval_seconds.clamp(2, 3600);
|
||||
input.discovery_timeout_ms = input.discovery_timeout_ms.clamp(300, 30_000);
|
||||
if !matches!(input.house_mode.as_str(), "cool" | "heat" | "off") { return Err(AppError::BadRequest("house mode must be cool, heat or off".into())); }
|
||||
if input.control_strategy != "setpoint" { input.control_strategy = "setpoint".into(); }
|
||||
input.compressor_protection_seconds = input.compressor_protection_seconds.clamp(30, 1800);
|
||||
if !(input.discovery_broadcast.eq_ignore_ascii_case("auto")
|
||||
|| input.discovery_broadcast.to_ascii_lowercase().starts_with("auto:")) {
|
||||
|| input.discovery_broadcast.to_ascii_lowercase().starts_with("auto:"))
|
||||
{
|
||||
input.discovery_broadcast.parse::<std::net::SocketAddr>()
|
||||
.map_err(|_| AppError::BadRequest("invalid discovery broadcast address".into()))?;
|
||||
}
|
||||
if input.controller_id.trim().is_empty() { input.controller_id = old.controller_id; }
|
||||
if input.home_assistant.token.trim().is_empty() { input.home_assistant.token = old.home_assistant.token; }
|
||||
input.home_assistant.sensor_stale_after_seconds = input.home_assistant.sensor_stale_after_seconds.clamp(30, 86_400);
|
||||
if input.notifications.pushover_app_token.trim().is_empty() { input.notifications.pushover_app_token = old.notifications.pushover_app_token; }
|
||||
if input.notifications.pushover_user_key.trim().is_empty() { input.notifications.pushover_user_key = old.notifications.pushover_user_key; }
|
||||
if input.notifications.slack_webhook_url.trim().is_empty() { input.notifications.slack_webhook_url = old.notifications.slack_webhook_url; }
|
||||
if input.notifications.discord_webhook_url.trim().is_empty() { input.notifications.discord_webhook_url = old.notifications.discord_webhook_url; }
|
||||
input.notifications.cooldown_seconds = input.notifications.cooldown_seconds.clamp(30, 86_400);
|
||||
input.notifications.communication_failure_threshold = input.notifications.communication_failure_threshold.clamp(2, 100);
|
||||
input.notifications.target_timeout_minutes = input.notifications.target_timeout_minutes.clamp(5, 24 * 60);
|
||||
if !matches!(input.notifications.mode.as_str(), "problems" | "important") { return Err(AppError::BadRequest("notification mode must be problems or important".into())); }
|
||||
if !matches!(input.notifications.provider.as_str(), "pushover" | "slack" | "discord") { return Err(AppError::BadRequest("unsupported notification provider".into())); }
|
||||
input.history_retention_days = input.history_retention_days.clamp(1, 3650);
|
||||
input.event_log_retention_days = input.event_log_retention_days.clamp(1, 3650);
|
||||
input.compressor_protection_seconds = input.compressor_protection_seconds.clamp(30, 1800);
|
||||
let compressor_settings_changed = input.compressor_protection_enabled != old.compressor_protection_enabled
|
||||
|| input.compressor_protection_seconds != old.compressor_protection_seconds;
|
||||
normalize_sensor_aliases(&mut input);
|
||||
validate_flow_shared_inputs(&mut input, &state)?;
|
||||
canonicalize_home_assistant_entities(&mut input);
|
||||
validate_night_mode(&mut input)?;
|
||||
input.influxdb.history_threshold_days = input.influxdb.history_threshold_days.clamp(1, 3650);
|
||||
if input.influxdb.token.trim().is_empty() { input.influxdb.token = old.influxdb.token; }
|
||||
if input.influxdb.password.trim().is_empty() { input.influxdb.password = old.influxdb.password; }
|
||||
influxdb::validate(&input.influxdb).map_err(|err| AppError::BadRequest(err.to_string()))?;
|
||||
if !input.home_assistant.url.trim().is_empty() {
|
||||
let parsed = url::Url::parse(&input.home_assistant.url).map_err(|_| AppError::BadRequest("invalid Home Assistant URL".into()))?;
|
||||
if !matches!(parsed.scheme(), "http" | "https") { return Err(AppError::BadRequest("Home Assistant URL must use http or https".into())); }
|
||||
}
|
||||
state.db.save_runtime_settings(&input)?;
|
||||
canonicalize_saved_zone_entities(&state, &input).await?;
|
||||
state.debug_gree_frames.store(input.debug.gree_frames, Ordering::Relaxed);
|
||||
*state.settings.write().await = input.clone();
|
||||
if compressor_settings_changed {
|
||||
// A changed protection policy invalidates old deadlines. Clear transient tasks under
|
||||
// the same control-cycle exclusion; the next thermostat pass re-evaluates intent
|
||||
// against the new enabled flag/duration.
|
||||
let mut zone_ids: Vec<String> = state.db.list_zones()?.into_iter().map(|zone| zone.id).collect();
|
||||
zone_ids.sort();
|
||||
zone_ids.dedup();
|
||||
for zone_id in zone_ids {
|
||||
let _zone_guard = state.lock_zone_operation(&zone_id).await;
|
||||
let Some(mut zone) = state.db.get_zone(&zone_id)? else { continue; };
|
||||
if zone.compressor_pending_action.is_none() && zone.compressor_cancelled_action.is_none()
|
||||
&& zone.lockout_until.is_none() && zone.lockout_reason.is_none() { continue; }
|
||||
engine::clear_compressor_pending(&mut zone, true);
|
||||
zone.revision = zone.revision.saturating_add(1);
|
||||
zone.updated_at = Utc::now();
|
||||
state.db.save_zone(&zone)?;
|
||||
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
|
||||
}
|
||||
}
|
||||
state.log("info", "settings.updated", "Settings updated", json!({
|
||||
"compressor_protection_enabled": input.compressor_protection_enabled,
|
||||
"compressor_protection_seconds": input.compressor_protection_seconds
|
||||
}));
|
||||
state.broadcast("settings.updated", public_settings(&input));
|
||||
state.wake_zone_control();
|
||||
Ok(Json(public_settings(&input)))
|
||||
Ok(input)
|
||||
}
|
||||
|
||||
fn normalize_sensor_aliases(settings: &mut RuntimeSettings) {
|
||||
settings.home_assistant.sensor_aliases = settings.home_assistant.sensor_aliases
|
||||
async fn clear_compressor_runtime_after_settings_change(state: &AppState) -> Result<(), AppError> {
|
||||
let mut zone_ids: Vec<String> = state.db.list_zones()?.into_iter().map(|zone| zone.id).collect();
|
||||
zone_ids.sort();
|
||||
zone_ids.dedup();
|
||||
for zone_id in zone_ids {
|
||||
let _zone_guard = state.lock_zone_operation(&zone_id).await;
|
||||
let Some(mut zone) = state.db.get_zone(&zone_id)? else { continue; };
|
||||
if zone.compressor_pending_action.is_none()
|
||||
&& zone.compressor_cancelled_action.is_none()
|
||||
&& zone.lockout_until.is_none()
|
||||
&& zone.lockout_reason.is_none()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
engine::clear_compressor_pending(&mut zone, true);
|
||||
zone.revision = zone.revision.saturating_add(1);
|
||||
zone.updated_at = Utc::now();
|
||||
state.db.save_zone(&zone)?;
|
||||
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn update_gree_settings(
|
||||
State(state): State<AppState>,
|
||||
Json(input): Json<GreeSettings>,
|
||||
) -> Result<Json<GreeSettings>, AppError> {
|
||||
let input = normalize_gree_settings(input)?;
|
||||
let _configuration_guard = state.lock_configuration_operation().await;
|
||||
let _house_guard = state.lock_house_operation().await;
|
||||
let _cycle_guard = state.lock_zone_control_cycle().await;
|
||||
let (payload, compressor_changed) = {
|
||||
let mut settings = state.settings.write().await;
|
||||
let compressor_changed = settings.compressor_protection_enabled != input.compressor_protection_enabled
|
||||
|| settings.compressor_protection_seconds != input.compressor_protection_seconds;
|
||||
settings.controller_id = input.controller_id;
|
||||
settings.poll_interval_seconds = input.poll_interval_seconds;
|
||||
settings.zone_interval_seconds = input.zone_interval_seconds;
|
||||
settings.discovery_timeout_ms = input.discovery_timeout_ms;
|
||||
settings.discovery_broadcast = input.discovery_broadcast;
|
||||
settings.suppress_device_beep = input.suppress_device_beep;
|
||||
settings.compressor_protection_enabled = input.compressor_protection_enabled;
|
||||
settings.compressor_protection_seconds = input.compressor_protection_seconds;
|
||||
state.db.save_runtime_settings(&settings)?;
|
||||
(gree_settings(&settings), compressor_changed)
|
||||
};
|
||||
if compressor_changed {
|
||||
clear_compressor_runtime_after_settings_change(&state).await?;
|
||||
}
|
||||
state.log("info", "settings.gree.updated", "GREE settings updated", json!({
|
||||
"compressor_protection_enabled": payload.compressor_protection_enabled,
|
||||
"compressor_protection_seconds": payload.compressor_protection_seconds
|
||||
}));
|
||||
state.broadcast("settings.gree.updated", serde_json::to_value(&payload)?);
|
||||
state.wake_zone_control();
|
||||
Ok(Json(payload))
|
||||
}
|
||||
|
||||
async fn get_history_settings(State(state): State<AppState>) -> Json<HistorySettings> {
|
||||
Json(history_settings(&state.settings.read().await))
|
||||
}
|
||||
|
||||
async fn update_history_settings(
|
||||
State(state): State<AppState>,
|
||||
Json(mut input): Json<HistorySettings>,
|
||||
) -> Result<Json<HistorySettings>, AppError> {
|
||||
input.retention_days = input.retention_days.clamp(1, 3650);
|
||||
input.event_retention_days = input.event_retention_days.clamp(1, 3650);
|
||||
let _configuration_guard = state.lock_configuration_operation().await;
|
||||
let payload = {
|
||||
let mut settings = state.settings.write().await;
|
||||
settings.history_retention_days = input.retention_days;
|
||||
settings.history_compaction_enabled = input.compaction_enabled;
|
||||
settings.event_log_retention_days = input.event_retention_days;
|
||||
state.db.save_runtime_settings(&settings)?;
|
||||
history_settings(&settings)
|
||||
};
|
||||
let removed = state.db.prune_events(payload.event_retention_days as i64)?;
|
||||
state.log("info", "settings.history.updated", "History settings updated", json!({
|
||||
"retention_days": payload.retention_days,
|
||||
"event_retention_days": payload.event_retention_days,
|
||||
"event_rows_removed": removed
|
||||
}));
|
||||
state.broadcast("settings.history.updated", serde_json::to_value(&payload)?);
|
||||
Ok(Json(payload))
|
||||
}
|
||||
|
||||
async fn get_influxdb_settings(State(state): State<AppState>) -> Json<InfluxDbSettingsView> {
|
||||
Json(influxdb_settings(&state.settings.read().await))
|
||||
}
|
||||
|
||||
fn apply_influxdb_update(current: &InfluxDbSettings, input: InfluxDbSettingsUpdate) -> Result<InfluxDbSettings, AppError> {
|
||||
let mut next = InfluxDbSettings {
|
||||
enabled: input.enabled,
|
||||
version: input.version,
|
||||
url: input.url,
|
||||
database: input.database,
|
||||
username: input.username,
|
||||
password: current.password.clone(),
|
||||
org: input.org,
|
||||
bucket: input.bucket,
|
||||
token: current.token.clone(),
|
||||
history_threshold_days: input.history_threshold_days.clamp(1, 3650),
|
||||
};
|
||||
if let Some(password) = input.password { next.password = password; }
|
||||
if let Some(token) = input.token { next.token = token; }
|
||||
influxdb::validate(&next).map_err(|err| AppError::BadRequest(err.to_string()))?;
|
||||
Ok(next)
|
||||
}
|
||||
|
||||
async fn update_influxdb_settings(
|
||||
State(state): State<AppState>,
|
||||
Json(input): Json<InfluxDbSettingsUpdate>,
|
||||
) -> Result<Json<InfluxDbSettingsView>, AppError> {
|
||||
let _configuration_guard = state.lock_configuration_operation().await;
|
||||
let payload = {
|
||||
let mut settings = state.settings.write().await;
|
||||
let next = apply_influxdb_update(&settings.influxdb, input)?;
|
||||
settings.influxdb = next;
|
||||
state.db.save_runtime_settings(&settings)?;
|
||||
influxdb_settings(&settings)
|
||||
};
|
||||
state.log("info", "settings.influxdb.updated", "InfluxDB settings updated", json!({"enabled": payload.enabled, "version": payload.version}));
|
||||
state.broadcast("settings.influxdb.updated", serde_json::to_value(&payload)?);
|
||||
Ok(Json(payload))
|
||||
}
|
||||
|
||||
async fn get_notification_settings(State(state): State<AppState>) -> Json<NotificationSettingsView> {
|
||||
Json(notification_settings(&state.settings.read().await))
|
||||
}
|
||||
|
||||
fn apply_notification_update(current: &NotificationSettings, mut input: NotificationSettingsUpdate) -> Result<NotificationSettings, AppError> {
|
||||
input.cooldown_seconds = input.cooldown_seconds.clamp(30, 86_400);
|
||||
input.communication_failure_threshold = input.communication_failure_threshold.clamp(2, 100);
|
||||
input.target_timeout_minutes = input.target_timeout_minutes.clamp(5, 24 * 60);
|
||||
if !matches!(input.mode.as_str(), "problems" | "important") {
|
||||
return Err(AppError::BadRequest("notification mode must be problems or important".into()));
|
||||
}
|
||||
if !matches!(input.provider.as_str(), "pushover" | "slack" | "discord") {
|
||||
return Err(AppError::BadRequest("unsupported notification provider".into()));
|
||||
}
|
||||
let mut next = NotificationSettings {
|
||||
enabled: input.enabled,
|
||||
mode: input.mode,
|
||||
provider: input.provider,
|
||||
pushover_app_token: current.pushover_app_token.clone(),
|
||||
pushover_user_key: current.pushover_user_key.clone(),
|
||||
slack_webhook_url: current.slack_webhook_url.clone(),
|
||||
discord_webhook_url: current.discord_webhook_url.clone(),
|
||||
cooldown_seconds: input.cooldown_seconds,
|
||||
communication_failure_threshold: input.communication_failure_threshold,
|
||||
target_timeout_minutes: input.target_timeout_minutes,
|
||||
alert_types: input.alert_types,
|
||||
};
|
||||
if let Some(value) = input.pushover_app_token { next.pushover_app_token = value; }
|
||||
if let Some(value) = input.pushover_user_key { next.pushover_user_key = value; }
|
||||
if let Some(value) = input.slack_webhook_url { next.slack_webhook_url = value; }
|
||||
if let Some(value) = input.discord_webhook_url { next.discord_webhook_url = value; }
|
||||
Ok(next)
|
||||
}
|
||||
|
||||
async fn update_notification_settings(
|
||||
State(state): State<AppState>,
|
||||
Json(input): Json<NotificationSettingsUpdate>,
|
||||
) -> Result<Json<NotificationSettingsView>, AppError> {
|
||||
let _configuration_guard = state.lock_configuration_operation().await;
|
||||
let payload = {
|
||||
let mut settings = state.settings.write().await;
|
||||
let next = apply_notification_update(&settings.notifications, input)?;
|
||||
settings.notifications = next;
|
||||
state.db.save_runtime_settings(&settings)?;
|
||||
notification_settings(&settings)
|
||||
};
|
||||
state.log("info", "settings.notifications.updated", "Notification settings updated", json!({"enabled": payload.enabled, "provider": payload.provider}));
|
||||
state.broadcast("settings.notifications.updated", serde_json::to_value(&payload)?);
|
||||
Ok(Json(payload))
|
||||
}
|
||||
|
||||
async fn get_night_settings(State(state): State<AppState>) -> Json<NightModeSettings> {
|
||||
Json(state.settings.read().await.night_mode.clone())
|
||||
}
|
||||
|
||||
fn validate_night_mode(settings: &mut NightModeSettings) -> Result<(), AppError> {
|
||||
NaiveTime::parse_from_str(&settings.start_time, "%H:%M")
|
||||
.map_err(|_| AppError::BadRequest("night mode start time must use HH:MM".into()))?;
|
||||
NaiveTime::parse_from_str(&settings.end_time, "%H:%M")
|
||||
.map_err(|_| AppError::BadRequest("night mode end time must use HH:MM".into()))?;
|
||||
settings.max_fan_speed = settings.max_fan_speed.clamp(1, 5);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn update_night_settings(
|
||||
State(state): State<AppState>,
|
||||
Json(mut input): Json<NightModeSettings>,
|
||||
) -> Result<Json<NightModeSettings>, AppError> {
|
||||
validate_night_mode(&mut input)?;
|
||||
let _configuration_guard = state.lock_configuration_operation().await;
|
||||
let _cycle_guard = state.lock_zone_control_cycle().await;
|
||||
{
|
||||
let mut settings = state.settings.write().await;
|
||||
settings.night_mode = input.clone();
|
||||
state.db.save_runtime_settings(&settings)?;
|
||||
}
|
||||
state.log("info", "settings.night.updated", "Night mode settings updated", json!({"enabled": input.enabled}));
|
||||
state.broadcast("settings.night.updated", serde_json::to_value(&input)?);
|
||||
state.wake_zone_control();
|
||||
Ok(Json(input))
|
||||
}
|
||||
|
||||
async fn get_home_assistant_settings(State(state): State<AppState>) -> Json<HomeAssistantSettingsView> {
|
||||
Json(home_assistant_settings(&state.settings.read().await))
|
||||
}
|
||||
|
||||
fn normalize_sensor_aliases(settings: &mut HomeAssistantSettings) {
|
||||
settings.sensor_aliases = settings.sensor_aliases
|
||||
.iter()
|
||||
.filter_map(|(entity, alias)| {
|
||||
let entity = entity.trim();
|
||||
@@ -98,12 +335,12 @@ fn normalize_sensor_aliases(settings: &mut RuntimeSettings) {
|
||||
.collect();
|
||||
}
|
||||
|
||||
fn validate_flow_shared_inputs(settings: &mut RuntimeSettings, state: &AppState) -> Result<(), AppError> {
|
||||
fn normalize_flow_shared_inputs(settings: &mut HomeAssistantSettings) -> Result<(), AppError> {
|
||||
let mut ids = std::collections::HashSet::new();
|
||||
if settings.home_assistant.flow_inputs.len() > 128 {
|
||||
if settings.flow_inputs.len() > 128 {
|
||||
return Err(AppError::BadRequest("too many shared Flow inputs (max 128)".into()));
|
||||
}
|
||||
for item in &mut settings.home_assistant.flow_inputs {
|
||||
for item in &mut settings.flow_inputs {
|
||||
item.id = item.id.trim().chars().take(120).collect();
|
||||
item.name = item.name.trim().chars().take(100).collect();
|
||||
item.kind = item.kind.trim().to_string();
|
||||
@@ -129,30 +366,47 @@ fn validate_flow_shared_inputs(settings: &mut RuntimeSettings, state: &AppState)
|
||||
if shared_input_comparison_kind(&item.kind) && item.config.get("value").is_some() {
|
||||
return Err(AppError::BadRequest("shared Flow inputs are value sources; comparison value belongs to the Flow block".into()));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_flow_shared_inputs(settings: &mut HomeAssistantSettings, state: &AppState) -> Result<(), AppError> {
|
||||
normalize_flow_shared_inputs(settings)?;
|
||||
for item in &settings.flow_inputs {
|
||||
validate_shared_input_source(&item.kind, &item.config, state)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn canonicalize_home_assistant_entities(settings: &mut RuntimeSettings) {
|
||||
let default_entity = settings.home_assistant.default_entity_id.clone();
|
||||
if let Some(entity_id) = home_assistant::resolve_entity_id(&settings.home_assistant, Some(&default_entity)) {
|
||||
settings.home_assistant.default_entity_id = entity_id;
|
||||
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.home_assistant.outdoor_entity_id.clone();
|
||||
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.home_assistant, Some(&outdoor_entity)) {
|
||||
settings.home_assistant.outdoor_entity_id = entity_id;
|
||||
if let Some(entity_id) = home_assistant::resolve_entity_id(settings, Some(&outdoor_entity)) {
|
||||
settings.outdoor_entity_id = entity_id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn canonicalize_zone_ha_entity(zone: &mut Zone, settings: &RuntimeSettings) {
|
||||
let Some(configured) = zone.ha_entity_id.clone() else { return; };
|
||||
zone.ha_entity_id = home_assistant::resolve_entity_id(&settings.home_assistant, Some(&configured));
|
||||
fn validate_home_assistant_url(settings: &HomeAssistantSettings) -> Result<(), AppError> {
|
||||
if settings.url.trim().is_empty() { return Ok(()); }
|
||||
let parsed = url::Url::parse(&settings.url)
|
||||
.map_err(|_| AppError::BadRequest("invalid Home Assistant URL".into()))?;
|
||||
if !matches!(parsed.scheme(), "http" | "https") {
|
||||
return Err(AppError::BadRequest("Home Assistant URL must use http or https".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn canonicalize_saved_zone_entities(state: &AppState, settings: &RuntimeSettings) -> Result<(), AppError> {
|
||||
fn canonicalize_zone_ha_entity(zone: &mut Zone, settings: &HomeAssistantSettings) {
|
||||
let Some(configured) = zone.ha_entity_id.clone() else { return; };
|
||||
zone.ha_entity_id = home_assistant::resolve_entity_id(settings, Some(&configured));
|
||||
}
|
||||
|
||||
async fn canonicalize_saved_zone_entities(state: &AppState, settings: &HomeAssistantSettings) -> Result<(), AppError> {
|
||||
let mut zone_ids: Vec<String> = state.db.list_zones()?.into_iter().map(|zone| zone.id).collect();
|
||||
zone_ids.sort();
|
||||
zone_ids.dedup();
|
||||
@@ -172,348 +426,68 @@ async fn canonicalize_saved_zone_entities(state: &AppState, settings: &RuntimeSe
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_night_mode(settings: &mut RuntimeSettings) -> Result<(), AppError> {
|
||||
NaiveTime::parse_from_str(&settings.night_mode.start_time, "%H:%M")
|
||||
.map_err(|_| AppError::BadRequest("night mode start time must use HH:MM".into()))?;
|
||||
NaiveTime::parse_from_str(&settings.night_mode.end_time, "%H:%M")
|
||||
.map_err(|_| AppError::BadRequest("night mode end time must use HH:MM".into()))?;
|
||||
settings.night_mode.max_fan_speed = settings.night_mode.max_fan_speed.clamp(1, 5);
|
||||
Ok(())
|
||||
fn apply_home_assistant_update(
|
||||
current: &HomeAssistantSettings,
|
||||
input: HomeAssistantSettingsUpdate,
|
||||
) -> HomeAssistantSettings {
|
||||
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,
|
||||
sensor_aliases: input.sensor_aliases,
|
||||
flow_inputs: input.flow_inputs,
|
||||
};
|
||||
if let Some(token) = input.token { next.token = token; }
|
||||
next
|
||||
}
|
||||
|
||||
async fn export_settings(State(state): State<AppState>) -> Result<Json<ConfigurationExport>, AppError> {
|
||||
let settings = state.settings.read().await.clone();
|
||||
let mut export = state.db.export_configuration(settings)?;
|
||||
// Backups are configuration snapshots, not a way to resurrect transient ownership,
|
||||
// timers or a stale physical device state after restore (K9).
|
||||
sanitize_configuration_runtime(&mut export);
|
||||
Ok(Json(export))
|
||||
}
|
||||
|
||||
fn validate_configuration_export(export: &ConfigurationExport) -> Result<(), AppError> {
|
||||
if !matches!(export.format_version, 1 | 2) { return Err(AppError::BadRequest("unsupported configuration export version".into())); }
|
||||
influxdb::validate(&export.settings.influxdb).map_err(|err| AppError::BadRequest(err.to_string()))?;
|
||||
if !matches!(export.settings.house_mode.as_str(), "cool" | "heat" | "off") {
|
||||
return Err(AppError::BadRequest("import contains an invalid house mode".into()));
|
||||
}
|
||||
|
||||
let devices: std::collections::HashSet<&str> = export.devices.iter().map(|item| item.id.as_str()).collect();
|
||||
let zones: std::collections::HashSet<&str> = export.zones.iter().map(|item| item.id.as_str()).collect();
|
||||
let schedules: std::collections::HashSet<&str> = export.schedules.iter().map(|item| item.id.as_str()).collect();
|
||||
let automations: std::collections::HashSet<&str> = export.automations.iter().map(|item| item.id.as_str()).collect();
|
||||
let flows: std::collections::HashSet<&str> = export.flows.iter().map(|item| item.id.as_str()).collect();
|
||||
let draft_flows: std::collections::HashSet<&str> = export.flows.iter().filter(|item| item.draft).map(|item| item.id.as_str()).collect();
|
||||
if export.flows.iter().any(|item| item.draft && (item.enabled || !item.compiled_schedule_ids.is_empty() || !item.compiled_automation_ids.is_empty()))
|
||||
|| export.schedules.iter().any(|item| item.flow_id.as_deref().is_some_and(|id| draft_flows.contains(id)))
|
||||
|| export.automations.iter().any(|item| item.flow_id.as_deref().is_some_and(|id| draft_flows.contains(id)))
|
||||
{
|
||||
return Err(AppError::BadRequest("import contains an executable Flow draft".into()));
|
||||
}
|
||||
if devices.len() != export.devices.len() || zones.len() != export.zones.len()
|
||||
|| schedules.len() != export.schedules.len() || automations.len() != export.automations.len() || flows.len() != export.flows.len()
|
||||
|| devices.contains("") || zones.contains("") || schedules.contains("") || automations.contains("") || flows.contains("")
|
||||
{
|
||||
return Err(AppError::BadRequest("import contains duplicate or empty resource IDs".into()));
|
||||
}
|
||||
let device_macs: std::collections::HashSet<&str> = export.devices.iter().map(|item| item.mac.as_str()).collect();
|
||||
if device_macs.len() != export.devices.len() {
|
||||
return Err(AppError::BadRequest("import contains duplicate device MAC addresses".into()));
|
||||
}
|
||||
if export.zones.iter().any(|item| !devices.contains(item.device_id.as_str())) {
|
||||
return Err(AppError::BadRequest("import contains a zone referencing a missing device".into()));
|
||||
}
|
||||
let mut zone_devices = std::collections::HashSet::new();
|
||||
for zone in &export.zones {
|
||||
if !zone_devices.insert(zone.device_id.as_str()) {
|
||||
return Err(AppError::BadRequest("import assigns one device to more than one thermostat zone".into()));
|
||||
}
|
||||
if !matches!(zone.mode.as_str(), "cool" | "heat") {
|
||||
return Err(AppError::BadRequest("import contains an invalid zone mode".into()));
|
||||
}
|
||||
if !matches!(zone.sensor_source.as_str(), "device" | "home_assistant" | "combined") {
|
||||
return Err(AppError::BadRequest("import contains an invalid zone sensor source".into()));
|
||||
}
|
||||
}
|
||||
|
||||
if export.schedules.iter().any(|item| !zones.contains(item.zone_id.as_str())) {
|
||||
return Err(AppError::BadRequest("import contains a schedule referencing a missing zone".into()));
|
||||
}
|
||||
for item in &export.schedules {
|
||||
if item.flow_id.as_deref().is_some_and(|flow_id| !flows.contains(flow_id)) {
|
||||
return Err(AppError::BadRequest("import contains a Flow-generated schedule referencing a missing Flow".into()));
|
||||
}
|
||||
if item.weekdays.is_empty() || item.weekdays.iter().any(|day| !(1..=7).contains(day)) {
|
||||
return Err(AppError::BadRequest("import contains invalid schedule weekdays".into()));
|
||||
}
|
||||
NaiveTime::parse_from_str(&item.start_time, "%H:%M").map_err(|_| AppError::BadRequest("import contains an invalid schedule start time".into()))?;
|
||||
NaiveTime::parse_from_str(&item.end_time, "%H:%M").map_err(|_| AppError::BadRequest("import contains an invalid schedule end time".into()))?;
|
||||
if !matches!(item.preset.as_str(), "comfort" | "sleep" | "away" | "custom") {
|
||||
return Err(AppError::BadRequest("import contains an invalid schedule preset".into()));
|
||||
}
|
||||
if item.preset == "custom" && !(8.0..=30.0).contains(&item.setpoint) {
|
||||
return Err(AppError::BadRequest("import contains an invalid schedule setpoint".into()));
|
||||
}
|
||||
}
|
||||
validate_schedule_set(&export.schedules)?;
|
||||
|
||||
if export.groups.iter().any(|group| {
|
||||
let members: std::collections::HashSet<&str> = group.zone_ids.iter().map(String::as_str).collect();
|
||||
group.id.trim().is_empty() || group.zone_ids.is_empty() || members.len() != group.zone_ids.len()
|
||||
|| group.zone_ids.iter().any(|zone_id| !zones.contains(zone_id.as_str()))
|
||||
}) {
|
||||
return Err(AppError::BadRequest("import contains an invalid group, duplicate members or a missing zone reference".into()));
|
||||
}
|
||||
let groups: std::collections::HashSet<&str> = export.groups.iter().map(|item| item.id.as_str()).collect();
|
||||
if groups.len() != export.groups.len() {
|
||||
return Err(AppError::BadRequest("import contains duplicate group IDs".into()));
|
||||
}
|
||||
|
||||
for item in &export.automations {
|
||||
match item.trigger_kind.as_str() {
|
||||
"temperature_above" | "temperature_below" => {
|
||||
let Some(trigger_id) = item.trigger_device_id.as_deref() else {
|
||||
return Err(AppError::BadRequest("import contains a temperature automation without a trigger device".into()));
|
||||
};
|
||||
if !devices.contains(trigger_id) || item.threshold.is_none() {
|
||||
return Err(AppError::BadRequest("import contains an invalid temperature automation trigger".into()));
|
||||
}
|
||||
}
|
||||
"time" => {
|
||||
let at = item.at_time.as_deref().ok_or_else(|| AppError::BadRequest("import contains a time automation without at_time".into()))?;
|
||||
NaiveTime::parse_from_str(at, "%H:%M").map_err(|_| AppError::BadRequest("import contains an invalid automation time".into()))?;
|
||||
}
|
||||
"flow" => {
|
||||
if item.flow_id.as_deref().filter(|id| flows.contains(*id)).is_none() || item.flow_conditions.is_empty() {
|
||||
return Err(AppError::BadRequest("import contains an invalid Flow-generated automation".into()));
|
||||
}
|
||||
}
|
||||
_ => return Err(AppError::BadRequest("import contains an unsupported automation trigger".into())),
|
||||
}
|
||||
|
||||
if let Some(zone_id) = item.action_zone_id.as_deref().filter(|value| !value.is_empty()) {
|
||||
if !zones.contains(zone_id) { return Err(AppError::BadRequest("import contains a Flow automation referencing a missing zone".into())); }
|
||||
if let Some(preset) = item.action_zone_preset.as_deref() {
|
||||
if !matches!(preset, "auto" | "custom" | "comfort" | "sleep" | "away") {
|
||||
return Err(AppError::BadRequest("import contains an invalid Flow thermostat preset".into()));
|
||||
}
|
||||
}
|
||||
if item.action_zone_preset.as_deref() == Some("custom")
|
||||
&& item.action.target_temperature.is_some_and(|value| !(8.0..=30.0).contains(&value))
|
||||
{
|
||||
return Err(AppError::BadRequest("import contains an invalid Flow thermostat target".into()));
|
||||
}
|
||||
} else if let Some(group_id) = item.action_group_id.as_deref().filter(|value| !value.is_empty()) {
|
||||
if !groups.contains(group_id) {
|
||||
return Err(AppError::BadRequest("import contains an automation referencing a missing group".into()));
|
||||
}
|
||||
if let Some(mode) = item.action.mode.as_deref() {
|
||||
if !matches!(mode, "auto" | "house" | "cool" | "heat") {
|
||||
return Err(AppError::BadRequest("import contains an invalid group automation mode".into()));
|
||||
}
|
||||
}
|
||||
let flow_custom_group = item.flow_id.is_some() && item.action_preset.as_deref() == Some("custom");
|
||||
if let Some(preset) = item.action_preset.as_deref() {
|
||||
if !matches!(preset, "auto" | "comfort" | "sleep" | "away") && !(flow_custom_group && preset == "custom") {
|
||||
return Err(AppError::BadRequest("import contains an invalid group automation preset".into()));
|
||||
}
|
||||
}
|
||||
if flow_custom_group {
|
||||
let Some(target) = item.action.target_temperature else { return Err(AppError::BadRequest("import contains a Flow custom group preset without a target".into())); };
|
||||
if !(8.0..=30.0).contains(&target) { return Err(AppError::BadRequest("import contains an invalid Flow group target".into())); }
|
||||
} else if item.action.target_temperature.is_some() {
|
||||
return Err(AppError::BadRequest("import contains unsupported target temperature in a group automation".into()));
|
||||
}
|
||||
if item.action.fan_speed.is_some() || item.action.swing_vertical.is_some() || item.action.swing_horizontal.is_some()
|
||||
|| item.action.quiet.is_some() || item.action.turbo.is_some() || item.action.light.is_some()
|
||||
|| item.action.air.is_some() || item.action.xfan.is_some() || item.action.health.is_some() || item.action.sleep.is_some()
|
||||
{
|
||||
return Err(AppError::BadRequest("import contains unsupported device fields in a group automation".into()));
|
||||
}
|
||||
if item.action.power.is_none() && item.action.mode.is_none() && item.action_preset.as_deref().filter(|v| !v.is_empty()).is_none() {
|
||||
return Err(AppError::BadRequest("import contains an empty group automation action".into()));
|
||||
}
|
||||
} else {
|
||||
if !devices.contains(item.action_device_id.as_str()) {
|
||||
return Err(AppError::BadRequest("import contains an automation referencing a missing device".into()));
|
||||
}
|
||||
engine::validate_command(&item.action)?;
|
||||
if item.action.is_empty() {
|
||||
return Err(AppError::BadRequest("import contains an empty automation action".into()));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn sanitize_configuration_runtime(export: &mut ConfigurationExport) {
|
||||
let now = Utc::now();
|
||||
// Do not restore the pre-v0.9.3 persistent global-OFF gate from backups.
|
||||
export.settings.house_power_enabled = true;
|
||||
for device in &mut export.devices {
|
||||
device.power = false;
|
||||
device.mode = "cool".into();
|
||||
device.target_temperature = 23.0;
|
||||
device.fan_speed = 0;
|
||||
device.swing_vertical = false;
|
||||
device.swing_horizontal = false;
|
||||
device.quiet = false;
|
||||
device.turbo = false;
|
||||
device.light = false;
|
||||
device.air = false;
|
||||
device.xfan = false;
|
||||
device.health = false;
|
||||
device.sleep = false;
|
||||
device.current_temperature = None;
|
||||
device.outdoor_temperature = None;
|
||||
device.online = false;
|
||||
device.response_time_ms = None;
|
||||
device.last_seen = None;
|
||||
device.last_error = None;
|
||||
device.communication_failures = 0;
|
||||
device.updated_at = now;
|
||||
}
|
||||
for zone in &mut export.zones {
|
||||
zone.device_temperature = None;
|
||||
zone.external_temperature = None;
|
||||
zone.current_temperature = None;
|
||||
zone.control_temperature_source = "device".into();
|
||||
zone.active_preset = "comfort".into();
|
||||
zone.manual_preset = None;
|
||||
zone.manual_setpoint = None;
|
||||
zone.manual_override_until = None;
|
||||
zone.local_thermostat_power = None;
|
||||
zone.local_thermostat_resume_at = None;
|
||||
zone.local_thermostat_restore_zone_enabled = None;
|
||||
zone.temporary_quick_thermostat = None;
|
||||
zone.device_manual_override = false;
|
||||
zone.device_manual_override_since = None;
|
||||
zone.device_manual_override_until = None;
|
||||
zone.device_manual_override_fields.clear();
|
||||
zone.device_manual_override_baseline = None;
|
||||
zone.control_owner = "automation".into();
|
||||
zone.control_source = "automation".into();
|
||||
zone.control_since = None;
|
||||
zone.control_resume_at = None;
|
||||
zone.control_reason = "Imported configuration; runtime ownership reset".into();
|
||||
zone.last_power_change_at = None;
|
||||
zone.last_mode_change_at = None;
|
||||
zone.lockout_until = None;
|
||||
zone.lockout_reason = None;
|
||||
zone.compressor_pending_action = None;
|
||||
zone.compressor_pending_since = None;
|
||||
zone.compressor_pending_until = None;
|
||||
zone.compressor_cancelled_action = None;
|
||||
zone.effective_mode.clear();
|
||||
zone.effective_setpoint = None;
|
||||
zone.device_setpoint = None;
|
||||
zone.demand = false;
|
||||
zone.demand_since = None;
|
||||
zone.target_alerted_at = None;
|
||||
zone.last_action_at = None;
|
||||
zone.revision = 0;
|
||||
zone.updated_at = now;
|
||||
}
|
||||
for automation in &mut export.automations {
|
||||
automation.last_fired_at = None;
|
||||
automation.updated_at = now;
|
||||
}
|
||||
}
|
||||
|
||||
async fn import_settings(State(state): State<AppState>, Json(mut export): Json<ConfigurationExport>) -> Result<Json<Value>, AppError> {
|
||||
validate_configuration_export(&export)?;
|
||||
export.settings.history_retention_days = export.settings.history_retention_days.clamp(1, 3650);
|
||||
export.settings.event_log_retention_days = export.settings.event_log_retention_days.clamp(1, 3650);
|
||||
export.settings.compressor_protection_seconds = export.settings.compressor_protection_seconds.clamp(30, 1800);
|
||||
normalize_sensor_aliases(&mut export.settings);
|
||||
canonicalize_home_assistant_entities(&mut export.settings);
|
||||
for zone in &mut export.zones { canonicalize_zone_ha_entity(zone, &export.settings); }
|
||||
validate_night_mode(&mut export.settings)?;
|
||||
export.settings.influxdb.history_threshold_days = export.settings.influxdb.history_threshold_days.clamp(1, 3650);
|
||||
|
||||
// Configuration replacement is the broadest mutation in the application. Serialize it
|
||||
// against every structural editor and every live owner that can write zone/device state.
|
||||
// Global lock order: configuration -> automation -> house -> schedule -> thermostat-cycle -> zones -> devices.
|
||||
async fn update_home_assistant_settings(
|
||||
State(state): State<AppState>,
|
||||
Json(input): Json<HomeAssistantSettingsUpdate>,
|
||||
) -> Result<Json<HomeAssistantSettingsView>, AppError> {
|
||||
let _configuration_guard = state.lock_configuration_operation().await;
|
||||
let _automation_guard = state.lock_automation_operation().await;
|
||||
let _house_guard = state.lock_house_operation().await;
|
||||
let _schedule_guard = state.lock_schedule_operation().await;
|
||||
let _cycle_guard = state.lock_zone_control_cycle().await;
|
||||
|
||||
let current_zones = state.db.list_zones()?;
|
||||
let current_devices = state.db.list_devices()?;
|
||||
let mut locked_zone_ids: Vec<String> = current_zones.iter().map(|zone| zone.id.clone())
|
||||
.chain(export.zones.iter().map(|zone| zone.id.clone()))
|
||||
.collect();
|
||||
locked_zone_ids.sort();
|
||||
locked_zone_ids.dedup();
|
||||
let mut _zone_guards = Vec::with_capacity(locked_zone_ids.len());
|
||||
for zone_id in &locked_zone_ids {
|
||||
_zone_guards.push(state.lock_zone_operation(zone_id).await);
|
||||
}
|
||||
|
||||
let mut locked_device_ids: Vec<String> = current_devices.iter().map(|device| device.id.clone())
|
||||
.chain(export.devices.iter().map(|device| device.id.clone()))
|
||||
.collect();
|
||||
locked_device_ids.sort();
|
||||
locked_device_ids.dedup();
|
||||
let mut _device_guards = Vec::with_capacity(locked_device_ids.len());
|
||||
for device_id in &locked_device_ids {
|
||||
_device_guards.push(state.lock_device_operation(device_id).await);
|
||||
}
|
||||
|
||||
// Before replacing ownership, safely stop every currently managed device whose zone is
|
||||
// removed or rewired by the imported configuration. Otherwise an orphaned physical unit
|
||||
// could keep running after its database owner disappears.
|
||||
let imported_zone_map: std::collections::HashMap<String, String> = export.zones.iter()
|
||||
.map(|zone| (zone.id.clone(), zone.device_id.clone()))
|
||||
.collect();
|
||||
let mut detach_devices = std::collections::HashSet::new();
|
||||
for current in ¤t_zones {
|
||||
if imported_zone_map.get(¤t.id).map(String::as_str) != Some(current.device_id.as_str()) {
|
||||
detach_devices.insert(current.device_id.clone());
|
||||
}
|
||||
}
|
||||
for device_id in detach_devices {
|
||||
let Some(device) = state.db.get_device(&device_id)? else { continue; };
|
||||
if !device.enabled {
|
||||
return Err(AppError::BadRequest("cannot safely detach a technically disabled device; enable it so the controller can confirm it is powered off first".into()));
|
||||
}
|
||||
engine::force_power_off_device_locked(&state, &device_id).await?;
|
||||
state.log("info", "zone.detach_power_off", &format!("Powered off {} before detaching thermostat ownership", device.name), json!({
|
||||
"device_id": device.id, "source": "configuration.import"
|
||||
}));
|
||||
}
|
||||
|
||||
// Configuration import never restores ephemeral owners/timers or cached physical state.
|
||||
// Imported devices are reconciled from a fresh poll and current house/zone gates.
|
||||
sanitize_configuration_runtime(&mut export);
|
||||
state.initial_device_sync_complete.store(false, Ordering::Release);
|
||||
state.db.replace_configuration(&export)?;
|
||||
state.debug_gree_frames.store(export.settings.debug.gree_frames, Ordering::Relaxed);
|
||||
*state.settings.write().await = export.settings.clone();
|
||||
|
||||
let controllable_devices: std::collections::HashSet<String> = export.zones.iter()
|
||||
.filter(|zone| {
|
||||
let effective_mode = if zone.inherit_house_mode { export.settings.house_mode.as_str() } else { zone.mode.as_str() };
|
||||
zone.enabled
|
||||
&& effective_mode != "off"
|
||||
})
|
||||
.map(|zone| zone.device_id.clone())
|
||||
.collect();
|
||||
for device in export.devices.iter().filter(|device| device.enabled && !controllable_devices.contains(&device.id)) {
|
||||
if let Err(err) = engine::force_power_off_device_locked(&state, &device.id).await {
|
||||
state.log("error", "settings.import_reconcile_error", &err.to_string(), json!({"device_id": device.id}));
|
||||
return Err(err);
|
||||
}
|
||||
}
|
||||
// Rebuild live device snapshots before allowing the thermostat loop to make decisions.
|
||||
// Network failures are represented in device health by poll_one rather than reviving
|
||||
// imported cache values.
|
||||
engine::poll_all_locked(&state).await?;
|
||||
state.initial_device_sync_complete.store(true, Ordering::Release);
|
||||
let payload = {
|
||||
let mut settings = state.settings.write().await;
|
||||
let mut next = apply_home_assistant_update(&settings.home_assistant, input.clone());
|
||||
normalize_sensor_aliases(&mut next);
|
||||
validate_flow_shared_inputs(&mut next, &state)?;
|
||||
canonicalize_home_assistant_entities(&mut next);
|
||||
validate_home_assistant_url(&next)?;
|
||||
settings.home_assistant = next.clone();
|
||||
settings.outdoor_assist_enabled = input.outdoor_assist_enabled;
|
||||
state.db.save_runtime_settings(&settings)?;
|
||||
home_assistant_settings(&settings)
|
||||
};
|
||||
let saved = state.settings.read().await.home_assistant.clone();
|
||||
canonicalize_saved_zone_entities(&state, &saved).await?;
|
||||
state.log("info", "settings.home_assistant.updated", "Home Assistant settings updated", json!({
|
||||
"configured": payload.token_configured,
|
||||
"flow_inputs": payload.flow_inputs.len()
|
||||
}));
|
||||
state.broadcast("settings.home_assistant.updated", serde_json::to_value(&payload)?);
|
||||
state.wake_zone_control();
|
||||
state.log("info", "settings.imported", "Application configuration imported", json!({"format_version": export.format_version}));
|
||||
state.broadcast("configuration.imported", json!({"at": Utc::now()}));
|
||||
Ok(Json(json!({"ok": true})))
|
||||
Ok(Json(payload))
|
||||
}
|
||||
|
||||
async fn get_debug_settings(State(state): State<AppState>) -> Json<DebugSettings> {
|
||||
Json(state.settings.read().await.debug.clone())
|
||||
}
|
||||
|
||||
async fn update_debug_settings(
|
||||
State(state): State<AppState>,
|
||||
Json(input): Json<DebugSettings>,
|
||||
) -> Result<Json<DebugSettings>, AppError> {
|
||||
let _configuration_guard = state.lock_configuration_operation().await;
|
||||
{
|
||||
let mut settings = state.settings.write().await;
|
||||
settings.debug = input.clone();
|
||||
state.db.save_runtime_settings(&settings)?;
|
||||
}
|
||||
state.debug_gree_frames.store(input.gree_frames, Ordering::Relaxed);
|
||||
state.broadcast("settings.debug.updated", serde_json::to_value(&input)?);
|
||||
Ok(Json(input))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user