Skip to main content

pamoja_ffi/
lora_region.rs

1//! The C ABI for LoRaWAN regional channel plans.
2//!
3//! A channel plan is the set of facts a regulator and the LoRa Alliance publish
4//! about one band: which data rates exist, how much they carry, what a device may
5//! radiate, where it listens for a downlink. This module hands those facts across
6//! the boundary and costs nothing out of them. It never refuses a transmission,
7//! because a deployment may hold licensed spectrum or be operating under emergency
8//! provisions, and only the operator knows which.
9//!
10//! A plan crosses as an opaque handle rather than by value, because it is a set of
11//! tables rather than a few scalars. A handle comes either from a published region
12//! or from [`pamoja_lora_plan_builder_build`], and the query functions cannot tell
13//! the difference: a private plan on licensed spectrum answers every question a
14//! published one does.
15//!
16//! Region codes are assigned here and are stable. They are deliberately not the
17//! discriminants of the Rust enum, whose variants are individually feature-gated,
18//! so a build carrying one region would otherwise number it differently from a
19//! build carrying all of them.
20
21use pamoja_lora::region::{
22    Beacon, ChannelBlock, ChannelPlan, ChannelPlanBuilder, DataRate, MaxPayload, Modulation,
23    OwnedChannelPlan, PayloadTable, SubBand,
24};
25// A build that carries no region still offers the builder, and then names no
26// published plan at all.
27#[cfg(any(
28    feature = "eu868",
29    feature = "us915",
30    feature = "eu433",
31    feature = "au915",
32    feature = "cn470",
33    feature = "as923",
34    feature = "kr920",
35    feature = "in865",
36    feature = "ru864"
37))]
38use pamoja_lora::region::Region;
39
40use crate::lora::PamojaLoraLink;
41use crate::{set_last_error, PamojaStatus, PamojaString};
42
43/// The EU863-870 band.
44pub const PAMOJA_LORA_REGION_EU868: u32 = 1;
45/// The US902-928 band.
46pub const PAMOJA_LORA_REGION_US915: u32 = 2;
47/// The EU433 band.
48pub const PAMOJA_LORA_REGION_EU433: u32 = 3;
49/// The AU915-928 band.
50pub const PAMOJA_LORA_REGION_AU915: u32 = 4;
51/// The CN470-510 band.
52pub const PAMOJA_LORA_REGION_CN470: u32 = 5;
53/// The AS923 band.
54pub const PAMOJA_LORA_REGION_AS923: u32 = 6;
55/// The KR920-923 band.
56pub const PAMOJA_LORA_REGION_KR920: u32 = 7;
57/// The IN865-867 band.
58pub const PAMOJA_LORA_REGION_IN865: u32 = 8;
59/// The RU864-870 band.
60pub const PAMOJA_LORA_REGION_RU864: u32 = 9;
61
62/// A data rate carried by LoRa modulation.
63pub const PAMOJA_LORA_MODULATION_LORA: u8 = 0;
64/// A data rate carried by FSK modulation.
65pub const PAMOJA_LORA_MODULATION_FSK: u8 = 1;
66/// A data rate carried by long-range frequency-hopping spread spectrum.
67pub const PAMOJA_LORA_MODULATION_LR_FHSS: u8 = 2;
68/// A data-rate number the region reserves, which carries nothing.
69pub const PAMOJA_LORA_MODULATION_RESERVED: u8 = 3;
70
71/// The uplink payload limits for a device that may sit behind a repeater.
72pub const PAMOJA_LORA_PAYLOAD_TABLE_UPLINK_REPEATER: u32 = 0;
73/// The uplink payload limits for a device that will not sit behind a repeater.
74pub const PAMOJA_LORA_PAYLOAD_TABLE_UPLINK_DIRECT: u32 = 1;
75/// The downlink payload limits for a device that may sit behind a repeater.
76pub const PAMOJA_LORA_PAYLOAD_TABLE_DOWNLINK_REPEATER: u32 = 2;
77/// The downlink payload limits for a device that will not sit behind a repeater.
78pub const PAMOJA_LORA_PAYLOAD_TABLE_DOWNLINK_DIRECT: u32 = 3;
79/// The payload limits that apply under a dwell-time limit.
80pub const PAMOJA_LORA_PAYLOAD_TABLE_DWELL_LIMITED: u32 = 4;
81
82/// The channels a device must use to send a join request.
83pub const PAMOJA_LORA_CHANNELS_JOIN: u32 = 0;
84/// The channels a device starts with before a network adds any.
85pub const PAMOJA_LORA_CHANNELS_DEFAULT: u32 = 1;
86
87/// The uplink direction, for a table that differs between the two.
88pub const PAMOJA_LORA_DIRECTION_UPLINK: u32 = 0;
89/// The downlink direction, for a table that differs between the two.
90pub const PAMOJA_LORA_DIRECTION_DOWNLINK: u32 = 1;
91
92/// One data rate: how a number on the wire maps onto radio settings.
93///
94/// `kind` selects which fields carry meaning. A LoRa rate uses
95/// `spreading_factor` and `bandwidth_hz`; an LR-FHSS rate uses the coding-rate
96/// pair and `bandwidth_hz`; an FSK rate uses `bitrate_bps` alone. A reserved
97/// number leaves every field zero.
98#[repr(C)]
99#[derive(Clone, Copy, Debug, PartialEq, Eq)]
100pub struct PamojaLoraDataRate {
101    /// The payload bitrate in bits per second.
102    pub bitrate_bps: u32,
103    /// The channel bandwidth in hertz, or zero for FSK.
104    pub bandwidth_hz: u32,
105    /// One of the `PAMOJA_LORA_MODULATION_*` constants.
106    pub kind: u8,
107    /// The spreading factor, for a LoRa rate.
108    pub spreading_factor: u8,
109    /// The coding-rate numerator, for an LR-FHSS rate.
110    pub coding_rate_numerator: u8,
111    /// The coding-rate denominator, for an LR-FHSS rate.
112    pub coding_rate_denominator: u8,
113}
114
115/// What one data rate may carry in a single frame.
116#[repr(C)]
117#[derive(Clone, Copy, Debug, PartialEq, Eq)]
118pub struct PamojaLoraMaxPayload {
119    /// The largest MAC payload, frame options included, in bytes.
120    pub mac_payload: u16,
121    /// The largest application payload, in bytes.
122    pub application: u16,
123}
124
125/// A run of evenly spaced channels.
126#[repr(C)]
127#[derive(Clone, Copy, Debug, PartialEq, Eq)]
128pub struct PamojaLoraChannelBlock {
129    /// The first channel's centre frequency in hertz.
130    pub start_hz: u32,
131    /// The spacing between channels in hertz.
132    pub step_hz: u32,
133    /// How many channels the block holds.
134    pub count: u16,
135    /// The slowest data rate the block allows.
136    pub min_data_rate: u8,
137    /// The fastest data rate the block allows.
138    pub max_data_rate: u8,
139}
140
141/// A slice of a band with its own transmit limits.
142#[repr(C)]
143#[derive(Clone, Copy, Debug, PartialEq, Eq)]
144pub struct PamojaLoraSubBand {
145    /// The first frequency in the sub-band, in hertz.
146    pub start_hz: u32,
147    /// The last frequency in the sub-band, in hertz.
148    pub end_hz: u32,
149    /// The share of time a transmitter may hold the channel, in parts per
150    /// thousand, so `10` is one percent and `1000` is unrestricted.
151    pub duty_cycle_permille: u32,
152    /// The power ceiling in dBm EIRP.
153    pub max_eirp_dbm: i8,
154}
155
156/// The Class B beacon settings of a plan.
157#[repr(C)]
158#[derive(Clone, Copy, Debug, PartialEq, Eq)]
159pub struct PamojaLoraBeacon {
160    /// The frequency the beacon is broadcast on, in hertz.
161    pub frequency_hz: u32,
162    /// The default ping-slot frequency, in hertz.
163    pub ping_slot_frequency_hz: u32,
164    /// The data rate the beacon is broadcast at.
165    pub data_rate: u8,
166}
167
168/// The scalar facts of a plan, gathered so a caller reads them in one call.
169#[repr(C)]
170#[derive(Clone, Copy, Debug, PartialEq, Eq)]
171pub struct PamojaLoraPlanInfo {
172    /// The fixed frequency the second receive window listens on, in hertz.
173    pub rx2_frequency_hz: u32,
174    /// How many uplink data-rate numbers the plan defines, reserved included.
175    pub uplink_data_rate_count: u16,
176    /// How many downlink data-rate numbers the plan defines.
177    pub downlink_data_rate_count: u16,
178    /// How many channels the plan starts a device with.
179    pub default_channel_count: u16,
180    /// How many join channels the plan defines.
181    pub join_channel_block_count: u16,
182    /// How many default channel blocks the plan defines.
183    pub default_channel_block_count: u16,
184    /// How many sub-bands the plan defines.
185    pub sub_band_count: u16,
186    /// The Class B beacon settings.
187    pub beacon: PamojaLoraBeacon,
188    /// The data rate the second receive window listens at.
189    pub rx2_data_rate: u8,
190    /// The power ceiling assumed when no sub-band says otherwise, in dBm.
191    pub default_max_eirp_dbm: i8,
192    /// The step between transmit-power settings, in dB.
193    pub tx_power_step_db: u8,
194    /// The highest transmit-power index the plan defines.
195    pub max_tx_power_index: u8,
196    /// The highest RX1 data-rate offset the plan allows.
197    pub max_rx1_data_rate_offset: u8,
198    /// `1` if the plan limits how long one transmission may hold a channel.
199    pub has_dwell_time_limit: u8,
200    /// `1` if the plan publishes a payload table for a dwell-limited device.
201    pub has_dwell_limited_payloads: u8,
202    /// `1` if the plan publishes a second RX1 mapping for a dwell-limited
203    /// downlink.
204    pub has_dwell_limited_rx1: u8,
205}
206
207/// A regional channel plan, published or private.
208///
209/// The handle always owns its tables, so a published region and one assembled
210/// here are the same type and answer the same queries.
211///
212/// A handle the caller must release with [`pamoja_lora_plan_free`].
213pub struct PamojaLoraPlan {
214    plan: OwnedChannelPlan,
215}
216
217impl PamojaLoraPlan {
218    /// Moves a plan onto the heap and hands the caller its handle.
219    ///
220    /// # Arguments
221    ///
222    /// * `plan` - the plan to wrap.
223    ///
224    /// # Returns
225    ///
226    /// A handle the caller must release with [`pamoja_lora_plan_free`].
227    fn into_handle(plan: OwnedChannelPlan) -> *mut Self {
228        Box::into_raw(Box::new(Self { plan }))
229    }
230
231    /// Runs a query against the plan.
232    ///
233    /// # Arguments
234    ///
235    /// * `f` - the query to run.
236    ///
237    /// # Returns
238    ///
239    /// Whatever the query returned.
240    fn with<R>(&self, f: impl FnOnce(&ChannelPlan<'_>) -> R) -> R {
241        self.plan.with_plan(f)
242    }
243}
244
245/// Converts a data rate into the shape that crosses the boundary.
246///
247/// # Arguments
248///
249/// * `rate` - the data rate to convert, or `None` for a reserved number.
250///
251/// # Returns
252///
253/// The equivalent C struct.
254fn data_rate_out(rate: Option<DataRate>) -> PamojaLoraDataRate {
255    let Some(rate) = rate else {
256        return PamojaLoraDataRate {
257            bitrate_bps: 0,
258            bandwidth_hz: 0,
259            kind: PAMOJA_LORA_MODULATION_RESERVED,
260            spreading_factor: 0,
261            coding_rate_numerator: 0,
262            coding_rate_denominator: 0,
263        };
264    };
265    let mut out = PamojaLoraDataRate {
266        bitrate_bps: rate.bitrate_bps,
267        bandwidth_hz: 0,
268        kind: PAMOJA_LORA_MODULATION_FSK,
269        spreading_factor: 0,
270        coding_rate_numerator: 0,
271        coding_rate_denominator: 0,
272    };
273    match rate.modulation {
274        Modulation::LoRa {
275            spreading_factor,
276            bandwidth_hz,
277        } => {
278            out.kind = PAMOJA_LORA_MODULATION_LORA;
279            out.spreading_factor = spreading_factor;
280            out.bandwidth_hz = bandwidth_hz;
281        }
282        Modulation::Fsk { .. } => {}
283        Modulation::LrFhss {
284            coding_rate_numerator,
285            coding_rate_denominator,
286            bandwidth_hz,
287        } => {
288            out.kind = PAMOJA_LORA_MODULATION_LR_FHSS;
289            out.coding_rate_numerator = coding_rate_numerator;
290            out.coding_rate_denominator = coding_rate_denominator;
291            out.bandwidth_hz = bandwidth_hz;
292        }
293    }
294    out
295}
296
297/// Converts a data rate that crossed the boundary into the Rust type.
298///
299/// # Arguments
300///
301/// * `rate` - the data rate as the caller supplied it.
302///
303/// # Returns
304///
305/// `Ok(Some(rate))`, `Ok(None)` for a reserved number, or a status if the kind is
306/// not one this ABI defines.
307fn data_rate_in(rate: &PamojaLoraDataRate) -> Result<Option<DataRate>, PamojaStatus> {
308    match rate.kind {
309        PAMOJA_LORA_MODULATION_LORA => Ok(Some(DataRate::lora(
310            rate.spreading_factor,
311            rate.bandwidth_hz,
312            rate.bitrate_bps,
313        ))),
314        PAMOJA_LORA_MODULATION_FSK => Ok(Some(DataRate::fsk(rate.bitrate_bps))),
315        PAMOJA_LORA_MODULATION_LR_FHSS => Ok(Some(DataRate::lr_fhss(
316            rate.coding_rate_numerator,
317            rate.coding_rate_denominator,
318            rate.bandwidth_hz,
319            rate.bitrate_bps,
320        ))),
321        PAMOJA_LORA_MODULATION_RESERVED => Ok(None),
322        other => {
323            set_last_error(format!("{other} is not a modulation this build defines"));
324            Err(PamojaStatus::InvalidArgument)
325        }
326    }
327}
328
329/// Reports whether a code names a region at all, whatever this build carries.
330///
331/// The codes are contiguous, so this stays a range check as regions are added.
332///
333/// # Arguments
334///
335/// * `region` - the code to check.
336///
337/// # Returns
338///
339/// `true` if the code names one of the published regions.
340fn is_region_code(region: u32) -> bool {
341    (PAMOJA_LORA_REGION_EU868..=PAMOJA_LORA_REGION_RU864).contains(&region)
342}
343
344/// Resolves a region code to its published plan.
345///
346/// An unknown code and a region left out of this build are told apart: the first
347/// is an invalid argument, the second is unsupported. A host that offers a choice
348/// of regions needs the difference, because one is a bug and the other is a build
349/// that was trimmed to fit a device.
350///
351/// # Arguments
352///
353/// * `region` - one of the `PAMOJA_LORA_REGION_*` constants.
354///
355/// # Returns
356///
357/// The published plan, or a status explaining why there is none.
358fn published(region: u32) -> Result<&'static ChannelPlan<'static>, PamojaStatus> {
359    let plan: Option<&'static ChannelPlan<'static>> = match region {
360        #[cfg(feature = "eu868")]
361        PAMOJA_LORA_REGION_EU868 => Some(Region::Eu868.plan()),
362        #[cfg(feature = "us915")]
363        PAMOJA_LORA_REGION_US915 => Some(Region::Us915.plan()),
364        #[cfg(feature = "eu433")]
365        PAMOJA_LORA_REGION_EU433 => Some(Region::Eu433.plan()),
366        #[cfg(feature = "au915")]
367        PAMOJA_LORA_REGION_AU915 => Some(Region::Au915.plan()),
368        #[cfg(feature = "cn470")]
369        PAMOJA_LORA_REGION_CN470 => Some(Region::Cn470.plan()),
370        #[cfg(feature = "as923")]
371        PAMOJA_LORA_REGION_AS923 => Some(Region::As923.plan()),
372        #[cfg(feature = "kr920")]
373        PAMOJA_LORA_REGION_KR920 => Some(Region::Kr920.plan()),
374        #[cfg(feature = "in865")]
375        PAMOJA_LORA_REGION_IN865 => Some(Region::In865.plan()),
376        #[cfg(feature = "ru864")]
377        PAMOJA_LORA_REGION_RU864 => Some(Region::Ru864.plan()),
378        _ => None,
379    };
380    match plan {
381        Some(plan) => Ok(plan),
382        None if is_region_code(region) => {
383            set_last_error(format!(
384                "region {region} is not compiled into this build of pamoja-lora"
385            ));
386            Err(PamojaStatus::Unsupported)
387        }
388        None => {
389            set_last_error(format!("{region} is not a region code"));
390            Err(PamojaStatus::InvalidArgument)
391        }
392    }
393}
394
395/// Returns the published channel plan for a region.
396///
397/// # Arguments
398///
399/// * `region` - one of the `PAMOJA_LORA_REGION_*` constants.
400/// * `out_plan` - set to the plan handle on success, and to null otherwise.
401///
402/// # Returns
403///
404/// [`PamojaStatus::Ok`] on success.
405///
406/// # Errors
407///
408/// Returns [`PamojaStatus::InvalidArgument`] if `out_plan` is null or `region` is
409/// not a region code, and [`PamojaStatus::Unsupported`] if the region is real but
410/// was not compiled into this build.
411///
412/// # Safety
413///
414/// `out_plan` must point at writable storage for one pointer.
415#[no_mangle]
416pub unsafe extern "C" fn pamoja_lora_plan_for_region(
417    region: u32,
418    out_plan: *mut *mut PamojaLoraPlan,
419) -> PamojaStatus {
420    if out_plan.is_null() {
421        set_last_error("out_plan must not be null".to_owned());
422        return PamojaStatus::InvalidArgument;
423    }
424    let slot = &mut *out_plan;
425    *slot = std::ptr::null_mut();
426
427    match published(region) {
428        Ok(plan) => {
429            *slot = PamojaLoraPlan::into_handle(OwnedChannelPlan::from_plan(plan));
430            PamojaStatus::Ok
431        }
432        Err(status) => status,
433    }
434}
435
436/// Reports whether a region is compiled into this build.
437///
438/// A slim build carries only the regions its device operates in, so a host that
439/// offers a choice asks this before offering one.
440///
441/// # Arguments
442///
443/// * `region` - one of the `PAMOJA_LORA_REGION_*` constants.
444///
445/// # Returns
446///
447/// `1` if the region is available, `0` if it is a known region left out of this
448/// build or is not a region code at all.
449#[no_mangle]
450pub extern "C" fn pamoja_lora_region_is_available(region: u32) -> u8 {
451    u8::from(published(region).is_ok())
452}
453
454/// Releases a channel plan.
455///
456/// # Arguments
457///
458/// * `plan` - the handle to release; null is ignored.
459///
460/// # Safety
461///
462/// `plan` must have come from [`pamoja_lora_plan_for_region`] or
463/// [`pamoja_lora_plan_builder_build`] and must not be used afterwards.
464#[no_mangle]
465pub unsafe extern "C" fn pamoja_lora_plan_free(plan: *mut PamojaLoraPlan) {
466    if !plan.is_null() {
467        drop(Box::from_raw(plan));
468    }
469}
470
471/// Returns the plan's name, such as `EU863-870`.
472///
473/// # Arguments
474///
475/// * `plan` - the plan to read.
476///
477/// # Returns
478///
479/// A string the caller must release with
480/// [`pamoja_string_free`](crate::pamoja_string_free), or null if `plan` is null.
481///
482/// # Safety
483///
484/// `plan` must be a live plan handle, or null.
485#[no_mangle]
486pub unsafe extern "C" fn pamoja_lora_plan_name(plan: *const PamojaLoraPlan) -> *mut PamojaString {
487    let Some(plan) = plan.as_ref() else {
488        set_last_error("plan must not be null".to_owned());
489        return std::ptr::null_mut();
490    };
491    plan.with(|plan| PamojaString::into_raw(plan.name.to_owned()))
492}
493
494/// Reads the scalar facts of a plan in one call.
495///
496/// # Arguments
497///
498/// * `plan` - the plan to read.
499/// * `out_info` - set to the plan's scalars on success.
500///
501/// # Returns
502///
503/// [`PamojaStatus::Ok`] on success.
504///
505/// # Errors
506///
507/// Returns [`PamojaStatus::InvalidArgument`] if either pointer is null.
508///
509/// # Safety
510///
511/// `plan` must be a live plan handle and `out_info` must point at writable
512/// storage for one [`PamojaLoraPlanInfo`].
513#[no_mangle]
514pub unsafe extern "C" fn pamoja_lora_plan_info(
515    plan: *const PamojaLoraPlan,
516    out_info: *mut PamojaLoraPlanInfo,
517) -> PamojaStatus {
518    let (Some(plan), false) = (plan.as_ref(), out_info.is_null()) else {
519        set_last_error("plan and out_info must not be null".to_owned());
520        return PamojaStatus::InvalidArgument;
521    };
522    *out_info = plan.with(|plan| PamojaLoraPlanInfo {
523        rx2_frequency_hz: plan.rx2_frequency_hz,
524        uplink_data_rate_count: plan.uplink_data_rates.len() as u16,
525        downlink_data_rate_count: plan.downlink_data_rates.len() as u16,
526        default_channel_count: plan.default_channel_count(),
527        join_channel_block_count: plan.join_channels.len() as u16,
528        default_channel_block_count: plan.default_channels.len() as u16,
529        sub_band_count: plan.sub_bands.len() as u16,
530        beacon: PamojaLoraBeacon {
531            frequency_hz: plan.beacon.frequency_hz,
532            ping_slot_frequency_hz: plan.beacon.ping_slot_frequency_hz,
533            data_rate: plan.beacon.data_rate,
534        },
535        rx2_data_rate: plan.rx2_data_rate,
536        default_max_eirp_dbm: plan.default_max_eirp_dbm,
537        tx_power_step_db: plan.tx_power_step_db,
538        max_tx_power_index: plan.max_tx_power_index,
539        max_rx1_data_rate_offset: plan.max_rx1_data_rate_offset,
540        has_dwell_time_limit: u8::from(plan.has_dwell_time_limit),
541        has_dwell_limited_payloads: u8::from(plan.max_payload_dwell_limited.is_some()),
542        has_dwell_limited_rx1: u8::from(plan.rx1_data_rate_offsets_dwell_limited.is_some()),
543    });
544    PamojaStatus::Ok
545}
546
547/// Returns the data rate a number selects.
548///
549/// # Arguments
550///
551/// * `plan` - the plan to read.
552/// * `direction` - [`PAMOJA_LORA_DIRECTION_UPLINK`] or
553///   [`PAMOJA_LORA_DIRECTION_DOWNLINK`], which differ in the 900 MHz plans.
554/// * `data_rate` - the data-rate number.
555/// * `out_rate` - set to the data rate on success.
556///
557/// # Returns
558///
559/// [`PamojaStatus::Ok`] on success. A reserved number succeeds and reports
560/// [`PAMOJA_LORA_MODULATION_RESERVED`].
561///
562/// # Errors
563///
564/// Returns [`PamojaStatus::InvalidArgument`] if either pointer is null, the
565/// direction is not one of the two constants, or the number is past the end of
566/// the plan's table.
567///
568/// # Safety
569///
570/// `plan` must be a live plan handle and `out_rate` must point at writable
571/// storage for one [`PamojaLoraDataRate`].
572#[no_mangle]
573pub unsafe extern "C" fn pamoja_lora_plan_data_rate(
574    plan: *const PamojaLoraPlan,
575    direction: u32,
576    data_rate: u8,
577    out_rate: *mut PamojaLoraDataRate,
578) -> PamojaStatus {
579    let (Some(plan), false) = (plan.as_ref(), out_rate.is_null()) else {
580        set_last_error("plan and out_rate must not be null".to_owned());
581        return PamojaStatus::InvalidArgument;
582    };
583    let found = plan.with(|plan| match direction {
584        PAMOJA_LORA_DIRECTION_UPLINK => Ok(plan
585            .uplink_data_rates
586            .get(usize::from(data_rate))
587            .copied()
588            .map(data_rate_out)),
589        PAMOJA_LORA_DIRECTION_DOWNLINK => Ok(plan
590            .downlink_data_rates
591            .get(usize::from(data_rate))
592            .copied()
593            .map(data_rate_out)),
594        other => Err(other),
595    });
596    match found {
597        Ok(Some(rate)) => {
598            *out_rate = rate;
599            PamojaStatus::Ok
600        }
601        Ok(None) => {
602            set_last_error(format!("this plan defines no data rate {data_rate}"));
603            PamojaStatus::InvalidArgument
604        }
605        Err(other) => {
606            set_last_error(format!("{other} is not a direction"));
607            PamojaStatus::InvalidArgument
608        }
609    }
610}
611
612/// Returns the radio settings an uplink data rate selects.
613///
614/// This is what turns a data-rate number into something a radio can be told: the
615/// spreading factor and bandwidth to transmit at, ready for
616/// `pamoja_lora_airtime_us`.
617///
618/// # Arguments
619///
620/// * `plan` - the plan to read.
621/// * `data_rate` - the uplink data-rate number.
622/// * `out_link` - set to the radio settings on success.
623///
624/// # Returns
625///
626/// [`PamojaStatus::Ok`] on success.
627///
628/// # Errors
629///
630/// Returns [`PamojaStatus::InvalidArgument`] if either pointer is null, and
631/// [`PamojaStatus::Unsupported`] if the number is reserved or names a rate that
632/// is not LoRa, which has no spreading factor to report.
633///
634/// # Safety
635///
636/// `plan` must be a live plan handle and `out_link` must point at writable
637/// storage for one [`PamojaLoraLink`].
638#[no_mangle]
639pub unsafe extern "C" fn pamoja_lora_plan_link_settings(
640    plan: *const PamojaLoraPlan,
641    data_rate: u8,
642    out_link: *mut PamojaLoraLink,
643) -> PamojaStatus {
644    let (Some(plan), false) = (plan.as_ref(), out_link.is_null()) else {
645        set_last_error("plan and out_link must not be null".to_owned());
646        return PamojaStatus::InvalidArgument;
647    };
648    let Some(settings) = plan.with(|plan| plan.link_settings(data_rate)) else {
649        set_last_error(format!(
650            "data rate {data_rate} is reserved or is not carried by LoRa in this plan"
651        ));
652        return PamojaStatus::Unsupported;
653    };
654    *out_link = PamojaLoraLink {
655        bandwidth_hz: settings.bandwidth_hz(),
656        preamble_symbols: 8,
657        spreading_factor: settings.spreading_factor(),
658        coding_rate_denominator: 5,
659        explicit_header: 1,
660        crc: 1,
661    };
662    PamojaStatus::Ok
663}
664
665/// Returns what a data rate may carry in one frame.
666///
667/// # Arguments
668///
669/// * `plan` - the plan to read.
670/// * `table` - one of the `PAMOJA_LORA_PAYLOAD_TABLE_*` constants.
671/// * `data_rate` - the data-rate number.
672/// * `out_payload` - set to the limits on success.
673///
674/// # Returns
675///
676/// [`PamojaStatus::Ok`] on success.
677///
678/// # Errors
679///
680/// Returns [`PamojaStatus::InvalidArgument`] if either pointer is null, the table
681/// is not one of the constants, or the plan publishes no limit for that number.
682/// Returns [`PamojaStatus::Unsupported`] if the dwell-limited table was asked for
683/// and this plan has no dwell-time limit.
684///
685/// # Safety
686///
687/// `plan` must be a live plan handle and `out_payload` must point at writable
688/// storage for one [`PamojaLoraMaxPayload`].
689#[no_mangle]
690pub unsafe extern "C" fn pamoja_lora_plan_max_payload(
691    plan: *const PamojaLoraPlan,
692    table: u32,
693    data_rate: u8,
694    out_payload: *mut PamojaLoraMaxPayload,
695) -> PamojaStatus {
696    let (Some(plan), false) = (plan.as_ref(), out_payload.is_null()) else {
697        set_last_error("plan and out_payload must not be null".to_owned());
698        return PamojaStatus::InvalidArgument;
699    };
700    let found = plan.with(|plan| match table {
701        PAMOJA_LORA_PAYLOAD_TABLE_UPLINK_REPEATER => Ok(plan.max_payload(data_rate, true)),
702        PAMOJA_LORA_PAYLOAD_TABLE_UPLINK_DIRECT => Ok(plan.max_payload(data_rate, false)),
703        PAMOJA_LORA_PAYLOAD_TABLE_DOWNLINK_REPEATER => {
704            Ok(plan.downlink_max_payload(data_rate, true))
705        }
706        PAMOJA_LORA_PAYLOAD_TABLE_DOWNLINK_DIRECT => {
707            Ok(plan.downlink_max_payload(data_rate, false))
708        }
709        PAMOJA_LORA_PAYLOAD_TABLE_DWELL_LIMITED => {
710            if plan.max_payload_dwell_limited.is_none() {
711                Err(PamojaStatus::Unsupported)
712            } else {
713                Ok(plan.max_payload_dwell_limited(data_rate))
714            }
715        }
716        _ => Err(PamojaStatus::InvalidArgument),
717    });
718    match found {
719        Ok(Some(payload)) => {
720            *out_payload = PamojaLoraMaxPayload {
721                mac_payload: payload.mac_payload,
722                application: payload.application,
723            };
724            PamojaStatus::Ok
725        }
726        Ok(None) => {
727            set_last_error(format!(
728                "this plan publishes no payload limit for data rate {data_rate}"
729            ));
730            PamojaStatus::InvalidArgument
731        }
732        Err(PamojaStatus::Unsupported) => {
733            set_last_error("this plan has no dwell-time limit".to_owned());
734            PamojaStatus::Unsupported
735        }
736        Err(_) => {
737            set_last_error(format!("{table} is not a payload table"));
738            PamojaStatus::InvalidArgument
739        }
740    }
741}
742
743/// Returns the share of time a transmitter may hold a frequency.
744///
745/// This reports the limit; it does not impose it. Pair it with
746/// `pamoja_lora_min_off_time_us` to turn the
747/// limit into the silence a given frame costs.
748///
749/// # Arguments
750///
751/// * `plan` - the plan to read.
752/// * `frequency_hz` - the frequency in hertz.
753/// * `out_permille` - set to the limit in parts per thousand on success, where
754///   `1000` means the sub-band is unrestricted.
755///
756/// # Returns
757///
758/// [`PamojaStatus::Ok`] on success.
759///
760/// # Errors
761///
762/// Returns [`PamojaStatus::InvalidArgument`] if either pointer is null, and
763/// [`PamojaStatus::Unsupported`] if the frequency falls in no sub-band this plan
764/// describes.
765///
766/// # Safety
767///
768/// `plan` must be a live plan handle and `out_permille` must point at writable
769/// storage for one `uint32_t`.
770#[no_mangle]
771pub unsafe extern "C" fn pamoja_lora_plan_duty_cycle_permille(
772    plan: *const PamojaLoraPlan,
773    frequency_hz: u32,
774    out_permille: *mut u32,
775) -> PamojaStatus {
776    let (Some(plan), false) = (plan.as_ref(), out_permille.is_null()) else {
777        set_last_error("plan and out_permille must not be null".to_owned());
778        return PamojaStatus::InvalidArgument;
779    };
780    let Some(permille) = plan.with(|plan| plan.duty_cycle_permille(frequency_hz)) else {
781        set_last_error(format!(
782            "{frequency_hz} Hz falls in no sub-band this plan describes"
783        ));
784        return PamojaStatus::Unsupported;
785    };
786    *out_permille = permille;
787    PamojaStatus::Ok
788}
789
790/// Returns the power ceiling that applies at a frequency, in dBm EIRP.
791///
792/// # Arguments
793///
794/// * `plan` - the plan to read.
795/// * `frequency_hz` - the frequency in hertz.
796/// * `out_dbm` - set to the ceiling on success, falling back to the plan's default
797///   where no sub-band says otherwise.
798///
799/// # Returns
800///
801/// [`PamojaStatus::Ok`] on success.
802///
803/// # Errors
804///
805/// Returns [`PamojaStatus::InvalidArgument`] if either pointer is null.
806///
807/// # Safety
808///
809/// `plan` must be a live plan handle and `out_dbm` must point at writable storage
810/// for one `int8_t`.
811#[no_mangle]
812pub unsafe extern "C" fn pamoja_lora_plan_max_eirp_dbm(
813    plan: *const PamojaLoraPlan,
814    frequency_hz: u32,
815    out_dbm: *mut i8,
816) -> PamojaStatus {
817    let (Some(plan), false) = (plan.as_ref(), out_dbm.is_null()) else {
818        set_last_error("plan and out_dbm must not be null".to_owned());
819        return PamojaStatus::InvalidArgument;
820    };
821    *out_dbm = plan.with(|plan| plan.max_eirp_dbm(frequency_hz));
822    PamojaStatus::Ok
823}
824
825/// Returns the radiated power a transmit-power index selects, in dBm.
826///
827/// # Arguments
828///
829/// * `plan` - the plan to read.
830/// * `index` - the transmit-power index, where zero is the ceiling.
831/// * `max_eirp_dbm` - the ceiling the index steps down from, usually from
832///   [`pamoja_lora_plan_max_eirp_dbm`].
833/// * `out_dbm` - set to the radiated power on success.
834///
835/// # Returns
836///
837/// [`PamojaStatus::Ok`] on success.
838///
839/// # Errors
840///
841/// Returns [`PamojaStatus::InvalidArgument`] if either pointer is null or the
842/// index is past the highest the plan defines.
843///
844/// # Safety
845///
846/// `plan` must be a live plan handle and `out_dbm` must point at writable storage
847/// for one `int8_t`.
848#[no_mangle]
849pub unsafe extern "C" fn pamoja_lora_plan_tx_power_dbm(
850    plan: *const PamojaLoraPlan,
851    index: u8,
852    max_eirp_dbm: i8,
853    out_dbm: *mut i8,
854) -> PamojaStatus {
855    let (Some(plan), false) = (plan.as_ref(), out_dbm.is_null()) else {
856        set_last_error("plan and out_dbm must not be null".to_owned());
857        return PamojaStatus::InvalidArgument;
858    };
859    let Some(dbm) = plan.with(|plan| plan.tx_power_dbm(index, max_eirp_dbm)) else {
860        set_last_error(format!("this plan defines no transmit-power index {index}"));
861        return PamojaStatus::InvalidArgument;
862    };
863    *out_dbm = dbm;
864    PamojaStatus::Ok
865}
866
867/// Returns the downlink data rate the first receive window listens at.
868///
869/// # Arguments
870///
871/// * `plan` - the plan to read.
872/// * `uplink_data_rate` - the data rate the uplink was sent at.
873/// * `offset` - the RX1 data-rate offset the network assigned.
874/// * `dwell_limited` - `1` to use the mapping for a dwell-limited downlink, `0`
875///   for the ordinary one.
876/// * `out_data_rate` - set to the downlink data rate on success.
877///
878/// # Returns
879///
880/// [`PamojaStatus::Ok`] on success.
881///
882/// # Errors
883///
884/// Returns [`PamojaStatus::InvalidArgument`] if either pointer is null, or the
885/// uplink data rate or offset is outside what the plan defines. Returns
886/// [`PamojaStatus::Unsupported`] if a dwell-limited mapping was asked for and this
887/// plan publishes none.
888///
889/// # Safety
890///
891/// `plan` must be a live plan handle and `out_data_rate` must point at writable
892/// storage for one `uint8_t`.
893#[no_mangle]
894pub unsafe extern "C" fn pamoja_lora_plan_rx1_data_rate(
895    plan: *const PamojaLoraPlan,
896    uplink_data_rate: u8,
897    offset: u8,
898    dwell_limited: u8,
899    out_data_rate: *mut u8,
900) -> PamojaStatus {
901    let (Some(plan), false) = (plan.as_ref(), out_data_rate.is_null()) else {
902        set_last_error("plan and out_data_rate must not be null".to_owned());
903        return PamojaStatus::InvalidArgument;
904    };
905    let wants_dwell = dwell_limited != 0;
906    if wants_dwell && plan.with(|plan| plan.rx1_data_rate_offsets_dwell_limited.is_none()) {
907        set_last_error("this plan publishes no dwell-limited RX1 mapping".to_owned());
908        return PamojaStatus::Unsupported;
909    }
910    let found = plan.with(|plan| {
911        if wants_dwell {
912            plan.rx1_data_rate_dwell_limited(uplink_data_rate, offset)
913        } else {
914            plan.rx1_data_rate(uplink_data_rate, offset)
915        }
916    });
917    let Some(data_rate) = found else {
918        set_last_error(format!(
919            "this plan maps no RX1 downlink for uplink data rate {uplink_data_rate} at offset {offset}"
920        ));
921        return PamojaStatus::InvalidArgument;
922    };
923    *out_data_rate = data_rate;
924    PamojaStatus::Ok
925}
926
927/// Returns the next lower data rate to fall back to during adaptive back-off.
928///
929/// A device that has lost the network steps down this chain, trading airtime for
930/// range until it is heard again.
931///
932/// # Arguments
933///
934/// * `plan` - the plan to read.
935/// * `data_rate` - the data rate currently in use.
936/// * `out_data_rate` - set to the next lower data rate on success.
937///
938/// # Returns
939///
940/// [`PamojaStatus::Ok`] on success.
941///
942/// # Errors
943///
944/// Returns [`PamojaStatus::InvalidArgument`] if either pointer is null or the
945/// number is outside the plan's table, and [`PamojaStatus::Unsupported`] if there
946/// is nothing lower to fall back to.
947///
948/// # Safety
949///
950/// `plan` must be a live plan handle and `out_data_rate` must point at writable
951/// storage for one `uint8_t`.
952#[no_mangle]
953pub unsafe extern "C" fn pamoja_lora_plan_next_backoff_data_rate(
954    plan: *const PamojaLoraPlan,
955    data_rate: u8,
956    out_data_rate: *mut u8,
957) -> PamojaStatus {
958    let (Some(plan), false) = (plan.as_ref(), out_data_rate.is_null()) else {
959        set_last_error("plan and out_data_rate must not be null".to_owned());
960        return PamojaStatus::InvalidArgument;
961    };
962    let known = plan.with(|plan| usize::from(data_rate) < plan.data_rate_backoff.len());
963    if !known {
964        set_last_error(format!("this plan defines no data rate {data_rate}"));
965        return PamojaStatus::InvalidArgument;
966    }
967    let Some(lower) = plan.with(|plan| plan.next_backoff_data_rate(data_rate)) else {
968        set_last_error(format!(
969            "data rate {data_rate} is the slowest this plan has"
970        ));
971        return PamojaStatus::Unsupported;
972    };
973    *out_data_rate = lower;
974    PamojaStatus::Ok
975}
976
977/// Returns the centre frequency of one of the plan's default channels.
978///
979/// # Arguments
980///
981/// * `plan` - the plan to read.
982/// * `channel` - the channel number, counting across the default blocks in order.
983/// * `out_frequency_hz` - set to the centre frequency on success.
984///
985/// # Returns
986///
987/// [`PamojaStatus::Ok`] on success.
988///
989/// # Errors
990///
991/// Returns [`PamojaStatus::InvalidArgument`] if either pointer is null or the
992/// channel is past the last one the plan starts a device with.
993///
994/// # Safety
995///
996/// `plan` must be a live plan handle and `out_frequency_hz` must point at
997/// writable storage for one `uint32_t`.
998#[no_mangle]
999pub unsafe extern "C" fn pamoja_lora_plan_channel_frequency_hz(
1000    plan: *const PamojaLoraPlan,
1001    channel: u16,
1002    out_frequency_hz: *mut u32,
1003) -> PamojaStatus {
1004    let (Some(plan), false) = (plan.as_ref(), out_frequency_hz.is_null()) else {
1005        set_last_error("plan and out_frequency_hz must not be null".to_owned());
1006        return PamojaStatus::InvalidArgument;
1007    };
1008    let Some(frequency) = plan.with(|plan| plan.channel_frequency_hz(channel)) else {
1009        set_last_error(format!("this plan has no default channel {channel}"));
1010        return PamojaStatus::InvalidArgument;
1011    };
1012    *out_frequency_hz = frequency;
1013    PamojaStatus::Ok
1014}
1015
1016/// Returns one of the plan's channel blocks.
1017///
1018/// # Arguments
1019///
1020/// * `plan` - the plan to read.
1021/// * `which` - [`PAMOJA_LORA_CHANNELS_JOIN`] or [`PAMOJA_LORA_CHANNELS_DEFAULT`].
1022/// * `index` - the block's position, below the count
1023///   [`pamoja_lora_plan_info`] reports.
1024/// * `out_block` - set to the block on success.
1025///
1026/// # Returns
1027///
1028/// [`PamojaStatus::Ok`] on success.
1029///
1030/// # Errors
1031///
1032/// Returns [`PamojaStatus::InvalidArgument`] if either pointer is null, `which`
1033/// is not one of the constants, or the index is past the end.
1034///
1035/// # Safety
1036///
1037/// `plan` must be a live plan handle and `out_block` must point at writable
1038/// storage for one [`PamojaLoraChannelBlock`].
1039#[no_mangle]
1040pub unsafe extern "C" fn pamoja_lora_plan_channel_block(
1041    plan: *const PamojaLoraPlan,
1042    which: u32,
1043    index: u16,
1044    out_block: *mut PamojaLoraChannelBlock,
1045) -> PamojaStatus {
1046    let (Some(plan), false) = (plan.as_ref(), out_block.is_null()) else {
1047        set_last_error("plan and out_block must not be null".to_owned());
1048        return PamojaStatus::InvalidArgument;
1049    };
1050    if which != PAMOJA_LORA_CHANNELS_JOIN && which != PAMOJA_LORA_CHANNELS_DEFAULT {
1051        set_last_error(format!("{which} is not a channel set"));
1052        return PamojaStatus::InvalidArgument;
1053    }
1054    let found = plan.with(|plan| {
1055        let blocks = if which == PAMOJA_LORA_CHANNELS_JOIN {
1056            plan.join_channels
1057        } else {
1058            plan.default_channels
1059        };
1060        blocks.get(usize::from(index)).copied()
1061    });
1062    let Some(block) = found else {
1063        set_last_error(format!("this plan has no channel block {index}"));
1064        return PamojaStatus::InvalidArgument;
1065    };
1066    *out_block = PamojaLoraChannelBlock {
1067        start_hz: block.start_hz,
1068        step_hz: block.step_hz,
1069        count: block.count,
1070        min_data_rate: block.min_data_rate,
1071        max_data_rate: block.max_data_rate,
1072    };
1073    PamojaStatus::Ok
1074}
1075
1076/// Returns one of the plan's sub-bands.
1077///
1078/// # Arguments
1079///
1080/// * `plan` - the plan to read.
1081/// * `index` - the sub-band's position, below the count
1082///   [`pamoja_lora_plan_info`] reports.
1083/// * `out_band` - set to the sub-band on success.
1084///
1085/// # Returns
1086///
1087/// [`PamojaStatus::Ok`] on success.
1088///
1089/// # Errors
1090///
1091/// Returns [`PamojaStatus::InvalidArgument`] if either pointer is null or the
1092/// index is past the end.
1093///
1094/// # Safety
1095///
1096/// `plan` must be a live plan handle and `out_band` must point at writable
1097/// storage for one [`PamojaLoraSubBand`].
1098#[no_mangle]
1099pub unsafe extern "C" fn pamoja_lora_plan_sub_band(
1100    plan: *const PamojaLoraPlan,
1101    index: u16,
1102    out_band: *mut PamojaLoraSubBand,
1103) -> PamojaStatus {
1104    let (Some(plan), false) = (plan.as_ref(), out_band.is_null()) else {
1105        set_last_error("plan and out_band must not be null".to_owned());
1106        return PamojaStatus::InvalidArgument;
1107    };
1108    let found = plan.with(|plan| plan.sub_bands.get(usize::from(index)).copied());
1109    let Some(band) = found else {
1110        set_last_error(format!("this plan has no sub-band {index}"));
1111        return PamojaStatus::InvalidArgument;
1112    };
1113    *out_band = PamojaLoraSubBand {
1114        start_hz: band.start_hz,
1115        end_hz: band.end_hz,
1116        duty_cycle_permille: band.duty_cycle_permille,
1117        max_eirp_dbm: band.max_eirp_dbm,
1118    };
1119    PamojaStatus::Ok
1120}
1121
1122/// A channel plan under construction.
1123///
1124/// A handle the caller must release with [`pamoja_lora_plan_builder_free`], or
1125/// hand to [`pamoja_lora_plan_builder_build`], which consumes it.
1126pub struct PamojaLoraPlanBuilder {
1127    builder: Option<ChannelPlanBuilder>,
1128}
1129
1130/// Applies one step to a builder held behind a handle.
1131///
1132/// The Rust builder consumes itself at each step, so the handle lends it out and
1133/// takes it back.
1134///
1135/// # Arguments
1136///
1137/// * `builder` - the handle to update.
1138/// * `step` - the step to apply.
1139///
1140/// # Returns
1141///
1142/// [`PamojaStatus::Ok`] on success.
1143///
1144/// # Errors
1145///
1146/// Returns [`PamojaStatus::Closed`] if the builder was already built.
1147fn update(
1148    builder: &mut PamojaLoraPlanBuilder,
1149    step: impl FnOnce(ChannelPlanBuilder) -> ChannelPlanBuilder,
1150) -> PamojaStatus {
1151    let Some(inner) = builder.builder.take() else {
1152        set_last_error("this builder has already been built".to_owned());
1153        return PamojaStatus::Closed;
1154    };
1155    builder.builder = Some(step(inner));
1156    PamojaStatus::Ok
1157}
1158
1159/// Maps a payload-table code onto the table it names.
1160///
1161/// # Arguments
1162///
1163/// * `table` - one of the `PAMOJA_LORA_PAYLOAD_TABLE_*` constants.
1164///
1165/// # Returns
1166///
1167/// The table, or `None` if the code names none.
1168fn payload_table(table: u32) -> Option<PayloadTable> {
1169    match table {
1170        PAMOJA_LORA_PAYLOAD_TABLE_UPLINK_REPEATER => Some(PayloadTable::UplinkRepeater),
1171        PAMOJA_LORA_PAYLOAD_TABLE_UPLINK_DIRECT => Some(PayloadTable::UplinkDirect),
1172        PAMOJA_LORA_PAYLOAD_TABLE_DOWNLINK_REPEATER => Some(PayloadTable::DownlinkRepeater),
1173        PAMOJA_LORA_PAYLOAD_TABLE_DOWNLINK_DIRECT => Some(PayloadTable::DownlinkDirect),
1174        PAMOJA_LORA_PAYLOAD_TABLE_DWELL_LIMITED => Some(PayloadTable::DwellLimited),
1175        _ => None,
1176    }
1177}
1178
1179/// Creates an empty plan builder.
1180///
1181/// The builder starts with no data rates, channels, or sub-bands, and with a
1182/// permissive power ceiling; push the tables the deployment uses, then build.
1183///
1184/// # Arguments
1185///
1186/// * `name` - a null-terminated name for the plan, such as the band it covers.
1187/// * `out_builder` - set to the builder handle on success, and to null otherwise.
1188///
1189/// # Returns
1190///
1191/// [`PamojaStatus::Ok`] on success.
1192///
1193/// # Errors
1194///
1195/// Returns [`PamojaStatus::InvalidArgument`] if either pointer is null or `name`
1196/// is not valid UTF-8.
1197///
1198/// # Safety
1199///
1200/// `name` must be a valid null-terminated string and `out_builder` must point at
1201/// writable storage for one pointer.
1202#[no_mangle]
1203pub unsafe extern "C" fn pamoja_lora_plan_builder_new(
1204    name: *const std::os::raw::c_char,
1205    out_builder: *mut *mut PamojaLoraPlanBuilder,
1206) -> PamojaStatus {
1207    if out_builder.is_null() {
1208        set_last_error("out_builder must not be null".to_owned());
1209        return PamojaStatus::InvalidArgument;
1210    }
1211    let slot = &mut *out_builder;
1212    *slot = std::ptr::null_mut();
1213
1214    let Some(name) = crate::read_str(name, "name") else {
1215        return PamojaStatus::InvalidArgument;
1216    };
1217
1218    *slot = Box::into_raw(Box::new(PamojaLoraPlanBuilder {
1219        builder: Some(ChannelPlanBuilder::new(name)),
1220    }));
1221    PamojaStatus::Ok
1222}
1223
1224/// Releases a plan builder that will not be built.
1225///
1226/// # Arguments
1227///
1228/// * `builder` - the handle to release; null is ignored.
1229///
1230/// # Safety
1231///
1232/// `builder` must have come from [`pamoja_lora_plan_builder_new`], must not have
1233/// been passed to [`pamoja_lora_plan_builder_build`], and must not be used
1234/// afterwards.
1235#[no_mangle]
1236pub unsafe extern "C" fn pamoja_lora_plan_builder_free(builder: *mut PamojaLoraPlanBuilder) {
1237    if !builder.is_null() {
1238        drop(Box::from_raw(builder));
1239    }
1240}
1241
1242/// Appends a data rate to the end of a direction's table.
1243///
1244/// Data rates are numbered by their position, so push them in order and use a
1245/// [`PAMOJA_LORA_MODULATION_RESERVED`] entry for a number the plan does not use.
1246/// A plan that leaves its downlink table empty reuses its uplink table, which is
1247/// what most regions do.
1248///
1249/// # Arguments
1250///
1251/// * `builder` - the builder to extend.
1252/// * `direction` - [`PAMOJA_LORA_DIRECTION_UPLINK`] or
1253///   [`PAMOJA_LORA_DIRECTION_DOWNLINK`].
1254/// * `rate` - the data rate to append.
1255///
1256/// # Returns
1257///
1258/// [`PamojaStatus::Ok`] on success.
1259///
1260/// # Errors
1261///
1262/// Returns [`PamojaStatus::InvalidArgument`] if either pointer is null, the
1263/// direction is not one of the constants, or the modulation kind is not one this
1264/// ABI defines, and [`PamojaStatus::Closed`] if the builder was already built.
1265///
1266/// # Safety
1267///
1268/// `builder` must be a live builder handle and `rate` must point at one readable
1269/// [`PamojaLoraDataRate`].
1270#[no_mangle]
1271pub unsafe extern "C" fn pamoja_lora_plan_builder_push_data_rate(
1272    builder: *mut PamojaLoraPlanBuilder,
1273    direction: u32,
1274    rate: *const PamojaLoraDataRate,
1275) -> PamojaStatus {
1276    let (Some(builder), Some(rate)) = (builder.as_mut(), rate.as_ref()) else {
1277        set_last_error("builder and rate must not be null".to_owned());
1278        return PamojaStatus::InvalidArgument;
1279    };
1280    let rate = match data_rate_in(rate) {
1281        Ok(rate) => rate,
1282        Err(status) => return status,
1283    };
1284    match direction {
1285        PAMOJA_LORA_DIRECTION_UPLINK => update(builder, |b| b.uplink_data_rate(rate)),
1286        PAMOJA_LORA_DIRECTION_DOWNLINK => update(builder, |b| b.downlink_data_rate(rate)),
1287        other => {
1288            set_last_error(format!("{other} is not a direction"));
1289            PamojaStatus::InvalidArgument
1290        }
1291    }
1292}
1293
1294/// Appends a payload limit to the end of one of the plan's tables.
1295///
1296/// Limits are numbered by their position, matching the data rates. A plan that
1297/// leaves a downlink table empty reuses the matching uplink one.
1298///
1299/// # Arguments
1300///
1301/// * `builder` - the builder to extend.
1302/// * `table` - one of the `PAMOJA_LORA_PAYLOAD_TABLE_*` constants.
1303/// * `present` - `0` to append a reserved entry, for a data rate the plan does
1304///   not define; the two lengths are then ignored.
1305/// * `mac_payload` - the largest MAC payload in bytes.
1306/// * `application` - the largest application payload in bytes.
1307///
1308/// # Returns
1309///
1310/// [`PamojaStatus::Ok`] on success.
1311///
1312/// # Errors
1313///
1314/// Returns [`PamojaStatus::InvalidArgument`] if `builder` is null or `table` is
1315/// not one of the constants, and [`PamojaStatus::Closed`] if the builder was
1316/// already built.
1317///
1318/// # Safety
1319///
1320/// `builder` must be a live builder handle.
1321#[no_mangle]
1322pub unsafe extern "C" fn pamoja_lora_plan_builder_push_max_payload(
1323    builder: *mut PamojaLoraPlanBuilder,
1324    table: u32,
1325    present: u8,
1326    mac_payload: u16,
1327    application: u16,
1328) -> PamojaStatus {
1329    let Some(builder) = builder.as_mut() else {
1330        set_last_error("builder must not be null".to_owned());
1331        return PamojaStatus::InvalidArgument;
1332    };
1333    let Some(table) = payload_table(table) else {
1334        set_last_error(format!("{table} is not a payload table"));
1335        return PamojaStatus::InvalidArgument;
1336    };
1337    let entry = (present != 0).then(|| MaxPayload::new(mac_payload, application));
1338    update(builder, |b| b.max_payload(table, entry))
1339}
1340
1341/// Appends a run of evenly spaced channels to the plan.
1342///
1343/// # Arguments
1344///
1345/// * `builder` - the builder to extend.
1346/// * `which` - [`PAMOJA_LORA_CHANNELS_JOIN`] or [`PAMOJA_LORA_CHANNELS_DEFAULT`].
1347/// * `block` - the channel block to append.
1348///
1349/// # Returns
1350///
1351/// [`PamojaStatus::Ok`] on success.
1352///
1353/// # Errors
1354///
1355/// Returns [`PamojaStatus::InvalidArgument`] if either pointer is null or `which`
1356/// is not one of the constants, and [`PamojaStatus::Closed`] if the builder was
1357/// already built.
1358///
1359/// # Safety
1360///
1361/// `builder` must be a live builder handle and `block` must point at one readable
1362/// [`PamojaLoraChannelBlock`].
1363#[no_mangle]
1364pub unsafe extern "C" fn pamoja_lora_plan_builder_push_channel_block(
1365    builder: *mut PamojaLoraPlanBuilder,
1366    which: u32,
1367    block: *const PamojaLoraChannelBlock,
1368) -> PamojaStatus {
1369    let (Some(builder), Some(block)) = (builder.as_mut(), block.as_ref()) else {
1370        set_last_error("builder and block must not be null".to_owned());
1371        return PamojaStatus::InvalidArgument;
1372    };
1373    let entry = ChannelBlock::new(
1374        block.start_hz,
1375        block.step_hz,
1376        block.count,
1377        block.min_data_rate,
1378        block.max_data_rate,
1379    );
1380    match which {
1381        PAMOJA_LORA_CHANNELS_JOIN => update(builder, |b| b.join_channel(entry)),
1382        PAMOJA_LORA_CHANNELS_DEFAULT => update(builder, |b| b.default_channel(entry)),
1383        other => {
1384            set_last_error(format!("{other} is not a channel set"));
1385            PamojaStatus::InvalidArgument
1386        }
1387    }
1388}
1389
1390/// Appends a sub-band and its transmit limits to the plan.
1391///
1392/// A deployment on licensed spectrum gives its sub-band a duty cycle of `1000`,
1393/// which reports as unrestricted.
1394///
1395/// # Arguments
1396///
1397/// * `builder` - the builder to extend.
1398/// * `band` - the sub-band to append.
1399///
1400/// # Returns
1401///
1402/// [`PamojaStatus::Ok`] on success.
1403///
1404/// # Errors
1405///
1406/// Returns [`PamojaStatus::InvalidArgument`] if either pointer is null, and
1407/// [`PamojaStatus::Closed`] if the builder was already built.
1408///
1409/// # Safety
1410///
1411/// `builder` must be a live builder handle and `band` must point at one readable
1412/// [`PamojaLoraSubBand`].
1413#[no_mangle]
1414pub unsafe extern "C" fn pamoja_lora_plan_builder_push_sub_band(
1415    builder: *mut PamojaLoraPlanBuilder,
1416    band: *const PamojaLoraSubBand,
1417) -> PamojaStatus {
1418    let (Some(builder), Some(band)) = (builder.as_mut(), band.as_ref()) else {
1419        set_last_error("builder and band must not be null".to_owned());
1420        return PamojaStatus::InvalidArgument;
1421    };
1422    let entry = SubBand::new(
1423        band.start_hz,
1424        band.end_hz,
1425        band.duty_cycle_permille,
1426        band.max_eirp_dbm,
1427    );
1428    update(builder, |b| b.sub_band(entry))
1429}
1430
1431/// Appends one uplink data rate's row of RX1 downlink data rates.
1432///
1433/// Rows are numbered by their position, matching the uplink data rates, and every
1434/// row must be as wide as the plan's highest RX1 offset allows.
1435///
1436/// # Arguments
1437///
1438/// * `builder` - the builder to extend.
1439/// * `dwell_limited` - `1` to append to the mapping used under a dwell-time
1440///   limit, `0` for the ordinary one.
1441/// * `offsets` - the downlink data rate for each offset, in order.
1442/// * `offsets_len` - how many offsets `offsets` holds.
1443///
1444/// # Returns
1445///
1446/// [`PamojaStatus::Ok`] on success.
1447///
1448/// # Errors
1449///
1450/// Returns [`PamojaStatus::InvalidArgument`] if `builder` or `offsets` is null,
1451/// and [`PamojaStatus::Closed`] if the builder was already built.
1452///
1453/// # Safety
1454///
1455/// `builder` must be a live builder handle and `offsets` must point at
1456/// `offsets_len` readable bytes.
1457#[no_mangle]
1458pub unsafe extern "C" fn pamoja_lora_plan_builder_push_rx1_row(
1459    builder: *mut PamojaLoraPlanBuilder,
1460    dwell_limited: u8,
1461    offsets: *const u8,
1462    offsets_len: usize,
1463) -> PamojaStatus {
1464    let Some(builder) = builder.as_mut() else {
1465        set_last_error("builder must not be null".to_owned());
1466        return PamojaStatus::InvalidArgument;
1467    };
1468    let row = match crate::read_bytes(offsets, offsets_len) {
1469        Ok(row) => row,
1470        Err(status) => return status,
1471    };
1472    if dwell_limited == 0 {
1473        update(builder, |b| b.rx1_row(&row))
1474    } else {
1475        update(builder, |b| b.rx1_row_dwell_limited(&row))
1476    }
1477}
1478
1479/// Appends the next entry in the adaptive back-off chain.
1480///
1481/// Entries are numbered by their position, matching the uplink data rates.
1482///
1483/// # Arguments
1484///
1485/// * `builder` - the builder to extend.
1486/// * `has_lower` - `0` if this data rate is the slowest, with nothing below it;
1487///   `data_rate` is then ignored.
1488/// * `data_rate` - the data rate to fall back to.
1489///
1490/// # Returns
1491///
1492/// [`PamojaStatus::Ok`] on success.
1493///
1494/// # Errors
1495///
1496/// Returns [`PamojaStatus::InvalidArgument`] if `builder` is null, and
1497/// [`PamojaStatus::Closed`] if the builder was already built.
1498///
1499/// # Safety
1500///
1501/// `builder` must be a live builder handle.
1502#[no_mangle]
1503pub unsafe extern "C" fn pamoja_lora_plan_builder_push_backoff(
1504    builder: *mut PamojaLoraPlanBuilder,
1505    has_lower: u8,
1506    data_rate: u8,
1507) -> PamojaStatus {
1508    let Some(builder) = builder.as_mut() else {
1509        set_last_error("builder must not be null".to_owned());
1510        return PamojaStatus::InvalidArgument;
1511    };
1512    let lower = (has_lower != 0).then_some(data_rate);
1513    update(builder, |b| b.backoff(lower))
1514}
1515
1516/// Sets the plan's transmit-power ladder.
1517///
1518/// # Arguments
1519///
1520/// * `builder` - the builder to set.
1521/// * `default_max_eirp_dbm` - the ceiling assumed where no sub-band says
1522///   otherwise.
1523/// * `tx_power_step_db` - the step between transmit-power settings, in dB.
1524/// * `max_tx_power_index` - the highest transmit-power index the plan defines.
1525///
1526/// # Returns
1527///
1528/// [`PamojaStatus::Ok`] on success.
1529///
1530/// # Errors
1531///
1532/// Returns [`PamojaStatus::InvalidArgument`] if `builder` is null, and
1533/// [`PamojaStatus::Closed`] if the builder was already built.
1534///
1535/// # Safety
1536///
1537/// `builder` must be a live builder handle.
1538#[no_mangle]
1539pub unsafe extern "C" fn pamoja_lora_plan_builder_set_power(
1540    builder: *mut PamojaLoraPlanBuilder,
1541    default_max_eirp_dbm: i8,
1542    tx_power_step_db: u8,
1543    max_tx_power_index: u8,
1544) -> PamojaStatus {
1545    let Some(builder) = builder.as_mut() else {
1546        set_last_error("builder must not be null".to_owned());
1547        return PamojaStatus::InvalidArgument;
1548    };
1549    update(builder, |b| {
1550        b.power(default_max_eirp_dbm, tx_power_step_db, max_tx_power_index)
1551    })
1552}
1553
1554/// Sets the plan's receive windows.
1555///
1556/// # Arguments
1557///
1558/// * `builder` - the builder to set.
1559/// * `rx2_frequency_hz` - the fixed frequency the second window listens on.
1560/// * `rx2_data_rate` - the data rate the second window listens at.
1561/// * `max_rx1_data_rate_offset` - the highest RX1 offset the plan allows, which
1562///   fixes how wide every RX1 row must be.
1563///
1564/// # Returns
1565///
1566/// [`PamojaStatus::Ok`] on success.
1567///
1568/// # Errors
1569///
1570/// Returns [`PamojaStatus::InvalidArgument`] if `builder` is null, and
1571/// [`PamojaStatus::Closed`] if the builder was already built.
1572///
1573/// # Safety
1574///
1575/// `builder` must be a live builder handle.
1576#[no_mangle]
1577pub unsafe extern "C" fn pamoja_lora_plan_builder_set_rx(
1578    builder: *mut PamojaLoraPlanBuilder,
1579    rx2_frequency_hz: u32,
1580    rx2_data_rate: u8,
1581    max_rx1_data_rate_offset: u8,
1582) -> PamojaStatus {
1583    let Some(builder) = builder.as_mut() else {
1584        set_last_error("builder must not be null".to_owned());
1585        return PamojaStatus::InvalidArgument;
1586    };
1587    update(builder, |b| {
1588        b.rx(rx2_frequency_hz, rx2_data_rate, max_rx1_data_rate_offset)
1589    })
1590}
1591
1592/// Sets the plan's Class B beacon and whether it limits dwell time.
1593///
1594/// # Arguments
1595///
1596/// * `builder` - the builder to set.
1597/// * `beacon` - the beacon settings.
1598/// * `has_dwell_time_limit` - `1` if the plan caps how long one transmission may
1599///   hold a channel.
1600///
1601/// # Returns
1602///
1603/// [`PamojaStatus::Ok`] on success.
1604///
1605/// # Errors
1606///
1607/// Returns [`PamojaStatus::InvalidArgument`] if either pointer is null, and
1608/// [`PamojaStatus::Closed`] if the builder was already built.
1609///
1610/// # Safety
1611///
1612/// `builder` must be a live builder handle and `beacon` must point at one
1613/// readable [`PamojaLoraBeacon`].
1614#[no_mangle]
1615pub unsafe extern "C" fn pamoja_lora_plan_builder_set_beacon(
1616    builder: *mut PamojaLoraPlanBuilder,
1617    beacon: *const PamojaLoraBeacon,
1618    has_dwell_time_limit: u8,
1619) -> PamojaStatus {
1620    let (Some(builder), Some(beacon)) = (builder.as_mut(), beacon.as_ref()) else {
1621        set_last_error("builder and beacon must not be null".to_owned());
1622        return PamojaStatus::InvalidArgument;
1623    };
1624    let entry = Beacon {
1625        data_rate: beacon.data_rate,
1626        frequency_hz: beacon.frequency_hz,
1627        ping_slot_frequency_hz: beacon.ping_slot_frequency_hz,
1628    };
1629    update(builder, |b| {
1630        b.beacon(entry).dwell_time_limit(has_dwell_time_limit != 0)
1631    })
1632}
1633
1634/// Finishes a plan and hands back a handle the query functions accept.
1635///
1636/// The builder is consumed whether the plan is accepted or rejected, so the
1637/// caller must not free or reuse it afterwards.
1638///
1639/// Tables left empty are filled in where a region would share them: an empty
1640/// downlink data-rate table reuses the uplink one, an empty downlink payload
1641/// table reuses the matching uplink one, and an empty back-off chain steps down
1642/// one data rate at a time. What cannot be guessed is checked instead, so a plan
1643/// that would answer a question wrongly is refused here rather than at the
1644/// question.
1645///
1646/// # Arguments
1647///
1648/// * `builder` - the builder to finish, which this call consumes.
1649/// * `out_plan` - set to the plan handle on success, and to null otherwise.
1650///
1651/// # Returns
1652///
1653/// [`PamojaStatus::Ok`] on success.
1654///
1655/// # Errors
1656///
1657/// Returns [`PamojaStatus::InvalidArgument`] if either pointer is null or the
1658/// plan is inconsistent, with the reason available from
1659/// `pamoja_last_error_message`, and
1660/// [`PamojaStatus::Closed`] if the builder was already built.
1661///
1662/// # Safety
1663///
1664/// `builder` must be a live builder handle from
1665/// [`pamoja_lora_plan_builder_new`], and `out_plan` must point at writable
1666/// storage for one pointer.
1667#[no_mangle]
1668pub unsafe extern "C" fn pamoja_lora_plan_builder_build(
1669    builder: *mut PamojaLoraPlanBuilder,
1670    out_plan: *mut *mut PamojaLoraPlan,
1671) -> PamojaStatus {
1672    if builder.is_null() || out_plan.is_null() {
1673        set_last_error("builder and out_plan must not be null".to_owned());
1674        return PamojaStatus::InvalidArgument;
1675    }
1676    let slot = &mut *out_plan;
1677    *slot = std::ptr::null_mut();
1678
1679    let Some(inner) = Box::from_raw(builder).builder else {
1680        set_last_error("this builder has already been built".to_owned());
1681        return PamojaStatus::Closed;
1682    };
1683
1684    match inner.build() {
1685        Ok(plan) => {
1686            *slot = PamojaLoraPlan::into_handle(plan);
1687            PamojaStatus::Ok
1688        }
1689        Err(error) => {
1690            set_last_error(error.to_string());
1691            PamojaStatus::InvalidArgument
1692        }
1693    }
1694}
1695
1696#[cfg(test)]
1697mod tests {
1698    use super::*;
1699    use std::ffi::CString;
1700    use std::ptr;
1701
1702    /// Builds a small but complete two-rate plan, the way a private deployment on
1703    /// licensed spectrum would.
1704    unsafe fn private_plan() -> *mut PamojaLoraPlan {
1705        let name = CString::new("private-915").expect("name");
1706        let mut builder = ptr::null_mut();
1707        assert_eq!(
1708            pamoja_lora_plan_builder_new(name.as_ptr(), &mut builder),
1709            PamojaStatus::Ok
1710        );
1711
1712        for rate in [
1713            PamojaLoraDataRate {
1714                bitrate_bps: 250,
1715                bandwidth_hz: 125_000,
1716                kind: PAMOJA_LORA_MODULATION_LORA,
1717                spreading_factor: 12,
1718                coding_rate_numerator: 0,
1719                coding_rate_denominator: 0,
1720            },
1721            PamojaLoraDataRate {
1722                bitrate_bps: 5_470,
1723                bandwidth_hz: 125_000,
1724                kind: PAMOJA_LORA_MODULATION_LORA,
1725                spreading_factor: 7,
1726                coding_rate_numerator: 0,
1727                coding_rate_denominator: 0,
1728            },
1729        ] {
1730            assert_eq!(
1731                pamoja_lora_plan_builder_push_data_rate(
1732                    builder,
1733                    PAMOJA_LORA_DIRECTION_UPLINK,
1734                    &rate
1735                ),
1736                PamojaStatus::Ok
1737            );
1738        }
1739
1740        for (mac, app) in [(59u16, 51u16), (230, 222)] {
1741            for table in [
1742                PAMOJA_LORA_PAYLOAD_TABLE_UPLINK_REPEATER,
1743                PAMOJA_LORA_PAYLOAD_TABLE_UPLINK_DIRECT,
1744            ] {
1745                assert_eq!(
1746                    pamoja_lora_plan_builder_push_max_payload(builder, table, 1, mac, app),
1747                    PamojaStatus::Ok
1748                );
1749            }
1750        }
1751
1752        let block = PamojaLoraChannelBlock {
1753            start_hz: 915_000_000,
1754            step_hz: 500_000,
1755            count: 4,
1756            min_data_rate: 0,
1757            max_data_rate: 1,
1758        };
1759        assert_eq!(
1760            pamoja_lora_plan_builder_push_channel_block(
1761                builder,
1762                PAMOJA_LORA_CHANNELS_DEFAULT,
1763                &block
1764            ),
1765            PamojaStatus::Ok
1766        );
1767        assert_eq!(
1768            pamoja_lora_plan_builder_push_channel_block(builder, PAMOJA_LORA_CHANNELS_JOIN, &block),
1769            PamojaStatus::Ok
1770        );
1771
1772        // Licensed spectrum: the holder may occupy the channel continuously.
1773        let band = PamojaLoraSubBand {
1774            start_hz: 915_000_000,
1775            end_hz: 917_000_000,
1776            duty_cycle_permille: 1000,
1777            max_eirp_dbm: 30,
1778        };
1779        assert_eq!(
1780            pamoja_lora_plan_builder_push_sub_band(builder, &band),
1781            PamojaStatus::Ok
1782        );
1783
1784        assert_eq!(
1785            pamoja_lora_plan_builder_set_rx(builder, 915_000_000, 0, 0),
1786            PamojaStatus::Ok
1787        );
1788        for row in [[0u8], [1u8]] {
1789            assert_eq!(
1790                pamoja_lora_plan_builder_push_rx1_row(builder, 0, row.as_ptr(), row.len()),
1791                PamojaStatus::Ok
1792            );
1793        }
1794        assert_eq!(
1795            pamoja_lora_plan_builder_set_power(builder, 30, 2, 7),
1796            PamojaStatus::Ok
1797        );
1798
1799        let mut plan = ptr::null_mut();
1800        assert_eq!(
1801            pamoja_lora_plan_builder_build(builder, &mut plan),
1802            PamojaStatus::Ok
1803        );
1804        assert!(!plan.is_null());
1805        plan
1806    }
1807
1808    #[test]
1809    #[cfg(feature = "eu868")]
1810    fn a_published_region_reports_its_own_tables() {
1811        unsafe {
1812            let mut plan = ptr::null_mut();
1813            assert_eq!(
1814                pamoja_lora_plan_for_region(PAMOJA_LORA_REGION_EU868, &mut plan),
1815                PamojaStatus::Ok
1816            );
1817
1818            let name = pamoja_lora_plan_name(plan);
1819            let text = std::ffi::CStr::from_ptr(crate::pamoja_string_data(name));
1820            assert_eq!(text.to_str().expect("utf-8"), "EU863-870");
1821            crate::pamoja_string_free(name);
1822
1823            let mut link = PamojaLoraLink {
1824                bandwidth_hz: 0,
1825                preamble_symbols: 0,
1826                spreading_factor: 0,
1827                coding_rate_denominator: 0,
1828                explicit_header: 0,
1829                crc: 0,
1830            };
1831            assert_eq!(
1832                pamoja_lora_plan_link_settings(plan, 0, &mut link),
1833                PamojaStatus::Ok
1834            );
1835            assert_eq!(link.spreading_factor, 12);
1836            assert_eq!(link.bandwidth_hz, 125_000);
1837
1838            let mut permille = 0;
1839            assert_eq!(
1840                pamoja_lora_plan_duty_cycle_permille(plan, 868_100_000, &mut permille),
1841                PamojaStatus::Ok
1842            );
1843            assert_eq!(permille, 10, "the 868.1 MHz sub-band is limited to 1%");
1844
1845            pamoja_lora_plan_free(plan);
1846        }
1847    }
1848
1849    #[test]
1850    fn an_unknown_region_code_is_told_from_one_left_out_of_the_build() {
1851        unsafe {
1852            let mut plan = ptr::null_mut();
1853            assert_eq!(
1854                pamoja_lora_plan_for_region(4242, &mut plan),
1855                PamojaStatus::InvalidArgument
1856            );
1857            assert!(plan.is_null());
1858        }
1859
1860        // Every code in the range is a real region, so it is never an invalid
1861        // argument, whatever this build happens to carry.
1862        for region in PAMOJA_LORA_REGION_EU868..=PAMOJA_LORA_REGION_RU864 {
1863            let mut plan = ptr::null_mut();
1864            let status = unsafe { pamoja_lora_plan_for_region(region, &mut plan) };
1865            assert!(
1866                status == PamojaStatus::Ok || status == PamojaStatus::Unsupported,
1867                "region {region} reported {status:?}"
1868            );
1869            assert_eq!(
1870                status == PamojaStatus::Ok,
1871                pamoja_lora_region_is_available(region) == 1
1872            );
1873            unsafe { pamoja_lora_plan_free(plan) };
1874        }
1875    }
1876
1877    #[test]
1878    fn a_private_plan_answers_the_same_questions_a_published_one_does() {
1879        unsafe {
1880            let plan = private_plan();
1881
1882            let name = pamoja_lora_plan_name(plan);
1883            let text = std::ffi::CStr::from_ptr(crate::pamoja_string_data(name));
1884            assert_eq!(text.to_str().expect("utf-8"), "private-915");
1885            crate::pamoja_string_free(name);
1886
1887            let mut info = PamojaLoraPlanInfo {
1888                rx2_frequency_hz: 0,
1889                uplink_data_rate_count: 0,
1890                downlink_data_rate_count: 0,
1891                default_channel_count: 0,
1892                join_channel_block_count: 0,
1893                default_channel_block_count: 0,
1894                sub_band_count: 0,
1895                beacon: PamojaLoraBeacon {
1896                    frequency_hz: 0,
1897                    ping_slot_frequency_hz: 0,
1898                    data_rate: 0,
1899                },
1900                rx2_data_rate: 0,
1901                default_max_eirp_dbm: 0,
1902                tx_power_step_db: 0,
1903                max_tx_power_index: 0,
1904                max_rx1_data_rate_offset: 0,
1905                has_dwell_time_limit: 1,
1906                has_dwell_limited_payloads: 1,
1907                has_dwell_limited_rx1: 1,
1908            };
1909            assert_eq!(pamoja_lora_plan_info(plan, &mut info), PamojaStatus::Ok);
1910            assert_eq!(info.default_channel_count, 4);
1911            assert_eq!(info.uplink_data_rate_count, 2);
1912            // An empty downlink table falls back to the uplink one.
1913            assert_eq!(info.downlink_data_rate_count, 2);
1914            assert_eq!(info.has_dwell_time_limit, 0);
1915            assert_eq!(info.has_dwell_limited_payloads, 0);
1916            assert_eq!(info.has_dwell_limited_rx1, 0);
1917
1918            let mut frequency = 0;
1919            assert_eq!(
1920                pamoja_lora_plan_channel_frequency_hz(plan, 3, &mut frequency),
1921                PamojaStatus::Ok
1922            );
1923            assert_eq!(frequency, 916_500_000);
1924
1925            let mut permille = 0;
1926            assert_eq!(
1927                pamoja_lora_plan_duty_cycle_permille(plan, 915_000_000, &mut permille),
1928                PamojaStatus::Ok
1929            );
1930            assert_eq!(permille, 1000, "licensed spectrum is unrestricted");
1931
1932            let mut payload = PamojaLoraMaxPayload {
1933                mac_payload: 0,
1934                application: 0,
1935            };
1936            assert_eq!(
1937                pamoja_lora_plan_max_payload(
1938                    plan,
1939                    PAMOJA_LORA_PAYLOAD_TABLE_DOWNLINK_DIRECT,
1940                    1,
1941                    &mut payload
1942                ),
1943                PamojaStatus::Ok
1944            );
1945            assert_eq!(
1946                payload.application, 222,
1947                "the downlink table mirrors uplink"
1948            );
1949
1950            let mut dbm = 0;
1951            assert_eq!(
1952                pamoja_lora_plan_max_eirp_dbm(plan, 915_000_000, &mut dbm),
1953                PamojaStatus::Ok
1954            );
1955            assert_eq!(dbm, 30);
1956
1957            let mut lower = 0;
1958            assert_eq!(
1959                pamoja_lora_plan_next_backoff_data_rate(plan, 1, &mut lower),
1960                PamojaStatus::Ok
1961            );
1962            assert_eq!(lower, 0, "an unset chain steps down one rate at a time");
1963            assert_eq!(
1964                pamoja_lora_plan_next_backoff_data_rate(plan, 0, &mut lower),
1965                PamojaStatus::Unsupported,
1966                "the slowest rate has nothing below it"
1967            );
1968
1969            pamoja_lora_plan_free(plan);
1970        }
1971    }
1972
1973    #[test]
1974    fn a_plan_whose_rx1_rows_are_too_narrow_is_refused() {
1975        unsafe {
1976            let name = CString::new("too-narrow").expect("name");
1977            let mut builder = ptr::null_mut();
1978            assert_eq!(
1979                pamoja_lora_plan_builder_new(name.as_ptr(), &mut builder),
1980                PamojaStatus::Ok
1981            );
1982            let rate = PamojaLoraDataRate {
1983                bitrate_bps: 250,
1984                bandwidth_hz: 125_000,
1985                kind: PAMOJA_LORA_MODULATION_LORA,
1986                spreading_factor: 12,
1987                coding_rate_numerator: 0,
1988                coding_rate_denominator: 0,
1989            };
1990            assert_eq!(
1991                pamoja_lora_plan_builder_push_data_rate(
1992                    builder,
1993                    PAMOJA_LORA_DIRECTION_UPLINK,
1994                    &rate
1995                ),
1996                PamojaStatus::Ok
1997            );
1998            // Offsets up to 5 need six entries in every row; this row has one.
1999            assert_eq!(
2000                pamoja_lora_plan_builder_set_rx(builder, 915_000_000, 0, 5),
2001                PamojaStatus::Ok
2002            );
2003            let row = [0u8];
2004            assert_eq!(
2005                pamoja_lora_plan_builder_push_rx1_row(builder, 0, row.as_ptr(), row.len()),
2006                PamojaStatus::Ok
2007            );
2008
2009            let mut plan = ptr::null_mut();
2010            assert_eq!(
2011                pamoja_lora_plan_builder_build(builder, &mut plan),
2012                PamojaStatus::InvalidArgument
2013            );
2014            assert!(plan.is_null());
2015        }
2016    }
2017
2018    #[test]
2019    fn a_plan_that_listens_at_a_data_rate_it_lacks_is_refused() {
2020        unsafe {
2021            let name = CString::new("bad-rx2").expect("name");
2022            let mut builder = ptr::null_mut();
2023            assert_eq!(
2024                pamoja_lora_plan_builder_new(name.as_ptr(), &mut builder),
2025                PamojaStatus::Ok
2026            );
2027            let rate = PamojaLoraDataRate {
2028                bitrate_bps: 250,
2029                bandwidth_hz: 125_000,
2030                kind: PAMOJA_LORA_MODULATION_LORA,
2031                spreading_factor: 12,
2032                coding_rate_numerator: 0,
2033                coding_rate_denominator: 0,
2034            };
2035            assert_eq!(
2036                pamoja_lora_plan_builder_push_data_rate(
2037                    builder,
2038                    PAMOJA_LORA_DIRECTION_UPLINK,
2039                    &rate
2040                ),
2041                PamojaStatus::Ok
2042            );
2043            // The plan defines DR0 alone, so listening at DR3 could never work.
2044            assert_eq!(
2045                pamoja_lora_plan_builder_set_rx(builder, 915_000_000, 3, 0),
2046                PamojaStatus::Ok
2047            );
2048            let row = [0u8];
2049            assert_eq!(
2050                pamoja_lora_plan_builder_push_rx1_row(builder, 0, row.as_ptr(), row.len()),
2051                PamojaStatus::Ok
2052            );
2053
2054            let mut plan = ptr::null_mut();
2055            assert_eq!(
2056                pamoja_lora_plan_builder_build(builder, &mut plan),
2057                PamojaStatus::InvalidArgument
2058            );
2059            assert!(plan.is_null());
2060        }
2061    }
2062
2063    #[test]
2064    fn a_reserved_data_rate_crosses_and_comes_back_reserved() {
2065        let reserved = PamojaLoraDataRate {
2066            bitrate_bps: 0,
2067            bandwidth_hz: 0,
2068            kind: PAMOJA_LORA_MODULATION_RESERVED,
2069            spreading_factor: 0,
2070            coding_rate_numerator: 0,
2071            coding_rate_denominator: 0,
2072        };
2073        assert_eq!(data_rate_in(&reserved).expect("reserved"), None);
2074        assert_eq!(data_rate_out(None), reserved);
2075    }
2076
2077    #[test]
2078    fn every_modulation_survives_the_round_trip() {
2079        for rate in [
2080            DataRate::lora(9, 125_000, 1_760),
2081            DataRate::fsk(50_000),
2082            DataRate::lr_fhss(1, 3, 137_000, 162),
2083        ] {
2084            let crossed = data_rate_out(Some(rate));
2085            let back = data_rate_in(&crossed).expect("valid").expect("present");
2086            assert_eq!(back, rate);
2087        }
2088    }
2089}