mirror of
https://github.com/meshtastic/Meshtastic-Apple.git
synced 2026-04-20 22:13:56 +00:00
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>
This commit is contained in:
parent
0c3f1bd2d6
commit
3f27e3b925
15 changed files with 372 additions and 114 deletions
|
|
@ -18921,6 +18921,13 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Last seen device:" : {
|
||||
"comment" : "A label displayed next to the last seen device text in the `DeviceConnectRow`.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Last seen device: %@" : {
|
||||
|
||||
},
|
||||
"Latitude" : {
|
||||
"localizations" : {
|
||||
|
|
@ -20441,6 +20448,9 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Manual Connections" : {
|
||||
|
||||
},
|
||||
"Map Data" : {
|
||||
"localizations" : {
|
||||
|
|
@ -20926,8 +20936,8 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"Meshtastic does not collect any personal information. We do anonymously collect usage and crash data to improve the app. You can opt out under app settings." : {
|
||||
"comment" : "A description of Meshtastic's data collection practices.",
|
||||
"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" : {
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@
|
|||
2344A2B12D68DFF800170A77 /* Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25C49D8F2C471AEA0024FBD1 /* Constants.swift */; };
|
||||
2346A7192E2FB9A300CB9239 /* SerialConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2346A7182E2FB9A300CB9239 /* SerialConnection.swift */; };
|
||||
2346A71D2E2FB9C500CB9239 /* SerialTransport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2346A71C2E2FB9C500CB9239 /* SerialTransport.swift */; };
|
||||
2349A04A2EAE4DA30060A581 /* ManualConnectionList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2349A0492EAE4DA30060A581 /* ManualConnectionList.swift */; };
|
||||
2373AE132D0A216C0086C749 /* MetricsChartSeries.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2373AE122D0A216C0086C749 /* MetricsChartSeries.swift */; };
|
||||
2373AE152D0A24930086C749 /* MetricsSeriesList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2373AE142D0A24930086C749 /* MetricsSeriesList.swift */; };
|
||||
2373AE172D0A26620086C749 /* EnvironmentDefaultSeries.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2373AE162D0A26620086C749 /* EnvironmentDefaultSeries.swift */; };
|
||||
|
|
@ -357,6 +358,7 @@
|
|||
2344A2AE2D6697A700170A77 /* TelemetryEntity+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TelemetryEntity+CoreDataProperties.swift"; sourceTree = "<group>"; };
|
||||
2346A7182E2FB9A300CB9239 /* SerialConnection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SerialConnection.swift; sourceTree = "<group>"; };
|
||||
2346A71C2E2FB9C500CB9239 /* SerialTransport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SerialTransport.swift; sourceTree = "<group>"; };
|
||||
2349A0492EAE4DA30060A581 /* ManualConnectionList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManualConnectionList.swift; sourceTree = "<group>"; };
|
||||
2373AE122D0A216C0086C749 /* MetricsChartSeries.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricsChartSeries.swift; sourceTree = "<group>"; };
|
||||
2373AE142D0A24930086C749 /* MetricsSeriesList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricsSeriesList.swift; sourceTree = "<group>"; };
|
||||
2373AE162D0A26620086C749 /* EnvironmentDefaultSeries.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnvironmentDefaultSeries.swift; sourceTree = "<group>"; };
|
||||
|
|
@ -832,6 +834,7 @@
|
|||
23D316922E5618D2002FA4FB /* AsyncGate.swift */,
|
||||
23E23F912E392C2B00919073 /* LogRecord+StringRepresentation.swift */,
|
||||
23D9D9382E50DA97005D1C18 /* ResettableTimer.swift */,
|
||||
2349A0492EAE4DA30060A581 /* ManualConnectionList.swift */,
|
||||
);
|
||||
path = Helpers;
|
||||
sourceTree = "<group>";
|
||||
|
|
@ -1841,6 +1844,7 @@
|
|||
BCDDFA9A2DBB180D0065189C /* ScrollToBottomButton.swift in Sources */,
|
||||
DDC4C9FF2A8D982900CE201C /* DetectionSensorConfig.swift in Sources */,
|
||||
2344A2AF2D6697A700170A77 /* TelemetryEntity+CoreDataClass.swift in Sources */,
|
||||
2349A04A2EAE4DA30060A581 /* ManualConnectionList.swift in Sources */,
|
||||
2344A2B02D6697A700170A77 /* TelemetryEntity+CoreDataProperties.swift in Sources */,
|
||||
D9C983A22B79D1A600BDBE6A /* RequestPositionButton.swift in Sources */,
|
||||
237AEB8F2E1FE457003B7CE3 /* Transport.swift in Sources */,
|
||||
|
|
|
|||
|
|
@ -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,15 @@ 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
|
||||
// Remember, Device is a value type (struct) so don't use use `device` here, thats
|
||||
// The value at the instantiation of the connect process. We want the currently
|
||||
// updated device object in `activeConnection` with its additonal metadata from
|
||||
// NodeInfo packets.
|
||||
if let activeDevice = self.activeConnection?.device, activeDevice.isManualConnection {
|
||||
ManualConnectionList.shared.insert(device: activeDevice)
|
||||
}
|
||||
}
|
||||
|
||||
// Step 8: Update UI and status to connected
|
||||
|
|
|
|||
|
|
@ -116,6 +116,15 @@ extension AccessoryManager {
|
|||
updateDevice(deviceId: activeDevice.id, key: \.shortName, value: user.shortName ?? "?")
|
||||
updateDevice(deviceId: activeDevice.id, key: \.longName, value: user.longName ?? "Unknown".localized)
|
||||
updateDevice(deviceId: activeDevice.id, key: \.hardwareModel, value: user.hwModel)
|
||||
|
||||
if activeDevice.isManualConnection {
|
||||
// We just received a NodeInfo for the currently connected node and this is a
|
||||
// manual connection. Update the metadata for the device entry in UserDefaults
|
||||
// with this information for better display later
|
||||
ManualConnectionList.shared.updateDevice(deviceId: activeDevice.id, key: \.shortName, value: user.shortName)
|
||||
ManualConnectionList.shared.updateDevice(deviceId: activeDevice.id, key: \.longName, value: user.longName)
|
||||
ManualConnectionList.shared.updateDevice(deviceId: activeDevice.id, key: \.hardwareModel, value: user.hwModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -302,9 +302,9 @@ class AccessoryManager: ObservableObject, MqttClientProxyManagerDelegate {
|
|||
Logger.transport.error("updateDevice<T> with nil deviceId")
|
||||
return
|
||||
}
|
||||
|
||||
// Update the active device
|
||||
if let activeConnection {
|
||||
|
||||
// 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
|
||||
|
|
@ -714,7 +714,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()
|
||||
}
|
||||
|
|
@ -722,7 +723,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()
|
||||
|
|
|
|||
61
Meshtastic/Accessory/Helpers/ManualConnectionList.swift
Normal file
61
Meshtastic/Accessory/Helpers/ManualConnectionList.swift
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
//
|
||||
// ManualConnectionList.swift
|
||||
// Meshtastic
|
||||
//
|
||||
// Created by jake on 10/26/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
// Maintains an observable list of devices that's backed by UserDefaults
|
||||
public class ManualConnectionList: ObservableObject {
|
||||
static let shared = ManualConnectionList()
|
||||
|
||||
@Published private var _list: [Device]
|
||||
|
||||
private init() {
|
||||
_list = UserDefaults.manualConnections
|
||||
}
|
||||
|
||||
var connectionsList: [Device] {
|
||||
get {
|
||||
return _list
|
||||
}
|
||||
}
|
||||
|
||||
func insert(device: Device) {
|
||||
// Don't insert if already there
|
||||
guard !_list.contains(where: {$0.id == device.id}) else {
|
||||
return
|
||||
}
|
||||
|
||||
// Add the new entry
|
||||
var list = _list
|
||||
list.append(device)
|
||||
_list = list
|
||||
UserDefaults.manualConnections = list
|
||||
}
|
||||
|
||||
func updateDevice<T>(deviceId: UUID, key: WritableKeyPath<Device, T>, value: T) where T: Equatable {
|
||||
var list = _list
|
||||
if let deviceIndex = list.firstIndex(where: {$0.id == deviceId}) {
|
||||
list[deviceIndex][keyPath: key] = value
|
||||
_list = list
|
||||
UserDefaults.manualConnections = list
|
||||
}
|
||||
}
|
||||
|
||||
func remove(device: Device) {
|
||||
var list = _list
|
||||
list.removeAll(where: {$0.id == device.id})
|
||||
_list = list
|
||||
UserDefaults.manualConnections = list
|
||||
}
|
||||
|
||||
func remove(atOffsets: IndexSet) {
|
||||
var list = _list
|
||||
list.remove(atOffsets: atOffsets)
|
||||
_list = list
|
||||
UserDefaults.manualConnections = list
|
||||
}
|
||||
}
|
||||
|
|
@ -31,7 +31,7 @@ enum ConnectionEvent {
|
|||
case disconnected(shouldReconnect: Bool)
|
||||
}
|
||||
|
||||
enum ConnectionState: Equatable {
|
||||
enum ConnectionState: Equatable, Codable {
|
||||
case disconnected
|
||||
case connecting
|
||||
case connected
|
||||
|
|
|
|||
|
|
@ -7,7 +7,8 @@
|
|||
|
||||
import Foundation
|
||||
|
||||
struct Device: Identifiable, Hashable {
|
||||
struct Device: Identifiable, Hashable, Codable, CustomStringConvertible {
|
||||
|
||||
let id: UUID
|
||||
var name: String
|
||||
var transportType: TransportType
|
||||
|
|
@ -23,10 +24,9 @@ struct Device: Identifiable, Hashable {
|
|||
|
||||
var connectionState: ConnectionState
|
||||
var wasRestored: Bool = false
|
||||
var isManualConnection: Bool = false
|
||||
|
||||
var connectionDetails: String?
|
||||
|
||||
init(id: UUID, name: String, transportType: TransportType, identifier: String, connectionState: ConnectionState = .disconnected, rssi: Int? = nil, num: Int64? = nil, connectionDetails: String? = nil, wasRestored: 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
|
||||
|
|
@ -35,7 +35,7 @@ struct Device: Identifiable, Hashable {
|
|||
self.rssi = rssi
|
||||
self.num = num
|
||||
self.wasRestored = wasRestored
|
||||
self.connectionDetails = connectionDetails
|
||||
self.isManualConnection = isManualConnection
|
||||
}
|
||||
|
||||
var rssiString: String {
|
||||
|
|
@ -57,4 +57,16 @@ struct Device: Identifiable, Hashable {
|
|||
}
|
||||
}
|
||||
|
||||
var description: String {
|
||||
switch (shortName, longName) {
|
||||
case (let shortName?, let longName?): // Both shortName and longName are non-nil
|
||||
return "\(longName) (\(shortName))"
|
||||
case (let shortName?, nil): // shortName is non-nil, longName is nil
|
||||
return "\(shortName)"
|
||||
case (nil, let longName?): // shortName is nil, longName is non-nil
|
||||
return "\(longName)"
|
||||
default: // Both are nil
|
||||
return "Device(id: \(id))"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -97,8 +97,7 @@ class TCPTransport: NSObject, Transport, NetServiceBrowserDelegate, NetServiceDe
|
|||
let device = Device(id: idString,
|
||||
name: name,
|
||||
transportType: .tcp,
|
||||
identifier: "\(host):\(port)",
|
||||
connectionDetails: "\(ip):\(port)")
|
||||
identifier: "\(host):\(port)")
|
||||
continuation?.yield(.deviceFound(device))
|
||||
}
|
||||
|
||||
|
|
@ -151,16 +150,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 (0–65535)
|
||||
return port >= 0 && port <= 65535
|
||||
}
|
||||
}
|
||||
|
||||
extension NetService {
|
||||
var ipv4Address: String? {
|
||||
for addressData in addresses ?? [] {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -26,8 +26,7 @@ struct Connect: View {
|
|||
@State var isUnsetRegion = false
|
||||
@State var invalidFirmwareVersion = false
|
||||
@State var liveActivityStarted = false
|
||||
@State var presentingSwitchPreferredPeripheral = false
|
||||
@State var selectedPeripherialId = ""
|
||||
@ObservedObject var manualConnections = ManualConnectionList.shared
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
|
|
@ -64,22 +63,10 @@ struct Connect: View {
|
|||
}
|
||||
Text("Connection Name").font(.callout)+Text(": \(connectedDevice.name.addingVariationSelectors)")
|
||||
.font(.callout).foregroundColor(Color.gray)
|
||||
HStack {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
TransportIcon(transportType: connectedDevice.transportType)
|
||||
if connectedDevice.transportType == .ble {
|
||||
// baseline aligned looks better for the signal meter
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
TransportIcon(transportType: connectedDevice.transportType)
|
||||
connectedDevice.getSignalStrength().map { SignalStrengthIndicator(signalStrength: $0, width: 5, height: 20) }
|
||||
}
|
||||
} else if connectedDevice.transportType == .tcp {
|
||||
// Not baseline aligned looks better for the connection string
|
||||
HStack {
|
||||
TransportIcon(transportType: connectedDevice.transportType)
|
||||
Text("\(connectedDevice.connectionDetails ?? connectedDevice.identifier)")
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
} else {
|
||||
TransportIcon(transportType: connectedDevice.transportType)
|
||||
connectedDevice.getSignalStrength().map { SignalStrengthIndicator(signalStrength: $0, width: 5, height: 20) }
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
|
|
@ -276,68 +263,33 @@ 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
|
||||
HStack {
|
||||
TransportIcon(transportType: device.transportType)
|
||||
if device.transportType == .tcp {
|
||||
// Show IP and Port
|
||||
Text("\(device.connectionDetails ?? device.identifier)")
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
}
|
||||
}
|
||||
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 manualConnections.connectionsList.count > 0 {
|
||||
Section(header: Text("Manual Connections").font(.title)) {
|
||||
ForEach(manualConnections.connectionsList) { device in
|
||||
DeviceConnectRow(device: device)
|
||||
#if targetEnvironment(macCatalyst)
|
||||
.contextMenu {
|
||||
Button {
|
||||
manualConnections.remove(device: device)
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}.onDelete { offsets in
|
||||
manualConnections.remove(atOffsets: offsets)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -491,6 +443,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
|
||||
|
|
@ -509,7 +465,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
|
||||
|
|
@ -539,11 +497,114 @@ 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
|
||||
let 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
|
||||
#if !targetEnvironment(macCatalyst)
|
||||
HStack(alignment: .center){
|
||||
TransportIcon(transportType: device.transportType)
|
||||
if device.isManualConnection && (device.longName != nil || device.shortName != nil) {
|
||||
VStack (alignment: .leading) {
|
||||
Text("Last seen device:")
|
||||
Text("\(String(describing: device))")
|
||||
}
|
||||
}
|
||||
}.padding(.top, 3.0)
|
||||
#else
|
||||
//Different alignment for Mac
|
||||
HStack(alignment: .firstTextBaseline){
|
||||
TransportIcon(transportType: device.transportType)
|
||||
if device.isManualConnection && (device.longName != nil || device.shortName != nil) {
|
||||
Text("Last seen device: \(String(describing: device))")
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue