use core::time::Duration;
pub const STATS_FRESH_WINDOW: Duration = Duration::from_secs(3);
pub const RF_PROOF_GRACE: Duration = Duration::from_secs(5);
pub const LQ_DEGRADED_BELOW: u8 = 50;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LaneState {
Disabled,
Unconfigured,
Ready,
LinkOk,
Degraded,
RfUnverified,
}
impl LaneState {
pub fn as_str(self) -> &'static str {
match self {
LaneState::Disabled => "disabled",
LaneState::Unconfigured => "unconfigured",
LaneState::Ready => "ready",
LaneState::LinkOk => "link_ok",
LaneState::Degraded => "degraded",
LaneState::RfUnverified => "rf_unverified",
}
}
pub fn rf_unverified_flag(self) -> Option<bool> {
match self {
LaneState::LinkOk | LaneState::Degraded => Some(false),
LaneState::RfUnverified => Some(true),
LaneState::Disabled | LaneState::Unconfigured | LaneState::Ready => None,
}
}
pub fn flyable(self) -> bool {
matches!(self, LaneState::LinkOk | LaneState::Degraded)
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct LinkInputs {
pub enabled: bool,
pub device_open: bool,
pub tx_running_for: Option<Duration>,
pub stats_age: Option<Duration>,
pub uplink_lq: Option<u8>,
}
pub fn derive_state(i: &LinkInputs) -> LaneState {
if !i.enabled {
return LaneState::Disabled;
}
if !i.device_open {
return LaneState::Unconfigured;
}
if let Some(age) = i.stats_age {
if age <= STATS_FRESH_WINDOW {
return match i.uplink_lq {
Some(lq) if lq >= LQ_DEGRADED_BELOW => LaneState::LinkOk,
Some(lq) if lq > 0 => LaneState::Degraded,
_ => no_proof_state(i.tx_running_for),
};
}
}
no_proof_state(i.tx_running_for)
}
fn no_proof_state(tx_running_for: Option<Duration>) -> LaneState {
match tx_running_for {
Some(running) if running > RF_PROOF_GRACE => LaneState::RfUnverified,
_ => LaneState::Ready,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn secs(s: u64) -> Duration {
Duration::from_secs(s)
}
#[test]
fn disabled_wins_over_everything() {
let i = LinkInputs {
enabled: false,
device_open: true,
tx_running_for: Some(secs(60)),
stats_age: Some(secs(0)),
uplink_lq: Some(100),
};
assert_eq!(derive_state(&i), LaneState::Disabled);
assert_eq!(LaneState::Disabled.rf_unverified_flag(), None);
}
#[test]
fn no_device_reads_unconfigured() {
let i = LinkInputs {
enabled: true,
device_open: false,
..Default::default()
};
assert_eq!(derive_state(&i), LaneState::Unconfigured);
assert_eq!(LaneState::Unconfigured.rf_unverified_flag(), None);
}
#[test]
fn open_but_not_transmitting_is_ready_with_no_verdict() {
let i = LinkInputs {
enabled: true,
device_open: true,
tx_running_for: None,
stats_age: None,
uplink_lq: None,
};
assert_eq!(derive_state(&i), LaneState::Ready);
assert_eq!(LaneState::Ready.rf_unverified_flag(), None);
}
#[test]
fn transmitting_inside_grace_stays_ready() {
let i = LinkInputs {
enabled: true,
device_open: true,
tx_running_for: Some(secs(3)),
stats_age: None,
uplink_lq: None,
};
assert_eq!(derive_state(&i), LaneState::Ready);
}
#[test]
fn transmitting_past_grace_with_no_stats_is_rf_unverified() {
let i = LinkInputs {
enabled: true,
device_open: true,
tx_running_for: Some(secs(6)),
stats_age: None,
uplink_lq: None,
};
assert_eq!(derive_state(&i), LaneState::RfUnverified);
assert_eq!(LaneState::RfUnverified.rf_unverified_flag(), Some(true));
}
#[test]
fn fresh_stats_with_good_lq_is_link_ok() {
let i = LinkInputs {
enabled: true,
device_open: true,
tx_running_for: Some(secs(60)),
stats_age: Some(secs(1)),
uplink_lq: Some(99),
};
assert_eq!(derive_state(&i), LaneState::LinkOk);
assert_eq!(LaneState::LinkOk.rf_unverified_flag(), Some(false));
}
#[test]
fn fresh_stats_with_poor_lq_is_degraded() {
let i = LinkInputs {
enabled: true,
device_open: true,
tx_running_for: Some(secs(60)),
stats_age: Some(secs(1)),
uplink_lq: Some(LQ_DEGRADED_BELOW - 1),
};
assert_eq!(derive_state(&i), LaneState::Degraded);
assert_eq!(LaneState::Degraded.rf_unverified_flag(), Some(false));
}
#[test]
fn stale_stats_fall_back_to_the_no_proof_ladder() {
let i = LinkInputs {
enabled: true,
device_open: true,
tx_running_for: Some(secs(60)),
stats_age: Some(secs(10)),
uplink_lq: Some(100),
};
assert_eq!(derive_state(&i), LaneState::RfUnverified);
}
#[test]
fn tx_advancing_with_zero_uplink_lq_is_rf_unverified() {
let i = LinkInputs {
enabled: true,
device_open: true,
tx_running_for: Some(secs(60)),
stats_age: Some(secs(1)),
uplink_lq: Some(0),
};
assert_eq!(derive_state(&i), LaneState::RfUnverified);
assert_eq!(LaneState::RfUnverified.rf_unverified_flag(), Some(true));
assert!(!LaneState::RfUnverified.flyable());
let missing = LinkInputs {
uplink_lq: None,
..i
};
assert_eq!(derive_state(&missing), LaneState::RfUnverified);
}
#[test]
fn zero_lq_inside_the_grace_window_is_still_no_verdict() {
let i = LinkInputs {
enabled: true,
device_open: true,
tx_running_for: Some(secs(3)),
stats_age: Some(secs(1)),
uplink_lq: Some(0),
};
assert_eq!(derive_state(&i), LaneState::Ready);
}
#[test]
fn recovery_from_rf_unverified_needs_a_real_lq() {
let unheard = LinkInputs {
enabled: true,
device_open: true,
tx_running_for: Some(secs(60)),
stats_age: Some(secs(1)),
uplink_lq: Some(0),
};
assert_eq!(derive_state(&unheard), LaneState::RfUnverified);
let heard = LinkInputs {
uplink_lq: Some(97),
..unheard
};
assert_eq!(derive_state(&heard), LaneState::LinkOk);
assert!(LaneState::LinkOk.flyable());
}
#[test]
fn only_received_side_proven_states_are_flyable() {
assert!(LaneState::LinkOk.flyable());
assert!(LaneState::Degraded.flyable());
for state in [
LaneState::Disabled,
LaneState::Unconfigured,
LaneState::Ready,
LaneState::RfUnverified,
] {
assert!(!state.flyable(), "{state:?} must never read flyable");
}
}
#[test]
fn state_strings_match_the_sidecar_vocabulary() {
assert_eq!(LaneState::Disabled.as_str(), "disabled");
assert_eq!(LaneState::Unconfigured.as_str(), "unconfigured");
assert_eq!(LaneState::Ready.as_str(), "ready");
assert_eq!(LaneState::LinkOk.as_str(), "link_ok");
assert_eq!(LaneState::Degraded.as_str(), "degraded");
assert_eq!(LaneState::RfUnverified.as_str(), "rf_unverified");
}
}