use crate::{
app::NebulusApp,
settings::{ReceiverSource, RouteAction, MAX_LINK_ID},
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum PreflightSeverity {
Pass,
Warning,
Fail,
}
#[derive(Debug, Clone)]
pub(crate) struct PreflightCheck {
pub(crate) name: &'static str,
pub(crate) detail: String,
pub(crate) severity: PreflightSeverity,
}
#[derive(Debug, Clone, Default)]
pub(crate) struct PreflightReport {
pub(crate) checks: Vec<PreflightCheck>,
}
impl PreflightReport {
pub(crate) fn run(app: &NebulusApp) -> Self {
let mut checks = Vec::new();
let selected = app.settings.selected_device_ids();
let missing = selected
.iter()
.filter(|id| !app.devices.iter().any(|device| &device.id == *id))
.cloned()
.collect::<Vec<_>>();
checks.push(match app.settings.receiver_source {
ReceiverSource::Usb if selected.is_empty() && cfg!(target_arch = "wasm32") => warning(
"Receiver",
"The browser will open its WebUSB device picker when RX starts".to_owned(),
),
ReceiverSource::Usb if selected.is_empty() && app.devices.is_empty() => fail(
"Receiver",
"No supported USB adapter is selected or visible".to_owned(),
),
ReceiverSource::Usb if selected.is_empty() => {
fail("Receiver", "Select a USB adapter".to_owned())
}
ReceiverSource::Usb if missing.is_empty() => pass(
"Receiver",
format!("All {} selected adapter(s) are available", selected.len()),
),
ReceiverSource::Usb => warning(
"Receiver",
format!(
"{} selected adapter(s) are unavailable: {}",
missing.len(),
missing.join(", ")
),
),
ReceiverSource::UdpRtp if cfg!(target_arch = "wasm32") => fail(
"Receiver",
"Browsers cannot bind arbitrary UDP sockets; use the native app or WebUSB"
.to_owned(),
),
ReceiverSource::UdpRtp => {
match app
.settings
.udp_bind_address
.trim()
.parse::<std::net::IpAddr>()
{
Ok(address) if app.settings.udp_bind_port != 0 => pass(
"Receiver",
format!(
"UDP RTP listener will bind {address}:{}",
app.settings.udp_bind_port
),
),
_ => fail(
"Receiver",
"Enter a local IP address and a UDP port from 1 to 65535".to_owned(),
),
}
}
});
if app.settings.receiver_source == ReceiverSource::Usb && selected.len() > 1 {
checks.push(pass(
"Receive diversity",
format!(
"{} radios will use first-valid-copy selection; the primary handles uplink",
selected.len()
),
));
}
checks.push(if app.settings.receiver_source == ReceiverSource::UdpRtp {
pass(
"WFB processing",
"Direct RTP input bypasses 802.11, WFB decryption, and FEC".to_owned(),
)
} else {
match openipc_core::WfbKeypair::from_bytes(&app.settings.key_bytes) {
Ok(_) => pass(
"Ground-station key",
format!("Valid {}-byte WFB key", app.settings.key_bytes.len()),
),
Err(error) => fail("Ground-station key", error.to_string()),
}
});
checks.push(
if app.settings.telemetry.mavlink_signing.requires_key()
&& app.settings.telemetry.mavlink_signing_key.len() != 32
{
fail(
"MAVLink signing",
"The selected verification policy requires a 32-byte signing key".to_owned(),
)
} else if app.settings.telemetry.mavlink_signing.requires_key() {
pass(
"MAVLink signing",
format!(
"{} with a 32-byte key",
app.settings.telemetry.mavlink_signing.label()
),
)
} else {
pass("MAVLink signing", "Verification disabled".to_owned())
},
);
checks.push(if app.settings.receiver_source == ReceiverSource::UdpRtp {
pass(
"Radio configuration",
"Not used by direct UDP RTP input".to_owned(),
)
} else if (1..=177).contains(&app.settings.channel)
&& [5, 10, 20, 40, 80].contains(&app.settings.channel_width_mhz)
&& app.settings.channel_offset <= 4
&& app.settings.link_id <= MAX_LINK_ID
{
pass(
"Radio configuration",
format!(
"Channel {} / {} MHz / offset {} / link 0x{:06x}",
app.settings.channel,
app.settings.channel_width_mhz,
app.settings.channel_offset,
app.settings.link_id
),
)
} else {
fail(
"Radio configuration",
"Channel, width, offset, or link ID is outside the supported range".to_owned(),
)
});
let enabled_routes = app
.settings
.payload_routes
.iter()
.filter(|route| route.enabled)
.collect::<Vec<_>>();
let mut route_errors = Vec::new();
let mut ids = std::collections::BTreeSet::new();
for route in &enabled_routes {
if !ids.insert(route.id) {
route_errors.push(format!("duplicate route id {}", route.id));
}
match route.action {
RouteAction::Udp if cfg!(target_arch = "wasm32") => {
route_errors.push(format!(
"{} uses UDP, which browsers cannot open",
route.name
));
}
RouteAction::Udp if route.udp_host.trim().is_empty() || route.udp_port == 0 => {
route_errors.push(format!("{} has an invalid UDP destination", route.name));
}
RouteAction::Audio
if route.sample_rate == 0 || !matches!(route.channels, 1 | 2) =>
{
route_errors.push(format!("{} has an invalid audio format", route.name));
}
_ => {}
}
}
checks.push(if route_errors.is_empty() {
pass(
"Payload routes",
format!("{} enabled route(s) validated", enabled_routes.len()),
)
} else {
fail("Payload routes", route_errors.join("; "))
});
if app.settings.receiver_source == ReceiverSource::UdpRtp {
let inactive_routes = enabled_routes
.iter()
.filter(|route| route.radio_port != openipc_core::RadioPort::Video.as_u8())
.count();
if inactive_routes > 0 {
checks.push(warning(
"Direct RTP routes",
format!(
"{inactive_routes} enabled route(s) use non-video radio ports and will not receive direct RTP datagrams"
),
));
}
}
checks.push(if app.settings.receiver_source == ReceiverSource::UdpRtp {
pass(
"VPN/TUN",
"Not available without the WFB radio transport".to_owned(),
)
} else if app.settings.vpn_enabled && !app.vpn_available() {
fail(
"VPN/TUN",
"VPN is enabled but this target has no available TUN backend".to_owned(),
)
} else if app.settings.vpn_enabled {
pass(
"VPN/TUN",
"Native tunnel will be created on start".to_owned(),
)
} else {
pass("VPN/TUN", "Disabled".to_owned())
});
checks.push(if app.settings.receiver_source == ReceiverSource::UdpRtp {
pass(
"Adaptive link",
"Not available without a radio uplink".to_owned(),
)
} else if app.settings.adaptive_link {
pass(
"Adaptive link",
format!(
"Feedback uplink enabled at TX power {}",
app.settings.tx_power
),
)
} else {
warning(
"Adaptive link",
"Feedback is disabled; the VTX will not receive live link-quality reports"
.to_owned(),
)
});
checks.push(if app.environment.decoder_backend.is_empty() {
warning(
"Video decoder",
"Backend capabilities are verified while the receiver connects".to_owned(),
)
} else {
pass(
"Video decoder",
format!(
"{}; H.264 {}; H.265 {}",
app.environment.decoder_backend, app.environment.h264, app.environment.h265
),
)
});
if let Some(result) = app
.scan_results
.iter()
.find(|result| result.channel == app.settings.channel)
{
checks.push(if result.wfb_frames > 0 {
pass(
"Channel survey",
format!(
"Channel {} observed {} WFB frame(s) at {}/{} dBm average RSSI",
result.channel,
result.wfb_frames,
result.average_rssi_dbm[0],
result.average_rssi_dbm[1]
),
)
} else {
warning(
"Channel survey",
format!(
"The latest survey saw no recognizable WFB frames on channel {}",
result.channel
),
)
});
}
Self { checks }
}
pub(crate) fn can_start(&self) -> bool {
!self.checks.is_empty()
&& self
.checks
.iter()
.all(|check| check.severity != PreflightSeverity::Fail)
}
pub(crate) fn counts(&self) -> [usize; 3] {
let mut counts = [0; 3];
for check in &self.checks {
counts[match check.severity {
PreflightSeverity::Pass => 0,
PreflightSeverity::Warning => 1,
PreflightSeverity::Fail => 2,
}] += 1;
}
counts
}
}
fn pass(name: &'static str, detail: String) -> PreflightCheck {
PreflightCheck {
name,
detail,
severity: PreflightSeverity::Pass,
}
}
fn warning(name: &'static str, detail: String) -> PreflightCheck {
PreflightCheck {
name,
detail,
severity: PreflightSeverity::Warning,
}
}
fn fail(name: &'static str, detail: String) -> PreflightCheck {
PreflightCheck {
name,
detail,
severity: PreflightSeverity::Fail,
}
}