prns-config 0.3.4

Config discovery, parsing, and reconciliation for the Personal Reticulum daemon
Documentation
use prns_core::interfaces::rnode::multi::{RadioConfig, RadioConfigError, RadioConfigInput, VPort};
use prns_core::interfaces::{AnnounceRateLimit, InterfaceCommonPolicy, InterfaceGravity};

use crate::reference::keys::interface as interface_key;
use crate::reference::{RNodeSubinterface, ReferenceConfigParams, ReferenceInterface};

use super::interface::{
    airtime_limit, effective_policy, plan_access, plan_interface_discovery,
    ready_command_flow_control, station_identification, ConfiguredInterfaceLifecycle,
    MemberEgressPolicy, PlanErrorKind, PlannedInterface, PlannedMedium, ReadyCommandFlowControl,
    StationIdentificationPlan,
};

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RNodeMultiDevicePlan {
    name: String,
    device: String,
    station_id: Option<StationIdentificationPlan>,
}

impl RNodeMultiDevicePlan {
    pub fn name(&self) -> &str {
        &self.name
    }

    pub fn device(&self) -> &str {
        &self.device
    }

    pub fn station_id(&self) -> Option<&StationIdentificationPlan> {
        self.station_id.as_ref()
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RNodeMultiMemberPlan {
    parent: RNodeMultiDevicePlan,
    vport: VPort,
    radio: RadioConfig,
    flow_control: ReadyCommandFlowControl,
}

impl RNodeMultiMemberPlan {
    pub fn parent(&self) -> &RNodeMultiDevicePlan {
        &self.parent
    }

    pub const fn vport(&self) -> VPort {
        self.vport
    }

    pub const fn radio(&self) -> RadioConfig {
        self.radio
    }

    pub const fn flow_control(&self) -> ReadyCommandFlowControl {
        self.flow_control
    }
}

pub(super) struct PlanFailure {
    pub(super) subinterface_name: Option<String>,
    pub(super) kind: PlanErrorKind,
}

impl PlanFailure {
    fn parent(kind: PlanErrorKind) -> Self {
        Self {
            subinterface_name: None,
            kind,
        }
    }

    fn member(name: &str, kind: PlanErrorKind) -> Self {
        Self {
            subinterface_name: Some(name.to_string()),
            kind,
        }
    }
}

pub(super) fn plan(
    interface: &ReferenceInterface,
    global_common: InterfaceCommonPolicy,
    global_announce_rate: Option<AnnounceRateLimit>,
    default_gravity: InterfaceGravity,
    transport_enabled: bool,
) -> Result<Vec<PlannedInterface>, PlanFailure> {
    let ReferenceConfigParams::RnodeMulti {
        port,
        id_callsign,
        id_interval,
        subinterfaces,
    } = &interface.params
    else {
        return Err(PlanFailure::parent(PlanErrorKind::UnsupportedKind));
    };
    let device = port.clone().ok_or_else(|| {
        PlanFailure::parent(PlanErrorKind::MissingRequiredField {
            key: interface_key::PORT,
        })
    })?;
    let station_id = station_identification(id_callsign.as_deref(), *id_interval, Some(32))
        .map_err(PlanFailure::parent)?;
    let parent = RNodeMultiDevicePlan {
        name: interface.name.clone(),
        device,
        station_id,
    };
    subinterfaces
        .iter()
        .map(|subinterface| {
            plan_member(
                interface,
                subinterface,
                parent.clone(),
                global_common,
                global_announce_rate,
                default_gravity,
                transport_enabled,
            )
            .map_err(|kind| PlanFailure::member(&subinterface.name, kind))
        })
        .collect()
}

fn plan_member(
    interface: &ReferenceInterface,
    subinterface: &RNodeSubinterface,
    parent: RNodeMultiDevicePlan,
    global_common: InterfaceCommonPolicy,
    global_announce_rate: Option<AnnounceRateLimit>,
    default_gravity: InterfaceGravity,
    transport_enabled: bool,
) -> Result<PlannedInterface, PlanErrorKind> {
    let member = RNodeMultiMemberPlan {
        parent,
        vport: VPort::new(required(subinterface.vport, interface_key::VPORT)?).ok_or(
            PlanErrorKind::InvalidSetting {
                key: interface_key::VPORT,
            },
        )?,
        radio: radio_config(subinterface)?,
        flow_control: ready_command_flow_control(subinterface.flow_control),
    };
    let medium = PlannedMedium::RnodeMulti { member };
    let discovery = plan_interface_discovery(interface, &medium);
    let policy = effective_policy(
        interface,
        &medium,
        &discovery,
        super::interface::InheritedInterfacePolicy {
            common: global_common,
            announce_rate: global_announce_rate,
            gravity: default_gravity,
        },
        transport_enabled,
        MemberEgressPolicy::from_outgoing(subinterface.outgoing),
    )?;
    Ok(PlannedInterface {
        name: format!("{}[{}]", interface.name, subinterface.name),
        policy,
        access: plan_access(interface, &medium)?,
        medium,
        discovery,
        lifecycle: if interface.bootstrap_only == Some(true) {
            ConfiguredInterfaceLifecycle::BootstrapOnly
        } else {
            ConfiguredInterfaceLifecycle::Persistent
        },
    })
}

fn radio_config(subinterface: &RNodeSubinterface) -> Result<RadioConfig, PlanErrorKind> {
    let short = airtime_limit(
        subinterface.airtime_limit_short,
        interface_key::AIRTIME_LIMIT_SHORT,
    )?;
    let long = airtime_limit(
        subinterface.airtime_limit_long,
        interface_key::AIRTIME_LIMIT_LONG,
    )?;
    RadioConfig::new(RadioConfigInput {
        frequency_hz: required(subinterface.radio.frequency, interface_key::FREQUENCY)?,
        bandwidth_hz: required(subinterface.radio.bandwidth, interface_key::BANDWIDTH)?,
        tx_power_dbm: required(subinterface.radio.txpower, interface_key::TXPOWER)?,
        spreading_factor: required(
            subinterface.radio.spreadingfactor,
            interface_key::SPREADINGFACTOR,
        )?,
        coding_rate: required(subinterface.radio.codingrate, interface_key::CODINGRATE)?,
        airtime_limit_short_centi_percent: short.map(|limit| limit.get()),
        airtime_limit_long_centi_percent: long.map(|limit| limit.get()),
    })
    .map_err(radio_config_error)
}

fn required<T>(value: Option<T>, key: &'static str) -> Result<T, PlanErrorKind> {
    value.ok_or(PlanErrorKind::MissingRequiredField { key })
}

fn radio_config_error(error: RadioConfigError) -> PlanErrorKind {
    let key = match error {
        RadioConfigError::Frequency(_) => interface_key::FREQUENCY,
        RadioConfigError::Bandwidth(_) => interface_key::BANDWIDTH,
        RadioConfigError::TxPower(_) => interface_key::TXPOWER,
        RadioConfigError::SpreadingFactor(_) => interface_key::SPREADINGFACTOR,
        RadioConfigError::CodingRate(_) => interface_key::CODINGRATE,
        RadioConfigError::ShortAirtimeLimit(_) => interface_key::AIRTIME_LIMIT_SHORT,
        RadioConfigError::LongAirtimeLimit(_) => interface_key::AIRTIME_LIMIT_LONG,
    };
    PlanErrorKind::InvalidSetting { key }
}

#[cfg(test)]
mod tests {
    use prns_core::interfaces::IfacSize;
    use prns_core::interfaces::{
        AnnounceBandwidthCap, AnnounceRateLimit, EgressCapability, InterfaceMode,
        TransportCapability,
    };

    use crate::plan::{
        DaemonPlan, DiscoveryAdvertisementPlan, InterfaceAccessPlan, InterfaceDiscoveryPlan,
        PlannedInterface, PlannedMedium, ReadyCommandFlowControl, StationIdentificationPlan,
    };

    use super::RNodeMultiMemberPlan;

    fn plan_of(config: &str) -> DaemonPlan {
        crate::parse_and_plan(config).expect("config plans").value
    }

    fn named<'a>(plan: &'a DaemonPlan, name: &str) -> &'a PlannedInterface {
        plan.interfaces
            .iter()
            .find(|interface| interface.name == name)
            .unwrap_or_else(|| panic!("interface '{name}' was planned"))
    }

