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
18pub 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 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 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 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
474pub 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
581const 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 listener_count: 0,
602 })
603 .collect(),
604 ))
605}
606
607pub(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 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
695pub 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 #[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}