From 82f28301ca00b078d2f91f5a3aacd6644dfb30d3 Mon Sep 17 00:00:00 2001 From: Garth Vander Houwen Date: Sun, 19 Apr 2026 20:26:45 -0700 Subject: [PATCH] working fox hunt prototype --- Localizable.xcstrings | 7 +- .../AppIcon.appiconset/Contents.json | 1 + .../AppIcon.appiconset/watch-icon.png | Bin 0 -> 29726 bytes .../custom.foxhunt.symbolset/Contents.json | 12 + .../custom.foxhunt.svg | 66 ++++ .../logo-white.imageset/Contents.json | 12 + .../logo-white.imageset/Mesh_Logo_White.svg | 12 + Meshtastic Watch App/ContentView.swift | 14 +- Meshtastic Watch App/Info.plist | 4 +- .../Managers/PhoneConnectivityManager.swift | 144 +++++++ .../Managers/WatchBLEManager.swift | 374 ------------------ .../Meshtastic Watch App.entitlements | 2 - Meshtastic Watch App/Models/MeshNode.swift | 4 +- .../Views/DeviceConnectionView.swift | 170 +++----- .../Views/FoxhuntCompassView.swift | 20 +- .../Views/NearbyNodesListView.swift | 49 ++- .../Views/WatchCircleText.swift | 38 ++ Meshtastic.xcodeproj/project.pbxproj | 38 +- .../Accessory Manager/AccessoryManager.swift | 4 + .../AppIntents/NavigateToNodeIntent.swift | 61 --- Meshtastic/AppIntents/TracerouteIntent.swift | 27 -- .../custom.foxhunt.symbolset/Contents.json | 12 + .../custom.foxhunt.svg | 66 ++++ Meshtastic/Helpers/BluetoothManager.swift | 27 -- Meshtastic/Helpers/EmojiOnlyTextField.swift | 105 ----- Meshtastic/Helpers/Preferences.swift | 33 -- .../Helpers/TAK/MeshToCoTConverter.swift | 271 ------------- Meshtastic/Helpers/WatchSessionManager.swift | 183 +++++++++ Meshtastic/MeshtasticApp.swift | 4 + Meshtastic/ShowTime.swift | 0 .../Views/Nodes/Helpers/NodeDetail.swift | 12 + Meshtastic/Views/Nodes/NodeRow.swift | 70 ---- 32 files changed, 696 insertions(+), 1146 deletions(-) create mode 100644 Meshtastic Watch App/Assets.xcassets/AppIcon.appiconset/watch-icon.png create mode 100644 Meshtastic Watch App/Assets.xcassets/custom.foxhunt.symbolset/Contents.json create mode 100644 Meshtastic Watch App/Assets.xcassets/custom.foxhunt.symbolset/custom.foxhunt.svg create mode 100644 Meshtastic Watch App/Assets.xcassets/logo-white.imageset/Contents.json create mode 100644 Meshtastic Watch App/Assets.xcassets/logo-white.imageset/Mesh_Logo_White.svg create mode 100644 Meshtastic Watch App/Managers/PhoneConnectivityManager.swift delete mode 100644 Meshtastic Watch App/Managers/WatchBLEManager.swift create mode 100644 Meshtastic Watch App/Views/WatchCircleText.swift delete mode 100644 Meshtastic/AppIntents/NavigateToNodeIntent.swift delete mode 100644 Meshtastic/AppIntents/TracerouteIntent.swift create mode 100644 Meshtastic/Assets.xcassets/custom.foxhunt.symbolset/Contents.json create mode 100644 Meshtastic/Assets.xcassets/custom.foxhunt.symbolset/custom.foxhunt.svg delete mode 100644 Meshtastic/Helpers/BluetoothManager.swift delete mode 100644 Meshtastic/Helpers/EmojiOnlyTextField.swift delete mode 100644 Meshtastic/Helpers/Preferences.swift delete mode 100644 Meshtastic/Helpers/TAK/MeshToCoTConverter.swift create mode 100644 Meshtastic/Helpers/WatchSessionManager.swift delete mode 100644 Meshtastic/ShowTime.swift delete mode 100644 Meshtastic/Views/Nodes/NodeRow.swift diff --git a/Localizable.xcstrings b/Localizable.xcstrings index 236ae149..69fd75e4 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -23584,6 +23584,9 @@ } } } + }, + "Foxhunt on your watch" : { + }, "Frequency" : { "localizations" : { @@ -29153,10 +29156,10 @@ } } }, - "Loading..." : { + "Loading TAK config from the node." : { }, - "Loading TAK config from the node." : { + "Loading..." : { }, "Local Network Access" : { diff --git a/Meshtastic Watch App/Assets.xcassets/AppIcon.appiconset/Contents.json b/Meshtastic Watch App/Assets.xcassets/AppIcon.appiconset/Contents.json index d57ffc59..28a2189a 100644 --- a/Meshtastic Watch App/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/Meshtastic Watch App/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,6 +1,7 @@ { "images" : [ { + "filename" : "watch-icon.png", "idiom" : "universal", "platform" : "watchOS", "size" : "1024x1024" diff --git a/Meshtastic Watch App/Assets.xcassets/AppIcon.appiconset/watch-icon.png b/Meshtastic Watch App/Assets.xcassets/AppIcon.appiconset/watch-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..b991d25831fae60a162ad1b09023eb24fabf71d5 GIT binary patch literal 29726 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 + + + + + + + + + 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 index 1ff0e215..56069b36 100644 --- a/Meshtastic Watch App/ContentView.swift +++ b/Meshtastic Watch App/ContentView.swift @@ -11,23 +11,19 @@ import SwiftUI /// /// Uses a tab-based layout: /// 1. **Foxhunt** – nearby nodes list → compass -/// 2. **Radio** – BLE device connection +/// 2. **Phone** – companion phone connectivity status struct ContentView: View { - @StateObject private var bleManager = WatchBLEManager() + @StateObject private var phoneManager = PhoneConnectivityManager() @StateObject private var locationManager = WatchLocationManager() var body: some View { TabView { // Tab 1: Foxhunt - NavigationStack { - NearbyNodesListView(bleManager: bleManager, locationManager: locationManager) - } + NearbyNodesListView(phoneManager: phoneManager, locationManager: locationManager) - // Tab 2: Radio connection - NavigationStack { - DeviceConnectionView(bleManager: bleManager) - } + // Tab 2: Phone connectivity + DeviceConnectionView(phoneManager: phoneManager) } .tabViewStyle(.verticalPage) .onAppear { diff --git a/Meshtastic Watch App/Info.plist b/Meshtastic Watch App/Info.plist index a27ae3d4..32b378eb 100644 --- a/Meshtastic Watch App/Info.plist +++ b/Meshtastic Watch App/Info.plist @@ -2,13 +2,11 @@ - NSBluetoothAlwaysUsageDescription - Meshtastic needs Bluetooth to connect directly to your Meshtastic radio for foxhunt direction finding. NSLocationWhenInUseUsageDescription Meshtastic needs your location to calculate distance and bearing to mesh nodes during foxhunt. WKApplication WKRunsIndependentlyOfCompanionApp - + diff --git a/Meshtastic Watch App/Managers/PhoneConnectivityManager.swift b/Meshtastic Watch App/Managers/PhoneConnectivityManager.swift new file mode 100644 index 00000000..d9df88f2 --- /dev/null +++ b/Meshtastic Watch App/Managers/PhoneConnectivityManager.swift @@ -0,0 +1,144 @@ +// +// PhoneConnectivityManager.swift +// Meshtastic Watch App +// +// Copyright(c) Meshtastic 2025. +// + +import Foundation +import WatchConnectivity +import os + +/// Receives mesh node data from the companion iOS app via WatchConnectivity. +/// +/// The iOS app pushes node updates using `updateApplicationContext(_:)`. +/// The watch can also request a refresh by sending a message. +@MainActor +final class PhoneConnectivityManager: NSObject, ObservableObject { + + // MARK: - Published state + + /// All mesh nodes received from the phone, keyed by node number. + @Published var nodes: [UInt32: MeshNode] = [:] + + /// Whether the companion iPhone is reachable right now. + @Published var isPhoneReachable = false + + /// Whether we have received at least one update from the phone. + @Published var hasReceivedData = false + + /// Node numbers pinned as foxhunt targets from the iOS app. + @Published var foxhuntTargets: Set = [] + + // MARK: - Private + + private let logger = Logger(subsystem: "gvh.MeshtasticClient.watchkitapp", category: "📱 Phone") + private var session: WCSession? + + // MARK: - Lifecycle + + override init() { + super.init() + guard WCSession.isSupported() else { + logger.warning("WCSession is not supported on this device") + return + } + let session = WCSession.default + session.delegate = self + session.activate() + self.session = session + logger.info("WCSession activated") + } + + // MARK: - Public API + + /// Ask the phone to send fresh node data. + func requestRefresh() { + guard let session, session.isReachable else { + logger.warning("Cannot request refresh – phone not reachable") + return + } + session.sendMessage(["request": "refreshNodes"], replyHandler: nil) { error in + Task { @MainActor in + self.logger.error("Failed to request refresh: \(error.localizedDescription, privacy: .public)") + } + } + logger.info("Requested node refresh from phone") + } + + // MARK: - Decoding + + private func decodeNodes(from context: [String: Any]) { + // Handle foxhunt target messages + if let targetNum = context["foxhuntTarget"] as? UInt32 { + foxhuntTargets.insert(targetNum) + logger.info("Added foxhunt target: \(targetNum)") + return + } + + guard let data = context["nodes"] as? Data else { + logger.warning("No 'nodes' key in application context") + return + } + do { + let decoded = try JSONDecoder().decode([MeshNode].self, from: data) + var nodeDict: [UInt32: MeshNode] = [:] + for node in decoded { + nodeDict[node.num] = node + } + nodes = nodeDict + hasReceivedData = true + logger.info("Decoded \(decoded.count) nodes from phone") + } catch { + logger.error("Failed to decode nodes: \(error.localizedDescription, privacy: .public)") + } + } +} + +// MARK: - WCSessionDelegate +extension PhoneConnectivityManager: @preconcurrency WCSessionDelegate { + + nonisolated func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) { + Task { @MainActor in + if let error { + logger.error("WCSession activation failed: \(error.localizedDescription, privacy: .public)") + } else { + logger.info("WCSession activation complete (state=\(activationState.rawValue))") + isPhoneReachable = session.isReachable + + // Load any existing application context + if !session.receivedApplicationContext.isEmpty { + decodeNodes(from: session.receivedApplicationContext) + } + } + } + } + + nonisolated func sessionReachabilityDidChange(_ session: WCSession) { + Task { @MainActor in + isPhoneReachable = session.isReachable + logger.info("Phone reachability changed: \(session.isReachable)") + if session.isReachable && !hasReceivedData { + requestRefresh() + } + } + } + + nonisolated func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String: Any]) { + Task { @MainActor in + decodeNodes(from: applicationContext) + } + } + + nonisolated func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any] = [:]) { + Task { @MainActor in + decodeNodes(from: userInfo) + } + } + + nonisolated func session(_ session: WCSession, didReceiveMessage message: [String: Any]) { + Task { @MainActor in + decodeNodes(from: message) + } + } +} diff --git a/Meshtastic Watch App/Managers/WatchBLEManager.swift b/Meshtastic Watch App/Managers/WatchBLEManager.swift deleted file mode 100644 index 4d121d6b..00000000 --- a/Meshtastic Watch App/Managers/WatchBLEManager.swift +++ /dev/null @@ -1,374 +0,0 @@ -// -// WatchBLEManager.swift -// Meshtastic Watch App -// -// Copyright(c) Meshtastic 2025. -// - -import Foundation -import CoreBluetooth -import MeshtasticProtobufs -import os - -// MARK: - Meshtastic BLE UUIDs (same as the main app) -private let meshtasticServiceUUID = CBUUID(string: "6BA1B218-15A8-461F-9FA8-5DCAE273EAFD") -private let toRadioUUID = CBUUID(string: "F75C76D2-129E-4DAD-A1DD-7866124401E7") -private let fromRadioUUID = CBUUID(string: "2C55E69E-4993-11ED-B878-0242AC120002") -private let fromNumUUID = CBUUID(string: "ED9DA18C-A800-4F66-A670-AA7547E34453") - -/// Standalone BLE manager that lets the watch connect directly to a -/// Meshtastic radio without relying on the paired iPhone. -/// -/// It discovers Meshtastic peripherals, connects, requests the node -/// database, and keeps the `nodes` dictionary up-to-date as position -/// packets arrive. -@MainActor -final class WatchBLEManager: NSObject, ObservableObject { - - // MARK: - Published state - - /// Discovered but not-yet-connected peripherals. - @Published var discoveredDevices: [DiscoveredDevice] = [] - - /// All mesh nodes we know about, keyed by node number. - @Published var nodes: [UInt32: MeshNode] = [:] - - /// Current connection state. - @Published var connectionState: WatchConnectionState = .disconnected - - /// Name of the connected peripheral (if any). - @Published var connectedDeviceName: String? - - /// Whether the central manager is currently scanning. - @Published var isScanning = false - - // MARK: - Internal state - - private let logger = Logger(subsystem: "gvh.MeshtasticClient.watchkitapp", category: "🛜 BLE") - private var centralManager: CBCentralManager! - private var connectedPeripheral: CBPeripheral? - private var toRadioCharacteristic: CBCharacteristic? - private var fromRadioCharacteristic: CBCharacteristic? - private var fromNumCharacteristic: CBCharacteristic? - - /// Our own node number, learned from `MyNodeInfo`. - private var myNodeNum: UInt32? - - /// Nonce we send in the wantConfig request so we can identify the - /// `configCompleteId` response. - private let wantConfigNonce: UInt32 = 69421 // matches NONCE_ONLY_DB - - // MARK: - Lifecycle - - override init() { - super.init() - centralManager = CBCentralManager(delegate: self, queue: nil) - } - - // MARK: - Public API - - func startScanning() { - guard centralManager.state == .poweredOn else { - logger.warning("Cannot scan – Bluetooth not powered on (\(self.centralManager.state.rawValue))") - return - } - logger.info("Starting BLE scan for Meshtastic devices") - discoveredDevices.removeAll() - centralManager.scanForPeripherals(withServices: [meshtasticServiceUUID], - options: [CBCentralManagerScanOptionAllowDuplicatesKey: false]) - isScanning = true - } - - func stopScanning() { - centralManager.stopScan() - isScanning = false - } - - func connect(to device: DiscoveredDevice) { - stopScanning() - connectionState = .connecting - connectedDeviceName = device.name - logger.info("Connecting to \(device.name, privacy: .public)") - centralManager.connect(device.peripheral, options: nil) - } - - func disconnect() { - if let peripheral = connectedPeripheral { - centralManager.cancelPeripheralConnection(peripheral) - } - cleanup() - } - - // MARK: - Helpers - - private func cleanup() { - connectedPeripheral = nil - toRadioCharacteristic = nil - fromRadioCharacteristic = nil - fromNumCharacteristic = nil - connectionState = .disconnected - connectedDeviceName = nil - myNodeNum = nil - } - - /// Send a `ToRadio` protobuf to the connected radio. - private func send(_ message: ToRadio) { - guard let peripheral = connectedPeripheral, - let characteristic = toRadioCharacteristic, - let data = try? message.serializedData() else { - logger.error("Cannot send – not connected or characteristic missing") - return - } - let writeType: CBCharacteristicWriteType = - characteristic.properties.contains(.writeWithoutResponse) ? .withoutResponse : .withResponse - peripheral.writeValue(data, for: characteristic, type: writeType) - } - - /// Request the full node database from the radio. - private func requestNodeDatabase() { - var toRadio = ToRadio() - toRadio.wantConfigID = wantConfigNonce - send(toRadio) - logger.info("Sent wantConfigID=\(self.wantConfigNonce)") - } - - /// Read (drain) packets from the FROMRADIO characteristic until an empty - /// response is received. - private func drainFromRadio() { - guard let peripheral = connectedPeripheral, - let characteristic = fromRadioCharacteristic else { return } - peripheral.readValue(for: characteristic) - } - - // MARK: - Packet handling - - private func handleFromRadio(_ data: Data) { - guard !data.isEmpty else { return } - guard let fromRadio = try? FromRadio(serializedBytes: data) else { - logger.error("Failed to decode FromRadio packet (\(data.count) bytes)") - return - } - - switch fromRadio.payloadVariant { - case .myInfo(let myInfo): - myNodeNum = myInfo.myNodeNum - logger.info("My node num: \(myInfo.myNodeNum)") - - case .nodeInfo(let nodeInfo): - upsertNode(from: nodeInfo) - - case .packet(let meshPacket): - handleMeshPacket(meshPacket) - - case .configCompleteID(let id): - logger.info("Config complete (nonce=\(id))") - connectionState = .connected - - default: - break - } - } - - private func handleMeshPacket(_ packet: MeshPacket) { - guard packet.hasDecoded else { return } - let decoded = packet.decoded - - switch decoded.portnum { - case .positionApp: - if let position = try? Position(serializedBytes: decoded.payload) { - upsertPosition(from: packet.from, position: position) - } - case .nodeInfoApp: - if let user = try? User(serializedBytes: decoded.payload) { - upsertUser(from: packet.from, user: user) - } - default: - break - } - } - - // MARK: - Node management - - private func upsertNode(from nodeInfo: NodeInfo) { - let num = nodeInfo.num - var node = nodes[num] ?? MeshNode(num: num, longName: "Node \(String(num, radix: 16))", shortName: String(String(num, radix: 16).suffix(4))) - - if nodeInfo.hasUser { - node.longName = nodeInfo.user.longName - node.shortName = nodeInfo.user.shortName - } - if nodeInfo.hasPosition, nodeInfo.position.latitudeI != 0, nodeInfo.position.longitudeI != 0 { - node.latitude = Double(nodeInfo.position.latitudeI) / 1e7 - node.longitude = Double(nodeInfo.position.longitudeI) / 1e7 - node.altitude = nodeInfo.position.altitude - node.lastPositionTime = Date(timeIntervalSince1970: TimeInterval(nodeInfo.position.time)) - } - if nodeInfo.lastHeard > 0 { - node.lastHeard = Date(timeIntervalSince1970: TimeInterval(nodeInfo.lastHeard)) - } - node.snr = nodeInfo.snr - nodes[num] = node - } - - private func upsertPosition(from nodeNum: UInt32, position: Position) { - guard position.latitudeI != 0, position.longitudeI != 0 else { return } - var node = nodes[nodeNum] ?? MeshNode(num: nodeNum, longName: "Node \(String(nodeNum, radix: 16))", shortName: String(String(nodeNum, radix: 16).suffix(4))) - node.latitude = Double(position.latitudeI) / 1e7 - node.longitude = Double(position.longitudeI) / 1e7 - node.altitude = position.altitude - node.lastPositionTime = Date() - node.lastHeard = Date() - nodes[nodeNum] = node - } - - private func upsertUser(from nodeNum: UInt32, user: User) { - var node = nodes[nodeNum] ?? MeshNode(num: nodeNum, longName: user.longName, shortName: user.shortName) - node.longName = user.longName - node.shortName = user.shortName - node.lastHeard = Date() - nodes[nodeNum] = node - } -} - -// MARK: - DiscoveredDevice -struct DiscoveredDevice: Identifiable { - let id: UUID - let peripheral: CBPeripheral - let name: String - let rssi: Int -} - -// MARK: - ConnectionState -enum WatchConnectionState: Equatable { - case disconnected - case connecting - case connected -} - -// MARK: - CBCentralManagerDelegate -extension WatchBLEManager: @preconcurrency CBCentralManagerDelegate { - - nonisolated func centralManagerDidUpdateState(_ central: CBCentralManager) { - Task { @MainActor in - switch central.state { - case .poweredOn: - logger.info("Bluetooth powered on") - case .poweredOff: - logger.warning("Bluetooth powered off") - cleanup() - case .unauthorized: - logger.warning("Bluetooth unauthorised") - default: - break - } - } - } - - nonisolated func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, - advertisementData: [String: Any], rssi RSSI: NSNumber) { - Task { @MainActor in - let name = peripheral.name ?? advertisementData[CBAdvertisementDataLocalNameKey] as? String ?? "Unknown" - if !discoveredDevices.contains(where: { $0.peripheral.identifier == peripheral.identifier }) { - let device = DiscoveredDevice(id: peripheral.identifier, peripheral: peripheral, name: name, rssi: RSSI.intValue) - discoveredDevices.append(device) - logger.info("Discovered \(name, privacy: .public) RSSI=\(RSSI.intValue)") - } - } - } - - nonisolated func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { - Task { @MainActor in - logger.info("Connected to \(peripheral.name ?? "Unknown", privacy: .public)") - connectedPeripheral = peripheral - peripheral.delegate = self - peripheral.discoverServices([meshtasticServiceUUID]) - } - } - - nonisolated func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) { - Task { @MainActor in - logger.error("Failed to connect: \(error?.localizedDescription ?? "unknown", privacy: .public)") - cleanup() - } - } - - nonisolated func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) { - Task { @MainActor in - logger.info("Disconnected from \(peripheral.name ?? "Unknown", privacy: .public)") - cleanup() - } - } -} - -// MARK: - CBPeripheralDelegate -extension WatchBLEManager: @preconcurrency CBPeripheralDelegate { - - nonisolated func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) { - Task { @MainActor in - guard error == nil, let services = peripheral.services else { - logger.error("Service discovery error: \(error?.localizedDescription ?? "nil", privacy: .public)") - return - } - for service in services where service.uuid == meshtasticServiceUUID { - peripheral.discoverCharacteristics([toRadioUUID, fromRadioUUID, fromNumUUID], for: service) - } - } - } - - nonisolated func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) { - Task { @MainActor in - guard error == nil, let characteristics = service.characteristics else { - logger.error("Characteristic discovery error: \(error?.localizedDescription ?? "nil", privacy: .public)") - return - } - for characteristic in characteristics { - switch characteristic.uuid { - case toRadioUUID: - toRadioCharacteristic = characteristic - case fromRadioUUID: - fromRadioCharacteristic = characteristic - case fromNumUUID: - fromNumCharacteristic = characteristic - peripheral.setNotifyValue(true, for: characteristic) - default: - break - } - } - if toRadioCharacteristic != nil && fromRadioCharacteristic != nil && fromNumCharacteristic != nil { - logger.info("All characteristics discovered – requesting node database") - requestNodeDatabase() - drainFromRadio() - } - } - } - - nonisolated func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) { - Task { @MainActor in - guard error == nil else { - logger.error("Value update error for \(characteristic.uuid): \(error!.localizedDescription, privacy: .public)") - return - } - switch characteristic.uuid { - case fromRadioUUID: - if let data = characteristic.value, !data.isEmpty { - handleFromRadio(data) - // Continue draining - drainFromRadio() - } - case fromNumUUID: - // New data available – start draining - drainFromRadio() - default: - break - } - } - } - - nonisolated func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) { - Task { @MainActor in - if let error { - logger.error("Write error: \(error.localizedDescription, privacy: .public)") - } - } - } -} diff --git a/Meshtastic Watch App/Meshtastic Watch App.entitlements b/Meshtastic Watch App/Meshtastic Watch App.entitlements index 800f23d0..b8cf6f9e 100644 --- a/Meshtastic Watch App/Meshtastic Watch App.entitlements +++ b/Meshtastic Watch App/Meshtastic Watch App.entitlements @@ -2,8 +2,6 @@ - com.apple.security.device.bluetooth - com.apple.security.personal-information.location diff --git a/Meshtastic Watch App/Models/MeshNode.swift b/Meshtastic Watch App/Models/MeshNode.swift index 3929d38e..9b1877b1 100644 --- a/Meshtastic Watch App/Models/MeshNode.swift +++ b/Meshtastic Watch App/Models/MeshNode.swift @@ -9,8 +9,8 @@ import Foundation import CoreLocation /// Lightweight in-memory model for a mesh node seen by the watch. -/// Avoids Core Data dependency so the watch app can run standalone. -struct MeshNode: Identifiable, Equatable { +/// Transferred from the companion iOS app via WatchConnectivity. +struct MeshNode: Identifiable, Equatable, Codable { /// Meshtastic node number (unique on the mesh). let num: UInt32 /// Stable identifier derived from the node number. diff --git a/Meshtastic Watch App/Views/DeviceConnectionView.swift b/Meshtastic Watch App/Views/DeviceConnectionView.swift index e7da814a..0abb3821 100644 --- a/Meshtastic Watch App/Views/DeviceConnectionView.swift +++ b/Meshtastic Watch App/Views/DeviceConnectionView.swift @@ -7,144 +7,64 @@ import SwiftUI -/// View for scanning and connecting to a Meshtastic BLE radio directly -/// from the Apple Watch (no phone required). +/// Shows the connectivity status between the Watch and the companion +/// iPhone app. Node data is received via WatchConnectivity. struct DeviceConnectionView: View { - @ObservedObject var bleManager: WatchBLEManager + @ObservedObject var phoneManager: PhoneConnectivityManager var body: some View { - Group { - switch bleManager.connectionState { - case .disconnected: - disconnectedView - case .connecting: - connectingView - case .connected: - connectedView - } - } - .navigationTitle("Radio") - } - - // MARK: - Disconnected - - @ViewBuilder - private var disconnectedView: some View { - VStack(spacing: 8) { - if bleManager.discoveredDevices.isEmpty && !bleManager.isScanning { - VStack(spacing: 8) { - Image(systemName: "antenna.radiowaves.left.and.right.slash") - .font(.title2) - .foregroundStyle(.secondary) - Text("No radio connected") - .font(.headline) - Text("Scan to find nearby Meshtastic radios.") - .font(.caption2) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - } - .padding() - } - - if bleManager.isScanning && bleManager.discoveredDevices.isEmpty { - VStack(spacing: 8) { - ProgressView() - Text("Scanning…") - .font(.caption) - .foregroundStyle(.secondary) - } - .padding() - } - - if !bleManager.discoveredDevices.isEmpty { - List(bleManager.discoveredDevices) { device in - Button { - bleManager.connect(to: device) - } label: { - HStack { - VStack(alignment: .leading, spacing: 2) { - Text(device.name) - .font(.system(size: 14, weight: .semibold)) - .lineLimit(1) - Text("\(device.rssi) dBm") - .font(.system(size: 11)) - .foregroundStyle(.secondary) - } - Spacer() - signalIcon(rssi: device.rssi) - } - } - } - } - - Button { - if bleManager.isScanning { - bleManager.stopScanning() - } else { - bleManager.startScanning() - } - } label: { - Label(bleManager.isScanning ? "Stop" : "Scan", - systemImage: bleManager.isScanning ? "stop.fill" : "magnifyingglass") - } - .buttonStyle(.borderedProminent) - .tint(bleManager.isScanning ? .red : .accentColor) - } - } - - // MARK: - Connecting - - @ViewBuilder - private var connectingView: some View { - VStack(spacing: 8) { - ProgressView() - Text("Connecting…") - .font(.headline) - if let name = bleManager.connectedDeviceName { - Text(name) - .font(.caption) - .foregroundStyle(.secondary) + VStack(spacing: 12) { + if phoneManager.isPhoneReachable { + reachableView + } else { + unreachableView } } .padding() + .navigationTitle("Phone") } - // MARK: - Connected + // MARK: - Phone Reachable @ViewBuilder - private var connectedView: some View { - VStack(spacing: 8) { - Image(systemName: "checkmark.circle.fill") - .font(.title2) - .foregroundStyle(.green) - Text("Connected") - .font(.headline) - if let name = bleManager.connectedDeviceName { - Text(name) - .font(.caption) - .foregroundStyle(.secondary) - } - Text("\(bleManager.nodes.count) nodes") + private var reachableView: some View { + Image(systemName: "iphone.radiowaves.left.and.right") + .font(.title2) + .foregroundStyle(.green) + Text("Phone Connected") + .font(.headline) + Text("\(phoneManager.nodes.count) nodes") + .font(.caption2) + .foregroundStyle(.secondary) + + Button { + phoneManager.requestRefresh() + } label: { + Label("Refresh", systemImage: "arrow.clockwise") + } + .buttonStyle(.bordered) + } + + // MARK: - Phone Unreachable + + @ViewBuilder + private var unreachableView: some View { + Image(systemName: "iphone.slash") + .font(.title2) + .foregroundStyle(.secondary) + Text("Phone Not Reachable") + .font(.headline) + + if phoneManager.hasReceivedData { + Text("\(phoneManager.nodes.count) cached nodes") .font(.caption2) .foregroundStyle(.secondary) - - Button(role: .destructive) { - bleManager.disconnect() - } label: { - Label("Disconnect", systemImage: "xmark.circle") - } - .buttonStyle(.bordered) + } else { + Text("Open Meshtastic on your iPhone to sync node data.") + .font(.caption2) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) } - .padding() - } - - // MARK: - Helpers - - @ViewBuilder - private func signalIcon(rssi: Int) -> some View { - Image(systemName: rssi > -85 ? "wifi" : "wifi.exclamationmark") - .font(.system(size: 12)) - .foregroundStyle(rssi > -65 ? .green : (rssi > -85 ? .yellow : .red)) } } diff --git a/Meshtastic Watch App/Views/FoxhuntCompassView.swift b/Meshtastic Watch App/Views/FoxhuntCompassView.swift index b7117060..6d60e3cf 100644 --- a/Meshtastic Watch App/Views/FoxhuntCompassView.swift +++ b/Meshtastic Watch App/Views/FoxhuntCompassView.swift @@ -34,14 +34,15 @@ struct FoxhuntCompassView: View { var body: some View { GeometryReader { geometry in let size = min(geometry.size.width, geometry.size.height) - let dialRadius = size * 0.38 + let dialRadius = size * 0.44 VStack(spacing: 2) { - // Node name - Text(node.shortName.isEmpty ? node.longName : node.shortName) - .font(.system(size: 13, weight: .semibold, design: .rounded)) - .foregroundStyle(.secondary) - .lineLimit(1) + // Node short name circle + WatchCircleText( + text: node.shortName.isEmpty ? "?" : node.shortName, + color: WatchCircleText.color(for: node.num), + circleSize: 32 + ) ZStack { // Fixed heading indicator at top @@ -55,7 +56,7 @@ struct FoxhuntCompassView: View { ZStack { // Outer ring Circle() - .stroke(Color.primary.opacity(0.15), lineWidth: 1) + .stroke(Color.primary.opacity(0.3), lineWidth: 3) .frame(width: dialRadius * 2 + 8, height: dialRadius * 2 + 8) // Tick marks (every 10° for watch readability) @@ -265,6 +266,9 @@ extension Color { var isWatchLight: Bool { // Approximate: yellow and lighter colours are "light" if self == .yellow || self == .orange || self == .white { return true } - return false + // For arbitrary colours, resolve RGBA and compute relative luminance + guard let components = cgColor?.components, components.count >= 3 else { return false } + let luminance = 0.299 * components[0] + 0.587 * components[1] + 0.114 * components[2] + return luminance > 0.6 } } diff --git a/Meshtastic Watch App/Views/NearbyNodesListView.swift b/Meshtastic Watch App/Views/NearbyNodesListView.swift index de99336c..4192292e 100644 --- a/Meshtastic Watch App/Views/NearbyNodesListView.swift +++ b/Meshtastic Watch App/Views/NearbyNodesListView.swift @@ -12,19 +12,28 @@ import CoreLocation /// position. Tapping a node opens the foxhunt compass pointing at it. struct NearbyNodesListView: View { - @ObservedObject var bleManager: WatchBLEManager + @ObservedObject var phoneManager: PhoneConnectivityManager @ObservedObject var locationManager: WatchLocationManager + @State private var selectedNode: MeshNode? /// Nodes filtered to ≤ 0.5 miles with a known position, sorted by distance. + /// Also includes any nodes pinned as foxhunt targets from the iOS app. private var nearbyNodes: [MeshNode] { guard let userLoc = locationManager.currentLocation else { return [] } - return bleManager.nodes.values + let targets = phoneManager.foxhuntTargets + return phoneManager.nodes.values .filter { node in - guard node.coordinate != nil, - let dist = node.distance(from: userLoc) else { return false } + guard node.coordinate != nil else { return false } + // Always include foxhunt targets regardless of distance + if targets.contains(node.num) { return true } + guard let dist = node.distance(from: userLoc) else { return false } return dist <= FoxhuntCompassView.maxDistanceMetres } .sorted { a, b in + let aIsTarget = targets.contains(a.num) + let bIsTarget = targets.contains(b.num) + // Foxhunt targets sort first + if aIsTarget != bIsTarget { return aIsTarget } let dA = a.distance(from: userLoc) ?? .greatestFiniteMagnitude let dB = b.distance(from: userLoc) ?? .greatestFiniteMagnitude return dA < dB @@ -39,7 +48,23 @@ struct NearbyNodesListView: View { nodeList } } - .navigationTitle("Foxhunt") + .navigationTitle { + HStack(spacing: 4) { + Image("logo-white") + .resizable() + .scaledToFit() + .frame(height: 16) + Image("custom.foxhunt") + .font(.system(size: 14)) + .foregroundStyle(.orange) + Text("Foxhunt") + .font(.headline) + .foregroundStyle(.green) + } + } + .sheet(item: $selectedNode) { node in + FoxhuntCompassView(node: node, locationManager: locationManager) + } } // MARK: - Sub-views @@ -58,8 +83,8 @@ struct NearbyNodesListView: View { .multilineTextAlignment(.center) .padding(.horizontal) - if bleManager.connectionState != .connected { - Text("Connect to a radio first.") + if !phoneManager.hasReceivedData { + Text("Open Meshtastic on your iPhone to sync.") .font(.caption2) .foregroundStyle(.orange) } @@ -70,7 +95,9 @@ struct NearbyNodesListView: View { @ViewBuilder private var nodeList: some View { List(nearbyNodes) { node in - NavigationLink(destination: FoxhuntCompassView(node: node, locationManager: locationManager)) { + Button { + selectedNode = node + } label: { nodeRow(node) } } @@ -79,7 +106,13 @@ struct NearbyNodesListView: View { @ViewBuilder private func nodeRow(_ node: MeshNode) -> some View { let userLoc = locationManager.currentLocation + let isTarget = phoneManager.foxhuntTargets.contains(node.num) HStack { + WatchCircleText( + text: node.shortName, + color: WatchCircleText.color(for: node.num), + circleSize: 28 + ) VStack(alignment: .leading, spacing: 2) { Text(node.longName) .font(.system(size: 14, weight: .semibold)) diff --git a/Meshtastic Watch App/Views/WatchCircleText.swift b/Meshtastic Watch App/Views/WatchCircleText.swift new file mode 100644 index 00000000..daede796 --- /dev/null +++ b/Meshtastic Watch App/Views/WatchCircleText.swift @@ -0,0 +1,38 @@ +// +// WatchCircleText.swift +// Meshtastic Watch App +// +// Copyright(c) Meshtastic 2025. +// + +import SwiftUI + +/// A small circle showing the node's short name, colored by node number. +/// Watch-only equivalent of the iOS `CircleText` view. +struct WatchCircleText: View { + var text: String + var color: Color + var circleSize: CGFloat = 28 + + var body: some View { + ZStack { + Circle() + .fill(color) + .frame(width: circleSize, height: circleSize) + Text(text) + .frame(width: circleSize * 0.9, height: circleSize * 0.9, alignment: .center) + .foregroundColor(color.isWatchLight ? .black : .white) + .minimumScaleFactor(0.001) + .font(.system(size: 1300)) + } + } + + /// Derives a `Color` from a Meshtastic node number, matching the iOS + /// `UIColor(hex:)` algorithm so circles look the same on both platforms. + static func color(for nodeNum: UInt32) -> Color { + let red = Double((nodeNum & 0xFF0000) >> 16) / 255.0 + let green = Double((nodeNum & 0x00FF00) >> 8) / 255.0 + let blue = Double(nodeNum & 0x0000FF) / 255.0 + return Color(red: red, green: green, blue: blue) + } +} diff --git a/Meshtastic.xcodeproj/project.pbxproj b/Meshtastic.xcodeproj/project.pbxproj index 30dbe152..98fd6628 100644 --- a/Meshtastic.xcodeproj/project.pbxproj +++ b/Meshtastic.xcodeproj/project.pbxproj @@ -93,6 +93,7 @@ 3D3417B42E2730EC006A988B /* GeoJSONOverlayManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D3417B32E2730EC006A988B /* GeoJSONOverlayManager.swift */; }; 3D3417C82E29D38A006A988B /* GeoJSONOverlayConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D3417C72E29D38A006A988B /* GeoJSONOverlayConfig.swift */; }; 3D3417D22E2DC260006A988B /* MapDataManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D3417D12E2DC260006A988B /* MapDataManager.swift */; }; + AA0006WTSM00000000BF0001 /* WatchSessionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0006WTSM00000000FR0001 /* WatchSessionManager.swift */; }; 3D3417D42E2DC293006A988B /* MapDataFiles.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D3417D32E2DC293006A988B /* MapDataFiles.swift */; }; 655AF7816E76D5F310DF87A6 /* FountainCodec.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F203877F307073096C89179 /* FountainCodec.swift */; }; 6D825E622C34786C008DBEE4 /* CommonRegex.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D825E612C34786C008DBEE4 /* CommonRegex.swift */; }; @@ -330,13 +331,14 @@ AA0005WTCH00000000BF0001 /* MeshtasticWatchApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0005WTCH00000000FR0001 /* MeshtasticWatchApp.swift */; }; AA0005WTCH00000000BF0002 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0005WTCH00000000FR0002 /* ContentView.swift */; }; AA0005WTCH00000000BF0003 /* MeshNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0005WTCH00000000FR0003 /* MeshNode.swift */; }; - AA0005WTCH00000000BF0004 /* WatchBLEManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0005WTCH00000000FR0004 /* WatchBLEManager.swift */; }; + AA0005WTCH00000000BF0004 /* PhoneConnectivityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0005WTCH00000000FR0004 /* PhoneConnectivityManager.swift */; }; AA0005WTCH00000000BF0005 /* WatchLocationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0005WTCH00000000FR0005 /* WatchLocationManager.swift */; }; AA0005WTCH00000000BF0006 /* FoxhuntCompassView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0005WTCH00000000FR0006 /* FoxhuntCompassView.swift */; }; AA0005WTCH00000000BF0007 /* NearbyNodesListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0005WTCH00000000FR0007 /* NearbyNodesListView.swift */; }; AA0005WTCH00000000BF0008 /* DeviceConnectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0005WTCH00000000FR0008 /* DeviceConnectionView.swift */; }; + AA0005WTCH00000000BF0013 /* WatchCircleText.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0005WTCH00000000FR0013 /* WatchCircleText.swift */; }; AA0005WTCH00000000BF0009 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = AA0005WTCH00000000FR0009 /* Assets.xcassets */; }; - AA0005WTCH00000000BF0010 /* MeshtasticProtobufs in Frameworks */ = {isa = PBXBuildFile; productRef = AA0005WTCH00000000PD0001 /* MeshtasticProtobufs */; }; + AA0005WTCH00000000BF0011 /* Meshtastic Watch App.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = AA0005WTCH00000000FR0012 /* Meshtastic Watch App.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; /* End PBXBuildFile section */ @@ -482,6 +484,7 @@ 3D3417B32E2730EC006A988B /* GeoJSONOverlayManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeoJSONOverlayManager.swift; sourceTree = ""; }; 3D3417C72E29D38A006A988B /* GeoJSONOverlayConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeoJSONOverlayConfig.swift; sourceTree = ""; }; 3D3417D12E2DC260006A988B /* MapDataManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapDataManager.swift; sourceTree = ""; }; + AA0006WTSM00000000FR0001 /* WatchSessionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchSessionManager.swift; sourceTree = ""; }; 3D3417D32E2DC293006A988B /* MapDataFiles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapDataFiles.swift; sourceTree = ""; }; 3F203877F307073096C89179 /* FountainCodec.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FountainCodec.swift; sourceTree = ""; }; 4AA216CF50721EE1AE7D7251 /* CoTMessage.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CoTMessage.swift; sourceTree = ""; }; @@ -776,11 +779,12 @@ AA0005WTCH00000000FR0001 /* MeshtasticWatchApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeshtasticWatchApp.swift; sourceTree = ""; }; AA0005WTCH00000000FR0002 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; AA0005WTCH00000000FR0003 /* MeshNode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeshNode.swift; sourceTree = ""; }; - AA0005WTCH00000000FR0004 /* WatchBLEManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchBLEManager.swift; sourceTree = ""; }; + AA0005WTCH00000000FR0004 /* PhoneConnectivityManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhoneConnectivityManager.swift; sourceTree = ""; }; AA0005WTCH00000000FR0005 /* WatchLocationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchLocationManager.swift; sourceTree = ""; }; AA0005WTCH00000000FR0006 /* FoxhuntCompassView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FoxhuntCompassView.swift; sourceTree = ""; }; AA0005WTCH00000000FR0007 /* NearbyNodesListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NearbyNodesListView.swift; sourceTree = ""; }; AA0005WTCH00000000FR0008 /* DeviceConnectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceConnectionView.swift; sourceTree = ""; }; + AA0005WTCH00000000FR0013 /* WatchCircleText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchCircleText.swift; sourceTree = ""; }; AA0005WTCH00000000FR0009 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; AA0005WTCH00000000FR0010 /* Meshtastic Watch App.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "Meshtastic Watch App.entitlements"; sourceTree = ""; }; AA0005WTCH00000000FR0011 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; @@ -828,7 +832,6 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - AA0005WTCH00000000BF0010 /* MeshtasticProtobufs in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1476,6 +1479,7 @@ 6D825E612C34786C008DBEE4 /* CommonRegex.swift */, 3D3417B32E2730EC006A988B /* GeoJSONOverlayManager.swift */, C37572859BC745C4284A9B42 /* TAK */, + AA0006WTSM00000000FR0001 /* WatchSessionManager.swift */, ); path = Helpers; sourceTree = ""; @@ -1596,6 +1600,7 @@ AA0005WTCH00000000FR0006 /* FoxhuntCompassView.swift */, AA0005WTCH00000000FR0007 /* NearbyNodesListView.swift */, AA0005WTCH00000000FR0008 /* DeviceConnectionView.swift */, + AA0005WTCH00000000FR0013 /* WatchCircleText.swift */, ); path = Views; sourceTree = ""; @@ -1603,7 +1608,7 @@ AA0005WTCH00000000GR0002 /* Managers */ = { isa = PBXGroup; children = ( - AA0005WTCH00000000FR0004 /* WatchBLEManager.swift */, + AA0005WTCH00000000FR0004 /* PhoneConnectivityManager.swift */, AA0005WTCH00000000FR0005 /* WatchLocationManager.swift */, ); path = Managers; @@ -1721,9 +1726,6 @@ dependencies = ( ); name = "Meshtastic Watch App"; - packageProductDependencies = ( - AA0005WTCH00000000PD0001 /* MeshtasticProtobufs */, - ); productName = "Meshtastic Watch App"; productReference = AA0005WTCH00000000FR0012 /* Meshtastic Watch App.app */; productType = "com.apple.product-type.application"; @@ -2082,6 +2084,7 @@ DDB6ABE428B13FFF00384BA1 /* DisplayEnums.swift in Sources */, DD4975A52B147BA90026544E /* AmbientLightingConfig.swift in Sources */, 3D3417D22E2DC260006A988B /* MapDataManager.swift in Sources */, + AA0006WTSM00000000BF0001 /* WatchSessionManager.swift in Sources */, D93068D92B81509C0066FBC8 /* TapbackResponses.swift in Sources */, D93068DA2B81509D0066FBC8 /* TapbackInputView.swift in Sources */, DDA9F5E82E77FAC100E70DEB /* AnimatedNodePin.swift in Sources */, @@ -2180,11 +2183,12 @@ AA0005WTCH00000000BF0001 /* MeshtasticWatchApp.swift in Sources */, AA0005WTCH00000000BF0002 /* ContentView.swift in Sources */, AA0005WTCH00000000BF0003 /* MeshNode.swift in Sources */, - AA0005WTCH00000000BF0004 /* WatchBLEManager.swift in Sources */, + AA0005WTCH00000000BF0004 /* PhoneConnectivityManager.swift in Sources */, AA0005WTCH00000000BF0005 /* WatchLocationManager.swift in Sources */, AA0005WTCH00000000BF0006 /* FoxhuntCompassView.swift in Sources */, AA0005WTCH00000000BF0007 /* NearbyNodesListView.swift in Sources */, AA0005WTCH00000000BF0008 /* DeviceConnectionView.swift in Sources */, + AA0005WTCH00000000BF0013 /* WatchCircleText.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -2574,9 +2578,9 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = "Meshtastic Watch App/Info.plist"; INFOPLIST_KEY_CFBundleDisplayName = "Meshtastic Foxhunt"; - INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Meshtastic needs Bluetooth to connect directly to your Meshtastic radio for foxhunt direction finding."; INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "Meshtastic needs your location to calculate distance and bearing to mesh nodes during foxhunt."; - INFOPLIST_KEY_WKRunsIndependentlyOfCompanionApp = YES; + INFOPLIST_KEY_WKCompanionAppBundleIdentifier = gvh.MeshtasticClient; + INFOPLIST_KEY_WKRunsIndependentlyOfCompanionApp = NO; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -2585,7 +2589,7 @@ PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient.watchkitapp; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = watchos; - SKIP_INSTALL = YES; + SKIP_INSTALL = NO; SUPPORTED_PLATFORMS = "watchos watchsimulator"; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; @@ -2605,9 +2609,9 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = "Meshtastic Watch App/Info.plist"; INFOPLIST_KEY_CFBundleDisplayName = "Meshtastic Foxhunt"; - INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Meshtastic needs Bluetooth to connect directly to your Meshtastic radio for foxhunt direction finding."; INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "Meshtastic needs your location to calculate distance and bearing to mesh nodes during foxhunt."; - INFOPLIST_KEY_WKRunsIndependentlyOfCompanionApp = YES; + INFOPLIST_KEY_WKCompanionAppBundleIdentifier = gvh.MeshtasticClient; + INFOPLIST_KEY_WKRunsIndependentlyOfCompanionApp = NO; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -2616,7 +2620,7 @@ PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient.watchkitapp; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = watchos; - SKIP_INSTALL = YES; + SKIP_INSTALL = NO; SUPPORTED_PLATFORMS = "watchos watchsimulator"; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; @@ -2754,10 +2758,6 @@ package = DD0D3D202A55CEB10066DB71 /* XCRemoteSwiftPackageReference "CocoaMQTT" */; productName = CocoaMQTT; }; - AA0005WTCH00000000PD0001 /* MeshtasticProtobufs */ = { - isa = XCSwiftPackageProductDependency; - productName = MeshtasticProtobufs; - }; /* End XCSwiftPackageProductDependency section */ /* Begin XCVersionGroup section */ diff --git a/Meshtastic/Accessory/Accessory Manager/AccessoryManager.swift b/Meshtastic/Accessory/Accessory Manager/AccessoryManager.swift index 551ce3dd..5188af37 100644 --- a/Meshtastic/Accessory/Accessory Manager/AccessoryManager.swift +++ b/Meshtastic/Accessory/Accessory Manager/AccessoryManager.swift @@ -532,6 +532,7 @@ class AccessoryManager: ObservableObject, MqttClientProxyManagerDelegate { Logger.mesh.info("🕸️ MESH PACKET received for Remote Hardware App UNHANDLED \((try? decodedInfo.packet.jsonString()) ?? "JSON Decode Failure", privacy: .public)") case .positionApp: await MeshPackets.shared.upsertPositionPacket(packet: packet) + WatchSessionManager.shared.sendNodesToWatch() // Broadcast position to TAK clients if let position = try? Position(serializedBytes: data.payload) { Logger.tak.debug("Position received, calling broadcast") @@ -738,6 +739,9 @@ class AccessoryManager: ObservableObject, MqttClientProxyManagerDelegate { do { try context.save() Logger.data.info("💾 [Database] Batch saved all node info after database retrieval") + + // Push updated node data to the companion Watch app + WatchSessionManager.shared.sendNodesToWatch() } catch { context.rollback() let nsError = error as NSError diff --git a/Meshtastic/AppIntents/NavigateToNodeIntent.swift b/Meshtastic/AppIntents/NavigateToNodeIntent.swift deleted file mode 100644 index 9559796c..00000000 --- a/Meshtastic/AppIntents/NavigateToNodeIntent.swift +++ /dev/null @@ -1,61 +0,0 @@ -// -// NavigateToNodeIntent.swift -// Meshtastic -// -// Created by Benjamin Faershtein on 2/8/25. -// - -import Foundation -import AppIntents -import CoreLocation -import CoreData -import UIKit - -@available(iOS 16.4, *) -struct NavigateToNodeIntent: ForegroundContinuableIntent { - - static var title: LocalizedStringResource = "Navigate to Node Position" - static var openAppWhenRun: Bool = false - - @Parameter(title: "Node Number") - var nodeNum: Int - - @MainActor - func perform() async throws -> some IntentResult & ProvidesDialog { - if !BLEManager.shared.isConnected { - throw AppIntentErrors.AppIntentError.notConnected - } - - let fetchNodeInfoRequest: NSFetchRequest = NSFetchRequest(entityName: "NodeInfoEntity") - fetchNodeInfoRequest.predicate = NSPredicate(format: "num == %lld", Int64(nodeNum)) - - do { - guard let fetchedNode = try PersistenceController.shared.container.viewContext.fetch(fetchNodeInfoRequest) as? [NodeInfoEntity], - fetchedNode.count == 1 else { - throw $nodeNum.needsValueError("Could not find node") - } - - let nodeInfo = fetchedNode[0] - if let latitude = nodeInfo.latestPosition?.coordinate.latitude, - let longitude = nodeInfo.latestPosition?.coordinate.longitude { - - let url = URL(string: "maps://?saddr=&daddr=\(latitude),\(longitude)") - - if let mapURL = url, UIApplication.shared.canOpenURL(mapURL) { - // Request to continue in foreground before opening the app - try await requestToContinueInForeground() - - // Open Apple Maps for navigation - UIApplication.shared.open(mapURL, options: [:], completionHandler: nil) - return .result(dialog: "Navigating to node location.") - } else { - throw AppIntentErrors.AppIntentError.message("Unable to open Apple Maps.") - } - } else { - throw AppIntentErrors.AppIntentError.message("Node does not have a recorded position.") - } - } catch { - throw AppIntentErrors.AppIntentError.message("Failed to fetch node data.") - } - } -} diff --git a/Meshtastic/AppIntents/TracerouteIntent.swift b/Meshtastic/AppIntents/TracerouteIntent.swift deleted file mode 100644 index 99c19348..00000000 --- a/Meshtastic/AppIntents/TracerouteIntent.swift +++ /dev/null @@ -1,27 +0,0 @@ -import Foundation -import AppIntents - -struct TracerouteIntent: AppIntent { - static var title: LocalizedStringResource = "Send a Traceroute" - - static var description: IntentDescription = "Send a traceroute request to a certain Meshtastic node" - - @Parameter(title: "Node Number") - var nodeNumber: Int - - static var parameterSummary: some ParameterSummary { - Summary("Send traceroute to \(\.$nodeNumber)") - } - - func perform() async throws -> some IntentResult { - if !BLEManager.shared.isConnected { - throw AppIntentErrors.AppIntentError.notConnected - } - - if !BLEManager.shared.sendTraceRouteRequest(destNum: Int64(nodeNumber), wantResponse: true) { - throw AppIntentErrors.AppIntentError.message("Failed to send traceroute request") - } - - return .result() - } -} diff --git a/Meshtastic/Assets.xcassets/custom.foxhunt.symbolset/Contents.json b/Meshtastic/Assets.xcassets/custom.foxhunt.symbolset/Contents.json new file mode 100644 index 00000000..f8182534 --- /dev/null +++ b/Meshtastic/Assets.xcassets/custom.foxhunt.symbolset/Contents.json @@ -0,0 +1,12 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "symbols" : [ + { + "filename" : "custom.foxhunt.svg", + "idiom" : "universal" + } + ] +} diff --git a/Meshtastic/Assets.xcassets/custom.foxhunt.symbolset/custom.foxhunt.svg b/Meshtastic/Assets.xcassets/custom.foxhunt.symbolset/custom.foxhunt.svg new file mode 100644 index 00000000..aff04982 --- /dev/null +++ b/Meshtastic/Assets.xcassets/custom.foxhunt.symbolset/custom.foxhunt.svg @@ -0,0 +1,66 @@ + + + + + + + + + + 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/EmojiOnlyTextField.swift b/Meshtastic/Helpers/EmojiOnlyTextField.swift deleted file mode 100644 index aae9e3a3..00000000 --- a/Meshtastic/Helpers/EmojiOnlyTextField.swift +++ /dev/null @@ -1,105 +0,0 @@ -// -// EmojiKeyboard.swift -// Meshtastic -// -// Copyright(c) Garth Vander Houwen 1/10/23. -// -import SwiftUI - -class SwiftUIEmojiTextField: UITextField { - var shouldBecomeFirstResponderOnAppear = false - - func setEmoji() { - _ = self.textInputMode - } - - override var textInputContextIdentifier: String? { - return "" - } - - override var textInputMode: UITextInputMode? { - for mode in UITextInputMode.activeInputModes where mode.primaryLanguage == "emoji" { - self.keyboardType = .default // do not remove this - return mode - } - return nil - } - - override func didMoveToWindow() { - super.didMoveToWindow() - if shouldBecomeFirstResponderOnAppear && window != nil { - DispatchQueue.main.async { [weak self] in - self?.becomeFirstResponder() - } - } - } -} - -struct EmojiOnlyTextField: UIViewRepresentable { - @Binding var text: String - var placeholder: String = "" - var onBecomeFirstResponder: (() -> Void)? - var onKeyboardTypeChanged: ((Bool) -> Void)? // true if NOT emoji (should dismiss), false if emoji - var onKeyboardDismissed: (() -> Void)? // Called when keyboard is dismissed - - func makeUIView(context: Context) -> SwiftUIEmojiTextField { - let emojiTextField = SwiftUIEmojiTextField() - emojiTextField.placeholder = placeholder - emojiTextField.text = text - emojiTextField.delegate = context.coordinator - emojiTextField.shouldBecomeFirstResponderOnAppear = true - context.coordinator.textField = emojiTextField - return emojiTextField - } - - func updateUIView(_ uiView: SwiftUIEmojiTextField, context: Context) { - uiView.text = text - context.coordinator.onBecomeFirstResponder = onBecomeFirstResponder - context.coordinator.onKeyboardTypeChanged = onKeyboardTypeChanged - context.coordinator.onKeyboardDismissed = onKeyboardDismissed - } - - func makeCoordinator() -> Coordinator { - Coordinator(parent: self) - } - - class Coordinator: NSObject, UITextFieldDelegate { - var parent: EmojiOnlyTextField - var textField: SwiftUIEmojiTextField? - var onBecomeFirstResponder: (() -> Void)? - var onKeyboardTypeChanged: ((Bool) -> Void)? - var onKeyboardDismissed: (() -> Void)? - var previousInputMode: String? - - init(parent: EmojiOnlyTextField) { - self.parent = parent - } - - func textFieldDidBeginEditing(_ textField: UITextField) { - onBecomeFirstResponder?() - checkInputMode(textField) - } - - func textFieldDidEndEditing(_ textField: UITextField) { - // Keyboard was dismissed - onKeyboardDismissed?() - } - - func textFieldDidChangeSelection(_ textField: UITextField) { - DispatchQueue.main.async { [weak self] in - self?.parent.text = textField.text ?? "" - } - checkInputMode(textField) - } - - private func checkInputMode(_ textField: UITextField) { - if let inputMode = textField.textInputMode { - let isEmoji = inputMode.primaryLanguage == "emoji" - if previousInputMode != inputMode.primaryLanguage { - previousInputMode = inputMode.primaryLanguage - onKeyboardTypeChanged?(!isEmoji) // true if NOT emoji (should dismiss) - } - } - } - } -} diff --git a/Meshtastic/Helpers/Preferences.swift b/Meshtastic/Helpers/Preferences.swift deleted file mode 100644 index 93ff482a..00000000 --- a/Meshtastic/Helpers/Preferences.swift +++ /dev/null @@ -1,33 +0,0 @@ -// -// Preferences.swift -// MeshtasticClient -// -// Created by Garth Vander Houwen on 12/16/21. -// -// import Foundation -// import Combine -// import SwiftUI -// -// class Prefs -// { -// private let defaults = UserDefaults.standard -// -// private let keyIntExample = "intExample" -// -// var intExample = { -// set { -// defaults.setValue(newValue, forKey: keyIntExample) -// } -// get { -// return defaults.integer(forKey: keyIntExample) -// } -// } -// -// class var shared: Prefs { -// struct Static { -// static let instance = Prefs() -// } -// -// return Static.instance -// } -// } diff --git a/Meshtastic/Helpers/TAK/MeshToCoTConverter.swift b/Meshtastic/Helpers/TAK/MeshToCoTConverter.swift deleted file mode 100644 index 6c9f9029..00000000 --- a/Meshtastic/Helpers/TAK/MeshToCoTConverter.swift +++ /dev/null @@ -1,271 +0,0 @@ -// -// MeshToCoTConverter.swift -// Meshtastic -// -// Converts Meshtastic packets to CoT format for TAK Server -// - -import Foundation -import MeshtasticProtobufs -import CoreLocation -import OSLog -import Combine - -/// Converts Meshtastic packets to CoT format for bridging to TAK Server -final class MeshToCoTConverter: ObservableObject { - - static let shared = MeshToCoTConverter() - - private let logger = Logger(subsystem: "Meshtastic", category: "MeshToCoT") - - private init() {} - - // MARK: - Position // MARK: Packet to CoT - - /// Convert a Meshtastic position packet to CoT message - func convertPosition(_ position: Position, from node: NodeInfoEntity) -> CoTMessage? { - guard let user = node.user else { - logger.warning("Cannot convert position: node has no user info") - return nil - } - - let callsign = user.longName ?? user.shortName ?? "Unknown" - let uid = "MESHTASTIC-\(node.num.toHex())" - - let latitude = Double(position.latitudeI) / 1e7 - let longitude = Double(position.longitudeI) / 1e7 - let altitude = Double(position.altitude) - - var speed: Double = 0 - var course: Double = 0 - if position.speed != 0 { - speed = Double(position.speed) * 0.194384 // Convert to knots - } - if position.heading != 0 { - course = Double(position.heading) - } - - let battery = Int(position.batteryLevel) - - return CoTMessage.pli( - uid: uid, - callsign: callsign, - latitude: latitude, - longitude: longitude, - altitude: altitude, - speed: speed, - course: course, - team: "Meshtastic", - role: "Team Member", - battery: battery > 0 ? battery : 100, - staleMinutes: 10 - ) - } - - // MARK: - Node Info to CoT - - /// Convert node info to CoT message (for node presence updates) - func convertNodeInfo(_ node: NodeInfoEntity) -> CoTMessage? { - guard let user = node.user else { - logger.warning("Cannot convert node info: node has no user info") - return nil - } - - let callsign = user.longName ?? user.shortName ?? "Unknown" - let uid = "MESHTASTIC-\(node.num.toHex())" - - var latitude = 0.0 - var longitude = 0.0 - var altitude = 9999999.0 - - if let position = node.position { - latitude = Double(position.latitudeI) / 1e7 - longitude = Double(position.longitudeI) / 1e7 - if position.altitude != 0 { - altitude = Double(position.altitude) - } - } - - // Determine CoT type based on device role - let cotType = getCoTTypeForRole(user.role) - - let now = Date() - return CoTMessage( - uid: uid, - type: cotType, - time: now, - start: now, - stale: now.addingTimeInterval(3600), // 1 hour stale for node info - how: "m-g", - latitude: latitude, - longitude: longitude, - hae: altitude, - ce: 9999999.0, - le: 9999999.0, - contact: CoTContact(callsign: callsign, endpoint: "0.0.0.0:4242:tcp"), - group: CoTGroup(name: "Meshtastic", role: getRoleNameForDeviceRole(user.role)), - remarks: "Meshtastic Node: \(callsign)" - ) - } - - // MARK: - Waypoint to CoT - - /// Convert a Meshtastic waypoint to CoT message - func convertWaypoint(_ waypoint: Waypoint, from node: NodeInfoEntity?) -> CoTMessage? { - let uid = "WAYPOINT-\(waypoint.id)" - - let latitude = Double(waypoint.latitudeI) / 1e7 - let longitude = Double(waypoint.longitudeI) / 1e7 - let altitude = waypoint.altitude > 0 ? Double(waypoint.altitude) : 9999999.0 - - let name = waypoint.name.isEmpty ? "Unnamed Waypoint" : waypoint.name - let description = waypoint.description_p.isEmpty ? "Meshtastic Waypoint" : waypoint.description_p - - // Get emoji based on waypoint icon/expire time - let iconEmoji = getEmojiForWaypoint(waypoint) - - // Handle expiry - if expire is 0, never expire. Otherwise use the expire time as Unix timestamp - let stale: Date - if waypoint.expire == 0 { - // Never expire - set to 1 year from now - stale = Date().addingTimeInterval(365 * 24 * 60 * 60) - } else { - // expire is Unix timestamp when waypoint expires - let expireDate = Date(timeIntervalSince1970: TimeInterval(waypoint.expire)) - if expireDate > Date() { - stale = expireDate - } else { - // Already expired, don't broadcast - return nil - } - } - - return CoTMessage( - uid: uid, - type: "b-ttf-ff", // Point feature friend - standard CoT type for waypoints/markers - time: Date(), - start: Date(), - stale: stale, - how: "m-g", - latitude: latitude, - longitude: longitude, - hae: altitude, - ce: 100.0, - le: 100.0, - contact: CoTContact(callsign: "\(iconEmoji) \(name)", endpoint: "0.0.0.0:4242:tcp"), - remarks: "\(description)\nCreated by: \(node?.user?.longName ?? "Unknown")" - ) - } - - // MARK: - Text Message to CoT - - /// Convert a Meshtastic text message to CoT chat message - func convertTextMessage(_ message: MessageEntity, from sender: NodeInfoEntity) -> CoTMessage? { - guard let user = sender.user, - let text = message.text else { - return nil - } - - let senderName = user.longName ?? user.shortName ?? "Unknown" - let senderUid = "MESHTASTIC-\(sender.num.toHex())" - let messageId = "MSG-\(message.id)" - - return CoTMessage.chat( - senderUid: senderUid, - senderCallsign: senderName, - message: text, - chatroom: "Primary" - ) - } - - // MARK: - Helper Methods - - /// Get CoT type based on device role - private func getCoTTypeForRole(_ role: UInt32) -> String { - switch DeviceRoles(rawValue: Int(role)) { - case .router, .routerLate: - return "a-f-G-E" // Group entity (router) - case .tracker: - return "a-f-G-T-C" // Ground unit tracker - case .tak: - return "a-f-G-U-C" // TAK client - case .takTracker: - return "a-f-G-T-C" // TAK tracker - case .sensor: - return "a-f-G-s" // Sensor with friendly affiliation - case .client, .clientMute, .clientHidden, .lostAndFound: - return "a-f-G-U-C" // Friendly ground unit - default: - return "a-f-G-U-C" // Default to friendly unit - } - } - - /// Get role name for device role - private func getRoleNameForDeviceRole(_ role: UInt32) -> String { - switch DeviceRoles(rawValue: Int(role)) { - case .router, .routerLate: - return "Router" - case .tracker: - return "Tracker" - case .tak: - return "TAK" - case .takTracker: - return "TAK Tracker" - case .sensor: - return "Sensor" - case .client: - return "Client" - case .clientMute: - return "Muted" - case .clientHidden: - return "Hidden" - default: - return "User" - } - } - - /// Get emoji for waypoint based on icon - private func getEmojiForWaypoint(_ waypoint: Waypoint) -> String { - // Use icon field if available, otherwise use expire time to guess - if waypoint.icon != 0 { - switch waypoint.icon { - case 1: return "📍" // Marker - case 2: return "🚗" // Car - case 3: return "🚶" // Person - case 4: return "🏠" // Home - case 5: return "⛺" // Camp - case 6: return "⚠️" // Warning - case 7: return "🏁" // Flag - case 8: return "🔍" // Search - case 9: return "🏥" // Medical - case 10: return "🔥" // Fire - case 11: return "🚁" // Helicopter - case 12: return "⛵" // Boat - case 13: return "🛸" // UFO - default: return "📍" - } - } - - // Fallback based on name - let name = waypoint.name.lowercased() - if name.contains("help") || name.contains("emergency") { - return "🆘" - } else if name.contains("medical") || name.contains("hospital") { - return "🏥" - } else if name.contains("danger") || name.contains("warning") { - return "⚠️" - } else if name.contains("camp") { - return "⛺" - } else if name.contains("home") || name.contains("house") { - return "🏠" - } else if name.contains("car") || name.contains("vehicle") { - return "🚗" - } else if name.contains("flag") { - return "🏁" - } else if name.contains("person") || name.contains("me") { - return "🚶" - } else { - return "📍" - } - } -} diff --git a/Meshtastic/Helpers/WatchSessionManager.swift b/Meshtastic/Helpers/WatchSessionManager.swift new file mode 100644 index 00000000..872eb074 --- /dev/null +++ b/Meshtastic/Helpers/WatchSessionManager.swift @@ -0,0 +1,183 @@ +// +// WatchSessionManager.swift +// Meshtastic +// +// Copyright(c) Meshtastic 2025. +// + +import Foundation +import WatchConnectivity +import CoreData +import os + +/// Manages the WatchConnectivity session on the iOS side, sending mesh node +/// data to the companion Apple Watch app. +/// +/// Call `sendNodesToWatch()` whenever node data changes (e.g., after +/// receiving position updates from the radio). +final class WatchSessionManager: NSObject, ObservableObject { + + static let shared = WatchSessionManager() + + private let logger = Logger(subsystem: "gvh.MeshtasticClient", category: "⌚ Watch") + private var session: WCSession? + + override init() { + super.init() + guard WCSession.isSupported() else { + logger.info("WCSession not supported on this device") + return + } + let session = WCSession.default + session.delegate = self + session.activate() + self.session = session + logger.info("WCSession activated on iOS") + } + + // MARK: - Public API + + /// Send a specific node to the Watch as a foxhunt target. + /// The Watch will pin this node in its foxhunt list regardless of distance. + func sendNodeForFoxhunt(_ nodeNum: Int64) { + guard let session, session.activationState == .activated, session.isPaired, session.isWatchAppInstalled else { + logger.warning("Cannot send foxhunt target – Watch not available") + return + } + guard session.isReachable else { + // Fall back to transferUserInfo when not reachable + session.transferUserInfo(["foxhuntTarget": UInt32(nodeNum)]) + logger.info("Queued foxhunt target \(nodeNum) via transferUserInfo") + return + } + session.sendMessage(["foxhuntTarget": UInt32(nodeNum)], replyHandler: nil) { error in + Task { @MainActor in + self.logger.error("Failed to send foxhunt target: \(error.localizedDescription, privacy: .public)") + } + } + logger.info("Sent foxhunt target \(nodeNum) to Watch") + } + + /// Fetch nodes from Core Data and push them to the Watch via application context. + func sendNodesToWatch() { + guard let session, session.activationState == .activated, session.isPaired, session.isWatchAppInstalled else { + return + } + + let context = PersistenceController.shared.container.viewContext + context.perform { [weak self] in + guard let self else { return } + let nodes = self.fetchNodesForWatch(context: context) + guard !nodes.isEmpty else { return } + + do { + let data = try JSONEncoder().encode(nodes) + try session.updateApplicationContext(["nodes": data]) + self.logger.info("Sent \(nodes.count) nodes to Watch via applicationContext") + } catch { + self.logger.error("Failed to send nodes to Watch: \(error.localizedDescription, privacy: .public)") + } + } + } + + // MARK: - Core Data → Watch Node Serialization + + private func fetchNodesForWatch(context: NSManagedObjectContext) -> [WatchNode] { + let fetchRequest = NSFetchRequest(entityName: "NodeInfoEntity") + fetchRequest.predicate = NSPredicate(format: "user != nil") + + do { + let results = try context.fetch(fetchRequest) + return results.compactMap { nodeInfo -> WatchNode? in + guard let user = nodeInfo.value(forKey: "user") as? NSManagedObject else { return nil } + + let num = nodeInfo.value(forKey: "num") as? Int64 ?? 0 + let longName = user.value(forKey: "longName") as? String ?? "Unknown" + let shortName = user.value(forKey: "shortName") as? String ?? "?" + let snr = nodeInfo.value(forKey: "snr") as? Float + let lastHeard = nodeInfo.value(forKey: "lastHeard") as? Date + + // Get the latest position from the ordered set + var latitude: Double? + var longitude: Double? + var altitude: Int32? + var lastPositionTime: Date? + + if let positions = nodeInfo.value(forKey: "positions") as? NSOrderedSet { + // Find the position marked as latest, or use the last one + let posArray = positions.array as? [NSManagedObject] ?? [] + let latestPosition = posArray.first(where: { + ($0.value(forKey: "latest") as? Bool) == true + }) ?? posArray.last + + if let pos = latestPosition { + let latI = pos.value(forKey: "latitudeI") as? Int32 ?? 0 + let lonI = pos.value(forKey: "longitudeI") as? Int32 ?? 0 + if latI != 0, lonI != 0 { + latitude = Double(latI) / 1e7 + longitude = Double(lonI) / 1e7 + altitude = pos.value(forKey: "altitude") as? Int32 + lastPositionTime = pos.value(forKey: "time") as? Date + } + } + } + + return WatchNode( + num: UInt32(num), + longName: longName, + shortName: shortName, + latitude: latitude, + longitude: longitude, + altitude: altitude, + lastPositionTime: lastPositionTime, + lastHeard: lastHeard, + snr: snr + ) + } + } catch { + logger.error("Failed to fetch nodes for Watch: \(error.localizedDescription, privacy: .public)") + return [] + } + } +} + +// MARK: - WCSessionDelegate +extension WatchSessionManager: WCSessionDelegate { + + func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) { + if let error { + logger.error("WCSession activation failed: \(error.localizedDescription, privacy: .public)") + } else { + logger.info("WCSession activated (state=\(activationState.rawValue))") + } + } + + func sessionDidBecomeInactive(_ session: WCSession) { + logger.info("WCSession became inactive") + } + + func sessionDidDeactivate(_ session: WCSession) { + logger.info("WCSession deactivated – reactivating") + session.activate() + } + + func session(_ session: WCSession, didReceiveMessage message: [String: Any]) { + if message["request"] as? String == "refreshNodes" { + logger.info("Watch requested node refresh") + sendNodesToWatch() + } + } +} + +// MARK: - WatchNode (mirrors the Watch app's MeshNode, Codable for transfer) +struct WatchNode: Codable { + let num: UInt32 + let longName: String + let shortName: String + let latitude: Double? + let longitude: Double? + let altitude: Int32? + let lastPositionTime: Date? + let lastHeard: Date? + let snr: Float? +} diff --git a/Meshtastic/MeshtasticApp.swift b/Meshtastic/MeshtasticApp.swift index 9d9f6789..2e0171ae 100644 --- a/Meshtastic/MeshtasticApp.swift +++ b/Meshtastic/MeshtasticApp.swift @@ -5,6 +5,7 @@ import CoreData import OSLog import TipKit import MeshtasticProtobufs +import WatchConnectivity import DatadogCore import DatadogCrashReporting import DatadogRUM @@ -91,6 +92,9 @@ struct MeshtasticAppleApp: App { // Initialize map data manager MapDataManager.shared.initialize() + + // Initialize WatchConnectivity session + _ = WatchSessionManager.shared #if DEBUG // Show tips in development try? Tips.resetDatastore() diff --git a/Meshtastic/ShowTime.swift b/Meshtastic/ShowTime.swift deleted file mode 100644 index e69de29b..00000000 diff --git a/Meshtastic/Views/Nodes/Helpers/NodeDetail.swift b/Meshtastic/Views/Nodes/Helpers/NodeDetail.swift index fcfad87d..f282881f 100644 --- a/Meshtastic/Views/Nodes/Helpers/NodeDetail.swift +++ b/Meshtastic/Views/Nodes/Helpers/NodeDetail.swift @@ -8,6 +8,7 @@ import WeatherKit import MapKit import CoreLocation import OSLog +import WatchConnectivity struct NodeDetail: View { private let gridItemLayout = Array(repeating: GridItem(.flexible(), spacing: 10), count: 2) @@ -479,6 +480,17 @@ struct NodeDetail: View { } if node.hasPositions { #if !targetEnvironment(macCatalyst) + if node.latestPosition?.isPreciseLocation == true && WCSession.isSupported() && WCSession.default.isPaired && WCSession.default.isWatchAppInstalled { + Button { + WatchSessionManager.shared.sendNodeForFoxhunt(node.num) + } label: { + Label { + Text("Foxhunt on your watch") + } icon: { + Image("custom.foxhunt") + } + } + } if node.latestPosition?.isPreciseLocation == true { Button { showingCompassSheet = true diff --git a/Meshtastic/Views/Nodes/NodeRow.swift b/Meshtastic/Views/Nodes/NodeRow.swift deleted file mode 100644 index 91d70de8..00000000 --- a/Meshtastic/Views/Nodes/NodeRow.swift +++ /dev/null @@ -1,70 +0,0 @@ -import SwiftUI - -struct NodeRow: View { - var node: NodeInfoEntity - var connected: Bool - - var body: some View { - VStack(alignment: .leading) { - - HStack { - - CircleText(text: node.user?.shortName ?? "???", color: Color.accentColor).offset(y: 1).padding(.trailing, 5) - .offset(x: -15) - - if UIDevice.current.userInterfaceIdiom == .pad { - Text(node.user?.longName ?? "Unknown").font(.headline) - .offset(x: -15) - } else { - Text(node.user?.longName ?? "Unknown").font(.title) - .offset(x: -15) - } - } - .padding(.bottom, 10) - - if connected { - HStack(alignment: .bottom) { - - Image(systemName: "repeat.circle.fill").font(.title3) - .foregroundColor(.accentColor).symbolRenderingMode(.hierarchical) - Text("Currently Connected").font(.title3).foregroundColor(Color.accentColor) - } - Spacer() - } - - HStack(alignment: .bottom) { - - Image(systemName: "clock.badge.checkmark.fill").font(.title3).foregroundColor(.accentColor).symbolRenderingMode(.hierarchical) - - if UIDevice.current.userInterfaceIdiom == .pad { - - if node.lastHeard != nil { - Text("Last Heard: \(node.lastHeard!, style: .relative) ago").font(.caption).foregroundColor(.gray) - .padding(.bottom) - } else { - Text("Last Heard: Unknown").font(.caption).foregroundColor(.gray) - } - - } else { - - if node.lastHeard != nil { - Text("Last Heard: \(node.lastHeard!, style: .relative) ago").font(.subheadline).foregroundColor(.gray) - } else { - Text("Last Heard: Unknown").font(.subheadline).foregroundColor(.gray) - } - } - } - }.padding([.leading, .top, .bottom]) - } -} - -struct NodeRow_Previews: PreviewProvider { - // static var nodes = BLEManager().meshData.nodes - - static var previews: some View { - Group { - // NodeRow(node: nodes[0], connected: true) - } - .previewLayout(.fixed(width: 300, height: 70)) - } -}