Skip to main content

ebus_rumqttc/
lib.rs

1//! Bundled rumqttc 0.25 connection for the eBus Homie 5 controller and
2//! device on tokio.
3//!
4//! [`controller`] builds an [`ebus_homie_tokio`] controller with its own
5//! rumqttc client, [`device`] a device tree, and [`pair`] both on one
6//! connection. The host spawns the task's `run`; the SDK spawns nothing. The
7//! broker coordinates, client id, credentials and TLS come from the caller's
8//! [`MqttOptions`]. A host that found its broker over mDNS gives the task a
9//! [`Reresolve`] hook ([`ControllerTask::reresolve`]), which finds the same
10//! broker again when connecting fails, at startup or after a lost link, so a
11//! broker whose address changed is reached at its new one.
12//!
13//! ```no_run
14//! use ebus_rumqttc::ebus_homie_tokio::ebus_homie::controller::{Config, Event};
15//! use ebus_rumqttc::ebus_homie_tokio::{ControllerOptions, Delivered};
16//!
17//! # async fn run() -> Result<(), Box<dyn std::error::Error>> {
18//! let mqtt = rumqttc::MqttOptions::new(ebus_rumqttc::controller_client_id(), "localhost", 1883);
19//! let run = ebus_rumqttc::controller(mqtt, Config::wildcard(), ControllerOptions::default())?;
20//! tokio::spawn(run.task.run());
21//!
22//! let mut events = run.events;
23//! while let Some(d) = events.recv().await {
24//!     match d {
25//!         Delivered::Event(Event::DeviceSynced { device, missing: 0, .. }) => {
26//!             let _values = run.handle.device(&device);
27//!         }
28//!         Delivered::Overflow { .. } => {
29//!             let _all = run.handle.devices();
30//!         }
31//!         _ => {}
32//!     }
33//! }
34//! # Ok(())
35//! # }
36//! ```
37//!
38//! The transport is MQTT over TCP, or TLS with the `rustls` or `native-tls`
39//! feature. rumqttc's default maximum incoming packet size is 10 KiB, and a
40//! larger `$description` fails the connection; raise it with
41//! [`MqttOptions::set_max_packet_size`](rumqttc::MqttOptions::set_max_packet_size)
42//! for devices that publish one.
43//!
44//! On a clean-session reconnect rumqttc discards the requests it had not sent
45//! and restores no subscriptions; the controller resubscribes its filters on
46//! every CONNACK. The executor hands rumqttc nothing while the link is down
47//! and skips every SUBSCRIBE queued before a clean-session link-up, so none is
48//! sent after it, and the controller runs with
49//! [`Config::link_up_discards_queued`] set.
50
51#![cfg_attr(docsrs, feature(doc_cfg))]
52
53mod conn;
54mod device;
55
56use std::collections::hash_map::RandomState;
57use std::fmt;
58use std::hash::{BuildHasher, Hasher};
59use std::time::Duration;
60
61pub use ebus_homie_tokio;
62pub use rumqttc;
63
64pub use conn::Reresolve;
65pub use device::{DeviceTask, PairTask, RunningDevice, RunningPair, device, pair};
66
67use ebus_homie_tokio::ebus_homie::controller::{Config, ConfigError};
68use ebus_homie_tokio::{
69    Commands, ControllerDriver, ControllerHandle, ControllerOptions, DriverError, EventReceiver,
70    Inbox, attach_controller,
71};
72use rumqttc::{AsyncClient, EventLoop, MqttOptions};
73
74/// Why [`ControllerTask::run`] stopped early.
75#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
76#[non_exhaustive]
77pub enum Error {
78    /// The driver stopped with an error.
79    Driver(DriverError),
80}
81
82impl fmt::Display for Error {
83    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
84        match self {
85            Error::Driver(_) => f.write_str("driver stopped"),
86        }
87    }
88}
89
90impl std::error::Error for Error {
91    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
92        match self {
93            Error::Driver(e) => Some(e),
94        }
95    }
96}
97
98impl From<DriverError> for Error {
99    fn from(e: DriverError) -> Self {
100        Error::Driver(e)
101    }
102}
103
104/// A controller with its own connection.
105#[derive(Debug)]
106#[non_exhaustive]
107pub struct RunningController {
108    /// Snapshot reads, `/set` and `$broadcast`.
109    pub handle: ControllerHandle,
110    /// The controller's events.
111    pub events: EventReceiver,
112    /// The future the host spawns.
113    pub task: ControllerTask,
114}
115
116/// The driver, the rumqttc event loop and the command executor, as one
117/// future.
118pub struct ControllerTask {
119    driver: ControllerDriver,
120    inbox: Inbox,
121    commands: Commands,
122    client: AsyncClient,
123    eventloop: EventLoop,
124    reresolve: Option<Reresolve>,
125}
126
127impl fmt::Debug for ControllerTask {
128    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
129        f.debug_struct("ControllerTask")
130            .field("driver", &self.driver)
131            .field("broker", &self.eventloop.mqtt_options.broker_address())
132            .finish_non_exhaustive()
133    }
134}
135
136/// A controller for `config` that connects with `mqtt`, with
137/// [`Config::link_up_discards_queued`] set. Nothing runs until the host spawns
138/// [`ControllerTask::run`].
139pub fn controller(
140    mqtt: MqttOptions,
141    mut config: Config,
142    opts: ControllerOptions,
143) -> Result<RunningController, ConfigError> {
144    config.link_up_discards_queued = true;
145    let a = attach_controller(config, opts)?;
146    let (client, eventloop) = AsyncClient::new(mqtt, conn::REQUEST_CAPACITY);
147    Ok(RunningController {
148        handle: a.handle,
149        events: a.events,
150        task: ControllerTask {
151            driver: a.driver,
152            inbox: a.inbox,
153            commands: a.commands,
154            client,
155            eventloop,
156            reresolve: None,
157        },
158    })
159}
160
161/// `ebus-controller-` and 16 random hex digits, after python-sdk's
162/// `homie-controller-{uuid}` (homie.py:3870).
163pub fn controller_client_id() -> String {
164    let mut h = RandomState::new().build_hasher();
165    h.write_u128(
166        std::time::SystemTime::now()
167            .duration_since(std::time::UNIX_EPOCH)
168            .map_or(0, |d| d.as_nanos()),
169    );
170    h.write_u32(std::process::id());
171    format!("ebus-controller-{:016x}", h.finish())
172}
173
174impl ControllerTask {
175    /// Looks the broker up again while its address may have changed: after
176    /// the first failed connect since the task started or since the last
177    /// successful connect, and after every third failed connect after that,
178    /// the connection calls `f`, and options it returns replace the event
179    /// loop's before the next attempt. They keep the inflight cap and
180    /// manual-ack setting of the options the task was built with. `f`
181    /// returning `None` keeps the current options. Calls start with the
182    /// first connect, so a broker that got a new address while the host was
183    /// off is found at it. A host that found its broker over mDNS returns
184    /// the same broker at its new address; one connected by a host name, or
185    /// to a configured broker, needs no hook.
186    pub fn reresolve(mut self, f: Reresolve) -> Self {
187        self.reresolve = Some(f);
188        self
189    }
190
191    /// Runs until [`ControllerHandle::shutdown`]: the driver; an
192    /// `EventLoop::poll` loop that feeds the inbox and reconnects with a
193    /// 1-30 s backoff; and a command executor, separate so a full request
194    /// channel cannot deadlock against `poll`. After the shutdown's
195    /// UNSUBSCRIBE the executor sends a DISCONNECT, and the task ends once it
196    /// is written, or at once when the link is down, discarding the commands
197    /// not yet written. The future is `Send`.
198    ///
199    /// The three run in one task. The driver yields between batches of
200    /// received messages, so a backlog does not starve the event loop.
201    pub async fn run(self) -> Result<(), Error> {
202        let ControllerTask {
203            driver,
204            inbox,
205            commands,
206            client,
207            eventloop,
208            reresolve,
209        } = self;
210        let conn = conn::Conn {
211            inbox,
212            commands,
213            client,
214            eventloop,
215            receipts: false,
216            flush_timeout: Duration::ZERO,
217            redirect: reresolve.map(|f| conn::Redirect::new(f, None)),
218        };
219        conn::run(driver.run(), conn).await.map_err(Error::Driver)
220    }
221}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226
227    use conn::{BACKOFF_MIN, Executor, REQUEST_CAPACITY, Unsent, Wire, next_backoff};
228    use ebus_homie_tokio::ebus_mqtt::{self, Command};
229    use rumqttc::{Request, SubscribeFilter};
230    use tokio::sync::{Notify, watch};
231
232    fn try_send(client: &AsyncClient, cmd: Command) -> Result<(), Unsent> {
233        conn::try_send(client, cmd, None)
234    }
235
236    fn send_check<T: Send>(_: &T) {}
237
238    #[test]
239    fn task_future_is_send() {
240        let mqtt = MqttOptions::new(controller_client_id(), "localhost", 1883);
241        let run = controller(mqtt, Config::wildcard(), ControllerOptions::default())
242            .expect("valid config");
243        send_check(
244            &run.task
245                .reresolve(Box::new(|| Box::pin(async { None })))
246                .run(),
247        );
248    }
249
250    #[test]
251    fn driver_error_is_reported_once() {
252        use std::error::Error as _;
253        let inner = DriverError::CommandsClosed;
254        let e = Error::from(inner);
255        assert!(!e.to_string().contains(&inner.to_string()), "{e}");
256        assert_eq!(e.source().map(ToString::to_string), Some(inner.to_string()));
257    }
258
259    #[test]
260    fn client_ids_are_distinct_and_prefixed() {
261        let a = controller_client_id();
262        let b = controller_client_id();
263        assert!(a.starts_with("ebus-controller-"));
264        assert_eq!(a.len(), "ebus-controller-".len() + 16);
265        assert_ne!(a, b);
266    }
267
268    #[test]
269    fn backoff_doubles_to_thirty_seconds() {
270        let mut b = BACKOFF_MIN;
271        let mut seen = vec![b.as_secs()];
272        for _ in 0..7 {
273            b = next_backoff(b);
274            seen.push(b.as_secs());
275        }
276        assert_eq!(seen, [1, 2, 4, 8, 16, 30, 30, 30]);
277    }
278
279    #[tokio::test]
280    async fn commands_become_rumqttc_requests() {
281        let mqtt = MqttOptions::new("t", "localhost", 1883);
282        let (client, mut el) = AsyncClient::new(mqtt, 16);
283        let cmds = [
284            Command::subscribe(vec![
285                ("ebus/5/+/$state".into(), ebus_mqtt::QoS::ExactlyOnce),
286                ("ebus/5/d/+/+".into(), ebus_mqtt::QoS::AtLeastOnce),
287            ]),
288            Command::unsubscribe(vec!["a/b".into(), "c/d".into()]),
289            Command::publish(
290                "ebus/5/d/n/p/set",
291                b"true".to_vec(),
292                ebus_mqtt::QoS::AtMostOnce,
293                false,
294            ),
295        ];
296        for c in cmds {
297            try_send(&client, c).expect("queued");
298        }
299        // clean() moves the queued requests into `pending`.
300        el.clean();
301        let got: Vec<Request> = el.pending.drain(..).collect();
302        assert_eq!(got.len(), 4, "{got:?}");
303        let Request::Subscribe(s) = &got[0] else {
304            panic!("{got:?}");
305        };
306        assert_eq!(
307            s.filters,
308            vec![
309                SubscribeFilter::new("ebus/5/+/$state".into(), rumqttc::QoS::ExactlyOnce),
310                SubscribeFilter::new("ebus/5/d/+/+".into(), rumqttc::QoS::AtLeastOnce),
311            ]
312        );
313        assert!(matches!(&got[1], Request::Unsubscribe(u) if u.topics == ["a/b"]));
314        assert!(matches!(&got[2], Request::Unsubscribe(u) if u.topics == ["c/d"]));
315        let Request::Publish(p) = &got[3] else {
316            panic!("{got:?}");
317        };
318        assert_eq!(p.topic, "ebus/5/d/n/p/set");
319        assert_eq!(&p.payload[..], b"true");
320        assert_eq!(p.qos, rumqttc::QoS::AtMostOnce);
321        assert!(!p.retain);
322    }
323
324    #[tokio::test]
325    async fn shutdown_while_the_broker_is_down_ends_the_task() {
326        let port = std::net::TcpListener::bind("127.0.0.1:0")
327            .and_then(|l| l.local_addr())
328            .expect("free port")
329            .port();
330        let mqtt = MqttOptions::new(controller_client_id(), "127.0.0.1", port);
331        let run =
332            controller(mqtt, Config::wildcard(), ControllerOptions::default()).expect("valid");
333        // The controller believes it is connected and holds more filters than
334        // rumqttc's request channel buffers, while no broker listens.
335        let inbox = run.task.inbox.clone();
336        inbox.connected(false);
337        for i in 0..2 * REQUEST_CAPACITY {
338            let topic = format!("ebus/5/d{i}/$state");
339            assert!(inbox.message(topic, b"ready".to_vec().into(), Some(true)));
340        }
341        drop(inbox);
342        let task = tokio::spawn(run.task.run());
343        tokio::time::sleep(Duration::from_millis(200)).await;
344        run.handle.shutdown().await;
345        let ended = tokio::time::timeout(Duration::from_secs(10), task).await;
346        assert!(
347            matches!(ended, Ok(Ok(Ok(())))),
348            "the task did not end: {ended:?}"
349        );
350    }
351
352    /// Runs `fut` for `ms` milliseconds; it must not finish.
353    async fn run_for(fut: &mut std::pin::Pin<&mut impl std::future::Future<Output = ()>>, ms: u64) {
354        tokio::select! {
355            () = fut.as_mut() => panic!("the executor stopped"),
356            () = tokio::time::sleep(Duration::from_millis(ms)) => {}
357        }
358    }
359
360    #[tokio::test]
361    async fn the_executor_holds_commands_while_down_and_skips_stale_subscribes() {
362        // As `controller` configures it.
363        let mut cfg = Config::wildcard();
364        cfg.link_up_discards_queued = true;
365        let a = attach_controller(cfg, ControllerOptions::default()).expect("valid");
366        let inbox = a.inbox.clone();
367        tokio::spawn(a.driver.run());
368        let (client, mut el) = AsyncClient::new(MqttOptions::new("t", "localhost", 1883), 16);
369        let (end_tx, _end_rx) = watch::channel(conn::End::Running);
370        let (wire_tx, wire_rx) = watch::channel(Wire::Down);
371        let progress = Notify::new();
372        let exec = Executor {
373            client,
374            inbox: a.inbox,
375            track: None,
376            progress: &progress,
377            flush_timeout: Duration::ZERO,
378        }
379        .run(a.commands, end_tx, wire_rx);
380        tokio::pin!(exec);
381
382        // The discovery SUBSCRIBE waits while the link is down.
383        inbox.connected(false);
384        run_for(&mut exec, 50).await;
385        el.clean();
386        assert!(el.pending.is_empty(), "{:?}", el.pending);
387
388        // A clean link-up while it waits makes it stale; the link-up's own
389        // SUBSCRIBE goes out.
390        inbox.disconnected();
391        inbox.connected(false);
392        run_for(&mut exec, 50).await;
393        wire_tx.send_replace(Wire::Up);
394        run_for(&mut exec, 50).await;
395        el.clean();
396        let subs: Vec<&Request> = el
397            .pending
398            .iter()
399            .filter(|r| matches!(r, Request::Subscribe(_)))
400            .collect();
401        assert_eq!(subs.len(), 1, "{:?}", el.pending);
402    }
403
404    #[tokio::test]
405    async fn a_closed_client_returns_the_command() {
406        let mqtt = MqttOptions::new("t", "localhost", 1883);
407        let (client, el) = AsyncClient::new(mqtt, 16);
408        drop(el);
409        let cmd = Command::unsubscribe(vec!["a".into(), "b".into()]);
410        assert_eq!(try_send(&client, cmd.clone()), Err(Unsent::Full(cmd)));
411        let cmd = Command::subscribe(vec![("a".into(), ebus_mqtt::QoS::AtMostOnce)]);
412        assert_eq!(try_send(&client, cmd.clone()), Err(Unsent::Full(cmd)));
413        let cmd = Command::subscribe(vec![("a/#/b".into(), ebus_mqtt::QoS::AtMostOnce)]);
414        assert_eq!(try_send(&client, cmd.clone()), Err(Unsent::Invalid(cmd)));
415    }
416}
417
418#[cfg(doctest)]
419#[doc = include_str!("../README.md")]
420struct ReadmeDoctests;