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
21#[cfg(feature = "cn470")]
22use pamoja_lora::region::Cn470Plan;
23use pamoja_lora::region::{
24    Beacon, ChannelBlock, ChannelPlan, ChannelPlanBuilder, DataRate, FixedChannelList,
25    JoinSequence, MaskControl, MaxPayload, Modulation, OwnedChannelPlan, PayloadTable, PlanKind,
26    PowerReference, RelayChannel, SubBand,
27};
28// A build that carries no region still offers the builder, and then names no
29// published plan at all.
30#[cfg(any(
31    feature = "eu868",
32    feature = "us915",
33    feature = "eu433",
34    feature = "au915",
35    feature = "cn470",
36    feature = "as923",
37    feature = "kr920",
38    feature = "in865",
39    feature = "ru864"
40))]
41use pamoja_lora::region::Region;
42
43use crate::lora::PamojaLoraLink;
44use crate::{set_last_error, PamojaStatus, PamojaString};
45
46/// The EU863-870 band.
47pub const PAMOJA_LORA_REGION_EU868: u32 = 1;
48/// The US902-928 band.
49pub const PAMOJA_LORA_REGION_US915: u32 = 2;
50/// The EU433 band.
51pub const PAMOJA_LORA_REGION_EU433: u32 = 3;
52/// The AU915-928 band.
53pub const PAMOJA_LORA_REGION_AU915: u32 = 4;
54/// The CN470-510 band.
55pub const PAMOJA_LORA_REGION_CN470: u32 = 5;
56/// The AS923 band.
57pub const PAMOJA_LORA_REGION_AS923: u32 = 6;
58/// The KR920-923 band.
59pub const PAMOJA_LORA_REGION_KR920: u32 = 7;
60/// The IN865-867 band.
61pub const PAMOJA_LORA_REGION_IN865: u32 = 8;
62/// The RU864-870 band.
63pub const PAMOJA_LORA_REGION_RU864: u32 = 9;
64
65/// A data rate carried by LoRa modulation.
66pub const PAMOJA_LORA_MODULATION_LORA: u8 = 0;
67/// A data rate carried by FSK modulation.
68pub const PAMOJA_LORA_MODULATION_FSK: u8 = 1;
69/// A data rate carried by long-range frequency-hopping spread spectrum.
70pub const PAMOJA_LORA_MODULATION_LR_FHSS: u8 = 2;
71/// A data-rate number the region reserves, which carries nothing.
72pub const PAMOJA_LORA_MODULATION_RESERVED: u8 = 3;
73
74/// The uplink payload limits for a device that may sit behind a repeater.
75pub const PAMOJA_LORA_PAYLOAD_TABLE_UPLINK_REPEATER: u32 = 0;
76/// The uplink payload limits for a device that will not sit behind a repeater.
77pub const PAMOJA_LORA_PAYLOAD_TABLE_UPLINK_DIRECT: u32 = 1;
78/// The downlink payload limits for a device that may sit behind a repeater.
79pub const PAMOJA_LORA_PAYLOAD_TABLE_DOWNLINK_REPEATER: u32 = 2;
80/// The downlink payload limits for a device that will not sit behind a repeater.
81pub const PAMOJA_LORA_PAYLOAD_TABLE_DOWNLINK_DIRECT: u32 = 3;
82/// The payload limits that apply under a dwell-time limit.
83pub const PAMOJA_LORA_PAYLOAD_TABLE_DWELL_LIMITED: u32 = 4;
84
85/// The channels a device must use to send a join request.
86pub const PAMOJA_LORA_CHANNELS_JOIN: u32 = 0;
87/// The channels a device starts with before a network adds any.
88pub const PAMOJA_LORA_CHANNELS_DEFAULT: u32 = 1;
89/// The numbered downlink channels a fixed plan answers the first receive window on.
90pub const PAMOJA_LORA_CHANNELS_DOWNLINK: u32 = 2;
91
92/// A plan whose network creates channels and moves them.
93pub const PAMOJA_LORA_PLAN_KIND_DYNAMIC: u8 = 0;
94/// A plan whose channels are numbered in advance and only enabled or disabled.
95pub const PAMOJA_LORA_PLAN_KIND_FIXED: u8 = 1;
96
97/// A plan that reads a type 1 channel list against no numbering.
98pub const PAMOJA_LORA_CHANNEL_LIST_NONE: u8 = 0;
99/// The 800 MHz numbering of RP002-1.0.5 section 3.3.1.1.
100pub const PAMOJA_LORA_CHANNEL_LIST_MHZ800: u8 = 1;
101/// The 900 MHz numbering of RP002-1.0.5 section 3.3.1.2.
102pub const PAMOJA_LORA_CHANNEL_LIST_MHZ900: u8 = 2;
103
104/// A join channel at random, stepping the data rate down across attempts.
105pub const PAMOJA_LORA_JOIN_RANDOM: u8 = 0;
106/// The octet passes of RP002-1.0.5 section 3.5.2, eight 125 kHz channels from successive
107/// groups and then a 500 kHz one.
108pub const PAMOJA_LORA_JOIN_OCTET_PASSES: u8 = 1;
109
110/// Power indexes that count down from a radiated ceiling.
111pub const PAMOJA_LORA_POWER_EIRP: u8 = 0;
112/// Power indexes that count down from a conducted ceiling.
113pub const PAMOJA_LORA_POWER_CONDUCTED: u8 = 1;
114
115/// A channel mask control that sets one group of sixteen channels.
116pub const PAMOJA_LORA_MASK_GROUP: u8 = 0;
117/// A channel mask control whose ten low bits switch banks of eight.
118pub const PAMOJA_LORA_MASK_BANKS: u8 = 1;
119/// A channel mask control whose eight low bits switch banks of eight with their 500 kHz
120/// channel.
121pub const PAMOJA_LORA_MASK_PAIRED_BANKS: u8 = 2;
122/// A channel mask control that turns every channel on or off, then sets a group.
123pub const PAMOJA_LORA_MASK_ALL: u8 = 3;
124/// A channel mask control the region reserves.
125pub const PAMOJA_LORA_MASK_RESERVED: u8 = 4;
126
127/// No CN470-510 plan, for a join plan that points at a plan built elsewhere.
128pub const PAMOJA_LORA_CN470_NONE: u32 = 0;
129/// The CN470-510 plan for a 20 MHz antenna, type A.
130pub const PAMOJA_LORA_CN470_ANTENNA_20MHZ_A: u32 = 1;
131/// The CN470-510 plan for a 20 MHz antenna, type B.
132pub const PAMOJA_LORA_CN470_ANTENNA_20MHZ_B: u32 = 2;
133/// The CN470-510 plan for a 26 MHz antenna, type A.
134pub const PAMOJA_LORA_CN470_ANTENNA_26MHZ_A: u32 = 3;
135/// The CN470-510 plan for a 26 MHz antenna, type B.
136pub const PAMOJA_LORA_CN470_ANTENNA_26MHZ_B: u32 = 4;
137/// The 96-channel CN470-510 plan of the LoRaWAN 1.0.3 Regional Parameters revision A.
138pub const PAMOJA_LORA_CN470_CHANNELS_96: u32 = 5;
139
140/// The uplink direction, for a table that differs between the two.
141pub const PAMOJA_LORA_DIRECTION_UPLINK: u32 = 0;
142/// The downlink direction, for a table that differs between the two.
143pub const PAMOJA_LORA_DIRECTION_DOWNLINK: u32 = 1;
144
145/// One data rate: how a number on the wire maps onto radio settings.
146///
147/// `kind` selects which fields carry meaning. A LoRa rate uses
148/// `spreading_factor` and `bandwidth_hz`; an LR-FHSS rate uses the coding-rate
149/// pair and `bandwidth_hz`; an FSK rate uses `bitrate_bps` alone. A reserved
150/// number leaves every field zero.
151#[repr(C)]
152#[derive(Clone, Copy, Debug, PartialEq, Eq)]
153pub struct PamojaLoraDataRate {
154    /// The payload bitrate in bits per second.
155    pub bitrate_bps: u32,
156    /// The channel bandwidth in hertz, or zero for FSK.
157    pub bandwidth_hz: u32,
158    /// One of the `PAMOJA_LORA_MODULATION_*` constants.
159    pub kind: u8,
160    /// The spreading factor, for a LoRa rate.
161    pub spreading_factor: u8,
162    /// The coding-rate numerator, for an LR-FHSS rate.
163    pub coding_rate_numerator: u8,
164    /// The coding-rate denominator, for an LR-FHSS rate.
165    pub coding_rate_denominator: u8,
166}
167
168/// What one data rate may carry in a single frame.
169#[repr(C)]
170#[derive(Clone, Copy, Debug, PartialEq, Eq)]
171pub struct PamojaLoraMaxPayload {
172    /// The largest MAC payload, frame options included, in bytes.
173    pub mac_payload: u16,
174    /// The largest application payload, in bytes.
175    pub application: u16,
176}
177
178/// A run of evenly spaced channels.
179#[repr(C)]
180#[derive(Clone, Copy, Debug, PartialEq, Eq)]
181pub struct PamojaLoraChannelBlock {
182    /// The first channel's center frequency in hertz.
183    pub start_hz: u32,
184    /// The spacing between channels in hertz.
185    pub step_hz: u32,
186    /// How many channels the block holds.
187    pub count: u16,
188    /// The slowest data rate the block allows.
189    pub min_data_rate: u8,
190    /// The fastest data rate the block allows.
191    pub max_data_rate: u8,
192}
193
194/// A slice of a band with its own transmit limits.
195#[repr(C)]
196#[derive(Clone, Copy, Debug, PartialEq, Eq)]
197pub struct PamojaLoraSubBand {
198    /// The first frequency in the sub-band, in hertz.
199    pub start_hz: u32,
200    /// The last frequency in the sub-band, in hertz.
201    pub end_hz: u32,
202    /// The share of time a transmitter may hold the channel, in parts per
203    /// thousand, so `10` is one percent and `1000` is unrestricted.
204    pub duty_cycle_permille: u32,
205    /// The power ceiling in dBm EIRP.
206    pub max_eirp_dbm: i8,
207}
208
209/// The Class B beacon settings of a plan.
210#[repr(C)]
211#[derive(Clone, Copy, Debug, PartialEq, Eq)]
212pub struct PamojaLoraBeacon {
213    /// The frequency the beacon is broadcast on, in hertz.
214    pub frequency_hz: u32,
215    /// The default ping-slot frequency, in hertz.
216    pub ping_slot_frequency_hz: u32,
217    /// The data rate the beacon is broadcast at.
218    pub data_rate: u8,
219}
220
221/// A default channel of a LoRaWAN relay, TS011-1.0.1.
222#[repr(C)]
223#[derive(Clone, Copy, Debug, PartialEq, Eq)]
224pub struct PamojaLoraRelayChannel {
225    /// Where an end device sends its wake-on-radio frame, in hertz.
226    pub wor_frequency_hz: u32,
227    /// Where the relay acknowledges it, in hertz.
228    pub ack_frequency_hz: u32,
229    /// The data rate of both, numbered as the plan's downlink data rates.
230    pub data_rate: u8,
231}
232
233/// The scalar facts of a plan, gathered so a caller reads them in one call.
234#[repr(C)]
235#[derive(Clone, Copy, Debug, PartialEq, Eq)]
236pub struct PamojaLoraPlanInfo {
237    /// The fixed frequency the second receive window listens on, in hertz.
238    pub rx2_frequency_hz: u32,
239    /// How many uplink data-rate numbers the plan defines, reserved included.
240    pub uplink_data_rate_count: u16,
241    /// How many downlink data-rate numbers the plan defines.
242    pub downlink_data_rate_count: u16,
243    /// How many channels the plan starts a device with.
244    pub default_channel_count: u16,
245    /// How many join channels the plan defines.
246    pub join_channel_block_count: u16,
247    /// How many default channel blocks the plan defines.
248    pub default_channel_block_count: u16,
249    /// How many sub-bands the plan defines.
250    pub sub_band_count: u16,
251    /// The Class B beacon settings.
252    pub beacon: PamojaLoraBeacon,
253    /// The data rate the second receive window listens at.
254    pub rx2_data_rate: u8,
255    /// The power ceiling assumed when no sub-band says otherwise, in dBm.
256    pub default_max_eirp_dbm: i8,
257    /// The step between transmit-power settings, in dB.
258    pub tx_power_step_db: u8,
259    /// The highest transmit-power index the plan defines.
260    pub max_tx_power_index: u8,
261    /// The highest RX1 data-rate offset the plan allows.
262    pub max_rx1_data_rate_offset: u8,
263    /// `1` if the plan limits how long one transmission may hold a channel.
264    pub has_dwell_time_limit: u8,
265    /// `1` if the plan publishes a payload table for a dwell-limited device.
266    pub has_dwell_limited_payloads: u8,
267    /// `1` if the plan publishes a second RX1 mapping for a dwell-limited
268    /// downlink.
269    pub has_dwell_limited_rx1: u8,
270}
271
272/// How a plan's channels are defined and used, read in one call.
273#[repr(C)]
274#[derive(Clone, Copy, Debug, PartialEq, Eq)]
275pub struct PamojaLoraPlanRules {
276    /// [`PAMOJA_LORA_PLAN_KIND_DYNAMIC`] or [`PAMOJA_LORA_PLAN_KIND_FIXED`].
277    pub kind: u8,
278    /// For a dynamic plan, one of the `PAMOJA_LORA_CHANNEL_LIST_*` constants.
279    pub channel_list: u8,
280    /// `1` if devices on the plan answer `TXParamSetupReq`.
281    pub tx_param_setup: u8,
282    /// One of the `PAMOJA_LORA_JOIN_*` constants.
283    pub join_sequence: u8,
284    /// One of the `PAMOJA_LORA_POWER_*` constants.
285    pub power_reference: u8,
286    /// For a conducted ceiling, the antenna gain it already allows for, in dB.
287    pub gain_allowance_db: u8,
288    /// How many downlink channel blocks the plan defines.
289    pub downlink_channel_block_count: u16,
290    /// How many runs of join channels select a plan, which only the published CN470-510
291    /// plans carry.
292    pub join_plan_count: u16,
293}
294
295/// What one `ChMaskCntl` value of a `LinkADRReq` does.
296#[repr(C)]
297#[derive(Clone, Copy, Debug, PartialEq, Eq)]
298pub struct PamojaLoraMaskControl {
299    /// One of the `PAMOJA_LORA_MASK_*` constants.
300    pub kind: u8,
301    /// For [`PAMOJA_LORA_MASK_GROUP`], the group the mask sets.
302    pub group: u8,
303    /// For [`PAMOJA_LORA_MASK_ALL`], `1` to turn every channel on and `0` to turn it off.
304    pub on: u8,
305    /// For [`PAMOJA_LORA_MASK_ALL`], `1` if the mask then sets `then_group`.
306    pub has_then_group: u8,
307    /// The group the mask then sets.
308    pub then_group: u8,
309}
310
311/// A run of join channels that puts a device on a plan.
312#[repr(C)]
313#[derive(Clone, Copy, Debug, PartialEq, Eq)]
314pub struct PamojaLoraJoinPlan {
315    /// The join channels and the data rates a request may use on them.
316    pub channels: PamojaLoraChannelBlock,
317    /// Where the accept answering the first channel arrives, in hertz.
318    pub accept_start_hz: u32,
319    /// How far the accept frequency moves for each next channel, in hertz.
320    pub accept_step_hz: u32,
321    /// The second receive window's frequency after joining on the first channel, in hertz.
322    pub rx2_start_hz: u32,
323    /// How far that frequency moves for each next channel, in hertz.
324    pub rx2_step_hz: u32,
325    /// The plan a join on these channels selects, one of the `PAMOJA_LORA_CN470_*`
326    /// constants.
327    pub cn470_plan: u32,
328}
329
330/// A regional channel plan, published or private.
331///
332/// The handle always owns its tables, so a published region and one assembled
333/// here are the same type and answer the same queries. A published plan also keeps
334/// the plans a join selects between, which point at other published plans and so
335/// cannot be owned.
336///
337/// A handle the caller must release with [`pamoja_lora_plan_free`].
338pub struct PamojaLoraPlan {
339    plan: OwnedChannelPlan,
340    published: Option<&'static ChannelPlan<'static>>,
341}
342
343impl PamojaLoraPlan {
344    /// Moves a plan onto the heap and hands the caller its handle.
345    ///
346    /// # Arguments
347    ///
348    /// * `plan` - the plan to wrap.
349    ///
350    /// # Returns
351    ///
352    /// A handle the caller must release with [`pamoja_lora_plan_free`].
353    fn into_handle(plan: OwnedChannelPlan) -> *mut Self {
354        Box::into_raw(Box::new(Self {
355            plan,
356            published: None,
357        }))
358    }
359
360    /// Wraps a published plan.
361    ///
362    /// # Arguments
363    ///
364    /// * `plan` - the published plan.
365    ///
366    /// # Returns
367    ///
368    /// A handle the caller must release with [`pamoja_lora_plan_free`].
369    #[cfg_attr(
370        not(any(
371            feature = "eu868",
372            feature = "us915",
373            feature = "eu433",
374            feature = "au915",
375            feature = "cn470",
376            feature = "as923",
377            feature = "kr920",
378            feature = "in865",
379            feature = "ru864"
380        )),
381        allow(dead_code)
382    )]
383    fn published_handle(plan: &'static ChannelPlan<'static>) -> *mut Self {
384        Box::into_raw(Box::new(Self {
385            plan: OwnedChannelPlan::from_plan(plan),
386            published: Some(plan),
387        }))
388    }
389
390    /// The published plan this handle wraps, for a caller that needs it for as long as
391    /// the program runs.
392    ///
393    /// # Returns
394    ///
395    /// The plan, or `None` for one built at runtime.
396    pub(crate) fn published(&self) -> Option<&'static ChannelPlan<'static>> {
397        self.published
398    }
399
400    /// Runs a query against the plan.
401    ///
402    /// # Arguments
403    ///
404    /// * `f` - the query to run.
405    ///
406    /// # Returns
407    ///
408    /// Whatever the query returned.
409    pub(crate) fn with<R>(&self, f: impl FnOnce(&ChannelPlan<'_>) -> R) -> R {
410        self.plan.with_plan(f)
411    }
412}
413
414/// Converts a data rate into the shape that crosses the boundary.
415///
416/// # Arguments
417///
418/// * `rate` - the data rate to convert, or `None` for a reserved number.
419///
420/// # Returns
421///
422/// The equivalent C struct.
423fn data_rate_out(rate: Option<DataRate>) -> PamojaLoraDataRate {
424    let Some(rate) = rate else {
425        return PamojaLoraDataRate {
426            bitrate_bps: 0,
427            bandwidth_hz: 0,
428            kind: PAMOJA_LORA_MODULATION_RESERVED,
429            spreading_factor: 0,
430            coding_rate_numerator: 0,
431            coding_rate_denominator: 0,
432        };
433    };
434    let mut out = PamojaLoraDataRate {
435        bitrate_bps: rate.bitrate_bps,
436        bandwidth_hz: 0,
437        kind: PAMOJA_LORA_MODULATION_FSK,
438        spreading_factor: 0,
439        coding_rate_numerator: 0,
440        coding_rate_denominator: 0,
441    };
442    match rate.modulation {
443        Modulation::LoRa {
444            spreading_factor,
445            bandwidth_hz,
446        } => {
447            out.kind = PAMOJA_LORA_MODULATION_LORA;
448            out.spreading_factor = spreading_factor;
449            out.bandwidth_hz = bandwidth_hz;
450        }
451        Modulation::Fsk { .. } => {}
452        Modulation::LrFhss {
453            coding_rate_numerator,
454            coding_rate_denominator,
455            bandwidth_hz,
456        } => {
457            out.kind = PAMOJA_LORA_MODULATION_LR_FHSS;
458            out.coding_rate_numerator = coding_rate_numerator;
459            out.coding_rate_denominator = coding_rate_denominator;
460            out.bandwidth_hz = bandwidth_hz;
461        }
462    }
463    out
464}
465
466/// Converts a data rate that crossed the boundary into the Rust type.
467///
468/// # Arguments
469///
470/// * `rate` - the data rate as the caller supplied it.
471///
472/// # Returns
473///
474/// `Ok(Some(rate))`, `Ok(None)` for a reserved number, or a status if the kind is
475/// not one this ABI defines.
476fn data_rate_in(rate: &PamojaLoraDataRate) -> Result<Option<DataRate>, PamojaStatus> {
477    match rate.kind {
478        PAMOJA_LORA_MODULATION_LORA => Ok(Some(DataRate::lora(
479            rate.spreading_factor,
480            rate.bandwidth_hz,
481            rate.bitrate_bps,
482        ))),
483        PAMOJA_LORA_MODULATION_FSK => Ok(Some(DataRate::fsk(rate.bitrate_bps))),
484        PAMOJA_LORA_MODULATION_LR_FHSS => Ok(Some(DataRate::lr_fhss(
485            rate.coding_rate_numerator,
486            rate.coding_rate_denominator,
487            rate.bandwidth_hz,
488            rate.bitrate_bps,
489        ))),
490        PAMOJA_LORA_MODULATION_RESERVED => Ok(None),
491        other => {
492            set_last_error(format!("{other} is not a modulation this build defines"));
493            Err(PamojaStatus::InvalidArgument)
494        }
495    }
496}
497
498/// Reports whether a code names a region at all, whatever this build carries.
499///
500/// The codes are contiguous, so this stays a range check as regions are added.
501///
502/// # Arguments
503///
504/// * `region` - the code to check.
505///
506/// # Returns
507///
508/// `true` if the code names one of the published regions.
509fn is_region_code(region: u32) -> bool {
510    (PAMOJA_LORA_REGION_EU868..=PAMOJA_LORA_REGION_RU864).contains(&region)
511}
512
513/// Resolves a region code to its published plan.
514///
515/// An unknown code and a region left out of this build are told apart: the first
516/// is an invalid argument, the second is unsupported. A host that offers a choice
517/// of regions needs the difference, because one is a bug and the other is a build
518/// that was trimmed to fit a device.
519///
520/// # Arguments
521///
522/// * `region` - one of the `PAMOJA_LORA_REGION_*` constants.
523///
524/// # Returns
525///
526/// The published plan, or a status explaining why there is none.
527fn published(region: u32) -> Result<&'static ChannelPlan<'static>, PamojaStatus> {
528    let plan: Option<&'static ChannelPlan<'static>> = match region {
529        #[cfg(feature = "eu868")]
530        PAMOJA_LORA_REGION_EU868 => Some(Region::Eu868.plan()),
531        #[cfg(feature = "us915")]
532        PAMOJA_LORA_REGION_US915 => Some(Region::Us915.plan()),
533        #[cfg(feature = "eu433")]
534        PAMOJA_LORA_REGION_EU433 => Some(Region::Eu433.plan()),
535        #[cfg(feature = "au915")]
536        PAMOJA_LORA_REGION_AU915 => Some(Region::Au915.plan()),
537        #[cfg(feature = "cn470")]
538        PAMOJA_LORA_REGION_CN470 => Some(Region::Cn470.plan()),
539        #[cfg(feature = "as923")]
540        PAMOJA_LORA_REGION_AS923 => Some(Region::As923.plan()),
541        #[cfg(feature = "kr920")]
542        PAMOJA_LORA_REGION_KR920 => Some(Region::Kr920.plan()),
543        #[cfg(feature = "in865")]
544        PAMOJA_LORA_REGION_IN865 => Some(Region::In865.plan()),
545        #[cfg(feature = "ru864")]
546        PAMOJA_LORA_REGION_RU864 => Some(Region::Ru864.plan()),
547        _ => None,
548    };
549    match plan {
550        Some(plan) => Ok(plan),
551        None if is_region_code(region) => {
552            set_last_error(format!(
553                "region {region} is not compiled into this build of pamoja-lora"
554            ));
555            Err(PamojaStatus::Unsupported)
556        }
557        None => {
558            set_last_error(format!("{region} is not a region code"));
559            Err(PamojaStatus::InvalidArgument)
560        }
561    }
562}
563
564/// Returns the published channel plan for a region.
565///
566/// # Arguments
567///
568/// * `region` - one of the `PAMOJA_LORA_REGION_*` constants.
569/// * `out_plan` - set to the plan handle on success, and to null otherwise.
570///
571/// # Returns
572///
573/// [`PamojaStatus::Ok`] on success.
574///
575/// # Errors
576///
577/// Returns [`PamojaStatus::InvalidArgument`] if `out_plan` is null or `region` is
578/// not a region code, and [`PamojaStatus::Unsupported`] if the region is real but
579/// was not compiled into this build.
580///
581/// # Safety
582///
583/// `out_plan` must point at writable storage for one pointer.
584#[no_mangle]
585pub unsafe extern "C" fn pamoja_lora_plan_for_region(
586    region: u32,
587    out_plan: *mut *mut PamojaLoraPlan,
588) -> PamojaStatus {
589    if out_plan.is_null() {
590        set_last_error("out_plan must not be null".to_owned());
591        return PamojaStatus::InvalidArgument;
592    }
593    let slot = &mut *out_plan;
594    *slot = std::ptr::null_mut();
595
596    match published(region) {
597        Ok(plan) => {
598            *slot = PamojaLoraPlan::published_handle(plan);
599            PamojaStatus::Ok
600        }
601        Err(status) => status,
602    }
603}
604
605/// Returns one of the five CN470-510 channel plans.
606///
607/// # Arguments
608///
609/// * `which` - one of the `PAMOJA_LORA_CN470_*` constants other than
610///   [`PAMOJA_LORA_CN470_NONE`].
611/// * `out_plan` - set to the plan handle on success, and to null otherwise.
612///
613/// # Returns
614///
615/// [`PamojaStatus::Ok`] on success.
616///
617/// # Errors
618///
619/// Returns [`PamojaStatus::InvalidArgument`] if `out_plan` is null or `which` names no
620/// plan, and [`PamojaStatus::Unsupported`] if CN470-510 was not compiled into this build.
621///
622/// # Safety
623///
624/// `out_plan` must point at writable storage for one pointer.
625#[no_mangle]
626pub unsafe extern "C" fn pamoja_lora_plan_for_cn470(
627    which: u32,
628    out_plan: *mut *mut PamojaLoraPlan,
629) -> PamojaStatus {
630    if out_plan.is_null() {
631        set_last_error("out_plan must not be null".to_owned());
632        return PamojaStatus::InvalidArgument;
633    }
634    let slot = &mut *out_plan;
635    *slot = std::ptr::null_mut();
636    if !(PAMOJA_LORA_CN470_ANTENNA_20MHZ_A..=PAMOJA_LORA_CN470_CHANNELS_96).contains(&which) {
637        set_last_error(format!("{which} is not a CN470 plan"));
638        return PamojaStatus::InvalidArgument;
639    }
640
641    #[cfg(feature = "cn470")]
642    {
643        let plan = Cn470Plan::all()[(which - 1) as usize].plan();
644        *slot = PamojaLoraPlan::published_handle(plan);
645        PamojaStatus::Ok
646    }
647    #[cfg(not(feature = "cn470"))]
648    {
649        set_last_error("CN470-510 is not compiled into this build of pamoja-lora".to_owned());
650        PamojaStatus::Unsupported
651    }
652}
653
654/// The code a join plan's target crosses as.
655fn cn470_code(target: &ChannelPlan<'_>) -> u32 {
656    #[cfg(feature = "cn470")]
657    {
658        if let Some(position) = Cn470Plan::all()
659            .iter()
660            .position(|plan| std::ptr::eq(plan.plan(), target))
661        {
662            return position as u32 + 1;
663        }
664    }
665    let _ = target;
666    PAMOJA_LORA_CN470_NONE
667}
668
669/// Reads how a plan defines and uses its channels.
670///
671/// # Arguments
672///
673/// * `plan` - the plan to read.
674/// * `out_rules` - set to the rules on success.
675///
676/// # Returns
677///
678/// [`PamojaStatus::Ok`] on success.
679///
680/// # Errors
681///
682/// Returns [`PamojaStatus::InvalidArgument`] if either pointer is null.
683///
684/// # Safety
685///
686/// `plan` must be a live plan handle and `out_rules` must point at writable storage for
687/// one [`PamojaLoraPlanRules`].
688#[no_mangle]
689pub unsafe extern "C" fn pamoja_lora_plan_rules(
690    plan: *const PamojaLoraPlan,
691    out_rules: *mut PamojaLoraPlanRules,
692) -> PamojaStatus {
693    let (Some(handle), false) = (plan.as_ref(), out_rules.is_null()) else {
694        set_last_error("plan and out_rules must not be null".to_owned());
695        return PamojaStatus::InvalidArgument;
696    };
697    let join_plan_count = handle
698        .published
699        .map_or(0, |plan| plan.join_plans.len() as u16);
700    *out_rules = handle.with(|plan| {
701        let (kind, channel_list) = match plan.kind {
702            PlanKind::Dynamic { channel_list } => (
703                PAMOJA_LORA_PLAN_KIND_DYNAMIC,
704                match channel_list {
705                    None => PAMOJA_LORA_CHANNEL_LIST_NONE,
706                    Some(FixedChannelList::Mhz800) => PAMOJA_LORA_CHANNEL_LIST_MHZ800,
707                    Some(FixedChannelList::Mhz900) => PAMOJA_LORA_CHANNEL_LIST_MHZ900,
708                },
709            ),
710            PlanKind::Fixed => (PAMOJA_LORA_PLAN_KIND_FIXED, PAMOJA_LORA_CHANNEL_LIST_NONE),
711        };
712        let (power_reference, gain_allowance_db) = match plan.power_reference {
713            PowerReference::Eirp => (PAMOJA_LORA_POWER_EIRP, 0),
714            PowerReference::Conducted { gain_allowance_db } => {
715                (PAMOJA_LORA_POWER_CONDUCTED, gain_allowance_db)
716            }
717        };
718        PamojaLoraPlanRules {
719            kind,
720            channel_list,
721            tx_param_setup: u8::from(plan.tx_param_setup),
722            join_sequence: match plan.join_sequence {
723                JoinSequence::Random => PAMOJA_LORA_JOIN_RANDOM,
724                JoinSequence::OctetPasses => PAMOJA_LORA_JOIN_OCTET_PASSES,
725            },
726            power_reference,
727            gain_allowance_db,
728            downlink_channel_block_count: plan.downlink_channels.len() as u16,
729            join_plan_count,
730        }
731    });
732    PamojaStatus::Ok
733}
734
735/// Converts a channel mask control into the shape that crosses the boundary.
736fn mask_control_out(control: MaskControl) -> PamojaLoraMaskControl {
737    let mut out = PamojaLoraMaskControl {
738        kind: PAMOJA_LORA_MASK_RESERVED,
739        group: 0,
740        on: 0,
741        has_then_group: 0,
742        then_group: 0,
743    };
744    match control {
745        MaskControl::Group(group) => {
746            out.kind = PAMOJA_LORA_MASK_GROUP;
747            out.group = group;
748        }
749        MaskControl::Banks => out.kind = PAMOJA_LORA_MASK_BANKS,
750        MaskControl::PairedBanks => out.kind = PAMOJA_LORA_MASK_PAIRED_BANKS,
751        MaskControl::All { on, then_group } => {
752            out.kind = PAMOJA_LORA_MASK_ALL;
753            out.on = u8::from(on);
754            out.has_then_group = u8::from(then_group.is_some());
755            out.then_group = then_group.unwrap_or(0);
756        }
757        MaskControl::Reserved => {}
758    }
759    out
760}
761
762/// Converts a channel mask control that crossed the boundary.
763fn mask_control_in(control: &PamojaLoraMaskControl) -> Result<MaskControl, PamojaStatus> {
764    match control.kind {
765        PAMOJA_LORA_MASK_GROUP => Ok(MaskControl::Group(control.group)),
766        PAMOJA_LORA_MASK_BANKS => Ok(MaskControl::Banks),
767        PAMOJA_LORA_MASK_PAIRED_BANKS => Ok(MaskControl::PairedBanks),
768        PAMOJA_LORA_MASK_ALL => Ok(MaskControl::All {
769            on: control.on != 0,
770            then_group: (control.has_then_group != 0).then_some(control.then_group),
771        }),
772        PAMOJA_LORA_MASK_RESERVED => Ok(MaskControl::Reserved),
773        other => {
774            set_last_error(format!("{other} is not a channel mask control"));
775            Err(PamojaStatus::InvalidArgument)
776        }
777    }
778}
779
780/// Reads what one `ChMaskCntl` value does on a plan.
781///
782/// # Arguments
783///
784/// * `plan` - the plan to read.
785/// * `value` - the `ChMaskCntl` value, 0 to 7.
786/// * `out_control` - set to the control on success.
787///
788/// # Returns
789///
790/// [`PamojaStatus::Ok`] on success.
791///
792/// # Errors
793///
794/// Returns [`PamojaStatus::InvalidArgument`] if either pointer is null or `value` is past 7.
795///
796/// # Safety
797///
798/// `plan` must be a live plan handle and `out_control` must point at writable storage for
799/// one [`PamojaLoraMaskControl`].
800#[no_mangle]
801pub unsafe extern "C" fn pamoja_lora_plan_mask_control(
802    plan: *const PamojaLoraPlan,
803    value: u8,
804    out_control: *mut PamojaLoraMaskControl,
805) -> PamojaStatus {
806    let (Some(plan), false) = (plan.as_ref(), out_control.is_null()) else {
807        set_last_error("plan and out_control must not be null".to_owned());
808        return PamojaStatus::InvalidArgument;
809    };
810    if value > 7 {
811        set_last_error(format!(
812            "{value} is not a ChMaskCntl value, which is three bits"
813        ));
814        return PamojaStatus::InvalidArgument;
815    }
816    *out_control = plan.with(|plan| mask_control_out(plan.mask_controls[usize::from(value)]));
817    PamojaStatus::Ok
818}
819
820/// Returns where the first receive window listens after an uplink.
821///
822/// # Arguments
823///
824/// * `plan` - the plan to read.
825/// * `uplink_channel` - the channel number the uplink went out on.
826/// * `uplink_hz` - the frequency it went out on.
827/// * `out_frequency_hz` - set to the window's frequency on success.
828///
829/// # Returns
830///
831/// [`PamojaStatus::Ok`] on success: the uplink's own frequency on a plan with no numbered
832/// downlink channels, and otherwise the downlink channel the uplink channel maps to.
833///
834/// # Errors
835///
836/// Returns [`PamojaStatus::InvalidArgument`] if either pointer is null or the plan's
837/// downlink channels leave the window undefined.
838///
839/// # Safety
840///
841/// `plan` must be a live plan handle and `out_frequency_hz` must point at writable storage
842/// for one `uint32_t`.
843#[no_mangle]
844pub unsafe extern "C" fn pamoja_lora_plan_rx1_frequency_hz(
845    plan: *const PamojaLoraPlan,
846    uplink_channel: u16,
847    uplink_hz: u32,
848    out_frequency_hz: *mut u32,
849) -> PamojaStatus {
850    let (Some(plan), false) = (plan.as_ref(), out_frequency_hz.is_null()) else {
851        set_last_error("plan and out_frequency_hz must not be null".to_owned());
852        return PamojaStatus::InvalidArgument;
853    };
854    let Some(frequency) = plan.with(|plan| plan.rx1_frequency_hz(uplink_channel, uplink_hz)) else {
855        set_last_error(format!(
856            "this plan names no downlink for uplink channel {uplink_channel}"
857        ));
858        return PamojaStatus::InvalidArgument;
859    };
860    *out_frequency_hz = frequency;
861    PamojaStatus::Ok
862}
863
864/// Returns the frequency of one of the plan's numbered downlink channels.
865///
866/// # Arguments
867///
868/// * `plan` - the plan to read.
869/// * `channel` - the downlink channel number.
870/// * `out_frequency_hz` - set to its frequency on success.
871///
872/// # Returns
873///
874/// [`PamojaStatus::Ok`] on success.
875///
876/// # Errors
877///
878/// Returns [`PamojaStatus::InvalidArgument`] if either pointer is null or the plan numbers
879/// no such downlink channel.
880///
881/// # Safety
882///
883/// `plan` must be a live plan handle and `out_frequency_hz` must point at writable storage
884/// for one `uint32_t`.
885#[no_mangle]
886pub unsafe extern "C" fn pamoja_lora_plan_downlink_channel_frequency_hz(
887    plan: *const PamojaLoraPlan,
888    channel: u16,
889    out_frequency_hz: *mut u32,
890) -> PamojaStatus {
891    let (Some(plan), false) = (plan.as_ref(), out_frequency_hz.is_null()) else {
892        set_last_error("plan and out_frequency_hz must not be null".to_owned());
893        return PamojaStatus::InvalidArgument;
894    };
895    let Some(frequency) = plan.with(|plan| plan.downlink_channel_frequency_hz(channel)) else {
896        set_last_error(format!("this plan has no downlink channel {channel}"));
897        return PamojaStatus::InvalidArgument;
898    };
899    *out_frequency_hz = frequency;
900    PamojaStatus::Ok
901}
902
903/// Returns one run of join channels that selects a plan.
904///
905/// # Arguments
906///
907/// * `plan` - the plan to read.
908/// * `index` - the run's position, below the count [`pamoja_lora_plan_rules`] reports.
909/// * `out_join_plan` - set to the run on success.
910///
911/// # Returns
912///
913/// [`PamojaStatus::Ok`] on success.
914///
915/// # Errors
916///
917/// Returns [`PamojaStatus::InvalidArgument`] if either pointer is null or the index is past
918/// the end, which it always is for a plan that was built rather than published.
919///
920/// # Safety
921///
922/// `plan` must be a live plan handle and `out_join_plan` must point at writable storage for
923/// one [`PamojaLoraJoinPlan`].
924#[no_mangle]
925pub unsafe extern "C" fn pamoja_lora_plan_join_plan(
926    plan: *const PamojaLoraPlan,
927    index: u16,
928    out_join_plan: *mut PamojaLoraJoinPlan,
929) -> PamojaStatus {
930    let (Some(handle), false) = (plan.as_ref(), out_join_plan.is_null()) else {
931        set_last_error("plan and out_join_plan must not be null".to_owned());
932        return PamojaStatus::InvalidArgument;
933    };
934    let Some(run) = handle
935        .published
936        .and_then(|plan| plan.join_plans.get(usize::from(index)))
937    else {
938        set_last_error(format!("this plan has no join plan {index}"));
939        return PamojaStatus::InvalidArgument;
940    };
941    *out_join_plan = PamojaLoraJoinPlan {
942        channels: PamojaLoraChannelBlock {
943            start_hz: run.channels.start_hz,
944            step_hz: run.channels.step_hz,
945            count: run.channels.count,
946            min_data_rate: run.channels.min_data_rate,
947            max_data_rate: run.channels.max_data_rate,
948        },
949        accept_start_hz: run.accept_start_hz,
950        accept_step_hz: run.accept_step_hz,
951        rx2_start_hz: run.rx2_start_hz,
952        rx2_step_hz: run.rx2_step_hz,
953        cn470_plan: cn470_code(run.plan),
954    };
955    PamojaStatus::Ok
956}
957
958/// Finds the run of join channels a join channel belongs to.
959///
960/// # Arguments
961///
962/// * `plan` - the plan to read.
963/// * `join_channel` - the join channel, counted through the runs in order.
964/// * `out_index` - set to the run's position on success.
965/// * `out_offset` - set to the channel's place within the run on success.
966///
967/// # Returns
968///
969/// [`PamojaStatus::Ok`] on success. The run's accept frequency for the channel is
970/// `accept_start_hz + offset * accept_step_hz`, and its second window
971/// `rx2_start_hz + offset * rx2_step_hz`.
972///
973/// # Errors
974///
975/// Returns [`PamojaStatus::InvalidArgument`] if a pointer is null or no run holds the
976/// channel.
977///
978/// # Safety
979///
980/// `plan` must be a live plan handle, and `out_index` and `out_offset` must each point at
981/// writable storage for one `uint16_t`.
982#[no_mangle]
983pub unsafe extern "C" fn pamoja_lora_plan_join_plan_for_channel(
984    plan: *const PamojaLoraPlan,
985    join_channel: u16,
986    out_index: *mut u16,
987    out_offset: *mut u16,
988) -> PamojaStatus {
989    let (Some(handle), false, false) = (plan.as_ref(), out_index.is_null(), out_offset.is_null())
990    else {
991        set_last_error("plan, out_index and out_offset must not be null".to_owned());
992        return PamojaStatus::InvalidArgument;
993    };
994    let mut remaining = join_channel;
995    for (index, run) in handle
996        .published
997        .map_or(&[][..], |plan| plan.join_plans)
998        .iter()
999        .enumerate()
1000    {
1001        if remaining < run.channels.count {
1002            *out_index = index as u16;
1003            *out_offset = remaining;
1004            return PamojaStatus::Ok;
1005        }
1006        remaining -= run.channels.count;
1007    }
1008    set_last_error(format!("no join plan holds join channel {join_channel}"));
1009    PamojaStatus::InvalidArgument
1010}
1011
1012/// Reports whether a region is compiled into this build.
1013///
1014/// A slim build carries only the regions its device operates in, so a host that
1015/// offers a choice asks this before offering one.
1016///
1017/// # Arguments
1018///
1019/// * `region` - one of the `PAMOJA_LORA_REGION_*` constants.
1020///
1021/// # Returns
1022///
1023/// `1` if the region is available, `0` if it is a known region left out of this
1024/// build or is not a region code at all.
1025#[no_mangle]
1026pub extern "C" fn pamoja_lora_region_is_available(region: u32) -> u8 {
1027    u8::from(published(region).is_ok())
1028}
1029
1030/// Releases a channel plan.
1031///
1032/// # Arguments
1033///
1034/// * `plan` - the handle to release; null is ignored.
1035///
1036/// # Safety
1037///
1038/// `plan` must have come from [`pamoja_lora_plan_for_region`] or
1039/// [`pamoja_lora_plan_builder_build`] and must not be used afterwards.
1040#[no_mangle]
1041pub unsafe extern "C" fn pamoja_lora_plan_free(plan: *mut PamojaLoraPlan) {
1042    if !plan.is_null() {
1043        drop(Box::from_raw(plan));
1044    }
1045}
1046
1047/// Returns the plan's name, such as `EU863-870`.
1048///
1049/// # Arguments
1050///
1051/// * `plan` - the plan to read.
1052///
1053/// # Returns
1054///
1055/// A string the caller must release with
1056/// [`pamoja_string_free`](crate::pamoja_string_free), or null if `plan` is null.
1057///
1058/// # Safety
1059///
1060/// `plan` must be a live plan handle, or null.
1061#[no_mangle]
1062pub unsafe extern "C" fn pamoja_lora_plan_name(plan: *const PamojaLoraPlan) -> *mut PamojaString {
1063    let Some(plan) = plan.as_ref() else {
1064        set_last_error("plan must not be null".to_owned());
1065        return std::ptr::null_mut();
1066    };
1067    plan.with(|plan| PamojaString::into_raw(plan.name.to_owned()))
1068}
1069
1070/// Reads the scalar facts of a plan in one call.
1071///
1072/// # Arguments
1073///
1074/// * `plan` - the plan to read.
1075/// * `out_info` - set to the plan's scalars on success.
1076///
1077/// # Returns
1078///
1079/// [`PamojaStatus::Ok`] on success.
1080///
1081/// # Errors
1082///
1083/// Returns [`PamojaStatus::InvalidArgument`] if either pointer is null.
1084///
1085/// # Safety
1086///
1087/// `plan` must be a live plan handle and `out_info` must point at writable
1088/// storage for one [`PamojaLoraPlanInfo`].
1089#[no_mangle]
1090pub unsafe extern "C" fn pamoja_lora_plan_info(
1091    plan: *const PamojaLoraPlan,
1092    out_info: *mut PamojaLoraPlanInfo,
1093) -> PamojaStatus {
1094    let (Some(plan), false) = (plan.as_ref(), out_info.is_null()) else {
1095        set_last_error("plan and out_info must not be null".to_owned());
1096        return PamojaStatus::InvalidArgument;
1097    };
1098    *out_info = plan.with(|plan| PamojaLoraPlanInfo {
1099        rx2_frequency_hz: plan.rx2_frequency_hz,
1100        uplink_data_rate_count: plan.uplink_data_rates.len() as u16,
1101        downlink_data_rate_count: plan.downlink_data_rates.len() as u16,
1102        default_channel_count: plan.default_channel_count(),
1103        join_channel_block_count: plan.join_channels.len() as u16,
1104        default_channel_block_count: plan.default_channels.len() as u16,
1105        sub_band_count: plan.sub_bands.len() as u16,
1106        beacon: PamojaLoraBeacon {
1107            frequency_hz: plan.beacon.frequency_hz,
1108            ping_slot_frequency_hz: plan.beacon.ping_slot_frequency_hz,
1109            data_rate: plan.beacon.data_rate,
1110        },
1111        rx2_data_rate: plan.rx2_data_rate,
1112        default_max_eirp_dbm: plan.default_max_eirp_dbm,
1113        tx_power_step_db: plan.tx_power_step_db,
1114        max_tx_power_index: plan.max_tx_power_index,
1115        max_rx1_data_rate_offset: plan.max_rx1_data_rate_offset,
1116        has_dwell_time_limit: u8::from(plan.has_dwell_time_limit),
1117        has_dwell_limited_payloads: u8::from(plan.max_payload_dwell_limited.is_some()),
1118        has_dwell_limited_rx1: u8::from(plan.rx1_data_rate_offsets_dwell_limited.is_some()),
1119    });
1120    PamojaStatus::Ok
1121}
1122
1123/// Returns the data rate a number selects.
1124///
1125/// # Arguments
1126///
1127/// * `plan` - the plan to read.
1128/// * `direction` - [`PAMOJA_LORA_DIRECTION_UPLINK`] or
1129///   [`PAMOJA_LORA_DIRECTION_DOWNLINK`], which differ in the 900 MHz plans.
1130/// * `data_rate` - the data-rate number.
1131/// * `out_rate` - set to the data rate on success.
1132///
1133/// # Returns
1134///
1135/// [`PamojaStatus::Ok`] on success. A reserved number succeeds and reports
1136/// [`PAMOJA_LORA_MODULATION_RESERVED`].
1137///
1138/// # Errors
1139///
1140/// Returns [`PamojaStatus::InvalidArgument`] if either pointer is null, the
1141/// direction is not one of the two constants, or the number is past the end of
1142/// the plan's table.
1143///
1144/// # Safety
1145///
1146/// `plan` must be a live plan handle and `out_rate` must point at writable
1147/// storage for one [`PamojaLoraDataRate`].
1148#[no_mangle]
1149pub unsafe extern "C" fn pamoja_lora_plan_data_rate(
1150    plan: *const PamojaLoraPlan,
1151    direction: u32,
1152    data_rate: u8,
1153    out_rate: *mut PamojaLoraDataRate,
1154) -> PamojaStatus {
1155    let (Some(plan), false) = (plan.as_ref(), out_rate.is_null()) else {
1156        set_last_error("plan and out_rate must not be null".to_owned());
1157        return PamojaStatus::InvalidArgument;
1158    };
1159    let found = plan.with(|plan| match direction {
1160        PAMOJA_LORA_DIRECTION_UPLINK => Ok(plan
1161            .uplink_data_rates
1162            .get(usize::from(data_rate))
1163            .copied()
1164            .map(data_rate_out)),
1165        PAMOJA_LORA_DIRECTION_DOWNLINK => Ok(plan
1166            .downlink_data_rates
1167            .get(usize::from(data_rate))
1168            .copied()
1169            .map(data_rate_out)),
1170        other => Err(other),
1171    });
1172    match found {
1173        Ok(Some(rate)) => {
1174            *out_rate = rate;
1175            PamojaStatus::Ok
1176        }
1177        Ok(None) => {
1178            set_last_error(format!("this plan defines no data rate {data_rate}"));
1179            PamojaStatus::InvalidArgument
1180        }
1181        Err(other) => {
1182            set_last_error(format!("{other} is not a direction"));
1183            PamojaStatus::InvalidArgument
1184        }
1185    }
1186}
1187
1188/// Returns the radio settings an uplink data rate selects.
1189///
1190/// This is what turns a data-rate number into something a radio can be told: the
1191/// spreading factor and bandwidth to transmit at, ready for
1192/// `pamoja_lora_airtime_us`.
1193///
1194/// # Arguments
1195///
1196/// * `plan` - the plan to read.
1197/// * `data_rate` - the uplink data-rate number.
1198/// * `out_link` - set to the radio settings on success.
1199///
1200/// # Returns
1201///
1202/// [`PamojaStatus::Ok`] on success.
1203///
1204/// # Errors
1205///
1206/// Returns [`PamojaStatus::InvalidArgument`] if either pointer is null, and
1207/// [`PamojaStatus::Unsupported`] if the number is reserved or names a rate that
1208/// is not LoRa, which has no spreading factor to report.
1209///
1210/// # Safety
1211///
1212/// `plan` must be a live plan handle and `out_link` must point at writable
1213/// storage for one [`PamojaLoraLink`].
1214#[no_mangle]
1215pub unsafe extern "C" fn pamoja_lora_plan_link_settings(
1216    plan: *const PamojaLoraPlan,
1217    data_rate: u8,
1218    out_link: *mut PamojaLoraLink,
1219) -> PamojaStatus {
1220    let (Some(plan), false) = (plan.as_ref(), out_link.is_null()) else {
1221        set_last_error("plan and out_link must not be null".to_owned());
1222        return PamojaStatus::InvalidArgument;
1223    };
1224    let Some(settings) = plan.with(|plan| plan.link_settings(data_rate)) else {
1225        set_last_error(format!(
1226            "data rate {data_rate} is reserved or is not carried by LoRa in this plan"
1227        ));
1228        return PamojaStatus::Unsupported;
1229    };
1230    *out_link = PamojaLoraLink {
1231        bandwidth_hz: settings.bandwidth_hz(),
1232        preamble_symbols: 8,
1233        spreading_factor: settings.spreading_factor(),
1234        coding_rate_denominator: 5,
1235        explicit_header: 1,
1236        crc: 1,
1237    };
1238    PamojaStatus::Ok
1239}
1240
1241/// Returns what a data rate may carry in one frame.
1242///
1243/// # Arguments
1244///
1245/// * `plan` - the plan to read.
1246/// * `table` - one of the `PAMOJA_LORA_PAYLOAD_TABLE_*` constants.
1247/// * `data_rate` - the data-rate number.
1248/// * `out_payload` - set to the limits on success.
1249///
1250/// # Returns
1251///
1252/// [`PamojaStatus::Ok`] on success.
1253///
1254/// # Errors
1255///
1256/// Returns [`PamojaStatus::InvalidArgument`] if either pointer is null, the table
1257/// is not one of the constants, or the plan publishes no limit for that number.
1258/// Returns [`PamojaStatus::Unsupported`] if the dwell-limited table was asked for
1259/// and this plan has no dwell-time limit.
1260///
1261/// # Safety
1262///
1263/// `plan` must be a live plan handle and `out_payload` must point at writable
1264/// storage for one [`PamojaLoraMaxPayload`].
1265#[no_mangle]
1266pub unsafe extern "C" fn pamoja_lora_plan_max_payload(
1267    plan: *const PamojaLoraPlan,
1268    table: u32,
1269    data_rate: u8,
1270    out_payload: *mut PamojaLoraMaxPayload,
1271) -> PamojaStatus {
1272    let (Some(plan), false) = (plan.as_ref(), out_payload.is_null()) else {
1273        set_last_error("plan and out_payload must not be null".to_owned());
1274        return PamojaStatus::InvalidArgument;
1275    };
1276    let found = plan.with(|plan| match table {
1277        PAMOJA_LORA_PAYLOAD_TABLE_UPLINK_REPEATER => Ok(plan.max_payload(data_rate, true)),
1278        PAMOJA_LORA_PAYLOAD_TABLE_UPLINK_DIRECT => Ok(plan.max_payload(data_rate, false)),
1279        PAMOJA_LORA_PAYLOAD_TABLE_DOWNLINK_REPEATER => {
1280            Ok(plan.downlink_max_payload(data_rate, true))
1281        }
1282        PAMOJA_LORA_PAYLOAD_TABLE_DOWNLINK_DIRECT => {
1283            Ok(plan.downlink_max_payload(data_rate, false))
1284        }
1285        PAMOJA_LORA_PAYLOAD_TABLE_DWELL_LIMITED => {
1286            if plan.max_payload_dwell_limited.is_none() {
1287                Err(PamojaStatus::Unsupported)
1288            } else {
1289                Ok(plan.max_payload_dwell_limited(data_rate))
1290            }
1291        }
1292        _ => Err(PamojaStatus::InvalidArgument),
1293    });
1294    match found {
1295        Ok(Some(payload)) => {
1296            *out_payload = PamojaLoraMaxPayload {
1297                mac_payload: payload.mac_payload,
1298                application: payload.application,
1299            };
1300            PamojaStatus::Ok
1301        }
1302        Ok(None) => {
1303            set_last_error(format!(
1304                "this plan publishes no payload limit for data rate {data_rate}"
1305            ));
1306            PamojaStatus::InvalidArgument
1307        }
1308        Err(PamojaStatus::Unsupported) => {
1309            set_last_error("this plan has no dwell-time limit".to_owned());
1310            PamojaStatus::Unsupported
1311        }
1312        Err(_) => {
1313            set_last_error(format!("{table} is not a payload table"));
1314            PamojaStatus::InvalidArgument
1315        }
1316    }
1317}
1318
1319/// Returns the share of time a transmitter may hold a frequency.
1320///
1321/// This reports the limit; it does not impose it. Pair it with
1322/// `pamoja_lora_min_off_time_us` to turn the
1323/// limit into the silence a given frame costs.
1324///
1325/// # Arguments
1326///
1327/// * `plan` - the plan to read.
1328/// * `frequency_hz` - the frequency in hertz.
1329/// * `out_permille` - set to the limit in parts per thousand on success, where
1330///   `1000` means the sub-band is unrestricted.
1331///
1332/// # Returns
1333///
1334/// [`PamojaStatus::Ok`] on success.
1335///
1336/// # Errors
1337///
1338/// Returns [`PamojaStatus::InvalidArgument`] if either pointer is null, and
1339/// [`PamojaStatus::Unsupported`] if the frequency falls in no sub-band this plan
1340/// describes.
1341///
1342/// # Safety
1343///
1344/// `plan` must be a live plan handle and `out_permille` must point at writable
1345/// storage for one `uint32_t`.
1346#[no_mangle]
1347pub unsafe extern "C" fn pamoja_lora_plan_duty_cycle_permille(
1348    plan: *const PamojaLoraPlan,
1349    frequency_hz: u32,
1350    out_permille: *mut u32,
1351) -> PamojaStatus {
1352    let (Some(plan), false) = (plan.as_ref(), out_permille.is_null()) else {
1353        set_last_error("plan and out_permille must not be null".to_owned());
1354        return PamojaStatus::InvalidArgument;
1355    };
1356    let Some(permille) = plan.with(|plan| plan.duty_cycle_permille(frequency_hz)) else {
1357        set_last_error(format!(
1358            "{frequency_hz} Hz falls in no sub-band this plan describes"
1359        ));
1360        return PamojaStatus::Unsupported;
1361    };
1362    *out_permille = permille;
1363    PamojaStatus::Ok
1364}
1365
1366/// Returns the power ceiling that applies at a frequency, in dBm EIRP.
1367///
1368/// # Arguments
1369///
1370/// * `plan` - the plan to read.
1371/// * `frequency_hz` - the frequency in hertz.
1372/// * `out_dbm` - set to the ceiling on success, falling back to the plan's default
1373///   where no sub-band says otherwise.
1374///
1375/// # Returns
1376///
1377/// [`PamojaStatus::Ok`] on success.
1378///
1379/// # Errors
1380///
1381/// Returns [`PamojaStatus::InvalidArgument`] if either pointer is null.
1382///
1383/// # Safety
1384///
1385/// `plan` must be a live plan handle and `out_dbm` must point at writable storage
1386/// for one `int8_t`.
1387#[no_mangle]
1388pub unsafe extern "C" fn pamoja_lora_plan_max_eirp_dbm(
1389    plan: *const PamojaLoraPlan,
1390    frequency_hz: u32,
1391    out_dbm: *mut i8,
1392) -> PamojaStatus {
1393    let (Some(plan), false) = (plan.as_ref(), out_dbm.is_null()) else {
1394        set_last_error("plan and out_dbm must not be null".to_owned());
1395        return PamojaStatus::InvalidArgument;
1396    };
1397    *out_dbm = plan.with(|plan| plan.max_eirp_dbm(frequency_hz));
1398    PamojaStatus::Ok
1399}
1400
1401/// Returns the radiated power a transmit-power index selects, in dBm.
1402///
1403/// # Arguments
1404///
1405/// * `plan` - the plan to read.
1406/// * `index` - the transmit-power index, where zero is the ceiling.
1407/// * `max_eirp_dbm` - the ceiling the index steps down from, usually from
1408///   [`pamoja_lora_plan_max_eirp_dbm`].
1409/// * `out_dbm` - set to the radiated power on success.
1410///
1411/// # Returns
1412///
1413/// [`PamojaStatus::Ok`] on success.
1414///
1415/// # Errors
1416///
1417/// Returns [`PamojaStatus::InvalidArgument`] if either pointer is null or the
1418/// index is past the highest the plan defines.
1419///
1420/// # Safety
1421///
1422/// `plan` must be a live plan handle and `out_dbm` must point at writable storage
1423/// for one `int8_t`.
1424#[no_mangle]
1425pub unsafe extern "C" fn pamoja_lora_plan_tx_power_dbm(
1426    plan: *const PamojaLoraPlan,
1427    index: u8,
1428    max_eirp_dbm: i8,
1429    out_dbm: *mut i8,
1430) -> PamojaStatus {
1431    let (Some(plan), false) = (plan.as_ref(), out_dbm.is_null()) else {
1432        set_last_error("plan and out_dbm must not be null".to_owned());
1433        return PamojaStatus::InvalidArgument;
1434    };
1435    let Some(dbm) = plan.with(|plan| plan.tx_power_dbm(index, max_eirp_dbm)) else {
1436        set_last_error(format!("this plan defines no transmit-power index {index}"));
1437        return PamojaStatus::InvalidArgument;
1438    };
1439    *out_dbm = dbm;
1440    PamojaStatus::Ok
1441}
1442
1443/// Returns the downlink data rate the first receive window listens at.
1444///
1445/// # Arguments
1446///
1447/// * `plan` - the plan to read.
1448/// * `uplink_data_rate` - the data rate the uplink was sent at.
1449/// * `offset` - the RX1 data-rate offset the network assigned.
1450/// * `dwell_limited` - `1` to use the mapping for a dwell-limited downlink, `0`
1451///   for the ordinary one.
1452/// * `out_data_rate` - set to the downlink data rate on success.
1453///
1454/// # Returns
1455///
1456/// [`PamojaStatus::Ok`] on success.
1457///
1458/// # Errors
1459///
1460/// Returns [`PamojaStatus::InvalidArgument`] if either pointer is null, or the
1461/// uplink data rate or offset is outside what the plan defines. Returns
1462/// [`PamojaStatus::Unsupported`] if a dwell-limited mapping was asked for and this
1463/// plan publishes none.
1464///
1465/// # Safety
1466///
1467/// `plan` must be a live plan handle and `out_data_rate` must point at writable
1468/// storage for one `uint8_t`.
1469#[no_mangle]
1470pub unsafe extern "C" fn pamoja_lora_plan_rx1_data_rate(
1471    plan: *const PamojaLoraPlan,
1472    uplink_data_rate: u8,
1473    offset: u8,
1474    dwell_limited: u8,
1475    out_data_rate: *mut u8,
1476) -> PamojaStatus {
1477    let (Some(plan), false) = (plan.as_ref(), out_data_rate.is_null()) else {
1478        set_last_error("plan and out_data_rate must not be null".to_owned());
1479        return PamojaStatus::InvalidArgument;
1480    };
1481    let wants_dwell = dwell_limited != 0;
1482    if wants_dwell && plan.with(|plan| plan.rx1_data_rate_offsets_dwell_limited.is_none()) {
1483        set_last_error("this plan publishes no dwell-limited RX1 mapping".to_owned());
1484        return PamojaStatus::Unsupported;
1485    }
1486    let found = plan.with(|plan| {
1487        if wants_dwell {
1488            plan.rx1_data_rate_dwell_limited(uplink_data_rate, offset)
1489        } else {
1490            plan.rx1_data_rate(uplink_data_rate, offset)
1491        }
1492    });
1493    let Some(data_rate) = found else {
1494        set_last_error(format!(
1495            "this plan maps no RX1 downlink for uplink data rate {uplink_data_rate} at offset {offset}"
1496        ));
1497        return PamojaStatus::InvalidArgument;
1498    };
1499    *out_data_rate = data_rate;
1500    PamojaStatus::Ok
1501}
1502
1503/// Returns the next lower data rate to fall back to during adaptive back-off.
1504///
1505/// A device that has lost the network steps down this chain, trading airtime for
1506/// range until it is heard again.
1507///
1508/// # Arguments
1509///
1510/// * `plan` - the plan to read.
1511/// * `data_rate` - the data rate currently in use.
1512/// * `out_data_rate` - set to the next lower data rate on success.
1513///
1514/// # Returns
1515///
1516/// [`PamojaStatus::Ok`] on success.
1517///
1518/// # Errors
1519///
1520/// Returns [`PamojaStatus::InvalidArgument`] if either pointer is null or the
1521/// number is outside the plan's table, and [`PamojaStatus::Unsupported`] if there
1522/// is nothing lower to fall back to.
1523///
1524/// # Safety
1525///
1526/// `plan` must be a live plan handle and `out_data_rate` must point at writable
1527/// storage for one `uint8_t`.
1528#[no_mangle]
1529pub unsafe extern "C" fn pamoja_lora_plan_next_backoff_data_rate(
1530    plan: *const PamojaLoraPlan,
1531    data_rate: u8,
1532    out_data_rate: *mut u8,
1533) -> PamojaStatus {
1534    let (Some(plan), false) = (plan.as_ref(), out_data_rate.is_null()) else {
1535        set_last_error("plan and out_data_rate must not be null".to_owned());
1536        return PamojaStatus::InvalidArgument;
1537    };
1538    let known = plan.with(|plan| usize::from(data_rate) < plan.data_rate_backoff.len());
1539    if !known {
1540        set_last_error(format!("this plan defines no data rate {data_rate}"));
1541        return PamojaStatus::InvalidArgument;
1542    }
1543    let Some(lower) = plan.with(|plan| plan.next_backoff_data_rate(data_rate)) else {
1544        set_last_error(format!(
1545            "data rate {data_rate} is the slowest this plan has"
1546        ));
1547        return PamojaStatus::Unsupported;
1548    };
1549    *out_data_rate = lower;
1550    PamojaStatus::Ok
1551}
1552
1553/// Returns the center frequency of one of the plan's default channels.
1554///
1555/// # Arguments
1556///
1557/// * `plan` - the plan to read.
1558/// * `channel` - the channel number, counting across the default blocks in order.
1559/// * `out_frequency_hz` - set to the center frequency on success.
1560///
1561/// # Returns
1562///
1563/// [`PamojaStatus::Ok`] on success.
1564///
1565/// # Errors
1566///
1567/// Returns [`PamojaStatus::InvalidArgument`] if either pointer is null or the
1568/// channel is past the last one the plan starts a device with.
1569///
1570/// # Safety
1571///
1572/// `plan` must be a live plan handle and `out_frequency_hz` must point at
1573/// writable storage for one `uint32_t`.
1574#[no_mangle]
1575pub unsafe extern "C" fn pamoja_lora_plan_channel_frequency_hz(
1576    plan: *const PamojaLoraPlan,
1577    channel: u16,
1578    out_frequency_hz: *mut u32,
1579) -> PamojaStatus {
1580    let (Some(plan), false) = (plan.as_ref(), out_frequency_hz.is_null()) else {
1581        set_last_error("plan and out_frequency_hz must not be null".to_owned());
1582        return PamojaStatus::InvalidArgument;
1583    };
1584    let Some(frequency) = plan.with(|plan| plan.channel_frequency_hz(channel)) else {
1585        set_last_error(format!("this plan has no default channel {channel}"));
1586        return PamojaStatus::InvalidArgument;
1587    };
1588    *out_frequency_hz = frequency;
1589    PamojaStatus::Ok
1590}
1591
1592/// Returns one of the plan's channel blocks.
1593///
1594/// # Arguments
1595///
1596/// * `plan` - the plan to read.
1597/// * `which` - [`PAMOJA_LORA_CHANNELS_JOIN`], [`PAMOJA_LORA_CHANNELS_DEFAULT`] or
1598///   [`PAMOJA_LORA_CHANNELS_DOWNLINK`].
1599/// * `index` - the block's position, below the count [`pamoja_lora_plan_info`] or
1600///   [`pamoja_lora_plan_rules`] reports.
1601/// * `out_block` - set to the block on success.
1602///
1603/// # Returns
1604///
1605/// [`PamojaStatus::Ok`] on success.
1606///
1607/// # Errors
1608///
1609/// Returns [`PamojaStatus::InvalidArgument`] if either pointer is null, `which`
1610/// is not one of the constants, or the index is past the end.
1611///
1612/// # Safety
1613///
1614/// `plan` must be a live plan handle and `out_block` must point at writable
1615/// storage for one [`PamojaLoraChannelBlock`].
1616#[no_mangle]
1617pub unsafe extern "C" fn pamoja_lora_plan_channel_block(
1618    plan: *const PamojaLoraPlan,
1619    which: u32,
1620    index: u16,
1621    out_block: *mut PamojaLoraChannelBlock,
1622) -> PamojaStatus {
1623    let (Some(plan), false) = (plan.as_ref(), out_block.is_null()) else {
1624        set_last_error("plan and out_block must not be null".to_owned());
1625        return PamojaStatus::InvalidArgument;
1626    };
1627    if !(PAMOJA_LORA_CHANNELS_JOIN..=PAMOJA_LORA_CHANNELS_DOWNLINK).contains(&which) {
1628        set_last_error(format!("{which} is not a channel set"));
1629        return PamojaStatus::InvalidArgument;
1630    }
1631    let found = plan.with(|plan| {
1632        let blocks = match which {
1633            PAMOJA_LORA_CHANNELS_JOIN => plan.join_channels,
1634            PAMOJA_LORA_CHANNELS_DEFAULT => plan.default_channels,
1635            _ => plan.downlink_channels,
1636        };
1637        blocks.get(usize::from(index)).copied()
1638    });
1639    let Some(block) = found else {
1640        set_last_error(format!("this plan has no channel block {index}"));
1641        return PamojaStatus::InvalidArgument;
1642    };
1643    *out_block = PamojaLoraChannelBlock {
1644        start_hz: block.start_hz,
1645        step_hz: block.step_hz,
1646        count: block.count,
1647        min_data_rate: block.min_data_rate,
1648        max_data_rate: block.max_data_rate,
1649    };
1650    PamojaStatus::Ok
1651}
1652
1653/// Counts the plan's default relay channels.
1654///
1655/// # Arguments
1656///
1657/// * `plan` - the plan to read.
1658/// * `out_count` - set to how many there are.
1659///
1660/// # Returns
1661///
1662/// [`PamojaStatus::Ok`] on success: none for a region with no relay parameters, such as
1663/// EU433.
1664///
1665/// # Errors
1666///
1667/// Returns [`PamojaStatus::InvalidArgument`] if either pointer is null.
1668///
1669/// # Safety
1670///
1671/// `plan` must be a live plan handle and `out_count` must be writable.
1672#[no_mangle]
1673pub unsafe extern "C" fn pamoja_lora_plan_relay_channel_count(
1674    plan: *const PamojaLoraPlan,
1675    out_count: *mut u8,
1676) -> PamojaStatus {
1677    let (Some(plan), false) = (plan.as_ref(), out_count.is_null()) else {
1678        set_last_error("plan and out_count must not be null".to_owned());
1679        return PamojaStatus::InvalidArgument;
1680    };
1681    *out_count = plan.with(|plan| plan.relay_channels.len().min(usize::from(u8::MAX)) as u8);
1682    PamojaStatus::Ok
1683}
1684
1685/// Returns one of the plan's default relay channels, RP002-1.0.5 sections 3.4.9 to 3.13.9.
1686///
1687/// # Arguments
1688///
1689/// * `plan` - the plan to read.
1690/// * `index` - the channel index a relay configuration names, below the count
1691///   [`pamoja_lora_plan_relay_channel_count`] reports.
1692/// * `out_channel` - set to the channel on success.
1693///
1694/// # Returns
1695///
1696/// [`PamojaStatus::Ok`] on success.
1697///
1698/// # Errors
1699///
1700/// Returns [`PamojaStatus::InvalidArgument`] if either pointer is null or the index is past
1701/// the end.
1702///
1703/// # Safety
1704///
1705/// `plan` must be a live plan handle and `out_channel` must point at writable storage for
1706/// one [`PamojaLoraRelayChannel`].
1707#[no_mangle]
1708pub unsafe extern "C" fn pamoja_lora_plan_relay_channel(
1709    plan: *const PamojaLoraPlan,
1710    index: u8,
1711    out_channel: *mut PamojaLoraRelayChannel,
1712) -> PamojaStatus {
1713    let (Some(plan), false) = (plan.as_ref(), out_channel.is_null()) else {
1714        set_last_error("plan and out_channel must not be null".to_owned());
1715        return PamojaStatus::InvalidArgument;
1716    };
1717    let Some(channel) = plan.with(|plan| plan.relay_channel(index)) else {
1718        set_last_error(format!("this plan has no relay channel {index}"));
1719        return PamojaStatus::InvalidArgument;
1720    };
1721    *out_channel = PamojaLoraRelayChannel {
1722        wor_frequency_hz: channel.wor_frequency_hz,
1723        ack_frequency_hz: channel.ack_frequency_hz,
1724        data_rate: channel.data_rate,
1725    };
1726    PamojaStatus::Ok
1727}
1728
1729/// Returns one of the plan's sub-bands.
1730///
1731/// # Arguments
1732///
1733/// * `plan` - the plan to read.
1734/// * `index` - the sub-band's position, below the count
1735///   [`pamoja_lora_plan_info`] reports.
1736/// * `out_band` - set to the sub-band on success.
1737///
1738/// # Returns
1739///
1740/// [`PamojaStatus::Ok`] on success.
1741///
1742/// # Errors
1743///
1744/// Returns [`PamojaStatus::InvalidArgument`] if either pointer is null or the
1745/// index is past the end.
1746///
1747/// # Safety
1748///
1749/// `plan` must be a live plan handle and `out_band` must point at writable
1750/// storage for one [`PamojaLoraSubBand`].
1751#[no_mangle]
1752pub unsafe extern "C" fn pamoja_lora_plan_sub_band(
1753    plan: *const PamojaLoraPlan,
1754    index: u16,
1755    out_band: *mut PamojaLoraSubBand,
1756) -> PamojaStatus {
1757    let (Some(plan), false) = (plan.as_ref(), out_band.is_null()) else {
1758        set_last_error("plan and out_band must not be null".to_owned());
1759        return PamojaStatus::InvalidArgument;
1760    };
1761    let found = plan.with(|plan| plan.sub_bands.get(usize::from(index)).copied());
1762    let Some(band) = found else {
1763        set_last_error(format!("this plan has no sub-band {index}"));
1764        return PamojaStatus::InvalidArgument;
1765    };
1766    *out_band = PamojaLoraSubBand {
1767        start_hz: band.start_hz,
1768        end_hz: band.end_hz,
1769        duty_cycle_permille: band.duty_cycle_permille,
1770        max_eirp_dbm: band.max_eirp_dbm,
1771    };
1772    PamojaStatus::Ok
1773}
1774
1775/// A channel plan under construction.
1776///
1777/// A handle the caller must release with [`pamoja_lora_plan_builder_free`], or
1778/// hand to [`pamoja_lora_plan_builder_build`], which consumes it.
1779pub struct PamojaLoraPlanBuilder {
1780    builder: Option<ChannelPlanBuilder>,
1781}
1782
1783/// Applies one step to a builder held behind a handle.
1784///
1785/// The Rust builder consumes itself at each step, so the handle lends it out and
1786/// takes it back.
1787///
1788/// # Arguments
1789///
1790/// * `builder` - the handle to update.
1791/// * `step` - the step to apply.
1792///
1793/// # Returns
1794///
1795/// [`PamojaStatus::Ok`] on success.
1796///
1797/// # Errors
1798///
1799/// Returns [`PamojaStatus::Closed`] if the builder was already built.
1800fn update(
1801    builder: &mut PamojaLoraPlanBuilder,
1802    step: impl FnOnce(ChannelPlanBuilder) -> ChannelPlanBuilder,
1803) -> PamojaStatus {
1804    let Some(inner) = builder.builder.take() else {
1805        set_last_error("this builder has already been built".to_owned());
1806        return PamojaStatus::Closed;
1807    };
1808    builder.builder = Some(step(inner));
1809    PamojaStatus::Ok
1810}
1811
1812/// Maps a payload-table code onto the table it names.
1813///
1814/// # Arguments
1815///
1816/// * `table` - one of the `PAMOJA_LORA_PAYLOAD_TABLE_*` constants.
1817///
1818/// # Returns
1819///
1820/// The table, or `None` if the code names none.
1821fn payload_table(table: u32) -> Option<PayloadTable> {
1822    match table {
1823        PAMOJA_LORA_PAYLOAD_TABLE_UPLINK_REPEATER => Some(PayloadTable::UplinkRepeater),
1824        PAMOJA_LORA_PAYLOAD_TABLE_UPLINK_DIRECT => Some(PayloadTable::UplinkDirect),
1825        PAMOJA_LORA_PAYLOAD_TABLE_DOWNLINK_REPEATER => Some(PayloadTable::DownlinkRepeater),
1826        PAMOJA_LORA_PAYLOAD_TABLE_DOWNLINK_DIRECT => Some(PayloadTable::DownlinkDirect),
1827        PAMOJA_LORA_PAYLOAD_TABLE_DWELL_LIMITED => Some(PayloadTable::DwellLimited),
1828        _ => None,
1829    }
1830}
1831
1832/// Creates an empty plan builder.
1833///
1834/// The builder starts with no data rates, channels, or sub-bands, and with a
1835/// permissive power ceiling; push the tables the deployment uses, then build.
1836///
1837/// # Arguments
1838///
1839/// * `name` - a null-terminated name for the plan, such as the band it covers.
1840/// * `out_builder` - set to the builder handle on success, and to null otherwise.
1841///
1842/// # Returns
1843///
1844/// [`PamojaStatus::Ok`] on success.
1845///
1846/// # Errors
1847///
1848/// Returns [`PamojaStatus::InvalidArgument`] if either pointer is null or `name`
1849/// is not valid UTF-8.
1850///
1851/// # Safety
1852///
1853/// `name` must be a valid null-terminated string and `out_builder` must point at
1854/// writable storage for one pointer.
1855#[no_mangle]
1856pub unsafe extern "C" fn pamoja_lora_plan_builder_new(
1857    name: *const std::os::raw::c_char,
1858    out_builder: *mut *mut PamojaLoraPlanBuilder,
1859) -> PamojaStatus {
1860    if out_builder.is_null() {
1861        set_last_error("out_builder must not be null".to_owned());
1862        return PamojaStatus::InvalidArgument;
1863    }
1864    let slot = &mut *out_builder;
1865    *slot = std::ptr::null_mut();
1866
1867    let Some(name) = crate::read_str(name, "name") else {
1868        return PamojaStatus::InvalidArgument;
1869    };
1870
1871    *slot = Box::into_raw(Box::new(PamojaLoraPlanBuilder {
1872        builder: Some(ChannelPlanBuilder::new(name)),
1873    }));
1874    PamojaStatus::Ok
1875}
1876
1877/// Releases a plan builder that will not be built.
1878///
1879/// # Arguments
1880///
1881/// * `builder` - the handle to release; null is ignored.
1882///
1883/// # Safety
1884///
1885/// `builder` must have come from [`pamoja_lora_plan_builder_new`], must not have
1886/// been passed to [`pamoja_lora_plan_builder_build`], and must not be used
1887/// afterwards.
1888#[no_mangle]
1889pub unsafe extern "C" fn pamoja_lora_plan_builder_free(builder: *mut PamojaLoraPlanBuilder) {
1890    if !builder.is_null() {
1891        drop(Box::from_raw(builder));
1892    }
1893}
1894
1895/// Appends a data rate to the end of a direction's table.
1896///
1897/// Data rates are numbered by their position, so push them in order and use a
1898/// [`PAMOJA_LORA_MODULATION_RESERVED`] entry for a number the plan does not use.
1899/// A plan that leaves its downlink table empty reuses its uplink table, which is
1900/// what most regions do.
1901///
1902/// # Arguments
1903///
1904/// * `builder` - the builder to extend.
1905/// * `direction` - [`PAMOJA_LORA_DIRECTION_UPLINK`] or
1906///   [`PAMOJA_LORA_DIRECTION_DOWNLINK`].
1907/// * `rate` - the data rate to append.
1908///
1909/// # Returns
1910///
1911/// [`PamojaStatus::Ok`] on success.
1912///
1913/// # Errors
1914///
1915/// Returns [`PamojaStatus::InvalidArgument`] if either pointer is null, the
1916/// direction is not one of the constants, or the modulation kind is not one this
1917/// ABI defines, and [`PamojaStatus::Closed`] if the builder was already built.
1918///
1919/// # Safety
1920///
1921/// `builder` must be a live builder handle and `rate` must point at one readable
1922/// [`PamojaLoraDataRate`].
1923#[no_mangle]
1924pub unsafe extern "C" fn pamoja_lora_plan_builder_push_data_rate(
1925    builder: *mut PamojaLoraPlanBuilder,
1926    direction: u32,
1927    rate: *const PamojaLoraDataRate,
1928) -> PamojaStatus {
1929    let (Some(builder), Some(rate)) = (builder.as_mut(), rate.as_ref()) else {
1930        set_last_error("builder and rate must not be null".to_owned());
1931        return PamojaStatus::InvalidArgument;
1932    };
1933    let rate = match data_rate_in(rate) {
1934        Ok(rate) => rate,
1935        Err(status) => return status,
1936    };
1937    match direction {
1938        PAMOJA_LORA_DIRECTION_UPLINK => update(builder, |b| b.uplink_data_rate(rate)),
1939        PAMOJA_LORA_DIRECTION_DOWNLINK => update(builder, |b| b.downlink_data_rate(rate)),
1940        other => {
1941            set_last_error(format!("{other} is not a direction"));
1942            PamojaStatus::InvalidArgument
1943        }
1944    }
1945}
1946
1947/// Appends a payload limit to the end of one of the plan's tables.
1948///
1949/// Limits are numbered by their position, matching the data rates. A plan that
1950/// leaves a downlink table empty reuses the matching uplink one.
1951///
1952/// # Arguments
1953///
1954/// * `builder` - the builder to extend.
1955/// * `table` - one of the `PAMOJA_LORA_PAYLOAD_TABLE_*` constants.
1956/// * `present` - `0` to append a reserved entry, for a data rate the plan does
1957///   not define; the two lengths are then ignored.
1958/// * `mac_payload` - the largest MAC payload in bytes.
1959/// * `application` - the largest application payload in bytes.
1960///
1961/// # Returns
1962///
1963/// [`PamojaStatus::Ok`] on success.
1964///
1965/// # Errors
1966///
1967/// Returns [`PamojaStatus::InvalidArgument`] if `builder` is null or `table` is
1968/// not one of the constants, and [`PamojaStatus::Closed`] if the builder was
1969/// already built.
1970///
1971/// # Safety
1972///
1973/// `builder` must be a live builder handle.
1974#[no_mangle]
1975pub unsafe extern "C" fn pamoja_lora_plan_builder_push_max_payload(
1976    builder: *mut PamojaLoraPlanBuilder,
1977    table: u32,
1978    present: u8,
1979    mac_payload: u16,
1980    application: u16,
1981) -> PamojaStatus {
1982    let Some(builder) = builder.as_mut() else {
1983        set_last_error("builder must not be null".to_owned());
1984        return PamojaStatus::InvalidArgument;
1985    };
1986    let Some(table) = payload_table(table) else {
1987        set_last_error(format!("{table} is not a payload table"));
1988        return PamojaStatus::InvalidArgument;
1989    };
1990    let entry = (present != 0).then(|| MaxPayload::new(mac_payload, application));
1991    update(builder, |b| b.max_payload(table, entry))
1992}
1993
1994/// Appends a run of evenly spaced channels to the plan.
1995///
1996/// # Arguments
1997///
1998/// * `builder` - the builder to extend.
1999/// * `which` - [`PAMOJA_LORA_CHANNELS_JOIN`], [`PAMOJA_LORA_CHANNELS_DEFAULT`] or
2000///   [`PAMOJA_LORA_CHANNELS_DOWNLINK`].
2001/// * `block` - the channel block to append.
2002///
2003/// # Returns
2004///
2005/// [`PamojaStatus::Ok`] on success.
2006///
2007/// # Errors
2008///
2009/// Returns [`PamojaStatus::InvalidArgument`] if either pointer is null or `which`
2010/// is not one of the constants, and [`PamojaStatus::Closed`] if the builder was
2011/// already built.
2012///
2013/// # Safety
2014///
2015/// `builder` must be a live builder handle and `block` must point at one readable
2016/// [`PamojaLoraChannelBlock`].
2017#[no_mangle]
2018pub unsafe extern "C" fn pamoja_lora_plan_builder_push_channel_block(
2019    builder: *mut PamojaLoraPlanBuilder,
2020    which: u32,
2021    block: *const PamojaLoraChannelBlock,
2022) -> PamojaStatus {
2023    let (Some(builder), Some(block)) = (builder.as_mut(), block.as_ref()) else {
2024        set_last_error("builder and block must not be null".to_owned());
2025        return PamojaStatus::InvalidArgument;
2026    };
2027    let entry = ChannelBlock::new(
2028        block.start_hz,
2029        block.step_hz,
2030        block.count,
2031        block.min_data_rate,
2032        block.max_data_rate,
2033    );
2034    match which {
2035        PAMOJA_LORA_CHANNELS_JOIN => update(builder, |b| b.join_channel(entry)),
2036        PAMOJA_LORA_CHANNELS_DEFAULT => update(builder, |b| b.default_channel(entry)),
2037        PAMOJA_LORA_CHANNELS_DOWNLINK => update(builder, |b| b.downlink_channel(entry)),
2038        other => {
2039            set_last_error(format!("{other} is not a channel set"));
2040            PamojaStatus::InvalidArgument
2041        }
2042    }
2043}
2044
2045/// Appends a sub-band and its transmit limits to the plan.
2046///
2047/// A deployment on licensed spectrum gives its sub-band a duty cycle of `1000`,
2048/// which reports as unrestricted.
2049///
2050/// # Arguments
2051///
2052/// * `builder` - the builder to extend.
2053/// * `band` - the sub-band to append.
2054///
2055/// # Returns
2056///
2057/// [`PamojaStatus::Ok`] on success.
2058///
2059/// # Errors
2060///
2061/// Returns [`PamojaStatus::InvalidArgument`] if either pointer is null, and
2062/// [`PamojaStatus::Closed`] if the builder was already built.
2063///
2064/// # Safety
2065///
2066/// `builder` must be a live builder handle and `band` must point at one readable
2067/// [`PamojaLoraSubBand`].
2068#[no_mangle]
2069pub unsafe extern "C" fn pamoja_lora_plan_builder_push_sub_band(
2070    builder: *mut PamojaLoraPlanBuilder,
2071    band: *const PamojaLoraSubBand,
2072) -> PamojaStatus {
2073    let (Some(builder), Some(band)) = (builder.as_mut(), band.as_ref()) else {
2074        set_last_error("builder and band must not be null".to_owned());
2075        return PamojaStatus::InvalidArgument;
2076    };
2077    let entry = SubBand::new(
2078        band.start_hz,
2079        band.end_hz,
2080        band.duty_cycle_permille,
2081        band.max_eirp_dbm,
2082    );
2083    update(builder, |b| b.sub_band(entry))
2084}
2085
2086/// Appends one uplink data rate's row of RX1 downlink data rates.
2087///
2088/// Rows are numbered by their position, matching the uplink data rates, and every
2089/// row must be as wide as the plan's highest RX1 offset allows.
2090///
2091/// # Arguments
2092///
2093/// * `builder` - the builder to extend.
2094/// * `dwell_limited` - `1` to append to the mapping used under a dwell-time
2095///   limit, `0` for the ordinary one.
2096/// * `offsets` - the downlink data rate for each offset, in order.
2097/// * `offsets_len` - how many offsets `offsets` holds.
2098///
2099/// # Returns
2100///
2101/// [`PamojaStatus::Ok`] on success.
2102///
2103/// # Errors
2104///
2105/// Returns [`PamojaStatus::InvalidArgument`] if `builder` or `offsets` is null,
2106/// and [`PamojaStatus::Closed`] if the builder was already built.
2107///
2108/// # Safety
2109///
2110/// `builder` must be a live builder handle and `offsets` must point at
2111/// `offsets_len` readable bytes.
2112#[no_mangle]
2113pub unsafe extern "C" fn pamoja_lora_plan_builder_push_rx1_row(
2114    builder: *mut PamojaLoraPlanBuilder,
2115    dwell_limited: u8,
2116    offsets: *const u8,
2117    offsets_len: usize,
2118) -> PamojaStatus {
2119    let Some(builder) = builder.as_mut() else {
2120        set_last_error("builder must not be null".to_owned());
2121        return PamojaStatus::InvalidArgument;
2122    };
2123    let row = match crate::read_bytes(offsets, offsets_len) {
2124        Ok(row) => row,
2125        Err(status) => return status,
2126    };
2127    if dwell_limited == 0 {
2128        update(builder, |b| b.rx1_row(&row))
2129    } else {
2130        update(builder, |b| b.rx1_row_dwell_limited(&row))
2131    }
2132}
2133
2134/// Appends the next entry in the adaptive back-off chain.
2135///
2136/// Entries are numbered by their position, matching the uplink data rates.
2137///
2138/// # Arguments
2139///
2140/// * `builder` - the builder to extend.
2141/// * `has_lower` - `0` if this data rate is the slowest, with nothing below it;
2142///   `data_rate` is then ignored.
2143/// * `data_rate` - the data rate to fall back to.
2144///
2145/// # Returns
2146///
2147/// [`PamojaStatus::Ok`] on success.
2148///
2149/// # Errors
2150///
2151/// Returns [`PamojaStatus::InvalidArgument`] if `builder` is null, and
2152/// [`PamojaStatus::Closed`] if the builder was already built.
2153///
2154/// # Safety
2155///
2156/// `builder` must be a live builder handle.
2157#[no_mangle]
2158pub unsafe extern "C" fn pamoja_lora_plan_builder_push_backoff(
2159    builder: *mut PamojaLoraPlanBuilder,
2160    has_lower: u8,
2161    data_rate: u8,
2162) -> PamojaStatus {
2163    let Some(builder) = builder.as_mut() else {
2164        set_last_error("builder must not be null".to_owned());
2165        return PamojaStatus::InvalidArgument;
2166    };
2167    let lower = (has_lower != 0).then_some(data_rate);
2168    update(builder, |b| b.backoff(lower))
2169}
2170
2171/// Sets the plan's transmit-power ladder.
2172///
2173/// # Arguments
2174///
2175/// * `builder` - the builder to set.
2176/// * `default_max_eirp_dbm` - the ceiling assumed where no sub-band says
2177///   otherwise.
2178/// * `tx_power_step_db` - the step between transmit-power settings, in dB.
2179/// * `max_tx_power_index` - the highest transmit-power index the plan defines.
2180///
2181/// # Returns
2182///
2183/// [`PamojaStatus::Ok`] on success.
2184///
2185/// # Errors
2186///
2187/// Returns [`PamojaStatus::InvalidArgument`] if `builder` is null, and
2188/// [`PamojaStatus::Closed`] if the builder was already built.
2189///
2190/// # Safety
2191///
2192/// `builder` must be a live builder handle.
2193#[no_mangle]
2194pub unsafe extern "C" fn pamoja_lora_plan_builder_set_power(
2195    builder: *mut PamojaLoraPlanBuilder,
2196    default_max_eirp_dbm: i8,
2197    tx_power_step_db: u8,
2198    max_tx_power_index: u8,
2199) -> PamojaStatus {
2200    let Some(builder) = builder.as_mut() else {
2201        set_last_error("builder must not be null".to_owned());
2202        return PamojaStatus::InvalidArgument;
2203    };
2204    update(builder, |b| {
2205        b.power(default_max_eirp_dbm, tx_power_step_db, max_tx_power_index)
2206    })
2207}
2208
2209/// Sets the plan's receive windows.
2210///
2211/// # Arguments
2212///
2213/// * `builder` - the builder to set.
2214/// * `rx2_frequency_hz` - the fixed frequency the second window listens on.
2215/// * `rx2_data_rate` - the data rate the second window listens at.
2216/// * `max_rx1_data_rate_offset` - the highest RX1 offset the plan allows, which
2217///   fixes how wide every RX1 row must be.
2218///
2219/// # Returns
2220///
2221/// [`PamojaStatus::Ok`] on success.
2222///
2223/// # Errors
2224///
2225/// Returns [`PamojaStatus::InvalidArgument`] if `builder` is null, and
2226/// [`PamojaStatus::Closed`] if the builder was already built.
2227///
2228/// # Safety
2229///
2230/// `builder` must be a live builder handle.
2231#[no_mangle]
2232pub unsafe extern "C" fn pamoja_lora_plan_builder_set_rx(
2233    builder: *mut PamojaLoraPlanBuilder,
2234    rx2_frequency_hz: u32,
2235    rx2_data_rate: u8,
2236    max_rx1_data_rate_offset: u8,
2237) -> PamojaStatus {
2238    let Some(builder) = builder.as_mut() else {
2239        set_last_error("builder must not be null".to_owned());
2240        return PamojaStatus::InvalidArgument;
2241    };
2242    update(builder, |b| {
2243        b.rx(rx2_frequency_hz, rx2_data_rate, max_rx1_data_rate_offset)
2244    })
2245}
2246
2247/// Sets the plan's Class B beacon and whether it limits dwell time.
2248///
2249/// # Arguments
2250///
2251/// * `builder` - the builder to set.
2252/// * `beacon` - the beacon settings.
2253/// * `has_dwell_time_limit` - `1` if the plan caps how long one transmission may
2254///   hold a channel.
2255///
2256/// # Returns
2257///
2258/// [`PamojaStatus::Ok`] on success.
2259///
2260/// # Errors
2261///
2262/// Returns [`PamojaStatus::InvalidArgument`] if either pointer is null, and
2263/// [`PamojaStatus::Closed`] if the builder was already built.
2264///
2265/// # Safety
2266///
2267/// `builder` must be a live builder handle and `beacon` must point at one
2268/// readable [`PamojaLoraBeacon`].
2269#[no_mangle]
2270pub unsafe extern "C" fn pamoja_lora_plan_builder_set_beacon(
2271    builder: *mut PamojaLoraPlanBuilder,
2272    beacon: *const PamojaLoraBeacon,
2273    has_dwell_time_limit: u8,
2274) -> PamojaStatus {
2275    let (Some(builder), Some(beacon)) = (builder.as_mut(), beacon.as_ref()) else {
2276        set_last_error("builder and beacon must not be null".to_owned());
2277        return PamojaStatus::InvalidArgument;
2278    };
2279    let entry = Beacon {
2280        data_rate: beacon.data_rate,
2281        frequency_hz: beacon.frequency_hz,
2282        ping_slot_frequency_hz: beacon.ping_slot_frequency_hz,
2283    };
2284    update(builder, |b| {
2285        b.beacon(entry).dwell_time_limit(has_dwell_time_limit != 0)
2286    })
2287}
2288
2289/// Appends the next default relay channel.
2290///
2291/// # Arguments
2292///
2293/// * `builder` - the builder to update.
2294/// * `channel` - the channel, whose position is the index a relay configuration names.
2295///
2296/// # Returns
2297///
2298/// [`PamojaStatus::Ok`] on success. A data rate that is not one of the plan's LoRa downlink
2299/// rates is refused when the plan is built.
2300///
2301/// # Errors
2302///
2303/// Returns [`PamojaStatus::InvalidArgument`] if either pointer is null, and
2304/// [`PamojaStatus::Closed`] if the builder was already built.
2305///
2306/// # Safety
2307///
2308/// `builder` must be a live builder handle and `channel` must point at one readable
2309/// [`PamojaLoraRelayChannel`].
2310#[no_mangle]
2311pub unsafe extern "C" fn pamoja_lora_plan_builder_add_relay_channel(
2312    builder: *mut PamojaLoraPlanBuilder,
2313    channel: *const PamojaLoraRelayChannel,
2314) -> PamojaStatus {
2315    let (Some(builder), Some(channel)) = (builder.as_mut(), channel.as_ref()) else {
2316        set_last_error("builder and channel must not be null".to_owned());
2317        return PamojaStatus::InvalidArgument;
2318    };
2319    let entry = RelayChannel::new(
2320        channel.wor_frequency_hz,
2321        channel.ack_frequency_hz,
2322        channel.data_rate,
2323    );
2324    update(builder, |b| b.relay_channel(entry))
2325}
2326
2327/// Sets whether the plan's network creates channels, and the numbering a dynamic plan reads
2328/// a type 1 channel list against.
2329///
2330/// # Arguments
2331///
2332/// * `builder` - the builder to update.
2333/// * `kind` - [`PAMOJA_LORA_PLAN_KIND_DYNAMIC`] or [`PAMOJA_LORA_PLAN_KIND_FIXED`].
2334/// * `channel_list` - for a dynamic plan, one of the `PAMOJA_LORA_CHANNEL_LIST_*` constants;
2335///   ignored for a fixed one.
2336///
2337/// # Returns
2338///
2339/// [`PamojaStatus::Ok`] on success.
2340///
2341/// # Errors
2342///
2343/// Returns [`PamojaStatus::InvalidArgument`] if `builder` is null or a code names nothing,
2344/// and [`PamojaStatus::Closed`] if the builder was already built.
2345///
2346/// # Safety
2347///
2348/// `builder` must be a live builder handle.
2349#[no_mangle]
2350pub unsafe extern "C" fn pamoja_lora_plan_builder_set_kind(
2351    builder: *mut PamojaLoraPlanBuilder,
2352    kind: u8,
2353    channel_list: u8,
2354) -> PamojaStatus {
2355    let Some(builder) = builder.as_mut() else {
2356        set_last_error("builder must not be null".to_owned());
2357        return PamojaStatus::InvalidArgument;
2358    };
2359    let kind = match (kind, channel_list) {
2360        (PAMOJA_LORA_PLAN_KIND_FIXED, _) => PlanKind::Fixed,
2361        (PAMOJA_LORA_PLAN_KIND_DYNAMIC, PAMOJA_LORA_CHANNEL_LIST_NONE) => {
2362            PlanKind::Dynamic { channel_list: None }
2363        }
2364        (PAMOJA_LORA_PLAN_KIND_DYNAMIC, PAMOJA_LORA_CHANNEL_LIST_MHZ800) => PlanKind::Dynamic {
2365            channel_list: Some(FixedChannelList::Mhz800),
2366        },
2367        (PAMOJA_LORA_PLAN_KIND_DYNAMIC, PAMOJA_LORA_CHANNEL_LIST_MHZ900) => PlanKind::Dynamic {
2368            channel_list: Some(FixedChannelList::Mhz900),
2369        },
2370        _ => {
2371            set_last_error(format!(
2372                "{kind} with channel list {channel_list} is not a plan kind"
2373            ));
2374            return PamojaStatus::InvalidArgument;
2375        }
2376    };
2377    update(builder, |b| b.kind(kind))
2378}
2379
2380/// Sets whether devices on the plan answer `TXParamSetupReq`.
2381///
2382/// # Arguments
2383///
2384/// * `builder` - the builder to update.
2385/// * `answered` - `1` if the command applies.
2386///
2387/// # Returns
2388///
2389/// [`PamojaStatus::Ok`] on success.
2390///
2391/// # Errors
2392///
2393/// Returns [`PamojaStatus::InvalidArgument`] if `builder` is null, and
2394/// [`PamojaStatus::Closed`] if the builder was already built.
2395///
2396/// # Safety
2397///
2398/// `builder` must be a live builder handle.
2399#[no_mangle]
2400pub unsafe extern "C" fn pamoja_lora_plan_builder_set_tx_param_setup(
2401    builder: *mut PamojaLoraPlanBuilder,
2402    answered: u8,
2403) -> PamojaStatus {
2404    let Some(builder) = builder.as_mut() else {
2405        set_last_error("builder must not be null".to_owned());
2406        return PamojaStatus::InvalidArgument;
2407    };
2408    update(builder, |b| b.tx_param_setup(answered != 0))
2409}
2410
2411/// Sets what each `ChMaskCntl` value does.
2412///
2413/// # Arguments
2414///
2415/// * `builder` - the builder to update.
2416/// * `controls` - the eight controls, indexed by value.
2417/// * `len` - how many `controls` points at, which must be 8.
2418///
2419/// # Returns
2420///
2421/// [`PamojaStatus::Ok`] on success.
2422///
2423/// # Errors
2424///
2425/// Returns [`PamojaStatus::InvalidArgument`] if a pointer is null, `len` is not 8, or a
2426/// control's kind names nothing, and [`PamojaStatus::Closed`] if the builder was already
2427/// built.
2428///
2429/// # Safety
2430///
2431/// `builder` must be a live builder handle and `controls` must point at `len` readable
2432/// [`PamojaLoraMaskControl`] values.
2433#[no_mangle]
2434pub unsafe extern "C" fn pamoja_lora_plan_builder_set_mask_controls(
2435    builder: *mut PamojaLoraPlanBuilder,
2436    controls: *const PamojaLoraMaskControl,
2437    len: usize,
2438) -> PamojaStatus {
2439    let Some(builder) = builder.as_mut() else {
2440        set_last_error("builder must not be null".to_owned());
2441        return PamojaStatus::InvalidArgument;
2442    };
2443    if controls.is_null() || len != 8 {
2444        set_last_error("controls must point at exactly eight controls".to_owned());
2445        return PamojaStatus::InvalidArgument;
2446    }
2447    let crossed = std::slice::from_raw_parts(controls, len);
2448    let mut table = [MaskControl::Reserved; 8];
2449    for (slot, control) in table.iter_mut().zip(crossed) {
2450        *slot = match mask_control_in(control) {
2451            Ok(control) => control,
2452            Err(status) => return status,
2453        };
2454    }
2455    update(builder, |b| b.mask_controls(table))
2456}
2457
2458/// Sets the order a device tries the join channels in.
2459///
2460/// # Arguments
2461///
2462/// * `builder` - the builder to update.
2463/// * `sequence` - one of the `PAMOJA_LORA_JOIN_*` constants.
2464///
2465/// # Returns
2466///
2467/// [`PamojaStatus::Ok`] on success.
2468///
2469/// # Errors
2470///
2471/// Returns [`PamojaStatus::InvalidArgument`] if `builder` is null or `sequence` names
2472/// nothing, and [`PamojaStatus::Closed`] if the builder was already built.
2473///
2474/// # Safety
2475///
2476/// `builder` must be a live builder handle.
2477#[no_mangle]
2478pub unsafe extern "C" fn pamoja_lora_plan_builder_set_join_sequence(
2479    builder: *mut PamojaLoraPlanBuilder,
2480    sequence: u8,
2481) -> PamojaStatus {
2482    let Some(builder) = builder.as_mut() else {
2483        set_last_error("builder must not be null".to_owned());
2484        return PamojaStatus::InvalidArgument;
2485    };
2486    let sequence = match sequence {
2487        PAMOJA_LORA_JOIN_RANDOM => JoinSequence::Random,
2488        PAMOJA_LORA_JOIN_OCTET_PASSES => JoinSequence::OctetPasses,
2489        other => {
2490            set_last_error(format!("{other} is not a join sequence"));
2491            return PamojaStatus::InvalidArgument;
2492        }
2493    };
2494    update(builder, |b| b.join_sequence(sequence))
2495}
2496
2497/// Sets what the plan's transmit power indexes count down from.
2498///
2499/// # Arguments
2500///
2501/// * `builder` - the builder to update.
2502/// * `reference` - [`PAMOJA_LORA_POWER_EIRP`] or [`PAMOJA_LORA_POWER_CONDUCTED`].
2503/// * `gain_allowance_db` - for a conducted ceiling, the antenna gain it allows for.
2504///
2505/// # Returns
2506///
2507/// [`PamojaStatus::Ok`] on success.
2508///
2509/// # Errors
2510///
2511/// Returns [`PamojaStatus::InvalidArgument`] if `builder` is null or `reference` names
2512/// nothing, and [`PamojaStatus::Closed`] if the builder was already built.
2513///
2514/// # Safety
2515///
2516/// `builder` must be a live builder handle.
2517#[no_mangle]
2518pub unsafe extern "C" fn pamoja_lora_plan_builder_set_power_reference(
2519    builder: *mut PamojaLoraPlanBuilder,
2520    reference: u8,
2521    gain_allowance_db: u8,
2522) -> PamojaStatus {
2523    let Some(builder) = builder.as_mut() else {
2524        set_last_error("builder must not be null".to_owned());
2525        return PamojaStatus::InvalidArgument;
2526    };
2527    let reference = match reference {
2528        PAMOJA_LORA_POWER_EIRP => PowerReference::Eirp,
2529        PAMOJA_LORA_POWER_CONDUCTED => PowerReference::Conducted { gain_allowance_db },
2530        other => {
2531            set_last_error(format!("{other} is not a power reference"));
2532            return PamojaStatus::InvalidArgument;
2533        }
2534    };
2535    update(builder, |b| b.power_reference(reference))
2536}
2537
2538/// Finishes a plan and hands back a handle the query functions accept.
2539///
2540/// The builder is consumed whether the plan is accepted or rejected, so the
2541/// caller must not free or reuse it afterwards.
2542///
2543/// Tables left empty are filled in where a region would share them: an empty
2544/// downlink data-rate table reuses the uplink one, an empty downlink payload
2545/// table reuses the matching uplink one, and an empty back-off chain steps down
2546/// one data rate at a time. What cannot be guessed is checked instead, so a plan
2547/// that would answer a question wrongly is refused here rather than at the
2548/// question.
2549///
2550/// # Arguments
2551///
2552/// * `builder` - the builder to finish, which this call consumes.
2553/// * `out_plan` - set to the plan handle on success, and to null otherwise.
2554///
2555/// # Returns
2556///
2557/// [`PamojaStatus::Ok`] on success.
2558///
2559/// # Errors
2560///
2561/// Returns [`PamojaStatus::InvalidArgument`] if either pointer is null or the
2562/// plan is inconsistent, with the reason available from
2563/// `pamoja_last_error_message`, and
2564/// [`PamojaStatus::Closed`] if the builder was already built.
2565///
2566/// # Safety
2567///
2568/// `builder` must be a live builder handle from
2569/// [`pamoja_lora_plan_builder_new`], and `out_plan` must point at writable
2570/// storage for one pointer.
2571#[no_mangle]
2572pub unsafe extern "C" fn pamoja_lora_plan_builder_build(
2573    builder: *mut PamojaLoraPlanBuilder,
2574    out_plan: *mut *mut PamojaLoraPlan,
2575) -> PamojaStatus {
2576    if builder.is_null() || out_plan.is_null() {
2577        set_last_error("builder and out_plan must not be null".to_owned());
2578        return PamojaStatus::InvalidArgument;
2579    }
2580    let slot = &mut *out_plan;
2581    *slot = std::ptr::null_mut();
2582
2583    let Some(inner) = Box::from_raw(builder).builder else {
2584        set_last_error("this builder has already been built".to_owned());
2585        return PamojaStatus::Closed;
2586    };
2587
2588    match inner.build() {
2589        Ok(plan) => {
2590            *slot = PamojaLoraPlan::into_handle(plan);
2591            PamojaStatus::Ok
2592        }
2593        Err(error) => {
2594            set_last_error(error.to_string());
2595            PamojaStatus::InvalidArgument
2596        }
2597    }
2598}
2599
2600#[cfg(test)]
2601mod tests {
2602    use super::*;
2603    use std::ffi::CString;
2604    use std::ptr;
2605
2606    /// Builds a small but complete two-rate plan, the way a private deployment on
2607    /// licensed spectrum would.
2608    unsafe fn private_plan() -> *mut PamojaLoraPlan {
2609        let name = CString::new("private-915").expect("name");
2610        let mut builder = ptr::null_mut();
2611        assert_eq!(
2612            pamoja_lora_plan_builder_new(name.as_ptr(), &mut builder),
2613            PamojaStatus::Ok
2614        );
2615
2616        for rate in [
2617            PamojaLoraDataRate {
2618                bitrate_bps: 250,
2619                bandwidth_hz: 125_000,
2620                kind: PAMOJA_LORA_MODULATION_LORA,
2621                spreading_factor: 12,
2622                coding_rate_numerator: 0,
2623                coding_rate_denominator: 0,
2624            },
2625            PamojaLoraDataRate {
2626                bitrate_bps: 5_470,
2627                bandwidth_hz: 125_000,
2628                kind: PAMOJA_LORA_MODULATION_LORA,
2629                spreading_factor: 7,
2630                coding_rate_numerator: 0,
2631                coding_rate_denominator: 0,
2632            },
2633        ] {
2634            assert_eq!(
2635                pamoja_lora_plan_builder_push_data_rate(
2636                    builder,
2637                    PAMOJA_LORA_DIRECTION_UPLINK,
2638                    &rate
2639                ),
2640                PamojaStatus::Ok
2641            );
2642        }
2643
2644        for (mac, app) in [(59u16, 51u16), (230, 222)] {
2645            for table in [
2646                PAMOJA_LORA_PAYLOAD_TABLE_UPLINK_REPEATER,
2647                PAMOJA_LORA_PAYLOAD_TABLE_UPLINK_DIRECT,
2648            ] {
2649                assert_eq!(
2650                    pamoja_lora_plan_builder_push_max_payload(builder, table, 1, mac, app),
2651                    PamojaStatus::Ok
2652                );
2653            }
2654        }
2655
2656        let block = PamojaLoraChannelBlock {
2657            start_hz: 915_000_000,
2658            step_hz: 500_000,
2659            count: 4,
2660            min_data_rate: 0,
2661            max_data_rate: 1,
2662        };
2663        assert_eq!(
2664            pamoja_lora_plan_builder_push_channel_block(
2665                builder,
2666                PAMOJA_LORA_CHANNELS_DEFAULT,
2667                &block
2668            ),
2669            PamojaStatus::Ok
2670        );
2671        assert_eq!(
2672            pamoja_lora_plan_builder_push_channel_block(builder, PAMOJA_LORA_CHANNELS_JOIN, &block),
2673            PamojaStatus::Ok
2674        );
2675
2676        // Licensed spectrum: the holder may occupy the channel continuously.
2677        let band = PamojaLoraSubBand {
2678            start_hz: 915_000_000,
2679            end_hz: 917_000_000,
2680            duty_cycle_permille: 1000,
2681            max_eirp_dbm: 30,
2682        };
2683        assert_eq!(
2684            pamoja_lora_plan_builder_push_sub_band(builder, &band),
2685            PamojaStatus::Ok
2686        );
2687
2688        assert_eq!(
2689            pamoja_lora_plan_builder_set_rx(builder, 915_000_000, 0, 0),
2690            PamojaStatus::Ok
2691        );
2692        for row in [[0u8], [1u8]] {
2693            assert_eq!(
2694                pamoja_lora_plan_builder_push_rx1_row(builder, 0, row.as_ptr(), row.len()),
2695                PamojaStatus::Ok
2696            );
2697        }
2698        assert_eq!(
2699            pamoja_lora_plan_builder_set_power(builder, 30, 2, 7),
2700            PamojaStatus::Ok
2701        );
2702        let relay = PamojaLoraRelayChannel {
2703            wor_frequency_hz: 915_200_000,
2704            ack_frequency_hz: 915_400_000,
2705            data_rate: 1,
2706        };
2707        assert_eq!(
2708            pamoja_lora_plan_builder_add_relay_channel(builder, &relay),
2709            PamojaStatus::Ok
2710        );
2711
2712        let mut plan = ptr::null_mut();
2713        assert_eq!(
2714            pamoja_lora_plan_builder_build(builder, &mut plan),
2715            PamojaStatus::Ok
2716        );
2717        assert!(!plan.is_null());
2718        plan
2719    }
2720
2721    #[test]
2722    #[cfg(feature = "eu868")]
2723    fn a_published_region_reports_its_own_tables() {
2724        unsafe {
2725            let mut plan = ptr::null_mut();
2726            assert_eq!(
2727                pamoja_lora_plan_for_region(PAMOJA_LORA_REGION_EU868, &mut plan),
2728                PamojaStatus::Ok
2729            );
2730
2731            let name = pamoja_lora_plan_name(plan);
2732            let text = std::ffi::CStr::from_ptr(crate::pamoja_string_data(name));
2733            assert_eq!(text.to_str().expect("utf-8"), "EU863-870");
2734            crate::pamoja_string_free(name);
2735
2736            let mut link = PamojaLoraLink {
2737                bandwidth_hz: 0,
2738                preamble_symbols: 0,
2739                spreading_factor: 0,
2740                coding_rate_denominator: 0,
2741                explicit_header: 0,
2742                crc: 0,
2743            };
2744            assert_eq!(
2745                pamoja_lora_plan_link_settings(plan, 0, &mut link),
2746                PamojaStatus::Ok
2747            );
2748            assert_eq!(link.spreading_factor, 12);
2749            assert_eq!(link.bandwidth_hz, 125_000);
2750
2751            let mut permille = 0;
2752            assert_eq!(
2753                pamoja_lora_plan_duty_cycle_permille(plan, 868_100_000, &mut permille),
2754                PamojaStatus::Ok
2755            );
2756            assert_eq!(permille, 10, "the 868.1 MHz sub-band is limited to 1%");
2757
2758            let mut count = 0u8;
2759            assert_eq!(
2760                pamoja_lora_plan_relay_channel_count(plan, &mut count),
2761                PamojaStatus::Ok
2762            );
2763            assert_eq!(count, 2);
2764            let mut channel = PamojaLoraRelayChannel {
2765                wor_frequency_hz: 0,
2766                ack_frequency_hz: 0,
2767                data_rate: 0,
2768            };
2769            assert_eq!(
2770                pamoja_lora_plan_relay_channel(plan, 1, &mut channel),
2771                PamojaStatus::Ok
2772            );
2773            assert_eq!(
2774                (
2775                    channel.wor_frequency_hz,
2776                    channel.ack_frequency_hz,
2777                    channel.data_rate
2778                ),
2779                (865_500_000, 865_900_000, 5),
2780                "RP002-1.0.5 table 18"
2781            );
2782            assert_eq!(
2783                pamoja_lora_plan_relay_channel(plan, 2, &mut channel),
2784                PamojaStatus::InvalidArgument
2785            );
2786
2787            pamoja_lora_plan_free(plan);
2788        }
2789    }
2790
2791    #[test]
2792    fn an_unknown_region_code_is_told_from_one_left_out_of_the_build() {
2793        unsafe {
2794            let mut plan = ptr::null_mut();
2795            assert_eq!(
2796                pamoja_lora_plan_for_region(4242, &mut plan),
2797                PamojaStatus::InvalidArgument
2798            );
2799            assert!(plan.is_null());
2800        }
2801
2802        // Every code in the range is a real region, so it is never an invalid
2803        // argument, whatever this build happens to carry.
2804        for region in PAMOJA_LORA_REGION_EU868..=PAMOJA_LORA_REGION_RU864 {
2805            let mut plan = ptr::null_mut();
2806            let status = unsafe { pamoja_lora_plan_for_region(region, &mut plan) };
2807            assert!(
2808                status == PamojaStatus::Ok || status == PamojaStatus::Unsupported,
2809                "region {region} reported {status:?}"
2810            );
2811            assert_eq!(
2812                status == PamojaStatus::Ok,
2813                pamoja_lora_region_is_available(region) == 1
2814            );
2815            unsafe { pamoja_lora_plan_free(plan) };
2816        }
2817    }
2818
2819    #[test]
2820    fn a_private_plan_answers_the_same_questions_a_published_one_does() {
2821        unsafe {
2822            let plan = private_plan();
2823
2824            let name = pamoja_lora_plan_name(plan);
2825            let text = std::ffi::CStr::from_ptr(crate::pamoja_string_data(name));
2826            assert_eq!(text.to_str().expect("utf-8"), "private-915");
2827            crate::pamoja_string_free(name);
2828
2829            let mut info = PamojaLoraPlanInfo {
2830                rx2_frequency_hz: 0,
2831                uplink_data_rate_count: 0,
2832                downlink_data_rate_count: 0,
2833                default_channel_count: 0,
2834                join_channel_block_count: 0,
2835                default_channel_block_count: 0,
2836                sub_band_count: 0,
2837                beacon: PamojaLoraBeacon {
2838                    frequency_hz: 0,
2839                    ping_slot_frequency_hz: 0,
2840                    data_rate: 0,
2841                },
2842                rx2_data_rate: 0,
2843                default_max_eirp_dbm: 0,
2844                tx_power_step_db: 0,
2845                max_tx_power_index: 0,
2846                max_rx1_data_rate_offset: 0,
2847                has_dwell_time_limit: 1,
2848                has_dwell_limited_payloads: 1,
2849                has_dwell_limited_rx1: 1,
2850            };
2851            assert_eq!(pamoja_lora_plan_info(plan, &mut info), PamojaStatus::Ok);
2852            assert_eq!(info.default_channel_count, 4);
2853            assert_eq!(info.uplink_data_rate_count, 2);
2854            // An empty downlink table falls back to the uplink one.
2855            assert_eq!(info.downlink_data_rate_count, 2);
2856            assert_eq!(info.has_dwell_time_limit, 0);
2857            assert_eq!(info.has_dwell_limited_payloads, 0);
2858            assert_eq!(info.has_dwell_limited_rx1, 0);
2859
2860            let mut frequency = 0;
2861            assert_eq!(
2862                pamoja_lora_plan_channel_frequency_hz(plan, 3, &mut frequency),
2863                PamojaStatus::Ok
2864            );
2865            assert_eq!(frequency, 916_500_000);
2866
2867            let mut permille = 0;
2868            assert_eq!(
2869                pamoja_lora_plan_duty_cycle_permille(plan, 915_000_000, &mut permille),
2870                PamojaStatus::Ok
2871            );
2872            assert_eq!(permille, 1000, "licensed spectrum is unrestricted");
2873
2874            let mut payload = PamojaLoraMaxPayload {
2875                mac_payload: 0,
2876                application: 0,
2877            };
2878            assert_eq!(
2879                pamoja_lora_plan_max_payload(
2880                    plan,
2881                    PAMOJA_LORA_PAYLOAD_TABLE_DOWNLINK_DIRECT,
2882                    1,
2883                    &mut payload
2884                ),
2885                PamojaStatus::Ok
2886            );
2887            assert_eq!(
2888                payload.application, 222,
2889                "the downlink table mirrors uplink"
2890            );
2891
2892            let mut dbm = 0;
2893            assert_eq!(
2894                pamoja_lora_plan_max_eirp_dbm(plan, 915_000_000, &mut dbm),
2895                PamojaStatus::Ok
2896            );
2897            assert_eq!(dbm, 30);
2898
2899            let mut lower = 0;
2900            assert_eq!(
2901                pamoja_lora_plan_next_backoff_data_rate(plan, 1, &mut lower),
2902                PamojaStatus::Ok
2903            );
2904            assert_eq!(lower, 0, "an unset chain steps down one rate at a time");
2905            assert_eq!(
2906                pamoja_lora_plan_next_backoff_data_rate(plan, 0, &mut lower),
2907                PamojaStatus::Unsupported,
2908                "the slowest rate has nothing below it"
2909            );
2910
2911            pamoja_lora_plan_free(plan);
2912        }
2913    }
2914
2915    #[test]
2916    fn a_plan_whose_rx1_rows_are_too_narrow_is_refused() {
2917        unsafe {
2918            let name = CString::new("too-narrow").expect("name");
2919            let mut builder = ptr::null_mut();
2920            assert_eq!(
2921                pamoja_lora_plan_builder_new(name.as_ptr(), &mut builder),
2922                PamojaStatus::Ok
2923            );
2924            let rate = PamojaLoraDataRate {
2925                bitrate_bps: 250,
2926                bandwidth_hz: 125_000,
2927                kind: PAMOJA_LORA_MODULATION_LORA,
2928                spreading_factor: 12,
2929                coding_rate_numerator: 0,
2930                coding_rate_denominator: 0,
2931            };
2932            assert_eq!(
2933                pamoja_lora_plan_builder_push_data_rate(
2934                    builder,
2935                    PAMOJA_LORA_DIRECTION_UPLINK,
2936                    &rate
2937                ),
2938                PamojaStatus::Ok
2939            );
2940            // Offsets up to 5 need six entries in every row; this row has one.
2941            assert_eq!(
2942                pamoja_lora_plan_builder_set_rx(builder, 915_000_000, 0, 5),
2943                PamojaStatus::Ok
2944            );
2945            let row = [0u8];
2946            assert_eq!(
2947                pamoja_lora_plan_builder_push_rx1_row(builder, 0, row.as_ptr(), row.len()),
2948                PamojaStatus::Ok
2949            );
2950
2951            let mut plan = ptr::null_mut();
2952            assert_eq!(
2953                pamoja_lora_plan_builder_build(builder, &mut plan),
2954                PamojaStatus::InvalidArgument
2955            );
2956            assert!(plan.is_null());
2957        }
2958    }
2959
2960    #[test]
2961    fn a_plan_that_listens_at_a_data_rate_it_lacks_is_refused() {
2962        unsafe {
2963            let name = CString::new("bad-rx2").expect("name");
2964            let mut builder = ptr::null_mut();
2965            assert_eq!(
2966                pamoja_lora_plan_builder_new(name.as_ptr(), &mut builder),
2967                PamojaStatus::Ok
2968            );
2969            let rate = PamojaLoraDataRate {
2970                bitrate_bps: 250,
2971                bandwidth_hz: 125_000,
2972                kind: PAMOJA_LORA_MODULATION_LORA,
2973                spreading_factor: 12,
2974                coding_rate_numerator: 0,
2975                coding_rate_denominator: 0,
2976            };
2977            assert_eq!(
2978                pamoja_lora_plan_builder_push_data_rate(
2979                    builder,
2980                    PAMOJA_LORA_DIRECTION_UPLINK,
2981                    &rate
2982                ),
2983                PamojaStatus::Ok
2984            );
2985            // The plan defines DR0 alone, so listening at DR3 could never work.
2986            assert_eq!(
2987                pamoja_lora_plan_builder_set_rx(builder, 915_000_000, 3, 0),
2988                PamojaStatus::Ok
2989            );
2990            let row = [0u8];
2991            assert_eq!(
2992                pamoja_lora_plan_builder_push_rx1_row(builder, 0, row.as_ptr(), row.len()),
2993                PamojaStatus::Ok
2994            );
2995
2996            let mut plan = ptr::null_mut();
2997            assert_eq!(
2998                pamoja_lora_plan_builder_build(builder, &mut plan),
2999                PamojaStatus::InvalidArgument
3000            );
3001            assert!(plan.is_null());
3002        }
3003    }
3004
3005    #[test]
3006    #[cfg(all(feature = "us915", feature = "cn470", feature = "eu868"))]
3007    fn the_rules_a_published_plan_follows_cross_the_boundary() {
3008        unsafe {
3009            let empty_rules = PamojaLoraPlanRules {
3010                kind: 9,
3011                channel_list: 9,
3012                tx_param_setup: 9,
3013                join_sequence: 9,
3014                power_reference: 9,
3015                gain_allowance_db: 9,
3016                downlink_channel_block_count: 9,
3017                join_plan_count: 9,
3018            };
3019
3020            let mut plan = ptr::null_mut();
3021            assert_eq!(
3022                pamoja_lora_plan_for_region(PAMOJA_LORA_REGION_US915, &mut plan),
3023                PamojaStatus::Ok
3024            );
3025            let mut rules = empty_rules;
3026            assert_eq!(pamoja_lora_plan_rules(plan, &mut rules), PamojaStatus::Ok);
3027            assert_eq!(rules.kind, PAMOJA_LORA_PLAN_KIND_FIXED);
3028            assert_eq!(rules.join_sequence, PAMOJA_LORA_JOIN_OCTET_PASSES);
3029            assert_eq!(
3030                (rules.power_reference, rules.gain_allowance_db),
3031                (PAMOJA_LORA_POWER_CONDUCTED, 6)
3032            );
3033            assert_eq!(
3034                (rules.downlink_channel_block_count, rules.join_plan_count),
3035                (1, 0)
3036            );
3037
3038            let mut control = mask_control_out(MaskControl::Reserved);
3039            assert_eq!(
3040                pamoja_lora_plan_mask_control(plan, 5, &mut control),
3041                PamojaStatus::Ok
3042            );
3043            assert_eq!(control.kind, PAMOJA_LORA_MASK_PAIRED_BANKS);
3044            assert_eq!(
3045                pamoja_lora_plan_mask_control(plan, 7, &mut control),
3046                PamojaStatus::Ok
3047            );
3048            assert_eq!(
3049                (
3050                    control.kind,
3051                    control.on,
3052                    control.has_then_group,
3053                    control.then_group
3054                ),
3055                (PAMOJA_LORA_MASK_ALL, 0, 1, 4)
3056            );
3057            assert_eq!(
3058                pamoja_lora_plan_mask_control(plan, 8, &mut control),
3059                PamojaStatus::InvalidArgument
3060            );
3061
3062            let mut hz = 0;
3063            assert_eq!(
3064                pamoja_lora_plan_rx1_frequency_hz(plan, 65, 904_600_000, &mut hz),
3065                PamojaStatus::Ok
3066            );
3067            assert_eq!(hz, 923_900_000);
3068            assert_eq!(
3069                pamoja_lora_plan_downlink_channel_frequency_hz(plan, 7, &mut hz),
3070                PamojaStatus::Ok
3071            );
3072            assert_eq!(hz, 927_500_000);
3073            pamoja_lora_plan_free(plan);
3074
3075            let mut plan = ptr::null_mut();
3076            assert_eq!(
3077                pamoja_lora_plan_for_region(PAMOJA_LORA_REGION_EU868, &mut plan),
3078                PamojaStatus::Ok
3079            );
3080            let mut rules = empty_rules;
3081            assert_eq!(pamoja_lora_plan_rules(plan, &mut rules), PamojaStatus::Ok);
3082            assert_eq!(
3083                (rules.kind, rules.channel_list),
3084                (
3085                    PAMOJA_LORA_PLAN_KIND_DYNAMIC,
3086                    PAMOJA_LORA_CHANNEL_LIST_MHZ800
3087                )
3088            );
3089            assert_eq!(
3090                pamoja_lora_plan_rx1_frequency_hz(plan, 2, 868_500_000, &mut hz),
3091                PamojaStatus::Ok
3092            );
3093            assert_eq!(hz, 868_500_000, "a dynamic plan answers where it sent");
3094            pamoja_lora_plan_free(plan);
3095
3096            // RP002-1.0.5 table 49: common join channels 8 and 9 select the 20 MHz
3097            // antenna's plan B, answered on their own frequency.
3098            let mut plan = ptr::null_mut();
3099            assert_eq!(
3100                pamoja_lora_plan_for_cn470(PAMOJA_LORA_CN470_ANTENNA_26MHZ_A, &mut plan),
3101                PamojaStatus::Ok
3102            );
3103            let mut rules = empty_rules;
3104            assert_eq!(pamoja_lora_plan_rules(plan, &mut rules), PamojaStatus::Ok);
3105            assert_eq!(rules.join_plan_count, 5);
3106            let mut run = PamojaLoraJoinPlan {
3107                channels: PamojaLoraChannelBlock {
3108                    start_hz: 0,
3109                    step_hz: 0,
3110                    count: 0,
3111                    min_data_rate: 0,
3112                    max_data_rate: 0,
3113                },
3114                accept_start_hz: 0,
3115                accept_step_hz: 0,
3116                rx2_start_hz: 0,
3117                rx2_step_hz: 0,
3118                cn470_plan: 0,
3119            };
3120            assert_eq!(
3121                pamoja_lora_plan_join_plan(plan, 2, &mut run),
3122                PamojaStatus::Ok
3123            );
3124            assert_eq!(
3125                (
3126                    run.channels.start_hz,
3127                    run.channels.step_hz,
3128                    run.channels.count
3129                ),
3130                (479_900_000, 20_000_000, 2)
3131            );
3132            assert_eq!(
3133                (run.accept_start_hz, run.rx2_start_hz, run.rx2_step_hz),
3134                (479_900_000, 478_300_000, 20_000_000)
3135            );
3136            assert_eq!(run.cn470_plan, PAMOJA_LORA_CN470_ANTENNA_20MHZ_B);
3137            assert_eq!(
3138                pamoja_lora_plan_join_plan(plan, 5, &mut run),
3139                PamojaStatus::InvalidArgument
3140            );
3141            let (mut index, mut offset) = (0, 0);
3142            assert_eq!(
3143                pamoja_lora_plan_join_plan_for_channel(plan, 9, &mut index, &mut offset),
3144                PamojaStatus::Ok
3145            );
3146            assert_eq!((index, offset), (2, 1));
3147            assert_eq!(
3148                pamoja_lora_plan_join_plan_for_channel(plan, 20, &mut index, &mut offset),
3149                PamojaStatus::InvalidArgument
3150            );
3151            pamoja_lora_plan_free(plan);
3152
3153            assert_eq!(
3154                pamoja_lora_plan_for_cn470(PAMOJA_LORA_CN470_NONE, &mut plan),
3155                PamojaStatus::InvalidArgument
3156            );
3157        }
3158    }
3159
3160    #[test]
3161    fn a_built_plan_takes_every_rule_the_builder_is_given() {
3162        unsafe {
3163            let name = CString::new("fixed-private").expect("name");
3164            let mut builder = ptr::null_mut();
3165            assert_eq!(
3166                pamoja_lora_plan_builder_new(name.as_ptr(), &mut builder),
3167                PamojaStatus::Ok
3168            );
3169            let rate = PamojaLoraDataRate {
3170                bitrate_bps: 5_470,
3171                bandwidth_hz: 125_000,
3172                kind: PAMOJA_LORA_MODULATION_LORA,
3173                spreading_factor: 7,
3174                coding_rate_numerator: 0,
3175                coding_rate_denominator: 0,
3176            };
3177            assert_eq!(
3178                pamoja_lora_plan_builder_push_data_rate(
3179                    builder,
3180                    PAMOJA_LORA_DIRECTION_UPLINK,
3181                    &rate
3182                ),
3183                PamojaStatus::Ok
3184            );
3185            assert_eq!(
3186                pamoja_lora_plan_builder_set_rx(builder, 923_300_000, 0, 0),
3187                PamojaStatus::Ok
3188            );
3189            let row = [0u8];
3190            assert_eq!(
3191                pamoja_lora_plan_builder_push_rx1_row(builder, 0, row.as_ptr(), row.len()),
3192                PamojaStatus::Ok
3193            );
3194            for (which, start) in [
3195                (PAMOJA_LORA_CHANNELS_DEFAULT, 902_300_000),
3196                (PAMOJA_LORA_CHANNELS_DOWNLINK, 923_300_000),
3197            ] {
3198                let block = PamojaLoraChannelBlock {
3199                    start_hz: start,
3200                    step_hz: 600_000,
3201                    count: 4,
3202                    min_data_rate: 0,
3203                    max_data_rate: 0,
3204                };
3205                assert_eq!(
3206                    pamoja_lora_plan_builder_push_channel_block(builder, which, &block),
3207                    PamojaStatus::Ok
3208                );
3209            }
3210            assert_eq!(
3211                pamoja_lora_plan_builder_set_kind(builder, PAMOJA_LORA_PLAN_KIND_FIXED, 0),
3212                PamojaStatus::Ok
3213            );
3214            assert_eq!(
3215                pamoja_lora_plan_builder_set_tx_param_setup(builder, 1),
3216                PamojaStatus::Ok
3217            );
3218            let controls = [mask_control_out(MaskControl::All {
3219                on: true,
3220                then_group: Some(1),
3221            }); 8];
3222            assert_eq!(
3223                pamoja_lora_plan_builder_set_mask_controls(builder, controls.as_ptr(), 8),
3224                PamojaStatus::Ok
3225            );
3226            assert_eq!(
3227                pamoja_lora_plan_builder_set_mask_controls(builder, controls.as_ptr(), 7),
3228                PamojaStatus::InvalidArgument
3229            );
3230            assert_eq!(
3231                pamoja_lora_plan_builder_set_join_sequence(builder, PAMOJA_LORA_JOIN_OCTET_PASSES),
3232                PamojaStatus::Ok
3233            );
3234            assert_eq!(
3235                pamoja_lora_plan_builder_set_power_reference(
3236                    builder,
3237                    PAMOJA_LORA_POWER_CONDUCTED,
3238                    3
3239                ),
3240                PamojaStatus::Ok
3241            );
3242            assert_eq!(
3243                pamoja_lora_plan_builder_set_kind(builder, PAMOJA_LORA_PLAN_KIND_DYNAMIC, 9),
3244                PamojaStatus::InvalidArgument
3245            );
3246
3247            let mut plan = ptr::null_mut();
3248            assert_eq!(
3249                pamoja_lora_plan_builder_build(builder, &mut plan),
3250                PamojaStatus::Ok
3251            );
3252            let mut rules = PamojaLoraPlanRules {
3253                kind: 0,
3254                channel_list: 0,
3255                tx_param_setup: 0,
3256                join_sequence: 0,
3257                power_reference: 0,
3258                gain_allowance_db: 0,
3259                downlink_channel_block_count: 0,
3260                join_plan_count: 9,
3261            };
3262            assert_eq!(pamoja_lora_plan_rules(plan, &mut rules), PamojaStatus::Ok);
3263            assert_eq!(
3264                rules,
3265                PamojaLoraPlanRules {
3266                    kind: PAMOJA_LORA_PLAN_KIND_FIXED,
3267                    channel_list: PAMOJA_LORA_CHANNEL_LIST_NONE,
3268                    tx_param_setup: 1,
3269                    join_sequence: PAMOJA_LORA_JOIN_OCTET_PASSES,
3270                    power_reference: PAMOJA_LORA_POWER_CONDUCTED,
3271                    gain_allowance_db: 3,
3272                    downlink_channel_block_count: 1,
3273                    join_plan_count: 0,
3274                }
3275            );
3276            let mut hz = 0;
3277            assert_eq!(
3278                pamoja_lora_plan_rx1_frequency_hz(plan, 5, 905_300_000, &mut hz),
3279                PamojaStatus::Ok
3280            );
3281            assert_eq!(
3282                hz, 923_900_000,
3283                "uplink channel 5 answers on downlink channel 1"
3284            );
3285            let mut control = mask_control_out(MaskControl::Reserved);
3286            assert_eq!(
3287                pamoja_lora_plan_mask_control(plan, 3, &mut control),
3288                PamojaStatus::Ok
3289            );
3290            assert_eq!(
3291                (
3292                    control.kind,
3293                    control.on,
3294                    control.has_then_group,
3295                    control.then_group
3296                ),
3297                (PAMOJA_LORA_MASK_ALL, 1, 1, 1)
3298            );
3299            pamoja_lora_plan_free(plan);
3300        }
3301    }
3302
3303    #[test]
3304    fn a_reserved_data_rate_crosses_and_comes_back_reserved() {
3305        let reserved = PamojaLoraDataRate {
3306            bitrate_bps: 0,
3307            bandwidth_hz: 0,
3308            kind: PAMOJA_LORA_MODULATION_RESERVED,
3309            spreading_factor: 0,
3310            coding_rate_numerator: 0,
3311            coding_rate_denominator: 0,
3312        };
3313        assert_eq!(data_rate_in(&reserved).expect("reserved"), None);
3314        assert_eq!(data_rate_out(None), reserved);
3315    }
3316
3317    #[test]
3318    fn every_modulation_survives_the_round_trip() {
3319        for rate in [
3320            DataRate::lora(9, 125_000, 1_760),
3321            DataRate::fsk(50_000),
3322            DataRate::lr_fhss(1, 3, 137_000, 162),
3323        ] {
3324            let crossed = data_rate_out(Some(rate));
3325            let back = data_rate_in(&crossed).expect("valid").expect("present");
3326            assert_eq!(back, rate);
3327        }
3328    }
3329}