working fox hunt prototype

This commit is contained in:
Garth Vander Houwen 2026-04-19 20:26:45 -07:00
parent 319f3303e2
commit 82f28301ca
32 changed files with 696 additions and 1146 deletions

View file

@ -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" : {

View file

@ -1,6 +1,7 @@
{
"images" : [
{
"filename" : "watch-icon.png",
"idiom" : "universal",
"platform" : "watchOS",
"size" : "1024x1024"

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

View file

@ -0,0 +1,12 @@
{
"info" : {
"author" : "xcode",
"version" : 1
},
"symbols" : [
{
"filename" : "custom.foxhunt.svg",
"idiom" : "universal"
}
]
}

View file

@ -0,0 +1,66 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--Generator: Custom SF Symbol Foxhunt-->
<!DOCTYPE svg
PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 3300 2200">
<!--glyph: "foxhunt", point size: 100.0, template writer version: "138.0.0"-->
<style>.monochrome-0 {-sfsymbols-motion-group:0;-sfsymbols-layer-tags:-a1b2c3d4e5f60001}
.multicolor-0:tintColor {-sfsymbols-motion-group:0;-sfsymbols-layer-tags:-a1b2c3d4e5f60001}
.hierarchical-0:primary {-sfsymbols-motion-group:0;-sfsymbols-layer-tags:-a1b2c3d4e5f60001}
.SFSymbolsPreviewWireframe {fill:none;opacity:1.0;stroke:black;stroke-width:0.5}
</style>
<g id="Notes">
<rect height="2200" id="artboard" style="fill:white;opacity:1" width="3300" x="0" y="0"/>
<line style="fill:none;stroke:black;opacity:1;stroke-width:0.5;" x1="263" x2="3036" y1="292" y2="292"/>
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;font-weight:bold;" transform="matrix(1 0 0 1 263 322)">Weight/Scale Variations</text>
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;text-anchor:middle;" transform="matrix(1 0 0 1 559.711 322)">Ultralight</text>
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;text-anchor:middle;" transform="matrix(1 0 0 1 1449.84 322)">Regular</text>
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;text-anchor:middle;" transform="matrix(1 0 0 1 2933.4 322)">Black</text>
<line style="fill:none;stroke:black;opacity:1;stroke-width:0.5;" x1="263" x2="3036" y1="1903" y2="1903"/>
<text id="template-version" style="stroke:none;fill:black;font-family:sans-serif;font-size:13;text-anchor:end;" transform="matrix(1 0 0 1 3036 1933)">Template v.6.0</text>
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;text-anchor:end;" transform="matrix(1 0 0 1 3036 1951)">Requires Xcode 16 or greater</text>
<text id="descriptive-name" style="stroke:none;fill:black;font-family:sans-serif;font-size:13;text-anchor:end;" transform="matrix(1 0 0 1 3036 1969)">Generated from custom.foxhunt</text>
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;text-anchor:end;" transform="matrix(1 0 0 1 3036 1987)">Typeset at 100.0 points</text>
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;" transform="matrix(1 0 0 1 263 726)">Small</text>
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;" transform="matrix(1 0 0 1 263 1156)">Medium</text>
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;" transform="matrix(1 0 0 1 263 1586)">Large</text>
</g>
<g id="Guides">
<g id="H-reference" style="fill:#27AAE1;stroke:none;" transform="matrix(1 0 0 1 339 696)">
<path d="M0.993654 0L3.63775 0L29.3281-67.1323L30.0303-67.1323L30.0303-70.459L28.1226-70.459ZM11.6885-24.4799L46.9815-24.4799L46.2315-26.7285L12.4385-26.7285ZM55.1196 0L57.7637 0L30.6382-70.459L29.4326-70.459L29.4326-67.1323Z"/>
</g>
<line id="Baseline-S" style="fill:none;stroke:#27AAE1;opacity:1;stroke-width:0.5;" x1="263" x2="3036" y1="696" y2="696"/>
<line id="Capline-S" style="fill:none;stroke:#27AAE1;opacity:1;stroke-width:0.5;" x1="263" x2="3036" y1="625.541" y2="625.541"/>
<g id="H-reference" style="fill:#27AAE1;stroke:none;" transform="matrix(1 0 0 1 339 1126)">
<path d="M0.993654 0L3.63775 0L29.3281-67.1323L30.0303-67.1323L30.0303-70.459L28.1226-70.459ZM11.6885-24.4799L46.9815-24.4799L46.2315-26.7285L12.4385-26.7285ZM55.1196 0L57.7637 0L30.6382-70.459L29.4326-70.459L29.4326-67.1323Z"/>
</g>
<line id="Baseline-M" style="fill:none;stroke:#27AAE1;opacity:1;stroke-width:0.5;" x1="263" x2="3036" y1="1126" y2="1126"/>
<line id="Capline-M" style="fill:none;stroke:#27AAE1;opacity:1;stroke-width:0.5;" x1="263" x2="3036" y1="1055.54" y2="1055.54"/>
<g id="H-reference" style="fill:#27AAE1;stroke:none;" transform="matrix(1 0 0 1 339 1556)">
<path d="M0.993654 0L3.63775 0L29.3281-67.1323L30.0303-67.1323L30.0303-70.459L28.1226-70.459ZM11.6885-24.4799L46.9815-24.4799L46.2315-26.7285L12.4385-26.7285ZM55.1196 0L57.7637 0L30.6382-70.459L29.4326-70.459L29.4326-67.1323Z"/>
</g>
<line id="Baseline-L" style="fill:none;stroke:#27AAE1;opacity:1;stroke-width:0.5;" x1="263" x2="3036" y1="1556" y2="1556"/>
<line id="Capline-L" style="fill:none;stroke:#27AAE1;opacity:1;stroke-width:0.5;" x1="263" x2="3036" y1="1485.54" y2="1485.54"/>
<line id="left-margin-Ultralight-S" style="fill:none;stroke:#00AEEF;stroke-width:0.5;opacity:1.0;" x1="515.423" x2="515.423" y1="600.785" y2="720.121"/>
<line id="right-margin-Ultralight-S" style="fill:none;stroke:#00AEEF;stroke-width:0.5;opacity:1.0;" x1="604" x2="604" y1="600.785" y2="720.121"/>
<line id="left-margin-Regular-S" style="fill:none;stroke:#00AEEF;stroke-width:0.5;opacity:1.0;" x1="1405.56" x2="1405.56" y1="600.785" y2="720.121"/>
<line id="right-margin-Regular-S" style="fill:none;stroke:#00AEEF;stroke-width:0.5;opacity:1.0;" x1="1494.13" x2="1494.13" y1="600.785" y2="720.121"/>
<line id="left-margin-Black-S" style="fill:none;stroke:#00AEEF;stroke-width:0.5;opacity:1.0;" x1="2889.11" x2="2889.11" y1="600.785" y2="720.121"/>
<line id="right-margin-Black-S" style="fill:none;stroke:#00AEEF;stroke-width:0.5;opacity:1.0;" x1="2977.69" x2="2977.69" y1="600.785" y2="720.121"/>
</g>
<g id="Symbols">
<g id="Black-S" transform="matrix(1 0 0 1 2889.11 696)">
<path class="monochrome-0 multicolor-0:tintColor hierarchical-0:primary SFSymbolsPreviewWireframe" fill-rule="evenodd" d="M 44 -2 L 55 -13 L 60 -20 L 68 -24 L 66 -28 L 65 -35 L 63 -44 L 61 -68 L 53 -48 L 50 -46 L 44 -49 L 38 -46 L 35 -48 L 27 -68 L 25 -44 L 23 -35 L 22 -28 L 20 -24 L 28 -20 L 33 -13 Z M 31 -32 L 35 -36 L 39 -32 L 35 -28 Z M 49 -32 L 53 -36 L 57 -32 L 53 -28 Z M 42 -10 L 44 -7 L 46 -10 L 44 -13 Z M 38 -43 L 44 -37 L 50 -43 L 48 -44 L 44 -40 L 40 -44 Z"/>
</g>
<g id="Regular-S" transform="matrix(1 0 0 1 1405.56 696)">
<path class="monochrome-0 multicolor-0:tintColor hierarchical-0:primary SFSymbolsPreviewWireframe" fill-rule="evenodd" d="M 44 -2 L 55 -13 L 60 -20 L 68 -24 L 66 -28 L 65 -35 L 63 -44 L 61 -68 L 53 -48 L 50 -46 L 44 -49 L 38 -46 L 35 -48 L 27 -68 L 25 -44 L 23 -35 L 22 -28 L 20 -24 L 28 -20 L 33 -13 Z M 31 -32 L 35 -36 L 39 -32 L 35 -28 Z M 49 -32 L 53 -36 L 57 -32 L 53 -28 Z M 42 -10 L 44 -7 L 46 -10 L 44 -13 Z M 38 -43 L 44 -37 L 50 -43 L 48 -44 L 44 -40 L 40 -44 Z"/>
</g>
<g id="Ultralight-S" transform="matrix(1 0 0 1 515.423 696)">
<path class="monochrome-0 multicolor-0:tintColor hierarchical-0:primary SFSymbolsPreviewWireframe" fill-rule="evenodd" d="M 44 -2 L 55 -13 L 60 -20 L 68 -24 L 66 -28 L 65 -35 L 63 -44 L 61 -68 L 53 -48 L 50 -46 L 44 -49 L 38 -46 L 35 -48 L 27 -68 L 25 -44 L 23 -35 L 22 -28 L 20 -24 L 28 -20 L 33 -13 Z M 31 -32 L 35 -36 L 39 -32 L 35 -28 Z M 49 -32 L 53 -36 L 57 -32 L 53 -28 Z M 42 -10 L 44 -7 L 46 -10 L 44 -13 Z M 38 -43 L 44 -37 L 50 -43 L 48 -44 L 44 -40 L 40 -44 Z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 7 KiB

View file

@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "Mesh_Logo_White.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 100 55" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(0.802386,0,0,0.460028,-421.748,-122.127)">
<g transform="matrix(0.579082,0,0,1.01004,460.975,-39.6867)">
<path d="M250.908,330.267L193.126,415.005L180.938,406.694L244.802,313.037C246.174,311.024 248.453,309.819 250.889,309.816C253.326,309.814 255.606,311.015 256.982,313.026L320.994,406.536L308.821,414.869L250.908,330.267Z" style="fill:white;"/>
</g>
<g transform="matrix(0.582378,0,0,1.01579,485.019,-211.182)">
<path d="M87.642,581.398L154.757,482.977L142.638,474.713L75.523,573.134L87.642,581.398Z" style="fill:white;"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1 KiB

View file

@ -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 {

View file

@ -2,13 +2,11 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSBluetoothAlwaysUsageDescription</key>
<string>Meshtastic needs Bluetooth to connect directly to your Meshtastic radio for foxhunt direction finding.</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>Meshtastic needs your location to calculate distance and bearing to mesh nodes during foxhunt.</string>
<key>WKApplication</key>
<true/>
<key>WKRunsIndependentlyOfCompanionApp</key>
<true/>
<false/>
</dict>
</plist>

View file

@ -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<UInt32> = []
// 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)
}
}
}

View file

@ -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)")
}
}
}
}

