Meshtastic-Apple/Meshtastic/Accessory/Accessory Manager/AccessoryManager.swift
Garth Vander Houwen b30dc8645a
2.7.6 Working Changes (#1479)
* Bump version

* Message list performance fixes into 2.7.6 (#1475)

* Remove extra want config call when adding a contact

* App badge and unnecessary notification fixes (#1455)

* - Fix for app badge not going to zero if a message arrives while you have that chat open
- Fix for push notifications popping up when a message is received while that chat is open

* Fix for cancelling notifications, works now. And scroll to bottom of conversation upon new message

* Fix: Channels help grammer fix (#1452)

* remove outdated TCP not available on Apple devices (#1450)

* Update initial onboarding view

* remove toggle gating for mac

* Update crash reporting opt out in real time

* Update onboarding text

* Use mDNS text records for node name

* TCP IP and port on the connection screen

* Hide app icon chooser on mac

* Infinite loop hang bugfixes and performance improvements for both `UserMessageList` and `ChannelMessageList` (#1465)

* 2.7.5 Working Changes (#1460)

* Remove extra want config call when adding a contact

* App badge and unnecessary notification fixes (#1455)

* - Fix for app badge not going to zero if a message arrives while you have that chat open
- Fix for push notifications popping up when a message is received while that chat is open

* Fix for cancelling notifications, works now. And scroll to bottom of conversation upon new message

* Fix: Channels help grammer fix (#1452)

* remove outdated TCP not available on Apple devices (#1450)

* Update initial onboarding view

* remove toggle gating for mac

* Update crash reporting opt out in real time

* Update onboarding text

---------

Co-authored-by: Gnome Adrift <646322+gnomeadrift@users.noreply.github.com>
Co-authored-by: Zain Kergaye <62440012+ZainKergaye@users.noreply.github.com>
Co-authored-by: NillRudd <102033730+NillRudd@users.noreply.github.com>

* UserEntity: add mostRecentMessage and unreadMessages with early exit when lastMessage is nil, and fetch 1 row (not N) otherwise

* UserList: replace 5 slow calls to user.messageList with new fast calls

* NodeList: always put the connected node at the top of list (if it matches the node filters)

* ChannelEntity: add faster mostRecentPrivateMessage and unreadMessages which fetch 1 row (not N)

* ChannelList: replace 5 calls to channel.allPrivateMessage with new fast calls

* Fix incorrect appState.unreadDirectMessages calculations

* MyInfoEntity: also fix unreadMessages count here to be fast, and use it for appState.unreadChannelMessages

* UserMessageList: use @FetchRequest to prevent the N^2 behavior that was happening in calls to allPrivateMessages

* Refactor ChannelEntityExtension and MyInfoEntityExtension to be more similar to UserEntityExtension

* Remove SwiftUI-infinite-loop-causing `.id(redrawTapbacksTrigger)` in ChannelMessageList and UserMessageList (duplicate row ids)

* MyInfoEntityExtension: exclude emoji tapbacks (which never get marked as read anyway) from unread message count

* Add SaveChannelLinkData so MessageText and MeshtasticApp can use .sheet(item: ...) and avoid infinite loop hang due to Binding rebuild

* ChannelMessageList and UserMessageList: switch to stable messageId for ForEach SwiftUI row identity

* ChannelMessageList and UserMessageList: debouncedScrollToBottom; keyboardWillShowNotification/keyboardDidShowNotification

* ChannelMessageList and UserMessageList: scroll to bottom onFirstAppear

* ChannelMessageList and UserMessageList: block spurious markMessagesAsRead when this View is not active

---------

Co-authored-by: Garth Vander Houwen <garth@meshtastic.com>
Co-authored-by: Gnome Adrift <646322+gnomeadrift@users.noreply.github.com>
Co-authored-by: Zain Kergaye <62440012+ZainKergaye@users.noreply.github.com>
Co-authored-by: NillRudd <102033730+NillRudd@users.noreply.github.com>

* message-list-performance: revert scrolling changes (#1472)

* Revert e0f0b4a0f7 (ChannelMessageList and UserMessageList: scroll to bottom onFirstAppear)

* Revert "ChannelMessageList and UserMessageList: debouncedScrollToBottom; keyboardWillShowNotification/keyboardDidShowNotification"

This reverts commit ee1a7c4415.

---------

Co-authored-by: Gnome Adrift <646322+gnomeadrift@users.noreply.github.com>
Co-authored-by: Zain Kergaye <62440012+ZainKergaye@users.noreply.github.com>
Co-authored-by: NillRudd <102033730+NillRudd@users.noreply.github.com>
Co-authored-by: Jake-B <jake-b@users.noreply.github.com>
Co-authored-by: Mike Robbins <mrobbins@alum.mit.edu>

* Explicitly set unmessagable, seems unnessary

* Add back missing mesh map features

* Fix: "Retrieving nodes" significantly slower after reconnect  extracted from #1424 (#1477)

* Fix: "Retrieving nodes" significantly slower after reconnect (#1424)

The node database retrieval was calling context.save() for every single
NodeInfo packet received (250 saves for 250 nodes). This caused severe
performance degradation on reconnect when CoreData had accumulated state.

Root Cause:
- nodeInfoPacket() called context.save() immediately for each node
- With 250 nodes, this meant 250 individual CoreData save operations
- On first connection, CoreData is fresh and fast
- On reconnect, CoreData has accumulated change tracking, undo management,
  and memory pressure, making each save progressively slower
- This resulted in 10+ second retrieval times vs 1-2 seconds initially

Solution:
- Added deferSave parameter to nodeInfoPacket() function
- During database retrieval (.retrievingDatabase state), defer all saves
- Perform a single batch save when database retrieval completes
  (when NONCE_ONLY_DB configCompleteID is received)
- This reduces 250 saves to 1 save

Performance Impact:
- Eliminates N individual saves during node database sync
- Reduces database retrieval time back to 1-2 seconds on reconnect
- Matches first-connection performance consistently

Fixes #1424

* Revert *MessageListUnified files

---------

Co-authored-by: Martin Bogomolni <martinbogo@gmail.com>
Co-authored-by: Jake-B <jake-b@users.noreply.github.com>

* Hide route lines filter from mesh map

* Mesh Map: fuzz imprecise locations so they're distinguishable and clickable at the highest zoom levels (#1478)

Co-authored-by: Garth Vander Houwen <garth@meshtastic.com>

* Fix bad merge

* Update Meshtastic/Extensions/CoreData/UserEntityExtension.swift

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update Meshtastic/Views/Nodes/Helpers/Map/MapContent/MeshMapContent.swift

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update Meshtastic/Extensions/CoreData/ChannelEntityExtension.swift

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update Meshtastic/Extensions/CoreData/MyInfoEntityExtension.swift

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update Meshtastic/Extensions/CoreData/ChannelEntityExtension.swift

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Keep list of previous manual connections (#1484)

* Keep list of previous manual connections

* More descriptive manual connection rows

* Merge fixes and new way to show IP on Connect view

---------

Co-authored-by: Jake-B <jake-b@users.noreply.github.com>

* Show who relayed messages (#1486)

* Add identification for node that relayed text messages and add count for ammount of relayers of your message

* Ack Relays

* upsertPositionPacket: don't use future timestamps to set node's lastHeard (#1488)

* R1 NEO

* Neo

* Update Meshtastic/Views/Settings/AppSettings.swift

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Remove bad if

* Git rid of extra environment variable

* Update Meshtastic/Accessory/Transports/TCP/TCPTransport.swift

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* MeshMap performance: quick wins (#1490)

* MeshMap: change onMapCameraChange frequency to .onEnd so that zooming doesn't cause continuous SwiftUI reevaluation on every frame

* MeshMapContent: factor out reducedPrecisionMapCircles into a separate function

* MeshMapContent: when multiple reducedPrecisionCircles have the same (lat,lon,radius), just draw one (big perf boost in dense areas)

* NodeMap performance improvements for high # positions history (#1480)

* NodeMapContent: move Route Lines out of ForEach

* NodeMapContent: move Convex Hull out of ForEach

* NodeMapContent: Replace `position.nodePosition?` with `node`

* NodeMapContent: drop unnecessary LazyVStack in showNodeHistory

* NodeMapContent: hoist out nodeColorSwift

* Move lineCoords, loraCoords calculations within showRouteLines, showConvexHull respectively

* Hoist out repeated node.metadata?.positionFlags lookups / PositionFlags creation

* NodeMapContent: remove unused @State

* NodeMapSwiftUI: add NodeMapContentEquatableWrapper and NodeMapContentSignature to prevent frequent NodeMapContent recomputation and infinite render loops

* NodeMapSwiftUI: disable animation during SwiftUI transactions

* NodeMapContent: hoist nodeBorderColor and set allowsHitTesting(false) on history point views

* NodeMapContent: prerenderHistoryPointCircle and prerenderHistoryPointArrow to avoid thousands of vector draw operations

* NodeMapContent: Shared coordinate list for Route Lines and Convex Hull

* NodeMapContent.prerenderHistoryPointArrow: add .frame(width: 16, height: 16)

* Fix wantRangeTestPackets to correctly follow rangeTestConfig.enabled (#1489)

* Fix interval drop down formatter

* Clean up channel qr code functionality.

* perferredPeripheralId fix

* Set opt in

* Retry once 5 second timer. dont throw the error

* Queue for peripherals

* Fix: hoplimit of dms would always fallback to hops away of the node even when configured hops was higher (#1495)

* fix hops setting in dms

* Fix hops for exchange position

* Final fix

* Don't favorite client base

* Update device hardware

* Prevent nil environment metrics

* Bump datadog sdk

* fix setting device telemetry enabled (#1515)

* Update Muzi R1 Neo to actively supported

* fix setting device telemetry enabled

---------

Co-authored-by: Jonathan Bennett <jbennett@incomsystems.biz>
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>

* Don't subscribe to mqtt topic if downlink is not on (#1501)

* Dont sub if no downlink

* moved reload mqtt connect config

* Preview enabled in connected devices (#1509)

* Update Muzi R1 Neo to actively supported

* Preview enabled in connected devices

* Fixing indentation

* Fixing indentation

---------

Co-authored-by: Jonathan Bennett <jbennett@incomsystems.biz>
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>

* UpdateCoreData.updateAnyPacketFrom: mirror firmware's lastHeard/snr/rssi/hopsAway update logic from NodeDB::updateFrom (#1492)

* `CLIENT_BASE` add-favorite/role-change confirmation dialog (#1493)

* FavoriteNodeButton: refactor task out

* AccessoryManager.connectedDeviceRole helper

* FavoriteNodeButton: show confirmation dialog when a CLIENT_BASE is trying to add a favorite

* addContactFromURL: add comment referencing upcoming change in https://github.com/meshtastic/firmware/pull/8495

* DeviceConfig: role picker: show a warning when selecting CLIENT_BASE, similar to warning shown for ROUTER

* Adjust device configuration Client Base warning text

* Compass view (#1521)

* Added compass view

* Added Compass View

* Node colors in compass

* Update Muzi R1 Neo to actively supported

* Update PositionPopover.swift

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update CompassView.swift

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update CompassView.swift

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update Meshtastic/Views/Helpers/CompassView.swift

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update Meshtastic/Views/Helpers/CompassView.swift

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Benjamin Faershtein <119711889+RCGV1@users.noreply.github.com>
Co-authored-by: Jonathan Bennett <jbennett@incomsystems.biz>
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Remove discovery queue

* revert problematic retry functionalliy

* format file

* Update & improve zh-Hans translation (#1523)

* Update Muzi R1 Neo to actively supported

* update & improve zh-Hans translation

rt

---------

Co-authored-by: Jonathan Bennett <jbennett@incomsystems.biz>
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>

* Update protobufs to 2.7.1

* Add long-turbo preset

* Disable Range Test module when primary channel is public/unsecured (#1512)

* Update Muzi R1 Neo to actively supported

* Disable Range Test module when primary channel is public/unsecured

Updated RangeTestConfig.swift to determine whether the primary channel (index 0) is operating without encryption or with a 1-byte minimal PSK.

Disabled Range Test UI controls when on a public/default channel to prevent user interaction.

Added safety enforcement in the save operation: Range Test enabled flag is automatically forced to false before sending updates to the device.

Introduced a computed property isPrimaryChannelPublic following existing code patterns and security indicators (e.g., hexDescription PSK length).

Matches the behavior implemented in the Android client for consistent policy across platforms.

---------

Co-authored-by: Jonathan Bennett <jbennett@incomsystems.biz>
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>

* Add new device images

* Remove print statement, get rid of cut and paste error

---------

Co-authored-by: Gnome Adrift <646322+gnomeadrift@users.noreply.github.com>
Co-authored-by: Zain Kergaye <62440012+ZainKergaye@users.noreply.github.com>
Co-authored-by: NillRudd <102033730+NillRudd@users.noreply.github.com>
Co-authored-by: Jake-B <jake-b@users.noreply.github.com>
Co-authored-by: Mike Robbins <mrobbins@alum.mit.edu>
Co-authored-by: Martin Bogomolni <martinbogo@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: jake-b <1012393+jake-b@users.noreply.github.com>
Co-authored-by: Benjamin Faershtein <119711889+RCGV1@users.noreply.github.com>
Co-authored-by: Jonathan Bennett <jbennett@incomsystems.biz>
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
Co-authored-by: Charles Pinesky <25388414+Vaidios@users.noreply.github.com>
Co-authored-by: Radio <35003866+radiolee@users.noreply.github.com>
Co-authored-by: Jason Houk <dubsectordevelopment@gmail.com>
2025-12-21 12:15:01 -08:00

790 lines
31 KiB
Swift

//
// AccessoryManager.swift
// Created by Jake Bordens on 7/10/25.
//
import Foundation
import SwiftUI
import MeshtasticProtobufs
import CoreBluetooth
import OSLog
import CocoaMQTT
import Combine
enum AccessoryError: Error, LocalizedError {
case discoveryFailed(String)
case connectionFailed(String)
case versionMismatch(String)
case ioFailed(String)
case appError(String)
case timeout
case disconnected(String)
case tooManyRetries
case eventStreamCancelled
case coreBluetoothError(CBError)
case coreBluetoothATTError(CBATTError)
var errorDescription: String? {
switch self {
case .discoveryFailed(let message):
return "Discovery failed. \(message)"
case .connectionFailed(let message):
return "Connection failed. \(message)"
case .versionMismatch(let message):
return "Version mismatch: \(message)"
case .ioFailed(let message):
return "Communication failure: \(message)"
case .appError(let message):
return "Application error: \(message)"
case .timeout:
return "Connection Timeout"
case .disconnected(let message):
return "Disconnected: \(message)"
case .tooManyRetries:
return "Too Many Retries"
case .eventStreamCancelled:
return "Event stream cancelled"
case .coreBluetoothError(let cbError):
// Map specific CBError values to a more user-friendly message
switch cbError.code {
case .connectionTimeout: // 6
return "The Bluetooth connection to the radio unexpectedly disconnected, it will automatically reconnect to the preferred radio when it comes back in range or is powered back on.".localized
case .peripheralDisconnected: // 7
return "The Bluetooth connection to the radio was disconnected, it will automatically reconnect to the preferred radio when it is powered back on or finishes rebooting.".localized
case .peerRemovedPairingInformation: // 14
return "The radio has deleted its stored pairing information, but your device has not. To resolve this, you must forget the radio under Settings > Bluetooth to clear the old, now invalid, pairing information.".localized
default:
// Fallback for other CBError codes
return "A Bluetooth error occurred: \(cbError.localizedDescription)"
}
case .coreBluetoothATTError(let attError):
// Map specific CBATTError values to a more user-friendly message
switch attError.code {
case .insufficientAuthentication: // 5
return "Bluetooth \(attError.localizedDescription) Please try connecting again and check the BLE PIN carefully.".localized
case .insufficientEncryption: // 15
return "Bluetooth \(attError.localizedDescription) Please try connecting again and check the BLE PIN carefully.".localized
default:
// Fallback for other CBError codes
return "A Bluetooth Attribute Protocol error occurred: \(attError.localizedDescription)"
}
}
}
}
enum AccessoryManagerState: Equatable {
case uninitialized
case idle
case discovering
case connecting
case retrying(attempt: Int)
case retrievingDatabase(nodeCount: Int)
case communicating
case subscribed
var description: String {
switch self {
case .uninitialized:
return "Uninitialized"
case .idle:
return "Idle"
case .discovering:
return "Discovering"
case .connecting:
return "Connecting"
case .retrying(let attempt):
return "Retrying Connection (\(attempt))"
case .communicating:
return "Communicating"
case .subscribed:
return "Subscribed"
case .retrievingDatabase(let nodeCount):
return "Retreiving nodes \(nodeCount)"
}
}
}
@MainActor
class AccessoryManager: ObservableObject, MqttClientProxyManagerDelegate {
// Singleton Access. Conditionally compiled
#if targetEnvironment(macCatalyst)
static let shared = AccessoryManager(transports: [BLETransport(), TCPTransport(), SerialTransport()])
#else
static let shared = AccessoryManager(transports: [BLETransport(), TCPTransport()])
#endif
// Constants
let NONCE_ONLY_CONFIG = 69420
let NONCE_ONLY_DB = 69421
let minimumVersion = "2.3.15"
// Global Objects
// Chicken/Egg problem. Set in the App object immediately after
// AppState and AccessoryManager are created
var appState: AppState!
let context = PersistenceController.shared.container.viewContext
let mqttManager = MqttClientProxyManager.shared
// Published Stuff
@Published var mqttProxyConnected: Bool = false
@Published var devices: [Device] = []
@Published var state: AccessoryManagerState
@Published var mqttError: String = ""
@Published var activeDeviceNum: Int64?
@Published var allowDisconnect = false
@Published var lastConnectionError: Error?
@Published var isConnected: Bool = false
@Published var isConnecting: Bool = false
var activeConnection: (device: Device, connection: any Connection)?
let transports: [any Transport]
// Config
public var wantRangeTestPackets = false
var wantStoreAndForwardPackets = false
var shouldAutomaticallyConnectToPreferredPeripheral = true
// Conncetion process
var connectionSteps: SequentialSteps?
// Public due to file separation
var discoveryTask: Task<Void, Never>?
var connectionEventTask: Task <Void, Error>?
var locationTask: Task<Void, Error>?
var connectionStepper: SequentialSteps?
// Flash subjects
@Published var packetsSent: Int = 0
@Published var packetsReceived: Int = 0
// Continuations
var wantConfigContinuation: CheckedContinuation<Void, Error>?
var firstDatabaseNodeInfoContinuation: CheckedContinuation<Void, Error>?
var wantDatabaseGate: AsyncGate = AsyncGate()
// Misc
@Published var expectedNodeDBSize: Int?
var heartbeatTimer: ResettableTimer?
var heartbeatResponseTimer: ResettableTimer?
init(transports: [any Transport] = [BLETransport(), TCPTransport()]) {
self.transports = transports
self.state = .uninitialized
self.mqttManager.delegate = self
}
func transportForType(_ type: TransportType) -> Transport? {
return transports.first(where: {$0.type == type })
}
func connectToPreferredDevice() {
if !self.isConnected && !self.isConnecting,
let preferredDevice = self.devices.first(where: { $0.id.uuidString == UserDefaults.preferredPeripheralId }) {
Task { try await self.connect(to: preferredDevice) }
}
}
func sendWantConfig() async throws {
if let inProgressWantConfigContinuation = wantConfigContinuation {
Logger.transport.info("[Accessory] Existing continuation for wantConfig(Config). Cancelling.")
inProgressWantConfigContinuation.resume(throwing: CancellationError())
wantConfigContinuation = nil
}
guard let connection = activeConnection?.connection else {
Logger.transport.error("Unable to send wantConfig (config): No device connected")
return
}
try await withTaskCancellationHandler {
var toRadio: ToRadio = ToRadio()
toRadio.wantConfigID = UInt32(NONCE_ONLY_CONFIG)
try await self.send(toRadio)
try await connection.startDrainPendingPackets()
try await withCheckedThrowingContinuation { cont in
self.wantConfigContinuation = cont
}
self.wantConfigContinuation = nil
Logger.transport.info("✅ [Accessory] NONCE_ONLY_CONFIG Done")
} onCancel: {
Task { @MainActor in
wantConfigContinuation?.resume(throwing: CancellationError())
wantConfigContinuation = nil
}
}
}
func sendWantDatabase() async throws {
if let firstDatabaseNodeInfoContinuation = firstDatabaseNodeInfoContinuation {
Logger.transport.info("[Accessory] Existing continuation for firstDatabaseNodeInfo. Cancelling.")
firstDatabaseNodeInfoContinuation.resume(throwing: CancellationError())
self.firstDatabaseNodeInfoContinuation = nil
}
guard let connection = activeConnection?.connection else {
Logger.transport.error("Unable to send wantConfig (Database): No device connected")
return
}
try await withTaskCancellationHandler {
var toRadio: ToRadio = ToRadio()
toRadio.wantConfigID = UInt32(NONCE_ONLY_DB)
try await self.send(toRadio)
try await connection.startDrainPendingPackets()
try await withCheckedThrowingContinuation { cont in
firstDatabaseNodeInfoContinuation = cont
}
firstDatabaseNodeInfoContinuation = nil
Logger.transport.info("✅ [Accessory] NONCE_ONLY_DB first NodeInfo received.")
} onCancel: {
Task { @MainActor in
firstDatabaseNodeInfoContinuation?.resume(throwing: CancellationError())
firstDatabaseNodeInfoContinuation = nil
}
}
}
func waitForWantDatabaseResponse() async throws {
try await wantDatabaseGate.wait()
}
// Fully tears down a connection and sets up the AccessoryManager for the next.
// If you are calling this in response to an error, then you should have
// exposed the error to the UI or handled the error prior to calling this.
func closeConnection() async throws {
Logger.transport.debug("[AccessoryManager] received disconnect request")
if let activeConnection {
updateDevice(deviceId: activeConnection.device.id, key: \.connectionState, value: .disconnected)
self.activeConnection = nil
}
connectionEventTask?.cancel()
connectionEventTask = nil
locationTask?.cancel()
locationTask = nil
await heartbeatTimer?.cancel(withReason: "Closing connection")
await heartbeatResponseTimer?.cancel(withReason: "Closing connection")
heartbeatTimer = nil
heartbeatResponseTimer = nil
// Clean up continuations
wantConfigContinuation?.resume(throwing: CancellationError())
wantConfigContinuation = nil
firstDatabaseNodeInfoContinuation?.resume(throwing: CancellationError())
firstDatabaseNodeInfoContinuation = nil
await wantDatabaseGate.cancelAll()
await wantDatabaseGate.reset()
// Turn off the disconnect buttons
allowDisconnect = false
self.startDiscovery()
}
// Should only be called by UI-facing callers.
func disconnect() async throws {
// Cancel ongoing connection task if it exists
await self.connectionStepper?.cancel()
// Close out the connection
if let activeConnection = activeConnection {
try await activeConnection.connection.disconnect(withError: nil, shouldReconnect: false)
}
}
// Update device attributes on MainActor for presentation in the UI
func updateDevice<T>(deviceId: UUID? = nil, key: WritableKeyPath<Device, T>, value: T) where T: Equatable {
guard let deviceId = deviceId ?? self.activeConnection?.device.id else {
Logger.transport.error("updateDevice<T> with nil deviceId")
return
}
// Update the active device if the UUID's match
if let activeConnection, activeConnection.device.id == deviceId {
var device = activeConnection.device
if device[keyPath: key] != value {
// Update the @Published stuff for the UI
self.objectWillChange.send()
device[keyPath: key] = value
self.activeConnection = (device: device, connection: activeConnection.connection)
self.activeDeviceNum = device.num
}
}
// Update the device in the devices array if it exists
if let index = devices.firstIndex(where: { $0.id == deviceId }) {
var device = devices[index]
device[keyPath: key] = value
if device[keyPath: key] != value {
// Update the @Published stuff for the UI
self.objectWillChange.send()
if let index = devices.firstIndex(where: { $0.id == deviceId }) {
devices[index] = device
}
}
} else {
// Durring active connections, this discover list will be empty, so this is expected.
// Logger.transport.error("Device with ID \(deviceId) not found in devices list.")
}
}
// Update state on MainActor for presentation in the UI
func updateState(_ newState: AccessoryManagerState) {
#if DEBUG
Logger.transport.info("🔗 Updating state from \(self.state.description, privacy: .public) to \(newState.description, privacy: .public)")
#endif
switch newState {
case .uninitialized, .idle, .discovering:
self.isConnected = false
self.isConnecting = false
case .connecting, .communicating, .retrying:
self.isConnected = false
self.isConnecting = true
case .subscribed, .retrievingDatabase:
self.isConnected = true
self.isConnecting = false
}
self.state = newState
}
func send(_ data: ToRadio, debugDescription: String? = nil) async throws {
packetsSent += 1
guard let active = activeConnection,
await active.connection.isConnected else {
throw AccessoryError.connectionFailed("Not connected to any device")
}
try await active.connection.send(data)
if let debugDescription {
Logger.transport.info("📻 \(debugDescription, privacy: .public)")
}
}
func didReceive(_ event: ConnectionEvent) {
packetsReceived += 1
switch event {
case .data(let fromRadio):
// Logger.transport.info(" [Accessory] didReceive: \(fromRadio.payloadVariant.debugDescription)")
self.processFromRadio(fromRadio)
Task {
await self.heartbeatResponseTimer?.cancel(withReason: "Data packet received")
await self.heartbeatTimer?.reset(delay: .seconds(15.0))
}
case .logMessage(let message):
self.didReceiveLog(message: message)
Task {
await self.heartbeatResponseTimer?.cancel(withReason: "Log message packet received")
await self.heartbeatTimer?.reset(delay: .seconds(15.0))
}
case .rssiUpdate(let rssi):
guard let deviceId = self.activeConnection?.device.id else {
Logger.transport.error("Could not update RSSI, no active connection")
return
}
updateDevice(deviceId: deviceId, key: \.rssi, value: rssi)
case .error(let error), .errorWithoutReconnect(let error):
Task {
// Figure out if we'll reconnect
if case .errorWithoutReconnect = event {
shouldAutomaticallyConnectToPreferredPeripheral = false
} else {
shouldAutomaticallyConnectToPreferredPeripheral = true
}
Logger.transport.info("🚨 [Accessory] didReceive with failure: \(error.localizedDescription, privacy: .public) (willReconnect = \(self.shouldAutomaticallyConnectToPreferredPeripheral, privacy: .public))")
lastConnectionError = error
if let connectionStepper = self.connectionStepper {
// If we're in the midst of a connection process, tell the stepper that something happened
// This cancels retry connection attempts if we've been asked not to reconnect
await connectionStepper.cancelCurrentlyExecutingStep(withError: error, cancelFullProcess: !shouldAutomaticallyConnectToPreferredPeripheral)
} else {
// Normal processing. Expose the error and disconnect
try? await self.closeConnection()
// If we were actively reconnecting, then don't update the status because
// we're in the midst of a reconnection flow
if !(await self.connectionStepper?.isRunning ?? false) {
updateState(.discovering)
}
}
}
case .disconnected:
Task {
// This is user-initatied, so don't reconnect
shouldAutomaticallyConnectToPreferredPeripheral = false
try? await self.closeConnection()
updateState(.discovering)
}
Logger.transport.info("[Accessory] Connection reported user-initiated disconnect.")
}
}
func didReceiveLog(message: String) {
var log = message
/// Debug Log Level
if log.starts(with: "DEBUG |") {
do {
let logString = log
if let coordsMatch = try CommonRegex.COORDS_REGEX.firstMatch(in: logString) {
log = "\(log.replacingOccurrences(of: "DEBUG |", with: "").trimmingCharacters(in: .whitespaces))"
log = log.replacingOccurrences(of: "[,]", with: "", options: .regularExpression)
Logger.radio.debug("🛰️ \(log.prefix(upTo: coordsMatch.range.lowerBound), privacy: .public) \(coordsMatch.0.replacingOccurrences(of: "[,]", with: "", options: .regularExpression), privacy: .private(mask: .none)) \(log.suffix(from: coordsMatch.range.upperBound), privacy: .public)")
} else {
log = log.replacingOccurrences(of: "[,]", with: "", options: .regularExpression)
Logger.radio.debug("🕵🏻‍♂️ \(log.replacingOccurrences(of: "DEBUG |", with: "").trimmingCharacters(in: .whitespaces), privacy: .public)")
}
} catch {
log = log.replacingOccurrences(of: "[,]", with: "", options: .regularExpression)
Logger.radio.debug("🕵🏻‍♂️ \(log.replacingOccurrences(of: "DEBUG |", with: "").trimmingCharacters(in: .whitespaces), privacy: .public)")
}
} else if log.starts(with: "INFO |") {
do {
let logString = log
if let coordsMatch = try CommonRegex.COORDS_REGEX.firstMatch(in: logString) {
log = "\(log.replacingOccurrences(of: "INFO |", with: "").trimmingCharacters(in: .whitespaces))"
log = log.replacingOccurrences(of: "[,]", with: "", options: .regularExpression)
Logger.radio.info("🛰️ \(log.prefix(upTo: coordsMatch.range.lowerBound), privacy: .public) \(coordsMatch.0.replacingOccurrences(of: "[,]", with: "", options: .regularExpression), privacy: .private) \(log.suffix(from: coordsMatch.range.upperBound), privacy: .public)")
} else {
log = log.replacingOccurrences(of: "[,]", with: "", options: .regularExpression)
Logger.radio.info("📢 \(log.replacingOccurrences(of: "INFO |", with: "").trimmingCharacters(in: .whitespaces), privacy: .public)")
}
} catch {
log = log.replacingOccurrences(of: "[,]", with: "", options: .regularExpression)
Logger.radio.info("📢 \(log.replacingOccurrences(of: "INFO |", with: "").trimmingCharacters(in: .whitespaces), privacy: .public)")
}
} else if log.starts(with: "WARN |") {
log = log.replacingOccurrences(of: "[,]", with: "", options: .regularExpression)
Logger.radio.warning("⚠️ \(log.replacingOccurrences(of: "WARN |", with: "").trimmingCharacters(in: .whitespaces), privacy: .public)")
} else if log.starts(with: "ERROR |") {
log = log.replacingOccurrences(of: "[,]", with: "", options: .regularExpression)
Logger.radio.error("💥 \(log.replacingOccurrences(of: "ERROR |", with: "").trimmingCharacters(in: .whitespaces), privacy: .public)")
} else if log.starts(with: "CRIT |") {
log = log.replacingOccurrences(of: "[,]", with: "", options: .regularExpression)
Logger.radio.critical("🧨 \(log.replacingOccurrences(of: "CRIT |", with: "").trimmingCharacters(in: .whitespaces), privacy: .public)")
} else {
log = log.replacingOccurrences(of: "[,]", with: "", options: .regularExpression)
Logger.radio.debug("📟 \(log, privacy: .public)")
}
}
private func processFromRadio(_ decodedInfo: FromRadio) {
switch decodedInfo.payloadVariant {
case .mqttClientProxyMessage(let mqttClientProxyMessage):
handleMqttClientProxyMessage(mqttClientProxyMessage)
case .clientNotification(let clientNotification):
handleClientNotification(clientNotification)
case .myInfo(let myNodeInfo):
handleMyInfo(myNodeInfo)
case .packet(let packet):
// All received packets get passed through updateAnyPacketFrom to update lastHeard, rxSnr, etc. (like firmware's NodeDB::updateFrom).
if let connectedNodeNum = self.activeDeviceNum {
updateAnyPacketFrom(packet: packet, activeDeviceNum: connectedNodeNum, context: context)
} else {
Logger.mesh.error("🕸️ Unable to determine connectedNodeNum for updateAnyPacketFrom. Skipping.")
}
// Dispatch based on packet contents.
if case let .decoded(data) = packet.payloadVariant {
switch data.portnum {
case .textMessageApp, .detectionSensorApp, .alertApp:
handleTextMessageAppPacket(packet)
case .remoteHardwareApp:
Logger.mesh.info("🕸️ MESH PACKET received for Remote Hardware App UNHANDLED \((try? decodedInfo.packet.jsonString()) ?? "JSON Decode Failure", privacy: .public)")
case .positionApp:
upsertPositionPacket(packet: packet, context: context)
case .waypointApp:
waypointPacket(packet: packet, context: context)
case .nodeinfoApp:
guard let connectedNodeNum = self.activeDeviceNum else {
Logger.mesh.error("🕸️ Unable to determine connectedNodeNum for node info upsert.")
return
}
if packet.from != connectedNodeNum {
upsertNodeInfoPacket(packet: packet, context: context)
} else {
Logger.mesh.error("🕸️ Received a node info packet from ourselves over the mesh. Dropping.")
}
case .routingApp:
guard let deviceNum = activeConnection?.device.num else {
Logger.mesh.error("🕸️ No active connection. Unable to determine connectedNodeNum for routingPacket.")
return
}
routingPacket(packet: packet, connectedNodeNum: deviceNum, context: context)
case .adminApp:
adminAppPacket(packet: packet, context: context)
case .replyApp:
Logger.mesh.info("🕸️ MESH PACKET received for Reply App handling as a text message")
guard let deviceNum = activeConnection?.device.num else {
Logger.mesh.error("🕸️ No active connection. Unable to determine connectedNodeNum for replyApp.")
return
}
textMessageAppPacket(packet: packet, wantRangeTestPackets: wantRangeTestPackets, connectedNode: deviceNum, context: context, appState: appState)
case .ipTunnelApp:
Logger.mesh.info("🕸️ MESH PACKET received for IP Tunnel App UNHANDLED UNHANDLED")
case .serialApp:
Logger.mesh.info("🕸️ MESH PACKET received for Serial App UNHANDLED UNHANDLED")
case .storeForwardApp:
guard let deviceNum = activeConnection?.device.num else {
Logger.mesh.error("🕸️ No active connection. Unable to determine connectedNodeNum for storeAndForward.")
return
}
storeAndForwardPacket(packet: decodedInfo.packet, connectedNodeNum: deviceNum)
case .rangeTestApp:
guard let deviceNum = activeConnection?.device.num else {
Logger.mesh.error("🕸️ No active connection. Unable to determine connectedNodeNum for rangeTestApp.")
return
}
if wantRangeTestPackets {
textMessageAppPacket(
packet: packet,
wantRangeTestPackets: true,
connectedNode: deviceNum,
context: context,
appState: appState
)
} else {
Logger.mesh.info("🕸️ MESH PACKET received for Range Test App Range testing is disabled.")
}
case .telemetryApp:
guard let deviceNum = activeConnection?.device.num else {
Logger.mesh.error("🕸️ No active connection. Unable to determine connectedNodeNum for telemetryApp.")
return
}
telemetryPacket(packet: packet, connectedNode: deviceNum, context: context)
case .textMessageCompressedApp:
Logger.mesh.info("🕸️ MESH PACKET received for Text Message Compressed App UNHANDLED")
case .zpsApp:
Logger.mesh.info("🕸️ MESH PACKET received for Zero Positioning System App UNHANDLED")
case .privateApp:
Logger.mesh.info("🕸️ MESH PACKET received for Private App UNHANDLED UNHANDLED")
case .atakForwarder:
Logger.mesh.info("🕸️ MESH PACKET received for ATAK Forwarder App UNHANDLED UNHANDLED")
case .simulatorApp:
Logger.mesh.info("🕸️ MESH PACKET received for Simulator App UNHANDLED UNHANDLED")
case .storeForwardPlusplusApp:
Logger.mesh.info("🕸️ MESH PACKET received for SFPP App UNHANDLED UNHANDLED")
case .audioApp:
Logger.mesh.info("🕸️ MESH PACKET received for Audio App UNHANDLED UNHANDLED")
case .tracerouteApp:
handleTraceRouteApp(packet)
case .neighborinfoApp:
if let neighborInfo = try? NeighborInfo(serializedBytes: decodedInfo.packet.decoded.payload) {
Logger.mesh.info("🕸️ MESH PACKET received for Neighbor Info App UNHANDLED \((try? neighborInfo.jsonString()) ?? "JSON Decode Failure", privacy: .public)")
}
case .paxcounterApp:
paxCounterPacket(packet: decodedInfo.packet, context: context)
case .mapReportApp:
Logger.mesh.info("🕸️ MESH PACKET received Map Report App UNHANDLED \((try? decodedInfo.packet.jsonString()) ?? "JSON Decode Failure", privacy: .public)")
case .UNRECOGNIZED:
Logger.mesh.info("🕸️ MESH PACKET received UNRECOGNIZED App UNHANDLED \((try? decodedInfo.packet.jsonString()) ?? "JSON Decode Failure", privacy: .public)")
case .max:
Logger.services.info("MAX PORT NUM OF 511")
case .atakPlugin:
Logger.mesh.info("🕸️ MESH PACKET received for ATAK Plugin App UNHANDLED \((try? decodedInfo.packet.jsonString()) ?? "JSON Decode Failure", privacy: .public)")
case .powerstressApp:
Logger.mesh.info("🕸️ MESH PACKET received for Power Stress App UNHANDLED \((try? decodedInfo.packet.jsonString()) ?? "JSON Decode Failure", privacy: .public)")
case .reticulumTunnelApp:
Logger.mesh.info("🕸️ MESH PACKET received for Reticulum Tunnel App UNHANDLED \((try? decodedInfo.packet.jsonString()) ?? "JSON Decode Failure", privacy: .public)")
case .keyVerificationApp:
Logger.mesh.warning("🕸️ MESH PACKET received for Key Verification App UNHANDLED \((try? decodedInfo.packet.jsonString()) ?? "JSON Decode Failure", privacy: .public)")
case .unknownApp:
Logger.mesh.warning("🕸️ MESH PACKET received for unknown App UNHANDLED \((try? decodedInfo.packet.jsonString()) ?? "JSON Decode Failure", privacy: .public)")
case .cayenneApp:
Logger.mesh.info("🕸️ MESH PACKET received Cayenne App UNHANDLED \((try? decodedInfo.packet.jsonString()) ?? "JSON Decode Failure", privacy: .public)")
}
}
case .nodeInfo(let nodeInfo):
handleNodeInfo(nodeInfo)
case .channel(let channel):
handleChannel(channel)
case .config(let config):
handleConfig(config)
case .moduleConfig(let moduleConfig):
handleModuleConfig(moduleConfig)
case .metadata(let metadata):
handleDeviceMetadata(metadata)
case .deviceuiConfig:
#if DEBUG
Logger.mesh.info("🕸️ MESH PACKET received for deviceUIConfig UNHANDLED \((try? decodedInfo.packet.jsonString()) ?? "JSON Decode Failure", privacy: .public)")
#endif
case .fileInfo:
#if DEBUG
Logger.mesh.info("🕸️ MESH PACKET received for fileInfo UNHANDLED \((try? decodedInfo.packet.jsonString()) ?? "JSON Decode Failure", privacy: .public)")
#endif
case .queueStatus:
#if DEBUG
Logger.mesh.info("🕸️ MESH PACKET received for queueStatus \((try? decodedInfo.packet.jsonString()) ?? "JSON Decode Failure", privacy: .public)")
#else
Logger.mesh.info("🕸️ MESH PACKET received for heartbeat response")
#endif
case .logRecord(let record):
didReceiveLog(message: record.stringRepresentation)
case .configCompleteID(let configCompleteID):
// Not sure if we want to do anythign here directly? The continuation stuff lets you
// do the next step right in the connection flow.
// switch configCompleteID {
// case UInt32(NONCE_ONLY_CONFIG):
// break;
// case UInt32(NONCE_ONLY_DB):
// case UInt32(NONCE_ONLY_DB):
// break;
// break:
// Logger.mesh.error(" [Accessory] Unknown UNHANDLED confligCompleteID: \(configCompleteID)")
// }
Logger.transport.info("✅ [Accessory] Notifying completions that have completed for configCompleteID: \(configCompleteID)")
switch configCompleteID {
case UInt32(NONCE_ONLY_CONFIG):
if let continuation = wantConfigContinuation {
continuation.resume()
}
case UInt32(NONCE_ONLY_DB):
// Open the gate for the wantDatabaseContinuation
Task { await wantDatabaseGate.open() }
// If we get the "done" for NONCE_ONLY_DB, but are still waiting for the first NodeInfo,
// Then the database is probably empty, and can continue
if let firstDatabaseNodeInfoContinuation {
firstDatabaseNodeInfoContinuation.resume()
self.firstDatabaseNodeInfoContinuation = nil
}
// Perform a single batch save after database retrieval completes
// This significantly improves performance on reconnect
do {
try context.save()
Logger.data.info("💾 [Database] Batch saved all node info after database retrieval")
} catch {
context.rollback()
let nsError = error as NSError
Logger.data.error("💥 [Database] Error saving batch node info: \(nsError, privacy: .public)")
}
default:
Logger.transport.error("[Accessory] Unknown nonce completed: \(configCompleteID)")
}
case .rebooted:
// If we had an existing connection, then we can probably get away with just a wantConfig?
if state == .subscribed {
Task { try? await sendWantConfig() }
}
default:
Logger.mesh.error("Unknown FromRadio variant: \(decodedInfo.payloadVariant.debugDescription)")
}
}
}
extension AccessoryManager {
var connectedVersion: String? {
return activeConnection?.device.firmwareVersion
}
var connectedDeviceRole: DeviceRoles? {
guard let connectedNodeNum = activeDeviceNum else { return nil }
guard let connectedNode = getNodeInfo(id: connectedNodeNum, context: context) else { return nil }
guard let connectedNodeUser = connectedNode.user else { return nil }
return DeviceRoles(rawValue: Int(connectedNodeUser.role))
}
func checkIsVersionSupported(forVersion: String) -> Bool {
let myVersion = connectedVersion ?? "0.0.0"
let supportedVersion = UserDefaults.firmwareVersion == "0.0.0" ||
forVersion.compare(myVersion, options: .numeric) == .orderedAscending ||
forVersion.compare(myVersion, options: .numeric) == .orderedSame
return supportedVersion
}
}
extension AccessoryManager {
func setupPeriodicHeartbeat() async {
if heartbeatTimer != nil {
Logger.transport.debug("💓 [Heartbeat] Cancelling existing heartbeat timer")
await self.heartbeatTimer?.cancel(withReason: "Duplicate setup, cancelling previous timer")
self.heartbeatTimer = nil
}
self.heartbeatTimer = ResettableTimer(isRepeating: true, debugName: Bundle.main.isDebug ? "Send Heartbeat" : nil) {
Logger.transport.debug("💓 [Heartbeat] Sending periodic heartbeat")
try? await self.sendHeartbeat()
}
// We can send heartbeats for older versions just fine, but only 2.7.4 and up will respond with
// a definite queueStatus packet.
if self.checkIsVersionSupported(forVersion: "2.7.4") {
self.heartbeatResponseTimer = ResettableTimer(isRepeating: false, debugName: Bundle.main.isDebug ? "Heartbeat Timeout" : nil) { @MainActor in
Logger.transport.error("💓 [Heartbeat] Connection Timeout: Did not receive a packet after heartbeat.")
// If we're in the middle of a connection cancel it.
await self.connectionStepper?.cancel()
// Close out the connection
if let activeConnection = self.activeConnection {
try? await activeConnection.connection.disconnect(withError: AccessoryError.timeout, shouldReconnect: true)
} else {
self.lastConnectionError = AccessoryError.timeout
try? await self.closeConnection()
}
}
}
await self.heartbeatTimer?.reset(delay: .seconds(15.0))
}
}
enum PossiblyAlreadyDoneContinuation {
case alreadyDone
case notDone(CheckedContinuation<Void, Error>)
}
extension AccessoryManager {
func appDidEnterBackground() {
if self.state == .uninitialized { return }
if let connection = self.activeConnection?.connection {
Logger.transport.info("[AccessoryManager] informing active connection that we are entering the background")
Task { await connection.appDidEnterBackground() }
} else {
Logger.transport.info("[AccessoryManager] suspending scanning while in the background")
stopDiscovery()
}
}
func appDidBecomeActive() {
if self.state == .uninitialized { return }
if let connection = self.activeConnection?.connection {
Logger.transport.info("[AccessoryManager] informing previously active connection that we are active again")
Task { await connection.appDidBecomeActive() }
} else {
if self.discoveryTask == nil {
Logger.transport.info("[AccessoryManager] Previosuly in the background but not scanning, starting scanning again")
self.startDiscovery()
}
}
}
}