    fn member(interface: &PlannedInterface) -> &RNodeMultiMemberPlan {
        let PlannedMedium::RnodeMulti { member } = &interface.medium else {
            panic!("RNodeMulti member expected")
        };
        member
    }

    #[test]
    fn members_inherit_one_typed_parent_policy_and_access_plan() {
        let plan = plan_of(
            "[interfaces]\n[[Dual]]\ntype = RNodeMultiInterface\nenabled = Yes\nport = /dev/ttyACM0\n\
             interface_mode = internal\nannounce_cap = 3.5\nannounce_rate_target = 120\n\
             network_name = field\npassphrase = secret\nifac_size = 64\nrecursive_prs = Yes\n\
             announces_from_internal = No\nannounces_to_internal = Yes\ningress_control = No\negress_control = Yes\n\
             id_callsign = N0CALL\nid_interval = 600\n\
             [[[Low]]]\ninterface_enabled = Yes\nvport = 0\nfrequency = 868000000\n\
             bandwidth = 125000\ntxpower = -4\nspreadingfactor = 8\ncodingrate = 5\n\
             flow_control = Yes\noutgoing = No\nairtime_limit_short = 1.5\n\
             [[[High]]]\ninterface_enabled = Yes\nvport = 1\nfrequency = 2400000000\n\
             bandwidth = 812500\ntxpower = 10\nspreadingfactor = 7\ncodingrate = 6\n\
             outgoing = Yes\n",
        );
        assert_eq!(plan.interfaces.len(), 2);
        let low = named(&plan, "Dual[Low]");
        let high = named(&plan, "Dual[High]");
        let low_member = member(low);
        let high_member = member(high);

        assert_eq!(low_member.parent(), high_member.parent());
        assert_eq!(low_member.parent().name(), "Dual");
        assert_eq!(low_member.parent().device(), "/dev/ttyACM0");
        assert_eq!(
            low_member
                .parent()
                .station_id()
                .map(StationIdentificationPlan::callsign),
            Some("N0CALL")
        );
        assert_eq!(low_member.vport().get(), 0);
        assert_eq!(high_member.vport().get(), 1);
        assert_eq!(low_member.flow_control(), ReadyCommandFlowControl::Enabled);
        assert_eq!(
            high_member.flow_control(),
            ReadyCommandFlowControl::Disabled
        );
        assert_eq!(
            low_member.radio().airtime_limit_short_centi_percent(),
            Some(150)
        );

        assert_eq!(low.access, high.access);
        assert!(matches!(
            low.access,
            InterfaceAccessPlan::Ifac {
                size: IfacSize::NARROW,
                ..
            }
        ));
        assert_eq!(low.policy.mode, InterfaceMode::Internal);
        assert_eq!(high.policy.mode, InterfaceMode::Internal);
        assert_eq!(low.policy.common, high.policy.common);
        assert_eq!(
            low.policy.common.forwarding.recursive_path_requests,
            prns_core::interfaces::RecursivePathRequestPolicy::Enabled
        );
        assert!(!low.policy.common.forwarding.announces_from_internal);
        assert!(low.policy.common.forwarding.announces_to_internal);
        assert!(!low.policy.common.ingress_control.enabled);
        assert!(low.policy.common.path_request_egress.enabled);
        assert_eq!(low.policy.bitrate.get(), 3_125);
        assert_eq!(high.policy.bitrate.get(), 29_622);
        assert_eq!(low.policy.mtu.resolve(low.policy.bitrate), Some(508));
        assert_eq!(high.policy.mtu.resolve(high.policy.bitrate), Some(508));
        assert_eq!(low.policy.capabilities.egress, EgressCapability::Disabled);
        assert_eq!(
            high.policy.capabilities.egress,
            EgressCapability::Enabled(TransportCapability::SameInterfaceRepeat)
        );
        assert_eq!(
            low.policy.announce_bandwidth_cap,
            AnnounceBandwidthCap::Limited { cap_per_mille: 35 }
        );
        assert_eq!(
            low.policy.announce_rate_limit,
            Some(AnnounceRateLimit {
                target_ms: 120_000,
                grace: 0,
                penalty_ms: 0,
            })
        );
        assert_eq!(
            low.policy.announce_rate_limit,
            high.policy.announce_rate_limit
        );
    }

