Skip to main content

pamoja_gateway/
chirpstack.rs

1//! Reading what a ChirpStack network server publishes about the devices it hears.
2//!
3//! A gateway hands packets to a network server, and the server hands what they carried to the
4//! application: decrypted, deduplicated across gateways, and tagged with the device it came
5//! from. ChirpStack publishes that on MQTT, one event per uplink, on
6//! `application/{application id}/device/{dev eui}/event/up`, as the JSON form of its
7//! `integration.UplinkEvent` message. [`UplinkEvent::from_json`] reads it into plain fields,
8//! so a program that feeds a dashboard or a store needs to know nothing about the message
9//! definitions.
10//!
11//! Two things about the JSON are worth knowing, both from protobuf's JSON mapping rather than
12//! from ChirpStack. Every name is camelCase, so `f_port` arrives as `fPort`. And a field at its
13//! default value is left out, so the first uplink after a join has no `fCnt`, and an uplink
14//! without ADR has no `adr`; they read here as zero and `false`.
15//!
16//! The layout follows ChirpStack v4's `api/proto/integration/integration.proto` and the example
17//! on its integration events documentation page, and the ChirpStack interop job reads a live
18//! server's events with it.
19//!
20//! # Examples
21//!
22//! A soil probe's reading as ChirpStack publishes it, in the layout its integration events
23//! documentation shows. The payload arrives decrypted and in base64, `MjEuNQ==` being the
24//! text `21.5`:
25//!
26//! ```
27//! use pamoja_gateway::chirpstack::UplinkEvent;
28//!
29//! let json = r#"{
30//!     "deduplicationId": "3ac7e3c4-4401-4b8d-9386-a5c902f9202d",
31//!     "time": "2026-07-18T09:34:15.775023242+00:00",
32//!     "deviceInfo": {
33//!         "applicationId": "17c82e96-be03-4f38-aef3-f83d48582d97",
34//!         "deviceName": "soil probe, north bed",
35//!         "devEui": "70b3d57ed0001234"
36//!     },
37//!     "devAddr": "26011bda",
38//!     "dr": 5,
39//!     "fPort": 2,
40//!     "data": "MjEuNQ==",
41//!     "rxInfo": [{ "gatewayId": "b827ebfffe010203", "rssi": -36, "snr": 10.5 }],
42//!     "txInfo": { "frequency": 868100000 }
43//! }"#;
44//!
45//! let event = UplinkEvent::from_json(json)?;
46//! assert_eq!(event.dev_eui.to_hex(), "70b3d57ed0001234");
47//! assert_eq!(event.fport, Some(2));
48//! assert_eq!(event.data, b"21.5");
49//! assert_eq!(event.fcnt, 0, "left out of the JSON because it is zero");
50//! assert_eq!(event.best_reception().map(|heard| heard.rssi_dbm), Some(-36));
51//! # Ok::<(), pamoja_gateway::chirpstack::EventError>(())
52//! ```
53
54use std::fmt;
55
56use serde_json::{Map, Value};
57
58use crate::base64;
59use crate::udp::Eui;
60
61/// The topic filter for every uplink event of every application on a server.
62pub const UPLINK_TOPIC: &str = "application/+/device/+/event/up";
63
64/// Returns the topic filter for one application's uplink events.
65///
66/// # Arguments
67///
68/// * `application_id` - the application's identifier, as ChirpStack shows it.
69///
70/// # Returns
71///
72/// The filter, with every device of the application matched.
73///
74/// # Examples
75///
76/// ```
77/// use pamoja_gateway::chirpstack::uplink_topic;
78///
79/// assert_eq!(
80///     uplink_topic("17c82e96-be03-4f38-aef3-f83d48582d97"),
81///     "application/17c82e96-be03-4f38-aef3-f83d48582d97/device/+/event/up"
82/// );
83/// ```
84pub fn uplink_topic(application_id: &str) -> String {
85    format!("application/{application_id}/device/+/event/up")
86}
87
88/// One gateway's reception of an uplink.
89#[derive(Clone, Debug, PartialEq)]
90pub struct Reception {
91    /// The gateway that heard it.
92    pub gateway: Eui,
93    /// The signal strength it heard, in dBm.
94    pub rssi_dbm: i32,
95    /// The signal-to-noise ratio it measured, in dB.
96    pub snr_db: f32,
97}
98
99/// An uplink as a ChirpStack server published it.
100#[derive(Clone, Debug, PartialEq)]
101pub struct UplinkEvent {
102    /// The identifier ChirpStack gave the uplink once it had deduplicated it across gateways.
103    pub deduplication_id: String,
104    /// When the server received it, as RFC 3339 text, if the event carried the time.
105    pub time: Option<String>,
106    /// The application the device belongs to.
107    pub application_id: String,
108    /// The device's name on the server.
109    pub device_name: String,
110    /// The device's identifier.
111    pub dev_eui: Eui,
112    /// The device's address in its current session.
113    pub dev_addr: Option<u32>,
114    /// Whether the uplink had adaptive data rate on.
115    pub adr: bool,
116    /// The data rate it arrived at, as the region numbers them.
117    pub data_rate: u8,
118    /// The uplink frame counter.
119    pub fcnt: u32,
120    /// The application port, or `None` for an uplink the server reports without one.
121    pub fport: Option<u8>,
122    /// Whether the device asked for an acknowledgment.
123    pub confirmed: bool,
124    /// The decrypted application payload.
125    pub data: Vec<u8>,
126    /// The frequency it arrived on, in hertz.
127    pub frequency_hz: Option<u32>,
128    /// Every gateway that heard it.
129    pub receptions: Vec<Reception>,
130}
131
132/// Why a message is not an uplink event.
133#[derive(Clone, Debug, PartialEq, Eq)]
134pub enum EventError {
135    /// The message is not JSON, or its top level is not an object.
136    Json(String),
137    /// A field the event always carries is not there.
138    Missing(&'static str),
139    /// A field is there but does not hold what it should.
140    Invalid(&'static str),
141}
142
143impl fmt::Display for EventError {
144    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
145        match self {
146            EventError::Json(why) => write!(f, "the event is not a JSON object: {why}"),
147            EventError::Missing(field) => write!(f, "the event has no {field}"),
148            EventError::Invalid(field) => write!(f, "the event's {field} is not valid"),
149        }
150    }
151}
152
153impl std::error::Error for EventError {}
154
155impl UplinkEvent {
156    /// Reads an uplink event.
157    ///
158    /// # Arguments
159    ///
160    /// * `text` - the MQTT message's payload.
161    ///
162    /// # Returns
163    ///
164    /// The event.
165    ///
166    /// # Errors
167    ///
168    /// Returns [`EventError::Json`] for text that is not a JSON object,
169    /// [`EventError::Missing`] without `deviceInfo.devEui`, and [`EventError::Invalid`] for a
170    /// field of the wrong kind: an identifier that is not hexadecimal, a payload that is not
171    /// base64, or a number out of its range.
172    pub fn from_json(text: &str) -> Result<UplinkEvent, EventError> {
173        let value: Value =
174            serde_json::from_str(text).map_err(|error| EventError::Json(error.to_string()))?;
175        let event = value
176            .as_object()
177            .ok_or_else(|| EventError::Json("the top level is not an object".to_owned()))?;
178        let empty = Map::new();
179        let device = match event.get("deviceInfo") {
180            Some(Value::Object(device)) => device,
181            Some(_) => return Err(EventError::Invalid("deviceInfo")),
182            None => &empty,
183        };
184
185        let dev_eui = text_field(device, "devEui")
186            .ok_or(EventError::Missing("deviceInfo.devEui"))
187            .and_then(|hex| Eui::from_hex(&hex).ok_or(EventError::Invalid("deviceInfo.devEui")))?;
188        let dev_addr = match text_field(event, "devAddr") {
189            Some(hex) => {
190                Some(u32::from_str_radix(&hex, 16).map_err(|_| EventError::Invalid("devAddr"))?)
191            }
192            None => None,
193        };
194        let data = match text_field(event, "data") {
195            Some(encoded) => base64::decode(&encoded).map_err(|_| EventError::Invalid("data"))?,
196            None => Vec::new(),
197        };
198
199        let mut receptions = Vec::new();
200        if let Some(Value::Array(heard)) = event.get("rxInfo") {
201            for entry in heard {
202                let Some(entry) = entry.as_object() else {
203                    return Err(EventError::Invalid("rxInfo"));
204                };
205                let gateway = text_field(entry, "gatewayId")
206                    .and_then(|hex| Eui::from_hex(&hex))
207                    .ok_or(EventError::Invalid("rxInfo.gatewayId"))?;
208                receptions.push(Reception {
209                    gateway,
210                    rssi_dbm: whole(entry, "rssi", "rxInfo.rssi")?.unwrap_or(0),
211                    snr_db: entry.get("snr").and_then(Value::as_f64).unwrap_or(0.0) as f32,
212                });
213            }
214        }
215
216        let frequency_hz = match event.get("txInfo") {
217            Some(Value::Object(transmitted)) => {
218                whole(transmitted, "frequency", "txInfo.frequency")?
219            }
220            _ => None,
221        };
222
223        Ok(UplinkEvent {
224            deduplication_id: text_field(event, "deduplicationId").unwrap_or_default(),
225            time: text_field(event, "time"),
226            application_id: text_field(device, "applicationId").unwrap_or_default(),
227            device_name: text_field(device, "deviceName").unwrap_or_default(),
228            dev_eui,
229            dev_addr,
230            adr: event.get("adr").and_then(Value::as_bool).unwrap_or(false),
231            data_rate: whole(event, "dr", "dr")?.unwrap_or(0),
232            fcnt: whole(event, "fCnt", "fCnt")?.unwrap_or(0),
233            fport: whole(event, "fPort", "fPort")?,
234            confirmed: event
235                .get("confirmed")
236                .and_then(Value::as_bool)
237                .unwrap_or(false),
238            data,
239            frequency_hz,
240            receptions,
241        })
242    }
243
244    /// Returns the gateway that heard the uplink best.
245    ///
246    /// # Returns
247    ///
248    /// The reception with the highest signal-to-noise ratio, or `None` for an event that
249    /// named no gateway.
250    pub fn best_reception(&self) -> Option<&Reception> {
251        self.receptions
252            .iter()
253            .max_by(|one, other| one.snr_db.total_cmp(&other.snr_db))
254    }
255}
256
257fn text_field(object: &Map<String, Value>, key: &str) -> Option<String> {
258    object.get(key).and_then(Value::as_str).map(str::to_owned)
259}
260
261/// Reads a whole number into a type, where it is present.
262fn whole<T: TryFrom<i64>>(
263    object: &Map<String, Value>,
264    key: &str,
265    field: &'static str,
266) -> Result<Option<T>, EventError> {
267    match object.get(key) {
268        None | Some(Value::Null) => Ok(None),
269        Some(value) => value
270            .as_i64()
271            .and_then(|number| T::try_from(number).ok())
272            .map(Some)
273            .ok_or(EventError::Invalid(field)),
274    }
275}
276
277#[cfg(test)]
278mod tests {
279    use super::*;
280
281    /// The uplink event on ChirpStack's integration events documentation page, as published.
282    const DOCUMENTED: &str = r#"{
283	"deduplicationId": "3ac7e3c4-4401-4b8d-9386-a5c902f9202d",
284	"time": "2022-07-18T09:34:15.775023242+00:00",
285	"deviceInfo": {
286		"tenantId": "52f14cd4-c6f1-4fbd-8f87-4025e1d49242",
287		"tenantName": "ChirpStack",
288		"applicationId": "17c82e96-be03-4f38-aef3-f83d48582d97",
289		"applicationName": "Test application",
290		"deviceProfileId": "14855bf7-d10d-4aee-b618-ebfcb64dc7ad",
291		"deviceProfileName": "Test device-profile",
292		"deviceName": "Test device",
293		"devEui": "0101010101010101",
294		"tags": {
295			"key": "value"
296		}
297	},
298	"devAddr": "00189440",
299	"dr": 1,
300	"fPort": 1,
301	"data": "qg==",
302	"rxInfo": [{
303		"gatewayId": "0016c001f153a14c",
304		"uplinkId": 4217106255,
305		"rssi": -36,
306		"snr": 10.5,
307		"context": "E3OWOQ==",
308		"metadata": {
309			"region_name": "eu868",
310			"region_common_name": "EU868"
311		}
312	}],
313	"txInfo": {
314		"frequency": 867100000,
315		"modulation": {
316			"lora": {
317				"bandwidth": 125000,
318				"spreadingFactor": 11,
319				"codeRate": "CR_4_5"
320			}
321		}
322	}
323}"#;
324
325    #[test]
326    fn the_documented_event_reads_field_for_field() {
327        let event = UplinkEvent::from_json(DOCUMENTED).expect("reads");
328        assert_eq!(
329            event.deduplication_id,
330            "3ac7e3c4-4401-4b8d-9386-a5c902f9202d"
331        );
332        assert_eq!(
333            event.time.as_deref(),
334            Some("2022-07-18T09:34:15.775023242+00:00")
335        );
336        assert_eq!(event.application_id, "17c82e96-be03-4f38-aef3-f83d48582d97");
337        assert_eq!(event.device_name, "Test device");
338        assert_eq!(event.dev_eui, Eui::new([1; 8]));
339        assert_eq!(event.dev_addr, Some(0x0018_9440));
340        assert_eq!(event.data_rate, 1);
341        assert_eq!(event.fport, Some(1));
342        assert_eq!(event.data, [0xAA]);
343        assert_eq!(event.frequency_hz, Some(867_100_000));
344        assert_eq!(
345            event.receptions,
346            [Reception {
347                gateway: Eui::new([0x00, 0x16, 0xC0, 0x01, 0xF1, 0x53, 0xA1, 0x4C]),
348                rssi_dbm: -36,
349                snr_db: 10.5,
350            }]
351        );
352    }
353
354    #[test]
355    fn fields_left_at_their_defaults_read_as_zero_and_false() {
356        // Protobuf's JSON mapping leaves them out, which is why the documented event has no
357        // fCnt, adr or confirmed.
358        let event = UplinkEvent::from_json(DOCUMENTED).expect("reads");
359        assert_eq!(event.fcnt, 0);
360        assert!(!event.adr);
361        assert!(!event.confirmed);
362
363        let later = DOCUMENTED.replacen(
364            r#""dr": 1,"#,
365            r#""dr": 1, "fCnt": 70000, "adr": true, "confirmed": true,"#,
366            1,
367        );
368        let event = UplinkEvent::from_json(&later).expect("reads");
369        assert_eq!(event.fcnt, 70_000);
370        assert!(event.adr && event.confirmed);
371    }
372
373    #[test]
374    fn the_best_reception_is_the_clearest_one() {
375        let two = DOCUMENTED.replacen(
376            r#""rxInfo": [{"#,
377            r#""rxInfo": [{ "gatewayId": "0202020202020202", "rssi": -110, "snr": -4.25 }, {"#,
378            1,
379        );
380        let event = UplinkEvent::from_json(&two).expect("reads");
381        assert_eq!(event.receptions.len(), 2);
382        assert_eq!(
383            event.best_reception().map(|heard| heard.gateway.to_hex()),
384            Some("0016c001f153a14c".to_owned())
385        );
386    }
387
388    #[test]
389    fn an_event_without_its_device_or_with_a_bad_field_is_refused() {
390        assert_eq!(
391            UplinkEvent::from_json(r#"{"fPort": 2}"#),
392            Err(EventError::Missing("deviceInfo.devEui"))
393        );
394        assert_eq!(
395            UplinkEvent::from_json(&DOCUMENTED.replacen("qg==", "q", 1)),
396            Err(EventError::Invalid("data"))
397        );
398        assert_eq!(
399            UplinkEvent::from_json(&DOCUMENTED.replacen(r#""fPort": 1"#, r#""fPort": 300"#, 1)),
400            Err(EventError::Invalid("fPort"))
401        );
402        assert!(matches!(
403            UplinkEvent::from_json("[]"),
404            Err(EventError::Json(_))
405        ));
406    }
407
408    #[test]
409    fn the_topic_filters_match_what_chirpstack_publishes_on() {
410        assert_eq!(UPLINK_TOPIC, "application/+/device/+/event/up");
411        assert!(uplink_topic("app").ends_with("/device/+/event/up"));
412    }
413}