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/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..28a2189a
--- /dev/null
+++ b/Meshtastic Watch App/Assets.xcassets/AppIcon.appiconset/Contents.json
@@ -0,0 +1,14 @@
+{
+ "images" : [
+ {
+ "filename" : "watch-icon.png",
+ "idiom" : "universal",
+ "platform" : "watchOS",
+ "size" : "1024x1024"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
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 00000000..b991d258
Binary files /dev/null and b/Meshtastic Watch App/Assets.xcassets/AppIcon.appiconset/watch-icon.png differ
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/Assets.xcassets/custom.foxhunt.symbolset/Contents.json b/Meshtastic Watch App/Assets.xcassets/custom.foxhunt.symbolset/Contents.json
new file mode 100644
index 00000000..f8182534
--- /dev/null
+++ b/Meshtastic Watch App/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 Watch App/Assets.xcassets/custom.foxhunt.symbolset/custom.foxhunt.svg b/Meshtastic Watch App/Assets.xcassets/custom.foxhunt.symbolset/custom.foxhunt.svg
new file mode 100644
index 00000000..aff04982
--- /dev/null
+++ b/Meshtastic Watch App/Assets.xcassets/custom.foxhunt.symbolset/custom.foxhunt.svg
@@ -0,0 +1,66 @@
+
+
+
+
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
new file mode 100644
index 00000000..56069b36
--- /dev/null
+++ b/Meshtastic Watch App/ContentView.swift
@@ -0,0 +1,37 @@
+//
+// 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. **Phone** β companion phone connectivity status
+struct ContentView: View {
+
+ @StateObject private var phoneManager = PhoneConnectivityManager()
+ @StateObject private var locationManager = WatchLocationManager()
+
+ var body: some View {
+ TabView {
+ // Tab 1: Foxhunt
+ NearbyNodesListView(phoneManager: phoneManager, locationManager: locationManager)
+
+ // Tab 2: Phone connectivity
+ DeviceConnectionView(phoneManager: phoneManager)
+ }
+ .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..32b378eb
--- /dev/null
+++ b/Meshtastic Watch App/Info.plist
@@ -0,0 +1,12 @@
+
+
+
+
+ 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/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..b8cf6f9e
--- /dev/null
+++ b/Meshtastic Watch App/Meshtastic Watch App.entitlements
@@ -0,0 +1,8 @@
+
+
+
+
+ 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..9b1877b1
--- /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.
+/// 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.
+ 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..0abb3821
--- /dev/null
+++ b/Meshtastic Watch App/Views/DeviceConnectionView.swift
@@ -0,0 +1,70 @@
+//
+// DeviceConnectionView.swift
+// Meshtastic Watch App
+//
+// Copyright(c) Meshtastic 2025.
+//
+
+import SwiftUI
+
+/// Shows the connectivity status between the Watch and the companion
+/// iPhone app. Node data is received via WatchConnectivity.
+struct DeviceConnectionView: View {
+
+ @ObservedObject var phoneManager: PhoneConnectivityManager
+
+ var body: some View {
+ VStack(spacing: 12) {
+ if phoneManager.isPhoneReachable {
+ reachableView
+ } else {
+ unreachableView
+ }
+ }
+ .padding()
+ .navigationTitle("Phone")
+ }
+
+ // MARK: - Phone Reachable
+
+ @ViewBuilder
+ 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)
+ } else {
+ Text("Open Meshtastic on your iPhone to sync node data.")
+ .font(.caption2)
+ .foregroundStyle(.secondary)
+ .multilineTextAlignment(.center)
+ }
+ }
+}
diff --git a/Meshtastic Watch App/Views/FoxhuntCompassView.swift b/Meshtastic Watch App/Views/FoxhuntCompassView.swift
new file mode 100644
index 00000000..221c3a5e
--- /dev/null
+++ b/Meshtastic Watch App/Views/FoxhuntCompassView.swift
@@ -0,0 +1,336 @@
+//
+// 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.48
+
+ 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
+ )
+ .offset(y: -(dialRadius + 32))
+ }
+ .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: 24, weight: .light, design: .rounded))
+ .monospacedDigit()
+ .foregroundStyle(textColor)
+
+ if let bearing = bearingToNode() {
+ Text("\(String(format: "%.0fΒ°", bearing))")
+ .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))
+ }
+ }
+ }
+ .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 red (far) β yellow (mid) β green (close).
+ private var distanceColor: Color {
+ guard let dist = distanceToNode() else { return .red }
+ let ratio = min(dist / Self.maxDistanceMetres, 1.0)
+ if ratio > 0.66 { return .red }
+ if ratio > 0.33 { return .yellow }
+ return .green
+ }
+
+ 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: - 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 {
+ 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 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 }
+ // 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
new file mode 100644
index 00000000..4192292e
--- /dev/null
+++ b/Meshtastic Watch App/Views/NearbyNodesListView.swift
@@ -0,0 +1,159 @@
+//
+// 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 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 [] }
+ let targets = phoneManager.foxhuntTargets
+ return phoneManager.nodes.values
+ .filter { node in
+ 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
+ }
+ }
+
+ var body: some View {
+ Group {
+ if nearbyNodes.isEmpty {
+ emptyState
+ } else {
+ nodeList
+ }
+ }
+ .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
+
+ @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 !phoneManager.hasReceivedData {
+ Text("Open Meshtastic on your iPhone to sync.")
+ .font(.caption2)
+ .foregroundStyle(.orange)
+ }
+ }
+ .padding()
+ }
+
+ @ViewBuilder
+ private var nodeList: some View {
+ List(nearbyNodes) { node in
+ Button {
+ selectedNode = node
+ } label: {
+ nodeRow(node)
+ }
+ }
+ }
+
+ @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))
+ .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
+ }
+}
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 e9b19fea..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 */; };
@@ -327,6 +328,18 @@
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 /* 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 */; };
+
+ 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 +357,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 +378,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 */
@@ -453,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 = ""; };
@@ -744,6 +776,19 @@
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 /* 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 = ""; };
+ 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 +828,13 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
+ AA0005WTCH00000000FP0001 /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
@@ -1270,6 +1322,7 @@
DDC2E15626CE248E0042C5E4 /* Meshtastic */,
DDDE59F729AF163D00490C6C /* Widgets */,
25F5D5C82C4375A8008036E3 /* MeshtasticTests */,
+ AA0005WTCH00000000GR0001 /* Meshtastic Watch App */,
DDC2E15526CE248E0042C5E4 /* Products */,
DD8EDE9226F97A2B00A5A10B /* Frameworks */,
);
@@ -1282,6 +1335,7 @@
DDC2E15426CE248E0042C5E4 /* Meshtastic.app */,
DDDE59F429AF163D00490C6C /* WidgetsExtension.appex */,
25F5D5C72C4375A8008036E3 /* MeshtasticTests.xctest */,
+ AA0005WTCH00000000FR0012 /* Meshtastic Watch App.app */,
);
name = Products;
sourceTree = "";
@@ -1425,6 +1479,7 @@
6D825E612C34786C008DBEE4 /* CommonRegex.swift */,
3D3417B32E2730EC006A988B /* GeoJSONOverlayManager.swift */,
C37572859BC745C4284A9B42 /* TAK */,
+ AA0006WTSM00000000FR0001 /* WatchSessionManager.swift */,
);
path = Helpers;
sourceTree = "";
@@ -1539,6 +1594,49 @@
path = Widgets;
sourceTree = "";
};
+ AA0005WTCH00000000GR0004 /* Views */ = {
+ isa = PBXGroup;
+ children = (
+ AA0005WTCH00000000FR0006 /* FoxhuntCompassView.swift */,
+ AA0005WTCH00000000FR0007 /* NearbyNodesListView.swift */,
+ AA0005WTCH00000000FR0008 /* DeviceConnectionView.swift */,
+ AA0005WTCH00000000FR0013 /* WatchCircleText.swift */,
+ );
+ path = Views;
+ sourceTree = "";
+ };
+ AA0005WTCH00000000GR0002 /* Managers */ = {
+ isa = PBXGroup;
+ children = (
+ AA0005WTCH00000000FR0004 /* PhoneConnectivityManager.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 +1667,13 @@
DDC2E15126CE248E0042C5E4 /* Frameworks */,
DDC2E15226CE248E0042C5E4 /* Resources */,
DDDE5A0829AF163F00490C6C /* Embed Foundation Extensions */,
+ AA0005WTCH00000000CP0001 /* Embed Watch Content */,
);
buildRules = (
);
dependencies = (
DDDE5A0229AF163E00490C6C /* PBXTargetDependency */,
+ AA0005WTCH00000000TD0001 /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = (
DD4C11E02E8099C3003F2F2E /* PreferenceKeys */,
@@ -1613,6 +1713,23 @@
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";
+ productName = "Meshtastic Watch App";
+ productReference = AA0005WTCH00000000FR0012 /* Meshtastic Watch App.app */;
+ productType = "com.apple.product-type.application";
+ };
/* End PBXNativeTarget section */
/* Begin PBXProject section */
@@ -1671,6 +1788,7 @@
DDC2E15326CE248E0042C5E4 /* Meshtastic */,
DDDE59F329AF163D00490C6C /* WidgetsExtension */,
25F5D5C62C4375A8008036E3 /* MeshtasticTests */,
+ AA0005WTCH00000000NT0001 /* Meshtastic Watch App */,
);
};
/* End PBXProject section */
@@ -1712,6 +1830,14 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
+ AA0005WTCH00000000RP0001 /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ AA0005WTCH00000000BF0009 /* Assets.xcassets in Resources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
@@ -1958,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 */,
@@ -2049,6 +2176,22 @@
);
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 /* 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;
+ };
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
@@ -2063,6 +2206,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 +2567,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_NSLocationWhenInUseUsageDescription = "Meshtastic needs your location to calculate distance and bearing to mesh nodes during foxhunt.";
+ INFOPLIST_KEY_WKCompanionAppBundleIdentifier = gvh.MeshtasticClient;
+ INFOPLIST_KEY_WKRunsIndependentlyOfCompanionApp = NO;
+ 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 = NO;
+ 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_NSLocationWhenInUseUsageDescription = "Meshtastic needs your location to calculate distance and bearing to mesh nodes during foxhunt.";
+ INFOPLIST_KEY_WKCompanionAppBundleIdentifier = gvh.MeshtasticClient;
+ INFOPLIST_KEY_WKRunsIndependentlyOfCompanionApp = NO;
+ 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 = NO;
+ 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 +2668,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 */
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/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..93b53681
--- /dev/null
+++ b/Meshtastic/Helpers/WatchSessionManager.swift
@@ -0,0 +1,189 @@
+//
+// 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
+
+ /// 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) {
+ 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..2d7e57be 100644
--- a/Meshtastic/Views/Nodes/Helpers/NodeDetail.swift
+++ b/Meshtastic/Views/Nodes/Helpers/NodeDetail.swift
@@ -479,6 +479,17 @@ struct NodeDetail: View {
}
if node.hasPositions {
#if !targetEnvironment(macCatalyst)
+ if node.latestPosition?.isPreciseLocation == true && WatchSessionManager.shared.isWatchAvailable {
+ 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))
- }
-}