    #[test]
    fn parent_egress_bitrate_and_discovery_apply_to_every_member() {
        let plan = plan_of(
            "[interfaces]\n[[Dual]]\ntype = RNodeMultiInterface\nenabled = Yes\nport = /dev/ttyACM0\n\
             outgoing = No\nbitrate = 500000\ndiscoverable = Yes\n\
             [[[Low]]]\ninterface_enabled = Yes\nvport = 0\nfrequency = 868000000\n\
             bandwidth = 125000\ntxpower = 7\nspreadingfactor = 8\ncodingrate = 5\n\
             outgoing = Yes\n\
             [[[High]]]\ninterface_enabled = Yes\nvport = 1\nfrequency = 2400000000\n\
             bandwidth = 812500\ntxpower = 10\nspreadingfactor = 7\ncodingrate = 6\n",
        );
        let low = named(&plan, "Dual[Low]");
        let high = named(&plan, "Dual[High]");
        for member in [low, high] {
            assert_eq!(member.policy.bitrate.get(), 500_000);
            assert_eq!(member.policy.mtu.resolve(member.policy.bitrate), Some(508));
            assert_eq!(
                member.policy.capabilities.egress,
                EgressCapability::Disabled
            );
            assert_eq!(member.policy.mode, InterfaceMode::AccessPoint);
        }
        let InterfaceDiscoveryPlan::Announce(low_discovery) = &low.discovery else {
            panic!("low radio discovery plan expected")
        };
        let InterfaceDiscoveryPlan::Announce(high_discovery) = &high.discovery else {
            panic!("high radio discovery plan expected")
        };
        assert_eq!(
            low_discovery.advertisement,
            DiscoveryAdvertisementPlan::RNode {
                frequency_hz: 868_000_000,
                bandwidth_hz: 125_000,
                spreading_factor: 8,
                coding_rate: 5,
            }
        );
        assert_eq!(
            high_discovery.advertisement,
            DiscoveryAdvertisementPlan::RNode {
                frequency_hz: 2_400_000_000,
                bandwidth_hz: 812_500,
                spreading_factor: 7,
                coding_rate: 6,
            }
        );
    }

    #[test]
    fn discoverable_members_preserve_explicit_internal_mode() {
        let plan = plan_of(
            "[interfaces]\n[[Dual]]\ntype = RNodeMultiInterface\nenabled = Yes\nport = /dev/ttyACM0\n\
             mode = internal\ndiscoverable = Yes\n\
             [[[Radio]]]\ninterface_enabled = Yes\nvport = 0\nfrequency = 868000000\n\
             bandwidth = 125000\ntxpower = 7\nspreadingfactor = 8\ncodingrate = 5\n",
        );

        assert_eq!(
            named(&plan, "Dual[Radio]").policy.mode,
            InterfaceMode::Internal,
        );
    }
}