Skip to main content

pamoja_ffi/
gateway_chirpstack.rs

1//! The C ABI for the uplink events a ChirpStack network server publishes.
2//!
3//! ChirpStack publishes every uplink it deduplicates to an MQTT topic,
4//! `application/<id>/device/<dev_eui>/event/up`, as the JSON form of its `UplinkEvent`
5//! message. [`pamoja_chirpstack_uplink_parse`] reads one into a handle, and the calls here
6//! read the device, the counter, the payload and every gateway that heard it back out.
7//!
8//! Fields protobuf's JSON mapping leaves out when they hold their default read as that default:
9//! a frame counter of zero, ADR off, unconfirmed.
10
11use std::ffi::c_char;
12use std::ptr;
13
14use pamoja_gateway::chirpstack::{uplink_topic, UplinkEvent};
15
16use crate::{read_bytes, read_str, set_last_error, PamojaBuffer, PamojaStatus, PamojaString};
17
18/// The scalar fields of an uplink event, read in one call.
19#[repr(C)]
20#[derive(Clone, Copy, Debug, PartialEq)]
21pub struct PamojaChirpstackUplinkSummary {
22    /// The device's address, meaningful when `has_dev_addr` is `1`.
23    pub dev_addr: u32,
24    /// The uplink frame counter.
25    pub fcnt: u32,
26    /// The carrier it was heard on, in hertz, meaningful when `has_frequency` is `1`.
27    pub frequency_hz: u32,
28    /// How many gateways heard it.
29    pub reception_count: u32,
30    /// The device EUI, most-significant byte first.
31    pub dev_eui: [u8; 8],
32    /// `1` when the event names the device's address.
33    pub has_dev_addr: u8,
34    /// `1` when the device had adaptive data rate on.
35    pub adr: u8,
36    /// The data rate, as the region numbers them.
37    pub data_rate: u8,
38    /// `1` when the frame carried an application port.
39    pub has_fport: u8,
40    /// The application port.
41    pub fport: u8,
42    /// `1` for a confirmed uplink.
43    pub confirmed: u8,
44    /// `1` when the event names the carrier.
45    pub has_frequency: u8,
46}
47
48/// One gateway that heard an uplink.
49#[repr(C)]
50#[derive(Clone, Copy, Debug, PartialEq)]
51pub struct PamojaChirpstackReception {
52    /// The received signal strength, in dBm.
53    pub rssi_dbm: i32,
54    /// The signal-to-noise ratio, in dB.
55    pub snr_db: f32,
56    /// The gateway's EUI, most-significant byte first.
57    pub gateway: [u8; 8],
58}
59
60/// An opaque handle to a parsed uplink event.
61///
62/// Release it with [`pamoja_chirpstack_uplink_free`].
63pub struct PamojaChirpstackUplink {
64    event: UplinkEvent,
65}
66
67/// Reads an uplink event from the JSON ChirpStack published.
68///
69/// # Arguments
70///
71/// * `text` - the MQTT message's payload, UTF-8 JSON.
72/// * `text_len` - its length.
73/// * `out_uplink` - receives the event.
74///
75/// # Returns
76///
77/// [`PamojaStatus::Ok`] on success, with `*out_uplink` set to a handle the caller must release
78/// with [`pamoja_chirpstack_uplink_free`].
79///
80/// # Errors
81///
82/// Returns [`PamojaStatus::InvalidArgument`] if a pointer is null, and [`PamojaStatus::Codec`]
83/// for text that is not a JSON object, an event with no device EUI, or a field that does not
84/// read as what it should, with the field named by `pamoja_last_error`.
85///
86/// # Safety
87///
88/// `text` must point to `text_len` readable bytes when that is non-zero, and `out_uplink` must
89/// be writable.
90#[no_mangle]
91pub unsafe extern "C" fn pamoja_chirpstack_uplink_parse(
92    text: *const u8,
93    text_len: usize,
94    out_uplink: *mut *mut PamojaChirpstackUplink,
95) -> PamojaStatus {
96    if out_uplink.is_null() {
97        set_last_error("out_uplink must not be null".to_owned());
98        return PamojaStatus::InvalidArgument;
99    }
100    let slot = &mut *out_uplink;
101    *slot = ptr::null_mut();
102    let bytes = match read_bytes(text, text_len) {
103        Ok(bytes) => bytes,
104        Err(status) => return status,
105    };
106    let Ok(text) = std::str::from_utf8(&bytes) else {
107        set_last_error("the event is not UTF-8".to_owned());
108        return PamojaStatus::Codec;
109    };
110    match UplinkEvent::from_json(text) {
111        Ok(event) => {
112            *slot = Box::into_raw(Box::new(PamojaChirpstackUplink { event }));
113            PamojaStatus::Ok
114        }
115        Err(error) => {
116            set_last_error(error.to_string());
117            PamojaStatus::Codec
118        }
119    }
120}
121
122/// Releases an uplink event handle.
123///
124/// Passing null is a no-op.
125///
126/// # Safety
127///
128/// `uplink` must be a handle from [`pamoja_chirpstack_uplink_parse`] that has not already been
129/// freed, or null.
130#[no_mangle]
131pub unsafe extern "C" fn pamoja_chirpstack_uplink_free(uplink: *mut PamojaChirpstackUplink) {
132    if !uplink.is_null() {
133        drop(Box::from_raw(uplink));
134    }
135}
136
137/// Reads the scalar fields of an uplink event.
138///
139/// # Arguments
140///
141/// * `uplink` - the event.
142/// * `out_summary` - receives the fields.
143///
144/// # Returns
145///
146/// [`PamojaStatus::Ok`] on success.
147///
148/// # Errors
149///
150/// Returns [`PamojaStatus::InvalidArgument`] if either pointer is null.
151///
152/// # Safety
153///
154/// `uplink` must be a live handle and `out_summary` writable.
155#[no_mangle]
156pub unsafe extern "C" fn pamoja_chirpstack_uplink_summary(
157    uplink: *const PamojaChirpstackUplink,
158    out_summary: *mut PamojaChirpstackUplinkSummary,
159) -> PamojaStatus {
160    let (Some(uplink), false) = (uplink.as_ref(), out_summary.is_null()) else {
161        set_last_error("uplink and out_summary must not be null".to_owned());
162        return PamojaStatus::InvalidArgument;
163    };
164    let event = &uplink.event;
165    *out_summary = PamojaChirpstackUplinkSummary {
166        dev_addr: event.dev_addr.unwrap_or(0),
167        fcnt: event.fcnt,
168        frequency_hz: event.frequency_hz.unwrap_or(0),
169        reception_count: event.receptions.len() as u32,
170        dev_eui: event.dev_eui.bytes(),
171        has_dev_addr: u8::from(event.dev_addr.is_some()),
172        adr: u8::from(event.adr),
173        data_rate: event.data_rate,
174        has_fport: u8::from(event.fport.is_some()),
175        fport: event.fport.unwrap_or(0),
176        confirmed: u8::from(event.confirmed),
177        has_frequency: u8::from(event.frequency_hz.is_some()),
178    };
179    PamojaStatus::Ok
180}
181
182/// Which text field of an event to read.
183fn text_of(event: &UplinkEvent, field: u8) -> Option<Option<&str>> {
184    match field {
185        PAMOJA_CHIRPSTACK_DEDUPLICATION_ID => Some(Some(&event.deduplication_id)),
186        PAMOJA_CHIRPSTACK_TIME => Some(event.time.as_deref()),
187        PAMOJA_CHIRPSTACK_APPLICATION_ID => Some(Some(&event.application_id)),
188        PAMOJA_CHIRPSTACK_DEVICE_NAME => Some(Some(&event.device_name)),
189        _ => None,
190    }
191}
192
193/// The identifier ChirpStack gave the uplink once it deduplicated the gateways' copies.
194pub const PAMOJA_CHIRPSTACK_DEDUPLICATION_ID: u8 = 0;
195/// When the uplink was received, as ChirpStack wrote it.
196pub const PAMOJA_CHIRPSTACK_TIME: u8 = 1;
197/// The application the device belongs to.
198pub const PAMOJA_CHIRPSTACK_APPLICATION_ID: u8 = 2;
199/// The name the device was given in ChirpStack.
200pub const PAMOJA_CHIRPSTACK_DEVICE_NAME: u8 = 3;
201
202/// Reads one of an uplink event's text fields.
203///
204/// # Arguments
205///
206/// * `uplink` - the event.
207/// * `field` - one of the `PAMOJA_CHIRPSTACK_*` field constants.
208///
209/// # Returns
210///
211/// The text, which the caller releases with [`pamoja_string_free`](crate::pamoja_string_free);
212/// an empty string for a field the event left out; or null for a time the event left out, a
213/// null handle, or a field constant that names nothing.
214///
215/// # Safety
216///
217/// `uplink` must be a live handle or null.
218#[no_mangle]
219pub unsafe extern "C" fn pamoja_chirpstack_uplink_text(
220    uplink: *const PamojaChirpstackUplink,
221    field: u8,
222) -> *mut PamojaString {
223    let Some(uplink) = uplink.as_ref() else {
224        set_last_error("uplink must not be null".to_owned());
225        return ptr::null_mut();
226    };
227    match text_of(&uplink.event, field) {
228        Some(Some(text)) => PamojaString::into_raw(text.to_owned()),
229        Some(None) => ptr::null_mut(),
230        None => {
231            set_last_error(format!("{field} names no text field of an uplink event"));
232            ptr::null_mut()
233        }
234    }
235}
236
237/// Copies out the application payload an uplink carried, decoded from base64.
238///
239/// # Arguments
240///
241/// * `uplink` - the event.
242/// * `out_data` - receives the payload, empty when the event carried none.
243///
244/// # Returns
245///
246/// [`PamojaStatus::Ok`] on success, with `*out_data` set to a buffer the caller releases with
247/// [`pamoja_buffer_free`](crate::pamoja_buffer_free).
248///
249/// # Errors
250///
251/// Returns [`PamojaStatus::InvalidArgument`] if either pointer is null.
252///
253/// # Safety
254///
255/// `uplink` must be a live handle and `out_data` writable.
256#[no_mangle]
257pub unsafe extern "C" fn pamoja_chirpstack_uplink_data(
258    uplink: *const PamojaChirpstackUplink,
259    out_data: *mut *mut PamojaBuffer,
260) -> PamojaStatus {
261    let (Some(uplink), false) = (uplink.as_ref(), out_data.is_null()) else {
262        set_last_error("uplink and out_data must not be null".to_owned());
263        return PamojaStatus::InvalidArgument;
264    };
265    *out_data = PamojaBuffer::into_raw(uplink.event.data.clone());
266    PamojaStatus::Ok
267}
268
269/// Reads one gateway that heard an uplink.
270///
271/// # Arguments
272///
273/// * `uplink` - the event.
274/// * `index` - the reception's position, below the count the summary reports.
275/// * `out_reception` - receives the reception.
276///
277/// # Returns
278///
279/// [`PamojaStatus::Ok`] on success.
280///
281/// # Errors
282///
283/// Returns [`PamojaStatus::InvalidArgument`] if a pointer is null or `index` is past the end.
284///
285/// # Safety
286///
287/// `uplink` must be a live handle and `out_reception` writable.
288#[no_mangle]
289pub unsafe extern "C" fn pamoja_chirpstack_uplink_reception(
290    uplink: *const PamojaChirpstackUplink,
291    index: u32,
292    out_reception: *mut PamojaChirpstackReception,
293) -> PamojaStatus {
294    let (Some(uplink), false) = (uplink.as_ref(), out_reception.is_null()) else {
295        set_last_error("uplink and out_reception must not be null".to_owned());
296        return PamojaStatus::InvalidArgument;
297    };
298    let Some(reception) = uplink.event.receptions.get(index as usize) else {
299        set_last_error(format!("the event has no reception {index}"));
300        return PamojaStatus::InvalidArgument;
301    };
302    *out_reception = PamojaChirpstackReception {
303        rssi_dbm: reception.rssi_dbm,
304        snr_db: reception.snr_db,
305        gateway: reception.gateway.bytes(),
306    };
307    PamojaStatus::Ok
308}
309
310/// Finds the gateway that heard an uplink best.
311///
312/// # Arguments
313///
314/// * `uplink` - the event.
315/// * `out_index` - receives the position of the reception with the highest signal-to-noise
316///   ratio.
317///
318/// # Returns
319///
320/// [`PamojaStatus::Ok`] on success.
321///
322/// # Errors
323///
324/// Returns [`PamojaStatus::InvalidArgument`] if a pointer is null or the event named no
325/// gateway.
326///
327/// # Safety
328///
329/// `uplink` must be a live handle and `out_index` writable.
330#[no_mangle]
331pub unsafe extern "C" fn pamoja_chirpstack_uplink_best_reception(
332    uplink: *const PamojaChirpstackUplink,
333    out_index: *mut u32,
334) -> PamojaStatus {
335    let (Some(uplink), false) = (uplink.as_ref(), out_index.is_null()) else {
336        set_last_error("uplink and out_index must not be null".to_owned());
337        return PamojaStatus::InvalidArgument;
338    };
339    let event = &uplink.event;
340    let Some(best) = event.best_reception() else {
341        set_last_error("the event named no gateway".to_owned());
342        return PamojaStatus::InvalidArgument;
343    };
344    let index = event
345        .receptions
346        .iter()
347        .position(|reception| ptr::eq(reception, best))
348        .unwrap_or(0);
349    *out_index = index as u32;
350    PamojaStatus::Ok
351}
352
353/// Builds the MQTT topic an application's uplink events are published on, with a wildcard in
354/// place of the device.
355///
356/// # Arguments
357///
358/// * `application_id` - the application's identifier, as ChirpStack shows it, or `+` for every
359///   application's events.
360///
361/// # Returns
362///
363/// The topic, `application/<id>/device/+/event/up`, which the caller releases with
364/// [`pamoja_string_free`](crate::pamoja_string_free), or null for a null or non-UTF-8 argument.
365///
366/// # Safety
367///
368/// `application_id` must be a NUL-terminated string or null.
369#[no_mangle]
370pub unsafe extern "C" fn pamoja_chirpstack_uplink_topic(
371    application_id: *const c_char,
372) -> *mut PamojaString {
373    let Some(application_id) = read_str(application_id, "application_id") else {
374        return ptr::null_mut();
375    };
376    PamojaString::into_raw(uplink_topic(application_id))
377}
378
379#[cfg(test)]
380mod tests {
381    use super::*;
382
383    use crate::{pamoja_buffer_data, pamoja_buffer_free, pamoja_buffer_len, pamoja_string_free};
384
385    const TWO: &str = r#"{
386        "deduplicationId": "3ac7e3c4-4401-4b8d-9386-a5c902f9202d",
387        "deviceInfo": { "applicationId": "17c8", "deviceName": "Test device", "devEui": "0101010101010101" },
388        "devAddr": "00189440", "dr": 1, "fPort": 1, "fCnt": 7, "confirmed": true, "data": "qg==",
389        "rxInfo": [
390            { "gatewayId": "0202020202020202", "rssi": -110, "snr": -4.25 },
391            { "gatewayId": "0016c001f153a14c", "rssi": -36, "snr": 10.5 }
392        ],
393        "txInfo": { "frequency": 867100000 }
394    }"#;
395
396    #[test]
397    fn an_event_reads_back_across_the_boundary() {
398        unsafe {
399            let mut uplink = ptr::null_mut();
400            assert_eq!(
401                pamoja_chirpstack_uplink_parse(TWO.as_ptr(), TWO.len(), &mut uplink),
402                PamojaStatus::Ok
403            );
404            let mut summary = std::mem::zeroed::<PamojaChirpstackUplinkSummary>();
405            assert_eq!(
406                pamoja_chirpstack_uplink_summary(uplink, &mut summary),
407                PamojaStatus::Ok
408            );
409            assert_eq!(summary.dev_eui, [1; 8]);
410            assert_eq!((summary.has_dev_addr, summary.dev_addr), (1, 0x0018_9440));
411            assert_eq!((summary.fcnt, summary.confirmed, summary.adr), (7, 1, 0));
412            assert_eq!(
413                (summary.has_fport, summary.fport, summary.data_rate),
414                (1, 1, 1)
415            );
416            assert_eq!(
417                (summary.has_frequency, summary.frequency_hz),
418                (1, 867_100_000)
419            );
420            assert_eq!(summary.reception_count, 2);
421
422            let name = pamoja_chirpstack_uplink_text(uplink, PAMOJA_CHIRPSTACK_DEVICE_NAME);
423            assert!(!name.is_null());
424            pamoja_string_free(name);
425            assert!(pamoja_chirpstack_uplink_text(uplink, PAMOJA_CHIRPSTACK_TIME).is_null());
426            assert!(pamoja_chirpstack_uplink_text(uplink, 9).is_null());
427
428            let mut data = ptr::null_mut();
429            assert_eq!(
430                pamoja_chirpstack_uplink_data(uplink, &mut data),
431                PamojaStatus::Ok
432            );
433            assert_eq!(
434                std::slice::from_raw_parts(pamoja_buffer_data(data), pamoja_buffer_len(data)),
435                [0xAA]
436            );
437            pamoja_buffer_free(data);
438
439            let mut best = 9;
440            assert_eq!(
441                pamoja_chirpstack_uplink_best_reception(uplink, &mut best),
442                PamojaStatus::Ok
443            );
444            assert_eq!(best, 1);
445            let mut reception = std::mem::zeroed::<PamojaChirpstackReception>();
446            assert_eq!(
447                pamoja_chirpstack_uplink_reception(uplink, best, &mut reception),
448                PamojaStatus::Ok
449            );
450            assert_eq!((reception.rssi_dbm, reception.snr_db), (-36, 10.5));
451            assert_eq!(
452                reception.gateway,
453                [0x00, 0x16, 0xC0, 0x01, 0xF1, 0x53, 0xA1, 0x4C]
454            );
455            assert_eq!(
456                pamoja_chirpstack_uplink_reception(uplink, 2, &mut reception),
457                PamojaStatus::InvalidArgument
458            );
459            pamoja_chirpstack_uplink_free(uplink);
460
461            let broken = r#"{ "deviceInfo": {} }"#;
462            assert_eq!(
463                pamoja_chirpstack_uplink_parse(broken.as_ptr(), broken.len(), &mut uplink),
464                PamojaStatus::Codec
465            );
466            assert!(uplink.is_null());
467        }
468    }
469}