Skip to main content

pamoja_core/
transport.rs

1//! The transport abstraction: how bytes move between the SDK and a device or peer.
2
3use alloc::string::String;
4use alloc::vec::Vec;
5use core::future::Future;
6
7use crate::error::Result;
8
9/// A message that arrived on a subscribed topic.
10///
11/// Every link that can deliver hands back the same shape, so a node reads a
12/// command the same way whether it came over MQTT, CoAP, Zenoh, or the in-process
13/// broker used in tests.
14#[derive(Clone, Debug, PartialEq, Eq)]
15pub struct Message {
16    /// The topic the message was published to.
17    pub topic: String,
18    /// The raw payload bytes.
19    pub payload: Vec<u8>,
20}
21
22impl Message {
23    /// Creates a message from a topic and a payload.
24    ///
25    /// # Arguments
26    ///
27    /// * `topic` - the topic the message was published to.
28    /// * `payload` - the raw payload bytes.
29    ///
30    /// # Returns
31    ///
32    /// The message.
33    pub fn new(topic: impl Into<String>, payload: impl Into<Vec<u8>>) -> Self {
34        Self {
35            topic: topic.into(),
36            payload: payload.into(),
37        }
38    }
39
40    /// Reads the payload as text.
41    ///
42    /// Most readings and commands on a topic are words or a number written out, and
43    /// this is the accessor for them; a payload that is a codec's bytes goes through
44    /// the codec instead.
45    ///
46    /// # Returns
47    ///
48    /// The payload as a string slice.
49    ///
50    /// # Errors
51    ///
52    /// Returns [`Error::Codec`](crate::Error::Codec) if the payload is not UTF-8 text.
53    pub fn text(&self) -> Result<&str> {
54        core::str::from_utf8(&self.payload)
55            .map_err(|_| crate::Error::Codec("the payload is not UTF-8 text".into()))
56    }
57
58    /// Reads the payload as a number written out as text, such as `21.5`.
59    ///
60    /// # Returns
61    ///
62    /// The number.
63    ///
64    /// # Errors
65    ///
66    /// Returns [`Error::Codec`](crate::Error::Codec) if the payload is not UTF-8 text
67    /// or the text is not a number.
68    pub fn number(&self) -> Result<f64> {
69        self.text()?
70            .trim()
71            .parse()
72            .map_err(|_| crate::Error::Codec("the payload is not a number".into()))
73    }
74}
75
76/// A bidirectional, topic-addressed message transport.
77///
78/// Implementations include MQTT, CoAP, LoRa, serial, and CAN. They are expected
79/// to handle reconnection and backpressure internally so that callers see a
80/// uniform, protocol-agnostic surface.
81///
82/// This trait is the outbound half of a link: connect, publish, and register
83/// interest in a topic. A link that can also deliver what it subscribed to
84/// implements [`Receive`] as well. The two are separate because not every link
85/// listens: a LoRa uplink or a satellite messenger only sends, and it still belongs
86/// on a transport ladder.
87///
88/// The returned futures are `Send`, so a transport can be driven from a task on
89/// a multi-threaded runtime and can be erased behind a trait object that is.
90/// Both matter in practice: a gateway ticks its links from spawned tasks, and a
91/// transport ladder holds rungs of different concrete types in one list. An
92/// implementation written as `async fn` satisfies this as long as everything it
93/// holds across an await is `Send`, which every transport here already is.
94pub trait Transport {
95    /// Establishes the connection to the broker, peer, or bus.
96    ///
97    /// # Returns
98    ///
99    /// `Ok(())` once the transport is connected and ready to carry traffic.
100    ///
101    /// # Errors
102    ///
103    /// Returns [`Error::Transport`](crate::Error::Transport) if the connection
104    /// cannot be established.
105    fn connect(&mut self) -> impl Future<Output = Result<()>> + Send;
106
107    /// Publishes a payload to a topic.
108    ///
109    /// # Arguments
110    ///
111    /// * `topic` - the destination topic or channel address.
112    /// * `payload` - the raw bytes to publish.
113    ///
114    /// # Returns
115    ///
116    /// `Ok(())` once the payload has been handed to the transport for delivery.
117    ///
118    /// # Errors
119    ///
120    /// Returns [`Error::Transport`](crate::Error::Transport) if the payload cannot
121    /// be sent, or [`Error::Closed`](crate::Error::Closed) if the transport is not
122    /// connected.
123    fn send(&mut self, topic: &str, payload: &[u8]) -> impl Future<Output = Result<()>> + Send;
124
125    /// Publishes text to a topic: words, or a number written out.
126    ///
127    /// This is [`send`](Transport::send) with the text's UTF-8 bytes, so a reading or a
128    /// command that is plain text needs no encoding step on either end; the receiver
129    /// reads it back with [`Message::text`] or [`Message::number`].
130    ///
131    /// # Arguments
132    ///
133    /// * `topic` - the destination topic.
134    /// * `text` - the text to publish.
135    ///
136    /// # Returns
137    ///
138    /// `Ok(())` once the transport has accepted the message.
139    ///
140    /// # Errors
141    ///
142    /// Whatever [`send`](Transport::send) returns.
143    fn send_text(&mut self, topic: &str, text: &str) -> impl Future<Output = Result<()>> + Send {
144        self.send(topic, text.as_bytes())
145    }
146
147    /// Subscribes to a topic so that matching payloads are routed to this transport.
148    ///
149    /// # Arguments
150    ///
151    /// * `topic` - the topic or channel filter to subscribe to.
152    ///
153    /// # Returns
154    ///
155    /// `Ok(())` once the subscription is registered with the transport.
156    ///
157    /// # Errors
158    ///
159    /// Returns [`Error::Transport`](crate::Error::Transport) if the subscription
160    /// is rejected, or [`Error::Closed`](crate::Error::Closed) if the transport is
161    /// not connected.
162    fn subscribe(&mut self, topic: &str) -> impl Future<Output = Result<()>> + Send;
163}
164
165/// The inbound half of a link: the messages that arrive on subscribed topics.
166///
167/// A transport that can deliver implements this beside [`Transport`], and anything
168/// that waits for a command, a reply, or another node's reading takes `Transport +
169/// Receive`. A ladder listens on every rung that implements it and hands up
170/// whichever delivers first.
171///
172/// # Cancel safety
173///
174/// Dropping the future [`recv`](Receive::recv) returns before it completes must
175/// not lose a message: the message stays queued for the next call. A ladder and a
176/// `select!` both depend on this, since they drop every future but the one that
177/// completed. Reading from a channel whose receive is itself cancel-safe, which the
178/// queue behind every transport here is, satisfies it.
179///
180/// # Examples
181///
182/// A subscriber loop that stops when the link ends:
183///
184/// ```
185/// use pamoja_core::{Message, Receive, Result};
186///
187/// async fn drain(link: &mut impl Receive) -> Result<Vec<Message>> {
188///     let mut seen = Vec::new();
189///     while let Some(message) = link.recv().await? {
190///         seen.push(message);
191///     }
192///     Ok(seen)
193/// }
194/// ```
195pub trait Receive {
196    /// Awaits the next message from any subscribed topic.
197    ///
198    /// # Returns
199    ///
200    /// `Some(message)` for the next message, or `None` once the link has ended and
201    /// no further messages will arrive.
202    ///
203    /// # Errors
204    ///
205    /// Returns [`Error::Closed`](crate::Error::Closed) if the transport is not
206    /// connected, or [`Error::Transport`](crate::Error::Transport) if the link
207    /// fails while waiting.
208    fn recv(&mut self) -> impl Future<Output = Result<Option<Message>>> + Send;
209}
210
211/// Returns whether an MQTT topic filter matches a topic.
212///
213/// `+` matches exactly one level and `#` matches the remaining levels, including none, as
214/// the OASIS MQTT specification defines them. A filter that begins with a wildcard does not
215/// match a topic that begins with `$`, which MQTT reserves for server topics. A transport
216/// that delivers by topic itself, rather than through a broker, filters with this.
217///
218/// # Arguments
219///
220/// * `filter` - the subscription filter, which may hold wildcards.
221/// * `topic` - the topic a message was published to.
222///
223/// # Returns
224///
225/// `true` when the filter selects the topic.
226///
227/// # Examples
228///
229/// ```
230/// use pamoja_core::topic_matches;
231///
232/// assert!(topic_matches("sensors/+/temperature", "sensors/1/temperature"));
233/// assert!(topic_matches("sensors/#", "sensors"));
234/// assert!(!topic_matches("#", "$SYS/broker/uptime"));
235/// ```
236pub fn topic_matches(filter: &str, topic: &str) -> bool {
237    if topic.starts_with('$') {
238        if let Some(first) = filter.split('/').next() {
239            if first == "#" || first == "+" {
240                return false;
241            }
242        }
243    }
244    let mut filter_levels = filter.split('/');
245    let mut topic_levels = topic.split('/');
246    loop {
247        match (filter_levels.next(), topic_levels.next()) {
248            (Some("#"), _) => return true,
249            (Some("+"), Some(_)) => {}
250            (Some(filter_level), Some(topic_level)) if filter_level == topic_level => {}
251            (None, None) => return true,
252            _ => return false,
253        }
254    }
255}
256
257#[cfg(test)]
258mod tests {
259    #[test]
260    fn a_message_reads_as_text_or_a_number() {
261        let reading = super::Message::new("garden/bed-1/moisture", "28.5");
262        assert_eq!(reading.text().unwrap(), "28.5");
263        assert_eq!(reading.number().unwrap(), 28.5);
264        let spaced = super::Message::new("t", " 42 \n");
265        assert_eq!(spaced.number().unwrap(), 42.0);
266        let words = super::Message::new("garden/bed-1/valve", "open");
267        assert_eq!(words.text().unwrap(), "open");
268        assert!(matches!(words.number(), Err(crate::Error::Codec(_))));
269        let raw = super::Message::new("t", vec![0xff, 0xfe]);
270        assert!(matches!(raw.text(), Err(crate::Error::Codec(_))));
271    }
272
273    use super::*;
274
275    #[test]
276    fn a_message_is_built_from_any_topic_and_payload_shape() {
277        let message = Message::new("sensors/1", b"21.5");
278        assert_eq!(message.topic, "sensors/1");
279        assert_eq!(message.payload, b"21.5");
280        assert_eq!(
281            message,
282            Message::new(String::from("sensors/1"), vec![0x32, 0x31, 0x2E, 0x35])
283        );
284    }
285
286    #[test]
287    fn exact_topics_match() {
288        assert!(topic_matches("a/b/c", "a/b/c"));
289        assert!(!topic_matches("a/b/c", "a/b/d"));
290        assert!(!topic_matches("a/b", "a/b/c"));
291        assert!(!topic_matches("a/b/c", "a/b"));
292    }
293
294    #[test]
295    fn single_level_wildcard_matches_one_level() {
296        assert!(topic_matches("a/+/c", "a/b/c"));
297        assert!(topic_matches(
298            "sensors/+/temperature",
299            "sensors/1/temperature"
300        ));
301        assert!(!topic_matches("a/+/c", "a/b/c/d"));
302        assert!(!topic_matches("a/+", "a"));
303    }
304
305    #[test]
306    fn multi_level_wildcard_matches_the_rest() {
307        assert!(topic_matches("a/#", "a/b/c"));
308        assert!(topic_matches("a/#", "a"));
309        assert!(topic_matches("#", "a/b/c"));
310        assert!(!topic_matches("a/#", "b/c"));
311    }
312
313    #[test]
314    fn the_worked_examples_in_section_4_7_1_hold() {
315        assert!(topic_matches("sport/#", "sport"));
316        assert!(topic_matches(
317            "sport/tennis/player1/#",
318            "sport/tennis/player1/score/wimbledon"
319        ));
320        assert!(topic_matches("sport/tennis/+", "sport/tennis/player2"));
321        assert!(!topic_matches(
322            "sport/tennis/+",
323            "sport/tennis/player1/ranking"
324        ));
325        assert!(topic_matches("+/+", "/finance"));
326        assert!(topic_matches("/+", "/finance"));
327        assert!(!topic_matches("+", "/finance"));
328    }
329
330    #[test]
331    fn leading_wildcards_do_not_match_dollar_topics() {
332        assert!(!topic_matches("#", "$SYS/broker/uptime"));
333        assert!(!topic_matches("+/broker", "$SYS/broker"));
334        assert!(topic_matches("$SYS/#", "$SYS/broker/uptime"));
335        assert!(topic_matches("$SYS/+", "$SYS/uptime"));
336    }
337}