View file

@ -2,8 +2,6 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.device.bluetooth</key>
<true/>
<key>com.apple.security.personal-information.location</key>
<true/>
</dict>

View file

@ -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.

View file

@ -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))
}
}

View file

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

View file

@ -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))

View file

@ -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)
}
}

View file

@ -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 = "<group>"; };
3D3417C72E29D38A006A988B /* GeoJSONOverlayConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeoJSONOverlayConfig.swift; sourceTree = "<group>"; };
3D3417D12E2DC260006A988B /* MapDataManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapDataManager.swift; sourceTree = "<group>"; };
AA0006WTSM00000000FR0001 /* WatchSessionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchSessionManager.swift; sourceTree = "<group>"; };
3D3417D32E2DC293006A988B /* MapDataFiles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapDataFiles.swift; sourceTree = "<group>"; };
3F203877F307073096C89179 /* FountainCodec.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FountainCodec.swift; sourceTree = "<group>"; };
4AA216CF50721EE1AE7D7251 /* CoTMessage.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CoTMessage.swift; sourceTree = "<group>"; };
@ -776,11 +779,12 @@
AA0005WTCH00000000FR0001 /* MeshtasticWatchApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeshtasticWatchApp.swift; sourceTree = "<group>"; };
AA0005WTCH00000000FR0002 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
AA0005WTCH00000000FR0003 /* MeshNode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeshNode.swift; sourceTree = "<group>"; };
AA0005WTCH00000000FR0004 /* WatchBLEManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchBLEManager.swift; sourceTree = "<group>"; };
AA0005WTCH00000000FR0004 /* PhoneConnectivityManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhoneConnectivityManager.swift; sourceTree = "<group>"; };
AA0005WTCH00000000FR0005 /* WatchLocationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchLocationManager.swift; sourceTree = "<group>"; };
AA0005WTCH00000000FR0006 /* FoxhuntCompassView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FoxhuntCompassView.swift; sourceTree = "<group>"; };
AA0005WTCH00000000FR0007 /* NearbyNodesListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NearbyNodesListView.swift; sourceTree = "<group>"; };
AA0005WTCH00000000FR0008 /* DeviceConnectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceConnectionView.swift; sourceTree = "<group>"; };
AA0005WTCH00000000FR0013 /* WatchCircleText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchCircleText.swift; sourceTree = "<group>"; };
AA0005WTCH00000000FR0009 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
AA0005WTCH00000000FR0010 /* Meshtastic Watch App.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "Meshtastic Watch App.entitlements"; sourceTree = "<group>"; };
AA0005WTCH00000000FR0011 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
@ -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 = "<group>";
@ -1596,6 +1600,7 @@
AA0005WTCH00000000FR0006 /* FoxhuntCompassView.swift */,
AA0005WTCH00000000FR0007 /* NearbyNodesListView.swift */,
AA0005WTCH00000000FR0008 /* DeviceConnectionView.swift */,
AA0005WTCH00000000FR0013 /* WatchCircleText.swift */,
);
path = Views;
sourceTree = "<group>";
@ -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 */

