#![forbid(unsafe_code)]
use std::collections::BTreeMap;
use std::pin::Pin;
use std::sync::Arc;
use futures::future::BoxFuture;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use thiserror::Error;
pub type DeviceId = uuid::Uuid;
pub type TransitionName = String;
pub type StateName = String;
#[derive(Debug, Error)]
pub enum DeviceError {
#[error("invalid input: {0}")]
Invalid(String),
#[error("conflict: {0}")]
Conflict(String),
#[error("not allowed in current state: {0}")]
NotAllowed(String),
#[error("internal: {0}")]
Internal(String),
}
#[derive(Debug, Default, Clone)]
pub struct TransitionInput {
pub fields: BTreeMap<String, Value>,
}
impl TransitionInput {
pub fn get(&self, name: &str) -> Option<&Value> {
self.fields.get(name)
}
pub fn get_str(&self, name: &str) -> Option<&str> {
self.fields.get(name).and_then(Value::as_str)
}
}
pub trait Device: Send + Sync + 'static {
fn config(&self, cfg: &mut DeviceConfig);
fn state(&self) -> &str;
fn properties(&self) -> Map<String, Value> {
Map::new()
}
fn transition<'a>(
&'a mut self,
name: &'a str,
input: TransitionInput,
) -> BoxFuture<'a, Result<(), DeviceError>>;
fn on_start(&self, _ctx: DeviceCtx) {}
}
#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum StreamKind {
#[default]
Object,
Binary,
}
#[derive(Debug, Default, Clone)]
pub struct StreamSpec {
pub name: String,
pub kind: StreamKind,
}
#[derive(Debug, Clone)]
pub struct FieldSpec {
pub name: String,
pub type_: String,
pub title: Option<String>,
pub value: Option<Value>,
}
#[derive(Debug, Default, Clone)]
pub struct TransitionSpec {
pub name: TransitionName,
pub fields: Vec<FieldSpec>,
}
#[derive(Default, Debug, Clone)]
pub struct DeviceConfig {
pub type_: Option<String>,
pub name: Option<String>,
pub initial_state: Option<StateName>,
pub state_transitions: BTreeMap<StateName, Vec<TransitionName>>,
pub transitions: BTreeMap<TransitionName, TransitionSpec>,
pub streams: Vec<StreamSpec>,
pub monitored: Vec<String>,
}
impl DeviceConfig {
pub fn type_(&mut self, ty: impl Into<String>) -> &mut Self {
self.type_ = Some(ty.into());
self
}
pub fn name(&mut self, name: impl Into<String>) -> &mut Self {
self.name = Some(name.into());
self
}
pub fn state(&mut self, state: impl Into<StateName>) -> &mut Self {
self.initial_state = Some(state.into());
self
}
pub fn when(&mut self, state: impl Into<StateName>, allow: &[&str]) -> &mut Self {
let s = state.into();
let names: Vec<String> = allow.iter().map(|s| s.to_string()).collect();
for t in &names {
self.transitions
.entry(t.clone())
.or_insert_with(|| TransitionSpec {
name: t.clone(),
fields: vec![],
});
}
self.state_transitions.insert(s, names);
self
}
pub fn transition(
&mut self,
name: impl Into<TransitionName>,
fields: Vec<FieldSpec>,
) -> &mut Self {
let n: TransitionName = name.into();
self.transitions
.insert(n.clone(), TransitionSpec { name: n, fields });
self
}
pub fn stream(&mut self, name: impl Into<String>, kind: StreamKind) -> &mut Self {
self.streams.push(StreamSpec {
name: name.into(),
kind,
});
self
}
pub fn monitor(&mut self, name: impl Into<String>) -> &mut Self {
let n = name.into();
self.streams.push(StreamSpec {
name: n.clone(),
kind: StreamKind::Object,
});
self.monitored.push(n);
self
}
pub fn allowed_in(&self, state: &str) -> &[TransitionName] {
self.state_transitions
.get(state)
.map(Vec::as_slice)
.unwrap_or(&[])
}
}
pub struct DeviceCtx {
pub id: DeviceId,
pub type_: String,
pub publish: Arc<dyn StreamSink>,
}
pub trait StreamSink: Send + Sync {
fn publish(&self, stream: &str, data: Value);
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeviceProperties {
pub id: DeviceId,
#[serde(rename = "type")]
pub type_: String,
pub name: Option<String>,
pub state: StateName,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
pub type DynFuture<'a, T> = Pin<Box<dyn std::future::Future<Output = T> + Send + 'a>>;
#[macro_export]
macro_rules! transitions {
( $( $wire:literal => $method:ident ),* $(,)? ) => {
fn transition<'a>(
&'a mut self,
name: &'a str,
_input: $crate::TransitionInput,
) -> ::futures::future::BoxFuture<'a, ::std::result::Result<(), $crate::DeviceError>> {
::std::boxed::Box::pin(async move {
match name {
$( $wire => self.$method().await, )*
other => ::std::result::Result::Err(
$crate::DeviceError::Invalid(::std::format!(
"unknown transition `{}`", other
)),
),
}
})
}
};
}
#[cfg(test)]
mod tests {
use super::*;
struct Led {
on: bool,
}
impl Device for Led {
fn config(&self, cfg: &mut DeviceConfig) {
cfg.type_("led")
.name("LED")
.state(if self.on { "on" } else { "off" })
.when("off", &["turn-on"])
.when("on", &["turn-off"])
.monitor("state");
}
fn state(&self) -> &str {
if self.on { "on" } else { "off" }
}
fn transition<'a>(
&'a mut self,
name: &'a str,
_input: TransitionInput,
) -> BoxFuture<'a, Result<(), DeviceError>> {
Box::pin(async move {
match name {
"turn-on" => {
self.on = true;
Ok(())
}
"turn-off" => {
self.on = false;
Ok(())
}
other => Err(DeviceError::Invalid(format!("unknown transition {other}"))),
}
})
}
}
#[tokio::test]
async fn led_transitions() {
let mut led = Led { on: false };
let mut cfg = DeviceConfig::default();
led.config(&mut cfg);
assert_eq!(cfg.type_.as_deref(), Some("led"));
assert_eq!(cfg.initial_state.as_deref(), Some("off"));
assert_eq!(cfg.allowed_in("off"), &["turn-on".to_string()]);
assert!(cfg.streams.iter().any(|s| s.name == "state"));
assert!(cfg.monitored.contains(&"state".to_string()));
led.transition("turn-on", TransitionInput::default())
.await
.unwrap();
assert_eq!(led.state(), "on");
let err = led
.transition("nope", TransitionInput::default())
.await
.unwrap_err();
assert!(matches!(err, DeviceError::Invalid(_)));
}
#[test]
fn unknown_state_yields_empty_allowed() {
let cfg = DeviceConfig::default();
assert!(cfg.allowed_in("anything").is_empty());
}
}