diff --git a/Meshtastic.xcodeproj/project.pbxproj b/Meshtastic.xcodeproj/project.pbxproj index 5ad009b7..bb725735 100644 --- a/Meshtastic.xcodeproj/project.pbxproj +++ b/Meshtastic.xcodeproj/project.pbxproj @@ -1491,7 +1491,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.2.18; + MARKETING_VERSION = 2.2.19; PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTS_MACCATALYST = YES; @@ -1525,7 +1525,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.2.18; + MARKETING_VERSION = 2.2.19; PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTS_MACCATALYST = YES; @@ -1647,7 +1647,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 2.2.18; + MARKETING_VERSION = 2.2.19; PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient.Widgets; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1680,7 +1680,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 2.2.18; + MARKETING_VERSION = 2.2.19; PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient.Widgets; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; diff --git a/Meshtastic/Export/WriteCsvFile.swift b/Meshtastic/Export/WriteCsvFile.swift index c246fa40..bdd516a9 100644 --- a/Meshtastic/Export/WriteCsvFile.swift +++ b/Meshtastic/Export/WriteCsvFile.swift @@ -96,3 +96,27 @@ func positionToCsvFile(positions: [PositionEntity]) -> String { } return csvString } + + +func routeToCsvFile(locations: [LocationEntity]) -> String { + var csvString: String = "" + let localeDateFormat = DateFormatter.dateFormat(fromTemplate: "yyMMddjmma", options: 0, locale: Locale.current) + let dateFormatString = (localeDateFormat ?? "MM/dd/YY j:mma").replacingOccurrences(of: ",", with: "") + // Create Position Header + csvString = "Id, Latitude, Longitude, Altitude, Speed, Heading" + for loc in locations { + csvString += "\n" + csvString += String(loc.id) + csvString += ", " + csvString += String((loc.latitude ?? 0)) + csvString += ", " + csvString += String(loc.longitude ?? 0) + csvString += ", " + csvString += String(loc.altitude) + csvString += ", " + csvString += String(loc.speed) + csvString += ", " + csvString += String(loc.heading) + } + return csvString +} diff --git a/Meshtastic/Extensions/CoreData/NodeInfoEntityExtension.swift b/Meshtastic/Extensions/CoreData/NodeInfoEntityExtension.swift index 09673104..00175801 100644 --- a/Meshtastic/Extensions/CoreData/NodeInfoEntityExtension.swift +++ b/Meshtastic/Extensions/CoreData/NodeInfoEntityExtension.swift @@ -6,6 +6,7 @@ // import Foundation +import CoreData extension NodeInfoEntity { @@ -37,4 +38,22 @@ extension NodeInfoEntity { } return false } + +} + +public func createNodeInfo(num: Int64, context: NSManagedObjectContext) -> NodeInfoEntity { + + let newNode = NodeInfoEntity(context: context) + newNode.id = Int64(num) + newNode.num = Int64(num) + let newUser = UserEntity(context: context) + newUser.num = Int64(num) + let userId = String(format:"%2X", num) + newUser.userId = "!\(userId)" + let last4 = String(userId.suffix(4)) + newUser.longName = "Meshtastic \(last4)" + newUser.shortName = last4 + newUser.hwModel = "UNSET" + newNode.user = newUser + return newNode } diff --git a/Meshtastic/Helpers/BLEManager.swift b/Meshtastic/Helpers/BLEManager.swift index 0e7e4ae8..26014859 100644 --- a/Meshtastic/Helpers/BLEManager.swift +++ b/Meshtastic/Helpers/BLEManager.swift @@ -649,7 +649,10 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate var routeString = "You --> " var hopNodes: [TraceRouteHopEntity] = [] for node in routingMessage.route { - let hopNode = getNodeInfo(id: Int64(node), context: context!) + var hopNode = getNodeInfo(id: Int64(node), context: context!) + if hopNode == nil { + hopNode = createNodeInfo(num: Int64(node), context: context!) + } let traceRouteHop = TraceRouteHopEntity(context: context!) traceRouteHop.time = Date() if hopNode?.hasPositions ?? false { @@ -1107,6 +1110,28 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate return false } + public func sendEnterDfuMode(fromUser: UserEntity, toUser: UserEntity) -> Bool { + var adminPacket = AdminMessage() + adminPacket.enterDfuModeRequest = true + var meshPacket: MeshPacket = MeshPacket() + meshPacket.to = UInt32(toUser.num) + meshPacket.from = UInt32(fromUser.num) + meshPacket.id = UInt32.random(in: UInt32(UInt8.max).. Bool { var adminPacket = AdminMessage() adminPacket.factoryReset = 5 @@ -2257,6 +2282,33 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate return false } + public func requestStoreAndForwardModuleConfig(fromUser: UserEntity, toUser: UserEntity, adminIndex: Int32) -> Bool { + + var adminPacket = AdminMessage() + adminPacket.getModuleConfigRequest = AdminMessage.ModuleConfigType.storeforwardConfig + + var meshPacket: MeshPacket = MeshPacket() + meshPacket.to = UInt32(toUser.num) + meshPacket.from = UInt32(fromUser.num) + meshPacket.id = UInt32.random(in: UInt32(UInt8.max).. Bool { var adminPacket = AdminMessage() diff --git a/Meshtastic/Helpers/MeshPackets.swift b/Meshtastic/Helpers/MeshPackets.swift index 83cb25db..3fab145b 100644 --- a/Meshtastic/Helpers/MeshPackets.swift +++ b/Meshtastic/Helpers/MeshPackets.swift @@ -192,29 +192,31 @@ func deviceMetadataPacket (metadata: DeviceMetadata, fromNum: Int64, context: NS guard let fetchedNode = try context.fetch(fetchedNodeRequest) as? [NodeInfoEntity] else { return } + let newMetadata = DeviceMetadataEntity(context: context) + newMetadata.deviceStateVersion = Int32(metadata.deviceStateVersion) + newMetadata.canShutdown = metadata.canShutdown + newMetadata.hasWifi = metadata.hasWifi_p + newMetadata.hasBluetooth = metadata.hasBluetooth_p + newMetadata.hasEthernet = metadata.hasEthernet_p + newMetadata.role = Int32(metadata.role.rawValue) + newMetadata.positionFlags = Int32(metadata.positionFlags) + // Swift does strings weird, this does work to get the version without the github hash + let lastDotIndex = metadata.firmwareVersion.lastIndex(of: ".") + var version = metadata.firmwareVersion[...(lastDotIndex ?? String.Index(utf16Offset: 6, in: metadata.firmwareVersion))] + version = version.dropLast() + newMetadata.firmwareVersion = String(version) if fetchedNode.count > 0 { - let newMetadata = DeviceMetadataEntity(context: context) - newMetadata.deviceStateVersion = Int32(metadata.deviceStateVersion) - newMetadata.canShutdown = metadata.canShutdown - newMetadata.hasWifi = metadata.hasWifi_p - newMetadata.hasBluetooth = metadata.hasBluetooth_p - newMetadata.hasEthernet = metadata.hasEthernet_p - newMetadata.role = Int32(metadata.role.rawValue) - newMetadata.positionFlags = Int32(metadata.positionFlags) - // Swift does strings weird, this does work to get the version without the github hash - let lastDotIndex = metadata.firmwareVersion.lastIndex(of: ".") - var version = metadata.firmwareVersion[...(lastDotIndex ?? String.Index(utf16Offset: 6, in: metadata.firmwareVersion))] - version = version.dropLast() - newMetadata.firmwareVersion = String(version) fetchedNode[0].metadata = newMetadata - - do { - try context.save() - } catch { - print("Failed to save device metadata") - } - print("💾 Updated Device Metadata from Admin App Packet For: \(fromNum)") + } else { + let newNode = createNodeInfo(num: Int64(fromNum), context: context) + newNode.metadata = newMetadata } + do { + try context.save() + } catch { + print("Failed to save device metadata") + } + print("💾 Updated Device Metadata from Admin App Packet For: \(fromNum)") } catch { context.rollback() let nsError = error as NSError diff --git a/Meshtastic/Views/Nodes/DeviceMetricsLog.swift b/Meshtastic/Views/Nodes/DeviceMetricsLog.swift index 1dab8c04..b3d338e0 100644 --- a/Meshtastic/Views/Nodes/DeviceMetricsLog.swift +++ b/Meshtastic/Views/Nodes/DeviceMetricsLog.swift @@ -33,9 +33,7 @@ struct DeviceMetricsLog: View { GroupBox(label: Label("\(deviceMetrics.count) Readings Total", systemImage: "chart.xyaxis.line")) { Chart { - ForEach(chartData, id: \.self) { point in - Plot { LineMark( x: .value("x", point.time!), @@ -45,7 +43,7 @@ struct DeviceMetricsLog: View { .accessibilityLabel("Line Series") .accessibilityValue("X: \(point.time!), Y: \(point.batteryLevel)") .foregroundStyle(batteryChartColor) - .interpolationMethod(.catmullRom(alpha: 1.0)) + .interpolationMethod(.cardinal) Plot { PointMark( diff --git a/Meshtastic/Views/Settings/About.swift b/Meshtastic/Views/Settings/About.swift index 9bcb5b72..be1b4dc2 100644 --- a/Meshtastic/Views/Settings/About.swift +++ b/Meshtastic/Views/Settings/About.swift @@ -52,10 +52,6 @@ struct AboutMeshtastic: View { .font(.title2) Text("Version: \(Bundle.main.appVersionLong) (\(Bundle.main.appBuild)) ") - - Text(Bundle.main.copyright) - .font(.system(size: 10, weight: .thin)) - .multilineTextAlignment(.center) } Section(header: Text("Project information")) { diff --git a/Meshtastic/Views/Settings/Channels.swift b/Meshtastic/Views/Settings/Channels.swift index 15b4a508..39d69d8e 100644 --- a/Meshtastic/Views/Settings/Channels.swift +++ b/Meshtastic/Views/Settings/Channels.swift @@ -37,7 +37,7 @@ struct Channels: View { var body: some View { - NavigationStack { + VStack { List { if node != nil && node?.myInfo != nil { ForEach(node?.myInfo?.channels?.array as? [ChannelEntity] ?? [], id: \.self) { (channel: ChannelEntity) in @@ -167,16 +167,17 @@ struct Channels: View { HStack(alignment: .top) { Text("Key") Spacer() - TextField( - "", - text: $channelKey, - axis: .vertical - ) - .foregroundColor(Color.gray) - .disabled(true) - + Text(channelKey) + .foregroundColor(Color.gray) + .textSelection(.enabled) +// TextField( +// "", +// text: $channelKey, +// axis: .vertical +// ) +// .foregroundColor(Color.gray) +// .disabled(true) } - .textSelection(.enabled) Picker("Channel Role", selection: $channelRole) { if channelRole == 1 { Text("Primary").tag(1) @@ -192,9 +193,6 @@ struct Channels: View { Toggle("Downlink Enabled", isOn: $downlink) .toggleStyle(SwitchToggleStyle(tint: .accentColor)) } - // .onSubmit { - // validate(name: channelName) - // } .onChange(of: channelName) { _ in hasChanges = true } diff --git a/Meshtastic/Views/Settings/Config/Module/StoreForward.swift b/Meshtastic/Views/Settings/Config/Module/StoreForward.swift index 7241442f..483ff4cc 100644 --- a/Meshtastic/Views/Settings/Config/Module/StoreForward.swift +++ b/Meshtastic/Views/Settings/Config/Module/StoreForward.swift @@ -44,7 +44,7 @@ struct StoreForwardConfig: View { Text("Remote administration for: \(node?.user?.longName ?? "Unknown")") .font(.title3) .onAppear { - setDetectionSensorValues() + setStoreAndForwardValues() } } } else if node != nil && node?.num ?? 0 == bleManager.connectedPeripheral?.num ?? 0 { @@ -140,13 +140,13 @@ struct StoreForwardConfig: View { if self.bleManager.context == nil { self.bleManager.context = context } - setDetectionSensorValues() + setStoreAndForwardValues() // Need to request a Detection Sensor Module Config from the remote node before allowing changes - if bleManager.connectedPeripheral != nil && node?.detectionSensorConfig == nil { + if bleManager.connectedPeripheral != nil && node?.storeForwardConfig == nil { print("empty store and forward module config") let connectedNode = getNodeInfo(id: bleManager.connectedPeripheral.num, context: context) if node != nil && connectedNode != nil { - _ = bleManager.requestDetectionSensorModuleConfig(fromUser: connectedNode!.user!, toUser: node!.user!, adminIndex: connectedNode?.myInfo?.adminIndex ?? 0) + _ = bleManager.requestStoreAndForwardModuleConfig(fromUser: connectedNode!.user!, toUser: node!.user!, adminIndex: connectedNode?.myInfo?.adminIndex ?? 0) } } } @@ -176,7 +176,7 @@ struct StoreForwardConfig: View { } } } - func setDetectionSensorValues() { + func setStoreAndForwardValues() { self.enabled = (node?.storeForwardConfig?.enabled ?? false) self.heartbeat = (node?.storeForwardConfig?.heartbeat ?? true) self.records = Int(node?.storeForwardConfig?.records ?? 50) diff --git a/Meshtastic/Views/Settings/Firmware.swift b/Meshtastic/Views/Settings/Firmware.swift index 8878b0d9..c0141523 100644 --- a/Meshtastic/Views/Settings/Firmware.swift +++ b/Meshtastic/Views/Settings/Firmware.swift @@ -12,38 +12,131 @@ struct Firmware: View { @Environment(\.managedObjectContext) var context @EnvironmentObject var bleManager: BLEManager var node: NodeInfoEntity? - @State var minimumVersion = "2.2.16" + @State var minimumVersion = "2.2.17" @State var version = "" @State private var currentDevice: DeviceHardware? - + @State private var latestStable: FirmwareRelease? + @State private var latestAlpha: FirmwareRelease? + var body: some View { - VStack { + + let supportedVersion = bleManager.connectedVersion == "0.0.0" || self.minimumVersion.compare(bleManager.connectedVersion, options: .numeric) == .orderedAscending || minimumVersion.compare(bleManager.connectedVersion, options: .numeric) == .orderedSame + ScrollView { VStack(alignment: .leading) { let deviceString = currentDevice?.hwModelSlug.replacingOccurrences(of: "_", with: "") - - Text("Your Device Model: \(currentDevice?.displayName ?? "Unknown")") - .font(.largeTitle) + HStack { + VStack { + Image(systemName: currentDevice?.activelySupported ?? false ? "checkmark.seal.fill" : "x.circle") + .font(.largeTitle) + .foregroundStyle(currentDevice?.activelySupported ?? false ? .green : .red) + Text( currentDevice?.activelySupported ?? false ? "Supported" : "Unsupported") + .foregroundStyle(.gray) + .font(.caption2) + } + Text("Device Model: \(currentDevice?.displayName ?? "Unknown")") + .font(.largeTitle) + .fixedSize(horizontal: false, vertical: true) + } VStack { Image(deviceString ?? "UNSET") .resizable() .aspectRatio(contentMode: .fit) - .frame(width: 200, height: 200) + .frame(width: 300, height: 300) .cornerRadius(5) } - Text("Current Firmware Version: \(bleManager.connectedVersion)") - .font(.title) - + + if supportedVersion { + Text("Your Firmware is up to date") + .fixedSize(horizontal: false, vertical: true) + .foregroundStyle(.green) + .font(.title2) + .padding(.bottom) + Text("Current Firmware Version: \(bleManager.connectedVersion)") + .fixedSize(horizontal: false, vertical: true) + .font(.title3) + .padding(.bottom) + } else { + Text("Your Firmware is out of date") + .fixedSize(horizontal: false, vertical: true) + .foregroundStyle(.red) + .font(.title2) + .padding(.bottom) + Text("Current Firmware Version: \(bleManager.connectedVersion), Minimium Firmware Version: \(minimumVersion)") + .fixedSize(horizontal: false, vertical: true) + .font(.title3) + .padding(.bottom) + } + Divider() + Text("How to update Firmware") + .fixedSize(horizontal: false, vertical: true) + .font(.title2) + .padding(.bottom) + + Text("Get the latest stable firmware") + .fixedSize(horizontal: false, vertical: true) + .font(.callout) + Link("\(latestStable?.title ?? "unknown".localized)", destination: URL(string: "\(latestStable?.zipURL ?? "https://meshtastic.org")")!) + .font(.caption) + Link("Release Notes", destination: URL(string: "\(latestStable?.pageURL ?? "https://meshtastic.org")")!) + .font(.caption) + .padding(.bottom) + + Text("Get the latest alpha firmware") + .fixedSize(horizontal: false, vertical: true) + .font(.callout) + Link("\(latestAlpha?.title ?? "unknown".localized)", destination: URL(string: "\(latestAlpha?.zipURL ?? "https://meshtastic.org")")!) + .font(.caption) + Link("Release Notes", destination: URL(string: "\(latestAlpha?.pageURL ?? "https://meshtastic.org")")!) + .font(.caption) + .padding(.bottom) + if currentDevice?.architecture == Meshtastic.Architecture.nrf52840 { VStack(alignment: .leading) { + + Text("Drag & Drop is the reccomended way to update firmware for NRF devices. If your iPhone or iPad is USB-C it will work with your regular USB-C charging cable, for lightning devices you need the Apple Lightning to USB camera adaptor.") + .fixedSize(horizontal: false, vertical: true) + .foregroundStyle(.gray) + .font(.caption) + Link("Drag & Drop Firmware Update Documentation", destination: URL(string: "https://meshtastic.org/docs/getting-started/flashing-firmware/nrf52/drag-n-drop")!) + .font(.caption) + .padding(.bottom) + VStack { + Text("If it is hard to access your device's reset button enter DFU mode here.") + .fixedSize(horizontal: false, vertical: true) + .foregroundStyle(.gray) + .font(.caption) + Button { + let connectedNode = getNodeInfo(id: bleManager.connectedPeripheral?.num ?? 0, context: context) + if connectedNode != nil { + + if bleManager.sendEnterDfuMode(fromUser: connectedNode!.user!, toUser: node!.user!) { + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + bleManager.automaticallyReconnect = false + bleManager.disconnectPeripheral() + } + } else { + print("Enter DFU Failed") + } + } + } label: { + Label("Enter DFU Mode", systemImage: "square.and.arrow.down") + } + .buttonStyle(.bordered) + .buttonBorderShape(.capsule) + .controlSize(.regular) + .padding(5) + } + Spacer() /// RAK 4631 if currentDevice?.hwModel == 9 { - Text("nRF OTA Device Firmware Update App") - .font(.title3) - Text("You can update your Meshtastic device over bluetooth using the Nordic DFU app. This currently works for RAK NRF devices.") + Text("You can also update your Meshtastic device over bluetooth using the Nordic DFU app.") + .fixedSize(horizontal: false, vertical: true) + .foregroundStyle(.gray) .font(.caption) Link("Get NRF DFU from the App Store", destination: URL(string: "https://apps.apple.com/us/app/nrf-device-firmware-update/id1624454660")!) .font(.callout) + .padding(.bottom) } else { Text("OTA Updates are not supported on the this NRF Device.") .font(.title3) @@ -55,7 +148,7 @@ struct Firmware: View { VStack(alignment: .leading) { Text("ESP32 Device Firmware Update") .font(.title3) - Text("Currently the reccomended way to update ESP32 devices is using the web flasher from a chrome based browser. It does not work on mobile devices or over BLE.") + Text("Currently the reccomended way to update ESP32 devices is using the web flasher on a desktop computer from a chrome based browser. It does not work on mobile devices or over BLE.") .font(.caption) Link("Web Flasher", destination: URL(string: "https://flash.meshtastic.org")!) .font(.callout) @@ -86,170 +179,29 @@ struct Firmware: View { .font(.title3) Text(node?.user?.hwModel ?? "UNSET") .font(.title3) - // Text(hwModel.platform().description) - // .font(.title3) + Text ( currentDevice?.architecture.rawValue ?? "UNKNOWN") + .font(.title3) } } - .padding() - VStack(alignment: .leading) { - Text("Firmware Releases") - .font(.title3) - .padding([.leading, .trailing]) -// List { -// Section(header: Text("Stable")) { -// ForEach(firmwareReleaseData.releases?.stable ?? [], id: \.id) { fr in -// Link(destination: URL(string: fr.zipUrl ?? "")!) { -// HStack { -// Text(fr.title ?? "Unknown") -// .font(.caption) -// Spacer() -// Image(systemName: "square.and.arrow.down") -// .font(.title3) -// } -// } -// } -// } -// Section("Alpha") { -// ForEach(firmwareReleaseData.releases?.alpha ?? [], id: \.id) { fr in -// Link(destination: URL(string: fr.zipUrl ?? "")!) { -// HStack { -// Text(fr.title ?? "Unknown") -// .font(.caption) -// Spacer() -// Image(systemName: "square.and.arrow.down") -// .font(.title3) -// } -// } -// } -// } -// Section("Pull Requests") { -// ForEach(firmwareReleaseData.pullRequests ?? [], id: \.id) { fr in -// Link(destination: URL(string: fr.zipUrl ?? "")!) { -// HStack { -// Text(fr.title ?? "Unknown") -// .font(.caption) -// Spacer() -// Image(systemName: "square.and.arrow.down") -// .font(.title3) -// } -// } -// } -// } -// } - } + .padding() .padding(.bottom, 5) .onAppear() { Api().loadDeviceHardwareData { (hw) in for device in hw { - print(device) let currentHardware = node?.user?.hwModel ?? "UNSET" let deviceString = device.hwModelSlug.replacingOccurrences(of: "_", with: "") if deviceString == currentHardware { - print("Selected: \(device)") currentDevice = device } } } -// Api().loadFirmwareReleaseData { (bks) in -// //sel = bks -// } + Api().loadFirmwareReleaseData { (fw) in + latestStable = fw.releases.stable.first + latestAlpha = fw.releases.alpha.first + } } .navigationTitle("Firmware Updates") .navigationBarTitleDisplayMode(.inline) } } } - -//struct FirmwareRelease: Codable { -// var releases: Releases? = Releases() -// var pullRequests: [PullRequests]? = [] -// enum CodingKeys: String, CodingKey { -// case releases = "Releases" -// case pullRequests = "Pull Requests" -// } -// init(from decoder: Decoder) throws { -// let values = try decoder.container(keyedBy: CodingKeys.self) -// releases = try values.decodeIfPresent(Releases.self, forKey: .releases ) -// pullRequests = try values.decodeIfPresent([PullRequests].self, forKey: .pullRequests ) -// } -// init() { -// } -//} -// -//struct Releases: Codable { -// var stable: [Stable]? = [] -// var alpha: [Alpha]? = [] -// enum CodingKeys: String, CodingKey { -// case stable = "Stable" -// case alpha = "Alpha" -// } -// init(from decoder: Decoder) throws { -// let values = try decoder.container(keyedBy: CodingKeys.self) -// stable = try values.decodeIfPresent([Stable].self, forKey: .stable ) -// alpha = try values.decodeIfPresent([Alpha].self, forKey: .alpha ) -// } -// init() {} -//} -// -//struct Alpha: Codable { -// var id: String? -// var title: String? -// var pageUrl: String? -// var zipUrl: String? -// enum CodingKeys: String, CodingKey { -// case id = "id" -// case title = "title" -// case pageUrl = "page_url" -// case zipUrl = "zip_url" -// } -// init(from decoder: Decoder) throws { -// let values = try decoder.container(keyedBy: CodingKeys.self) -// id = try values.decodeIfPresent(String.self, forKey: .id ) -// title = try values.decodeIfPresent(String.self, forKey: .title ) -// pageUrl = try values.decodeIfPresent(String.self, forKey: .pageUrl ) -// zipUrl = try values.decodeIfPresent(String.self, forKey: .zipUrl ) -// } -// init() {} -//} -// -//struct Stable: Codable { -// var id: String? -// var title: String? -// var pageUrl: String? -// var zipUrl: String? -// enum CodingKeys: String, CodingKey { -// case id = "id" -// case title = "title" -// case pageUrl = "page_url" -// case zipUrl = "zip_url" -// } -// init(from decoder: Decoder) throws { -// let values = try decoder.container(keyedBy: CodingKeys.self) -// id = try values.decodeIfPresent(String.self, forKey: .id ) -// title = try values.decodeIfPresent(String.self, forKey: .title ) -// pageUrl = try values.decodeIfPresent(String.self, forKey: .pageUrl ) -// zipUrl = try values.decodeIfPresent(String.self, forKey: .zipUrl ) -// } -// init() {} -//} -// -//struct PullRequests: Codable { -// var id: String? -// var title: String? -// var pageUrl: String? -// var zipUrl: String? -// enum CodingKeys: String, CodingKey { -// case id = "id" -// case title = "title" -// case pageUrl = "page_url" -// case zipUrl = "zip_url" -// } -// init(from decoder: Decoder) throws { -// let values = try decoder.container(keyedBy: CodingKeys.self) -// id = try values.decodeIfPresent(String.self, forKey: .id ) -// title = try values.decodeIfPresent(String.self, forKey: .title ) -// pageUrl = try values.decodeIfPresent(String.self, forKey: .pageUrl ) -// zipUrl = try values.decodeIfPresent(String.self, forKey: .zipUrl ) -// } -// init() {} -//} diff --git a/Meshtastic/Views/Settings/FirmwareApi.swift b/Meshtastic/Views/Settings/FirmwareApi.swift index 5eb2f488..f020e514 100644 --- a/Meshtastic/Views/Settings/FirmwareApi.swift +++ b/Meshtastic/Views/Settings/FirmwareApi.swift @@ -7,14 +7,7 @@ import Foundation -//struct DeviceHardware: Codable { -// var hwModel: Int -// var hwModelSlug: String -// var platformioTarget: String -// var activelySupported: Bool -// var displayName: String -//} - +/// Device Hardware API struct DeviceHardware: Codable { let hwModel: Int let hwModelSlug, platformioTarget: String @@ -22,7 +15,6 @@ struct DeviceHardware: Codable { let activelySupported: Bool let displayName: String } - enum Architecture: String, Codable { case esp32 = "esp32" case esp32C3 = "esp32-c3" @@ -31,9 +23,28 @@ enum Architecture: String, Codable { case rp2040 = "rp2040" } +/// Firmware Release Lists +struct FirmwareReleases: Codable { + let releases: Releases + let pullRequests: [FirmwareRelease] +} +struct Releases: Codable { + let stable, alpha: [FirmwareRelease] +} +struct FirmwareRelease: Codable { + let id, title: String + let pageURL: String + let zipURL: String + + enum CodingKeys: String, CodingKey { + case id, title + case pageURL = "page_url" + case zipURL = "zip_url" + } +} + class Api : ObservableObject{ -// @Published var devices = [DeviceHardware]() - + func loadDeviceHardwareData(completion:@escaping ([DeviceHardware]) -> ()) { guard let url = URL(string: "https://api.meshtastic.org/resource/deviceHardware") else { print("Invalid url...") @@ -41,23 +52,22 @@ class Api : ObservableObject{ } URLSession.shared.dataTask(with: url) { data, response, error in let deviceHardware = try! JSONDecoder().decode([DeviceHardware].self, from: data!) - //print(deviceHardware) DispatchQueue.main.async { completion(deviceHardware) } }.resume() } -// func loadFirmwareReleaseData(completion:@escaping ([FirmwareRelease]) -> ()) { -// guard let url = URL(string: "https://api.meshtastic.org/github/firmware/list") else { -// print("Invalid url...") -// return -// } -// URLSession.shared.dataTask(with: url) { data, response, error in -// let deviceHardware = try! JSONDecoder().decode([FirmwareRelease].self, from: data!) -// print(deviceHardware) -// DispatchQueue.main.async { -// completion(deviceHardware) -// } -// }.resume() -// } + + func loadFirmwareReleaseData(completion:@escaping (FirmwareReleases) -> ()) { + guard let url = URL(string: "https://api.meshtastic.org/github/firmware/list") else { + print("Invalid url...") + return + } + URLSession.shared.dataTask(with: url) { data, response, error in + let firmwareReleases = try! JSONDecoder().decode(FirmwareReleases.self, from: data!) + DispatchQueue.main.async { + completion(firmwareReleases) + } + }.resume() + } } diff --git a/Meshtastic/Views/Settings/RouteRecorder.swift b/Meshtastic/Views/Settings/RouteRecorder.swift index 086cb6e5..342d7aca 100644 --- a/Meshtastic/Views/Settings/RouteRecorder.swift +++ b/Meshtastic/Views/Settings/RouteRecorder.swift @@ -1,289 +1,289 @@ +//// +//// Routes.swift +//// Meshtastic +//// +//// Created by Garth Vander Houwen on 11/21/23. +//// // -// Routes.swift -// Meshtastic +//import SwiftUI +//import CoreData +//import MapKit +//import CoreLocation +//import CoreMotion // -// Created by Garth Vander Houwen on 11/21/23. +//@available(iOS 17.0, macOS 14.0, *) +//struct RouteRecorder: View { +// +// @ObservedObject var locationsHandler: LocationsHandler = LocationsHandler.shared +// @Environment(\.managedObjectContext) var context +// @State private var position: MapCameraPosition = .userLocation(followsHeading: true, fallback: .automatic) +// //@State var mapStyle: MapStyle = MapStyle.hybrid(elevation: .realistic, pointsOfInterest: .all, showsTraffic: true) +// @State var mapStyle: MapStyle = MapStyle.standard(elevation: .realistic) +// @State var isShowingDetails = false +// @Namespace var namespace +// @Namespace var routerecorderscope +// @State var recording: RouteEntity? +// @State var color: Color = .blue +// +// var body: some View { +// VStack { +// ZStack { +// Map(position: $position, scope: routerecorderscope) { +// UserAnnotation() +// /// Route Lines +// let lineCoords = locationsHandler.locationsArray.compactMap({(position) -> CLLocationCoordinate2D in +// return position.coordinate +// }) +// +// let gradient = LinearGradient( +// colors: [color], +// startPoint: .leading, endPoint: .trailing +// ) +// let dashed = StrokeStyle( +// lineWidth: 3, +// lineCap: .round, lineJoin: .round, dash: [10, 10] +// ) +// MapPolyline(coordinates: lineCoords) +// .stroke(gradient, style: dashed) // - -import SwiftUI -import CoreData -import MapKit -import CoreLocation -import CoreMotion - -@available(iOS 17.0, macOS 14.0, *) -struct RouteRecorder: View { - - @ObservedObject var locationsHandler: LocationsHandler = LocationsHandler.shared - @Environment(\.managedObjectContext) var context - @State private var position: MapCameraPosition = .userLocation(followsHeading: true, fallback: .automatic) - //@State var mapStyle: MapStyle = MapStyle.hybrid(elevation: .realistic, pointsOfInterest: .all, showsTraffic: true) - @State var mapStyle: MapStyle = MapStyle.standard(elevation: .realistic) - @State var isShowingDetails = false - @Namespace var namespace - @Namespace var routerecorderscope - @State var recording: RouteEntity? - @State var color: Color = .blue - - var body: some View { - VStack { - ZStack { - Map(position: $position, scope: routerecorderscope) { - UserAnnotation() - /// Route Lines - let lineCoords = locationsHandler.locationsArray.compactMap({(position) -> CLLocationCoordinate2D in - return position.coordinate - }) - - let gradient = LinearGradient( - colors: [color], - startPoint: .leading, endPoint: .trailing - ) - let dashed = StrokeStyle( - lineWidth: 3, - lineCap: .round, lineJoin: .round, dash: [10, 10] - ) - MapPolyline(coordinates: lineCoords) - .stroke(gradient, style: dashed) - - } - .mapStyle(mapStyle) - } - .ignoresSafeArea(.all, edges: [.top, .leading, .trailing]) - .mapScope(routerecorderscope) - .safeAreaInset(edge: .bottom) { - ZStack { - VStack { - HStack(spacing: 10) { - Spacer() - - Button { - isShowingDetails = true - } label: { - Image(systemName: locationsHandler.isRecording ? "record.circle.fill" : "record.circle") - .font(.system(size: 72)) - .symbolRenderingMode(.multicolor) - .foregroundColor(.red) - } - .buttonStyle(.bordered) - .foregroundColor(.red) - .buttonBorderShape(.circle) - .matchedGeometryEffect(id: "Details Button", in: namespace) - - Spacer() - } - } - } - .padding() - } - .sheet(isPresented: $isShowingDetails) { - NavigationStack { - VStack { - if locationsHandler.isRecording { - HStack (alignment: .center) { - Image(systemName: "record.circle.fill") - .symbolRenderingMode(.multicolor) - .font(.title) - .foregroundColor(.red) - Text("Recording route") - .font(.title) - Spacer() - Text("\(locationsHandler.count)") - .foregroundColor(.red) - .font(.title2) - } - .padding() - } else if locationsHandler.isRecordingPaused { - HStack (alignment: .center) { - - Image(systemName: "playpause") - .symbolRenderingMode(.multicolor) - .font(.title3) - .foregroundColor(.red) - Text("Route recording paused") - .font(.title) - } - .padding(.top) - } - - if locationsHandler.isRecording || locationsHandler.isRecordingPaused { - Divider() - HStack { - VStack { - Text(locationsHandler.recordingStarted ?? Date(), style: .timer) - .font(.title) - .fixedSize() - Text("Time") - .font(.callout) - .fixedSize() - } - .padding(.horizontal) - Divider() - VStack { - let distance = Measurement(value: locationsHandler.distanceTraveled, unit: UnitLength.meters) - Text("\(distance.formatted())") - .font(.title) - .fixedSize() - Text("Distance") - .font(.callout) - .fixedSize() - } - .padding(.horizontal) - Divider() - VStack { - let gain = Measurement(value: locationsHandler.elevationGain, unit: UnitLength.meters) - Text(gain.formatted()) - .font(.title) - Text("Elev. Gain") - .font(.callout) - } - .padding(.horizontal) - } - .frame(maxHeight: 90) - } - Divider() - VStack(alignment: .leading) { - List { - GPSStatus(largeFont: .body, smallFont: .callout) - } - .listStyle(.plain) - HStack { - Spacer() - if !locationsHandler.isRecording && !locationsHandler.isRecordingPaused { - /// We are not recording or paused, show start recording button - Button { - locationsHandler.isRecording = true - locationsHandler.count = 0 - locationsHandler.distanceTraveled = 0.0 - locationsHandler.elevationGain = 0.0 - locationsHandler.locationsArray.removeAll() - locationsHandler.recordingStarted = Date() - let newRoute = RouteEntity(context: context) - newRoute.name = String("Route Recording") - newRoute.id = Int32.random(in: Int32(Int8.max) ... Int32.max) - newRoute.color = Int64(UIColor.random.hex) - newRoute.date = Date() - newRoute.enabled = false - color = Color(UIColor(hex: UInt32(newRoute.color))) - self.recording = newRoute - do { - try context.save() - print("💾 Saved a new route") - } catch { - context.rollback() - let nsError = error as NSError - print("💥 Error Saving RouteEntity from the Route Recorder \(nsError)") - } - } label: { - Label("start", systemImage: "play") - } - .buttonStyle(.bordered) - .buttonBorderShape(.capsule) - .controlSize(.large) - .padding(.bottom) - - } else if locationsHandler.isRecording { - /// We are recording show pause button - Button { - locationsHandler.isRecording = false - locationsHandler.isRecordingPaused = true - } label: { - Label("pause", systemImage: "pause") - } - .buttonStyle(.bordered) - .buttonBorderShape(.capsule) - .controlSize(.large) - .padding(.bottom) - } else if locationsHandler.isRecordingPaused { - /// We are paused show resume button - Button { - locationsHandler.isRecording = true - locationsHandler.isRecordingPaused = false - } label: { - Label("resume", systemImage: "playpause") - } - .buttonStyle(.bordered) - .buttonBorderShape(.capsule) - .controlSize(.large) - .padding(.bottom) - } - - if locationsHandler.isRecording || locationsHandler.isRecordingPaused { - /// We are recording or paused, show finish button - Button { - locationsHandler.isRecording = false - locationsHandler.isRecordingPaused = false - locationsHandler.distanceTraveled = 0.0 - locationsHandler.elevationGain = 0.0 - locationsHandler.locationsArray.removeAll() - locationsHandler.recordingStarted = nil - if let rec = recording { - rec.enabled = true - context.refresh(rec, mergeChanges:true) - } - - do { - try context.save() - print("💾 Saved a route finish") - } catch { - context.rollback() - let nsError = error as NSError - print("💥 Error Saving RouteEntity from the Route Recorder \(nsError)") - } - } label: { - Label("finish", systemImage: "flag.checkered") - } - .buttonStyle(.bordered) - .buttonBorderShape(.capsule) - .controlSize(.large) - .padding(.bottom) - } -#if targetEnvironment(macCatalyst) - Button(role: .cancel) { - isShowingDetails = false - } label: { - Label("close", systemImage: "xmark") - } - .buttonStyle(.bordered) - .buttonBorderShape(.capsule) - .controlSize(.large) - .padding(.bottom) -#endif - Spacer() - } - - } - } - } - .presentationDetents([.fraction(0.30), .fraction(0.65)]) - .presentationDragIndicator(.hidden) - .interactiveDismissDisabled(false) - .onChange(of: locationsHandler.locationsArray.last) { newLoc in - if locationsHandler.isRecording { - if let loc = newLoc { - if recording != nil { - let locationEntity = LocationEntity(context: context) - locationEntity.routeLocation = recording - locationEntity.id = Int32(locationsHandler.count) - locationEntity.altitude = Int32(loc.altitude) - locationEntity.heading = Int32(loc.course) - locationEntity.speed = Int32(loc.speed) - locationEntity.latitudeI = Int32(loc.coordinate.latitude * 1e7) - locationEntity.longitudeI = Int32(loc.coordinate.longitude * 1e7) - do { - try context.save() - print("💾 Saved a new route location") - //print("💾 Updated Canned Messages Messages For: \(fetchedNode[0].num)") - } catch { - context.rollback() - let nsError = error as NSError - print("💥 Error Saving LocationEntity from the Route Recorder \(nsError)") - } - } - } - } - } - } - } - } -} +// } +// .mapStyle(mapStyle) +// } +// .mapScope(routerecorderscope) +// .safeAreaInset(edge: .bottom) { +// ZStack { +// VStack { +// HStack(spacing: 10) { +// Spacer() +// +// Button { +// isShowingDetails = true +// } label: { +// Image(systemName: locationsHandler.isRecording ? "record.circle.fill" : "record.circle") +// .font(.system(size: 72)) +// .symbolRenderingMode(.multicolor) +// .foregroundColor(.red) +// } +// .buttonStyle(.bordered) +// .foregroundColor(.red) +// .buttonBorderShape(.circle) +// .matchedGeometryEffect(id: "Details Button", in: namespace) +// +// Spacer() +// } +// } +// } +// .padding() +// } +// .sheet(isPresented: $isShowingDetails) { +// NavigationStack { +// VStack { +// if locationsHandler.isRecording { +// HStack (alignment: .center) { +// Image(systemName: "record.circle.fill") +// .symbolRenderingMode(.multicolor) +// .font(.title) +// .foregroundColor(.red) +// Text("Recording route") +// .font(.title) +// Spacer() +// Text("\(locationsHandler.count)") +// .foregroundColor(.red) +// .font(.title2) +// } +// .padding() +// } else if locationsHandler.isRecordingPaused { +// HStack (alignment: .center) { +// +// Image(systemName: "playpause") +// .symbolRenderingMode(.multicolor) +// .font(.title3) +// .foregroundColor(.red) +// Text("Route recording paused") +// .font(.title) +// } +// .padding(.top) +// } +// +// if locationsHandler.isRecording || locationsHandler.isRecordingPaused { +// Divider() +// HStack { +// VStack { +// Text(locationsHandler.recordingStarted ?? Date(), style: .timer) +// .font(.title) +// .fixedSize() +// Text("Time") +// .font(.callout) +// .fixedSize() +// } +// .padding(.horizontal) +// Divider() +// VStack { +// let distance = Measurement(value: locationsHandler.distanceTraveled, unit: UnitLength.meters) +// Text("\(distance.formatted())") +// .font(.title) +// .fixedSize() +// Text("Distance") +// .font(.callout) +// .fixedSize() +// } +// .padding(.horizontal) +// Divider() +// VStack { +// let gain = Measurement(value: locationsHandler.elevationGain, unit: UnitLength.meters) +// Text(gain.formatted()) +// .font(.title) +// Text("Elev. Gain") +// .font(.callout) +// } +// .padding(.horizontal) +// } +// .frame(maxHeight: 90) +// } +// Divider() +// VStack(alignment: .leading) { +// List { +// GPSStatus(largeFont: .body, smallFont: .callout) +// } +// .listStyle(.plain) +// HStack { +// Spacer() +// if !locationsHandler.isRecording && !locationsHandler.isRecordingPaused { +// /// We are not recording or paused, show start recording button +// Button { +// locationsHandler.isRecording = true +// locationsHandler.count = 0 +// locationsHandler.distanceTraveled = 0.0 +// locationsHandler.elevationGain = 0.0 +// locationsHandler.locationsArray.removeAll() +// locationsHandler.recordingStarted = Date() +// let newRoute = RouteEntity(context: context) +// newRoute.name = String("Route Recording") +// newRoute.id = Int32.random(in: Int32(Int8.max) ... Int32.max) +// newRoute.color = Int64(UIColor.random.hex) +// newRoute.date = Date() +// newRoute.enabled = false +// color = Color(UIColor(hex: UInt32(newRoute.color))) +// self.recording = newRoute +// do { +// try context.save() +// print("💾 Saved a new route") +// } catch { +// context.rollback() +// let nsError = error as NSError +// print("💥 Error Saving RouteEntity from the Route Recorder \(nsError)") +// } +// } label: { +// Label("start", systemImage: "play") +// } +// .buttonStyle(.bordered) +// .buttonBorderShape(.capsule) +// .controlSize(.large) +// .padding(.bottom) +// +// } else if locationsHandler.isRecording { +// /// We are recording show pause button +// Button { +// locationsHandler.isRecording = false +// locationsHandler.isRecordingPaused = true +// } label: { +// Label("pause", systemImage: "pause") +// } +// .buttonStyle(.bordered) +// .buttonBorderShape(.capsule) +// .controlSize(.large) +// .padding(.bottom) +// } else if locationsHandler.isRecordingPaused { +// /// We are paused show resume button +// Button { +// locationsHandler.isRecording = true +// locationsHandler.isRecordingPaused = false +// } label: { +// Label("resume", systemImage: "playpause") +// } +// .buttonStyle(.bordered) +// .buttonBorderShape(.capsule) +// .controlSize(.large) +// .padding(.bottom) +// } +// +// if locationsHandler.isRecording || locationsHandler.isRecordingPaused { +// /// We are recording or paused, show finish button +// Button { +// locationsHandler.isRecording = false +// locationsHandler.isRecordingPaused = false +// locationsHandler.distanceTraveled = 0.0 +// locationsHandler.elevationGain = 0.0 +// locationsHandler.locationsArray.removeAll() +// locationsHandler.recordingStarted = nil +// if let rec = recording { +// rec.enabled = true +// context.refresh(rec, mergeChanges:true) +// } +// +// do { +// try context.save() +// print("💾 Saved a route finish") +// } catch { +// context.rollback() +// let nsError = error as NSError +// print("💥 Error Saving RouteEntity from the Route Recorder \(nsError)") +// } +// } label: { +// Label("finish", systemImage: "flag.checkered") +// } +// .buttonStyle(.bordered) +// .buttonBorderShape(.capsule) +// .controlSize(.large) +// .padding(.bottom) +// } +//#if targetEnvironment(macCatalyst) +// Button(role: .cancel) { +// isShowingDetails = false +// } label: { +// Label("close", systemImage: "xmark") +// } +// .buttonStyle(.bordered) +// .buttonBorderShape(.capsule) +// .controlSize(.large) +// .padding(.bottom) +//#endif +// Spacer() +// } +// +// } +// } +// } +// .presentationDetents([.fraction(0.30), .fraction(0.65)]) +// .presentationDragIndicator(.hidden) +// .interactiveDismissDisabled(false) +// .onChange(of: locationsHandler.locationsArray.last) { newLoc in +// if locationsHandler.isRecording { +// if let loc = newLoc { +// if recording != nil { +// let locationEntity = LocationEntity(context: context) +// locationEntity.routeLocation = recording +// locationEntity.id = Int32(locationsHandler.count) +// locationEntity.altitude = Int32(loc.altitude) +// locationEntity.heading = Int32(loc.course) +// locationEntity.speed = Int32(loc.speed) +// locationEntity.latitudeI = Int32(loc.coordinate.latitude * 1e7) +// locationEntity.longitudeI = Int32(loc.coordinate.longitude * 1e7) +// do { +// try context.save() +// print("💾 Saved a new route location") +// //print("💾 Updated Canned Messages Messages For: \(fetchedNode[0].num)") +// } catch { +// context.rollback() +// let nsError = error as NSError +// print("💥 Error Saving LocationEntity from the Route Recorder \(nsError)") +// } +// } +// } +// } +// } +// } +// } +// .ignoresSafeArea(.all, edges: [.top, .leading, .trailing]) +// } +//} diff --git a/Meshtastic/Views/Settings/Routes.swift b/Meshtastic/Views/Settings/Routes.swift index 44a65690..6c1de5a0 100644 --- a/Meshtastic/Views/Settings/Routes.swift +++ b/Meshtastic/Views/Settings/Routes.swift @@ -18,6 +18,8 @@ struct Routes: View { @State private var selectedRoute: RouteEntity? @State private var importing = false @State private var isShowingBadFileAlert = false + @State var isExporting = false + @State var exportString = "" @FetchRequest(sortDescriptors: [NSSortDescriptor(key: "enabled", ascending: false), NSSortDescriptor(key: "name", ascending: true), NSSortDescriptor(key: "date", ascending: false)], animation: .default) @@ -33,7 +35,7 @@ struct Routes: View { .padding() .alert(isPresented: $isShowingBadFileAlert) { - Alert(title: Text("Not a valid route file"), message: Text("Your route file must have both Latitude and Longitude."), dismissButton: .default(Text("OK"))) + Alert(title: Text("Not a valid route file"), message: Text("Your route file must have both Latitude and Longitude columns and headers."), dismissButton: .default(Text("OK"))) } .fileImporter( isPresented: $importing, @@ -187,8 +189,36 @@ struct Routes: View { .stroke(Color(UIColor(hex: UInt32(selectedRoute?.color ?? 0))), style: dashed) } .frame(maxWidth: .infinity, maxHeight: .infinity) + .safeAreaInset(edge: .bottom, alignment: UIDevice.current.userInterfaceIdiom == .phone ? .leading : .trailing) { + Button { + exportString = routeToCsvFile(locations: selectedRoute!.locations!.array as? [LocationEntity] ?? []) + isExporting = true + } label: { + Label("save", systemImage: "square.and.arrow.down") + } + .buttonStyle(.bordered) + .buttonBorderShape(.capsule) + .controlSize(.large) + .padding(.bottom) + .padding(.leading) + } + } }.navigationTitle(" \(selectedRoute?.name ?? "Unknown Route") \(selectedRoute?.locations?.count ?? 0) points") } + .fileExporter( + isPresented: $isExporting, + document: CsvDocument(emptyCsv: exportString), + contentType: .commaSeparatedText, + defaultFilename: String("\(selectedRoute?.name ?? "Route") Log"), + onCompletion: { result in + if case .success = result { + print("Route log download succeeded.") + self.isExporting = false + } else { + print("Route log download failed: \(result).") + } + } + ) } } diff --git a/Meshtastic/Views/Settings/Settings.swift b/Meshtastic/Views/Settings/Settings.swift index a7baadf0..66d1e02e 100644 --- a/Meshtastic/Views/Settings/Settings.swift +++ b/Meshtastic/Views/Settings/Settings.swift @@ -69,7 +69,6 @@ struct Settings: View { Text("routes") } .tag(SettingsSidebar.routes) - // NavigationLink { // RouteRecorder() // } label: { @@ -299,22 +298,26 @@ struct Settings: View { } .tag(SettingsSidebar.adminMessageLog) } -// Section(header: Text("Firmware")) { -// NavigationLink { -// Firmware(node: nodes.first(where: { $0.num == preferredNodeNum })) -// } label: { -// Image(systemName: "arrow.up.arrow.down.square") -// .symbolRenderingMode(.hierarchical) -// Text("Firmware Updates") -// } -// .tag(SettingsSidebar.about) -// .disabled(selectedNode > 0 && selectedNode != preferredNodeNum) -// } + Section(header: Text("Firmware")) { + NavigationLink { + Firmware(node: nodes.first(where: { $0.num == preferredNodeNum })) + } label: { + Image(systemName: "arrow.up.arrow.down.square") + .symbolRenderingMode(.hierarchical) + Text("Firmware Updates") + } + .tag(SettingsSidebar.about) + .disabled(selectedNode > 0 && selectedNode != preferredNodeNum) + } } } .onAppear { self.preferredNodeNum = UserDefaults.preferredPeripheralNum - if selectedNode == 0 { + if nodes.count > 1 { + if selectedNode == 0 { + self.selectedNode = Int(bleManager.connectedPeripheral != nil ? UserDefaults.preferredPeripheralNum : 0) + } + } else { self.selectedNode = Int(bleManager.connectedPeripheral != nil ? UserDefaults.preferredPeripheralNum : 0) } }