prns-core 0.3.4

Pure Reticulum engine and wire contract for Personal Reticulum
use ::core::time::Duration;
use alloc::vec::Vec;

use crate::units::{DurationMillis, InstantMillis};

use super::protocol::{self, DeviceReport, RadioConfig};
use super::FirmwareVersion;

const VALIDATION_TIMEOUT: Duration = Duration::from_secs(2);

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct DetectTimeout(Duration);

impl DetectTimeout {
    #[must_use]
    pub const fn new(duration: Duration) -> Option<Self> {
        if duration.is_zero() {
            None
        } else {
            Some(Self(duration))
        }
    }

    #[must_use]
    pub const fn duration(self) -> Duration {
        self.0
    }
}

pub const DEFAULT_DETECT_TIMEOUT: DetectTimeout = DetectTimeout(Duration::from_secs(2));
pub const REMOTE_DETECT_TIMEOUT: DetectTimeout = DetectTimeout(Duration::from_secs(5));

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BringUpError {
    DetectTimedOut,
    RadioMismatch,
}

#[derive(Debug, PartialEq, Eq)]
pub enum BringUpAction {
    WriteDetect([u8; 13]),
    WriteRadioConfiguration {
        bytes: Vec<u8>,
        outdated_firmware: Option<FirmwareVersion>,
    },
    ReadUntil(InstantMillis),
    Complete,
    Failed(BringUpError),
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Phase {
    Start,
    AwaitingDetect { deadline: InstantMillis },
    ConfigureRadio,
    AwaitingValidation { deadline: InstantMillis },
    Complete,
    Failed(BringUpError),
}

pub struct BringUp {
    radio: RadioConfig,
    detect_timeout: DetectTimeout,
    report: DeviceReport,
    phase: Phase,
}

impl BringUp {
    #[must_use]
    pub fn new(radio: RadioConfig, detect_timeout: DetectTimeout) -> Self {
        Self {
            radio,
            detect_timeout,
            report: DeviceReport::default(),
            phase: Phase::Start,
        }
    }

    pub fn next_action(&mut self, now: InstantMillis) -> BringUpAction {
        match self.phase {
            Phase::Start => {
                self.phase = Phase::AwaitingDetect {
                    deadline: deadline_after(now, self.detect_timeout.duration()),
                };
                BringUpAction::WriteDetect(protocol::detect_frames())
            }
            Phase::AwaitingDetect { deadline } | Phase::AwaitingValidation { deadline } => {
                BringUpAction::ReadUntil(deadline)
            }
            Phase::ConfigureRadio => {
                self.phase = Phase::AwaitingValidation {
                    deadline: deadline_after(now, VALIDATION_TIMEOUT),
                };
                BringUpAction::WriteRadioConfiguration {
                    bytes: self.radio.init_command_bytes(),
                    outdated_firmware: self.outdated_firmware(),
                }
            }
            Phase::Complete => BringUpAction::Complete,
            Phase::Failed(error) => BringUpAction::Failed(error),
        }
    }

    pub fn apply_command(&mut self, command: u8, payload: &[u8]) {
        match self.phase {
            Phase::AwaitingDetect { .. } => {
                self.report.apply(command, payload);
                if self.report.detected {
                    self.report.clear_radio_parameters();
                    self.phase = Phase::ConfigureRadio;
                }
            }
            Phase::AwaitingValidation { .. } => {
                self.report.apply(command, payload);
                if self.report.all_radio_params_present() {
                    self.conclude_validation();
                }
            }
            _ => {}
        }
    }

    pub fn deadline_elapsed(&mut self, now: InstantMillis) {
        match self.phase {
            Phase::AwaitingDetect { deadline } if now >= deadline => {
                self.phase = Phase::Failed(BringUpError::DetectTimedOut);
            }
            Phase::AwaitingValidation { deadline } if now >= deadline => {
                self.conclude_validation();
            }
            _ => {}
        }
    }

    fn conclude_validation(&mut self) {
        self.phase = if self.report.radio_validated(&self.radio) {
            Phase::Complete
        } else {
            Phase::Failed(BringUpError::RadioMismatch)
        };
    }

