Skip to main content

ebus_mqtt/
role.rs

1//! Roles that share one MQTT link, and the combinator that runs two of them.
2
3use alloc::{string::String, vec::Vec};
4
5use crate::{Command, Handled, Instant, Link, Message};
6
7/// A broker or client report on a command already sent, for a host that
8/// tracks delivery (a device built with `TreeConfig::receipts`).
9#[derive(Clone, Debug, PartialEq, Eq, Hash)]
10#[non_exhaustive]
11pub enum Receipt {
12    /// The SUBACK of one SUBSCRIBE.
13    Subscribed {
14        /// The SUBSCRIBE's filters, in packet order.
15        filters: Vec<String>,
16        /// False when any return code is `0x80` (failure).
17        granted: bool,
18    },
19    /// A publish was written to the connection.
20    Written {
21        /// The publish's topic.
22        topic: String,
23    },
24    /// The broker acknowledged a publish (PUBACK at QoS 1, PUBCOMP at QoS 2).
25    Acknowledged {
26        /// The publish's topic.
27        topic: String,
28        /// The publish had a zero-length payload.
29        cleared: bool,
30    },
31}
32
33/// One protocol role on an MQTT link: a sans-IO state machine fed with link
34/// changes, messages and the caller's time, and drained of commands.
35///
36/// The methods mirror the controller's and the device's own; see either for
37/// the calling contract.
38pub trait Role {
39    /// A CONNACK, or a lost connection.
40    fn handle_link(&mut self, now: Instant, link: Link);
41    /// One received message.
42    fn handle_message(&mut self, now: Instant, msg: Message<'_>) -> Handled;
43    /// A command the host could not hand to its client.
44    fn handle_command_failed(&mut self, now: Instant, cmd: Command);
45    /// The instant [`poll_timeout`](Self::poll_timeout) returned has passed.
46    fn handle_timeout(&mut self, now: Instant);
47    /// The host dropped messages for `topic`'s device; ignored by default.
48    fn handle_dropped(&mut self, _now: Instant, _topic: &str) {}
49    /// [`handle_dropped`](Self::handle_dropped) at its place in the message
50    /// order; ignored by default.
51    fn handle_lost(&mut self, _topic: &str) {}
52    /// A delivery report; ignored by default.
53    fn handle_receipt(&mut self, _now: Instant, _receipt: &Receipt) {}
54    /// The next command for the MQTT client.
55    fn poll_command(&mut self) -> Option<Command>;
56    /// When to call [`handle_timeout`](Self::handle_timeout) next.
57    fn poll_timeout(&self) -> Option<Instant>;
58    /// True when the role takes messages on `topic`.
59    fn wants(&self, topic: &str) -> bool;
60    /// True when `cmd` came from this role, so a failure goes back to it.
61    fn owns(&self, cmd: &Command) -> bool;
62    /// True when this role holds a subscription to `filter`.
63    fn holds(&self, filter: &str) -> bool;
64}
65
66/// Two roles on one link.
67///
68/// Link changes and commands go to `a` first, then `b`. Messages go to every
69/// role that [`wants`](Role::wants) them, receipts and drop notices to both,
70/// and a failed command to the first role that [`owns`](Role::owns) it. Each
71/// role keeps its own subscriptions: a filter one role unsubscribes while the
72/// other still [`holds`](Role::holds) it is removed from the UNSUBSCRIBE, so
73/// the role that releases a shared filter last unsubscribes it.
74///
75/// A wanted message while the link is down (or before the first link-up)
76/// counts as `Link::Up { session_present: false }`, delivered to both roles
77/// before the message, so neither comes up alone.
78#[derive(Debug)]
79pub struct Pair<A: Role, B: Role> {
80    /// The role served first; the device in a device and controller pair.
81    pub a: A,
82    /// The role served second.
83    pub b: B,
84    up: bool,
85}
86
87impl<A: Role, B: Role> Pair<A, B> {
88    /// Both roles on one link that is not up yet.
89    pub fn new(a: A, b: B) -> Self {
90        Pair { a, b, up: false }
91    }
92
93    /// True between a link-up (explicit or implicit) and the next link-down.
94    pub fn is_up(&self) -> bool {
95        self.up
96    }
97}
98
99/// `cmd` without the filters `other` holds; `None` when nothing is left.
100fn strip(cmd: Command, other: &impl Role) -> Option<Command> {
101    match cmd {
102        Command::Unsubscribe { filters } => {
103            let filters: Vec<String> = filters.into_iter().filter(|f| !other.holds(f)).collect();
104            (!filters.is_empty()).then_some(Command::Unsubscribe { filters })
105        }
106        cmd => Some(cmd),
107    }
108}
109
110impl<A: Role, B: Role> Role for Pair<A, B> {
111    fn handle_link(&mut self, now: Instant, link: Link) {
112        self.up = matches!(link, Link::Up { .. });
113        self.a.handle_link(now, link);
114        self.b.handle_link(now, link);
115    }
116
117    fn handle_message(&mut self, now: Instant, msg: Message<'_>) -> Handled {
118        let (wa, wb) = (self.a.wants(msg.topic), self.b.wants(msg.topic));
119        if !wa && !wb {
120            return Handled::Ignored;
121        }
122        if !self.up {
123            self.handle_link(
124                now,
125                Link::Up {
126                    session_present: false,
127                },
128            );
129        }
130        let mut handled = Handled::Ignored;
131        if wa && self.a.handle_message(now, msg) == Handled::Consumed {
132            handled = Handled::Consumed;
133        }
134        if wb && self.b.handle_message(now, msg) == Handled::Consumed {
135            handled = Handled::Consumed;
136        }
137        handled
138    }
139
140    fn handle_command_failed(&mut self, now: Instant, cmd: Command) {
141        if self.a.owns(&cmd) {
142            self.a.handle_command_failed(now, cmd);
143        } else if self.b.owns(&cmd) {
144            self.b.handle_command_failed(now, cmd);
145        }
146    }
147
148    fn handle_timeout(&mut self, now: Instant) {
149        self.a.handle_timeout(now);
150        self.b.handle_timeout(now);
151    }
152
153    fn handle_dropped(&mut self, now: Instant, topic: &str) {
154        self.a.handle_dropped(now, topic);
155        self.b.handle_dropped(now, topic);
156    }
157
158    fn handle_lost(&mut self, topic: &str) {
159        self.a.handle_lost(topic);
160        self.b.handle_lost(topic);
161    }
162
163    fn handle_receipt(&mut self, now: Instant, receipt: &Receipt) {
164        self.a.handle_receipt(now, receipt);
165        self.b.handle_receipt(now, receipt);
166    }
167
168    fn poll_command(&mut self) -> Option<Command> {
169        while let Some(cmd) = self.a.poll_command() {
170            if let Some(cmd) = strip(cmd, &self.b) {
171                return Some(cmd);
172            }
173        }
174        while let Some(cmd) = self.b.poll_command() {
175            if let Some(cmd) = strip(cmd, &self.a) {
176                return Some(cmd);
177            }
178        }
179        None
180    }
181
182    fn poll_timeout(&self) -> Option<Instant> {
183        match (self.a.poll_timeout(), self.b.poll_timeout()) {
184            (Some(x), Some(y)) => Some(x.min(y)),
185            (x, y) => x.or(y),
186        }
187    }
188
189    fn wants(&self, topic: &str) -> bool {
190        self.a.wants(topic) || self.b.wants(topic)
191    }
192
193    fn owns(&self, cmd: &Command) -> bool {
194        self.a.owns(cmd) || self.b.owns(cmd)
195    }
196
197    fn holds(&self, filter: &str) -> bool {
198        self.a.holds(filter) || self.b.holds(filter)
199    }
200}
201
202#[cfg(test)]
203mod tests {
204    extern crate std;
205
206    use super::*;
207    use crate::{FilterLedger, QoS};
208    use alloc::{collections::VecDeque, string::ToString, vec, vec::Vec};
209
210    /// A role that records its input and replays scripted commands.
211    #[derive(Default)]
212    struct Fake {
213        name: &'static str,
214        prefix: &'static str,
215        ledger: FilterLedger,
216        queue: VecDeque<Command>,
217        log: Vec<std::string::String>,
218        timeout: Option<Instant>,
219    }
220
221    impl Fake {
222        fn new(name: &'static str, prefix: &'static str) -> Self {
223            Fake {
224                name,
225                prefix,
226                ..Fake::default()
227            }
228        }
229
230        fn sub(&mut self, filters: &[&str]) {
231            let cmd = self.ledger.subscribe(
232                filters
233                    .iter()
234                    .map(|f| (f.to_string(), QoS::ExactlyOnce))
235                    .collect(),
236            );
237            self.queue.extend(cmd);
238        }
239
240        fn unsub(&mut self, filters: &[&str]) {
241            let cmd = self
242                .ledger
243                .unsubscribe(filters.iter().map(|f| f.to_string()).collect());
244            self.queue.extend(cmd);
245        }
246
247        fn publish(&mut self, topic: &str) {
248            self.queue.push_back(Command::Publish {
249                topic: topic.to_string(),
250                payload: Vec::new(),
251                qos: QoS::AtMostOnce,
252                retain: false,
253            });
254        }
255    }
256
257    impl Role for Fake {
258        fn handle_link(&mut self, _now: Instant, link: Link) {
259            self.log.push(std::format!("{}:{link:?}", self.name));
260        }
261        fn handle_message(&mut self, _now: Instant, msg: Message<'_>) -> Handled {
262            self.log
263                .push(std::format!("{}:msg {}", self.name, msg.topic));
264            Handled::Consumed
265        }
266        fn handle_command_failed(&mut self, _now: Instant, cmd: Command) {
267            self.log.push(std::format!("{}:failed {cmd:?}", self.name));
268        }
269        fn handle_timeout(&mut self, _now: Instant) {
270            self.log.push(std::format!("{}:timeout", self.name));
271        }
272        fn handle_dropped(&mut self, _now: Instant, topic: &str) {
273            self.log.push(std::format!("{}:dropped {topic}", self.name));
274        }
275        fn handle_receipt(&mut self, _now: Instant, receipt: &Receipt) {
276            self.log
277                .push(std::format!("{}:receipt {receipt:?}", self.name));
278        }
279        fn poll_command(&mut self) -> Option<Command> {
280            self.queue.pop_front()
281        }
282        fn poll_timeout(&self) -> Option<Instant> {
283            self.timeout
284        }
285        fn wants(&self, topic: &str) -> bool {
286            topic.starts_with(self.prefix)
287        }
288        fn owns(&self, cmd: &Command) -> bool {
289            match cmd {
290                Command::Publish { topic, .. } => topic.starts_with(self.prefix),
291                _ => false,
292            }
293        }
294        fn holds(&self, filter: &str) -> bool {
295            self.ledger.count(filter) > 0
296        }
297    }
298
299    fn pair() -> Pair<Fake, Fake> {
300        Pair::new(Fake::new("dev", "d/"), Fake::new("ctl", "c/"))
301    }
302
303    fn drain(p: &mut Pair<Fake, Fake>) -> Vec<Command> {
304        core::iter::from_fn(|| p.poll_command()).collect()
305    }
306
307    fn unsub_cmd(filters: &[&str]) -> Command {
308        Command::Unsubscribe {
309            filters: filters.iter().map(|f| f.to_string()).collect(),
310        }
311    }
312
313    #[test]
314    fn link_and_commands_go_to_a_first() {
315        let mut p = pair();
316        p.b.publish("c/1");
317        p.a.publish("d/1");
318        p.a.publish("d/2");
319        p.handle_link(
320            Instant(0),
321            Link::Up {
322                session_present: true,
323            },
324        );
325        assert_eq!(
326            p.a.log,
327            ["dev:Up { session_present: true }"],
328            "a sees the link"
329        );
330        assert_eq!(p.b.log, ["ctl:Up { session_present: true }"]);
331        let topics: Vec<_> = drain(&mut p)
332            .into_iter()
333            .map(|c| match c {
334                Command::Publish { topic, .. } => topic,
335                other => std::format!("{other:?}"),
336            })
337            .collect();
338        assert_eq!(topics, ["d/1", "d/2", "c/1"]);
339    }
340
341    #[test]
342    fn unsubscribe_keeps_filters_the_other_role_holds() {
343        let mut p = pair();
344        p.a.sub(&["x/$state", "d/+/+/set"]);
345        p.b.sub(&["x/$state", "x/$description"]);
346        drain(&mut p);
347        // The controller releases everything; the device still holds x/$state.
348        p.b.unsub(&["x/$state", "x/$description"]);
349        assert_eq!(drain(&mut p), [unsub_cmd(&["x/$description"])]);
350        // The device releases the shared filter last, so it unsubscribes it.
351        p.a.unsub(&["x/$state"]);
352        assert_eq!(drain(&mut p), [unsub_cmd(&["x/$state"])]);
353    }
354
355    #[test]
356    fn unsubscribe_entirely_held_by_the_other_role_is_dropped() {
357        let mut p = pair();
358        p.a.sub(&["x/$state"]);
359        p.b.sub(&["x/$state"]);
360        drain(&mut p);
361        p.a.unsub(&["x/$state"]);
362        p.a.publish("d/after");
363        let cmds = drain(&mut p);
364        assert_eq!(cmds.len(), 1);
365        assert!(matches!(&cmds[0], Command::Publish { topic, .. } if topic == "d/after"));
366        assert!(p.holds("x/$state"));
367    }
368
369    #[test]
370    fn messages_go_to_every_role_that_wants_them() {
371        let mut p = pair();
372        p.handle_link(
373            Instant(0),
374            Link::Up {
375                session_present: false,
376            },
377        );
378        let msg = |topic| Message {
379            topic,
380            payload: b"",
381            retain: None,
382        };
383        assert_eq!(p.handle_message(Instant(0), msg("d/x")), Handled::Consumed);
384        assert_eq!(p.handle_message(Instant(0), msg("c/x")), Handled::Consumed);
385        assert_eq!(p.handle_message(Instant(0), msg("z/x")), Handled::Ignored);
386        assert_eq!(p.a.log.last().map(|s| s.as_str()), Some("dev:msg d/x"));
387        assert_eq!(p.b.log.last().map(|s| s.as_str()), Some("ctl:msg c/x"));
388        assert_eq!(p.a.log.len(), 2);
389        assert_eq!(p.b.log.len(), 2);
390    }
391
392    #[test]
393    fn a_message_while_down_brings_both_roles_up_first() {
394        let mut p = pair();
395        let msg = Message {
396            topic: "c/x",
397            payload: b"1",
398            retain: Some(false),
399        };
400        p.handle_message(Instant(5), msg);
401        assert!(p.is_up());
402        assert_eq!(p.a.log, ["dev:Up { session_present: false }"]);
403        assert_eq!(
404            p.b.log,
405            ["ctl:Up { session_present: false }", "ctl:msg c/x"]
406        );
407        // Once up, no second link-up.
408        p.handle_message(Instant(6), msg);
409        assert_eq!(p.a.log.len(), 1);
410        // Down, then a message unwanted by both: still down.
411        p.handle_link(Instant(7), Link::Down);
412        p.handle_message(
413            Instant(8),
414            Message {
415                topic: "z/x",
416                ..msg
417            },
418        );
419        assert!(!p.is_up());
420    }
421
422    #[test]
423    fn failed_command_goes_to_its_owner() {
424        let mut p = pair();
425        let publish = |topic: &str| Command::Publish {
426            topic: topic.to_string(),
427            payload: vec![],
428            qos: QoS::AtMostOnce,
429            retain: false,
430        };
431        p.handle_command_failed(Instant(0), publish("c/1"));
432        p.handle_command_failed(Instant(0), publish("d/1"));
433        p.handle_command_failed(Instant(0), publish("z/1"));
434        assert_eq!(p.a.log.len(), 1);
435        assert!(p.a.log[0].contains("d/1"));
436        assert_eq!(p.b.log.len(), 1);
437        assert!(p.b.log[0].contains("c/1"));
438    }
439
440    #[test]
441    fn receipts_drops_and_timeouts_reach_both_roles() {
442        let mut p = pair();
443        let r = Receipt::Written {
444            topic: "d/1".to_string(),
445        };
446        p.handle_receipt(Instant(0), &r);
447        p.handle_dropped(Instant(0), "c/x");
448        p.handle_timeout(Instant(0));
449        for log in [&p.a.log, &p.b.log] {
450            assert_eq!(log.len(), 3);
451            assert!(log[0].contains("Written"));
452            assert!(log[1].contains("dropped c/x"));
453            assert!(log[2].ends_with("timeout"));
454        }
455    }
456
457    #[test]
458    fn poll_timeout_is_the_earlier_one() {
459        let mut p = pair();
460        assert_eq!(p.poll_timeout(), None);
461        p.b.timeout = Some(Instant(9));
462        assert_eq!(p.poll_timeout(), Some(Instant(9)));
463        p.a.timeout = Some(Instant(4));
464        assert_eq!(p.poll_timeout(), Some(Instant(4)));
465    }
466}