mod country;
mod elrs;
mod research;
mod table;
pub use country::country_plan;
pub use elrs::elrs_domains;
use serde::Serialize;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum PowerRef {
Erp,
Eirp,
Conducted,
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
pub struct PowerRule {
pub limit_mw: f64,
pub reference: PowerRef,
pub psd_dbm_per_3khz: Option<f64>,
pub antenna_max_dbi: Option<f64>,
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
pub struct DutyRule {
pub limit_pct: f64,
pub window_s: u32,
}
impl DutyRule {
pub fn budget_us(&self) -> u64 {
(self.limit_pct / 100.0 * f64::from(self.window_s) * 1_000_000.0).round() as u64
}
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
pub struct DwellRule {
pub limit_ms: Option<u32>,
pub per_s: Option<u32>,
pub single_tx_max_ms: u32,
pub pause_ms: u32,
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
pub struct LbtRule {
pub threshold_dbm: i16,
pub listen_us: u32,
pub gap_max_us: u32,
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
pub struct Grid {
pub start_mhz: f64,
pub step_mhz: f64,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SubBand {
pub lo_mhz: f64,
pub hi_mhz: f64,
pub duty: Option<DutyRule>,
pub power: Option<PowerRule>,
}
impl SubBand {
pub fn label(&self) -> String {
format!("{}-{}", fmt_mhz(self.lo_mhz), fmt_mhz(self.hi_mhz))
}
pub fn fits(&self, mhz: f64, bw_khz: u32) -> bool {
let half = f64::from(bw_khz) / 2000.0;
mhz - half >= self.lo_mhz - 1e-9 && mhz + half <= self.hi_mhz + 1e-9
}
}
fn fmt_mhz(v: f64) -> String {
let s = format!("{v:.3}");
let s = s.trim_end_matches('0');
if s.ends_with('.') {
format!("{s}0")
} else {
s.to_string()
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct Plan {
pub code: &'static str,
pub name: &'static str,
pub plan_id: &'static str,
pub band_mhz: (f64, f64),
pub citation: &'static str,
pub sub_bands: &'static [SubBand],
pub dwell: Option<DwellRule>,
pub lbt: Option<LbtRule>,
pub grid: Option<Grid>,
pub hop: bool,
pub hop_channels_min: u32,
pub default_mhz: Option<f64>,
pub bw_max_khz: Option<u32>,
pub apc: bool,
pub meshtastic: Option<&'static str>,
pub tx: bool,
}
pub const RULE_DUTY: &str = "duty_cycle";
pub const RULE_DWELL: &str = "dwell_time";
pub const RULE_LBT: &str = "lbt";
pub const RULE_POWER: &str = "max_power";
impl Plan {
pub fn rules(&self) -> Vec<&'static str> {
if !self.tx {
return Vec::new();
}
let mut out = Vec::new();
if self.sub_bands.iter().any(|s| s.power.is_some()) {
out.push(RULE_POWER);
}
if self.dwell.is_some() {
out.push(RULE_DWELL);
}
if self.sub_bands.iter().any(|s| s.duty.is_some()) {
out.push(RULE_DUTY);
}
if self.lbt.is_some() {
out.push(RULE_LBT);
}
out
}
pub fn sub_band_for(&self, mhz: f64, bw_khz: u32) -> Option<&'static SubBand> {
self.sub_bands.iter().find(|s| s.fits(mhz, bw_khz))
}
pub fn on_grid(&self, mhz: f64) -> bool {
match self.grid {
None => true,
Some(g) => {
let n = (mhz - g.start_mhz) / g.step_mhz;
n >= -1e-6 && (n - n.round()).abs() < 1e-6
}
}
}
pub fn bandwidth_ok(&self, bw_khz: u32) -> bool {
self.bw_max_khz.is_none_or(|max| bw_khz <= max)
}
}
pub fn all() -> impl Iterator<Item = &'static Plan> {
table::PLANS.iter().chain(research::RESEARCH.iter())
}
pub fn by_code(code: &str) -> Option<&'static Plan> {
let code = code.trim().to_ascii_uppercase();
all().find(|p| p.code == code)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub enum RegionSource {
#[serde(rename = "lora.region")]
LoraRegion,
#[serde(rename = "network.regulatory.region")]
NetworkRegulatory,
}
impl RegionSource {
pub fn as_str(self) -> &'static str {
match self {
RegionSource::LoraRegion => "lora.region",
RegionSource::NetworkRegulatory => "network.regulatory.region",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Resolved {
pub plan: &'static Plan,
pub source: RegionSource,
}
pub fn lookup(code: &str) -> Option<&'static Plan> {
by_code(code).or_else(|| country_plan(code).and_then(by_code))
}
pub fn resolve(lora_region: Option<&str>, country: Option<&str>) -> Option<Resolved> {
fn pick(v: Option<&str>) -> Option<&str> {
v.map(str::trim).filter(|s| !s.is_empty())
}
if let Some(code) = pick(lora_region) {
return lookup(code).map(|plan| Resolved {
plan,
source: RegionSource::LoraRegion,
});
}
pick(country).and_then(lookup).map(|plan| Resolved {
plan,
source: RegionSource::NetworkRegulatory,
})
}
#[cfg(test)]
mod tests;