View file

@ -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

View file

@ -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<NSFetchRequestResult> = 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.")
}
}
}

View file

@ -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()
}
}

View file

@ -0,0 +1,12 @@
{
"info" : {
"author" : "xcode",
"version" : 1
},
"symbols" : [
{
"filename" : "custom.foxhunt.svg",
"idiom" : "universal"
}
]
}

View file

@ -0,0 +1,66 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--Generator: Custom SF Symbol Foxhunt-->
<!DOCTYPE svg
PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 3300 2200">
<!--glyph: "foxhunt", point size: 100.0, template writer version: "138.0.0"-->
<style>.monochrome-0 {-sfsymbols-motion-group:0;-sfsymbols-layer-tags:-a1b2c3d4e5f60001}
.multicolor-0:tintColor {-sfsymbols-motion-group:0;-sfsymbols-layer-tags:-a1b2c3d4e5f60001}
.hierarchical-0:primary {-sfsymbols-motion-group:0;-sfsymbols-layer-tags:-a1b2c3d4e5f60001}
.SFSymbolsPreviewWireframe {fill:none;opacity:1.0;stroke:black;stroke-width:0.5}
</style>
<g id="Notes">
<rect height="2200" id="artboard" style="fill:white;opacity:1" width="3300" x="0" y="0"/>
<line style="fill:none;stroke:black;opacity:1;stroke-width:0.5;" x1="263" x2="3036" y1="292" y2="292"/>
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;font-weight:bold;" transform="matrix(1 0 0 1 263 322)">Weight/Scale Variations</text>
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;text-anchor:middle;" transform="matrix(1 0 0 1 559.711 322)">Ultralight</text>
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;text-anchor:middle;" transform="matrix(1 0 0 1 1449.84 322)">Regular</text>
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;text-anchor:middle;" transform="matrix(1 0 0 1 2933.4 322)">Black</text>
<line style="fill:none;stroke:black;opacity:1;stroke-width:0.5;" x1="263" x2="3036" y1="1903" y2="1903"/>
<text id="template-version" style="stroke:none;fill:black;font-family:sans-serif;font-size:13;text-anchor:end;" transform="matrix(1 0 0 1 3036 1933)">Template v.6.0</text>
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;text-anchor:end;" transform="matrix(1 0 0 1 3036 1951)">Requires Xcode 16 or greater</text>
<text id="descriptive-name" style="stroke:none;fill:black;font-family:sans-serif;font-size:13;text-anchor:end;" transform="matrix(1 0 0 1 3036 1969)">Generated from custom.foxhunt</text>
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;text-anchor:end;" transform="matrix(1 0 0 1 3036 1987)">Typeset at 100.0 points</text>
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;" transform="matrix(1 0 0 1 263 726)">Small</text>
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;" transform="matrix(1 0 0 1 263 1156)">Medium</text>
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;" transform="matrix(1 0 0 1 263 1586)">Large</text>
</g>
<g id="Guides">
<g id="H-reference" style="fill:#27AAE1;stroke:none;" transform="matrix(1 0 0 1 339 696)">
<path d="M0.993654 0L3.63775 0L29.3281-67.1323L30.0303-67.1323L30.0303-70.459L28.1226-70.459ZM11.6885-24.4799L46.9815-24.4799L46.2315-26.7285L12.4385-26.7285ZM55.1196 0L57.7637 0L30.6382-70.459L29.4326-70.459L29.4326-67.1323Z"/>
</g>
<line id="Baseline-S" style="fill:none;stroke:#27AAE1;opacity:1;stroke-width:0.5;" x1="263" x2="3036" y1="696" y2="696"/>
<line id="Capline-S" style="fill:none;stroke:#27AAE1;opacity:1;stroke-width:0.5;" x1="263" x2="3036" y1="625.541" y2="625.541"/>
<g id="H-reference" style="fill:#27AAE1;stroke:none;" transform="matrix(1 0 0 1 339 1126)">
<path d="M0.993654 0L3.63775 0L29.3281-67.1323L30.0303-67.1323L30.0303-70.459L28.1226-70.459ZM11.6885-24.4799L46.9815-24.4799L46.2315-26.7285L12.4385-26.7285ZM55.1196 0L57.7637 0L30.6382-70.459L29.4326-70.459L29.4326-67.1323Z"/>
</g>
<line id="Baseline-M" style="fill:none;stroke:#27AAE1;opacity:1;stroke-width:0.5;" x1="263" x2="3036" y1="1126" y2="1126"/>
<line id="Capline-M" style="fill:none;stroke:#27AAE1;opacity:1;stroke-width:0.5;" x1="263" x2="3036" y1="1055.54" y2="1055.54"/>
<g id="H-reference" style="fill:#27AAE1;stroke:none;" transform="matrix(1 0 0 1 339 1556)">
<path d="M0.993654 0L3.63775 0L29.3281-67.1323L30.0303-67.1323L30.0303-70.459L28.1226-70.459ZM11.6885-24.4799L46.9815-24.4799L46.2315-26.7285L12.4385-26.7285ZM55.1196 0L57.7637 0L30.6382-70.459L29.4326-70.459L29.4326-67.1323Z"/>
</g>
<line id="Baseline-L" style="fill:none;stroke:#27AAE1;opacity:1;stroke-width:0.5;" x1="263" x2="3036" y1="1556" y2="1556"/>
<line id="Capline-L" style="fill:none;stroke:#27AAE1;opacity:1;stroke-width:0.5;" x1="263" x2="3036" y1="1485.54" y2="1485.54"/>
<line id="left-margin-Ultralight-S" style="fill:none;stroke:#00AEEF;stroke-width:0.5;opacity:1.0;" x1="515.423" x2="515.423" y1="600.785" y2="720.121"/>
<line id="right-margin-Ultralight-S" style="fill:none;stroke:#00AEEF;stroke-width:0.5;opacity:1.0;" x1="604" x2="604" y1="600.785" y2="720.121"/>
<line id="left-margin-Regular-S" style="fill:none;stroke:#00AEEF;stroke-width:0.5;opacity:1.0;" x1="1405.56" x2="1405.56" y1="600.785" y2="720.121"/>
<line id="right-margin-Regular-S" style="fill:none;stroke:#00AEEF;stroke-width:0.5;opacity:1.0;" x1="1494.13" x2="1494.13" y1="600.785" y2="720.121"/>
<line id="left-margin-Black-S" style="fill:none;stroke:#00AEEF;stroke-width:0.5;opacity:1.0;" x1="2889.11" x2="2889.11" y1="600.785" y2="720.121"/>
<line id="right-margin-Black-S" style="fill:none;stroke:#00AEEF;stroke-width:0.5;opacity:1.0;" x1="2977.69" x2="2977.69" y1="600.785" y2="720.121"/>
</g>
<g id="Symbols">
<g id="Black-S" transform="matrix(1 0 0 1 2889.11 696)">
<path class="monochrome-0 multicolor-0:tintColor hierarchical-0:primary SFSymbolsPreviewWireframe" fill-rule="evenodd" d="M 44 -2 L 55 -13 L 60 -20 L 68 -24 L 66 -28 L 65 -35 L 63 -44 L 61 -68 L 53 -48 L 50 -46 L 44 -49 L 38 -46 L 35 -48 L 27 -68 L 25 -44 L 23 -35 L 22 -28 L 20 -24 L 28 -20 L 33 -13 Z M 31 -32 L 35 -36 L 39 -32 L 35 -28 Z M 49 -32 L 53 -36 L 57 -32 L 53 -28 Z M 42 -10 L 44 -7 L 46 -10 L 44 -13 Z M 38 -43 L 44 -37 L 50 -43 L 48 -44 L 44 -40 L 40 -44 Z"/>
</g>
<g id="Regular-S" transform="matrix(1 0 0 1 1405.56 696)">
<path class="monochrome-0 multicolor-0:tintColor hierarchical-0:primary SFSymbolsPreviewWireframe" fill-rule="evenodd" d="M 44 -2 L 55 -13 L 60 -20 L 68 -24 L 66 -28 L 65 -35 L 63 -44 L 61 -68 L 53 -48 L 50 -46 L 44 -49 L 38 -46 L 35 -48 L 27 -68 L 25 -44 L 23 -35 L 22 -28 L 20 -24 L 28 -20 L 33 -13 Z M 31 -32 L 35 -36 L 39 -32 L 35 -28 Z M 49 -32 L 53 -36 L 57 -32 L 53 -28 Z M 42 -10 L 44 -7 L 46 -10 L 44 -13 Z M 38 -43 L 44 -37 L 50 -43 L 48 -44 L 44 -40 L 40 -44 Z"/>
</g>
<g id="Ultralight-S" transform="matrix(1 0 0 1 515.423 696)">
<path class="monochrome-0 multicolor-0:tintColor hierarchical-0:primary SFSymbolsPreviewWireframe" fill-rule="evenodd" d="M 44 -2 L 55 -13 L 60 -20 L 68 -24 L 66 -28 L 65 -35 L 63 -44 L 61 -68 L 53 -48 L 50 -46 L 44 -49 L 38 -46 L 35 -48 L 27 -68 L 25 -44 L 23 -35 L 22 -28 L 20 -24 L 28 -20 L 33 -13 Z M 31 -32 L 35 -36 L 39 -32 L 35 -28 Z M 49 -32 L 53 -36 L 57 -32 L 53 -28 Z M 42 -10 L 44 -7 L 46 -10 L 44 -13 Z M 38 -43 L 44 -37 L 50 -43 L 48 -44 L 44 -40 L 40 -44 Z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 7 KiB

View file

@ -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<CBManagerState, Never> = .init()
var peripheralSubject: PassthroughSubject<CBPeripheral, Never> = .init()
func start() {
centralManager = .init(delegate: self, queue: .main)
}
func connect(_ peripheral: CBPeripheral) {
centralManager.stopScan()
peripheral.delegate = self
centralManager.connect(peripheral)
}
}

View file

@ -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)
}
}
}
}
}

View file

@ -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
// }
// }

View file

@ -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 "📍"
}
}
}

View file

@ -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<NSManagedObject>(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?
}

View file

@ -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()

View file

@ -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

View file

@ -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))
}
}