nebulus 0.1.29

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

use crate::{
    app::NebulusApp,
    model::ReceiverState,
    settings::RouteAction,
    telemetry::{
        MavlinkSigningPolicy, MspDirectionFilter, MspVersionFilter, TelemetryProtocol,
        CRSF_ANY_ADDRESS,
    },
};

pub(crate) fn show(app: &mut NebulusApp, ui: &mut egui::Ui) {
    let editable = matches!(app.state, ReceiverState::Idle | ReceiverState::Failed);
    status(app, ui);
    ui.add_space(8.0);

    section(ui, "Sources", |ui| {
        let routes = app
            .settings
            .payload_routes
            .iter()
            .filter(|route| route.enabled && route.action == RouteAction::Telemetry)
            .collect::<Vec<_>>();
        if routes.is_empty() {
            ui.colored_label(ui.visuals().warn_fg_color, "No telemetry route enabled");
        } else {
            egui::Grid::new("telemetry-routes")
                .num_columns(3)
                .striped(true)
                .spacing([16.0, 6.0])
                .show(ui, |ui| {
                    ui.strong("Route");
                    ui.strong("Radio port");
                    ui.strong("Format");
                    ui.end_row();
                    for route in routes {
                        ui.label(&route.name);
                        ui.monospace(format!("0x{:02X}", route.radio_port));
                        ui.label(route.telemetry_protocol.label());
                        ui.end_row();
                    }
                });
        }
        if ui.button("Edit routes").clicked() {
            app.active_tab = super::PanelTab::Routes;
        }
    });

    section(ui, "General", |ui| {
        ui.horizontal(|ui| {
            ui.label("Stale after");
            ui.add(
                egui::Slider::new(&mut app.settings.telemetry.stale_timeout_ms, 500..=30_000)
                    .suffix(" ms")
                    .logarithmic(true),
            );
        });
        if ui.small_button("Clear live telemetry").clicked() {
            app.telemetry.reset();
        }
    });

    section(ui, "MAVLink", |ui| mavlink_settings(app, ui, editable));
    section(ui, "MSP", |ui| msp_settings(app, ui, editable));
    section(ui, "CRSF", |ui| crsf_settings(app, ui, editable));
}

fn status(app: &NebulusApp, ui: &mut egui::Ui) {
    ui.horizontal_wrapped(|ui| {
        ui.heading("Telemetry");
        ui.separator();
        ui.label(
            app.telemetry
                .protocol
                .map_or("Waiting", TelemetryProtocol::label),
        );
        ui.separator();
        ui.monospace(format!("{} decoded", app.telemetry.messages));
        if let Some(age) = app.telemetry.age_seconds() {
            ui.separator();
            ui.monospace(format!("{age:.1}s ago"));
        }
    });

    let counters = &app.telemetry.counters;
    egui::Grid::new("telemetry-status")
        .num_columns(2)
        .striped(true)
        .spacing([18.0, 6.0])
        .show(ui, |ui| {
            row(
                ui,
                "Stream",
                app.telemetry.frame_age_seconds().map_or_else(
                    || "No frames".to_owned(),
                    |age| format!("last frame {age:.1}s ago"),
                ),
            );
            row(ui, "Accepted frames", counters.accepted_frames.to_string());
            row(ui, "Rejected frames", counters.rejected_frames.to_string());
            row(ui, "Filtered frames", counters.filtered_frames.to_string());
        });
}

