From 12e090059d1b81f2f9737313c15b163e15162647 Mon Sep 17 00:00:00 2001 From: Garth Vander Houwen Date: Sun, 23 Jun 2024 13:00:20 -0700 Subject: [PATCH 1/4] Backup database functionality --- Meshtastic.xcodeproj/project.pbxproj | 6 +- Meshtastic/Extensions/Url.swift | 20 +++ Meshtastic/Helpers/BLEManager.swift | 21 +++ Meshtastic/Persistence/Persistence.swift | 128 ++++++++++++++++ Meshtastic/Views/Bluetooth/Connect.swift | 93 ++++++------ Meshtastic/Views/Settings/AppData.swift | 157 ++++++++++++++++++++ Meshtastic/Views/Settings/AppSettings.swift | 20 +++ Meshtastic/Views/Settings/Settings.swift | 15 ++ 8 files changed, 415 insertions(+), 45 deletions(-) create mode 100644 Meshtastic/Views/Settings/AppData.swift diff --git a/Meshtastic.xcodeproj/project.pbxproj b/Meshtastic.xcodeproj/project.pbxproj index c70dcb98..371531fe 100644 --- a/Meshtastic.xcodeproj/project.pbxproj +++ b/Meshtastic.xcodeproj/project.pbxproj @@ -170,6 +170,7 @@ DDD5BB0B2C285E45007E03CA /* LogDetail.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDD5BB0A2C285E45007E03CA /* LogDetail.swift */; }; DDD5BB0D2C285F00007E03CA /* Logger.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDD5BB0C2C285F00007E03CA /* Logger.swift */; }; DDD5BB102C285FB3007E03CA /* AppLogFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDD5BB0F2C285FB3007E03CA /* AppLogFilter.swift */; }; + DDD5BB162C28B1E4007E03CA /* AppData.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDD5BB152C28B1E4007E03CA /* AppData.swift */; }; DDD6EEAF29BC024700383354 /* Firmware.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDD6EEAE29BC024700383354 /* Firmware.swift */; }; DDD94A502845C8F5004A87A0 /* DateTimeText.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDD94A4F2845C8F5004A87A0 /* DateTimeText.swift */; }; DDD9E4E4284B208E003777C5 /* UserEntityExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDD9E4E3284B208E003777C5 /* UserEntityExtension.swift */; }; @@ -448,6 +449,7 @@ DDD5BB0C2C285F00007E03CA /* Logger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Logger.swift; sourceTree = ""; }; DDD5BB0F2C285FB3007E03CA /* AppLogFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLogFilter.swift; sourceTree = ""; }; DDD5BB142C28680D007E03CA /* MeshtasticDataModelV 38.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "MeshtasticDataModelV 38.xcdatamodel"; sourceTree = ""; }; + DDD5BB152C28B1E4007E03CA /* AppData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppData.swift; sourceTree = ""; }; DDD6EEAE29BC024700383354 /* Firmware.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Firmware.swift; sourceTree = ""; }; DDD94A4F2845C8F5004A87A0 /* DateTimeText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateTimeText.swift; sourceTree = ""; }; DDD9E4E3284B208E003777C5 /* UserEntityExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserEntityExtension.swift; sourceTree = ""; }; @@ -614,6 +616,8 @@ DDD5BB0E2C285F92007E03CA /* Logs */, DD93800C2BA74CE3008BEC06 /* Channels */, DD97E96728EFE9A00056DDA4 /* About.swift */, + DDD5BB152C28B1E4007E03CA /* AppData.swift */, + DDD5BB082C285DDC007E03CA /* AppLog.swift */, DD4A911D2708C65400501B7E /* AppSettings.swift */, DDAB580C2B0DAA9E00147258 /* Routes.swift */, DDE9659B2B1C3B6A00531070 /* RouteRecorder.swift */, @@ -627,7 +631,6 @@ DDCE4E2B2869F92900BE9F8F /* UserConfig.swift */, DD61937A2863876A00E59241 /* Config */, DD1B8F3F2B35E2F10022AABC /* GPSStatus.swift */, - DDD5BB082C285DDC007E03CA /* AppLog.swift */, ); path = Settings; sourceTree = ""; @@ -1318,6 +1321,7 @@ DD23A50F26FD1B4400D9B90C /* PeripheralModel.swift in Sources */, DDB6ABDB28B0AC6000384BA1 /* DistanceText.swift in Sources */, DD5E520D298EE33B00D21B61 /* storeforward.pb.swift in Sources */, + DDD5BB162C28B1E4007E03CA /* AppData.swift in Sources */, DD94B7402ACCE3BE00DCD1D1 /* MapSettingsForm.swift in Sources */, DD964FC2297272AE007C176F /* WaypointEntityExtension.swift in Sources */, 6DA39D8E2A92DC52007E311C /* MeshtasticAppDelegate.swift in Sources */, diff --git a/Meshtastic/Extensions/Url.swift b/Meshtastic/Extensions/Url.swift index 6d594dd9..b890e498 100644 --- a/Meshtastic/Extensions/Url.swift +++ b/Meshtastic/Extensions/Url.swift @@ -30,4 +30,24 @@ extension URL { return nil } } + var attributes: [FileAttributeKey: Any]? { + do { + return try FileManager.default.attributesOfItem(atPath: path) + } catch let error as NSError { + print("FileAttribute error: \(error)") + } + return nil + } + + var fileSize: UInt64 { + return attributes?[.size] as? UInt64 ?? UInt64(0) + } + + var fileSizeString: String { + return ByteCountFormatter.string(fromByteCount: Int64(fileSize), countStyle: .file) + } + + var creationDate: Date? { + return attributes?[.creationDate] as? Date + } } diff --git a/Meshtastic/Helpers/BLEManager.swift b/Meshtastic/Helpers/BLEManager.swift index 87e33b4a..79bba86c 100644 --- a/Meshtastic/Helpers/BLEManager.swift +++ b/Meshtastic/Helpers/BLEManager.swift @@ -637,6 +637,27 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate connectedPeripheral.num = myInfo?.myNodeNum ?? 0 connectedPeripheral.name = myInfo?.bleName ?? "unknown".localized connectedPeripheral.longName = myInfo?.bleName ?? "unknown".localized + let newConnection = Int64(UserDefaults.preferredPeripheralNum) != Int64(decodedInfo.myInfo.myNodeNum) + if newConnection { + let container = NSPersistentContainer(name: "Meshtastic") + if let url = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first { + let databasePath = url.appendingPathComponent("backup") + .appendingPathComponent("\(UserDefaults.preferredPeripheralNum)") + .appendingPathComponent("Meshtastic.sqlite") + if FileManager.default.fileExists(atPath: databasePath.path) { + do { + disconnectPeripheral(reconnect: false) + try container.restorePersistentStore(from: databasePath) + UserDefaults.preferredPeripheralNum = Int(myInfo?.myNodeNum ?? 0) + context!.reset() + connectTo(peripheral: peripheral) + Logger.data.notice("🗂️ Restored Core data for /\(UserDefaults.preferredPeripheralNum)") + } catch { + Logger.data.error("Copy error: \(error)") + } + } + } + } } tryClearExistingChannels() } diff --git a/Meshtastic/Persistence/Persistence.swift b/Meshtastic/Persistence/Persistence.swift index 0da6a264..92a2e381 100644 --- a/Meshtastic/Persistence/Persistence.swift +++ b/Meshtastic/Persistence/Persistence.swift @@ -89,3 +89,131 @@ extension NSManagedObjectContext { NSManagedObjectContext.mergeChanges(fromRemoteContextSave: changes, into: [self]) } } + + +// Created by Tom Harrington on 5/12/20. +// Copyright © 2020 Atomic Bird LLC. All rights reserved. +// Gist from https://atomicbird.com/blog/core-data-back-up-store/ +// +extension NSPersistentContainer { + + enum CopyPersistentStoreErrors: Error { + case invalidDestination(String) + case destinationError(String) + case destinationNotRemoved(String) + case copyStoreError(String) + case invalidSource(String) + } + + /// Restore a persistent store for a URL `backupURL`. + /// + /// **Be very careful with this**. To restore a persistent store, the current persistent store must be removed from the container. When that happens, **all currently loaded Core Data objects** will become invalid. Using them after restoring will cause your app to crash. When calling this method you **must** ensure that you do not continue to use any previously fetched managed objects or existing fetched results controllers. **If this method does not throw, that does not mean your app is safe.** You need to take extra steps to prevent crashes. The details vary depending on the nature of your app. + /// - Parameter backupURL: A file URL containing backup copies of all currently loaded persistent stores. + /// - Throws: `CopyPersistentStoreError` in various situations. + /// - Returns: Nothing. If no errors are thrown, the restore is complete. + func restorePersistentStore(from backupURL: URL) throws -> Void { + guard backupURL.isFileURL else { + throw CopyPersistentStoreErrors.invalidSource("Backup URL must be a file URL") + } + + for persistentStoreDescription in persistentStoreDescriptions { + guard let loadedStoreURL = persistentStoreDescription.url else { + continue + } + guard FileManager.default.fileExists(atPath: backupURL.path) else { + throw CopyPersistentStoreErrors.invalidSource("Missing backup store for \(backupURL)") + } + do { + let storeOptions = persistentStoreDescription.options + let configurationName = persistentStoreDescription.configuration + let storeType = persistentStoreDescription.type + + // Replace the current store with the backup copy. This has a side effect of removing the current store from the Core Data stack. + // When restoring, it's necessary to use the current persistent store coordinator. + try persistentStoreCoordinator.replacePersistentStore(at: loadedStoreURL, destinationOptions: storeOptions, withPersistentStoreFrom: backupURL, sourceOptions: storeOptions, ofType: storeType) + // Add the persistent store at the same location we've been using, because it was removed in the previous step. + try persistentStoreCoordinator.addPersistentStore(ofType: storeType, configurationName: configurationName, at: loadedStoreURL, options: storeOptions) + } catch { + throw CopyPersistentStoreErrors.copyStoreError("Could not restore: \(error.localizedDescription)") + } + } + } + + /// Copy all loaded persistent stores to a new directory. Each currently loaded file-based persistent store will be copied (including journal files, external binary storage, and anything else Core Data needs) into the destination directory to a persistent store with the same name and type as the existing store. In-memory stores, if any, are skipped. + /// - Parameters: + /// - destinationURL: Destination for new persistent store files. Must be a file URL. If `overwriting` is `false` and `destinationURL` exists, it must be a directory. + /// - overwriting: If `true`, any existing copies of the persistent store will be replaced or updated. If `false`, existing copies will not be changed or remoted. When this is `false`, the destination persistent store file must not already exist. + /// - Throws: `CopyPersistentStoreError` + /// - Returns: Nothing. If no errors are thrown, all loaded persistent stores will be copied to the destination directory. + func copyPersistentStores(to destinationURL: URL, overwriting: Bool = false) throws -> Void { + print(destinationURL) + guard !destinationURL.relativeString.contains("/0/") else { + throw CopyPersistentStoreErrors.invalidDestination("Invalid 0 Node Id") + } + + guard destinationURL.isFileURL else { + throw CopyPersistentStoreErrors.invalidDestination("Destination URL must be a file URL") + } + + // If the destination exists and we aren't overwriting it, then it must be a directory. (If we are overwriting, we'll remove it anyway, so it doesn't matter whether it's a directory). + var isDirectory: ObjCBool = false + if !overwriting && FileManager.default.fileExists(atPath: destinationURL.path, isDirectory: &isDirectory) { + if !isDirectory.boolValue { + throw CopyPersistentStoreErrors.invalidDestination("Destination URL must be a directory") + } + // Don't check if destination stores exist in the destination dir, that comes later on a per-store basis. + } + // If we're overwriting, remove the destination. + if overwriting && FileManager.default.fileExists(atPath: destinationURL.path) { + do { + try FileManager.default.removeItem(at: destinationURL) + } catch { + throw CopyPersistentStoreErrors.destinationNotRemoved("Can't overwrite destination at \(destinationURL)") + } + } + + // Create the destination directory + do { + try FileManager.default.createDirectory(at: destinationURL, withIntermediateDirectories: true, attributes: nil) + } catch { + throw CopyPersistentStoreErrors.destinationError("Could not create destination directory at \(destinationURL)") + } + + for persistentStoreDescription in persistentStoreDescriptions { + guard let storeURL = persistentStoreDescription.url else { + continue + } + guard persistentStoreDescription.type != NSInMemoryStoreType else { + continue + } + let temporaryPSC = NSPersistentStoreCoordinator(managedObjectModel: persistentStoreCoordinator.managedObjectModel) + let destinationStoreURL = destinationURL.appendingPathComponent(storeURL.lastPathComponent) + if !overwriting && FileManager.default.fileExists(atPath: destinationStoreURL.path) { + // If the destination exists, the migratePersistentStore call will update it in place. That's fine unless we're not overwriting. + throw CopyPersistentStoreErrors.destinationError("Destination already exists at \(destinationStoreURL)") + } + do { + let newStore = try temporaryPSC.addPersistentStore(ofType: persistentStoreDescription.type, configurationName: persistentStoreDescription.configuration, at: persistentStoreDescription.url, options: persistentStoreDescription.options) + let _ = try temporaryPSC.migratePersistentStore(newStore, to: destinationStoreURL, options: persistentStoreDescription.options, withType: persistentStoreDescription.type) + + /// Cleanup extra files + let directory = destinationStoreURL.deletingLastPathComponent() + /// Delete -wal file + do { + try FileManager.default.removeItem(at: directory.appendingPathComponent("Meshtastic.sqlite-wal")) + /// Delete -shm file + do { + try FileManager.default.removeItem(at: directory.appendingPathComponent("Meshtastic.sqlite-shm")) + } catch { + print(error) + } + } catch { + print(error) + } + + } catch { + throw CopyPersistentStoreErrors.copyStoreError("\(error.localizedDescription)") + } + } + } +} diff --git a/Meshtastic/Views/Bluetooth/Connect.swift b/Meshtastic/Views/Bluetooth/Connect.swift index 63537d86..a7d8ca71 100644 --- a/Meshtastic/Views/Bluetooth/Connect.swift +++ b/Meshtastic/Views/Bluetooth/Connect.swift @@ -26,7 +26,6 @@ struct Connect: View { @State var isUnsetRegion = false @State var invalidFirmwareVersion = false @State var liveActivityStarted = false - @State var presentingSwitchPreferredPeripheral = false @State var selectedPeripherialId = "" init () { @@ -49,7 +48,7 @@ struct Connect: View { List { if bleManager.isSwitchedOn { Section(header: Text("connected.radio").font(.title)) { - if bleManager.connectedPeripheral != nil && bleManager.connectedPeripheral.peripheral.state == .connected { + if let connectedPeripheral = bleManager.connectedPeripheral, connectedPeripheral.peripheral.state == .connected { if #available(iOS 17.0, macOS 14.0, *) { TipView(BluetoothConnectionTip(), arrowEdge: .bottom) } @@ -60,9 +59,9 @@ struct Connect: View { .padding(.trailing) VStack(alignment: .leading) { if node != nil { - Text(bleManager.connectedPeripheral.longName).font(.title2) + Text(connectedPeripheral.longName).font(.title2) } - Text("ble.name").font(.callout)+Text(": \(bleManager.connectedPeripheral.peripheral.name ?? "unknown".localized)") + Text("ble.name").font(.callout)+Text(": \(bleManager.connectedPeripheral?.peripheral.name ?? "unknown".localized)") .font(.callout).foregroundColor(Color.gray) if node != nil { Text("firmware.version").font(.callout)+Text(": \(node?.metadata?.firmwareVersion ?? "unknown".localized)") @@ -91,7 +90,8 @@ struct Connect: View { .padding([.top, .bottom]) .swipeActions { Button(role: .destructive) { - if bleManager.connectedPeripheral != nil && bleManager.connectedPeripheral.peripheral.state == CBPeripheralState.connected { + if let connectedPeripheral = bleManager.connectedPeripheral, + connectedPeripheral.peripheral.state == .connected { bleManager.disconnectPeripheral(reconnect: false) } } label: { @@ -121,7 +121,8 @@ struct Connect: View { Text("Num: \(String(node!.num))") Text("Short Name: \(node?.user?.shortName ?? "?")") Text("Long Name: \(node?.user?.longName ?? "unknown".localized)") - Text("BLE RSSI: \(bleManager.connectedPeripheral.rssi)") + Text("BLE RSSI: \(connectedPeripheral.rssi)") + Button { if !bleManager.sendShutdown(fromUser: node!.user!, toUser: node!.user!, adminIndex: node!.myInfo!.adminIndex) { Logger.mesh.error("Shutdown Failed") @@ -210,8 +211,27 @@ struct Connect: View { } Button(action: { if UserDefaults.preferredPeripheralId.count > 0 && peripheral.peripheral.identifier.uuidString != UserDefaults.preferredPeripheralId { - presentingSwitchPreferredPeripheral = true - selectedPeripherialId = peripheral.peripheral.identifier.uuidString + if let connectedPeripheral = bleManager.connectedPeripheral, connectedPeripheral.peripheral.state == CBPeripheralState.connected { + bleManager.disconnectPeripheral() + } + //clearCoreDataDatabase(context: context, includeRoutes: false) + let container = NSPersistentContainer(name : "Meshtastic") + guard let url = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { + Logger.data.error("nil File path for back") + return + } + do { + try container.copyPersistentStores(to: url.appendingPathComponent("backup").appendingPathComponent("\(UserDefaults.preferredPeripheralNum)"), overwriting: true) + + Logger.data.notice("🗂️ Made a core data backup to backup/\(UserDefaults.preferredPeripheralNum)") + } catch { + print("Copy error: \(error)") + } + UserDefaults.preferredPeripheralId = selectedPeripherialId + let radio = bleManager.peripherals.first(where: { $0.peripheral.identifier.uuidString == selectedPeripherialId }) + if radio != nil { + bleManager.connectTo(peripheral: radio!.peripheral) + } } else { self.bleManager.connectTo(peripheral: peripheral.peripheral) } @@ -225,23 +245,6 @@ struct Connect: View { }.padding([.bottom, .top]) } } - .confirmationDialog("Connecting to a new radio will clear all local app data on the phone.", isPresented: $presentingSwitchPreferredPeripheral, titleVisibility: .visible) { - - Button("Connect to new radio?", role: .destructive) { - UserDefaults.preferredPeripheralId = selectedPeripherialId - UserDefaults.preferredPeripheralNum = 0 - if bleManager.connectedPeripheral != nil && bleManager.connectedPeripheral.peripheral.state == CBPeripheralState.connected { - bleManager.disconnectPeripheral() - } - clearCoreDataDatabase(context: context, includeRoutes: false) - - let radio = bleManager.peripherals.first(where: { $0.peripheral.identifier.uuidString == selectedPeripherialId }) - if radio != nil { - bleManager.connectTo(peripheral: radio!.peripheral) - } - } - } - .textCase(nil) } } else { @@ -254,9 +257,9 @@ struct Connect: View { HStack(alignment: .center) { Spacer() #if targetEnvironment(macCatalyst) - if bleManager.connectedPeripheral != nil { + if let connectedPeripheral = bleManager.connectedPeripheral { Button(role: .destructive, action: { - if bleManager.connectedPeripheral != nil && bleManager.connectedPeripheral.peripheral.state == CBPeripheralState.connected { + if connectedPeripheral.peripheral.state == CBPeripheralState.connected { bleManager.disconnectPeripheral(reconnect: false) } }) { @@ -285,10 +288,18 @@ struct Connect: View { .padding(.bottom, 10) } .navigationTitle("bluetooth") - .navigationBarItems(leading: MeshtasticLogo(), trailing: - ZStack { - ConnectedDevice(bluetoothOn: bleManager.isSwitchedOn, deviceConnected: bleManager.connectedPeripheral != nil, name: (bleManager.connectedPeripheral != nil) ? bleManager.connectedPeripheral.shortName : "?", mqttProxyConnected: bleManager.mqttProxyConnected, mqttTopic: bleManager.mqttManager.topic) - }) + .navigationBarItems( + leading: MeshtasticLogo(), + trailing: ZStack { + ConnectedDevice( + bluetoothOn: bleManager.isSwitchedOn, + deviceConnected: bleManager.connectedPeripheral != nil, + name: bleManager.connectedPeripheral?.shortName ?? "?", + mqttProxyConnected: bleManager.mqttProxyConnected, + mqttTopic: bleManager.mqttManager.topic + ) + } + ) } .sheet(isPresented: $invalidFirmwareVersion, onDismiss: didDismissSheet) { InvalidVersion(minimumVersion: self.bleManager.minimumVersion, version: self.bleManager.connectedVersion) @@ -302,24 +313,18 @@ struct Connect: View { if UserDefaults.preferredPeripheralId.count > 0 && sub { - let fetchNodeInfoRequest: NSFetchRequest = NSFetchRequest.init(entityName: "NodeInfoEntity") + let fetchNodeInfoRequest = NodeInfoEntity.fetchRequest() fetchNodeInfoRequest.predicate = NSPredicate(format: "num == %lld", Int64(bleManager.connectedPeripheral?.num ?? -1)) do { - guard let fetchedNode = try context.fetch(fetchNodeInfoRequest) as? [NodeInfoEntity] else { - return - } - // Found a node, check it for a region - if !fetchedNode.isEmpty { - node = fetchedNode[0] - if node!.loRaConfig != nil && node!.loRaConfig?.regionCode ?? 0 == RegionCodes.unset.rawValue { - isUnsetRegion = true - } else { - isUnsetRegion = false - } + node = try context.fetch(fetchNodeInfoRequest).first + if let loRaConfig = node?.loRaConfig, loRaConfig.regionCode == RegionCodes.unset.rawValue { + isUnsetRegion = true + } else { + isUnsetRegion = false } } catch { - + Logger.data.error("💥 Error fetching node info: \(error.localizedDescription)") } } } diff --git a/Meshtastic/Views/Settings/AppData.swift b/Meshtastic/Views/Settings/AppData.swift new file mode 100644 index 00000000..631cccf4 --- /dev/null +++ b/Meshtastic/Views/Settings/AppData.swift @@ -0,0 +1,157 @@ +// +// BackupData.swift +// Meshtastic +// +// Copyright(c) Garth Vander Houwen 6/8/24. +// + +import SwiftUI +import OSLog +import CoreData +import Foundation + +struct AppData: View { + + @Environment(\.managedObjectContext) var context + @EnvironmentObject var bleManager: BLEManager + @State private var files = [URL]() + private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom } + + var body: some View { + + + VStack { + Button(action: { + let container = NSPersistentContainer(name : "Meshtastic") + guard let url = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { + Logger.data.error("nil File path for back") + return + } + do { + try container.copyPersistentStores(to: url.appendingPathComponent("backup").appendingPathComponent("\(UserDefaults.preferredPeripheralNum)"), overwriting: true) + loadFiles() + Logger.data.notice("🗂️ Made a core data backup to backup/\(UserDefaults.preferredPeripheralNum)") + } catch { + print("Copy error: \(error)") + } + + }) { + Label { + Text("Backup Database") + .font(idiom == .phone ? .callout : .title) + } icon: { + Image(systemName: "cylinder.split.1x2") + .symbolRenderingMode(.hierarchical) + .font(idiom == .phone ? .callout : .title) + .frame(width: 35) + } + } + .buttonStyle(.bordered) + .buttonBorderShape(.capsule) + .controlSize(.large) + + } + List(files, id: \.self) { file in + HStack { + VStack (alignment: .leading ) { + if file.pathExtension.contains("sqlite") { //} == "sqlite" { + Label { + Text("Node Core Data Backup \(file.pathComponents[9])/\(file.lastPathComponent) - \(file.creationDate?.formatted() ?? "") - \(file.fileSizeString)") + .swipeActions { + Button(role: .none) { + bleManager.disconnectPeripheral(reconnect: false) + let container = NSPersistentContainer(name : "Meshtastic") + do { + context.reset() + try container.restorePersistentStore(from: file.absoluteURL) + UserDefaults.preferredPeripheralId = "" + UserDefaults.preferredPeripheralNum = Int(file.pathComponents[10]) ?? 0 + Logger.data.notice("🗂️ Restored a core data backup to backup/\(UserDefaults.preferredPeripheralNum)") + } catch { + print("Copy error: \(error)") + } + } label: { + Label("restore", systemImage: "arrow.counterclockwise") + } + Button(role: .destructive) { + do { + try FileManager.default.removeItem(at: file) + } catch { + print(error) + } + } label: { + Label("delete", systemImage: "trash") + } + } + } icon: { + Image(systemName: "cylinder.split.1x2") + .symbolRenderingMode(.hierarchical) + .font(idiom == .phone ? .callout : .title) + .frame(width: 35) + } + } + else { + Label { + Text("\(file.lastPathComponent) - \(file.creationDate?.formatted() ?? "") - \(file.fileSizeString)") + .swipeActions { + Button(role: .destructive) { + do { + try FileManager.default.removeItem(at: file) + } catch { + print(error) + } + } label: { + Label("delete", systemImage: "trash") + } + } + + } icon: { + Image(systemName: "doc.text") + .symbolRenderingMode(.hierarchical) + .font(idiom == .phone ? .callout : .title) + .frame(width: 35) + } + } + } +#if targetEnvironment(macCatalyst) + Spacer() + VStack (alignment: .trailing) { + Button() { + do { + try FileManager.default.removeItem(at: file) + loadFiles() + } catch { + print(error) + } + } label: { + Label("", systemImage: "trash") + } + } +#endif + } + } + .navigationBarTitle("File Storage", displayMode: .inline) + .onAppear(perform: { + loadFiles() + }) + .listStyle(.inset) + } + + private func loadFiles() { + files = [] + guard let url = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { + Logger.data.error("🗂️ nil default document directory path for backup, core data backup failed.") + return + } + if let enumerator = FileManager.default.enumerator(at: url, includingPropertiesForKeys: [.isRegularFileKey], options: [.skipsHiddenFiles, .skipsPackageDescendants]) { + for case let fileURL as URL in enumerator { + do { + let fileAttributes = try fileURL.resourceValues(forKeys:[.isRegularFileKey]) + if fileAttributes.isRegularFile! { + files.append(fileURL) + } + } catch { print(error, fileURL) } + } + } + } +} diff --git a/Meshtastic/Views/Settings/AppSettings.swift b/Meshtastic/Views/Settings/AppSettings.swift index 7eb03933..528843af 100644 --- a/Meshtastic/Views/Settings/AppSettings.swift +++ b/Meshtastic/Views/Settings/AppSettings.swift @@ -76,6 +76,26 @@ struct AppSettings: View { ) { Button("Erase all app data?", role: .destructive) { bleManager.disconnectPeripheral() + /// Delete any database backups too + if var url = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first { + url = url.appendingPathComponent("backup").appendingPathComponent(String(UserDefaults.preferredPeripheralNum)) + do { + try FileManager.default.removeItem(at: url.appendingPathComponent("Meshtastic.sqlite")) + /// Delete -shm file + do { + try FileManager.default.removeItem(at: url.appendingPathComponent("Meshtastic.sqlite-wal")) + do { + try FileManager.default.removeItem(at: url.appendingPathComponent("Meshtastic.sqlite-shm")) + } catch { + Logger.services.error("Error Deleting Meshtastic.sqlite-shm file \(error)") + } + } catch { + Logger.services.error("Error Deleting Meshtastic.sqlite-wal file \(error)") + } + } catch { + Logger.services.error("Error Deleting Meshtastic.sqlite file \(error)") + } + } clearCoreDataDatabase(context: context, includeRoutes: true) context.refreshAllObjects() UserDefaults.standard.reset() diff --git a/Meshtastic/Views/Settings/Settings.swift b/Meshtastic/Views/Settings/Settings.swift index 2817e8b6..394bc941 100644 --- a/Meshtastic/Views/Settings/Settings.swift +++ b/Meshtastic/Views/Settings/Settings.swift @@ -49,6 +49,7 @@ struct Settings: View { case adminMessageLog case about case appLog + case appData } var body: some View { NavigationSplitView { @@ -426,6 +427,20 @@ struct Settings: View { .tag(SettingsSidebar.appLog) } } +#if DEBUG + Section(header: Text("Developers")) { + NavigationLink { + AppData() + } label: { + Label { + Text("App Files") + } icon: { + Image(systemName: "folder") + } + } + .tag(SettingsSidebar.appData) + } +#endif Section(header: Text("Firmware")) { NavigationLink { Firmware(node: nodes.first(where: { $0.num == preferredNodeNum })) From f132589e9c038ac817524136edf3aed5c5ed685b Mon Sep 17 00:00:00 2001 From: Garth Vander Houwen Date: Sun, 23 Jun 2024 13:08:15 -0700 Subject: [PATCH 2/4] Log updates --- Meshtastic/Helpers/BLEManager.swift | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/Meshtastic/Helpers/BLEManager.swift b/Meshtastic/Helpers/BLEManager.swift index 79bba86c..cf37fd3a 100644 --- a/Meshtastic/Helpers/BLEManager.swift +++ b/Meshtastic/Helpers/BLEManager.swift @@ -301,21 +301,21 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate switch characteristic.uuid { case TORADIO_UUID: - Logger.services.info("✅ BLE did discover TORADIO characteristic for Meshtastic by \(peripheral.name ?? "Unknown")") + Logger.services.info("✅ [BLE] did discover TORADIO characteristic for Meshtastic by \(peripheral.name ?? "Unknown", privacy: .public)") TORADIO_characteristic = characteristic case FROMRADIO_UUID: - Logger.services.info("✅ BLE did discover FROMRADIO characteristic for Meshtastic by \(peripheral.name ?? "Unknown")") + Logger.services.info("✅ [BLE] did discover FROMRADIO characteristic for Meshtastic by \(peripheral.name ?? "Unknown", privacy: .public)") FROMRADIO_characteristic = characteristic peripheral.readValue(for: FROMRADIO_characteristic) case FROMNUM_UUID: - Logger.services.info("✅ BLE did discover FROMNUM (Notify) characteristic for Meshtastic by \(peripheral.name ?? "Unknown")") + Logger.services.info("✅ [BLE] did discover FROMNUM (Notify) characteristic for Meshtastic by \(peripheral.name ?? "Unknown", privacy: .public)") FROMNUM_characteristic = characteristic peripheral.setNotifyValue(true, for: characteristic) - + case LOGRADIO_UUID: - Logger.services.info("✅ BLE did discover LOGRADIO (Notify) characteristic for Meshtastic by \(peripheral.name ?? "Unknown")") + Logger.services.info("✅ [BLE] did discover LOGRADIO (Notify) characteristic for Meshtastic by \(peripheral.name ?? "Unknown", privacy: .public)") LOGRADIO_characteristic = characteristic peripheral.setNotifyValue(true, for: characteristic) @@ -335,7 +335,7 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate func onMqttConnected() { mqttProxyConnected = true mqttError = "" - Logger.services.info("📲 Mqtt Client Proxy onMqttConnected now subscribing to \(self.mqttManager.topic).") + Logger.services.info("📲 [MQTT Client Proxy] onMqttConnected now subscribing to \(self.mqttManager.topic, privacy: .public).") mqttManager.mqttClientProxy?.subscribe(mqttManager.topic) } @@ -368,7 +368,7 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate func onMqttError(message: String) { mqttProxyConnected = false mqttError = message - Logger.services.info("📲 Mqtt Client Proxy onMqttError: \(message)") + Logger.services.info("📲 [MQTT Client Proxy] onMqttError: \(message, privacy: .public)") } // MARK: Protobuf Methods @@ -395,7 +395,7 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate return 0 } - let messageDescription = "🛎️ Requested Device Metadata for node \(toUser.longName ?? "unknown".localized) by \(fromUser.longName ?? "unknown".localized)" + let messageDescription = "🛎️ [Device Metadata] Requested for node \(toUser.longName ?? "unknown".localized) by \(fromUser.longName ?? "unknown".localized)" if sendAdminMessageToRadio(meshPacket: meshPacket, adminDescription: messageDescription) { return Int64(meshPacket.id) } From 921c648a6bff79c0f3b1674b8436cf1bef1f9edf Mon Sep 17 00:00:00 2001 From: Garth Vander Houwen Date: Sun, 23 Jun 2024 14:30:55 -0700 Subject: [PATCH 3/4] Update log privacy --- .../Helpers/Mqtt/MqttClientProxyManager.swift | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/Meshtastic/Helpers/Mqtt/MqttClientProxyManager.swift b/Meshtastic/Helpers/Mqtt/MqttClientProxyManager.swift index 66f94366..277735a5 100644 --- a/Meshtastic/Helpers/Mqtt/MqttClientProxyManager.swift +++ b/Meshtastic/Helpers/Mqtt/MqttClientProxyManager.swift @@ -81,16 +81,16 @@ class MqttClientProxyManager { } } func subscribe(topic: String, qos: CocoaMQTTQoS) { - Logger.mqtt.info("📲 [MQTT Client Proxy] subscribed to: \(topic)") + Logger.mqtt.info("📲 [MQTT Client Proxy] subscribed to: \(topic, privacy: .public)") mqttClientProxy?.subscribe(topic, qos: qos) } func unsubscribe(topic: String) { mqttClientProxy?.unsubscribe(topic) - Logger.mqtt.info("📲 [MQTT Client Proxy] unsubscribe to topic: \(topic)") + Logger.mqtt.info("📲 [MQTT Client Proxy] unsubscribe to topic: \(topic, privacy: .public)") } func publish(message: String, topic: String, qos: CocoaMQTTQoS) { mqttClientProxy?.publish(topic, withString: message, qos: qos) - Logger.mqtt.debug("📲 [MQTT Client Proxy] publish for: \(topic)") + Logger.mqtt.debug("📲 [MQTT Client Proxy] publish for: \(topic, privacy: .public)") } func disconnect() { if let client = mqttClientProxy { @@ -137,21 +137,21 @@ extension MqttClientProxyManager: CocoaMQTTDelegate { delegate?.onMqttDisconnected() } func mqtt(_ mqtt: CocoaMQTT, didPublishMessage message: CocoaMQTTMessage, id: UInt16) { - Logger.mqtt.info("📲 [MQTT Client Proxy] published messsage from MqttClientProxyManager: \(message)") + Logger.mqtt.info("📲 [MQTT Client Proxy] published messsage from MqttClientProxyManager: \(message, privacy: .public)") } func mqtt(_ mqtt: CocoaMQTT, didPublishAck id: UInt16) { - Logger.mqtt.info("📲 [MQTT Client Proxy] published Ack from MqttClientProxyManager: \(id)") + Logger.mqtt.info("📲 [MQTT Client Proxy] published Ack from MqttClientProxyManager: \(id, privacy: .public)") } public func mqtt(_ mqtt: CocoaMQTT, didReceiveMessage message: CocoaMQTTMessage, id: UInt16) { delegate?.onMqttMessageReceived(message: message) - Logger.mqtt.info("📲 [MQTT Client Proxy] message received on topic: \(message.topic)") + Logger.mqtt.info("📲 [MQTT Client Proxy] message received on topic: \(message.topic, privacy: .public)") } func mqtt(_ mqtt: CocoaMQTT, didSubscribeTopics success: NSDictionary, failed: [String]) { - Logger.mqtt.debug("📲 [MQTT Client Proxy] subscribed to topics: \(success.allKeys.count) topics. failed: \(failed.count) topics") + Logger.mqtt.debug("📲 [MQTT Client Proxy] subscribed to topics: \(success.allKeys.count, privacy: .public) topics. failed: \(failed.count, privacy: .public) topics") } func mqtt(_ mqtt: CocoaMQTT, didUnsubscribeTopics topics: [String]) { - Logger.mqtt.debug("📲 [MQTT Client Proxy] unsubscribed from topics: \(topics.joined(separator: "- "))") + Logger.mqtt.debug("📲 [MQTT Client Proxy] unsubscribed from topics: \(topics.joined(separator: "- "), privacy: .public)") } func mqttDidPing(_ mqtt: CocoaMQTT) { Logger.mqtt.debug("📲 [MQTT Client Proxy] ping") From d6f5c77b1ab6841b6639168f395b9d6f233ba64d Mon Sep 17 00:00:00 2001 From: Garth Vander Houwen Date: Sun, 23 Jun 2024 14:40:28 -0700 Subject: [PATCH 4/4] position config --- .../Settings/Config/PositionConfig.swift | 766 +++++++++--------- 1 file changed, 382 insertions(+), 384 deletions(-) diff --git a/Meshtastic/Views/Settings/Config/PositionConfig.swift b/Meshtastic/Views/Settings/Config/PositionConfig.swift index 1f51b801..b723935b 100644 --- a/Meshtastic/Views/Settings/Config/PositionConfig.swift +++ b/Meshtastic/Views/Settings/Config/PositionConfig.swift @@ -10,7 +10,6 @@ import OSLog struct PositionFlags: OptionSet { let rawValue: Int - static let Altitude = PositionFlags(rawValue: 1) static let AltitudeMsl = PositionFlags(rawValue: 2) static let GeoidalSeparation = PositionFlags(rawValue: 4) @@ -28,12 +27,9 @@ struct PositionConfig: View { @Environment(\.managedObjectContext) var context @EnvironmentObject var bleManager: BLEManager @Environment(\.dismiss) private var goBack - var node: NodeInfoEntity? - @State var hasChanges = false @State var hasFlagChanges = false - @State var smartPositionEnabled = true @State var deviceGpsEnabled = true @State var gpsMode = 0 @@ -72,301 +68,313 @@ struct PositionConfig: View { /// Intended for use with vehicle not walking speeds /// walking speeds are likely to be error prone like the compass @State var includeHeading = false - /// Minimum Version for fixed postion admin messages @State var minimumVersion = "2.3.3" @State private var supportedVersion = true @State private var showingSetFixedAlert = false // @State private var showingRemoveFixedAlert = false + @ViewBuilder + var positionPacketSection: some View { + Section(header: Text("Position Packet")) { + + VStack(alignment: .leading) { + Picker("Broadcast Interval", selection: $positionBroadcastSeconds) { + ForEach(UpdateIntervals.allCases) { at in + if at.rawValue >= 300 { + Text(at.description) + } + } + } + .pickerStyle(DefaultPickerStyle()) + Text("The maximum interval that can elapse without a node broadcasting a position") + .foregroundColor(.gray) + .font(.callout) + } + + Toggle(isOn: $smartPositionEnabled) { + Label("Smart Position", systemImage: "brain") + } + .toggleStyle(SwitchToggleStyle(tint: .accentColor)) + + if smartPositionEnabled { + VStack(alignment: .leading) { + Picker("Minimum Interval", selection: $broadcastSmartMinimumIntervalSecs) { + ForEach(UpdateIntervals.allCases) { at in + Text(at.description) + } + } + .pickerStyle(DefaultPickerStyle()) + Text("The fastest that position updates will be sent if the minimum distance has been satisfied") + .foregroundColor(.gray) + .font(.callout) + } + VStack(alignment: .leading) { + Picker("Minimum Distance", selection: $broadcastSmartMinimumDistance) { + ForEach(10..<151) { + if $0 == 0 { + Text("unset") + } else { + if $0.isMultiple(of: 5) { + Text("\($0)") + .tag($0) + } + } + } + } + .pickerStyle(DefaultPickerStyle()) + Text("The minimum distance change in meters to be considered for a smart position broadcast.") + .foregroundColor(.gray) + .font(.callout) + } + } + } + } + + @ViewBuilder + var deviceGPSSection: some View { + Section(header: Text("Device GPS")) { + Picker("", selection: $gpsMode) { + ForEach(GpsMode.allCases, id: \.self) { at in + Text(at.description) + .tag(at.id) + + } + } + .pickerStyle(SegmentedPickerStyle()) + .padding(.top, 5) + .padding(.bottom, 5) + .disabled(fixedPosition && !(gpsMode == 1)) + if gpsMode == 1 { + Text("Positions will be provided by your device GPS, if you select disabled or not present you can set a fixed position.") + .foregroundColor(.gray) + .font(.callout) + VStack(alignment: .leading) { + Picker("Update Interval", selection: $gpsUpdateInterval) { + ForEach(GpsUpdateIntervals.allCases) { ui in + Text(ui.description) + } + } + Text("How often should we try to get a GPS position.") + .foregroundColor(.gray) + .font(.callout) + } + } + if (gpsMode != 1 && node?.num ?? 0 == bleManager.connectedPeripheral?.num ?? -1) || fixedPosition { + VStack(alignment: .leading) { + Toggle(isOn: $fixedPosition) { + Label("Fixed Position", systemImage: "location.square.fill") + if !(node?.positionConfig?.fixedPosition ?? false) { + Text("Your current location will be set as the fixed position and broadcast over the mesh on the position interval.") + } else { + + } + } + .toggleStyle(SwitchToggleStyle(tint: .accentColor)) + } + } + } + } + + @ViewBuilder + var positionFlagsSection: some View { + Section(header: Text("Position Flags")) { + + Text("Optional fields to include when assembling position messages. the more fields are included, the larger the message will be - leading to longer airtime and a higher risk of packet loss") + .foregroundColor(.gray) + .font(.callout) + + Toggle(isOn: $includeAltitude) { + Label("Altitude", systemImage: "arrow.up") + } + .toggleStyle(SwitchToggleStyle(tint: .accentColor)) + + Toggle(isOn: $includeSatsinview) { + Label("Number of satellites", systemImage: "skew") + } + .toggleStyle(SwitchToggleStyle(tint: .accentColor)) + + Toggle(isOn: $includeSeqNo) { // 64 + Label("Sequence number", systemImage: "number") + } + .toggleStyle(SwitchToggleStyle(tint: .accentColor)) + + Toggle(isOn: $includeTimestamp) { // 128 + Label("timestamp", systemImage: "clock") + } + .toggleStyle(SwitchToggleStyle(tint: .accentColor)) + + Toggle(isOn: $includeHeading) { // 128 + Label("Vehicle heading", systemImage: "location.circle") + } + .toggleStyle(SwitchToggleStyle(tint: .accentColor)) + + Toggle(isOn: $includeSpeed) { // 128 + + Label("Vehicle speed", systemImage: "speedometer") + } + .toggleStyle(SwitchToggleStyle(tint: .accentColor)) + } + } + + @ViewBuilder + var advancedPositionFlagsSection: some View { + Section(header: Text("Advanced Position Flags")) { + + if includeAltitude { + Toggle(isOn: $includeAltitudeMsl) { + Label("Altitude is Mean Sea Level", systemImage: "arrow.up.to.line.compact") + } + .toggleStyle(SwitchToggleStyle(tint: .accentColor)) + Toggle(isOn: $includeGeoidalSeparation) { + Label("Altitude Geoidal Separation", systemImage: "globe.americas") + } + .toggleStyle(SwitchToggleStyle(tint: .accentColor)) + } + + Toggle(isOn: $includeDop) { + Text("Dilution of precision (DOP) PDOP used by default") + } + .toggleStyle(SwitchToggleStyle(tint: .accentColor)) + + if includeDop { + Toggle(isOn: $includeHvdop) { + Text("If DOP is set, use HDOP / VDOP values instead of PDOP") + } + .toggleStyle(SwitchToggleStyle(tint: .accentColor)) + } + } + } + + @ViewBuilder + var advancedDeviceGPSSection: some View { + Section(header: Text("Advanced Device GPS")) { + Picker("GPS Receive GPIO", selection: $rxGpio) { + ForEach(0..<49) { + if $0 == 0 { + Text("unset") + } else { + Text("Pin \($0)") + } + } + } + .pickerStyle(DefaultPickerStyle()) + Picker("GPS Transmit GPIO", selection: $txGpio) { + ForEach(0..<49) { + if $0 == 0 { + Text("unset") + } else { + Text("Pin \($0)") + } + } + } + .pickerStyle(DefaultPickerStyle()) + Picker("GPS EN GPIO", selection: $gpsEnGpio) { + ForEach(0..<49) { + if $0 == 0 { + Text("unset") + } else { + Text("Pin \($0)") + } + } + } + .pickerStyle(DefaultPickerStyle()) + Text("(Re)define PIN_GPS_EN for your board.") + .font(.caption) + } + } + + var saveButton: some View { + SaveConfigButton(node: node, hasChanges: $hasChanges) { + if fixedPosition && !supportedVersion { + _ = bleManager.sendPosition(channel: 0, destNum: node?.num ?? 0, wantResponse: true) + } + let connectedNode = getNodeInfo(id: bleManager.connectedPeripheral!.num, context: context) + + if connectedNode != nil { + var pc = Config.PositionConfig() + pc.positionBroadcastSmartEnabled = smartPositionEnabled + pc.gpsEnabled = gpsMode == 1 + pc.gpsMode = Config.PositionConfig.GpsMode(rawValue: gpsMode) ?? Config.PositionConfig.GpsMode.notPresent + pc.fixedPosition = fixedPosition + pc.gpsUpdateInterval = UInt32(gpsUpdateInterval) + pc.positionBroadcastSecs = UInt32(positionBroadcastSeconds) + pc.broadcastSmartMinimumIntervalSecs = UInt32(broadcastSmartMinimumIntervalSecs) + pc.broadcastSmartMinimumDistance = UInt32(broadcastSmartMinimumDistance) + pc.rxGpio = UInt32(rxGpio) + pc.txGpio = UInt32(txGpio) + pc.gpsEnGpio = UInt32(gpsEnGpio) + var pf: PositionFlags = [] + if includeAltitude { pf.insert(.Altitude) } + if includeAltitudeMsl { pf.insert(.AltitudeMsl) } + if includeGeoidalSeparation { pf.insert(.GeoidalSeparation) } + if includeDop { pf.insert(.Dop) } + if includeHvdop { pf.insert(.Hvdop) } + if includeSatsinview { pf.insert(.Satsinview) } + if includeSeqNo { pf.insert(.SeqNo) } + if includeTimestamp { pf.insert(.Timestamp) } + if includeSpeed { pf.insert(.Speed) } + if includeHeading { pf.insert(.Heading) } + pc.positionFlags = UInt32(pf.rawValue) + let adminMessageId = bleManager.savePositionConfig(config: pc, fromUser: connectedNode!.user!, toUser: node!.user!, adminIndex: connectedNode?.myInfo?.adminIndex ?? 0) + if adminMessageId > 0 { + // Disable the button after a successful save + hasChanges = false + goBack() + } + } + } + } + + var setFixedAlertTitle: String { + if node?.positionConfig?.fixedPosition == true { + return "Remove Fixed Position" + } else { + return "Set Fixed Position" + } + } + var body: some View { VStack { Form { ConfigHeader(title: "Position", config: \.positionConfig, node: node, onAppear: setPositionValues) - - Section(header: Text("Position Packet")) { - - VStack(alignment: .leading) { - Picker("Broadcast Interval", selection: $positionBroadcastSeconds) { - ForEach(UpdateIntervals.allCases) { at in - if at.rawValue >= 300 { - Text(at.description) - } - } - } - .pickerStyle(DefaultPickerStyle()) - Text("The maximum interval that can elapse without a node broadcasting a position") - .foregroundColor(.gray) - .font(.callout) - } - - Toggle(isOn: $smartPositionEnabled) { - Label("Smart Position", systemImage: "brain") - } - .toggleStyle(SwitchToggleStyle(tint: .accentColor)) - - if smartPositionEnabled { - VStack(alignment: .leading) { - Picker("Minimum Interval", selection: $broadcastSmartMinimumIntervalSecs) { - ForEach(UpdateIntervals.allCases) { at in - Text(at.description) - } - } - .pickerStyle(DefaultPickerStyle()) - Text("The fastest that position updates will be sent if the minimum distance has been satisfied") - .foregroundColor(.gray) - .font(.callout) - } - VStack(alignment: .leading) { - Picker("Minimum Distance", selection: $broadcastSmartMinimumDistance) { - ForEach(10..<151) { - if $0 == 0 { - Text("unset") - } else { - if $0.isMultiple(of: 5) { - Text("\($0)") - .tag($0) - } - } - } - } - .pickerStyle(DefaultPickerStyle()) - Text("The minimum distance change in meters to be considered for a smart position broadcast.") - .foregroundColor(.gray) - .font(.callout) - } - } - } - Section(header: Text("Device GPS")) { - Picker("", selection: $gpsMode) { - ForEach(GpsMode.allCases, id: \.self) { at in - Text(at.description) - .tag(at.id) - } - } - .pickerStyle(SegmentedPickerStyle()) - .padding(.top, 5) - .padding(.bottom, 5) - if gpsMode == 1 { - - Text("Positions will be provided by your device GPS, if you select disabled or not present you can set a fixed position.") - .foregroundColor(.gray) - .font(.callout) - VStack(alignment: .leading) { - Picker("Update Interval", selection: $gpsUpdateInterval) { - ForEach(GpsUpdateIntervals.allCases) { ui in - Text(ui.description) - } - } - Text("How often should we try to get a GPS position.") - .foregroundColor(.gray) - .font(.callout) - } - } - if gpsMode != 1 && node?.num ?? 0 == bleManager.connectedPeripheral?.num ?? -1 { - VStack(alignment: .leading) { - Toggle(isOn: $fixedPosition) { - Label("Fixed Position", systemImage: "location.square.fill") - if !(node?.positionConfig?.fixedPosition ?? false) { - Text("Your current location will be set as the fixed position and broadcast over the mesh on the position interval.") - } else { - - } - } - .toggleStyle(SwitchToggleStyle(tint: .accentColor)) - } - } - } - Section(header: Text("Position Flags")) { - - Text("Optional fields to include when assembling position messages. the more fields are included, the larger the message will be - leading to longer airtime and a higher risk of packet loss") - .foregroundColor(.gray) - .font(.callout) - - Toggle(isOn: $includeAltitude) { - Label("Altitude", systemImage: "arrow.up") - } - .toggleStyle(SwitchToggleStyle(tint: .accentColor)) - - Toggle(isOn: $includeSatsinview) { - Label("Number of satellites", systemImage: "skew") - } - .toggleStyle(SwitchToggleStyle(tint: .accentColor)) - - Toggle(isOn: $includeSeqNo) { // 64 - Label("Sequence number", systemImage: "number") - } - .toggleStyle(SwitchToggleStyle(tint: .accentColor)) - - Toggle(isOn: $includeTimestamp) { // 128 - Label("timestamp", systemImage: "clock") - } - .toggleStyle(SwitchToggleStyle(tint: .accentColor)) - - Toggle(isOn: $includeHeading) { // 128 - Label("Vehicle heading", systemImage: "location.circle") - } - .toggleStyle(SwitchToggleStyle(tint: .accentColor)) - - Toggle(isOn: $includeSpeed) { // 128 - - Label("Vehicle speed", systemImage: "speedometer") - } - .toggleStyle(SwitchToggleStyle(tint: .accentColor)) - } - Section(header: Text("Advanced Position Flags")) { - - if includeAltitude { - Toggle(isOn: $includeAltitudeMsl) { - Label("Altitude is Mean Sea Level", systemImage: "arrow.up.to.line.compact") - } - .toggleStyle(SwitchToggleStyle(tint: .accentColor)) - Toggle(isOn: $includeGeoidalSeparation) { - Label("Altitude Geoidal Separation", systemImage: "globe.americas") - } - .toggleStyle(SwitchToggleStyle(tint: .accentColor)) - } - - Toggle(isOn: $includeDop) { - Text("Dilution of precision (DOP) PDOP used by default") - } - .toggleStyle(SwitchToggleStyle(tint: .accentColor)) - - if includeDop { - Toggle(isOn: $includeHvdop) { - Text("If DOP is set use, HDOP / VDOP values instead of PDOP") - } - .toggleStyle(SwitchToggleStyle(tint: .accentColor)) - } - } - + positionPacketSection + deviceGPSSection + positionFlagsSection + advancedPositionFlagsSection if gpsMode == 1 { - Section(header: Text("Advanced Device GPS")) { - Picker("GPS Receive GPIO", selection: $rxGpio) { - ForEach(0..<49) { - if $0 == 0 { - Text("unset") - } else { - Text("Pin \($0)") - } - } - } - .pickerStyle(DefaultPickerStyle()) - Picker("GPS Transmit GPIO", selection: $txGpio) { - ForEach(0..<49) { - if $0 == 0 { - Text("unset") - } else { - Text("Pin \($0)") - } - } - } - .pickerStyle(DefaultPickerStyle()) - Picker("GPS EN GPIO", selection: $gpsEnGpio) { - ForEach(0..<49) { - if $0 == 0 { - Text("unset") - } else { - Text("Pin \($0)") - } - } - } - .pickerStyle(DefaultPickerStyle()) - Text("(Re)define PIN_GPS_EN for your board.") - .font(.caption) - } + advancedDeviceGPSSection } } .disabled(self.bleManager.connectedPeripheral == nil || node?.positionConfig == nil) - .alert(node?.positionConfig?.fixedPosition ?? false ? "Remove Fixed Position" : "Set Fixed Position", isPresented: $showingSetFixedAlert) { + .alert(setFixedAlertTitle, isPresented: $showingSetFixedAlert) { Button("Cancel", role: .cancel) { fixedPosition = !fixedPosition } if node?.positionConfig?.fixedPosition ?? false { Button("Remove", role: .destructive) { - if !bleManager.removeFixedPosition(fromUser: node!.user!, channel: 0) { - Logger.mesh.error("Remove Fixed Position Failed") - } - let mutablePositions = node?.positions?.mutableCopy() as? NSMutableOrderedSet - mutablePositions?.removeAllObjects() - node?.positions = mutablePositions - node?.positionConfig?.fixedPosition = false - do { - try context.save() - Logger.data.info("💾 Updated Position Config with Fixed Position = false") - } catch { - context.rollback() - let nsError = error as NSError - Logger.data.error("Error Saving Position Config Entity \(nsError)") - } + removeFixedPosition() } } else { Button("Set") { - if !bleManager.setFixedPosition(fromUser: node!.user!, channel: 0) { - Logger.mesh.error("Set Position Failed") - } - node?.positionConfig?.fixedPosition = true - do { - try context.save() - Logger.data.info("💾 Updated Position Config with Fixed Position = true") - } catch { - context.rollback() - let nsError = error as NSError - Logger.data.error("Error Saving Position Config Entity \(nsError)") - } + setFixedPosition() } } } message: { Text(node?.positionConfig?.fixedPosition ?? false ? "This will disable fixed position and remove the currently set position." : "This will send a current position from your phone and enable fixed position.") } - - SaveConfigButton(node: node, hasChanges: $hasChanges) { - if fixedPosition && !supportedVersion { - _ = bleManager.sendPosition(channel: 0, destNum: node?.num ?? 0, wantResponse: true) - } - let connectedNode = getNodeInfo(id: bleManager.connectedPeripheral.num, context: context) - - if connectedNode != nil { - var pc = Config.PositionConfig() - pc.positionBroadcastSmartEnabled = smartPositionEnabled - pc.gpsEnabled = gpsMode == 1 - pc.gpsMode = Config.PositionConfig.GpsMode(rawValue: gpsMode) ?? Config.PositionConfig.GpsMode.notPresent - pc.fixedPosition = fixedPosition - pc.gpsUpdateInterval = UInt32(gpsUpdateInterval) - pc.positionBroadcastSecs = UInt32(positionBroadcastSeconds) - pc.broadcastSmartMinimumIntervalSecs = UInt32(broadcastSmartMinimumIntervalSecs) - pc.broadcastSmartMinimumDistance = UInt32(broadcastSmartMinimumDistance) - pc.rxGpio = UInt32(rxGpio) - pc.txGpio = UInt32(txGpio) - pc.gpsEnGpio = UInt32(gpsEnGpio) - var pf: PositionFlags = [] - if includeAltitude { pf.insert(.Altitude) } - if includeAltitudeMsl { pf.insert(.AltitudeMsl) } - if includeGeoidalSeparation { pf.insert(.GeoidalSeparation) } - if includeDop { pf.insert(.Dop) } - if includeHvdop { pf.insert(.Hvdop) } - if includeSatsinview { pf.insert(.Satsinview) } - if includeSeqNo { pf.insert(.SeqNo) } - if includeTimestamp { pf.insert(.Timestamp) } - if includeSpeed { pf.insert(.Speed) } - if includeHeading { pf.insert(.Heading) } - pc.positionFlags = UInt32(pf.rawValue) - let adminMessageId = bleManager.savePositionConfig(config: pc, fromUser: connectedNode!.user!, toUser: node!.user!, adminIndex: connectedNode?.myInfo?.adminIndex ?? 0) - if adminMessageId > 0 { - // Disable the button after a successful save - hasChanges = false - goBack() - } - } - } + saveButton } .navigationTitle("position.config") - .navigationBarItems(trailing: - - ZStack { - - ConnectedDevice(bluetoothOn: bleManager.isSwitchedOn, deviceConnected: bleManager.connectedPeripheral != nil, name: (bleManager.connectedPeripheral != nil) ? bleManager.connectedPeripheral.shortName : "?") - }) + .navigationBarItems( + trailing: ZStack { + ConnectedDevice( + bluetoothOn: bleManager.isSwitchedOn, + deviceConnected: bleManager.connectedPeripheral != nil, + name: bleManager.connectedPeripheral?.shortName ?? "?" + ) + } + ) .onAppear { if self.bleManager.context == nil { self.bleManager.context = context @@ -374,133 +382,88 @@ struct PositionConfig: View { setPositionValues() supportedVersion = bleManager.connectedVersion == "0.0.0" || self.minimumVersion.compare(bleManager.connectedVersion, options: .numeric) == .orderedAscending || minimumVersion.compare(bleManager.connectedVersion, options: .numeric) == .orderedSame // Need to request a PositionConfig from the remote node before allowing changes - if bleManager.connectedPeripheral != nil && node?.positionConfig == nil { + if let connectedPeripheral = bleManager.connectedPeripheral, node?.positionConfig == nil { Logger.mesh.info("empty position config") - let connectedNode = getNodeInfo(id: bleManager.connectedPeripheral.num, context: context) - if node != nil && connectedNode != nil { - _ = bleManager.requestPositionConfig(fromUser: connectedNode!.user!, toUser: node!.user!, adminIndex: connectedNode?.myInfo?.adminIndex ?? 0) + let connectedNode = getNodeInfo(id: connectedPeripheral.num, context: context) + if let node, let connectedNode { + _ = bleManager.requestPositionConfig( + fromUser: connectedNode.user!, + toUser: node.user!, + adminIndex: connectedNode.myInfo?.adminIndex ?? 0 + ) } } } .onChange(of: fixedPosition) { newFixed in if supportedVersion { - if node != nil && node!.positionConfig != nil { + if let positionConfig = node?.positionConfig { /// Fixed Position is off to start - if !node!.positionConfig!.fixedPosition && newFixed { + if !positionConfig.fixedPosition && newFixed { showingSetFixedAlert = true - } else if node!.positionConfig!.fixedPosition && !newFixed { + } else if positionConfig.fixedPosition && !newFixed { /// Fixed Position is on to start showingSetFixedAlert = true } } } } - .onChange(of: deviceGpsEnabled) { newDeviceGps in - if node != nil && node!.positionConfig != nil { - if newDeviceGps != node!.positionConfig!.deviceGpsEnabled { hasChanges = true } - } + .onChange(of: gpsMode) { _ in + handleChanges() } - .onChange(of: gpsMode) { newGpsMode in - if node != nil && node!.positionConfig != nil { - if newGpsMode != node!.positionConfig!.gpsMode { hasChanges = true } - } + .onChange(of: rxGpio) { _ in + handleChanges() } - .onChange(of: rxGpio) { newRxGpio in - if node != nil && node!.positionConfig != nil { - if newRxGpio != node!.positionConfig!.rxGpio { hasChanges = true } - } + .onChange(of: txGpio) { _ in + handleChanges() } - .onChange(of: txGpio) { newTxGpio in - if node != nil && node!.positionConfig != nil { - if newTxGpio != node!.positionConfig!.txGpio { hasChanges = true } - } + .onChange(of: gpsEnGpio) { _ in + handleChanges() } - .onChange(of: txGpio) { newGpsEnGpio in - if node != nil && node!.positionConfig != nil { - if newGpsEnGpio != node!.positionConfig!.gpsEnGpio { hasChanges = true } - } + .onChange(of: smartPositionEnabled) { _ in + handleChanges() } - .onChange(of: smartPositionEnabled) { newSmartPositionEnabled in - if node != nil && node!.positionConfig != nil { - if newSmartPositionEnabled != node!.positionConfig!.smartPositionEnabled { hasChanges = true } - } + .onChange(of: positionBroadcastSeconds) { _ in + handleChanges() } - .onChange(of: positionBroadcastSeconds) { newPositionBroadcastSeconds in - if node != nil && node!.positionConfig != nil { - if newPositionBroadcastSeconds != node!.positionConfig!.positionBroadcastSeconds { hasChanges = true } - } + .onChange(of: broadcastSmartMinimumIntervalSecs) { _ in + handleChanges() } - .onChange(of: broadcastSmartMinimumIntervalSecs) { newBroadcastSmartMinimumIntervalSecs in - if node != nil && node!.positionConfig != nil { - if newBroadcastSmartMinimumIntervalSecs != node!.positionConfig!.broadcastSmartMinimumIntervalSecs { hasChanges = true } - } + .onChange(of: broadcastSmartMinimumDistance) { _ in + handleChanges() } - .onChange(of: broadcastSmartMinimumDistance) { newBroadcastSmartMinimumDistance in - if node != nil && node!.positionConfig != nil { - if newBroadcastSmartMinimumDistance != node!.positionConfig!.broadcastSmartMinimumDistance { hasChanges = true } - } + .onChange(of: gpsUpdateInterval) { _ in + handleChanges() } - .onChange(of: gpsUpdateInterval) { newGpsUpdateInterval in - if node != nil && node!.positionConfig != nil { - if newGpsUpdateInterval != node!.positionConfig!.gpsUpdateInterval { hasChanges = true } - } - } - .onChange(of: includeAltitude) { altFlag in - let pf = PositionFlags(rawValue: self.positionFlags) - let existingValue = pf.contains(.Altitude) - if existingValue != altFlag { hasChanges = true } - } - .onChange(of: includeAltitudeMsl) { altMslFlag in - let pf = PositionFlags(rawValue: self.positionFlags) - let existingValue = pf.contains(.AltitudeMsl) - if existingValue != altMslFlag { hasChanges = true } - } - .onChange(of: includeSatsinview) { satsFlag in - let pf = PositionFlags(rawValue: self.positionFlags) - let existingValue = pf.contains(.Satsinview) - if existingValue != satsFlag { hasChanges = true } - } - .onChange(of: includeSeqNo) { seqFlag in - let pf = PositionFlags(rawValue: self.positionFlags) - let existingValue = pf.contains(.SeqNo) - if existingValue != seqFlag { hasChanges = true } - } - .onChange(of: includeTimestamp) { timestampFlag in - let pf = PositionFlags(rawValue: self.positionFlags) - let existingValue = pf.contains(.Timestamp) - if existingValue != timestampFlag { hasChanges = true } - } - .onChange(of: includeTimestamp) { timestampFlag in - let pf = PositionFlags(rawValue: self.positionFlags) - let existingValue = pf.contains(.Timestamp) - if existingValue != timestampFlag { hasChanges = true } - } - .onChange(of: includeSpeed) { speedFlag in - let pf = PositionFlags(rawValue: self.positionFlags) - let existingValue = pf.contains(.Speed) - if existingValue != speedFlag { hasChanges = true } - } - .onChange(of: includeHeading) { headingFlag in - let pf = PositionFlags(rawValue: self.positionFlags) - let existingValue = pf.contains(.Heading) - if existingValue != headingFlag { hasChanges = true } - } - .onChange(of: includeGeoidalSeparation) { geoSepFlag in - let pf = PositionFlags(rawValue: self.positionFlags) - let existingValue = pf.contains(.GeoidalSeparation) - if existingValue != geoSepFlag { hasChanges = true } - } - .onChange(of: includeDop) { dopFlag in - let pf = PositionFlags(rawValue: self.positionFlags) - let existingValue = pf.contains(.Dop) - if existingValue != dopFlag { hasChanges = true } - } - .onChange(of: includeHvdop) { hvdopFlag in - let pf = PositionFlags(rawValue: self.positionFlags) - let existingValue = pf.contains(.Hvdop) - if existingValue != hvdopFlag { hasChanges = true } + .onChange(of: positionFlags) { _ in + handleChanges() } } + + func handleChanges() { + guard let positionConfig = node?.positionConfig else { return } + let pf = PositionFlags(rawValue: self.positionFlags) + hasChanges = positionConfig.deviceGpsEnabled != deviceGpsEnabled || + positionConfig.gpsMode != gpsMode || + positionConfig.rxGpio != rxGpio || + positionConfig.txGpio != txGpio || + positionConfig.gpsEnGpio != gpsEnGpio || + positionConfig.smartPositionEnabled != smartPositionEnabled || + positionConfig.positionBroadcastSeconds != positionBroadcastSeconds || + positionConfig.broadcastSmartMinimumIntervalSecs != broadcastSmartMinimumIntervalSecs || + positionConfig.broadcastSmartMinimumDistance != broadcastSmartMinimumDistance || + positionConfig.gpsUpdateInterval != gpsUpdateInterval || + pf.contains(.Altitude) || + pf.contains(.AltitudeMsl) || + pf.contains(.Satsinview) || + pf.contains(.SeqNo) || + pf.contains(.Timestamp) || + pf.contains(.Speed) || + pf.contains(.Heading) || + pf.contains(.GeoidalSeparation) || + pf.contains(.Dop) || + pf.contains(.Hvdop) + } + func setPositionValues() { self.smartPositionEnabled = node?.positionConfig?.smartPositionEnabled ?? true self.deviceGpsEnabled = node?.positionConfig?.deviceGpsEnabled ?? false @@ -517,19 +480,54 @@ struct PositionConfig: View { self.broadcastSmartMinimumIntervalSecs = Int(node?.positionConfig?.broadcastSmartMinimumIntervalSecs ?? 30) self.broadcastSmartMinimumDistance = Int(node?.positionConfig?.broadcastSmartMinimumDistance ?? 50) self.positionFlags = Int(node?.positionConfig?.positionFlags ?? 3) - let pf = PositionFlags(rawValue: self.positionFlags) - if pf.contains(.Altitude) { self.includeAltitude = true } else { self.includeAltitude = false } - if pf.contains(.AltitudeMsl) { self.includeAltitudeMsl = true } else { self.includeAltitudeMsl = false } - if pf.contains(.GeoidalSeparation) { self.includeGeoidalSeparation = true } else { self.includeGeoidalSeparation = false } - if pf.contains(.Dop) { self.includeDop = true } else { self.includeDop = false } - if pf.contains(.Hvdop) { self.includeHvdop = true } else { self.includeHvdop = false } - if pf.contains(.Satsinview) { self.includeSatsinview = true } else { self.includeSatsinview = false } - if pf.contains(.SeqNo) { self.includeSeqNo = true } else { self.includeSeqNo = false } - if pf.contains(.Timestamp) { self.includeTimestamp = true } else { self.includeTimestamp = false } - if pf.contains(.Speed) { self.includeSpeed = true } else { self.includeSpeed = false } - if pf.contains(.Heading) { self.includeHeading = true } else { self.includeHeading = false } - + self.includeAltitude = pf.contains(.Altitude) + self.includeAltitudeMsl = pf.contains(.AltitudeMsl) + self.includeGeoidalSeparation = pf.contains(.GeoidalSeparation) + self.includeDop = pf.contains(.Dop) + self.includeHvdop = pf.contains(.Hvdop) + self.includeSatsinview = pf.contains(.Satsinview) + self.includeSeqNo = pf.contains(.SeqNo) + self.includeTimestamp = pf.contains(.Timestamp) + self.includeSpeed = pf.contains(.Speed) + self.includeHeading = pf.contains(.Heading) self.hasChanges = false } + + private func setFixedPosition() { + guard let nodeNum = bleManager.connectedPeripheral?.num, + nodeNum > 0 else { return } + if !bleManager.setFixedPosition(fromUser: node!.user!, channel: 0) { + Logger.mesh.error("Set Position Failed") + } + node?.positionConfig?.fixedPosition = true + do { + try context.save() + Logger.data.info("💾 Updated Position Config with Fixed Position = true") + } catch { + context.rollback() + let nsError = error as NSError + Logger.data.error("Error Saving Position Config Entity \(nsError)") + } + } + + private func removeFixedPosition() { + guard let nodeNum = bleManager.connectedPeripheral?.num, + nodeNum > 0 else { return } + if !bleManager.removeFixedPosition(fromUser: node!.user!, channel: 0) { + Logger.mesh.error("Remove Fixed Position Failed") + } + let mutablePositions = node?.positions?.mutableCopy() as? NSMutableOrderedSet + mutablePositions?.removeAllObjects() + node?.positions = mutablePositions + node?.positionConfig?.fixedPosition = false + do { + try context.save() + Logger.data.info("💾 Updated Position Config with Fixed Position = false") + } catch { + context.rollback() + let nsError = error as NSError + Logger.data.error("Error Saving Position Config Entity \(nsError)") + } + } }