use std::collections::BTreeSet;
use std::fmt;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use thiserror::Error;
pub const PROTOCOL: &str = "nestor.satellite/1";
pub const TEXT_INPUT: &str = "text.input";
pub const TEXT_DISPLAY: &str = "text.display";
pub const AUDIO_CAPTURE: &str = "audio.capture";
pub const AUDIO_PLAYBACK: &str = "audio.playback";
pub const EXPRESSION: &str = "expression.emotion";
pub const HEAD_MOTION: &str = "motion.head";
pub const PERSON_DETECTION: &str = "vision.person_detection";
pub const SPEECH_ACTIVITY: &str = "audio.speech_activity";
pub const IDENTIFY: &str = "device.identify";
pub const MAX_CAPABILITIES: usize = 32;
const MAX_TEXT: usize = 64;
#[derive(Debug, Clone, PartialEq, Eq, Error)]
#[error("{0}")]
pub struct ProtocolError(pub(crate) String);
impl ProtocolError {
pub(crate) fn new(message: impl Into<String>) -> Self {
Self(message.into())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Behavior {
Idle,
Listening,
Thinking,
Speaking,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Expression {
Joy,
Sigh,
Thoughtful,
Whisper,
Excited,
Ahem,
}
impl Behavior {
pub const ALL: [Self; 4] = [Self::Idle, Self::Listening, Self::Thinking, Self::Speaking];
pub fn as_str(self) -> &'static str {
match self {
Self::Idle => "idle",
Self::Listening => "listening",
Self::Thinking => "thinking",
Self::Speaking => "speaking",
}
}
}
impl Expression {
pub const ALL: [Self; 6] = [Self::Joy, Self::Sigh, Self::Thoughtful, Self::Whisper, Self::Excited, Self::Ahem];
pub fn as_str(self) -> &'static str {
match self {
Self::Joy => "joy",
Self::Sigh => "sigh",
Self::Thoughtful => "thoughtful",
Self::Whisper => "whisper",
Self::Excited => "excited",
Self::Ahem => "ahem",
}
}
}
impl fmt::Display for Behavior {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
impl fmt::Display for Expression {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub enum PairingMethod {
#[serde(rename = "device_code.display")]
DeviceCodeDisplay,
#[serde(rename = "device_code.speech")]
DeviceCodeSpeech,
#[serde(rename = "enrollment.input")]
EnrollmentInput,
#[serde(rename = "enrollment.provisioning")]
EnrollmentProvisioning,
}
impl PairingMethod {
pub const ALL: [Self; 4] =
[Self::DeviceCodeDisplay, Self::DeviceCodeSpeech, Self::EnrollmentInput, Self::EnrollmentProvisioning];
pub fn as_str(self) -> &'static str {
match self {
Self::DeviceCodeDisplay => "device_code.display",
Self::DeviceCodeSpeech => "device_code.speech",
Self::EnrollmentInput => "enrollment.input",
Self::EnrollmentProvisioning => "enrollment.provisioning",
}
}
}
impl fmt::Display for PairingMethod {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub enum EnrollmentMethod {
#[serde(rename = "enrollment.input")]
Input,
#[serde(rename = "enrollment.provisioning")]
Provisioning,
}
impl From<EnrollmentMethod> for PairingMethod {
fn from(method: EnrollmentMethod) -> Self {
match method {
EnrollmentMethod::Input => Self::EnrollmentInput,
EnrollmentMethod::Provisioning => Self::EnrollmentProvisioning,
}
}
}
impl fmt::Display for EnrollmentMethod {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
PairingMethod::from(*self).fmt(f)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Device {
id: String,
model: String,
room: String,
#[serde(skip_serializing_if = "Option::is_none")]
brand: Option<String>,
}
impl Device {
pub fn new(
id: impl Into<String>,
model: impl Into<String>,
room: impl Into<String>,
) -> Result<Self, ProtocolError> {
let (id, model, room) = (id.into(), model.into(), room.into());
for (name, value) in [("id", &id), ("room", &room)] {
if !is_slug(value) {
return Err(ProtocolError::new(format!(
"device {name} {value:?} must be a lowercase slug (a-z, 0-9, - and _)"
)));
}
}
if model.is_empty() || model.chars().count() > MAX_TEXT {
return Err(ProtocolError::new("device model must be 1 to 64 characters"));
}
Ok(Self { id, model, room, brand: None })
}
pub fn with_brand(mut self, brand: impl Into<String>) -> Result<Self, ProtocolError> {
let brand = brand.into();
if !is_slug(&brand) {
return Err(ProtocolError::new(format!(
"device brand {brand:?} must be a lowercase slug (a-z, 0-9, - and _)"
)));
}
self.brand = Some(brand);
Ok(self)
}
pub fn id(&self) -> &str {
&self.id
}
pub fn model(&self) -> &str {
&self.model
}
pub fn room(&self) -> &str {
&self.room
}
pub fn brand(&self) -> Option<&str> {
self.brand.as_deref()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Sdk {
name: String,
version: String,
}
impl Sdk {
pub fn new(name: impl Into<String>, version: impl Into<String>) -> Result<Self, ProtocolError> {
let (name, version) = (name.into(), version.into());
for (key, value) in [("name", &name), ("version", &version)] {
if value.is_empty() || value.chars().count() > MAX_TEXT {
return Err(ProtocolError::new(format!("sdk {key} must be 1 to 64 characters")));
}
}
Ok(Self { name, version })
}
pub fn this() -> Self {
Self { name: "nestor-satellite-rust".to_owned(), version: env!("CARGO_PKG_VERSION").to_owned() }
}
pub fn name(&self) -> &str {
&self.name
}
pub fn version(&self) -> &str {
&self.version
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct Capability {
name: String,
params: Map<String, Value>,
}
impl Capability {
pub fn new(name: impl Into<String>) -> Result<Self, ProtocolError> {
let name = name.into();
if !is_capability(&name) {
return Err(ProtocolError::new(format!("invalid capability {name:?}: expected 'family.feature'")));
}
Ok(Self { name, params: Map::new() })
}
pub fn with_param(mut self, key: impl Into<String>, value: impl Into<Value>) -> Result<Self, ProtocolError> {
let key = key.into();
if key == "name" {
return Err(ProtocolError::new("a capability parameter cannot be called 'name'"));
}
self.params.insert(key, value.into());
Ok(self)
}
pub fn name(&self) -> &str {
&self.name
}
pub fn params(&self) -> &Map<String, Value> {
&self.params
}
}
impl Serialize for Capability {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
let mut entry = Map::with_capacity(self.params.len() + 1);
entry.insert("name".to_owned(), Value::String(self.name.clone()));
entry.extend(self.params.clone());
entry.serialize(serializer)
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct Manifest {
device: Device,
capabilities: Vec<Capability>,
sdk: Option<Sdk>,
pairing_methods: BTreeSet<PairingMethod>,
}
impl Manifest {
pub fn new<I, S>(device: Device, capabilities: I) -> Result<Self, ProtocolError>
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
let capabilities = capabilities.into_iter().map(Capability::new).collect::<Result<Vec<_>, _>>()?;
Self::with_capabilities(device, capabilities)
}
pub fn with_capabilities(device: Device, capabilities: Vec<Capability>) -> Result<Self, ProtocolError> {
if capabilities.len() > MAX_CAPABILITIES {
return Err(ProtocolError::new(format!("at most {MAX_CAPABILITIES} capabilities")));
}
let names: BTreeSet<&str> = capabilities.iter().map(Capability::name).collect();
if names.len() != capabilities.len() {
return Err(ProtocolError::new("a capability is declared twice"));
}
Ok(Self {
device,
capabilities,
sdk: Some(Sdk::this()),
pairing_methods: BTreeSet::from([PairingMethod::EnrollmentProvisioning]),
})
}
pub fn with_pairing_methods(
mut self,
methods: impl IntoIterator<Item = PairingMethod>,
) -> Result<Self, ProtocolError> {
let methods: BTreeSet<PairingMethod> = methods.into_iter().collect();
if methods.is_empty() {
return Err(ProtocolError::new("a manifest declares at least one pairing method"));
}
self.pairing_methods = methods;
Ok(self)
}
pub(crate) fn declaring<'a>(mut self, names: impl IntoIterator<Item = &'a str>) -> Result<Self, ProtocolError> {
for name in names {
if !self.declares(name) {
self.capabilities.push(Capability::new(name)?);
}
}
if self.capabilities.len() > MAX_CAPABILITIES {
return Err(ProtocolError::new(format!("at most {MAX_CAPABILITIES} capabilities")));
}
Ok(self)
}
pub fn with_sdk(mut self, sdk: Option<Sdk>) -> Self {
self.sdk = sdk;
self
}
pub fn device(&self) -> &Device {
&self.device
}
pub fn capabilities(&self) -> &[Capability] {
&self.capabilities
}
pub fn sdk(&self) -> Option<&Sdk> {
self.sdk.as_ref()
}
pub fn pairing_methods(&self) -> impl Iterator<Item = PairingMethod> + '_ {
self.pairing_methods.iter().copied()
}
pub fn supports(&self, method: PairingMethod) -> bool {
self.pairing_methods.contains(&method)
}
pub fn declares(&self, name: &str) -> bool {
self.capabilities.iter().any(|c| c.name == name)
}
pub fn to_json(&self) -> Value {
let mut data = Map::new();
data.insert("protocol".to_owned(), Value::String(PROTOCOL.to_owned()));
data.insert("device".to_owned(), to_value(&self.device));
data.insert("capabilities".to_owned(), Value::Array(self.capabilities.iter().map(to_value).collect()));
if let Some(sdk) = &self.sdk {
data.insert("sdk".to_owned(), to_value(sdk));
}
let methods = self.pairing_methods.iter().map(|m| Value::String(m.as_str().to_owned())).collect();
data.insert("pairing_methods".to_owned(), Value::Array(methods));
Value::Object(data)
}
}
fn to_value(value: &impl Serialize) -> Value {
serde_json::to_value(value).unwrap_or(Value::Null)
}
pub(crate) fn is_slug(value: &str) -> bool {
let mut chars = value.chars();
let first_ok = chars.next().is_some_and(|c| c.is_ascii_lowercase() || c.is_ascii_digit());
first_ok
&& value.len() <= MAX_TEXT
&& chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
}
fn is_capability(value: &str) -> bool {
let parts: Vec<&str> = value.split('.').collect();
parts.len() >= 2
&& parts.iter().all(|part| {
let mut chars = part.chars();
chars.next().is_some_and(|c| c.is_ascii_lowercase())
&& chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
})
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn device() -> Device {
Device::new("enceinte-salon", "raspberry-pi-4", "salon").unwrap()
}
#[test]
fn a_device_id_and_room_must_be_lowercase_slugs() {
assert!(Device::new("Salon", "m", "salon").is_err());
assert!(Device::new("ok", "m", "sa lon").is_err());
assert!(Device::new("-x", "m", "salon").is_err());
assert!(Device::new("x".repeat(65), "m", "salon").is_err());
assert!(Device::new("x".repeat(64), "m", "salon").is_ok());
assert!(device().with_brand("Pollen").is_err());
}
#[test]
fn a_device_model_has_one_to_64_characters() {
assert!(Device::new("a", "", "salon").is_err());
assert!(Device::new("a", "é".repeat(64), "salon").is_ok());
assert!(Device::new("a", "m".repeat(65), "salon").is_err());
}
#[test]
fn a_capability_is_a_dotted_family_and_feature() {
assert!(Capability::new("audio.capture").is_ok());
assert!(Capability::new("vision.person_detection").is_ok());
for wrong in ["audio", "Audio.capture", "audio.", ".capture", "audio.1x", "a..b"] {
assert!(Capability::new(wrong).is_err(), "{wrong}");
}
assert!(Capability::new("audio.capture").unwrap().with_param("name", 1).is_err());
}
#[test]
fn a_manifest_refuses_duplicates_and_too_many_capabilities() {
assert!(Manifest::new(device(), [AUDIO_CAPTURE, AUDIO_CAPTURE]).is_err());
let many: Vec<String> = (0..33).map(|i| format!("custom.f{i}")).collect();
assert!(Manifest::new(device(), many).is_err());
}
#[test]
fn a_manifest_serializes_as_the_core_reads_it() {
let capture = Capability::new(AUDIO_CAPTURE).unwrap().with_param("formats", json!(["opus/48000"])).unwrap();
let manifest = Manifest::with_capabilities(device().with_brand("raspberry").unwrap(), vec![capture]).unwrap();
assert_eq!(
manifest.to_json(),
json!({
"protocol": "nestor.satellite/1",
"device": {"id": "enceinte-salon", "model": "raspberry-pi-4", "room": "salon", "brand": "raspberry"},
"capabilities": [{"name": "audio.capture", "formats": ["opus/48000"]}],
"sdk": {"name": "nestor-satellite-rust", "version": env!("CARGO_PKG_VERSION")},
"pairing_methods": ["enrollment.provisioning"],
})
);
assert!(manifest.declares(AUDIO_CAPTURE) && !manifest.declares(AUDIO_PLAYBACK));
let bare = Manifest::new(device(), [AUDIO_PLAYBACK]).unwrap().with_sdk(None);
assert!(bare.to_json().get("sdk").is_none());
assert!(bare.to_json()["device"].get("brand").is_none());
}
#[test]
fn pairing_methods_are_declared_once_each_in_the_schema_s_order() {
use PairingMethod::*;
let manifest = Manifest::new(device(), [IDENTIFY]).unwrap();
assert!(manifest.with_pairing_methods([]).is_err(), "at least one");
let manifest = Manifest::new(device(), [IDENTIFY])
.unwrap()
.with_pairing_methods([EnrollmentProvisioning, DeviceCodeSpeech, DeviceCodeDisplay, DeviceCodeSpeech])
.unwrap();
assert_eq!(
manifest.to_json()["pairing_methods"],
json!(["device_code.display", "device_code.speech", "enrollment.provisioning"])
);
assert!(manifest.supports(DeviceCodeSpeech) && !manifest.supports(EnrollmentInput));
let names: Vec<&str> = PairingMethod::ALL.iter().map(|m| m.as_str()).collect();
assert_eq!(serde_json::to_value(PairingMethod::ALL).unwrap(), json!(names));
assert_eq!(PairingMethod::from(EnrollmentMethod::Input), EnrollmentInput);
}
}