Skip to content

feat(uas): MAVLink connection + 3-backend video receiver#45

Open
jfuginay wants to merge 9 commits into
mainfrom
feat/uas-mavlink-and-video
Open

feat(uas): MAVLink connection + 3-backend video receiver#45
jfuginay wants to merge 9 commits into
mainfrom
feat/uas-mavlink-and-video

Conversation

@jfuginay

@jfuginay jfuginay commented May 28, 2026

Copy link
Copy Markdown
Contributor

Tracks OmniTAK-iOS#40. First chunk of iOS UAS parity with Android sibling (engindearing-projects/OmniTAK-Android#56, #62).

What ships

Receiver-side parity with Android's UAS Video PIP. iOS now has the full UAS connection + telemetry foundation plus a live video pipe through libVLC.

  • VideoSource sealed enum (.none / .rtsp / .rawH264Udp / .mpegTsUdp), mirroring the Kotlin sealed class with vlcURL mapping each active source to a libVLC-compatible URL
  • DroneState struct + MAVAutopilot/MAVType enums, value type so SwiftUI never sees torn reads
  • MAVLinkCodec — hand-rolled MAVLink 2 parser, 8 message types we need today (HEARTBEAT, GLOBAL_POSITION_INT, ATTITUDE, GPS_RAW_INT, SYS_STATUS, BATTERY_STATUS, COMMAND_LONG, COMMAND_ACK), CRC-16/MCRF4XX + per-message CRC_EXTRA. ~370 lines pure Swift, no runtime dependencies
  • MAVLinkConnection — NWConnection UDP, 1 Hz GCS heartbeat, rolling RX buffer + frame extraction
  • UASManager@MainActor ObservableObject, publishes DroneState, exposes arm/disarm/takeoff/RTL via COMMAND_LONG
  • UASVideoPipView — backed by MobileVLCKit (libVLC's url-scheme dispatch handles all three transports: RTSP demuxer, raw H264 via --demux=h264, MPEG-TS native)
  • UASConnectView — SwiftUI form: host/port/callsign + segmented Video Source picker with dynamic per-mode field (RTSP URL, raw UDP port 5000, MPEG-TS UDP port 5010)
  • Toolbar — new "Vehicles" BarItem (tool.uas, airplane.departure icon, orange tint) routed through ToolSheetHost to UASConnectView
  • VLCKitSPM 3.6.0 (tylerjonesio wrapper) added as SPM dependency — VideoLAN itself doesn't publish an official SPM package (their issue #302 still open); this wrapper ships the official XCFramework with Package.swift on top and keeps the MobileVLCKit module name so existing call sites stay drop-in. LGPL-2.1+, App Store compatible with attribution

Why hand-roll MAVLink

Surveyed the Swift MAVLink package landscape: zero actively-maintained, MAVLink-2-capable SPM packages.

  • mavlink/MAVSDK-Swift — heavy gRPC wrapper around MAVSDK C++, last commit 2023-10. Wrong shape for an in-process codec.
  • modnovolyk/MAVLinkSwift — only pure-Swift SPM codec that ever shipped, Swift 3 / MAVLink v1 only, last commit 2018-03.
  • ArduPilot/pymavlink's Swift generator — alive (2025 commits) but produces vendored code, not a published package.

Given the survey result and the small message subset we need today, hand-rolling kept the dependency footprint at zero and the codec at ~370 lines of readable Swift.

What's NOT in this PR

Reason Phase
Map integration (drone MKAnnotation, HUD card) Touches MapViewController coordinator wiring; cleaner as its own PR PR-B
Gating the UAS Video PIP on drone-connected state on the map Today the picker writes to UASManager.videoSource but the map doesn't render the PIP yet PR-B
arm/disarm/takeoff/RTL buttons UASManager has the commands; UI needs to land alongside the map HUD PR-B
CoT broadcast (telemetry → UASCoTGeneratorTAKService.sendCoT) Generator + broadcaster pair to convert MAVLink position to CoT XML PR-B
iOS+Android side-by-side composite per the parity rule Needs end-to-end PIP rendering on the map, gated on PR-B After PR-B
MARKETING_VERSION / CURRENT_PROJECT_VERSION bump Happens at PR-B release prep, semver + YYMMDDNN PR-B

Build

  • xcodebuild -project OmniTAKMobile.xcodeproj -scheme OmniTAKMobile -sdk iphonesimulator -destination 'generic/platform=iOS Simulator' -configuration Debug buildBUILD SUCCEEDED with VLCKit linked
  • Deployment target stays at iOS 15 (used NavigationView + 1-arg onChange(of:_:))

To validate locally

  1. Open in Xcode (will resolve VLCKitSPM 3.6.0 from SPM on first open if not cached)
  2. Run on Simulator
  3. Bottom toolbar → "Vehicles" → fill in host (127.0.0.1 for SITL on same Mac), port 14550, callsign
  4. Pick a Video source + port
  5. Stand up endpoints:
    • MAVLink: python3 fake_drone.py (from /tmp/omnitak-verify/)
    • Raw H264: ffmpeg -re -f lavfi -i testsrc -c:v libx264 -profile:v baseline -bsf:v h264_mp4toannexb -an -f h264 'udp://127.0.0.1:5000?pkt_size=1024'
    • MPEG-TS: ... -f mpegts 'udp://127.0.0.1:5010?pkt_size=1316'

Once PR-B lands, UAS PIP will appear bottom-right on the map gated on state.isConnected().

🤖 Generated with Claude Code

jfuginay and others added 2 commits May 27, 2026 17:12
First chunk of iOS UAS parity with the Android sibling (OmniTAK-Android
#56, #62). Closes the receiver-side gap surfaced by Foxbat in the
TAK Discord RubyFPV thread.

What ships:

- VideoSource sealed enum (none / rtsp / rawH264Udp / mpegTsUdp),
  mirroring the Kotlin sealed class
- DroneState struct + MAVAutopilot/MAVType enums
- MAVLinkCodec — hand-rolled MAVLink 2 frame parser with the 8
  messages we need (HEARTBEAT, GLOBAL_POSITION_INT, ATTITUDE,
  GPS_RAW_INT, SYS_STATUS, BATTERY_STATUS, COMMAND_LONG, COMMAND_ACK).
  CRC-16/MCRF4XX + per-message CRC_EXTRA byte. Pure Swift, no
  dependencies; the survey turned up no actively-maintained Swift
  MAVLink SPM package (MAVSDK-Swift is a heavy gRPC wrapper, only
  alternative is modnovolyk/MAVLinkSwift which is Swift 3 / v1 only)
- MAVLinkConnection — NWConnection UDP transport, 1 Hz GCS heartbeat,
  rolling RX buffer + frame extraction
- UASManager — @mainactor ObservableObject, owns the link + publishes
  DroneState; sendCommandLong wrappers for arm / takeoff / RTL
- UASVideoPipView — backed by MobileVLCKit (libVLC handles RTSP +
  raw H264 over UDP + MPEG-TS over UDP via url-scheme dispatch).
  Graceful fallback view when MobileVLCKit isn't linked, matching
  the existing VLCPlayerView pattern
- UASConnectView — SwiftUI form: host/port/callsign for MAVLink +
  segmented Video Source picker with dynamic field per mode
- Toolbar entry: BarItem "tool.uas" (Vehicles, airplane.departure
  icon) wired through ToolSheetHost to UASConnectView

Not in this PR (separate follow-up):
- MapView integration (drone MKAnnotation, HUD card, gated UAS PIP)
- arm/disarm/takeoff/RTL buttons
- CoT broadcast (telemetry → UASCoTGenerator → TAKService.sendCoT)
- MobileVLCKit SPM dependency (today's #if canImport renders fallback)

xcodebuild clean (iOS Simulator, Debug, SDK 26.5).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wires tylerjonesio/vlckit-spm 3.6.0 as a Swift Package Manager
dependency on the OmniTAKMobile target. Exposes the upstream
MobileVLCKit module (LGPL-2.1+) so the `#if canImport(MobileVLCKit)`
branches in VLCPlayerView and UASVideoPipView now compile the real
playback path instead of the "Add MobileVLCKit" fallback.

Why this wrapper vs. upstream: VideoLAN doesn't publish a first-party
SPM package (their issue #302 is still open). Tyler Jones' wrapper
ships the official VideoLAN XCFramework with a Package.swift on top
and tracks the 3.x line — `import MobileVLCKit` stays as the module
name, so existing call sites are unchanged.

Build verified clean on iOS Simulator (Debug).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@jfuginay jfuginay marked this pull request as ready for review May 28, 2026 01:38
jfuginay and others added 7 commits May 30, 2026 13:11
…pass

gyb_detect ESP32 drone detector integration (BLE GATT) + a performance
and structural cleanup of the iOS client.

gyb detector:
- BLE GATT client/parser/manager + in-app discover/connect/status UI
- Detections feed the shared RemoteIdTrackStore (dedup with on-device RID)
- Drones render as live CoT air contacts (MIL-STD symbol, 3D altitude
  leader line), federate to servers, but are gated out of the chat
  participant list (not messageable EUDs)

Performance (main-thread invalidation storm on TAKService.shared):
- Move CoT regex parse off the main thread (serial queue; ordering kept)
- Remove dead @published lastMessage (full XML stored per packet, 0 readers)
- Coalesce messagesReceived/bytesReceived publishing to <=4x/sec so the
  map body no longer re-renders per packet
- Dedup cotEvents in the Rust-FFI callback path (was appending unbounded)
- Hash-guard Cesium setEntities/setTrails bridge calls (were re-encoding +
  re-crossing the WKWebView boundary every frame)
- Move SettingsView cache-size walk off the main thread

UI routing:
- Wire previously-dead radial-menu actions: centerMap + draw
  line/circle/polygon (via a ViewModifier to dodge the type-checker
  ceiling on ATAKMapView.body), and route createRoute/quickChat/
  emergency/getInfo to real screens

Dead code:
- Delete orphaned MapKit stack (Map3DViewController + 6 Map/Overlays MKOverlay
  files, superseded by Mapbox+Cesium) and unused ConnectionCoordinator
  (~3,700 lines)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The drone altitude leader line mutated a LIVE Cesium entity's `.polyline`
graphics in place — reassigning `new Cesium.PolylineGraphics(...)` and
`= undefined` on each position update. That's the only place in the bridge
that mutated graphics on a live entity (every other polyline — drawings,
measurements, trails — is a standalone add/remove entity), and it crashed
the WebGL globe when a drone appeared.

Render the leader as its own `uid:leader` entity in a dedicated
`_state.leaders` map, remove-then-add on every upsert (matching trails),
and clean it up in removeEntity/removeAll. Kept out of `_state.entities`
so setEntities' stale-cleanup doesn't touch it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Cesium globe pulls ~5MB of Cesium.js + terrain from the cesium.com
CDN on every launch. When the device can't reach it (bad network, dead
CDN), the page sat on "Loading 3D world…" forever with no escape.

Add a 12s load watchdog in the CesiumMainMap coordinator: if the bridge
hasn't signalled omniBridgeReady by then, post .cesiumLoadTimedOut.
ATAKMapView (via the RadialMenuExtraObservers modifier, to stay off the
already-maxed body type-checker) switches the engine to 2D and shows a
brief "switched to 2D" banner. Cancelled the instant the globe loads, so
it never fires on a healthy load.

Validated in the simulator (which can't run Cesium's WebGL): instead of
hanging, it now falls back to the 2D map after 12s.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ROOT CAUSE of the "Loading 3D world…" hang (sim AND device): the leader
label built its HAE string with `'\n'` written as a single backslash
inside the Cesium HTML, which is a Swift `"""` multiline literal. Swift
turned `\n` into a REAL newline, producing an unterminated JS string
literal — a SyntaxError ("Unexpected EOF") that aborted the ENTIRE inline
Cesium <script>. The viewer never initialized, so the globe spun forever.

Confirmed on-device via a temporary JS-error bridge:
  JSERR: SyntaxError: Unexpected EOF @:138   (the labelText line)
  typeof Cesium=object                       (CDN load was fine all along)
  viewer=no, ready=n/a                        (main script never ran)

Fix: write `\\n` so Swift emits a literal `\n` JS newline escape.
node --check now parses the whole inline script clean.

This was never a network/CDN issue or related to the dead-code cleanup —
both were ruled out. The watchdog→2D fallback (prev commit) stays as a
safety net for genuine load failures.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Symptom: the 3D globe worked for a while then went black on device, and
only a manual 2D↔3D toggle brought it back. That toggle recreates the
WKWebView — confirming the WebContent (WebGL) process had been terminated
by iOS under memory pressure (Cesium is GPU-heavy on mobile), leaving a
dead black view with no automatic recovery.

Set the WKWebView's navigationDelegate to the coordinator and implement
webViewWebContentProcessDidTerminate: reset isReady, clear the bridge
payload-hash cache (so the fresh page gets a full re-push), reload the
Cesium HTML, and restart the load watchdog. omniBridgeReady re-fires on
reload and updateUIView re-pushes the entity/drawing/trail snapshots, so
the scene restores itself without user intervention. If the reload can't
complete, the watchdog still falls back to 2D.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The globe was stacking three heavy network layers on every launch —
World Terrain + World Imagery + Google Photorealistic 3D Tiles. The
photoreal tileset is the heavy hitter: slow to stream/sharpen and a
GPU-memory hog that contributed to the WebGL process being killed
(the "worked then went black" + "takes forever to sharpen" reports).

Now the base is selectable from the Layers panel instead of all-on:
- Default = World Imagery + terrain (fast, sharp, stable).
- "Photoreal 3D" is a new Layers option (globe only), loaded on demand
  via setBaseLayer('photoreal') and unloaded when another base is picked.
- When it does load, maximumScreenSpaceError=24 (coarser than the
  default 16) so it sharpens faster and uses less GPU.

setBaseLayer now manages the tileset lifecycle; _state.photoreal tracks
it. The always-on createGooglePhotorealistic3DTileset at viewer init is
removed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bumps from 2.32.0 (which was never archived — it shipped the globe-load
syntax bug). This is the first working build of the gyb-detector +
perf/structure release, plus the new selectable globe base layers
(photoreal opt-in). Build 26053002 (2026-05-30 #2).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant