nebulus 0.1.25

Low-latency native OpenIPC FPV ground station built with egui
use eframe::egui;

use crate::{app::NebulusApp, model::ReceiverState};

#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
enum View {
    #[default]
    Health,
    Rtp,
    Latency,
    Environment,
}

pub(crate) fn show(app: &NebulusApp, ui: &mut egui::Ui) {
    let id = ui.make_persistent_id("diagnostics-view");
    let mut view = ui.data(|data| data.get_temp::<View>(id).unwrap_or_default());
    ui.horizontal_wrapped(|ui| {
        for (candidate, label) in [
            (View::Health, "Pipeline health"),
            (View::Rtp, "RTP"),
            (View::Latency, "Stage latency"),
            (View::Environment, "Environment"),
        ] {
            if ui.selectable_label(view == candidate, label).clicked() {
                view = candidate;
            }
        }
    });
    ui.data_mut(|data| data.insert_temp(id, view));
    ui.separator();
    match view {
        View::Health => health(app, ui),
        View::Rtp => rtp(app, ui),
        View::Latency => latency(app, ui),
        View::Environment => environment(app, ui),
    }
}

fn health(app: &NebulusApp, ui: &mut egui::Ui) {
    let counters = app.diagnostics.counters;
    health_row(ui, "USB adapter initialized", app.chip.is_some());
    health_row(ui, "USB transfers arriving", app.metrics.usb_transfers > 0);
    health_row(ui, "802.11 packets parsed", app.metrics.wifi_packets > 0);
    health_row(ui, "Frames accepted", counters.accepted_packets > 0);
    health_row(ui, "WFB payload recovered", counters.wfb_payloads > 0);
    health_row(ui, "RTP packets arriving", app.metrics.rtp_packets > 0);
    health_row(ui, "Codec configuration ready", codec_config_ready(app));
    health_row(
        ui,
        "Encoded frames extracted",
        app.metrics.encoded_frames > 0,
    );
    health_row(
        ui,
        "Platform decoder active",
        app.metrics.decoded_frames > 0,
    );
    health_row(
        ui,
        "Audio route healthy",
        !app.audio.enabled || (app.audio.supported && app.audio.errors == 0),
    );
    health_row(
        ui,
        "VPN bridge healthy",
        !app.settings.vpn_enabled || (app.vpn.active && app.vpn.errors == 0),
    );

    ui.add_space(10.0);
    ui.heading("Packet filtering");
    value_grid(ui, "health-counters", |ui| {
        row(ui, "Accepted", counters.accepted_packets);
        row(ui, "Dropped", counters.dropped_packets);
        row(ui, "CRC drops", counters.crc_dropped);
        row(ui, "ICV drops", counters.icv_dropped);
        row(ui, "Reports dropped", counters.report_dropped);
        row(ui, "Ignored frames", counters.ignored_frames);
        row(ui, "WFB sessions", counters.sessions);
        row(ui, "Route errors", counters.route_errors);
    });
}

fn rtp(app: &NebulusApp, ui: &mut egui::Ui) {
    let status = app.diagnostics.rtp;
    let reorder = app.diagnostics.reorder;
    value_grid(ui, "rtp-diagnostics", |ui| {
        text_row(ui, "Codec", &format!("{:?}", status.last_codec));
        text_row(
            ui,
            "H.264 config",
            &format!(
                "SPS {} / PPS {}",
                yes_no(status.codec_config.h264_sps),
                yes_no(status.codec_config.h264_pps),
            ),
        );
        text_row(
            ui,
            "H.265 config",
            &format!(
                "VPS {} / SPS {} / PPS {}",
                yes_no(status.codec_config.h265_vps),
                yes_no(status.codec_config.h265_sps),
                yes_no(status.codec_config.h265_pps),
            ),
        );
        option_row(ui, "Payload type", status.last_payload_type);
        option_row(ui, "NAL type", status.last_nal_type);
        option_row(ui, "Sequence", status.last_sequence_number);
        option_row(ui, "RTP timestamp", status.last_timestamp);
        row(ui, "Packets", status.packets);
        row(ui, "Frames emitted", status.frames_emitted);
        row(ui, "Config wait drops", status.config_wait_drops);
        row(
            ui,
            "Keyframes with config",
            status.keyframes_with_prepended_config,
        );
        row(
            ui,
            "Parameter sets prepended",
            status.parameter_sets_prepended,
        );
        row(ui, "Fragment gaps", status.fragment_sequence_gaps);
        row(ui, "Fragment overflows", status.fragment_overflows);
        row(ui, "Malformed", status.malformed_packets);
        row(ui, "Unsupported payloads", status.unsupported_payloads);
        row(ui, "Reorder buffered", reorder.buffered_packets);
        row(ui, "Reordered", reorder.reordered_packets);
        row(ui, "Late packets", reorder.late_packets);
        row(ui, "Forced flushes", reorder.forced_flushes);
    });
}

