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 1/7] 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
+ }
+}
From 98afec1b811d663777d4b6ff18c30b5cc9cf596a Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 19 Apr 2026 00:30:13 +0000
Subject: [PATCH 2/7] Fix code review feedback: simplify signal icon logic and
fix spelling
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>
---
Meshtastic Watch App/Views/DeviceConnectionView.swift | 10 +---------
Meshtastic Watch App/Views/FoxhuntCompassView.swift | 2 +-
2 files changed, 2 insertions(+), 10 deletions(-)
diff --git a/Meshtastic Watch App/Views/DeviceConnectionView.swift b/Meshtastic Watch App/Views/DeviceConnectionView.swift
index 6000f2e3..e7da814a 100644
--- a/Meshtastic Watch App/Views/DeviceConnectionView.swift
+++ b/Meshtastic Watch App/Views/DeviceConnectionView.swift
@@ -143,15 +143,7 @@ struct DeviceConnectionView: View {
@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)
+ Image(systemName: rssi > -85 ? "wifi" : "wifi.exclamationmark")
.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
index 58294b8f..b7117060 100644
--- a/Meshtastic Watch App/Views/FoxhuntCompassView.swift
+++ b/Meshtastic Watch App/Views/FoxhuntCompassView.swift
@@ -261,7 +261,7 @@ private struct WatchCompassLabel {
// MARK: - Color helper
extension Color {
- /// Quick luminance check so centre text is readable on the distance colour.
+ /// Quick luminance check so center text is readable on the distance color.
var isWatchLight: Bool {
// Approximate: yellow and lighter colours are "light"
if self == .yellow || self == .orange || self == .white { return true }
From 319f3303e2fe2bf410d0d8da8da18a2033deb6e3 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 19 Apr 2026 04:32:59 +0000
Subject: [PATCH 3/7] Add Meshtastic Watch App target to Xcode project
Agent-Logs-Url: https://github.com/meshtastic/Meshtastic-Apple/sessions/c00f322c-e5f5-40cc-8ade-2ce2e34693f6
Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com>
---
Meshtastic.xcodeproj/project.pbxproj | 219 +++++++++++++++++++++++++++
1 file changed, 219 insertions(+)
diff --git a/Meshtastic.xcodeproj/project.pbxproj b/Meshtastic.xcodeproj/project.pbxproj
index e9b19fea..30dbe152 100644
--- a/Meshtastic.xcodeproj/project.pbxproj
+++ b/Meshtastic.xcodeproj/project.pbxproj
@@ -327,6 +327,17 @@
AB4622DCF4B1D4115ED00312 /* SendMessageIntentHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5FCB3877F157D9011FA5C6CF /* SendMessageIntentHandler.swift */; };
B0E4EEF2D2C41A884A5E949C /* SearchForMessagesIntentHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E644AE784C52500A9241481 /* SearchForMessagesIntentHandler.swift */; };
9BC51D7EF97090D149658843 /* SetMessageAttributeIntentHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDA6A18109101FA06A9FBBFB /* SetMessageAttributeIntentHandler.swift */; };
+ AA0005WTCH00000000BF0001 /* MeshtasticWatchApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0005WTCH00000000FR0001 /* MeshtasticWatchApp.swift */; };
+ AA0005WTCH00000000BF0002 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0005WTCH00000000FR0002 /* ContentView.swift */; };
+ AA0005WTCH00000000BF0003 /* MeshNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0005WTCH00000000FR0003 /* MeshNode.swift */; };
+ AA0005WTCH00000000BF0004 /* WatchBLEManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0005WTCH00000000FR0004 /* WatchBLEManager.swift */; };
+ AA0005WTCH00000000BF0005 /* WatchLocationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0005WTCH00000000FR0005 /* WatchLocationManager.swift */; };
+ AA0005WTCH00000000BF0006 /* FoxhuntCompassView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0005WTCH00000000FR0006 /* FoxhuntCompassView.swift */; };
+ AA0005WTCH00000000BF0007 /* NearbyNodesListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0005WTCH00000000FR0007 /* NearbyNodesListView.swift */; };
+ AA0005WTCH00000000BF0008 /* DeviceConnectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0005WTCH00000000FR0008 /* DeviceConnectionView.swift */; };
+ AA0005WTCH00000000BF0009 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = AA0005WTCH00000000FR0009 /* Assets.xcassets */; };
+ AA0005WTCH00000000BF0010 /* MeshtasticProtobufs in Frameworks */ = {isa = PBXBuildFile; productRef = AA0005WTCH00000000PD0001 /* MeshtasticProtobufs */; };
+ AA0005WTCH00000000BF0011 /* Meshtastic Watch App.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = AA0005WTCH00000000FR0012 /* Meshtastic Watch App.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -344,6 +355,13 @@
remoteGlobalIDString = DDDE59F329AF163D00490C6C;
remoteInfo = WidgetsExtension;
};
+ AA0005WTCH00000000CX0001 /* PBXContainerItemProxy */ = {
+ isa = PBXContainerItemProxy;
+ containerPortal = DDC2E14C26CE248E0042C5E4 /* Project object */;
+ proxyType = 1;
+ remoteGlobalIDString = AA0005WTCH00000000NT0001;
+ remoteInfo = "Meshtastic Watch App";
+ };
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
@@ -358,6 +376,17 @@
name = "Embed Foundation Extensions";
runOnlyForDeploymentPostprocessing = 0;
};
+ AA0005WTCH00000000CP0001 /* Embed Watch Content */ = {
+ isa = PBXCopyFilesBuildPhase;
+ buildActionMask = 2147483647;
+ dstPath = "$(CONTENTS_FOLDER_PATH)/Watch";
+ dstSubfolderSpec = 16;
+ files = (
+ AA0005WTCH00000000BF0011 /* Meshtastic Watch App.app in Embed Watch Content */,
+ );
+ name = "Embed Watch Content";
+ runOnlyForDeploymentPostprocessing = 0;
+ };
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
@@ -744,6 +773,18 @@
5FCB3877F157D9011FA5C6CF /* SendMessageIntentHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendMessageIntentHandler.swift; sourceTree = ""; };
0E644AE784C52500A9241481 /* SearchForMessagesIntentHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchForMessagesIntentHandler.swift; sourceTree = ""; };
CDA6A18109101FA06A9FBBFB /* SetMessageAttributeIntentHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetMessageAttributeIntentHandler.swift; sourceTree = ""; };
+ AA0005WTCH00000000FR0001 /* MeshtasticWatchApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeshtasticWatchApp.swift; sourceTree = ""; };
+ AA0005WTCH00000000FR0002 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; };
+ AA0005WTCH00000000FR0003 /* MeshNode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeshNode.swift; sourceTree = ""; };
+ AA0005WTCH00000000FR0004 /* WatchBLEManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchBLEManager.swift; sourceTree = ""; };
+ AA0005WTCH00000000FR0005 /* WatchLocationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchLocationManager.swift; sourceTree = ""; };
+ AA0005WTCH00000000FR0006 /* FoxhuntCompassView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FoxhuntCompassView.swift; sourceTree = ""; };
+ AA0005WTCH00000000FR0007 /* NearbyNodesListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NearbyNodesListView.swift; sourceTree = ""; };
+ AA0005WTCH00000000FR0008 /* DeviceConnectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceConnectionView.swift; sourceTree = ""; };
+ AA0005WTCH00000000FR0009 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; };
+ AA0005WTCH00000000FR0010 /* Meshtastic Watch App.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "Meshtastic Watch App.entitlements"; sourceTree = ""; };
+ AA0005WTCH00000000FR0011 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; };
+ AA0005WTCH00000000FR0012 /* Meshtastic Watch App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Meshtastic Watch App.app"; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
@@ -783,6 +824,14 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
+ AA0005WTCH00000000FP0001 /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ AA0005WTCH00000000BF0010 /* MeshtasticProtobufs in Frameworks */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
@@ -1270,6 +1319,7 @@
DDC2E15626CE248E0042C5E4 /* Meshtastic */,
DDDE59F729AF163D00490C6C /* Widgets */,
25F5D5C82C4375A8008036E3 /* MeshtasticTests */,
+ AA0005WTCH00000000GR0001 /* Meshtastic Watch App */,
DDC2E15526CE248E0042C5E4 /* Products */,
DD8EDE9226F97A2B00A5A10B /* Frameworks */,
);
@@ -1282,6 +1332,7 @@
DDC2E15426CE248E0042C5E4 /* Meshtastic.app */,
DDDE59F429AF163D00490C6C /* WidgetsExtension.appex */,
25F5D5C72C4375A8008036E3 /* MeshtasticTests.xctest */,
+ AA0005WTCH00000000FR0012 /* Meshtastic Watch App.app */,
);
name = Products;
sourceTree = "";
@@ -1539,6 +1590,48 @@
path = Widgets;
sourceTree = "";
};
+ AA0005WTCH00000000GR0004 /* Views */ = {
+ isa = PBXGroup;
+ children = (
+ AA0005WTCH00000000FR0006 /* FoxhuntCompassView.swift */,
+ AA0005WTCH00000000FR0007 /* NearbyNodesListView.swift */,
+ AA0005WTCH00000000FR0008 /* DeviceConnectionView.swift */,
+ );
+ path = Views;
+ sourceTree = "";
+ };
+ AA0005WTCH00000000GR0002 /* Managers */ = {
+ isa = PBXGroup;
+ children = (
+ AA0005WTCH00000000FR0004 /* WatchBLEManager.swift */,
+ AA0005WTCH00000000FR0005 /* WatchLocationManager.swift */,
+ );
+ path = Managers;
+ sourceTree = "";
+ };
+ AA0005WTCH00000000GR0003 /* Models */ = {
+ isa = PBXGroup;
+ children = (
+ AA0005WTCH00000000FR0003 /* MeshNode.swift */,
+ );
+ path = Models;
+ sourceTree = "";
+ };
+ AA0005WTCH00000000GR0001 /* Meshtastic Watch App */ = {
+ isa = PBXGroup;
+ children = (
+ AA0005WTCH00000000FR0001 /* MeshtasticWatchApp.swift */,
+ AA0005WTCH00000000FR0002 /* ContentView.swift */,
+ AA0005WTCH00000000FR0010 /* Meshtastic Watch App.entitlements */,
+ AA0005WTCH00000000FR0011 /* Info.plist */,
+ AA0005WTCH00000000FR0009 /* Assets.xcassets */,
+ AA0005WTCH00000000GR0002 /* Managers */,
+ AA0005WTCH00000000GR0003 /* Models */,
+ AA0005WTCH00000000GR0004 /* Views */,
+ );
+ path = "Meshtastic Watch App";
+ sourceTree = "";
+ };
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
@@ -1569,11 +1662,13 @@
DDC2E15126CE248E0042C5E4 /* Frameworks */,
DDC2E15226CE248E0042C5E4 /* Resources */,
DDDE5A0829AF163F00490C6C /* Embed Foundation Extensions */,
+ AA0005WTCH00000000CP0001 /* Embed Watch Content */,
);
buildRules = (
);
dependencies = (
DDDE5A0229AF163E00490C6C /* PBXTargetDependency */,
+ AA0005WTCH00000000TD0001 /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = (
DD4C11E02E8099C3003F2F2E /* PreferenceKeys */,
@@ -1613,6 +1708,26 @@
productReference = DDDE59F429AF163D00490C6C /* WidgetsExtension.appex */;
productType = "com.apple.product-type.app-extension";
};
+ AA0005WTCH00000000NT0001 /* Meshtastic Watch App */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = AA0005WTCH00000000CL0001 /* Build configuration list for PBXNativeTarget "Meshtastic Watch App" */;
+ buildPhases = (
+ AA0005WTCH00000000SP0001 /* Sources */,
+ AA0005WTCH00000000FP0001 /* Frameworks */,
+ AA0005WTCH00000000RP0001 /* Resources */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ );
+ name = "Meshtastic Watch App";
+ packageProductDependencies = (
+ AA0005WTCH00000000PD0001 /* MeshtasticProtobufs */,
+ );
+ productName = "Meshtastic Watch App";
+ productReference = AA0005WTCH00000000FR0012 /* Meshtastic Watch App.app */;
+ productType = "com.apple.product-type.application";
+ };
/* End PBXNativeTarget section */
/* Begin PBXProject section */
@@ -1671,6 +1786,7 @@
DDC2E15326CE248E0042C5E4 /* Meshtastic */,
DDDE59F329AF163D00490C6C /* WidgetsExtension */,
25F5D5C62C4375A8008036E3 /* MeshtasticTests */,
+ AA0005WTCH00000000NT0001 /* Meshtastic Watch App */,
);
};
/* End PBXProject section */
@@ -1712,6 +1828,14 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
+ AA0005WTCH00000000RP0001 /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ AA0005WTCH00000000BF0009 /* Assets.xcassets in Resources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
@@ -2049,6 +2173,21 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
+ AA0005WTCH00000000SP0001 /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ AA0005WTCH00000000BF0001 /* MeshtasticWatchApp.swift in Sources */,
+ AA0005WTCH00000000BF0002 /* ContentView.swift in Sources */,
+ AA0005WTCH00000000BF0003 /* MeshNode.swift in Sources */,
+ AA0005WTCH00000000BF0004 /* WatchBLEManager.swift in Sources */,
+ AA0005WTCH00000000BF0005 /* WatchLocationManager.swift in Sources */,
+ AA0005WTCH00000000BF0006 /* FoxhuntCompassView.swift in Sources */,
+ AA0005WTCH00000000BF0007 /* NearbyNodesListView.swift in Sources */,
+ AA0005WTCH00000000BF0008 /* DeviceConnectionView.swift in Sources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
@@ -2063,6 +2202,11 @@
target = DDDE59F329AF163D00490C6C /* WidgetsExtension */;
targetProxy = DDDE5A0129AF163E00490C6C /* PBXContainerItemProxy */;
};
+ AA0005WTCH00000000TD0001 /* PBXTargetDependency */ = {
+ isa = PBXTargetDependency;
+ target = AA0005WTCH00000000NT0001 /* Meshtastic Watch App */;
+ targetProxy = AA0005WTCH00000000CX0001 /* PBXContainerItemProxy */;
+ };
/* End PBXTargetDependency section */
/* Begin PBXVariantGroup section */
@@ -2419,6 +2563,68 @@
};
name = Release;
};
+ AA0005WTCH00000000BC0001 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+ CODE_SIGN_ENTITLEMENTS = "Meshtastic Watch App/Meshtastic Watch App.entitlements";
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_FILE = "Meshtastic Watch App/Info.plist";
+ INFOPLIST_KEY_CFBundleDisplayName = "Meshtastic Foxhunt";
+ INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Meshtastic needs Bluetooth to connect directly to your Meshtastic radio for foxhunt direction finding.";
+ INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "Meshtastic needs your location to calculate distance and bearing to mesh nodes during foxhunt.";
+ INFOPLIST_KEY_WKRunsIndependentlyOfCompanionApp = YES;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ MARKETING_VERSION = 2.7.10;
+ PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient.watchkitapp;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SDKROOT = watchos;
+ SKIP_INSTALL = YES;
+ SUPPORTED_PLATFORMS = "watchos watchsimulator";
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = 4;
+ WATCHOS_DEPLOYMENT_TARGET = 10.0;
+ };
+ name = Debug;
+ };
+ AA0005WTCH00000000BC0002 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+ CODE_SIGN_ENTITLEMENTS = "Meshtastic Watch App/Meshtastic Watch App.entitlements";
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_FILE = "Meshtastic Watch App/Info.plist";
+ INFOPLIST_KEY_CFBundleDisplayName = "Meshtastic Foxhunt";
+ INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Meshtastic needs Bluetooth to connect directly to your Meshtastic radio for foxhunt direction finding.";
+ INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "Meshtastic needs your location to calculate distance and bearing to mesh nodes during foxhunt.";
+ INFOPLIST_KEY_WKRunsIndependentlyOfCompanionApp = YES;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ MARKETING_VERSION = 2.7.10;
+ PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient.watchkitapp;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SDKROOT = watchos;
+ SKIP_INSTALL = YES;
+ SUPPORTED_PLATFORMS = "watchos watchsimulator";
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = 4;
+ WATCHOS_DEPLOYMENT_TARGET = 10.0;
+ };
+ name = Release;
+ };
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
@@ -2458,6 +2664,15 @@
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
+ AA0005WTCH00000000CL0001 /* Build configuration list for PBXNativeTarget "Meshtastic Watch App" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ AA0005WTCH00000000BC0001 /* Debug */,
+ AA0005WTCH00000000BC0002 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
/* End XCConfigurationList section */
/* Begin XCLocalSwiftPackageReference section */
@@ -2539,6 +2754,10 @@
package = DD0D3D202A55CEB10066DB71 /* XCRemoteSwiftPackageReference "CocoaMQTT" */;
productName = CocoaMQTT;
};
+ AA0005WTCH00000000PD0001 /* MeshtasticProtobufs */ = {
+ isa = XCSwiftPackageProductDependency;
+ productName = MeshtasticProtobufs;
+ };
/* End XCSwiftPackageProductDependency section */
/* Begin XCVersionGroup section */
From 82f28301ca00b078d2f91f5a3aacd6644dfb30d3 Mon Sep 17 00:00:00 2001
From: Garth Vander Houwen
Date: Sun, 19 Apr 2026 20:26:45 -0700
Subject: [PATCH 4/7] working fox hunt prototype
---
Localizable.xcstrings | 7 +-
.../AppIcon.appiconset/Contents.json | 1 +
.../AppIcon.appiconset/watch-icon.png | Bin 0 -> 29726 bytes
.../custom.foxhunt.symbolset/Contents.json | 12 +
.../custom.foxhunt.svg | 66 ++++
.../logo-white.imageset/Contents.json | 12 +
.../logo-white.imageset/Mesh_Logo_White.svg | 12 +
Meshtastic Watch App/ContentView.swift | 14 +-
Meshtastic Watch App/Info.plist | 4 +-
.../Managers/PhoneConnectivityManager.swift | 144 +++++++
.../Managers/WatchBLEManager.swift | 374 ------------------
.../Meshtastic Watch App.entitlements | 2 -
Meshtastic Watch App/Models/MeshNode.swift | 4 +-
.../Views/DeviceConnectionView.swift | 170 +++-----
.../Views/FoxhuntCompassView.swift | 20 +-
.../Views/NearbyNodesListView.swift | 49 ++-
.../Views/WatchCircleText.swift | 38 ++
Meshtastic.xcodeproj/project.pbxproj | 38 +-
.../Accessory Manager/AccessoryManager.swift | 4 +
.../AppIntents/NavigateToNodeIntent.swift | 61 ---
Meshtastic/AppIntents/TracerouteIntent.swift | 27 --
.../custom.foxhunt.symbolset/Contents.json | 12 +
.../custom.foxhunt.svg | 66 ++++
Meshtastic/Helpers/BluetoothManager.swift | 27 --
Meshtastic/Helpers/EmojiOnlyTextField.swift | 105 -----
Meshtastic/Helpers/Preferences.swift | 33 --
.../Helpers/TAK/MeshToCoTConverter.swift | 271 -------------
Meshtastic/Helpers/WatchSessionManager.swift | 183 +++++++++
Meshtastic/MeshtasticApp.swift | 4 +
Meshtastic/ShowTime.swift | 0
.../Views/Nodes/Helpers/NodeDetail.swift | 12 +
Meshtastic/Views/Nodes/NodeRow.swift | 70 ----
32 files changed, 696 insertions(+), 1146 deletions(-)
create mode 100644 Meshtastic Watch App/Assets.xcassets/AppIcon.appiconset/watch-icon.png
create mode 100644 Meshtastic Watch App/Assets.xcassets/custom.foxhunt.symbolset/Contents.json
create mode 100644 Meshtastic Watch App/Assets.xcassets/custom.foxhunt.symbolset/custom.foxhunt.svg
create mode 100644 Meshtastic Watch App/Assets.xcassets/logo-white.imageset/Contents.json
create mode 100644 Meshtastic Watch App/Assets.xcassets/logo-white.imageset/Mesh_Logo_White.svg
create mode 100644 Meshtastic Watch App/Managers/PhoneConnectivityManager.swift
delete mode 100644 Meshtastic Watch App/Managers/WatchBLEManager.swift
create mode 100644 Meshtastic Watch App/Views/WatchCircleText.swift
delete mode 100644 Meshtastic/AppIntents/NavigateToNodeIntent.swift
delete mode 100644 Meshtastic/AppIntents/TracerouteIntent.swift
create mode 100644 Meshtastic/Assets.xcassets/custom.foxhunt.symbolset/Contents.json
create mode 100644 Meshtastic/Assets.xcassets/custom.foxhunt.symbolset/custom.foxhunt.svg
delete mode 100644 Meshtastic/Helpers/BluetoothManager.swift
delete mode 100644 Meshtastic/Helpers/EmojiOnlyTextField.swift
delete mode 100644 Meshtastic/Helpers/Preferences.swift
delete mode 100644 Meshtastic/Helpers/TAK/MeshToCoTConverter.swift
create mode 100644 Meshtastic/Helpers/WatchSessionManager.swift
delete mode 100644 Meshtastic/ShowTime.swift
delete mode 100644 Meshtastic/Views/Nodes/NodeRow.swift
diff --git a/Localizable.xcstrings b/Localizable.xcstrings
index 236ae149..69fd75e4 100644
--- a/Localizable.xcstrings
+++ b/Localizable.xcstrings
@@ -23584,6 +23584,9 @@
}
}
}
+ },
+ "Foxhunt on your watch" : {
+
},
"Frequency" : {
"localizations" : {
@@ -29153,10 +29156,10 @@
}
}
},
- "Loading..." : {
+ "Loading TAK config from the node." : {
},
- "Loading TAK config from the node." : {
+ "Loading..." : {
},
"Local Network Access" : {
diff --git a/Meshtastic Watch App/Assets.xcassets/AppIcon.appiconset/Contents.json b/Meshtastic Watch App/Assets.xcassets/AppIcon.appiconset/Contents.json
index d57ffc59..28a2189a 100644
--- a/Meshtastic Watch App/Assets.xcassets/AppIcon.appiconset/Contents.json
+++ b/Meshtastic Watch App/Assets.xcassets/AppIcon.appiconset/Contents.json
@@ -1,6 +1,7 @@
{
"images" : [
{
+ "filename" : "watch-icon.png",
"idiom" : "universal",
"platform" : "watchOS",
"size" : "1024x1024"
diff --git a/Meshtastic Watch App/Assets.xcassets/AppIcon.appiconset/watch-icon.png b/Meshtastic Watch App/Assets.xcassets/AppIcon.appiconset/watch-icon.png
new file mode 100644
index 0000000000000000000000000000000000000000..b991d25831fae60a162ad1b09023eb24fabf71d5
GIT binary patch
literal 29726
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
+
+
+
diff --git a/Meshtastic Watch App/Assets.xcassets/logo-white.imageset/Contents.json b/Meshtastic Watch App/Assets.xcassets/logo-white.imageset/Contents.json
new file mode 100644
index 00000000..c4481011
--- /dev/null
+++ b/Meshtastic Watch App/Assets.xcassets/logo-white.imageset/Contents.json
@@ -0,0 +1,12 @@
+{
+ "images" : [
+ {
+ "filename" : "Mesh_Logo_White.svg",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Meshtastic Watch App/Assets.xcassets/logo-white.imageset/Mesh_Logo_White.svg b/Meshtastic Watch App/Assets.xcassets/logo-white.imageset/Mesh_Logo_White.svg
new file mode 100644
index 00000000..b1bcd575
--- /dev/null
+++ b/Meshtastic Watch App/Assets.xcassets/logo-white.imageset/Mesh_Logo_White.svg
@@ -0,0 +1,12 @@
+
+
+
diff --git a/Meshtastic Watch App/ContentView.swift b/Meshtastic Watch App/ContentView.swift
index 1ff0e215..56069b36 100644
--- a/Meshtastic Watch App/ContentView.swift
+++ b/Meshtastic Watch App/ContentView.swift
@@ -11,23 +11,19 @@ import SwiftUI
///
/// Uses a tab-based layout:
/// 1. **Foxhunt** β nearby nodes list β compass
-/// 2. **Radio** β BLE device connection
+/// 2. **Phone** β companion phone connectivity status
struct ContentView: View {
- @StateObject private var bleManager = WatchBLEManager()
+ @StateObject private var phoneManager = PhoneConnectivityManager()
@StateObject private var locationManager = WatchLocationManager()
var body: some View {
TabView {
// Tab 1: Foxhunt
- NavigationStack {
- NearbyNodesListView(bleManager: bleManager, locationManager: locationManager)
- }
+ NearbyNodesListView(phoneManager: phoneManager, locationManager: locationManager)
- // Tab 2: Radio connection
- NavigationStack {
- DeviceConnectionView(bleManager: bleManager)
- }
+ // Tab 2: Phone connectivity
+ DeviceConnectionView(phoneManager: phoneManager)
}
.tabViewStyle(.verticalPage)
.onAppear {
diff --git a/Meshtastic Watch App/Info.plist b/Meshtastic Watch App/Info.plist
index a27ae3d4..32b378eb 100644
--- a/Meshtastic Watch App/Info.plist
+++ b/Meshtastic Watch App/Info.plist
@@ -2,13 +2,11 @@
- 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/PhoneConnectivityManager.swift b/Meshtastic Watch App/Managers/PhoneConnectivityManager.swift
new file mode 100644
index 00000000..d9df88f2
--- /dev/null
+++ b/Meshtastic Watch App/Managers/PhoneConnectivityManager.swift
@@ -0,0 +1,144 @@
+//
+// PhoneConnectivityManager.swift
+// Meshtastic Watch App
+//
+// Copyright(c) Meshtastic 2025.
+//
+
+import Foundation
+import WatchConnectivity
+import os
+
+/// Receives mesh node data from the companion iOS app via WatchConnectivity.
+///
+/// The iOS app pushes node updates using `updateApplicationContext(_:)`.
+/// The watch can also request a refresh by sending a message.
+@MainActor
+final class PhoneConnectivityManager: NSObject, ObservableObject {
+
+ // MARK: - Published state
+
+ /// All mesh nodes received from the phone, keyed by node number.
+ @Published var nodes: [UInt32: MeshNode] = [:]
+
+ /// Whether the companion iPhone is reachable right now.
+ @Published var isPhoneReachable = false
+
+ /// Whether we have received at least one update from the phone.
+ @Published var hasReceivedData = false
+
+ /// Node numbers pinned as foxhunt targets from the iOS app.
+ @Published var foxhuntTargets: Set = []
+
+ // MARK: - Private
+
+ private let logger = Logger(subsystem: "gvh.MeshtasticClient.watchkitapp", category: "π± Phone")
+ private var session: WCSession?
+
+ // MARK: - Lifecycle
+
+ override init() {
+ super.init()
+ guard WCSession.isSupported() else {
+ logger.warning("WCSession is not supported on this device")
+ return
+ }
+ let session = WCSession.default
+ session.delegate = self
+ session.activate()
+ self.session = session
+ logger.info("WCSession activated")
+ }
+
+ // MARK: - Public API
+
+ /// Ask the phone to send fresh node data.
+ func requestRefresh() {
+ guard let session, session.isReachable else {
+ logger.warning("Cannot request refresh β phone not reachable")
+ return
+ }
+ session.sendMessage(["request": "refreshNodes"], replyHandler: nil) { error in
+ Task { @MainActor in
+ self.logger.error("Failed to request refresh: \(error.localizedDescription, privacy: .public)")
+ }
+ }
+ logger.info("Requested node refresh from phone")
+ }
+
+ // MARK: - Decoding
+
+ private func decodeNodes(from context: [String: Any]) {
+ // Handle foxhunt target messages
+ if let targetNum = context["foxhuntTarget"] as? UInt32 {
+ foxhuntTargets.insert(targetNum)
+ logger.info("Added foxhunt target: \(targetNum)")
+ return
+ }
+
+ guard let data = context["nodes"] as? Data else {
+ logger.warning("No 'nodes' key in application context")
+ return
+ }
+ do {
+ let decoded = try JSONDecoder().decode([MeshNode].self, from: data)
+ var nodeDict: [UInt32: MeshNode] = [:]
+ for node in decoded {
+ nodeDict[node.num] = node
+ }
+ nodes = nodeDict
+ hasReceivedData = true
+ logger.info("Decoded \(decoded.count) nodes from phone")
+ } catch {
+ logger.error("Failed to decode nodes: \(error.localizedDescription, privacy: .public)")
+ }
+ }
+}
+
+// MARK: - WCSessionDelegate
+extension PhoneConnectivityManager: @preconcurrency WCSessionDelegate {
+
+ nonisolated func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
+ Task { @MainActor in
+ if let error {
+ logger.error("WCSession activation failed: \(error.localizedDescription, privacy: .public)")
+ } else {
+ logger.info("WCSession activation complete (state=\(activationState.rawValue))")
+ isPhoneReachable = session.isReachable
+
+ // Load any existing application context
+ if !session.receivedApplicationContext.isEmpty {
+ decodeNodes(from: session.receivedApplicationContext)
+ }
+ }
+ }
+ }
+
+ nonisolated func sessionReachabilityDidChange(_ session: WCSession) {
+ Task { @MainActor in
+ isPhoneReachable = session.isReachable
+ logger.info("Phone reachability changed: \(session.isReachable)")
+ if session.isReachable && !hasReceivedData {
+ requestRefresh()
+ }
+ }
+ }
+
+ nonisolated func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String: Any]) {
+ Task { @MainActor in
+ decodeNodes(from: applicationContext)
+ }
+ }
+
+ nonisolated func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any] = [:]) {
+ Task { @MainActor in
+ decodeNodes(from: userInfo)
+ }
+ }
+
+ nonisolated func session(_ session: WCSession, didReceiveMessage message: [String: Any]) {
+ Task { @MainActor in
+ decodeNodes(from: message)
+ }
+ }
+}
diff --git a/Meshtastic Watch App/Managers/WatchBLEManager.swift b/Meshtastic Watch App/Managers/WatchBLEManager.swift
deleted file mode 100644
index 4d121d6b..00000000
--- a/Meshtastic Watch App/Managers/WatchBLEManager.swift
+++ /dev/null
@@ -1,374 +0,0 @@
-//
-// 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/Meshtastic Watch App.entitlements b/Meshtastic Watch App/Meshtastic Watch App.entitlements
index 800f23d0..b8cf6f9e 100644
--- a/Meshtastic Watch App/Meshtastic Watch App.entitlements
+++ b/Meshtastic Watch App/Meshtastic Watch App.entitlements
@@ -2,8 +2,6 @@
- com.apple.security.device.bluetooth
-
com.apple.security.personal-information.location
diff --git a/Meshtastic Watch App/Models/MeshNode.swift b/Meshtastic Watch App/Models/MeshNode.swift
index 3929d38e..9b1877b1 100644
--- a/Meshtastic Watch App/Models/MeshNode.swift
+++ b/Meshtastic Watch App/Models/MeshNode.swift
@@ -9,8 +9,8 @@ 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 {
+/// Transferred from the companion iOS app via WatchConnectivity.
+struct MeshNode: Identifiable, Equatable, Codable {
/// Meshtastic node number (unique on the mesh).
let num: UInt32
/// Stable identifier derived from the node number.
diff --git a/Meshtastic Watch App/Views/DeviceConnectionView.swift b/Meshtastic Watch App/Views/DeviceConnectionView.swift
index e7da814a..0abb3821 100644
--- a/Meshtastic Watch App/Views/DeviceConnectionView.swift
+++ b/Meshtastic Watch App/Views/DeviceConnectionView.swift
@@ -7,144 +7,64 @@
import SwiftUI
-/// View for scanning and connecting to a Meshtastic BLE radio directly
-/// from the Apple Watch (no phone required).
+/// Shows the connectivity status between the Watch and the companion
+/// iPhone app. Node data is received via WatchConnectivity.
struct DeviceConnectionView: View {
- @ObservedObject var bleManager: WatchBLEManager
+ @ObservedObject var phoneManager: PhoneConnectivityManager
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)
+ VStack(spacing: 12) {
+ if phoneManager.isPhoneReachable {
+ reachableView
+ } else {
+ unreachableView
}
}
.padding()
+ .navigationTitle("Phone")
}
- // MARK: - Connected
+ // MARK: - Phone Reachable
@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")
+ private var reachableView: some View {
+ Image(systemName: "iphone.radiowaves.left.and.right")
+ .font(.title2)
+ .foregroundStyle(.green)
+ Text("Phone Connected")
+ .font(.headline)
+ Text("\(phoneManager.nodes.count) nodes")
+ .font(.caption2)
+ .foregroundStyle(.secondary)
+
+ Button {
+ phoneManager.requestRefresh()
+ } label: {
+ Label("Refresh", systemImage: "arrow.clockwise")
+ }
+ .buttonStyle(.bordered)
+ }
+
+ // MARK: - Phone Unreachable
+
+ @ViewBuilder
+ private var unreachableView: some View {
+ Image(systemName: "iphone.slash")
+ .font(.title2)
+ .foregroundStyle(.secondary)
+ Text("Phone Not Reachable")
+ .font(.headline)
+
+ if phoneManager.hasReceivedData {
+ Text("\(phoneManager.nodes.count) cached nodes")
.font(.caption2)
.foregroundStyle(.secondary)
-
- Button(role: .destructive) {
- bleManager.disconnect()
- } label: {
- Label("Disconnect", systemImage: "xmark.circle")
- }
- .buttonStyle(.bordered)
+ } else {
+ Text("Open Meshtastic on your iPhone to sync node data.")
+ .font(.caption2)
+ .foregroundStyle(.secondary)
+ .multilineTextAlignment(.center)
}
- .padding()
- }
-
- // MARK: - Helpers
-
- @ViewBuilder
- private func signalIcon(rssi: Int) -> some View {
- Image(systemName: rssi > -85 ? "wifi" : "wifi.exclamationmark")
- .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
index b7117060..6d60e3cf 100644
--- a/Meshtastic Watch App/Views/FoxhuntCompassView.swift
+++ b/Meshtastic Watch App/Views/FoxhuntCompassView.swift
@@ -34,14 +34,15 @@ struct FoxhuntCompassView: View {
var body: some View {
GeometryReader { geometry in
let size = min(geometry.size.width, geometry.size.height)
- let dialRadius = size * 0.38
+ let dialRadius = size * 0.44
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)
+ // Node short name circle
+ WatchCircleText(
+ text: node.shortName.isEmpty ? "?" : node.shortName,
+ color: WatchCircleText.color(for: node.num),
+ circleSize: 32
+ )
ZStack {
// Fixed heading indicator at top
@@ -55,7 +56,7 @@ struct FoxhuntCompassView: View {
ZStack {
// Outer ring
Circle()
- .stroke(Color.primary.opacity(0.15), lineWidth: 1)
+ .stroke(Color.primary.opacity(0.3), lineWidth: 3)
.frame(width: dialRadius * 2 + 8, height: dialRadius * 2 + 8)
// Tick marks (every 10Β° for watch readability)
@@ -265,6 +266,9 @@ extension Color {
var isWatchLight: Bool {
// Approximate: yellow and lighter colours are "light"
if self == .yellow || self == .orange || self == .white { return true }
- return false
+ // For arbitrary colours, resolve RGBA and compute relative luminance
+ guard let components = cgColor?.components, components.count >= 3 else { return false }
+ let luminance = 0.299 * components[0] + 0.587 * components[1] + 0.114 * components[2]
+ return luminance > 0.6
}
}
diff --git a/Meshtastic Watch App/Views/NearbyNodesListView.swift b/Meshtastic Watch App/Views/NearbyNodesListView.swift
index de99336c..4192292e 100644
--- a/Meshtastic Watch App/Views/NearbyNodesListView.swift
+++ b/Meshtastic Watch App/Views/NearbyNodesListView.swift
@@ -12,19 +12,28 @@ import CoreLocation
/// position. Tapping a node opens the foxhunt compass pointing at it.
struct NearbyNodesListView: View {
- @ObservedObject var bleManager: WatchBLEManager
+ @ObservedObject var phoneManager: PhoneConnectivityManager
@ObservedObject var locationManager: WatchLocationManager
+ @State private var selectedNode: MeshNode?
/// Nodes filtered to β€ 0.5 miles with a known position, sorted by distance.
+ /// Also includes any nodes pinned as foxhunt targets from the iOS app.
private var nearbyNodes: [MeshNode] {
guard let userLoc = locationManager.currentLocation else { return [] }
- return bleManager.nodes.values
+ let targets = phoneManager.foxhuntTargets
+ return phoneManager.nodes.values
.filter { node in
- guard node.coordinate != nil,
- let dist = node.distance(from: userLoc) else { return false }
+ guard node.coordinate != nil else { return false }
+ // Always include foxhunt targets regardless of distance
+ if targets.contains(node.num) { return true }
+ guard let dist = node.distance(from: userLoc) else { return false }
return dist <= FoxhuntCompassView.maxDistanceMetres
}
.sorted { a, b in
+ let aIsTarget = targets.contains(a.num)
+ let bIsTarget = targets.contains(b.num)
+ // Foxhunt targets sort first
+ if aIsTarget != bIsTarget { return aIsTarget }
let dA = a.distance(from: userLoc) ?? .greatestFiniteMagnitude
let dB = b.distance(from: userLoc) ?? .greatestFiniteMagnitude
return dA < dB
@@ -39,7 +48,23 @@ struct NearbyNodesListView: View {
nodeList
}
}
- .navigationTitle("Foxhunt")
+ .navigationTitle {
+ HStack(spacing: 4) {
+ Image("logo-white")
+ .resizable()
+ .scaledToFit()
+ .frame(height: 16)
+ Image("custom.foxhunt")
+ .font(.system(size: 14))
+ .foregroundStyle(.orange)
+ Text("Foxhunt")
+ .font(.headline)
+ .foregroundStyle(.green)
+ }
+ }
+ .sheet(item: $selectedNode) { node in
+ FoxhuntCompassView(node: node, locationManager: locationManager)
+ }
}
// MARK: - Sub-views
@@ -58,8 +83,8 @@ struct NearbyNodesListView: View {
.multilineTextAlignment(.center)
.padding(.horizontal)
- if bleManager.connectionState != .connected {
- Text("Connect to a radio first.")
+ if !phoneManager.hasReceivedData {
+ Text("Open Meshtastic on your iPhone to sync.")
.font(.caption2)
.foregroundStyle(.orange)
}
@@ -70,7 +95,9 @@ struct NearbyNodesListView: View {
@ViewBuilder
private var nodeList: some View {
List(nearbyNodes) { node in
- NavigationLink(destination: FoxhuntCompassView(node: node, locationManager: locationManager)) {
+ Button {
+ selectedNode = node
+ } label: {
nodeRow(node)
}
}
@@ -79,7 +106,13 @@ struct NearbyNodesListView: View {
@ViewBuilder
private func nodeRow(_ node: MeshNode) -> some View {
let userLoc = locationManager.currentLocation
+ let isTarget = phoneManager.foxhuntTargets.contains(node.num)
HStack {
+ WatchCircleText(
+ text: node.shortName,
+ color: WatchCircleText.color(for: node.num),
+ circleSize: 28
+ )
VStack(alignment: .leading, spacing: 2) {
Text(node.longName)
.font(.system(size: 14, weight: .semibold))
diff --git a/Meshtastic Watch App/Views/WatchCircleText.swift b/Meshtastic Watch App/Views/WatchCircleText.swift
new file mode 100644
index 00000000..daede796
--- /dev/null
+++ b/Meshtastic Watch App/Views/WatchCircleText.swift
@@ -0,0 +1,38 @@
+//
+// WatchCircleText.swift
+// Meshtastic Watch App
+//
+// Copyright(c) Meshtastic 2025.
+//
+
+import SwiftUI
+
+/// A small circle showing the node's short name, colored by node number.
+/// Watch-only equivalent of the iOS `CircleText` view.
+struct WatchCircleText: View {
+ var text: String
+ var color: Color
+ var circleSize: CGFloat = 28
+
+ var body: some View {
+ ZStack {
+ Circle()
+ .fill(color)
+ .frame(width: circleSize, height: circleSize)
+ Text(text)
+ .frame(width: circleSize * 0.9, height: circleSize * 0.9, alignment: .center)
+ .foregroundColor(color.isWatchLight ? .black : .white)
+ .minimumScaleFactor(0.001)
+ .font(.system(size: 1300))
+ }
+ }
+
+ /// Derives a `Color` from a Meshtastic node number, matching the iOS
+ /// `UIColor(hex:)` algorithm so circles look the same on both platforms.
+ static func color(for nodeNum: UInt32) -> Color {
+ let red = Double((nodeNum & 0xFF0000) >> 16) / 255.0
+ let green = Double((nodeNum & 0x00FF00) >> 8) / 255.0
+ let blue = Double(nodeNum & 0x0000FF) / 255.0
+ return Color(red: red, green: green, blue: blue)
+ }
+}
diff --git a/Meshtastic.xcodeproj/project.pbxproj b/Meshtastic.xcodeproj/project.pbxproj
index 30dbe152..98fd6628 100644
--- a/Meshtastic.xcodeproj/project.pbxproj
+++ b/Meshtastic.xcodeproj/project.pbxproj
@@ -93,6 +93,7 @@
3D3417B42E2730EC006A988B /* GeoJSONOverlayManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D3417B32E2730EC006A988B /* GeoJSONOverlayManager.swift */; };
3D3417C82E29D38A006A988B /* GeoJSONOverlayConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D3417C72E29D38A006A988B /* GeoJSONOverlayConfig.swift */; };
3D3417D22E2DC260006A988B /* MapDataManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D3417D12E2DC260006A988B /* MapDataManager.swift */; };
+ AA0006WTSM00000000BF0001 /* WatchSessionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0006WTSM00000000FR0001 /* WatchSessionManager.swift */; };
3D3417D42E2DC293006A988B /* MapDataFiles.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D3417D32E2DC293006A988B /* MapDataFiles.swift */; };
655AF7816E76D5F310DF87A6 /* FountainCodec.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F203877F307073096C89179 /* FountainCodec.swift */; };
6D825E622C34786C008DBEE4 /* CommonRegex.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D825E612C34786C008DBEE4 /* CommonRegex.swift */; };
@@ -330,13 +331,14 @@
AA0005WTCH00000000BF0001 /* MeshtasticWatchApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0005WTCH00000000FR0001 /* MeshtasticWatchApp.swift */; };
AA0005WTCH00000000BF0002 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0005WTCH00000000FR0002 /* ContentView.swift */; };
AA0005WTCH00000000BF0003 /* MeshNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0005WTCH00000000FR0003 /* MeshNode.swift */; };
- AA0005WTCH00000000BF0004 /* WatchBLEManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0005WTCH00000000FR0004 /* WatchBLEManager.swift */; };
+ AA0005WTCH00000000BF0004 /* PhoneConnectivityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0005WTCH00000000FR0004 /* PhoneConnectivityManager.swift */; };
AA0005WTCH00000000BF0005 /* WatchLocationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0005WTCH00000000FR0005 /* WatchLocationManager.swift */; };
AA0005WTCH00000000BF0006 /* FoxhuntCompassView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0005WTCH00000000FR0006 /* FoxhuntCompassView.swift */; };
AA0005WTCH00000000BF0007 /* NearbyNodesListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0005WTCH00000000FR0007 /* NearbyNodesListView.swift */; };
AA0005WTCH00000000BF0008 /* DeviceConnectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0005WTCH00000000FR0008 /* DeviceConnectionView.swift */; };
+ AA0005WTCH00000000BF0013 /* WatchCircleText.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0005WTCH00000000FR0013 /* WatchCircleText.swift */; };
AA0005WTCH00000000BF0009 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = AA0005WTCH00000000FR0009 /* Assets.xcassets */; };
- AA0005WTCH00000000BF0010 /* MeshtasticProtobufs in Frameworks */ = {isa = PBXBuildFile; productRef = AA0005WTCH00000000PD0001 /* MeshtasticProtobufs */; };
+
AA0005WTCH00000000BF0011 /* Meshtastic Watch App.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = AA0005WTCH00000000FR0012 /* Meshtastic Watch App.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
/* End PBXBuildFile section */
@@ -482,6 +484,7 @@
3D3417B32E2730EC006A988B /* GeoJSONOverlayManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeoJSONOverlayManager.swift; sourceTree = ""; };
3D3417C72E29D38A006A988B /* GeoJSONOverlayConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeoJSONOverlayConfig.swift; sourceTree = ""; };
3D3417D12E2DC260006A988B /* MapDataManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapDataManager.swift; sourceTree = ""; };
+ AA0006WTSM00000000FR0001 /* WatchSessionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchSessionManager.swift; sourceTree = ""; };
3D3417D32E2DC293006A988B /* MapDataFiles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapDataFiles.swift; sourceTree = ""; };
3F203877F307073096C89179 /* FountainCodec.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FountainCodec.swift; sourceTree = ""; };
4AA216CF50721EE1AE7D7251 /* CoTMessage.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CoTMessage.swift; sourceTree = ""; };
@@ -776,11 +779,12 @@
AA0005WTCH00000000FR0001 /* MeshtasticWatchApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeshtasticWatchApp.swift; sourceTree = ""; };
AA0005WTCH00000000FR0002 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; };
AA0005WTCH00000000FR0003 /* MeshNode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeshNode.swift; sourceTree = ""; };
- AA0005WTCH00000000FR0004 /* WatchBLEManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchBLEManager.swift; sourceTree = ""; };
+ AA0005WTCH00000000FR0004 /* PhoneConnectivityManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhoneConnectivityManager.swift; sourceTree = ""; };
AA0005WTCH00000000FR0005 /* WatchLocationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchLocationManager.swift; sourceTree = ""; };
AA0005WTCH00000000FR0006 /* FoxhuntCompassView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FoxhuntCompassView.swift; sourceTree = ""; };
AA0005WTCH00000000FR0007 /* NearbyNodesListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NearbyNodesListView.swift; sourceTree = ""; };
AA0005WTCH00000000FR0008 /* DeviceConnectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceConnectionView.swift; sourceTree = ""; };
+ AA0005WTCH00000000FR0013 /* WatchCircleText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchCircleText.swift; sourceTree = ""; };
AA0005WTCH00000000FR0009 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; };
AA0005WTCH00000000FR0010 /* Meshtastic Watch App.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "Meshtastic Watch App.entitlements"; sourceTree = ""; };
AA0005WTCH00000000FR0011 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; };
@@ -828,7 +832,6 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
- AA0005WTCH00000000BF0010 /* MeshtasticProtobufs in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -1476,6 +1479,7 @@
6D825E612C34786C008DBEE4 /* CommonRegex.swift */,
3D3417B32E2730EC006A988B /* GeoJSONOverlayManager.swift */,
C37572859BC745C4284A9B42 /* TAK */,
+ AA0006WTSM00000000FR0001 /* WatchSessionManager.swift */,
);
path = Helpers;
sourceTree = "";
@@ -1596,6 +1600,7 @@
AA0005WTCH00000000FR0006 /* FoxhuntCompassView.swift */,
AA0005WTCH00000000FR0007 /* NearbyNodesListView.swift */,
AA0005WTCH00000000FR0008 /* DeviceConnectionView.swift */,
+ AA0005WTCH00000000FR0013 /* WatchCircleText.swift */,
);
path = Views;
sourceTree = "";
@@ -1603,7 +1608,7 @@
AA0005WTCH00000000GR0002 /* Managers */ = {
isa = PBXGroup;
children = (
- AA0005WTCH00000000FR0004 /* WatchBLEManager.swift */,
+ AA0005WTCH00000000FR0004 /* PhoneConnectivityManager.swift */,
AA0005WTCH00000000FR0005 /* WatchLocationManager.swift */,
);
path = Managers;
@@ -1721,9 +1726,6 @@
dependencies = (
);
name = "Meshtastic Watch App";
- packageProductDependencies = (
- AA0005WTCH00000000PD0001 /* MeshtasticProtobufs */,
- );
productName = "Meshtastic Watch App";
productReference = AA0005WTCH00000000FR0012 /* Meshtastic Watch App.app */;
productType = "com.apple.product-type.application";
@@ -2082,6 +2084,7 @@
DDB6ABE428B13FFF00384BA1 /* DisplayEnums.swift in Sources */,
DD4975A52B147BA90026544E /* AmbientLightingConfig.swift in Sources */,
3D3417D22E2DC260006A988B /* MapDataManager.swift in Sources */,
+ AA0006WTSM00000000BF0001 /* WatchSessionManager.swift in Sources */,
D93068D92B81509C0066FBC8 /* TapbackResponses.swift in Sources */,
D93068DA2B81509D0066FBC8 /* TapbackInputView.swift in Sources */,
DDA9F5E82E77FAC100E70DEB /* AnimatedNodePin.swift in Sources */,
@@ -2180,11 +2183,12 @@
AA0005WTCH00000000BF0001 /* MeshtasticWatchApp.swift in Sources */,
AA0005WTCH00000000BF0002 /* ContentView.swift in Sources */,
AA0005WTCH00000000BF0003 /* MeshNode.swift in Sources */,
- AA0005WTCH00000000BF0004 /* WatchBLEManager.swift in Sources */,
+ AA0005WTCH00000000BF0004 /* PhoneConnectivityManager.swift in Sources */,
AA0005WTCH00000000BF0005 /* WatchLocationManager.swift in Sources */,
AA0005WTCH00000000BF0006 /* FoxhuntCompassView.swift in Sources */,
AA0005WTCH00000000BF0007 /* NearbyNodesListView.swift in Sources */,
AA0005WTCH00000000BF0008 /* DeviceConnectionView.swift in Sources */,
+ AA0005WTCH00000000BF0013 /* WatchCircleText.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -2574,9 +2578,9 @@
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "Meshtastic Watch App/Info.plist";
INFOPLIST_KEY_CFBundleDisplayName = "Meshtastic Foxhunt";
- INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Meshtastic needs Bluetooth to connect directly to your Meshtastic radio for foxhunt direction finding.";
INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "Meshtastic needs your location to calculate distance and bearing to mesh nodes during foxhunt.";
- INFOPLIST_KEY_WKRunsIndependentlyOfCompanionApp = YES;
+ INFOPLIST_KEY_WKCompanionAppBundleIdentifier = gvh.MeshtasticClient;
+ INFOPLIST_KEY_WKRunsIndependentlyOfCompanionApp = NO;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@@ -2585,7 +2589,7 @@
PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient.watchkitapp;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = watchos;
- SKIP_INSTALL = YES;
+ SKIP_INSTALL = NO;
SUPPORTED_PLATFORMS = "watchos watchsimulator";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
@@ -2605,9 +2609,9 @@
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "Meshtastic Watch App/Info.plist";
INFOPLIST_KEY_CFBundleDisplayName = "Meshtastic Foxhunt";
- INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Meshtastic needs Bluetooth to connect directly to your Meshtastic radio for foxhunt direction finding.";
INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "Meshtastic needs your location to calculate distance and bearing to mesh nodes during foxhunt.";
- INFOPLIST_KEY_WKRunsIndependentlyOfCompanionApp = YES;
+ INFOPLIST_KEY_WKCompanionAppBundleIdentifier = gvh.MeshtasticClient;
+ INFOPLIST_KEY_WKRunsIndependentlyOfCompanionApp = NO;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@@ -2616,7 +2620,7 @@
PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient.watchkitapp;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = watchos;
- SKIP_INSTALL = YES;
+ SKIP_INSTALL = NO;
SUPPORTED_PLATFORMS = "watchos watchsimulator";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
@@ -2754,10 +2758,6 @@
package = DD0D3D202A55CEB10066DB71 /* XCRemoteSwiftPackageReference "CocoaMQTT" */;
productName = CocoaMQTT;
};
- AA0005WTCH00000000PD0001 /* MeshtasticProtobufs */ = {
- isa = XCSwiftPackageProductDependency;
- productName = MeshtasticProtobufs;
- };
/* End XCSwiftPackageProductDependency section */
/* Begin XCVersionGroup section */
diff --git a/Meshtastic/Accessory/Accessory Manager/AccessoryManager.swift b/Meshtastic/Accessory/Accessory Manager/AccessoryManager.swift
index 551ce3dd..5188af37 100644
--- a/Meshtastic/Accessory/Accessory Manager/AccessoryManager.swift
+++ b/Meshtastic/Accessory/Accessory Manager/AccessoryManager.swift
@@ -532,6 +532,7 @@ class AccessoryManager: ObservableObject, MqttClientProxyManagerDelegate {
Logger.mesh.info("πΈοΈ MESH PACKET received for Remote Hardware App UNHANDLED \((try? decodedInfo.packet.jsonString()) ?? "JSON Decode Failure", privacy: .public)")
case .positionApp:
await MeshPackets.shared.upsertPositionPacket(packet: packet)
+ WatchSessionManager.shared.sendNodesToWatch()
// Broadcast position to TAK clients
if let position = try? Position(serializedBytes: data.payload) {
Logger.tak.debug("Position received, calling broadcast")
@@ -738,6 +739,9 @@ class AccessoryManager: ObservableObject, MqttClientProxyManagerDelegate {
do {
try context.save()
Logger.data.info("πΎ [Database] Batch saved all node info after database retrieval")
+
+ // Push updated node data to the companion Watch app
+ WatchSessionManager.shared.sendNodesToWatch()
} catch {
context.rollback()
let nsError = error as NSError
diff --git a/Meshtastic/AppIntents/NavigateToNodeIntent.swift b/Meshtastic/AppIntents/NavigateToNodeIntent.swift
deleted file mode 100644
index 9559796c..00000000
--- a/Meshtastic/AppIntents/NavigateToNodeIntent.swift
+++ /dev/null
@@ -1,61 +0,0 @@
-//
-// NavigateToNodeIntent.swift
-// Meshtastic
-//
-// Created by Benjamin Faershtein on 2/8/25.
-//
-
-import Foundation
-import AppIntents
-import CoreLocation
-import CoreData
-import UIKit
-
-@available(iOS 16.4, *)
-struct NavigateToNodeIntent: ForegroundContinuableIntent {
-
- static var title: LocalizedStringResource = "Navigate to Node Position"
- static var openAppWhenRun: Bool = false
-
- @Parameter(title: "Node Number")
- var nodeNum: Int
-
- @MainActor
- func perform() async throws -> some IntentResult & ProvidesDialog {
- if !BLEManager.shared.isConnected {
- throw AppIntentErrors.AppIntentError.notConnected
- }
-
- let fetchNodeInfoRequest: NSFetchRequest = NSFetchRequest(entityName: "NodeInfoEntity")
- fetchNodeInfoRequest.predicate = NSPredicate(format: "num == %lld", Int64(nodeNum))
-
- do {
- guard let fetchedNode = try PersistenceController.shared.container.viewContext.fetch(fetchNodeInfoRequest) as? [NodeInfoEntity],
- fetchedNode.count == 1 else {
- throw $nodeNum.needsValueError("Could not find node")
- }
-
- let nodeInfo = fetchedNode[0]
- if let latitude = nodeInfo.latestPosition?.coordinate.latitude,
- let longitude = nodeInfo.latestPosition?.coordinate.longitude {
-
- let url = URL(string: "maps://?saddr=&daddr=\(latitude),\(longitude)")
-
- if let mapURL = url, UIApplication.shared.canOpenURL(mapURL) {
- // Request to continue in foreground before opening the app
- try await requestToContinueInForeground()
-
- // Open Apple Maps for navigation
- UIApplication.shared.open(mapURL, options: [:], completionHandler: nil)
- return .result(dialog: "Navigating to node location.")
- } else {
- throw AppIntentErrors.AppIntentError.message("Unable to open Apple Maps.")
- }
- } else {
- throw AppIntentErrors.AppIntentError.message("Node does not have a recorded position.")
- }
- } catch {
- throw AppIntentErrors.AppIntentError.message("Failed to fetch node data.")
- }
- }
-}
diff --git a/Meshtastic/AppIntents/TracerouteIntent.swift b/Meshtastic/AppIntents/TracerouteIntent.swift
deleted file mode 100644
index 99c19348..00000000
--- a/Meshtastic/AppIntents/TracerouteIntent.swift
+++ /dev/null
@@ -1,27 +0,0 @@
-import Foundation
-import AppIntents
-
-struct TracerouteIntent: AppIntent {
- static var title: LocalizedStringResource = "Send a Traceroute"
-
- static var description: IntentDescription = "Send a traceroute request to a certain Meshtastic node"
-
- @Parameter(title: "Node Number")
- var nodeNumber: Int
-
- static var parameterSummary: some ParameterSummary {
- Summary("Send traceroute to \(\.$nodeNumber)")
- }
-
- func perform() async throws -> some IntentResult {
- if !BLEManager.shared.isConnected {
- throw AppIntentErrors.AppIntentError.notConnected
- }
-
- if !BLEManager.shared.sendTraceRouteRequest(destNum: Int64(nodeNumber), wantResponse: true) {
- throw AppIntentErrors.AppIntentError.message("Failed to send traceroute request")
- }
-
- return .result()
- }
-}
diff --git a/Meshtastic/Assets.xcassets/custom.foxhunt.symbolset/Contents.json b/Meshtastic/Assets.xcassets/custom.foxhunt.symbolset/Contents.json
new file mode 100644
index 00000000..f8182534
--- /dev/null
+++ b/Meshtastic/Assets.xcassets/custom.foxhunt.symbolset/Contents.json
@@ -0,0 +1,12 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "symbols" : [
+ {
+ "filename" : "custom.foxhunt.svg",
+ "idiom" : "universal"
+ }
+ ]
+}
diff --git a/Meshtastic/Assets.xcassets/custom.foxhunt.symbolset/custom.foxhunt.svg b/Meshtastic/Assets.xcassets/custom.foxhunt.symbolset/custom.foxhunt.svg
new file mode 100644
index 00000000..aff04982
--- /dev/null
+++ b/Meshtastic/Assets.xcassets/custom.foxhunt.symbolset/custom.foxhunt.svg
@@ -0,0 +1,66 @@
+
+
+
+
diff --git a/Meshtastic/Helpers/BluetoothManager.swift b/Meshtastic/Helpers/BluetoothManager.swift
deleted file mode 100644
index dc86b613..00000000
--- a/Meshtastic/Helpers/BluetoothManager.swift
+++ /dev/null
@@ -1,27 +0,0 @@
-//
-// BluetoothManager.swift
-// MeshtasticClient
-//
-// Created by Garth Vander Houwen on 12/1/21.
-//
-
-import Combine
-import CoreBluetooth
-
-final class BluetoothManager: NSObject {
-
- private var centralManager: CBCentralManager!
-
- var stateSubject: PassthroughSubject = .init()
- var peripheralSubject: PassthroughSubject = .init()
-
- func start() {
- centralManager = .init(delegate: self, queue: .main)
- }
-
- func connect(_ peripheral: CBPeripheral) {
- centralManager.stopScan()
- peripheral.delegate = self
- centralManager.connect(peripheral)
- }
-}
diff --git a/Meshtastic/Helpers/EmojiOnlyTextField.swift b/Meshtastic/Helpers/EmojiOnlyTextField.swift
deleted file mode 100644
index aae9e3a3..00000000
--- a/Meshtastic/Helpers/EmojiOnlyTextField.swift
+++ /dev/null
@@ -1,105 +0,0 @@
-//
-// EmojiKeyboard.swift
-// Meshtastic
-//
-// Copyright(c) Garth Vander Houwen 1/10/23.
-//
-import SwiftUI
-
-class SwiftUIEmojiTextField: UITextField {
- var shouldBecomeFirstResponderOnAppear = false
-
- func setEmoji() {
- _ = self.textInputMode
- }
-
- override var textInputContextIdentifier: String? {
- return ""
- }
-
- override var textInputMode: UITextInputMode? {
- for mode in UITextInputMode.activeInputModes where mode.primaryLanguage == "emoji" {
- self.keyboardType = .default // do not remove this
- return mode
- }
- return nil
- }
-
- override func didMoveToWindow() {
- super.didMoveToWindow()
- if shouldBecomeFirstResponderOnAppear && window != nil {
- DispatchQueue.main.async { [weak self] in
- self?.becomeFirstResponder()
- }
- }
- }
-}
-
-struct EmojiOnlyTextField: UIViewRepresentable {
- @Binding var text: String
- var placeholder: String = ""
- var onBecomeFirstResponder: (() -> Void)?
- var onKeyboardTypeChanged: ((Bool) -> Void)? // true if NOT emoji (should dismiss), false if emoji
- var onKeyboardDismissed: (() -> Void)? // Called when keyboard is dismissed
-
- func makeUIView(context: Context) -> SwiftUIEmojiTextField {
- let emojiTextField = SwiftUIEmojiTextField()
- emojiTextField.placeholder = placeholder
- emojiTextField.text = text
- emojiTextField.delegate = context.coordinator
- emojiTextField.shouldBecomeFirstResponderOnAppear = true
- context.coordinator.textField = emojiTextField
- return emojiTextField
- }
-
- func updateUIView(_ uiView: SwiftUIEmojiTextField, context: Context) {
- uiView.text = text
- context.coordinator.onBecomeFirstResponder = onBecomeFirstResponder
- context.coordinator.onKeyboardTypeChanged = onKeyboardTypeChanged
- context.coordinator.onKeyboardDismissed = onKeyboardDismissed
- }
-
- func makeCoordinator() -> Coordinator {
- Coordinator(parent: self)
- }
-
- class Coordinator: NSObject, UITextFieldDelegate {
- var parent: EmojiOnlyTextField
- var textField: SwiftUIEmojiTextField?
- var onBecomeFirstResponder: (() -> Void)?
- var onKeyboardTypeChanged: ((Bool) -> Void)?
- var onKeyboardDismissed: (() -> Void)?
- var previousInputMode: String?
-
- init(parent: EmojiOnlyTextField) {
- self.parent = parent
- }
-
- func textFieldDidBeginEditing(_ textField: UITextField) {
- onBecomeFirstResponder?()
- checkInputMode(textField)
- }
-
- func textFieldDidEndEditing(_ textField: UITextField) {
- // Keyboard was dismissed
- onKeyboardDismissed?()
- }
-
- func textFieldDidChangeSelection(_ textField: UITextField) {
- DispatchQueue.main.async { [weak self] in
- self?.parent.text = textField.text ?? ""
- }
- checkInputMode(textField)
- }
-
- private func checkInputMode(_ textField: UITextField) {
- if let inputMode = textField.textInputMode {
- let isEmoji = inputMode.primaryLanguage == "emoji"
- if previousInputMode != inputMode.primaryLanguage {
- previousInputMode = inputMode.primaryLanguage
- onKeyboardTypeChanged?(!isEmoji) // true if NOT emoji (should dismiss)
- }
- }
- }
- }
-}
diff --git a/Meshtastic/Helpers/Preferences.swift b/Meshtastic/Helpers/Preferences.swift
deleted file mode 100644
index 93ff482a..00000000
--- a/Meshtastic/Helpers/Preferences.swift
+++ /dev/null
@@ -1,33 +0,0 @@
-//
-// Preferences.swift
-// MeshtasticClient
-//
-// Created by Garth Vander Houwen on 12/16/21.
-//
-// import Foundation
-// import Combine
-// import SwiftUI
-//
-// class Prefs
-// {
-// private let defaults = UserDefaults.standard
-//
-// private let keyIntExample = "intExample"
-//
-// var intExample = {
-// set {
-// defaults.setValue(newValue, forKey: keyIntExample)
-// }
-// get {
-// return defaults.integer(forKey: keyIntExample)
-// }
-// }
-//
-// class var shared: Prefs {
-// struct Static {
-// static let instance = Prefs()
-// }
-//
-// return Static.instance
-// }
-// }
diff --git a/Meshtastic/Helpers/TAK/MeshToCoTConverter.swift b/Meshtastic/Helpers/TAK/MeshToCoTConverter.swift
deleted file mode 100644
index 6c9f9029..00000000
--- a/Meshtastic/Helpers/TAK/MeshToCoTConverter.swift
+++ /dev/null
@@ -1,271 +0,0 @@
-//
-// MeshToCoTConverter.swift
-// Meshtastic
-//
-// Converts Meshtastic packets to CoT format for TAK Server
-//
-
-import Foundation
-import MeshtasticProtobufs
-import CoreLocation
-import OSLog
-import Combine
-
-/// Converts Meshtastic packets to CoT format for bridging to TAK Server
-final class MeshToCoTConverter: ObservableObject {
-
- static let shared = MeshToCoTConverter()
-
- private let logger = Logger(subsystem: "Meshtastic", category: "MeshToCoT")
-
- private init() {}
-
- // MARK: - Position // MARK: Packet to CoT
-
- /// Convert a Meshtastic position packet to CoT message
- func convertPosition(_ position: Position, from node: NodeInfoEntity) -> CoTMessage? {
- guard let user = node.user else {
- logger.warning("Cannot convert position: node has no user info")
- return nil
- }
-
- let callsign = user.longName ?? user.shortName ?? "Unknown"
- let uid = "MESHTASTIC-\(node.num.toHex())"
-
- let latitude = Double(position.latitudeI) / 1e7
- let longitude = Double(position.longitudeI) / 1e7
- let altitude = Double(position.altitude)
-
- var speed: Double = 0
- var course: Double = 0
- if position.speed != 0 {
- speed = Double(position.speed) * 0.194384 // Convert to knots
- }
- if position.heading != 0 {
- course = Double(position.heading)
- }
-
- let battery = Int(position.batteryLevel)
-
- return CoTMessage.pli(
- uid: uid,
- callsign: callsign,
- latitude: latitude,
- longitude: longitude,
- altitude: altitude,
- speed: speed,
- course: course,
- team: "Meshtastic",
- role: "Team Member",
- battery: battery > 0 ? battery : 100,
- staleMinutes: 10
- )
- }
-
- // MARK: - Node Info to CoT
-
- /// Convert node info to CoT message (for node presence updates)
- func convertNodeInfo(_ node: NodeInfoEntity) -> CoTMessage? {
- guard let user = node.user else {
- logger.warning("Cannot convert node info: node has no user info")
- return nil
- }
-
- let callsign = user.longName ?? user.shortName ?? "Unknown"
- let uid = "MESHTASTIC-\(node.num.toHex())"
-
- var latitude = 0.0
- var longitude = 0.0
- var altitude = 9999999.0
-
- if let position = node.position {
- latitude = Double(position.latitudeI) / 1e7
- longitude = Double(position.longitudeI) / 1e7
- if position.altitude != 0 {
- altitude = Double(position.altitude)
- }
- }
-
- // Determine CoT type based on device role
- let cotType = getCoTTypeForRole(user.role)
-
- let now = Date()
- return CoTMessage(
- uid: uid,
- type: cotType,
- time: now,
- start: now,
- stale: now.addingTimeInterval(3600), // 1 hour stale for node info
- how: "m-g",
- latitude: latitude,
- longitude: longitude,
- hae: altitude,
- ce: 9999999.0,
- le: 9999999.0,
- contact: CoTContact(callsign: callsign, endpoint: "0.0.0.0:4242:tcp"),
- group: CoTGroup(name: "Meshtastic", role: getRoleNameForDeviceRole(user.role)),
- remarks: "Meshtastic Node: \(callsign)"
- )
- }
-
- // MARK: - Waypoint to CoT
-
- /// Convert a Meshtastic waypoint to CoT message
- func convertWaypoint(_ waypoint: Waypoint, from node: NodeInfoEntity?) -> CoTMessage? {
- let uid = "WAYPOINT-\(waypoint.id)"
-
- let latitude = Double(waypoint.latitudeI) / 1e7
- let longitude = Double(waypoint.longitudeI) / 1e7
- let altitude = waypoint.altitude > 0 ? Double(waypoint.altitude) : 9999999.0
-
- let name = waypoint.name.isEmpty ? "Unnamed Waypoint" : waypoint.name
- let description = waypoint.description_p.isEmpty ? "Meshtastic Waypoint" : waypoint.description_p
-
- // Get emoji based on waypoint icon/expire time
- let iconEmoji = getEmojiForWaypoint(waypoint)
-
- // Handle expiry - if expire is 0, never expire. Otherwise use the expire time as Unix timestamp
- let stale: Date
- if waypoint.expire == 0 {
- // Never expire - set to 1 year from now
- stale = Date().addingTimeInterval(365 * 24 * 60 * 60)
- } else {
- // expire is Unix timestamp when waypoint expires
- let expireDate = Date(timeIntervalSince1970: TimeInterval(waypoint.expire))
- if expireDate > Date() {
- stale = expireDate
- } else {
- // Already expired, don't broadcast
- return nil
- }
- }
-
- return CoTMessage(
- uid: uid,
- type: "b-ttf-ff", // Point feature friend - standard CoT type for waypoints/markers
- time: Date(),
- start: Date(),
- stale: stale,
- how: "m-g",
- latitude: latitude,
- longitude: longitude,
- hae: altitude,
- ce: 100.0,
- le: 100.0,
- contact: CoTContact(callsign: "\(iconEmoji) \(name)", endpoint: "0.0.0.0:4242:tcp"),
- remarks: "\(description)\nCreated by: \(node?.user?.longName ?? "Unknown")"
- )
- }
-
- // MARK: - Text Message to CoT
-
- /// Convert a Meshtastic text message to CoT chat message
- func convertTextMessage(_ message: MessageEntity, from sender: NodeInfoEntity) -> CoTMessage? {
- guard let user = sender.user,
- let text = message.text else {
- return nil
- }
-
- let senderName = user.longName ?? user.shortName ?? "Unknown"
- let senderUid = "MESHTASTIC-\(sender.num.toHex())"
- let messageId = "MSG-\(message.id)"
-
- return CoTMessage.chat(
- senderUid: senderUid,
- senderCallsign: senderName,
- message: text,
- chatroom: "Primary"
- )
- }
-
- // MARK: - Helper Methods
-
- /// Get CoT type based on device role
- private func getCoTTypeForRole(_ role: UInt32) -> String {
- switch DeviceRoles(rawValue: Int(role)) {
- case .router, .routerLate:
- return "a-f-G-E" // Group entity (router)
- case .tracker:
- return "a-f-G-T-C" // Ground unit tracker
- case .tak:
- return "a-f-G-U-C" // TAK client
- case .takTracker:
- return "a-f-G-T-C" // TAK tracker
- case .sensor:
- return "a-f-G-s" // Sensor with friendly affiliation
- case .client, .clientMute, .clientHidden, .lostAndFound:
- return "a-f-G-U-C" // Friendly ground unit
- default:
- return "a-f-G-U-C" // Default to friendly unit
- }
- }
-
- /// Get role name for device role
- private func getRoleNameForDeviceRole(_ role: UInt32) -> String {
- switch DeviceRoles(rawValue: Int(role)) {
- case .router, .routerLate:
- return "Router"
- case .tracker:
- return "Tracker"
- case .tak:
- return "TAK"
- case .takTracker:
- return "TAK Tracker"
- case .sensor:
- return "Sensor"
- case .client:
- return "Client"
- case .clientMute:
- return "Muted"
- case .clientHidden:
- return "Hidden"
- default:
- return "User"
- }
- }
-
- /// Get emoji for waypoint based on icon
- private func getEmojiForWaypoint(_ waypoint: Waypoint) -> String {
- // Use icon field if available, otherwise use expire time to guess
- if waypoint.icon != 0 {
- switch waypoint.icon {
- case 1: return "π" // Marker
- case 2: return "π" // Car
- case 3: return "πΆ" // Person
- case 4: return "π " // Home
- case 5: return "βΊ" // Camp
- case 6: return "β οΈ" // Warning
- case 7: return "π" // Flag
- case 8: return "π" // Search
- case 9: return "π₯" // Medical
- case 10: return "π₯" // Fire
- case 11: return "π" // Helicopter
- case 12: return "β΅" // Boat
- case 13: return "πΈ" // UFO
- default: return "π"
- }
- }
-
- // Fallback based on name
- let name = waypoint.name.lowercased()
- if name.contains("help") || name.contains("emergency") {
- return "π"
- } else if name.contains("medical") || name.contains("hospital") {
- return "π₯"
- } else if name.contains("danger") || name.contains("warning") {
- return "β οΈ"
- } else if name.contains("camp") {
- return "βΊ"
- } else if name.contains("home") || name.contains("house") {
- return "π "
- } else if name.contains("car") || name.contains("vehicle") {
- return "π"
- } else if name.contains("flag") {
- return "π"
- } else if name.contains("person") || name.contains("me") {
- return "πΆ"
- } else {
- return "π"
- }
- }
-}
diff --git a/Meshtastic/Helpers/WatchSessionManager.swift b/Meshtastic/Helpers/WatchSessionManager.swift
new file mode 100644
index 00000000..872eb074
--- /dev/null
+++ b/Meshtastic/Helpers/WatchSessionManager.swift
@@ -0,0 +1,183 @@
+//
+// WatchSessionManager.swift
+// Meshtastic
+//
+// Copyright(c) Meshtastic 2025.
+//
+
+import Foundation
+import WatchConnectivity
+import CoreData
+import os
+
+/// Manages the WatchConnectivity session on the iOS side, sending mesh node
+/// data to the companion Apple Watch app.
+///
+/// Call `sendNodesToWatch()` whenever node data changes (e.g., after
+/// receiving position updates from the radio).
+final class WatchSessionManager: NSObject, ObservableObject {
+
+ static let shared = WatchSessionManager()
+
+ private let logger = Logger(subsystem: "gvh.MeshtasticClient", category: "β Watch")
+ private var session: WCSession?
+
+ override init() {
+ super.init()
+ guard WCSession.isSupported() else {
+ logger.info("WCSession not supported on this device")
+ return
+ }
+ let session = WCSession.default
+ session.delegate = self
+ session.activate()
+ self.session = session
+ logger.info("WCSession activated on iOS")
+ }
+
+ // MARK: - Public API
+
+ /// Send a specific node to the Watch as a foxhunt target.
+ /// The Watch will pin this node in its foxhunt list regardless of distance.
+ func sendNodeForFoxhunt(_ nodeNum: Int64) {
+ guard let session, session.activationState == .activated, session.isPaired, session.isWatchAppInstalled else {
+ logger.warning("Cannot send foxhunt target β Watch not available")
+ return
+ }
+ guard session.isReachable else {
+ // Fall back to transferUserInfo when not reachable
+ session.transferUserInfo(["foxhuntTarget": UInt32(nodeNum)])
+ logger.info("Queued foxhunt target \(nodeNum) via transferUserInfo")
+ return
+ }
+ session.sendMessage(["foxhuntTarget": UInt32(nodeNum)], replyHandler: nil) { error in
+ Task { @MainActor in
+ self.logger.error("Failed to send foxhunt target: \(error.localizedDescription, privacy: .public)")
+ }
+ }
+ logger.info("Sent foxhunt target \(nodeNum) to Watch")
+ }
+
+ /// Fetch nodes from Core Data and push them to the Watch via application context.
+ func sendNodesToWatch() {
+ guard let session, session.activationState == .activated, session.isPaired, session.isWatchAppInstalled else {
+ return
+ }
+
+ let context = PersistenceController.shared.container.viewContext
+ context.perform { [weak self] in
+ guard let self else { return }
+ let nodes = self.fetchNodesForWatch(context: context)
+ guard !nodes.isEmpty else { return }
+
+ do {
+ let data = try JSONEncoder().encode(nodes)
+ try session.updateApplicationContext(["nodes": data])
+ self.logger.info("Sent \(nodes.count) nodes to Watch via applicationContext")
+ } catch {
+ self.logger.error("Failed to send nodes to Watch: \(error.localizedDescription, privacy: .public)")
+ }
+ }
+ }
+
+ // MARK: - Core Data β Watch Node Serialization
+
+ private func fetchNodesForWatch(context: NSManagedObjectContext) -> [WatchNode] {
+ let fetchRequest = NSFetchRequest(entityName: "NodeInfoEntity")
+ fetchRequest.predicate = NSPredicate(format: "user != nil")
+
+ do {
+ let results = try context.fetch(fetchRequest)
+ return results.compactMap { nodeInfo -> WatchNode? in
+ guard let user = nodeInfo.value(forKey: "user") as? NSManagedObject else { return nil }
+
+ let num = nodeInfo.value(forKey: "num") as? Int64 ?? 0
+ let longName = user.value(forKey: "longName") as? String ?? "Unknown"
+ let shortName = user.value(forKey: "shortName") as? String ?? "?"
+ let snr = nodeInfo.value(forKey: "snr") as? Float
+ let lastHeard = nodeInfo.value(forKey: "lastHeard") as? Date
+
+ // Get the latest position from the ordered set
+ var latitude: Double?
+ var longitude: Double?
+ var altitude: Int32?
+ var lastPositionTime: Date?
+
+ if let positions = nodeInfo.value(forKey: "positions") as? NSOrderedSet {
+ // Find the position marked as latest, or use the last one
+ let posArray = positions.array as? [NSManagedObject] ?? []
+ let latestPosition = posArray.first(where: {
+ ($0.value(forKey: "latest") as? Bool) == true
+ }) ?? posArray.last
+
+ if let pos = latestPosition {
+ let latI = pos.value(forKey: "latitudeI") as? Int32 ?? 0
+ let lonI = pos.value(forKey: "longitudeI") as? Int32 ?? 0
+ if latI != 0, lonI != 0 {
+ latitude = Double(latI) / 1e7
+ longitude = Double(lonI) / 1e7
+ altitude = pos.value(forKey: "altitude") as? Int32
+ lastPositionTime = pos.value(forKey: "time") as? Date
+ }
+ }
+ }
+
+ return WatchNode(
+ num: UInt32(num),
+ longName: longName,
+ shortName: shortName,
+ latitude: latitude,
+ longitude: longitude,
+ altitude: altitude,
+ lastPositionTime: lastPositionTime,
+ lastHeard: lastHeard,
+ snr: snr
+ )
+ }
+ } catch {
+ logger.error("Failed to fetch nodes for Watch: \(error.localizedDescription, privacy: .public)")
+ return []
+ }
+ }
+}
+
+// MARK: - WCSessionDelegate
+extension WatchSessionManager: WCSessionDelegate {
+
+ func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
+ if let error {
+ logger.error("WCSession activation failed: \(error.localizedDescription, privacy: .public)")
+ } else {
+ logger.info("WCSession activated (state=\(activationState.rawValue))")
+ }
+ }
+
+ func sessionDidBecomeInactive(_ session: WCSession) {
+ logger.info("WCSession became inactive")
+ }
+
+ func sessionDidDeactivate(_ session: WCSession) {
+ logger.info("WCSession deactivated β reactivating")
+ session.activate()
+ }
+
+ func session(_ session: WCSession, didReceiveMessage message: [String: Any]) {
+ if message["request"] as? String == "refreshNodes" {
+ logger.info("Watch requested node refresh")
+ sendNodesToWatch()
+ }
+ }
+}
+
+// MARK: - WatchNode (mirrors the Watch app's MeshNode, Codable for transfer)
+struct WatchNode: Codable {
+ let num: UInt32
+ let longName: String
+ let shortName: String
+ let latitude: Double?
+ let longitude: Double?
+ let altitude: Int32?
+ let lastPositionTime: Date?
+ let lastHeard: Date?
+ let snr: Float?
+}
diff --git a/Meshtastic/MeshtasticApp.swift b/Meshtastic/MeshtasticApp.swift
index 9d9f6789..2e0171ae 100644
--- a/Meshtastic/MeshtasticApp.swift
+++ b/Meshtastic/MeshtasticApp.swift
@@ -5,6 +5,7 @@ import CoreData
import OSLog
import TipKit
import MeshtasticProtobufs
+import WatchConnectivity
import DatadogCore
import DatadogCrashReporting
import DatadogRUM
@@ -91,6 +92,9 @@ struct MeshtasticAppleApp: App {
// Initialize map data manager
MapDataManager.shared.initialize()
+
+ // Initialize WatchConnectivity session
+ _ = WatchSessionManager.shared
#if DEBUG
// Show tips in development
try? Tips.resetDatastore()
diff --git a/Meshtastic/ShowTime.swift b/Meshtastic/ShowTime.swift
deleted file mode 100644
index e69de29b..00000000
diff --git a/Meshtastic/Views/Nodes/Helpers/NodeDetail.swift b/Meshtastic/Views/Nodes/Helpers/NodeDetail.swift
index fcfad87d..f282881f 100644
--- a/Meshtastic/Views/Nodes/Helpers/NodeDetail.swift
+++ b/Meshtastic/Views/Nodes/Helpers/NodeDetail.swift
@@ -8,6 +8,7 @@ import WeatherKit
import MapKit
import CoreLocation
import OSLog
+import WatchConnectivity
struct NodeDetail: View {
private let gridItemLayout = Array(repeating: GridItem(.flexible(), spacing: 10), count: 2)
@@ -479,6 +480,17 @@ struct NodeDetail: View {
}
if node.hasPositions {
#if !targetEnvironment(macCatalyst)
+ if node.latestPosition?.isPreciseLocation == true && WCSession.isSupported() && WCSession.default.isPaired && WCSession.default.isWatchAppInstalled {
+ Button {
+ WatchSessionManager.shared.sendNodeForFoxhunt(node.num)
+ } label: {
+ Label {
+ Text("Foxhunt on your watch")
+ } icon: {
+ Image("custom.foxhunt")
+ }
+ }
+ }
if node.latestPosition?.isPreciseLocation == true {
Button {
showingCompassSheet = true
diff --git a/Meshtastic/Views/Nodes/NodeRow.swift b/Meshtastic/Views/Nodes/NodeRow.swift
deleted file mode 100644
index 91d70de8..00000000
--- a/Meshtastic/Views/Nodes/NodeRow.swift
+++ /dev/null
@@ -1,70 +0,0 @@
-import SwiftUI
-
-struct NodeRow: View {
- var node: NodeInfoEntity
- var connected: Bool
-
- var body: some View {
- VStack(alignment: .leading) {
-
- HStack {
-
- CircleText(text: node.user?.shortName ?? "???", color: Color.accentColor).offset(y: 1).padding(.trailing, 5)
- .offset(x: -15)
-
- if UIDevice.current.userInterfaceIdiom == .pad {
- Text(node.user?.longName ?? "Unknown").font(.headline)
- .offset(x: -15)
- } else {
- Text(node.user?.longName ?? "Unknown").font(.title)
- .offset(x: -15)
- }
- }
- .padding(.bottom, 10)
-
- if connected {
- HStack(alignment: .bottom) {
-
- Image(systemName: "repeat.circle.fill").font(.title3)
- .foregroundColor(.accentColor).symbolRenderingMode(.hierarchical)
- Text("Currently Connected").font(.title3).foregroundColor(Color.accentColor)
- }
- Spacer()
- }
-
- HStack(alignment: .bottom) {
-
- Image(systemName: "clock.badge.checkmark.fill").font(.title3).foregroundColor(.accentColor).symbolRenderingMode(.hierarchical)
-
- if UIDevice.current.userInterfaceIdiom == .pad {
-
- if node.lastHeard != nil {
- Text("Last Heard: \(node.lastHeard!, style: .relative) ago").font(.caption).foregroundColor(.gray)
- .padding(.bottom)
- } else {
- Text("Last Heard: Unknown").font(.caption).foregroundColor(.gray)
- }
-
- } else {
-
- if node.lastHeard != nil {
- Text("Last Heard: \(node.lastHeard!, style: .relative) ago").font(.subheadline).foregroundColor(.gray)
- } else {
- Text("Last Heard: Unknown").font(.subheadline).foregroundColor(.gray)
- }
- }
- }
- }.padding([.leading, .top, .bottom])
- }
-}
-
-struct NodeRow_Previews: PreviewProvider {
- // static var nodes = BLEManager().meshData.nodes
-
- static var previews: some View {
- Group {
- // NodeRow(node: nodes[0], connected: true)
- }
- .previewLayout(.fixed(width: 300, height: 70))
- }
-}
From 704c08803cd14533a4cd000780e726c73b227541 Mon Sep 17 00:00:00 2001
From: Garth Vander Houwen
Date: Mon, 20 Apr 2026 09:03:01 -0700
Subject: [PATCH 5/7] Connected watch updates
---
Meshtastic/Helpers/WatchSessionManager.swift | 6 ++++++
Meshtastic/Views/Nodes/Helpers/NodeDetail.swift | 3 +--
2 files changed, 7 insertions(+), 2 deletions(-)
diff --git a/Meshtastic/Helpers/WatchSessionManager.swift b/Meshtastic/Helpers/WatchSessionManager.swift
index 872eb074..93b53681 100644
--- a/Meshtastic/Helpers/WatchSessionManager.swift
+++ b/Meshtastic/Helpers/WatchSessionManager.swift
@@ -37,6 +37,12 @@ final class WatchSessionManager: NSObject, ObservableObject {
// MARK: - Public API
+ /// Whether a paired Watch with the Meshtastic app installed is available.
+ var isWatchAvailable: Bool {
+ guard let session, session.activationState == .activated else { return false }
+ return session.isPaired && session.isWatchAppInstalled
+ }
+
/// Send a specific node to the Watch as a foxhunt target.
/// The Watch will pin this node in its foxhunt list regardless of distance.
func sendNodeForFoxhunt(_ nodeNum: Int64) {
diff --git a/Meshtastic/Views/Nodes/Helpers/NodeDetail.swift b/Meshtastic/Views/Nodes/Helpers/NodeDetail.swift
index f282881f..2d7e57be 100644
--- a/Meshtastic/Views/Nodes/Helpers/NodeDetail.swift
+++ b/Meshtastic/Views/Nodes/Helpers/NodeDetail.swift
@@ -8,7 +8,6 @@ import WeatherKit
import MapKit
import CoreLocation
import OSLog
-import WatchConnectivity
struct NodeDetail: View {
private let gridItemLayout = Array(repeating: GridItem(.flexible(), spacing: 10), count: 2)
@@ -480,7 +479,7 @@ struct NodeDetail: View {
}
if node.hasPositions {
#if !targetEnvironment(macCatalyst)
- if node.latestPosition?.isPreciseLocation == true && WCSession.isSupported() && WCSession.default.isPaired && WCSession.default.isWatchAppInstalled {
+ if node.latestPosition?.isPreciseLocation == true && WatchSessionManager.shared.isWatchAvailable {
Button {
WatchSessionManager.shared.sendNodeForFoxhunt(node.num)
} label: {
From 3a2a4833e15c7803780c437860d078670cbcd8ad Mon Sep 17 00:00:00 2001
From: Garth Vander Houwen
Date: Mon, 20 Apr 2026 13:51:06 -0700
Subject: [PATCH 6/7] improve fox hunt compass
---
.../Views/FoxhuntCompassView.swift | 93 ++++++++++++++++---
1 file changed, 79 insertions(+), 14 deletions(-)
diff --git a/Meshtastic Watch App/Views/FoxhuntCompassView.swift b/Meshtastic Watch App/Views/FoxhuntCompassView.swift
index 6d60e3cf..c4ab2894 100644
--- a/Meshtastic Watch App/Views/FoxhuntCompassView.swift
+++ b/Meshtastic Watch App/Views/FoxhuntCompassView.swift
@@ -36,21 +36,21 @@ struct FoxhuntCompassView: View {
let size = min(geometry.size.width, geometry.size.height)
let dialRadius = size * 0.44
- VStack(spacing: 2) {
+ VStack(spacing: 0) {
// Node short name circle
WatchCircleText(
text: node.shortName.isEmpty ? "?" : node.shortName,
color: WatchCircleText.color(for: node.num),
- circleSize: 32
+ circleSize: 26
)
- ZStack {
- // Fixed heading indicator at top
+ ZStack {
+ // Fixed heading indicator at top of ring
Image(systemName: "triangle.fill")
.font(.system(size: 8, weight: .bold))
- .foregroundStyle(.primary)
+ .foregroundStyle(.white.opacity(0.9))
.rotationEffect(.degrees(180))
- .offset(y: -(dialRadius + 12))
+ .offset(y: -(dialRadius + 6))
// Rotating compass group
ZStack {
@@ -86,10 +86,19 @@ struct FoxhuntCompassView: View {
// Bearing arrow to target
if let bearing = bearingToNode() {
- Image(systemName: "arrowtriangle.up.fill")
- .font(.system(size: 12, weight: .bold))
+ // Directional cone showing general heading direction
+ DirectionCone(
+ bearing: bearing,
+ heading: locationManager.heading,
+ radius: dialRadius + 10,
+ color: distanceColor
+ )
+
+ Image(systemName: "location.north.fill")
+ .font(.system(size: 22, weight: .bold))
.foregroundStyle(distanceColor)
- .offset(y: -(dialRadius + 8))
+ .shadow(color: distanceColor.opacity(0.8), radius: 6)
+ .offset(y: -(dialRadius + 20))
.rotationEffect(.degrees(bearing))
.onChange(of: locationManager.heading) {
checkAlignment(bearing: bearing, heading: locationManager.heading)
@@ -98,7 +107,7 @@ struct FoxhuntCompassView: View {
}
.rotationEffect(.degrees(-locationManager.heading))
}
- .frame(width: dialRadius * 2 + 30, height: dialRadius * 2 + 30)
+ .frame(width: dialRadius * 2 + 48, height: dialRadius * 2 + 48)
// Distance at bottom
if let dist = distanceToNode() {
@@ -164,13 +173,13 @@ struct FoxhuntCompassView: View {
return node.distance(from: userLoc)
}
- /// Colour that shifts from blue (far) β yellow (mid) β red (close).
+ /// Colour that shifts from red (far) β yellow (mid) β green (close).
private var distanceColor: Color {
- guard let dist = distanceToNode() else { return .blue }
+ guard let dist = distanceToNode() else { return .red }
let ratio = min(dist / Self.maxDistanceMetres, 1.0)
- if ratio > 0.66 { return .blue }
+ if ratio > 0.66 { return .red }
if ratio > 0.33 { return .yellow }
- return .red
+ return .green
}
private func checkAlignment(bearing: Double, heading: Double) {
@@ -211,6 +220,62 @@ struct FoxhuntCompassView: View {
}
}
+// MARK: - Direction Cone (Backtrack-style scope)
+
+/// A translucent cone drawn from the centre of the compass toward the
+/// bearing, giving a visual "scope" so the user can see when they are
+/// heading in roughly the right direction.
+private struct DirectionCone: View {
+ let bearing: Double
+ let heading: Double
+ let radius: CGFloat
+ let color: Color
+
+ /// Half-width of the cone in degrees.
+ private let coneHalfAngle: Double = 20
+
+ var body: some View {
+ let onTarget = isOnTarget
+
+ ConeShape(halfAngle: coneHalfAngle, radius: radius)
+ .fill(
+ RadialGradient(
+ colors: [
+ color.opacity(onTarget ? 0.55 : 0.3),
+ color.opacity(onTarget ? 0.25 : 0.08)
+ ],
+ center: .center,
+ startRadius: 0,
+ endRadius: radius
+ )
+ )
+ .rotationEffect(.degrees(bearing))
+ }
+
+ private var isOnTarget: Bool {
+ let rawDiff = abs(heading - bearing).truncatingRemainder(dividingBy: 360)
+ let diff = min(rawDiff, 360 - rawDiff)
+ return diff <= coneHalfAngle
+ }
+}
+
+private struct ConeShape: Shape {
+ let halfAngle: Double
+ let radius: CGFloat
+
+ func path(in rect: CGRect) -> Path {
+ let center = CGPoint(x: rect.midX, y: rect.midY)
+ let startAngle = Angle(degrees: -90 - halfAngle)
+ let endAngle = Angle(degrees: -90 + halfAngle)
+
+ var path = Path()
+ path.move(to: center)
+ path.addArc(center: center, radius: radius, startAngle: startAngle, endAngle: endAngle, clockwise: false)
+ path.closeSubpath()
+ return path
+ }
+}
+
// MARK: - Watch-sized compass sub-views
private struct WatchTickMark: View {
From 2f21c4bfafd66c69fe3a43c15936af280c6cfd74 Mon Sep 17 00:00:00 2001
From: Garth Vander Houwen
Date: Mon, 20 Apr 2026 14:22:48 -0700
Subject: [PATCH 7/7] Clean up compass
---
.../Views/FoxhuntCompassView.swift | 151 +++++++++---------
1 file changed, 74 insertions(+), 77 deletions(-)
diff --git a/Meshtastic Watch App/Views/FoxhuntCompassView.swift b/Meshtastic Watch App/Views/FoxhuntCompassView.swift
index c4ab2894..221c3a5e 100644
--- a/Meshtastic Watch App/Views/FoxhuntCompassView.swift
+++ b/Meshtastic Watch App/Views/FoxhuntCompassView.swift
@@ -34,87 +34,78 @@ struct FoxhuntCompassView: View {
var body: some View {
GeometryReader { geometry in
let size = min(geometry.size.width, geometry.size.height)
- let dialRadius = size * 0.44
+ let dialRadius = size * 0.48
- VStack(spacing: 0) {
- // Node short name circle
+ ZStack {
+ // Fixed heading indicator at top of ring
+ Image(systemName: "triangle.fill")
+ .font(.system(size: 8, weight: .bold))
+ .foregroundStyle(.white.opacity(0.9))
+ .rotationEffect(.degrees(180))
+ .offset(y: -(dialRadius + 6))
+
+ // Rotating compass group
+ ZStack {
+ // Outer ring
+ Circle()
+ .stroke(Color.primary.opacity(0.3), lineWidth: 3)
+ .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 (includes distance)
+ centreReadout(dialRadius: dialRadius)
+
+ // Bearing arrow to target
+ if let bearing = bearingToNode() {
+ // Directional cone showing general heading direction
+ DirectionCone(
+ bearing: bearing,
+ heading: locationManager.heading,
+ radius: dialRadius + 4,
+ color: distanceColor
+ )
+
+ Image(systemName: "location.north.fill")
+ .font(.system(size: 26, weight: .bold))
+ .foregroundStyle(distanceColor)
+ .shadow(color: distanceColor.opacity(0.8), radius: 6)
+ .offset(y: -(dialRadius + 16))
+ .rotationEffect(.degrees(bearing))
+ .onChange(of: locationManager.heading) {
+ checkAlignment(bearing: bearing, heading: locationManager.heading)
+ }
+ }
+ }
+ .rotationEffect(.degrees(-locationManager.heading))
+
+ // Node short name circle at top
WatchCircleText(
text: node.shortName.isEmpty ? "?" : node.shortName,
color: WatchCircleText.color(for: node.num),
circleSize: 26
)
-
- ZStack {
- // Fixed heading indicator at top of ring
- Image(systemName: "triangle.fill")
- .font(.system(size: 8, weight: .bold))
- .foregroundStyle(.white.opacity(0.9))
- .rotationEffect(.degrees(180))
- .offset(y: -(dialRadius + 6))
-
- // Rotating compass group
- ZStack {
- // Outer ring
- Circle()
- .stroke(Color.primary.opacity(0.3), lineWidth: 3)
- .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() {
- // Directional cone showing general heading direction
- DirectionCone(
- bearing: bearing,
- heading: locationManager.heading,
- radius: dialRadius + 10,
- color: distanceColor
- )
-
- Image(systemName: "location.north.fill")
- .font(.system(size: 22, weight: .bold))
- .foregroundStyle(distanceColor)
- .shadow(color: distanceColor.opacity(0.8), radius: 6)
- .offset(y: -(dialRadius + 20))
- .rotationEffect(.degrees(bearing))
- .onChange(of: locationManager.heading) {
- checkAlignment(bearing: bearing, heading: locationManager.heading)
- }
- }
- }
- .rotationEffect(.degrees(-locationManager.heading))
- }
- .frame(width: dialRadius * 2 + 48, height: dialRadius * 2 + 48)
-
- // Distance at bottom
- if let dist = distanceToNode() {
- Text(formatDistance(dist))
- .font(.system(size: 14, weight: .semibold, design: .rounded))
- .foregroundStyle(distanceColor)
- }
+ .offset(y: -(dialRadius + 32))
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
@@ -142,13 +133,19 @@ struct FoxhuntCompassView: View {
VStack(spacing: 1) {
Text(headingText)
- .font(.system(size: 20, weight: .light, design: .rounded))
+ .font(.system(size: 24, weight: .light, design: .rounded))
.monospacedDigit()
.foregroundStyle(textColor)
if let bearing = bearingToNode() {
Text("\(String(format: "%.0fΒ°", bearing))")
- .font(.system(size: 10, weight: .medium, design: .rounded))
+ .font(.system(size: 12, weight: .medium, design: .rounded))
+ .foregroundStyle(textColor.opacity(0.8))
+ }
+
+ if let dist = distanceToNode() {
+ Text(formatDistance(dist))
+ .font(.system(size: 12, weight: .semibold, design: .rounded))
.foregroundStyle(textColor.opacity(0.8))
}
}