mirror of
https://github.com/meshtastic/Meshtastic-Apple.git
synced 2026-04-20 22:13:56 +00:00
Merge 2f21c4bfaf into 91731b83f6
This commit is contained in:
commit
cf974777b1
34 changed files with 1654 additions and 491 deletions
|
|
@ -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" : {
|
||||
|
|
|
|||
|
|
@ -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,14 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "watch-icon.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "watchOS",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 29 KiB |
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
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"symbols" : [
|
||||
{
|
||||
"filename" : "custom.foxhunt.svg",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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 |
12
Meshtastic Watch App/Assets.xcassets/logo-white.imageset/Contents.json
vendored
Normal file
12
Meshtastic Watch App/Assets.xcassets/logo-white.imageset/Contents.json
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "Mesh_Logo_White.svg",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
12
Meshtastic Watch App/Assets.xcassets/logo-white.imageset/Mesh_Logo_White.svg
vendored
Normal file
12
Meshtastic Watch App/Assets.xcassets/logo-white.imageset/Mesh_Logo_White.svg
vendored
Normal 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 |
37
Meshtastic Watch App/ContentView.swift
Normal file
37
Meshtastic Watch App/ContentView.swift
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
//
|
||||
// ContentView.swift
|
||||
// Meshtastic Watch App
|
||||
//
|
||||
// Copyright(c) Meshtastic 2025.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// Root view of the Meshtastic Watch App.
|
||||
///
|
||||
/// Uses a tab-based layout:
|
||||
/// 1. **Foxhunt** – nearby nodes list → compass
|
||||
/// 2. **Phone** – companion phone connectivity status
|
||||
struct ContentView: View {
|
||||
|
||||
@StateObject private var phoneManager = PhoneConnectivityManager()
|
||||
@StateObject private var locationManager = WatchLocationManager()
|
||||
|
||||
var body: some View {
|
||||
TabView {
|
||||
// Tab 1: Foxhunt
|
||||
NearbyNodesListView(phoneManager: phoneManager, locationManager: locationManager)
|
||||
|
||||
// Tab 2: Phone connectivity
|
||||
DeviceConnectionView(phoneManager: phoneManager)
|
||||
}
|
||||
.tabViewStyle(.verticalPage)
|
||||
.onAppear {
|
||||
locationManager.requestAuthorization()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ContentView()
|
||||
}
|
||||
12
Meshtastic Watch App/Info.plist
Normal file
12
Meshtastic Watch App/Info.plist
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<?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>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>
|
||||
<false/>
|
||||
</dict>
|
||||
</plist>
|
||||
144
Meshtastic Watch App/Managers/PhoneConnectivityManager.swift
Normal file
144
Meshtastic Watch App/Managers/PhoneConnectivityManager.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
8
Meshtastic Watch App/Meshtastic Watch App.entitlements
Normal file
8
Meshtastic Watch App/Meshtastic Watch App.entitlements
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
<?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.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.
|
||||
/// Transferred from the companion iOS app via WatchConnectivity.
|
||||
struct MeshNode: Identifiable, Equatable, Codable {
|
||||
/// Meshtastic node number (unique on the mesh).
|
||||
let num: UInt32
|
||||
/// Stable identifier derived from the node number.
|
||||
var id: UInt32 { num }
|
||||
|
||||
var longName: String
|
||||
var shortName: String
|
||||
|
||||
/// Latest known position (latitude / longitude in degrees, altitude in metres).
|
||||
var latitude: Double?
|
||||
var longitude: Double?
|
||||
var altitude: Int32?
|
||||
|
||||
/// When the position was last updated.
|
||||
var lastPositionTime: Date?
|
||||
|
||||
/// When we last heard *any* packet from this node.
|
||||
var lastHeard: Date?
|
||||
|
||||
/// Signal-to-noise ratio of the last received packet (dB).
|
||||
var snr: Float?
|
||||
|
||||
// MARK: - Derived helpers
|
||||
|
||||
/// A coordinate suitable for bearing/distance calculations, or `nil` when we
|
||||
/// have no valid position.
|
||||
var coordinate: CLLocationCoordinate2D? {
|
||||
guard let lat = latitude, let lon = longitude,
|
||||
lat != 0, lon != 0 else { return nil }
|
||||
return CLLocationCoordinate2D(latitude: lat, longitude: lon)
|
||||
}
|
||||
|
||||
/// `CLLocation` wrapper – handy for `distance(from:)`.
|
||||
var location: CLLocation? {
|
||||
guard let coord = coordinate else { return nil }
|
||||
return CLLocation(latitude: coord.latitude, longitude: coord.longitude)
|
||||
}
|
||||
|
||||
/// Distance in metres from the given user location, or `nil` when there is
|
||||
/// no valid node position.
|
||||
func distance(from userLocation: CLLocation) -> CLLocationDistance? {
|
||||
guard let nodeLoc = location else { return nil }
|
||||
return userLocation.distance(from: nodeLoc)
|
||||
}
|
||||
|
||||
/// `true` when the node has been heard in the last two hours.
|
||||
var isOnline: Bool {
|
||||
guard let lastHeard else { return false }
|
||||
return lastHeard.timeIntervalSinceNow > -7200
|
||||
}
|
||||
}
|
||||
70
Meshtastic Watch App/Views/DeviceConnectionView.swift
Normal file
70
Meshtastic Watch App/Views/DeviceConnectionView.swift
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
//
|
||||
// DeviceConnectionView.swift
|
||||
// Meshtastic Watch App
|
||||
//
|
||||
// Copyright(c) Meshtastic 2025.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// Shows the connectivity status between the Watch and the companion
|
||||
/// iPhone app. Node data is received via WatchConnectivity.
|
||||
struct DeviceConnectionView: View {
|
||||
|
||||
@ObservedObject var phoneManager: PhoneConnectivityManager
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 12) {
|
||||
if phoneManager.isPhoneReachable {
|
||||
reachableView
|
||||
} else {
|
||||
unreachableView
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.navigationTitle("Phone")
|
||||
}
|
||||
|
||||
// MARK: - Phone Reachable
|
||||
|
||||
@ViewBuilder
|
||||
private var reachableView: some View {
|
||||
Image(systemName: "iphone.radiowaves.left.and.right")
|
||||
.font(.title2)
|
||||
.foregroundStyle(.green)
|
||||
Text("Phone Connected")
|
||||
.font(.headline)
|
||||
Text("\(phoneManager.nodes.count) nodes")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Button {
|
||||
phoneManager.requestRefresh()
|
||||
} label: {
|
||||
Label("Refresh", systemImage: "arrow.clockwise")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
|
||||
// MARK: - Phone Unreachable
|
||||
|
||||
@ViewBuilder
|
||||
private var unreachableView: some View {
|
||||
Image(systemName: "iphone.slash")
|
||||
.font(.title2)
|
||||
.foregroundStyle(.secondary)
|
||||
Text("Phone Not Reachable")
|
||||
.font(.headline)
|
||||
|
||||
if phoneManager.hasReceivedData {
|
||||
Text("\(phoneManager.nodes.count) cached nodes")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
Text("Open Meshtastic on your iPhone to sync node data.")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
}
|
||||
}
|
||||
336
Meshtastic Watch App/Views/FoxhuntCompassView.swift
Normal file
336
Meshtastic Watch App/Views/FoxhuntCompassView.swift
Normal file
|
|
@ -0,0 +1,336 @@
|
|||
//
|
||||
// FoxhuntCompassView.swift
|
||||
// Meshtastic Watch App
|
||||
//
|
||||
// Copyright(c) Meshtastic 2025.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import CoreLocation
|
||||
import WatchKit
|
||||
|
||||
/// A compass view optimised for Apple Watch that points toward a target
|
||||
/// mesh node. Designed for "foxhunt" (radio direction-finding) scenarios.
|
||||
///
|
||||
/// Features:
|
||||
/// - Rotating compass dial showing heading
|
||||
/// - Bearing arrow pointing toward the target node
|
||||
/// - Distance readout
|
||||
/// - Haptic feedback when aligned with the target (within 10°)
|
||||
/// - Hot/warm/cold colour coding based on distance
|
||||
struct FoxhuntCompassView: View {
|
||||
|
||||
let node: MeshNode
|
||||
@ObservedObject var locationManager: WatchLocationManager
|
||||
|
||||
@State private var inAlignment = false
|
||||
private let alignmentTolerance: Double = 10.0
|
||||
|
||||
/// Half a mile in metres – the maximum distance for foxhunt targets.
|
||||
static let maxDistanceMetres: Double = 804.672
|
||||
|
||||
// MARK: - Body
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geometry in
|
||||
let size = min(geometry.size.width, geometry.size.height)
|
||||
let dialRadius = size * 0.48
|
||||
|
||||
ZStack {
|
||||
// Fixed heading indicator at top of ring
|
||||
Image(systemName: "triangle.fill")
|
||||
.font(.system(size: 8, weight: .bold))
|
||||
.foregroundStyle(.white.opacity(0.9))
|
||||
.rotationEffect(.degrees(180))
|
||||
.offset(y: -(dialRadius + 6))
|
||||
|
||||
// Rotating compass group
|
||||
ZStack {
|
||||
// Outer ring
|
||||
Circle()
|
||||
.stroke(Color.primary.opacity(0.3), lineWidth: 3)
|
||||
.frame(width: dialRadius * 2 + 8, height: dialRadius * 2 + 8)
|
||||
|
||||
// Tick marks (every 10° for watch readability)
|
||||
ForEach(0..<36, id: \.self) { i in
|
||||
let deg = Double(i) * 10
|
||||
WatchTickMark(degree: deg, radius: dialRadius)
|
||||
}
|
||||
|
||||
// Cardinal labels
|
||||
ForEach(WatchCompassLabel.allLabels, id: \.degrees) { label in
|
||||
Text(label.text)
|
||||
.font(.system(size: label.isCardinal ? 11 : 8, weight: label.isCardinal ? .bold : .medium))
|
||||
.foregroundStyle(label.degrees == 0 ? .orange : .primary)
|
||||
.rotationEffect(.degrees(-label.degrees + locationManager.heading))
|
||||
.offset(y: -(dialRadius - 14))
|
||||
.rotationEffect(.degrees(label.degrees))
|
||||
}
|
||||
|
||||
// North indicator
|
||||
WatchTriangle()
|
||||
.fill(.orange)
|
||||
.frame(width: 7, height: 6)
|
||||
.offset(y: -(dialRadius + 3))
|
||||
|
||||
// Centre readout (includes distance)
|
||||
centreReadout(dialRadius: dialRadius)
|
||||
|
||||
// Bearing arrow to target
|
||||
if let bearing = bearingToNode() {
|
||||
// Directional cone showing general heading direction
|
||||
DirectionCone(
|
||||
bearing: bearing,
|
||||
heading: locationManager.heading,
|
||||
radius: dialRadius + 4,
|
||||
color: distanceColor
|
||||
)
|
||||
|
||||
Image(systemName: "location.north.fill")
|
||||
.font(.system(size: 26, weight: .bold))
|
||||
.foregroundStyle(distanceColor)
|
||||
.shadow(color: distanceColor.opacity(0.8), radius: 6)
|
||||
.offset(y: -(dialRadius + 16))
|
||||
.rotationEffect(.degrees(bearing))
|
||||
.onChange(of: locationManager.heading) {
|
||||
checkAlignment(bearing: bearing, heading: locationManager.heading)
|
||||
}
|
||||
}
|
||||
}
|
||||
.rotationEffect(.degrees(-locationManager.heading))
|
||||
|
||||
// Node short name circle at top
|
||||
WatchCircleText(
|
||||
text: node.shortName.isEmpty ? "?" : node.shortName,
|
||||
color: WatchCircleText.color(for: node.num),
|
||||
circleSize: 26
|
||||
)
|
||||
.offset(y: -(dialRadius + 32))
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
.onAppear {
|
||||
locationManager.startUpdates()
|
||||
}
|
||||
.onDisappear {
|
||||
locationManager.stopUpdates()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Centre readout
|
||||
|
||||
@ViewBuilder
|
||||
private func centreReadout(dialRadius: CGFloat) -> some View {
|
||||
let textColor: Color = distanceColor.isWatchLight ? .black : .white
|
||||
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(distanceColor)
|
||||
.overlay(
|
||||
Circle().stroke(textColor.opacity(0.6), lineWidth: 2)
|
||||
)
|
||||
.frame(width: dialRadius * 1.1, height: dialRadius * 1.1)
|
||||
|
||||
VStack(spacing: 1) {
|
||||
Text(headingText)
|
||||
.font(.system(size: 24, weight: .light, design: .rounded))
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(textColor)
|
||||
|
||||
if let bearing = bearingToNode() {
|
||||
Text("\(String(format: "%.0f°", bearing))")
|
||||
.font(.system(size: 12, weight: .medium, design: .rounded))
|
||||
.foregroundStyle(textColor.opacity(0.8))
|
||||
}
|
||||
|
||||
if let dist = distanceToNode() {
|
||||
Text(formatDistance(dist))
|
||||
.font(.system(size: 12, weight: .semibold, design: .rounded))
|
||||
.foregroundStyle(textColor.opacity(0.8))
|
||||
}
|
||||
}
|
||||
}
|
||||
.rotationEffect(.degrees(locationManager.heading))
|
||||
}
|
||||
|
||||
// MARK: - Calculations
|
||||
|
||||
private var headingText: String {
|
||||
"\(Int(locationManager.heading.rounded()) % 360)°"
|
||||
}
|
||||
|
||||
private func bearingToNode() -> Double? {
|
||||
guard let target = node.coordinate,
|
||||
let user = locationManager.currentLocation?.coordinate else { return nil }
|
||||
return Self.bearingBetween(from: user, to: target)
|
||||
}
|
||||
|
||||
private func distanceToNode() -> CLLocationDistance? {
|
||||
guard let userLoc = locationManager.currentLocation else { return nil }
|
||||
return node.distance(from: userLoc)
|
||||
}
|
||||
|
||||
/// Colour that shifts from red (far) → yellow (mid) → green (close).
|
||||
private var distanceColor: Color {
|
||||
guard let dist = distanceToNode() else { return .red }
|
||||
let ratio = min(dist / Self.maxDistanceMetres, 1.0)
|
||||
if ratio > 0.66 { return .red }
|
||||
if ratio > 0.33 { return .yellow }
|
||||
return .green
|
||||
}
|
||||
|
||||
private func checkAlignment(bearing: Double, heading: Double) {
|
||||
let rawDiff = abs(heading - bearing).truncatingRemainder(dividingBy: 360)
|
||||
let diff = min(rawDiff, 360 - rawDiff)
|
||||
|
||||
if diff <= alignmentTolerance {
|
||||
if !inAlignment {
|
||||
inAlignment = true
|
||||
WKInterfaceDevice.current().play(.click)
|
||||
}
|
||||
} else {
|
||||
inAlignment = false
|
||||
}
|
||||
}
|
||||
|
||||
private func formatDistance(_ distance: CLLocationDistance) -> String {
|
||||
let measurement = Measurement(value: distance, unit: UnitLength.meters)
|
||||
let formatter = MeasurementFormatter()
|
||||
formatter.unitOptions = .naturalScale
|
||||
formatter.numberFormatter.maximumFractionDigits = 0
|
||||
return formatter.string(from: measurement)
|
||||
}
|
||||
|
||||
// MARK: - Bearing maths (same algorithm as the main app)
|
||||
|
||||
static func bearingBetween(from user: CLLocationCoordinate2D, to target: CLLocationCoordinate2D) -> Double {
|
||||
let lat1 = user.latitude * .pi / 180
|
||||
let lon1 = user.longitude * .pi / 180
|
||||
let lat2 = target.latitude * .pi / 180
|
||||
let lon2 = target.longitude * .pi / 180
|
||||
let dLon = lon2 - lon1
|
||||
let y = sin(dLon) * cos(lat2)
|
||||
let x = cos(lat1) * sin(lat2) - sin(lat1) * cos(lat2) * cos(dLon)
|
||||
var bearing = atan2(y, x) * 180 / .pi
|
||||
if bearing < 0 { bearing += 360 }
|
||||
return bearing
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Direction Cone (Backtrack-style scope)
|
||||
|
||||
/// A translucent cone drawn from the centre of the compass toward the
|
||||
/// bearing, giving a visual "scope" so the user can see when they are
|
||||
/// heading in roughly the right direction.
|
||||
private struct DirectionCone: View {
|
||||
let bearing: Double
|
||||
let heading: Double
|
||||
let radius: CGFloat
|
||||
let color: Color
|
||||
|
||||
/// Half-width of the cone in degrees.
|
||||
private let coneHalfAngle: Double = 20
|
||||
|
||||
var body: some View {
|
||||
let onTarget = isOnTarget
|
||||
|
||||
ConeShape(halfAngle: coneHalfAngle, radius: radius)
|
||||
.fill(
|
||||
RadialGradient(
|
||||
colors: [
|
||||
color.opacity(onTarget ? 0.55 : 0.3),
|
||||
color.opacity(onTarget ? 0.25 : 0.08)
|
||||
],
|
||||
center: .center,
|
||||
startRadius: 0,
|
||||
endRadius: radius
|
||||
)
|
||||
)
|
||||
.rotationEffect(.degrees(bearing))
|
||||
}
|
||||
|
||||
private var isOnTarget: Bool {
|
||||
let rawDiff = abs(heading - bearing).truncatingRemainder(dividingBy: 360)
|
||||
let diff = min(rawDiff, 360 - rawDiff)
|
||||
return diff <= coneHalfAngle
|
||||
}
|
||||
}
|
||||
|
||||
private struct ConeShape: Shape {
|
||||
let halfAngle: Double
|
||||
let radius: CGFloat
|
||||
|
||||
func path(in rect: CGRect) -> Path {
|
||||
let center = CGPoint(x: rect.midX, y: rect.midY)
|
||||
let startAngle = Angle(degrees: -90 - halfAngle)
|
||||
let endAngle = Angle(degrees: -90 + halfAngle)
|
||||
|
||||
var path = Path()
|
||||
path.move(to: center)
|
||||
path.addArc(center: center, radius: radius, startAngle: startAngle, endAngle: endAngle, clockwise: false)
|
||||
path.closeSubpath()
|
||||
return path
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Watch-sized compass sub-views
|
||||
|
||||
private struct WatchTickMark: View {
|
||||
let degree: Double
|
||||
let radius: CGFloat
|
||||
|
||||
var body: some View {
|
||||
let isCardinal = degree.truncatingRemainder(dividingBy: 90) == 0
|
||||
let isMajor = degree.truncatingRemainder(dividingBy: 30) == 0
|
||||
let length: CGFloat = isCardinal ? 8 : (isMajor ? 5 : 3)
|
||||
let width: CGFloat = isCardinal ? 2 : 1
|
||||
|
||||
Capsule()
|
||||
.fill(isCardinal ? Color.primary : Color.primary.opacity(isMajor ? 0.7 : 0.3))
|
||||
.frame(width: width, height: length)
|
||||
.offset(y: -(radius - length / 2))
|
||||
.rotationEffect(.degrees(degree))
|
||||
}
|
||||
}
|
||||
|
||||
private struct WatchTriangle: Shape {
|
||||
func path(in rect: CGRect) -> Path {
|
||||
var path = Path()
|
||||
path.move(to: CGPoint(x: rect.midX, y: rect.minY))
|
||||
path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY))
|
||||
path.addLine(to: CGPoint(x: rect.minX, y: rect.maxY))
|
||||
path.closeSubpath()
|
||||
return path
|
||||
}
|
||||
}
|
||||
|
||||
private struct WatchCompassLabel {
|
||||
let degrees: Double
|
||||
let text: String
|
||||
let isCardinal: Bool
|
||||
|
||||
static let allLabels: [WatchCompassLabel] = [
|
||||
WatchCompassLabel(degrees: 0, text: "N", isCardinal: true),
|
||||
WatchCompassLabel(degrees: 45, text: "NE", isCardinal: false),
|
||||
WatchCompassLabel(degrees: 90, text: "E", isCardinal: true),
|
||||
WatchCompassLabel(degrees: 135, text: "SE", isCardinal: false),
|
||||
WatchCompassLabel(degrees: 180, text: "S", isCardinal: true),
|
||||
WatchCompassLabel(degrees: 225, text: "SW", isCardinal: false),
|
||||
WatchCompassLabel(degrees: 270, text: "W", isCardinal: true),
|
||||
WatchCompassLabel(degrees: 315, text: "NW", isCardinal: false)
|
||||
]
|
||||
}
|
||||
|
||||
// MARK: - Color helper
|
||||
|
||||
extension Color {
|
||||
/// Quick luminance check so center text is readable on the distance color.
|
||||
var isWatchLight: Bool {
|
||||
// Approximate: yellow and lighter colours are "light"
|
||||
if self == .yellow || self == .orange || self == .white { return true }
|
||||
// For arbitrary colours, resolve RGBA and compute relative luminance
|
||||
guard let components = cgColor?.components, components.count >= 3 else { return false }
|
||||
let luminance = 0.299 * components[0] + 0.587 * components[1] + 0.114 * components[2]
|
||||
return luminance > 0.6
|
||||
}
|
||||
}
|
||||
159
Meshtastic Watch App/Views/NearbyNodesListView.swift
Normal file
159
Meshtastic Watch App/Views/NearbyNodesListView.swift
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
//
|
||||
// NearbyNodesListView.swift
|
||||
// Meshtastic Watch App
|
||||
//
|
||||
// Copyright(c) Meshtastic 2025.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import CoreLocation
|
||||
|
||||
/// Shows mesh nodes within half a mile (≈ 805 m) that have a valid
|
||||
/// position. Tapping a node opens the foxhunt compass pointing at it.
|
||||
struct NearbyNodesListView: View {
|
||||
|
||||
@ObservedObject var phoneManager: PhoneConnectivityManager
|
||||
@ObservedObject var locationManager: WatchLocationManager
|
||||
@State private var selectedNode: MeshNode?
|
||||
|
||||
/// Nodes filtered to ≤ 0.5 miles with a known position, sorted by distance.
|
||||
/// Also includes any nodes pinned as foxhunt targets from the iOS app.
|
||||
private var nearbyNodes: [MeshNode] {
|
||||
guard let userLoc = locationManager.currentLocation else { return [] }
|
||||
let targets = phoneManager.foxhuntTargets
|
||||
return phoneManager.nodes.values
|
||||
.filter { node in
|
||||
guard node.coordinate != nil else { return false }
|
||||
// Always include foxhunt targets regardless of distance
|
||||
if targets.contains(node.num) { return true }
|
||||
guard let dist = node.distance(from: userLoc) else { return false }
|
||||
return dist <= FoxhuntCompassView.maxDistanceMetres
|
||||
}
|
||||
.sorted { a, b in
|
||||
let aIsTarget = targets.contains(a.num)
|
||||
let bIsTarget = targets.contains(b.num)
|
||||
// Foxhunt targets sort first
|
||||
if aIsTarget != bIsTarget { return aIsTarget }
|
||||
let dA = a.distance(from: userLoc) ?? .greatestFiniteMagnitude
|
||||
let dB = b.distance(from: userLoc) ?? .greatestFiniteMagnitude
|
||||
return dA < dB
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if nearbyNodes.isEmpty {
|
||||
emptyState
|
||||
} else {
|
||||
nodeList
|
||||
}
|
||||
}
|
||||
.navigationTitle {
|
||||
HStack(spacing: 4) {
|
||||
Image("logo-white")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(height: 16)
|
||||
Image("custom.foxhunt")
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(.orange)
|
||||
Text("Foxhunt")
|
||||
.font(.headline)
|
||||
.foregroundStyle(.green)
|
||||
}
|
||||
}
|
||||
.sheet(item: $selectedNode) { node in
|
||||
FoxhuntCompassView(node: node, locationManager: locationManager)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Sub-views
|
||||
|
||||
@ViewBuilder
|
||||
private var emptyState: some View {
|
||||
VStack(spacing: 8) {
|
||||
Image(systemName: "antenna.radiowaves.left.and.right")
|
||||
.font(.title2)
|
||||
.foregroundStyle(.secondary)
|
||||
Text("No nearby nodes")
|
||||
.font(.headline)
|
||||
Text("Nodes within ½ mile with a known position will appear here.")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal)
|
||||
|
||||
if !phoneManager.hasReceivedData {
|
||||
Text("Open Meshtastic on your iPhone to sync.")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var nodeList: some View {
|
||||
List(nearbyNodes) { node in
|
||||
Button {
|
||||
selectedNode = node
|
||||
} label: {
|
||||
nodeRow(node)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func nodeRow(_ node: MeshNode) -> some View {
|
||||
let userLoc = locationManager.currentLocation
|
||||
let isTarget = phoneManager.foxhuntTargets.contains(node.num)
|
||||
HStack {
|
||||
WatchCircleText(
|
||||
text: node.shortName,
|
||||
color: WatchCircleText.color(for: node.num),
|
||||
circleSize: 28
|
||||
)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(node.longName)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.lineLimit(1)
|
||||
if let userLoc, let dist = node.distance(from: userLoc) {
|
||||
Text(formatDistance(dist))
|
||||
.font(.system(size: 12, design: .rounded))
|
||||
.foregroundStyle(distanceColor(dist))
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
// Mini bearing arrow
|
||||
if let bearing = bearing(to: node) {
|
||||
Image(systemName: "location.north.fill")
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(userLoc.flatMap { node.distance(from: $0) }.map { distanceColor($0) } ?? .secondary)
|
||||
.rotationEffect(.degrees(bearing - locationManager.heading))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func bearing(to node: MeshNode) -> Double? {
|
||||
guard let target = node.coordinate,
|
||||
let user = locationManager.currentLocation?.coordinate else { return nil }
|
||||
return FoxhuntCompassView.bearingBetween(from: user, to: target)
|
||||
}
|
||||
|
||||
private func formatDistance(_ distance: CLLocationDistance) -> String {
|
||||
let measurement = Measurement(value: distance, unit: UnitLength.meters)
|
||||
let formatter = MeasurementFormatter()
|
||||
formatter.unitOptions = .naturalScale
|
||||
formatter.numberFormatter.maximumFractionDigits = 0
|
||||
return formatter.string(from: measurement)
|
||||
}
|
||||
|
||||
private func distanceColor(_ distance: CLLocationDistance) -> Color {
|
||||
let ratio = min(distance / FoxhuntCompassView.maxDistanceMetres, 1.0)
|
||||
if ratio > 0.66 { return .blue }
|
||||
if ratio > 0.33 { return .yellow }
|
||||
return .red
|
||||
}
|
||||
}
|
||||
38
Meshtastic Watch App/Views/WatchCircleText.swift
Normal file
38
Meshtastic Watch App/Views/WatchCircleText.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -93,6 +93,7 @@
|
|||
3D3417B42E2730EC006A988B /* GeoJSONOverlayManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D3417B32E2730EC006A988B /* GeoJSONOverlayManager.swift */; };
|
||||
3D3417C82E29D38A006A988B /* GeoJSONOverlayConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D3417C72E29D38A006A988B /* GeoJSONOverlayConfig.swift */; };
|
||||
3D3417D22E2DC260006A988B /* MapDataManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D3417D12E2DC260006A988B /* MapDataManager.swift */; };
|
||||
AA0006WTSM00000000BF0001 /* WatchSessionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0006WTSM00000000FR0001 /* WatchSessionManager.swift */; };
|
||||
3D3417D42E2DC293006A988B /* MapDataFiles.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D3417D32E2DC293006A988B /* MapDataFiles.swift */; };
|
||||
655AF7816E76D5F310DF87A6 /* FountainCodec.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F203877F307073096C89179 /* FountainCodec.swift */; };
|
||||
6D825E622C34786C008DBEE4 /* CommonRegex.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D825E612C34786C008DBEE4 /* CommonRegex.swift */; };
|
||||
|
|
@ -327,6 +328,18 @@
|
|||
AB4622DCF4B1D4115ED00312 /* SendMessageIntentHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5FCB3877F157D9011FA5C6CF /* SendMessageIntentHandler.swift */; };
|
||||
B0E4EEF2D2C41A884A5E949C /* SearchForMessagesIntentHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E644AE784C52500A9241481 /* SearchForMessagesIntentHandler.swift */; };
|
||||
9BC51D7EF97090D149658843 /* SetMessageAttributeIntentHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDA6A18109101FA06A9FBBFB /* SetMessageAttributeIntentHandler.swift */; };
|
||||
AA0005WTCH00000000BF0001 /* MeshtasticWatchApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0005WTCH00000000FR0001 /* MeshtasticWatchApp.swift */; };
|
||||
AA0005WTCH00000000BF0002 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0005WTCH00000000FR0002 /* ContentView.swift */; };
|
||||
AA0005WTCH00000000BF0003 /* MeshNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0005WTCH00000000FR0003 /* MeshNode.swift */; };
|
||||
AA0005WTCH00000000BF0004 /* PhoneConnectivityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0005WTCH00000000FR0004 /* PhoneConnectivityManager.swift */; };
|
||||
AA0005WTCH00000000BF0005 /* WatchLocationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0005WTCH00000000FR0005 /* WatchLocationManager.swift */; };
|
||||
AA0005WTCH00000000BF0006 /* FoxhuntCompassView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0005WTCH00000000FR0006 /* FoxhuntCompassView.swift */; };
|
||||
AA0005WTCH00000000BF0007 /* NearbyNodesListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0005WTCH00000000FR0007 /* NearbyNodesListView.swift */; };
|
||||
AA0005WTCH00000000BF0008 /* DeviceConnectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0005WTCH00000000FR0008 /* DeviceConnectionView.swift */; };
|
||||
AA0005WTCH00000000BF0013 /* WatchCircleText.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0005WTCH00000000FR0013 /* WatchCircleText.swift */; };
|
||||
AA0005WTCH00000000BF0009 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = AA0005WTCH00000000FR0009 /* Assets.xcassets */; };
|
||||
|
||||
AA0005WTCH00000000BF0011 /* Meshtastic Watch App.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = AA0005WTCH00000000FR0012 /* Meshtastic Watch App.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
|
|
@ -344,6 +357,13 @@
|
|||
remoteGlobalIDString = DDDE59F329AF163D00490C6C;
|
||||
remoteInfo = WidgetsExtension;
|
||||
};
|
||||
AA0005WTCH00000000CX0001 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = DDC2E14C26CE248E0042C5E4 /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = AA0005WTCH00000000NT0001;
|
||||
remoteInfo = "Meshtastic Watch App";
|
||||
};
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXCopyFilesBuildPhase section */
|
||||
|
|
@ -358,6 +378,17 @@
|
|||
name = "Embed Foundation Extensions";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
AA0005WTCH00000000CP0001 /* Embed Watch Content */ = {
|
||||
isa = PBXCopyFilesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
dstPath = "$(CONTENTS_FOLDER_PATH)/Watch";
|
||||
dstSubfolderSpec = 16;
|
||||
files = (
|
||||
AA0005WTCH00000000BF0011 /* Meshtastic Watch App.app in Embed Watch Content */,
|
||||
);
|
||||
name = "Embed Watch Content";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXCopyFilesBuildPhase section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
|
|
@ -453,6 +484,7 @@
|
|||
3D3417B32E2730EC006A988B /* GeoJSONOverlayManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeoJSONOverlayManager.swift; sourceTree = "<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>"; };
|
||||
|
|
@ -744,6 +776,19 @@
|
|||
5FCB3877F157D9011FA5C6CF /* SendMessageIntentHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendMessageIntentHandler.swift; sourceTree = "<group>"; };
|
||||
0E644AE784C52500A9241481 /* SearchForMessagesIntentHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchForMessagesIntentHandler.swift; sourceTree = "<group>"; };
|
||||
CDA6A18109101FA06A9FBBFB /* SetMessageAttributeIntentHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetMessageAttributeIntentHandler.swift; sourceTree = "<group>"; };
|
||||
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 /* 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>"; };
|
||||
AA0005WTCH00000000FR0012 /* Meshtastic Watch App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Meshtastic Watch App.app"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||
|
|
@ -783,6 +828,13 @@
|
|||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
AA0005WTCH00000000FP0001 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
|
|
@ -1270,6 +1322,7 @@
|
|||
DDC2E15626CE248E0042C5E4 /* Meshtastic */,
|
||||
DDDE59F729AF163D00490C6C /* Widgets */,
|
||||
25F5D5C82C4375A8008036E3 /* MeshtasticTests */,
|
||||
AA0005WTCH00000000GR0001 /* Meshtastic Watch App */,
|
||||
DDC2E15526CE248E0042C5E4 /* Products */,
|
||||
DD8EDE9226F97A2B00A5A10B /* Frameworks */,
|
||||
);
|
||||
|
|
@ -1282,6 +1335,7 @@
|
|||
DDC2E15426CE248E0042C5E4 /* Meshtastic.app */,
|
||||
DDDE59F429AF163D00490C6C /* WidgetsExtension.appex */,
|
||||
25F5D5C72C4375A8008036E3 /* MeshtasticTests.xctest */,
|
||||
AA0005WTCH00000000FR0012 /* Meshtastic Watch App.app */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
|
|
@ -1425,6 +1479,7 @@
|
|||
6D825E612C34786C008DBEE4 /* CommonRegex.swift */,
|
||||
3D3417B32E2730EC006A988B /* GeoJSONOverlayManager.swift */,
|
||||
C37572859BC745C4284A9B42 /* TAK */,
|
||||
AA0006WTSM00000000FR0001 /* WatchSessionManager.swift */,
|
||||
);
|
||||
path = Helpers;
|
||||
sourceTree = "<group>";
|
||||
|
|
@ -1539,6 +1594,49 @@
|
|||
path = Widgets;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
AA0005WTCH00000000GR0004 /* Views */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
AA0005WTCH00000000FR0006 /* FoxhuntCompassView.swift */,
|
||||
AA0005WTCH00000000FR0007 /* NearbyNodesListView.swift */,
|
||||
AA0005WTCH00000000FR0008 /* DeviceConnectionView.swift */,
|
||||
AA0005WTCH00000000FR0013 /* WatchCircleText.swift */,
|
||||
);
|
||||
path = Views;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
AA0005WTCH00000000GR0002 /* Managers */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
AA0005WTCH00000000FR0004 /* PhoneConnectivityManager.swift */,
|
||||
AA0005WTCH00000000FR0005 /* WatchLocationManager.swift */,
|
||||
);
|
||||
path = Managers;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
AA0005WTCH00000000GR0003 /* Models */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
AA0005WTCH00000000FR0003 /* MeshNode.swift */,
|
||||
);
|
||||
path = Models;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
AA0005WTCH00000000GR0001 /* Meshtastic Watch App */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
AA0005WTCH00000000FR0001 /* MeshtasticWatchApp.swift */,
|
||||
AA0005WTCH00000000FR0002 /* ContentView.swift */,
|
||||
AA0005WTCH00000000FR0010 /* Meshtastic Watch App.entitlements */,
|
||||
AA0005WTCH00000000FR0011 /* Info.plist */,
|
||||
AA0005WTCH00000000FR0009 /* Assets.xcassets */,
|
||||
AA0005WTCH00000000GR0002 /* Managers */,
|
||||
AA0005WTCH00000000GR0003 /* Models */,
|
||||
AA0005WTCH00000000GR0004 /* Views */,
|
||||
);
|
||||
path = "Meshtastic Watch App";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
|
|
@ -1569,11 +1667,13 @@
|
|||
DDC2E15126CE248E0042C5E4 /* Frameworks */,
|
||||
DDC2E15226CE248E0042C5E4 /* Resources */,
|
||||
DDDE5A0829AF163F00490C6C /* Embed Foundation Extensions */,
|
||||
AA0005WTCH00000000CP0001 /* Embed Watch Content */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
DDDE5A0229AF163E00490C6C /* PBXTargetDependency */,
|
||||
AA0005WTCH00000000TD0001 /* PBXTargetDependency */,
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
DD4C11E02E8099C3003F2F2E /* PreferenceKeys */,
|
||||
|
|
@ -1613,6 +1713,23 @@
|
|||
productReference = DDDE59F429AF163D00490C6C /* WidgetsExtension.appex */;
|
||||
productType = "com.apple.product-type.app-extension";
|
||||
};
|
||||
AA0005WTCH00000000NT0001 /* Meshtastic Watch App */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = AA0005WTCH00000000CL0001 /* Build configuration list for PBXNativeTarget "Meshtastic Watch App" */;
|
||||
buildPhases = (
|
||||
AA0005WTCH00000000SP0001 /* Sources */,
|
||||
AA0005WTCH00000000FP0001 /* Frameworks */,
|
||||
AA0005WTCH00000000RP0001 /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
name = "Meshtastic Watch App";
|
||||
productName = "Meshtastic Watch App";
|
||||
productReference = AA0005WTCH00000000FR0012 /* Meshtastic Watch App.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
|
|
@ -1671,6 +1788,7 @@
|
|||
DDC2E15326CE248E0042C5E4 /* Meshtastic */,
|
||||
DDDE59F329AF163D00490C6C /* WidgetsExtension */,
|
||||
25F5D5C62C4375A8008036E3 /* MeshtasticTests */,
|
||||
AA0005WTCH00000000NT0001 /* Meshtastic Watch App */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
|
@ -1712,6 +1830,14 @@
|
|||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
AA0005WTCH00000000RP0001 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
AA0005WTCH00000000BF0009 /* Assets.xcassets in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXShellScriptBuildPhase section */
|
||||
|
|
@ -1958,6 +2084,7 @@
|
|||
DDB6ABE428B13FFF00384BA1 /* DisplayEnums.swift in Sources */,
|
||||
DD4975A52B147BA90026544E /* AmbientLightingConfig.swift in Sources */,
|
||||
3D3417D22E2DC260006A988B /* MapDataManager.swift in Sources */,
|
||||
AA0006WTSM00000000BF0001 /* WatchSessionManager.swift in Sources */,
|
||||
D93068D92B81509C0066FBC8 /* TapbackResponses.swift in Sources */,
|
||||
D93068DA2B81509D0066FBC8 /* TapbackInputView.swift in Sources */,
|
||||
DDA9F5E82E77FAC100E70DEB /* AnimatedNodePin.swift in Sources */,
|
||||
|
|
@ -2049,6 +2176,22 @@
|
|||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
AA0005WTCH00000000SP0001 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
AA0005WTCH00000000BF0001 /* MeshtasticWatchApp.swift in Sources */,
|
||||
AA0005WTCH00000000BF0002 /* ContentView.swift in Sources */,
|
||||
AA0005WTCH00000000BF0003 /* MeshNode.swift in Sources */,
|
||||
AA0005WTCH00000000BF0004 /* PhoneConnectivityManager.swift in Sources */,
|
||||
AA0005WTCH00000000BF0005 /* WatchLocationManager.swift in Sources */,
|
||||
AA0005WTCH00000000BF0006 /* FoxhuntCompassView.swift in Sources */,
|
||||
AA0005WTCH00000000BF0007 /* NearbyNodesListView.swift in Sources */,
|
||||
AA0005WTCH00000000BF0008 /* DeviceConnectionView.swift in Sources */,
|
||||
AA0005WTCH00000000BF0013 /* WatchCircleText.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXTargetDependency section */
|
||||
|
|
@ -2063,6 +2206,11 @@
|
|||
target = DDDE59F329AF163D00490C6C /* WidgetsExtension */;
|
||||
targetProxy = DDDE5A0129AF163E00490C6C /* PBXContainerItemProxy */;
|
||||
};
|
||||
AA0005WTCH00000000TD0001 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = AA0005WTCH00000000NT0001 /* Meshtastic Watch App */;
|
||||
targetProxy = AA0005WTCH00000000CX0001 /* PBXContainerItemProxy */;
|
||||
};
|
||||
/* End PBXTargetDependency section */
|
||||
|
||||
/* Begin PBXVariantGroup section */
|
||||
|
|
@ -2419,6 +2567,68 @@
|
|||
};
|
||||
name = Release;
|
||||
};
|
||||
AA0005WTCH00000000BC0001 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = "Meshtastic Watch App/Meshtastic Watch App.entitlements";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = "Meshtastic Watch App/Info.plist";
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "Meshtastic Foxhunt";
|
||||
INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "Meshtastic needs your location to calculate distance and bearing to mesh nodes during foxhunt.";
|
||||
INFOPLIST_KEY_WKCompanionAppBundleIdentifier = gvh.MeshtasticClient;
|
||||
INFOPLIST_KEY_WKRunsIndependentlyOfCompanionApp = NO;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2.7.10;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient.watchkitapp;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = watchos;
|
||||
SKIP_INSTALL = NO;
|
||||
SUPPORTED_PLATFORMS = "watchos watchsimulator";
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = 4;
|
||||
WATCHOS_DEPLOYMENT_TARGET = 10.0;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
AA0005WTCH00000000BC0002 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = "Meshtastic Watch App/Meshtastic Watch App.entitlements";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = "Meshtastic Watch App/Info.plist";
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "Meshtastic Foxhunt";
|
||||
INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "Meshtastic needs your location to calculate distance and bearing to mesh nodes during foxhunt.";
|
||||
INFOPLIST_KEY_WKCompanionAppBundleIdentifier = gvh.MeshtasticClient;
|
||||
INFOPLIST_KEY_WKRunsIndependentlyOfCompanionApp = NO;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2.7.10;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient.watchkitapp;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = watchos;
|
||||
SKIP_INSTALL = NO;
|
||||
SUPPORTED_PLATFORMS = "watchos watchsimulator";
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = 4;
|
||||
WATCHOS_DEPLOYMENT_TARGET = 10.0;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
|
|
@ -2458,6 +2668,15 @@
|
|||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
AA0005WTCH00000000CL0001 /* Build configuration list for PBXNativeTarget "Meshtastic Watch App" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
AA0005WTCH00000000BC0001 /* Debug */,
|
||||
AA0005WTCH00000000BC0002 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
|
||||
/* Begin XCLocalSwiftPackageReference section */
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"symbols" : [
|
||||
{
|
||||
"filename" : "custom.foxhunt.svg",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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 |
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
// }
|
||||
// }
|
||||
|
|
@ -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 "📍"
|
||||
}
|
||||
}
|
||||
}
|
||||
189
Meshtastic/Helpers/WatchSessionManager.swift
Normal file
189
Meshtastic/Helpers/WatchSessionManager.swift
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
//
|
||||
// WatchSessionManager.swift
|
||||
// Meshtastic
|
||||
//
|
||||
// Copyright(c) Meshtastic 2025.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import WatchConnectivity
|
||||
import CoreData
|
||||
import os
|
||||
|
||||
/// Manages the WatchConnectivity session on the iOS side, sending mesh node
|
||||
/// data to the companion Apple Watch app.
|
||||
///
|
||||
/// Call `sendNodesToWatch()` whenever node data changes (e.g., after
|
||||
/// receiving position updates from the radio).
|
||||
final class WatchSessionManager: NSObject, ObservableObject {
|
||||
|
||||
static let shared = WatchSessionManager()
|
||||
|
||||
private let logger = Logger(subsystem: "gvh.MeshtasticClient", category: "⌚ Watch")
|
||||
private var session: WCSession?
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
guard WCSession.isSupported() else {
|
||||
logger.info("WCSession not supported on this device")
|
||||
return
|
||||
}
|
||||
let session = WCSession.default
|
||||
session.delegate = self
|
||||
session.activate()
|
||||
self.session = session
|
||||
logger.info("WCSession activated on iOS")
|
||||
}
|
||||
|
||||
// MARK: - Public API
|
||||
|
||||
/// Whether a paired Watch with the Meshtastic app installed is available.
|
||||
var isWatchAvailable: Bool {
|
||||
guard let session, session.activationState == .activated else { return false }
|
||||
return session.isPaired && session.isWatchAppInstalled
|
||||
}
|
||||
|
||||
/// Send a specific node to the Watch as a foxhunt target.
|
||||
/// The Watch will pin this node in its foxhunt list regardless of distance.
|
||||
func sendNodeForFoxhunt(_ nodeNum: Int64) {
|
||||
guard let session, session.activationState == .activated, session.isPaired, session.isWatchAppInstalled else {
|
||||
logger.warning("Cannot send foxhunt target – Watch not available")
|
||||
return
|
||||
}
|
||||
guard session.isReachable else {
|
||||
// Fall back to transferUserInfo when not reachable
|
||||
session.transferUserInfo(["foxhuntTarget": UInt32(nodeNum)])
|
||||
logger.info("Queued foxhunt target \(nodeNum) via transferUserInfo")
|
||||
return
|
||||
}
|
||||
session.sendMessage(["foxhuntTarget": UInt32(nodeNum)], replyHandler: nil) { error in
|
||||
Task { @MainActor in
|
||||
self.logger.error("Failed to send foxhunt target: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
logger.info("Sent foxhunt target \(nodeNum) to Watch")
|
||||
}
|
||||
|
||||
/// Fetch nodes from Core Data and push them to the Watch via application context.
|
||||
func sendNodesToWatch() {
|
||||
guard let session, session.activationState == .activated, session.isPaired, session.isWatchAppInstalled else {
|
||||
return
|
||||
}
|
||||
|
||||
let context = PersistenceController.shared.container.viewContext
|
||||
context.perform { [weak self] in
|
||||
guard let self else { return }
|
||||
let nodes = self.fetchNodesForWatch(context: context)
|
||||
guard !nodes.isEmpty else { return }
|
||||
|
||||
do {
|
||||
let data = try JSONEncoder().encode(nodes)
|
||||
try session.updateApplicationContext(["nodes": data])
|
||||
self.logger.info("Sent \(nodes.count) nodes to Watch via applicationContext")
|
||||
} catch {
|
||||
self.logger.error("Failed to send nodes to Watch: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Core Data → Watch Node Serialization
|
||||
|
||||
private func fetchNodesForWatch(context: NSManagedObjectContext) -> [WatchNode] {
|
||||
let fetchRequest = NSFetchRequest<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?
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -479,6 +479,17 @@ struct NodeDetail: View {
|
|||
}
|
||||
if node.hasPositions {
|
||||
#if !targetEnvironment(macCatalyst)
|
||||
if node.latestPosition?.isPreciseLocation == true && WatchSessionManager.shared.isWatchAvailable {
|
||||
Button {
|
||||
WatchSessionManager.shared.sendNodeForFoxhunt(node.num)
|
||||
} label: {
|
||||
Label {
|
||||
Text("Foxhunt on your watch")
|
||||
} icon: {
|
||||
Image("custom.foxhunt")
|
||||
}
|
||||
}
|
||||
}
|
||||
if node.latestPosition?.isPreciseLocation == true {
|
||||
Button {
|
||||
showingCompassSheet = true
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue