From 183223e522d4d2842a04f6f67f689fa35f5dffca Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 19 Apr 2026 00:29:19 +0000 Subject: [PATCH] Add watchOS foxhunt compass app for standalone direction finding to mesh nodes Agent-Logs-Url: https://github.com/meshtastic/Meshtastic-Apple/sessions/07fad82b-9a10-4db0-a0b0-445d8ef28e50 Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com> --- .../AccentColor.colorset/Contents.json | 20 + .../AppIcon.appiconset/Contents.json | 13 + .../Assets.xcassets/Contents.json | 6 + Meshtastic Watch App/ContentView.swift | 41 ++ Meshtastic Watch App/Info.plist | 14 + .../Managers/WatchBLEManager.swift | 374 ++++++++++++++++++ .../Managers/WatchLocationManager.swift | 117 ++++++ .../Meshtastic Watch App.entitlements | 10 + Meshtastic Watch App/MeshtasticWatchApp.swift | 17 + Meshtastic Watch App/Models/MeshNode.swift | 64 +++ .../Views/DeviceConnectionView.swift | 158 ++++++++ .../Views/FoxhuntCompassView.swift | 270 +++++++++++++ .../Views/NearbyNodesListView.swift | 126 ++++++ 13 files changed, 1230 insertions(+) create mode 100644 Meshtastic Watch App/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 Meshtastic Watch App/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 Meshtastic Watch App/Assets.xcassets/Contents.json create mode 100644 Meshtastic Watch App/ContentView.swift create mode 100644 Meshtastic Watch App/Info.plist create mode 100644 Meshtastic Watch App/Managers/WatchBLEManager.swift create mode 100644 Meshtastic Watch App/Managers/WatchLocationManager.swift create mode 100644 Meshtastic Watch App/Meshtastic Watch App.entitlements create mode 100644 Meshtastic Watch App/MeshtasticWatchApp.swift create mode 100644 Meshtastic Watch App/Models/MeshNode.swift create mode 100644 Meshtastic Watch App/Views/DeviceConnectionView.swift create mode 100644 Meshtastic Watch App/Views/FoxhuntCompassView.swift create mode 100644 Meshtastic Watch App/Views/NearbyNodesListView.swift diff --git a/Meshtastic Watch App/Assets.xcassets/AccentColor.colorset/Contents.json b/Meshtastic Watch App/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 00000000..f888b210 --- /dev/null +++ b/Meshtastic Watch App/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.000", + "green" : "0.776", + "red" : "0.404" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Meshtastic Watch App/Assets.xcassets/AppIcon.appiconset/Contents.json b/Meshtastic Watch App/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 00000000..d57ffc59 --- /dev/null +++ b/Meshtastic Watch App/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "watchOS", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Meshtastic Watch App/Assets.xcassets/Contents.json b/Meshtastic Watch App/Assets.xcassets/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/Meshtastic Watch App/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Meshtastic Watch App/ContentView.swift b/Meshtastic Watch App/ContentView.swift new file mode 100644 index 00000000..1ff0e215 --- /dev/null +++ b/Meshtastic Watch App/ContentView.swift @@ -0,0 +1,41 @@ +// +// ContentView.swift +// Meshtastic Watch App +// +// Copyright(c) Meshtastic 2025. +// + +import SwiftUI + +/// Root view of the Meshtastic Watch App. +/// +/// Uses a tab-based layout: +/// 1. **Foxhunt** – nearby nodes list → compass +/// 2. **Radio** – BLE device connection +struct ContentView: View { + + @StateObject private var bleManager = WatchBLEManager() + @StateObject private var locationManager = WatchLocationManager() + + var body: some View { + TabView { + // Tab 1: Foxhunt + NavigationStack { + NearbyNodesListView(bleManager: bleManager, locationManager: locationManager) + } + + // Tab 2: Radio connection + NavigationStack { + DeviceConnectionView(bleManager: bleManager) + } + } + .tabViewStyle(.verticalPage) + .onAppear { + locationManager.requestAuthorization() + } + } +} + +#Preview { + ContentView() +} diff --git a/Meshtastic Watch App/Info.plist b/Meshtastic Watch App/Info.plist new file mode 100644 index 00000000..a27ae3d4 --- /dev/null +++ b/Meshtastic Watch App/Info.plist @@ -0,0 +1,14 @@ + + + + + NSBluetoothAlwaysUsageDescription + Meshtastic needs Bluetooth to connect directly to your Meshtastic radio for foxhunt direction finding. + NSLocationWhenInUseUsageDescription + Meshtastic needs your location to calculate distance and bearing to mesh nodes during foxhunt. + WKApplication + + WKRunsIndependentlyOfCompanionApp + + + diff --git a/Meshtastic Watch App/Managers/WatchBLEManager.swift b/Meshtastic Watch App/Managers/WatchBLEManager.swift new file mode 100644 index 00000000..4d121d6b --- /dev/null +++ b/Meshtastic Watch App/Managers/WatchBLEManager.swift @@ -0,0 +1,374 @@ +// +// WatchBLEManager.swift +// Meshtastic Watch App +// +// Copyright(c) Meshtastic 2025. +// + +import Foundation +import CoreBluetooth +import MeshtasticProtobufs +import os + +// MARK: - Meshtastic BLE UUIDs (same as the main app) +private let meshtasticServiceUUID = CBUUID(string: "6BA1B218-15A8-461F-9FA8-5DCAE273EAFD") +private let toRadioUUID = CBUUID(string: "F75C76D2-129E-4DAD-A1DD-7866124401E7") +private let fromRadioUUID = CBUUID(string: "2C55E69E-4993-11ED-B878-0242AC120002") +private let fromNumUUID = CBUUID(string: "ED9DA18C-A800-4F66-A670-AA7547E34453") + +/// Standalone BLE manager that lets the watch connect directly to a +/// Meshtastic radio without relying on the paired iPhone. +/// +/// It discovers Meshtastic peripherals, connects, requests the node +/// database, and keeps the `nodes` dictionary up-to-date as position +/// packets arrive. +@MainActor +final class WatchBLEManager: NSObject, ObservableObject { + + // MARK: - Published state + + /// Discovered but not-yet-connected peripherals. + @Published var discoveredDevices: [DiscoveredDevice] = [] + + /// All mesh nodes we know about, keyed by node number. + @Published var nodes: [UInt32: MeshNode] = [:] + + /// Current connection state. + @Published var connectionState: WatchConnectionState = .disconnected + + /// Name of the connected peripheral (if any). + @Published var connectedDeviceName: String? + + /// Whether the central manager is currently scanning. + @Published var isScanning = false + + // MARK: - Internal state + + private let logger = Logger(subsystem: "gvh.MeshtasticClient.watchkitapp", category: "🛜 BLE") + private var centralManager: CBCentralManager! + private var connectedPeripheral: CBPeripheral? + private var toRadioCharacteristic: CBCharacteristic? + private var fromRadioCharacteristic: CBCharacteristic? + private var fromNumCharacteristic: CBCharacteristic? + + /// Our own node number, learned from `MyNodeInfo`. + private var myNodeNum: UInt32? + + /// Nonce we send in the wantConfig request so we can identify the + /// `configCompleteId` response. + private let wantConfigNonce: UInt32 = 69421 // matches NONCE_ONLY_DB + + // MARK: - Lifecycle + + override init() { + super.init() + centralManager = CBCentralManager(delegate: self, queue: nil) + } + + // MARK: - Public API + + func startScanning() { + guard centralManager.state == .poweredOn else { + logger.warning("Cannot scan – Bluetooth not powered on (\(self.centralManager.state.rawValue))") + return + } + logger.info("Starting BLE scan for Meshtastic devices") + discoveredDevices.removeAll() + centralManager.scanForPeripherals(withServices: [meshtasticServiceUUID], + options: [CBCentralManagerScanOptionAllowDuplicatesKey: false]) + isScanning = true + } + + func stopScanning() { + centralManager.stopScan() + isScanning = false + } + + func connect(to device: DiscoveredDevice) { + stopScanning() + connectionState = .connecting + connectedDeviceName = device.name + logger.info("Connecting to \(device.name, privacy: .public)") + centralManager.connect(device.peripheral, options: nil) + } + + func disconnect() { + if let peripheral = connectedPeripheral { + centralManager.cancelPeripheralConnection(peripheral) + } + cleanup() + } + + // MARK: - Helpers + + private func cleanup() { + connectedPeripheral = nil + toRadioCharacteristic = nil + fromRadioCharacteristic = nil + fromNumCharacteristic = nil + connectionState = .disconnected + connectedDeviceName = nil + myNodeNum = nil + } + + /// Send a `ToRadio` protobuf to the connected radio. + private func send(_ message: ToRadio) { + guard let peripheral = connectedPeripheral, + let characteristic = toRadioCharacteristic, + let data = try? message.serializedData() else { + logger.error("Cannot send – not connected or characteristic missing") + return + } + let writeType: CBCharacteristicWriteType = + characteristic.properties.contains(.writeWithoutResponse) ? .withoutResponse : .withResponse + peripheral.writeValue(data, for: characteristic, type: writeType) + } + + /// Request the full node database from the radio. + private func requestNodeDatabase() { + var toRadio = ToRadio() + toRadio.wantConfigID = wantConfigNonce + send(toRadio) + logger.info("Sent wantConfigID=\(self.wantConfigNonce)") + } + + /// Read (drain) packets from the FROMRADIO characteristic until an empty + /// response is received. + private func drainFromRadio() { + guard let peripheral = connectedPeripheral, + let characteristic = fromRadioCharacteristic else { return } + peripheral.readValue(for: characteristic) + } + + // MARK: - Packet handling + + private func handleFromRadio(_ data: Data) { + guard !data.isEmpty else { return } + guard let fromRadio = try? FromRadio(serializedBytes: data) else { + logger.error("Failed to decode FromRadio packet (\(data.count) bytes)") + return + } + + switch fromRadio.payloadVariant { + case .myInfo(let myInfo): + myNodeNum = myInfo.myNodeNum + logger.info("My node num: \(myInfo.myNodeNum)") + + case .nodeInfo(let nodeInfo): + upsertNode(from: nodeInfo) + + case .packet(let meshPacket): + handleMeshPacket(meshPacket) + + case .configCompleteID(let id): + logger.info("Config complete (nonce=\(id))") + connectionState = .connected + + default: + break + } + } + + private func handleMeshPacket(_ packet: MeshPacket) { + guard packet.hasDecoded else { return } + let decoded = packet.decoded + + switch decoded.portnum { + case .positionApp: + if let position = try? Position(serializedBytes: decoded.payload) { + upsertPosition(from: packet.from, position: position) + } + case .nodeInfoApp: + if let user = try? User(serializedBytes: decoded.payload) { + upsertUser(from: packet.from, user: user) + } + default: + break + } + } + + // MARK: - Node management + + private func upsertNode(from nodeInfo: NodeInfo) { + let num = nodeInfo.num + var node = nodes[num] ?? MeshNode(num: num, longName: "Node \(String(num, radix: 16))", shortName: String(String(num, radix: 16).suffix(4))) + + if nodeInfo.hasUser { + node.longName = nodeInfo.user.longName + node.shortName = nodeInfo.user.shortName + } + if nodeInfo.hasPosition, nodeInfo.position.latitudeI != 0, nodeInfo.position.longitudeI != 0 { + node.latitude = Double(nodeInfo.position.latitudeI) / 1e7 + node.longitude = Double(nodeInfo.position.longitudeI) / 1e7 + node.altitude = nodeInfo.position.altitude + node.lastPositionTime = Date(timeIntervalSince1970: TimeInterval(nodeInfo.position.time)) + } + if nodeInfo.lastHeard > 0 { + node.lastHeard = Date(timeIntervalSince1970: TimeInterval(nodeInfo.lastHeard)) + } + node.snr = nodeInfo.snr + nodes[num] = node + } + + private func upsertPosition(from nodeNum: UInt32, position: Position) { + guard position.latitudeI != 0, position.longitudeI != 0 else { return } + var node = nodes[nodeNum] ?? MeshNode(num: nodeNum, longName: "Node \(String(nodeNum, radix: 16))", shortName: String(String(nodeNum, radix: 16).suffix(4))) + node.latitude = Double(position.latitudeI) / 1e7 + node.longitude = Double(position.longitudeI) / 1e7 + node.altitude = position.altitude + node.lastPositionTime = Date() + node.lastHeard = Date() + nodes[nodeNum] = node + } + + private func upsertUser(from nodeNum: UInt32, user: User) { + var node = nodes[nodeNum] ?? MeshNode(num: nodeNum, longName: user.longName, shortName: user.shortName) + node.longName = user.longName + node.shortName = user.shortName + node.lastHeard = Date() + nodes[nodeNum] = node + } +} + +// MARK: - DiscoveredDevice +struct DiscoveredDevice: Identifiable { + let id: UUID + let peripheral: CBPeripheral + let name: String + let rssi: Int +} + +// MARK: - ConnectionState +enum WatchConnectionState: Equatable { + case disconnected + case connecting + case connected +} + +// MARK: - CBCentralManagerDelegate +extension WatchBLEManager: @preconcurrency CBCentralManagerDelegate { + + nonisolated func centralManagerDidUpdateState(_ central: CBCentralManager) { + Task { @MainActor in + switch central.state { + case .poweredOn: + logger.info("Bluetooth powered on") + case .poweredOff: + logger.warning("Bluetooth powered off") + cleanup() + case .unauthorized: + logger.warning("Bluetooth unauthorised") + default: + break + } + } + } + + nonisolated func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, + advertisementData: [String: Any], rssi RSSI: NSNumber) { + Task { @MainActor in + let name = peripheral.name ?? advertisementData[CBAdvertisementDataLocalNameKey] as? String ?? "Unknown" + if !discoveredDevices.contains(where: { $0.peripheral.identifier == peripheral.identifier }) { + let device = DiscoveredDevice(id: peripheral.identifier, peripheral: peripheral, name: name, rssi: RSSI.intValue) + discoveredDevices.append(device) + logger.info("Discovered \(name, privacy: .public) RSSI=\(RSSI.intValue)") + } + } + } + + nonisolated func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { + Task { @MainActor in + logger.info("Connected to \(peripheral.name ?? "Unknown", privacy: .public)") + connectedPeripheral = peripheral + peripheral.delegate = self + peripheral.discoverServices([meshtasticServiceUUID]) + } + } + + nonisolated func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) { + Task { @MainActor in + logger.error("Failed to connect: \(error?.localizedDescription ?? "unknown", privacy: .public)") + cleanup() + } + } + + nonisolated func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) { + Task { @MainActor in + logger.info("Disconnected from \(peripheral.name ?? "Unknown", privacy: .public)") + cleanup() + } + } +} + +// MARK: - CBPeripheralDelegate +extension WatchBLEManager: @preconcurrency CBPeripheralDelegate { + + nonisolated func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) { + Task { @MainActor in + guard error == nil, let services = peripheral.services else { + logger.error("Service discovery error: \(error?.localizedDescription ?? "nil", privacy: .public)") + return + } + for service in services where service.uuid == meshtasticServiceUUID { + peripheral.discoverCharacteristics([toRadioUUID, fromRadioUUID, fromNumUUID], for: service) + } + } + } + + nonisolated func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) { + Task { @MainActor in + guard error == nil, let characteristics = service.characteristics else { + logger.error("Characteristic discovery error: \(error?.localizedDescription ?? "nil", privacy: .public)") + return + } + for characteristic in characteristics { + switch characteristic.uuid { + case toRadioUUID: + toRadioCharacteristic = characteristic + case fromRadioUUID: + fromRadioCharacteristic = characteristic + case fromNumUUID: + fromNumCharacteristic = characteristic + peripheral.setNotifyValue(true, for: characteristic) + default: + break + } + } + if toRadioCharacteristic != nil && fromRadioCharacteristic != nil && fromNumCharacteristic != nil { + logger.info("All characteristics discovered – requesting node database") + requestNodeDatabase() + drainFromRadio() + } + } + } + + nonisolated func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) { + Task { @MainActor in + guard error == nil else { + logger.error("Value update error for \(characteristic.uuid): \(error!.localizedDescription, privacy: .public)") + return + } + switch characteristic.uuid { + case fromRadioUUID: + if let data = characteristic.value, !data.isEmpty { + handleFromRadio(data) + // Continue draining + drainFromRadio() + } + case fromNumUUID: + // New data available – start draining + drainFromRadio() + default: + break + } + } + } + + nonisolated func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) { + Task { @MainActor in + if let error { + logger.error("Write error: \(error.localizedDescription, privacy: .public)") + } + } + } +} diff --git a/Meshtastic Watch App/Managers/WatchLocationManager.swift b/Meshtastic Watch App/Managers/WatchLocationManager.swift new file mode 100644 index 00000000..521750cb --- /dev/null +++ b/Meshtastic Watch App/Managers/WatchLocationManager.swift @@ -0,0 +1,117 @@ +// +// WatchLocationManager.swift +// Meshtastic Watch App +// +// Copyright(c) Meshtastic 2025. +// + +import Foundation +import CoreLocation +import os + +/// Manages location and heading updates for the watchOS foxhunt compass. +/// +/// On Apple Watch models with a magnetometer (Series 5+) the compass heading +/// is used. On older models the GPS course (direction of travel) is used as a +/// fallback – this only works while the user is moving. +@MainActor +final class WatchLocationManager: NSObject, ObservableObject { + + private let manager = CLLocationManager() + private let logger = Logger(subsystem: "gvh.MeshtasticClient.watchkitapp", category: "📍 Location") + + /// Current heading in degrees (0‑360). Updated from the magnetometer when + /// available, otherwise falls back to GPS course. + @Published var heading: Double = 0 + + /// Most recent location of the watch. + @Published var currentLocation: CLLocation? + + /// `true` once the manager is actively delivering updates. + @Published var isUpdating = false + + /// Authorisation status surfaced to the UI so it can prompt if needed. + @Published var authorizationStatus: CLAuthorizationStatus = .notDetermined + + /// Whether the device has a compass (magnetometer). + var hasCompass: Bool { CLLocationManager.headingAvailable() } + + // MARK: - Lifecycle + + override init() { + super.init() + manager.delegate = self + manager.desiredAccuracy = kCLLocationAccuracyBest + manager.distanceFilter = 2 // metres + authorizationStatus = manager.authorizationStatus + } + + func requestAuthorization() { + logger.info("Requesting location authorisation") + manager.requestWhenInUseAuthorization() + } + + func startUpdates() { + guard authorizationStatus == .authorizedAlways || + authorizationStatus == .authorizedWhenInUse else { + logger.warning("Cannot start updates – insufficient authorisation (\(self.authorizationStatus.rawValue))") + return + } + logger.info("Starting location updates") + manager.startUpdatingLocation() + if CLLocationManager.headingAvailable() { + manager.headingFilter = 1 + manager.startUpdatingHeading() + } + isUpdating = true + } + + func stopUpdates() { + logger.info("Stopping location updates") + manager.stopUpdatingLocation() + if CLLocationManager.headingAvailable() { + manager.stopUpdatingHeading() + } + isUpdating = false + } +} + +// MARK: - CLLocationManagerDelegate +extension WatchLocationManager: @preconcurrency CLLocationManagerDelegate { + + nonisolated func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { + Task { @MainActor in + guard let latest = locations.last else { return } + self.currentLocation = latest + + // Fallback heading from GPS course when no magnetometer. + if !CLLocationManager.headingAvailable(), latest.course >= 0 { + self.heading = latest.course + } + } + } + + nonisolated func locationManager(_ manager: CLLocationManager, didUpdateHeading newHeading: CLHeading) { + Task { @MainActor in + self.heading = newHeading.trueHeading >= 0 + ? newHeading.trueHeading + : newHeading.magneticHeading + } + } + + nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { + Task { @MainActor in + self.authorizationStatus = manager.authorizationStatus + if self.authorizationStatus == .authorizedAlways || + self.authorizationStatus == .authorizedWhenInUse { + self.startUpdates() + } + } + } + + nonisolated func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { + Task { @MainActor in + logger.error("Location error: \(error.localizedDescription, privacy: .public)") + } + } +} diff --git a/Meshtastic Watch App/Meshtastic Watch App.entitlements b/Meshtastic Watch App/Meshtastic Watch App.entitlements new file mode 100644 index 00000000..800f23d0 --- /dev/null +++ b/Meshtastic Watch App/Meshtastic Watch App.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.device.bluetooth + + com.apple.security.personal-information.location + + + diff --git a/Meshtastic Watch App/MeshtasticWatchApp.swift b/Meshtastic Watch App/MeshtasticWatchApp.swift new file mode 100644 index 00000000..3c433b15 --- /dev/null +++ b/Meshtastic Watch App/MeshtasticWatchApp.swift @@ -0,0 +1,17 @@ +// +// MeshtasticWatchApp.swift +// Meshtastic Watch App +// +// Copyright(c) Meshtastic 2025. +// + +import SwiftUI + +@main +struct MeshtasticWatchApp: App { + var body: some Scene { + WindowGroup { + ContentView() + } + } +} diff --git a/Meshtastic Watch App/Models/MeshNode.swift b/Meshtastic Watch App/Models/MeshNode.swift new file mode 100644 index 00000000..3929d38e --- /dev/null +++ b/Meshtastic Watch App/Models/MeshNode.swift @@ -0,0 +1,64 @@ +// +// MeshNode.swift +// Meshtastic Watch App +// +// Copyright(c) Meshtastic 2025. +// + +import Foundation +import CoreLocation + +/// Lightweight in-memory model for a mesh node seen by the watch. +/// Avoids Core Data dependency so the watch app can run standalone. +struct MeshNode: Identifiable, Equatable { + /// Meshtastic node number (unique on the mesh). + let num: UInt32 + /// Stable identifier derived from the node number. + var id: UInt32 { num } + + var longName: String + var shortName: String + + /// Latest known position (latitude / longitude in degrees, altitude in metres). + var latitude: Double? + var longitude: Double? + var altitude: Int32? + + /// When the position was last updated. + var lastPositionTime: Date? + + /// When we last heard *any* packet from this node. + var lastHeard: Date? + + /// Signal-to-noise ratio of the last received packet (dB). + var snr: Float? + + // MARK: - Derived helpers + + /// A coordinate suitable for bearing/distance calculations, or `nil` when we + /// have no valid position. + var coordinate: CLLocationCoordinate2D? { + guard let lat = latitude, let lon = longitude, + lat != 0, lon != 0 else { return nil } + return CLLocationCoordinate2D(latitude: lat, longitude: lon) + } + + /// `CLLocation` wrapper – handy for `distance(from:)`. + var location: CLLocation? { + guard let coord = coordinate else { return nil } + return CLLocation(latitude: coord.latitude, longitude: coord.longitude) + } + + /// Distance in metres from the given user location, or `nil` when there is + /// no valid node position. + func distance(from userLocation: CLLocation) -> CLLocationDistance? { + guard let nodeLoc = location else { return nil } + return userLocation.distance(from: nodeLoc) + } + + /// `true` when the node has been heard in the last two hours. + var isOnline: Bool { + guard let lastHeard else { return false } + return lastHeard.timeIntervalSinceNow > -7200 + } +} diff --git a/Meshtastic Watch App/Views/DeviceConnectionView.swift b/Meshtastic Watch App/Views/DeviceConnectionView.swift new file mode 100644 index 00000000..6000f2e3 --- /dev/null +++ b/Meshtastic Watch App/Views/DeviceConnectionView.swift @@ -0,0 +1,158 @@ +// +// DeviceConnectionView.swift +// Meshtastic Watch App +// +// Copyright(c) Meshtastic 2025. +// + +import SwiftUI + +/// View for scanning and connecting to a Meshtastic BLE radio directly +/// from the Apple Watch (no phone required). +struct DeviceConnectionView: View { + + @ObservedObject var bleManager: WatchBLEManager + + var body: some View { + Group { + switch bleManager.connectionState { + case .disconnected: + disconnectedView + case .connecting: + connectingView + case .connected: + connectedView + } + } + .navigationTitle("Radio") + } + + // MARK: - Disconnected + + @ViewBuilder + private var disconnectedView: some View { + VStack(spacing: 8) { + if bleManager.discoveredDevices.isEmpty && !bleManager.isScanning { + VStack(spacing: 8) { + Image(systemName: "antenna.radiowaves.left.and.right.slash") + .font(.title2) + .foregroundStyle(.secondary) + Text("No radio connected") + .font(.headline) + Text("Scan to find nearby Meshtastic radios.") + .font(.caption2) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + .padding() + } + + if bleManager.isScanning && bleManager.discoveredDevices.isEmpty { + VStack(spacing: 8) { + ProgressView() + Text("Scanning…") + .font(.caption) + .foregroundStyle(.secondary) + } + .padding() + } + + if !bleManager.discoveredDevices.isEmpty { + List(bleManager.discoveredDevices) { device in + Button { + bleManager.connect(to: device) + } label: { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(device.name) + .font(.system(size: 14, weight: .semibold)) + .lineLimit(1) + Text("\(device.rssi) dBm") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + } + Spacer() + signalIcon(rssi: device.rssi) + } + } + } + } + + Button { + if bleManager.isScanning { + bleManager.stopScanning() + } else { + bleManager.startScanning() + } + } label: { + Label(bleManager.isScanning ? "Stop" : "Scan", + systemImage: bleManager.isScanning ? "stop.fill" : "magnifyingglass") + } + .buttonStyle(.borderedProminent) + .tint(bleManager.isScanning ? .red : .accentColor) + } + } + + // MARK: - Connecting + + @ViewBuilder + private var connectingView: some View { + VStack(spacing: 8) { + ProgressView() + Text("Connecting…") + .font(.headline) + if let name = bleManager.connectedDeviceName { + Text(name) + .font(.caption) + .foregroundStyle(.secondary) + } + } + .padding() + } + + // MARK: - Connected + + @ViewBuilder + private var connectedView: some View { + VStack(spacing: 8) { + Image(systemName: "checkmark.circle.fill") + .font(.title2) + .foregroundStyle(.green) + Text("Connected") + .font(.headline) + if let name = bleManager.connectedDeviceName { + Text(name) + .font(.caption) + .foregroundStyle(.secondary) + } + Text("\(bleManager.nodes.count) nodes") + .font(.caption2) + .foregroundStyle(.secondary) + + Button(role: .destructive) { + bleManager.disconnect() + } label: { + Label("Disconnect", systemImage: "xmark.circle") + } + .buttonStyle(.bordered) + } + .padding() + } + + // MARK: - Helpers + + @ViewBuilder + private func signalIcon(rssi: Int) -> some View { + let imageName: String + if rssi > -65 { + imageName = "wifi" + } else if rssi > -85 { + imageName = "wifi" + } else { + imageName = "wifi.exclamationmark" + } + Image(systemName: imageName) + .font(.system(size: 12)) + .foregroundStyle(rssi > -65 ? .green : (rssi > -85 ? .yellow : .red)) + } +} diff --git a/Meshtastic Watch App/Views/FoxhuntCompassView.swift b/Meshtastic Watch App/Views/FoxhuntCompassView.swift new file mode 100644 index 00000000..58294b8f --- /dev/null +++ b/Meshtastic Watch App/Views/FoxhuntCompassView.swift @@ -0,0 +1,270 @@ +// +// FoxhuntCompassView.swift +// Meshtastic Watch App +// +// Copyright(c) Meshtastic 2025. +// + +import SwiftUI +import CoreLocation +import WatchKit + +/// A compass view optimised for Apple Watch that points toward a target +/// mesh node. Designed for "foxhunt" (radio direction-finding) scenarios. +/// +/// Features: +/// - Rotating compass dial showing heading +/// - Bearing arrow pointing toward the target node +/// - Distance readout +/// - Haptic feedback when aligned with the target (within 10°) +/// - Hot/warm/cold colour coding based on distance +struct FoxhuntCompassView: View { + + let node: MeshNode + @ObservedObject var locationManager: WatchLocationManager + + @State private var inAlignment = false + private let alignmentTolerance: Double = 10.0 + + /// Half a mile in metres – the maximum distance for foxhunt targets. + static let maxDistanceMetres: Double = 804.672 + + // MARK: - Body + + var body: some View { + GeometryReader { geometry in + let size = min(geometry.size.width, geometry.size.height) + let dialRadius = size * 0.38 + + VStack(spacing: 2) { + // Node name + Text(node.shortName.isEmpty ? node.longName : node.shortName) + .font(.system(size: 13, weight: .semibold, design: .rounded)) + .foregroundStyle(.secondary) + .lineLimit(1) + + ZStack { + // Fixed heading indicator at top + Image(systemName: "triangle.fill") + .font(.system(size: 8, weight: .bold)) + .foregroundStyle(.primary) + .rotationEffect(.degrees(180)) + .offset(y: -(dialRadius + 12)) + + // Rotating compass group + ZStack { + // Outer ring + Circle() + .stroke(Color.primary.opacity(0.15), lineWidth: 1) + .frame(width: dialRadius * 2 + 8, height: dialRadius * 2 + 8) + + // Tick marks (every 10° for watch readability) + ForEach(0..<36, id: \.self) { i in + let deg = Double(i) * 10 + WatchTickMark(degree: deg, radius: dialRadius) + } + + // Cardinal labels + ForEach(WatchCompassLabel.allLabels, id: \.degrees) { label in + Text(label.text) + .font(.system(size: label.isCardinal ? 11 : 8, weight: label.isCardinal ? .bold : .medium)) + .foregroundStyle(label.degrees == 0 ? .orange : .primary) + .rotationEffect(.degrees(-label.degrees + locationManager.heading)) + .offset(y: -(dialRadius - 14)) + .rotationEffect(.degrees(label.degrees)) + } + + // North indicator + WatchTriangle() + .fill(.orange) + .frame(width: 7, height: 6) + .offset(y: -(dialRadius + 3)) + + // Centre readout + centreReadout(dialRadius: dialRadius) + + // Bearing arrow to target + if let bearing = bearingToNode() { + Image(systemName: "arrowtriangle.up.fill") + .font(.system(size: 12, weight: .bold)) + .foregroundStyle(distanceColor) + .offset(y: -(dialRadius + 8)) + .rotationEffect(.degrees(bearing)) + .onChange(of: locationManager.heading) { + checkAlignment(bearing: bearing, heading: locationManager.heading) + } + } + } + .rotationEffect(.degrees(-locationManager.heading)) + } + .frame(width: dialRadius * 2 + 30, height: dialRadius * 2 + 30) + + // Distance at bottom + if let dist = distanceToNode() { + Text(formatDistance(dist)) + .font(.system(size: 14, weight: .semibold, design: .rounded)) + .foregroundStyle(distanceColor) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .onAppear { + locationManager.startUpdates() + } + .onDisappear { + locationManager.stopUpdates() + } + } + + // MARK: - Centre readout + + @ViewBuilder + private func centreReadout(dialRadius: CGFloat) -> some View { + let textColor: Color = distanceColor.isWatchLight ? .black : .white + + ZStack { + Circle() + .fill(distanceColor) + .overlay( + Circle().stroke(textColor.opacity(0.6), lineWidth: 2) + ) + .frame(width: dialRadius * 1.1, height: dialRadius * 1.1) + + VStack(spacing: 1) { + Text(headingText) + .font(.system(size: 20, weight: .light, design: .rounded)) + .monospacedDigit() + .foregroundStyle(textColor) + + if let bearing = bearingToNode() { + Text("\(String(format: "%.0f°", bearing))") + .font(.system(size: 10, weight: .medium, design: .rounded)) + .foregroundStyle(textColor.opacity(0.8)) + } + } + } + .rotationEffect(.degrees(locationManager.heading)) + } + + // MARK: - Calculations + + private var headingText: String { + "\(Int(locationManager.heading.rounded()) % 360)°" + } + + private func bearingToNode() -> Double? { + guard let target = node.coordinate, + let user = locationManager.currentLocation?.coordinate else { return nil } + return Self.bearingBetween(from: user, to: target) + } + + private func distanceToNode() -> CLLocationDistance? { + guard let userLoc = locationManager.currentLocation else { return nil } + return node.distance(from: userLoc) + } + + /// Colour that shifts from blue (far) → yellow (mid) → red (close). + private var distanceColor: Color { + guard let dist = distanceToNode() else { return .blue } + let ratio = min(dist / Self.maxDistanceMetres, 1.0) + if ratio > 0.66 { return .blue } + if ratio > 0.33 { return .yellow } + return .red + } + + private func checkAlignment(bearing: Double, heading: Double) { + let rawDiff = abs(heading - bearing).truncatingRemainder(dividingBy: 360) + let diff = min(rawDiff, 360 - rawDiff) + + if diff <= alignmentTolerance { + if !inAlignment { + inAlignment = true + WKInterfaceDevice.current().play(.click) + } + } else { + inAlignment = false + } + } + + private func formatDistance(_ distance: CLLocationDistance) -> String { + let measurement = Measurement(value: distance, unit: UnitLength.meters) + let formatter = MeasurementFormatter() + formatter.unitOptions = .naturalScale + formatter.numberFormatter.maximumFractionDigits = 0 + return formatter.string(from: measurement) + } + + // MARK: - Bearing maths (same algorithm as the main app) + + static func bearingBetween(from user: CLLocationCoordinate2D, to target: CLLocationCoordinate2D) -> Double { + let lat1 = user.latitude * .pi / 180 + let lon1 = user.longitude * .pi / 180 + let lat2 = target.latitude * .pi / 180 + let lon2 = target.longitude * .pi / 180 + let dLon = lon2 - lon1 + let y = sin(dLon) * cos(lat2) + let x = cos(lat1) * sin(lat2) - sin(lat1) * cos(lat2) * cos(dLon) + var bearing = atan2(y, x) * 180 / .pi + if bearing < 0 { bearing += 360 } + return bearing + } +} + +// MARK: - Watch-sized compass sub-views + +private struct WatchTickMark: View { + let degree: Double + let radius: CGFloat + + var body: some View { + let isCardinal = degree.truncatingRemainder(dividingBy: 90) == 0 + let isMajor = degree.truncatingRemainder(dividingBy: 30) == 0 + let length: CGFloat = isCardinal ? 8 : (isMajor ? 5 : 3) + let width: CGFloat = isCardinal ? 2 : 1 + + Capsule() + .fill(isCardinal ? Color.primary : Color.primary.opacity(isMajor ? 0.7 : 0.3)) + .frame(width: width, height: length) + .offset(y: -(radius - length / 2)) + .rotationEffect(.degrees(degree)) + } +} + +private struct WatchTriangle: Shape { + func path(in rect: CGRect) -> Path { + var path = Path() + path.move(to: CGPoint(x: rect.midX, y: rect.minY)) + path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY)) + path.addLine(to: CGPoint(x: rect.minX, y: rect.maxY)) + path.closeSubpath() + return path + } +} + +private struct WatchCompassLabel { + let degrees: Double + let text: String + let isCardinal: Bool + + static let allLabels: [WatchCompassLabel] = [ + WatchCompassLabel(degrees: 0, text: "N", isCardinal: true), + WatchCompassLabel(degrees: 45, text: "NE", isCardinal: false), + WatchCompassLabel(degrees: 90, text: "E", isCardinal: true), + WatchCompassLabel(degrees: 135, text: "SE", isCardinal: false), + WatchCompassLabel(degrees: 180, text: "S", isCardinal: true), + WatchCompassLabel(degrees: 225, text: "SW", isCardinal: false), + WatchCompassLabel(degrees: 270, text: "W", isCardinal: true), + WatchCompassLabel(degrees: 315, text: "NW", isCardinal: false) + ] +} + +// MARK: - Color helper + +extension Color { + /// Quick luminance check so centre text is readable on the distance colour. + var isWatchLight: Bool { + // Approximate: yellow and lighter colours are "light" + if self == .yellow || self == .orange || self == .white { return true } + return false + } +} diff --git a/Meshtastic Watch App/Views/NearbyNodesListView.swift b/Meshtastic Watch App/Views/NearbyNodesListView.swift new file mode 100644 index 00000000..de99336c --- /dev/null +++ b/Meshtastic Watch App/Views/NearbyNodesListView.swift @@ -0,0 +1,126 @@ +// +// NearbyNodesListView.swift +// Meshtastic Watch App +// +// Copyright(c) Meshtastic 2025. +// + +import SwiftUI +import CoreLocation + +/// Shows mesh nodes within half a mile (≈ 805 m) that have a valid +/// position. Tapping a node opens the foxhunt compass pointing at it. +struct NearbyNodesListView: View { + + @ObservedObject var bleManager: WatchBLEManager + @ObservedObject var locationManager: WatchLocationManager + + /// Nodes filtered to ≤ 0.5 miles with a known position, sorted by distance. + private var nearbyNodes: [MeshNode] { + guard let userLoc = locationManager.currentLocation else { return [] } + return bleManager.nodes.values + .filter { node in + guard node.coordinate != nil, + let dist = node.distance(from: userLoc) else { return false } + return dist <= FoxhuntCompassView.maxDistanceMetres + } + .sorted { a, b in + let dA = a.distance(from: userLoc) ?? .greatestFiniteMagnitude + let dB = b.distance(from: userLoc) ?? .greatestFiniteMagnitude + return dA < dB + } + } + + var body: some View { + Group { + if nearbyNodes.isEmpty { + emptyState + } else { + nodeList + } + } + .navigationTitle("Foxhunt") + } + + // MARK: - Sub-views + + @ViewBuilder + private var emptyState: some View { + VStack(spacing: 8) { + Image(systemName: "antenna.radiowaves.left.and.right") + .font(.title2) + .foregroundStyle(.secondary) + Text("No nearby nodes") + .font(.headline) + Text("Nodes within ½ mile with a known position will appear here.") + .font(.caption2) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal) + + if bleManager.connectionState != .connected { + Text("Connect to a radio first.") + .font(.caption2) + .foregroundStyle(.orange) + } + } + .padding() + } + + @ViewBuilder + private var nodeList: some View { + List(nearbyNodes) { node in + NavigationLink(destination: FoxhuntCompassView(node: node, locationManager: locationManager)) { + nodeRow(node) + } + } + } + + @ViewBuilder + private func nodeRow(_ node: MeshNode) -> some View { + let userLoc = locationManager.currentLocation + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(node.longName) + .font(.system(size: 14, weight: .semibold)) + .lineLimit(1) + if let userLoc, let dist = node.distance(from: userLoc) { + Text(formatDistance(dist)) + .font(.system(size: 12, design: .rounded)) + .foregroundStyle(distanceColor(dist)) + } + } + Spacer() + // Mini bearing arrow + if let bearing = bearing(to: node) { + Image(systemName: "location.north.fill") + .font(.system(size: 14)) + .foregroundStyle(userLoc.flatMap { node.distance(from: $0) }.map { distanceColor($0) } ?? .secondary) + .rotationEffect(.degrees(bearing - locationManager.heading)) + } + } + } + + // MARK: - Helpers + + private func bearing(to node: MeshNode) -> Double? { + guard let target = node.coordinate, + let user = locationManager.currentLocation?.coordinate else { return nil } + return FoxhuntCompassView.bearingBetween(from: user, to: target) + } + + private func formatDistance(_ distance: CLLocationDistance) -> String { + let measurement = Measurement(value: distance, unit: UnitLength.meters) + let formatter = MeasurementFormatter() + formatter.unitOptions = .naturalScale + formatter.numberFormatter.maximumFractionDigits = 0 + return formatter.string(from: measurement) + } + + private func distanceColor(_ distance: CLLocationDistance) -> Color { + let ratio = min(distance / FoxhuntCompassView.maxDistanceMetres, 1.0) + if ratio > 0.66 { return .blue } + if ratio > 0.33 { return .yellow } + return .red + } +}