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}