prns-core 0.3.4

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

use crate::interfaces::kiss_framing;
use crate::interfaces::PacketPhyStats;
use crate::units::{DurationMillis, InstantMillis};

use super::protocol::{self, PacketPhyState, RadioConfig};

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

impl KeepaliveInterval {
    #[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
    }
}

#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum Keepalive {
    #[default]
    Disabled,
    Detect(KeepaliveInterval),
}

pub const TCP_KEEPALIVE: Keepalive =
    Keepalive::Detect(KeepaliveInterval(Duration::from_millis(3_500)));

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct KeepaliveSchedule {
    interval: Option<KeepaliveInterval>,
    deadline: Option<InstantMillis>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct KeepaliveTransmission([u8; 4]);

impl KeepaliveTransmission {
    #[must_use]
    pub const fn wire_bytes(&self) -> &[u8; 4] {
        &self.0
    }
}

impl KeepaliveSchedule {
    #[must_use]
    pub fn new(keepalive: Keepalive, now: InstantMillis) -> Self {
        let interval = match keepalive {
            Keepalive::Disabled => None,
            Keepalive::Detect(interval) => Some(interval),
        };
        Self {
            interval,
            deadline: interval.map(|interval| deadline_after(now, interval.duration())),
        }
    }

    #[must_use]
    pub const fn deadline(self) -> Option<InstantMillis> {
        self.deadline
    }

    #[must_use]
    pub fn due(self, now: InstantMillis) -> Option<KeepaliveTransmission> {
        self.deadline
            .is_some_and(|deadline| now >= deadline)
            .then(|| KeepaliveTransmission(protocol::detect_request_frame()))
    }

    pub fn wrote(&mut self, now: InstantMillis) {
        self.deadline = self
            .interval
            .map(|interval| deadline_after(now, interval.duration()));
    }
}

#[derive(Debug, PartialEq, Eq)]
pub enum LiveCommand<'a> {
    Data {
        payload: &'a [u8],
        phy: PacketPhyStats,
    },
    Ready,
    Consumed,
}

#[derive(Debug, Default)]
pub struct LiveProtocol {
    packet_phy: PacketPhyState,
}

impl LiveProtocol {
    pub fn apply<'a>(
        &mut self,
        command: u8,
        payload: &'a [u8],
        radio: &RadioConfig,
    ) -> LiveCommand<'a> {
        match command {
            protocol::CMD_DATA if payload.is_empty() => LiveCommand::Consumed,
            protocol::CMD_DATA => LiveCommand::Data {
                payload,
                phy: self.packet_phy.take_for_data(),
            },
            kiss_framing::CMD_READY => LiveCommand::Ready,
            _ => {
                self.packet_phy.apply(command, payload, radio);
                LiveCommand::Consumed
            }
        }
    }
}

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, CMD_STAT_RSSI};
    use crate::interfaces::RssiDbm;

    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 telemetry_is_attached_to_exactly_the_next_data_frame() {
        let mut protocol = LiveProtocol::default();
        assert_eq!(
            protocol.apply(CMD_STAT_RSSI, &[100], &radio()),
            LiveCommand::Consumed
        );
        assert_eq!(
            protocol.apply(protocol::CMD_DATA, b"first", &radio()),
            LiveCommand::Data {
                payload: b"first",
                phy: PacketPhyStats {
                    rssi: Some(RssiDbm::new(-57)),
                    ..PacketPhyStats::default()
                }
            }
        );
        assert_eq!(
            protocol.apply(protocol::CMD_DATA, b"second", &radio()),
            LiveCommand::Data {
                payload: b"second",
                phy: PacketPhyStats::default()
            }
        );
    }

    #[test]
    fn keepalive_deadlines_rearm_only_after_a_write() {
        let mut schedule = KeepaliveSchedule::new(TCP_KEEPALIVE, InstantMillis(100));
        assert_eq!(schedule.deadline(), Some(InstantMillis(3_600)));
        assert_eq!(schedule.due(InstantMillis(3_599)), None);
        assert_eq!(
            schedule
                .due(InstantMillis(3_600))
                .map(|transmission| *transmission.wire_bytes()),
            Some([0xc0, protocol::CMD_DETECT, protocol::DETECT_REQ, 0xc0])
        );
        schedule.wrote(InstantMillis(4_000));
        assert_eq!(schedule.deadline(), Some(InstantMillis(7_500)));
    }
}