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
+ }
+}