v0.12.0-preety_code

This commit is contained in:
Mateusz Gruszczyński
2026-09-03 15:33:37 +02:00
parent be67932b47
commit 07be4b85d9
87 changed files with 11691 additions and 3172 deletions
+208 -64
View File
@@ -1,5 +1,7 @@
fn application_settings(settings: &RuntimeSettings) -> ApplicationSettings {
ApplicationSettings { simulator_enabled: settings.simulator_enabled }
ApplicationSettings {
simulator_enabled: settings.simulator_enabled,
}
}
fn gree_settings(settings: &RuntimeSettings) -> GreeSettings {
@@ -83,8 +85,16 @@ async fn update_application_settings(
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)?);
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))
}
@@ -102,21 +112,33 @@ fn normalize_gree_settings(mut input: GreeSettings) -> Result<GreeSettings, AppE
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
.to_ascii_lowercase()
.starts_with("auto:"))
{
input.discovery_broadcast.parse::<std::net::SocketAddr>()
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();
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; };
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()
@@ -143,7 +165,8 @@ async fn update_gree_settings(
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
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;
@@ -159,10 +182,15 @@ async fn update_gree_settings(
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.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))
@@ -188,11 +216,16 @@ async fn update_history_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.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))
}
@@ -201,7 +234,10 @@ async fn get_influxdb_settings(State(state): State<AppState>) -> Json<InfluxDbSe
Json(influxdb_settings(&*state.settings.read().await))
}
fn apply_influxdb_update(current: &InfluxDbSettings, input: InfluxDbSettingsUpdate) -> Result<InfluxDbSettings, AppError> {
fn apply_influxdb_update(
current: &InfluxDbSettings,
input: InfluxDbSettingsUpdate,
) -> Result<InfluxDbSettings, AppError> {
let mut next = InfluxDbSettings {
enabled: input.enabled,
version: input.version,
@@ -214,8 +250,12 @@ fn apply_influxdb_update(current: &InfluxDbSettings, input: InfluxDbSettingsUpda
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; }
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)
}
@@ -232,24 +272,38 @@ async fn update_influxdb_settings(
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.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> {
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> {
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()));
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()));
return Err(AppError::BadRequest(
"unsupported notification provider".into(),
));
}
let mut next = NotificationSettings {
enabled: input.enabled,
@@ -264,10 +318,18 @@ fn apply_notification_update(current: &NotificationSettings, mut input: Notifica
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; }
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)
}
@@ -283,8 +345,16 @@ async fn update_notification_settings(
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)?);
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))
}
@@ -313,24 +383,37 @@ async fn update_night_settings(
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.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> {
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
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>()))
if entity.is_empty() || alias.is_empty() {
return None;
}
Some((
entity.chars().take(160).collect::<String>(),
alias.chars().take(80).collect::<String>(),
))
})
.collect();
}
@@ -338,39 +421,69 @@ fn normalize_sensor_aliases(settings: &mut HomeAssistantSettings) {
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()));
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()));
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()));
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 !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()));
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()));
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()));
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> {
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)?;
@@ -385,36 +498,55 @@ fn canonicalize_home_assistant_entities(settings: &mut HomeAssistantSettings) {
}
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)) {
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(()); }
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()));
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; };
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();
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 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 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 {
@@ -440,7 +572,9 @@ fn apply_home_assistant_update(
sensor_aliases: input.sensor_aliases,
flow_inputs: input.flow_inputs,
};
if let Some(token) = input.token { next.token = token; }
if let Some(token) = input.token {
next.token = token;
}
next
}
@@ -464,11 +598,19 @@ async fn update_home_assistant_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.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))
}
@@ -487,7 +629,9 @@ async fn update_debug_settings(
settings.debug = input.clone();
state.db.save_runtime_settings(&settings)?;
}
state.debug_gree_frames.store(input.gree_frames, Ordering::Relaxed);
state
.debug_gree_frames
.store(input.gree_frames, Ordering::Relaxed);
state.broadcast("settings.debug.updated", serde_json::to_value(&input)?);
Ok(Json(input))
}