#![cfg_attr(docsrs, feature(doc_cfg))]
mod conn;
mod device;
use std::collections::hash_map::RandomState;
use std::fmt;
use std::hash::{BuildHasher, Hasher};
use std::time::Duration;
pub use ebus_homie_tokio;
pub use rumqttc;
pub use conn::Reresolve;
pub use device::{DeviceTask, PairTask, RunningDevice, RunningPair, device, pair};
use ebus_homie_tokio::ebus_homie::controller::{Config, ConfigError};
use ebus_homie_tokio::{
Commands, ControllerDriver, ControllerHandle, ControllerOptions, DriverError, EventReceiver,
Inbox, attach_controller,
};
use rumqttc::{AsyncClient, EventLoop, MqttOptions};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum Error {
Driver(DriverError),
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Error::Driver(_) => f.write_str("driver stopped"),
}
}
}
impl std::error::Error for Error {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Error::Driver(e) => Some(e),
}
}
}
impl From<DriverError> for Error {
fn from(e: DriverError) -> Self {
Error::Driver(e)
}
}
#[derive(Debug)]
#[non_exhaustive]
pub struct RunningController {
pub handle: ControllerHandle,
pub events: EventReceiver,
pub task: ControllerTask,
}
pub struct ControllerTask {
driver: ControllerDriver,
inbox: Inbox,
commands: Commands,
client: AsyncClient,
eventloop: EventLoop,
reresolve: Option<Reresolve>,
}
impl fmt::Debug for ControllerTask {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ControllerTask")
.field("driver", &self.driver)
.field("broker", &self.eventloop.mqtt_options.broker_address())
.finish_non_exhaustive()
}
}
pub fn controller(
mqtt: MqttOptions,
mut config: Config,
opts: ControllerOptions,
) -> Result<RunningController, ConfigError> {
config.link_up_discards_queued = true;
let a = attach_controller(config, opts)?;
let (client, eventloop) = AsyncClient::new(mqtt, conn::REQUEST_CAPACITY);
Ok(RunningController {
handle: a.handle,
events: a.events,
task: ControllerTask {
driver: a.driver,
inbox: a.inbox,
commands: a.commands,
client,
eventloop,
reresolve: None,
},
})
}
pub fn controller_client_id() -> String {
let mut h = RandomState::new().build_hasher();
h.write_u128(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_nanos()),
);
h.write_u32(std::process::id());
format!("ebus-controller-{:016x}", h.finish())
}
impl ControllerTask {
pub fn reresolve(mut self, f: Reresolve) -> Self {
self.reresolve = Some(f);
self
}
pub async fn run(self) -> Result<(), Error> {
let ControllerTask {
driver,
inbox,
commands,
client,
eventloop,
reresolve,
} = self;
let conn = conn::Conn {
inbox,
commands,
client,
eventloop,
receipts: false,
flush_timeout: Duration::ZERO,
redirect: reresolve.map(|f| conn::Redirect::new(f, None)),
};
conn::run(driver.run(), conn).await.map_err(Error::Driver)
}
}
#[cfg(test)]
mod tests {
use super::*;
use conn::{BACKOFF_MIN, Executor, REQUEST_CAPACITY, Unsent, Wire, next_backoff};
use ebus_homie_tokio::ebus_mqtt::{self, Command};
use rumqttc::{Request, SubscribeFilter};
use tokio::sync::{Notify, watch};
fn try_send(client: &AsyncClient, cmd: Command) -> Result<(), Unsent> {
conn::try_send(client, cmd, None)
}
fn send_check<T: Send>(_: &T) {}
#[test]
fn task_future_is_send() {
let mqtt = MqttOptions::new(controller_client_id(), "localhost", 1883);
let run = controller(mqtt, Config::wildcard(), ControllerOptions::default())
.expect("valid config");
send_check(
&run.task
.reresolve(Box::new(|| Box::pin(async { None })))
.run(),
);
}
#[test]
fn driver_error_is_reported_once() {
use std::error::Error as _;
let inner = DriverError::CommandsClosed;
let e = Error::from(inner);
assert!(!e.to_string().contains(&inner.to_string()), "{e}");
assert_eq!(e.source().map(ToString::to_string), Some(inner.to_string()));
}
#[test]
fn client_ids_are_distinct_and_prefixed() {
let a = controller_client_id();
let b = controller_client_id();
assert!(a.starts_with("ebus-controller-"));
assert_eq!(a.len(), "ebus-controller-".len() + 16);
assert_ne!(a, b);
}
#[test]
fn backoff_doubles_to_thirty_seconds() {
let mut b = BACKOFF_MIN;
let mut seen = vec![b.as_secs()];
for _ in 0..7 {
b = next_backoff(b);
seen.push(b.as_secs());
}
assert_eq!(seen, [1, 2, 4, 8, 16, 30, 30, 30]);
}
#[tokio::test]
async fn commands_become_rumqttc_requests() {
let mqtt = MqttOptions::new("t", "localhost", 1883);
let (client, mut el) = AsyncClient::new(mqtt, 16);
let cmds = [
Command::subscribe(vec![
("ebus/5/+/$state".into(), ebus_mqtt::QoS::ExactlyOnce),
("ebus/5/d/+/+".into(), ebus_mqtt::QoS::AtLeastOnce),
]),
Command::unsubscribe(vec!["a/b".into(), "c/d".into()]),
Command::publish(
"ebus/5/d/n/p/set",
b"true".to_vec(),
ebus_mqtt::QoS::AtMostOnce,
false,
),
];
for c in cmds {
try_send(&client, c).expect("queued");
}
el.clean();
let got: Vec<Request> = el.pending.drain(..).collect();
assert_eq!(got.len(), 4, "{got:?}");
let Request::Subscribe(s) = &got[0] else {
panic!("{got:?}");
};
assert_eq!(
s.filters,
vec![
SubscribeFilter::new("ebus/5/+/$state".into(), rumqttc::QoS::ExactlyOnce),
SubscribeFilter::new("ebus/5/d/+/+".into(), rumqttc::QoS::AtLeastOnce),
]
);
assert!(matches!(&got[1], Request::Unsubscribe(u) if u.topics == ["a/b"]));
assert!(matches!(&got[2], Request::Unsubscribe(u) if u.topics == ["c/d"]));
let Request::Publish(p) = &got[3] else {
panic!("{got:?}");
};
assert_eq!(p.topic, "ebus/5/d/n/p/set");
assert_eq!(&p.payload[..], b"true");
assert_eq!(p.qos, rumqttc::QoS::AtMostOnce);
assert!(!p.retain);
}
#[tokio::test]
async fn shutdown_while_the_broker_is_down_ends_the_task() {
let port = std::net::TcpListener::bind("127.0.0.1:0")
.and_then(|l| l.local_addr())
.expect("free port")
.port();
let mqtt = MqttOptions::new(controller_client_id(), "127.0.0.1", port);
let run =
controller(mqtt, Config::wildcard(), ControllerOptions::default()).expect("valid");
let inbox = run.task.inbox.clone();
inbox.connected(false);
for i in 0..2 * REQUEST_CAPACITY {
let topic = format!("ebus/5/d{i}/$state");
assert!(inbox.message(topic, b"ready".to_vec().into(), Some(true)));
}
drop(inbox);
let task = tokio::spawn(run.task.run());
tokio::time::sleep(Duration::from_millis(200)).await;
run.handle.shutdown().await;
let ended = tokio::time::timeout(Duration::from_secs(10), task).await;
assert!(
matches!(ended, Ok(Ok(Ok(())))),
"the task did not end: {ended:?}"
);
}
async fn run_for(fut: &mut std::pin::Pin<&mut impl std::future::Future<Output = ()>>, ms: u64) {
tokio::select! {
() = fut.as_mut() => panic!("the executor stopped"),
() = tokio::time::sleep(Duration::from_millis(ms)) => {}
}
}
#[tokio::test]
async fn the_executor_holds_commands_while_down_and_skips_stale_subscribes() {
let mut cfg = Config::wildcard();
cfg.link_up_discards_queued = true;
let a = attach_controller(cfg, ControllerOptions::default()).expect("valid");
let inbox = a.inbox.clone();
tokio::spawn(a.driver.run());
let (client, mut el) = AsyncClient::new(MqttOptions::new("t", "localhost", 1883), 16);
let (end_tx, _end_rx) = watch::channel(conn::End::Running);
let (wire_tx, wire_rx) = watch::channel(Wire::Down);
let progress = Notify::new();
let exec = Executor {
client,
inbox: a.inbox,
track: None,
progress: &progress,
flush_timeout: Duration::ZERO,
}
.run(a.commands, end_tx, wire_rx);
tokio::pin!(exec);
inbox.connected(false);
run_for(&mut exec, 50).await;
el.clean();
assert!(el.pending.is_empty(), "{:?}", el.pending);
inbox.disconnected();
inbox.connected(false);
run_for(&mut exec, 50).await;
wire_tx.send_replace(Wire::Up);
run_for(&mut exec, 50).await;
el.clean();
let subs: Vec<&Request> = el
.pending
.iter()
.filter(|r| matches!(r, Request::Subscribe(_)))
.collect();
assert_eq!(subs.len(), 1, "{:?}", el.pending);
}
#[tokio::test]
async fn a_closed_client_returns_the_command() {
let mqtt = MqttOptions::new("t", "localhost", 1883);
let (client, el) = AsyncClient::new(mqtt, 16);
drop(el);
let cmd = Command::unsubscribe(vec!["a".into(), "b".into()]);
assert_eq!(try_send(&client, cmd.clone()), Err(Unsent::Full(cmd)));
let cmd = Command::subscribe(vec![("a".into(), ebus_mqtt::QoS::AtMostOnce)]);
assert_eq!(try_send(&client, cmd.clone()), Err(Unsent::Full(cmd)));
let cmd = Command::subscribe(vec![("a/#/b".into(), ebus_mqtt::QoS::AtMostOnce)]);
assert_eq!(try_send(&client, cmd.clone()), Err(Unsent::Invalid(cmd)));
}
}
#[cfg(doctest)]
#[doc = include_str!("../README.md")]
struct ReadmeDoctests;