651 lines
24 KiB
Rust
651 lines
24 KiB
Rust
fn application_settings(settings: &RuntimeSettings) -> ApplicationSettings {
|
|
ApplicationSettings {
|
|
simulator_enabled: settings.simulator_enabled,
|
|
}
|
|
}
|
|
|
|
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,
|
|
}
|
|
}
|
|
|
|
fn settings_snapshot(settings: &RuntimeSettings) -> SettingsSnapshot {
|
|
SettingsSnapshot {
|
|
application: application_settings(settings),
|
|
gree: gree_settings(settings),
|
|
history: history_settings(settings),
|
|
influxdb: influxdb_settings(settings),
|
|
notifications: notification_settings(settings),
|
|
night: settings.night_mode.clone(),
|
|
home_assistant: home_assistant_settings(settings),
|
|
debug: settings.debug.clone(),
|
|
}
|
|
}
|
|
|
|
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()));
|
|
}
|
|
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);
|
|
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
|
|
.parse::<std::net::SocketAddr>()
|
|
.map_err(|_| AppError::BadRequest("invalid discovery broadcast address".into()))?;
|
|
}
|
|
Ok(input)
|
|
}
|
|
|
|
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();
|
|
let alias = alias.trim();
|
|
if entity.is_empty() || alias.is_empty() {
|
|
return None;
|
|
}
|
|
Some((
|
|
entity.chars().take(160).collect::<String>(),
|
|
alias.chars().take(80).collect::<String>(),
|
|
))
|
|
})
|
|
.collect();
|
|
}
|
|
|
|
fn normalize_flow_shared_inputs(settings: &mut HomeAssistantSettings) -> Result<(), AppError> {
|
|
let mut ids = std::collections::HashSet::new();
|
|
if settings.flow_inputs.len() > 128 {
|
|
return Err(AppError::BadRequest(
|
|
"too many shared Flow inputs (max 128)".into(),
|
|
));
|
|
}
|
|
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();
|
|
if item.id.is_empty() || item.name.is_empty() {
|
|
return Err(AppError::BadRequest(
|
|
"shared Flow input requires id and name".into(),
|
|
));
|
|
}
|
|
if !ids.insert(item.id.clone()) {
|
|
return Err(AppError::BadRequest(
|
|
"shared Flow input IDs must be unique".into(),
|
|
));
|
|
}
|
|
if !matches!(
|
|
item.kind.as_str(),
|
|
"outdoor_temperature"
|
|
| "device_temperature"
|
|
| "zone_temperature"
|
|
| "ha_state"
|
|
| "ha_numeric"
|
|
| "ha_attribute"
|
|
| "ha_available"
|
|
| "house_mode"
|
|
| "device_state"
|
|
| "zone_state"
|
|
| "group_state"
|
|
| "night_mode"
|
|
| "constant"
|
|
) {
|
|
return Err(AppError::BadRequest(format!(
|
|
"unsupported shared Flow input kind: {}",
|
|
item.kind
|
|
)));
|
|
}
|
|
if !item.config.is_object() {
|
|
return Err(AppError::BadRequest(
|
|
"shared Flow input config must be an object".into(),
|
|
));
|
|
}
|
|
if item.config.get("operator").is_some() {
|
|
return Err(AppError::BadRequest(
|
|
"shared Flow inputs are value sources; operator belongs to the Flow block".into(),
|
|
));
|
|
}
|
|
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 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))
|
|
{
|
|
settings.outdoor_entity_id = entity_id;
|
|
}
|
|
}
|
|
}
|
|
|
|
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(())
|
|
}
|
|
|
|
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();
|
|
for zone_id in zone_ids {
|
|
let _zone_guard = state.lock_zone_operation(&zone_id).await;
|
|
let Some(snapshot) = state.db.get_zone(&zone_id)? else {
|
|
continue;
|
|
};
|
|
let _device_guard = state.lock_device_operation(&snapshot.device_id).await;
|
|
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 {
|
|
zone.updated_at = Utc::now();
|
|
state.db.save_zone(&zone)?;
|
|
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
|
|
}
|
|
}
|
|
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 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 _cycle_guard = state.lock_zone_control_cycle().await;
|
|
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();
|
|
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))
|
|
}
|