    fn outdated_firmware(&self) -> Option<FirmwareVersion> {
        if self.report.firmware_ok() != Some(false) {
            return None;
        }
        self.report.firmware_version()
    }
}

fn deadline_after(now: InstantMillis, duration: Duration) -> InstantMillis {
    now.saturating_add(DurationMillis::from_duration_saturating(duration))
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::interfaces::rnode::protocol::{RadioConfigInput, DETECT_RESP, RADIO_STATE_ON};

    fn radio() -> RadioConfig {
        RadioConfig::new(RadioConfigInput {
            frequency_hz: 868_000_000,
            bandwidth_hz: 125_000,
            tx_power_dbm: 7,
            spreading_factor: 8,
            coding_rate: 5,
            airtime_limit_short_centi_percent: None,
            airtime_limit_long_centi_percent: None,
        })
        .unwrap()
    }

    #[test]
    fn detection_and_validation_are_one_typed_progression() {
        let mut bring_up = BringUp::new(radio(), DEFAULT_DETECT_TIMEOUT);
        assert!(matches!(
            bring_up.next_action(InstantMillis(100)),
            BringUpAction::WriteDetect(_)
        ));
        assert_eq!(
            bring_up.next_action(InstantMillis(100)),
            BringUpAction::ReadUntil(InstantMillis(2_100))
        );
        bring_up.apply_command(protocol::CMD_FW_VERSION, &[1, 51]);
        bring_up.apply_command(protocol::CMD_DETECT, &[DETECT_RESP]);
        assert!(matches!(
            bring_up.next_action(InstantMillis(200)),
            BringUpAction::WriteRadioConfiguration {
                outdated_firmware: Some(FirmwareVersion {
                    major: 1,
                    minor: 51
                }),
                ..
            }
        ));
        bring_up.apply_command(protocol::CMD_FREQUENCY, &868_000_000u32.to_be_bytes());
        bring_up.apply_command(protocol::CMD_BANDWIDTH, &125_000u32.to_be_bytes());
        bring_up.apply_command(protocol::CMD_TXPOWER, &[7]);
        bring_up.apply_command(protocol::CMD_SF, &[8]);
        bring_up.apply_command(protocol::CMD_RADIO_STATE, &[RADIO_STATE_ON]);
        assert_eq!(
            bring_up.next_action(InstantMillis(300)),
            BringUpAction::Complete
        );
    }

    #[test]
    fn deadlines_select_phase_specific_failures() {
        let mut bring_up = BringUp::new(radio(), DEFAULT_DETECT_TIMEOUT);
        let _ = bring_up.next_action(InstantMillis(0));
        bring_up.deadline_elapsed(InstantMillis(1_999));
        assert_eq!(
            bring_up.next_action(InstantMillis(1_999)),
            BringUpAction::ReadUntil(InstantMillis(2_000))
        );
        bring_up.deadline_elapsed(InstantMillis(2_000));
        assert_eq!(
            bring_up.next_action(InstantMillis(2_000)),
            BringUpAction::Failed(BringUpError::DetectTimedOut)
        );
    }

    #[test]
    fn radio_reports_received_before_detection_cannot_satisfy_validation() {
        let mut bring_up = BringUp::new(radio(), DEFAULT_DETECT_TIMEOUT);
        let _ = bring_up.next_action(InstantMillis(0));
        bring_up.apply_command(protocol::CMD_FREQUENCY, &868_000_000u32.to_be_bytes());
        bring_up.apply_command(protocol::CMD_BANDWIDTH, &125_000u32.to_be_bytes());
        bring_up.apply_command(protocol::CMD_TXPOWER, &[7]);
        bring_up.apply_command(protocol::CMD_SF, &[8]);
        bring_up.apply_command(protocol::CMD_RADIO_STATE, &[RADIO_STATE_ON]);
        bring_up.apply_command(protocol::CMD_DETECT, &[DETECT_RESP]);
        assert!(matches!(
            bring_up.next_action(InstantMillis(100)),
            BringUpAction::WriteRadioConfiguration { .. }
        ));
        bring_up.deadline_elapsed(InstantMillis(2_100));
        assert_eq!(
            bring_up.next_action(InstantMillis(2_100)),
            BringUpAction::Failed(BringUpError::RadioMismatch)
        );
    }
}