fn mavlink_settings(app: &mut NebulusApp, ui: &mut egui::Ui, editable: bool) {
    ui.add_enabled_ui(editable, |ui| {
        egui::Grid::new("mavlink-settings")
            .num_columns(2)
            .spacing([18.0, 7.0])
            .show(ui, |ui| {
                ui.label("Signing policy");
                egui::ComboBox::from_id_salt("mavlink-signing-policy")
                    .selected_text(app.settings.telemetry.mavlink_signing.label())
                    .show_ui(ui, |ui| {
                        for policy in MavlinkSigningPolicy::ALL {
                            ui.selectable_value(
                                &mut app.settings.telemetry.mavlink_signing,
                                policy,
                                policy.label(),
                            );
                        }
                    });
                ui.end_row();

                id_filter(
                    ui,
                    "System ID",
                    &mut app.settings.telemetry.mavlink_system_id,
                );
                id_filter(
                    ui,
                    "Component ID",
                    &mut app.settings.telemetry.mavlink_component_id,
                );
            });

        ui.horizontal(|ui| {
            if ui.button("Open signing key").clicked() {
                app.open_mavlink_key_file(ui.ctx());
            }
            if ui
                .add_enabled(
                    !app.settings.telemetry.mavlink_signing_key.is_empty(),
                    egui::Button::new("Remove key"),
                )
                .clicked()
            {
                app.clear_mavlink_key();
            }
        });
    });

    ui.horizontal_wrapped(|ui| {
        ui.label(&app.mavlink_key_name);
        ui.label(
            egui::RichText::new(format!(
                "{} bytes",
                app.settings.telemetry.mavlink_signing_key.len()
            ))
            .small()
            .color(ui.visuals().weak_text_color()),
        );
    });
    if let Some(error) = &app.mavlink_key_error {
        ui.colored_label(ui.visuals().error_fg_color, error);
    } else if app.settings.telemetry.mavlink_signing.requires_key()
        && app.settings.telemetry.mavlink_signing_key.len() != 32
    {
        ui.colored_label(
            ui.visuals().warn_fg_color,
            "A 32-byte signing key is required by this policy",
        );
    }

    let counters = &app.telemetry.counters;
    egui::Grid::new("mavlink-status")
        .num_columns(2)
        .striped(true)
        .spacing([18.0, 6.0])
        .show(ui, |ui| {
            row(ui, "Dialect", "Common".to_owned());
            row(
                ui,
                "Source",
                match (
                    app.telemetry.mavlink_system_id,
                    app.telemetry.mavlink_component_id,
                ) {
                    (Some(system), Some(component)) => {
                        format!("system {system} / component {component}")
                    }
                    _ => "Not detected".to_owned(),
                },
            );
            row(
                ui,
                "Version",
                app.telemetry.mavlink_version.map_or_else(
                    || "Not detected".to_owned(),
                    |version| format!("MAVLink {version}"),
                ),
            );
            row(
                ui,
                "Signing link",
                app.telemetry
                    .mavlink_signing_link_id
                    .map_or_else(|| "None".to_owned(), |link| link.to_string()),
            );
            row(
                ui,
                "Last frame",
                app.telemetry.mavlink_last_signed.map_or_else(
                    || "Not detected".to_owned(),
                    |signed| if signed { "Signed" } else { "Unsigned" }.to_owned(),
                ),
            );
            row(ui, "Signed", counters.mavlink_signed_frames.to_string());
            row(ui, "Unsigned", counters.mavlink_unsigned_frames.to_string());
            row(ui, "Verified", counters.mavlink_verified_frames.to_string());
            row(
                ui,
                "Invalid signatures",
                counters.mavlink_invalid_signatures.to_string(),
            );
            row(ui, "Replays", counters.mavlink_replay_drops.to_string());
            row(
                ui,
                "Stale timestamps",
                counters.mavlink_stale_timestamp_drops.to_string(),
            );
            row(
                ui,
                "Missing key",
                counters.mavlink_missing_key_drops.to_string(),
            );
        });
}

fn msp_settings(app: &mut NebulusApp, ui: &mut egui::Ui, editable: bool) {
    ui.add_enabled_ui(editable, |ui| {
        egui::Grid::new("msp-settings")
            .num_columns(2)
            .spacing([18.0, 7.0])
            .show(ui, |ui| {
                ui.label("Version");
                egui::ComboBox::from_id_salt("msp-version")
                    .selected_text(app.settings.telemetry.msp_version.label())
                    .show_ui(ui, |ui| {
                        for version in MspVersionFilter::ALL {
                            ui.selectable_value(
                                &mut app.settings.telemetry.msp_version,
                                version,
                                version.label(),
                            );
                        }
                    });
                ui.end_row();
                ui.label("Direction");
                egui::ComboBox::from_id_salt("msp-direction")
                    .selected_text(app.settings.telemetry.msp_direction.label())
                    .show_ui(ui, |ui| {
                        for direction in MspDirectionFilter::ALL {
                            ui.selectable_value(
                                &mut app.settings.telemetry.msp_direction,
                                direction,
                                direction.label(),
                            );
                        }
                    });
                ui.end_row();
            });
    });
}

fn crsf_settings(app: &mut NebulusApp, ui: &mut egui::Ui, editable: bool) {
    ui.add_enabled_ui(editable, |ui| {
        ui.horizontal(|ui| {
            ui.label("Device address");
            egui::ComboBox::from_id_salt("crsf-address")
                .selected_text(crsf_address_label(app.settings.telemetry.crsf_address))
                .show_ui(ui, |ui| {
                    for (address, label) in [
                        (CRSF_ANY_ADDRESS, "Any address"),
                        (0x00, "Broadcast (0x00)"),
                        (0x14, "Video receiver (0x14)"),
                        (0xc8, "Flight controller (0xC8)"),
                        (0xec, "RC receiver (0xEC)"),
                        (0xee, "TX module (0xEE)"),
                    ] {
                        ui.selectable_value(
                            &mut app.settings.telemetry.crsf_address,
                            address,
                            label,
                        );
                    }
                });
            if app.settings.telemetry.crsf_address != CRSF_ANY_ADDRESS {
                ui.add(
                    egui::DragValue::new(&mut app.settings.telemetry.crsf_address)
                        .range(0..=255)
                        .hexadecimal(2, false, true),
                );
            }
        });
    });
}

fn id_filter(ui: &mut egui::Ui, label: &str, value: &mut u8) {
    ui.label(label);
    ui.horizontal(|ui| {
        let mut enabled = *value != 0;
        if ui.checkbox(&mut enabled, "Filter").changed() {
            *value = if enabled { 1 } else { 0 };
        }
        if enabled {
            ui.add(egui::DragValue::new(value).range(1..=255));
        } else {
            ui.add_enabled(false, egui::Label::new("Any"));
        }
    });
    ui.end_row();
}

fn crsf_address_label(address: u16) -> String {
    if address == CRSF_ANY_ADDRESS {
        "Any address".to_owned()
    } else {
        format!("0x{address:02X}")
    }
}

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

fn section(ui: &mut egui::Ui, title: &str, add: impl FnOnce(&mut egui::Ui)) {
    egui::CollapsingHeader::new(title)
        .default_open(true)
        .show(ui, |ui| {
            ui.add_space(3.0);
            add(ui);
            ui.add_space(6.0);
        });
}