fn map_home_assistant_integration_error(error: anyhow::Error) -> AppError { let message = error.to_string(); if message.contains("Home Assistant URL is not configured") || message.contains("Home Assistant token is not configured") || message.contains("Home Assistant Supervisor token is not available") || message.contains("invalid Home Assistant URL") || message.contains("Home Assistant URL must use http or https") || message.contains("cannot build Home Assistant API URL") || message.contains("cannot build Home Assistant service URL") { AppError::BadRequest(message) } else { AppError::Dependency(message) } } fn map_notification_test_error(message: String) -> AppError { if matches!( message.as_str(), "Pushover credentials are incomplete" | "invalid webhook URL" | "webhook URL must use HTTPS" | "webhook host is not supported" | "unsupported notification provider" ) { AppError::BadRequest(message) } else { AppError::Dependency(message) } } async fn home_assistant_snapshot( State(state): State, ) -> Result, AppError> { let control_plan = engine::get_control_plan_snapshot(&state).await?; let groups = list_home_assistant_groups(State(state.clone())).await?.0; Ok(Json(json!({ "devices": state.db.list_devices()?, "control_plan": control_plan.plan.as_ref(), "control_plan_revision": control_plan.revision, "groups": groups, }))) } async fn test_home_assistant( State(state): State, ) -> Result, AppError> { let settings = state.settings.read().await.clone(); let sample = home_assistant::test_connection(&state.http, &settings.home_assistant) .await .map_err(map_home_assistant_integration_error)?; Ok(Json(json!({ "ok": true, "auth_mode": home_assistant::auth_mode(&settings.home_assistant), "sample": sample, }))) } async fn list_home_assistant_entities( State(state): State, ) -> Result, AppError> { let settings = state.settings.read().await.home_assistant.clone(); if !home_assistant::token_configured(&settings) || home_assistant::effective_url(&settings).trim().is_empty() { return Ok(Json(json!({ "configured": false, "entities": [], }))); } let mut entities = home_assistant::list_entities(&state.http, &settings) .await .map_err(map_home_assistant_integration_error)? .into_iter() .filter_map(|entity| { let entity_id = entity.get("entity_id")?.as_str()?.trim(); if entity_id.is_empty() { return None; } let attributes = entity.get("attributes").and_then(Value::as_object); Some(json!({ "entity_id": entity_id, "name": attributes.and_then(|value| value.get("friendly_name")).and_then(Value::as_str).unwrap_or_default(), "state": entity.get("state").and_then(Value::as_str).unwrap_or_default(), "unit": attributes.and_then(|value| value.get("unit_of_measurement")).and_then(Value::as_str).unwrap_or_default(), "device_class": attributes.and_then(|value| value.get("device_class")).and_then(Value::as_str).unwrap_or_default(), })) }) .collect::>(); entities.sort_by(|a, b| { a.get("entity_id") .and_then(Value::as_str) .unwrap_or_default() .cmp(b.get("entity_id").and_then(Value::as_str).unwrap_or_default()) }); Ok(Json(json!({ "configured": true, "entities": entities, }))) } #[derive(Debug, Deserialize)] struct HaEntityRequest { entity_id: String, } async fn inspect_home_assistant_entity( State(state): State, Json(input): Json, ) -> Result, AppError> { let settings = state.settings.read().await.clone(); let entity_id = home_assistant::resolve_entity_id(&settings.home_assistant, Some(input.entity_id.as_str())) .filter(|value| !value.trim().is_empty()) .ok_or_else(|| AppError::BadRequest("Home Assistant entity_id is required".into()))?; let payload = home_assistant::read_entity( &state.http, &settings.home_assistant, Some(entity_id.as_str()), ) .await .map_err(map_home_assistant_integration_error)?; let raw_state = payload .get("state") .and_then(Value::as_str) .unwrap_or_default() .to_string(); let available = !matches!(raw_state.as_str(), "unknown" | "unavailable" | ""); Ok(Json(json!({ "ok": true, "entity_id": entity_id, "state": raw_state, "available": available, "attributes": payload.get("attributes").cloned().unwrap_or_else(|| json!({})), "last_changed": payload.get("last_changed").cloned().unwrap_or(Value::Null), "last_updated": payload.get("last_updated").cloned().unwrap_or(Value::Null), }))) } async fn test_notifications( State(state): State, Json(mut input): Json, ) -> Result, AppError> { let old = state.settings.read().await.notifications.clone(); if input.pushover_app_token.trim().is_empty() { input.pushover_app_token = old.pushover_app_token; } if input.pushover_user_key.trim().is_empty() { input.pushover_user_key = old.pushover_user_key; } if input.slack_webhook_url.trim().is_empty() { input.slack_webhook_url = old.slack_webhook_url; } if input.discord_webhook_url.trim().is_empty() { input.discord_webhook_url = old.discord_webhook_url; } notifications::test(&state, input) .await .map_err(map_notification_test_error)?; Ok(Json(json!({"ok": true}))) } async fn list_home_assistant_energy_sensors( State(state): State, ) -> Result, AppError> { let settings = state.settings.read().await.home_assistant.clone(); if !home_assistant::token_configured(&settings) || home_assistant::effective_url(&settings).trim().is_empty() { return Ok(Json(json!({ "configured": false, "sensors": [], }))); } let entities = home_assistant::list_entities(&state.http, &settings) .await .map_err(|error| AppError::Dependency(error.to_string()))?; let sensors = entities .into_iter() .filter_map(|entity| { let attributes = entity.get("attributes")?.as_object()?; let device_class = attributes.get("device_class")?.as_str()?; let state_class = attributes.get("state_class")?.as_str()?; let unit = attributes.get("unit_of_measurement")?.as_str()?; if device_class != "energy" || !matches!(state_class, "total" | "total_increasing") || !matches!(unit.to_ascii_lowercase().as_str(), "wh" | "kwh") { return None; } Some(json!({ "entity_id": entity.get("entity_id").and_then(Value::as_str).unwrap_or_default(), "name": attributes.get("friendly_name").and_then(Value::as_str).unwrap_or_default(), "state": entity.get("state").and_then(Value::as_str).unwrap_or_default(), "unit": unit, "device_class": device_class, "state_class": state_class, })) }) .collect::>(); Ok(Json(json!({ "configured": true, "sensors": sensors, }))) }