Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
173 changes: 133 additions & 40 deletions OmniTAKMobile.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

45 changes: 31 additions & 14 deletions OmniTAKMobile/CoT/CoTEventHandler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,15 @@ class CoTEventHandler: ObservableObject {
}
}

/// Remove a single tracked contact by UID (e.g. a stale Remote ID / gyb
/// drone) so the map drops its marker immediately rather than waiting for
/// the staleEventThreshold backstop.
func removeEvent(uid: String) {
guard let service = takService else { return }
service.cotEvents.removeAll { $0.uid == uid }
service.enhancedMarkers.removeValue(forKey: uid)
}

// MARK: - Setup

func configure(takService: TAKService, chatManager: ChatManager) {
Expand Down Expand Up @@ -188,20 +197,28 @@ class CoTEventHandler: ObservableObject {

// Update participant info for chat, tagged with the source server so
// the contact list + DM routing are server-aware (multi-server).
if var participant = ChatXMLParser.parseParticipantFromPresence(xml: createPresenceXML(from: event)) {
participant.serverId = serverId
chatManager?.updateParticipant(participant)
chatManager?.updateParticipantLastSeen(id: participant.id)
} else {
// Create basic participant from CoT event (callsign + UID are always present)
let participant = ChatParticipant(
id: event.uid,
callsign: event.detail.callsign,
lastSeen: event.time,
isOnline: true,
serverId: serverId
)
chatManager?.updateParticipant(participant)
//
// Skip detected drones (RID-{uasId}): they render on the map and
// federate to servers like any CoT contact, but they are not EUDs and
// can't receive DMs, so they must not pollute the "KNOWN CONTACTS"
// list / New-Chat sheet. The `RID-` prefix is assigned only by
// RemoteIdAppBridge for on-device + gyb-sensor drone detections.
if !event.uid.hasPrefix("RID-") {
if var participant = ChatXMLParser.parseParticipantFromPresence(xml: createPresenceXML(from: event)) {
participant.serverId = serverId
chatManager?.updateParticipant(participant)
chatManager?.updateParticipantLastSeen(id: participant.id)
} else {
// Create basic participant from CoT event (callsign + UID are always present)
let participant = ChatParticipant(
id: event.uid,
callsign: event.detail.callsign,
lastSeen: event.time,
isOnline: true,
serverId: serverId
)
chatManager?.updateParticipant(participant)
}
}

// Publish to Combine subscribers
Expand Down
9 changes: 9 additions & 0 deletions OmniTAKMobile/Core/App/OmniTAKMobileApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ struct OmniTAKMobileApp: App {
@StateObject private var deepLinkHandler = DeepLinkHandler.shared
@AppStorage("hasCompletedOnboarding") private var hasCompletedOnboarding = false
@AppStorage("remoteIdScanEnabled") private var remoteIdScanEnabled = false
@AppStorage("gybDetectorEnabled") private var gybDetectorEnabled = false

init() {
// Eagerly initialize the Meshtastic manager so its COT bridge is wired
Expand All @@ -30,6 +31,10 @@ struct OmniTAKMobileApp: App {
// value is the single source of truth — Settings toggle flips
// the scanner without needing to talk to the bridge directly.
_ = RemoteIdAppBridge.shared

// Wire the external gyb_detect sensor bridge (BLE GATT WiFi-RID
// stream). On/off mirrors @AppStorage("gybDetectorEnabled") below.
_ = GybManager.shared
}

var body: some Scene {
Expand All @@ -38,10 +43,14 @@ struct OmniTAKMobileApp: App {
RootTabView()
.onAppear {
RemoteIdAppBridge.shared.setEnabled(remoteIdScanEnabled)
GybManager.shared.setEnabled(gybDetectorEnabled)
}
.onChange(of: remoteIdScanEnabled) { newValue in
RemoteIdAppBridge.shared.setEnabled(newValue)
}
.onChange(of: gybDetectorEnabled) { newValue in
GybManager.shared.setEnabled(newValue)
}
#if DEBUG
.task {
// Auto-import any TAK data package staged in
Expand Down
1 change: 1 addition & 0 deletions OmniTAKMobile/Core/App/ToolSheetHost.swift
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ struct ToolSheetHost: ViewModifier {
case "missionsync": MissionSyncView()
case "plugins": PluginsListView()
case "kml": KMLOverlaysPanel()
case "uas": UASConnectView()
case "pointer":
PointDropperSheetView(isPresented: Binding(
get: { active != nil },
Expand Down
1 change: 1 addition & 0 deletions OmniTAKMobile/Core/App/ToolbarCustomization.swift
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ extension BarItem {
BarItem(id: "tool.los", label: "Line of Sight", icon: "eye.fill", tint: BarTint.teal, kind: .command(.openTool("los"))),
BarItem(id: "tool.missionsync",label: "Mission Sync", icon: "arrow.triangle.2.circlepath", tint: BarTint.chat, kind: .command(.openTool("missionsync"))),
BarItem(id: "tool.plugins", label: "Plugins", icon: "puzzlepiece.extension.fill", tint: BarTint.settings, kind: .command(.openTool("plugins"))),
BarItem(id: "tool.uas", label: "Vehicles", icon: "airplane.departure", tint: BarTint.orange, kind: .command(.openTool("uas"))),
]

static func item(for id: String) -> BarItem? { catalog.first { $0.id == id } }
Expand Down
221 changes: 221 additions & 0 deletions OmniTAKMobile/Features/GybDetector/Networking/GybBLEClient.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
//
// GybBLEClient.swift
// OmniTAKMobile
//
// CoreBluetooth central for the gyb_detect drone detector. Mirrors the
// MeshtasticBLEClient pattern (scan by service UUID → connect → discover →
// subscribe to a notify characteristic) but the gyb stream is plain
// newline-delimited JSON, not protobuf.
//
// The firmware (ghostnet-fw/src/gatt_link.cpp) chunks each JSON object into
// 20-byte notifications and terminates it with '\n'. We buffer inbound bytes
// and emit one `onLine` callback per complete object. iOS cannot use the
// legacy Bluetooth Classic SPP link the ATAK plugin used, so BLE GATT is the
// only viable transport here — and it pairs in-app with no Settings detour.
//

import Foundation
import CoreBluetooth
import os

enum GybBLEUUID {
static let service = CBUUID(string: "e3f1b8a0-9c1d-4a2e-9b00-67796236d701")
static let detection = CBUUID(string: "e3f1b8a0-9c1d-4a2e-9b00-67796236d702")
}

@MainActor
final class GybBLEClient: NSObject, ObservableObject {

// Reuses the Meshtastic-defined DiscoveredBLEDevice value type.
@Published private(set) var isScanning = false
@Published private(set) var isConnected = false
@Published private(set) var discoveredDevices: [DiscoveredBLEDevice] = []
@Published private(set) var connectedDeviceName: String?

/// One callback per reassembled JSON line off the GATT stream.
var onLine: ((String) -> Void)?

private let log = Logger(subsystem: "com.omnitak.mobile", category: "gyb")
private let deviceNamePrefix = "gyb_detect"

private var central: CBCentralManager!
private var peripheral: CBPeripheral?
private var rxBuffer = Data()

/// Last connected peripheral UUID for auto-reconnect.
private static let lastDeviceKey = "gyb_last_device_uuid"

override init() {
super.init()
central = CBCentralManager(delegate: self, queue: .main)
}

// MARK: - Scan

func startScanning() {
guard central.state == .poweredOn else {
log.info("startScanning deferred — central not powered on")
return
}
discoveredDevices.removeAll()
isScanning = true
// Filter by the gyb service UUID — the firmware advertises it.
central.scanForPeripherals(
withServices: [GybBLEUUID.service],
options: [CBCentralManagerScanOptionAllowDuplicatesKey: false]
)
log.info("scanning for gyb detectors")
}

func stopScanning() {
guard isScanning else { return }
central.stopScan()
isScanning = false
}

// MARK: - Connect

func connect(to device: DiscoveredBLEDevice) {
stopScanning()
peripheral = device.peripheral
peripheral?.delegate = self
central.connect(device.peripheral, options: nil)
log.info("connecting to \(device.name, privacy: .public)")
}

func disconnect() {
if let p = peripheral {
central.cancelPeripheralConnection(p)
}
peripheral = nil
rxBuffer.removeAll()
isConnected = false
connectedDeviceName = nil
}

/// Try to silently reconnect to the last paired detector at launch.
func reconnectLast() {
guard central.state == .poweredOn,
let uuidStr = UserDefaults.standard.string(forKey: Self.lastDeviceKey),
let uuid = UUID(uuidString: uuidStr) else { return }
let known = central.retrievePeripherals(withIdentifiers: [uuid])
if let p = known.first {
peripheral = p
p.delegate = self
central.connect(p, options: nil)
log.info("auto-reconnecting to last gyb detector")
}
}

// MARK: - RX reassembly

private func ingest(_ data: Data) {
rxBuffer.append(data)
// Split on newline; emit each complete line, keep the remainder.
while let nl = rxBuffer.firstIndex(of: 0x0A) {
let lineData = rxBuffer.subdata(in: rxBuffer.startIndex..<nl)
rxBuffer.removeSubrange(rxBuffer.startIndex...nl)
guard !lineData.isEmpty,
let line = String(data: lineData, encoding: .utf8) else { continue }
let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty { onLine?(trimmed) }
}
// Guard against a runaway buffer if a '\n' never arrives.
if rxBuffer.count > 8192 { rxBuffer.removeAll() }
}
}

// MARK: - CBCentralManagerDelegate

extension GybBLEClient: CBCentralManagerDelegate {

nonisolated func centralManagerDidUpdateState(_ central: CBCentralManager) {
Task { @MainActor in
switch central.state {
case .poweredOn:
self.reconnectLast()
case .poweredOff, .unauthorized, .unsupported, .resetting, .unknown:
self.isConnected = false
self.isScanning = false
@unknown default:
break
}
}
}

nonisolated func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral,
advertisementData: [String: Any], rssi RSSI: NSNumber) {
let advName = (advertisementData[CBAdvertisementDataLocalNameKey] as? String)
?? peripheral.name ?? "gyb detector"
// Defensive: only surface gyb-named devices even though we filter by
// service UUID (some adverts omit the name in the primary packet).
let name = advName
Task { @MainActor in
guard name.lowercased().hasPrefix(self.deviceNamePrefix) || peripheral.name == nil
|| (peripheral.name?.lowercased().hasPrefix(self.deviceNamePrefix) ?? false) else {
// Still allow service-matched devices through even if unnamed.
return
}
let device = DiscoveredBLEDevice(id: peripheral.identifier, name: name,
rssi: RSSI.intValue, peripheral: peripheral)
if let idx = self.discoveredDevices.firstIndex(where: { $0.id == device.id }) {
self.discoveredDevices[idx] = device
} else {
self.discoveredDevices.append(device)
}
}
}

nonisolated func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
Task { @MainActor in
self.isConnected = true
self.connectedDeviceName = peripheral.name
self.rxBuffer.removeAll()
UserDefaults.standard.set(peripheral.identifier.uuidString, forKey: Self.lastDeviceKey)
peripheral.discoverServices([GybBLEUUID.service])
self.log.info("connected; discovering gyb service")
}
}

nonisolated func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) {
Task { @MainActor in
self.isConnected = false
self.connectedDeviceName = nil
}
}

nonisolated func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) {
Task { @MainActor in
self.isConnected = false
self.connectedDeviceName = nil
self.rxBuffer.removeAll()
}
}
}

// MARK: - CBPeripheralDelegate

extension GybBLEClient: CBPeripheralDelegate {

nonisolated func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
guard error == nil, let services = peripheral.services else { return }
for service in services where service.uuid == GybBLEUUID.service {
peripheral.discoverCharacteristics([GybBLEUUID.detection], for: service)
}
}

nonisolated func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) {
guard error == nil, let chars = service.characteristics else { return }
for ch in chars where ch.uuid == GybBLEUUID.detection {
peripheral.setNotifyValue(true, for: ch)
}
}

nonisolated func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) {
guard error == nil, characteristic.uuid == GybBLEUUID.detection,
let value = characteristic.value else { return }
Task { @MainActor in
self.ingest(value)
}
}
}
Loading