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 @@ + + + + + + + + + + Weight/Scale Variations + Ultralight + Regular + Black + + Template v.6.0 + Requires Xcode 16 or greater + Generated from custom.foxhunt + Typeset at 100.0 points + Small + Medium + Large + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 @@ + + + + + + + + + + Weight/Scale Variations + Ultralight + Regular + Black + + Template v.6.0 + Requires Xcode 16 or greater + Generated from custom.foxhunt + Typeset at 100.0 points + Small + Medium + Large + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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)) - } -}