Skip to main content

Nebulus

Nebulus is the workspace's pure-Rust ground station. It is built with egui and targets macOS, Linux, Windows, Android, and browsers from one application crate. Use it when you want a native Rust UI or a compact reference showing how the driver, protocol, and decoder crates fit together.

Nebulus is separate from OpenIPC Station. Station remains the React/Tauri application. Nebulus provides the lowest-latency video receive path, adaptive link, configurable payload routes, Opus playback, encoded recording, native VPN bridging, diagnostics, and a portable all-Rust UI.

The header always shows the package version. CI builds also embed the current tag and short commit hash from the same OPENIPC_* metadata used by Station.

Architecture

The desktop and Android app keep the blocking USB receive loop on a dedicated worker thread. It initializes the radio, keeps four bulk-IN transfers in flight, parses each Realtek aggregate, advances the receiver state machine, submits complete access units to the decoder, and sends compact state updates to egui. The UI thread never waits on USB or codec work.

The browser follows the same stages on the browser's local async executor. WebUSB and WebCodecs objects cannot cross Rust threads, so Nebulus polls them without a Web Worker. Every asynchronous completion requests an egui repaint; the UI does not busy-loop while idle.

Platform Boundaries

TargetUSB accessVideo decodeAudio output
macOSnusbVideoToolboxCPAL/CoreAudio
LinuxnusbVA-API through cros-codecsCPAL/ALSA
WindowsnusbMedia Foundation and D3D11CPAL/WASAPI
AndroidUsbManager, then nusb::Device::from_fdNDK MediaCodec and ImageReaderCPAL/AAudio
Browsernusb-webusb / WebUSBWebCodecsWeb Audio

Android's JNI bridge only handles discovery, permission, and opening the USB file descriptor. Radio control transfers and streaming transfers are still performed by the Rust driver.

Desktop

From the repository root:

cargo run -p nebulus --bin nebulus-desktop --release

Select a supported adapter, set the radio channel and width to match the VTX, confirm the WFB key, and press Start RX. The default OpenIPC gs.key is embedded. Open file uses the native desktop dialog, browser picker, or Android Storage Access Framework to load another key. The key is never text editable. Dropping a gs.key file on the window remains available where the platform supports file drops. Channel, offset, and Link ID use bounded sliders with individual buttons that restore OpenIPC defaults.

The Linux decoder requires VA-API development packages. See Platform Video Decoding for the package list and render-node override.

Browser

rustup target add wasm32-unknown-unknown
cargo install trunk --locked
cd apps/nebulus
trunk serve --release --open

WebUSB requires localhost or HTTPS. Pressing Start RX directly opens the device picker inside the click handler, preserving the browser's required user gesture. The selected device is initialized into monitor mode by the same Rust HAL used by native targets.

To build static deployment files:

cd apps/nebulus
trunk build --release

Serve the generated dist/ directory over HTTPS. Do not open index.html directly from disk; WebUSB is unavailable from a file: origin.

Run trunk serve without --release to expose the development-only Codec mock button. The same button is available from a debug native build started with cargo run -p nebulus --bin nebulus-desktop. It loops an embedded, pre-recorded 1920x1080 H.264 stream with 48 kHz Opus audio. Rust packetizes and interleaves both tracks as RTP. Video runs through the normal depacketizer and production decode/presentation path; audio runs through the configured mixed-audio route, Opus decoder, volume control, and output queue. WASM uses WebCodecs only for video decoding; the mock does not use an encoder. It requires no USB adapter and is omitted from release builds.

Android

Nebulus uses Android NativeActivity and declares USB-host support through Cargo APK metadata.

rustup target add aarch64-linux-android
cargo install cargo-apk2 --locked
cargo apk2 build -p nebulus --lib --target aarch64-linux-android

On first start, Android displays its USB permission prompt after the user starts the receiver. The app keeps the UsbDeviceConnection alive for the whole receiver session and gives a duplicated descriptor to nusb, avoiding a second Java/Kotlin data path.

The Android entrypoint installs android_logger, so driver and application messages are available in logcat under the Nebulus tag.

The default build uses Android's debug key. A distribution build additionally needs a release keystore configured through [package.metadata.android.signing.release]; do not commit keystore passwords to the repository.

Latency Behavior

Nebulus favors current video over complete playback:

  • Four USB reads remain in flight to avoid endpoint starvation.
  • WFB FEC and optional RTP reorder happen before decode.
  • Decoder work is capped at three access units in flight.
  • Decoded output is a single-slot latest-frame mailbox.
  • Runtime events coalesce pending video to one frame and merge pending batch counters, so a slow UI cannot build a decoded-frame queue.
  • egui presents only the newest output available after a receive batch.
  • The receiver thread calls Context::request_repaint() when new state or a frame is ready.

On macOS, Linux, and Windows, the receiver hands retained native decoder surfaces to the UI through a latest-only event slot. Stale surfaces are dropped before presentation work begins. The UI uploads the newest frame's Y and UV planes into persistent R8Unorm and Rg8Unorm wgpu textures and converts them in a GPU fragment shader. This reduces a 1080p upload from about 8.3 MB of RGBA to 3.1 MB of NV12 and removes per-pixel CPU color conversion.