fn latency(app: &NebulusApp, ui: &mut egui::Ui) {
    egui::Grid::new("latency-stages")
        .num_columns(6)
        .striped(true)
        .spacing([12.0, 7.0])
        .show(ui, |ui| {
            for heading in ["Stage", "Last", "Average", "P95", "Maximum", "Samples"] {
                ui.strong(heading);
            }
            ui.end_row();
            for (name, values) in &app.diagnostics.stages {
                let summary = values.summary();
                ui.label(*name);
                ui.monospace(format!("{:.2} ms", summary.last));
                ui.monospace(format!("{:.2} ms", summary.average));
                ui.monospace(format!("{:.2} ms", summary.p95));
                ui.monospace(format!("{:.2} ms", summary.maximum));
                ui.monospace(summary.samples.to_string());
                ui.end_row();
            }
        });
}

fn environment(app: &NebulusApp, ui: &mut egui::Ui) {
    let environment = &app.environment;
    let maximum_fps = if environment.maximum_observed_fps > 0.0 {
        format!("{:.1} FPS observed", environment.maximum_observed_fps)
    } else {
        "Not reported; waiting for stream".to_owned()
    };
    value_grid(ui, "environment-details", |ui| {
        text_row(ui, "Platform", &environment.platform);
        text_row(ui, "Architecture", &environment.architecture);
        text_row(ui, "Runtime", &environment.runtime);
        text_row(ui, "Renderer", &environment.renderer);
        text_row(ui, "Logical processors", &environment.logical_processors);
        text_row(ui, "User agent", &environment.user_agent);
        text_row(ui, "Media backend", &environment.decoder_backend);
        text_row(ui, "H.264", &environment.h264);
        text_row(ui, "H.265", &environment.h265);
        text_row(
            ui,
            "Native/GPU surfaces",
            if environment.native_surfaces {
                "Yes"
            } else {
                "No"
            },
        );
        text_row(
            ui,
            "Maximum resolution",
            &environment
                .maximum_observed_resolution
                .map(|[width, height]| format!("{width} x {height} observed"))
                .unwrap_or_else(|| "Not reported; waiting for stream".to_owned()),
        );
        text_row(ui, "Maximum frame rate", &maximum_fps);
        text_row(
            ui,
            "Receiver state",
            match app.state {
                ReceiverState::Idle => "Idle",
                ReceiverState::Connecting => "Connecting",
                ReceiverState::Ready => "Ready",
                ReceiverState::Receiving => "Receiving",
                ReceiverState::Stopping => "Stopping",
                ReceiverState::Failed => "Failed",
            },
        );
        text_row(
            ui,
            "USB API",
            if cfg!(target_arch = "wasm32") {
                "nusb WebUSB"
            } else if cfg!(target_os = "android") {
                "Android UsbManager + nusb fd"
            } else {
                "nusb native"
            },
        );
        text_row(
            ui,
            "VPN/TUN",
            if cfg!(target_arch = "wasm32") {
                "Unavailable in browser"
            } else {
                "Native TUN supported"
            },
        );
    });
    ui.add_space(8.0);
    ui.label(
        egui::RichText::new(
            "Platform decoders generally do not expose a reliable global maximum resolution or frame rate. Nebulus reports the highest stream values observed in this session.",
        )
        .small()
        .color(ui.visuals().weak_text_color()),
    );
}

fn codec_config_ready(app: &NebulusApp) -> bool {
    app.diagnostics
        .rtp
        .last_codec
        .is_some_and(|codec| app.diagnostics.rtp.codec_config.is_complete_for(codec))
}

fn health_row(ui: &mut egui::Ui, label: &str, healthy: bool) {
    ui.horizontal(|ui| {
        let (rect, response) = ui.allocate_exact_size(egui::vec2(14.0, 14.0), egui::Sense::hover());
        let color = if healthy {
            egui::Color32::from_rgb(61, 214, 154)
        } else {
            ui.visuals().weak_text_color()
        };
        if healthy {
            ui.painter().circle_filled(rect.center(), 4.0, color);
        } else {
            ui.painter()
                .circle_stroke(rect.center(), 4.5, egui::Stroke::new(1.5, color));
        }
        response.on_hover_text(if healthy { "Healthy" } else { "Waiting" });
        ui.label(label);
    });
}

fn value_grid(ui: &mut egui::Ui, id: &str, add: impl FnOnce(&mut egui::Ui)) {
    let max_column_width = ((ui.available_width() - 20.0) * 0.5).max(80.0);
    egui::Grid::new(id)
        .num_columns(2)
        .striped(true)
        .max_col_width(max_column_width)
        .spacing([20.0, 7.0])
        .show(ui, add);
}

fn yes_no(value: bool) -> &'static str {
    if value {
        "yes"
    } else {
        "no"
    }
}

fn row(ui: &mut egui::Ui, label: &str, value: impl std::fmt::Display) {
    text_row(ui, label, &value.to_string());
}

fn option_row(ui: &mut egui::Ui, label: &str, value: Option<impl std::fmt::Display>) {
    text_row(
        ui,
        label,
        &value.map_or_else(|| "--".to_owned(), |value| value.to_string()),
    );
}

fn text_row(ui: &mut egui::Ui, label: &str, value: &str) {
    ui.label(egui::RichText::new(label).color(ui.visuals().weak_text_color()));
    ui.monospace(value);
    ui.end_row();
}