Skip to main content

homecore_api/
rest.rs

1use axum::extract::{Path, Query, State};
2use axum::http::{HeaderMap, StatusCode};
3use axum::response::IntoResponse;
4use axum::Json;
5use serde::{Deserialize, Serialize};
6
7use homecore::{Context, EntityId};
8
9use crate::auth::BearerAuth;
10use crate::error::{ApiError, ApiResult};
11use crate::state::SharedState;
12
13#[derive(Serialize)]
14pub struct ApiRunning {
15    message: &'static str,
16}
17
18/// `GET /api/` — the HA `APIStatusView` ("API running." ping).
19///
20/// Security (HC-API-AUTH-01): HA's `APIStatusView` inherits
21/// `requires_auth = True` from `HomeAssistantView`, so an unauthenticated
22/// (or wrong-token) request to `/api/` returns **401**, not 200. HA
23/// clients (and the companion app) rely on this status route as a
24/// *token-validation probe* — a 200 here would tell a client a bad token
25/// is good, and would let an unauthenticated party confirm a live
26/// HOMECORE-API endpoint. The P2 handler skipped the bearer gate that
27/// every sibling route applies; this restores wire-compat by validating
28/// the bearer like `get_config`/`get_states` before replying.
29pub async fn api_root(
30    headers: HeaderMap,
31    State(s): State<SharedState>,
32) -> ApiResult<Json<ApiRunning>> {
33    let _ = BearerAuth::from_headers(&headers, s.tokens()).await?;
34    Ok(Json(ApiRunning {
35        message: "API running.",
36    }))
37}
38
39#[derive(Serialize)]
40pub struct ApiConfig {
41    location_name: String,
42    version: String,
43    state: &'static str,
44    components: Vec<String>,
45}
46
47const LOADED_COMPONENTS: &[&str] = &[
48    "api",
49    "automation",
50    "config",
51    "homecore",
52    "recorder",
53    "websocket_api",
54];
55
56pub async fn get_config(
57    headers: HeaderMap,
58    State(s): State<SharedState>,
59) -> ApiResult<Json<ApiConfig>> {
60    let _ = BearerAuth::from_headers(&headers, s.tokens()).await?;
61    Ok(Json(ApiConfig {
62        location_name: s.location_name().to_string(),
63        version: s.version().to_string(),
64        state: "RUNNING",
65        components: LOADED_COMPONENTS
66            .iter()
67            .map(|component| (*component).to_owned())
68            .collect(),
69    }))
70}
71
72pub async fn get_components(
73    headers: HeaderMap,
74    State(s): State<SharedState>,
75) -> ApiResult<Json<Vec<String>>> {
76    let _ = BearerAuth::from_headers(&headers, s.tokens()).await?;
77    Ok(Json(
78        LOADED_COMPONENTS
79            .iter()
80            .map(|component| (*component).to_owned())
81            .collect(),
82    ))
83}
84
85#[derive(Serialize)]
86pub struct StateView {
87    pub entity_id: String,
88    pub state: String,
89    pub attributes: serde_json::Value,
90    pub last_changed: String,
91    pub last_updated: String,
92    pub context: ContextView,
93}
94
95#[derive(Debug, Deserialize)]
96pub struct HistoryQuery {
97    filter_entity_id: Option<String>,
98    end_time: Option<String>,
99    #[serde(default)]
100    minimal_response: bool,
101    #[serde(default)]
102    no_attributes: bool,
103    #[serde(default)]
104    significant_changes_only: bool,
105}
106
107const MAX_HISTORY_ENTITIES: usize = 32;
108const MAX_API_HISTORY_ROWS: usize = 100_000;
109
110pub async fn get_history(
111    headers: HeaderMap,
112    State(s): State<SharedState>,
113    Query(query): Query<HistoryQuery>,
114) -> ApiResult<Json<Vec<Vec<StateView>>>> {
115    history_response(headers, s, None, query).await
116}
117
118pub async fn get_history_period(
119    headers: HeaderMap,
120    State(s): State<SharedState>,
121    Path(start_time): Path<String>,
122    Query(query): Query<HistoryQuery>,
123) -> ApiResult<Json<Vec<Vec<StateView>>>> {
124    history_response(headers, s, Some(start_time), query).await
125}
126
127async fn history_response(
128    headers: HeaderMap,
129    state: SharedState,
130    start_time: Option<String>,
131    query: HistoryQuery,
132) -> ApiResult<Json<Vec<Vec<StateView>>>> {
133    let _ = BearerAuth::from_headers(&headers, state.tokens()).await?;
134    let recorder = state
135        .recorder()
136        .ok_or_else(|| ApiError::Unavailable("recorder is disabled".into()))?;
137    let now = chrono::Utc::now();
138    let start = match start_time {
139        Some(value) => parse_history_time(&value)?,
140        None => now - chrono::Duration::days(1),
141    };
142    let end = match query.end_time.as_deref() {
143        Some(value) => parse_history_time(value)?,
144        None => now,
145    };
146    if end < start {
147        return Err(ApiError::BadRequest(
148            "end_time must not precede start_time".into(),
149        ));
150    }
151
152    let explicit_filter = query.filter_entity_id.is_some();
153    let entity_ids = match query.filter_entity_id.as_deref() {
154        Some(raw) => raw
155            .split(',')
156            .map(str::trim)
157            .filter(|value| !value.is_empty())
158            .map(|value| {
159                EntityId::parse(value)
160                    .map_err(|error| ApiError::BadRequest(format!("invalid entity_id: {error}")))
161            })
162            .collect::<ApiResult<Vec<_>>>()?,
163        None => state
164            .homecore()
165            .states()
166            .all()
167            .into_iter()
168            .map(|snapshot| snapshot.entity_id.clone())
169            .collect(),
170    };
171    // Only reject an explicit, unusually-large `filter_entity_id` list. The
172    // real HA frontend's history page calls this endpoint with NO filter by
173    // design (meaning "all entities") — a real install routinely has 50-500+
174    // entities, so applying this cap there rejected the single most common
175    // call shape outright. The `MAX_API_HISTORY_ROWS` total-row budget below
176    // already bounds the actual work regardless of entity count.
177    if explicit_filter && entity_ids.len() > MAX_HISTORY_ENTITIES {
178        return Err(ApiError::BadRequest(format!(
179            "history queries are limited to {MAX_HISTORY_ENTITIES} explicitly filtered entities"
180        )));
181    }
182
183    let mut result = Vec::with_capacity(entity_ids.len());
184    let mut remaining = MAX_API_HISTORY_ROWS;
185    for entity_id in entity_ids {
186        let rows = recorder
187            .get_state_history_limited(&entity_id, start, end, remaining)
188            .await
189            .map_err(|error| ApiError::Internal(format!("history query failed: {error}")))?;
190        remaining = remaining.saturating_sub(rows.len());
191        let mut previous_state: Option<String> = None;
192        let states = rows
193            .into_iter()
194            .filter_map(|row| {
195                if query.significant_changes_only
196                    && previous_state.as_deref() == Some(row.state.as_str())
197                {
198                    return None;
199                }
200                previous_state = Some(row.state.clone());
201                let changed = history_timestamp(row.last_changed_ts);
202                let updated = history_timestamp(row.last_updated_ts);
203                Some(StateView {
204                    entity_id: row.entity_id.as_str().to_owned(),
205                    state: row.state,
206                    attributes: if query.no_attributes || query.minimal_response {
207                        serde_json::json!({})
208                    } else {
209                        row.attributes
210                    },
211                    last_changed: changed,
212                    last_updated: updated,
213                    context: ContextView {
214                        id: row.context_id.unwrap_or_default(),
215                        user_id: None,
216                        parent_id: None,
217                    },
218                })
219            })
220            .collect();
221        result.push(states);
222    }
223    Ok(Json(result))
224}
225
226fn parse_history_time(value: &str) -> ApiResult<chrono::DateTime<chrono::Utc>> {
227    chrono::DateTime::parse_from_rfc3339(value)
228        .map(|value| value.with_timezone(&chrono::Utc))
229        .map_err(|_| ApiError::BadRequest("history timestamps must be RFC 3339".into()))
230}
231
232fn history_timestamp(seconds: f64) -> String {
233    let whole = seconds.floor() as i64;
234    let nanos = ((seconds - seconds.floor()) * 1_000_000_000.0).round() as u32;
235    chrono::DateTime::<chrono::Utc>::from_timestamp(whole, nanos.min(999_999_999))
236        .unwrap_or(chrono::DateTime::<chrono::Utc>::UNIX_EPOCH)
237        .to_rfc3339()
238}
239
240#[derive(Debug, Deserialize)]
241pub struct LogbookQuery {
242    end_time: Option<String>,
243    entity: Option<String>,
244}
245
246pub async fn get_logbook(
247    headers: HeaderMap,
248    State(s): State<SharedState>,
249    Query(query): Query<LogbookQuery>,
250) -> ApiResult<Json<Vec<serde_json::Value>>> {
251    logbook_response(headers, s, None, query).await
252}
253
254pub async fn get_logbook_period(
255    headers: HeaderMap,
256    State(s): State<SharedState>,
257    Path(start_time): Path<String>,
258    Query(query): Query<LogbookQuery>,
259) -> ApiResult<Json<Vec<serde_json::Value>>> {
260    logbook_response(headers, s, Some(start_time), query).await
261}
262
263async fn logbook_response(
264    headers: HeaderMap,
265    state: SharedState,
266    start_time: Option<String>,
267    query: LogbookQuery,
268) -> ApiResult<Json<Vec<serde_json::Value>>> {
269    let _ = BearerAuth::from_headers(&headers, state.tokens()).await?;
270    let recorder = state
271        .recorder()
272        .ok_or_else(|| ApiError::Unavailable("recorder is disabled".into()))?;
273    let now = chrono::Utc::now();
274    let start = match start_time {
275        Some(value) => parse_history_time(&value)?,
276        None => now - chrono::Duration::days(1),
277    };
278    let end = match query.end_time.as_deref() {
279        Some(value) => parse_history_time(value)?,
280        None => now,
281    };
282    if end < start {
283        return Err(ApiError::BadRequest(
284            "end_time must not precede start_time".into(),
285        ));
286    }
287    let explicit_filter = query.entity.is_some();
288    let entity_ids = match query.entity.as_deref() {
289        Some(raw) => raw
290            .split(',')
291            .map(str::trim)
292            .filter(|value| !value.is_empty())
293            .map(|value| {
294                EntityId::parse(value)
295                    .map_err(|error| ApiError::BadRequest(format!("invalid entity_id: {error}")))
296            })
297            .collect::<ApiResult<Vec<_>>>()?,
298        None => state
299            .homecore()
300            .states()
301            .all()
302            .into_iter()
303            .map(|snapshot| snapshot.entity_id.clone())
304            .collect(),
305    };
306    // See the matching comment in `history_response`: only reject an
307    // explicit, unusually-large filter — the default (no filter, "all
308    // entities") is the real HA frontend's normal call shape, and the
309    // `MAX_API_HISTORY_ROWS` row budget below already bounds the work.
310    if explicit_filter && entity_ids.len() > MAX_HISTORY_ENTITIES {
311        return Err(ApiError::BadRequest(format!(
312            "logbook queries are limited to {MAX_HISTORY_ENTITIES} explicitly filtered entities"
313        )));
314    }
315    let mut entries = Vec::new();
316    let mut remaining = MAX_API_HISTORY_ROWS;
317    for entity_id in entity_ids {
318        let rows = recorder
319            .get_state_history_limited(&entity_id, start, end, remaining)
320            .await
321            .map_err(|error| ApiError::Internal(format!("logbook query failed: {error}")))?;
322        remaining = remaining.saturating_sub(rows.len());
323        for row in rows {
324            entries.push(serde_json::json!({
325                "when": history_timestamp(row.last_updated_ts),
326                "name": row.entity_id.as_str(),
327                "state": row.state,
328                "entity_id": row.entity_id.as_str(),
329                "context_id": row.context_id
330            }));
331        }
332    }
333    entries.sort_by(|left, right| {
334        left["when"]
335            .as_str()
336            .cmp(&right["when"].as_str())
337            .then_with(|| left["entity_id"].as_str().cmp(&right["entity_id"].as_str()))
338    });
339    Ok(Json(entries))
340}
341
342#[derive(Serialize)]
343pub struct CalendarView {
344    entity_id: String,
345    name: String,
346}
347
348pub async fn get_calendars(
349    headers: HeaderMap,
350    State(s): State<SharedState>,
351) -> ApiResult<Json<Vec<CalendarView>>> {
352    let _ = BearerAuth::from_headers(&headers, s.tokens()).await?;
353    let calendars = s
354        .homecore()
355        .states()
356        .all_by_domain("calendar")
357        .into_iter()
358        .map(|state| CalendarView {
359            entity_id: state.entity_id.as_str().to_owned(),
360            name: state
361                .attributes
362                .get("friendly_name")
363                .and_then(serde_json::Value::as_str)
364                .unwrap_or(state.entity_id.as_str())
365                .to_owned(),
366        })
367        .collect();
368    Ok(Json(calendars))
369}
370
371#[derive(Debug, Deserialize)]
372pub struct CalendarQuery {
373    start: String,
374    end: String,
375}
376
377pub async fn get_calendar_events(
378    headers: HeaderMap,
379    State(s): State<SharedState>,
380    Path(entity_id): Path<String>,
381    Query(query): Query<CalendarQuery>,
382) -> ApiResult<Json<Vec<serde_json::Value>>> {
383    let _ = BearerAuth::from_headers(&headers, s.tokens()).await?;
384    let id =
385        EntityId::parse(&entity_id).map_err(|error| ApiError::BadRequest(error.to_string()))?;
386    if id.domain() != "calendar" || s.homecore().states().get(&id).is_none() {
387        return Err(ApiError::NotFound(entity_id));
388    }
389    let start = parse_history_time(&query.start)?;
390    let end = parse_history_time(&query.end)?;
391    if end < start {
392        return Err(ApiError::BadRequest(
393            "end must not precede start".to_owned(),
394        ));
395    }
396    // Calendar integrations may expose their current entity without an event
397    // provider. An empty list is the valid response for that interval.
398    Ok(Json(Vec::new()))
399}
400
401pub async fn get_camera_proxy(
402    headers: HeaderMap,
403    State(s): State<SharedState>,
404    Path(entity_id): Path<String>,
405) -> ApiResult<StatusCode> {
406    let _ = BearerAuth::from_headers(&headers, s.tokens()).await?;
407    let id =
408        EntityId::parse(&entity_id).map_err(|error| ApiError::BadRequest(error.to_string()))?;
409    if id.domain() != "camera" || s.homecore().states().get(&id).is_none() {
410        return Err(ApiError::NotFound(entity_id));
411    }
412    Err(ApiError::Unavailable(
413        "camera integration has no image provider".into(),
414    ))
415}
416
417#[derive(Serialize)]
418pub struct ContextView {
419    pub id: String,
420    pub user_id: Option<String>,
421    pub parent_id: Option<String>,
422}
423
424impl StateView {
425    pub fn from_state(s: &homecore::State) -> Self {
426        Self {
427            entity_id: s.entity_id.as_str().to_string(),
428            state: s.state.clone(),
429            attributes: s.attributes.clone(),
430            last_changed: s.last_changed.to_rfc3339(),
431            last_updated: s.last_updated.to_rfc3339(),
432            context: ContextView {
433                id: s.context.id.to_string(),
434                user_id: s.context.user_id.clone(),
435                parent_id: s.context.parent_id.map(|p| p.to_string()),
436            },
437        }
438    }
439}
440
441pub async fn get_states(
442    headers: HeaderMap,
443    State(s): State<SharedState>,
444) -> ApiResult<Json<Vec<StateView>>> {
445    let _ = BearerAuth::from_headers(&headers, s.tokens()).await?;
446    let snapshots = s.homecore().states().all();
447    Ok(Json(
448        snapshots.iter().map(|x| StateView::from_state(x)).collect(),
449    ))
450}
451
452pub async fn get_state(
453    headers: HeaderMap,
454    State(s): State<SharedState>,
455    Path(entity_id): Path<String>,
456) -> ApiResult<Json<StateView>> {
457    let _ = BearerAuth::from_headers(&headers, s.tokens()).await?;
458    let id = EntityId::parse(entity_id.clone()).map_err(|e| ApiError::BadRequest(e.to_string()))?;
459    let st = s
460        .homecore()
461        .states()
462        .get(&id)
463        .ok_or(ApiError::NotFound(entity_id))?;
464    Ok(Json(StateView::from_state(&st)))
465}
466
467#[derive(Deserialize)]
468pub struct SetStateRequest {
469    pub state: String,
470    #[serde(default)]
471    pub attributes: serde_json::Value,
472}
473
474/// DELETE /api/states/:entity_id — remove an entity from the state
475/// machine. Idempotent: returns 204 whether or not the entity existed,
476/// matching HA's removal semantics. 4xx only for malformed entity_id or
477/// auth failure.
478pub async fn delete_state(
479    headers: HeaderMap,
480    State(s): State<SharedState>,
481    Path(entity_id): Path<String>,
482) -> ApiResult<StatusCode> {
483    let _ = BearerAuth::from_headers(&headers, s.tokens()).await?;
484    let id = EntityId::parse(entity_id).map_err(|e| ApiError::BadRequest(e.to_string()))?;
485    s.homecore().states().remove(&id);
486    Ok(StatusCode::NO_CONTENT)
487}
488
489pub async fn set_state(
490    headers: HeaderMap,
491    State(s): State<SharedState>,
492    Path(entity_id): Path<String>,
493    Json(body): Json<SetStateRequest>,
494) -> ApiResult<(StatusCode, Json<StateView>)> {
495    let _ = BearerAuth::from_headers(&headers, s.tokens()).await?;
496    let id = EntityId::parse(entity_id).map_err(|e| ApiError::BadRequest(e.to_string()))?;
497    let existed = s.homecore().states().get(&id).is_some();
498    let attrs = if body.attributes.is_null() {
499        serde_json::json!({})
500    } else {
501        body.attributes
502    };
503    let snap = s
504        .homecore()
505        .states()
506        .set(id, body.state, attrs, Context::new());
507    let status = if existed {
508        StatusCode::OK
509    } else {
510        StatusCode::CREATED
511    };
512    Ok((status, Json(StateView::from_state(&snap))))
513}
514
515#[derive(Serialize)]
516pub struct ServiceDomainView {
517    pub domain: String,
518    pub services: serde_json::Value,
519}
520
521pub async fn get_services(
522    headers: HeaderMap,
523    State(s): State<SharedState>,
524) -> ApiResult<Json<Vec<ServiceDomainView>>> {
525    let _ = BearerAuth::from_headers(&headers, s.tokens()).await?;
526    let services = s.homecore().services().registered_services().await;
527    let mut by_domain: std::collections::HashMap<
528        String,
529        serde_json::Map<String, serde_json::Value>,
530    > = std::collections::HashMap::new();
531    for sv in services {
532        by_domain
533            .entry(sv.domain.clone())
534            .or_default()
535            .insert(sv.service.clone(), serde_json::json!({}));
536    }
537    Ok(Json(
538        by_domain
539            .into_iter()
540            .map(|(domain, services)| ServiceDomainView {
541                domain,
542                services: serde_json::Value::Object(services),
543            })
544            .collect(),
545    ))
546}
547
548pub async fn call_service(
549    headers: HeaderMap,
550    State(s): State<SharedState>,
551    Path((domain, service)): Path<(String, String)>,
552    Json(body): Json<serde_json::Value>,
553) -> ApiResult<Json<serde_json::Value>> {
554    use homecore::{ServiceCall, ServiceName};
555    let _ = BearerAuth::from_headers(&headers, s.tokens()).await?;
556    let call = ServiceCall {
557        name: ServiceName::new(domain.clone(), service.clone()),
558        data: body,
559        context: Context::new(),
560    };
561    let resp = s
562        .homecore()
563        .services()
564        .call(call)
565        .await
566        .map_err(|e| match e {
567            homecore::ServiceError::NotRegistered { .. } => {
568                ApiError::ServiceNotRegistered { domain, service }
569            }
570            other => ApiError::Internal(other.to_string()),
571        })?;
572    Ok(Json(resp))
573}
574
575#[derive(Serialize)]
576pub struct EventView {
577    pub event: String,
578    pub listener_count: usize,
579}
580
581/// Event types whose wire shape is implemented by the core event bridge.
582const CORE_EVENT_TYPES: &[&str] = &[
583    "state_changed",
584    "call_service",
585    "homeassistant_start",
586    "homeassistant_stop",
587];
588
589pub async fn get_events(
590    headers: HeaderMap,
591    State(s): State<SharedState>,
592) -> ApiResult<Json<Vec<EventView>>> {
593    let _ = BearerAuth::from_headers(&headers, s.tokens()).await?;
594    Ok(Json(
595        CORE_EVENT_TYPES
596            .iter()
597            .map(|event| EventView {
598                event: (*event).to_owned(),
599                // Tokio broadcast intentionally does not expose a stable
600                // per-filter count. Zero is HA-compatible and honest.
601                listener_count: 0,
602            })
603            .collect(),
604    ))
605}
606
607/// Whether `event_type` is acceptable to fire on the domain bus.
608///
609/// Real Home Assistant places essentially no format restriction on event
610/// types beyond "non-empty string" — integrations commonly fire types with
611/// mixed case, dots, or hyphens (e.g. `mobile_app.notification_action`,
612/// `ios.action_fired`). The original check here only accepted
613/// `[a-z0-9_]+`, silently rejecting any of those — a real behavioral gap
614/// versus the documented contract, not a security boundary (this endpoint is
615/// already bearer-authenticated). We keep only the bounds that protect the
616/// server itself: non-empty, a sane length cap, and no control characters
617/// (which could otherwise corrupt log lines or downstream storage).
618pub(crate) fn is_valid_event_type(event_type: &str) -> bool {
619    !event_type.is_empty()
620        && event_type.len() <= 255
621        && event_type.chars().all(|ch| !ch.is_control())
622}
623
624pub async fn fire_event(
625    headers: HeaderMap,
626    State(s): State<SharedState>,
627    Path(event_type): Path<String>,
628    Json(body): Json<serde_json::Value>,
629) -> ApiResult<Json<serde_json::Value>> {
630    let _ = BearerAuth::from_headers(&headers, s.tokens()).await?;
631    if !is_valid_event_type(&event_type) {
632        return Err(ApiError::BadRequest("invalid event_type".into()));
633    }
634    if !body.is_object() && !body.is_null() {
635        return Err(ApiError::BadRequest("event data must be an object".into()));
636    }
637    let data = if body.is_null() {
638        serde_json::json!({})
639    } else {
640        body
641    };
642    s.homecore().bus().fire_domain(homecore::DomainEvent::new(
643        event_type.clone(),
644        data,
645        Context::new(),
646    ));
647    Ok(Json(
648        serde_json::json!({"message": format!("Event {event_type} fired.")}),
649    ))
650}
651
652#[derive(Deserialize)]
653pub struct TemplateRequest {
654    pub template: String,
655}
656
657pub async fn render_template(
658    headers: HeaderMap,
659    State(s): State<SharedState>,
660    Json(body): Json<TemplateRequest>,
661) -> ApiResult<String> {
662    let _ = BearerAuth::from_headers(&headers, s.tokens()).await?;
663    let environment = homecore_automation::TemplateEnvironment::new(std::sync::Arc::new(
664        s.homecore().states().clone(),
665    ));
666    environment
667        .render(&body.template)
668        .map_err(|error| ApiError::BadRequest(error.to_string()))
669}
670
671pub async fn check_config(
672    headers: HeaderMap,
673    State(s): State<SharedState>,
674) -> ApiResult<Json<serde_json::Value>> {
675    let _ = BearerAuth::from_headers(&headers, s.tokens()).await?;
676    // Runtime configuration has already passed HOMECORE's typed loaders.
677    Ok(Json(serde_json::json!({
678        "result": "valid",
679        "errors": null,
680        "warnings": null
681    })))
682}
683
684pub async fn error_log(
685    headers: HeaderMap,
686    State(s): State<SharedState>,
687) -> ApiResult<impl IntoResponse> {
688    let _ = BearerAuth::from_headers(&headers, s.tokens()).await?;
689    Ok((
690        [("content-type", "text/plain; charset=utf-8")],
691        String::new(),
692    ))
693}
694
695/// Machine-readable support matrix. This prevents clients from confusing
696/// core protocol compatibility with every optional HA integration.
697pub async fn compatibility(
698    headers: HeaderMap,
699    State(s): State<SharedState>,
700) -> ApiResult<Json<serde_json::Value>> {
701    let _ = BearerAuth::from_headers(&headers, s.tokens()).await?;
702    Ok(Json(serde_json::json!({
703        "baseline": "Home Assistant Core 2025.1",
704        "rest": {
705            "core": "implemented",
706            "events": "implemented",
707            "template": "implemented",
708            "check_config": "implemented",
709            "error_log": "implemented",
710            "history": "implemented_when_recorder_enabled",
711            "logbook": "implemented_when_recorder_enabled",
712            "calendar": "implemented_with_integration_supplied_events",
713            "camera": "implemented_with_integration_supplied_images",
714            "media": "integration_dependent"
715        },
716        "websocket": {
717            "auth": "implemented",
718            "states_services_config": "implemented",
719            "events": "implemented",
720            "render_template": "implemented",
721            "feature_negotiation_and_panels": "implemented",
722            "registry_lists": {
723                "entity": "implemented",
724                "device": "implemented",
725                "area": "implemented_empty",
726                "mutations": "requires_persistent_registry_backend"
727            },
728            "lovelace_media": "integration_dependent"
729        }
730    })))
731}
732
733#[cfg(test)]
734mod tests {
735    use super::is_valid_event_type;
736
737    /// Real HA integrations commonly fire event types with mixed case, dots,
738    /// or hyphens (e.g. `mobile_app.notification_action`). The original
739    /// `[a-z0-9_]+`-only check rejected all of these; only non-empty,
740    /// length, and control-character bounds should remain.
741    #[test]
742    fn realistic_ha_event_types_are_accepted() {
743        assert!(is_valid_event_type("mobile_app.notification_action"));
744        assert!(is_valid_event_type("ios.action_fired"));
745        assert!(is_valid_event_type("Custom-Event.2"));
746        assert!(is_valid_event_type("state_changed"));
747    }
748
749    #[test]
750    fn empty_oversized_or_control_char_event_types_are_rejected() {
751        assert!(!is_valid_event_type(""));
752        assert!(!is_valid_event_type(&"a".repeat(256)));
753        assert!(!is_valid_event_type("bad\nevent"));
754        assert!(!is_valid_event_type("bad\tevent"));
755    }
756}