VideoToolbox exposes mapped NV12 planes on macOS. Linux maps the selected VA-API DMA surface only after coalescing. Windows retains the Media Foundation D3D11 texture through coalescing, reuses one resolution-matched staging texture for readback, and then uploads NV12. The CPU RGBA presenter remains a failure fallback. Stable wgpu does not currently expose portable IOSurface, DMA-BUF, or D3D11 texture import; those imports are the remaining route to a fully zero-copy presentation path.

Android also queues retained decoder outputs rather than converted pixels. Only the newest MediaCodec AImage is mapped. Its Y/U/V planes are packed into reused buffers, uploaded to persistent R8Unorm textures, and converted in a GPU shader. Contiguous planes use row copies; interleaved chroma honors the reported pixel stride. CPU RGBA conversion is only the failure fallback.

Browser builds retain the WebCodecs VideoFrame through the latest-only event queue and use WebGL's native VideoFrame texture upload. There is no copyTo(RGBA), JavaScript pixel array, or decoded-frame copy across the WASM boundary. The persistent texture is updated in place when resolution is unchanged.

Payload Routes And Audio

The Routes tab configures application outputs without changing protocol parsing in openipc-core. A route has a stable numeric ID, a radio port under the current Link ID, and one action:

ActionBehavior
InspectCounts recovered payloads and bytes without parsing them.
LogAdds a rate-limited size, sequence, and hexadecimal preview to Logs.
AudioSelects an RTP payload type, decodes Opus with ropus, and plays PCM.
UDP forwardSends the unchanged recovered payload to a native UDP destination.

UDP is unavailable in browsers and cannot be enabled there. The default routes match Station: telemetry on 0x10, mixed RTP audio on video port 0x00 using payload type 98, and a disabled data route on 0x20. A separate transmitter audio profile can instead be selected with audio port 0x30.

Routes using the same channel and key slot share one PayloadPipeline. Mixed audio therefore shares video's WFB session, decryption, and FEC state; only the matching RTP payload is copied into the audio action. Route topology, ports, actions, and codec settings are locked while receiving and apply on the next start. Output volume remains adjustable during reception and updates every active audio route on the next packet on native, Android, and Web builds.

Diagnostics

The Metrics tab keeps six operational signals over a rolling window: best-path link score, unrecoverable post-FEC loss, the percentage of damaged primary packets repaired by FEC, encoded video bitrate, delivered video FPS, and local receive-through-decode processing latency. Loss and FEC percentages use deltas from each sampling interval rather than lifetime counters. RSSI/SNR remain in the video OSD and audio queue/counter details remain with route diagnostics. Plots disable dragging, zooming, wheel navigation, and double-click reset; their bounds follow the newest retained samples. Diagnostics is divided into four views:

  • Pipeline health follows USB initialization, 802.11 parsing, WFB recovery, RTP arrival, codec configuration, decoding, audio, and VPN state.
  • RTP exposes payload/NAL type, sequence and timestamp, codec parameter-set state, malformed and unsupported packets, fragment gaps, config-wait drops, and reorder-buffer counters.
  • Stage latency keeps rolling last, average, p95, maximum, and sample count values for USB wait, Realtek parsing, WFB/RTP, routes, decoder submission, hardware decode, and the complete receive batch.
  • Environment reports target OS and architecture, runtime, renderer, USB API, media backend, H.264/H.265 availability and acceleration status, native surface support, logical processors, browser user agent where applicable, and the maximum resolution/FPS observed in the current session. Platform decoder APIs do not expose a reliable global maximum on every target, so observed limits are labeled as such.

Diagnostic verbosity is persisted as Low, Normal, or High. Logs remain bounded to avoid memory growth.

Recording

Nebulus records encoded H.264/H.265 Annex-B access units before decode. The recorder arms immediately but does not open/write the stream until the next keyframe, preserving a decodable random-access start without adding an encoder to the receive path.

Desktop builds stream directly to the selected .annexb, .h264, or .h265 file. Browser builds buffer at most 512 MiB and initiate a download when the operator stops recording. The current elementary-stream recorder is video-only; it does not mux the Opus route. This is intentionally different from Station's canvas MediaRecorder output, which re-records rendered media into WebM.

VPN / TUN

On macOS, Linux, Windows, and Android, the VPN tab can create a native layer-three interface at 10.5.0.3/24 when RX starts. Downlink payloads recovered on radio port 0x20 are length-decoded and written to TUN. Uplink IP packets are length-prefixed, passed through WfbTransmitter, and injected by openipc-rtl88xx on radio port 0xa0. The transmitter refreshes its WFB session packet once per second and drains at most 32 queued packets per receive iteration to keep video work bounded.

VPN is unavailable in browser builds because browsers cannot create an OS network interface or send arbitrary UDP/IP packets. Android uses a small VpnService solely for user consent and TUN creation. Its descriptor is duplicated into rust-tun; packet transport, WFB wrapping, and Realtek injection remain in Rust.

Malformed USB aggregates and recoverable bulk-transfer failures are logged and skipped. A stalled endpoint is cleared before reads resume. A disconnect or fatal initialization/decode error moves the app to the failed state instead of leaving the UI stuck in a connecting state.

Validate

cargo test -p nebulus --all-targets
cargo clippy -p nebulus --all-targets --no-deps -- -D warnings
cargo check -p nebulus --target wasm32-unknown-unknown
cargo check -p nebulus --target aarch64-linux-android --lib

Cross-compilation validates target APIs. Actual radio initialization, codec selection, pixel output, and adaptive-link transmission still need a supported adapter and VTX for end-to-end validation.