Keep list of previous manual connections

This commit is contained in:
Jake-B 2025-10-24 19:55:40 -04:00
parent 8f9be79c55
commit 486b4c1821
12 changed files with 252 additions and 90 deletions

View file

@ -3905,10 +3905,6 @@
}
}
},
"Anonymous Usage and Crash data" : {
"comment" : "A description of how the app collects and uses data about its usage and crashes. It emphasizes that this data is anonymous and non-personally identifiable.",
"isCommentAutoGenerated" : true
},
"Any missed messages will be delivered again." : {
"localizations" : {
"it" : {
@ -20445,6 +20441,9 @@
}
}
}
},
"Manual Connections" : {
},
"Map Data" : {
"localizations" : {
@ -20930,6 +20929,10 @@
}
}
},
"Meshtastic does not collect any personal information. We do anonymously collect usage and crash data to improve the app. This helps us understand how the app is being used and where we can make improvements. The data we collect is non-personally identifiable and cannot be linked to you as an individual. You can opt out of this under app settings." : {
"comment" : "Privacy policy text for Meshtastic.",
"isCommentAutoGenerated" : true
},
"Meshtastic Node %@ has shared channels with you" : {
"localizations" : {
"de" : {
@ -40176,6 +40179,9 @@
}
}
}
},
"User Privacy" : {
},
"User Uploaded" : {
"comment" : "Data source label for user uploaded files",
@ -41040,10 +41046,6 @@
},
"Waypoints" : {
},
"We anonymously collect usage and crash data to improve the app. This helps us understand how the app is being used and where we can make improvements. The data we collect is non-personally identifiable and cannot be linked to you as an individual. You can opt out of this under app settings." : {
"comment" : "A description of how the app collects and uses user data. Includes a link to the app settings.",
"isCommentAutoGenerated" : true
},
"Weather Conditions" : {
"localizations" : {

View file

@ -67,7 +67,12 @@ extension AccessoryManager {
}
self.activeConnection = (device: device, connection: connection)
if UserDefaults.preferredPeripheralId.count < 1 {
// if we don't have a peripheralId, set it now at the beginning of the
// connect process (because I think it is used in other parts of the app
// during the connect process?
// Otherwise, UserDefault.preferredPeripheralId is set in the Connect
// view, as part of the "Connect to new radio?" confirmation dialog logic.
if UserDefaults.preferredPeripheralId.isEmpty {
UserDefaults.preferredPeripheralId = device.id.uuidString
}
} catch let error as CBError where error.code == .peerRemovedPairingInformation {
@ -168,6 +173,11 @@ extension AccessoryManager {
// We have an active connection
self.updateDevice(deviceId: device.id, key: \.connectionState, value: .connected)
self.updateState(.subscribed)
// If we successfully connected to a manual connection, then save it to the list
if device.isManualConnection {
self.saveManualConnection(device: device)
}
}
// Step 8: Update UI and status to connected
@ -214,6 +224,15 @@ extension AccessoryManager {
// All done, one way or another, clean up
self.connectionStepper = nil
}
fileprivate func saveManualConnection(device: Device) {
var manualConnections = UserDefaults.manualConnections
if manualConnections.first(where: {$0.id == device.id}) == nil {
manualConnections.append(device)
UserDefaults.manualConnections = manualConnections
}
}
}
// Sequentially stepped tasks

View file

@ -703,7 +703,8 @@ extension AccessoryManager {
await self.heartbeatTimer?.cancel(withReason: "Duplicate setup, cancelling previous timer")
self.heartbeatTimer = nil
}
self.heartbeatTimer = ResettableTimer(isRepeating: true, debugName: "Send Heartbeat") {
self.heartbeatTimer = ResettableTimer(isRepeating: true, debugName: Bundle.main.isDebug ? "Send Heartbeat" : nil) {
Logger.transport.debug("💓 [Heartbeat] Sending periodic heartbeat")
try? await self.sendHeartbeat()
}
@ -711,7 +712,7 @@ extension AccessoryManager {
// 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: "Heartbeat Timeout") { @MainActor in
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()

View file

@ -31,7 +31,7 @@ enum ConnectionEvent {
case disconnected(shouldReconnect: Bool)
}
enum ConnectionState: Equatable {
enum ConnectionState: Equatable, Codable {
case disconnected
case connecting
case connected

View file

@ -7,7 +7,7 @@
import Foundation
struct Device: Identifiable, Hashable {
struct Device: Identifiable, Hashable, Codable {
let id: UUID
var name: String
var transportType: TransportType
@ -23,7 +23,9 @@ struct Device: Identifiable, Hashable {
var connectionState: ConnectionState
var wasRestored: Bool = false
init(id: UUID, name: String, transportType: TransportType, identifier: String, connectionState: ConnectionState = .disconnected, rssi: Int? = nil, num: Int64? = nil, wasRestored: Bool = false) {
var isManualConnection: Bool = false
init(id: UUID, name: String, transportType: TransportType, identifier: String, connectionState: ConnectionState = .disconnected, rssi: Int? = nil, num: Int64? = nil, wasRestored: Bool = false, isManualConnection: Bool = false) {
self.id = id
self.name = name
self.transportType = transportType
@ -32,6 +34,7 @@ struct Device: Identifiable, Hashable {
self.rssi = rssi
self.num = num
self.wasRestored = wasRestored
self.isManualConnection = isManualConnection
}
var rssiString: String {

View file

@ -9,7 +9,7 @@ import Foundation
import CommonCrypto
import SwiftUI
enum TransportType: String, CaseIterable {
enum TransportType: String, CaseIterable, Codable {
case ble = "BLE"
case tcp = "TCP"
case serial = "Serial"
@ -53,14 +53,15 @@ protocol Transport {
var requiresPeriodicHeartbeat: Bool { get }
var supportsManualConnection: Bool { get }
func manuallyConnect(withConnectionString: String) async throws
func device(forManualConnection: String) -> Device?
func manuallyConnect(toDevice: Device) async throws
}
// Used to make stable-ish ID's for accessories that don't have a UUID
extension String {
func toUUIDFormatHash() -> UUID? {
func toUUIDFormatHash() -> UUID {
// Convert string to data
guard let data = self.data(using: .utf8) else { return nil }
let data = self.data(using: .utf8) ?? Data()
// Create buffer for SHA-256 hash (32 bytes)
var digest = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH))

View file

@ -403,7 +403,11 @@ class BLETransport: Transport {
}
func manuallyConnect(withConnectionString: String) async throws {
func device(forManualConnection: String) -> Device? {
return nil
}
func manuallyConnect(toDevice: Device) async throws {
Logger.transport.error("🛜 [BLE] This transport does not support manual connections")
}

View file

@ -31,7 +31,7 @@ class SerialTransport: Transport {
while !Task.isCancelled {
let ports = self.getSerialPorts()
for port in ports {
let id = port.toUUIDFormatHash() ?? UUID()
let id = port.toUUIDFormatHash()
if !portsAlreadyNotified.contains(port) {
Logger.transport.info("🔱 [Serial] Port \(port, privacy: .public) found.")
let newDevice = Device(id: id,
@ -45,9 +45,8 @@ class SerialTransport: Transport {
for knownPort in portsAlreadyNotified where !ports.contains(knownPort) {
// Previosuly seen port is no longer available
Logger.transport.info("🔱 [Serial] Port \(knownPort, privacy: .public) is no longer connected.")
if let uuid = knownPort.toUUIDFormatHash() {
cont.yield(.deviceLost(uuid))
}
let uuid = knownPort.toUUIDFormatHash()
cont.yield(.deviceLost(uuid))
portsAlreadyNotified.removeAll(where: {$0 == knownPort})
}
try? await Task.sleep(for: .seconds(5))
@ -118,7 +117,11 @@ class SerialTransport: Transport {
return SerialConnection(path: device.identifier)
}
func manuallyConnect(withConnectionString: String) async throws {
func device(forManualConnection: String) -> Device? {
return nil
}
func manuallyConnect(toDevice: Device) async throws {
Logger.transport.error("🔱 [USB] This transport does not support manual connections")
}
}

View file

@ -73,7 +73,7 @@ class TCPTransport: NSObject, Transport, NetServiceBrowserDelegate, NetServiceDe
let ip = service.ipv4Address ?? "Unknown IP"
// Use a mishmash of things and hash for stable? ID.
let idString = "\(service.name):\(host):\(ip):\(port)".toUUIDFormatHash() ?? UUID()
let idString = "\(service.name):\(host):\(ip):\(port)".toUUIDFormatHash()
// Save the resolved service locally for later
services[service.name] = ResolvedService(id: idString, service: service, host: host, port: port)
@ -134,16 +134,56 @@ class TCPTransport: NSObject, Transport, NetServiceBrowserDelegate, NetServiceDe
}
}
func manuallyConnect(withConnectionString: String) async throws {
let hashedIdentifier = withConnectionString.toUUIDFormatHash() ?? UUID()
let manualDevice = Device(id: hashedIdentifier,
name: "\(withConnectionString) (Manual)",
transportType: .tcp, identifier: withConnectionString)
try await AccessoryManager.shared.connect(to: manualDevice)
func device(forManualConnection connectionString: String) -> Device? {
let parts = connectionString.split(separator: ":")
var identifier: String
switch parts.count {
case 1:
// host & default port
identifier = "\(parts[0]):4403"
case 2:
// host & port
if parts[1].isValidTCPPort {
identifier = "\(parts[0]):\(parts[1])"
}
fallthrough
default:
return nil
}
let hashedIdentifier = identifier.toUUIDFormatHash()
return Device(id: hashedIdentifier,
name: "\(connectionString) (Manual)",
transportType: .tcp,
identifier: connectionString,
isManualConnection: true)
}
func manuallyConnect(toDevice device: Device) async throws {
try await AccessoryManager.shared.connect(to: device)
}
}
extension StringProtocol {
var isValidTCPPort: Bool {
// Check if the string is non-empty and contains only digits
guard !isEmpty, allSatisfy({ $0.isNumber }) else {
return false
}
// Parse the string to an integer
guard let port = Int(self) else {
return false // Fails if the string can't be converted to an integer
}
// Check if the port is in the valid TCP range (065535)
return port >= 0 && port <= 65535
}
}
extension NetService {
var ipv4Address: String? {
for addressData in addresses ?? [] {

View file

@ -23,4 +23,12 @@ extension Bundle {
public var isTestFlight: Bool {
return appStoreReceiptURL?.lastPathComponent == "sandboxReceipt"
}
public var isDebug: Bool {
#if DEBUG
return true
#else
return false
#endif
}
}

View file

@ -79,6 +79,7 @@ extension UserDefaults {
case showDeviceOnboarding
case usageDataAndCrashReporting
case autoconnectOnDiscovery
case manualConnections
case testIntEnum
}
@ -178,6 +179,36 @@ extension UserDefaults {
@UserDefault(.testIntEnum, defaultValue: .one)
static var testIntEnum: TestIntEnum
static var manualConnections: [Device] {
get {
// Retrieve data from UserDefaults
guard let data = UserDefaults.standard.data(forKey: Keys.manualConnections.rawValue) else {
return []
}
// Decode the Data back to [Device]
do {
let decoder = JSONDecoder()
let devices = try decoder.decode([Device].self, from: data)
return devices
} catch {
return []
}
}
set {
do {
// Encode the [Device] to Data
let encoder = JSONEncoder()
let data = try encoder.encode(newValue)
// Store the Data in UserDefaults
UserDefaults.standard.set(data, forKey: Keys.manualConnections.rawValue)
} catch {
print("Failed to encode manualConnections: \(error)")
}
}
}
}
enum TestIntEnum: Int, Decodable {

View file

@ -26,8 +26,6 @@ struct Connect: View {
@State var isUnsetRegion = false
@State var invalidFirmwareVersion = false
@State var liveActivityStarted = false
@State var presentingSwitchPreferredPeripheral = false
@State var selectedPeripherialId = ""
var body: some View {
NavigationStack {
@ -264,60 +262,24 @@ struct Connect: View {
.textCase(nil)
if !(accessoryManager.isConnected || accessoryManager .isConnecting) {
Section(header: HStack {
Text("Available Radios").font(.title)
Spacer()
ManualConnectionMenu()
}) {
ForEach(accessoryManager.devices.sorted(by: { $0.name < $1.name })) { device in
HStack {
if UserDefaults.preferredPeripheralId == device.id.uuidString {
Image(systemName: "star.fill")
.imageScale(.large).foregroundColor(.yellow)
.padding(.trailing)
} else {
Image(systemName: "circle.fill")
.imageScale(.large).foregroundColor(.gray)
.padding(.trailing)
}
VStack(alignment: .leading) {
Button(action: {
if UserDefaults.preferredPeripheralId.count > 0 && device.id.uuidString != UserDefaults.preferredPeripheralId {
if accessoryManager.allowDisconnect {
Task { try await accessoryManager.disconnect() }
}
presentingSwitchPreferredPeripheral = true
selectedPeripherialId = device.id.uuidString
} else {
Task {
try? await accessoryManager.connect(to: device)
}
}
}) {
Text(device.name).font(.callout)
}
// Show transport type
TransportIcon(transportType: device.transportType)
}
Spacer()
VStack {
device.getSignalStrength().map { SignalStrengthIndicator(signalStrength: $0) }
}
}.padding([.bottom, .top])
}
}
.confirmationDialog("Connecting to a new radio will clear all app data on the phone.", isPresented: $presentingSwitchPreferredPeripheral, titleVisibility: .visible) {
Button("Connect to new radio?", role: .destructive) {
UserDefaults.preferredPeripheralId = selectedPeripherialId
UserDefaults.preferredPeripheralNum = 0
if accessoryManager.allowDisconnect {
Task { try await accessoryManager.disconnect() }
Group {
Section(header: HStack {
Text("Available Radios").font(.title)
Spacer()
ManualConnectionMenu()
}) {
ForEach(accessoryManager.devices.sorted(by: { $0.name < $1.name })) { device in
DeviceConnectRow(device: device)
}
clearCoreDataDatabase(context: context, includeRoutes: false)
clearNotifications()
if let radio = accessoryManager.devices.first(where: { $0.id.uuidString == selectedPeripherialId }) {
Task {
try await accessoryManager.connect(to: radio)
}
if UserDefaults.manualConnections.count > 0 {
Section(header: Text("Manual Connections").font(.title)) {
ForEach(UserDefaults.manualConnections) { device in
DeviceConnectRow(device: device)
}.onDelete { offsets in
var list = UserDefaults.manualConnections
list.remove(atOffsets: offsets)
UserDefaults.manualConnections = list
}
}
}
@ -472,6 +434,10 @@ struct TransportIcon: View {
}
struct ManualConnectionMenu: View {
@EnvironmentObject var accessoryManager: AccessoryManager
@Environment(\.managedObjectContext) var context
private struct IterableTransport: Identifiable {
let id: UUID
let icon: Image
@ -490,7 +456,9 @@ struct ManualConnectionMenu: View {
@State private var selectedTransport: IterableTransport?
@State private var showAlert: Bool = false
@State private var connectionString = ""
@State var presentingSwitchPreferredPeripheral = false
@State var deviceForManualConnection: Device?
var body: some View {
Menu {
ForEach(transports) { transport in
@ -520,11 +488,93 @@ struct ManualConnectionMenu: View {
Button("OK", action: {
if !connectionString.isEmpty {
Task {
try await selectedTransport.transport.manuallyConnect(withConnectionString: connectionString)
if let device = selectedTransport.transport.device(forManualConnection: connectionString) {
if UserDefaults.preferredPeripheralId == device.id.uuidString {
Task {
try await selectedTransport.transport.manuallyConnect(toDevice: device)
}
} else {
deviceForManualConnection = device
presentingSwitchPreferredPeripheral = true
}
}
}
})
}
}.confirmationDialog("Connecting to a new radio will clear all app data on the phone.", isPresented: $presentingSwitchPreferredPeripheral, titleVisibility: .visible) {
Button("Connect to new radio?", role: .destructive) {
if let device = deviceForManualConnection {
UserDefaults.preferredPeripheralId = device.id.uuidString
UserDefaults.preferredPeripheralNum = 0
if accessoryManager.allowDisconnect {
Task { try await accessoryManager.disconnect() }
}
clearCoreDataDatabase(context: context, includeRoutes: false)
clearNotifications()
Task {
try await selectedTransport?.transport.manuallyConnect(toDevice: device)
}
// Clean up just in case
deviceForManualConnection = nil
}
}
}
}
}
struct DeviceConnectRow: View {
@Environment(\.managedObjectContext) var context
@EnvironmentObject var accessoryManager: AccessoryManager
@State var presentingSwitchPreferredPeripheral = false
var device: Device
var body: some View {
HStack {
if UserDefaults.preferredPeripheralId == device.id.uuidString {
Image(systemName: "star.fill")
.imageScale(.large).foregroundColor(.yellow)
.padding(.trailing)
} else {
Image(systemName: "circle.fill")
.imageScale(.large).foregroundColor(.gray)
.padding(.trailing)
}
VStack(alignment: .leading) {
Button(action: {
if UserDefaults.preferredPeripheralId.count > 0 && device.id.uuidString != UserDefaults.preferredPeripheralId {
if accessoryManager.allowDisconnect {
Task { try await accessoryManager.disconnect() }
}
presentingSwitchPreferredPeripheral = true
} else {
Task {
try? await accessoryManager.connect(to: device)
}
}
}) {
Text(device.name).font(.callout)
}
// Show transport type
TransportIcon(transportType: device.transportType)
}
Spacer()
VStack {
device.getSignalStrength().map { SignalStrengthIndicator(signalStrength: $0) }
}
}.padding([.bottom, .top])
.confirmationDialog("Connecting to a new radio will clear all app data on the phone.", isPresented: $presentingSwitchPreferredPeripheral, titleVisibility: .visible) {
Button("Connect to new radio?", role: .destructive) {
UserDefaults.preferredPeripheralId = device.id.uuidString
UserDefaults.preferredPeripheralNum = 0
if accessoryManager.allowDisconnect {
Task { try await accessoryManager.disconnect() }
}
clearCoreDataDatabase(context: context, includeRoutes: false)
clearNotifications()
Task {
try await accessoryManager.connect(to: device)
}
}
}
}
}