Merge pull request #716 from meshtastic/coredata_backup

Backup database functionality
This commit is contained in:
Garth Vander Houwen 2024-06-23 14:46:42 -07:00 committed by GitHub
commit b131714c5f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 813 additions and 445 deletions

View file

@ -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 = "<group>"; };
DDD5BB0F2C285FB3007E03CA /* AppLogFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLogFilter.swift; sourceTree = "<group>"; };
DDD5BB142C28680D007E03CA /* MeshtasticDataModelV 38.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "MeshtasticDataModelV 38.xcdatamodel"; sourceTree = "<group>"; };
DDD5BB152C28B1E4007E03CA /* AppData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppData.swift; sourceTree = "<group>"; };
DDD6EEAE29BC024700383354 /* Firmware.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Firmware.swift; sourceTree = "<group>"; };
DDD94A4F2845C8F5004A87A0 /* DateTimeText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateTimeText.swift; sourceTree = "<group>"; };
DDD9E4E3284B208E003777C5 /* UserEntityExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserEntityExtension.swift; sourceTree = "<group>"; };
@ -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 = "<group>";
@ -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 */,

View file

@ -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
}
}

View file

@ -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)
}
@ -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()
}

View file

@ -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")

View file

@ -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)")
}
}
}
}

View file

@ -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<NSFetchRequestResult> = 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)")
}
}
}

View file

@ -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) }
}
}
}
}

View file

@ -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()

View file

@ -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)")
}
}
}

View file

@ -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 }))