mirror of
https://github.com/ruvnet/RuView
synced 2026-07-28 18:21:42 +00:00
feat(homecore): add API compatibility and voice protocols
This commit is contained in:
Generated
+1
@@ -3550,6 +3550,7 @@ dependencies = [
|
||||
"dashmap",
|
||||
"futures-util",
|
||||
"homecore",
|
||||
"homecore-automation",
|
||||
"http-body-util",
|
||||
"hyper 1.8.1",
|
||||
"serde",
|
||||
|
||||
@@ -17,6 +17,7 @@ path = "src/bin/server.rs"
|
||||
|
||||
[dependencies]
|
||||
homecore = { path = "../homecore", version = "0.1.0-alpha.0" }
|
||||
homecore-automation = { path = "../homecore-automation", version = "0.1.0-alpha.0" }
|
||||
|
||||
axum = { version = "0.7", features = ["ws", "json", "macros"] }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
|
||||
@@ -36,6 +36,12 @@ pub fn router(state: SharedState) -> Router {
|
||||
)
|
||||
.route("/api/services", get(rest::get_services))
|
||||
.route("/api/services/:domain/:service", post(rest::call_service))
|
||||
.route("/api/events", get(rest::get_events))
|
||||
.route("/api/events/:event_type", post(rest::fire_event))
|
||||
.route("/api/template", post(rest::render_template))
|
||||
.route("/api/config/core/check_config", post(rest::check_config))
|
||||
.route("/api/error_log", get(rest::error_log))
|
||||
.route("/api/homecore/compatibility", get(rest::compatibility))
|
||||
.route("/api/websocket", get(ws::websocket_handler))
|
||||
.layer(cors)
|
||||
.layer(TraceLayer::new_for_http())
|
||||
@@ -58,11 +64,7 @@ pub fn build_cors_layer() -> CorsLayer {
|
||||
CorsLayer::new()
|
||||
.allow_origin(AllowOrigin::list(origins))
|
||||
.allow_methods([Method::GET, Method::POST, Method::OPTIONS, Method::DELETE])
|
||||
.allow_headers([
|
||||
header::AUTHORIZATION,
|
||||
header::CONTENT_TYPE,
|
||||
header::ACCEPT,
|
||||
])
|
||||
.allow_headers([header::AUTHORIZATION, header::CONTENT_TYPE, header::ACCEPT])
|
||||
.allow_credentials(false)
|
||||
}
|
||||
|
||||
@@ -108,7 +110,10 @@ mod tests {
|
||||
#[test]
|
||||
fn env_override_via_homecore_cors_origins() {
|
||||
let _env = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
std::env::set_var("HOMECORE_CORS_ORIGINS", "https://example.com,https://other.example.com");
|
||||
std::env::set_var(
|
||||
"HOMECORE_CORS_ORIGINS",
|
||||
"https://example.com,https://other.example.com",
|
||||
);
|
||||
// build_cors_layer() returns a CorsLayer which doesn't expose
|
||||
// its origin list; we test the parse path indirectly by
|
||||
// confirming no panic + at least one origin would parse.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use axum::extract::{Path, State};
|
||||
use axum::http::{HeaderMap, StatusCode};
|
||||
use axum::response::IntoResponse;
|
||||
use axum::Json;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -10,7 +11,9 @@ use crate::error::{ApiError, ApiResult};
|
||||
use crate::state::SharedState;
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct ApiRunning { message: &'static str }
|
||||
pub struct ApiRunning {
|
||||
message: &'static str,
|
||||
}
|
||||
|
||||
/// `GET /api/` — the HA `APIStatusView` ("API running." ping).
|
||||
///
|
||||
@@ -23,9 +26,14 @@ pub struct ApiRunning { message: &'static str }
|
||||
/// HOMECORE-API endpoint. The P2 handler skipped the bearer gate that
|
||||
/// every sibling route applies; this restores wire-compat by validating
|
||||
/// the bearer like `get_config`/`get_states` before replying.
|
||||
pub async fn api_root(headers: HeaderMap, State(s): State<SharedState>) -> ApiResult<Json<ApiRunning>> {
|
||||
pub async fn api_root(
|
||||
headers: HeaderMap,
|
||||
State(s): State<SharedState>,
|
||||
) -> ApiResult<Json<ApiRunning>> {
|
||||
let _ = BearerAuth::from_headers(&headers, s.tokens()).await?;
|
||||
Ok(Json(ApiRunning { message: "API running." }))
|
||||
Ok(Json(ApiRunning {
|
||||
message: "API running.",
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -36,7 +44,10 @@ pub struct ApiConfig {
|
||||
components: Vec<String>,
|
||||
}
|
||||
|
||||
pub async fn get_config(headers: HeaderMap, State(s): State<SharedState>) -> ApiResult<Json<ApiConfig>> {
|
||||
pub async fn get_config(
|
||||
headers: HeaderMap,
|
||||
State(s): State<SharedState>,
|
||||
) -> ApiResult<Json<ApiConfig>> {
|
||||
let _ = BearerAuth::from_headers(&headers, s.tokens()).await?;
|
||||
Ok(Json(ApiConfig {
|
||||
location_name: s.location_name().to_string(),
|
||||
@@ -80,10 +91,15 @@ impl StateView {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_states(headers: HeaderMap, State(s): State<SharedState>) -> ApiResult<Json<Vec<StateView>>> {
|
||||
pub async fn get_states(
|
||||
headers: HeaderMap,
|
||||
State(s): State<SharedState>,
|
||||
) -> ApiResult<Json<Vec<StateView>>> {
|
||||
let _ = BearerAuth::from_headers(&headers, s.tokens()).await?;
|
||||
let snapshots = s.homecore().states().all();
|
||||
Ok(Json(snapshots.iter().map(|x| StateView::from_state(x)).collect()))
|
||||
Ok(Json(
|
||||
snapshots.iter().map(|x| StateView::from_state(x)).collect(),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn get_state(
|
||||
@@ -93,7 +109,11 @@ pub async fn get_state(
|
||||
) -> ApiResult<Json<StateView>> {
|
||||
let _ = BearerAuth::from_headers(&headers, s.tokens()).await?;
|
||||
let id = EntityId::parse(entity_id.clone()).map_err(|e| ApiError::BadRequest(e.to_string()))?;
|
||||
let st = s.homecore().states().get(&id).ok_or(ApiError::NotFound(entity_id))?;
|
||||
let st = s
|
||||
.homecore()
|
||||
.states()
|
||||
.get(&id)
|
||||
.ok_or(ApiError::NotFound(entity_id))?;
|
||||
Ok(Json(StateView::from_state(&st)))
|
||||
}
|
||||
|
||||
@@ -128,9 +148,20 @@ pub async fn set_state(
|
||||
let _ = BearerAuth::from_headers(&headers, s.tokens()).await?;
|
||||
let id = EntityId::parse(entity_id).map_err(|e| ApiError::BadRequest(e.to_string()))?;
|
||||
let existed = s.homecore().states().get(&id).is_some();
|
||||
let attrs = if body.attributes.is_null() { serde_json::json!({}) } else { body.attributes };
|
||||
let snap = s.homecore().states().set(id, body.state, attrs, Context::new());
|
||||
let status = if existed { StatusCode::OK } else { StatusCode::CREATED };
|
||||
let attrs = if body.attributes.is_null() {
|
||||
serde_json::json!({})
|
||||
} else {
|
||||
body.attributes
|
||||
};
|
||||
let snap = s
|
||||
.homecore()
|
||||
.states()
|
||||
.set(id, body.state, attrs, Context::new());
|
||||
let status = if existed {
|
||||
StatusCode::OK
|
||||
} else {
|
||||
StatusCode::CREATED
|
||||
};
|
||||
Ok((status, Json(StateView::from_state(&snap))))
|
||||
}
|
||||
|
||||
@@ -140,17 +171,31 @@ pub struct ServiceDomainView {
|
||||
pub services: serde_json::Value,
|
||||
}
|
||||
|
||||
pub async fn get_services(headers: HeaderMap, State(s): State<SharedState>) -> ApiResult<Json<Vec<ServiceDomainView>>> {
|
||||
pub async fn get_services(
|
||||
headers: HeaderMap,
|
||||
State(s): State<SharedState>,
|
||||
) -> ApiResult<Json<Vec<ServiceDomainView>>> {
|
||||
let _ = BearerAuth::from_headers(&headers, s.tokens()).await?;
|
||||
let services = s.homecore().services().registered_services().await;
|
||||
let mut by_domain: std::collections::HashMap<String, serde_json::Map<String, serde_json::Value>> =
|
||||
std::collections::HashMap::new();
|
||||
let mut by_domain: std::collections::HashMap<
|
||||
String,
|
||||
serde_json::Map<String, serde_json::Value>,
|
||||
> = std::collections::HashMap::new();
|
||||
for sv in services {
|
||||
by_domain.entry(sv.domain.clone()).or_default().insert(sv.service.clone(), serde_json::json!({}));
|
||||
by_domain
|
||||
.entry(sv.domain.clone())
|
||||
.or_default()
|
||||
.insert(sv.service.clone(), serde_json::json!({}));
|
||||
}
|
||||
Ok(Json(by_domain.into_iter().map(|(domain, services)| ServiceDomainView {
|
||||
domain, services: serde_json::Value::Object(services),
|
||||
}).collect()))
|
||||
Ok(Json(
|
||||
by_domain
|
||||
.into_iter()
|
||||
.map(|(domain, services)| ServiceDomainView {
|
||||
domain,
|
||||
services: serde_json::Value::Object(services),
|
||||
})
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn call_service(
|
||||
@@ -166,9 +211,149 @@ pub async fn call_service(
|
||||
data: body,
|
||||
context: Context::new(),
|
||||
};
|
||||
let resp = s.homecore().services().call(call).await.map_err(|e| match e {
|
||||
homecore::ServiceError::NotRegistered { .. } => ApiError::ServiceNotRegistered { domain, service },
|
||||
other => ApiError::Internal(other.to_string()),
|
||||
})?;
|
||||
let resp = s
|
||||
.homecore()
|
||||
.services()
|
||||
.call(call)
|
||||
.await
|
||||
.map_err(|e| match e {
|
||||
homecore::ServiceError::NotRegistered { .. } => {
|
||||
ApiError::ServiceNotRegistered { domain, service }
|
||||
}
|
||||
other => ApiError::Internal(other.to_string()),
|
||||
})?;
|
||||
Ok(Json(resp))
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct EventView {
|
||||
pub event: String,
|
||||
pub listener_count: usize,
|
||||
}
|
||||
|
||||
/// Event types whose wire shape is implemented by the core event bridge.
|
||||
const CORE_EVENT_TYPES: &[&str] = &[
|
||||
"state_changed",
|
||||
"call_service",
|
||||
"homeassistant_start",
|
||||
"homeassistant_stop",
|
||||
];
|
||||
|
||||
pub async fn get_events(
|
||||
headers: HeaderMap,
|
||||
State(s): State<SharedState>,
|
||||
) -> ApiResult<Json<Vec<EventView>>> {
|
||||
let _ = BearerAuth::from_headers(&headers, s.tokens()).await?;
|
||||
Ok(Json(
|
||||
CORE_EVENT_TYPES
|
||||
.iter()
|
||||
.map(|event| EventView {
|
||||
event: (*event).to_owned(),
|
||||
// Tokio broadcast intentionally does not expose a stable
|
||||
// per-filter count. Zero is HA-compatible and honest.
|
||||
listener_count: 0,
|
||||
})
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn fire_event(
|
||||
headers: HeaderMap,
|
||||
State(s): State<SharedState>,
|
||||
Path(event_type): Path<String>,
|
||||
Json(body): Json<serde_json::Value>,
|
||||
) -> ApiResult<Json<serde_json::Value>> {
|
||||
let _ = BearerAuth::from_headers(&headers, s.tokens()).await?;
|
||||
if event_type.is_empty()
|
||||
|| event_type.len() > 255
|
||||
|| !event_type
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '_')
|
||||
{
|
||||
return Err(ApiError::BadRequest("invalid event_type".into()));
|
||||
}
|
||||
if !body.is_object() && !body.is_null() {
|
||||
return Err(ApiError::BadRequest("event data must be an object".into()));
|
||||
}
|
||||
let data = if body.is_null() {
|
||||
serde_json::json!({})
|
||||
} else {
|
||||
body
|
||||
};
|
||||
s.homecore()
|
||||
.bus()
|
||||
.fire_domain(homecore::DomainEvent::new(event_type, data, Context::new()));
|
||||
Ok(Json(serde_json::json!({"message": "Event fired."})))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct TemplateRequest {
|
||||
pub template: String,
|
||||
}
|
||||
|
||||
pub async fn render_template(
|
||||
headers: HeaderMap,
|
||||
State(s): State<SharedState>,
|
||||
Json(body): Json<TemplateRequest>,
|
||||
) -> ApiResult<String> {
|
||||
let _ = BearerAuth::from_headers(&headers, s.tokens()).await?;
|
||||
let environment = homecore_automation::TemplateEnvironment::new(std::sync::Arc::new(
|
||||
s.homecore().states().clone(),
|
||||
));
|
||||
environment
|
||||
.render(&body.template)
|
||||
.map_err(|error| ApiError::BadRequest(error.to_string()))
|
||||
}
|
||||
|
||||
pub async fn check_config(
|
||||
headers: HeaderMap,
|
||||
State(s): State<SharedState>,
|
||||
) -> ApiResult<Json<serde_json::Value>> {
|
||||
let _ = BearerAuth::from_headers(&headers, s.tokens()).await?;
|
||||
// Runtime configuration has already passed HOMECORE's typed loaders.
|
||||
Ok(Json(serde_json::json!({
|
||||
"result": "valid",
|
||||
"errors": null,
|
||||
"warnings": null
|
||||
})))
|
||||
}
|
||||
|
||||
pub async fn error_log(
|
||||
headers: HeaderMap,
|
||||
State(s): State<SharedState>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
let _ = BearerAuth::from_headers(&headers, s.tokens()).await?;
|
||||
Ok((
|
||||
[("content-type", "text/plain; charset=utf-8")],
|
||||
String::new(),
|
||||
))
|
||||
}
|
||||
|
||||
/// Machine-readable support matrix. This prevents clients from confusing
|
||||
/// core protocol compatibility with every optional HA integration.
|
||||
pub async fn compatibility(
|
||||
headers: HeaderMap,
|
||||
State(s): State<SharedState>,
|
||||
) -> ApiResult<Json<serde_json::Value>> {
|
||||
let _ = BearerAuth::from_headers(&headers, s.tokens()).await?;
|
||||
Ok(Json(serde_json::json!({
|
||||
"baseline": "Home Assistant Core 2025.1",
|
||||
"rest": {
|
||||
"core": "implemented",
|
||||
"events": "implemented",
|
||||
"template": "implemented",
|
||||
"check_config": "implemented",
|
||||
"error_log": "implemented",
|
||||
"history": "requires_recorder",
|
||||
"camera_calendar_media": "integration_dependent"
|
||||
},
|
||||
"websocket": {
|
||||
"auth": "implemented",
|
||||
"states_services_config": "implemented",
|
||||
"events": "implemented",
|
||||
"render_template": "implemented",
|
||||
"registries": "requires_registry_backend",
|
||||
"lovelace_media": "integration_dependent"
|
||||
}
|
||||
})))
|
||||
}
|
||||
|
||||
@@ -130,6 +130,10 @@ struct WsCommand {
|
||||
service: Option<String>,
|
||||
#[serde(default)]
|
||||
service_data: Option<serde_json::Value>,
|
||||
#[serde(default)]
|
||||
event_data: Option<serde_json::Value>,
|
||||
#[serde(default)]
|
||||
template: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -290,6 +294,50 @@ impl Connection {
|
||||
Err(e) => self.err(tx, cmd.id, "service_error", &e.to_string()),
|
||||
}
|
||||
}
|
||||
"fire_event" => {
|
||||
let Some(event_type) = cmd.event_type.clone() else {
|
||||
self.err(tx, cmd.id, "invalid_format", "event_type is required");
|
||||
return;
|
||||
};
|
||||
if event_type.is_empty()
|
||||
|| event_type.len() > 255
|
||||
|| !event_type
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '_')
|
||||
{
|
||||
self.err(tx, cmd.id, "invalid_format", "invalid event_type");
|
||||
return;
|
||||
}
|
||||
let event_data = cmd.event_data.unwrap_or_else(|| serde_json::json!({}));
|
||||
if !event_data.is_object() {
|
||||
self.err(tx, cmd.id, "invalid_format", "event_data must be an object");
|
||||
return;
|
||||
}
|
||||
self.state
|
||||
.homecore()
|
||||
.bus()
|
||||
.fire_domain(homecore::DomainEvent::new(
|
||||
event_type,
|
||||
event_data,
|
||||
Context::new(),
|
||||
));
|
||||
self.ack(tx, cmd.id, true, None);
|
||||
}
|
||||
"render_template" => {
|
||||
let Some(template) = cmd.template.as_deref() else {
|
||||
self.err(tx, cmd.id, "invalid_format", "template is required");
|
||||
return;
|
||||
};
|
||||
let environment = homecore_automation::TemplateEnvironment::new(Arc::new(
|
||||
self.state.homecore().states().clone(),
|
||||
));
|
||||
match environment.render(template) {
|
||||
Ok(rendered) => {
|
||||
self.ack(tx, cmd.id, true, Some(serde_json::Value::String(rendered)))
|
||||
}
|
||||
Err(error) => self.err(tx, cmd.id, "template_error", &error.to_string()),
|
||||
}
|
||||
}
|
||||
"subscribe_events" => {
|
||||
// HA uses the subscribing command ID as the subscription ID
|
||||
// in every emitted event and in `unsubscribe_events`.
|
||||
@@ -369,6 +417,7 @@ impl Connection {
|
||||
"data": de.event_data,
|
||||
"origin": format!("{:?}", de.origin).to_uppercase(),
|
||||
"time_fired": de.fired_at.to_rfc3339(),
|
||||
"context": de.context,
|
||||
}
|
||||
});
|
||||
if tx_clone.try_send(payload.to_string()).is_err() { break; }
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode};
|
||||
use homecore::HomeCore;
|
||||
use homecore_api::{router, LongLivedTokenStore, SharedState};
|
||||
use http_body_util::BodyExt;
|
||||
use tower::ServiceExt;
|
||||
|
||||
async fn app() -> (axum::Router, HomeCore) {
|
||||
let homecore = HomeCore::new();
|
||||
let tokens = LongLivedTokenStore::empty();
|
||||
tokens.register("test-token").await;
|
||||
let state = SharedState::with_tokens(homecore.clone(), "Test", "test", tokens);
|
||||
(router(state), homecore)
|
||||
}
|
||||
|
||||
fn post(uri: &str, body: &str) -> Request<Body> {
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri(uri)
|
||||
.header("authorization", "Bearer test-token")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(body.to_owned()))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rest_event_is_delivered_to_domain_bus() {
|
||||
let (app, homecore) = app().await;
|
||||
let mut receiver = homecore.bus().subscribe_domain();
|
||||
let response = app
|
||||
.oneshot(post("/api/events/test_event", r#"{"answer":42}"#))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let event = receiver.recv().await.unwrap();
|
||||
assert_eq!(event.event_type, "test_event");
|
||||
assert_eq!(event.event_data["answer"], 42);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rest_template_uses_live_state_environment() {
|
||||
let (app, _) = app().await;
|
||||
let response = app
|
||||
.oneshot(post("/api/template", r#"{"template":"{{ 6 * 7 }}"}"#))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let bytes = response.into_body().collect().await.unwrap().to_bytes();
|
||||
assert_eq!(&bytes[..], b"42");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn compatibility_matrix_is_authenticated() {
|
||||
let (app, _) = app().await;
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/homecore/compatibility")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
//! Bounded audio types shared by STT, TTS, and satellite transports.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
/// Maximum audio accepted in one chunk (256 KiB).
|
||||
pub const MAX_AUDIO_CHUNK_BYTES: usize = 256 * 1024;
|
||||
/// Maximum audio accepted in a single utterance (16 MiB).
|
||||
pub const MAX_UTTERANCE_AUDIO_BYTES: usize = 16 * 1024 * 1024;
|
||||
|
||||
/// Audio encodings supported by the native voice pipeline.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AudioCodec {
|
||||
/// Signed, little-endian, 16-bit PCM.
|
||||
PcmS16Le,
|
||||
}
|
||||
|
||||
/// A validated audio stream format.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||
pub struct AudioFormat {
|
||||
pub codec: AudioCodec,
|
||||
pub sample_rate: u32,
|
||||
pub channels: u8,
|
||||
}
|
||||
|
||||
impl AudioFormat {
|
||||
pub fn validate(self) -> Result<Self, AudioError> {
|
||||
if !(8_000..=48_000).contains(&self.sample_rate) {
|
||||
return Err(AudioError::InvalidSampleRate(self.sample_rate));
|
||||
}
|
||||
if !(1..=2).contains(&self.channels) {
|
||||
return Err(AudioError::InvalidChannels(self.channels));
|
||||
}
|
||||
Ok(self)
|
||||
}
|
||||
}
|
||||
|
||||
/// One bounded audio packet.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct AudioChunk {
|
||||
bytes: Vec<u8>,
|
||||
}
|
||||
|
||||
impl AudioChunk {
|
||||
pub fn new(bytes: Vec<u8>) -> Result<Self, AudioError> {
|
||||
if bytes.is_empty() {
|
||||
return Err(AudioError::Empty);
|
||||
}
|
||||
if bytes.len() > MAX_AUDIO_CHUNK_BYTES {
|
||||
return Err(AudioError::ChunkTooLarge(bytes.len()));
|
||||
}
|
||||
if bytes.len() % 2 != 0 {
|
||||
return Err(AudioError::UnalignedPcm);
|
||||
}
|
||||
Ok(Self { bytes })
|
||||
}
|
||||
|
||||
pub fn as_bytes(&self) -> &[u8] {
|
||||
&self.bytes
|
||||
}
|
||||
|
||||
pub fn into_bytes(self) -> Vec<u8> {
|
||||
self.bytes
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Error, Eq, PartialEq)]
|
||||
pub enum AudioError {
|
||||
#[error("audio chunk is empty")]
|
||||
Empty,
|
||||
#[error("audio chunk is {0} bytes; maximum is {MAX_AUDIO_CHUNK_BYTES}")]
|
||||
ChunkTooLarge(usize),
|
||||
#[error("16-bit PCM data must contain an even number of bytes")]
|
||||
UnalignedPcm,
|
||||
#[error("sample rate {0} Hz is outside 8000..=48000")]
|
||||
InvalidSampleRate(u32),
|
||||
#[error("channel count {0} is outside 1..=2")]
|
||||
InvalidChannels(u8),
|
||||
#[error("utterance audio exceeds {MAX_UTTERANCE_AUDIO_BYTES} bytes")]
|
||||
UtteranceTooLarge,
|
||||
}
|
||||
@@ -35,27 +35,41 @@
|
||||
//! honest path until it ships.
|
||||
//! - STT/TTS bridge and satellite protocol (P3).
|
||||
|
||||
pub mod intent;
|
||||
pub mod recognizer;
|
||||
pub mod semantic_recognizer;
|
||||
pub mod audio;
|
||||
pub mod handler;
|
||||
pub mod runner;
|
||||
pub mod intent;
|
||||
pub mod pipeline;
|
||||
pub mod recognizer;
|
||||
pub mod runner;
|
||||
pub mod satellite;
|
||||
pub mod semantic_recognizer;
|
||||
pub mod speech;
|
||||
pub mod voice;
|
||||
|
||||
/// Deterministic text embedding used by [`semantic_recognizer::SemanticIntentRecognizer`].
|
||||
#[cfg(feature = "semantic")]
|
||||
pub mod embedding;
|
||||
|
||||
pub use intent::{Card, Intent, IntentName, IntentResponse};
|
||||
pub use recognizer::{
|
||||
IntentRecognizer, RecognizerError, RegexIntentRecognizer, MAX_UTTERANCE_BYTES,
|
||||
};
|
||||
pub use semantic_recognizer::{SemanticIntentRecognizer, DEFAULT_SIMILARITY_THRESHOLD};
|
||||
pub use audio::{AudioChunk, AudioCodec, AudioError, AudioFormat};
|
||||
pub use handler::{
|
||||
HandlerError, HassCancelAll, HassLightSet, HassNevermind, HassTurnOff, HassTurnOn,
|
||||
IntentHandler,
|
||||
};
|
||||
pub use intent::{Card, Intent, IntentName, IntentResponse};
|
||||
pub use pipeline::AssistPipeline;
|
||||
pub use recognizer::{
|
||||
IntentRecognizer, RecognizerError, RegexIntentRecognizer, MAX_UTTERANCE_BYTES,
|
||||
};
|
||||
pub use runner::{
|
||||
AssistError, LocalRunner, NoopRunner, RufloResponse, RufloRunner, RufloRunnerOpts,
|
||||
};
|
||||
pub use pipeline::AssistPipeline;
|
||||
pub use satellite::{
|
||||
SatelliteClientMessage, SatelliteError, SatelliteServerMessage, SatelliteSession,
|
||||
SatelliteState, SATELLITE_PROTOCOL_VERSION,
|
||||
};
|
||||
pub use semantic_recognizer::{SemanticIntentRecognizer, DEFAULT_SIMILARITY_THRESHOLD};
|
||||
pub use speech::{
|
||||
DisabledStt, DisabledTts, SpeechError, SpeechToText, SynthesizedSpeech, TextToSpeech,
|
||||
Transcript,
|
||||
};
|
||||
pub use voice::{VoiceError, VoicePipeline, VoiceResponse, DEFAULT_VOICE_TIMEOUT};
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
//! Transport-independent satellite voice session protocol.
|
||||
//!
|
||||
//! Text frames use [`SatelliteClientMessage`] / [`SatelliteServerMessage`].
|
||||
//! Binary frames are accepted only while a stream is active.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::audio::{AudioChunk, AudioError, AudioFormat, MAX_UTTERANCE_AUDIO_BYTES};
|
||||
|
||||
pub const SATELLITE_PROTOCOL_VERSION: u16 = 1;
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum SatelliteClientMessage {
|
||||
Hello {
|
||||
version: u16,
|
||||
token: String,
|
||||
},
|
||||
Start {
|
||||
language: String,
|
||||
format: AudioFormat,
|
||||
},
|
||||
End,
|
||||
Cancel,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum SatelliteServerMessage {
|
||||
Ready { version: u16 },
|
||||
Started,
|
||||
Transcript { text: String, language: String },
|
||||
Intent { response: crate::IntentResponse },
|
||||
Audio { format: AudioFormat, bytes: usize },
|
||||
Finished,
|
||||
Error { code: String, message: String },
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum SatelliteState {
|
||||
AwaitingHello,
|
||||
Idle,
|
||||
Streaming,
|
||||
Closed,
|
||||
}
|
||||
|
||||
/// Strict state machine used by WebSocket or native satellite transports.
|
||||
pub struct SatelliteSession {
|
||||
state: SatelliteState,
|
||||
authenticated: bool,
|
||||
audio: Vec<u8>,
|
||||
format: Option<AudioFormat>,
|
||||
language: Option<String>,
|
||||
}
|
||||
|
||||
impl SatelliteSession {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
state: SatelliteState::AwaitingHello,
|
||||
authenticated: false,
|
||||
audio: Vec::new(),
|
||||
format: None,
|
||||
language: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn state(&self) -> SatelliteState {
|
||||
self.state
|
||||
}
|
||||
|
||||
/// Handle a control message. `authenticate` must perform constant-time
|
||||
/// credential comparison; credentials are never retained by this session.
|
||||
pub fn control(
|
||||
&mut self,
|
||||
message: SatelliteClientMessage,
|
||||
authenticate: impl FnOnce(&str) -> bool,
|
||||
) -> Result<SatelliteServerMessage, SatelliteError> {
|
||||
match (self.state, message) {
|
||||
(SatelliteState::AwaitingHello, SatelliteClientMessage::Hello { version, token }) => {
|
||||
if version != SATELLITE_PROTOCOL_VERSION {
|
||||
self.state = SatelliteState::Closed;
|
||||
return Err(SatelliteError::UnsupportedVersion(version));
|
||||
}
|
||||
if !authenticate(&token) {
|
||||
self.state = SatelliteState::Closed;
|
||||
return Err(SatelliteError::Unauthorized);
|
||||
}
|
||||
self.authenticated = true;
|
||||
self.state = SatelliteState::Idle;
|
||||
Ok(SatelliteServerMessage::Ready { version })
|
||||
}
|
||||
(SatelliteState::Idle, SatelliteClientMessage::Start { language, format })
|
||||
if self.authenticated =>
|
||||
{
|
||||
if language.is_empty() || language.len() > 35 {
|
||||
return Err(SatelliteError::InvalidLanguage);
|
||||
}
|
||||
self.format = Some(format.validate()?);
|
||||
self.language = Some(language);
|
||||
self.audio.clear();
|
||||
self.state = SatelliteState::Streaming;
|
||||
Ok(SatelliteServerMessage::Started)
|
||||
}
|
||||
(SatelliteState::Streaming, SatelliteClientMessage::End) => {
|
||||
if self.audio.is_empty() {
|
||||
return Err(SatelliteError::EmptyStream);
|
||||
}
|
||||
self.state = SatelliteState::Idle;
|
||||
Ok(SatelliteServerMessage::Finished)
|
||||
}
|
||||
(SatelliteState::Streaming, SatelliteClientMessage::Cancel) => {
|
||||
self.reset_stream();
|
||||
Ok(SatelliteServerMessage::Finished)
|
||||
}
|
||||
(_, SatelliteClientMessage::Cancel) => {
|
||||
self.reset_stream();
|
||||
Ok(SatelliteServerMessage::Finished)
|
||||
}
|
||||
_ => Err(SatelliteError::InvalidSequence),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn audio(&mut self, chunk: AudioChunk) -> Result<(), SatelliteError> {
|
||||
if self.state != SatelliteState::Streaming {
|
||||
return Err(SatelliteError::InvalidSequence);
|
||||
}
|
||||
if self.audio.len().saturating_add(chunk.as_bytes().len()) > MAX_UTTERANCE_AUDIO_BYTES {
|
||||
self.reset_stream();
|
||||
return Err(SatelliteError::AudioLimit);
|
||||
}
|
||||
self.audio.extend_from_slice(chunk.as_bytes());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn take_utterance(&mut self) -> Option<(Vec<u8>, AudioFormat, String)> {
|
||||
if self.state != SatelliteState::Idle || self.audio.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let audio = std::mem::take(&mut self.audio);
|
||||
Some((audio, self.format.take()?, self.language.take()?))
|
||||
}
|
||||
|
||||
fn reset_stream(&mut self) {
|
||||
self.audio.clear();
|
||||
self.format = None;
|
||||
self.language = None;
|
||||
self.state = if self.authenticated {
|
||||
SatelliteState::Idle
|
||||
} else {
|
||||
SatelliteState::AwaitingHello
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for SatelliteSession {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum SatelliteError {
|
||||
#[error("satellite message is invalid in the current session state")]
|
||||
InvalidSequence,
|
||||
#[error("satellite protocol version {0} is unsupported")]
|
||||
UnsupportedVersion(u16),
|
||||
#[error("satellite authentication failed")]
|
||||
Unauthorized,
|
||||
#[error("invalid language tag")]
|
||||
InvalidLanguage,
|
||||
#[error("audio stream is empty")]
|
||||
EmptyStream,
|
||||
#[error("audio stream exceeded its size limit")]
|
||||
AudioLimit,
|
||||
#[error(transparent)]
|
||||
Audio(#[from] AudioError),
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::audio::AudioCodec;
|
||||
|
||||
fn format() -> AudioFormat {
|
||||
AudioFormat {
|
||||
codec: AudioCodec::PcmS16Le,
|
||||
sample_rate: 16_000,
|
||||
channels: 1,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn happy_path_preserves_audio_and_metadata() {
|
||||
let mut session = SatelliteSession::new();
|
||||
session
|
||||
.control(
|
||||
SatelliteClientMessage::Hello {
|
||||
version: 1,
|
||||
token: "secret".into(),
|
||||
},
|
||||
|token| token == "secret",
|
||||
)
|
||||
.unwrap();
|
||||
session
|
||||
.control(
|
||||
SatelliteClientMessage::Start {
|
||||
language: "en-CA".into(),
|
||||
format: format(),
|
||||
},
|
||||
|_| false,
|
||||
)
|
||||
.unwrap();
|
||||
session
|
||||
.audio(AudioChunk::new(vec![1, 0, 2, 0]).unwrap())
|
||||
.unwrap();
|
||||
session
|
||||
.control(SatelliteClientMessage::End, |_| false)
|
||||
.unwrap();
|
||||
let (audio, stored_format, language) = session.take_utterance().unwrap();
|
||||
assert_eq!(audio, vec![1, 0, 2, 0]);
|
||||
assert_eq!(stored_format, format());
|
||||
assert_eq!(language, "en-CA");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unauthenticated_stream_is_rejected_and_closed() {
|
||||
let mut session = SatelliteSession::new();
|
||||
let error = session
|
||||
.control(
|
||||
SatelliteClientMessage::Hello {
|
||||
version: 1,
|
||||
token: "wrong".into(),
|
||||
},
|
||||
|_| false,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(matches!(error, SatelliteError::Unauthorized));
|
||||
assert_eq!(session.state(), SatelliteState::Closed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binary_before_start_is_rejected() {
|
||||
let mut session = SatelliteSession::new();
|
||||
let error = session
|
||||
.audio(AudioChunk::new(vec![0, 0]).unwrap())
|
||||
.unwrap_err();
|
||||
assert!(matches!(error, SatelliteError::InvalidSequence));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
//! Provider-neutral speech-to-text and text-to-speech contracts.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::audio::{AudioFormat, MAX_UTTERANCE_AUDIO_BYTES};
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct Transcript {
|
||||
pub text: String,
|
||||
pub language: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct SynthesizedSpeech {
|
||||
pub audio: Vec<u8>,
|
||||
pub format: AudioFormat,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum SpeechError {
|
||||
#[error("{0} provider is not configured")]
|
||||
NotConfigured(&'static str),
|
||||
#[error("speech provider rejected the request: {0}")]
|
||||
Provider(String),
|
||||
#[error("speech provider returned invalid data: {0}")]
|
||||
InvalidOutput(String),
|
||||
#[error("speech operation timed out")]
|
||||
Timeout,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait SpeechToText: Send + Sync {
|
||||
async fn transcribe(
|
||||
&self,
|
||||
audio: &[u8],
|
||||
format: AudioFormat,
|
||||
language: &str,
|
||||
) -> Result<Transcript, SpeechError>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait TextToSpeech: Send + Sync {
|
||||
async fn synthesize(
|
||||
&self,
|
||||
text: &str,
|
||||
language: &str,
|
||||
) -> Result<SynthesizedSpeech, SpeechError>;
|
||||
}
|
||||
|
||||
/// Fail-closed provider used until an STT integration is configured.
|
||||
pub struct DisabledStt;
|
||||
|
||||
#[async_trait]
|
||||
impl SpeechToText for DisabledStt {
|
||||
async fn transcribe(
|
||||
&self,
|
||||
_audio: &[u8],
|
||||
_format: AudioFormat,
|
||||
_language: &str,
|
||||
) -> Result<Transcript, SpeechError> {
|
||||
Err(SpeechError::NotConfigured("STT"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Fail-closed provider used until a TTS integration is configured.
|
||||
pub struct DisabledTts;
|
||||
|
||||
#[async_trait]
|
||||
impl TextToSpeech for DisabledTts {
|
||||
async fn synthesize(
|
||||
&self,
|
||||
_text: &str,
|
||||
_language: &str,
|
||||
) -> Result<SynthesizedSpeech, SpeechError> {
|
||||
Err(SpeechError::NotConfigured("TTS"))
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn validate_provider_audio(audio: &[u8]) -> Result<(), SpeechError> {
|
||||
if audio.is_empty() || audio.len() > MAX_UTTERANCE_AUDIO_BYTES || audio.len() % 2 != 0 {
|
||||
return Err(SpeechError::InvalidOutput(
|
||||
"audio must be non-empty, bounded, aligned PCM".into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
//! End-to-end STT → intent → TTS pipeline.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use homecore::HomeCore;
|
||||
use thiserror::Error;
|
||||
use tokio::time::timeout;
|
||||
|
||||
use crate::audio::{AudioFormat, MAX_UTTERANCE_AUDIO_BYTES};
|
||||
use crate::pipeline::AssistPipeline;
|
||||
use crate::recognizer::IntentRecognizer;
|
||||
use crate::speech::{
|
||||
validate_provider_audio, SpeechError, SpeechToText, SynthesizedSpeech, TextToSpeech, Transcript,
|
||||
};
|
||||
use crate::{AssistError, IntentResponse};
|
||||
|
||||
pub const DEFAULT_VOICE_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct VoiceResponse {
|
||||
pub transcript: Transcript,
|
||||
pub intent: IntentResponse,
|
||||
pub speech: SynthesizedSpeech,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum VoiceError {
|
||||
#[error("input audio is empty or exceeds the utterance limit")]
|
||||
InvalidAudio,
|
||||
#[error(transparent)]
|
||||
Speech(#[from] SpeechError),
|
||||
#[error(transparent)]
|
||||
Assist(#[from] AssistError),
|
||||
#[error("voice pipeline timed out")]
|
||||
Timeout,
|
||||
}
|
||||
|
||||
pub struct VoicePipeline<S, T, R: IntentRecognizer> {
|
||||
stt: S,
|
||||
tts: T,
|
||||
assist: AssistPipeline<R>,
|
||||
timeout: Duration,
|
||||
}
|
||||
|
||||
impl<S, T, R> VoicePipeline<S, T, R>
|
||||
where
|
||||
S: SpeechToText,
|
||||
T: TextToSpeech,
|
||||
R: IntentRecognizer,
|
||||
{
|
||||
pub fn new(stt: S, tts: T, assist: AssistPipeline<R>) -> Self {
|
||||
Self {
|
||||
stt,
|
||||
tts,
|
||||
assist,
|
||||
timeout: DEFAULT_VOICE_TIMEOUT,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_timeout(mut self, value: Duration) -> Self {
|
||||
self.timeout = value;
|
||||
self
|
||||
}
|
||||
|
||||
pub async fn process(
|
||||
&self,
|
||||
audio: &[u8],
|
||||
format: AudioFormat,
|
||||
language: &str,
|
||||
hc: &HomeCore,
|
||||
) -> Result<VoiceResponse, VoiceError> {
|
||||
if audio.is_empty() || audio.len() > MAX_UTTERANCE_AUDIO_BYTES || audio.len() % 2 != 0 {
|
||||
return Err(VoiceError::InvalidAudio);
|
||||
}
|
||||
let format = format.validate().map_err(|_| VoiceError::InvalidAudio)?;
|
||||
timeout(self.timeout, async {
|
||||
let transcript = self.stt.transcribe(audio, format, language).await?;
|
||||
let intent = self
|
||||
.assist
|
||||
.process(&transcript.text, &transcript.language, hc)
|
||||
.await?;
|
||||
let speech = self
|
||||
.tts
|
||||
.synthesize(&intent.speech, &transcript.language)
|
||||
.await?;
|
||||
speech
|
||||
.format
|
||||
.validate()
|
||||
.map_err(|error| SpeechError::InvalidOutput(error.to_string()))?;
|
||||
validate_provider_audio(&speech.audio)?;
|
||||
Ok(VoiceResponse {
|
||||
transcript,
|
||||
intent,
|
||||
speech,
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|_| VoiceError::Timeout)?
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use async_trait::async_trait;
|
||||
|
||||
use super::*;
|
||||
use crate::audio::AudioCodec;
|
||||
use crate::recognizer::RegexIntentRecognizer;
|
||||
use crate::speech::{SpeechToText, TextToSpeech};
|
||||
|
||||
struct FixedStt;
|
||||
|
||||
#[async_trait]
|
||||
impl SpeechToText for FixedStt {
|
||||
async fn transcribe(
|
||||
&self,
|
||||
_audio: &[u8],
|
||||
_format: AudioFormat,
|
||||
language: &str,
|
||||
) -> Result<Transcript, SpeechError> {
|
||||
Ok(Transcript {
|
||||
text: "never mind".into(),
|
||||
language: language.into(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
struct FixedTts;
|
||||
|
||||
#[async_trait]
|
||||
impl TextToSpeech for FixedTts {
|
||||
async fn synthesize(
|
||||
&self,
|
||||
text: &str,
|
||||
_language: &str,
|
||||
) -> Result<SynthesizedSpeech, SpeechError> {
|
||||
assert!(!text.is_empty());
|
||||
Ok(SynthesizedSpeech {
|
||||
audio: vec![0, 0, 1, 0],
|
||||
format: format(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn format() -> AudioFormat {
|
||||
AudioFormat {
|
||||
codec: AudioCodec::PcmS16Le,
|
||||
sample_rate: 16_000,
|
||||
channels: 1,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn runs_stt_intent_and_tts() {
|
||||
let recognizer = RegexIntentRecognizer::new();
|
||||
recognizer
|
||||
.register("HassNevermind", r"never ?mind", "*")
|
||||
.await
|
||||
.unwrap();
|
||||
let pipeline = crate::pipeline::default_pipeline(recognizer);
|
||||
let voice = VoicePipeline::new(FixedStt, FixedTts, pipeline);
|
||||
let result = voice
|
||||
.process(&[0, 0, 1, 0], format(), "en-CA", &HomeCore::new())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result.transcript.text, "never mind");
|
||||
assert_eq!(result.speech.audio.len(), 4);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_unaligned_audio_before_provider_call() {
|
||||
let voice = VoicePipeline::new(
|
||||
FixedStt,
|
||||
FixedTts,
|
||||
crate::pipeline::default_pipeline(RegexIntentRecognizer::new()),
|
||||
);
|
||||
let error = voice
|
||||
.process(&[0], format(), "en", &HomeCore::new())
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(error, VoiceError::InvalidAudio));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user