From 24d53e954bffee18579987cb2d38b6ac8971137a Mon Sep 17 00:00:00 2001 From: Matthias Engelhardt Date: Wed, 9 Sep 2026 08:36:38 +0200 Subject: [PATCH] Add Ground Station Streaming: hand video/telemetry ports to an external app PixelPilot's own WfbngLink::initAgg() already sends decrypted video (RTP/H264) and MAVLink to 127.0.0.1:5600/14550 unconditionally via wfb-ng's AggregatorUDPv4 -- but the app's own in-app video player and MAVLink OSD parser also bind those same ports, so nothing else on the device can. This adds a way to free them up for any other UDP-based ground station app (QGroundControl, Mission Planner, a custom OSD, ...) running on the same device, without touching devourer/wfb-ng at all. Two independent settings-menu toggles under "Ground Station Streaming", "Video" and "Telemetry" (a ground station app might only care about one channel -- e.g. a video-only OSD box, or a telemetry-only companion computer): - Video: stops/starts the in-app UDPReceiver + decoder on port 5600. - Telemetry: stops/starts the in-app MAVLink OSD parser on port 14550. Both apply immediately (no restart) by starting/stopping the relevant in-app consumer right away, and persist via SharedPreferences so they survive a restart. onPause()/onStop() now skip wfbLinkManager.stopAdapters() while either channel is streaming, since the whole point is for wfb-ng to keep forwarding while this Activity is backgrounded (e.g. the ground station app in the foreground instead) -- both channels share one USB/wfb-ng adapter, so it can't be paused for one channel while kept alive for the other. This only covers ordinary Activity lifecycle transitions, not memory-pressure eviction; a Foreground Service would be needed for guaranteed long-running background survival, which is out of scope here. Verified on-device (RTL8812AU, Galaxy Tab S7 FE) with QGroundControl: both video and telemetry arrive correctly with their respective toggle enabled; each toggle independently frees/rebinds only its own port; the USB/wfb-ng pipeline is never restarted by toggling or by backgrounding while streaming is active; background/foreground cycling with only one channel enabled still keeps the pipeline alive. Co-Authored-By: Claude Sonnet 5 --- .../com/openipc/pixelpilot/VideoActivity.java | 134 +++++++++++++++++- 1 file changed, 129 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/openipc/pixelpilot/VideoActivity.java b/app/src/main/java/com/openipc/pixelpilot/VideoActivity.java index a3588f17..0dd07bfb 100644 --- a/app/src/main/java/com/openipc/pixelpilot/VideoActivity.java +++ b/app/src/main/java/com/openipc/pixelpilot/VideoActivity.java @@ -662,6 +662,9 @@ private void showSettingsMenu(View anchor) { // UDP Forwarding submenu setupUdpForwardingSubMenu(popup); + // Ground Station Streaming submenu + setupGroundStationStreamingSubMenu(popup); + // Help submenu setupHelpSubMenu(popup); @@ -1162,12 +1165,112 @@ private void setupHelpSubMenu(PopupMenu popup) { /** * Starts the native Mavlink service and posts an initial Runnable to the Handler. + * + * When telemetry streaming is enabled, wfb-ng's own mavlink_aggregator + * already forwards the raw MAVLink stream to 127.0.0.1:14550 (see + * WfbngLink::initAgg). Skip this app-internal OSD parser so it doesn't + * compete for that port. */ private void setupMavlink() { + if (isTelemetryStreamingEnabled()) { + return; + } MavlinkNative.nativeStart(this); handler.post(runnable); } + // ---------------------------------------------------------------------------- + // GROUND STATION STREAMING + // ---------------------------------------------------------------------------- + // Two independent toggles, since a ground station app might only care about + // one channel (e.g. a video-only OSD box, or a telemetry-only companion + // computer). wfb-ng's own aggregators forward raw RTP/H264 and MAVLink to + // 127.0.0.1:5600/14550 unconditionally (see WfbngLink::initAgg) regardless + // of these settings -- they only decide whether this app's own in-app + // consumers compete for those same ports, so any UDP-based ground station + // app (QGroundControl, Mission Planner, a custom OSD, ...) works, not just + // QGroundControl specifically. + + private static final String PREF_STREAM_VIDEO = "gs_stream_video"; + private static final String PREF_STREAM_TELEMETRY = "gs_stream_telemetry"; + + public boolean isVideoStreamingEnabled() { + return getSharedPreferences("general", MODE_PRIVATE).getBoolean(PREF_STREAM_VIDEO, false); + } + + public boolean isTelemetryStreamingEnabled() { + return getSharedPreferences("general", MODE_PRIVATE).getBoolean(PREF_STREAM_TELEMETRY, false); + } + + /** + * True while either channel is being streamed out -- used to keep the + * USB/wfb-ng pipeline alive across pause/resume instead of stopping it + * (see onPause()/onStop()). Both channels share one adapter, so it can't + * be paused for one and kept alive for the other. + */ + private boolean isGroundStationStreamingActive() { + return isVideoStreamingEnabled() || isTelemetryStreamingEnabled(); + } + + /** + * Persists the new video-streaming mode and applies it immediately + * instead of requiring a restart -- starting or stopping the in-app video + * player right away. + */ + private void setVideoStreamingEnabled(boolean enabled) { + getSharedPreferences("general", MODE_PRIVATE).edit().putBoolean(PREF_STREAM_VIDEO, enabled).apply(); + if (enabled) { + videoPlayer.stop(); + videoPlayer.stopAudio(); + } else { + videoPlayer.start(); + updateUdpForwardingState(); + videoPlayer.startAudio(); + } + } + + /** + * Persists the new telemetry-streaming mode and applies it immediately -- + * starting or stopping the in-app MAVLink OSD parser right away. + */ + private void setTelemetryStreamingEnabled(boolean enabled) { + getSharedPreferences("general", MODE_PRIVATE).edit().putBoolean(PREF_STREAM_TELEMETRY, enabled).apply(); + if (enabled) { + MavlinkNative.nativeStop(this); + handler.removeCallbacks(runnable); + } else { + MavlinkNative.nativeStart(this); + handler.post(runnable); + } + } + + /** + * Settings submenu with the two streaming toggles. + */ + private void setupGroundStationStreamingSubMenu(PopupMenu popup) { + SubMenu gsMenu = popup.getMenu().addSubMenu("Ground Station Streaming"); + + MenuItem videoItem = gsMenu.add("Video"); + videoItem.setCheckable(true); + videoItem.setChecked(isVideoStreamingEnabled()); + videoItem.setOnMenuItemClickListener(item -> { + boolean newState = !item.isChecked(); + item.setChecked(newState); + setVideoStreamingEnabled(newState); + return true; + }); + + MenuItem telemetryItem = gsMenu.add("Telemetry"); + telemetryItem.setCheckable(true); + telemetryItem.setChecked(isTelemetryStreamingEnabled()); + telemetryItem.setOnMenuItemClickListener(item -> { + boolean newState = !item.isChecked(); + item.setChecked(newState); + setTelemetryStreamingEnabled(newState); + return true; + }); + } + // ---------------------------------------------------------------------------- // BATTERY RECEIVER // ---------------------------------------------------------------------------- @@ -1545,7 +1648,18 @@ protected void onPause() { videoPlayer.stop(); videoPlayer.stopAudio(); - wfbLinkManager.stopAdapters(); + // While streaming to QGroundControl the whole point is for wfb-ng to keep + // decoding and forwarding to 127.0.0.1:5600/14550 while this Activity is + // backgrounded. Stopping/restarting the USB adapter on every pause/resume + // also hits a native teardown race in devourer's RtlJaguarDevice + // destructor (rtw_hal_deinit on a device that hasn't finished bringing up + // yet) when the two happen in quick succession. A real Foreground Service + // (Phase 4) is still needed for guaranteed survival under memory pressure + // -- this only keeps the pipeline alive across ordinary Activity + // lifecycle transitions. + if (!isGroundStationStreamingActive()) { + wfbLinkManager.stopAdapters(); + } // Stop VPN service Log.w(TAG, "onPause: stopping service"); @@ -1559,7 +1673,11 @@ protected void onStop() { MavlinkNative.nativeStop(this); handler.removeCallbacks(runnable); unregisterReceivers(); - wfbLinkManager.stopAdapters(); + // See the comment in onPause() -- streaming mode keeps the USB/wfb-ng + // pipeline running across ordinary lifecycle transitions. + if (!isGroundStationStreamingActive()) { + wfbLinkManager.stopAdapters(); + } videoPlayer.stop(); videoPlayer.stopAudio(); super.onStop(); @@ -1576,9 +1694,15 @@ protected void onResume() { wfbLinkManager.refreshAdapters(); wfbLinkManager.startAdapters(); - videoPlayer.start(); - updateUdpForwardingState(); - videoPlayer.startAudio(); + // While video streaming is enabled, skip the in-app video/audio + // receiver: wfb-ng's own video_aggregator already forwards RTP/H264 to + // 127.0.0.1:5600 (see WfbngLink::initAgg), and the ground station app + // binds that port instead. + if (!isVideoStreamingEnabled()) { + videoPlayer.start(); + updateUdpForwardingState(); + videoPlayer.startAudio(); + } SharedPreferences prefs = getSharedPreferences("general", MODE_PRIVATE); boolean odEnabled = prefs.getBoolean("od_enabled", false);