mirror of
https://github.com/meshtastic/Meshtastic-Apple.git
synced 2026-04-20 22:13:56 +00:00
Add watchOS foxhunt compass app for standalone direction finding to mesh nodes
Agent-Logs-Url: https://github.com/meshtastic/Meshtastic-Apple/sessions/07fad82b-9a10-4db0-a0b0-445d8ef28e50 Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com>
This commit is contained in:
parent
3b6f7fdbee
commit
183223e522
13 changed files with 1230 additions and 0 deletions
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0.000",
|
||||
"green" : "0.776",
|
||||
"red" : "0.404"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"platform" : "watchOS",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
6
Meshtastic Watch App/Assets.xcassets/Contents.json
Normal file
6
Meshtastic Watch App/Assets.xcassets/Contents.json
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
41
Meshtastic Watch App/ContentView.swift
Normal file
41
Meshtastic Watch App/ContentView.swift
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
//
|
||||
// ContentView.swift
|
||||
// Meshtastic Watch App
|
||||
//
|
||||
// Copyright(c) Meshtastic 2025.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// Root view of the Meshtastic Watch App.
|
||||
///
|
||||
/// Uses a tab-based layout:
|
||||
/// 1. **Foxhunt** – nearby nodes list → compass
|
||||
/// 2. **Radio** – BLE device connection
|
||||
struct ContentView: View {
|
||||
|
||||
@StateObject private var bleManager = WatchBLEManager()
|
||||
@StateObject private var locationManager = WatchLocationManager()
|
||||
|
||||
var body: some View {
|
||||
TabView {
|
||||
// Tab 1: Foxhunt
|
||||
NavigationStack {
|
||||
NearbyNodesListView(bleManager: bleManager, locationManager: locationManager)
|
||||
}
|
||||
|
||||
// Tab 2: Radio connection
|
||||
NavigationStack {
|
||||
DeviceConnectionView(bleManager: bleManager)
|
||||
}
|
||||
}
|
||||
.tabViewStyle(.verticalPage)
|
||||
.onAppear {
|
||||
locationManager.requestAuthorization()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ContentView()
|
||||
}
|
||||
14
Meshtastic Watch App/Info.plist
Normal file
14
Meshtastic Watch App/Info.plist
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!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/>
|
||||
</dict>
|
||||
</plist>
|
||||
374
Meshtastic Watch App/Managers/WatchBLEManager.swift
Normal file
374
Meshtastic Watch App/Managers/WatchBLEManager.swift
Normal file
|
|
@ -0,0 +1,374 @@
|
|||
//
|
||||
// WatchBLEManager.swift
|
||||
// Meshtastic Watch App
|
||||
//
|
||||
// Copyright(c) Meshtastic 2025.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CoreBluetooth
|
||||
import MeshtasticProtobufs
|
||||
import os
|
||||
|
||||
// MARK: - Meshtastic BLE UUIDs (same as the main app)
|
||||
private let meshtasticServiceUUID = CBUUID(string: "6BA1B218-15A8-461F-9FA8-5DCAE273EAFD")
|
||||
private let toRadioUUID = CBUUID(string: "F75C76D2-129E-4DAD-A1DD-7866124401E7")
|
||||
private let fromRadioUUID = CBUUID(string: "2C55E69E-4993-11ED-B878-0242AC120002")
|
||||
private let fromNumUUID = CBUUID(string: "ED9DA18C-A800-4F66-A670-AA7547E34453")
|
||||
|
||||
/// Standalone BLE manager that lets the watch connect directly to a
|
||||
/// Meshtastic radio without relying on the paired iPhone.
|
||||
///
|
||||
/// It discovers Meshtastic peripherals, connects, requests the node
|
||||
/// database, and keeps the `nodes` dictionary up-to-date as position
|
||||
/// packets arrive.
|
||||
@MainActor
|
||||
final class WatchBLEManager: NSObject, ObservableObject {
|
||||
|
||||
// MARK: - Published state
|
||||
|
||||
/// Discovered but not-yet-connected peripherals.
|
||||
@Published var discoveredDevices: [DiscoveredDevice] = []
|
||||
|
||||
/// All mesh nodes we know about, keyed by node number.
|
||||
@Published var nodes: [UInt32: MeshNode] = [:]
|
||||
|
||||
/// Current connection state.
|
||||
@Published var connectionState: WatchConnectionState = .disconnected
|
||||
|
||||
/// Name of the connected peripheral (if any).
|
||||
@Published var connectedDeviceName: String?
|
||||
|
||||
/// Whether the central manager is currently scanning.
|
||||
@Published var isScanning = false
|
||||
|
||||
// MARK: - Internal state
|
||||
|
||||
private let logger = Logger(subsystem: "gvh.MeshtasticClient.watchkitapp", category: "🛜 BLE")
|
||||
private var centralManager: CBCentralManager!
|
||||
private var connectedPeripheral: CBPeripheral?
|
||||
private var toRadioCharacteristic: CBCharacteristic?
|
||||
private var fromRadioCharacteristic: CBCharacteristic?
|
||||
private var fromNumCharacteristic: CBCharacteristic?
|
||||
|
||||
/// Our own node number, learned from `MyNodeInfo`.
|
||||
private var myNodeNum: UInt32?
|
||||
|
||||
/// Nonce we send in the wantConfig request so we can identify the
|
||||
/// `configCompleteId` response.
|
||||
private let wantConfigNonce: UInt32 = 69421 // matches NONCE_ONLY_DB
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
centralManager = CBCentralManager(delegate: self, queue: nil)
|
||||
}
|
||||
|
||||
// MARK: - Public API
|
||||
|
||||
func startScanning() {
|
||||
guard centralManager.state == .poweredOn else {
|
||||
logger.warning("Cannot scan – Bluetooth not powered on (\(self.centralManager.state.rawValue))")
|
||||
return
|
||||
}
|
||||
logger.info("Starting BLE scan for Meshtastic devices")
|
||||
discoveredDevices.removeAll()
|
||||
centralManager.scanForPeripherals(withServices: [meshtasticServiceUUID],
|
||||
options: [CBCentralManagerScanOptionAllowDuplicatesKey: false])
|
||||
isScanning = true
|
||||
}
|
||||
|
||||
func stopScanning() {
|
||||
centralManager.stopScan()
|
||||
isScanning = false
|
||||
}
|
||||
|
||||
func connect(to device: DiscoveredDevice) {
|
||||
stopScanning()
|
||||
connectionState = .connecting
|
||||
connectedDeviceName = device.name
|
||||
logger.info("Connecting to \(device.name, privacy: .public)")
|
||||
centralManager.connect(device.peripheral, options: nil)
|
||||
}
|
||||
|
||||
func disconnect() {
|
||||
if let peripheral = connectedPeripheral {
|
||||
centralManager.cancelPeripheralConnection(peripheral)
|
||||
}
|
||||
cleanup()
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func cleanup() {
|
||||
connectedPeripheral = nil
|
||||
toRadioCharacteristic = nil
|
||||
fromRadioCharacteristic = nil
|
||||
fromNumCharacteristic = nil
|
||||
connectionState = .disconnected
|
||||
connectedDeviceName = nil
|
||||
myNodeNum = nil
|
||||
}
|
||||
|
||||
/// Send a `ToRadio` protobuf to the connected radio.
|
||||
private func send(_ message: ToRadio) {
|
||||
guard let peripheral = connectedPeripheral,
|
||||
let characteristic = toRadioCharacteristic,
|
||||
let data = try? message.serializedData() else {
|
||||
logger.error("Cannot send – not connected or characteristic missing")
|
||||
return
|
||||
}
|
||||
let writeType: CBCharacteristicWriteType =
|
||||
characteristic.properties.contains(.writeWithoutResponse) ? .withoutResponse : .withResponse
|
||||
peripheral.writeValue(data, for: characteristic, type: writeType)
|
||||
}
|
||||
|
||||
/// Request the full node database from the radio.
|
||||
private func requestNodeDatabase() {
|
||||
var toRadio = ToRadio()
|
||||
toRadio.wantConfigID = wantConfigNonce
|
||||
send(toRadio)
|
||||
logger.info("Sent wantConfigID=\(self.wantConfigNonce)")
|
||||
}
|
||||
|
||||
/// Read (drain) packets from the FROMRADIO characteristic until an empty
|
||||
/// response is received.
|
||||
private func drainFromRadio() {
|
||||
guard let peripheral = connectedPeripheral,
|
||||
let characteristic = fromRadioCharacteristic else { return }
|
||||
peripheral.readValue(for: characteristic)
|
||||
}
|
||||
|
||||
// MARK: - Packet handling
|
||||
|
||||
private func handleFromRadio(_ data: Data) {
|
||||
guard !data.isEmpty else { return }
|
||||
guard let fromRadio = try? FromRadio(serializedBytes: data) else {
|
||||
logger.error("Failed to decode FromRadio packet (\(data.count) bytes)")
|
||||
return
|
||||
}
|
||||
|
||||
switch fromRadio.payloadVariant {
|
||||
case .myInfo(let myInfo):
|
||||
myNodeNum = myInfo.myNodeNum
|
||||
logger.info("My node num: \(myInfo.myNodeNum)")
|
||||
|
||||
case .nodeInfo(let nodeInfo):
|
||||
upsertNode(from: nodeInfo)
|
||||
|
||||
case .packet(let meshPacket):
|
||||
handleMeshPacket(meshPacket)
|
||||
|
||||
case .configCompleteID(let id):
|
||||
logger.info("Config complete (nonce=\(id))")
|
||||
connectionState = .connected
|
||||
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func handleMeshPacket(_ packet: MeshPacket) {
|
||||
guard packet.hasDecoded else { return }
|
||||
let decoded = packet.decoded
|
||||
|
||||
switch decoded.portnum {
|
||||
case .positionApp:
|
||||
if let position = try? Position(serializedBytes: decoded.payload) {
|
||||
upsertPosition(from: packet.from, position: position)
|
||||
}
|
||||
case .nodeInfoApp:
|
||||
if let user = try? User(serializedBytes: decoded.payload) {
|
||||
upsertUser(from: packet.from, user: user)
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Node management
|
||||
|
||||
private func upsertNode(from nodeInfo: NodeInfo) {
|
||||
let num = nodeInfo.num
|
||||
var node = nodes[num] ?? MeshNode(num: num, longName: "Node \(String(num, radix: 16))", shortName: String(String(num, radix: 16).suffix(4)))
|
||||
|
||||
if nodeInfo.hasUser {
|
||||
node.longName = nodeInfo.user.longName
|
||||
node.shortName = nodeInfo.user.shortName
|
||||
}
|
||||
if nodeInfo.hasPosition, nodeInfo.position.latitudeI != 0, nodeInfo.position.longitudeI != 0 {
|
||||
node.latitude = Double(nodeInfo.position.latitudeI) / 1e7
|
||||
node.longitude = Double(nodeInfo.position.longitudeI) / 1e7
|
||||
node.altitude = nodeInfo.position.altitude
|
||||
node.lastPositionTime = Date(timeIntervalSince1970: TimeInterval(nodeInfo.position.time))
|
||||
}
|
||||
if nodeInfo.lastHeard > 0 {
|
||||
node.lastHeard = Date(timeIntervalSince1970: TimeInterval(nodeInfo.lastHeard))
|
||||
}
|
||||
node.snr = nodeInfo.snr
|
||||
nodes[num] = node
|
||||
}
|
||||
|
||||
private func upsertPosition(from nodeNum: UInt32, position: Position) {
|
||||
guard position.latitudeI != 0, position.longitudeI != 0 else { return }
|
||||
var node = nodes[nodeNum] ?? MeshNode(num: nodeNum, longName: "Node \(String(nodeNum, radix: 16))", shortName: String(String(nodeNum, radix: 16).suffix(4)))
|
||||
node.latitude = Double(position.latitudeI) / 1e7
|
||||
node.longitude = Double(position.longitudeI) / 1e7
|
||||
node.altitude = position.altitude
|
||||
node.lastPositionTime = Date()
|
||||
node.lastHeard = Date()
|
||||
nodes[nodeNum] = node
|
||||
}
|
||||
|
||||
private func upsertUser(from nodeNum: UInt32, user: User) {
|
||||
var node = nodes[nodeNum] ?? MeshNode(num: nodeNum, longName: user.longName, shortName: user.shortName)
|
||||
node.longName = user.longName
|
||||
node.shortName = user.shortName
|
||||
node.lastHeard = Date()
|
||||
nodes[nodeNum] = node
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - DiscoveredDevice
|
||||
struct DiscoveredDevice: Identifiable {
|
||||
let id: UUID
|
||||
let peripheral: CBPeripheral
|
||||
let name: String
|
||||
let rssi: Int
|
||||
}
|
||||
|
||||
// MARK: - ConnectionState
|
||||
enum WatchConnectionState: Equatable {
|
||||
case disconnected
|
||||
case connecting
|
||||
case connected
|
||||
}
|
||||
|
||||
// MARK: - CBCentralManagerDelegate
|
||||
extension WatchBLEManager: @preconcurrency CBCentralManagerDelegate {
|
||||
|
||||
nonisolated func centralManagerDidUpdateState(_ central: CBCentralManager) {
|
||||
Task { @MainActor in
|
||||
switch central.state {
|
||||
case .poweredOn:
|
||||
logger.info("Bluetooth powered on")
|
||||
case .poweredOff:
|
||||
logger.warning("Bluetooth powered off")
|
||||
cleanup()
|
||||
case .unauthorized:
|
||||
logger.warning("Bluetooth unauthorised")
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral,
|
||||
advertisementData: [String: Any], rssi RSSI: NSNumber) {
|
||||
Task { @MainActor in
|
||||
let name = peripheral.name ?? advertisementData[CBAdvertisementDataLocalNameKey] as? String ?? "Unknown"
|
||||
if !discoveredDevices.contains(where: { $0.peripheral.identifier == peripheral.identifier }) {
|
||||
let device = DiscoveredDevice(id: peripheral.identifier, peripheral: peripheral, name: name, rssi: RSSI.intValue)
|
||||
discoveredDevices.append(device)
|
||||
logger.info("Discovered \(name, privacy: .public) RSSI=\(RSSI.intValue)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
|
||||
Task { @MainActor in
|
||||
logger.info("Connected to \(peripheral.name ?? "Unknown", privacy: .public)")
|
||||
connectedPeripheral = peripheral
|
||||
peripheral.delegate = self
|
||||
peripheral.discoverServices([meshtasticServiceUUID])
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) {
|
||||
Task { @MainActor in
|
||||
logger.error("Failed to connect: \(error?.localizedDescription ?? "unknown", privacy: .public)")
|
||||
cleanup()
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) {
|
||||
Task { @MainActor in
|
||||
logger.info("Disconnected from \(peripheral.name ?? "Unknown", privacy: .public)")
|
||||
cleanup()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - CBPeripheralDelegate
|
||||
extension WatchBLEManager: @preconcurrency CBPeripheralDelegate {
|
||||
|
||||
nonisolated func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
|
||||
Task { @MainActor in
|
||||
guard error == nil, let services = peripheral.services else {
|
||||
logger.error("Service discovery error: \(error?.localizedDescription ?? "nil", privacy: .public)")
|
||||
return
|
||||
}
|
||||
for service in services where service.uuid == meshtasticServiceUUID {
|
||||
peripheral.discoverCharacteristics([toRadioUUID, fromRadioUUID, fromNumUUID], for: service)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) {
|
||||
Task { @MainActor in
|
||||
guard error == nil, let characteristics = service.characteristics else {
|
||||
logger.error("Characteristic discovery error: \(error?.localizedDescription ?? "nil", privacy: .public)")
|
||||
return
|
||||
}
|
||||
for characteristic in characteristics {
|
||||
switch characteristic.uuid {
|
||||
case toRadioUUID:
|
||||
toRadioCharacteristic = characteristic
|
||||
case fromRadioUUID:
|
||||
fromRadioCharacteristic = characteristic
|
||||
case fromNumUUID:
|
||||
fromNumCharacteristic = characteristic
|
||||
peripheral.setNotifyValue(true, for: characteristic)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
if toRadioCharacteristic != nil && fromRadioCharacteristic != nil && fromNumCharacteristic != nil {
|
||||
logger.info("All characteristics discovered – requesting node database")
|
||||
requestNodeDatabase()
|
||||
drainFromRadio()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) {
|
||||
Task { @MainActor in
|
||||
guard error == nil else {
|
||||
logger.error("Value update error for \(characteristic.uuid): \(error!.localizedDescription, privacy: .public)")
|
||||
return
|
||||
}
|
||||
switch characteristic.uuid {
|
||||
case fromRadioUUID:
|
||||
if let data = characteristic.value, !data.isEmpty {
|
||||
handleFromRadio(data)
|
||||
// Continue draining
|
||||
drainFromRadio()
|
||||
}
|
||||
case fromNumUUID:
|
||||
// New data available – start draining
|
||||
drainFromRadio()
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) {
|
||||
Task { @MainActor in
|
||||
if let error {
|
||||
logger.error("Write error: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
117
Meshtastic Watch App/Managers/WatchLocationManager.swift
Normal file
117
Meshtastic Watch App/Managers/WatchLocationManager.swift
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
//
|
||||
// WatchLocationManager.swift
|
||||
// Meshtastic Watch App
|
||||
//
|
||||
// Copyright(c) Meshtastic 2025.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CoreLocation
|
||||
import os
|
||||
|
||||
/// Manages location and heading updates for the watchOS foxhunt compass.
|
||||
///
|
||||
/// On Apple Watch models with a magnetometer (Series 5+) the compass heading
|
||||
/// is used. On older models the GPS course (direction of travel) is used as a
|
||||
/// fallback – this only works while the user is moving.
|
||||
@MainActor
|
||||
final class WatchLocationManager: NSObject, ObservableObject {
|
||||
|
||||
private let manager = CLLocationManager()
|
||||
private let logger = Logger(subsystem: "gvh.MeshtasticClient.watchkitapp", category: "📍 Location")
|
||||
|
||||
/// Current heading in degrees (0‑360). Updated from the magnetometer when
|
||||
/// available, otherwise falls back to GPS course.
|
||||
@Published var heading: Double = 0
|
||||
|
||||
/// Most recent location of the watch.
|
||||
@Published var currentLocation: CLLocation?
|
||||
|
||||
/// `true` once the manager is actively delivering updates.
|
||||
@Published var isUpdating = false
|
||||
|
||||
/// Authorisation status surfaced to the UI so it can prompt if needed.
|
||||
@Published var authorizationStatus: CLAuthorizationStatus = .notDetermined
|
||||
|
||||
/// Whether the device has a compass (magnetometer).
|
||||
var hasCompass: Bool { CLLocationManager.headingAvailable() }
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
manager.delegate = self
|
||||
manager.desiredAccuracy = kCLLocationAccuracyBest
|
||||
manager.distanceFilter = 2 // metres
|
||||
authorizationStatus = manager.authorizationStatus
|
||||
}
|
||||
|
||||
func requestAuthorization() {
|
||||
logger.info("Requesting location authorisation")
|
||||
manager.requestWhenInUseAuthorization()
|
||||
}
|
||||
|
||||
func startUpdates() {
|
||||
guard authorizationStatus == .authorizedAlways ||
|
||||
authorizationStatus == .authorizedWhenInUse else {
|
||||
logger.warning("Cannot start updates – insufficient authorisation (\(self.authorizationStatus.rawValue))")
|
||||
return
|
||||
}
|
||||
logger.info("Starting location updates")
|
||||
manager.startUpdatingLocation()
|
||||
if CLLocationManager.headingAvailable() {
|
||||
manager.headingFilter = 1
|
||||
manager.startUpdatingHeading()
|
||||
}
|
||||
isUpdating = true
|
||||
}
|
||||
|
||||
func stopUpdates() {
|
||||
logger.info("Stopping location updates")
|
||||
manager.stopUpdatingLocation()
|
||||
if CLLocationManager.headingAvailable() {
|
||||
manager.stopUpdatingHeading()
|
||||
}
|
||||
isUpdating = false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - CLLocationManagerDelegate
|
||||
extension WatchLocationManager: @preconcurrency CLLocationManagerDelegate {
|
||||
|
||||
nonisolated func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
|
||||
Task { @MainActor in
|
||||
guard let latest = locations.last else { return }
|
||||
self.currentLocation = latest
|
||||
|
||||
// Fallback heading from GPS course when no magnetometer.
|
||||
if !CLLocationManager.headingAvailable(), latest.course >= 0 {
|
||||
self.heading = latest.course
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func locationManager(_ manager: CLLocationManager, didUpdateHeading newHeading: CLHeading) {
|
||||
Task { @MainActor in
|
||||
self.heading = newHeading.trueHeading >= 0
|
||||
? newHeading.trueHeading
|
||||
: newHeading.magneticHeading
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
|
||||
Task { @MainActor in
|
||||
self.authorizationStatus = manager.authorizationStatus
|
||||
if self.authorizationStatus == .authorizedAlways ||
|
||||
self.authorizationStatus == .authorizedWhenInUse {
|
||||
self.startUpdates()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
|
||||
Task { @MainActor in
|
||||
logger.error("Location error: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
}
|
||||
10
Meshtastic Watch App/Meshtastic Watch App.entitlements
Normal file
10
Meshtastic Watch App/Meshtastic Watch App.entitlements
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!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>
|
||||
</plist>
|
||||
17
Meshtastic Watch App/MeshtasticWatchApp.swift
Normal file
17
Meshtastic Watch App/MeshtasticWatchApp.swift
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
//
|
||||
// MeshtasticWatchApp.swift
|
||||
// Meshtastic Watch App
|
||||
//
|
||||
// Copyright(c) Meshtastic 2025.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct MeshtasticWatchApp: App {
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
}
|
||||
}
|
||||
}
|
||||
64
Meshtastic Watch App/Models/MeshNode.swift
Normal file
64
Meshtastic Watch App/Models/MeshNode.swift
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
//
|
||||
// MeshNode.swift
|
||||
// Meshtastic Watch App
|
||||
//
|
||||
// Copyright(c) Meshtastic 2025.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CoreLocation
|
||||
|
||||
/// Lightweight in-memory model for a mesh node seen by the watch.
|
||||
/// Avoids Core Data dependency so the watch app can run standalone.
|
||||
struct MeshNode: Identifiable, Equatable {
|
||||
/// Meshtastic node number (unique on the mesh).
|
||||
let num: UInt32
|
||||
/// Stable identifier derived from the node number.
|
||||
var id: UInt32 { num }
|
||||
|
||||
var longName: String
|
||||
var shortName: String
|
||||
|
||||
/// Latest known position (latitude / longitude in degrees, altitude in metres).
|
||||
var latitude: Double?
|
||||
var longitude: Double?
|
||||
var altitude: Int32?
|
||||
|
||||
/// When the position was last updated.
|
||||
var lastPositionTime: Date?
|
||||
|
||||
/// When we last heard *any* packet from this node.
|
||||
var lastHeard: Date?
|
||||
|
||||
/// Signal-to-noise ratio of the last received packet (dB).
|
||||
var snr: Float?
|
||||
|
||||
// MARK: - Derived helpers
|
||||
|
||||
/// A coordinate suitable for bearing/distance calculations, or `nil` when we
|
||||
/// have no valid position.
|
||||
var coordinate: CLLocationCoordinate2D? {
|
||||
guard let lat = latitude, let lon = longitude,
|
||||
lat != 0, lon != 0 else { return nil }
|
||||
return CLLocationCoordinate2D(latitude: lat, longitude: lon)
|
||||
}
|
||||
|
||||
/// `CLLocation` wrapper – handy for `distance(from:)`.
|
||||
var location: CLLocation? {
|
||||
guard let coord = coordinate else { return nil }
|
||||
return CLLocation(latitude: coord.latitude, longitude: coord.longitude)
|
||||
}
|
||||
|
||||
/// Distance in metres from the given user location, or `nil` when there is
|
||||
/// no valid node position.
|
||||
func distance(from userLocation: CLLocation) -> CLLocationDistance? {
|
||||
guard let nodeLoc = location else { return nil }
|
||||
return userLocation.distance(from: nodeLoc)
|
||||
}
|
||||
|
||||
/// `true` when the node has been heard in the last two hours.
|
||||
var isOnline: Bool {
|
||||
guard let lastHeard else { return false }
|
||||
return lastHeard.timeIntervalSinceNow > -7200
|
||||
}
|
||||
}
|
||||
158
Meshtastic Watch App/Views/DeviceConnectionView.swift
Normal file
158
Meshtastic Watch App/Views/DeviceConnectionView.swift
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
//
|
||||
// DeviceConnectionView.swift
|
||||
// Meshtastic Watch App
|
||||
//
|
||||
// Copyright(c) Meshtastic 2025.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// View for scanning and connecting to a Meshtastic BLE radio directly
|
||||
/// from the Apple Watch (no phone required).
|
||||
struct DeviceConnectionView: View {
|
||||
|
||||
@ObservedObject var bleManager: WatchBLEManager
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
switch bleManager.connectionState {
|
||||
case .disconnected:
|
||||
disconnectedView
|
||||
case .connecting:
|
||||
connectingView
|
||||
case .connected:
|
||||
connectedView
|
||||
}
|
||||
}
|
||||
.navigationTitle("Radio")
|
||||
}
|
||||
|
||||
// MARK: - Disconnected
|
||||
|
||||
@ViewBuilder
|
||||
private var disconnectedView: some View {
|
||||
VStack(spacing: 8) {
|
||||
if bleManager.discoveredDevices.isEmpty && !bleManager.isScanning {
|
||||
VStack(spacing: 8) {
|
||||
Image(systemName: "antenna.radiowaves.left.and.right.slash")
|
||||
.font(.title2)
|
||||
.foregroundStyle(.secondary)
|
||||
Text("No radio connected")
|
||||
.font(.headline)
|
||||
Text("Scan to find nearby Meshtastic radios.")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
|
||||
if bleManager.isScanning && bleManager.discoveredDevices.isEmpty {
|
||||
VStack(spacing: 8) {
|
||||
ProgressView()
|
||||
Text("Scanning…")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
|
||||
if !bleManager.discoveredDevices.isEmpty {
|
||||
List(bleManager.discoveredDevices) { device in
|
||||
Button {
|
||||
bleManager.connect(to: device)
|
||||
} label: {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(device.name)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.lineLimit(1)
|
||||
Text("\(device.rssi) dBm")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
signalIcon(rssi: device.rssi)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Button {
|
||||
if bleManager.isScanning {
|
||||
bleManager.stopScanning()
|
||||
} else {
|
||||
bleManager.startScanning()
|
||||
}
|
||||
} label: {
|
||||
Label(bleManager.isScanning ? "Stop" : "Scan",
|
||||
systemImage: bleManager.isScanning ? "stop.fill" : "magnifyingglass")
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(bleManager.isScanning ? .red : .accentColor)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Connecting
|
||||
|
||||
@ViewBuilder
|
||||
private var connectingView: some View {
|
||||
VStack(spacing: 8) {
|
||||
ProgressView()
|
||||
Text("Connecting…")
|
||||
.font(.headline)
|
||||
if let name = bleManager.connectedDeviceName {
|
||||
Text(name)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
|
||||
// MARK: - Connected
|
||||
|
||||
@ViewBuilder
|
||||
private var connectedView: some View {
|
||||
VStack(spacing: 8) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.title2)
|
||||
.foregroundStyle(.green)
|
||||
Text("Connected")
|
||||
.font(.headline)
|
||||
if let name = bleManager.connectedDeviceName {
|
||||
Text(name)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Text("\(bleManager.nodes.count) nodes")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Button(role: .destructive) {
|
||||
bleManager.disconnect()
|
||||
} label: {
|
||||
Label("Disconnect", systemImage: "xmark.circle")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
@ViewBuilder
|
||||
private func signalIcon(rssi: Int) -> some View {
|
||||
let imageName: String
|
||||
if rssi > -65 {
|
||||
imageName = "wifi"
|
||||
} else if rssi > -85 {
|
||||
imageName = "wifi"
|
||||
} else {
|
||||
imageName = "wifi.exclamationmark"
|
||||
}
|
||||
Image(systemName: imageName)
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(rssi > -65 ? .green : (rssi > -85 ? .yellow : .red))
|
||||
}
|
||||
}
|
||||
270
Meshtastic Watch App/Views/FoxhuntCompassView.swift
Normal file
270
Meshtastic Watch App/Views/FoxhuntCompassView.swift
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
//
|
||||
// FoxhuntCompassView.swift
|
||||
// Meshtastic Watch App
|
||||
//
|
||||
// Copyright(c) Meshtastic 2025.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import CoreLocation
|
||||
import WatchKit
|
||||
|
||||
/// A compass view optimised for Apple Watch that points toward a target
|
||||
/// mesh node. Designed for "foxhunt" (radio direction-finding) scenarios.
|
||||
///
|
||||
/// Features:
|
||||
/// - Rotating compass dial showing heading
|
||||
/// - Bearing arrow pointing toward the target node
|
||||
/// - Distance readout
|
||||
/// - Haptic feedback when aligned with the target (within 10°)
|
||||
/// - Hot/warm/cold colour coding based on distance
|
||||
struct FoxhuntCompassView: View {
|
||||
|
||||
let node: MeshNode
|
||||
@ObservedObject var locationManager: WatchLocationManager
|
||||
|
||||
@State private var inAlignment = false
|
||||
private let alignmentTolerance: Double = 10.0
|
||||
|
||||
/// Half a mile in metres – the maximum distance for foxhunt targets.
|
||||
static let maxDistanceMetres: Double = 804.672
|
||||
|
||||
// MARK: - Body
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geometry in
|
||||
let size = min(geometry.size.width, geometry.size.height)
|
||||
let dialRadius = size * 0.38
|
||||
|
||||
VStack(spacing: 2) {
|
||||
// Node name
|
||||
Text(node.shortName.isEmpty ? node.longName : node.shortName)
|
||||
.font(.system(size: 13, weight: .semibold, design: .rounded))
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
|
||||
ZStack {
|
||||
// Fixed heading indicator at top
|
||||
Image(systemName: "triangle.fill")
|
||||
.font(.system(size: 8, weight: .bold))
|
||||
.foregroundStyle(.primary)
|
||||
.rotationEffect(.degrees(180))
|
||||
.offset(y: -(dialRadius + 12))
|
||||
|
||||
// Rotating compass group
|
||||
ZStack {
|
||||
// Outer ring
|
||||
Circle()
|
||||
.stroke(Color.primary.opacity(0.15), lineWidth: 1)
|
||||
.frame(width: dialRadius * 2 + 8, height: dialRadius * 2 + 8)
|
||||
|
||||
// Tick marks (every 10° for watch readability)
|
||||
ForEach(0..<36, id: \.self) { i in
|
||||
let deg = Double(i) * 10
|
||||
WatchTickMark(degree: deg, radius: dialRadius)
|
||||
}
|
||||
|
||||
// Cardinal labels
|
||||
ForEach(WatchCompassLabel.allLabels, id: \.degrees) { label in
|
||||
Text(label.text)
|
||||
.font(.system(size: label.isCardinal ? 11 : 8, weight: label.isCardinal ? .bold : .medium))
|
||||
.foregroundStyle(label.degrees == 0 ? .orange : .primary)
|
||||
.rotationEffect(.degrees(-label.degrees + locationManager.heading))
|
||||
.offset(y: -(dialRadius - 14))
|
||||
.rotationEffect(.degrees(label.degrees))
|
||||
}
|
||||
|
||||
// North indicator
|
||||
WatchTriangle()
|
||||
.fill(.orange)
|
||||
.frame(width: 7, height: 6)
|
||||
.offset(y: -(dialRadius + 3))
|
||||
|
||||
// Centre readout
|
||||
centreReadout(dialRadius: dialRadius)
|
||||
|
||||
// Bearing arrow to target
|
||||
if let bearing = bearingToNode() {
|
||||
Image(systemName: "arrowtriangle.up.fill")
|
||||
.font(.system(size: 12, weight: .bold))
|
||||
.foregroundStyle(distanceColor)
|
||||
.offset(y: -(dialRadius + 8))
|
||||
.rotationEffect(.degrees(bearing))
|
||||
.onChange(of: locationManager.heading) {
|
||||
checkAlignment(bearing: bearing, heading: locationManager.heading)
|
||||
}
|
||||
}
|
||||
}
|
||||
.rotationEffect(.degrees(-locationManager.heading))
|
||||
}
|
||||
.frame(width: dialRadius * 2 + 30, height: dialRadius * 2 + 30)
|
||||
|
||||
// Distance at bottom
|
||||
if let dist = distanceToNode() {
|
||||
Text(formatDistance(dist))
|
||||
.font(.system(size: 14, weight: .semibold, design: .rounded))
|
||||
.foregroundStyle(distanceColor)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
.onAppear {
|
||||
locationManager.startUpdates()
|
||||
}
|
||||
.onDisappear {
|
||||
locationManager.stopUpdates()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Centre readout
|
||||
|
||||
@ViewBuilder
|
||||
private func centreReadout(dialRadius: CGFloat) -> some View {
|
||||
let textColor: Color = distanceColor.isWatchLight ? .black : .white
|
||||
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(distanceColor)
|
||||
.overlay(
|
||||
Circle().stroke(textColor.opacity(0.6), lineWidth: 2)
|
||||
)
|
||||
.frame(width: dialRadius * 1.1, height: dialRadius * 1.1)
|
||||
|
||||
VStack(spacing: 1) {
|
||||
Text(headingText)
|
||||
.font(.system(size: 20, weight: .light, design: .rounded))
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(textColor)
|
||||
|
||||
if let bearing = bearingToNode() {
|
||||
Text("\(String(format: "%.0f°", bearing))")
|
||||
.font(.system(size: 10, weight: .medium, design: .rounded))
|
||||
.foregroundStyle(textColor.opacity(0.8))
|
||||
}
|
||||
}
|
||||
}
|
||||
.rotationEffect(.degrees(locationManager.heading))
|
||||
}
|
||||
|
||||
// MARK: - Calculations
|
||||
|
||||
private var headingText: String {
|
||||
"\(Int(locationManager.heading.rounded()) % 360)°"
|
||||
}
|
||||
|
||||
private func bearingToNode() -> Double? {
|
||||
guard let target = node.coordinate,
|
||||
let user = locationManager.currentLocation?.coordinate else { return nil }
|
||||
return Self.bearingBetween(from: user, to: target)
|
||||
}
|
||||
|
||||
private func distanceToNode() -> CLLocationDistance? {
|
||||
guard let userLoc = locationManager.currentLocation else { return nil }
|
||||
return node.distance(from: userLoc)
|
||||
}
|
||||
|
||||
/// Colour that shifts from blue (far) → yellow (mid) → red (close).
|
||||
private var distanceColor: Color {
|
||||
guard let dist = distanceToNode() else { return .blue }
|
||||
let ratio = min(dist / Self.maxDistanceMetres, 1.0)
|
||||
if ratio > 0.66 { return .blue }
|
||||
if ratio > 0.33 { return .yellow }
|
||||
return .red
|
||||
}
|
||||
|
||||
private func checkAlignment(bearing: Double, heading: Double) {
|
||||
let rawDiff = abs(heading - bearing).truncatingRemainder(dividingBy: 360)
|
||||
let diff = min(rawDiff, 360 - rawDiff)
|
||||
|
||||
if diff <= alignmentTolerance {
|
||||
if !inAlignment {
|
||||
inAlignment = true
|
||||
WKInterfaceDevice.current().play(.click)
|
||||
}
|
||||
} else {
|
||||
inAlignment = false
|
||||
}
|
||||
}
|
||||
|
||||
private func formatDistance(_ distance: CLLocationDistance) -> String {
|
||||
let measurement = Measurement(value: distance, unit: UnitLength.meters)
|
||||
let formatter = MeasurementFormatter()
|
||||
formatter.unitOptions = .naturalScale
|
||||
formatter.numberFormatter.maximumFractionDigits = 0
|
||||
return formatter.string(from: measurement)
|
||||
}
|
||||
|
||||
// MARK: - Bearing maths (same algorithm as the main app)
|
||||
|
||||
static func bearingBetween(from user: CLLocationCoordinate2D, to target: CLLocationCoordinate2D) -> Double {
|
||||
let lat1 = user.latitude * .pi / 180
|
||||
let lon1 = user.longitude * .pi / 180
|
||||
let lat2 = target.latitude * .pi / 180
|
||||
let lon2 = target.longitude * .pi / 180
|
||||
let dLon = lon2 - lon1
|
||||
let y = sin(dLon) * cos(lat2)
|
||||
let x = cos(lat1) * sin(lat2) - sin(lat1) * cos(lat2) * cos(dLon)
|
||||
var bearing = atan2(y, x) * 180 / .pi
|
||||
if bearing < 0 { bearing += 360 }
|
||||
return bearing
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Watch-sized compass sub-views
|
||||
|
||||
private struct WatchTickMark: View {
|
||||
let degree: Double
|
||||
let radius: CGFloat
|
||||
|
||||
var body: some View {
|
||||
let isCardinal = degree.truncatingRemainder(dividingBy: 90) == 0
|
||||
let isMajor = degree.truncatingRemainder(dividingBy: 30) == 0
|
||||
let length: CGFloat = isCardinal ? 8 : (isMajor ? 5 : 3)
|
||||
let width: CGFloat = isCardinal ? 2 : 1
|
||||
|
||||
Capsule()
|
||||
.fill(isCardinal ? Color.primary : Color.primary.opacity(isMajor ? 0.7 : 0.3))
|
||||
.frame(width: width, height: length)
|
||||
.offset(y: -(radius - length / 2))
|
||||
.rotationEffect(.degrees(degree))
|
||||
}
|
||||
}
|
||||
|
||||
private struct WatchTriangle: Shape {
|
||||
func path(in rect: CGRect) -> Path {
|
||||
var path = Path()
|
||||
path.move(to: CGPoint(x: rect.midX, y: rect.minY))
|
||||
path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY))
|
||||
path.addLine(to: CGPoint(x: rect.minX, y: rect.maxY))
|
||||
path.closeSubpath()
|
||||
return path
|
||||
}
|
||||
}
|
||||
|
||||
private struct WatchCompassLabel {
|
||||
let degrees: Double
|
||||
let text: String
|
||||
let isCardinal: Bool
|
||||
|
||||
static let allLabels: [WatchCompassLabel] = [
|
||||
WatchCompassLabel(degrees: 0, text: "N", isCardinal: true),
|
||||
WatchCompassLabel(degrees: 45, text: "NE", isCardinal: false),
|
||||
WatchCompassLabel(degrees: 90, text: "E", isCardinal: true),
|
||||
WatchCompassLabel(degrees: 135, text: "SE", isCardinal: false),
|
||||
WatchCompassLabel(degrees: 180, text: "S", isCardinal: true),
|
||||
WatchCompassLabel(degrees: 225, text: "SW", isCardinal: false),
|
||||
WatchCompassLabel(degrees: 270, text: "W", isCardinal: true),
|
||||
WatchCompassLabel(degrees: 315, text: "NW", isCardinal: false)
|
||||
]
|
||||
}
|
||||
|
||||
// MARK: - Color helper
|
||||
|
||||
extension Color {
|
||||
/// Quick luminance check so centre text is readable on the distance colour.
|
||||
var isWatchLight: Bool {
|
||||
// Approximate: yellow and lighter colours are "light"
|
||||
if self == .yellow || self == .orange || self == .white { return true }
|
||||
return false
|
||||
}
|
||||
}
|
||||
126
Meshtastic Watch App/Views/NearbyNodesListView.swift
Normal file
126
Meshtastic Watch App/Views/NearbyNodesListView.swift
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
//
|
||||
// NearbyNodesListView.swift
|
||||
// Meshtastic Watch App
|
||||
//
|
||||
// Copyright(c) Meshtastic 2025.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import CoreLocation
|
||||
|
||||
/// Shows mesh nodes within half a mile (≈ 805 m) that have a valid
|
||||
/// position. Tapping a node opens the foxhunt compass pointing at it.
|
||||
struct NearbyNodesListView: View {
|
||||
|
||||
@ObservedObject var bleManager: WatchBLEManager
|
||||
@ObservedObject var locationManager: WatchLocationManager
|
||||
|
||||
/// Nodes filtered to ≤ 0.5 miles with a known position, sorted by distance.
|
||||
private var nearbyNodes: [MeshNode] {
|
||||
guard let userLoc = locationManager.currentLocation else { return [] }
|
||||
return bleManager.nodes.values
|
||||
.filter { node in
|
||||
guard node.coordinate != nil,
|
||||
let dist = node.distance(from: userLoc) else { return false }
|
||||
return dist <= FoxhuntCompassView.maxDistanceMetres
|
||||
}
|
||||
.sorted { a, b in
|
||||
let dA = a.distance(from: userLoc) ?? .greatestFiniteMagnitude
|
||||
let dB = b.distance(from: userLoc) ?? .greatestFiniteMagnitude
|
||||
return dA < dB
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if nearbyNodes.isEmpty {
|
||||
emptyState
|
||||
} else {
|
||||
nodeList
|
||||
}
|
||||
}
|
||||
.navigationTitle("Foxhunt")
|
||||
}
|
||||
|
||||
// MARK: - Sub-views
|
||||
|
||||
@ViewBuilder
|
||||
private var emptyState: some View {
|
||||
VStack(spacing: 8) {
|
||||
Image(systemName: "antenna.radiowaves.left.and.right")
|
||||
.font(.title2)
|
||||
.foregroundStyle(.secondary)
|
||||
Text("No nearby nodes")
|
||||
.font(.headline)
|
||||
Text("Nodes within ½ mile with a known position will appear here.")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal)
|
||||
|
||||
if bleManager.connectionState != .connected {
|
||||
Text("Connect to a radio first.")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var nodeList: some View {
|
||||
List(nearbyNodes) { node in
|
||||
NavigationLink(destination: FoxhuntCompassView(node: node, locationManager: locationManager)) {
|
||||
nodeRow(node)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func nodeRow(_ node: MeshNode) -> some View {
|
||||
let userLoc = locationManager.currentLocation
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(node.longName)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.lineLimit(1)
|
||||
if let userLoc, let dist = node.distance(from: userLoc) {
|
||||
Text(formatDistance(dist))
|
||||
.font(.system(size: 12, design: .rounded))
|
||||
.foregroundStyle(distanceColor(dist))
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
// Mini bearing arrow
|
||||
if let bearing = bearing(to: node) {
|
||||
Image(systemName: "location.north.fill")
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(userLoc.flatMap { node.distance(from: $0) }.map { distanceColor($0) } ?? .secondary)
|
||||
.rotationEffect(.degrees(bearing - locationManager.heading))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func bearing(to node: MeshNode) -> Double? {
|
||||
guard let target = node.coordinate,
|
||||
let user = locationManager.currentLocation?.coordinate else { return nil }
|
||||
return FoxhuntCompassView.bearingBetween(from: user, to: target)
|
||||
}
|
||||
|
||||
private func formatDistance(_ distance: CLLocationDistance) -> String {
|
||||
let measurement = Measurement(value: distance, unit: UnitLength.meters)
|
||||
let formatter = MeasurementFormatter()
|
||||
formatter.unitOptions = .naturalScale
|
||||
formatter.numberFormatter.maximumFractionDigits = 0
|
||||
return formatter.string(from: measurement)
|
||||
}
|
||||
|
||||
private func distanceColor(_ distance: CLLocationDistance) -> Color {
|
||||
let ratio = min(distance / FoxhuntCompassView.maxDistanceMetres, 1.0)
|
||||
if ratio > 0.66 { return .blue }
|
||||
if ratio > 0.33 { return .yellow }
|
||||
return .red
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue